| 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 |