From 525d4d0b8f5cf87a5eec53b04be53c331f511559 Mon Sep 17 00:00:00 2001 From: TensorNull Date: Thu, 30 Jul 2026 00:22:30 +0800 Subject: [PATCH] fix: recover exact publish artifact --- .github/workflows/publish.yml | 152 +++++++++++++++++++-- RELEASING.md | 14 ++ scripts/release-workflow-validation.mjs | 86 ++++++++++++ tests/release-workflow-validation.test.mjs | 64 +++++++++ tests/workflow-contract.test.mjs | 24 +++- 5 files changed, 322 insertions(+), 18 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a0579a6..7de370a 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,6 +1,11 @@ name: Publish on: + push: + branches: + - main + paths: + - .github/workflows/publish.yml workflow_run: workflows: - Release Please @@ -20,11 +25,17 @@ jobs: name: Verify the immutable release artifact if: >- vars.RELEASE_PLEASE_ENABLED == 'true' && - github.event.workflow_run.conclusion == 'success' && - github.event.workflow_run.event == 'push' && - github.event.workflow_run.head_branch == 'main' + ((github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success' && + github.event.workflow_run.event == 'push' && + github.event.workflow_run.head_branch == 'main') || + (github.event_name == 'push' && github.ref == 'refs/heads/main')) runs-on: ubuntu-latest timeout-minutes: 30 + env: + SOURCE_RELEASE_COMMIT: ${{ github.event_name == 'push' && 'c98b514227858cd183c781270a7f78f65b577e82' || github.event.workflow_run.head_sha }} + SOURCE_RELEASE_RUN_ATTEMPT: ${{ github.event_name == 'push' && '1' || github.event.workflow_run.run_attempt }} + SOURCE_RELEASE_RUN_ID: ${{ github.event_name == 'push' && '30469181724' || github.event.workflow_run.id }} outputs: artifact-name: ${{ steps.artifact-name.outputs.name }} dist-tag: ${{ steps.version.outputs.dist-tag }} @@ -32,20 +43,82 @@ jobs: release-tag: ${{ steps.trust.outputs.release-tag }} version: ${{ steps.version.outputs.version }} steps: - - name: Check out the current main branch + - name: Check out the workflow control commit uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: fetch-depth: 0 persist-credentials: false - ref: refs/heads/main + ref: ${{ github.sha }} + - name: Validate the one-cycle exact release recovery + if: github.event_name == 'push' + env: + ACTOR: ${{ github.actor }} + CHANGED_FILES: ${{ runner.temp }}/publish-recovery-files + EVENT_AFTER: ${{ github.event.after }} + EVENT_BEFORE: ${{ github.event.before }} + EVENT_NAME: ${{ github.event_name }} + EVENT_REF: ${{ github.ref }} + MAIN_COMMIT: ${{ github.sha }} + WORKFLOW_RUN_ATTEMPT: ${{ github.run_attempt }} + shell: bash + run: | + set -euo pipefail + if [[ "$(git rev-parse HEAD)" != "$MAIN_COMMIT" ]]; then + echo "The recovery control checkout does not match the triggering SHA." >&2 + exit 1 + fi + git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main + if [[ "$(git rev-parse refs/remotes/origin/main)" != "$MAIN_COMMIT" ]]; then + echo "main moved after the publish recovery was triggered." >&2 + exit 1 + fi + git diff --name-only "$EVENT_BEFORE" "$MAIN_COMMIT" > "$CHANGED_FILES" + MAIN_FIRST_PARENT="$(git rev-parse "${MAIN_COMMIT}^1")" \ + node --input-type=module <<'EOF' + import { readFileSync } from "node:fs"; + import { validatePublishRecoveryTrigger } from "./scripts/release-workflow-validation.mjs"; + + validatePublishRecoveryTrigger({ + actor: process.env.ACTOR, + changedFiles: readFileSync(process.env.CHANGED_FILES, "utf8") + .split("\n") + .filter((file) => file !== ""), + eventAfter: process.env.EVENT_AFTER, + eventBefore: process.env.EVENT_BEFORE, + eventName: process.env.EVENT_NAME, + eventRef: process.env.EVENT_REF, + mainCommit: process.env.MAIN_COMMIT, + mainFirstParent: process.env.MAIN_FIRST_PARENT, + sourceReleaseCommit: process.env.SOURCE_RELEASE_COMMIT, + sourceRunAttempt: Number(process.env.SOURCE_RELEASE_RUN_ATTEMPT), + sourceRunId: Number(process.env.SOURCE_RELEASE_RUN_ID), + workflowRunAttempt: Number(process.env.WORKFLOW_RUN_ATTEMPT), + }); + EOF + - name: Check out the exact release commit + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + ref: ${{ env.SOURCE_RELEASE_COMMIT }} + - name: Read the exact Release Please source run + env: + GH_TOKEN: ${{ github.token }} + SOURCE_RUN_FILE: ${{ runner.temp }}/release-please-source-run.json + shell: bash + run: | + set -euo pipefail + gh api \ + "repos/${GITHUB_REPOSITORY}/actions/runs/${SOURCE_RELEASE_RUN_ID}" \ + > "$SOURCE_RUN_FILE" - name: Download the exact Release Please result uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 with: - name: release-please-result-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }} - path: release-please-result + name: release-please-result-${{ env.SOURCE_RELEASE_RUN_ID }}-${{ env.SOURCE_RELEASE_RUN_ATTEMPT }} + path: ${{ runner.temp }}/release-please-result github-token: ${{ github.token }} repository: ${{ github.repository }} - run-id: ${{ github.event.workflow_run.id }} + run-id: ${{ env.SOURCE_RELEASE_RUN_ID }} - name: Reject an untrusted Release Please workflow run id: trust env: @@ -54,8 +127,13 @@ jobs: EXPECTED_REPOSITORY_URL: git+https://github.com/cometapi-dev/cometapi-node.git EXPECTED_WORKFLOW: Release Please EXPECTED_WORKFLOW_PATH: .github/workflows/release-please.yml - RELEASE_RESULT: release-please-result/result.json - WORKFLOW_SHA: ${{ github.event.workflow_run.head_sha }} + CONTROL_SHA: ${{ github.sha }} + EVENT_NAME: ${{ github.event_name }} + RELEASE_RESULT: ${{ runner.temp }}/release-please-result/result.json + SOURCE_RUN_FILE: ${{ runner.temp }}/release-please-source-run.json + SOURCE_RUN_ATTEMPT: ${{ env.SOURCE_RELEASE_RUN_ATTEMPT }} + SOURCE_RUN_ID: ${{ env.SOURCE_RELEASE_RUN_ID }} + WORKFLOW_SHA: ${{ env.SOURCE_RELEASE_COMMIT }} shell: bash run: | set -euo pipefail @@ -66,8 +144,13 @@ jobs: fi git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main - if [[ "$(git rev-parse refs/remotes/origin/main)" != "$WORKFLOW_SHA" ]]; then - echo "origin/main moved after the successful Release Please run." >&2 + case "$EVENT_NAME" in + workflow_run) expected_main="$WORKFLOW_SHA" ;; + push) expected_main="$CONTROL_SHA" ;; + *) echo "Publish received an unsupported event." >&2; exit 1 ;; + esac + if [[ "$(git rev-parse refs/remotes/origin/main)" != "$expected_main" ]]; then + echo "origin/main moved after the trusted publish event." >&2 exit 1 fi @@ -78,13 +161,54 @@ jobs: validateReleaseWorkflowRun, } from "./scripts/release-workflow-validation.mjs"; - const event = JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")); - const run = validateReleaseWorkflowRun(event, { + const sourceRun = JSON.parse( + readFileSync(process.env.SOURCE_RUN_FILE, "utf8"), + ); + const sourceEvent = { + action: "completed", + repository: { full_name: sourceRun.repository?.full_name }, + workflow_run: { + conclusion: sourceRun.conclusion, + event: sourceRun.event, + head_branch: sourceRun.head_branch, + head_repository: sourceRun.head_repository, + head_sha: sourceRun.head_sha, + id: sourceRun.id, + name: sourceRun.name, + path: sourceRun.path, + run_attempt: sourceRun.run_attempt, + }, + }; + const run = validateReleaseWorkflowRun(sourceEvent, { checkedOutSha: process.env.WORKFLOW_SHA, repository: process.env.EXPECTED_REPOSITORY, workflowName: process.env.EXPECTED_WORKFLOW, workflowPath: process.env.EXPECTED_WORKFLOW_PATH, }); + if ( + run.runId !== Number(process.env.SOURCE_RUN_ID) || + run.runAttempt !== Number(process.env.SOURCE_RUN_ATTEMPT) + ) { + throw new Error( + "Release workflow source run ID or attempt changed before publication.", + ); + } + if (process.env.EVENT_NAME === "workflow_run") { + const eventRun = validateReleaseWorkflowRun( + JSON.parse(readFileSync(process.env.GITHUB_EVENT_PATH, "utf8")), + { + checkedOutSha: process.env.WORKFLOW_SHA, + repository: process.env.EXPECTED_REPOSITORY, + workflowName: process.env.EXPECTED_WORKFLOW, + workflowPath: process.env.EXPECTED_WORKFLOW_PATH, + }, + ); + if (JSON.stringify(eventRun) !== JSON.stringify(run)) { + throw new Error( + "Release workflow source run differs from the workflow_run event.", + ); + } + } const manifest = JSON.parse(readFileSync("package.json", "utf8")); if ( manifest.repository?.type !== "git" || diff --git a/RELEASING.md b/RELEASING.md index 67b348c..2d9eee1 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -291,6 +291,20 @@ The repository maintains four independently auditable workflows: downloaded artifact, then repeats every bounded registry-state and signature check. + The first `0.1.1` Publish run + [30469240186](https://github.com/cometapi-dev/cometapi-node/actions/runs/30469240186) + validated the immutable tag, Release, and Release Please result, then failed + before packing, live smoke, OIDC, or npm because the downloaded runtime result + JSON was inside the workspace scanned by Prettier. One reviewed recovery merge + temporarily adds an automatic `publish.yml`-only `main` push path. It accepts + only human actor `tensornull`, exact Release Please run `30469181724` attempt + 1, release commit `c98b514227858cd183c781270a7f78f65b577e82`, a direct + first-parent recovery merge, and the five recorded repair files. The workflow + then checks out and rebuilds the immutable release commit, downloads runtime + evidence under `runner.temp`, and uses the unchanged live, OIDC, artifact, and + registry gates. The recovery trigger and constants must be removed in the + post-release evidence PR; the `runner.temp` isolation remains permanent. + Third-party actions are pinned to full commit SHAs. Workflow permissions remain read-only except where a documented job requires more; `id-token: write` belongs only to the publish job. Publication cannot run from an arbitrary branch or an diff --git a/scripts/release-workflow-validation.mjs b/scripts/release-workflow-validation.mjs index b9da318..3566dbc 100644 --- a/scripts/release-workflow-validation.mjs +++ b/scripts/release-workflow-validation.mjs @@ -11,6 +11,19 @@ const RELEASE_PR_FOOTER = const RELEASE_WORKFLOW_JOB = "Prepare a reviewed release pull request or GitHub release"; const RELEASE_WORKFLOW_STEP = "Run Release Please"; +const PUBLISH_RECOVERY = Object.freeze({ + actor: "tensornull", + changedFiles: Object.freeze([ + ".github/workflows/publish.yml", + "RELEASING.md", + "scripts/release-workflow-validation.mjs", + "tests/release-workflow-validation.test.mjs", + "tests/workflow-contract.test.mjs", + ]), + releaseCommit: "c98b514227858cd183c781270a7f78f65b577e82", + releaseRunAttempt: 1, + releaseRunId: 30469181724, +}); function fail(message) { throw new Error(message); @@ -62,6 +75,79 @@ function stablePatch(version, label) { return Number(match[1]); } +export function validatePublishRecoveryTrigger({ + actor, + changedFiles, + eventAfter, + eventBefore, + eventName, + eventRef, + mainCommit, + mainFirstParent, + sourceReleaseCommit, + sourceRunAttempt, + sourceRunId, + workflowRunAttempt, +}) { + requireEqual(actor, PUBLISH_RECOVERY.actor, "publish recovery actor"); + requireEqual(eventName, "push", "publish recovery event"); + requireEqual(eventRef, "refs/heads/main", "publish recovery ref"); + requireCommit(eventAfter, "publish recovery event after SHA"); + requireCommit(eventBefore, "publish recovery event before SHA"); + requireCommit(mainCommit, "publish recovery main commit"); + requireCommit(mainFirstParent, "publish recovery main first parent"); + requireCommit(sourceReleaseCommit, "publish recovery source release commit"); + requireEqual( + eventAfter, + mainCommit, + "publish recovery event and main commit agreement", + ); + requireEqual( + eventBefore, + PUBLISH_RECOVERY.releaseCommit, + "publish recovery event before SHA", + ); + requireEqual( + mainFirstParent, + PUBLISH_RECOVERY.releaseCommit, + "publish recovery main first parent", + ); + requireEqual( + sourceReleaseCommit, + PUBLISH_RECOVERY.releaseCommit, + "publish recovery source release commit", + ); + requirePositiveInteger(sourceRunId, "publish recovery source run ID"); + requireEqual( + sourceRunId, + PUBLISH_RECOVERY.releaseRunId, + "publish recovery source run ID", + ); + requirePositiveInteger( + sourceRunAttempt, + "publish recovery source run attempt", + ); + requireEqual( + sourceRunAttempt, + PUBLISH_RECOVERY.releaseRunAttempt, + "publish recovery source run attempt", + ); + requirePositiveInteger(workflowRunAttempt, "publish recovery run attempt"); + if (!Array.isArray(changedFiles)) { + fail("Release workflow publish recovery changed files must be an array."); + } + requireEqual( + JSON.stringify([...changedFiles].sort()), + JSON.stringify([...PUBLISH_RECOVERY.changedFiles].sort()), + "publish recovery changed files", + ); + return { + releaseCommit: PUBLISH_RECOVERY.releaseCommit, + releaseRunAttempt: PUBLISH_RECOVERY.releaseRunAttempt, + releaseRunId: PUBLISH_RECOVERY.releaseRunId, + }; +} + function releaseTitle(version) { return `chore(main): release ${version}`; } diff --git a/tests/release-workflow-validation.test.mjs b/tests/release-workflow-validation.test.mjs index ea2a94a..dfdca52 100644 --- a/tests/release-workflow-validation.test.mjs +++ b/tests/release-workflow-validation.test.mjs @@ -11,6 +11,7 @@ import { validateMergedReleasePullRequest, validateOpenReleasePullRequestCollisions, validatePostActionPullRequestSnapshot, + validatePublishRecoveryTrigger, validateReleasePleaseCommitMessages, validateReleasePleaseCompletion, validateReleasePleaseMutationConfiguration, @@ -159,6 +160,69 @@ describe("Release Please generated files", () => { }); }); +describe("Publish recovery trigger", () => { + const recoveryCommit = "c98b514227858cd183c781270a7f78f65b577e82"; + const recoveryFiles = [ + ".github/workflows/publish.yml", + "RELEASING.md", + "scripts/release-workflow-validation.mjs", + "tests/release-workflow-validation.test.mjs", + "tests/workflow-contract.test.mjs", + ]; + + function recoveryTrigger(overrides = {}) { + return { + actor: "tensornull", + changedFiles: recoveryFiles, + eventAfter: BRANCH_SHA, + eventBefore: recoveryCommit, + eventName: "push", + eventRef: "refs/heads/main", + mainCommit: BRANCH_SHA, + mainFirstParent: recoveryCommit, + sourceReleaseCommit: recoveryCommit, + sourceRunAttempt: 1, + sourceRunId: 30469181724, + workflowRunAttempt: 1, + ...overrides, + }; + } + + it("accepts only the reviewed one-cycle recovery merge", () => { + expect(validatePublishRecoveryTrigger(recoveryTrigger())).toEqual({ + releaseCommit: recoveryCommit, + releaseRunAttempt: 1, + releaseRunId: 30469181724, + }); + }); + + it.each([ + ["actor", { actor: "github-actions[bot]" }], + ["event", { eventName: "workflow_dispatch" }], + ["ref", { eventRef: "refs/heads/dev" }], + ["before SHA", { eventBefore: RELEASE_SHA }], + ["after SHA", { eventAfter: RELEASE_SHA }], + ["first parent", { mainFirstParent: RELEASE_SHA }], + ["source commit", { sourceReleaseCommit: RELEASE_SHA }], + ["source run ID", { sourceRunId: 30469181725 }], + ["source attempt", { sourceRunAttempt: 2 }], + ["missing file", { changedFiles: recoveryFiles.slice(1) }], + ["extra file", { changedFiles: [...recoveryFiles, "package.json"] }], + ])("rejects recovery trigger drift in %s", (_name, overrides) => { + expect(() => + validatePublishRecoveryTrigger(recoveryTrigger(overrides)), + ).toThrow(/release workflow/i); + }); + + it("accepts a rerun of the same immutable recovery event", () => { + expect( + validatePublishRecoveryTrigger( + recoveryTrigger({ workflowRunAttempt: 2 }), + ), + ).toMatchObject({ releaseRunId: 30469181724 }); + }); +}); + describe("Release Please push classification", () => { function publishedCurrentRelease(overrides = {}) { return { diff --git a/tests/workflow-contract.test.mjs b/tests/workflow-contract.test.mjs index 30dff59..58f39fb 100644 --- a/tests/workflow-contract.test.mjs +++ b/tests/workflow-contract.test.mjs @@ -31,6 +31,7 @@ const releaseWorkflowValidation = readFileSync( "utf8", ); const releasePleaseWorkflow = parse(workflows["release-please.yml"]); +const publishWorkflow = parse(workflows["publish.yml"]); function workflow(name) { const contents = workflows[name]; @@ -316,18 +317,29 @@ describe("GitHub Actions workflow contract", () => { } }); - it("starts publication only from the completed Release Please workflow", () => { + it("starts publication from Release Please or the exact one-cycle recovery", () => { const publish = workflow("publish.yml"); expect(publish).toMatch( /workflow_run:\n {4}workflows:\n {6}- Release Please\n {4}types:\n {6}- completed/, ); expect(publish).not.toMatch(/^ {2}release:/m); + expect(publish).not.toMatch(/^ {2}workflow_dispatch:/m); + expect(publishWorkflow.on.push).toEqual({ + branches: ["main"], + paths: [".github/workflows/publish.yml"], + }); const verify = job(publish, "verify"); expect(verify).toContain( "github.event.workflow_run.conclusion == 'success'", ); - expect(verify).toContain("ref: refs/heads/main"); + expect(verify).toContain("SOURCE_RELEASE_COMMIT:"); + expect(verify).toContain("c98b514227858cd183c781270a7f78f65b577e82"); + expect(verify).toContain("SOURCE_RELEASE_RUN_ID:"); + expect(verify).toContain("30469181724"); + expect(verify).toContain("SOURCE_RELEASE_RUN_ATTEMPT:"); + expect(verify).toContain("validatePublishRecoveryTrigger"); + expect(verify).toContain("ref: ${{ env.SOURCE_RELEASE_COMMIT }}"); expect(verify).toContain("EXPECTED_WORKFLOW: Release Please"); expect(verify).toContain( "EXPECTED_WORKFLOW_PATH: .github/workflows/release-please.yml", @@ -335,9 +347,13 @@ describe("GitHub Actions workflow contract", () => { expect(verify).toContain("github.event.workflow_run.head_sha"); expect(verify).toContain("github.event.workflow_run.run_attempt"); expect(verify).toContain( - "release-please-result-${{ github.event.workflow_run.id }}-${{ github.event.workflow_run.run_attempt }}", + "release-please-result-${{ env.SOURCE_RELEASE_RUN_ID }}-${{ env.SOURCE_RELEASE_RUN_ATTEMPT }}", + ); + expect(verify).toContain("path: ${{ runner.temp }}/release-please-result"); + expect(verify).toContain("run-id: ${{ env.SOURCE_RELEASE_RUN_ID }}"); + expect(verify).toContain( + "RELEASE_RESULT: ${{ runner.temp }}/release-please-result/result.json", ); - expect(verify).toContain("run-id: ${{ github.event.workflow_run.id }}"); const resultDownload = verify.slice( verify.indexOf("Download the exact Release Please result"), verify.indexOf("Reject an untrusted Release Please workflow run"),