Files
iptvnator/.github/workflows/build-and-make.yaml
T
4grayandClaude Fable 5 29e624b0dd ci(release): make test draft releases traceable and self-cleaning (#1202)
* ci(release): make test draft releases traceable and self-cleaning

Every PR and master build created a draft named "Release v<version>"
with tag test-<github.sha>, so 70+ identical drafts piled up and PR
drafts were untraceable (for pull_request events github.sha is the
ephemeral merge-commit SHA that resolves to nothing in the repo).

- Title test drafts as "v<ver> — PR #<n> @ <sha> [test]" /
  "v<ver> — master @ <sha> [test]"; tag releases keep "Release v<ver>"
- Prepend a context header (PR, head commit, workflow run links) to the
  auto-generated release notes
- Use the PR head SHA and pass target_commitish so generated notes
  actually cover the PR commits
- Use stable tags (test-pr-<n>, test-master) so action-gh-release
  updates one rolling draft in place instead of creating a new one per
  push
- Mark all non-tag drafts as prerelease
- Cancel superseded in-progress PR builds via a concurrency group
- Delete a PR's rolling draft when the PR closes (new workflow)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(release): close review-bot race windows in draft release flow

- Move concurrency from workflow level to job level: cancelling a whole
  run could interrupt action-gh-release mid-asset-replacement and leave
  the rolling draft incomplete. Build slots still cancel superseded PR
  work (matrix-aware groups); the release job gets its own serializing,
  never-cancelling group.
- Re-check the live PR state in the release job right before touching
  the draft, so a build that outlives its PR cannot recreate the draft
  after cleanup deleted it.
- In the cleanup workflow, cancel still-running builds of the closed PR
  (dead work anyway) and wait for them to settle before deleting.
- Emit an explicit empty `body=` output for tag builds instead of a
  blank-line heredoc.

Addresses Codex and Greptile review feedback on #1202.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(release): grant actions:write so PR-close cleanup can cancel builds

gh run cancel needs the actions scope; with only contents: write the
cancellation 403s silently and the settle-poll burns its full window.
Also skip the cleanup job for fork PRs entirely: they never get a
draft and their token is read-only regardless of the permissions block.

Addresses Greptile P1 / Codex P2 follow-up on #1202.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(release): re-assert rolling draft title after asset upload

action-gh-release@v2 updates name/body/target_commitish on the normal
draft-reuse path, but in a rare race (release listing transiently
missing the draft) it uploads assets to the canonical oldest draft
without refreshing its metadata. PATCH the title and commitish on the
release id the action actually used, so the draft title always names
the current head SHA; the body is left alone to preserve generated
notes.

Addresses Codex round-2 feedback on #1202.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(release): prune stale assets before updating a rolling draft

The release action only replaces same-name assets, so a PR that bumps
the app version would leave old-version installers beside the new set
in its rolling draft. Delete all existing assets of the matched draft
before the upload; the action re-uploads the full current set right
after. Published releases are never touched.

Addresses Codex round-3 feedback on #1202.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(release): rebuild full draft metadata after asset upload

Extend the post-upload metadata step to also rebuild the body (context
header + notes from the same generate-notes API the action uses), not
just title/commitish. The rolling draft now ends up with correct
metadata regardless of which internal action-gh-release path ran,
including the rare canonicalize-duplicate fallback. If notes
generation fails, the body is left as the action set it.

Addresses Codex round-4 feedback on #1202.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(release): only cancel pull_request runs when cleaning up a closed PR

A manually dispatched build on the same head branch is not the PR's
work; filter the cancellation list by event so PR-close cleanup cannot
abort it.

Addresses Codex round-5 feedback on #1202.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(release): guard PR-close cleanup against close-reopen races

Re-check the live PR state at the start of the cleanup job and again
right before deleting the draft, so a PR that is reopened while the
cleanup is queued or waiting keeps its rolling draft and its fresh
reopened-run builds are not cancelled.

Addresses Codex round-6 feedback on #1202.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* ci(release): keep tag_name when patching rolling draft metadata

PATCHing a draft release without tag_name makes GitHub drop the
pending tag (the draft turns into untagged-<hash>), so the next run
cannot find the rolling draft by tag and creates a duplicate — observed
live on this PR's own drafts. Include tag_name in both PATCH payloads
of the metadata step.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 07:59:53 +02:00

1578 lines
81 KiB
YAML

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: 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