From 1963cefdc69c664bd813546e43590597b7a4820a Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Fri, 4 Sep 2026 23:45:30 -0400 Subject: [PATCH 1/3] ci: repair and harden release publication --- .github/scripts/release-provenance.mjs | 96 +++++++++-- .github/scripts/release-registry-guard.mjs | 55 +++++- .github/workflows/release.yml | 189 +++++++++++++++++---- ui/release_provenance_test.js | 187 ++++++++++++++++---- 4 files changed, 444 insertions(+), 83 deletions(-) diff --git a/.github/scripts/release-provenance.mjs b/.github/scripts/release-provenance.mjs index 49dc168..a79d09a 100644 --- a/.github/scripts/release-provenance.mjs +++ b/.github/scripts/release-provenance.mjs @@ -3,8 +3,15 @@ import { execFileSync } from "node:child_process"; -const semverTagPattern = - /^v?(?[0-9]+\.[0-9]+\.[0-9]+(?:-[0-9A-Za-z.-]+)?)$/u; +const numericIdentifier = "(?:0|[1-9][0-9]*)"; +const prereleaseIdentifier = `(?:${numericIdentifier}|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)`; +const semverTagPattern = new RegExp( + `^v?(?${numericIdentifier}\\.${numericIdentifier}\\.${numericIdentifier}` + + `(?:-${prereleaseIdentifier}(?:\\.${prereleaseIdentifier})*)?)$`, + "u", +); +const versionLikeTagPattern = /^v?[0-9]+\.[0-9]+\.[0-9]+(?:[-.].*)?$/u; +const dockerTagPattern = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/u; const commitPattern = /^[0-9a-f]{40}$/u; function fail(message) { @@ -17,6 +24,9 @@ export function versionFromTag(tag) { } export function resolveReleaseSource(event, git) { + if (!commitPattern.test(event.eventSHA)) { + fail(`event SHA ${event.eventSHA} is not a full lowercase commit SHA`); + } const eventCommit = git.commit(event.eventSHA); if (event.releaseTarget !== event.defaultBranch) { fail(`release target ${event.releaseTarget} is not ${event.defaultBranch}`); @@ -36,24 +46,58 @@ export function resolveReleaseSource(event, git) { `event commit ${eventCommit} is not an ancestor of ${event.defaultBranch}`, ); } - return { imageTag: version, sourceSHA: eventCommit, immutableVersion: true }; + const tagIdentity = git.tagIdentity(event.releaseTag); + return { + imageTag: version, + immutableVersion: true, + releaseRefOID: tagIdentity.oid, + releaseRefType: tagIdentity.type, + sourceSHA: eventCommit, + }; } -export function resolveWorkflowSource(event) { +export function resolveWorkflowSource(event, git) { if (!commitPattern.test(event.eventSHA)) { 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"); + const defaultRef = `refs/heads/${event.defaultBranch}`; + if (event.eventRef !== defaultRef) { + fail(`event ref ${event.eventRef} is not ${defaultRef}`); + } + if (git.branchCommit(event.defaultBranch) !== event.eventSHA) { + fail( + `event commit ${event.eventSHA} is not the tip of ${event.defaultBranch}`, + ); + } + if (event.eventName === "push") { + if (event.inputTag) fail("main push unexpectedly supplied an image tag"); + return { + imageTag: "edge", + immutableVersion: false, + releaseRefOID: "", + releaseRefType: "", + sourceSHA: event.eventSHA, + }; + } + if (event.eventName !== "workflow_dispatch") { + fail(`unsupported workflow event ${event.eventName}`); + } + const imageTag = event.inputTag?.trim() ?? ""; + if (imageTag !== event.inputTag || !dockerTagPattern.test(imageTag)) { + fail("manual workflow dispatch supplied an invalid Docker image tag"); + } + if (imageTag === "edge" || imageTag === "latest") { + fail(`manual workflow dispatch may not publish ${imageTag}`); + } + if (versionLikeTagPattern.test(imageTag)) { + fail("manual workflow dispatch may not publish version tags"); } return { imageTag, sourceSHA: event.eventSHA, - immutableVersion: versionFromTag(imageTag) !== null, + immutableVersion: false, + releaseRefOID: "", + releaseRefType: "", }; } @@ -77,6 +121,13 @@ function gitForRelease(defaultBranch, releaseTag) { tagCommit(tag) { return runGit(["rev-parse", "--verify", `${tag}^{commit}`]); }, + tagIdentity(tag) { + const oid = runGit(["rev-parse", "--verify", `refs/tags/${tag}`]); + return { oid, type: runGit(["cat-file", "-t", oid]) }; + }, + branchCommit(branch) { + return runGit(["rev-parse", "--verify", `origin/${branch}^{commit}`]); + }, isAncestor(commit, branch) { try { runGit(["merge-base", "--is-ancestor", commit, `origin/${branch}`]); @@ -88,12 +139,29 @@ function gitForRelease(defaultBranch, releaseTag) { }; } +function gitForBranch(defaultBranch) { + runGit([ + "fetch", + "--force", + "--no-tags", + "origin", + `refs/heads/${defaultBranch}:refs/remotes/origin/${defaultBranch}`, + ]); + return { + branchCommit(branch) { + return runGit(["rev-parse", "--verify", `origin/${branch}^{commit}`]); + }, + }; +} + function writeOutput(result) { process.stdout.write( [ `source_sha=${result.sourceSHA}`, `image_tag=${result.imageTag}`, `immutable_version=${result.immutableVersion}`, + `release_ref_oid=${result.releaseRefOID}`, + `release_ref_type=${result.releaseRefType}`, ].join("\n") + "\n", ); } @@ -102,6 +170,7 @@ if (process.argv[1] === new URL(import.meta.url).pathname) { const event = { defaultBranch: process.env.DEFAULT_BRANCH, eventSHA: process.env.EVENT_SHA, + eventRef: process.env.EVENT_REF, inputTag: process.env.INPUT_TAG, releaseTag: process.env.RELEASE_TAG, releaseTarget: process.env.RELEASE_TARGET, @@ -118,7 +187,10 @@ if (process.argv[1] === new URL(import.meta.url).pathname) { ); } else { writeOutput( - resolveWorkflowSource({ ...event, eventName: process.env.EVENT_NAME }), + resolveWorkflowSource( + { ...event, eventName: process.env.EVENT_NAME }, + gitForBranch(event.defaultBranch), + ), ); } } diff --git a/.github/scripts/release-registry-guard.mjs b/.github/scripts/release-registry-guard.mjs index a5a2533..0538a39 100644 --- a/.github/scripts/release-registry-guard.mjs +++ b/.github/scripts/release-registry-guard.mjs @@ -26,7 +26,10 @@ export function resolveImmutableTag({ expectedDigest, publishedDigest }) { } export function tagsToCopy({ tags, immutableTag, immutableState }) { - if (!Array.isArray(tags) || tags.some((tag) => typeof tag !== "string" || !tag)) { + if ( + !Array.isArray(tags) || + tags.some((tag) => typeof tag !== "string" || !tag) + ) { fail("metadata action returned invalid image tags"); } if (immutableState === "matching") { @@ -39,10 +42,50 @@ export function tagsToCopy({ tags, immutableTag, immutableState }) { fail(`unknown immutable image state ${immutableState}`); } +export function assertPlatformIndex(index) { + if (!Array.isArray(index?.manifests)) { + fail("verified OCI archive is missing a manifest index"); + } + const platforms = index.manifests + .map((manifest) => manifest?.platform) + .filter( + (platform) => + platform && + platform.os !== "unknown" && + platform.architecture !== "unknown", + ) + .map((platform) => `${platform.os}/${platform.architecture}`) + .sort(); + if ( + platforms.length !== 2 || + platforms[0] !== "linux/amd64" || + platforms[1] !== "linux/arm64" + ) { + fail( + `verified OCI archive platforms are ${platforms.join(", ") || "empty"}, not linux/amd64 and linux/arm64`, + ); + } +} + if (process.argv[1] === new URL(import.meta.url).pathname) { - const state = resolveImmutableTag({ - expectedDigest: process.env.EXPECTED_DIGEST, - publishedDigest: process.env.PUBLISHED_DIGEST, - }); - process.stdout.write(`immutable_state=${state}\n`); + if (process.env.MODE === "validate-platforms") { + assertPlatformIndex(JSON.parse(process.env.MANIFEST_INDEX)); + } else { + const state = + process.env.IMMUTABLE_VERSION === "true" + ? resolveImmutableTag({ + expectedDigest: process.env.EXPECTED_DIGEST, + publishedDigest: process.env.PUBLISHED_DIGEST, + }) + : ""; + const tags = tagsToCopy({ + tags: process.env.TAGS.split("\n").filter(Boolean), + immutableTag: process.env.IMMUTABLE_TAG, + immutableState: state, + }); + process.stdout.write( + `immutable_state=${state}\ntags_to_copy<<__RELEASE_TAGS__\n` + + `${tags.join("\n")}\n__RELEASE_TAGS__\n`, + ); + } } diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9c50ddf..0427695 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -39,16 +39,21 @@ jobs: source_sha: ${{ steps.source.outputs.source_sha }} image_tag: ${{ steps.source.outputs.image_tag }} immutable_version: ${{ steps.source.outputs.immutable_version }} + release_ref_oid: ${{ steps.source.outputs.release_ref_oid }} + release_ref_type: ${{ steps.source.outputs.release_ref_type }} steps: - uses: actions/checkout@v7 with: - ref: ${{ github.sha }} + # Policy code comes from the current trusted default branch. The + # separately resolved event SHA remains the only image build source. + ref: ${{ github.event.repository.default_branch }} fetch-depth: 1 persist-credentials: false - id: source env: DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} EVENT_NAME: ${{ github.event_name }} + EVENT_REF: ${{ github.ref }} EVENT_SHA: ${{ github.sha }} INPUT_TAG: ${{ inputs.tag_name }} RELEASE_TAG: ${{ github.event.release.tag_name }} @@ -57,7 +62,10 @@ jobs: - uses: actions/upload-artifact@v7 with: name: release-registry-control-${{ github.run_id }} - path: .github/scripts/release-registry-guard.mjs + path: | + .github/scripts/release-provenance.mjs + .github/scripts/release-registry-guard.mjs + include-hidden-files: true if-no-files-found: error compression-level: 0 retention-days: 1 @@ -81,6 +89,10 @@ jobs: outputs: type=oci,dest=${{ runner.temp }}/${{ env.OCI_ARTIFACT_PATH }} provenance: mode=max sbom: true + labels: | + org.opencontainers.image.source=https://github.com/Snuffy2/shellport + org.opencontainers.image.revision=${{ needs.provenance.outputs.source_sha }} + org.opencontainers.image.version=${{ needs.provenance.outputs.image_tag }} 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 @@ -110,6 +122,11 @@ jobs: contents: read packages: write steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.repository.default_branch }} + fetch-depth: 1 + persist-credentials: false - uses: actions/download-artifact@v8 with: name: ${{ env.OCI_ARTIFACT_NAME }} @@ -124,36 +141,65 @@ jobs: 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 + OCI_INDEX: ${{ runner.temp }}/oci-index.json + OCI_LAYOUT_METADATA: ${{ runner.temp }}/oci-layout.json + PLATFORM_INDEX: ${{ runner.temp }}/platform-index.json 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" - index_digest="$(jq -er '.manifests | if length == 1 then .[0].digest else empty end' "$OCI_LAYOUT/index.json")" + tar --extract --to-stdout --file "$OCI_ARCHIVE" index.json > "$OCI_INDEX" + tar --extract --to-stdout --file "$OCI_ARCHIVE" oci-layout > "$OCI_LAYOUT_METADATA" + jq -e '.imageLayoutVersion == "1.0.0"' "$OCI_LAYOUT_METADATA" + index_digest="$(jq -er '.manifests | if length == 1 then .[0].digest else empty end' "$OCI_INDEX")" [[ "$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" + tar --extract --to-stdout --file "$OCI_ARCHIVE" "blobs/sha256/$index_blob" > "$PLATFORM_INDEX" + [[ "sha256:$(sha256sum "$PLATFORM_INDEX" | cut -d ' ' -f 1)" == "$index_digest" ]] + MODE=validate-platforms MANIFEST_INDEX="$(cat "$PLATFORM_INDEX")" node "$RUNNER_TEMP/release-control/release-registry-guard.mjs" printf 'index_digest=%s\n' "$index_digest" >> "$GITHUB_OUTPUT" - - name: Compare immutable image version with verified OCI archive - id: immutable + - name: Authorize current stable release alias + id: latest + if: github.event_name == 'release' && !github.event.release.prerelease + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + run: | + set -euo pipefail + latest_release_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)" + if [[ "$latest_release_tag" == "$RELEASE_TAG" ]]; then + printf 'publish_latest=true\n' >> "$GITHUB_OUTPUT" + else + printf 'publish_latest=false\n' >> "$GITHUB_OUTPUT" + printf 'Skipping latest: current stable release is %s, not %s.\n' "$latest_release_tag" "$RELEASE_TAG" >> "$GITHUB_STEP_SUMMARY" + fi + - id: meta + uses: docker/metadata-action@v6 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=edge,branch=main,enable=${{ github.event_name == 'push' }} + type=semver,pattern={{version}},value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' }} + type=raw,value=latest,enable=${{ steps.latest.outputs.publish_latest == 'true' }} + type=raw,value=${{ inputs.tag_name }},enable=${{ github.event_name == 'workflow_dispatch' }} + - name: Look up immutable image version + id: lookup if: needs.provenance.outputs.immutable_version == 'true' env: IMAGE_TAG: ${{ needs.provenance.outputs.image_tag }} - 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")" + token_status="$(curl --silent --show-error --output "$response_dir/token" --write-out '%{http_code}' --user "$REGISTRY_USERNAME:$REGISTRY_TOKEN" --get --data-urlencode "service=$REGISTRY" --data-urlencode "scope=repository:$IMAGE_NAME:pull" "https://$REGISTRY/token")" + [[ "$token_status" == "200" ]] + bearer="$(jq -er '.token // .access_token' "$response_dir/token")" + test -n "$bearer" + status="$(curl --silent --show-error --output "$response_dir/manifest" --dump-header "$response_dir/headers" --write-out '%{http_code}' --header "Authorization: Bearer $bearer" --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) @@ -167,42 +213,117 @@ jobs: exit 1 ;; esac - EXPECTED_DIGEST="$EXPECTED_DIGEST" PUBLISHED_DIGEST="$published_digest" node "$RUNNER_TEMP/release-control/release-registry-guard.mjs" >> "$GITHUB_OUTPUT" + printf 'published_digest=%s\n' "$published_digest" >> "$GITHUB_OUTPUT" + - name: Plan registry writes + id: plan + env: + EXPECTED_DIGEST: ${{ steps.artifact.outputs.index_digest }} + IMMUTABLE_TAG: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ needs.provenance.outputs.image_tag }} + IMMUTABLE_VERSION: ${{ needs.provenance.outputs.immutable_version }} + PUBLISHED_DIGEST: ${{ steps.lookup.outputs.published_digest }} + TAGS: ${{ steps.meta.outputs.tags }} + run: node "$RUNNER_TEMP/release-control/release-registry-guard.mjs" >> "$GITHUB_OUTPUT" + - name: Revalidate publication source + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EVENT_NAME: ${{ github.event_name }} + EVENT_REF: ${{ github.ref }} + EVENT_SHA: ${{ github.sha }} + INPUT_TAG: ${{ inputs.tag_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TARGET: ${{ github.event.release.target_commitish }} + EXPECTED_SOURCE_SHA: ${{ needs.provenance.outputs.source_sha }} + EXPECTED_IMAGE_TAG: ${{ needs.provenance.outputs.image_tag }} + EXPECTED_IMMUTABLE_VERSION: ${{ needs.provenance.outputs.immutable_version }} + EXPECTED_RELEASE_REF_OID: ${{ needs.provenance.outputs.release_ref_oid }} + EXPECTED_RELEASE_REF_TYPE: ${{ needs.provenance.outputs.release_ref_type }} + run: | + set -euo pipefail + expected="$RUNNER_TEMP/expected-source" + actual="$RUNNER_TEMP/actual-source" + printf '%s\n' \ + "source_sha=$EXPECTED_SOURCE_SHA" \ + "image_tag=$EXPECTED_IMAGE_TAG" \ + "immutable_version=$EXPECTED_IMMUTABLE_VERSION" \ + "release_ref_oid=$EXPECTED_RELEASE_REF_OID" \ + "release_ref_type=$EXPECTED_RELEASE_REF_TYPE" > "$expected" + node "$RUNNER_TEMP/release-control/release-provenance.mjs" > "$actual" + diff --unified "$expected" "$actual" - uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - id: meta - uses: docker/metadata-action@v6 - with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} - tags: | - type=edge,branch=main,enable=${{ github.event_name == 'push' }} - 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' }} - name: Upload verified OCI archive without rebuilding env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} 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 }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + TAGS: ${{ steps.plan.outputs.tags_to_copy }} REGISTRY_USERNAME: ${{ github.actor }} REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail + if [[ -z "$TAGS" ]]; then + exit 0 + fi while IFS= read -r tag; do test -n "$tag" - if [[ "$IMMUTABLE_STATE" == "matching" && "$tag" == "$IMMUTABLE_TAG" ]]; then - continue + if [[ "$tag" == "$REGISTRY/$IMAGE_NAME:latest" ]]; then + current_latest_release_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)" + if [[ "$current_latest_release_tag" != "$RELEASE_TAG" ]]; then + printf 'Refusing latest: current stable release is %s, not %s.\n' "$current_latest_release_tag" "$RELEASE_TAG" >&2 + exit 1 + fi 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" + docker run --rm -v "$OCI_ARCHIVE:/work/image.oci:ro" quay.io/skopeo/stable@sha256:8d25aabcf965e267b6a6ad02ff8da5512f77de1490063625093ff564797e88bc copy --all --preserve-digests --dest-creds "$REGISTRY_USERNAME:$REGISTRY_TOKEN" oci-archive:/work/image.oci docker://"$tag" done <<< "$TAGS" - - name: Verify published platforms + - name: Verify every published tag env: - IMAGE_TAG: ${{ needs.provenance.outputs.image_tag }} + EXPECTED_DIGEST: ${{ steps.artifact.outputs.index_digest }} + REGISTRY_USERNAME: ${{ github.actor }} + REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TAGS: ${{ steps.meta.outputs.tags }} + run: | + set -euo pipefail + response_dir="$RUNNER_TEMP/published-tags" + mkdir "$response_dir" + token_status="$(curl --silent --show-error --output "$response_dir/token" --write-out '%{http_code}' --user "$REGISTRY_USERNAME:$REGISTRY_TOKEN" --get --data-urlencode "service=$REGISTRY" --data-urlencode "scope=repository:$IMAGE_NAME:pull" "https://$REGISTRY/token")" + [[ "$token_status" == "200" ]] + bearer="$(jq -er '.token // .access_token' "$response_dir/token")" + test -n "$bearer" + while IFS= read -r tag; do + test -n "$tag" + image_tag="${tag##*:}" + status="$(curl --silent --show-error --output "$response_dir/manifest" --dump-header "$response_dir/headers" --write-out '%{http_code}' --header "Authorization: Bearer $bearer" --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")" + [[ "$status" == "200" ]] + published_digest="$(grep -i '^docker-content-digest:' "$response_dir/headers" | tail -n 1 | sed -E 's/^[^:]+:[[:space:]]*//' | tr -d '\r')" + [[ "$published_digest" == "$EXPECTED_DIGEST" ]] + [[ "sha256:$(sha256sum "$response_dir/manifest" | cut -d ' ' -f 1)" == "$published_digest" ]] + done <<< "$TAGS" + - name: Confirm publication source remains current + env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EVENT_NAME: ${{ github.event_name }} + EVENT_REF: ${{ github.ref }} + EVENT_SHA: ${{ github.sha }} + INPUT_TAG: ${{ inputs.tag_name }} + RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TARGET: ${{ github.event.release.target_commitish }} + EXPECTED_SOURCE_SHA: ${{ needs.provenance.outputs.source_sha }} + EXPECTED_IMAGE_TAG: ${{ needs.provenance.outputs.image_tag }} + EXPECTED_IMMUTABLE_VERSION: ${{ needs.provenance.outputs.immutable_version }} + EXPECTED_RELEASE_REF_OID: ${{ needs.provenance.outputs.release_ref_oid }} + EXPECTED_RELEASE_REF_TYPE: ${{ needs.provenance.outputs.release_ref_type }} 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" + expected="$RUNNER_TEMP/expected-source" + actual="$RUNNER_TEMP/final-source" + printf '%s\n' \ + "source_sha=$EXPECTED_SOURCE_SHA" \ + "image_tag=$EXPECTED_IMAGE_TAG" \ + "immutable_version=$EXPECTED_IMMUTABLE_VERSION" \ + "release_ref_oid=$EXPECTED_RELEASE_REF_OID" \ + "release_ref_type=$EXPECTED_RELEASE_REF_TYPE" > "$expected" + node "$RUNNER_TEMP/release-control/release-provenance.mjs" > "$actual" + diff --unified "$expected" "$actual" diff --git a/ui/release_provenance_test.js b/ui/release_provenance_test.js index e989d4e..b5b94e4 100644 --- a/ui/release_provenance_test.js +++ b/ui/release_provenance_test.js @@ -10,6 +10,7 @@ import { resolveWorkflowSource, } from "../.github/scripts/release-provenance.mjs"; import { + assertPlatformIndex, resolveImmutableTag, tagsToCopy, } from "../.github/scripts/release-registry-guard.mjs"; @@ -34,19 +35,34 @@ function releaseEvent(overrides = {}) { function repository(overrides = {}) { return { + branchCommit: () => eventSHA, commit: (value) => value, tagCommit: () => eventSHA, + tagIdentity: () => ({ oid: eventSHA, type: "commit" }), isAncestor: () => true, ...overrides, }; } +function workflowEvent(overrides = {}) { + return { + defaultBranch: "main", + eventName: "workflow_dispatch", + eventRef: "refs/heads/main", + eventSHA, + inputTag: "nightly", + ...overrides, + }; +} + 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, + releaseRefOID: eventSHA, + releaseRefType: "commit", }); }); test.each([ @@ -77,47 +93,91 @@ describe("release provenance", function () { ])("rejects %s", (_name, event, git, message) => { expect(() => resolveReleaseSource(event, git)).toThrow(message); }); + test("records the direct tag object as well as its peeled commit", function () { + expect( + resolveReleaseSource( + releaseEvent(), + repository({ + tagIdentity: () => ({ oid: "b".repeat(40), type: "tag" }), + }), + ), + ).toMatchObject({ + releaseRefOID: "b".repeat(40), + releaseRefType: "tag", + }); + }); + test.each([ + "v01.2.3", + "v1.02.3", + "v1.2.03", + "v1.2.3-", + "v1.2.3-alpha..1", + "v1.2.3-beta.01", + ])("rejects invalid semantic release tag %s", (releaseTag) => { + expect(() => + resolveReleaseSource(releaseEvent({ releaseTag }), repository()), + ).toThrow("not a supported semantic version"); + }); test("keeps the implicit edge tag for main pushes", function () { expect( - resolveWorkflowSource({ eventName: "push", eventSHA, inputTag: "" }), + resolveWorkflowSource( + workflowEvent({ eventName: "push", inputTag: "" }), + repository(), + ), ).toEqual({ imageTag: "edge", immutableVersion: false, + releaseRefOID: "", + releaseRefType: "", 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({ - eventName: "workflow_dispatch", - eventSHA, - inputTag: "nightly", - }), - ).toMatchObject({ immutableVersion: false, imageTag: "nightly" }); + test.each([ + ["edge", "may not publish edge"], + ["latest", "may not publish latest"], + ["1.2.3", "may not publish version tags"], + ["v1.2.3", "may not publish version tags"], + ["1.2.3-beta.01", "may not publish version tags"], + ["", "invalid Docker image tag"], + [" nightly ", "invalid Docker image tag"], + ["bad/tag", "invalid Docker image tag"], + ])("rejects reserved or invalid manual tag %j", (inputTag, message) => { + expect(() => + resolveWorkflowSource(workflowEvent({ inputTag }), repository()), + ).toThrow(message); + }); + test.each([ + [ + "non-default ref", + workflowEvent({ eventRef: "refs/heads/release" }), + repository(), + "is not refs/heads/main", + ], + [ + "stale default-branch commit", + workflowEvent(), + repository({ branchCommit: () => "b".repeat(40) }), + "is not the tip of main", + ], + [ + "unsupported event", + workflowEvent({ eventName: "pull_request" }), + repository(), + "unsupported workflow event", + ], + ])("rejects manual publication from %s", (_name, event, git, message) => { + expect(() => resolveWorkflowSource(event, git)).toThrow(message); + }); + test("accepts a custom tag only from the current default-branch tip", function () { + expect(resolveWorkflowSource(workflowEvent(), repository())).toMatchObject({ + immutableVersion: false, + imageTag: "nightly", + }); }); }); describe("registry immutability guard", function () { - test("retries a failed latest write from the same verified version archive", function () { + test("retries the current release's failed latest write from its verified archive", function () { const immutableTag = "ghcr.io/snuffy2/shellport:1.2.3"; expect( resolveImmutableTag({ @@ -138,6 +198,16 @@ describe("registry immutability guard", function () { resolveImmutableTag({ expectedDigest: indexDigest, publishedDigest: "" }), ).toBe("absent"); }); + test("makes an older matching immutable release a no-op without latest", function () { + const immutableTag = "ghcr.io/snuffy2/shellport:1.2.3"; + expect( + tagsToCopy({ + tags: [immutableTag], + immutableTag, + immutableState: "matching", + }), + ).toEqual([]); + }); test("rejects a full workflow rerun that rebuilds a different archive", function () { expect(() => resolveImmutableTag({ @@ -148,6 +218,31 @@ describe("registry immutability guard", function () { }); }); +describe("OCI archive policy", function () { + const descriptor = (os, architecture) => ({ platform: { architecture, os } }); + + test("requires one amd64 and one arm64 Linux image", function () { + expect(() => + assertPlatformIndex({ + manifests: [ + descriptor("linux", "amd64"), + descriptor("linux", "arm64"), + descriptor("unknown", "unknown"), + ], + }), + ).not.toThrow(); + }); + test.each([ + [descriptor("linux", "amd64"), descriptor("linux", "amd64")], + [descriptor("linux", "amd64")], + [descriptor("linux", "amd64"), descriptor("linux", "s390x")], + ])("rejects the invalid platform set %#", (...manifests) => { + expect(() => assertPlatformIndex({ manifests })).toThrow( + "not linux/amd64 and linux/arm64", + ); + }); +}); + describe("release publisher serialization", function () { test("shares one non-cancelling group for release and manual publishers", function () { const group = releaseWorkflow.match(/^ {2}group: (?.+)$/mu)?.groups @@ -160,8 +255,38 @@ describe("release publisher serialization", function () { 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("Revalidate publication source"); + expect(releaseWorkflow).toContain( + "Confirm publication source remains current", + ); + expect(releaseWorkflow).toContain( + "ref: ${{ github.event.repository.default_branch }}", + ); + expect(releaseWorkflow).toContain("include-hidden-files: true"); + expect(releaseWorkflow).toContain("https://$REGISTRY/token"); + expect(releaseWorkflow).toContain("Authorization: Bearer $bearer"); + expect(releaseWorkflow).not.toContain( + '--user "$REGISTRY_USERNAME:$REGISTRY_TOKEN" --header \'Accept:', + ); + expect(releaseWorkflow).toContain("steps.plan.outputs.tags_to_copy"); + expect(releaseWorkflow).toContain( + "steps.latest.outputs.publish_latest == 'true'", + ); + expect(releaseWorkflow).toContain( + 'gh api "repos/$GITHUB_REPOSITORY/releases/latest"', + ); + expect(releaseWorkflow).toContain( + '"$current_latest_release_tag" != "$RELEASE_TAG"', + ); + expect(releaseWorkflow).toContain("Verify every published tag"); + expect(releaseWorkflow).toContain("tar --extract --to-stdout"); + expect(releaseWorkflow).not.toContain( + 'tar --extract --file "$OCI_ARCHIVE" --directory', + ); + expect(releaseWorkflow).toContain( + '"$published_digest" == "$EXPECTED_DIGEST"', + ); expect(releaseWorkflow).toContain("copy --all"); + expect(releaseWorkflow).toContain("--preserve-digests"); }); }); From be0838bc0f9af34a8741fd7b5e59cf1c1ea19e0e Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Fri, 4 Sep 2026 23:54:42 -0400 Subject: [PATCH 2/3] ci: close release publication race windows Revalidate source provenance inside the registry write loop and confirm latest-release authority before successful completion. Validate that extra OCI descriptors are BuildKit attestations tied to the two expected platform manifests. --- .github/scripts/release-registry-guard.mjs | 42 +++++++++++++++----- .github/workflows/release.yml | 18 +++++++++ ui/release_provenance_test.js | 46 ++++++++++++++++++---- 3 files changed, 90 insertions(+), 16 deletions(-) diff --git a/.github/scripts/release-registry-guard.mjs b/.github/scripts/release-registry-guard.mjs index 0538a39..76d83ab 100644 --- a/.github/scripts/release-registry-guard.mjs +++ b/.github/scripts/release-registry-guard.mjs @@ -46,15 +46,31 @@ export function assertPlatformIndex(index) { if (!Array.isArray(index?.manifests)) { fail("verified OCI archive is missing a manifest index"); } - const platforms = index.manifests - .map((manifest) => manifest?.platform) - .filter( - (platform) => - platform && - platform.os !== "unknown" && - platform.architecture !== "unknown", - ) - .map((platform) => `${platform.os}/${platform.architecture}`) + const imageDescriptors = []; + const attestationSubjects = new Set(); + for (const descriptor of index.manifests) { + if (!descriptor?.platform || !digestPattern.test(descriptor.digest)) { + fail("verified OCI archive contains an invalid manifest descriptor"); + } + const { architecture, os } = descriptor.platform; + if (os === "unknown" || architecture === "unknown") { + const annotations = descriptor.annotations; + const subject = annotations?.["vnd.docker.reference.digest"]; + if ( + os !== "unknown" || + architecture !== "unknown" || + annotations?.["vnd.docker.reference.type"] !== "attestation-manifest" || + !digestPattern.test(subject) + ) { + fail("verified OCI archive contains an invalid attestation descriptor"); + } + attestationSubjects.add(subject); + continue; + } + imageDescriptors.push(descriptor); + } + const platforms = imageDescriptors + .map(({ platform }) => `${platform.os}/${platform.architecture}`) .sort(); if ( platforms.length !== 2 || @@ -65,6 +81,14 @@ export function assertPlatformIndex(index) { `verified OCI archive platforms are ${platforms.join(", ") || "empty"}, not linux/amd64 and linux/arm64`, ); } + const imageDigests = new Set(imageDescriptors.map(({ digest }) => digest)); + if ( + imageDigests.size !== 2 || + [...attestationSubjects].some((digest) => !imageDigests.has(digest)) || + [...imageDigests].some((digest) => !attestationSubjects.has(digest)) + ) { + fail("verified OCI archive attestations do not match its image manifests"); + } } if (process.argv[1] === new URL(import.meta.url).pathname) { diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0427695..aae4f36 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -256,19 +256,31 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Upload verified OCI archive without rebuilding env: + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + EVENT_NAME: ${{ github.event_name }} + EVENT_REF: ${{ github.ref }} + EVENT_SHA: ${{ github.sha }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT_TAG: ${{ inputs.tag_name }} OCI_ARCHIVE: ${{ runner.temp }}/release-image/${{ env.OCI_ARTIFACT_PATH }} RELEASE_TAG: ${{ github.event.release.tag_name }} + RELEASE_TARGET: ${{ github.event.release.target_commitish }} TAGS: ${{ steps.plan.outputs.tags_to_copy }} REGISTRY_USERNAME: ${{ github.actor }} REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | set -euo pipefail + revalidate_source() { + node "$RUNNER_TEMP/release-control/release-provenance.mjs" > "$RUNNER_TEMP/current-source" + diff --unified "$RUNNER_TEMP/expected-source" "$RUNNER_TEMP/current-source" + } + revalidate_source if [[ -z "$TAGS" ]]; then exit 0 fi while IFS= read -r tag; do test -n "$tag" + revalidate_source if [[ "$tag" == "$REGISTRY/$IMAGE_NAME:latest" ]]; then current_latest_release_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)" if [[ "$current_latest_release_tag" != "$RELEASE_TAG" ]]; then @@ -307,7 +319,9 @@ jobs: EVENT_NAME: ${{ github.event_name }} EVENT_REF: ${{ github.ref }} EVENT_SHA: ${{ github.sha }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} INPUT_TAG: ${{ inputs.tag_name }} + PUBLISH_LATEST: ${{ steps.latest.outputs.publish_latest }} RELEASE_TAG: ${{ github.event.release.tag_name }} RELEASE_TARGET: ${{ github.event.release.target_commitish }} EXPECTED_SOURCE_SHA: ${{ needs.provenance.outputs.source_sha }} @@ -327,3 +341,7 @@ jobs: "release_ref_type=$EXPECTED_RELEASE_REF_TYPE" > "$expected" node "$RUNNER_TEMP/release-control/release-provenance.mjs" > "$actual" diff --unified "$expected" "$actual" + if [[ "$PUBLISH_LATEST" == "true" ]]; then + current_latest_release_tag="$(gh api "repos/$GITHUB_REPOSITORY/releases/latest" --jq .tag_name)" + [[ "$current_latest_release_tag" == "$RELEASE_TAG" ]] + fi diff --git a/ui/release_provenance_test.js b/ui/release_provenance_test.js index b5b94e4..cd2331b 100644 --- a/ui/release_provenance_test.js +++ b/ui/release_provenance_test.js @@ -219,28 +219,56 @@ describe("registry immutability guard", function () { }); describe("OCI archive policy", function () { - const descriptor = (os, architecture) => ({ platform: { architecture, os } }); + const descriptor = (os, architecture, digestCharacter) => ({ + digest: `sha256:${digestCharacter.repeat(64)}`, + platform: { architecture, os }, + }); + const attestation = (digestCharacter, subjectCharacter) => ({ + annotations: { + "vnd.docker.reference.digest": `sha256:${subjectCharacter.repeat(64)}`, + "vnd.docker.reference.type": "attestation-manifest", + }, + digest: `sha256:${digestCharacter.repeat(64)}`, + platform: { architecture: "unknown", os: "unknown" }, + }); test("requires one amd64 and one arm64 Linux image", function () { expect(() => assertPlatformIndex({ manifests: [ - descriptor("linux", "amd64"), - descriptor("linux", "arm64"), - descriptor("unknown", "unknown"), + descriptor("linux", "amd64", "a"), + descriptor("linux", "arm64", "b"), + attestation("c", "a"), + attestation("d", "b"), ], }), ).not.toThrow(); }); test.each([ - [descriptor("linux", "amd64"), descriptor("linux", "amd64")], - [descriptor("linux", "amd64")], - [descriptor("linux", "amd64"), descriptor("linux", "s390x")], + [descriptor("linux", "amd64", "a"), descriptor("linux", "amd64", "b")], + [descriptor("linux", "amd64", "a")], + [descriptor("linux", "amd64", "a"), descriptor("linux", "s390x", "b")], ])("rejects the invalid platform set %#", (...manifests) => { expect(() => assertPlatformIndex({ manifests })).toThrow( "not linux/amd64 and linux/arm64", ); }); + test.each([ + [ + descriptor("linux", "amd64", "a"), + descriptor("linux", "arm64", "b"), + { digest: `sha256:${"c".repeat(64)}` }, + ], + [ + descriptor("linux", "amd64", "a"), + descriptor("linux", "arm64", "b"), + attestation("c", "e"), + ], + ])("rejects an unrelated extra descriptor %#", (...manifests) => { + expect(() => assertPlatformIndex({ manifests })).toThrow( + /invalid manifest descriptor|attestations do not match/u, + ); + }); }); describe("release publisher serialization", function () { @@ -278,6 +306,10 @@ describe("release publisher serialization", function () { expect(releaseWorkflow).toContain( '"$current_latest_release_tag" != "$RELEASE_TAG"', ); + expect(releaseWorkflow).toContain("revalidate_source"); + expect(releaseWorkflow).toContain( + '"$current_latest_release_tag" == "$RELEASE_TAG"', + ); expect(releaseWorkflow).toContain("Verify every published tag"); expect(releaseWorkflow).toContain("tar --extract --to-stdout"); expect(releaseWorkflow).not.toContain( From d0a5004364d965a8d4c07c0f2d436b98b642cdb8 Mon Sep 17 00:00:00 2001 From: Snuffy2 Date: Sun, 6 Sep 2026 22:50:12 -0400 Subject: [PATCH 3/3] ci: harden release metadata and provenance checks --- .github/workflows/release.yml | 6 ++++-- ui/release_provenance_test.js | 30 ++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index aae4f36..b743d46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,7 +47,7 @@ jobs: # Policy code comes from the current trusted default branch. The # separately resolved event SHA remains the only image build source. ref: ${{ github.event.repository.default_branch }} - fetch-depth: 1 + fetch-depth: 0 persist-credentials: false - id: source env: @@ -125,7 +125,7 @@ jobs: - uses: actions/checkout@v7 with: ref: ${{ github.event.repository.default_branch }} - fetch-depth: 1 + fetch-depth: 0 persist-credentials: false - uses: actions/download-artifact@v8 with: @@ -179,6 +179,8 @@ jobs: uses: docker/metadata-action@v6 with: images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + flavor: | + latest=false tags: | type=edge,branch=main,enable=${{ github.event_name == 'push' }} type=semver,pattern={{version}},value=${{ github.event.release.tag_name }},enable=${{ github.event_name == 'release' }} diff --git a/ui/release_provenance_test.js b/ui/release_provenance_test.js index cd2331b..17423d7 100644 --- a/ui/release_provenance_test.js +++ b/ui/release_provenance_test.js @@ -269,6 +269,34 @@ describe("OCI archive policy", function () { /invalid manifest descriptor|attestations do not match/u, ); }); + test("requires a manifest index", function () { + expect(() => assertPlatformIndex({})).toThrow("missing a manifest index"); + }); + test.each([ + { + annotations: { "vnd.docker.reference.type": "attestation-manifest" }, + digest: `sha256:${"c".repeat(64)}`, + platform: { architecture: "unknown", os: "unknown" }, + }, + { + annotations: { + "vnd.docker.reference.digest": `sha256:${"a".repeat(64)}`, + "vnd.docker.reference.type": "sbom", + }, + digest: `sha256:${"c".repeat(64)}`, + platform: { architecture: "unknown", os: "unknown" }, + }, + ])("rejects an invalid attestation descriptor %#", (extra) => { + expect(() => + assertPlatformIndex({ + manifests: [ + descriptor("linux", "amd64", "a"), + descriptor("linux", "arm64", "b"), + extra, + ], + }), + ).toThrow("invalid attestation descriptor"); + }); }); describe("release publisher serialization", function () { @@ -290,6 +318,7 @@ describe("release publisher serialization", function () { expect(releaseWorkflow).toContain( "ref: ${{ github.event.repository.default_branch }}", ); + expect(releaseWorkflow.match(/fetch-depth: 0/gu)).toHaveLength(2); expect(releaseWorkflow).toContain("include-hidden-files: true"); expect(releaseWorkflow).toContain("https://$REGISTRY/token"); expect(releaseWorkflow).toContain("Authorization: Bearer $bearer"); @@ -300,6 +329,7 @@ describe("release publisher serialization", function () { expect(releaseWorkflow).toContain( "steps.latest.outputs.publish_latest == 'true'", ); + expect(releaseWorkflow).toContain("flavor: |\n latest=false"); expect(releaseWorkflow).toContain( 'gh api "repos/$GITHUB_REPOSITORY/releases/latest"', );