From 3ed612ba65bc765ccca11809fd18fec877e4f80a Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 25 Sep 2026 23:34:44 +0200 Subject: [PATCH 1/5] fix(release): repair Snap uploads and retry published releases (#1691) * fix(release): allow Snapcraft scratch extraction and retry public releases * test(release): detect local Snap permission test prerequisites --- .github/workflows/publish-snap.yaml | 62 ++++++- docs/architecture/release-pipeline.md | 15 ++ tools/embedded-mpv/README.md | 14 +- .../packaging/publish-snap-workflow.test.mjs | 153 +++++++++++++++++- tools/packaging/release-snap-assets.test.mjs | 1 + .../snap-workflow-policy.test-helpers.mjs | 83 +++++++++- 6 files changed, 310 insertions(+), 18 deletions(-) diff --git a/.github/workflows/publish-snap.yaml b/.github/workflows/publish-snap.yaml index d74213ec2..57d3da701 100644 --- a/.github/workflows/publish-snap.yaml +++ b/.github/workflows/publish-snap.yaml @@ -1,6 +1,12 @@ name: Publish Snap after public release on: + workflow_dispatch: + inputs: + tag: + description: Existing public stable release tag to retry (for example v0.24.0) + required: true + type: string release: types: - published @@ -11,7 +17,7 @@ permissions: jobs: verify-snap: name: Verify public-release Snap assets - if: ${{ startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }} + if: ${{ (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master') }} runs-on: ubuntu-latest timeout-minutes: 45 env: @@ -20,10 +26,34 @@ jobs: receipt-sha256: ${{ steps.bind-transfer.outputs.receipt-sha256 }} steps: + - name: Resolve public release + id: resolve-release + shell: bash + env: + GH_TOKEN: ${{ github.token }} + REQUESTED_TAG: ${{ inputs.tag || github.event.release.tag_name }} + EVENT_RELEASE_ID: ${{ github.event.release.id }} + run: | + set -euo pipefail + + [[ "${REQUESTED_TAG}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] + RELEASE_JSON="${RUNNER_TEMP}/snap-public-release.json" + gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${REQUESTED_TAG}" > "${RELEASE_JSON}" + /usr/bin/jq --exit-status --arg tag "${REQUESTED_TAG}" ' + .tag_name == $tag and .draft == false and .prerelease == false and + (.published_at | type == "string" and length > 0) and + (.id | type == "number" and . > 0 and . == floor) + ' "${RELEASE_JSON}" > /dev/null + RELEASE_ID="$(/usr/bin/jq --raw-output '.id' "${RELEASE_JSON}")" + if [[ -n "${EVENT_RELEASE_ID}" ]]; then + test "${RELEASE_ID}" = "${EVENT_RELEASE_ID}" + fi + printf 'tag=%s\nrelease-id=%s\n' "${REQUESTED_TAG}" "${RELEASE_ID}" >> "${GITHUB_OUTPUT}" + - name: Checkout released tooling uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 with: - ref: ${{ github.event.release.tag_name }} + ref: refs/tags/${{ steps.resolve-release.outputs.tag }} persist-credentials: false - name: Install release source verifier @@ -41,13 +71,14 @@ jobs: shell: bash env: GH_TOKEN: ${{ github.token }} + RELEASE_ID: ${{ steps.resolve-release.outputs.release-id }} run: | set -euo pipefail gh api \ --paginate \ --slurp \ - "repos/${GITHUB_REPOSITORY}/releases/${{ github.event.release.id }}/assets?per_page=100" \ + "repos/${GITHUB_REPOSITORY}/releases/${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" \ @@ -133,7 +164,7 @@ jobs: publish-snap: name: Publish verified public-release Snap to edge needs: verify-snap - if: ${{ needs.verify-snap.result == 'success' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }} + if: ${{ needs.verify-snap.result == 'success' }} runs-on: ubuntu-latest timeout-minutes: 20 @@ -238,6 +269,26 @@ jobs: sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} + sudo chmod 0555 "${SEALED_ASSET_PARENT}" + - name: Prepare Snapcraft upload workspace + shell: bash + run: | + set -euo pipefail + + VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets" + UPLOAD_DIRECTORY="/var/lib/iptvnator-snap-upload" + sudo test ! -e "${UPLOAD_DIRECTORY}" + sudo install -d -m 0700 -o root -g root "${UPLOAD_DIRECTORY}" + shopt -s nullglob dotglob + SNAP_FILES=("${VERIFIED_ASSET_DIRECTORY}"/*.snap) + test "${#SNAP_FILES[@]}" -gt 0 + for SNAP_FILE in "${SNAP_FILES[@]}"; do + sudo ln -- "${SNAP_FILE}" "${UPLOAD_DIRECTORY}/${SNAP_FILE##*/}" + done + # Snapcraft extracts metadata beside the input file. Root-owned + # hard links remain read-only; the sticky bit prevents replacement. + sudo chmod 1777 "${UPLOAD_DIRECTORY}" + shopt -u nullglob dotglob + - name: Install Snapcraft shell: bash run: | @@ -253,6 +304,7 @@ jobs: set -euo pipefail VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets" + UPLOAD_DIRECTORY="/var/lib/iptvnator-snap-upload" STORE_CREDENTIALS="${SNAPCRAFT_STORE_CREDENTIALS}" unset SNAPCRAFT_STORE_CREDENTIALS shopt -s nullglob dotglob @@ -263,7 +315,7 @@ jobs: echo "Publishing public release asset: ${SNAP_NAME}" # Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke. # GitHub Actions never promotes automatically. - SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}" + SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${UPLOAD_DIRECTORY}/${SNAP_NAME}" done unset STORE_CREDENTIALS shopt -u nullglob dotglob diff --git a/docs/architecture/release-pipeline.md b/docs/architecture/release-pipeline.md index ef36a3dec..6f39c6c8a 100644 --- a/docs/architecture/release-pipeline.md +++ b/docs/architecture/release-pipeline.md @@ -439,6 +439,21 @@ candidate/stable promotion remain manual (see draft during artifact verification, then publish it in a follow-up commit and verify the website deployment. +If a Store upload fails after publication, run `publish-snap.yaml` from +`master` with its `tag` input set to the existing public stable tag, for example +`gh workflow run publish-snap.yaml --ref master -f tag=v0.24.0`. The workflow +resolves the public release through the API, rejects drafts/prereleases and +invalid tags, and repeats the full released-tooling, asset and source-archive +verification before uploading to `edge`. Do not move the release tag, rebuild +its assets or republish the GitHub release to retry a Store upload. + +Snapcraft extracts metadata into a temporary sibling of the input `.snap`. +The publisher therefore gives it root-owned read-only hard links in a separate +root-owned sticky directory. Temporary siblings are writable, while the sticky +bit prevents the unprivileged uploader from replacing the root-owned inputs. +The original verified snapshot stays sealed; upload filenames are enumerated +only from that snapshot, never from the writable scratch directory. + ## Validation ```bash diff --git a/tools/embedded-mpv/README.md b/tools/embedded-mpv/README.md index fca1985c6..ab49e2617 100644 --- a/tools/embedded-mpv/README.md +++ b/tools/embedded-mpv/README.md @@ -397,8 +397,11 @@ CI. This affects only Chromium's software-renderer admission; the manifest, hash, loader, and helper probes still fail closed, and `--no-sandbox` remains root-only. -Snap publication is a separate `release.published` workflow for public `v*` -GitHub releases. It verifies that the public release already contains at least +Snap publication is a separate `release.published` workflow for public stable +GitHub releases, with a `workflow_dispatch` retry from `master` for an existing +public stable tag. Both paths resolve the release through the API before +checking out its tag; draft, prerelease and mismatched event IDs are rejected. +It verifies that the public release already contains at least one Snap and exactly one non-empty `linux-frame-copy-runtime-sources.tar.xz` before uploading anything. The release verifier hashes the downloaded archive, checks its clean released @@ -430,7 +433,12 @@ The dependent publish job runs on a bounded GitHub-hosted `ubuntu-latest` runner with no checkout or release-tag code. It verifies that separate digest, the exact receipt schema, every asset size/hash, and the expected regular-file layout, rejects links and extras, root-seals the transferred data again, and -installs the official stable Snapcraft snap. Only its final fixed shell step +installs the official stable Snapcraft snap. Snapcraft creates temporary +metadata-extraction siblings beside its input, so the publisher creates +root-owned read-only hard links in a separate root-owned sticky directory. +The uploader can create temporary siblings but cannot modify or replace those +inputs; the original sealed snapshot supplies the upload filename list. +Only its final fixed shell step receives the Store credential; it executes no released code, resolves no PATH command, and passes the credential only to each exact `/snap/bin/snapcraft upload --release=edge` process. GitHub credentials remain diff --git a/tools/packaging/publish-snap-workflow.test.mjs b/tools/packaging/publish-snap-workflow.test.mjs index 9bc87f59b..2cac76b6c 100644 --- a/tools/packaging/publish-snap-workflow.test.mjs +++ b/tools/packaging/publish-snap-workflow.test.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; import test from 'node:test'; +import { spawnSync } from 'node:child_process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { parse } from 'yaml'; import { @@ -99,6 +100,152 @@ function assertStepRejectedByBothPolicies(stepSource) { } } +test('recovery resolves only an existing public stable release before checkout', (t) => { + const workflow = parse(fs.readFileSync(publishWorkflowPath, 'utf8')); + const steps = workflow.jobs['verify-snap'].steps; + const resolve = steps.find((step) => step.id === 'resolve-release'); + assert.ok( + resolve, + 'recovery must resolve the public release before checkout' + ); + assert.ok(steps.indexOf(resolve) < steps.findIndex((step) => step.uses)); + assert.equal(workflow.on.workflow_dispatch.inputs.tag.required, true); + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), 'snap-release-resolution-') + ); + t.after(() => fs.rmSync(directory, { recursive: true, force: true })); + fs.writeFileSync( + path.join(directory, 'gh'), + '#!/bin/sh\ncat "$RELEASE_FIXTURE"\n', + { mode: 0o755 } + ); + const output = path.join(directory, 'output'); + const fixture = path.join(directory, 'release.json'); + for (const [patch, tag, eventId, succeeds] of [ + [{}, 'v0.24.0', '', true], + [{}, 'v0.24.0', '123', true], + [{ draft: true }, 'v0.24.0', '', false], + [{ prerelease: true }, 'v0.24.0', '', false], + [{ tag_name: 'v0.25.0' }, 'v0.24.0', '', false], + [{}, 'v0.24.0', '456', false], + [{}, 'v0.24.0; touch injected', '', false], + [{}, '../../master', '', false], + ]) { + fs.writeFileSync( + fixture, + JSON.stringify({ + id: 123, + tag_name: 'v0.24.0', + draft: false, + prerelease: false, + published_at: '2026-09-24T06:58:11Z', + ...patch, + }) + ); + fs.writeFileSync(output, ''); + const result = spawnSync( + 'bash', + ['-e', '-o', 'pipefail', '-c', resolve.run], + { + encoding: 'utf8', + env: { + ...process.env, + PATH: `${directory}:${process.env.PATH}`, + RELEASE_FIXTURE: fixture, + RUNNER_TEMP: directory, + GITHUB_OUTPUT: output, + GITHUB_REPOSITORY: '4gray/iptvnator', + REQUESTED_TAG: tag, + EVENT_RELEASE_ID: eventId, + }, + } + ); + assert.equal(result.status === 0, succeeds, result.stderr); + if (succeeds) { + assert.match( + fs.readFileSync(output, 'utf8'), + /tag=v0\.24\.0\nrelease-id=123\n/ + ); + } else { + assert.equal(fs.readFileSync(output, 'utf8'), ''); + } + } +}); + +test( + 'Snapcraft scratch siblings are writable while upload payloads cannot be replaced', + { skip: process.platform !== 'linux' }, + (t) => { + const workflow = parse(fs.readFileSync(publishWorkflowPath, 'utf8')); + const step = workflow.jobs['publish-snap'].steps.find( + (entry) => entry.name === 'Prepare Snapcraft upload workspace' + ); + assert.ok( + step, + 'Snapcraft needs a writable sibling directory for metadata extraction' + ); + const canElevate = + process.getuid() === 0 || + spawnSync('sudo', ['-n', 'true']).status === 0; + const canDropPrivileges = + spawnSync('/usr/bin/setpriv', ['--version']).status === 0; + if (!canElevate || !canDropPrivileges) { + const reason = + 'Snap upload permission integration requires root or passwordless sudo and /usr/bin/setpriv'; + assert.ok(!process.env.CI, reason); + t.skip(reason); + return; + } + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), 'snap-upload-permissions-') + ); + const asRoot = (command) => + spawnSync( + process.getuid() === 0 ? 'bash' : 'sudo', + process.getuid() === 0 + ? ['-e', '-c', command] + : ['-n', 'bash', '-e', '-c', command], + { encoding: 'utf8' } + ); + t.after(() => asRoot(`rm -rf '${directory}'`)); + const sealed = path.join(directory, 'sealed'); + const upload = path.join(directory, 'upload'); + const setup = asRoot( + `chmod 0755 '${directory}'; mkdir '${sealed}'; printf payload > '${sealed}/package.snap'; chown -R root:root '${sealed}'; chmod 0444 '${sealed}/package.snap'; chmod 0555 '${sealed}'` + ); + assert.equal(setup.status, 0, setup.stderr); + const prepare = asRoot( + step.run + .replaceAll('/var/lib/iptvnator-snap-release/assets', sealed) + .replaceAll('/var/lib/iptvnator-snap-upload', upload) + .replaceAll('sudo ', '') + ); + assert.equal(prepare.status, 0, prepare.stderr); + const checks = ` + const fs = require('node:fs'); + const assert = require('node:assert/strict'); + const sealed = ${JSON.stringify(sealed)}; + const upload = ${JSON.stringify(upload)}; + assert.throws(() => fs.mkdtempSync(sealed + '/tmp-'), { code: 'EACCES' }); + const scratch = fs.mkdtempSync(upload + '/tmp-'); + fs.rmdirSync(scratch); + assert.equal(fs.statSync(upload).uid, 0); + assert.equal(fs.statSync(upload).mode & 0o1777, 0o1777); + assert.equal(fs.statSync(upload + '/package.snap').ino, fs.statSync(sealed + '/package.snap').ino); + assert.throws(() => fs.writeFileSync(upload + '/package.snap', 'changed'), { code: 'EACCES' }); + assert.throws(() => fs.unlinkSync(upload + '/package.snap'), { code: 'EPERM' }); + fs.writeFileSync(upload + '/replacement', 'changed'); + assert.throws(() => fs.renameSync(upload + '/replacement', upload + '/package.snap'), { code: 'EPERM' }); + assert.equal(fs.readFileSync(sealed + '/package.snap', 'utf8'), 'payload'); + `; + // Nobody models an unprivileged uploader even when the test runs in a root container. + const result = asRoot( + `/usr/bin/setpriv --reuid=65534 --regid=65534 --clear-groups '${process.execPath}' -e '${checks.replaceAll("'", "'\\''")}'` + ); + assert.equal(result.status, 0, result.stderr); + } +); + test('publishes Snap only after a public v-tag release contains binary and source assets', () => { assert.equal( fs.existsSync(publishWorkflowPath), @@ -134,8 +281,8 @@ test('publishes Snap only after a public v-tag release contains binary and sourc assert.match(workflowText, /release-snap-assets\.cjs verify/); assertPublishSnapWorkflowPolicy(workflowText); const disabledWorkflow = workflowText.replace( - 'github.event.release.draft == false }}', - 'github.event.release.draft == false && false }}' + 'github.event.release.draft == false)', + 'github.event.release.draft == false && false)' ); assert.notEqual(disabledWorkflow, workflowText); assert.doesNotThrow(() => parse(disabledWorkflow)); @@ -286,7 +433,7 @@ test('rejects Snap uploads that target candidate or stable channels', () => { test('rejects edge upload text in non-executing shell contexts', () => { const workflowText = fs.readFileSync(publishWorkflowPath, 'utf8'); const edgeUpload = - 'SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}"'; + 'SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${UPLOAD_DIRECTORY}/${SNAP_NAME}"'; const blockIndent = ' '.repeat(18); for (const replacement of [ `cat <<123\n${edgeUpload}\n123`, diff --git a/tools/packaging/release-snap-assets.test.mjs b/tools/packaging/release-snap-assets.test.mjs index 2bd76ac2d..8e7eb67bb 100644 --- a/tools/packaging/release-snap-assets.test.mjs +++ b/tools/packaging/release-snap-assets.test.mjs @@ -1731,6 +1731,7 @@ test('publish workflow installs the source verifier and binds the release tag re assert.equal(Object.hasOwn(publishJob.env ?? {}, 'GH_TOKEN'), false); assert.deepEqual(selectStep.env, { GH_TOKEN: '${{ github.token }}', + RELEASE_ID: '${{ steps.resolve-release.outputs.release-id }}', }); assert.deepEqual(downloadStep.env, { GH_TOKEN: '${{ github.token }}', diff --git a/tools/packaging/snap-workflow-policy.test-helpers.mjs b/tools/packaging/snap-workflow-policy.test-helpers.mjs index 1a922300b..28d9f5d84 100644 --- a/tools/packaging/snap-workflow-policy.test-helpers.mjs +++ b/tools/packaging/snap-workflow-policy.test-helpers.mjs @@ -1,6 +1,57 @@ import assert from 'node:assert/strict'; import { parse } from 'yaml'; +const PUBLISH_UPLOAD_WORKSPACE_STEP_CONTRACT = Object.freeze({ + name: 'Prepare Snapcraft upload workspace', + shell: 'bash', + run: [ + 'set -euo pipefail', + '', + 'VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"', + 'UPLOAD_DIRECTORY="/var/lib/iptvnator-snap-upload"', + 'sudo test ! -e "${UPLOAD_DIRECTORY}"', + 'sudo install -d -m 0700 -o root -g root "${UPLOAD_DIRECTORY}"', + 'shopt -s nullglob dotglob', + 'SNAP_FILES=("${VERIFIED_ASSET_DIRECTORY}"/*.snap)', + 'test "${#SNAP_FILES[@]}" -gt 0', + 'for SNAP_FILE in "${SNAP_FILES[@]}"; do', + ' sudo ln -- "${SNAP_FILE}" "${UPLOAD_DIRECTORY}/${SNAP_FILE##*/}"', + 'done', + '# Snapcraft extracts metadata beside the input file. Root-owned', + '# hard links remain read-only; the sticky bit prevents replacement.', + 'sudo chmod 1777 "${UPLOAD_DIRECTORY}"', + 'shopt -u nullglob dotglob', + '', + ].join('\n'), +}); +const PUBLISH_RESOLVE_STEP_CONTRACT = Object.freeze({ + name: 'Resolve public release', + id: 'resolve-release', + shell: 'bash', + env: { + GH_TOKEN: '${{ github.token }}', + REQUESTED_TAG: '${{ inputs.tag || github.event.release.tag_name }}', + EVENT_RELEASE_ID: '${{ github.event.release.id }}', + }, + run: [ + 'set -euo pipefail', + '', + '[[ "${REQUESTED_TAG}" =~ ^v[0-9]+\\.[0-9]+\\.[0-9]+$ ]]', + 'RELEASE_JSON="${RUNNER_TEMP}/snap-public-release.json"', + 'gh api "repos/${GITHUB_REPOSITORY}/releases/tags/${REQUESTED_TAG}" > "${RELEASE_JSON}"', + '/usr/bin/jq --exit-status --arg tag "${REQUESTED_TAG}" \'', + ' .tag_name == $tag and .draft == false and .prerelease == false and', + ' (.published_at | type == "string" and length > 0) and', + ' (.id | type == "number" and . > 0 and . == floor)', + '\' "${RELEASE_JSON}" > /dev/null', + 'RELEASE_ID="$(/usr/bin/jq --raw-output \'.id\' "${RELEASE_JSON}")"', + 'if [[ -n "${EVENT_RELEASE_ID}" ]]; then', + ' test "${RELEASE_ID}" = "${EVENT_RELEASE_ID}"', + 'fi', + 'printf \'tag=%s\\nrelease-id=%s\\n\' "${REQUESTED_TAG}" "${RELEASE_ID}" >> "${GITHUB_OUTPUT}"', + '', + ].join('\n'), +}); const PINNED_CHECKOUT_ACTION = 'actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1'; const PINNED_UPLOAD_ARTIFACT_ACTION = @@ -27,9 +78,8 @@ const BUILD_ACTION_ALLOWLIST = Object.freeze([ const VERIFY_JOB_ID = 'verify-snap'; const PUBLISH_JOB_ID = 'publish-snap'; const VERIFY_JOB_CONDITION = - "${{ startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}"; -const PUBLISH_JOB_CONDITION = - "${{ needs.verify-snap.result == 'success' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false }}"; + "${{ (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'v') && github.event.release.draft == false) || (github.event_name == 'workflow_dispatch' && github.ref == 'refs/heads/master') }}"; +const PUBLISH_JOB_CONDITION = "${{ needs.verify-snap.result == 'success' }}"; const VERIFIED_RELEASE_ARTIFACT_NAME = 'verified-snap-release-assets'; const PUBLISH_STEP_NAME = 'Publish all public-release snaps to edge'; const PUBLISH_CHECKOUT_STEP_NAME = 'Checkout released tooling'; @@ -37,7 +87,7 @@ const PUBLISH_CHECKOUT_STEP_CONTRACT = Object.freeze({ name: PUBLISH_CHECKOUT_STEP_NAME, uses: PINNED_CHECKOUT_ACTION, with: { - ref: '${{ github.event.release.tag_name }}', + ref: 'refs/tags/${{ steps.resolve-release.outputs.tag }}', 'persist-credentials': false, }, }); @@ -217,6 +267,7 @@ const PUBLISH_STEP_CONTRACT = Object.freeze({ 'set -euo pipefail', '', 'VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"', + 'UPLOAD_DIRECTORY="/var/lib/iptvnator-snap-upload"', 'STORE_CREDENTIALS="${SNAPCRAFT_STORE_CREDENTIALS}"', 'unset SNAPCRAFT_STORE_CREDENTIALS', 'shopt -s nullglob dotglob', @@ -227,7 +278,7 @@ const PUBLISH_STEP_CONTRACT = Object.freeze({ ' echo "Publishing public release asset: ${SNAP_NAME}"', ' # Candidate/stable promotion is manual after installed-Snap frame-copy and missing-runtime fallback smoke.', ' # GitHub Actions never promotes automatically.', - ' SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${SNAP_FILE}"', + ' SNAPCRAFT_STORE_CREDENTIALS="${STORE_CREDENTIALS}" /snap/bin/snapcraft upload --release=edge "${UPLOAD_DIRECTORY}/${SNAP_NAME}"', 'done', 'unset STORE_CREDENTIALS', 'shopt -u nullglob dotglob', @@ -404,8 +455,20 @@ export function assertPublishSnapWorkflowPolicy(workflowText) { assertWorkflowExecutionShape(policyInputs); assert.deepEqual( workflow.on, - { release: { types: ['published'] } }, - 'the publish workflow must retain its exact release trigger' + { + workflow_dispatch: { + inputs: { + tag: { + description: + 'Existing public stable release tag to retry (for example v0.24.0)', + required: true, + type: 'string', + }, + }, + }, + release: { types: ['published'] }, + }, + 'the publish workflow must retain its public-release and explicit recovery triggers' ); assert.deepEqual( Object.keys(workflow).sort(), @@ -491,6 +554,11 @@ export function assertPublishSnapWorkflowPolicy(workflowText) { PUBLISH_JOB_CONDITION, 'the publish job must retain its exact verified-release condition' ); + assert.deepEqual( + verifyJob.steps.filter((step) => step.id === 'resolve-release'), + [PUBLISH_RESOLVE_STEP_CONTRACT], + 'resolve and validate the public release before executing released tooling' + ); assert.deepEqual( verifyJob.steps.filter( (step) => step.name === PUBLISH_CHECKOUT_STEP_NAME @@ -524,6 +592,7 @@ export function assertPublishSnapWorkflowPolicy(workflowText) { [ PUBLISH_ARTIFACT_DOWNLOAD_STEP_CONTRACT, PUBLISH_TRANSFER_VERIFY_STEP_CONTRACT, + PUBLISH_UPLOAD_WORKSPACE_STEP_CONTRACT, PUBLISH_SNAPCRAFT_SETUP_STEP_CONTRACT, PUBLISH_STEP_CONTRACT, ], From 8bc877b6254f69f616fc1b7fd94e5feaddd3f361 Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:31:27 +0200 Subject: [PATCH 2/5] chore(performance): measure initial bytes of the built web app (#1692) First step of the performance-journeys ratchet (plan thread: J1 `launch`, counter `renderer.initialBytes`). - `tools/performance/measure-initial-bytes.mjs` reads the built `dist/apps/web/index.html` and sums `index.html` plus every same-origin ` + +