feat(release): add bilingual product changelog (#6576)

Problem:
Release notes were assembled independently from commit-oriented GitHub output, leaving the website, desktop updater, and CLI/Desktop releases without one reviewed product narrative.

Root cause:
There was no structured bilingual release catalog or review workflow shared by the site, desktop UI, and tag publishers.

Fix:
Add a validated release catalog and DeepSeek-assisted draft workflow, publish responsive website changelog pages with shared navigation, link Desktop update surfaces to the web history, and render the reviewed notes into CLI and Desktop GitHub releases.

Verification:
- node scripts/release-notes.mjs validate
- node --test scripts/release-notes.test.mjs
- npm test and Astro build in site/
- pnpm run build in desktop/frontend/
- actionlint for all affected workflows
- git diff --check

Cache-impact: none - release metadata, website UI, desktop links, and publication workflows do not change provider-visible prompts, tool schemas, request serialization, or ordering.
Cache-guard: existing scripts/cache-guard.sh remains applicable; no cache-sensitive path changed.
This commit is contained in:
SivanCola authored and GitHub committed 2026-07-16 18:49:57 +08:00
1 parent 5c2c3d5d64
commit 751acd4e6f
30 files changed
+1816 -87

No files matched your search

+199
View File
@@ -0,0 +1,199 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
import { loadCatalog, upsertRelease, validateCatalog } from "./release-notes.mjs";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const apiBase = process.env.DEEPSEEK_API_BASE || "https://api.deepseek.com";
const model = process.env.DEEPSEEK_MODEL || "deepseek-v4-pro";
function parseArgs(argv) {
const values = {};
for (let index = 0; index < argv.length; index += 1) {
const arg = argv[index];
if (!arg.startsWith("--")) throw new Error(`unexpected argument ${arg}`);
values[arg.slice(2)] = argv[++index];
}
return values;
}
function runGit(args) {
return execFileSync("git", args, { cwd: repoRoot, encoding: "utf8" }).trim();
}
function normalizeVersion(version) {
return String(version || "").replace(/^(?:desktop-|npm-)?v/, "");
}
function repositoryName() {
if (process.env.GITHUB_REPOSITORY) return process.env.GITHUB_REPOSITORY;
const remote = runGit(["remote", "get-url", "origin"]);
const match = remote.match(/github\.com[/:]([^/]+\/[^/.]+)(?:\.git)?$/);
if (!match) throw new Error("cannot determine GitHub repository; set GITHUB_REPOSITORY");
return match[1];
}
function commitRange(from, to) {
return runGit(["log", "--first-parent", "--format=%H%x09%s%x09%b%x00", `${from}..${to}`])
.split("\0")
.map((record) => record.trim())
.filter(Boolean)
.map((record) => {
const [sha, subject, ...body] = record.split("\t");
return { sha, subject, body: body.join("\t").trim() };
});
}
function prNumbersFromCommits(commits) {
const refs = new Set();
for (const commit of commits) {
for (const match of `${commit.subject}\n${commit.body}`.matchAll(/#(\d+)/g)) refs.add(Number(match[1]));
}
return [...refs];
}
async function githubJson(path, { allowMissing = false } = {}) {
const headers = { Accept: "application/vnd.github+json", "User-Agent": "reasonix-release-notes" };
if (process.env.GITHUB_TOKEN || process.env.GH_TOKEN) {
headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN || process.env.GH_TOKEN}`;
}
const response = await fetch(`https://api.github.com${path}`, { headers, signal: AbortSignal.timeout(30_000) });
if (allowMissing && response.status === 404) return null;
if (!response.ok) throw new Error(`GitHub API ${path} failed: ${response.status}`);
return response.json();
}
async function collectPullRequests(repository, commits) {
const numbers = new Set(prNumbersFromCommits(commits));
const associated = await Promise.all(
commits.map((commit) => githubJson(`/repos/${repository}/commits/${commit.sha}/pulls`, { allowMissing: true })),
);
for (const pulls of associated) for (const pull of pulls || []) numbers.add(pull.number);
const pulls = await Promise.all([...numbers].map((number) => githubJson(`/repos/${repository}/pulls/${number}`, { allowMissing: true })));
return pulls.filter(Boolean).map((pull) => ({
number: pull.number,
title: pull.title,
body: String(pull.body || "").slice(0, 2000),
author: pull.user?.login || "",
labels: (pull.labels || []).map((label) => label.name),
}));
}
function collectDocLinks(from, repository, to) {
const paths = runGit(["diff", "--name-only", `${from}..${to}`])
.split("\n")
.filter((path) => /^(?:docs|README)[/\w.-]*\.(?:md|mdx)$/i.test(path));
return paths.map((path) => `https://github.com/${repository}/blob/${to}/${path}`);
}
function assertGroundedRefs(value, allowedRefs, path = "release") {
if (Array.isArray(value)) {
value.forEach((item, index) => assertGroundedRefs(item, allowedRefs, `${path}[${index}]`));
return;
}
if (!value || typeof value !== "object") return;
if (Array.isArray(value.refs)) {
for (const ref of value.refs) {
if (!allowedRefs.has(ref)) throw new Error(`${path}.refs contains PR #${ref}, which is outside the release range`);
}
}
for (const [key, child] of Object.entries(value)) assertGroundedRefs(child, allowedRefs, `${path}.${key}`);
}
function extractJson(content) {
if (!content?.trim()) throw new Error("DeepSeek returned empty content");
const parsed = JSON.parse(content);
return parsed.release || parsed;
}
async function askDeepSeek(payload, retry = true) {
const key = process.env.DEEPSEEK_API_KEY;
if (!key) throw new Error("DEEPSEEK_API_KEY is required");
const response = await fetch(`${apiBase.replace(/\/$/, "")}/chat/completions`, {
method: "POST",
headers: { Authorization: `Bearer ${key}`, "Content-Type": "application/json" },
body: JSON.stringify({
model,
temperature: 0,
max_tokens: 8000,
response_format: { type: "json_object" },
messages: [
{
role: "system",
content: `You are Reasonix's release editor. Return one JSON object with a \"release\" property. Write factual, user-facing product release notes in equivalent English and Simplified Chinese. Group changes by user outcome, not by commit. Never invent capabilities, migrations, risks, PR numbers, contributors, URLs, or metrics. Every highlight and change must cite one or more supplied PR numbers. Use this exact release shape:
{
\"version\": \"semver\", \"date\": \"YYYY-MM-DD\", \"channel\": \"stable|prerelease\",
\"title\": {\"en\":\"\",\"zh\":\"\"}, \"summary\": {\"en\":\"\",\"zh\":\"\"},
\"surfaces\": [\"desktop\"],
\"guides\": [{\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"href\":\"https://...\"}],
\"highlights\": [{\"kind\":\"new|improved|fixed|security\",\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"refs\":[123]}],
\"changes\": {\"new\":[],\"improved\":[],\"fixed\":[]},
\"upgrade\": [{\"level\":\"info|warning\",\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"refs\":[123]}],
\"risks\": [{\"title\":{\"en\":\"\",\"zh\":\"\"},\"body\":{\"en\":\"\",\"zh\":\"\"},\"refs\":[123]}],
\"contributors\": [], \"links\": {\"github\":\"https://...\",\"compare\":\"https://...\",\"download\":\"https://...\"}
}
Return guides only for supplied documentation URLs. Mention upgrade action or risk only when explicitly supported; otherwise use empty arrays. Output JSON only.`,
},
{ role: "user", content: `Create the release record from these public GitHub sources:\n${JSON.stringify(payload)}` },
],
}),
});
if (!response.ok) throw new Error(`DeepSeek API failed: ${response.status} ${await response.text()}`);
const data = await response.json();
try {
return extractJson(data.choices?.[0]?.message?.content);
} catch (error) {
if (!retry) throw error;
return askDeepSeek(payload, false);
}
}
async function main() {
const args = parseArgs(process.argv.slice(2));
if (!args.version) throw new Error("--version is required");
const version = normalizeVersion(args.version);
const catalog = await loadCatalog();
const previous = args.from || catalog.releases.find((release) => release.version !== version)?.version;
if (!previous) throw new Error("--from is required when no previous release exists");
const from = previous.match(/^(?:desktop-|npm-)?v/) ? previous : `desktop-v${previous}`;
const to = args.to || "HEAD";
const repository = repositoryName();
const commits = commitRange(from, to);
if (!commits.length) throw new Error(`no commits found in ${from}..${to}`);
const pulls = await collectPullRequests(repository, commits);
if (!pulls.length) throw new Error(`no pull requests found in ${from}..${to}`);
const docLinks = collectDocLinks(from, repository, to);
const date = args.date || new Date().toISOString().slice(0, 10);
const tag = args.tag || `desktop-v${version}`;
const source = {
version,
date,
channel: version.includes("-") ? "prerelease" : "stable",
range: `${from}..${to}`,
pullRequests: pulls,
documentationUrls: docLinks,
};
const release = await askDeepSeek(source);
release.version = version;
release.date = date;
release.channel = source.channel;
release.contributors = [...new Set(pulls.map((pull) => pull.author).filter(Boolean))];
release.links = {
github: `https://github.com/${repository}/releases/tag/${tag}`,
compare: `https://github.com/${repository}/compare/${from}...${tag}`,
download: "https://reasonix.io/?download=desktop#start",
};
release.guides = (release.guides || []).filter((guide) => docLinks.includes(guide.href));
assertGroundedRefs(release, new Set(pulls.map((pull) => pull.number)));
validateCatalog({ schemaVersion: 1, releases: [release] });
await upsertRelease(release);
console.log(`Generated bilingual release notes for v${version} from ${pulls.length} pull request(s).`);
}
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
+259
View File
@@ -0,0 +1,259 @@
#!/usr/bin/env node
import { readFile, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
export const defaultCatalogPath = resolve(repoRoot, "release-notes/releases.json");
const localizedFields = ["title", "body"];
const changeKinds = ["new", "improved", "fixed"];
const itemKinds = new Set(["new", "improved", "fixed", "security"]);
function invariant(condition, message) {
if (!condition) throw new Error(message);
}
function isObject(value) {
return value !== null && typeof value === "object" && !Array.isArray(value);
}
function validateLocalized(value, path) {
invariant(isObject(value), `${path} must be an object`);
for (const lang of ["en", "zh"]) {
invariant(typeof value[lang] === "string" && value[lang].trim(), `${path}.${lang} must be a non-empty string`);
}
}
function validateRefs(refs, path) {
if (refs === undefined) return;
invariant(Array.isArray(refs), `${path} must be an array`);
for (const ref of refs) invariant(Number.isInteger(ref) && ref > 0, `${path} contains invalid PR number ${ref}`);
}
function validateItem(item, path, { kind = false, href = false, level = false } = {}) {
invariant(isObject(item), `${path} must be an object`);
for (const field of localizedFields) validateLocalized(item[field], `${path}.${field}`);
if (kind) invariant(itemKinds.has(item.kind), `${path}.kind is invalid`);
if (href) invariant(typeof item.href === "string" && /^https:\/\//.test(item.href), `${path}.href must be HTTPS`);
if (level) invariant(item.level === "info" || item.level === "warning", `${path}.level is invalid`);
validateRefs(item.refs, `${path}.refs`);
}
function semverParts(version) {
const match = String(version).match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
invariant(match, `invalid version ${version}`);
return [Number(match[1]), Number(match[2]), Number(match[3]), match[4] || ""];
}
function compareVersionsDesc(a, b) {
const aa = semverParts(a);
const bb = semverParts(b);
for (let i = 0; i < 3; i += 1) {
if (aa[i] !== bb[i]) return bb[i] - aa[i];
}
if (aa[3] === bb[3]) return 0;
if (!aa[3]) return -1;
if (!bb[3]) return 1;
return String(bb[3]).localeCompare(String(aa[3]), "en", { numeric: true });
}
export function validateCatalog(catalog) {
invariant(isObject(catalog), "catalog must be an object");
invariant(catalog.schemaVersion === 1, "catalog.schemaVersion must be 1");
invariant(Array.isArray(catalog.releases) && catalog.releases.length > 0, "catalog.releases must not be empty");
const versions = new Set();
for (const [index, release] of catalog.releases.entries()) {
const path = `releases[${index}]`;
invariant(isObject(release), `${path} must be an object`);
semverParts(release.version);
invariant(!versions.has(release.version), `duplicate version ${release.version}`);
versions.add(release.version);
invariant(/^\d{4}-\d{2}-\d{2}$/.test(release.date), `${path}.date must use YYYY-MM-DD`);
invariant(release.channel === "stable" || release.channel === "prerelease", `${path}.channel is invalid`);
validateLocalized(release.title, `${path}.title`);
validateLocalized(release.summary, `${path}.summary`);
invariant(Array.isArray(release.surfaces) && release.surfaces.length > 0, `${path}.surfaces must not be empty`);
invariant(new Set(release.surfaces).size === release.surfaces.length, `${path}.surfaces contains duplicates`);
invariant(Array.isArray(release.guides), `${path}.guides must be an array`);
release.guides.forEach((item, itemIndex) => validateItem(item, `${path}.guides[${itemIndex}]`, { href: true }));
invariant(Array.isArray(release.highlights) && release.highlights.length > 0, `${path}.highlights must not be empty`);
release.highlights.forEach((item, itemIndex) => validateItem(item, `${path}.highlights[${itemIndex}]`, { kind: true }));
invariant(isObject(release.changes), `${path}.changes must be an object`);
for (const changeKind of changeKinds) {
invariant(Array.isArray(release.changes[changeKind]), `${path}.changes.${changeKind} must be an array`);
release.changes[changeKind].forEach((item, itemIndex) =>
validateItem(item, `${path}.changes.${changeKind}[${itemIndex}]`),
);
}
invariant(Array.isArray(release.upgrade), `${path}.upgrade must be an array`);
release.upgrade.forEach((item, itemIndex) => validateItem(item, `${path}.upgrade[${itemIndex}]`, { level: true }));
invariant(Array.isArray(release.risks), `${path}.risks must be an array`);
release.risks.forEach((item, itemIndex) => validateItem(item, `${path}.risks[${itemIndex}]`));
invariant(Array.isArray(release.contributors), `${path}.contributors must be an array`);
invariant(isObject(release.links), `${path}.links must be an object`);
for (const link of ["github", "compare", "download"]) {
invariant(typeof release.links[link] === "string" && /^https:\/\//.test(release.links[link]), `${path}.links.${link} must be HTTPS`);
}
}
const sorted = [...catalog.releases].sort((a, b) => compareVersionsDesc(a.version, b.version));
invariant(
sorted.every((release, index) => release.version === catalog.releases[index].version),
"catalog.releases must be sorted newest first",
);
return catalog;
}
export async function loadCatalog(path = defaultCatalogPath) {
const catalog = JSON.parse(await readFile(path, "utf8"));
return validateCatalog(catalog);
}
export function releaseForVersion(catalog, version) {
const normalized = String(version).replace(/^(?:desktop-|npm-)?v/, "");
const release = catalog.releases.find((entry) => entry.version === normalized);
invariant(release, `release notes for v${normalized} are missing`);
return release;
}
function localized(value, lang) {
return value[lang] || value.en;
}
function refsSuffix(refs = []) {
if (!refs.length) return "";
return ` (${refs.map((ref) => `[#${ref}](https://github.com/esengine/DeepSeek-Reasonix/pull/${ref})`).join(", ")})`;
}
function renderItems(items, lang) {
return items
.map((item) => `- **${localized(item.title, lang)}** — ${localized(item.body, lang)}${refsSuffix(item.refs)}`)
.join("\n");
}
export function renderGitHubRelease(release, lang = "zh") {
const isZh = lang === "zh";
const lines = [
`> ${localized(release.summary, lang)}`,
"",
isZh
? `[English →](https://reasonix.io/changelog/v${release.version}/?lang=en) · [网页版完整更新日志 →](https://reasonix.io/changelog/v${release.version}/)`
: `[中文 →](https://reasonix.io/changelog/v${release.version}/?lang=zh) · [Full release notes →](https://reasonix.io/changelog/v${release.version}/?lang=en)`,
"",
];
if (release.guides.length) {
lines.push(`## ${isZh ? "使用攻略" : "Guides"}`, "");
for (const guide of release.guides) {
lines.push(`- [**${localized(guide.title, lang)}**](${guide.href}) — ${localized(guide.body, lang)}`);
}
lines.push("");
}
lines.push(
`## ${isZh ? "概览" : "Overview"}`,
"",
`**Reasonix v${release.version} — ${localized(release.title, lang)}**`,
"",
localized(release.summary, lang),
"",
`${isZh ? "发布日期" : "Released"}:${release.date}`,
"",
`## ${isZh ? "重点内容" : "Highlights"}`,
"",
renderItems(release.highlights, lang),
"",
);
const headings = {
new: isZh ? "新功能" : "New",
improved: isZh ? "改进" : "Improved",
fixed: isZh ? "修复" : "Fixed",
};
for (const kind of changeKinds) {
const items = release.changes[kind];
if (!items.length) continue;
lines.push(`## ${headings[kind]}`, "", renderItems(items, lang), "");
}
lines.push(`## ${isZh ? "升级提醒" : "Upgrade notes"}`, "");
if (release.upgrade.length) lines.push(renderItems(release.upgrade, lang));
else lines.push(isZh ? "本版本无需手动迁移。" : "No manual migration is required.");
lines.push("");
lines.push(`## ${isZh ? "风险提示" : "Risk notes"}`, "");
if (release.risks.length) lines.push(renderItems(release.risks, lang));
else lines.push(isZh ? "当前没有需要额外操作的已知风险。" : "There are no known risks requiring extra action.");
lines.push("");
if (release.contributors.length) {
lines.push(
`## ${isZh ? "致谢" : "Thanks"}`,
"",
`${isZh ? "感谢本版本的贡献者" : "Thanks to the contributors in this release"}:${release.contributors
.map((name) => `[@${name}](https://github.com/${name})`)
.join("、")}`,
"",
);
}
lines.push(
`## ${isZh ? "下载与安装" : "Download and install"}`,
"",
`- [${isZh ? "官网按平台下载" : "Platform downloads"}](${release.links.download})`,
`- [${isZh ? "查看完整差异" : "Full comparison"}](${release.links.compare})`,
"",
);
return `${lines.join("\n").trim()}\n`;
}
export async function upsertRelease(release, path = defaultCatalogPath) {
const catalog = await loadCatalog(path);
const next = {
...catalog,
releases: [release, ...catalog.releases.filter((entry) => entry.version !== release.version)].sort((a, b) =>
compareVersionsDesc(a.version, b.version),
),
};
validateCatalog(next);
await writeFile(path, `${JSON.stringify(next, null, 2)}\n`);
return next;
}
function parseArgs(argv) {
const [command = "validate", ...rest] = argv;
const values = { command };
for (let i = 0; i < rest.length; i += 1) {
const arg = rest[i];
if (!arg.startsWith("--")) throw new Error(`unexpected argument ${arg}`);
values[arg.slice(2)] = rest[++i];
}
return values;
}
async function main() {
const args = parseArgs(process.argv.slice(2));
const catalogPath = args.catalog ? resolve(args.catalog) : defaultCatalogPath;
const catalog = await loadCatalog(catalogPath);
if (args.command === "validate") {
console.log(`Validated ${catalog.releases.length} release note(s).`);
return;
}
if (args.command !== "render") throw new Error(`unknown command ${args.command}`);
invariant(args.version, "render requires --version");
invariant(args.output, "render requires --output");
const release = releaseForVersion(catalog, args.version);
const output = resolve(args.output);
await writeFile(output, renderGitHubRelease(release, args.lang === "en" ? "en" : "zh"));
console.log(`Rendered v${release.version} release notes to ${output}`);
}
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
main().catch((error) => {
console.error(error.message);
process.exitCode = 1;
});
}
+48
View File
@@ -0,0 +1,48 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import { loadCatalog, releaseForVersion, renderGitHubRelease, validateCatalog } from "./release-notes.mjs";
test("the committed release catalog is valid and newest first", async () => {
const catalog = await loadCatalog();
assert.equal(catalog.schemaVersion, 1);
assert.equal(catalog.releases[0].version, "1.17.13");
assert.equal(new Set(catalog.releases.map((release) => release.version)).size, catalog.releases.length);
});
test("tag namespaces resolve to the same product release", async () => {
const catalog = await loadCatalog();
assert.equal(releaseForVersion(catalog, "v1.17.13").version, "1.17.13");
assert.equal(releaseForVersion(catalog, "desktop-v1.17.13").version, "1.17.13");
assert.equal(releaseForVersion(catalog, "npm-v1.17.13").version, "1.17.13");
});
test("GitHub rendering keeps product sections and source PR links", async () => {
const catalog = await loadCatalog();
const markdown = renderGitHubRelease(releaseForVersion(catalog, "1.17.13"), "zh");
assert.match(markdown, /## 使用攻略/);
assert.match(markdown, /## 重点内容/);
assert.match(markdown, /## 升级提醒/);
assert.match(markdown, /## 风险提示/);
assert.match(markdown, /## 致谢/);
assert.match(markdown, /\/pull\/6460/);
assert.match(markdown, /reasonix\.io\/changelog\/v1\.17\.13/);
});
test("validation rejects bilingual drift", () => {
assert.throws(
() =>
validateCatalog({
schemaVersion: 1,
releases: [
{
version: "1.0.0",
date: "2026-01-01",
channel: "stable",
title: { en: "Title", zh: "" },
summary: { en: "Summary", zh: "摘要" },
},
],
}),
/title\.zh/,
);
});