From e297bc2ac53be2e439ea6c3205572238f04a7e44 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 14 Jul 2026 18:43:58 +0100 Subject: [PATCH] fix(release): validate npm preflight workflow trust --- scripts/release-candidate-checklist.d.mts | 13 +++++++++ scripts/release-candidate-checklist.mjs | 26 +++++++++++++++-- .../release-candidate-checklist.test.ts | 29 +++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) diff --git a/scripts/release-candidate-checklist.d.mts b/scripts/release-candidate-checklist.d.mts index 2cf5a38fa96c..7c2caa7c0599 100644 --- a/scripts/release-candidate-checklist.d.mts +++ b/scripts/release-candidate-checklist.d.mts @@ -162,6 +162,19 @@ export function requireRunIdFromDispatchOutput(output: unknown, workflowFile: un export function buildPublishCommand(options: unknown): string; export function validatePreflightManifest(manifest: unknown, params: unknown): void; export function validateFullManifest(manifest: unknown, params: unknown): void; +export function validateNpmPreflightRunSource({ + workflowRun, + workflowRef, + isTrustedWorkflowAncestor, +}: { + workflowRun: { headSha: string }; + workflowRef: string; + isTrustedWorkflowAncestor?: ((ancestor: string, target: string) => boolean) | undefined; +}): { + status: string; + headSha: string; + workflowRef: string; +}; export function candidateParallelsArgs( tarballPath: unknown, dependencyTarballPaths?: unknown[], diff --git a/scripts/release-candidate-checklist.mjs b/scripts/release-candidate-checklist.mjs index dfdcfd2835b5..52d888a131ba 100644 --- a/scripts/release-candidate-checklist.mjs +++ b/scripts/release-candidate-checklist.mjs @@ -587,6 +587,24 @@ function gitIsAncestor(ancestor, target) { ); } +export function validateNpmPreflightRunSource({ + workflowRun, + workflowRef, + isTrustedWorkflowAncestor = gitIsAncestor, +}) { + const trustedRef = `refs/remotes/origin/${workflowRef}`; + if (!isTrustedWorkflowAncestor(workflowRun.headSha, trustedRef)) { + throw new Error( + `npm preflight workflow SHA ${workflowRun.headSha} is not reachable from trusted ${workflowRef}`, + ); + } + return { + status: "passed", + headSha: workflowRun.headSha, + workflowRef, + }; +} + function candidateContributionRecordPullRequests( section, label, @@ -1332,9 +1350,10 @@ async function main() { workflowName: "OpenClaw NPM Release", workflowRef: options.workflowRef, }); - if (npmRun.headSha !== targetSha) { - throw new Error(`run SHA mismatch: tag=${targetSha} npm=${npmRun.headSha}`); - } + const npmPreflightSource = validateNpmPreflightRunSource({ + workflowRun: npmRun, + workflowRef: options.workflowRef, + }); const npmDir = join(options.outputDir, "npm-preflight"); const fullDir = join(options.outputDir, "full-release-validation"); @@ -1435,6 +1454,7 @@ async function main() { fullReleaseValidationUrl: fullRun.url, fullReleaseValidationControls: fullManifest.controls, npmPreflightUrl: npmRun.url, + npmPreflightSource, artifacts: { npmPreflight: npmArtifactName, fullReleaseValidation: fullArtifactName, diff --git a/test/scripts/release-candidate-checklist.test.ts b/test/scripts/release-candidate-checklist.test.ts index f3c2b046b1ed..8c38ae70a0c9 100644 --- a/test/scripts/release-candidate-checklist.test.ts +++ b/test/scripts/release-candidate-checklist.test.ts @@ -20,6 +20,7 @@ import { validateCandidateCheckout, validateCandidateReleaseNotes, validateFullManifest, + validateNpmPreflightRunSource, validatePreflightManifest, validateWindowsSourceRelease, } from "../../scripts/release-candidate-checklist.mjs"; @@ -520,6 +521,34 @@ describe("release candidate checklist", () => { ).toThrow("invalid dependency tarball metadata"); }); + it("trusts the npm workflow SHA while binding the candidate through its manifest", () => { + const workflowSha = "a".repeat(40); + const isTrustedWorkflowAncestor = vi.fn(() => true); + + expect( + validateNpmPreflightRunSource({ + workflowRun: { headSha: workflowSha }, + workflowRef: "main", + isTrustedWorkflowAncestor, + }), + ).toEqual({ + status: "passed", + headSha: workflowSha, + workflowRef: "main", + }); + expect(isTrustedWorkflowAncestor).toHaveBeenCalledWith(workflowSha, "refs/remotes/origin/main"); + }); + + it("rejects npm preflight workflow code outside the trusted ref", () => { + expect(() => + validateNpmPreflightRunSource({ + workflowRun: { headSha: "a".repeat(40) }, + workflowRef: "main", + isTrustedWorkflowAncestor: () => false, + }), + ).toThrow("is not reachable from trusted main"); + }); + it("requires run ids when dispatch is disabled", () => { expect(() => parseArgs(["--tag", "v2026.5.14-beta.3", "--skip-dispatch"])).toThrow( "--skip-dispatch requires --full-release-run and --npm-preflight-run",