feat(search): collapse language/quality variants in global search #1319
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Build and Make Electron App | |
| on: | |
| push: | |
| branches: | |
| - master | |
| tags: | |
| - 'v*.*.*' | |
| pull_request: | |
| branches: | |
| - master | |
| workflow_dispatch: | |
| 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@v4 | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: '22' | |
| - 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/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@v4 | |
| 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/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@v4 | |
| 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@v4 | |
| 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@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 \ | |
| 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@v4 | |
| 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 "<version> (<sha>)" 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 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_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: 6014aa0e6d8e98cdba90f5288295a7105d7d14ab0ca906f51465eeb478d5fea0 | |
| 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 && 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@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 && 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: | |
| 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-07-17-94335ab87a/mpv-dev-lgpl-x86_64-20260717-git-94335ab87a.7z | |
| IPTVNATOR_DEFAULT_WINDOWS_EMBEDDED_MPV_RUNTIME_SHA256: 6014aa0e6d8e98cdba90f5288295a7105d7d14ab0ca906f51465eeb478d5fea0 | |
| 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: 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@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: 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@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 system-runtime Linux artifacts | |
| if: matrix.os == 'linux' && matrix.linux_profile == 'system' | |
| uses: actions/upload-artifact@v4 | |
| 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@v4 | |
| 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@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 | |
| 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@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-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-<hash>"), 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 |