#!/usr/bin/env node const fs = require('node:fs'); const path = require('node:path'); const { execFileSync } = require('node:child_process'); const LOCALE_CONFIG = { en: { fileSuffix: 'en', summaryHeading: '## Summary', headings: [ '## Summary', '## Highlights', '## Product Updates', '## Fixes', '## Breaking Changes / Upgrade Notes', '## Developer Notes', ], productSectionHeading: '## Product Updates', productSubsections: ['### Desktop', '### Web', '### Extension', '### Core/Infra'], }, 'zh-CN': { fileSuffix: 'zh-CN', summaryHeading: '## 概括', headings: [ '## 概括', '## 亮点', '## 产品更新', '## 修复', '## 破坏性变更 / 升级说明', '## 开发者说明', ], productSectionHeading: '## 产品更新', productSubsections: ['### Desktop', '### Web', '### Extension', '### Core/Infra'], }, }; const PLACEHOLDER_PATTERNS = [/\bTODO\b/i, /\bTBD\b/i, /待补充/, /(^|[^A-Z])XX([^A-Z]|$)/]; function readRootPackage(cwd = process.cwd()) { return JSON.parse(fs.readFileSync(path.join(cwd, 'package.json'), 'utf8')); } function normalizeVersion(value, cwd = process.cwd()) { const candidate = String(value || readRootPackage(cwd).version).trim(); if (!/^v?\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(candidate)) { throw new Error(`Invalid version "${candidate}". Expected x.y.z or vx.y.z.`); } return candidate.startsWith('v') ? candidate.slice(1) : candidate; } function getTagVersion(version, cwd = process.cwd()) { return `v${normalizeVersion(version, cwd)}`; } function getLocaleConfig(locale) { const config = LOCALE_CONFIG[locale]; if (!config) { throw new Error(`Unsupported locale "${locale}". Expected one of: ${Object.keys(LOCALE_CONFIG).join(', ')}.`); } return config; } function getReleaseNotesRelativePath(version, locale, cwd = process.cwd()) { const normalizedVersion = normalizeVersion(version, cwd); const config = getLocaleConfig(locale); return `releases/v${normalizedVersion}.${config.fileSuffix}.md`; } function getReleaseNotesPath({ cwd = process.cwd(), version, locale }) { return path.join(cwd, getReleaseNotesRelativePath(version, locale, cwd)); } function getReleaseNotesPaths({ cwd = process.cwd(), version }) { return { en: getReleaseNotesPath({ cwd, version, locale: 'en' }), zhCN: getReleaseNotesPath({ cwd, version, locale: 'zh-CN' }), }; } function stripHtmlComments(content) { return content.replace(//g, '').trim(); } function escapeRegExp(value) { return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function getHeadingIndex(block, heading) { const match = new RegExp(`^${escapeRegExp(heading)}\\s*$`, 'm').exec(block); return match ? match.index : -1; } function findSection(content, heading, nextHeading = null) { const startIndex = getHeadingIndex(content, heading); if (startIndex === -1) { return null; } const bodyStart = content.indexOf('\n', startIndex); if (bodyStart === -1) { return content.slice(startIndex).trim(); } if (!nextHeading) { return content.slice(startIndex).trim(); } const remaining = content.slice(bodyStart + 1); const nextIndex = getHeadingIndex(remaining, nextHeading); if (nextIndex === -1) { return content.slice(startIndex).trim(); } return content.slice(startIndex, bodyStart + 1 + nextIndex).trim(); } function extractSectionBody(content, heading, nextHeading = null) { const section = findSection(content, heading, nextHeading); if (!section) { return null; } const firstLineBreak = section.indexOf('\n'); if (firstLineBreak === -1) { return ''; } return section.slice(firstLineBreak + 1).trim(); } function extractSubsectionBody(section, heading) { const startIndex = getHeadingIndex(section, heading); if (startIndex === -1) { return null; } const bodyStart = section.indexOf('\n', startIndex); if (bodyStart === -1) { return ''; } const remaining = section.slice(bodyStart + 1); const nextSubsectionMatch = /^###\s+.+$/m.exec(remaining); const body = nextSubsectionMatch ? remaining.slice(0, nextSubsectionMatch.index) : remaining; return stripHtmlComments(body).trim(); } function isNoChangeProductSubsection(body) { const normalized = String(body || '') .replace(/^[\s*>-]+/gm, '') .replace(/\s+/g, ' ') .trim() .toLowerCase(); if (!normalized) { return true; } return ( /\bno\b.*\b(extension|desktop)[-\s]specific\b.*\b(user[-\s]facing\s+)?changes?\b/.test(normalized) || /本次.*没有.*(扩展端|桌面端).*变化/.test(normalized) ); } function validateHeadingOrder(block, headings, label) { const errors = []; let lastIndex = -1; for (const heading of headings) { const headingIndex = getHeadingIndex(block, heading); if (headingIndex === -1) { errors.push(`${label} section is missing required heading "${heading}".`); continue; } if (headingIndex < lastIndex) { errors.push(`${label} section has heading "${heading}" out of order.`); } lastIndex = headingIndex; } return errors; } function validateProductSubsectionOrder(content, locale) { const config = getLocaleConfig(locale); const label = locale === 'en' ? 'English' : '中文'; const nextHeading = config.headings[config.headings.indexOf(config.productSectionHeading) + 1] ?? null; const productSection = findSection(content, config.productSectionHeading, nextHeading); if (!productSection) { return []; } const errors = []; let lastIndex = -1; let previousHeading = null; for (const heading of config.productSubsections) { const headingIndex = getHeadingIndex(productSection, heading); if (headingIndex === -1) { continue; } const subsectionBody = extractSubsectionBody(productSection, heading); if (isNoChangeProductSubsection(subsectionBody)) { continue; } if (headingIndex < lastIndex) { errors.push(`${label} Product Updates subsection "${heading}" must appear after "${previousHeading}".`); } lastIndex = headingIndex; previousHeading = heading; } return errors; } function findPlaceholderMatches(content) { const matches = []; for (const pattern of PLACEHOLDER_PATTERNS) { const matched = content.match(pattern); if (matched) { matches.push(matched[0]); } } return [...new Set(matches)]; } function validateReleaseNotesContent(content, version, locale, options = {}) { const config = getLocaleConfig(locale); const requireSummary = options.requireSummary !== false; const errors = []; const normalizedContent = stripHtmlComments(content).replace(/\r\n/g, '\n'); const expectedTitle = `# Prompt Optimizer ${getTagVersion(version)}`; if (!new RegExp(`^${escapeRegExp(expectedTitle)}\\s*$`, 'm').test(normalizedContent)) { errors.push(`Release notes title must be "${expectedTitle}" in ${locale}.`); } errors.push(...validateHeadingOrder(normalizedContent, config.headings, locale === 'en' ? 'English' : '中文')); errors.push(...validateProductSubsectionOrder(normalizedContent, locale)); if (requireSummary) { const summaryBody = extractSectionBody( normalizedContent, config.summaryHeading, config.headings[1] ?? null ); if (!summaryBody) { errors.push(`Release notes must include a non-empty "${config.summaryHeading}" section in ${locale}.`); } } const placeholders = findPlaceholderMatches(normalizedContent); if (placeholders.length > 0) { errors.push( `Release notes contain placeholder content in ${locale}: ${placeholders.join(', ')}. Replace placeholders before publishing.` ); } return errors; } function validateChangelogEntry(changelogContent, version, options = {}) { const requireTopEntry = options.requireTopEntry !== false; const errors = []; const normalizedContent = changelogContent.replace(/\r\n/g, '\n'); const headingMatches = [...normalizedContent.matchAll(/^## \[([^\]]+)\] - .+$/gm)]; if (headingMatches.length === 0) { return ['CHANGELOG.md must include at least one version entry.']; } const topEntry = headingMatches[0]; const topVersion = topEntry[1]; const normalizedVersion = normalizeVersion(version); if (requireTopEntry && topVersion !== normalizedVersion) { errors.push(`CHANGELOG.md top entry must be version ${normalizedVersion}.`); } const matchedEntry = requireTopEntry ? topEntry : headingMatches.find((match) => match[1] === normalizedVersion); if (!matchedEntry) { errors.push(`CHANGELOG.md must include version ${normalizedVersion}.`); return errors; } const matchedEntryIndex = headingMatches.findIndex((match) => match.index === matchedEntry.index); const entryStart = matchedEntry.index ?? 0; const nextEntryStart = headingMatches[matchedEntryIndex + 1]?.index ?? normalizedContent.length; const matchedEntryBlock = normalizedContent.slice(entryStart, nextEntryStart); const englishPath = getReleaseNotesRelativePath(version, 'en'); const chinesePath = getReleaseNotesRelativePath(version, 'zh-CN'); if (!matchedEntryBlock.includes(englishPath)) { errors.push( requireTopEntry ? `CHANGELOG.md top entry must link to ${englishPath}.` : `CHANGELOG.md entry for version ${normalizedVersion} must link to ${englishPath}.` ); } if (!matchedEntryBlock.includes(chinesePath)) { errors.push( requireTopEntry ? `CHANGELOG.md top entry must link to ${chinesePath}.` : `CHANGELOG.md entry for version ${normalizedVersion} must link to ${chinesePath}.` ); } if (!/^- EN:/m.test(matchedEntryBlock)) { errors.push( requireTopEntry ? 'CHANGELOG.md top entry must include an English summary line prefixed with "- EN:".' : `CHANGELOG.md entry for version ${normalizedVersion} must include an English summary line prefixed with "- EN:".` ); } if (!/^- 中文[::]/m.test(matchedEntryBlock)) { errors.push( requireTopEntry ? 'CHANGELOG.md top entry must include a Chinese summary line prefixed with "- 中文:".' : `CHANGELOG.md entry for version ${normalizedVersion} must include a Chinese summary line prefixed with "- 中文:".` ); } return errors; } function validateReleaseArtifacts({ cwd = process.cwd(), version, requireSummary = true, requireTopEntry = true }) { const normalizedVersion = normalizeVersion(version, cwd); const filePaths = getReleaseNotesPaths({ cwd, version: normalizedVersion }); const errors = []; for (const [locale, filePath] of [ ['en', filePaths.en], ['zh-CN', filePaths.zhCN], ]) { if (!fs.existsSync(filePath)) { errors.push(`Release notes file is missing: ${path.relative(cwd, filePath).replace(/\\/g, '/')}.`); continue; } const content = fs.readFileSync(filePath, 'utf8'); errors.push(...validateReleaseNotesContent(content, normalizedVersion, locale, { requireSummary })); } const changelogPath = path.join(cwd, 'CHANGELOG.md'); if (!fs.existsSync(changelogPath)) { errors.push('CHANGELOG.md is missing.'); } else { const changelogContent = fs.readFileSync(changelogPath, 'utf8'); errors.push(...validateChangelogEntry(changelogContent, normalizedVersion, { requireTopEntry })); } return { ok: errors.length === 0, errors, filePaths, version: normalizedVersion, }; } function safeExecFileSync(command, args, options) { try { return execFileSync(command, args, options); } catch { return null; } } function buildCommitDraft(cwd, version) { const currentTag = getTagVersion(version, cwd); const tagsOutput = safeExecFileSync('git', ['tag', '--sort=-version:refname'], { cwd, encoding: 'utf8', }); if (!tagsOutput) { return []; } const tags = tagsOutput .split(/\r?\n/) .map((tag) => tag.trim()) .filter(Boolean); const currentTagIndex = tags.indexOf(currentTag); const previousTag = currentTagIndex === -1 ? tags[0] : tags[currentTagIndex + 1]; const currentTagExists = Boolean( safeExecFileSync('git', ['rev-parse', '--verify', `refs/tags/${currentTag}`], { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], }) ); const rangeEnd = currentTagExists ? currentTag : 'HEAD'; const logArgs = previousTag ? ['log', '--pretty=format:- %s (%h)', `${previousTag}..${rangeEnd}`] : ['log', '--pretty=format:- %s (%h)', '--max-count=20']; const logOutput = safeExecFileSync('git', logArgs, { cwd, encoding: 'utf8' }); if (!logOutput) { return []; } const commits = logOutput .split(/\r?\n/) .map((line) => line.trim()) .filter(Boolean) .slice(0, 20); if (commits.length === 0) { return previousTag ? [`- No commits found between ${previousTag} and ${rangeEnd}.`] : ['- No commits found.']; } return previousTag ? [`- Range: ${previousTag}..${rangeEnd}`, ...commits] : commits; } function buildReleaseNotesTemplate({ version, locale, commitDraft = [] }) { const normalizedVersion = normalizeVersion(version); const config = getLocaleConfig(locale); const draftingLines = commitDraft.length > 0 ? commitDraft.join('\n') : '- No commit draft available. You can still write the notes manually.'; if (locale === 'en') { return `# Prompt Optimizer ${getTagVersion(normalizedVersion)} ## Summary - TODO: Add 2-4 concise bullets for the GitHub Release summary. ## Highlights - TODO: Summarize the most important user-facing change in English. ## Product Updates ### Desktop - TODO: Add desktop-specific changes or remove this subsection if none. ### Web - TODO: Add web-specific changes or remove this subsection if none. ### Extension - TODO: Add extension-specific changes or remove this subsection if none. ### Core/Infra - TODO: Add shared platform or infrastructure changes or remove this subsection if none. ## Fixes - TODO: Capture the most relevant fixes in English. ## Breaking Changes / Upgrade Notes - TODO: Describe required upgrade actions, or write "None." when nothing changed. ## Developer Notes - TODO: Capture implementation-facing details that are worth surfacing publicly. `; } return `# Prompt Optimizer ${getTagVersion(normalizedVersion)} ## 概括 - TODO: 用 2-4 条简短要点概括本次发布,供 GitHub Release 直接引用。 ## 亮点 - TODO: 用中文总结本次发布最重要的用户价值。 ## 产品更新 ### Desktop - TODO: 填写桌面端相关变化;如果没有,可以删除这个小节。 ### Web - TODO: 填写 Web 端相关变化;如果没有,可以删除这个小节。 ### Extension - TODO: 填写扩展端相关变化;如果没有,可以删除这个小节。 ### Core/Infra - TODO: 填写共享基础设施或核心能力变化;如果没有,可以删除这个小节。 ## 修复 - TODO: 用中文补充最重要的修复。 ## 破坏性变更 / 升级说明 - TODO: 说明升级动作;如果没有,请写“无”。 ## 开发者说明 - TODO: 补充对开发者或自托管用户有价值的实现说明。 `; } function createReleaseNotesFiles({ cwd = process.cwd(), version, commitDraft = [], force = false }) { const normalizedVersion = normalizeVersion(version, cwd); const filePaths = getReleaseNotesPaths({ cwd, version: normalizedVersion }); for (const filePath of [filePaths.en, filePaths.zhCN]) { if (fs.existsSync(filePath) && !force) { throw new Error(`Release notes already exist at ${path.relative(cwd, filePath).replace(/\\/g, '/')}.`); } } fs.mkdirSync(path.dirname(filePaths.en), { recursive: true }); fs.writeFileSync( filePaths.en, buildReleaseNotesTemplate({ version: normalizedVersion, locale: 'en', commitDraft }), 'utf8' ); fs.writeFileSync( filePaths.zhCN, buildReleaseNotesTemplate({ version: normalizedVersion, locale: 'zh-CN', commitDraft }), 'utf8' ); return { created: true, filePaths, version: normalizedVersion, }; } function buildTagScopedFileUrl(repository, tag, relativePath) { return `https://github.com/${repository}/blob/${tag}/${relativePath}`; } function renderMacSecurityNote(locale) { if (locale === 'en') { return '> macOS note: if the app is reported as damaged or unverified, remove quarantine with `xattr -rd com.apple.quarantine /Applications/PromptOptimizer.app` (or run it on `~/Downloads/PromptOptimizer-*.dmg` before installing).'; } return '> macOS 提示:如果提示“已损坏”或“无法验证开发者”,可用 `xattr -rd com.apple.quarantine /Applications/PromptOptimizer.app` 移除隔离属性;DMG 可先对 `~/Downloads/PromptOptimizer-*.dmg` 执行。'; } function renderGitHubReleaseBody({ cwd = process.cwd(), version, repository }) { const normalizedVersion = normalizeVersion(version, cwd); const tag = getTagVersion(normalizedVersion); const englishPath = getReleaseNotesPath({ cwd, version: normalizedVersion, locale: 'en' }); const chinesePath = getReleaseNotesPath({ cwd, version: normalizedVersion, locale: 'zh-CN' }); const englishContent = fs.readFileSync(englishPath, 'utf8').replace(/\r\n/g, '\n').trim(); const chineseContent = fs.readFileSync(chinesePath, 'utf8').replace(/\r\n/g, '\n').trim(); const englishGuideUrl = buildTagScopedFileUrl(repository, tag, 'mkdocs/docs/en/deployment/desktop.md'); const chineseGuideUrl = buildTagScopedFileUrl(repository, tag, 'mkdocs/docs/zh/deployment/desktop.md'); const englishSummary = extractSectionBody(englishContent, '## Summary', '## Highlights'); const chineseSummary = extractSectionBody(chineseContent, '## 概括', '## 亮点'); if (englishSummary && chineseSummary) { return [ '## English', '', '### Summary', englishSummary, '', renderMacSecurityNote('en'), '', `Installation guide: [English](${englishGuideUrl}) | [中文](${chineseGuideUrl})`, `[Full release notes (EN)](${buildTagScopedFileUrl(repository, tag, getReleaseNotesRelativePath(normalizedVersion, 'en', cwd))})`, '', '---', '', '## 中文', '', '### 概括', chineseSummary, '', renderMacSecurityNote('zh-CN'), '', `安装文档:[English](${englishGuideUrl}) | [中文](${chineseGuideUrl})`, `[完整发布说明(中文)](${buildTagScopedFileUrl(repository, tag, getReleaseNotesRelativePath(normalizedVersion, 'zh-CN', cwd))})`, '', ].join('\n'); } return [englishContent, '', '---', '', chineseContent, ''].join('\n'); } function printUsage() { console.log('Usage: node scripts/release-notes.js [version] [repository]'); console.log('Examples:'); console.log(' pnpm release:notes:new 2.9.0'); console.log(' pnpm release:notes:check v2.9.0'); console.log(' pnpm release:notes:check:entry v2.6.0'); console.log(' node scripts/release-notes.js render-body v2.9.0 linshenkx/prompt-optimizer'); } function main(argv = process.argv.slice(2), cwd = process.cwd()) { const [command, versionArg, repositoryArg] = argv; if (!command || command === '--help' || command === '-h') { printUsage(); return; } const version = normalizeVersion(versionArg, cwd); if (command === 'new') { const result = createReleaseNotesFiles({ cwd, version, commitDraft: buildCommitDraft(cwd, version), }); console.log( `Created ${path.relative(cwd, result.filePaths.en).replace(/\\/g, '/')} and ${path .relative(cwd, result.filePaths.zhCN) .replace(/\\/g, '/')} for ${getTagVersion(version)}.` ); console.log('Next steps: update both files and add the matching top entry in CHANGELOG.md.'); return; } if (command === 'check') { const result = validateReleaseArtifacts({ cwd, version, requireSummary: true }); if (!result.ok) { console.error(`Release notes validation failed for ${getTagVersion(version)}:`); for (const error of result.errors) { console.error(`- ${error}`); } process.exitCode = 1; return; } console.log(`Release notes validation passed for ${getTagVersion(version)}.`); return; } if (command === 'check-entry') { const result = validateReleaseArtifacts({ cwd, version, requireSummary: true, requireTopEntry: false, }); if (!result.ok) { console.error(`Release notes entry validation failed for ${getTagVersion(version)}:`); for (const error of result.errors) { console.error(`- ${error}`); } process.exitCode = 1; return; } console.log(`Release notes entry validation passed for ${getTagVersion(version)}.`); return; } if (command === 'render-body') { if (!repositoryArg) { throw new Error('render-body requires a repository argument like "linshenkx/prompt-optimizer".'); } process.stdout.write(renderGitHubReleaseBody({ cwd, version, repository: repositoryArg })); return; } throw new Error(`Unknown command "${command}".`); } if (require.main === module) { try { main(); } catch (error) { console.error(error.message || error); process.exit(1); } } module.exports = { buildCommitDraft, buildReleaseNotesTemplate, createReleaseNotesFiles, extractSectionBody, getReleaseNotesPath, getReleaseNotesPaths, getReleaseNotesRelativePath, getTagVersion, main, normalizeVersion, renderGitHubReleaseBody, renderMacSecurityNote, validateChangelogEntry, validateReleaseArtifacts, validateReleaseNotesContent, };