返回 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
7 const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
8 export const defaultCatalogPath = resolve(repoRoot, "release-notes/releases.json");
9
10 const localizedFields = ["title", "body"];
11 const changeKinds = ["new", "improved", "fixed"];
12 const itemKinds = new Set(["new", "improved", "fixed", "security"]);
13 const releaseChannels = new Set(["stable", "prerelease"]);
14 const releaseStatuses = new Set(["reviewed", "published"]);
15
16 function invariant(condition, message) {
17 if (!condition) throw new Error(message);
18 }
19
20 function isObject(value) {
21 return value !== null && typeof value === "object" && !Array.isArray(value);
22 }
23
24 function validateLocalized(value, path) {
25 invariant(isObject(value), `${path} must be an object`);
26 for (const lang of ["en", "zh"]) {
27 invariant(typeof value[lang] === "string" && value[lang].trim(), `${path}.${lang} must be a non-empty string`);
28 }
29 }
30
31 function validateRefs(refs, path) {
32 if (refs === undefined) return;
33 invariant(Array.isArray(refs), `${path} must be an array`);
34 for (const ref of refs) invariant(Number.isInteger(ref) && ref > 0, `${path} contains invalid PR number ${ref}`);
35 }
36
37 function validateItem(item, path, { kind = false, href = false, level = false } = {}) {
38 invariant(isObject(item), `${path} must be an object`);
39 for (const field of localizedFields) validateLocalized(item[field], `${path}.${field}`);
40 if (kind) invariant(itemKinds.has(item.kind), `${path}.kind is invalid`);
41 if (href) invariant(typeof item.href === "string" && /^https:\/\//.test(item.href), `${path}.href must be HTTPS`);
42 if (level) invariant(item.level === "info" || item.level === "warning", `${path}.level is invalid`);
43 validateRefs(item.refs, `${path}.refs`);
44 }
45
46 function semverParts(version) {
47 const match = String(version).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
48 invariant(match, `invalid version ${version}`);
49 return [Number(match[1]), Number(match[2]), Number(match[3]), match[4] || ""];
50 }
51
52 export function compareVersionsDesc(a, b) {
53 const aa = semverParts(a);
54 const bb = semverParts(b);
55 for (let i = 0; i < 3; i += 1) {
56 if (aa[i] !== bb[i]) return bb[i] - aa[i];
57 }
58 if (aa[3] === bb[3]) return 0;
59 if (!aa[3]) return -1;
60 if (!bb[3]) return 1;
61 return String(bb[3]).localeCompare(String(aa[3]), "en", { numeric: true });
62 }
63
64 export function validateCatalog(catalog) {
65 invariant(isObject(catalog), "catalog must be an object");
66 invariant(catalog.schemaVersion === 1, "catalog.schemaVersion must be 1");
67 invariant(Array.isArray(catalog.releases) && catalog.releases.length > 0, "catalog.releases must not be empty");
68
69 const versions = new Set();
70 for (const [index, release] of catalog.releases.entries()) {
71 const path = `releases[${index}]`;
72 invariant(isObject(release), `${path} must be an object`);
73 semverParts(release.version);
74 invariant(!versions.has(release.version), `duplicate version ${release.version}`);
75 versions.add(release.version);
76 invariant(/^\d{4}-\d{2}-\d{2}$/.test(release.date), `${path}.date must use YYYY-MM-DD`);
77 invariant(releaseChannels.has(release.channel), `${path}.channel is invalid`);
78 if (release.releaseId !== undefined) {
79 invariant(release.releaseId === release.version, `${path}.releaseId must equal version`);
80 }
81 if (release.baseVersion !== undefined) {
82 semverParts(release.baseVersion);
83 invariant(!release.baseVersion.includes("-"), `${path}.baseVersion must be stable semver`);
84 invariant(
85 release.version === release.baseVersion || release.version.startsWith(`${release.baseVersion}-`),
86 `${path}.baseVersion does not match version`,
87 );
88 }
89 if (release.status !== undefined) {
90 invariant(releaseStatuses.has(release.status), `${path}.status is invalid`);
91 }
92 if (release.candidateSha !== undefined) {
93 invariant(/^[0-9a-f]{40}$/.test(release.candidateSha), `${path}.candidateSha must be a full commit SHA`);
94 }
95 if (release.previousRelease !== undefined) {
96 semverParts(release.previousRelease);
97 invariant(release.previousRelease !== release.version, `${path}.previousRelease must differ from version`);
98 }
99 if (release.builds !== undefined) {
100 invariant(isObject(release.builds), `${path}.builds must be an object`);
101 for (const surface of ["cli", "desktop", "npm"]) {
102 invariant(
103 typeof release.builds[surface] === "string" && release.builds[surface].trim(),
104 `${path}.builds.${surface} must be a non-empty string`,
105 );
106 }
107 }
108 if (release.status !== undefined) {
109 for (const field of ["releaseId", "baseVersion", "previousRelease", "builds"]) {
110 invariant(release[field] !== undefined, `${path}.${field} is required for managed release records`);
111 }
112 if (release.channel === "stable") {
113 invariant(release.version === release.baseVersion, `${path}.stable version must equal baseVersion`);
114 invariant(release.builds.cli === `v${release.version}`, `${path}.builds.cli does not match Stable version`);
115 invariant(release.builds.desktop === `v${release.version}`, `${path}.builds.desktop does not match Stable version`);
116 invariant(release.builds.npm === release.version, `${path}.builds.npm does not match Stable version`);
117 } else {
118 const preview = release.version.match(/^(\d+\.\d+\.\d+)-preview\.([1-9][0-9]*)$/);
119 invariant(preview, `${path}.managed prerelease must use MAJOR.MINOR.PATCH-preview.N`);
120 invariant(preview[1] === release.baseVersion, `${path}.Preview baseVersion does not match version`);
121 invariant(release.builds.cli === `v${release.version}`, `${path}.builds.cli does not match Preview version`);
122 invariant(release.builds.desktop === `v${release.version}`, `${path}.builds.desktop does not match Preview version`);
123 invariant(
124 release.builds.npm === `${release.baseVersion}-canary.${preview[2]}`,
125 `${path}.builds.npm does not match Preview ordinal`,
126 );
127 }
128 }
129 validateLocalized(release.title, `${path}.title`);
130 validateLocalized(release.summary, `${path}.summary`);
131 invariant(Array.isArray(release.surfaces) && release.surfaces.length > 0, `${path}.surfaces must not be empty`);
132 invariant(new Set(release.surfaces).size === release.surfaces.length, `${path}.surfaces contains duplicates`);
133 invariant(Array.isArray(release.guides), `${path}.guides must be an array`);
134 release.guides.forEach((item, itemIndex) => validateItem(item, `${path}.guides[${itemIndex}]`, { href: true }));
135 invariant(Array.isArray(release.highlights) && release.highlights.length > 0, `${path}.highlights must not be empty`);
136 release.highlights.forEach((item, itemIndex) => validateItem(item, `${path}.highlights[${itemIndex}]`, { kind: true }));
137 invariant(isObject(release.changes), `${path}.changes must be an object`);
138 for (const changeKind of changeKinds) {
139 invariant(Array.isArray(release.changes[changeKind]), `${path}.changes.${changeKind} must be an array`);
140 release.changes[changeKind].forEach((item, itemIndex) =>
141 validateItem(item, `${path}.changes.${changeKind}[${itemIndex}]`),
142 );
143 }
144 invariant(Array.isArray(release.upgrade), `${path}.upgrade must be an array`);
145 release.upgrade.forEach((item, itemIndex) => validateItem(item, `${path}.upgrade[${itemIndex}]`, { level: true }));
146 invariant(Array.isArray(release.risks), `${path}.risks must be an array`);
147 release.risks.forEach((item, itemIndex) => validateItem(item, `${path}.risks[${itemIndex}]`));
148 invariant(Array.isArray(release.contributors), `${path}.contributors must be an array`);
149 invariant(isObject(release.links), `${path}.links must be an object`);
150 for (const link of ["github", "compare", "download"]) {
151 invariant(typeof release.links[link] === "string" && /^https:\/\//.test(release.links[link]), `${path}.links.${link} must be HTTPS`);
152 }
153 }
154
155 const sorted = [...catalog.releases].sort((a, b) => compareVersionsDesc(a.version, b.version));
156 invariant(
157 sorted.every((release, index) => release.version === catalog.releases[index].version),
158 "catalog.releases must be sorted newest first",
159 );
160 return catalog;
161 }
162
163 export async function loadCatalog(path = defaultCatalogPath) {
164 const catalog = JSON.parse(await readFile(path, "utf8"));
165 return validateCatalog(catalog);
166 }
167
168 export function releaseForVersion(catalog, version) {
169 const normalized = String(version).replace(/^(?:desktop-|npm-)?v/, "");
170 const release = catalog.releases.find((entry) => entry.version === normalized);
171 invariant(release, `release notes for v${normalized} are missing`);
172 return release;
173 }
174
175 function localized(value, lang) {
176 return value[lang] || value.en;
177 }
178
179 function refsSuffix(refs = []) {
180 if (!refs.length) return "";
181 return ` (${refs.map((ref) => `[#${ref}](https://github.com/esengine/DeepSeek-Reasonix/pull/${ref})`).join(", ")})`;
182 }
183
184 function renderItems(items, lang) {
185 return items
186 .map((item) => `- **${localized(item.title, lang)}** — ${localized(item.body, lang)}${refsSuffix(item.refs)}`)
187 .join("\n");
188 }
189
190 export function renderGitHubRelease(release, lang = "zh") {
191 const isZh = lang === "zh";
192 const isPreview = release.channel === "prerelease";
193 const channelLabel = isPreview ? (isZh ? "预览版" : "Preview") : (isZh ? "稳定版" : "Stable");
194 const lines = [
195 `> ${localized(release.summary, lang)}`,
196 "",
197 `**${isZh ? "发布渠道" : "Release channel"}:${channelLabel} · v${release.version}**`,
198 "",
199 isZh
200 ? `[English →](https://reasonix.io/changelog/v${release.version}/?lang=en) · [网页版完整更新日志 →](https://reasonix.io/changelog/v${release.version}/)`
201 : `[中文 →](https://reasonix.io/changelog/v${release.version}/?lang=zh) · [Full release notes →](https://reasonix.io/changelog/v${release.version}/?lang=en)`,
202 "",
203 ];
204
205 if (release.guides.length) {
206 lines.push(`## ${isZh ? "使用攻略" : "Guides"}`, "");
207 for (const guide of release.guides) {
208 lines.push(`- [**${localized(guide.title, lang)}**](${guide.href}) — ${localized(guide.body, lang)}`);
209 }
210 lines.push("");
211 }
212
213 lines.push(
214 `## ${isZh ? "概览" : "Overview"}`,
215 "",
216 `**Reasonix v${release.version} — ${localized(release.title, lang)}**`,
217 "",
218 localized(release.summary, lang),
219 "",
220 `${isZh ? "发布日期" : "Released"}:${release.date}`,
221 "",
222 `## ${isZh ? "重点内容" : "Highlights"}`,
223 "",
224 renderItems(release.highlights, lang),
225 "",
226 );
227
228 const headings = {
229 new: isZh ? "新功能" : "New",
230 improved: isZh ? "改进" : "Improved",
231 fixed: isZh ? "修复" : "Fixed",
232 };
233 for (const kind of changeKinds) {
234 const items = release.changes[kind];
235 if (!items.length) continue;
236 lines.push(`## ${headings[kind]}`, "", renderItems(items, lang), "");
237 }
238
239 lines.push(`## ${isZh ? "升级提醒" : "Upgrade notes"}`, "");
240 if (release.upgrade.length) lines.push(renderItems(release.upgrade, lang));
241 else lines.push(isZh ? "本版本无需手动迁移。" : "No manual migration is required.");
242 lines.push("");
243
244 lines.push(`## ${isZh ? "风险提示" : "Risk notes"}`, "");
245 if (release.risks.length) lines.push(renderItems(release.risks, lang));
246 else lines.push(isZh ? "当前没有需要额外操作的已知风险。" : "There are no known risks requiring extra action.");
247 lines.push("");
248
249 if (release.contributors.length) {
250 lines.push(
251 `## ${isZh ? "致谢" : "Thanks"}`,
252 "",
253 `${isZh ? "感谢本版本的贡献者" : "Thanks to the contributors in this release"}:${release.contributors
254 .map((name) => `[@${name}](https://github.com/${name})`)
255 .join("、")}`,
256 "",
257 );
258 }
259
260 lines.push(
261 `## ${isZh ? "下载与安装" : "Download and install"}`,
262 "",
263 `- [${isZh ? "官网按平台下载" : "Platform downloads"}](${release.links.download})`,
264 `- [${isZh ? "查看完整差异" : "Full comparison"}](${release.links.compare})`,
265 "",
266 );
267 return `${lines.join("\n").trim()}\n`;
268 }
269
270 export async function upsertRelease(release, path = defaultCatalogPath) {
271 const catalog = await loadCatalog(path);
272 const next = {
273 ...catalog,
274 releases: [release, ...catalog.releases.filter((entry) => entry.version !== release.version)].sort((a, b) =>
275 compareVersionsDesc(a.version, b.version),
276 ),
277 };
278 validateCatalog(next);
279 await writeFile(path, `${JSON.stringify(next, null, 2)}\n`);
280 return next;
281 }
282
283 function parseArgs(argv) {
284 const [command = "validate", ...rest] = argv;
285 const values = { command };
286 for (let i = 0; i < rest.length; i += 1) {
287 const arg = rest[i];
288 if (!arg.startsWith("--")) throw new Error(`unexpected argument ${arg}`);
289 values[arg.slice(2)] = rest[++i];
290 }
291 return values;
292 }
293
294 async function main() {
295 const args = parseArgs(process.argv.slice(2));
296 const catalogPath = args.catalog ? resolve(args.catalog) : defaultCatalogPath;
297 const catalog = await loadCatalog(catalogPath);
298 if (args.command === "validate") {
299 console.log(`Validated ${catalog.releases.length} release note(s).`);
300 return;
301 }
302 if (args.command !== "render") throw new Error(`unknown command ${args.command}`);
303 invariant(args.version, "render requires --version");
304 invariant(args.output, "render requires --output");
305 const release = releaseForVersion(catalog, args.version);
306 const output = resolve(args.output);
307 await writeFile(output, renderGitHubRelease(release, args.lang === "en" ? "en" : "zh"));
308 console.log(`Rendered v${release.version} release notes to ${output}`);
309 }
310
311 if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
312 main().catch((error) => {
313 console.error(error.message);
314 process.exitCode = 1;
315 });
316 }
317
317 lines Plain Text