返回 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 = 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 waitForPackage(
191 runner,
192 entry,
193 version,
194 candidateSha,
195 attempts,
196 sleep,
197 ) {
198 for (let attempt = 1; attempt <= attempts; attempt += 1) {
199 const metadata = registryPackage(runner, entry.name, version);
200 if (metadata) {
201 verifyRegistryPackage(metadata, entry.name, version, candidateSha);
202 return;
203 }
204 if (attempt < attempts) sleep(10_000);
205 }
206 throw new Error(`${entry.name}@${version} did not become visible in the npm registry`);
207 }
208
209 function ensurePackage(
210 runner,
211 entry,
212 version,
213 candidateSha,
214 stagingTag,
215 attempts,
216 sleep,
217 log,
218 ) {
219 readLocalPackage(entry, version, candidateSha);
220 const existing = registryPackage(runner, entry.name, version);
221 if (existing) {
222 verifyRegistryPackage(existing, entry.name, version, candidateSha);
223 log(`reuse ${entry.name}@${version} from candidate ${candidateSha}`);
224 return;
225 }
226
227 log(`publish ${entry.name}@${version} (${stagingTag})`);
228 try {
229 runner(
230 ["publish", "--access", "public", "--tag", stagingTag],
231 { cwd: entry.dir, inherit: true },
232 );
233 } catch (error) {
234 // A concurrent or retried publisher may have won after our read. Accept it
235 // only when the immutable registry metadata proves the same candidate.
236 const raced = registryPackage(runner, entry.name, version);
237 if (!raced) throw error;
238 verifyRegistryPackage(raced, entry.name, version, candidateSha);
239 }
240 waitForPackage(
241 runner,
242 entry,
243 version,
244 candidateSha,
245 attempts,
246 sleep,
247 );
248 }
249
250 function advanceDistTag(runner, name, version, distTag, attempts, sleep, log) {
251 const current = readDistTag(runner, name, distTag);
252 const comparison = compareDistTagVersions(distTag, version, current);
253 if (comparison > 0) {
254 log(`advance ${name} ${distTag}: ${current || "<unset>"} -> ${version}`);
255 runner(["dist-tag", "add", `${name}@${version}`, distTag], { inherit: true });
256 } else if (comparison === 0) {
257 log(`${name} ${distTag} already points to ${version}`);
258 } else {
259 log(`keep newer ${name} ${distTag} at ${current}; recovered ${version}`);
260 }
261
262 for (let attempt = 1; attempt <= attempts; attempt += 1) {
263 const observed = readDistTag(runner, name, distTag);
264 if (
265 observed &&
266 compareDistTagVersions(distTag, observed, version) >= 0
267 ) {
268 return;
269 }
270 if (attempt < attempts) sleep(10_000);
271 }
272 throw new Error(`${name} dist-tag ${distTag} did not reach ${version} or newer`);
273 }
274
275 function cleanupStagingTag(runner, name, version, stagingTag, log) {
276 const current = readDistTag(runner, name, stagingTag);
277 if (current !== version) return;
278 log(`remove temporary ${name} dist-tag ${stagingTag}`);
279 try {
280 // Capture stderr for this best-effort cleanup so an npm E403 can be
281 // distinguished from transport, authentication, and registry failures.
282 runner(["dist-tag", "rm", name, stagingTag]);
283 } catch (error) {
284 const detail = error instanceof Error ? error.message : String(error);
285 if (!/\bE403\b|\b403 Forbidden\b/.test(detail)) throw error;
286 log(
287 `keep temporary ${name} dist-tag ${stagingTag}: npm refused cleanup with E403`,
288 );
289 }
290 }
291
292 export function publishPackages({
293 packages,
294 version,
295 candidateSha,
296 runner = defaultRunner,
297 sleep = defaultSleep,
298 // npm's public registry can lag a successful immutable publish by several
299 // minutes. Keep this bounded, but allow enough time for normal replication
300 // before recovery treats the package as missing.
301 attempts = 31,
302 log = console.log,
303 }) {
304 if (!Array.isArray(packages) || packages.length === 0) {
305 throw new Error("npm publication requires at least one package");
306 }
307 if (!CANDIDATE_SHA_RE.test(candidateSha)) {
308 throw new Error(`invalid release candidate SHA: ${candidateSha}`);
309 }
310 const distTag = distTagForVersion(version);
311 const stagingTag = `${distTag}-staging`;
312 let failure;
313
314 try {
315 for (const entry of packages) {
316 ensurePackage(
317 runner,
318 entry,
319 version,
320 candidateSha,
321 stagingTag,
322 attempts,
323 sleep,
324 log,
325 );
326 }
327 for (const entry of packages) {
328 advanceDistTag(
329 runner,
330 entry.name,
331 version,
332 distTag,
333 attempts,
334 sleep,
335 log,
336 );
337 }
338 } catch (error) {
339 failure = error;
340 }
341
342 try {
343 for (const entry of packages) {
344 cleanupStagingTag(runner, entry.name, version, stagingTag, log);
345 }
346 } catch (error) {
347 if (!failure) failure = error;
348 }
349
350 if (failure) throw failure;
351 return { distTag, version };
352 }
353
353 lines Plain Text