diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5b8685c..e9b38cc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -382,7 +382,8 @@ jobs: mkdir -p "$RUNNER_TEMP/registry-install" cd "$RUNNER_TEMP/registry-install" npm init --yes >/dev/null - npm install --ignore-scripts "@davidahmann/mill@$version" + node "$GITHUB_WORKSPACE/scripts/retry-npm-install.mjs" \ + "$RUNNER_TEMP/registry-install" "@davidahmann/mill@$version" node "$GITHUB_WORKSPACE/scripts/retry-npm-signatures.mjs" "$RUNNER_TEMP/registry-install" - name: Requalify the registry-downloaded package run: | diff --git a/docs/release.md b/docs/release.md index e0aa9bd..e02c02e 100644 --- a/docs/release.md +++ b/docs/release.md @@ -214,9 +214,10 @@ npm publish "$artifact" --provenance --access public --tag latest ``` It does not run `npm pack` again. It verifies that npm's `latest` dist-tag names -the exact version, reads the package back, verifies registry signatures with a -bounded propagation retry, downloads and requalifies the registry artifact, -creates a plainly labelled draft public-alpha release with the same +the exact version, retries package installation until the registry serves that +exact version, then verifies registry signatures with a bounded propagation +retry. It downloads and requalifies the registry artifact, creates a plainly +labelled draft public-alpha release with the same tarball/checksum/SBOM/evidence, downloads the GitHub asset, checks every identity, uploads final evidence using the durable tag URL, and only then publishes the normal GitHub Release. diff --git a/scripts/release-workflow-policy.mjs b/scripts/release-workflow-policy.mjs index bcb14ee..c683520 100644 --- a/scripts/release-workflow-policy.mjs +++ b/scripts/release-workflow-policy.mjs @@ -139,6 +139,17 @@ export function releasePublicationFailures(jobs) { "publish: registry readback must bind latest to the published version", ); } + if ( + typeof registryReadback?.run !== "string" || + !registryReadback.run.includes("retry-npm-install.mjs") || + !registryReadback.run.includes("retry-npm-signatures.mjs") || + registryReadback.run.indexOf("retry-npm-install.mjs") > + registryReadback.run.indexOf("retry-npm-signatures.mjs") + ) { + failures.push( + "publish: package reachability must settle before signature verification", + ); + } if ( typeof create?.run !== "string" || !create.run.includes( diff --git a/scripts/retry-npm-install.mjs b/scripts/retry-npm-install.mjs new file mode 100644 index 0000000..0b0739f --- /dev/null +++ b/scripts/retry-npm-install.mjs @@ -0,0 +1,75 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +const [workingDirectory = process.cwd(), specifier] = process.argv.slice(2); +if (specifier === undefined || specifier.length === 0) { + throw new Error( + "usage: retry-npm-install.mjs [working-directory] ", + ); +} + +const attempts = parseBoundedInteger( + process.env.MILL_NPM_INSTALL_ATTEMPTS, + 12, + 1, + 20, + "MILL_NPM_INSTALL_ATTEMPTS", +); +const delayMs = parseBoundedInteger( + process.env.MILL_NPM_INSTALL_DELAY_MS, + 10_000, + 0, + 60_000, + "MILL_NPM_INSTALL_DELAY_MS", +); +const resolvedWorkingDirectory = path.resolve(workingDirectory); + +function parseBoundedInteger(value, fallback, minimum, maximum, name) { + if (value === undefined) return fallback; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new Error(`${name} must be an integer from ${minimum} to ${maximum}`); + } + return parsed; +} + +let lastFailure = "npm install failed without diagnostic output"; +for (let attempt = 1; attempt <= attempts; attempt += 1) { + const result = spawnSync( + "npm", + [ + "install", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--prefer-online", + specifier, + ], + { + cwd: resolvedWorkingDirectory, + encoding: "utf8", + env: process.env, + maxBuffer: 4 * 1024 * 1024, + timeout: 60_000, + }, + ); + if (result.status === 0) { + process.stdout.write(result.stdout); + process.exit(0); + } + lastFailure = + result.error === undefined + ? `${result.stderr}${result.stdout}`.trim().slice(-4_096) + : String(result.error); + if (attempt < attempts) { + process.stderr.write( + `npm package readback attempt ${attempt}/${attempts} failed; retrying in ${delayMs}ms\n`, + ); + await delay(delayMs); + } +} + +throw new Error( + `npm package readback did not settle after ${attempts} attempts: ${lastFailure}`, +); diff --git a/test/policy-scripts.test.ts b/test/policy-scripts.test.ts index c21a250..7f7d1bc 100644 --- a/test/policy-scripts.test.ts +++ b/test/policy-scripts.test.ts @@ -24,6 +24,7 @@ const releaseReadbackScript = path.resolve( const npmSignatureReadbackScript = path.resolve( "scripts/retry-npm-signatures.mjs", ); +const npmInstallReadbackScript = path.resolve("scripts/retry-npm-install.mjs"); const qualifyReleaseArtifactScript = path.resolve( "scripts/qualify-release-artifact.mjs", ); @@ -787,4 +788,64 @@ describe("repository policy scripts", () => { await temporary.cleanup(); } }); + + it("retries bounded npm package propagation before signature verification", async () => { + const temporary = await temporaryDirectory("mill-npm-install-"); + try { + const fakeBin = path.join(temporary.path, "bin"); + await mkdir(fakeBin); + const fakeNpm = path.join(fakeBin, "npm"); + const counter = path.join(temporary.path, "attempts"); + await writeFile( + fakeNpm, + [ + "#!/usr/bin/env node", + 'import {readFileSync,writeFileSync} from "node:fs";', + "const file=process.env.MILL_TEST_COUNTER;", + 'const count=Number(readFileSync(file,"utf8"))+1;', + "writeFileSync(file,String(count));", + 'if(process.argv.slice(2).join(" ")!=="install --ignore-scripts --no-audit --no-fund --prefer-online @davidahmann/mill@0.7.0")process.exit(2);', + 'if(count<3){process.stderr.write("package not propagated\\n");process.exit(1);}', + 'process.stdout.write("installed package\\n");', + "", + ].join("\n"), + { mode: 0o755 }, + ); + await writeFile(counter, "0"); + const recovered = run( + process.execPath, + [npmInstallReadbackScript, temporary.path, "@davidahmann/mill@0.7.0"], + temporary.path, + { + MILL_NPM_INSTALL_ATTEMPTS: "3", + MILL_NPM_INSTALL_DELAY_MS: "1", + MILL_TEST_COUNTER: counter, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + }, + ); + expect(recovered.status, recovered.stderr).toBe(0); + expect(recovered.stdout).toContain("installed package"); + expect(await readFile(counter, "utf8")).toBe("3"); + + await writeFile(counter, "0"); + const exhausted = run( + process.execPath, + [npmInstallReadbackScript, temporary.path, "@davidahmann/mill@0.7.0"], + temporary.path, + { + MILL_NPM_INSTALL_ATTEMPTS: "2", + MILL_NPM_INSTALL_DELAY_MS: "1", + MILL_TEST_COUNTER: counter, + PATH: `${fakeBin}:${process.env.PATH ?? ""}`, + }, + ); + expect(exhausted.status).toBe(1); + expect(exhausted.stderr).toContain( + "npm package readback did not settle after 2 attempts", + ); + expect(await readFile(counter, "utf8")).toBe("2"); + } finally { + await temporary.cleanup(); + } + }); }); diff --git a/test/release-workflow.test.ts b/test/release-workflow.test.ts index 54761b5..dc0523a 100644 --- a/test/release-workflow.test.ts +++ b/test/release-workflow.test.ts @@ -131,7 +131,7 @@ describe("release verifier preparation policy", () => { "draft release must retain the qualified candidate and independent verifier records", ); }); - it.each(["alpha", "missing-readback"])( + it.each(["alpha", "missing-readback", "missing-install-retry"])( "rejects a %s npm latest publication contract", async (mutation) => { const workflow = await fixture(); @@ -147,13 +147,17 @@ describe("release verifier preparation policy", () => { throw new Error("missing npm publication fixture"); if (mutation === "alpha") { publish.run = publish.run.replace("--tag latest", "--tag alpha"); - } else { + } else if (mutation === "missing-readback") { readback.run = readback.run.replace("tags.latest!==version", "false"); + } else { + readback.run = readback.run.replace("retry-npm-install.mjs", "retry"); } await expect(check(workflow)).rejects.toThrow( mutation === "alpha" ? "trusted publication must advance latest" - : "registry readback must bind latest", + : mutation === "missing-readback" + ? "registry readback must bind latest" + : "package reachability must settle", ); }, );