Wait for every released package to resolve on npm before the GitHub release

The release workflow publishes each package and moves on once npm accepts
the upload. A package whose processing on npm stalls was visible only on
npmjs.com.

A new npm-available job runs after "Publish to npm". It checks each package
of the release with `npm view <name>@<version> version --prefer-online`
every 2 minutes (scripts/wait-npm-available.mjs) until every package
resolves, and reports each package's time from upload to available in the
log and the job summary. When a package still does not resolve after 2
hours, the job fails and names each missing package and version. It only
reads from the registry.

The publish job lists every package of the release and records the time
npm accepted each upload, keeps those times with its upload receipts, and
passes both to the new job. Create GitHub Release now waits for the new
job, so the release is created and marked complete only once every
package resolves; a later release run checks again and skips uploads npm
already accepted.
This commit is contained in:
Chris Tate
2026-10-25 18:51:56 -05:00
parent 14bb8d4156
commit 68e148add9
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);
@@ -394,6 +395,8 @@ jobs:
contents: read
actions: read
id-token: write
outputs:
uploads: ${{ steps.publish.outputs.uploads }}
steps:
- name: Checkout repository
uses: actions/checkout@v4
@@ -413,7 +416,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
@@ -422,6 +425,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
@@ -471,6 +475,7 @@ jobs:
done
- name: Publish to npm
id: publish
shell: bash
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -490,10 +495,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
@@ -510,6 +519,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]}
@@ -533,7 +543,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
@@ -561,23 +572,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);
});