name: Build and Make Electron App # Docs-only changes never affect the packaged app, so they skip the build # matrix. apps/website/** is intentionally NOT ignored: the Linux build job # builds the website to verify AppStream screenshot assets. Tag pushes are # unaffected — GitHub does not evaluate paths filters for tags, so v* release # builds always run. # # Known accepted edge case: if a PR built a test-pr- draft and later # reverts its code changes so the remaining diff is docs-only, new pushes # skip this workflow and the draft keeps assets from the older commit. The # draft's title/body name the exact commit they were built from, and # cleanup-pr-draft.yml deletes the draft when the PR closes, so the stale # window is visible and bounded; refreshing drafts on skipped runs is not # worth a separate workflow. on: push: branches: - master tags: - 'v*.*.*' paths-ignore: - '**/*.md' - 'docs/**' - '.plans/**' - '.codex/**' - '.claude/**' pull_request: branches: - master paths-ignore: - '**/*.md' - 'docs/**' - '.plans/**' - '.codex/**' - '.claude/**' workflow_dispatch: # Build jobs only read the repo; the create-release job raises itself to # contents: write at the job level to manage the rolling draft release. permissions: contents: read jobs: linux-embedded-mpv-runtime: name: Build pinned Linux Embedded MPV runtime runs-on: ubuntu-22.04 timeout-minutes: 120 # Concurrency lives on the build 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 only serializes and is never cancelled. concurrency: group: ${{ github.workflow }}-build-linux-runtime-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} steps: - name: Checkout code uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' - name: Resolve Linux runtime toolchain cache key id: linux-runtime-cache-key shell: bash run: | set -euo pipefail sudo apt-get update { apt-cache policy \ binutils build-essential cmake curl git gperf \ libasound2-dev libdrm-dev libegl-dev libgbm-dev \ libgl-dev libpulse-dev libva-dev make nasm \ ninja-build patchelf perl pkg-config python3-pip \ tar xz-utils echo 'meson=1.7.2' } > "${RUNNER_TEMP}/linux-runtime-toolchain.txt" TOOLCHAIN_SHA256="$(sha256sum "${RUNNER_TEMP}/linux-runtime-toolchain.txt" | cut -d ' ' -f 1)" SOURCE_SHA256="${{ hashFiles('tools/embedded-mpv/build-linux-runtime.cjs', 'tools/embedded-mpv/build-linux-runtime.mjs', 'tools/embedded-mpv/download-pinned-source.mjs', 'tools/embedded-mpv/generate-linux-runtime-notices.cjs', 'tools/embedded-mpv/linux-runtime-manifest.cjs', 'tools/embedded-mpv/linux-source-archive-contract.cjs', 'tools/embedded-mpv/stage-runtime.mjs', 'tools/packaging/prepare-linux-runtime-source-snapshot.cjs') }}" echo "toolchain-sha256=${TOOLCHAIN_SHA256}" >> "${GITHUB_OUTPUT}" echo "key=linux-frame-copy-runtime-v5-ubuntu-22.04-${TOOLCHAIN_SHA256}-${SOURCE_SHA256}" >> "${GITHUB_OUTPUT}" - name: Restore pinned Linux runtime and immutable source inputs id: linux-runtime-cache uses: actions/cache@v6 with: path: | vendor/embedded-mpv/linux-x64/include vendor/embedded-mpv/linux-x64/lib vendor/embedded-mpv/linux-x64/runtime-manifest.json dist/linux-frame-copy-runtime-source-inputs key: ${{ steps.linux-runtime-cache-key.outputs.key }} - name: Install pinned Linux runtime build dependencies if: steps.linux-runtime-cache.outputs.cache-hit != 'true' shell: bash run: | set -euo pipefail sudo apt-get install --no-install-recommends -y \ binutils \ build-essential \ cmake \ curl \ git \ gperf \ libasound2-dev \ libdrm-dev \ libegl-dev \ libgbm-dev \ libgl-dev \ libpulse-dev \ libva-dev \ make \ nasm \ ninja-build \ patchelf \ perl \ pkg-config \ python3-pip \ tar \ xz-utils python3 -m pip install --user 'meson==1.7.2' - name: Build and stage pinned LGPL Linux runtime if: steps.linux-runtime-cache.outputs.cache-hit != 'true' shell: bash run: | set -euo pipefail export PATH="${HOME}/.local/bin:${PATH}" export IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT="${RUNNER_TEMP}/linux-frame-copy-runtime-build" export RUNTIME_PREFIX="${RUNNER_TEMP}/linux-frame-copy-runtime-prefix" node tools/embedded-mpv/build-linux-runtime.mjs "${RUNTIME_PREFIX}" node tools/embedded-mpv/stage-runtime.mjs linux x64 "${RUNTIME_PREFIX}" export SOURCE_INPUT_ROOT="${GITHUB_WORKSPACE}/dist/linux-frame-copy-runtime-source-inputs" rm -rf "${SOURCE_INPUT_ROOT}" mkdir -p \ "${SOURCE_INPUT_ROOT}/archives" \ "${SOURCE_INPUT_ROOT}/git" git -C "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" \ submodule foreach --recursive git clean -ffdqx git -C "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" \ clean -ffdqx cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/archives/." "${SOURCE_INPUT_ROOT}/archives/" cp -a "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources/libplacebo" "${SOURCE_INPUT_ROOT}/git/libplacebo" node tools/embedded-mpv/generate-linux-runtime-notices.cjs collect \ --runtime-manifest "${RUNTIME_PREFIX}/runtime-manifest.json" \ --source-root "${IPTVNATOR_EMBEDDED_MPV_LINUX_BUILD_ROOT}/sources" \ --output-root "${SOURCE_INPUT_ROOT}/license-inputs" - name: Generate Linux runtime notices and assemble source compliance shell: bash run: | set -euo pipefail export RUNTIME_ROOT="${GITHUB_WORKSPACE}/vendor/embedded-mpv/linux-x64" export SOURCE_INPUT_ROOT="${GITHUB_WORKSPACE}/dist/linux-frame-copy-runtime-source-inputs" export SOURCE_BUNDLE_ROOT="${RUNNER_TEMP}/linux-frame-copy-runtime-sources" export LIBPLACEBO_SOURCE_RECORD="${RUNNER_TEMP}/libplacebo-source-record.json" test -f "${RUNTIME_ROOT}/runtime-manifest.json" test -d "${SOURCE_INPUT_ROOT}/archives" test -d "${SOURCE_INPUT_ROOT}/git/libplacebo" test -f "${SOURCE_INPUT_ROOT}/license-inputs/linux-runtime-license-inputs.json" rm -rf "${RUNTIME_ROOT}/notices" "${SOURCE_BUNDLE_ROOT}" node tools/embedded-mpv/generate-linux-runtime-notices.cjs generate \ --runtime-manifest "${RUNTIME_ROOT}/runtime-manifest.json" \ --license-input-root "${SOURCE_INPUT_ROOT}/license-inputs" \ --output-root "${RUNTIME_ROOT}/notices" mkdir -p \ "${SOURCE_BUNDLE_ROOT}/archives" \ "${SOURCE_BUNDLE_ROOT}/git" \ "${SOURCE_BUNDLE_ROOT}/license-inputs" \ "${SOURCE_BUNDLE_ROOT}/metadata" \ "${SOURCE_BUNDLE_ROOT}/notices" \ "${SOURCE_BUNDLE_ROOT}/tooling" cp -a "${SOURCE_INPUT_ROOT}/archives/." "${SOURCE_BUNDLE_ROOT}/archives/" cp -a "${SOURCE_INPUT_ROOT}/license-inputs/." "${SOURCE_BUNDLE_ROOT}/license-inputs/" cp -a "${RUNTIME_ROOT}/notices/." "${SOURCE_BUNDLE_ROOT}/notices/" cp "${RUNTIME_ROOT}/runtime-manifest.json" "${SOURCE_BUNDLE_ROOT}/metadata/runtime-manifest.json" node tools/packaging/prepare-linux-runtime-source-snapshot.cjs prepare \ --runtime-manifest "${RUNTIME_ROOT}/runtime-manifest.json" \ --checkout "${SOURCE_INPUT_ROOT}/git/libplacebo" \ --output "${SOURCE_BUNDLE_ROOT}/git/libplacebo" \ --record-output "${LIBPLACEBO_SOURCE_RECORD}" cp \ tools/embedded-mpv/build-linux-runtime.cjs \ tools/embedded-mpv/build-linux-runtime.mjs \ tools/embedded-mpv/download-pinned-source.mjs \ tools/embedded-mpv/generate-linux-runtime-notices.cjs \ tools/embedded-mpv/linux-runtime-manifest.cjs \ tools/embedded-mpv/linux-source-archive-contract.cjs \ tools/embedded-mpv/stage-runtime.mjs \ tools/packaging/prepare-linux-runtime-source-snapshot.cjs \ "${SOURCE_BUNDLE_ROOT}/tooling/" test -f "${SOURCE_BUNDLE_ROOT}/notices/THIRD_PARTY_NOTICES.txt" test -f "${SOURCE_BUNDLE_ROOT}/notices/embedded-mpv-notices.json" git rev-parse HEAD > "${SOURCE_BUNDLE_ROOT}/metadata/iptvnator-git-revision.txt" git diff --binary HEAD > "${SOURCE_BUNDLE_ROOT}/metadata/local-changes.patch" node <<'NODE' const crypto = require('node:crypto'); const fs = require('node:fs'); const path = require('node:path'); const { EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, validateLinuxRuntimeSourceSnapshot, } = require('./tools/packaging/prepare-linux-runtime-source-snapshot.cjs'); const manifest = JSON.parse( fs.readFileSync(path.join(process.env.RUNTIME_ROOT, 'runtime-manifest.json'), 'utf8') ); const archivesDirectory = path.join(process.env.SOURCE_BUNDLE_ROOT, 'archives'); const archives = fs.readdirSync(archivesDirectory).sort().map((name) => { const contents = fs.readFileSync(path.join(archivesDirectory, name)); return { name, sha256: crypto.createHash('sha256').update(contents).digest('hex'), }; }); const expectedArchiveHashes = Object.values(manifest.packages) .map(({ sourceSha256 }) => sourceSha256) .filter(Boolean) .sort(); const actualArchiveHashes = archives.map(({ sha256 }) => sha256).sort(); if ( new Set(expectedArchiveHashes).size !== expectedArchiveHashes.length || new Set(actualArchiveHashes).size !== actualArchiveHashes.length || archives.length !== expectedArchiveHashes.length || JSON.stringify(actualArchiveHashes) !== JSON.stringify(expectedArchiveHashes) ) { throw new Error( 'Source bundle archives must match the exact unique pinned archive hash set.' ); } const libplacebo = JSON.parse( fs.readFileSync(process.env.LIBPLACEBO_SOURCE_RECORD, 'utf8') ); if ( libplacebo.sourceGitCommit !== manifest.packages.libplacebo.sourceGitCommit || JSON.stringify(libplacebo.sourceSubmodules) !== JSON.stringify(manifest.packages.libplacebo.sourceSubmodules) ) { throw new Error('Prepared libplacebo source identity does not match the runtime manifest.'); } validateLinuxRuntimeSourceSnapshot(libplacebo.sourceSnapshot, { expectedSha256: EXPECTED_LIBPLACEBO_V7_360_1_SOURCE_SNAPSHOT_SHA256, }); const notices = JSON.parse( fs.readFileSync( path.join(process.env.SOURCE_BUNDLE_ROOT, 'notices', 'embedded-mpv-notices.json'), 'utf8' ) ); const repositoryRevision = fs .readFileSync( path.join( process.env.SOURCE_BUNDLE_ROOT, 'metadata', 'iptvnator-git-revision.txt' ), 'utf8' ) .trim(); fs.writeFileSync( path.join(process.env.SOURCE_BUNDLE_ROOT, 'metadata', 'source-index.json'), `${JSON.stringify( { schemaVersion: 3, repositoryRevision, sourcePackages: manifest.packages, archives, libplacebo, legal: { manifest: 'notices/embedded-mpv-notices.json', noticeFile: notices.noticeFile, packages: notices.packages, }, }, null, 2 )}\n` ); NODE ( cd "${SOURCE_BUNDLE_ROOT}/archives" sha256sum * > "../metadata/archive-sha256.txt" ) node tools/packaging/prepare-linux-runtime-source-snapshot.cjs assert-vcs-free \ --directory "${SOURCE_BUNDLE_ROOT}" mkdir -p dist/compliance rm -f dist/compliance/linux-frame-copy-runtime-sources.tar.xz tar \ --create \ --xz \ --sort=name \ --mtime='UTC 1970-01-01' \ --owner=0 \ --group=0 \ --numeric-owner \ --file dist/compliance/linux-frame-copy-runtime-sources.tar.xz \ --directory "${SOURCE_BUNDLE_ROOT}" \ . rm -f "${RUNTIME_ROOT}/source-archive-binding.json" node tools/embedded-mpv/linux-source-archive-contract.cjs create \ --archive dist/compliance/linux-frame-copy-runtime-sources.tar.xz \ --repository-revision "$(git rev-parse HEAD)" \ --output "${RUNTIME_ROOT}/source-archive-binding.json" test -s "${RUNTIME_ROOT}/source-archive-binding.json" - name: Upload staged Linux runtime uses: actions/upload-artifact@v7 with: name: linux-embedded-mpv-runtime path: vendor/embedded-mpv/linux-x64 if-no-files-found: error retention-days: 7 - name: Upload Linux runtime source compliance uses: actions/upload-artifact@v7 with: name: linux-frame-copy-runtime-sources path: dist/compliance/linux-frame-copy-runtime-sources.tar.xz if-no-files-found: error retention-days: 7 build-cross-platform: name: Build on ${{ matrix.os }} ${{ matrix.arch }} runs-on: ${{ matrix.runner }} timeout-minutes: 120 concurrency: group: ${{ github.workflow }}-build-${{ matrix.os }}-${{ matrix.arch }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: fail-fast: false 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 - os: windows runner: windows-2022 arch: x64 embedded_mpv_platform: win32 embedded_mpv_arch: x64 embedded_mpv_build_runtime: false steps: &electron-build-steps - name: Checkout code uses: actions/checkout@v7 - name: Install pnpm uses: pnpm/action-setup@v6.0.10 - name: Setup Node.js uses: actions/setup-node@v7 with: node-version-file: '.nvmrc' cache: 'pnpm' - name: Install Linux system dependencies if: matrix.os == 'linux' run: | sudo apt-get update sudo apt-get install --no-install-recommends -y \ appstream \ binutils \ dbus-daemon \ flatpak \ flatpak-builder \ libarchive-tools \ libegl-dev \ libgbm-dev \ libgl-dev \ libx11-dev \ libxext-dev \ mpv \ pkg-config \ rpm \ snapd \ squashfs-tools \ xauth \ xvfb - name: Configure Flatpak build runtime if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' run: | set -euo pipefail flatpak remote-add --if-not-exists --user flathub https://flathub.org/repo/flathub.flatpakrepo 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: | cp electron-builder.json "${RUNNER_TEMP}/electron-builder.base.json" node tools/packaging/configure-linux-frame-copy-build.mjs --profile "${{ matrix.linux_profile }}" - name: Install dependencies run: pnpm install --frozen-lockfile - name: Download pinned Linux Embedded MPV runtime if: matrix.os == 'linux' uses: actions/download-artifact@v8 with: name: linux-embedded-mpv-runtime path: vendor/embedded-mpv/linux-x64 - 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: Inject build commit # Shows " ()" in Settings > About so bug reports # from test builds identify the exact commit. PR builds use the # head SHA — github.sha would be the ephemeral merge commit. env: BUILD_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} run: node tools/build/inject-build-commit.mjs - name: Build frontend run: pnpm nx build web --skip-nx-cache - name: Resolve pinned Windows Embedded MPV runtime if: matrix.os == 'windows' id: windows-embedded-mpv-runtime-pin shell: bash run: node tools/embedded-mpv/windows-runtime-pin.mjs --github-output - 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 && matrix.os != 'linux' && !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_PINNED_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: ${{ steps.windows-embedded-mpv-runtime-pin.outputs.sha256 || '' }} 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/download-pinned-source.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', 'tools/embedded-mpv/windows-runtime-pin.json', 'tools/embedded-mpv/windows-runtime-pin.mjs' ); const windowsRuntimeSha256 = process.env.IPTVNATOR_PINNED_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 && matrix.os != 'linux' && !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@v6 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 && matrix.os != 'linux' && !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: WINDOWS_RUNTIME_URL: ${{ steps.windows-embedded-mpv-runtime-pin.outputs.url }} WINDOWS_RUNTIME_SHA256: ${{ steps.windows-embedded-mpv-runtime-pin.outputs.sha256 }} run: | set -euo pipefail if [ -z "${WINDOWS_RUNTIME_URL}" ] || [ -z "${WINDOWS_RUNTIME_SHA256}" ]; then echo "::error::The checked-in Windows Embedded MPV runtime pin did not resolve a URL and SHA-256 digest." exit 1 fi pnpm embedded-mpv:stage-runtime:windows-archive -- "${WINDOWS_RUNTIME_URL}" "${WINDOWS_RUNTIME_SHA256}" - name: Build backend env: IPTVNATOR_EMBEDDED_MPV_PLATFORM: ${{ matrix.embedded_mpv_platform || '' }} IPTVNATOR_EMBEDDED_MPV_ARCH: ${{ matrix.embedded_mpv_arch || matrix.arch || '' }} IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ${{ matrix.linux_profile || '' }} 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 { execFileSync } = require('node:child_process'); const manifest = require('./dist/apps/electron-backend/native/embedded-mpv-runtime.json'); const revision = execFileSync('git', ['rev-parse', 'HEAD'], { encoding: 'utf8' }).trim(); if (manifest.origin !== 'linux-frame-copy-build' || manifest.sourceRuntimeValidated !== true || manifest.sourceArchive?.schemaVersion !== 1 || manifest.sourceArchive?.name !== 'linux-frame-copy-runtime-sources.tar.xz' || !/^[a-f0-9]{64}$/.test(manifest.sourceArchive?.sha256 ?? '') || manifest.sourceArchive?.repositoryRevision !== revision) { throw new Error('Linux embedded MPV build manifest must describe the validated source runtime and exact source archive.'); }" test -f dist/apps/electron-backend/native/lib/libmpv.so.2 test -f dist/apps/electron-backend/native/iptvnator_mpv_helper test -f dist/apps/electron-backend/native/embedded_mpv_frame_reader.node if readelf -d dist/apps/electron-backend/native/embedded_mpv.node | grep -Eq 'Shared library:.*libmpv\.so'; then echo "::error::Linux embedded MPV addon must not link directly to libmpv" readelf -d dist/apps/electron-backend/native/embedded_mpv.node exit 1 fi if readelf -d dist/apps/electron-backend/native/embedded_mpv_frame_reader.node | grep -Eq 'Shared library:.*libmpv\.so'; then echo "::error::Linux frame reader must not link directly to libmpv" readelf -d dist/apps/electron-backend/native/embedded_mpv_frame_reader.node exit 1 fi HELPER_DYNAMIC="$(readelf -d dist/apps/electron-backend/native/iptvnator_mpv_helper)" if ! printf '%s\n' "${HELPER_DYNAMIC}" | grep -Eq 'Shared library: \[libmpv\.so\.2\]'; then echo "::error::Linux frame-copy helper must need libmpv.so.2" printf '%s\n' "${HELPER_DYNAMIC}" exit 1 fi if ! printf '%s\n' "${HELPER_DYNAMIC}" | grep -Fq 'Library runpath: [$ORIGIN/lib]'; then echo "::error::Linux frame-copy helper must keep only the relative runtime path" printf '%s\n' "${HELPER_DYNAMIC}" 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 || '' }} IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ${{ matrix.linux_profile || '' }} # 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 || '' }} IPTVNATOR_LINUX_FRAME_COPY_PROFILE: ${{ matrix.linux_profile || '' }} # 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: Make marker-only foreign-architecture DEB packages if: matrix.os == 'linux' && matrix.linux_profile == 'system' shell: bash env: IPTVNATOR_EMBEDDED_MPV_PLATFORM: linux IPTVNATOR_EMBEDDED_MPV_ARCH: x64 IPTVNATOR_LINUX_FRAME_COPY_PROFILE: '' IPTVNATOR_REQUIRE_EMBEDDED_MPV: '1' run: | set -euo pipefail for foreign_arch in armv7l arm64; do rm -rf dist/executables-linux-foreign cp "${RUNNER_TEMP}/electron-builder.base.json" electron-builder.json node tools/packaging/configure-linux-frame-copy-build.mjs \ --foreign-deb \ --foreign-arch "${foreign_arch}" pnpm nx run electron-backend:make \ --arch="${foreign_arch}" \ --outputPath=dist/executables-linux-foreign \ --publishPolicy=never mapfile -t foreign_debs < <( find dist/executables-linux-foreign -maxdepth 1 -type f -name '*.deb' -print ) test "${#foreign_debs[@]}" -eq 1 case "${foreign_arch}" in armv7l) expected_deb_arch=armhf ;; arm64) expected_deb_arch=arm64 ;; *) echo "::error::Unexpected foreign DEB build architecture ${foreign_arch}" exit 1 ;; esac actual_deb_arch="$(dpkg-deb --field "${foreign_debs[0]}" Architecture)" test "${actual_deb_arch}" = "${expected_deb_arch}" mv "${foreign_debs[0]}" dist/executables/ done - name: Verify DEB payloads and x64 system runtime if: matrix.os == 'linux' && matrix.linux_profile == 'system' shell: bash run: | set -euo pipefail found=false for artifact in dist/executables/*.deb; do test -f "${artifact}" || continue found=true case "$(dpkg-deb --field "${artifact}" Architecture)" in amd64) docker run --rm \ --volume "${GITHUB_WORKSPACE}:/workspace:ro" \ --volume "$(realpath "${artifact}"):/artifact.deb:ro" \ --workdir /workspace \ ubuntu:24.04 \ bash -euo pipefail -c ' apt-get update DEBIAN_FRONTEND=noninteractive apt-get install --no-install-recommends -y \ binutils libegl1 libgbm1 libgl1 libgl1-mesa-dri libmpv2 \ nodejs squashfs-tools xauth xvfb xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ node tools/packaging/verify-linux-frame-copy-runtime.mjs \ --artifact /artifact.deb --profile system ' ;; arm64|armhf) node tools/packaging/verify-linux-frame-copy-runtime.mjs \ --artifact "${artifact}" --profile system ;; *) echo "::error::Unexpected DEB architecture in ${artifact}" exit 1 ;; esac done test "${found}" = true - name: Verify RPM payload and x64 system runtime if: matrix.os == 'linux' && matrix.linux_profile == 'system' shell: bash run: | set -euo pipefail artifact="$(find dist/executables -maxdepth 1 -type f -name '*.rpm' -print -quit)" test -n "${artifact}" docker run --rm \ --volume "${GITHUB_WORKSPACE}:/workspace:ro" \ --volume "$(realpath "${artifact}"):/artifact.rpm:ro" \ --workdir /workspace \ fedora:latest \ bash -euo pipefail -c ' dnf install -y \ binutils bsdtar libglvnd-egl libglvnd-glx mesa-dri-drivers \ mesa-libgbm mpv-libs nodejs rpm xorg-x11-server-Xvfb \ xorg-x11-xauth xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ node tools/packaging/verify-linux-frame-copy-runtime.mjs \ --artifact /artifact.rpm --profile system ' - name: Verify Pacman payload and x64 system runtime if: matrix.os == 'linux' && matrix.linux_profile == 'system' shell: bash run: | set -euo pipefail artifact="$(find dist/executables -maxdepth 1 -type f \( -name '*.pacman' -o -name '*.pkg.tar.*' \) -print -quit)" test -n "${artifact}" docker run --rm \ --volume "${GITHUB_WORKSPACE}:/workspace:ro" \ --volume "$(realpath "${artifact}"):/artifact.pacman:ro" \ --workdir /workspace \ archlinux:latest \ bash -euo pipefail -c ' pacman -Syu --noconfirm \ binutils libarchive libglvnd mesa mpv nodejs xorg-server-xvfb \ xorg-xauth xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ node tools/packaging/verify-linux-frame-copy-runtime.mjs \ --artifact /artifact.pacman --profile system ' - name: Verify AppImage payloads and bundled runtime if: matrix.os == 'linux' && matrix.linux_profile == 'portable' shell: bash run: | set -euo pipefail found=false for artifact in dist/executables/*.AppImage; do test -f "${artifact}" || continue found=true xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ node tools/packaging/verify-linux-frame-copy-runtime.mjs \ --artifact "${artifact}" --profile portable done test "${found}" = true - name: Verify Snap payloads and strict-confinement runtime if: matrix.os == 'linux' && matrix.linux_profile == 'portable' shell: bash run: | set -euo pipefail found=false installed_x64=false for artifact in dist/executables/*.snap; do test -f "${artifact}" || continue found=true verification="$( xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ node tools/packaging/verify-linux-frame-copy-runtime.mjs \ --artifact "${artifact}" --profile portable \ 2>&1 | tee /dev/stderr )" if printf '%s\n' "${verification}" | grep -Fq 'Verified snap x64 Linux'; then snap list mesa-core22 >/dev/null 2>&1 || sudo snap install mesa-core22 snap list gnome-3-28-1804 >/dev/null 2>&1 || sudo snap install gnome-3-28-1804 sudo snap install --dangerous "${artifact}" installed_x64=true fi done test "${found}" = true test "${installed_x64}" = true sudo snap connect iptvnator:graphics-core22 mesa-core22:graphics-core22 sudo snap connect iptvnator:gnome-3-28-1804 gnome-3-28-1804:gnome-3-28-1804 sudo snap disconnect iptvnator:graphics-core22 mesa-core22:graphics-core22 snap connections iptvnator | awk \ '$2 == "iptvnator:graphics-core22" && $3 == "-" { found=1 } END { exit !found }' set +e disconnected_probe="$( xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ snap run iptvnator --embedded-mpv-runtime-probe 2>&1 )" disconnected_status=$? set -e printf '%s\n' "${disconnected_probe}" test "${disconnected_status}" -eq 1 printf '%s\n' "${disconnected_probe}" | \ grep -Fx '{"usable":false,"reason":"snap-graphics-provider-unavailable"}' sudo snap connect iptvnator:graphics-core22 mesa-core22:graphics-core22 snap connections iptvnator | awk \ '$2 == "iptvnator:graphics-core22" && $3 == "mesa-core22:graphics-core22" { found=1 } END { exit !found }' snap connections iptvnator | awk \ '$2 == "iptvnator:gnome-3-28-1804" && $3 == "gnome-3-28-1804:gnome-3-28-1804" { found=1 } END { exit !found }' snap connections iptvnator | awk \ '$1 == "shared-memory" && $2 == "iptvnator:shared-memory" && $3 == ":shared-memory" { found=1 } END { exit !found }' xvfb-run -a env \ LIBGL_ALWAYS_SOFTWARE=1 \ IPTVNATOR_TRACE_PLAYER=1 \ EGL_LOG_LEVEL=debug \ LIBGL_DEBUG=verbose \ __EGL_VENDOR_LIBRARY_FILENAMES=/tmp/hostile-egl-vendor.json \ GBM_BACKEND=/tmp/hostile-gbm \ MESA_LOADER_DRIVER_OVERRIDE=/tmp/hostile-dri \ LIBVA_DRIVER_NAME=/tmp/hostile-va \ VDPAU_DRIVER_PATH=/tmp/hostile-vdpau \ VK_DRIVER_FILES=/tmp/hostile-vulkan-driver.json \ VK_ICD_FILENAMES=/tmp/hostile-vulkan-icd.json \ VK_ADD_DRIVER_FILES=/tmp/hostile-vulkan-add-driver.json \ VK_ADD_LAYER_PATH=/tmp/hostile-vulkan-layers \ VK_IMPLICIT_LAYER_PATH=/tmp/hostile-vulkan-implicit-layers \ VK_ADD_IMPLICIT_LAYER_PATH=/tmp/hostile-vulkan-add-implicit-layers \ XDG_CONFIG_HOME=/tmp/hostile-xdg-config-home \ XDG_CONFIG_DIRS=/tmp/hostile-xdg-config-dirs \ XDG_DATA_HOME=/tmp/hostile-xdg-data-home \ XDG_DATA_DIRS=/tmp/hostile-xdg-data-dirs \ snap run iptvnator --embedded-mpv-runtime-probe - name: Run packaged x64 frame-copy and fallback smoke if: matrix.os == 'linux' && matrix.linux_profile == 'portable' env: IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1' IPTVNATOR_E2E_PACKAGED_EXECUTABLE: ${{ github.workspace }}/dist/executables/linux-unpacked/iptvnator LIBGL_ALWAYS_SOFTWARE: '1' run: | xvfb-run -a pnpm nx run \ electron-backend-e2e:packaged-frame-copy-smoke \ --skip-nx-cache - name: Diagnose packaged x64 frame-copy hardware path if: matrix.os == 'linux' && matrix.linux_profile == 'portable' continue-on-error: true env: IPTVNATOR_E2E_REQUIRE_PACKAGED_FRAME_COPY: '1' IPTVNATOR_E2E_PACKAGED_EXECUTABLE: ${{ github.workspace }}/dist/executables/linux-unpacked/iptvnator run: | set -euo pipefail if [ ! -e /dev/dri/renderD128 ]; then echo "::notice::No /dev/dri/renderD128 is available; skipping the non-blocking hardware-path diagnostic." exit 0 fi ls -la /dev/dri xvfb-run -a pnpm nx run \ electron-backend-e2e:packaged-frame-copy-smoke \ --skip-nx-cache - 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@v6 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: Verify Flatpak payload, launcher, and sandboxed runtime 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 xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ node tools/packaging/verify-linux-frame-copy-runtime.mjs \ --artifact "${FLATPAK_BUNDLE}" --profile flatpak flatpak install --user --noninteractive -y "${FLATPAK_BUNDLE}" xvfb-run -a env LIBGL_ALWAYS_SOFTWARE=1 \ 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 -x "${LAUNCHER_PATH}" if [ -e "${LAUNCHER_PATH}.bin" ] || [ -L "${LAUNCHER_PATH}.bin" ]; then echo "::error::Flatpak must not contain ${LAUNCHER_PATH}.bin" exit 1 fi ELF_MAGIC="$(od -An -tx1 -N4 "${LAUNCHER_PATH}" | tr -d "[:space:]")" test "${ELF_MAGIC}" = "7f454c46" ' set +e PROBE_OUTPUT="$( xvfb-run -a dbus-run-session -- flatpak run \ --env=LIBGL_ALWAYS_SOFTWARE=1 \ com.fourgray.iptvnator \ --embedded-mpv-runtime-probe 2>&1 )" PROBE_STATUS=$? set -e PROBE_OUTPUT_LIMIT=16384 printf '%s\n' "${PROBE_OUTPUT:0:PROBE_OUTPUT_LIMIT}" if [ "${#PROBE_OUTPUT}" -gt "${PROBE_OUTPUT_LIMIT}" ]; then echo "::warning::Flatpak runtime probe output was truncated to ${PROBE_OUTPUT_LIMIT} characters." fi if [[ "${PROBE_OUTPUT}" == *"not an ELF file"* ]] || [[ "${PROBE_OUTPUT}" == *"Zypak needs to be called directly"* ]]; then echo "::error::Flatpak launched a wrapper instead of the Electron ELF." exit 1 fi if [ "${PROBE_STATUS}" -ne 0 ]; then echo "::error::Flatpak application runtime probe failed with status ${PROBE_STATUS}." exit "${PROBE_STATUS}" fi - name: Upload artifacts (macOS) if: matrix.os == 'macos' uses: actions/upload-artifact@v7 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 system-runtime Linux artifacts if: matrix.os == 'linux' && matrix.linux_profile == 'system' uses: actions/upload-artifact@v7 with: name: linux-system-artifacts path: | dist/executables/*.deb dist/executables/*.rpm dist/executables/*.pacman dist/executables/*.pkg.tar.* retention-days: 7 - name: Upload portable-runtime Linux artifacts if: matrix.os == 'linux' && matrix.linux_profile == 'portable' uses: actions/upload-artifact@v7 with: name: linux-portable-artifacts path: | dist/executables/*.AppImage dist/executables/*.snap dist/executables/**/latest-linux*.yml dist/executables/**/*.blockmap retention-days: 7 - name: Upload Flatpak-runtime Linux artifacts if: matrix.os == 'linux' && matrix.linux_profile == 'flatpak' uses: actions/upload-artifact@v7 with: name: linux-flatpak-artifacts path: | dist/executables/**/*.flatpak retention-days: 7 - name: Upload artifacts (Windows) if: matrix.os == 'windows' uses: actions/upload-artifact@v7 with: name: windows-artifacts path: | dist/executables/**/*.exe dist/executables/**/*.msi dist/executables/**/*.zip dist/executables/**/latest.yml dist/executables/**/*.blockmap retention-days: 7 build-linux: name: Build on ${{ matrix.os }} ${{ matrix.arch }} (${{ matrix.linux_profile }}) needs: linux-embedded-mpv-runtime runs-on: ${{ matrix.runner }} timeout-minutes: 120 concurrency: group: ${{ github.workflow }}-build-${{ matrix.os }}-${{ matrix.linux_profile }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: ${{ github.event_name == 'pull_request' }} strategy: fail-fast: false matrix: include: - os: linux runner: ubuntu-22.04 arch: x64 linux_profile: system embedded_mpv_platform: linux embedded_mpv_arch: x64 embedded_mpv_build_runtime: false - os: linux runner: ubuntu-22.04 arch: x64 linux_profile: portable embedded_mpv_platform: linux embedded_mpv_arch: x64 embedded_mpv_build_runtime: false - os: linux runner: ubuntu-24.04 arch: x64 linux_profile: flatpak embedded_mpv_platform: linux embedded_mpv_arch: x64 embedded_mpv_build_runtime: false steps: *electron-build-steps create-release: name: Create Draft Release needs: - build-cross-platform - build-linux 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@v7 - name: Download all artifacts uses: actions/download-artifact@v8 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-) / 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}" # The full committed CHANGELOG retains internal notes, # while the tag release uses authored public extraction. # An internal-only release intentionally has no authored # public text. GitHub's generated commit list remains # separate below. BODY="$(node tools/release/extract-changelog-section.mjs --public "${VERSION}")" 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<> "${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@v3 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-system-artifacts/*.deb artifacts/linux-system-artifacts/*.rpm artifacts/linux-system-artifacts/*.pacman artifacts/linux-system-artifacts/*.pkg.tar.* artifacts/linux-portable-artifacts/*.AppImage artifacts/linux-portable-artifacts/*.snap artifacts/linux-portable-artifacts/latest-linux*.yml artifacts/linux-portable-artifacts/*.blockmap artifacts/linux-flatpak-artifacts/*.flatpak artifacts/linux-frame-copy-runtime-sources/linux-frame-copy-runtime-sources.tar.xz 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)" # tag_name MUST be included in every PATCH: updating a draft # without it makes GitHub drop the pending tag (the draft # becomes "untagged-"), which breaks the rolling-draft # lookup on the next run. if [ -z "${GENERATED_NOTES}" ]; then jq -n \ --arg tag "${RELEASE_TAG}" \ --arg name "${RELEASE_NAME}" \ --arg commitish "${RELEASE_COMMITISH}" \ '{tag_name: $tag, 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 tag "${RELEASE_TAG}" \ --arg name "${RELEASE_NAME}" \ --arg commitish "${RELEASE_COMMITISH}" \ --arg body "${FULL_BODY}" \ '{tag_name: $tag, name: $name, target_commitish: $commitish, body: ($body | .[0:120000])}' | gh api -X PATCH "repos/${GITHUB_REPOSITORY}/releases/${RELEASE_ID}" --input - > /dev/null