mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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
This commit is contained in:
1 parent
0ecfc06494
commit
3ed612ba65
6 files changed
+310
-18
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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 }}',
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
Reference in new issue
Block a user