返回 DeepSeek-Reasonix
finalize-npm-official-release.mjs
根目录 / scripts / finalize-npm-official-release.mjs
1 #!/usr/bin/env node
2
3 import { execFileSync } from "node:child_process";
4 import { setTimeout as delay } from "node:timers/promises";
5
6 const version = process.argv[2];
7 const expectedSha = process.env.EXPECTED_SHA || "";
8 const verifyDelayMs = Number.parseInt(process.env.NPM_TAG_VERIFY_DELAY_MS || "2000", 10);
9 const verifyAttempts = Number.parseInt(process.env.NPM_TAG_VERIFY_ATTEMPTS || "15", 10);
10 if (!/^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/.test(version || "")) {
11 throw new Error("usage: finalize-npm-official-release.mjs MAJOR.MINOR.PATCH");
12 }
13 if (expectedSha && !/^[0-9a-f]{40}$/.test(expectedSha)) {
14 throw new Error("EXPECTED_SHA must be a full lowercase commit SHA");
15 }
16 if (!Number.isSafeInteger(verifyDelayMs) || verifyDelayMs < 0) {
17 throw new Error("NPM_TAG_VERIFY_DELAY_MS must be a non-negative integer");
18 }
19 if (!Number.isSafeInteger(verifyAttempts) || verifyAttempts < 1) {
20 throw new Error("NPM_TAG_VERIFY_ATTEMPTS must be a positive integer");
21 }
22
23 const packages = [
24 "reasonix",
25 "@reasonix/cli-darwin-arm64", "@reasonix/cli-darwin-x64",
26 "@reasonix/cli-linux-arm64", "@reasonix/cli-linux-x64",
27 "@reasonix/cli-win32-arm64", "@reasonix/cli-win32-x64",
28 ];
29
30 const OFFICIAL_ALIASES = ["latest", "canary", "next"];
31
32 // The publisher stages a stable release under "<dist-tag>-staging" and removes
33 // it once the official aliases land (npm/publish.mjs). This cleanup used to name
34 // "official-staging", which nothing creates, so it never removed anything and a
35 // staging alias left behind by a failed publish stayed on the registry.
36 const stagingTag = "latest-staging";
37
38 function metadata(name) {
39 const output = execFileSync(
40 "npm",
41 ["view", `${name}@${version}`, "name", "version", "gitHead", "reasonixCandidateSha", "--json"],
42 { encoding: "utf8" },
43 );
44 return JSON.parse(output);
45 }
46
47 function distTags(name) {
48 return JSON.parse(execFileSync("npm", ["view", name, "dist-tags", "--json"], { encoding: "utf8" }));
49 }
50
51 function removeStagingTag(name) {
52 try {
53 execFileSync("npm", ["dist-tag", "rm", name, stagingTag], { encoding: "utf8" });
54 } catch (error) {
55 const detail = [error?.message, error?.stdout, error?.stderr]
56 .filter(Boolean)
57 .join("\n");
58 if (!/\bE403\b|\b403 Forbidden\b/.test(detail)) throw error;
59 console.warn(
60 `npm refused cleanup of ${name} dist-tag ${stagingTag} with E403; official aliases are already verified`,
61 );
62 }
63 }
64
65 async function waitForOfficialAliases(name) {
66 let tags = {};
67 for (let attempt = 1; attempt <= verifyAttempts; attempt += 1) {
68 tags = distTags(name);
69 if (OFFICIAL_ALIASES.every((tag) => tags[tag] === version)) return tags;
70 if (attempt < verifyAttempts) await delay(verifyDelayMs);
71 }
72 const actual = OFFICIAL_ALIASES
73 .map((tag) => `${tag}=${tags[tag] || "<missing>"}`)
74 .join(", ");
75 throw new Error(`${name} aliases did not converge to ${version}: ${actual}`);
76 }
77
78 for (const name of packages) {
79 const actual = metadata(name);
80 if (actual.name !== name || actual.version !== version) {
81 throw new Error(`${name}@${version} is unavailable or resolved to another package`);
82 }
83 if (expectedSha && actual.gitHead !== expectedSha) {
84 throw new Error(`${name}@${version} gitHead ${actual.gitHead || "<missing>"} does not match ${expectedSha}`);
85 }
86 if (expectedSha && actual.reasonixCandidateSha !== expectedSha) {
87 throw new Error(`${name}@${version} reasonixCandidateSha ${actual.reasonixCandidateSha || "<missing>"} does not match ${expectedSha}`);
88 }
89 }
90
91 // Recovery reruns this after an alias write already succeeded — including after
92 // someone realigned the aliases by hand because the automation could not. Every
93 // dist-tag write is a registry mutation that can be refused (npm treats them as
94 // sensitive operations, so an automation token gets a 403 where an interactive
95 // session does not), and refusing a write we did not need would fail a rerun
96 // that had nothing left to do. So read first and only write what is missing.
97 for (const name of packages) {
98 const before = distTags(name);
99 for (const tag of OFFICIAL_ALIASES) {
100 if (before[tag] === version) continue;
101 execFileSync("npm", ["dist-tag", "add", `${name}@${version}`, tag], { stdio: "inherit" });
102 }
103 const tags = await waitForOfficialAliases(name);
104 if (tags[stagingTag] === version) {
105 removeStagingTag(name);
106 }
107 }
108
108 lines Plain Text