Files
iptvnator/.github/workflows/build-and-make.yaml
T
4grayandClaude Fable 5 1b4d276e17 ci(release): rebuild full draft metadata after asset upload
Extend the post-upload metadata step to also rebuild the body (context
header + notes from the same generate-notes API the action uses), not
just title/commitish. The rolling draft now ends up with correct
metadata regardless of which internal action-gh-release path ran,
including the rare canonicalize-duplicate fallback. If notes
generation fails, the body is left as the action set it.

Addresses Codex round-4 feedback on #1202.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-18 16:59:37 +02:00

1023 lines
52 KiB
YAML

name: Build and Make Electron App
on:
push:
branches:
- master
tags:
- 'v*.*.*'
pull_request:
branches:
- master
workflow_dispatch:
jobs:
build:
name: Build on ${{ matrix.os }} ${{ matrix.arch }}
runs-on: ${{ matrix.runner }}
timeout-minutes: 120
# Concurrency lives on the jobs, not the workflow: cancelling a whole
# run could interrupt action-gh-release mid-update and leave the
# rolling draft with missing assets. Build slots cancel superseded PR
# work; the release job below only serializes and is never cancelled.
concurrency:
group: ${{ github.workflow }}-build-${{ matrix.os }}-${{ matrix.arch }}${{ matrix.linux_profile }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
strategy:
matrix:
include:
# macOS builds - separate runners to avoid native module conflicts
- os: macos
runner: macos-15-intel
arch: x64
embedded_mpv_platform: darwin
embedded_mpv_arch: x64
embedded_mpv_build_runtime: true
- os: macos
runner: macos-latest
arch: arm64
embedded_mpv_platform: darwin
embedded_mpv_arch: arm64
embedded_mpv_build_runtime: true
# Linux and Windows
- os: linux
runner: ubuntu-22.04
linux_profile: standard
embedded_mpv_platform: linux
embedded_mpv_arch: x64
embedded_mpv_build_runtime: false
- os: linux
runner: ubuntu-24.04
linux_profile: flatpak
embedded_mpv_platform: linux
embedded_mpv_arch: x64
embedded_mpv_build_runtime: false
- os: windows
runner: windows-2022
embedded_mpv_platform: win32
embedded_mpv_arch: x64
embedded_mpv_build_runtime: false
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install pnpm
uses: pnpm/action-setup@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '22'
cache: 'pnpm'
- name: Install Linux system dependencies
if: matrix.os == 'linux'
run: |
sudo apt-get update
sudo apt-get install --no-install-recommends -y rpm libarchive-tools flatpak flatpak-builder appstream libx11-dev libxext-dev libmpv-dev mpv pkg-config libegl-dev libgl-dev libopengl-dev libgbm-dev
# Configure Flatpak
# 1. Add the Flathub repository (source of runtimes)
flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo
# 2. Install the standard Freedesktop Platform and SDK (required by electron-builder)
# We install version 24.08 as a safe default, electron-builder might pick what it needs
flatpak install --user -y flathub org.freedesktop.Platform//24.08 org.freedesktop.Sdk//24.08
- name: Select Linux packaging targets for CI profile
if: matrix.os == 'linux'
run: |
node -e "
const fs = require('fs');
const path = 'electron-builder.json';
const config = JSON.parse(fs.readFileSync(path, 'utf8'));
const targets = Array.isArray(config.linux?.target) ? config.linux.target : [];
const profile = '${{ matrix.linux_profile }}';
if (profile === 'standard') {
config.linux.target = targets.filter((entry) => String(entry.target).toLowerCase() !== 'flatpak');
} else if (profile === 'flatpak') {
config.linux.target = targets.filter((entry) => String(entry.target).toLowerCase() === 'flatpak');
}
fs.writeFileSync(path, JSON.stringify(config, null, 4) + '\n');
"
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Inject TMDB API key
# No-op when the secret is unavailable (e.g. fork PRs) — the
# app then requires a user-provided key for TMDB enrichment.
env:
TMDB_API_KEY: ${{ secrets.TMDB_API_KEY }}
run: node tools/tmdb/inject-tmdb-key.mjs
- name: Build frontend
run: pnpm nx build web --skip-nx-cache
- name: Resolve embedded MPV runtime cache key
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master')
id: embedded-mpv-runtime-cache-key
shell: bash
env:
IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: ${{ vars.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || secrets.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || '' }}
IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: a26e28fa55c15ac5b6209d8a9f9c3c8cb39649631a53d3c4774beeae559a078c
run: |
set -euo pipefail
node <<'NODE'
const childProcess = require('child_process');
const crypto = require('crypto');
const fs = require('fs');
const hash = (value) => crypto.createHash('sha256').update(value).digest('hex');
const targetPlatform = '${{ matrix.embedded_mpv_platform }}';
const targetArch = '${{ matrix.embedded_mpv_arch }}';
const sourceHashFiles = [
'tools/embedded-mpv/stage-runtime.mjs',
];
const cacheKeyParts = [
'embedded-mpv-runtime-v2',
targetPlatform,
targetArch,
process.env.RUNNER_OS,
];
let deploymentTarget = '';
let xcodeVersion = 'none';
if (targetPlatform === 'darwin') {
deploymentTarget = process.env.MACOSX_DEPLOYMENT_TARGET || '11.0';
const safeDeploymentTarget = deploymentTarget.replace(/[^A-Za-z0-9_.-]/g, '-');
sourceHashFiles.push(
'tools/embedded-mpv/build-macos-runtime.mjs',
'tools/embedded-mpv/stage-macos-runtime.mjs'
);
try {
xcodeVersion = childProcess
.execSync('xcodebuild -version', { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
.replace(/\s+$/g, '')
.replace(/\n/g, ' ');
} catch {
xcodeVersion = 'none';
}
cacheKeyParts.push(
`macos${safeDeploymentTarget}`,
`xcode${hash(xcodeVersion)}`
);
} else if (targetPlatform === 'win32') {
sourceHashFiles.push('tools/embedded-mpv/stage-windows-runtime-archive.mjs');
const windowsRuntimeSha256 =
process.env.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 ||
process.env.IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 ||
'missing';
cacheKeyParts.push(
`runtime${hash(windowsRuntimeSha256)}`
);
}
const sourceHash = hash(
sourceHashFiles.map((filePath) => fs.readFileSync(filePath)).join('\n')
);
cacheKeyParts.push(sourceHash);
const cacheKey = cacheKeyParts.join('-');
fs.appendFileSync(process.env.GITHUB_OUTPUT, `deployment-target=${deploymentTarget}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `xcode-version=${xcodeVersion}\n`);
fs.appendFileSync(process.env.GITHUB_OUTPUT, `key=${cacheKey}\n`);
console.log(`Embedded MPV runtime cache key: ${cacheKey}`);
console.log(`Xcode version: ${xcodeVersion}`);
NODE
- name: Restore embedded MPV runtime cache
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master')
id: embedded-mpv-runtime-cache
uses: actions/cache/restore@v4
with:
path: |
vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/include
vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/lib
vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/runtime-manifest.json
key: ${{ steps.embedded-mpv-runtime-cache-key.outputs.key }}
- name: Clear stale embedded MPV runtime files
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
if: matrix.embedded_mpv_platform && !startsWith(github.ref, 'refs/tags/v') && (github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true'
shell: bash
run: |
set -euo pipefail
rm -rf \
"vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/include" \
"vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/lib" \
"vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/runtime-manifest.json"
- name: Build embedded MPV runtime (macOS release)
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
if: matrix.embedded_mpv_build_runtime && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master') && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true'
shell: bash
run: |
set -euo pipefail
brew install meson ninja pkg-config nasm autoconf automake libtool xz
RUNTIME_PREFIX="${RUNNER_TEMP}/embedded-mpv-runtime/${{ matrix.embedded_mpv_arch }}/prefix"
pnpm embedded-mpv:build-runtime -- "${{ matrix.embedded_mpv_arch }}" "${RUNTIME_PREFIX}"
pnpm embedded-mpv:stage-runtime -- "${{ matrix.embedded_mpv_platform }}" "${{ matrix.embedded_mpv_arch }}" "${RUNTIME_PREFIX}"
- name: Stage Windows embedded MPV runtime archive
if: matrix.os == 'windows' && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true'
shell: bash
env:
IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL: ${{ vars.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL || secrets.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL || '' }}
IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: ${{ vars.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || secrets.IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 || '' }}
IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_URL: https://github.com/zhongfly/mpv-winbuild/releases/download/2026-06-14-7d245fd100/mpv-dev-lgpl-x86_64-20260614-git-7d245fd100.7z
IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: a26e28fa55c15ac5b6209d8a9f9c3c8cb39649631a53d3c4774beeae559a078c
run: |
set -euo pipefail
WINDOWS_RUNTIME_URL="${IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL}"
WINDOWS_RUNTIME_SHA256="${IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256}"
if [ -z "${WINDOWS_RUNTIME_URL}" ] && [ -z "${WINDOWS_RUNTIME_SHA256}" ]; then
case "${GITHUB_REF}" in
refs/tags/v*)
;;
*)
WINDOWS_RUNTIME_URL="${IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_URL}"
WINDOWS_RUNTIME_SHA256="${IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256}"
;;
esac
fi
if [ -z "${WINDOWS_RUNTIME_URL}" ] || [ -z "${WINDOWS_RUNTIME_SHA256}" ]; then
echo "::error::Windows Embedded MPV CI requires IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_URL and IPTVNATOR_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256 repository variables or secrets."
exit 1
fi
pnpm embedded-mpv:stage-runtime:windows-archive -- "${WINDOWS_RUNTIME_URL}" "${WINDOWS_RUNTIME_SHA256}"
- name: Stage Linux embedded MPV build inputs
if: matrix.os == 'linux'
shell: bash
run: |
set -euo pipefail
RUNTIME_PREFIX="${RUNNER_TEMP}/embedded-mpv-runtime/linux-x64/prefix"
rm -rf "${RUNTIME_PREFIX}"
mkdir -p "${RUNTIME_PREFIX}/include"
cp -a /usr/include/mpv "${RUNTIME_PREFIX}/include/"
LIBMPV_DEV_VERSION="$(dpkg-query -W -f='${Version}' libmpv-dev)"
MPV_VERSION="$(dpkg-query -W -f='${Version}' mpv)"
export RUNTIME_PREFIX LIBMPV_DEV_VERSION MPV_VERSION
node <<'NODE'
const fs = require('fs');
const path = require('path');
const manifest = {
linuxBackend: 'process-isolated mpv --wid',
buildInputs: {
libmpvDevPackage: process.env.LIBMPV_DEV_VERSION,
mpvPackage: process.env.MPV_VERSION,
},
sourceDistribution:
'Linux CI build inputs come from Ubuntu runner packages. Runtime playback uses the system mpv executable; IPTVnator does not bundle or load libmpv in the Electron process on Linux.',
};
fs.writeFileSync(
path.join(process.env.RUNTIME_PREFIX, 'runtime-manifest.json'),
`${JSON.stringify(manifest, null, 2)}\n`
);
NODE
pnpm embedded-mpv:stage-runtime -- linux x64 "${RUNTIME_PREFIX}"
- name: Build backend
env:
IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform || '' }}
IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }}
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master'))) && '1' || '0' }}
run: pnpm run build:backend
- name: Verify embedded MPV build output
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
if: matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master'))
shell: bash
run: |
set -euo pipefail
echo "::group::Native build output"
find apps/electron-backend/native/build/Release -maxdepth 3 -type f | sort
echo "::endgroup::"
echo "::group::Dist native output"
find dist/apps/electron-backend/native -maxdepth 3 -type f | sort
echo "::endgroup::"
test -f apps/electron-backend/native/build/Release/embedded_mpv.node
test -f dist/apps/electron-backend/native/embedded_mpv.node
test -f dist/apps/electron-backend/native/embedded-mpv-runtime.json
case "${{ matrix.embedded_mpv_platform }}" in
darwin)
test -f dist/apps/electron-backend/native/lib/libmpv.2.dylib || test -f dist/apps/electron-backend/native/lib/libmpv.dylib
;;
win32)
test -f dist/apps/electron-backend/native/lib/mpv-2.dll || test -f dist/apps/electron-backend/native/lib/libmpv-2.dll || test -f dist/apps/electron-backend/native/lib/mpv.dll || test -f dist/apps/electron-backend/native/lib/libmpv.dll
# Frame-copy engine artifacts ship on Windows. The helper
# resolves the mpv DLL from its own directory, so the DLL
# must sit beside it at the native/ top level too.
test -f dist/apps/electron-backend/native/iptvnator_mpv_helper.exe
test -f dist/apps/electron-backend/native/embedded_mpv_frame_reader.node
find dist/apps/electron-backend/native -maxdepth 1 \( -name 'mpv-2.dll' -o -name 'libmpv-2.dll' -o -name 'mpv.dll' -o -name 'libmpv.dll' \) -print -quit | grep -q .
;;
linux)
node -e "const manifest = require('./dist/apps/electron-backend/native/embedded-mpv-runtime.json'); if (manifest.origin !== 'external-mpv-process') { throw new Error('Linux embedded MPV manifest must use external-mpv-process origin.'); }"
if find dist/apps/electron-backend/native/lib -name 'libmpv.so*' -print -quit 2>/dev/null | grep -q .; then
echo "::error::Linux embedded MPV packages must not bundle libmpv"
find dist/apps/electron-backend/native/lib -name 'libmpv.so*' -print
exit 1
fi
if ldd dist/apps/electron-backend/native/embedded_mpv.node | grep -q 'libmpv'; then
echo "::error::Linux embedded MPV addon must not link directly to libmpv"
ldd dist/apps/electron-backend/native/embedded_mpv.node
exit 1
fi
# The frame-copy helper is the inverse: a separate process
# that MUST link libmpv (dev-mode engine; stripped from
# packages until the bundled-runtime staging lands).
# test -f, not -x: the webpack dist asset copy drops file
# modes; consumers restore the bit (after-pack) or require
# it via the X_OK support probe.
test -f dist/apps/electron-backend/native/iptvnator_mpv_helper
if ! ldd dist/apps/electron-backend/native/iptvnator_mpv_helper | grep -q 'libmpv'; then
echo "::error::Linux frame-copy helper must link libmpv"
ldd dist/apps/electron-backend/native/iptvnator_mpv_helper
exit 1
fi
;;
esac
- name: Validate AppStream metadata
if: matrix.os == 'linux'
run: appstreamcli validate --pedantic --no-net apps/electron-backend/linux/com.fourgray.iptvnator.metainfo.xml
- name: Build website
if: matrix.os == 'linux'
run: pnpm nx build website --skip-nx-cache
- name: Verify AppStream website assets
if: matrix.os == 'linux'
shell: bash
run: |
set -euo pipefail
for screenshot in player playlists epg settings; do
if ! find dist/apps/website -path "*/appstream/${screenshot}.png" -print -quit | grep -q .; then
echo "::error::Missing AppStream website asset for ${screenshot}.png"
exit 1
fi
done
- name: Override macOS arch in electron-builder.json
if: matrix.os == 'macos'
run: |
# Replace the mac arch array with just the target architecture
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('electron-builder.json', 'utf8'));
const targets = Array.isArray(pkg.mac?.target) ? pkg.mac.target : [pkg.mac.target];
for (const target of targets) {
if (target && typeof target === 'object') {
target.arch = ['${{ matrix.arch }}'];
}
}
fs.writeFileSync('electron-builder.json', JSON.stringify(pkg, null, 4));
"
- name: Override Windows arch in electron-builder.json
if: matrix.os == 'windows'
run: |
node -e "
const fs = require('fs');
const pkg = JSON.parse(fs.readFileSync('electron-builder.json', 'utf8'));
const targets = Array.isArray(pkg.win?.target) ? pkg.win.target : [];
for (const target of targets) {
if (target && typeof target === 'object') {
target.arch = ['${{ matrix.embedded_mpv_arch }}'];
}
}
fs.writeFileSync('electron-builder.json', JSON.stringify(pkg, null, 4) + '\n');
"
- name: Validate macOS signing configuration
if: matrix.os == 'macos' && github.event_name != 'pull_request'
shell: bash
env:
CSC_NAME: ${{ vars.CSC_NAME }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
if [ -z "${CSC_NAME}" ]; then
echo "::error::Missing CSC_NAME repository variable for deterministic macOS code signing."
exit 1
fi
if [ -z "${CSC_LINK}" ] || [ -z "${CSC_KEY_PASSWORD}" ]; then
echo "::error::Missing CSC_LINK or CSC_KEY_PASSWORD secret for macOS code signing."
exit 1
fi
has_api_key_credentials=false
if [ -n "${APPLE_API_KEY_CONTENT}" ] || [ -n "${APPLE_API_KEY_ID}" ] || [ -n "${APPLE_API_ISSUER}" ]; then
if [ -z "${APPLE_API_KEY_CONTENT}" ] || [ -z "${APPLE_API_KEY_ID}" ] || [ -z "${APPLE_API_ISSUER}" ]; then
echo "::error::APPLE_API_KEY, APPLE_API_KEY_ID, and APPLE_API_ISSUER must all be set for App Store Connect API key notarization."
exit 1
fi
has_api_key_credentials=true
fi
has_apple_id_credentials=false
if [ -n "${APPLE_ID}" ] || [ -n "${APPLE_APP_SPECIFIC_PASSWORD}" ] || [ -n "${APPLE_TEAM_ID}" ]; then
if [ -z "${APPLE_ID}" ] || [ -z "${APPLE_APP_SPECIFIC_PASSWORD}" ] || [ -z "${APPLE_TEAM_ID}" ]; then
echo "::error::APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, and APPLE_TEAM_ID must all be set for Apple ID notarization."
exit 1
fi
has_apple_id_credentials=true
fi
if [ "${has_api_key_credentials}" = false ] && [ "${has_apple_id_credentials}" = false ]; then
echo "::error::Missing notarization credentials. Configure either APPLE_API_KEY + APPLE_API_KEY_ID + APPLE_API_ISSUER, or APPLE_ID + APPLE_APP_SPECIFIC_PASSWORD + APPLE_TEAM_ID."
exit 1
fi
- name: Prepare macOS notarization credentials
if: matrix.os == 'macos' && github.event_name != 'pull_request'
shell: bash
env:
APPLE_API_KEY_CONTENT: ${{ secrets.APPLE_API_KEY }}
APPLE_API_KEY_ID: ${{ secrets.APPLE_API_KEY_ID }}
APPLE_API_ISSUER: ${{ secrets.APPLE_API_ISSUER }}
APPLE_ID: ${{ secrets.APPLE_ID }}
APPLE_APP_SPECIFIC_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }}
run: |
if [ -n "${APPLE_API_KEY_CONTENT}" ]; then
APPLE_API_KEY_PATH="${RUNNER_TEMP}/AuthKey_${APPLE_API_KEY_ID}.p8"
printf '%s' "${APPLE_API_KEY_CONTENT}" > "${APPLE_API_KEY_PATH}"
chmod 600 "${APPLE_API_KEY_PATH}"
echo "APPLE_API_KEY=${APPLE_API_KEY_PATH}" >> "${GITHUB_ENV}"
echo "APPLE_API_KEY_ID=${APPLE_API_KEY_ID}" >> "${GITHUB_ENV}"
echo "APPLE_API_ISSUER=${APPLE_API_ISSUER}" >> "${GITHUB_ENV}"
echo "APPLE_ID=" >> "${GITHUB_ENV}"
echo "APPLE_APP_SPECIFIC_PASSWORD=" >> "${GITHUB_ENV}"
echo "APPLE_TEAM_ID=" >> "${GITHUB_ENV}"
exit 0
fi
if [ -n "${APPLE_ID}" ]; then
echo "APPLE_API_KEY=" >> "${GITHUB_ENV}"
echo "APPLE_API_KEY_ID=" >> "${GITHUB_ENV}"
echo "APPLE_API_ISSUER=" >> "${GITHUB_ENV}"
echo "APPLE_ID=${APPLE_ID}" >> "${GITHUB_ENV}"
echo "APPLE_APP_SPECIFIC_PASSWORD=${APPLE_APP_SPECIFIC_PASSWORD}" >> "${GITHUB_ENV}"
echo "APPLE_TEAM_ID=${APPLE_TEAM_ID}" >> "${GITHUB_ENV}"
fi
- name: Make Electron app (macOS)
if: matrix.os == 'macos' && github.event_name != 'pull_request'
env:
CSC_NAME: ${{ vars.CSC_NAME }}
CSC_LINK: ${{ secrets.CSC_LINK }}
CSC_KEY_PASSWORD: ${{ secrets.CSC_KEY_PASSWORD }}
DEBUG: electron-builder,electron-notarize*
IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform }}
IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch }}
# TEMPORARY MASTER ARTIFACT TEST: remove `|| github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (startsWith(github.ref, 'refs/tags/v') || github.ref == 'refs/heads/master') && '1' || '0' }}
run: pnpm run make:app -- --publishPolicy=never
- name: Verify signed macOS app
if: matrix.os == 'macos' && github.event_name != 'pull_request'
shell: bash
run: |
set -euo pipefail
case "${{ matrix.arch }}" in
x64)
APP_PATH="dist/executables/mac/IPTVnator.app"
;;
arm64)
APP_PATH="dist/executables/mac-arm64/IPTVnator.app"
;;
*)
echo "::error::Unsupported macOS arch: ${{ matrix.arch }}"
exit 1
;;
esac
print_debug_attrs() {
echo "::group::Extended attributes"
xattr -lr "${APP_PATH}" | sed -n '1,120p' || true
echo "::endgroup::"
}
trap print_debug_attrs ERR
if [ ! -d "${APP_PATH}" ]; then
echo "::error::Expected app bundle not found at ${APP_PATH}"
exit 1
fi
SIGNATURE_INFO="$(codesign -dv --verbose=4 "${APP_PATH}" 2>&1)"
printf '%s\n' "${SIGNATURE_INFO}"
if printf '%s\n' "${SIGNATURE_INFO}" | grep -q 'Signature=adhoc'; then
echo "::error::macOS app is still ad-hoc signed."
exit 1
fi
if printf '%s\n' "${SIGNATURE_INFO}" | grep -q 'TeamIdentifier=not set'; then
echo "::error::macOS app is missing a TeamIdentifier."
exit 1
fi
codesign --verify --deep --strict --verbose=4 "${APP_PATH}"
spctl -a -vvv --type execute "${APP_PATH}"
xcrun stapler validate "${APP_PATH}"
- name: Make Electron app
if: matrix.os != 'macos' || github.event_name == 'pull_request'
env:
IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform || '' }}
IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }}
# TEMPORARY PR TEST: change this back to '0' after manually
# testing the macOS PR artifact with Embedded MPV included.
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && github.event_name == 'pull_request')) && '1' || '0' }}
run: pnpm run make:app -- --publishPolicy=never
- name: Verify packaged worker layout
shell: bash
env:
PACKAGE_OS: ${{ matrix.os }}
PACKAGE_ARCH: ${{ matrix.arch || matrix.embedded_mpv_arch || '' }}
# TEMPORARY ARTIFACT TEST: remove `|| github.event_name == 'pull_request' || github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
IPTVNATOR_REQUIRE_EMBEDDED_MPV: ${{ (matrix.os == 'linux' || matrix.os == 'windows' || (matrix.os == 'macos' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'pull_request' || github.ref == 'refs/heads/master'))) && '1' || '0' }}
run: pnpm run verify:package-layout -- "$PACKAGE_OS" "$PACKAGE_ARCH"
- name: Save embedded MPV runtime cache
# TEMPORARY ARTIFACT TEST: remove `|| github.ref == 'refs/heads/master'`
# after the macOS Embedded MPV artifacts are built and manually tested.
if: matrix.embedded_mpv_platform && matrix.os != 'linux' && !startsWith(github.ref, 'refs/tags/v') && github.repository == '4gray/iptvnator' && github.event_name != 'pull_request' && github.ref == 'refs/heads/master' && steps.embedded-mpv-runtime-cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v4
with:
path: |
vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/include
vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/lib
vendor/embedded-mpv/${{ matrix.embedded_mpv_platform }}-${{ matrix.embedded_mpv_arch }}/runtime-manifest.json
key: ${{ steps.embedded-mpv-runtime-cache-key.outputs.key }}
- name: Smoke test packaged Flatpak launcher
if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak'
shell: bash
run: |
set -euo pipefail
FLATPAK_BUNDLE="$(find dist/executables -maxdepth 1 -name '*.flatpak' | head -n 1)"
if [ -z "${FLATPAK_BUNDLE}" ]; then
echo "::error::Flatpak bundle not found in dist/executables"
exit 1
fi
flatpak install --user --noninteractive -y "${FLATPAK_BUNDLE}"
flatpak run --command=sh com.fourgray.iptvnator -c '
set -euo pipefail
test -f /app/share/metainfo/com.fourgray.iptvnator.metainfo.xml
test -L /app/bin/iptvnator
LAUNCHER_PATH="$(readlink -f /app/bin/iptvnator)"
test -f "${LAUNCHER_PATH}"
test -f "${LAUNCHER_PATH}.bin"
grep -q '\''readlink -f "$SCRIPT_PATH"'\'' "${LAUNCHER_PATH}"
grep -q '\''exec "$SCRIPT_DIR/iptvnator.bin"'\'' "${LAUNCHER_PATH}"
'
- name: Upload artifacts (macOS)
if: matrix.os == 'macos'
uses: actions/upload-artifact@v4
with:
name: macos-${{ matrix.arch }}-artifacts
path: |
dist/executables/**/*.dmg
dist/executables/**/*.zip
dist/executables/**/latest-mac.yml
dist/executables/**/*.blockmap
retention-days: 7
- name: Upload artifacts (Linux)
if: matrix.os == 'linux' && matrix.linux_profile == 'standard'
uses: actions/upload-artifact@v4
with:
name: linux-artifacts
path: |
dist/executables/**/*.deb
dist/executables/**/*.rpm
dist/executables/**/*.snap
dist/executables/**/*.AppImage
dist/executables/**/*.tar.gz
dist/executables/**/*.pacman
dist/executables/**/latest-linux*.yml
dist/executables/**/*.blockmap
retention-days: 7
- name: Upload artifacts (Flatpak)
if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak'
uses: actions/upload-artifact@v4
with:
name: linux-flatpak-artifacts
path: |
dist/executables/**/*.flatpak
retention-days: 7
- name: Upload artifacts (Windows)
if: matrix.os == 'windows'
uses: actions/upload-artifact@v4
with:
name: windows-artifacts
path: |
dist/executables/**/*.exe
dist/executables/**/*.msi
dist/executables/**/*.zip
dist/executables/**/latest.yml
dist/executables/**/*.blockmap
retention-days: 7
create-release:
name: Create Draft Release
needs: build
if: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}
runs-on: ubuntu-latest
concurrency:
group: ${{ github.workflow }}-release-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: false
permissions:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
path: artifacts
- name: Display structure of downloaded files
run: ls -R artifacts
- name: Merge macOS updater metadata
shell: bash
run: |
set -euo pipefail
node <<'NODE'
const fs = require('fs');
const candidates = [
'artifacts/macos-x64-artifacts/latest-mac.yml',
'artifacts/macos-arm64-artifacts/latest-mac.yml',
].filter((filePath) => fs.existsSync(filePath));
if (candidates.length === 0) {
console.log('No macOS update metadata found; skipping merge.');
process.exit(0);
}
function extractFilesBlock(text, filePath) {
const lines = text.split(/\r?\n/);
const start = lines.findIndex((line) => line === 'files:');
if (start === -1) {
throw new Error(`Missing files block in ${filePath}`);
}
const block = [];
for (let index = start + 1; index < lines.length; index += 1) {
const line = lines[index];
if (/^\S/.test(line) && line.trim() !== '') {
break;
}
if (line.trim() !== '') {
block.push(line);
}
}
return block;
}
function splitFileEntries(block) {
const entries = [];
let current = [];
for (const line of block) {
if (/^\s*-\s+url:/.test(line) && current.length > 0) {
entries.push(current);
current = [];
}
current.push(line);
}
if (current.length > 0) {
entries.push(current);
}
return entries;
}
function replaceFilesBlock(text, mergedBlock) {
const lines = text.split(/\r?\n/);
const start = lines.findIndex((line) => line === 'files:');
let end = lines.length;
for (let index = start + 1; index < lines.length; index += 1) {
const line = lines[index];
if (/^\S/.test(line) && line.trim() !== '') {
end = index;
break;
}
}
return [
...lines.slice(0, start + 1),
...mergedBlock,
...lines.slice(end),
].join('\n').replace(/\n*$/, '\n');
}
const mergedEntries = [];
const seenUrls = new Set();
for (const filePath of candidates) {
const text = fs.readFileSync(filePath, 'utf8');
const entries = splitFileEntries(extractFilesBlock(text, filePath));
for (const entry of entries) {
const urlLine = entry.find((line) => /^\s*-\s+url:/.test(line));
const url = urlLine?.replace(/^\s*-\s+url:\s*/, '').trim();
if (!url || seenUrls.has(url)) {
continue;
}
seenUrls.add(url);
mergedEntries.push(...entry);
}
}
const merged = replaceFilesBlock(
fs.readFileSync(candidates[0], 'utf8'),
mergedEntries
);
fs.writeFileSync('artifacts/latest-mac.yml', merged);
console.log(`Merged ${candidates.length} macOS update metadata files.`);
NODE
- name: Get version from package.json
id: package-version
run: echo "version=$(node -p "require('./package.json').version")" >> $GITHUB_OUTPUT
# Test drafts (PR/master) get a self-describing title plus a context
# header linking the PR, real head commit, and workflow run. A stable
# tag per PR (test-pr-<n>) / branch (test-master) makes the action
# update one rolling draft in place instead of piling up a new draft
# for every push. PR builds must not use github.sha here: that is the
# ephemeral merge-commit SHA, which resolves to nothing in the repo.
- name: Compose release metadata
id: release-meta
shell: bash
env:
VERSION: ${{ steps.package-version.outputs.version }}
EVENT_NAME: ${{ github.event_name }}
IS_TAG_BUILD: ${{ startsWith(github.ref, 'refs/tags/') }}
PR_NUMBER: ${{ github.event.pull_request.number }}
PR_TITLE: ${{ github.event.pull_request.title }}
PR_URL: ${{ github.event.pull_request.html_url }}
HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }}
SOURCE_BRANCH: ${{ github.head_ref || github.ref_name }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
run: |
set -euo pipefail
SHORT_SHA="${HEAD_SHA:0:7}"
SAFE_BRANCH="${SOURCE_BRANCH//\//-}"
if [ "${IS_TAG_BUILD}" = "true" ]; then
NAME="Release v${VERSION}"
TAG="${GITHUB_REF_NAME}"
BODY=""
elif [ "${EVENT_NAME}" = "pull_request" ]; then
NAME="v${VERSION} — PR #${PR_NUMBER} @ ${SHORT_SHA} [test]"
TAG="test-pr-${PR_NUMBER}"
BODY="$(printf '🧪 Test build for PR [#%s](%s) — %s\n\nCommit [`%s`](%s/commit/%s) · branch `%s` · [workflow run](%s)' \
"${PR_NUMBER}" "${PR_URL}" "${PR_TITLE}" \
"${SHORT_SHA}" "${REPO_URL}" "${HEAD_SHA}" "${SOURCE_BRANCH}" "${RUN_URL}")"
else
NAME="v${VERSION} — ${SAFE_BRANCH} @ ${SHORT_SHA} [test]"
TAG="test-${SAFE_BRANCH}"
BODY="$(printf '🧪 Test build from `%s` — commit [`%s`](%s/commit/%s) · [workflow run](%s)' \
"${SOURCE_BRANCH}" "${SHORT_SHA}" "${REPO_URL}" "${HEAD_SHA}" "${RUN_URL}")"
fi
{
echo "name=${NAME}"
echo "tag=${TAG}"
echo "commitish=${HEAD_SHA}"
} >> "${GITHUB_OUTPUT}"
if [ -n "${BODY}" ]; then
{
echo "body<<RELEASE_BODY_EOF"
echo "${BODY}"
echo "RELEASE_BODY_EOF"
} >> "${GITHUB_OUTPUT}"
else
echo "body=" >> "${GITHUB_OUTPUT}"
fi
# A PR can be closed while this workflow is still running; the
# cleanup workflow deletes the PR draft on close. Re-check the live
# PR state right before touching the draft so a late-finishing run
# cannot recreate a draft for a closed PR.
- name: Check PR is still open
if: github.event_name == 'pull_request'
id: pr-state
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
echo "state=$(gh api "repos/${GITHUB_REPOSITORY}/pulls/${{ github.event.pull_request.number }}" --jq '.state')" >> "${GITHUB_OUTPUT}"
# The rolling draft keeps assets across runs and the release action
# only replaces same-name files. If the app version changes between
# pushes, old-version installers would linger beside the new set,
# so drop every existing asset first — the action re-uploads the
# full current set right after. Only drafts are pruned; published
# releases are never touched.
- name: Prune stale draft assets
if: github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_TAG: ${{ steps.release-meta.outputs.tag }}
run: |
set -euo pipefail
release_id="$(gh api "repos/${GITHUB_REPOSITORY}/releases?per_page=100" --paginate |
jq -s --arg tag "${RELEASE_TAG}" \
'add | [.[] | select(.draft and .tag_name == $tag)][0].id // empty')"
if [ -z "${release_id}" ]; then
echo "No existing draft for ${RELEASE_TAG}; nothing to prune."
exit 0
fi
gh api "repos/${GITHUB_REPOSITORY}/releases/${release_id}/assets?per_page=100" --paginate --jq '.[].id' |
xargs -r -n1 -I{} gh api -X DELETE "repos/${GITHUB_REPOSITORY}/releases/assets/{}"
echo "Pruned assets from draft ${release_id} (${RELEASE_TAG})."
- name: Create Draft Release
id: draft-release
if: github.event_name != 'pull_request' || steps.pr-state.outputs.state == 'open'
uses: softprops/action-gh-release@v2
with:
draft: true
prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }}
name: ${{ steps.release-meta.outputs.name }}
tag_name: ${{ steps.release-meta.outputs.tag }}
target_commitish: ${{ steps.release-meta.outputs.commitish }}
body: ${{ steps.release-meta.outputs.body }}
generate_release_notes: true
files: |
artifacts/macos-x64-artifacts/*-x64.dmg
artifacts/macos-x64-artifacts/*-x64.zip
artifacts/macos-x64-artifacts/*.blockmap
artifacts/macos-arm64-artifacts/*-arm64.dmg
artifacts/macos-arm64-artifacts/*-arm64.zip
artifacts/macos-arm64-artifacts/*.blockmap
artifacts/latest-mac.yml
artifacts/linux-artifacts/*.AppImage
artifacts/linux-artifacts/*.deb
artifacts/linux-artifacts/*.rpm
artifacts/linux-artifacts/*.snap
artifacts/linux-artifacts/*.tar.gz
artifacts/linux-artifacts/*.pacman
artifacts/linux-artifacts/latest-linux*.yml
artifacts/linux-artifacts/*.blockmap
artifacts/linux-flatpak-artifacts/*.flatpak
artifacts/windows-artifacts/*-setup.exe
artifacts/windows-artifacts/*.msi
artifacts/windows-artifacts/*.zip
artifacts/windows-artifacts/latest.yml
artifacts/windows-artifacts/*.blockmap
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Rare action-gh-release path: when the release listing transiently
# misses the rolling draft, the action creates a duplicate, deletes
# it in favor of the canonical (oldest) draft, and uploads assets
# there WITHOUT refreshing that draft's metadata. Rebuild the full
# metadata (title, commitish, and body = context header + notes
# from the same generate-notes API the action uses) on the release
# id the action actually used, so the draft ends up correct no
# matter which internal path ran. If notes generation fails, the
# body is left as the action set it and only title/commitish are
# re-asserted.
- name: Ensure draft metadata is current
if: steps.draft-release.outputs.id != ''
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
RELEASE_ID: ${{ steps.draft-release.outputs.id }}
RELEASE_TAG: ${{ steps.release-meta.outputs.tag }}
RELEASE_NAME: ${{ steps.release-meta.outputs.name }}
RELEASE_COMMITISH: ${{ steps.release-meta.outputs.commitish }}
RELEASE_BODY: ${{ steps.release-meta.outputs.body }}
run: |
set -euo pipefail
GENERATED_NOTES="$(gh api -X POST "repos/${GITHUB_REPOSITORY}/releases/generate-notes" \
-f tag_name="${RELEASE_TAG}" \
-f target_commitish="${RELEASE_COMMITISH}" \
--jq '.body' || true)"
if [ -z "${GENERATED_NOTES}" ]; then
jq -n \
--arg name "${RELEASE_NAME}" \
--arg commitish "${RELEASE_COMMITISH}" \
'{name: $name, target_commitish: $commitish}' |
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --input - > /dev/null
exit 0
fi
if [ -n "${RELEASE_BODY}" ]; then
FULL_BODY="$(printf '%s\n\n%s' "${RELEASE_BODY}" "${GENERATED_NOTES}")"
else
FULL_BODY="${GENERATED_NOTES}"
fi
jq -n \
--arg name "${RELEASE_NAME}" \
--arg commitish "${RELEASE_COMMITISH}" \
--arg body "${FULL_BODY}" \
'{name: $name, target_commitish: $commitish, body: ($body | .[0:120000])}' |
gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --input - > /dev/null
publish-snap:
name: Publish to Snapcraft Store
needs: build
runs-on: ubuntu-latest
if: startsWith(github.ref, 'refs/tags/v')
env:
SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }}
steps:
- name: Download snap artifact
uses: actions/download-artifact@v4
with:
name: linux-artifacts
path: artifacts
- name: Setup Snapcraft
uses: samuelmeuli/action-snapcraft@v3
- name: Publish all snaps to edge channel
run: |
# Find and publish all snap files
for SNAP_FILE in artifacts/*.snap; do
if [ -f "$SNAP_FILE" ]; then
echo "Publishing: $SNAP_FILE"
snapcraft upload --release=edge "$SNAP_FILE"
fi
done