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