From d955870c74ec544336f9eaff530eee8f3f36ab35 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Fri, 4 Sep 2026 16:57:04 -0400 Subject: [PATCH 1/4] Harden release image provenance --- .github/scripts/release-provenance.mjs | 116 +++++++++++++ .github/scripts/release-registry-guard.mjs | 71 ++++++++ .github/workflows/release.yml | 179 ++++++++++++++++----- ui/release_provenance_test.js | 149 +++++++++++++++++ 4 files changed, 476 insertions(+), 39 deletions(-) create mode 100644 .github/scripts/release-provenance.mjs create mode 100644 .github/scripts/release-registry-guard.mjs create mode 100644 ui/release_provenance_test.js diff --git a/.github/scripts/release-provenance.mjs b/.github/scripts/release-provenance.mjs new file mode 100644 index 0000000..c2aef6c --- /dev/null +++ b/.github/scripts/release-provenance.mjs @@ -0,0 +1,116 @@ +// Copyright (C) 2026 Snuffy2 +// SPDX-License-Identifier: AGPL-3.0-only + +import { execFileSync } from "node:child_process"; + +const semverTagPattern = + /^v?(?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)$/u; +const commitPattern = /^[0-9a-f]{40}$/u; + +function fail(message) { + throw new Error(`Refusing Docker publication: ${message}`); +} + +export function versionFromTag(tag) { + const match = semverTagPattern.exec(tag); + return match?.groups?.version ?? null; +} + +export function resolveReleaseSource(event, git) { + const eventCommit = git.commit(event.eventSHA); + if (event.releaseTarget !== event.defaultBranch) { + fail(`release target ${event.releaseTarget} is not ${event.defaultBranch}`); + } + const version = versionFromTag(event.releaseTag); + if (version === null) { + fail(`release tag ${event.releaseTag} is not a supported semantic version`); + } + const tagCommit = git.tagCommit(event.releaseTag); + if (tagCommit !== eventCommit) { + fail( + `release tag ${event.releaseTag} resolves to ${tagCommit}, not event commit ${eventCommit}`, + ); + } + if (!git.isAncestor(eventCommit, event.defaultBranch)) { + fail( + `event commit ${eventCommit} is not an ancestor of ${event.defaultBranch}`, + ); + } + return { imageTag: version, sourceSHA: eventCommit, immutableVersion: true }; +} + +export function resolveWorkflowSource(event) { + if (!commitPattern.test(event.eventSHA)) { + fail(`event SHA ${event.eventSHA} is not a full lowercase commit SHA`); + } + const imageTag = event.inputTag || "edge"; + return { + imageTag, + sourceSHA: event.eventSHA, + immutableVersion: versionFromTag(imageTag) !== null, + }; +} + +function runGit(args) { + return execFileSync("git", args, { encoding: "utf8" }).trim(); +} + +function gitForRelease(defaultBranch, releaseTag) { + runGit([ + "fetch", + "--force", + "--no-tags", + "origin", + `refs/heads/${defaultBranch}:refs/remotes/origin/${defaultBranch}`, + `refs/tags/${releaseTag}:refs/tags/${releaseTag}`, + ]); + return { + commit(value) { + return runGit(["rev-parse", "--verify", `${value}^{commit}`]); + }, + tagCommit(tag) { + return runGit(["rev-parse", "--verify", `${tag}^{commit}`]); + }, + isAncestor(commit, branch) { + try { + runGit(["merge-base", "--is-ancestor", commit, `origin/${branch}`]); + return true; + } catch { + return false; + } + }, + }; +} + +function writeOutput(result) { + process.stdout.write( + [ + `source_sha=${result.sourceSHA}`, + `image_tag=${result.imageTag}`, + `immutable_version=${result.immutableVersion}`, + ].join("\n") + "\n", + ); +} + +if (process.argv[1] === new URL(import.meta.url).pathname) { + const event = { + defaultBranch: process.env.DEFAULT_BRANCH, + eventSHA: process.env.EVENT_SHA, + inputTag: process.env.INPUT_TAG, + releaseTag: process.env.RELEASE_TAG, + releaseTarget: process.env.RELEASE_TARGET, + }; + if (process.env.EVENT_NAME === "release") { + if (!event.defaultBranch || !event.releaseTag || !event.releaseTarget) { + fail("release event is missing immutable provenance fields"); + } + writeOutput( + resolveReleaseSource( + event, + gitForRelease(event.defaultBranch, event.releaseTag), + ), + ); + } else { + writeOutput(resolveWorkflowSource(event)); + } +} diff --git a/.github/scripts/release-registry-guard.mjs b/.github/scripts/release-registry-guard.mjs new file mode 100644 index 0000000..8813381 --- /dev/null +++ b/.github/scripts/release-registry-guard.mjs @@ -0,0 +1,71 @@ +// Copyright (C) 2026 Snuffy2 +// SPDX-License-Identifier: AGPL-3.0-only + +import { execFileSync } from "node:child_process"; + +function fail(message) { + throw new Error(`Refusing Docker publication: ${message}`); +} + +function flattenPages(value) { + if (!Array.isArray(value)) { + fail("GitHub Packages API returned a non-array response"); + } + return value.flat(Infinity); +} + +export function assertImageTagAvailable({ owner, packageName, imageTag, api }) { + const viewer = api("user"); + if (typeof viewer?.login !== "string" || viewer.login.length === 0) { + fail("could not verify the authenticated GitHub identity"); + } + const packages = flattenPages( + api(`users/${owner}/packages?package_type=container&per_page=100`), + ); + const target = packages.find((candidate) => candidate?.name === packageName); + if (target === undefined) return; + if ( + target.package_type !== "container" || + target.owner?.login?.toLowerCase() !== owner.toLowerCase() + ) { + fail("the matching package has an unexpected namespace or type"); + } + const versions = flattenPages( + api( + `users/${owner}/packages/container/${packageName}/versions?per_page=100`, + ), + ); + if ( + versions.some((version) => + version?.metadata?.container?.tags?.includes(imageTag), + ) + ) { + fail(`immutable image tag ${imageTag} already exists`); + } +} + +function githubAPI(endpoint) { + const output = execFileSync( + "gh", + ["api", "--paginate", "--slurp", endpoint], + { + encoding: "utf8", + env: process.env, + stdio: ["ignore", "pipe", "inherit"], + }, + ); + try { + return JSON.parse(output); + } catch (error) { + fail(`GitHub Packages API returned invalid JSON: ${error.message}`); + } +} + +if (process.argv[1] === new URL(import.meta.url).pathname) { + const owner = process.env.PACKAGE_OWNER; + const packageName = process.env.PACKAGE_NAME; + const imageTag = process.env.IMAGE_TAG; + if (!owner || !packageName || !imageTag) + fail("registry guard is missing package owner, name, or image tag"); + assertImageTagAvailable({ owner, packageName, imageTag, api: githubAPI }); +} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e9f7be0..b0a7995 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -2,11 +2,9 @@ name: Publish Docker Image on: push: - branches: - - main + branches: [main] release: - types: - - published + types: [published] workflow_dispatch: inputs: tag_name: @@ -16,7 +14,6 @@ on: permissions: contents: read - packages: write concurrency: group: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && format('{0}-main-edge', github.workflow) || github.run_id }} @@ -25,32 +22,133 @@ concurrency: env: REGISTRY: ghcr.io IMAGE_NAME: snuffy2/shellport + PACKAGE_OWNER: Snuffy2 + PACKAGE_NAME: shellport + OCI_ARTIFACT_NAME: shellport-oci-${{ github.run_id }} + OCI_ARTIFACT_PATH: shellport.oci.tar + OCI_ARTIFACT_MAX_BYTES: "2147483648" jobs: - publish: + provenance: if: github.event.repository.fork == false - name: Build and publish Docker image runs-on: ubuntu-latest - + permissions: + contents: read + packages: read + outputs: + source_sha: ${{ steps.source.outputs.source_sha }} + image_tag: ${{ steps.source.outputs.image_tag }} + immutable_version: ${{ steps.source.outputs.immutable_version }} steps: - - name: Checkout source - uses: actions/checkout@v7 - - - name: Set up QEMU - uses: docker/setup-qemu-action@v4 - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + - uses: actions/checkout@v7 + with: + ref: ${{ github.sha }} + fetch-depth: 1 + persist-credentials: false + - id: source + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EVENT_NAME: ${{ github.event_name }} + EVENT_SHA: ${{ github.sha }} + INPUT_TAG: ${{ inputs.tag_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TARGET: ${{ github.event.release.target_commitish }} + run: node .github/scripts/release-provenance.mjs >> "$GITHUB_OUTPUT" + - name: Refuse existing immutable image version + if: steps.source.outputs.immutable_version == 'true' + env: + GH_TOKEN: ${{ github.token }} + IMAGE_TAG: ${{ steps.source.outputs.image_tag }} + run: node .github/scripts/release-registry-guard.mjs + - uses: actions/upload-artifact@v7 + with: + name: release-registry-control-${{ github.run_id }} + path: .github/scripts/release-registry-guard.mjs + if-no-files-found: error + compression-level: 0 + retention-days: 1 - - name: Log in to GitHub Container Registry - uses: docker/login-action@v4 + build: + needs: provenance + runs-on: ubuntu-latest + outputs: + artifact_sha256: ${{ steps.artifact.outputs.sha256 }} + artifact_bytes: ${{ steps.artifact.outputs.bytes }} + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ needs.provenance.outputs.source_sha }} + persist-credentials: false + - uses: docker/setup-qemu-action@v4 + - uses: docker/setup-buildx-action@v4 + - uses: docker/build-push-action@v7 + with: + context: . + platforms: linux/amd64,linux/arm64 + outputs: type=oci,dest=${{ runner.temp }}/${{ env.OCI_ARTIFACT_PATH }} + provenance: mode=max + sbom: true + build-args: | + SHELLPORT_VERSION=${{ needs.provenance.outputs.image_tag }} + SHELLPORT_SOURCE_URL=https://github.com/Snuffy2/shellport/archive/${{ needs.provenance.outputs.source_sha }}.tar.gz + - id: artifact + env: + OCI_ARCHIVE: ${{ runner.temp }}/${{ env.OCI_ARTIFACT_PATH }} + run: | + set -euo pipefail + test -s "$OCI_ARCHIVE" + bytes="$(wc -c < "$OCI_ARCHIVE" | tr -d '[:space:]')" + [[ "$bytes" =~ ^[0-9]+$ ]] && (( bytes > 0 && bytes <= OCI_ARTIFACT_MAX_BYTES )) + printf 'bytes=%s\nsha256=%s\n' "$bytes" "$(sha256sum "$OCI_ARCHIVE" | cut -d ' ' -f 1)" >> "$GITHUB_OUTPUT" + printf 'OCI artifact: %s (%s bytes)\n' "$OCI_ARTIFACT_PATH" "$bytes" >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@v7 with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} + name: ${{ env.OCI_ARTIFACT_NAME }} + path: ${{ runner.temp }}/${{ env.OCI_ARTIFACT_PATH }} + if-no-files-found: error + compression-level: 0 + retention-days: 1 - - name: Generate Docker metadata - id: meta + publish: + if: github.event.repository.fork == false + needs: [provenance, build] + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + steps: + - uses: actions/download-artifact@v8 + with: + name: ${{ env.OCI_ARTIFACT_NAME }} + path: ${{ runner.temp }}/release-image + - uses: actions/download-artifact@v8 + with: + name: release-registry-control-${{ github.run_id }} + path: ${{ runner.temp }}/release-control + - name: Verify OCI artifact continuity + id: artifact + env: + OCI_ARCHIVE: ${{ runner.temp }}/release-image/${{ env.OCI_ARTIFACT_PATH }} + EXPECTED_BYTES: ${{ needs.build.outputs.artifact_bytes }} + EXPECTED_SHA256: ${{ needs.build.outputs.artifact_sha256 }} + OCI_LAYOUT: ${{ runner.temp }}/release-image/oci-layout + run: | + set -euo pipefail + actual_bytes="$(wc -c < "$OCI_ARCHIVE" | tr -d '[:space:]')" + actual_sha256="$(sha256sum "$OCI_ARCHIVE" | cut -d ' ' -f 1)" + [[ "$actual_bytes" == "$EXPECTED_BYTES" && "$actual_bytes" =~ ^[0-9]+$ ]] && (( actual_bytes > 0 && actual_bytes <= OCI_ARTIFACT_MAX_BYTES )) + [[ "$actual_sha256" == "$EXPECTED_SHA256" && "$actual_sha256" =~ ^[0-9a-f]{64}$ ]] + mkdir "$OCI_LAYOUT" + tar --extract --file "$OCI_ARCHIVE" --directory "$OCI_LAYOUT" + test -f "$OCI_LAYOUT/index.json" && test -f "$OCI_LAYOUT/oci-layout" + printf 'oci_layout=%s\n' "$OCI_LAYOUT" >> "$GITHUB_OUTPUT" + - name: Recheck immutable image version immediately before upload + if: needs.provenance.outputs.immutable_version == 'true' + env: + GH_TOKEN: ${{ github.token }} + IMAGE_TAG: ${{ needs.provenance.outputs.image_tag }} + run: node "$RUNNER_TEMP/release-control/release-registry-guard.mjs" + - id: meta uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} @@ -59,19 +157,22 @@ jobs: type=semver,pattern={{version}},value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' }} type=raw,value=latest,enable=${{ github.event_name == 'release' && !github.event.release.prerelease }} type=raw,value=${{ inputs.tag_name }},enable=${{ github.event_name == 'workflow_dispatch' }} - labels: | - org.opencontainers.image.source=https://github.com/Snuffy2/shellport/archive/${{ github.sha }}.tar.gz - - - name: Build and publish Docker image - uses: docker/build-push-action@v7 - with: - context: . - platforms: linux/amd64,linux/arm64 - push: true - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - build-args: | - SHELLPORT_VERSION=${{ github.event_name == 'release' && github.event.release.tag_name || github.event_name == 'workflow_dispatch' && inputs.tag_name || 'edge' }} - SHELLPORT_SOURCE_URL=https://github.com/Snuffy2/shellport/archive/${{ github.sha }}.tar.gz - cache-from: type=gha - cache-to: type=gha,mode=max + - name: Upload verified OCI archive without rebuilding + env: + OCI_ARCHIVE: ${{ runner.temp }}/release-image/${{ env.OCI_ARTIFACT_PATH }} + TAGS: ${{ steps.meta.outputs.tags }} + REGISTRY_USERNAME: ${{ github.actor }} + REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + while IFS= read -r tag; do + test -n "$tag" + docker run --rm -v "$OCI_ARCHIVE:/work/image.oci:ro" quay.io/skopeo/stable@sha256:8d25aabcf965e267b6a6ad02ff8da5512f77de1490063625093ff564797e88bc copy --all --dest-creds "$REGISTRY_USERNAME:$REGISTRY_TOKEN" oci-archive:/work/image.oci docker://"$tag" + done <<< "$TAGS" + - name: Verify published platforms + env: + IMAGE_TAG: ${{ needs.provenance.outputs.image_tag }} + run: | + set -euo pipefail + manifest="$(docker buildx imagetools inspect --raw "$REGISTRY/$IMAGE_NAME:$IMAGE_TAG")" + jq -e '[.manifests[]?.platform | select(.os == "linux" and (.architecture == "amd64" or .architecture == "arm64"))] | length == 2' <<< "$manifest" diff --git a/ui/release_provenance_test.js b/ui/release_provenance_test.js new file mode 100644 index 0000000..005099e --- /dev/null +++ b/ui/release_provenance_test.js @@ -0,0 +1,149 @@ +// Copyright (C) 2026 Snuffy2 +// SPDX-License-Identifier: AGPL-3.0-only + +import { describe, expect, test } from "vitest"; + +import { + resolveReleaseSource, + resolveWorkflowSource, +} from "../.github/scripts/release-provenance.mjs"; +import { assertImageTagAvailable } from "../.github/scripts/release-registry-guard.mjs"; + +const eventSHA = "a".repeat(40); +const packageEndpoint = + "users/Snuffy2/packages?package_type=container&per_page=100"; +const versionsEndpoint = + "users/Snuffy2/packages/container/shellport/versions?per_page=100"; + +function releaseEvent(overrides = {}) { + return { + defaultBranch: "main", + eventSHA, + releaseTag: "v1.2.3", + releaseTarget: "main", + ...overrides, + }; +} + +function repository(overrides = {}) { + return { + commit: (value) => value, + tagCommit: () => eventSHA, + isAncestor: () => true, + ...overrides, + }; +} + +function apiWith(responses) { + return (endpoint) => { + if (!(endpoint in responses)) + throw new Error(`unexpected API call: ${endpoint}`); + return responses[endpoint]; + }; +} + +describe("release provenance", function () { + test("uses the event commit only when the published tag still names it", function () { + expect(resolveReleaseSource(releaseEvent(), repository())).toEqual({ + sourceSHA: eventSHA, + imageTag: "1.2.3", + immutableVersion: true, + }); + }); + test.each([ + [ + "wrong target", + releaseEvent({ releaseTarget: "release" }), + repository(), + "not main", + ], + [ + "missing tag", + releaseEvent({ releaseTag: "candidate" }), + repository(), + "not a supported", + ], + [ + "non-ancestor", + releaseEvent(), + repository({ isAncestor: () => false }), + "not an ancestor", + ], + [ + "moved tag", + releaseEvent(), + repository({ tagCommit: () => "b".repeat(40) }), + "not event commit", + ], + ])("rejects %s", (_name, event, git, message) => { + expect(() => resolveReleaseSource(event, git)).toThrow(message); + }); + test("guards manual semantic versions but preserves the edge path", function () { + expect( + resolveWorkflowSource({ eventSHA, inputTag: "1.2.3" }), + ).toMatchObject({ immutableVersion: true, imageTag: "1.2.3" }); + expect( + resolveWorkflowSource({ eventSHA, inputTag: "nightly" }), + ).toMatchObject({ immutableVersion: false, imageTag: "nightly" }); + }); +}); + +describe("registry immutability guard", function () { + test("permits a verified namespace with no package", function () { + assertImageTagAvailable({ + owner: "Snuffy2", + packageName: "shellport", + imageTag: "1.2.3", + api: apiWith({ user: { login: "Snuffy2" }, [packageEndpoint]: [[]] }), + }); + }); + test.each([ + [ + "permission failure", + () => { + throw new Error("HTTP 403"); + }, + "HTTP 403", + ], + [ + "network failure", + () => { + throw new Error("network unavailable"); + }, + "network unavailable", + ], + ])("does not convert %s into a missing package", (_name, api, message) => { + expect(() => + assertImageTagAvailable({ + owner: "Snuffy2", + packageName: "shellport", + imageTag: "1.2.3", + api, + }), + ).toThrow(message); + }); + test("rejects an existing immutable tag", function () { + expect(() => + assertImageTagAvailable({ + owner: "Snuffy2", + packageName: "shellport", + imageTag: "1.2.3", + api: apiWith({ + user: { login: "Snuffy2" }, + [packageEndpoint]: [ + [ + { + name: "shellport", + package_type: "container", + owner: { login: "Snuffy2" }, + }, + ], + ], + [versionsEndpoint]: [ + [{ metadata: { container: { tags: ["1.2.3"] } } }], + ], + }), + }), + ).toThrow("already exists"); + }); +}); From abbe680f0375da6fa338f1a51f8a1503296e0b9c Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Fri, 4 Sep 2026 19:46:23 -0400 Subject: [PATCH 2/4] Fix release image retry safety --- .github/scripts/release-registry-guard.mjs | 81 +++++--------- .github/workflows/release.yml | 56 +++++++--- ui/release_provenance_test.js | 118 +++++++++------------ 3 files changed, 125 insertions(+), 130 deletions(-) diff --git a/.github/scripts/release-registry-guard.mjs b/.github/scripts/release-registry-guard.mjs index 8813381..a5a2533 100644 --- a/.github/scripts/release-registry-guard.mjs +++ b/.github/scripts/release-registry-guard.mjs @@ -1,71 +1,48 @@ // Copyright (C) 2026 Snuffy2 // SPDX-License-Identifier: AGPL-3.0-only -import { execFileSync } from "node:child_process"; +const digestPattern = /^sha256:[0-9a-f]{64}$/u; function fail(message) { throw new Error(`Refusing Docker publication: ${message}`); } -function flattenPages(value) { - if (!Array.isArray(value)) { - fail("GitHub Packages API returned a non-array response"); +function assertDigest(digest, name) { + if (!digestPattern.test(digest)) { + fail(`${name} is not a sha256 OCI manifest digest`); } - return value.flat(Infinity); } -export function assertImageTagAvailable({ owner, packageName, imageTag, api }) { - const viewer = api("user"); - if (typeof viewer?.login !== "string" || viewer.login.length === 0) { - fail("could not verify the authenticated GitHub identity"); - } - const packages = flattenPages( - api(`users/${owner}/packages?package_type=container&per_page=100`), - ); - const target = packages.find((candidate) => candidate?.name === packageName); - if (target === undefined) return; - if ( - target.package_type !== "container" || - target.owner?.login?.toLowerCase() !== owner.toLowerCase() - ) { - fail("the matching package has an unexpected namespace or type"); - } - const versions = flattenPages( - api( - `users/${owner}/packages/container/${packageName}/versions?per_page=100`, - ), - ); - if ( - versions.some((version) => - version?.metadata?.container?.tags?.includes(imageTag), - ) - ) { - fail(`immutable image tag ${imageTag} already exists`); +export function resolveImmutableTag({ expectedDigest, publishedDigest }) { + assertDigest(expectedDigest, "verified OCI archive index digest"); + if (publishedDigest === undefined || publishedDigest === "") return "absent"; + assertDigest(publishedDigest, "published registry manifest digest"); + if (publishedDigest !== expectedDigest) { + fail( + `immutable image version names ${publishedDigest}, not verified archive ${expectedDigest}`, + ); } + return "matching"; } -function githubAPI(endpoint) { - const output = execFileSync( - "gh", - ["api", "--paginate", "--slurp", endpoint], - { - encoding: "utf8", - env: process.env, - stdio: ["ignore", "pipe", "inherit"], - }, - ); - try { - return JSON.parse(output); - } catch (error) { - fail(`GitHub Packages API returned invalid JSON: ${error.message}`); +export function tagsToCopy({ tags, immutableTag, immutableState }) { + if (!Array.isArray(tags) || tags.some((tag) => typeof tag !== "string" || !tag)) { + fail("metadata action returned invalid image tags"); + } + if (immutableState === "matching") { + if (typeof immutableTag !== "string" || immutableTag.length === 0) { + fail("matching immutable version is missing its image tag"); + } + return tags.filter((tag) => tag !== immutableTag); } + if (immutableState === "" || immutableState === "absent") return tags; + fail(`unknown immutable image state ${immutableState}`); } if (process.argv[1] === new URL(import.meta.url).pathname) { - const owner = process.env.PACKAGE_OWNER; - const packageName = process.env.PACKAGE_NAME; - const imageTag = process.env.IMAGE_TAG; - if (!owner || !packageName || !imageTag) - fail("registry guard is missing package owner, name, or image tag"); - assertImageTagAvailable({ owner, packageName, imageTag, api: githubAPI }); + const state = resolveImmutableTag({ + expectedDigest: process.env.EXPECTED_DIGEST, + publishedDigest: process.env.PUBLISHED_DIGEST, + }); + process.stdout.write(`immutable_state=${state}\n`); } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b0a7995..9c50ddf 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,14 +16,14 @@ permissions: contents: read concurrency: - group: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && format('{0}-main-edge', github.workflow) || github.run_id }} + # Release and manual publishers share mutable tags such as `latest`, while + # edge-only main pushes are independent. + group: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && format('{0}-main-edge', github.workflow) || format('{0}-release-publishers', github.workflow) }} cancel-in-progress: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} env: REGISTRY: ghcr.io IMAGE_NAME: snuffy2/shellport - PACKAGE_OWNER: Snuffy2 - PACKAGE_NAME: shellport OCI_ARTIFACT_NAME: shellport-oci-${{ github.run_id }} OCI_ARTIFACT_PATH: shellport.oci.tar OCI_ARTIFACT_MAX_BYTES: "2147483648" @@ -54,12 +54,6 @@ jobs: RELEASE_TAG: ${{ github.event.release.tag_name }} RELEASE_TARGET: ${{ github.event.release.target_commitish }} run: node .github/scripts/release-provenance.mjs >> "$GITHUB_OUTPUT" - - name: Refuse existing immutable image version - if: steps.source.outputs.immutable_version == 'true' - env: - GH_TOKEN: ${{ github.token }} - IMAGE_TAG: ${{ steps.source.outputs.image_tag }} - run: node .github/scripts/release-registry-guard.mjs - uses: actions/upload-artifact@v7 with: name: release-registry-control-${{ github.run_id }} @@ -67,7 +61,6 @@ jobs: if-no-files-found: error compression-level: 0 retention-days: 1 - build: needs: provenance runs-on: ubuntu-latest @@ -141,13 +134,45 @@ jobs: mkdir "$OCI_LAYOUT" tar --extract --file "$OCI_ARCHIVE" --directory "$OCI_LAYOUT" test -f "$OCI_LAYOUT/index.json" && test -f "$OCI_LAYOUT/oci-layout" + index_digest="$(jq -er '.manifests | if length == 1 then .[0].digest else empty end' "$OCI_LAYOUT/index.json")" + [[ "$index_digest" =~ ^sha256:[0-9a-f]{64}$ ]] + index_blob="${index_digest#sha256:}" + test -f "$OCI_LAYOUT/blobs/sha256/$index_blob" + [[ "sha256:$(sha256sum "$OCI_LAYOUT/blobs/sha256/$index_blob" | cut -d ' ' -f 1)" == "$index_digest" ]] printf 'oci_layout=%s\n' "$OCI_LAYOUT" >> "$GITHUB_OUTPUT" - - name: Recheck immutable image version immediately before upload + printf 'index_digest=%s\n' "$index_digest" >> "$GITHUB_OUTPUT" + - name: Compare immutable image version with verified OCI archive + id: immutable if: needs.provenance.outputs.immutable_version == 'true' env: - GH_TOKEN: ${{ github.token }} IMAGE_TAG: ${{ needs.provenance.outputs.image_tag }} - run: node "$RUNNER_TEMP/release-control/release-registry-guard.mjs" + EXPECTED_DIGEST: ${{ steps.artifact.outputs.index_digest }} + REGISTRY_USERNAME: ${{ github.actor }} + REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + set -euo pipefail + response_dir="$RUNNER_TEMP/immutable-version" + mkdir "$response_dir" + status="$(curl --silent --show-error --output "$response_dir/manifest" --dump-header "$response_dir/headers" --write-out '%{http_code}' --user "$REGISTRY_USERNAME:$REGISTRY_TOKEN" --header 'Accept: application/vnd.oci.image.index.v1+json, application/vnd.docker.distribution.manifest.list.v2+json' "https://$REGISTRY/v2/$IMAGE_NAME/manifests/$IMAGE_TAG")" + published_digest="" + case "$status" in + 200) + published_digest="$(grep -i '^docker-content-digest:' "$response_dir/headers" | tail -n 1 | sed -E 's/^[^:]+:[[:space:]]*//' | tr -d '\r')" + [[ "$published_digest" =~ ^sha256:[0-9a-f]{64}$ ]] + [[ "sha256:$(sha256sum "$response_dir/manifest" | cut -d ' ' -f 1)" == "$published_digest" ]] + ;; + 404) ;; + *) + printf 'immutable registry lookup returned HTTP %s\n' "$status" >&2 + exit 1 + ;; + esac + EXPECTED_DIGEST="$EXPECTED_DIGEST" PUBLISHED_DIGEST="$published_digest" node "$RUNNER_TEMP/release-control/release-registry-guard.mjs" >> "$GITHUB_OUTPUT" + - uses: docker/login-action@v4 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} - id: meta uses: docker/metadata-action@v6 with: @@ -161,12 +186,17 @@ jobs: env: OCI_ARCHIVE: ${{ runner.temp }}/release-image/${{ env.OCI_ARTIFACT_PATH }} TAGS: ${{ steps.meta.outputs.tags }} + IMMUTABLE_STATE: ${{ steps.immutable.outputs.immutable_state }} + IMMUTABLE_TAG: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.provenance.outputs.image_tag }} REGISTRY_USERNAME: ${{ github.actor }} REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail while IFS= read -r tag; do test -n "$tag" + if [[ "$IMMUTABLE_STATE" == "matching" && "$tag" == "$IMMUTABLE_TAG" ]]; then + continue + fi docker run --rm -v "$OCI_ARCHIVE:/work/image.oci:ro" quay.io/skopeo/stable@sha256:8d25aabcf965e267b6a6ad02ff8da5512f77de1490063625093ff564797e88bc copy --all --dest-creds "$REGISTRY_USERNAME:$REGISTRY_TOKEN" oci-archive:/work/image.oci docker://"$tag" done <<< "$TAGS" - name: Verify published platforms diff --git a/ui/release_provenance_test.js b/ui/release_provenance_test.js index 005099e..1bc9ca7 100644 --- a/ui/release_provenance_test.js +++ b/ui/release_provenance_test.js @@ -1,19 +1,26 @@ // Copyright (C) 2026 Snuffy2 // SPDX-License-Identifier: AGPL-3.0-only +import { readFileSync } from "node:fs"; + import { describe, expect, test } from "vitest"; import { resolveReleaseSource, resolveWorkflowSource, } from "../.github/scripts/release-provenance.mjs"; -import { assertImageTagAvailable } from "../.github/scripts/release-registry-guard.mjs"; +import { + resolveImmutableTag, + tagsToCopy, +} from "../.github/scripts/release-registry-guard.mjs"; const eventSHA = "a".repeat(40); -const packageEndpoint = - "users/Snuffy2/packages?package_type=container&per_page=100"; -const versionsEndpoint = - "users/Snuffy2/packages/container/shellport/versions?per_page=100"; +const indexDigest = `sha256:${"b".repeat(64)}`; +const otherDigest = `sha256:${"c".repeat(64)}`; +const releaseWorkflow = readFileSync( + new URL("../.github/workflows/release.yml", import.meta.url), + "utf8", +); function releaseEvent(overrides = {}) { return { @@ -34,14 +41,6 @@ function repository(overrides = {}) { }; } -function apiWith(responses) { - return (endpoint) => { - if (!(endpoint in responses)) - throw new Error(`unexpected API call: ${endpoint}`); - return responses[endpoint]; - }; -} - describe("release provenance", function () { test("uses the event commit only when the published tag still names it", function () { expect(resolveReleaseSource(releaseEvent(), repository())).toEqual({ @@ -89,61 +88,50 @@ describe("release provenance", function () { }); describe("registry immutability guard", function () { - test("permits a verified namespace with no package", function () { - assertImageTagAvailable({ - owner: "Snuffy2", - packageName: "shellport", - imageTag: "1.2.3", - api: apiWith({ user: { login: "Snuffy2" }, [packageEndpoint]: [[]] }), - }); - }); - test.each([ - [ - "permission failure", - () => { - throw new Error("HTTP 403"); - }, - "HTTP 403", - ], - [ - "network failure", - () => { - throw new Error("network unavailable"); - }, - "network unavailable", - ], - ])("does not convert %s into a missing package", (_name, api, message) => { - expect(() => - assertImageTagAvailable({ - owner: "Snuffy2", - packageName: "shellport", - imageTag: "1.2.3", - api, + test("retries a failed latest write from the same verified version archive", function () { + const immutableTag = "ghcr.io/snuffy2/shellport:1.2.3"; + expect( + resolveImmutableTag({ + expectedDigest: indexDigest, + publishedDigest: indexDigest, }), - ).toThrow(message); + ).toBe("matching"); + expect( + tagsToCopy({ + tags: [immutableTag, "ghcr.io/snuffy2/shellport:latest"], + immutableTag, + immutableState: "matching", + }), + ).toEqual(["ghcr.io/snuffy2/shellport:latest"]); }); - test("rejects an existing immutable tag", function () { + test("publishes a previously absent immutable version", function () { + expect( + resolveImmutableTag({ expectedDigest: indexDigest, publishedDigest: "" }), + ).toBe("absent"); + }); + test("rejects a full workflow rerun that rebuilds a different archive", function () { expect(() => - assertImageTagAvailable({ - owner: "Snuffy2", - packageName: "shellport", - imageTag: "1.2.3", - api: apiWith({ - user: { login: "Snuffy2" }, - [packageEndpoint]: [ - [ - { - name: "shellport", - package_type: "container", - owner: { login: "Snuffy2" }, - }, - ], - ], - [versionsEndpoint]: [ - [{ metadata: { container: { tags: ["1.2.3"] } } }], - ], - }), + resolveImmutableTag({ + expectedDigest: indexDigest, + publishedDigest: otherDigest, }), - ).toThrow("already exists"); + ).toThrow("not verified archive"); + }); +}); + +describe("release publisher serialization", function () { + test("shares one non-cancelling group for release and manual publishers", function () { + const group = releaseWorkflow.match(/^ group: (?.+)$/mu)?.groups?.value; + const cancellation = releaseWorkflow.match( + /^ cancel-in-progress: (?.+)$/mu, + )?.groups?.value; + + expect(group).toContain("release-publishers"); + expect(group).not.toContain("github.run_id"); + expect(group).toContain("main-edge"); + expect(cancellation).toContain("github.event_name == 'push'"); + expect(releaseWorkflow).toContain('"$IMMUTABLE_STATE" == "matching"'); + expect(releaseWorkflow).toContain('"$tag" == "$IMMUTABLE_TAG"'); + expect(releaseWorkflow).toContain("copy --all"); }); }); From 54cf5b3a447200df003734e98ff2458ff82bc369 Mon Sep 17 00:00:00 2001 From: "prek-autofix[bot]" Date: Fri, 4 Sep 2026 19:49:06 -0400 Subject: [PATCH 3/4] [prek-autofix] apply automatic fixes --- ui/release_provenance_test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/ui/release_provenance_test.js b/ui/release_provenance_test.js index 1bc9ca7..aebc93a 100644 --- a/ui/release_provenance_test.js +++ b/ui/release_provenance_test.js @@ -121,9 +121,10 @@ describe("registry immutability guard", function () { describe("release publisher serialization", function () { test("shares one non-cancelling group for release and manual publishers", function () { - const group = releaseWorkflow.match(/^ group: (?.+)$/mu)?.groups?.value; + const group = releaseWorkflow.match(/^ {2}group: (?.+)$/mu)?.groups + ?.value; const cancellation = releaseWorkflow.match( - /^ cancel-in-progress: (?.+)$/mu, + /^ {2}cancel-in-progress: (?.+)$/mu, )?.groups?.value; expect(group).toContain("release-publishers"); From 09ef85e5e23577efadd3c12b4b239c40d4f7abc9 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Fri, 4 Sep 2026 22:49:36 -0400 Subject: [PATCH 4/4] ci: reject manual edge publications --- .github/scripts/release-provenance.mjs | 10 +++++++- ui/release_provenance_test.js | 35 +++++++++++++++++++++++--- 2 files changed, 41 insertions(+), 4 deletions(-) diff --git a/.github/scripts/release-provenance.mjs b/.github/scripts/release-provenance.mjs index c2aef6c..49dc168 100644 --- a/.github/scripts/release-provenance.mjs +++ b/.github/scripts/release-provenance.mjs @@ -44,6 +44,12 @@ export function resolveWorkflowSource(event) { fail(`event SHA ${event.eventSHA} is not a full lowercase commit SHA`); } const imageTag = event.inputTag || "edge"; + if ( + event.eventName === "workflow_dispatch" && + (imageTag === "edge" || !event.inputTag?.trim()) + ) { + fail("manual workflow dispatch may not publish edge"); + } return { imageTag, sourceSHA: event.eventSHA, @@ -111,6 +117,8 @@ if (process.argv[1] === new URL(import.meta.url).pathname) { ), ); } else { - writeOutput(resolveWorkflowSource(event)); + writeOutput( + resolveWorkflowSource({ ...event, eventName: process.env.EVENT_NAME }), + ); } } diff --git a/ui/release_provenance_test.js b/ui/release_provenance_test.js index aebc93a..e989d4e 100644 --- a/ui/release_provenance_test.js +++ b/ui/release_provenance_test.js @@ -77,12 +77,41 @@ describe("release provenance", function () { ])("rejects %s", (_name, event, git, message) => { expect(() => resolveReleaseSource(event, git)).toThrow(message); }); - test("guards manual semantic versions but preserves the edge path", function () { + test("keeps the implicit edge tag for main pushes", function () { expect( - resolveWorkflowSource({ eventSHA, inputTag: "1.2.3" }), + resolveWorkflowSource({ eventName: "push", eventSHA, inputTag: "" }), + ).toEqual({ + imageTag: "edge", + immutableVersion: false, + sourceSHA: eventSHA, + }); + }); + test.each(["edge", "", " \t "])( + "rejects manual edge publication from %j", + (inputTag) => { + expect(() => + resolveWorkflowSource({ + eventName: "workflow_dispatch", + eventSHA, + inputTag, + }), + ).toThrow("may not publish edge"); + }, + ); + test("accepts manual non-edge tags and preserves semver immutability", function () { + expect( + resolveWorkflowSource({ + eventName: "workflow_dispatch", + eventSHA, + inputTag: "1.2.3", + }), ).toMatchObject({ immutableVersion: true, imageTag: "1.2.3" }); expect( - resolveWorkflowSource({ eventSHA, inputTag: "nightly" }), + resolveWorkflowSource({ + eventName: "workflow_dispatch", + eventSHA, + inputTag: "nightly", + }), ).toMatchObject({ immutableVersion: false, imageTag: "nightly" }); }); });