fix(release): validate npm preflight workflow trust

This commit is contained in:
Peter Steinberger
2026-07-14 18:43:58 +01:00
parent 9f206933af
commit e297bc2ac5
3 changed files with 65 additions and 3 deletions
+13
View File
@@ -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[],
+23 -3
View File
@@ -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,
@@ -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",