From 0865fdf464f93c75a7705cd524726da94a488af6 Mon Sep 17 00:00:00 2001 From: 4gray Date: Fri, 17 Jul 2026 22:05:23 +0200 Subject: [PATCH] fix(release): gate Snap publish on public source release --- .github/workflows/build-and-make.yaml | 28 --- .github/workflows/publish-snap.yaml | 84 ++++++++ .../configure-linux-frame-copy-build.test.mjs | 7 +- tools/packaging/project.json | 11 +- .../packaging/publish-snap-workflow.test.mjs | 168 ++++++++++++++++ tools/packaging/release-snap-assets.cjs | 189 ++++++++++++++++++ 6 files changed, 453 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/publish-snap.yaml create mode 100644 tools/packaging/publish-snap-workflow.test.mjs create mode 100644 tools/packaging/release-snap-assets.cjs diff --git a/.github/workflows/build-and-make.yaml b/.github/workflows/build-and-make.yaml index 13b947036..335e6b26e 100644 --- a/.github/workflows/build-and-make.yaml +++ b/.github/workflows/build-and-make.yaml @@ -1318,31 +1318,3 @@ jobs: artifacts/windows-artifacts/*.blockmap env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - publish-snap: - name: Publish to Snapcraft Store - needs: build - runs-on: ubuntu-latest - if: startsWith(github.ref, 'refs/tags/v') - env: - SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }} - - steps: - - name: Download snap artifact - uses: actions/download-artifact@v4 - with: - name: linux-portable-artifacts - path: artifacts - - - name: Setup Snapcraft - uses: samuelmeuli/action-snapcraft@v3 - - - name: Publish all snaps to edge channel - run: | - # Find and publish all snap files - for SNAP_FILE in artifacts/*.snap; do - if [ -f "$SNAP_FILE" ]; then - echo "Publishing: $SNAP_FILE" - snapcraft upload --release=edge "$SNAP_FILE" - fi - done diff --git a/.github/workflows/publish-snap.yaml b/.github/workflows/publish-snap.yaml new file mode 100644 index 000000000..6f6fa9f4e --- /dev/null +++ b/.github/workflows/publish-snap.yaml @@ -0,0 +1,84 @@ +name: Publish Snap after public release + +on: + release: + types: + - published + +permissions: + contents: read + +jobs: + publish-snap: + name: Publish public-release Snap to edge + if: ${{ startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }} + runs-on: ubuntu-latest + env: + GH_TOKEN: ${{ github.token }} + SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.snapcraft_token }} + SOURCE_ARCHIVE_NAME: linux-frame-copy-runtime-sources.tar.xz + + steps: + - name: Checkout released tooling + uses: actions/checkout@v4 + with: + ref: ${{ github.event.release.tag_name }} + + - name: Select exact public release assets + shell: bash + run: | + set -euo pipefail + + gh api \ + --paginate \ + --slurp \ + "repos/${GITHUB_REPOSITORY}/releases/${{ github.event.release.id }}/assets?per_page=100" \ + > "${RUNNER_TEMP}/snap-release-assets.json" + node tools/packaging/release-snap-assets.cjs select \ + --assets-json "${RUNNER_TEMP}/snap-release-assets.json" \ + --output-json "${RUNNER_TEMP}/selected-snap-release-assets.json" + + - name: Download exact public release assets + shell: bash + run: | + set -euo pipefail + + ASSET_DIRECTORY="${RUNNER_TEMP}/snap-release-downloads" + rm -rf "${ASSET_DIRECTORY}" + mkdir -p "${ASSET_DIRECTORY}" + node -e \ + "const fs=require('node:fs'); const selected=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); for (const asset of [...selected.snapAssets, selected.sourceAsset]) console.log([asset.id, asset.name].join('\\t'));" \ + "${RUNNER_TEMP}/selected-snap-release-assets.json" | + while IFS=$'\t' read -r ASSET_ID ASSET_NAME; do + gh api \ + --header "Accept: application/octet-stream" \ + "repos/${GITHUB_REPOSITORY}/releases/assets/${ASSET_ID}" \ + > "${ASSET_DIRECTORY}/${ASSET_NAME}" + done + + - name: Verify downloaded public release assets + shell: bash + run: | + set -euo pipefail + + test -s "${RUNNER_TEMP}/snap-release-downloads/${SOURCE_ARCHIVE_NAME}" + node tools/packaging/release-snap-assets.cjs verify \ + --manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \ + --directory "${RUNNER_TEMP}/snap-release-downloads" + + - name: Setup Snapcraft + uses: samuelmeuli/action-snapcraft@v3 + + - name: Publish all public-release snaps to edge + shell: bash + run: | + set -euo pipefail + + node -e \ + "const fs=require('node:fs'); const selected=JSON.parse(fs.readFileSync(process.argv[1],'utf8')); for (const asset of selected.snapAssets) console.log(asset.name);" \ + "${RUNNER_TEMP}/selected-snap-release-assets.json" | + while IFS= read -r SNAP_NAME; do + SNAP_FILE="${RUNNER_TEMP}/snap-release-downloads/${SNAP_NAME}" + echo "Publishing public release asset: ${SNAP_NAME}" + snapcraft upload --release=edge "${SNAP_FILE}" + done diff --git a/tools/packaging/configure-linux-frame-copy-build.test.mjs b/tools/packaging/configure-linux-frame-copy-build.test.mjs index c2a22d06e..58160a06e 100644 --- a/tools/packaging/configure-linux-frame-copy-build.test.mjs +++ b/tools/packaging/configure-linux-frame-copy-build.test.mjs @@ -310,7 +310,7 @@ test('dedicated packaged x64 smoke cannot silently skip', () => { assert.doesNotMatch(hardwareDiagnostic, /LIBGL_ALWAYS_SOFTWARE/); }); -test('release and Snap publication consume split Linux artifacts and source compliance', () => { +test('draft release consumes split Linux artifacts and source compliance', () => { for (const artifactName of [ 'linux-system-artifacts', 'linux-portable-artifacts', @@ -357,8 +357,5 @@ test('release and Snap publication consume split Linux artifacts and source comp ]) { assert.ok(release.includes(releasePath)); } - assert.match( - workflowStep('Download snap artifact'), - /name: linux-portable-artifacts/ - ); + assert.doesNotMatch(buildWorkflow, /^ {4}publish-snap:/m); }); diff --git a/tools/packaging/project.json b/tools/packaging/project.json index bfba72af0..e31fca041 100644 --- a/tools/packaging/project.json +++ b/tools/packaging/project.json @@ -12,6 +12,7 @@ "{workspaceRoot}/package.json", "{workspaceRoot}/electron-builder.json", "{workspaceRoot}/.github/workflows/build-and-make.yaml", + "{workspaceRoot}/.github/workflows/publish-snap.yaml", "{workspaceRoot}/apps/electron-backend/build-embedded-mpv.js", "{workspaceRoot}/apps/electron-backend/project.json", "{workspaceRoot}/apps/electron-backend/native/src/embedded_mpv_win32.cc", @@ -25,6 +26,8 @@ "{workspaceRoot}/tools/packaging/configure-linux-frame-copy-build.test.mjs", "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.cjs", "{workspaceRoot}/tools/packaging/linux-frame-copy-profile.test.mjs", + "{workspaceRoot}/tools/packaging/publish-snap-workflow.test.mjs", + "{workspaceRoot}/tools/packaging/release-snap-assets.cjs", "{workspaceRoot}/tools/packaging/verify-linux-frame-copy-runtime.mjs", "{workspaceRoot}/tools/packaging/verify-linux-frame-copy-runtime.test.mjs", "{workspaceRoot}/tools/embedded-mpv/build-linux-runtime.cjs", @@ -38,11 +41,17 @@ "{workspaceRoot}/tools/embedded-mpv/stage-windows-runtime-archive.mjs" ], "options": { - "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", + "command": "node --test tools/packaging/electron-package-identity.test.mjs tools/packaging/asar-dependency-closure.test.mjs tools/packaging/embedded-mpv-arch.test.mjs tools/packaging/configure-linux-frame-copy-build.test.mjs tools/packaging/linux-frame-copy-profile.test.mjs tools/packaging/publish-snap-workflow.test.mjs tools/packaging/verify-linux-frame-copy-runtime.test.mjs tools/embedded-mpv/build-linux-runtime.test.mjs tools/embedded-mpv/generate-linux-runtime-notices.test.mjs tools/embedded-mpv/linux-runtime-manifest.test.mjs", "cwd": "{workspaceRoot}" } }, "lint": { + "inputs": [ + "default", + "{workspaceRoot}/.github/workflows/publish-snap.yaml", + "{workspaceRoot}/tools/packaging/publish-snap-workflow.test.mjs", + "{workspaceRoot}/tools/packaging/release-snap-assets.cjs" + ], "command": "eslint \"tools/packaging/**/*.{js,cjs,mjs,ts}\" \"tools/embedded-mpv/generate-linux-runtime-notices.{cjs,test.mjs}\"" } }, diff --git a/tools/packaging/publish-snap-workflow.test.mjs b/tools/packaging/publish-snap-workflow.test.mjs new file mode 100644 index 000000000..3b9ae70c6 --- /dev/null +++ b/tools/packaging/publish-snap-workflow.test.mjs @@ -0,0 +1,168 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test from 'node:test'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const workspaceRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..' +); +const buildWorkflowPath = path.join( + workspaceRoot, + '.github', + 'workflows', + 'build-and-make.yaml' +); +const publishWorkflowPath = path.join( + workspaceRoot, + '.github', + 'workflows', + 'publish-snap.yaml' +); +const releaseAssetHelperPath = path.join( + workspaceRoot, + 'tools', + 'packaging', + 'release-snap-assets.cjs' +); + +async function loadReleaseAssetHelper() { + if (!fs.existsSync(releaseAssetHelperPath)) { + return null; + } + return import(pathToFileURL(releaseAssetHelperPath).href); +} + +test('publishes Snap only after a public v-tag release contains binary and source assets', () => { + assert.equal( + fs.existsSync(publishWorkflowPath), + true, + 'the release-published Snap workflow must exist' + ); + const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); + assert.match( + workflowText, + /^on:\n {4}release:\n {8}types:\n {12}- published$/m + ); + assert.match(workflowText, /^permissions:\n {4}contents: read$/m); + assert.match( + workflowText, + /startsWith\(github\.event\.release\.tag_name,\s*'v'\)/ + ); + assert.match(workflowText, /github\.event\.release\.draft\s*==\s*false/); + + const validateIndex = workflowText.indexOf( + '- name: Select exact public release assets' + ); + const verifyIndex = workflowText.indexOf( + '- name: Verify downloaded public release assets' + ); + const uploadIndex = workflowText.indexOf( + '- name: Publish all public-release snaps to edge' + ); + assert.ok(validateIndex >= 0); + assert.ok(verifyIndex > validateIndex); + assert.ok(uploadIndex > verifyIndex); + + assert.match( + workflowText, + /github\.event\.release\.id[\s\S]*release-snap-assets\.cjs select/ + ); + assert.match(workflowText, /linux-frame-copy-runtime-sources\.tar\.xz/); + assert.match(workflowText, /release-snap-assets\.cjs verify/); + assert.match(workflowText, /snapcraft upload --release=edge/); + + const buildWorkflow = fs.readFileSync(buildWorkflowPath, 'utf8'); + assert.doesNotMatch(buildWorkflow, /^ {4}publish-snap:/m); + assert.doesNotMatch(buildWorkflow, /snapcraft upload/); +}); + +test('selects every exact Snap and exactly one compliance source asset', async () => { + const helper = await loadReleaseAssetHelper(); + assert.ok(helper, 'the release asset selection helper must exist'); + const selected = helper.selectSnapReleaseAssets([ + { id: 9, name: 'IPTVnator-1.0.0-amd64.snap' }, + { id: 3, name: 'linux-frame-copy-runtime-sources.tar.xz' }, + { id: 8, name: 'IPTVnator-1.0.0-armhf.snap' }, + { id: 7, name: 'IPTVnator-1.0.0.AppImage' }, + ]); + + assert.deepEqual(selected, { + snapAssets: [ + { id: 9, name: 'IPTVnator-1.0.0-amd64.snap' }, + { id: 8, name: 'IPTVnator-1.0.0-armhf.snap' }, + ], + sourceAsset: { + id: 3, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }); +}); + +test('rejects a release missing either exact asset class or containing ambiguous source assets', async () => { + const helper = await loadReleaseAssetHelper(); + assert.ok(helper, 'the release asset selection helper must exist'); + assert.throws( + () => + helper.selectSnapReleaseAssets([ + { + id: 1, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + ]), + /at least one \.snap asset/ + ); + assert.throws( + () => + helper.selectSnapReleaseAssets([{ id: 1, name: 'IPTVnator.snap' }]), + /exactly one linux-frame-copy-runtime-sources\.tar\.xz/ + ); + assert.throws( + () => + helper.selectSnapReleaseAssets([ + { id: 1, name: 'IPTVnator.snap' }, + { + id: 2, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + { + id: 3, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + ]), + /exactly one linux-frame-copy-runtime-sources\.tar\.xz/ + ); +}); + +test('verifies the complete selected download set before publication', async (t) => { + const helper = await loadReleaseAssetHelper(); + assert.ok(helper, 'the release asset selection helper must exist'); + const temporaryRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'iptvnator-snap-release-') + ); + t.after(() => fs.rmSync(temporaryRoot, { recursive: true, force: true })); + const manifest = { + snapAssets: [{ id: 1, name: 'IPTVnator.snap' }], + sourceAsset: { + id: 2, + name: 'linux-frame-copy-runtime-sources.tar.xz', + }, + }; + + fs.writeFileSync(path.join(temporaryRoot, 'IPTVnator.snap'), 'snap'); + assert.throws( + () => helper.verifySnapReleaseDownloads(manifest, temporaryRoot), + /missing or empty.*linux-frame-copy-runtime-sources\.tar\.xz/i + ); + fs.writeFileSync( + path.join(temporaryRoot, 'linux-frame-copy-runtime-sources.tar.xz'), + 'sources' + ); + assert.deepEqual( + helper.verifySnapReleaseDownloads(manifest, temporaryRoot), + manifest + ); +}); diff --git a/tools/packaging/release-snap-assets.cjs b/tools/packaging/release-snap-assets.cjs new file mode 100644 index 000000000..51b4d8f68 --- /dev/null +++ b/tools/packaging/release-snap-assets.cjs @@ -0,0 +1,189 @@ +#!/usr/bin/env node + +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); +const { isDeepStrictEqual } = require('node:util'); + +const SOURCE_ARCHIVE_NAME = 'linux-frame-copy-runtime-sources.tar.xz'; + +function flattenAssetPages(value) { + if (!Array.isArray(value)) { + throw new Error('GitHub release assets must be an array.'); + } + return value.flatMap((entry) => + Array.isArray(entry) ? flattenAssetPages(entry) : [entry] + ); +} + +function normalizeReleaseAsset(asset) { + if ( + asset === null || + typeof asset !== 'object' || + !Number.isSafeInteger(asset.id) || + asset.id <= 0 || + typeof asset.name !== 'string' || + asset.name.length === 0 || + asset.name === '.' || + asset.name === '..' || + asset.name.includes('/') || + asset.name.includes('\\') || + [...asset.name].some((character) => { + const codePoint = character.codePointAt(0); + return codePoint <= 0x1f || codePoint === 0x7f; + }) + ) { + throw new Error('GitHub release contains an invalid asset record.'); + } + return { + id: asset.id, + name: asset.name, + }; +} + +function selectSnapReleaseAssets(assets) { + const normalized = flattenAssetPages(assets).map(normalizeReleaseAsset); + const snapAssets = normalized + .filter(({ name }) => name.endsWith('.snap')) + .sort(({ name: left }, { name: right }) => left.localeCompare(right)); + if (snapAssets.length === 0) { + throw new Error( + 'Public release must contain at least one .snap asset.' + ); + } + + const sourceAssets = normalized.filter( + ({ name }) => name === SOURCE_ARCHIVE_NAME + ); + if (sourceAssets.length !== 1) { + throw new Error( + `Public release must contain exactly one ${SOURCE_ARCHIVE_NAME} asset.` + ); + } + const names = normalized.map(({ name }) => name); + if (new Set(names).size !== names.length) { + throw new Error('GitHub release asset names must be unique.'); + } + + return { + snapAssets, + sourceAsset: sourceAssets[0], + }; +} + +function canonicalSelection(selection) { + if ( + selection === null || + typeof selection !== 'object' || + !Array.isArray(selection.snapAssets) + ) { + throw new Error('Invalid selected Snap release asset manifest.'); + } + const canonical = selectSnapReleaseAssets([ + ...selection.snapAssets, + selection.sourceAsset, + ]); + if (!isDeepStrictEqual(selection, canonical)) { + throw new Error( + 'Selected Snap release asset manifest is not canonical.' + ); + } + return canonical; +} + +function verifySnapReleaseDownloads(selection, directoryPath) { + const canonical = canonicalSelection(selection); + const expectedNames = [ + ...canonical.snapAssets.map(({ name }) => name), + canonical.sourceAsset.name, + ].sort(); + try { + fs.accessSync(directoryPath); + } catch { + throw new Error( + `Missing public release asset directory: ${directoryPath}` + ); + } + for (const name of expectedNames) { + const assetPath = path.join(directoryPath, name); + let stat; + try { + stat = fs.lstatSync(assetPath); + } catch { + throw new Error(`Missing or empty public release asset: ${name}`); + } + if (!stat.isFile() || stat.isSymbolicLink() || stat.size === 0) { + throw new Error(`Missing or empty public release asset: ${name}`); + } + } + const actualNames = fs.readdirSync(directoryPath).sort(); + if (!isDeepStrictEqual(actualNames, expectedNames)) { + throw new Error( + 'Downloaded public release assets do not match the exact selected set.' + ); + } + return canonical; +} + +function parseArguments(argv) { + const [command, ...tokens] = argv; + const options = {}; + for (let index = 0; index < tokens.length; index += 2) { + const name = tokens[index]; + const value = tokens[index + 1]; + if (!name?.startsWith('--') || value === undefined) { + throw new Error(`Invalid command-line argument: ${name ?? ''}`); + } + options[name.slice(2)] = value; + } + return { command, options }; +} + +function readJson(filePath) { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); +} + +function writeJson(filePath, value) { + fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`); +} + +function main(argv = process.argv.slice(2)) { + const { command, options } = parseArguments(argv); + if ( + command === 'select' && + options['assets-json'] && + options['output-json'] + ) { + writeJson( + options['output-json'], + selectSnapReleaseAssets(readJson(options['assets-json'])) + ); + return; + } + if (command === 'verify' && options.manifest && options.directory) { + verifySnapReleaseDownloads( + readJson(options.manifest), + options.directory + ); + return; + } + throw new Error( + 'Usage: release-snap-assets.cjs select --assets-json --output-json | verify --manifest --directory ' + ); +} + +if (require.main === module) { + try { + main(); + } catch (error) { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} + +module.exports = { + SOURCE_ARCHIVE_NAME, + selectSnapReleaseAssets, + verifySnapReleaseDownloads, +};