feat: add bilingual release notes
This commit is contained in:
@@ -38,6 +38,7 @@ const portableMarkerName = '.goodbuddy-portable.json'
|
||||
const portableRequiredFiles = [
|
||||
`${productName}.exe`,
|
||||
'resources/app.asar',
|
||||
'resources/release-notes.json',
|
||||
'resources/icon.ico',
|
||||
'resources/tray-icon.png',
|
||||
'resources/runtimes/opencode/opencode.exe',
|
||||
@@ -380,6 +381,7 @@ function verifyUnpackedOutput(directory, options) {
|
||||
)
|
||||
assertFile(applicationExecutable, '应用主程序')
|
||||
assertFile(join(resources, 'app.asar'), '应用 ASAR')
|
||||
assertFile(join(resources, 'release-notes.json'), '版本更新说明')
|
||||
assertFile(runtimeExecutable, 'OpenCode Runtime')
|
||||
assertFile(
|
||||
join(resources, 'runtimes', 'continue', 'dist', 'index.js'),
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
const { readFileSync, writeFileSync } = require('node:fs')
|
||||
const { join, resolve } = require('node:path')
|
||||
|
||||
const root = resolve(__dirname, '..')
|
||||
const packageJson = JSON.parse(
|
||||
readFileSync(join(root, 'package.json'), 'utf8')
|
||||
)
|
||||
const releaseNotesFile = JSON.parse(
|
||||
readFileSync(join(root, 'resources', 'release-notes.json'), 'utf8')
|
||||
)
|
||||
|
||||
function fail(message) {
|
||||
throw new Error(`Release notes validation failed: ${message}`)
|
||||
}
|
||||
|
||||
function hasExactKeys(value, keys) {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).length === keys.length &&
|
||||
keys.every((key) => Object.hasOwn(value, key))
|
||||
)
|
||||
}
|
||||
|
||||
function validateItems(value, label) {
|
||||
if (!Array.isArray(value) || value.length > 20) {
|
||||
fail(`${label} must contain no more than 20 items`)
|
||||
}
|
||||
return value.map((item) => {
|
||||
if (typeof item !== 'string') {
|
||||
fail(`${label} contains a non-string item`)
|
||||
}
|
||||
const normalized = item.trim()
|
||||
if (!normalized || normalized.length > 240) {
|
||||
fail(`${label} contains an empty or oversized item`)
|
||||
}
|
||||
return normalized
|
||||
})
|
||||
}
|
||||
|
||||
function validateRelease(value, index) {
|
||||
const label = `releases[${index}]`
|
||||
if (!hasExactKeys(value, ['version', 'releasedAt', 'notes'])) {
|
||||
fail(`${label} has invalid fields`)
|
||||
}
|
||||
if (!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)$/u.test(
|
||||
value.version
|
||||
)) {
|
||||
fail(`${label}.version must be a stable semantic version`)
|
||||
}
|
||||
const date = new Date(`${value.releasedAt}T00:00:00.000Z`)
|
||||
if (
|
||||
!/^\d{4}-\d{2}-\d{2}$/u.test(value.releasedAt) ||
|
||||
Number.isNaN(date.getTime()) ||
|
||||
date.toISOString().slice(0, 10) !== value.releasedAt
|
||||
) {
|
||||
fail(`${label}.releasedAt must be a real YYYY-MM-DD date`)
|
||||
}
|
||||
if (!hasExactKeys(value.notes, ['zh-CN', 'en-US'])) {
|
||||
fail(`${label}.notes must contain zh-CN and en-US`)
|
||||
}
|
||||
const notes = Object.fromEntries(
|
||||
['zh-CN', 'en-US'].map((locale) => {
|
||||
const localized = value.notes[locale]
|
||||
if (!hasExactKeys(localized, ['features', 'fixes'])) {
|
||||
fail(`${label}.notes.${locale} has invalid fields`)
|
||||
}
|
||||
const features = validateItems(
|
||||
localized.features,
|
||||
`${label}.notes.${locale}.features`
|
||||
)
|
||||
const fixes = validateItems(
|
||||
localized.fixes,
|
||||
`${label}.notes.${locale}.fixes`
|
||||
)
|
||||
if (features.length + fixes.length === 0) {
|
||||
fail(`${label}.notes.${locale} must not be empty`)
|
||||
}
|
||||
return [locale, { features, fixes }]
|
||||
})
|
||||
)
|
||||
if (
|
||||
notes['zh-CN'].features.length !== notes['en-US'].features.length ||
|
||||
notes['zh-CN'].fixes.length !== notes['en-US'].fixes.length
|
||||
) {
|
||||
fail(`${label} localized section counts do not match`)
|
||||
}
|
||||
return {
|
||||
version: value.version,
|
||||
releasedAt: value.releasedAt,
|
||||
notes
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
!hasExactKeys(releaseNotesFile, ['formatVersion', 'releases']) ||
|
||||
releaseNotesFile.formatVersion !== 1 ||
|
||||
!Array.isArray(releaseNotesFile.releases) ||
|
||||
releaseNotesFile.releases.length < 1 ||
|
||||
releaseNotesFile.releases.length > 100
|
||||
) {
|
||||
fail('unsupported file format')
|
||||
}
|
||||
|
||||
const allReleases = releaseNotesFile.releases.map(validateRelease)
|
||||
const uniqueVersionCount = new Set(
|
||||
allReleases.map((release) => release.version)
|
||||
).size
|
||||
if (uniqueVersionCount !== allReleases.length) {
|
||||
fail('release versions must be unique')
|
||||
}
|
||||
|
||||
const releases = allReleases.filter(
|
||||
(release) => release?.version === packageJson.version
|
||||
)
|
||||
if (releases.length !== 1) {
|
||||
fail(
|
||||
`expected exactly one entry for package version ${packageJson.version}`
|
||||
)
|
||||
}
|
||||
|
||||
const release = releases[0]
|
||||
|
||||
const localizedDefinitions = [
|
||||
{
|
||||
locale: 'zh-CN',
|
||||
title: `GoodBuddy ${release.version} 更新内容`,
|
||||
features: '功能更新',
|
||||
fixes: '问题修复'
|
||||
},
|
||||
{
|
||||
locale: 'en-US',
|
||||
title: `What's New in GoodBuddy ${release.version}`,
|
||||
features: 'Features',
|
||||
fixes: 'Bug Fixes'
|
||||
}
|
||||
]
|
||||
|
||||
function markdownSection(title, items) {
|
||||
if (items.length === 0) {
|
||||
return []
|
||||
}
|
||||
return [`## ${title}`, '', ...items.map((item) => `- ${item}`), '']
|
||||
}
|
||||
|
||||
const markdown = localizedDefinitions
|
||||
.flatMap((definition, index) => {
|
||||
const notes = release.notes[definition.locale]
|
||||
return [
|
||||
...(index === 0 ? [] : ['---', '']),
|
||||
`# ${definition.title}`,
|
||||
'',
|
||||
...markdownSection(definition.features, notes.features),
|
||||
...markdownSection(definition.fixes, notes.fixes)
|
||||
]
|
||||
})
|
||||
.join('\n')
|
||||
.trimEnd()
|
||||
.concat('\n')
|
||||
|
||||
const outputIndex = process.argv.indexOf('--output')
|
||||
if (outputIndex >= 0) {
|
||||
const outputPath = process.argv[outputIndex + 1]
|
||||
if (!outputPath) {
|
||||
fail('--output requires a path')
|
||||
}
|
||||
writeFileSync(resolve(root, outputPath), markdown, 'utf8')
|
||||
} else {
|
||||
process.stdout.write(
|
||||
`Validated bilingual release notes for ${packageJson.version}\n`
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user