mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
* chore(deps): bump the actions-minor-patch group across 1 directory with 2 updates Bumps the actions-minor-patch group with 2 updates in the / directory: [pnpm/action-setup](https://github.com/pnpm/action-setup) and [github/codeql-action](https://github.com/github/codeql-action). Updates `pnpm/action-setup` from 6.0.10 to 6.1.0 - [Release notes](https://github.com/pnpm/action-setup/releases) - [Commits](https://github.com/pnpm/action-setup/compare/v6.0.10...v6.1.0) Updates `github/codeql-action` from 4.37.7 to 4.38.2 - [Release notes](https://github.com/github/codeql-action/releases) - [Changelog](https://github.com/github/codeql-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/github/codeql-action/compare/v4.37.7...v4.38.2) --- updated-dependencies: - dependency-name: github/codeql-action dependency-version: 4.38.2 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions-minor-patch - dependency-name: pnpm/action-setup dependency-version: 6.1.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions-minor-patch ... Signed-off-by: dependabot[bot] <support@github.com> * test(packaging): allow pnpm/action-setup v6.1.0 in the Snap build workflow policy The bump moves every workflow to pnpm/action-setup@v6.1.0; the build workflow's allowlist pins the exact version and must move with it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Signed-off-by: dependabot[bot] <support@github.com> Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: 4gray <fourgray@proton.me> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
635 lines
24 KiB
JavaScript
635 lines
24 KiB
JavaScript
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 =
|
|
'actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a';
|
|
const PINNED_DOWNLOAD_ARTIFACT_ACTION =
|
|
'actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c';
|
|
const PUBLISH_ACTION_ALLOWLIST = Object.freeze([
|
|
PINNED_CHECKOUT_ACTION,
|
|
PINNED_DOWNLOAD_ARTIFACT_ACTION,
|
|
PINNED_UPLOAD_ARTIFACT_ACTION,
|
|
]);
|
|
const BUILD_ACTION_ALLOWLIST = Object.freeze([
|
|
PINNED_UPLOAD_ARTIFACT_ACTION,
|
|
'actions/cache/restore@v6',
|
|
'actions/cache/save@v6',
|
|
'actions/cache@v6',
|
|
'actions/checkout@v7',
|
|
'actions/download-artifact@v8',
|
|
'actions/setup-node@v7',
|
|
'actions/upload-artifact@v7',
|
|
'pnpm/action-setup@v6.1.0',
|
|
'softprops/action-gh-release@v3',
|
|
]);
|
|
const VERIFY_JOB_ID = 'verify-snap';
|
|
const PUBLISH_JOB_ID = 'publish-snap';
|
|
const VERIFY_JOB_CONDITION =
|
|
"${{ (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';
|
|
const PUBLISH_CHECKOUT_STEP_CONTRACT = Object.freeze({
|
|
name: PUBLISH_CHECKOUT_STEP_NAME,
|
|
uses: PINNED_CHECKOUT_ACTION,
|
|
with: {
|
|
ref: 'refs/tags/${{ steps.resolve-release.outputs.tag }}',
|
|
'persist-credentials': false,
|
|
},
|
|
});
|
|
const PUBLISH_SNAPCRAFT_SETUP_STEP_NAME = 'Install Snapcraft';
|
|
const PUBLISH_SNAPCRAFT_SETUP_STEP_CONTRACT = Object.freeze({
|
|
name: PUBLISH_SNAPCRAFT_SETUP_STEP_NAME,
|
|
shell: 'bash',
|
|
run: [
|
|
'set -euo pipefail',
|
|
'',
|
|
'sudo snap install snapcraft --classic --channel=stable',
|
|
'',
|
|
].join('\n'),
|
|
});
|
|
const VERIFY_ARTIFACT_UPLOAD_STEP_NAME = 'Transfer verified release assets';
|
|
const VERIFY_ARTIFACT_UPLOAD_STEP_CONTRACT = Object.freeze({
|
|
name: VERIFY_ARTIFACT_UPLOAD_STEP_NAME,
|
|
uses: PINNED_UPLOAD_ARTIFACT_ACTION,
|
|
with: {
|
|
name: VERIFIED_RELEASE_ARTIFACT_NAME,
|
|
path: '/var/lib/iptvnator-snap-release/assets',
|
|
'if-no-files-found': 'error',
|
|
'retention-days': 1,
|
|
'compression-level': 0,
|
|
'include-hidden-files': true,
|
|
},
|
|
});
|
|
const PUBLISH_ARTIFACT_DOWNLOAD_STEP_NAME = 'Download verified release assets';
|
|
const PUBLISH_ARTIFACT_DOWNLOAD_STEP_CONTRACT = Object.freeze({
|
|
name: PUBLISH_ARTIFACT_DOWNLOAD_STEP_NAME,
|
|
uses: PINNED_DOWNLOAD_ARTIFACT_ACTION,
|
|
with: {
|
|
name: VERIFIED_RELEASE_ARTIFACT_NAME,
|
|
path: '${{ runner.temp }}/verified-snap-release-assets',
|
|
},
|
|
});
|
|
const PUBLISH_SEALED_VERIFY_STEP_NAME = 'Reverify sealed public release assets';
|
|
const PUBLISH_SEALED_VERIFY_STEP_CONTRACT = Object.freeze({
|
|
name: PUBLISH_SEALED_VERIFY_STEP_NAME,
|
|
shell: 'bash',
|
|
run: [
|
|
'set -euo pipefail',
|
|
'',
|
|
'VERIFIED_ASSET_DIRECTORY="/var/lib/iptvnator-snap-release/assets"',
|
|
'node tools/packaging/release-snap-assets.cjs verify-sealed \\',
|
|
' --manifest "${RUNNER_TEMP}/selected-snap-release-assets.json" \\',
|
|
' --directory "${VERIFIED_ASSET_DIRECTORY}" \\',
|
|
' --receipt "${VERIFIED_ASSET_DIRECTORY}/verified-release-assets.json" \\',
|
|
' --repository-revision "$(git rev-parse HEAD)"',
|
|
'',
|
|
].join('\n'),
|
|
});
|
|
const VERIFY_TRANSFER_BINDING_STEP_NAME = 'Bind verified release transfer';
|
|
const VERIFY_TRANSFER_BINDING_STEP_CONTRACT = Object.freeze({
|
|
name: VERIFY_TRANSFER_BINDING_STEP_NAME,
|
|
id: 'bind-transfer',
|
|
shell: 'bash',
|
|
run: [
|
|
'set -euo pipefail',
|
|
'',
|
|
'RECEIPT_PATH="/var/lib/iptvnator-snap-release/assets/verified-release-assets.json"',
|
|
'RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")"',
|
|
'RECEIPT_SHA256="${RECEIPT_RECORD%% *}"',
|
|
'[[ "${RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]]',
|
|
`printf 'receipt-sha256=%s\\n' "\${RECEIPT_SHA256}" >> "\${GITHUB_OUTPUT}"`,
|
|
'',
|
|
].join('\n'),
|
|
});
|
|
const PUBLISH_TRANSFER_VERIFY_STEP_NAME =
|
|
'Seal transferred public release assets';
|
|
const PUBLISH_TRANSFER_VERIFY_STEP_CONTRACT = Object.freeze({
|
|
name: PUBLISH_TRANSFER_VERIFY_STEP_NAME,
|
|
shell: 'bash',
|
|
env: {
|
|
EXPECTED_RECEIPT_SHA256:
|
|
'${{ needs.verify-snap.outputs.receipt-sha256 }}',
|
|
},
|
|
run: [
|
|
'set -euo pipefail',
|
|
'',
|
|
'TRANSFERRED_ASSET_DIRECTORY="${RUNNER_TEMP}/verified-snap-release-assets"',
|
|
'SEALED_ASSET_PARENT="/var/lib/iptvnator-snap-release"',
|
|
'SEALED_ASSET_DIRECTORY="${SEALED_ASSET_PARENT}/assets"',
|
|
'test -d "${TRANSFERRED_ASSET_DIRECTORY}"',
|
|
'test ! -L "${TRANSFERRED_ASSET_DIRECTORY}"',
|
|
'shopt -s nullglob dotglob',
|
|
'TRANSFERRED_FILES=("${TRANSFERRED_ASSET_DIRECTORY}"/*)',
|
|
'TRANSFERRED_SNAPS=("${TRANSFERRED_ASSET_DIRECTORY}"/*.snap)',
|
|
'test "${#TRANSFERRED_SNAPS[@]}" -gt 0',
|
|
'test "${#TRANSFERRED_FILES[@]}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 2 ))"',
|
|
'test -f "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz"',
|
|
'test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/linux-frame-copy-runtime-sources.tar.xz"',
|
|
'test -f "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"',
|
|
'test ! -L "${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"',
|
|
'for ASSET_FILE in "${TRANSFERRED_FILES[@]}"; do',
|
|
' test -f "${ASSET_FILE}"',
|
|
' test ! -L "${ASSET_FILE}"',
|
|
'done',
|
|
'RECEIPT_PATH="${TRANSFERRED_ASSET_DIRECTORY}/verified-release-assets.json"',
|
|
'RECEIPT_RECORD="$(/usr/bin/sha256sum --binary "${RECEIPT_PATH}")"',
|
|
'ACTUAL_RECEIPT_SHA256="${RECEIPT_RECORD%% *}"',
|
|
'[[ "${EXPECTED_RECEIPT_SHA256}" =~ ^[a-f0-9]{64}$ ]]',
|
|
'test "${ACTUAL_RECEIPT_SHA256}" = "${EXPECTED_RECEIPT_SHA256}"',
|
|
"/usr/bin/jq --exit-status '",
|
|
' type == "object" and',
|
|
' (keys == ["assets", "repositoryRevision", "schemaVersion"]) and',
|
|
' (.schemaVersion == 1) and',
|
|
' (.repositoryRevision |',
|
|
' type == "string" and test("^[a-f0-9]{40,64}$")) and',
|
|
' (.assets | type == "array" and length >= 2) and',
|
|
' (.assets | all(.[];',
|
|
' type == "object" and',
|
|
' (keys == ["id", "name", "sha256", "size"]) and',
|
|
' (.id |',
|
|
' type == "number" and . > 0 and',
|
|
' . <= 9007199254740991 and . == floor) and',
|
|
' (.name |',
|
|
' type == "string" and length > 0 and',
|
|
' . != "." and . != ".." and',
|
|
' (contains("/") | not) and',
|
|
' (contains("\\\\") | not) and',
|
|
' (explode | all(.[]; . > 31 and . != 127))) and',
|
|
' (.sha256 |',
|
|
' type == "string" and test("^[a-f0-9]{64}$")) and',
|
|
' (.size |',
|
|
' type == "number" and . > 0 and',
|
|
' . <= 9007199254740991 and . == floor))) and',
|
|
' ([.assets[].name] | length == (unique | length)) and',
|
|
' ([.assets[] |',
|
|
' select(.name == "linux-frame-copy-runtime-sources.tar.xz")] |',
|
|
' length == 1) and',
|
|
' ([.assets[] | select(.name | endswith(".snap"))] |',
|
|
' length >= 1) and',
|
|
' (.assets | all(.[];',
|
|
' .name == "linux-frame-copy-runtime-sources.tar.xz" or',
|
|
' (.name | endswith(".snap"))))',
|
|
'\' "${RECEIPT_PATH}" > /dev/null',
|
|
`RECEIPT_ASSET_COUNT="$(/usr/bin/jq --raw-output '.assets | length' "\${RECEIPT_PATH}")"`,
|
|
'test "${RECEIPT_ASSET_COUNT}" -eq "$(( ${#TRANSFERRED_SNAPS[@]} + 1 ))"',
|
|
'SIZE_MANIFEST="${RUNNER_TEMP}/verified-release-asset-sizes.tsv"',
|
|
'CHECKSUM_MANIFEST="${RUNNER_TEMP}/verified-release-asset-checksums.txt"',
|
|
'umask 077',
|
|
'/usr/bin/jq --raw-output \\',
|
|
` '.assets[] | [.name, (.size | tostring)] | @tsv' \\`,
|
|
' "${RECEIPT_PATH}" > "${SIZE_MANIFEST}"',
|
|
`while IFS=$'\\t' read -r ASSET_NAME EXPECTED_SIZE; do`,
|
|
' ASSET_PATH="${TRANSFERRED_ASSET_DIRECTORY}/${ASSET_NAME}"',
|
|
' ACTUAL_SIZE="$(/usr/bin/stat --format=%s -- "${ASSET_PATH}")"',
|
|
' test "${ACTUAL_SIZE}" = "${EXPECTED_SIZE}"',
|
|
'done < "${SIZE_MANIFEST}"',
|
|
'/usr/bin/jq --raw-output \\',
|
|
` '.assets[] | "\\(.sha256) \\(.name)"' \\`,
|
|
' "${RECEIPT_PATH}" > "${CHECKSUM_MANIFEST}"',
|
|
'(',
|
|
' cd "${TRANSFERRED_ASSET_DIRECTORY}"',
|
|
' /usr/bin/sha256sum --strict --check "${CHECKSUM_MANIFEST}"',
|
|
')',
|
|
'rm -f "${SIZE_MANIFEST}" "${CHECKSUM_MANIFEST}"',
|
|
'shopt -u nullglob dotglob',
|
|
'sudo test ! -e "${SEALED_ASSET_PARENT}"',
|
|
'sudo install -d -m 0700 -o root -g root "${SEALED_ASSET_PARENT}"',
|
|
'sudo mv "${TRANSFERRED_ASSET_DIRECTORY}" "${SEALED_ASSET_DIRECTORY}"',
|
|
'sudo chown -R root:root "${SEALED_ASSET_DIRECTORY}"',
|
|
'sudo find "${SEALED_ASSET_DIRECTORY}" -type d -exec chmod 0555 {} +',
|
|
'sudo find "${SEALED_ASSET_DIRECTORY}" -type f -exec chmod 0444 {} +',
|
|
'sudo chmod 0555 "${SEALED_ASSET_PARENT}"',
|
|
'',
|
|
].join('\n'),
|
|
});
|
|
const PUBLISH_STEP_CONTRACT = Object.freeze({
|
|
name: PUBLISH_STEP_NAME,
|
|
shell: 'bash',
|
|
env: {
|
|
SNAPCRAFT_STORE_CREDENTIALS: '${{ secrets.snapcraft_token }}',
|
|
},
|
|
run: [
|
|
'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',
|
|
'SNAP_FILES=("${VERIFIED_ASSET_DIRECTORY}"/*.snap)',
|
|
'test "${#SNAP_FILES[@]}" -gt 0',
|
|
'for SNAP_FILE in "${SNAP_FILES[@]}"; do',
|
|
' SNAP_NAME="${SNAP_FILE##*/}"',
|
|
' 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 "${UPLOAD_DIRECTORY}/${SNAP_NAME}"',
|
|
'done',
|
|
'unset STORE_CREDENTIALS',
|
|
'shopt -u nullglob dotglob',
|
|
'',
|
|
].join('\n'),
|
|
});
|
|
|
|
function stripShellComment(line) {
|
|
let quote = null;
|
|
for (let index = 0; index < line.length; index += 1) {
|
|
const character = line[index];
|
|
if (quote === "'") {
|
|
if (character === quote) {
|
|
quote = null;
|
|
}
|
|
continue;
|
|
}
|
|
if (character === '\\') {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (quote === '"') {
|
|
if (character === quote) {
|
|
quote = null;
|
|
}
|
|
continue;
|
|
}
|
|
if (character === "'" || character === '"') {
|
|
quote = character;
|
|
continue;
|
|
}
|
|
if (
|
|
character === '#' &&
|
|
(index === 0 || /[\s;|&()]/.test(line[index - 1]))
|
|
) {
|
|
return line.slice(0, index);
|
|
}
|
|
}
|
|
return line;
|
|
}
|
|
|
|
function isRecord(value) {
|
|
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
}
|
|
|
|
function normalizeRunSource(runSource) {
|
|
return runSource
|
|
.split('\n')
|
|
.map(stripShellComment)
|
|
.join('\n')
|
|
.replace(/\\\r?\n[ \t]*/g, '');
|
|
}
|
|
|
|
function collectDefaultShell(container, containerName, explicitShells) {
|
|
if (!Object.hasOwn(container, 'defaults')) {
|
|
return;
|
|
}
|
|
assert.ok(
|
|
isRecord(container.defaults),
|
|
`${containerName} defaults must be a mapping`
|
|
);
|
|
if (!Object.hasOwn(container.defaults, 'run')) {
|
|
return;
|
|
}
|
|
assert.ok(
|
|
isRecord(container.defaults.run),
|
|
`${containerName} run defaults must be a mapping`
|
|
);
|
|
if (!Object.hasOwn(container.defaults.run, 'shell')) {
|
|
return;
|
|
}
|
|
assert.equal(
|
|
typeof container.defaults.run.shell,
|
|
'string',
|
|
`${containerName} default shell must resolve to a string`
|
|
);
|
|
explicitShells.push(container.defaults.run.shell);
|
|
}
|
|
|
|
function collectWorkflowPolicyInputs(workflowText) {
|
|
const workflow = parse(workflowText);
|
|
assert.ok(isRecord(workflow), 'workflow must be a YAML mapping');
|
|
assert.ok(isRecord(workflow.jobs), 'workflow jobs must be a YAML mapping');
|
|
|
|
const actions = [];
|
|
const explicitShells = [];
|
|
const jobActions = [];
|
|
const jobsWithoutSteps = [];
|
|
const runSources = [];
|
|
collectDefaultShell(workflow, 'workflow', explicitShells);
|
|
for (const [jobName, job] of Object.entries(workflow.jobs)) {
|
|
assert.ok(isRecord(job), `workflow job "${jobName}" must be a mapping`);
|
|
collectDefaultShell(job, `workflow job "${jobName}"`, explicitShells);
|
|
if (Object.hasOwn(job, 'uses')) {
|
|
assert.equal(
|
|
typeof job.uses,
|
|
'string',
|
|
'reusable workflow identifiers must resolve to strings'
|
|
);
|
|
jobActions.push(job.uses);
|
|
}
|
|
if (!Array.isArray(job.steps)) {
|
|
jobsWithoutSteps.push(jobName);
|
|
continue;
|
|
}
|
|
for (const step of job.steps) {
|
|
assert.ok(isRecord(step), 'workflow steps must be mappings');
|
|
if (Object.hasOwn(step, 'uses')) {
|
|
assert.equal(
|
|
typeof step.uses,
|
|
'string',
|
|
'workflow action identifiers must resolve to strings'
|
|
);
|
|
actions.push(step.uses);
|
|
}
|
|
if (Object.hasOwn(step, 'run')) {
|
|
assert.equal(
|
|
typeof step.run,
|
|
'string',
|
|
'workflow run commands must resolve to strings'
|
|
);
|
|
runSources.push(normalizeRunSource(step.run));
|
|
}
|
|
if (Object.hasOwn(step, 'shell')) {
|
|
assert.equal(
|
|
typeof step.shell,
|
|
'string',
|
|
'workflow step shells must resolve to strings'
|
|
);
|
|
explicitShells.push(step.shell);
|
|
}
|
|
}
|
|
}
|
|
|
|
return {
|
|
actions,
|
|
commandSource: runSources.join('\n'),
|
|
explicitShells,
|
|
jobActions,
|
|
jobsWithoutSteps,
|
|
workflow,
|
|
};
|
|
}
|
|
|
|
function assertWorkflowExecutionShape({
|
|
explicitShells,
|
|
jobActions,
|
|
jobsWithoutSteps,
|
|
}) {
|
|
assert.deepEqual(
|
|
jobActions,
|
|
[],
|
|
'job-level reusable workflow delegation is not allowed'
|
|
);
|
|
assert.deepEqual(
|
|
jobsWithoutSteps,
|
|
[],
|
|
'every workflow job must define a concrete steps sequence'
|
|
);
|
|
assert.deepEqual(
|
|
explicitShells.filter((shell) => shell !== 'bash'),
|
|
[],
|
|
'every explicit workflow shell must be exactly "bash"'
|
|
);
|
|
}
|
|
|
|
function literalSnapcraftTokens(commandSource) {
|
|
return commandSource.match(/\bsnapcraft\b/g) ?? [];
|
|
}
|
|
|
|
export function assertPublishSnapWorkflowPolicy(workflowText) {
|
|
const policyInputs = collectWorkflowPolicyInputs(workflowText);
|
|
const { actions, commandSource, workflow } = policyInputs;
|
|
assertWorkflowExecutionShape(policyInputs);
|
|
assert.deepEqual(
|
|
workflow.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'] },
|
|
},
|
|
'the publish workflow must retain its public-release and explicit recovery triggers'
|
|
);
|
|
assert.deepEqual(
|
|
Object.keys(workflow).sort(),
|
|
['jobs', 'name', 'on', 'permissions'],
|
|
'the publish workflow must not add global execution or environment surfaces'
|
|
);
|
|
assert.deepEqual(
|
|
workflow.permissions,
|
|
{ contents: 'read' },
|
|
'the publish workflow must retain read-only repository permissions'
|
|
);
|
|
assert.deepEqual(
|
|
[...actions].sort(),
|
|
[...PUBLISH_ACTION_ALLOWLIST].sort(),
|
|
'the publish workflow must use exactly the allowlisted actions'
|
|
);
|
|
assert.deepEqual(
|
|
Object.keys(workflow.jobs),
|
|
[VERIFY_JOB_ID, PUBLISH_JOB_ID],
|
|
'the publish workflow must isolate verification and credentialed upload on two exact jobs'
|
|
);
|
|
const verifyJob = workflow.jobs[VERIFY_JOB_ID];
|
|
const publishJob = workflow.jobs[PUBLISH_JOB_ID];
|
|
assert.ok(isRecord(verifyJob), 'the canonical verification job must exist');
|
|
assert.ok(isRecord(publishJob), 'the canonical publish job must exist');
|
|
assert.deepEqual(
|
|
Object.keys(verifyJob).sort(),
|
|
['env', 'if', 'name', 'outputs', 'runs-on', 'steps', 'timeout-minutes'],
|
|
'the verification job must retain its exact execution surface'
|
|
);
|
|
assert.deepEqual(
|
|
verifyJob.env,
|
|
{
|
|
SOURCE_ARCHIVE_NAME: 'linux-frame-copy-runtime-sources.tar.xz',
|
|
},
|
|
'the verification job must expose only the fixed source archive name'
|
|
);
|
|
assert.deepEqual(
|
|
verifyJob.outputs,
|
|
{
|
|
'receipt-sha256':
|
|
'${{ steps.bind-transfer.outputs.receipt-sha256 }}',
|
|
},
|
|
'the verification job must expose only the separately bound receipt digest'
|
|
);
|
|
assert.deepEqual(
|
|
Object.keys(publishJob).sort(),
|
|
['if', 'name', 'needs', 'runs-on', 'steps', 'timeout-minutes'],
|
|
'the fresh credentialed job must retain its exact execution surface'
|
|
);
|
|
assert.equal(
|
|
verifyJob['runs-on'],
|
|
'ubuntu-latest',
|
|
'the verification job must use a fresh GitHub-hosted runner'
|
|
);
|
|
assert.equal(
|
|
publishJob['runs-on'],
|
|
'ubuntu-latest',
|
|
'the credentialed job must use a separate fresh GitHub-hosted runner'
|
|
);
|
|
assert.equal(
|
|
verifyJob['timeout-minutes'],
|
|
45,
|
|
'the verification job must retain its bounded timeout'
|
|
);
|
|
assert.equal(
|
|
publishJob['timeout-minutes'],
|
|
20,
|
|
'the credentialed job must retain its bounded timeout'
|
|
);
|
|
assert.equal(
|
|
verifyJob.if,
|
|
VERIFY_JOB_CONDITION,
|
|
'the verification job must retain its exact release condition'
|
|
);
|
|
assert.equal(
|
|
publishJob.needs,
|
|
VERIFY_JOB_ID,
|
|
'the publish job must depend on successful isolated verification'
|
|
);
|
|
assert.equal(
|
|
publishJob.if,
|
|
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
|
|
),
|
|
[PUBLISH_CHECKOUT_STEP_CONTRACT],
|
|
'the publish workflow must checkout released tooling without persisting repository credentials'
|
|
);
|
|
assert.deepEqual(
|
|
verifyJob.steps.filter(
|
|
(step) => step.name === PUBLISH_SEALED_VERIFY_STEP_NAME
|
|
),
|
|
[PUBLISH_SEALED_VERIFY_STEP_CONTRACT],
|
|
'the verification job must fully verify root-sealed assets before transfer'
|
|
);
|
|
assert.deepEqual(
|
|
verifyJob.steps.filter(
|
|
(step) => step.name === VERIFY_ARTIFACT_UPLOAD_STEP_NAME
|
|
),
|
|
[VERIFY_ARTIFACT_UPLOAD_STEP_CONTRACT],
|
|
'the verification job must transfer only the root-sealed verified data artifact'
|
|
);
|
|
assert.deepEqual(
|
|
verifyJob.steps.filter(
|
|
(step) => step.name === VERIFY_TRANSFER_BINDING_STEP_NAME
|
|
),
|
|
[VERIFY_TRANSFER_BINDING_STEP_CONTRACT],
|
|
'the verification job must bind the exact receipt outside artifact transport'
|
|
);
|
|
assert.deepEqual(
|
|
publishJob.steps,
|
|
[
|
|
PUBLISH_ARTIFACT_DOWNLOAD_STEP_CONTRACT,
|
|
PUBLISH_TRANSFER_VERIFY_STEP_CONTRACT,
|
|
PUBLISH_UPLOAD_WORKSPACE_STEP_CONTRACT,
|
|
PUBLISH_SNAPCRAFT_SETUP_STEP_CONTRACT,
|
|
PUBLISH_STEP_CONTRACT,
|
|
],
|
|
'the fresh publish runner must only download, validate, seal, install Snapcraft, and upload'
|
|
);
|
|
assert.equal(
|
|
JSON.stringify(verifyJob).includes('snapcraft_token'),
|
|
false,
|
|
'the release-tag verification job must never receive the Store credential'
|
|
);
|
|
assert.equal(
|
|
publishJob.steps
|
|
.slice(0, -1)
|
|
.some((step) => JSON.stringify(step).includes('snapcraft_token')),
|
|
false,
|
|
'only the final credentialed upload step may receive the Store credential'
|
|
);
|
|
assert.equal(
|
|
literalSnapcraftTokens(commandSource).length,
|
|
2,
|
|
'the publish workflow must contain exactly the reviewed install and upload Snapcraft commands'
|
|
);
|
|
}
|
|
|
|
export function assertBuildSnapWorkflowPolicy(workflowText) {
|
|
const policyInputs = collectWorkflowPolicyInputs(workflowText);
|
|
const { actions, commandSource } = policyInputs;
|
|
assertWorkflowExecutionShape(policyInputs);
|
|
assert.deepEqual(
|
|
actions.filter((action) => !BUILD_ACTION_ALLOWLIST.includes(action)),
|
|
[],
|
|
'the build workflow must use only allowlisted actions'
|
|
);
|
|
assert.equal(
|
|
literalSnapcraftTokens(commandSource).length,
|
|
0,
|
|
'the build workflow must not contain a literal Snapcraft token'
|
|
);
|
|
}
|