返回 DeepSeek-Reasonix
release-notes.mjs
根目录 / scripts / release-notes.mjs
1 #!/usr/bin/env node
2
3 import { readFile, writeFile } from "node:fs/promises";
4 import { dirname, resolve } from "node:path";
5 import { fileURLToPath } from "node:url";
6 import {
7 contributorLogins,
8 creditSuffix,
9 githubRefLookup,
10 reportWarnings,
11 resolveCredits,
12 tokenFromEnvironment,
13 unresolvedError,
14 } from "./release-credits.mjs";
15
16 const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
17 export const defaultCatalogPath = resolve(repoRoot, "release-notes/releases.json");
18
19 const localizedFields = ["title", "body"];
20 const changeKinds = ["new", "improved", "fixed"];
21 const itemKinds = new Set(["new", "improved", "fixed", "security"]);
22 const releaseChannels = new Set(["stable", "prerelease"]);
23 const releaseStatuses = new Set(["reviewed", "published"]);
24 const releaseTargetOrder = ["desktop", "cli", "site", "service"];
25 const releaseTargets = new Set(releaseTargetOrder);
26 const releaseTargetingRequiredFrom = "1.31.4";
27
28 function invariant(condition, message) {
29 if (!condition) throw new Error(message);
30 }
31
32 function isObject(value) {
33 return value !== null && typeof value === "object" && !Array.isArray(value);
34 }
35
36 function validateLocalized(value, path) {
37 invariant(isObject(value), `${path} must be an object`);
38 for (const lang of ["en", "zh"]) {
39 invariant(typeof value[lang] === "string" && value[lang].trim(), `${path}.${lang} must be a non-empty string`);
40 }
41 }
42
43 function validateRefs(refs, path) {
44 if (refs === undefined) return;
45 invariant(Array.isArray(refs), `${path} must be an array`);
46 for (const ref of refs) invariant(Number.isInteger(ref) && ref > 0, `${path} contains invalid PR number ${ref}`);
47 }
48
49 function validateItem(item, path, { kind = false, href = false, level = false, targets = false } = {}) {
50 invariant(isObject(item), `${path} must be an object`);
51 for (const field of localizedFields) validateLocalized(item[field], `${path}.${field}`);
52 if (kind) invariant(itemKinds.has(item.kind), `${path}.kind is invalid`);
53 if (href) invariant(typeof item.href === "string" && /^https:\/\//.test(item.href), `${path}.href must be HTTPS`);
54 if (level) invariant(item.level === "info" || item.level === "warning", `${path}.level is invalid`);
55 if (targets || item.targets !== undefined) {
56 invariant(Array.isArray(item.targets) && item.targets.length > 0, `${path}.targets must be a non-empty array`);
57 invariant(new Set(item.targets).size === item.targets.length, `${path}.targets contains duplicates`);
58 for (const target of item.targets) invariant(releaseTargets.has(target), `${path}.targets contains invalid target ${target}`);
59 const sorted = releaseTargetOrder.filter((target) => item.targets.includes(target));
60 invariant(sorted.every((target, index) => target === item.targets[index]), `${path}.targets must use canonical order`);
61 }
62 validateRefs(item.refs, `${path}.refs`);
63 }
64
65 function releaseItems(release) {
66 return [
67 ...release.highlights,
68 ...changeKinds.flatMap((kind) => release.changes[kind]),
69 ...release.upgrade,
70 ...release.risks,
71 ];
72 }
73
74 function itemTargetsUnion(release) {
75 const targets = new Set(releaseItems(release).flatMap((item) => item.targets || []));
76 return releaseTargetOrder.filter((target) => targets.has(target));
77 }
78
79 function semverParts(version) {
80 const match = String(version).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
81 invariant(match, `invalid version ${version}`);
82 return [Number(match[1]), Number(match[2]), Number(match[3]), match[4] || ""];
83 }
84
85 function isCoreVersionAtLeast(version, minimum) {
86 const current = semverParts(version);
87 const floor = semverParts(minimum);
88 for (let index = 0; index < 3; index += 1) {
89 if (current[index] !== floor[index]) return current[index] > floor[index];
90 }
91 return true;
92 }
93
94 export function compareVersionsDesc(a, b) {
95 const aa = semverParts(a);
96 const bb = semverParts(b);
97 for (let i = 0; i < 3; i += 1) {
98 if (aa[i] !== bb[i]) return bb[i] - aa[i];
99 }
100 if (aa[3] === bb[3]) return 0;
101 if (!aa[3]) return -1;
102 if (!bb[3]) return 1;
103 return String(bb[3]).localeCompare(String(aa[3]), "en", { numeric: true });
104 }
105
106 export function validateCatalog(catalog) {
107 invariant(isObject(catalog), "catalog must be an object");
108 invariant(catalog.schemaVersion === 1, "catalog.schemaVersion must be 1");
109 invariant(Array.isArray(catalog.releases) && catalog.releases.length > 0, "catalog.releases must not be empty");
110
111 const versions = new Set();
112 for (const [index, release] of catalog.releases.entries()) {
113 const path = `releases[${index}]`;
114 invariant(isObject(release), `${path} must be an object`);
115 semverParts(release.version);
116 invariant(!versions.has(release.version), `duplicate version ${release.version}`);
117 versions.add(release.version);
118 invariant(/^\d{4}-\d{2}-\d{2}$/.test(release.date), `${path}.date must use YYYY-MM-DD`);
119 invariant(releaseChannels.has(release.channel), `${path}.channel is invalid`);
120 if (release.releaseId !== undefined) {
121 invariant(release.releaseId === release.version, `${path}.releaseId must equal version`);
122 }
123 if (release.baseVersion !== undefined) {
124 semverParts(release.baseVersion);
125 invariant(!release.baseVersion.includes("-"), `${path}.baseVersion must be stable semver`);
126 invariant(
127 release.version === release.baseVersion || release.version.startsWith(`${release.baseVersion}-`),
128 `${path}.baseVersion does not match version`,
129 );
130 }
131 if (release.status !== undefined) {
132 invariant(releaseStatuses.has(release.status), `${path}.status is invalid`);
133 }
134 if (release.candidateSha !== undefined) {
135 invariant(/^[0-9a-f]{40}$/.test(release.candidateSha), `${path}.candidateSha must be a full commit SHA`);
136 }
137 if (release.previousRelease !== undefined) {
138 semverParts(release.previousRelease);
139 invariant(release.previousRelease !== release.version, `${path}.previousRelease must differ from version`);
140 }
141 if (release.builds !== undefined) {
142 invariant(isObject(release.builds), `${path}.builds must be an object`);
143 for (const surface of ["cli", "desktop", "npm"]) {
144 invariant(
145 typeof release.builds[surface] === "string" && release.builds[surface].trim(),
146 `${path}.builds.${surface} must be a non-empty string`,
147 );
148 }
149 }
150 if (release.status !== undefined) {
151 for (const field of ["releaseId", "baseVersion", "previousRelease", "builds"]) {
152 invariant(release[field] !== undefined, `${path}.${field} is required for managed release records`);
153 }
154 if (release.channel === "stable") {
155 invariant(release.version === release.baseVersion, `${path}.stable version must equal baseVersion`);
156 invariant(release.builds.cli === `v${release.version}`, `${path}.builds.cli does not match Stable version`);
157 invariant(release.builds.desktop === `v${release.version}`, `${path}.builds.desktop does not match Stable version`);
158 invariant(release.builds.npm === release.version, `${path}.builds.npm does not match Stable version`);
159 } else {
160 const preview = release.version.match(/^(\d+\.\d+\.\d+)-preview\.([1-9][0-9]*)$/);
161 invariant(preview, `${path}.managed prerelease must use MAJOR.MINOR.PATCH-preview.N`);
162 invariant(preview[1] === release.baseVersion, `${path}.Preview baseVersion does not match version`);
163 invariant(release.builds.cli === `v${release.version}`, `${path}.builds.cli does not match Preview version`);
164 invariant(release.builds.desktop === `v${release.version}`, `${path}.builds.desktop does not match Preview version`);
165 invariant(
166 release.builds.npm === `${release.baseVersion}-canary.${preview[2]}`,
167 `${path}.builds.npm does not match Preview ordinal`,
168 );
169 }
170 }
171 const targetingRequiredByVersion = isCoreVersionAtLeast(release.version, releaseTargetingRequiredFrom);
172 if (targetingRequiredByVersion) {
173 invariant(
174 release.targetingVersion === 1,
175 `${path}.targetingVersion must be 1 for v${releaseTargetingRequiredFrom} and newer`,
176 );
177 } else if (release.targetingVersion !== undefined) {
178 invariant(release.targetingVersion === 1, `${path}.targetingVersion is invalid`);
179 }
180 const targetsRequired = release.targetingVersion === 1;
181 validateLocalized(release.title, `${path}.title`);
182 validateLocalized(release.summary, `${path}.summary`);
183 invariant(Array.isArray(release.surfaces) && release.surfaces.length > 0, `${path}.surfaces must not be empty`);
184 invariant(new Set(release.surfaces).size === release.surfaces.length, `${path}.surfaces contains duplicates`);
185 invariant(Array.isArray(release.guides), `${path}.guides must be an array`);
186 release.guides.forEach((item, itemIndex) => validateItem(item, `${path}.guides[${itemIndex}]`, { href: true }));
187 invariant(Array.isArray(release.highlights) && release.highlights.length > 0, `${path}.highlights must not be empty`);
188 release.highlights.forEach((item, itemIndex) =>
189 validateItem(item, `${path}.highlights[${itemIndex}]`, { kind: true, targets: targetsRequired }),
190 );
191 invariant(isObject(release.changes), `${path}.changes must be an object`);
192 for (const changeKind of changeKinds) {
193 invariant(Array.isArray(release.changes[changeKind]), `${path}.changes.${changeKind} must be an array`);
194 release.changes[changeKind].forEach((item, itemIndex) =>
195 validateItem(item, `${path}.changes.${changeKind}[${itemIndex}]`, { targets: targetsRequired }),
196 );
197 }
198 invariant(Array.isArray(release.upgrade), `${path}.upgrade must be an array`);
199 release.upgrade.forEach((item, itemIndex) =>
200 validateItem(item, `${path}.upgrade[${itemIndex}]`, { level: true, targets: targetsRequired }),
201 );
202 invariant(Array.isArray(release.risks), `${path}.risks must be an array`);
203 release.risks.forEach((item, itemIndex) =>
204 validateItem(item, `${path}.risks[${itemIndex}]`, { targets: targetsRequired }),
205 );
206 if (targetsRequired) {
207 const derivedTargets = itemTargetsUnion(release);
208 invariant(
209 derivedTargets.includes("desktop") || derivedTargets.includes("cli"),
210 `${path} must contain at least one Desktop or CLI update`,
211 );
212 invariant(
213 derivedTargets.length === release.surfaces.length &&
214 derivedTargets.every((target, index) => target === release.surfaces[index]),
215 `${path}.surfaces must equal the canonical union of item targets`,
216 );
217 }
218 invariant(Array.isArray(release.contributors), `${path}.contributors must be an array`);
219 invariant(isObject(release.links), `${path}.links must be an object`);
220 for (const link of ["github", "compare", "download"]) {
221 invariant(typeof release.links[link] === "string" && /^https:\/\//.test(release.links[link]), `${path}.links.${link} must be HTTPS`);
222 }
223 }
224
225 const sorted = [...catalog.releases].sort((a, b) => compareVersionsDesc(a.version, b.version));
226 invariant(
227 sorted.every((release, index) => release.version === catalog.releases[index].version),
228 "catalog.releases must be sorted newest first",
229 );
230 return catalog;
231 }
232
233 export async function loadCatalog(path = defaultCatalogPath) {
234 const catalog = JSON.parse(await readFile(path, "utf8"));
235 return validateCatalog(catalog);
236 }
237
238 export function releaseForVersion(catalog, version) {
239 const normalized = String(version).replace(/^(?:desktop-|npm-)?v/, "");
240 const release = catalog.releases.find((entry) => entry.version === normalized);
241 invariant(release, `release notes for v${normalized} are missing`);
242 return release;
243 }
244
245 function localized(value, lang) {
246 return value[lang] || value.en;
247 }
248
249 function refsSuffix(refs = [], credits) {
250 if (!refs.length) return "";
251 return ` (${refs
252 .map((ref) => `[#${ref}](https://github.com/esengine/DeepSeek-Reasonix/pull/${ref})${creditSuffix(credits.get(ref))}`)
253 .join(", ")})`;
254 }
255
256 function targetLabel(target) {
257 return { desktop: "Desktop", cli: "CLI", site: "Site", service: "Service" }[target] || target;
258 }
259
260 function targetsPrefix(item) {
261 if (!item.targets?.length) return "";
262 return `**[${item.targets.map(targetLabel).join(" · ")}]** `;
263 }
264
265 function renderItems(items, lang, credits) {
266 return items
267 .map(
268 (item) =>
269 `- ${targetsPrefix(item)}**${localized(item.title, lang)}** — ${localized(item.body, lang)}${refsSuffix(item.refs, credits)}`,
270 )
271 .join("\n");
272 }
273
274 function hasTarget(item, target) {
275 return item.targets?.includes(target) || false;
276 }
277
278 function targetItemCount(release, target) {
279 return releaseItems(release).filter((item) => hasTarget(item, target)).length;
280 }
281
282 function appendTargetSection(lines, release, target, lang, credits) {
283 const isZh = lang === "zh";
284 const title = target === "desktop"
285 ? (isZh ? "桌面端更新" : "Desktop updates")
286 : (isZh ? "CLI 端更新" : "CLI updates");
287 lines.push(`## ${title}`, "");
288
289 const highlights = release.highlights.filter((item) => hasTarget(item, target));
290 if (highlights.length) {
291 lines.push(`### ${isZh ? "重点内容" : "Highlights"}`, "", renderItems(highlights, lang, credits), "");
292 }
293 const headings = {
294 new: isZh ? "新增" : "New",
295 improved: isZh ? "改进" : "Improved",
296 fixed: isZh ? "修复" : "Fixed",
297 };
298 for (const kind of changeKinds) {
299 const items = release.changes[kind].filter((item) => hasTarget(item, target));
300 if (items.length) lines.push(`### ${headings[kind]}`, "", renderItems(items, lang, credits), "");
301 }
302
303 if (!highlights.length && !changeKinds.some((kind) => release.changes[kind].some((item) => hasTarget(item, target)))) {
304 const noTargetedItems = targetItemCount(release, target) === 0;
305 lines.push(
306 target === "cli" && noTargetedItems
307 ? (isZh ? "本版本没有 CLI 相关功能、改进或修复,CLI 用户可按需跳过。" : "This release has no CLI features, improvements, or fixes; CLI users may skip it if preferred.")
308 : (isZh ? "此板块没有功能或修复条目;相关说明请查看下方升级提醒和风险提示。" : "No feature or fix entries are listed here; see the upgrade and risk notes below."),
309 "",
310 );
311 }
312 }
313
314 function appendOtherTargetSection(lines, release, lang, credits) {
315 const isZh = lang === "zh";
316 const isOtherOnly = (item) => item.targets?.length && !hasTarget(item, "desktop") && !hasTarget(item, "cli");
317 const highlights = release.highlights.filter(isOtherOnly);
318 const changes = Object.fromEntries(changeKinds.map((kind) => [kind, release.changes[kind].filter(isOtherOnly)]));
319 if (!highlights.length && !changeKinds.some((kind) => changes[kind].length)) return;
320
321 lines.push(`## ${isZh ? "其他项目更新" : "Other project updates"}`, "");
322 if (highlights.length) lines.push(renderItems(highlights, lang, credits), "");
323 for (const kind of changeKinds) {
324 if (changes[kind].length) lines.push(renderItems(changes[kind], lang, credits), "");
325 }
326 }
327
328 export function releaseRefs(release) {
329 return releaseItems(release).flatMap((item) => item.refs || []);
330 }
331
332 // credits maps a ref to its lookup result; a ref absent from it renders plain.
333 export function renderGitHubRelease(release, lang = "zh", credits = new Map()) {
334 const isZh = lang === "zh";
335 const isPreview = release.channel === "prerelease";
336 const channelLabel = isPreview ? (isZh ? "预览版" : "Preview") : (isZh ? "稳定版" : "Stable");
337 const lines = [
338 `> ${localized(release.summary, lang)}`,
339 "",
340 `**${isZh ? "发布渠道" : "Release channel"}:${channelLabel} · v${release.version}**`,
341 "",
342 isZh
343 ? `[English →](https://reasonix.io/changelog/v${release.version}/?lang=en) · [网页版完整更新日志 →](https://reasonix.io/changelog/v${release.version}/)`
344 : `[中文 →](https://reasonix.io/changelog/v${release.version}/?lang=zh) · [Full release notes →](https://reasonix.io/changelog/v${release.version}/?lang=en)`,
345 "",
346 ];
347
348 if (release.guides.length) {
349 lines.push(`## ${isZh ? "使用攻略" : "Guides"}`, "");
350 for (const guide of release.guides) {
351 lines.push(`- [**${localized(guide.title, lang)}**](${guide.href}) — ${localized(guide.body, lang)}`);
352 }
353 lines.push("");
354 }
355
356 lines.push(
357 `## ${isZh ? "概览" : "Overview"}`,
358 "",
359 `**Reasonix v${release.version} — ${localized(release.title, lang)}**`,
360 "",
361 localized(release.summary, lang),
362 "",
363 `${isZh ? "发布日期" : "Released"}:${release.date}`,
364 "",
365 );
366
367 if (release.targetingVersion === 1) {
368 lines.push(
369 `**Desktop ${targetItemCount(release, "desktop")} · CLI ${targetItemCount(release, "cli")}**`,
370 "",
371 );
372 appendTargetSection(lines, release, "desktop", lang, credits);
373 appendTargetSection(lines, release, "cli", lang, credits);
374 appendOtherTargetSection(lines, release, lang, credits);
375 } else {
376 lines.push(`## ${isZh ? "重点内容" : "Highlights"}`, "", renderItems(release.highlights, lang, credits), "");
377 const headings = {
378 new: isZh ? "新功能" : "New",
379 improved: isZh ? "改进" : "Improved",
380 fixed: isZh ? "修复" : "Fixed",
381 };
382 for (const kind of changeKinds) {
383 const items = release.changes[kind];
384 if (!items.length) continue;
385 lines.push(`## ${headings[kind]}`, "", renderItems(items, lang, credits), "");
386 }
387 }
388
389 lines.push(`## ${isZh ? "升级提醒" : "Upgrade notes"}`, "");
390 if (release.upgrade.length) lines.push(renderItems(release.upgrade, lang, credits));
391 else lines.push(isZh ? "本版本无需手动迁移。" : "No manual migration is required.");
392 lines.push("");
393
394 lines.push(`## ${isZh ? "风险提示" : "Risk notes"}`, "");
395 if (release.risks.length) lines.push(renderItems(release.risks, lang, credits));
396 else lines.push(isZh ? "当前没有需要额外操作的已知风险。" : "There are no known risks requiring extra action.");
397 lines.push("");
398
399 lines.push(
400 `## ${isZh ? "下载与安装" : "Download and install"}`,
401 "",
402 `- [${isZh ? "官网按平台下载" : "Platform downloads"}](${release.links.download})`,
403 `- [${isZh ? "查看完整差异" : "Full comparison"}](${release.links.compare})`,
404 "",
405 );
406
407 const contributors = contributorLogins(releaseRefs(release), credits);
408 if (contributors.length) {
409 lines.push(
410 `## ${isZh ? "贡献者" : "Contributors"}`,
411 "",
412 `${isZh ? "感谢本版本的贡献者:" : "Thanks to the contributors in this release: "}${contributors
413 .map((login) => `@${login}`)
414 .join(isZh ? "、" : ", ")}`,
415 "",
416 );
417 }
418 return `${lines.join("\n").trim()}\n`;
419 }
420
421 export async function upsertRelease(release, path = defaultCatalogPath) {
422 const catalog = await loadCatalog(path);
423 const next = {
424 ...catalog,
425 releases: [release, ...catalog.releases.filter((entry) => entry.version !== release.version)].sort((a, b) =>
426 compareVersionsDesc(a.version, b.version),
427 ),
428 };
429 validateCatalog(next);
430 await writeFile(path, `${JSON.stringify(next, null, 2)}\n`);
431 return next;
432 }
433
434 function parseArgs(argv) {
435 const [command = "validate", ...rest] = argv;
436 const values = { command };
437 for (let i = 0; i < rest.length; i += 1) {
438 const arg = rest[i];
439 if (!arg.startsWith("--")) throw new Error(`unexpected argument ${arg}`);
440 values[arg.slice(2)] = rest[++i];
441 }
442 return values;
443 }
444
445 async function main() {
446 const args = parseArgs(process.argv.slice(2));
447 const catalogPath = args.catalog ? resolve(args.catalog) : defaultCatalogPath;
448 const catalog = await loadCatalog(catalogPath);
449 if (args.command === "validate") {
450 console.log(`Validated ${catalog.releases.length} release note(s).`);
451 return;
452 }
453 if (args.command !== "render") throw new Error(`unknown command ${args.command}`);
454 invariant(args.version, "render requires --version");
455 invariant(args.output, "render requires --output");
456 const release = releaseForVersion(catalog, args.version);
457 const lookup = githubRefLookup({ token: tokenFromEnvironment() });
458 const { credits, failures, warnings } = await resolveCredits(releaseRefs(release), lookup);
459 reportWarnings(warnings);
460 const output = resolve(args.output);
461 await writeFile(output, renderGitHubRelease(release, args.lang === "en" ? "en" : "zh", credits));
462 if (failures.length) throw unresolvedError(failures);
463 console.log(`Rendered v${release.version} release notes to ${output}`);
464 }
465
466 if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
467 main().catch((error) => {
468 console.error(error.code ? `${error.code}: ${error.message}` : error.message);
469 process.exitCode = 1;
470 });
471 }
472
472 lines Plain Text