Wait for every released package to resolve on npm before the GitHub release (#2281)

This commit is contained in:
Chris Tate
2026-10-25 21:28:51 -05:00
committed by GitHub
3 changed files with 325 additions and 7 deletions
+61 -7
View File
@@ -83,6 +83,7 @@ jobs:
spawnSync('git', ['diff', '--quiet', run.head_sha, 'HEAD', '--', '.',
':(exclude).github/workflows/release.yml', ':(exclude)scripts/verify-native-cli.mjs',
':(exclude)scripts/package-native-cli.mjs', ':(exclude)tests/harness/native-cli-packaging.test.ts',
':(exclude)scripts/wait-npm-available.mjs', ':(exclude)tests/harness/npm-available.test.ts',
]).status !== 0) continue;
const artifacts = api(`repos/${repo}/actions/runs/${run.id}/artifacts?per_page=100`).artifacts
.filter((artifact) => !artifact.expired && artifact.size_in_bytes > 0);
@@ -429,6 +430,8 @@ jobs:
contents: read
actions: read
id-token: write
outputs:
uploads: ${{ steps.publish.outputs.uploads }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -448,7 +451,7 @@ jobs:
VERSION: ${{ needs.check-release.outputs.version }}
RECEIPT_RUN: ${{ needs.check-release.outputs.receipt_run }}
run: |
touch "$RUNNER_TEMP/npm-accepted.txt"
touch "$RUNNER_TEMP/npm-accepted.txt" "$RUNNER_TEMP/npm-accepted-times.txt"
# Include this run so re-running only failed jobs restores its receipts.
for run in "$GITHUB_RUN_ID" "$RECEIPT_RUN"; do
if [ -z "$run" ]; then continue; fi
@@ -457,6 +460,7 @@ jobs:
if [ "$count" -eq 0 ]; then continue; fi
gh run download "$run" --repo "$GITHUB_REPOSITORY" --pattern "npm-accepted-$VERSION-*" --dir "$RUNNER_TEMP/npm-receipts/$run"
find "$RUNNER_TEMP/npm-receipts/$run" -name npm-accepted.txt -exec cat {} + >> "$RUNNER_TEMP/npm-accepted.txt"
find "$RUNNER_TEMP/npm-receipts/$run" -name npm-accepted-times.txt -exec cat {} + >> "$RUNNER_TEMP/npm-accepted-times.txt"
done
- name: Setup pnpm
@@ -506,6 +510,7 @@ jobs:
done
- name: Publish to npm
id: publish
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -525,10 +530,14 @@ jobs:
# Upload acceptance and public availability are separate states.
# Save acceptance immediately so retries never depend on registry lag.
# npm-release.txt lists every package of the release, and
# npm-accepted-times.txt the time npm accepted each upload, for the
# npm-available job.
publish_dir() {
dir="$1"
packed="${2:-}"
name=$(node -p "require('./$dir/package.json').name")
echo "$name@$VERSION" >> "$RUNNER_TEMP/npm-release.txt"
if grep -Fxq "$name@$VERSION" "$RUNNER_TEMP/npm-accepted.txt"; then
echo "$name@$VERSION upload already accepted, skipping"
return 0
@@ -545,6 +554,7 @@ jobs:
publish_log="$RUNNER_TEMP/npm-publish.log"
if npm publish "$packed" $PROVENANCE --access public 2>&1 | tee "$publish_log"; then
echo "$name@$VERSION" >> "$RUNNER_TEMP/npm-accepted.txt"
echo "$name@$VERSION $(date +%s)" >> "$RUNNER_TEMP/npm-accepted-times.txt"
return 0
else
publish_status=${PIPESTATUS[0]}
@@ -568,7 +578,8 @@ jobs:
# pnpm pack resolves workspace dependencies; npm handles OIDC.
# Publish dependencies first. Registry propagation after an accepted
# upload must not block the remaining packages or the GitHub release.
# upload must not block the remaining packages; the npm-available
# job waits for it.
publish_dir packages/runtime
publish_dir packages/runtime-darwin-arm64
publish_dir packages/llvm-darwin-arm64
@@ -596,23 +607,66 @@ jobs:
publish_dir packages/cli
echo "All package uploads accepted; npm registry propagation may continue after this job." >> "$GITHUB_STEP_SUMMARY"
# Each package of the release with the time its upload was accepted,
# when a run recorded it.
{
echo "uploads<<NPM_UPLOADS"
awk 'FILENAME == ARGV[1] { accepted[$1] = $2; next } { print $1, accepted[$1] }' \
"$RUNNER_TEMP/npm-accepted-times.txt" "$RUNNER_TEMP/npm-release.txt"
echo "NPM_UPLOADS"
} >> "$GITHUB_OUTPUT"
- name: Save accepted upload receipts
if: always()
uses: actions/upload-artifact@v4
with:
name: npm-accepted-${{ needs.check-release.outputs.version }}-${{ github.run_attempt }}
path: ${{ runner.temp }}/npm-accepted.txt
path: |
${{ runner.temp }}/npm-accepted.txt
${{ runner.temp }}/npm-accepted-times.txt
if-no-files-found: ignore
retention-days: 90
# Publish standalone native distributions and the surface manifest after
# npm publishing succeeds. The release body comes from the marked changelog.
github-release:
name: Create GitHub Release
# Waits until every package of the release resolves on the npm registry,
# and reports each package's time from upload to available. Fails with the
# packages that are still missing after 2 hours. It only reads from the
# registry; the next release run retries it, and publishing skips uploads
# npm already accepted.
npm-available:
name: Wait for npm availability
needs: [check-release, publish]
if: >-
!cancelled() && needs.check-release.outputs.should_release == 'true' && needs.publish.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 135
permissions:
contents: read
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
- name: Wait for every package to resolve
env:
UPLOADS: ${{ needs.publish.outputs.uploads }}
run: |
printf '%s\n' "$UPLOADS" > "$RUNNER_TEMP/npm-uploads.txt"
node scripts/wait-npm-available.mjs "$RUNNER_TEMP/npm-uploads.txt"
# Publish standalone native distributions and the surface manifest after
# every npm package resolves. The release body comes from the marked
# changelog.
github-release:
name: Create GitHub Release
needs: [check-release, publish, npm-available]
if: >-
!cancelled() && needs.check-release.outputs.should_release == 'true' && needs.npm-available.result == 'success'
runs-on: ubuntu-latest
timeout-minutes: 10
permissions:
contents: write
+150
View File
@@ -0,0 +1,150 @@
#!/usr/bin/env node
// Waits until every package of a release resolves on the npm registry.
//
// usage: wait-npm-available.mjs <uploads file> [--interval <seconds>] [--timeout <seconds>]
//
// Each line of the uploads file is `<name>@<version>`, optionally followed by
// the Unix time (seconds) at which npm accepted the upload. Every interval
// (120 seconds by default), each package that has not resolved yet is asked
// for with `npm view <name>@<version> version --prefer-online`. A package
// resolves when npm prints its version. Each package is reported once it
// resolves, with the time from upload to the check that found it.
//
// Exits with 0 once every package resolves. When packages are still missing
// at the timeout (2 hours by default), prints their names and versions and
// exits with 1. The script only reads from the registry.
import { execFile } from "node:child_process";
import { appendFileSync, readFileSync } from "node:fs";
import { setTimeout as delay } from "node:timers/promises";
function usage() {
throw new Error(
"usage: wait-npm-available.mjs <uploads file> [--interval <seconds>] [--timeout <seconds>]",
);
}
const args = process.argv.slice(2);
let file;
let intervalSeconds = 120;
let timeoutSeconds = 2 * 60 * 60;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--interval" || arg === "--timeout") {
const value = Number(args[++i]);
if (!Number.isFinite(value) || value <= 0) usage();
if (arg === "--interval") intervalSeconds = value;
else timeoutSeconds = value;
} else if (file === undefined) {
file = arg;
} else {
usage();
}
}
if (file === undefined) usage();
const packages = [];
for (const line of readFileSync(file, "utf8").split("\n")) {
const fields = line.trim().split(/\s+/);
if (fields[0] === "") continue;
const spec = fields[0];
const at = spec.lastIndexOf("@");
if (at <= 0 || at === spec.length - 1 || fields.length > 2) {
throw new Error(`malformed upload line: ${line}`);
}
const acceptedAt = fields[1] === undefined ? null : Number(fields[1]) * 1000;
if (acceptedAt !== null && !Number.isFinite(acceptedAt)) {
throw new Error(`malformed upload time: ${line}`);
}
packages.push({ spec, version: spec.slice(at + 1), acceptedAt, availableAt: null });
}
if (packages.length === 0) throw new Error(`${file} names no packages`);
function resolves(pkg) {
return new Promise((resolve) => {
execFile(
"npm",
["view", pkg.spec, "version", "--prefer-online"],
{ encoding: "utf8", timeout: 60_000 },
(error, stdout, stderr) => {
if (error === null && stdout.trim() === pkg.version) {
resolve(true);
return;
}
const code = /npm error code (\S+)/.exec(stderr)?.[1];
if (error !== null && code !== "E404") {
const reason = code ?? error.code ?? error.message.split("\n")[0];
console.log(`${pkg.spec}: npm view failed (${reason}); checking again`);
}
resolve(false);
},
);
});
}
function duration(ms) {
const seconds = Math.max(0, Math.round(ms / 1000));
const minutes = Math.floor(seconds / 60);
return minutes === 0 ? `${seconds}s` : `${minutes}m ${String(seconds % 60).padStart(2, "0")}s`;
}
function uploadToAvailable(pkg) {
return pkg.acceptedAt === null
? "upload time not recorded"
: duration(pkg.availableAt - pkg.acceptedAt);
}
function summary(missing) {
const summaryFile = process.env.GITHUB_STEP_SUMMARY;
if (!summaryFile) return;
const rows = packages.map(
(pkg) =>
`| \`${pkg.spec}\` | ${pkg.availableAt === null ? "missing" : uploadToAvailable(pkg)} |`,
);
const heading =
missing.length === 0
? `Every package resolves on npm (${packages.length}).`
: `${missing.length} of ${packages.length} packages do not resolve on npm after ${duration(timeoutSeconds * 1000)}.`;
appendFileSync(
summaryFile,
[heading, "", "| Package | Upload to available |", "| --- | --- |", ...rows, ""].join("\n"),
);
}
const started = Date.now();
const deadline = started + timeoutSeconds * 1000;
for (;;) {
const pending = packages.filter((pkg) => pkg.availableAt === null);
const checked = [];
// A few checks at a time keep each round short without flooding the registry.
for (let i = 0; i < pending.length; i += 8) {
const batch = pending.slice(i, i + 8);
const results = await Promise.all(batch.map(resolves));
const now = Date.now();
batch.forEach((pkg, j) => {
if (results[j]) {
pkg.availableAt = now;
checked.push(pkg);
}
});
}
for (const pkg of checked) console.log(`${pkg.spec} is available (${uploadToAvailable(pkg)})`);
const missing = packages.filter((pkg) => pkg.availableAt === null);
if (missing.length === 0) {
console.log(`Every package resolves on npm (${packages.length}).`);
summary(missing);
process.exit(0);
}
const now = Date.now();
if (now >= deadline) {
console.log(
`${missing.length} of ${packages.length} packages do not resolve on npm after ${duration(now - started)}:`,
);
for (const pkg of missing) console.log(`::error::${pkg.spec} does not resolve on npm`);
summary(missing);
process.exit(1);
}
console.log(
`${missing.length} of ${packages.length} packages do not resolve yet; checking again in ${duration(Math.min(intervalSeconds * 1000, deadline - now))}`,
);
await delay(Math.min(intervalSeconds * 1000, deadline - now));
}
+114
View File
@@ -0,0 +1,114 @@
import { spawnSync } from "node:child_process";
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { delimiter, join } from "node:path";
import { expect, test } from "vitest";
const repoRoot = join(import.meta.dirname, "../..");
const script = join(repoRoot, "scripts", "wait-npm-available.mjs");
// A stand-in for `npm view <spec> version`: each spec resolves on the check
// numbered in registry.json (0 never resolves). Every call is logged.
const fakeNpm = `#!/usr/bin/env node
const { appendFileSync, readFileSync } = require("node:fs");
const { join } = require("node:path");
const dir = process.env.FAKE_NPM_DIR;
const args = process.argv.slice(2);
appendFileSync(join(dir, "calls.log"), JSON.stringify(args) + "\\n");
const spec = args[1];
const calls = readFileSync(join(dir, "calls.log"), "utf8").trim().split("\\n")
.filter((line) => JSON.parse(line)[1] === spec).length;
const registry = JSON.parse(readFileSync(join(dir, "registry.json"), "utf8"));
const resolvesOn = registry[spec];
if (resolvesOn === undefined) {
process.stderr.write("npm error code E404\\n");
process.exit(1);
}
if (resolvesOn !== 0 && calls >= resolvesOn) {
process.stdout.write(spec.slice(spec.lastIndexOf("@") + 1) + "\\n");
}
`;
function run(uploads: string, registry: Record<string, number>, extra: string[]) {
const dir = mkdtempSync(join(tmpdir(), "scriptc-npm-available-"));
try {
writeFileSync(join(dir, "npm"), fakeNpm);
chmodSync(join(dir, "npm"), 0o755);
writeFileSync(join(dir, "registry.json"), JSON.stringify(registry));
writeFileSync(join(dir, "calls.log"), "");
writeFileSync(join(dir, "uploads.txt"), uploads);
writeFileSync(join(dir, "summary.md"), "");
const result = spawnSync(process.execPath, [script, join(dir, "uploads.txt"), ...extra], {
encoding: "utf8",
env: {
...process.env,
PATH: `${dir}${delimiter}${process.env["PATH"] ?? ""}`,
FAKE_NPM_DIR: dir,
GITHUB_STEP_SUMMARY: join(dir, "summary.md"),
},
});
const calls = readFileSync(join(dir, "calls.log"), "utf8")
.trim()
.split("\n")
.filter((line) => line !== "")
.map((line) => JSON.parse(line) as string[]);
return {
status: result.status,
output: result.stdout + result.stderr,
calls,
summary: readFileSync(join(dir, "summary.md"), "utf8"),
};
} finally {
rmSync(dir, { recursive: true, force: true });
}
}
test.skipIf(process.platform === "win32")(
"waits until every package resolves and reports each package's time from upload",
() => {
const accepted = Math.floor(Date.now() / 1000) - 90;
const result = run(
`@scriptc/runtime@1.2.3 ${accepted}\nscriptc@1.2.3\n`,
{ "@scriptc/runtime@1.2.3": 1, "scriptc@1.2.3": 3 },
["--interval", "0.05", "--timeout", "30"],
);
expect(result.status, result.output).toBe(0);
expect(result.output).toMatch(/@scriptc\/runtime@1\.2\.3 is available \(1m 3\ds\)/);
expect(result.output).toContain("scriptc@1.2.3 is available (upload time not recorded)");
expect(result.output).toContain("Every package resolves on npm (2).");
// A package that resolved is not asked again.
expect(result.calls.filter((args) => args[1] === "@scriptc/runtime@1.2.3")).toHaveLength(1);
expect(result.calls.filter((args) => args[1] === "scriptc@1.2.3")).toHaveLength(3);
for (const args of result.calls)
expect(args).toEqual(["view", args[1], "version", "--prefer-online"]);
expect(result.summary).toContain("| `scriptc@1.2.3` | upload time not recorded |");
},
);
test.skipIf(process.platform === "win32")(
"fails with the names and versions of the packages that do not resolve by the timeout",
() => {
const result = run(
"@scriptc/runtime@1.2.3\n@scriptc/compiler@1.2.3\nscriptc@1.2.3\n",
{ "@scriptc/runtime@1.2.3": 1, "@scriptc/compiler@1.2.3": 0 },
["--interval", "0.1", "--timeout", "0.5"],
);
expect(result.status, result.output).toBe(1);
expect(result.output).toContain("2 of 3 packages do not resolve on npm after");
expect(result.output).toContain("::error::@scriptc/compiler@1.2.3 does not resolve on npm");
expect(result.output).toContain("::error::scriptc@1.2.3 does not resolve on npm");
expect(result.output).not.toContain("::error::@scriptc/runtime@1.2.3");
expect(
result.calls.filter((args) => args[1] === "@scriptc/compiler@1.2.3").length,
).toBeGreaterThan(1);
expect(result.summary).toContain("| `@scriptc/compiler@1.2.3` | missing |");
expect(result.summary).toContain("| `scriptc@1.2.3` | missing |");
},
);
test("rejects a malformed uploads file", () => {
const result = run("scriptc 1.2.3\n", {}, []);
expect(result.status).toBe(1);
expect(result.output).toContain("malformed upload line: scriptc 1.2.3");
expect(result.calls).toHaveLength(0);
});