diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 799337e0a..73f000431 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -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<> "$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 diff --git a/scripts/wait-npm-available.mjs b/scripts/wait-npm-available.mjs new file mode 100644 index 000000000..26d7db391 --- /dev/null +++ b/scripts/wait-npm-available.mjs @@ -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 [--interval ] [--timeout ] +// +// Each line of the uploads file is `@`, 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 @ 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 [--interval ] [--timeout ]", + ); +} + +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)); +} diff --git a/tests/harness/npm-available.test.ts b/tests/harness/npm-available.test.ts new file mode 100644 index 000000000..0970d1f98 --- /dev/null +++ b/tests/harness/npm-available.test.ts @@ -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 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, 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); +});