Files
prompt-optimizer/scripts/release-notes.js
2026-05-03 15:12:57 +08:00

695 lines
22 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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(/<!--[\s\S]*?-->/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.
<!-- Release drafting reference:
Remove every TODO placeholder before running "pnpm release:notes:check ${getTagVersion(normalizedVersion)}".
${draftingLines}
-->
`;
}
return `# Prompt Optimizer ${getTagVersion(normalizedVersion)}
## 概括
- TODO: 用 2-4 条简短要点概括本次发布,供 GitHub Release 直接引用。
## 亮点
- TODO: 用中文总结本次发布最重要的用户价值。
## 产品更新
### Desktop
- TODO: 填写桌面端相关变化;如果没有,可以删除这个小节。
### Web
- TODO: 填写 Web 端相关变化;如果没有,可以删除这个小节。
### Extension
- TODO: 填写扩展端相关变化;如果没有,可以删除这个小节。
### Core/Infra
- TODO: 填写共享基础设施或核心能力变化;如果没有,可以删除这个小节。
## 修复
- TODO: 用中文补充最重要的修复。
## 破坏性变更 / 升级说明
- TODO: 说明升级动作;如果没有,请写“无”。
## 开发者说明
- TODO: 补充对开发者或自托管用户有价值的实现说明。
<!-- Release drafting reference:
Remove every TODO placeholder before running "pnpm release:notes:check ${getTagVersion(normalizedVersion)}".
${draftingLines}
-->
`;
}
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 <new|check|check-entry|render-body> [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,
};