fix(release): gate Snap publish on public source release

This commit is contained in:
4gray committed 2026-07-17 22:42:37 +02:00
1 parent 6c74bf2743
commit 0865fdf464
6 files changed
+453 -34

No files matched your search

-28
View File
@@ -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
+84
View File
@@ -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
@@ -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);
});
+10 -1
View File
@@ -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}\""
}
},
@@ -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
);
});
+189
View File
@@ -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 <path> --output-json <path> | verify --manifest <path> --directory <path>'
);
}
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,
};