返回 DeepSeek-Reasonix
publish.mjs
根目录 / npm / publish.mjs
1 import { spawnSync } from "node:child_process";
2 import { readFileSync } from "node:fs";
3
4 const CANDIDATE_SHA_RE = /^[0-9a-f]{40}$/;
5 const STABLE_RE = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$/;
6 const CANARY_RE = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)-canary\.(0|[1-9][0-9]*)$/;
7 const SEMVER_RE = /^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?$/;
8
9 function compareNumeric(a, b) {
10 const normalizedA = a.replace(/^0+(?=\d)/, "");
11 const normalizedB = b.replace(/^0+(?=\d)/, "");
12 if (normalizedA.length !== normalizedB.length) {
13 return normalizedA.length > normalizedB.length ? 1 : -1;
14 }
15 if (normalizedA === normalizedB) return 0;
16 return normalizedA > normalizedB ? 1 : -1;
17 }
18
19 function compareIdentifiers(a, b) {
20 const numericA = /^[0-9]+$/.test(a);
21 const numericB = /^[0-9]+$/.test(b);
22 if (numericA && numericB) return compareNumeric(a, b);
23 if (numericA !== numericB) return numericA ? -1 : 1;
24 if (a === b) return 0;
25 return a > b ? 1 : -1;
26 }
27
28 function parseSemver(version) {
29 const match = String(version).match(SEMVER_RE);
30 if (!match) throw new Error(`invalid npm release version: ${version}`);
31 return {
32 core: match.slice(1, 4),
33 prerelease: match[4] ? match[4].split(".") : [],
34 };
35 }
36
37 function compareSemver(a, b) {
38 const aa = parseSemver(a);
39 const bb = parseSemver(b);
40 for (let i = 0; i < aa.core.length; i += 1) {
41 const compared = compareNumeric(aa.core[i], bb.core[i]);
42 if (compared !== 0) return compared;
43 }
44 if (!aa.prerelease.length || !bb.prerelease.length) {
45 if (aa.prerelease.length === bb.prerelease.length) return 0;
46 return aa.prerelease.length ? -1 : 1;
47 }
48 const count = Math.max(aa.prerelease.length, bb.prerelease.length);
49 for (let i = 0; i < count; i += 1) {
50 if (aa.prerelease[i] === undefined) return -1;
51 if (bb.prerelease[i] === undefined) return 1;
52 const compared = compareIdentifiers(aa.prerelease[i], bb.prerelease[i]);
53 if (compared !== 0) return compared;
54 }
55 return 0;
56 }
57
58 function requireVersionForDistTag(distTag, version) {
59 if (distTag === "latest" && STABLE_RE.test(version)) return;
60 if (distTag === "canary" && CANARY_RE.test(version)) return;
61 if (
62 distTag === "next" &&
63 SEMVER_RE.test(version) &&
64 version.includes("-") &&
65 !CANARY_RE.test(version)
66 ) {
67 return;
68 }
69 throw new Error(`version ${version} does not belong to npm dist-tag ${distTag}`);
70 }
71
72 export function distTagForVersion(version) {
73 if (CANARY_RE.test(version)) return "canary";
74 if (SEMVER_RE.test(version) && version.includes("-")) return "next";
75 if (STABLE_RE.test(version)) return "latest";
76 throw new Error(`invalid npm release version: ${version}`);
77 }
78
79 // Returns a positive value when candidate is newer, zero when it is current,
80 // and a negative value when recovery is for an older immutable version.
81 export function compareDistTagVersions(distTag, candidate, current) {
82 requireVersionForDistTag(distTag, candidate);
83 if (!current) return 1;
84 requireVersionForDistTag(distTag, current);
85 return compareSemver(candidate, current);
86 }
87
88 function defaultSleep(milliseconds) {
89 Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, milliseconds);
90 }
91
92 function defaultRunner(args, { cwd, missingOk = false, inherit = false } = {}) {
93 const result = spawnSync("npm", args, {
94 cwd,
95 encoding: "utf8",
96 env: process.env,
97 stdio: inherit ? "inherit" : ["ignore", "pipe", "pipe"],
98 });
99 if (result.status === 0) return inherit ? "" : result.stdout.trim();
100
101 const output = `${result.stdout || ""}\n${result.stderr || ""}`;
102 if (missingOk && /\bE404\b/.test(output)) return null;
103 const detail = output.trim();
104 throw new Error(
105 `npm ${args[0]} failed with exit code ${result.status}${detail ? `: ${detail}` : ""}`,
106 );
107 }
108
109 function parseJSON(output, description) {
110 if (output === null || output === "") return null;
111 try {
112 return JSON.parse(output);
113 } catch (error) {
114 throw new Error(`${description} returned invalid JSON: ${error.message}`);
115 }
116 }
117
118 function readLocalPackage(entry, version, candidateSha) {
119 const pkg = entry.manifest ?? JSON.parse(readFileSync(`${entry.dir}/package.json`, "utf8"));
120 if (pkg.name !== entry.name || pkg.version !== version) {
121 throw new Error(
122 `local package identity mismatch: expected ${entry.name}@${version}, got ${pkg.name}@${pkg.version}`,
123 );
124 }
125 if (pkg.reasonixCandidateSha !== candidateSha) {
126 throw new Error(
127 `${entry.name}@${version} does not record candidate ${candidateSha}`,
128 );
129 }
130 return pkg;
131 }
132
133 function registryPackage(runner, name, version) {
134 const output = runner(
135 [
136 "view",
137 `${name}@${version}`,
138 "name",
139 "version",
140 "reasonixCandidateSha",
141 "gitHead",
142 "--json",
143 ],
144 { missingOk: true },
145 );
146 return parseJSON(output, `${name}@${version} metadata`);
147 }
148
149 function verifyRegistryPackage(metadata, name, version, candidateSha) {
150 if (!metadata) return false;
151 if (metadata.name !== name || metadata.version !== version) {
152 throw new Error(
153 `registry package identity mismatch: expected ${name}@${version}, got ${metadata.name}@${metadata.version}`,
154 );
155 }
156
157 const recordedCandidate = metadata.reasonixCandidateSha;
158 const gitHead = metadata.gitHead;
159 if (recordedCandidate && recordedCandidate !== candidateSha) {
160 throw new Error(
161 `immutable npm package ${name}@${version} belongs to candidate ${recordedCandidate}, expected ${candidateSha}`,
162 );
163 }
164 if (gitHead && gitHead !== candidateSha) {
165 throw new Error(
166 `immutable npm package ${name}@${version} has gitHead ${gitHead}, expected ${candidateSha}`,
167 );
168 }
169 if (!recordedCandidate && !gitHead) {
170 throw new Error(
171 `immutable npm package ${name}@${version} has no candidate provenance`,
172 );
173 }
174 return true;
175 }
176
177 function readDistTag(runner, name, distTag) {
178 const output = runner(
179 ["view", name, `dist-tags.${distTag}`, "--json"],
180 { missingOk: true },
181 );
182 const value = parseJSON(output, `${name} dist-tag ${distTag}`);
183 if (value === null || value === undefined) return "";
184 if (typeof value !== "string") {
185 throw new Error(`${name} dist-tag ${distTag} returned a non-string value`);
186 }
187 return value;
188 }
189
190 function waitForPackages(
191 runner,
192 packages,
193 version,
194 candidateSha,
195 attempts,
196 sleep,
197 log,
198 ) {
199 const pending = new Map(packages.map((entry) => [entry.name, entry]));
200 for (let attempt = 1; attempt <= attempts; attempt += 1) {
201 for (const [name] of pending) {
202 const metadata = registryPackage(runner, name, version);
203 if (metadata) {
204 verifyRegistryPackage(metadata, name, version, candidateSha);
205 pending.delete(name);
206 }
207 }
208 if (pending.size === 0) return;
209 log(`waiting for npm registry visibility (${attempt}/${attempts}): ${[...pending.keys()].join(", ")}`);
210 if (attempt < attempts) sleep(10_000);
211 }
212 throw new Error(`npm packages did not become visible at ${version}: ${[...pending.keys()].join(", ")}`);
213 }
214
215 function ensurePackage(
216 runner,
217 entry,
218 version,
219 candidateSha,
220 stagingTag,
221 log,
222 ) {
223 readLocalPackage(entry, version, candidateSha);
224 const existing = registryPackage(runner, entry.name, version);
225 if (existing) {
226 verifyRegistryPackage(existing, entry.name, version, candidateSha);
227 log(`reuse ${entry.name}@${version} from candidate ${candidateSha}`);
228 return;
229 }
230
231 log(`publish ${entry.name}@${version} (${stagingTag})`);
232 try {
233 const publishTarget = entry.tarball ? [entry.tarball] : [];
234 runner(
235 ["publish", ...publishTarget, "--access", "public", "--provenance", "--tag", stagingTag],
236 { cwd: entry.dir, inherit: true },
237 );
238 } catch (error) {
239 // A concurrent or retried publisher may have won after our read. Accept it
240 // only when the immutable registry metadata proves the same candidate.
241 const raced = registryPackage(runner, entry.name, version);
242 if (!raced) throw error;
243 verifyRegistryPackage(raced, entry.name, version, candidateSha);
244 }
245 }
246
247 function advanceDistTag(runner, name, version, distTag, attempts, sleep, log) {
248 const current = readDistTag(runner, name, distTag);
249 const comparison = compareDistTagVersions(distTag, version, current);
250 if (comparison > 0) {
251 log(`advance ${name} ${distTag}: ${current || "<unset>"} -> ${version}`);
252 runner(["dist-tag", "add", `${name}@${version}`, distTag], { inherit: true });
253 } else if (comparison === 0) {
254 log(`${name} ${distTag} already points to ${version}`);
255 } else {
256 log(`keep newer ${name} ${distTag} at ${current}; recovered ${version}`);
257 }
258
259 for (let attempt = 1; attempt <= attempts; attempt += 1) {
260 const observed = readDistTag(runner, name, distTag);
261 if (
262 observed &&
263 compareDistTagVersions(distTag, observed, version) >= 0
264 ) {
265 return;
266 }
267 if (attempt < attempts) sleep(10_000);
268 }
269 throw new Error(`${name} dist-tag ${distTag} did not reach ${version} or newer`);
270 }
271
272 function cleanupStagingTag(runner, name, version, stagingTag, log) {
273 const current = readDistTag(runner, name, stagingTag);
274 if (current !== version) return;
275 log(`remove temporary ${name} dist-tag ${stagingTag}`);
276 try {
277 // Capture stderr for this best-effort cleanup so an npm E403 can be
278 // distinguished from transport, authentication, and registry failures.
279 runner(["dist-tag", "rm", name, stagingTag]);
280 } catch (error) {
281 const detail = error instanceof Error ? error.message : String(error);
282 if (!/\bE403\b|\b403 Forbidden\b/.test(detail)) throw error;
283 log(
284 `keep temporary ${name} dist-tag ${stagingTag}: npm refused cleanup with E403`,
285 );
286 }
287 }
288
289 export function publishPackages({
290 packages,
291 version,
292 candidateSha,
293 runner = defaultRunner,
294 sleep = defaultSleep,
295 // npm's public registry can lag a successful immutable publish by several
296 // minutes (v1.38.6 exceeded the old five-minute window). Submit the whole
297 // set first, then poll pending packages together for up to twenty minutes
298 // of scheduled waits, plus registry request time. Never republish on E404.
299 attempts = 121,
300 log = console.log,
301 }) {
302 if (!Array.isArray(packages) || packages.length === 0) {
303 throw new Error("npm publication requires at least one package");
304 }
305 if (!CANDIDATE_SHA_RE.test(candidateSha)) {
306 throw new Error(`invalid release candidate SHA: ${candidateSha}`);
307 }
308 const distTag = distTagForVersion(version);
309 const stagingTag = `${distTag}-staging`;
310 for (const entry of packages) {
311 ensurePackage(runner, entry, version, candidateSha, stagingTag, log);
312 }
313 // A delayed platform must not prevent the remaining immutable uploads.
314 // All packages must prove the candidate before any official alias moves.
315 waitForPackages(runner, packages, version, candidateSha, attempts, sleep, log);
316 for (const entry of packages) {
317 advanceDistTag(runner, entry.name, version, distTag, attempts, sleep, log);
318 }
319 // Keep staging evidence on failure so recovery can inspect and reuse it.
320 for (const entry of packages) {
321 cleanupStagingTag(runner, entry.name, version, stagingTag, log);
322 }
323 return { distTag, version };
324 }
325
325 lines Plain Text