Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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: |
Expand Down
7 changes: 4 additions & 3 deletions docs/release.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions scripts/release-workflow-policy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
75 changes: 75 additions & 0 deletions scripts/retry-npm-install.mjs
Original file line number Diff line number Diff line change
@@ -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] <package-specifier>",
);
}

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}`,
);
61 changes: 61 additions & 0 deletions test/policy-scripts.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
);
Expand Down Expand Up @@ -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();
}
});
});
10 changes: 7 additions & 3 deletions test/release-workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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",
);
},
);
Expand Down