Files
openclaude/scripts/sync-web-release-entry.ts
T
JATMNandGitHub 7743cf280e fix(release): synchronize web changelog entries (#2088)
* fix(release): sync web changelog entries from release please

* fix(release): provide sync push credentials

* fix(release): gate and finalize web release sync

* fix(release): make web release sync recoverable

* fix(release): target release PR commands explicitly

* fix(release): honor manifest release configuration

* fix(release): recover failed web sync retries

* ci(release): run full sync preflight

* fix(release): validate synchronized PR head

* fix(release): validate bot sync in release job

* fix(release): harden bot-owned web sync

* fix(release): validate exact bot PR head

* fix(release): isolate and bind PR synchronization

* fix(release): isolate validation from write credentials

* fix(release): reject non-regular generated inputs

* fix(release): require a valid forward version bump

* fix(release): scope sync artifacts to run attempts

* fix(release): reuse validated artifacts across retries

* fix(release): resume readiness after a completed push

* fix(release): bind retries to the validated commit

* fix(release): address synchronization review findings

* fix(release): support CRLF changelog recovery

* fix(release): keep validation transitions fail-closed

* fix(release): restore scoped web sync and marker ownership

Cut the multi-job finalize state machine back to a single draft-until-push
sync path, and fix consecutive releases leaving stacked automation markers
by stripping leftover draft ownership when inserting the next version.

* fix(release): keep web sync from blocking npm publish

Move pending Release Please web sync into its own job so a sync failure cannot skip install-verify, npm, or docker after a release tag is already created.

* fix(release): close web-sync trust and policy gaps

Remove hand-curation escape hatches, split read-only validation from
write-only push, discover bot PRs by branch identity, and run the full
local gate suite before marking the release PR ready.

* fix(release): validate gates against synchronized commit

Commit the synced releases.ts in the read-only validate job before
typecheck, security scan, and whitespace checks so those gates inspect
the content that will be marked ready, not the pre-sync HEAD.

* fix(release): harden web-sync trust boundary and draft gating

Run sync from trusted main with only changelog/manifest overlaid from
the bot PR, re-draft after release-please, serialize sync without
canceling in-flight pushes, and require an explicit sync base.

* fix(release): restore overlaid inputs before validate cleanliness gate

Fetching changelog/manifest from the bot PR dirtied tracked files on the
trusted main checkout and made the final git-diff gate fail on every
pending release. Restore those overlays after sync and fetch origin/main
for the security/whitespace checks.

* fix(release): reuse validated sync artifacts on retry

* fix(release): validate release sync inputs and retries

* fix(release): recover web sync state transitions

* fix(release): protect generated release ownership

* fix(release): repair web sync recovery gates

* fix(release): bind sync artifacts to validated base

* fix(release): verify synchronized file mode

* fix(release): paginate bot PR discovery
2026-08-11 18:37:49 +08:00

308 lines
12 KiB
TypeScript

import { execFileSync } from 'node:child_process'
import { readFileSync, writeFileSync } from 'node:fs'
export const RELEASES_TS_PATH = 'web/src/data/releases.ts'
export const CHANGELOG_PATH = 'CHANGELOG.md'
export const MANIFEST_PATH = '.release-please-manifest.json'
export const GENERATED_ENTRY_MARKER =
' // Generated by release automation; do not edit this entry by hand.'
/** Prior wording kept only so pending bot PRs with the old marker still refresh. */
const LEGACY_GENERATED_ENTRY_MARKER =
' // Generated by release automation; remove this comment before hand-curating.'
const GENERATED_ENTRY_MARKERS = [GENERATED_ENTRY_MARKER, LEGACY_GENERATED_ENTRY_MARKER] as const
const SEMVER_IDENTIFIER = '(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)'
const SEMVER_PATTERN = new RegExp(
`^(0|[1-9]\\d*)\\.(0|[1-9]\\d*)\\.(0|[1-9]\\d*)`
+ `(?:-(${SEMVER_IDENTIFIER}(?:\\.${SEMVER_IDENTIFIER})*))?`
+ `(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?$`,
)
export type ReleaseEntry = {
version: string
date: string
theme: string
highlights: string[]
}
type ParsedSemVer = {
core: [bigint, bigint, bigint]
prerelease: string[] | null
}
function parseSemVer(version: string): ParsedSemVer {
const match = version.match(SEMVER_PATTERN)
if (!match) throw new Error(`invalid SemVer: ${version}`)
return {
core: [BigInt(match[1]!), BigInt(match[2]!), BigInt(match[3]!)],
prerelease: match[4]?.split('.') ?? null,
}
}
export function compareSemVer(leftVersion: string, rightVersion: string): number {
const left = parseSemVer(leftVersion)
const right = parseSemVer(rightVersion)
for (let index = 0; index < left.core.length; index++) {
if (left.core[index] !== right.core[index])
return left.core[index]! < right.core[index]! ? -1 : 1
}
if (left.prerelease === null || right.prerelease === null)
return left.prerelease === right.prerelease ? 0 : left.prerelease === null ? 1 : -1
const length = Math.max(left.prerelease.length, right.prerelease.length)
for (let index = 0; index < length; index++) {
const a = left.prerelease[index]
const b = right.prerelease[index]
if (a === undefined || b === undefined)
return a === b ? 0 : a === undefined ? -1 : 1
if (a === b) continue
const aNumeric = /^\d+$/.test(a)
const bNumeric = /^\d+$/.test(b)
if (aNumeric && bNumeric) return BigInt(a) < BigInt(b) ? -1 : 1
if (aNumeric !== bNumeric) return aNumeric ? -1 : 1
return a < b ? -1 : 1
}
return 0
}
export function assertVersionAdvances(baseVersion: string, nextVersion: string): void {
if (compareSemVer(nextVersion, baseVersion) <= 0)
throw new Error(`release version must advance: ${baseVersion} -> ${nextVersion}`)
}
export function sanitizeChangelogBullet(line: string): string {
let sanitized = line
.replace(/^\*\s+/, '')
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.replace(/`([^`]+)`/g, '$1')
.replace(/\*\*([^*]+)\*\*/g, '$1')
.replace(/\*([^*]+)\*/g, '$1')
.trim()
while (true) {
const next = sanitized.replace(/\s*\((?:#\d+|[0-9a-f]{7,40})\)\s*$/i, '').trim()
if (next === sanitized) break
sanitized = next
}
return decodeHtmlEntities(sanitized.replace(/,\s*closes\s+#\d+$/i, '').trim())
}
function decodeHtmlEntities(value: string): string {
const named: Record<string, string> = {
amp: '&',
apos: "'",
gt: '>',
lt: '<',
quot: '"',
}
return value.replace(/&(?:#(\d+)|#x([0-9a-f]+)|([a-z]+));/gi, (entity, decimal, hexadecimal, name) => {
if (name) return named[name.toLowerCase()] ?? entity
const codePoint = Number.parseInt(decimal ?? hexadecimal, decimal ? 10 : 16)
if (!Number.isSafeInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff)
return entity
if (codePoint >= 0xd800 && codePoint <= 0xdfff) return entity
return String.fromCodePoint(codePoint)
})
}
export function parseChangelogSection(
changelog: string,
version?: string,
): { version: string; date: string; highlights: string[] } | null {
const target = version ?? readManifestVersion()
const sectionPattern = new RegExp(
`^## \\[${escapeRegExp(target)}\\][^\\n]*\\((\\d{4}-\\d{2}-\\d{2})\\)\\s*$`,
'm',
)
const headerMatch = changelog.match(sectionPattern)
if (!headerMatch) return null
const start = headerMatch.index ?? changelog.indexOf(headerMatch[0])
const afterHeader = changelog.slice(start + headerMatch[0].length)
const nextSection = afterHeader.search(/^## \[/m)
const sectionBody = nextSection === -1 ? afterHeader : afterHeader.slice(0, nextSection)
const highlights = sectionBody
.split('\n')
.map(line => line.trim())
.filter(line => line.startsWith('* '))
.map(sanitizeChangelogBullet)
.filter(Boolean)
.slice(0, 5)
return { version: target, date: headerMatch[1]!, highlights }
}
export function deriveTheme(highlights: string[]): string {
if (highlights.length === 0) return 'release highlights'
const first = highlights[0]!
const scopeMatch = first.match(/^([a-z0-9-]+):\s*/i)
const theme = scopeMatch ? first.slice(scopeMatch[0].length) : first
const characters = [...theme]
return characters.length > 72 ? `${characters.slice(0, 69).join('')}…` : theme
}
export function readManifestVersion(manifest = readFileSync(MANIFEST_PATH, 'utf8')): string {
const parsed = JSON.parse(manifest) as Record<string, string>
const version = parsed['.']
if (!version) throw new Error(`missing root version in ${MANIFEST_PATH}`)
return version
}
export function readCurrentTopVersion(releasesTs: string): string | null {
const match = releasesTs.match(
/export const releases: Release\[\] = \[\s*(?:\/\/[^\n]*\s*)?\{\s*version: ["']([^"']+)["']/s,
)
return match?.[1] ?? null
}
export function formatReleaseEntry(entry: ReleaseEntry, indent = ' '): string {
const quote = (value: string) => JSON.stringify(value)
const highlightLines = entry.highlights.map(highlight => `${indent} ${quote(highlight)},`).join('\n')
return `${indent}{
${indent} version: ${quote(entry.version)},
${indent} date: ${quote(entry.date)},
${indent} theme: ${quote(entry.theme)},
${indent} highlights: [
${highlightLines}
${indent} ],
${indent}},
`
}
export function insertReleaseEntry(releasesTs: string, entry: ReleaseEntry): string {
const marker = 'export const releases: Release[] = ['
const index = releasesTs.indexOf(marker)
if (index === -1) throw new Error(`could not find releases array in ${RELEASES_TS_PATH}`)
const insertAt = index + marker.length
const eol = detectLineEnding(releasesTs)
const formattedEntry = formatReleaseEntry(entry).replaceAll('\n', eol)
// A merged release can leave the automation marker on the published top
// entry. Strip that leftover marker so only the new draft owns it.
const rest = stripLeadingGeneratedMarker(releasesTs.slice(insertAt), eol)
return `${releasesTs.slice(0, insertAt)}${eol}${GENERATED_ENTRY_MARKER}${eol}${formattedEntry}${rest}`
}
export function replaceTopReleaseEntry(releasesTs: string, entry: ReleaseEntry): string {
const marker = 'export const releases: Release[] = ['
const index = releasesTs.indexOf(marker)
if (index === -1) throw new Error(`could not find releases array in ${RELEASES_TS_PATH}`)
const insertAt = index + marker.length
const existing = stripLeadingGeneratedMarker(releasesTs.slice(insertAt), detectLineEnding(releasesTs))
const endMatch = /^ \},\r?\n/m.exec(existing)
if (!endMatch) throw new Error(`could not find top release entry in ${RELEASES_TS_PATH}`)
const eol = detectLineEnding(releasesTs)
const formattedEntry = formatReleaseEntry(entry).replaceAll('\n', eol)
const afterEntry = endMatch.index + endMatch[0].length
return `${releasesTs.slice(0, insertAt)}${eol}${GENERATED_ENTRY_MARKER}${eol}${formattedEntry}${existing.slice(afterEntry)}`
}
export function hasGeneratedTopEntry(releasesTs: string): boolean {
const marker = 'export const releases: Release[] = ['
const index = releasesTs.indexOf(marker)
if (index === -1) return false
const existing = releasesTs.slice(index + marker.length)
return GENERATED_ENTRY_MARKERS.some(
generated =>
existing.startsWith(`\n${generated}\n`)
|| existing.startsWith(`\r\n${generated}\r\n`),
)
}
function detectLineEnding(value: string): '\n' | '\r\n' {
return value.includes('\r\n') ? '\r\n' : '\n'
}
function stripLeadingGeneratedMarker(value: string, eol: '\n' | '\r\n'): string {
for (const generated of GENERATED_ENTRY_MARKERS) {
const prefix = `${eol}${generated}${eol}`
if (value.startsWith(prefix)) return value.slice(prefix.length)
}
return value
}
export function readBaseReleasesTs(baseRef: string): string {
return execFileSync('git', ['show', `${baseRef}:${RELEASES_TS_PATH}`], { encoding: 'utf8' })
}
function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
export type SyncResult =
| { status: 'unchanged'; version: string; reason: string }
| { status: 'updated'; version: string; content: string }
export function syncWebReleaseEntry(options: {
changelog?: string
releasesTs?: string
baseReleasesTs?: string
baseRef?: string
manifestVersion?: string
} = {}): SyncResult {
const version = options.manifestVersion ?? readManifestVersion()
const releasesTs = options.releasesTs ?? readFileSync(RELEASES_TS_PATH, 'utf8')
const currentTop = readCurrentTopVersion(releasesTs)
const baseReleasesTs = options.baseReleasesTs
?? (options.baseRef ? readBaseReleasesTs(options.baseRef) : null)
if (baseReleasesTs === null)
throw new Error('missing base release ref; pass --base-ref <ref> or provide baseReleasesTs')
const baseTop = readCurrentTopVersion(baseReleasesTs)
const generatedTop = hasGeneratedTopEntry(releasesTs)
const divergedFromBase = currentTop !== baseTop
// Unmarked divergence is not automation-owned. Fail closed — never preserve
// or overwrite hand edits on the bot sync path.
if (divergedFromBase && !generatedTop) {
throw new Error(
`${RELEASES_TS_PATH} differs from the base without the generated-entry marker; refusing to overwrite it`,
)
}
// Same version without a generated marker is already published on the trusted base.
if (currentTop === version && !generatedTop)
return { status: 'unchanged', version, reason: 'releases.ts already lists this version first' }
const changelog = options.changelog ?? readFileSync(CHANGELOG_PATH, 'utf8')
const section = parseChangelogSection(changelog, version)
if (!section) throw new Error(`no CHANGELOG.md section found for version ${version}`)
if (section.highlights.length === 0)
throw new Error(`CHANGELOG.md section for ${version} has no bullet highlights`)
const entry: ReleaseEntry = {
version: section.version,
date: section.date,
theme: deriveTheme(section.highlights),
highlights: section.highlights,
}
// Generated tops are automation-owned: refresh in place for the same version,
// or replace when the pending Release Please PR bumps the draft version.
if (generatedTop && (currentTop === version || divergedFromBase))
return { status: 'updated', version, content: replaceTopReleaseEntry(releasesTs, entry) }
// New version on top of the trusted base. insertReleaseEntry strips any
// leftover marker that remained on a previously generated published entry.
return { status: 'updated', version, content: insertReleaseEntry(releasesTs, entry) }
}
if (import.meta.main) {
const baseRefIndex = process.argv.indexOf('--base-ref')
const baseRef = baseRefIndex === -1 ? undefined : process.argv[baseRefIndex + 1]
if (baseRefIndex !== -1 && (!baseRef || baseRef.startsWith('--')))
throw new Error('--base-ref requires a git ref argument')
const result = syncWebReleaseEntry({ baseRef })
if (result.status === 'unchanged') console.log(`sync-web-release-entry: ${result.reason}`)
else if (process.argv.includes('--write')) {
writeFileSync(RELEASES_TS_PATH, result.content)
console.log(`sync-web-release-entry: inserted ${result.version} into ${RELEASES_TS_PATH}`)
} else {
console.error(`sync-web-release-entry: ${RELEASES_TS_PATH} is missing ${result.version}; run with --write`)
process.exit(1)
}
}