From 11efa349b2bc6c2e63d4f0a91d0cf317e501c67f Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:16:45 -0700 Subject: [PATCH 1/2] fix(release): separate site control from source --- .github/workflows/deploy-site.yml | 34 +++++++++--- .github/workflows/release.yml | 2 + scripts/check-release-consistency.mjs | 63 +++++++++++++++++----- scripts/check-release-consistency.test.mjs | 15 ++++-- scripts/dispatch-site-deployment.mjs | 40 ++++++++++---- scripts/dispatch-site-deployment.test.mjs | 19 +++++-- 6 files changed, 133 insertions(+), 40 deletions(-) diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index f294bfa1..982641bf 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -27,6 +27,14 @@ on: description: Published release tag whose immutable source must be deployed. required: true type: string + release_commit: + description: Immutable commit resolved from the published release tag. + required: true + type: string + control_sha: + description: Trusted main commit that dispatched this deployment. + required: true + type: string dispatch_nonce: description: Unique release-workflow dispatch identity. required: true @@ -90,7 +98,7 @@ jobs: run: pnpm deploy:demo deploy: - if: ${{ github.event_name != 'workflow_dispatch' || startsWith(github.ref, 'refs/tags/') }} + if: ${{ github.event_name != 'workflow_dispatch' || github.ref == 'refs/heads/main' }} runs-on: ubuntu-24.04 timeout-minutes: 50 environment: @@ -157,6 +165,8 @@ jobs: env: EVENT_NAME: ${{ github.event_name }} GH_TOKEN: ${{ github.token }} + REQUESTED_CONTROL_SHA: ${{ inputs.control_sha }} + REQUESTED_RELEASE_COMMIT: ${{ inputs.release_commit }} TRUSTED_SHA: ${{ steps.source.outputs.trusted_sha }} REQUESTED_RELEASE_TAG: ${{ inputs.release_tag }} TRUSTED_VERSION: ${{ steps.source.outputs.version }} @@ -169,8 +179,16 @@ jobs: echo "release_tag must be the current release ${expected_tag}" >&2 exit 1 fi - if [[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]; then - echo "workflow_dispatch must run from the immutable release tag ${expected_tag}" >&2 + if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then + echo "workflow_dispatch must run from the trusted main branch" >&2 + exit 1 + fi + if [[ ! "$REQUESTED_CONTROL_SHA" =~ ^[0-9a-f]{40}$ || "$REQUESTED_CONTROL_SHA" != "$TRUSTED_SHA" ]]; then + echo "control_sha must match the trusted workflow source" >&2 + exit 1 + fi + if [[ ! "$REQUESTED_RELEASE_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + echo "release_commit must be an immutable commit SHA" >&2 exit 1 fi release_tag="$REQUESTED_RELEASE_TAG" @@ -188,11 +206,12 @@ jobs: exit 1 fi if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - if [[ "$source_sha" != "$TRUSTED_SHA" ]]; then - echo "release tag moved after this immutable deployment was dispatched" >&2 + if [[ "$source_sha" != "$REQUESTED_RELEASE_COMMIT" ]]; then + echo "release tag no longer resolves to the requested immutable commit" >&2 exit 1 fi - elif ! git merge-base --is-ancestor "$source_sha" "$TRUSTED_SHA"; then + fi + if ! git merge-base --is-ancestor "$source_sha" "$TRUSTED_SHA"; then echo "release tag source is not reachable from the trusted workflow source" >&2 exit 1 fi @@ -203,10 +222,12 @@ jobs: uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: ref: ${{ steps.immutable_source.outputs.source_sha }} + path: .release-source persist-credentials: false - name: Install dependencies if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} + working-directory: .release-source run: pnpm install --frozen-lockfile - name: Authenticate to AWS with GitHub OIDC @@ -226,6 +247,7 @@ jobs: - name: Build and deploy immutable release site if: ${{ steps.published_release.outcome == 'success' || steps.existing_release.outcome == 'success' }} + working-directory: .release-source env: GH_TOKEN: ${{ github.token }} T4_SITE_BUCKET: ${{ vars.T4_SITE_BUCKET }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9266c669..e3fcf720 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -490,9 +490,11 @@ jobs: - name: Dispatch and await the exact immutable production deployment env: + CONTROL_SHA: ${{ github.sha }} GH_TOKEN: ${{ github.token }} SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} run: >- node scripts/dispatch-site-deployment.mjs --tag "$RELEASE_TAG" --commit "$SOURCE_SHA" + --control-commit "$CONTROL_SHA" diff --git a/scripts/check-release-consistency.mjs b/scripts/check-release-consistency.mjs index ed9a8fe9..4e744021 100644 --- a/scripts/check-release-consistency.mjs +++ b/scripts/check-release-consistency.mjs @@ -194,6 +194,12 @@ function requireAnyText(text, expectedValues, path, errors) { } } +function requireOneVariant(text, variants, path, errors) { + if (!variants.some((variant) => variant.every((expected) => text.includes(expected)))) { + errors.push(`${path} does not match a supported release-control variant`); + } +} + function extractWorkflowJob(source, jobName, errors) { const lines = source.split(/\r?\n/u); const header = ` ${jobName}:`; @@ -1313,21 +1319,33 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { errors, ); } + const siteDispatcher = files.get("scripts/dispatch-site-deployment.mjs") ?? ""; for (const expected of [ "dispatchAndWaitForSiteDeployment", - "body: { ref: tag, inputs: { release_tag: tag, dispatch_nonce: dispatchNonce } }", - "run.head_branch === tag", - "run.head_sha === commit", "run.display_title === `Deploy project site ${tag} ${dispatchNonce}`", 'exact.conclusion !== "success"', ]) { - requireText( - files.get("scripts/dispatch-site-deployment.mjs") ?? "", - expected, - "scripts/dispatch-site-deployment.mjs", - errors, - ); + requireText(siteDispatcher, expected, "scripts/dispatch-site-deployment.mjs", errors); } + requireOneVariant( + siteDispatcher, + [ + [ + "body: { ref: tag, inputs: { release_tag: tag, dispatch_nonce: dispatchNonce } }", + "run.head_branch === tag", + "run.head_sha === commit", + ], + [ + "ref: CONTROL_BRANCH", + "release_commit: commit", + "control_sha: controlCommit", + "run.head_branch === CONTROL_BRANCH", + "run.head_sha === controlCommit", + ], + ], + "scripts/dispatch-site-deployment.mjs", + errors, + ); if (releaseWorkflow.includes("ref: ${{ env.RELEASE_TAG }}")) { errors.push( ".github/workflows/release.yml must build from the verified immutable source SHA, not env.RELEASE_TAG", @@ -1357,21 +1375,40 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { ".github/workflows/deploy-site.yml", errors, ); + const deploySiteWorkflow = files.get(".github/workflows/deploy-site.yml") ?? ""; for (const expected of [ "run-name: Deploy project site ${{ inputs.release_tag || github.ref_name }} ${{ inputs.dispatch_nonce || github.sha }}", - "startsWith(github.ref, 'refs/tags/')", "dispatch_nonce:", - '[[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]', - '[[ "$source_sha" != "$TRUSTED_SHA" ]]', 'git merge-base --is-ancestor "$source_sha" "$TRUSTED_SHA"', ]) { requireText( - files.get(".github/workflows/deploy-site.yml") ?? "", + deploySiteWorkflow, expected, ".github/workflows/deploy-site.yml", errors, ); } + requireOneVariant( + deploySiteWorkflow, + [ + [ + "startsWith(github.ref, 'refs/tags/')", + '[[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]', + '[[ "$source_sha" != "$TRUSTED_SHA" ]]', + ], + [ + "github.ref == 'refs/heads/main'", + "release_commit:", + "control_sha:", + '[[ "$GITHUB_REF" != "refs/heads/main" ]]', + '[[ "$source_sha" != "$REQUESTED_RELEASE_COMMIT" ]]', + "path: .release-source", + "working-directory: .release-source", + ], + ], + ".github/workflows/deploy-site.yml", + errors, + ); requireText( files.get(".github/workflows/deploy-site.yml") ?? "", "ref: ${{ steps.immutable_source.outputs.source_sha }}", diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index d574d048..5ce8cbfd 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -363,7 +363,7 @@ test("rejects updater channel, stable manifest, and publication-contract drift", ], [ "scripts/dispatch-site-deployment.mjs", - (text) => text.replace("body: { ref: tag", 'body: { ref: "main"'), + (text) => text.replace("ref: CONTROL_BRANCH", 'ref: "untrusted"'), ], [ "scripts/wait-for-exact-ci.mjs", @@ -376,7 +376,7 @@ test("rejects updater channel, stable manifest, and publication-contract drift", [ ".github/workflows/deploy-site.yml", (text) => - text.replace("startsWith(github.ref, 'refs/tags/')", "github.ref == 'refs/heads/main'"), + text.replace("github.ref == 'refs/heads/main'", "github.ref == 'refs/tags/untrusted'"), ], ]; for (const [path, replace] of cases) { @@ -793,6 +793,7 @@ test("deploys release site source only after artifact publication", () => { assert.ok(releaseWorkflow.includes("node scripts/dispatch-site-deployment.mjs")); assert.ok(releaseWorkflow.includes('--tag "$RELEASE_TAG"')); assert.ok(releaseWorkflow.includes('--commit "$SOURCE_SHA"')); + assert.ok(releaseWorkflow.includes('--control-commit "$CONTROL_SHA"')); assert.ok(!releaseWorkflow.includes("gh workflow run deploy-site.yml")); const dispatchSite = releaseWorkflow.slice(releaseWorkflow.indexOf(" dispatch-site:")); assert.ok(dispatchSite.includes("ref: ${{ github.sha }}")); @@ -811,16 +812,20 @@ test("deploys release site source only after artifact publication", () => { assert.ok(deployWorkflow.includes("workflow_dispatch:")); assert.ok(deployWorkflow.includes("release_tag:")); + assert.ok(deployWorkflow.includes("release_commit:")); + assert.ok(deployWorkflow.includes("control_sha:")); assert.ok(deployWorkflow.includes("dispatch_nonce:")); assert.ok(deployWorkflow.includes("inputs.dispatch_nonce || github.sha")); - assert.ok(deployWorkflow.includes("startsWith(github.ref, 'refs/tags/')")); - assert.ok(deployWorkflow.includes('[[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]')); + assert.ok(deployWorkflow.includes("github.ref == 'refs/heads/main'")); + assert.ok(deployWorkflow.includes('[[ "$GITHUB_REF" != "refs/heads/main" ]]')); assert.ok(deployWorkflow.includes('expected_tag="v${TRUSTED_VERSION}"')); assert.ok(deployWorkflow.includes('release_tag="$expected_tag"')); assert.ok(deployWorkflow.includes("releases/tags/${release_tag}")); - assert.ok(deployWorkflow.includes('[[ "$source_sha" != "$TRUSTED_SHA" ]]')); + assert.ok(deployWorkflow.includes('[[ "$source_sha" != "$REQUESTED_RELEASE_COMMIT" ]]')); assert.ok(deployWorkflow.includes('git merge-base --is-ancestor "$source_sha" "$TRUSTED_SHA"')); assert.ok(deployWorkflow.includes("ref: ${{ steps.immutable_source.outputs.source_sha }}")); + assert.ok(deployWorkflow.includes("path: .release-source")); + assert.ok(deployWorkflow.includes("working-directory: .release-source")); assert.ok(!deployWorkflow.includes('source_sha="$MAIN_SHA"')); assert.ok(!deployWorkflow.includes("cache: pnpm")); assert.ok( diff --git a/scripts/dispatch-site-deployment.mjs b/scripts/dispatch-site-deployment.mjs index 89d9396d..ad9dc533 100644 --- a/scripts/dispatch-site-deployment.mjs +++ b/scripts/dispatch-site-deployment.mjs @@ -7,6 +7,7 @@ import { readBoundedResponseBytes } from "./read-bounded-response.mjs"; const REPOSITORY = "LycaonLLC/t4-code"; const WORKFLOW = "deploy-site.yml"; const WORKFLOW_PATH = `.github/workflows/${WORKFLOW}`; +const CONTROL_BRANCH = "main"; const VERSION_TAG_PATTERN = /^v\d+\.\d+\.\d+$/u; const COMMIT_PATTERN = /^[0-9a-f]{40}$/u; const DISPATCH_NONCE_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/u; @@ -59,7 +60,7 @@ async function apiJson(url, options) { } } -function exactRuns(payload, commit, tag, dispatchNonce) { +function exactRuns(payload, controlCommit, tag, dispatchNonce) { if (!payload || typeof payload !== "object" || !Array.isArray(payload.workflow_runs)) { throw new Error("GitHub workflow run list was malformed"); } @@ -70,8 +71,8 @@ function exactRuns(payload, commit, tag, dispatchNonce) { run.id > 0 && run.path === WORKFLOW_PATH && run.event === "workflow_dispatch" && - run.head_sha === commit && - run.head_branch === tag && + run.head_sha === controlCommit && + run.head_branch === CONTROL_BRANCH && run.display_title === `Deploy project site ${tag} ${dispatchNonce}`, ); } @@ -79,6 +80,7 @@ function exactRuns(payload, commit, tag, dispatchNonce) { export async function dispatchAndWaitForSiteDeployment({ tag, commit, + controlCommit, token, fetchImpl = fetch, sleep = (milliseconds) => new Promise((resolveSleep) => setTimeout(resolveSleep, milliseconds)), @@ -90,16 +92,19 @@ export async function dispatchAndWaitForSiteDeployment({ }) { if (!VERSION_TAG_PATTERN.test(tag)) throw new Error("tag must be vX.Y.Z"); if (!COMMIT_PATTERN.test(commit)) throw new Error("commit must be a lowercase 40-character SHA"); + if (!COMMIT_PATTERN.test(controlCommit)) { + throw new Error("controlCommit must be a lowercase 40-character SHA"); + } if (!DISPATCH_NONCE_PATTERN.test(dispatchNonce)) throw new Error("dispatchNonce must be a UUIDv4"); positiveInteger(pollIntervalMs, "pollIntervalMs"); positiveInteger(creationTimeoutMs, "creationTimeoutMs"); positiveInteger(completionTimeoutMs, "completionTimeoutMs"); const workflowUrl = `https://api.github.com/repos/${REPOSITORY}/actions/workflows/${WORKFLOW}`; - const runsUrl = `${workflowUrl}/runs?event=workflow_dispatch&head_sha=${commit}&per_page=100`; + const runsUrl = `${workflowUrl}/runs?event=workflow_dispatch&head_sha=${controlCommit}&per_page=100`; const before = exactRuns( await apiJson(runsUrl, { token, fetchImpl }), - commit, + controlCommit, tag, dispatchNonce, ); @@ -109,7 +114,15 @@ export async function dispatchAndWaitForSiteDeployment({ token, fetchImpl, method: "POST", - body: { ref: tag, inputs: { release_tag: tag, dispatch_nonce: dispatchNonce } }, + body: { + ref: CONTROL_BRANCH, + inputs: { + release_tag: tag, + release_commit: commit, + control_sha: controlCommit, + dispatch_nonce: dispatchNonce, + }, + }, }); if (dispatch.status !== 204) { throw new Error(`GitHub workflow dispatch returned HTTP ${dispatch.status}`); @@ -120,7 +133,7 @@ export async function dispatchAndWaitForSiteDeployment({ while (now() <= creationDeadline) { const runs = exactRuns( await apiJson(runsUrl, { token, fetchImpl }), - commit, + controlCommit, tag, dispatchNonce, ); @@ -128,13 +141,15 @@ export async function dispatchAndWaitForSiteDeployment({ if (run) break; await sleep(pollIntervalMs); } - if (!run) throw new Error(`GitHub did not create an exact ${WORKFLOW} run for ${commit}`); + if (!run) { + throw new Error(`GitHub did not create an exact ${WORKFLOW} run for ${controlCommit}`); + } const completionDeadline = now() + completionTimeoutMs; const runUrl = `https://api.github.com/repos/${REPOSITORY}/actions/runs/${run.id}`; while (now() <= completionDeadline) { const current = await apiJson(runUrl, { token, fetchImpl }); - const exact = exactRuns({ workflow_runs: [current] }, commit, tag, dispatchNonce)[0]; + const exact = exactRuns({ workflow_runs: [current] }, controlCommit, tag, dispatchNonce)[0]; if (!exact || exact.id !== run.id) throw new Error("GitHub site deployment run changed identity"); if (exact.status === "completed") { if (exact.conclusion !== "success") { @@ -155,10 +170,13 @@ function parseArguments(args) { if (!value) throw new Error(`missing value for ${flag ?? "argument"}`); if (flag === "--tag") options.tag = value; else if (flag === "--commit") options.commit = value; + else if (flag === "--control-commit") options.controlCommit = value; else throw new Error(`unknown argument ${flag}`); } - if (!options.tag || !options.commit) { - throw new Error("usage: dispatch-site-deployment.mjs --tag vX.Y.Z --commit SHA"); + if (!options.tag || !options.commit || !options.controlCommit) { + throw new Error( + "usage: dispatch-site-deployment.mjs --tag vX.Y.Z --commit SHA --control-commit SHA", + ); } return options; } diff --git a/scripts/dispatch-site-deployment.test.mjs b/scripts/dispatch-site-deployment.test.mjs index 4b6fe0f5..d9b09aaf 100644 --- a/scripts/dispatch-site-deployment.test.mjs +++ b/scripts/dispatch-site-deployment.test.mjs @@ -5,6 +5,7 @@ import { dispatchAndWaitForSiteDeployment } from "./dispatch-site-deployment.mjs const tag = "v0.1.17"; const commit = "a".repeat(40); +const controlCommit = "b".repeat(40); const dispatchNonce = "11111111-1111-4111-8111-111111111111"; function run(id, overrides = {}) { @@ -13,8 +14,8 @@ function run(id, overrides = {}) { name: `Deploy project site ${tag} ${dispatchNonce}`, path: ".github/workflows/deploy-site.yml", event: "workflow_dispatch", - head_sha: commit, - head_branch: tag, + head_sha: controlCommit, + head_branch: "main", display_title: `Deploy project site ${tag} ${dispatchNonce}`, status: "queued", conclusion: null, @@ -34,6 +35,7 @@ test("dispatches the site workflow at the immutable tag and awaits its exact suc const result = await dispatchAndWaitForSiteDeployment({ tag, commit, + controlCommit, token: "test-token", dispatchNonce, pollIntervalMs: 1, @@ -56,8 +58,13 @@ test("dispatches the site workflow at the immutable tag and awaits its exact suc assert.equal(result.runId, 22); const dispatch = requests.find(({ url }) => url.endsWith("/dispatches")); assert.deepEqual(JSON.parse(dispatch.init.body), { - ref: tag, - inputs: { release_tag: tag, dispatch_nonce: dispatchNonce }, + ref: "main", + inputs: { + release_tag: tag, + release_commit: commit, + control_sha: controlCommit, + dispatch_nonce: dispatchNonce, + }, }); }); @@ -67,6 +74,7 @@ test("fails the release when the exact site deployment run fails", async () => { dispatchAndWaitForSiteDeployment({ tag, commit, + controlCommit, token: "test-token", dispatchNonce, pollIntervalMs: 1, @@ -90,6 +98,7 @@ test("ignores mutable-main and independently dispatched runs at the release comm dispatchAndWaitForSiteDeployment({ tag, commit, + controlCommit, token: "test-token", dispatchNonce, pollIntervalMs: 1, @@ -100,7 +109,7 @@ test("ignores mutable-main and independently dispatched runs at the release comm if (url.endsWith("/dispatches")) return new Response(null, { status: 204 }); return json({ workflow_runs: [ - run(22, { head_branch: "main" }), + run(22, { head_branch: tag }), run(23, { display_title: `Deploy project site ${tag} 22222222-2222-4222-8222-222222222222`, }), From 36d4c596a90d61fe2c37abeacdf32bdf1d467dcc Mon Sep 17 00:00:00 2001 From: Wolfgang Schoenberger <221313372+wolfiesch@users.noreply.github.com> Date: Thu, 23 Jul 2026 21:27:32 -0700 Subject: [PATCH 2/2] fix(release): preserve repair identity boundaries --- .github/workflows/deploy-site.yml | 42 +++++++++++++++------- .github/workflows/release.yml | 15 +++++++- scripts/check-release-consistency.mjs | 25 +++++++++---- scripts/check-release-consistency.test.mjs | 15 ++++++-- 4 files changed, 75 insertions(+), 22 deletions(-) diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index 982641bf..db818d6b 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -122,6 +122,27 @@ jobs: version=$(node -p "require('./package.json').version") printf 'version=%s\ntrusted_sha=%s\n' "$version" "$TRUSTED_SHA" >> "$GITHUB_OUTPUT" + - name: Resolve requested release identity + id: release_identity + shell: bash + env: + EVENT_NAME: ${{ github.event_name }} + REQUESTED_RELEASE_TAG: ${{ inputs.release_tag }} + TRUSTED_VERSION: ${{ steps.source.outputs.version }} + run: | + set -euo pipefail + release_tag="v${TRUSTED_VERSION}" + release_version="$TRUSTED_VERSION" + if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then + if [[ ! "$REQUESTED_RELEASE_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "release_tag must be vX.Y.Z" >&2 + exit 1 + fi + release_tag="$REQUESTED_RELEASE_TAG" + release_version="${release_tag#v}" + fi + printf 'release_tag=%s\nrelease_version=%s\n' "$release_tag" "$release_version" >> "$GITHUB_OUTPUT" + - name: Install pnpm uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4 with: @@ -136,7 +157,7 @@ jobs: id: published_release if: ${{ github.event_name == 'workflow_dispatch' }} env: - RELEASE_VERSION: ${{ steps.source.outputs.version }} + RELEASE_VERSION: ${{ steps.release_identity.outputs.release_version }} run: node scripts/wait-for-release-assets.mjs --version "$RELEASE_VERSION" --timeout-ms 2400000 --interval-ms 15000 - name: Classify the stable release referenced by an ordinary main push @@ -144,14 +165,14 @@ jobs: if: ${{ github.event_name == 'push' }} env: GH_TOKEN: ${{ github.token }} - RELEASE_VERSION: ${{ steps.source.outputs.version }} + RELEASE_VERSION: ${{ steps.release_identity.outputs.release_version }} run: node scripts/check-release-publication.mjs --version "$RELEASE_VERSION" --github-output "$GITHUB_OUTPUT" - name: Confirm assets for an existing stable release id: existing_release if: ${{ github.event_name == 'push' && steps.release_state.outputs.state == 'published' }} env: - RELEASE_VERSION: ${{ steps.source.outputs.version }} + RELEASE_VERSION: ${{ steps.release_identity.outputs.release_version }} run: node scripts/wait-for-release-assets.mjs --version "$RELEASE_VERSION" --timeout-ms 15000 --interval-ms 3000 - name: Defer a release-version site update until publication @@ -167,18 +188,13 @@ jobs: GH_TOKEN: ${{ github.token }} REQUESTED_CONTROL_SHA: ${{ inputs.control_sha }} REQUESTED_RELEASE_COMMIT: ${{ inputs.release_commit }} + RELEASE_TAG: ${{ steps.release_identity.outputs.release_tag }} + RELEASE_VERSION: ${{ steps.release_identity.outputs.release_version }} TRUSTED_SHA: ${{ steps.source.outputs.trusted_sha }} - REQUESTED_RELEASE_TAG: ${{ inputs.release_tag }} - TRUSTED_VERSION: ${{ steps.source.outputs.version }} run: | set -euo pipefail - expected_tag="v${TRUSTED_VERSION}" - release_tag="$expected_tag" + release_tag="$RELEASE_TAG" if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then - if [[ "$REQUESTED_RELEASE_TAG" != "$expected_tag" ]]; then - echo "release_tag must be the current release ${expected_tag}" >&2 - exit 1 - fi if [[ "$GITHUB_REF" != "refs/heads/main" ]]; then echo "workflow_dispatch must run from the trusted main branch" >&2 exit 1 @@ -201,8 +217,8 @@ jobs: git fetch --force origin "refs/tags/${release_tag}:refs/tags/${release_tag}" source_sha=$(git rev-parse "${release_tag}^{commit}") tag_version=$(git show "${source_sha}:package.json" | jq -er '.version') - if [[ "$tag_version" != "$TRUSTED_VERSION" ]]; then - echo "release tag package version ${tag_version} does not match trusted source ${TRUSTED_VERSION}" >&2 + if [[ "$tag_version" != "$RELEASE_VERSION" ]]; then + echo "release tag package version ${tag_version} does not match requested version ${RELEASE_VERSION}" >&2 exit 1 fi if [[ "$EVENT_NAME" == "workflow_dispatch" ]]; then diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e3fcf720..daa19a4d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -488,9 +488,22 @@ jobs: with: node-version: 24.13.1 + - name: Resolve trusted site-control source + id: site-control + shell: bash + run: | + set -euo pipefail + git fetch --force --no-tags origin "refs/heads/main:refs/remotes/origin/main" + control_sha=$(git rev-parse refs/remotes/origin/main) + if [[ ! "$control_sha" =~ ^[0-9a-f]{40}$ ]]; then + echo "main must resolve to an immutable commit SHA" >&2 + exit 1 + fi + printf 'control_sha=%s\n' "$control_sha" >> "$GITHUB_OUTPUT" + - name: Dispatch and await the exact immutable production deployment env: - CONTROL_SHA: ${{ github.sha }} + CONTROL_SHA: ${{ steps.site-control.outputs.control_sha }} GH_TOKEN: ${{ github.token }} SOURCE_SHA: ${{ needs.verify.outputs.source_sha }} run: >- diff --git a/scripts/check-release-consistency.mjs b/scripts/check-release-consistency.mjs index 4e744021..8669d0da 100644 --- a/scripts/check-release-consistency.mjs +++ b/scripts/check-release-consistency.mjs @@ -1207,6 +1207,20 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { ]) { requireText(releaseWorkflow, expected, ".github/workflows/release.yml", errors); } + if ( + (files.get("scripts/dispatch-site-deployment.mjs") ?? "").includes( + "control_sha: controlCommit", + ) + ) { + for (const expected of [ + "Resolve trusted site-control source", + "git rev-parse refs/remotes/origin/main", + "CONTROL_SHA: ${{ steps.site-control.outputs.control_sha }}", + '--control-commit "$CONTROL_SHA"', + ]) { + requireText(releaseWorkflow, expected, ".github/workflows/release.yml", errors); + } + } const releaseVerifyStart = releaseWorkflow.indexOf(" verify:"); const releaseAuthorityStart = releaseWorkflow.indexOf(" ci-authority:"); if (!(releaseVerifyStart >= 0 && releaseAuthorityStart > releaseVerifyStart)) { @@ -1395,13 +1409,18 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { "startsWith(github.ref, 'refs/tags/')", '[[ "$GITHUB_REF" != "refs/tags/${expected_tag}" ]]', '[[ "$source_sha" != "$TRUSTED_SHA" ]]', + 'release_tag="$expected_tag"', ], [ "github.ref == 'refs/heads/main'", "release_commit:", "control_sha:", + "Resolve requested release identity", + 'release_version="${release_tag#v}"', + "RELEASE_VERSION: ${{ steps.release_identity.outputs.release_version }}", '[[ "$GITHUB_REF" != "refs/heads/main" ]]', '[[ "$source_sha" != "$REQUESTED_RELEASE_COMMIT" ]]', + 'release_tag="$RELEASE_TAG"', "path: .release-source", "working-directory: .release-source", ], @@ -1415,12 +1434,6 @@ export function collectReleaseConsistencyErrors(files, releaseTag) { ".github/workflows/deploy-site.yml", errors, ); - requireText( - files.get(".github/workflows/deploy-site.yml") ?? "", - 'release_tag="$expected_tag"', - ".github/workflows/deploy-site.yml", - errors, - ); if ((files.get(".github/workflows/deploy-site.yml") ?? "").includes('source_sha="$MAIN_SHA"')) { errors.push( ".github/workflows/deploy-site.yml must deploy the published release tag, not a same-version main SHA", diff --git a/scripts/check-release-consistency.test.mjs b/scripts/check-release-consistency.test.mjs index 5ce8cbfd..1a7d8c1d 100644 --- a/scripts/check-release-consistency.test.mjs +++ b/scripts/check-release-consistency.test.mjs @@ -794,6 +794,11 @@ test("deploys release site source only after artifact publication", () => { assert.ok(releaseWorkflow.includes('--tag "$RELEASE_TAG"')); assert.ok(releaseWorkflow.includes('--commit "$SOURCE_SHA"')); assert.ok(releaseWorkflow.includes('--control-commit "$CONTROL_SHA"')); + assert.ok(releaseWorkflow.includes("Resolve trusted site-control source")); + assert.ok(releaseWorkflow.includes("git rev-parse refs/remotes/origin/main")); + assert.ok( + releaseWorkflow.includes("CONTROL_SHA: ${{ steps.site-control.outputs.control_sha }}"), + ); assert.ok(!releaseWorkflow.includes("gh workflow run deploy-site.yml")); const dispatchSite = releaseWorkflow.slice(releaseWorkflow.indexOf(" dispatch-site:")); assert.ok(dispatchSite.includes("ref: ${{ github.sha }}")); @@ -818,8 +823,14 @@ test("deploys release site source only after artifact publication", () => { assert.ok(deployWorkflow.includes("inputs.dispatch_nonce || github.sha")); assert.ok(deployWorkflow.includes("github.ref == 'refs/heads/main'")); assert.ok(deployWorkflow.includes('[[ "$GITHUB_REF" != "refs/heads/main" ]]')); - assert.ok(deployWorkflow.includes('expected_tag="v${TRUSTED_VERSION}"')); - assert.ok(deployWorkflow.includes('release_tag="$expected_tag"')); + assert.ok(deployWorkflow.includes("Resolve requested release identity")); + assert.ok(deployWorkflow.includes('release_version="${release_tag#v}"')); + assert.ok( + deployWorkflow.includes( + "RELEASE_VERSION: ${{ steps.release_identity.outputs.release_version }}", + ), + ); + assert.ok(!deployWorkflow.includes('release tag must be the current release')); assert.ok(deployWorkflow.includes("releases/tags/${release_tag}")); assert.ok(deployWorkflow.includes('[[ "$source_sha" != "$REQUESTED_RELEASE_COMMIT" ]]')); assert.ok(deployWorkflow.includes('git merge-base --is-ancestor "$source_sha" "$TRUSTED_SHA"'));