mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
* 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
308 lines
12 KiB
TypeScript
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)
|
|
}
|
|
}
|