mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-31 07:23:06 +08:00
Wait for every released package to resolve on npm before the GitHub release (#2281)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user