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:
4gray authored and GitHub committed 2026-09-25 23:34:44 +02:00
1 parent 0ecfc06494
commit 3ed612ba65
6 files changed
+310 -18

No files matched your search

+57 -5
View File
@@ -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
+15
View File
@@ -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
+11 -3
View File
@@ -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
+150 -3
View File
@@ -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,
],