112 lines
3.4 KiB
JavaScript
112 lines
3.4 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* check-changelog-append-only — CI guard (OPS44): the aggregated changelog
|
|
* must never lose an existing line, and docs/changelog/ entries are
|
|
* additions-only. Legitimate restorations (D8 pattern) and header rewrites
|
|
* escape via the documented `changelog-rewrite:` token in the PR body.
|
|
*
|
|
* Paths from git diff (merge-base…HEAD); body from PR_BODY env or `gh pr
|
|
* view` when PR_NUMBER is set. The escape is honored only when the diff
|
|
* actually touches the aggregate or docs/changelog/, and only as a
|
|
* standalone line (never the PR template's own checkbox text).
|
|
*/
|
|
import { execFileSync } from 'node:child_process'
|
|
import { readFileSync } from 'node:fs'
|
|
import { join } from 'node:path'
|
|
|
|
import {
|
|
assertChangelogAppendOnly,
|
|
CHANGELOG_AGGREGATE,
|
|
CHANGELOG_DIR,
|
|
CHANGELOG_REWRITE_ESCAPE_RE,
|
|
} from './lib/changelog.mjs'
|
|
|
|
const base = `origin/${process.env.GITHUB_BASE_REF ?? 'main'}`
|
|
|
|
function die(message) {
|
|
console.error(`[check-changelog-append-only] ${message}`)
|
|
process.exit(1)
|
|
}
|
|
|
|
function git(args) {
|
|
return execFileSync('git', args, { encoding: 'utf8' }).trim()
|
|
}
|
|
|
|
let mergeBase
|
|
try {
|
|
mergeBase = git(['merge-base', 'HEAD', base])
|
|
} catch {
|
|
die(`no merge-base with ${base} (shallow clone?)`)
|
|
}
|
|
|
|
function listChangedPaths() {
|
|
const raw = execFileSync(
|
|
'git',
|
|
['diff', '--name-status', `${mergeBase}...HEAD`, '--', CHANGELOG_AGGREGATE, CHANGELOG_DIR],
|
|
{ encoding: 'utf8' },
|
|
)
|
|
|
|
return raw
|
|
.split('\n')
|
|
.map((line) => line.trim())
|
|
.filter(Boolean)
|
|
}
|
|
|
|
function readPrBody() {
|
|
if (process.env.PR_BODY !== undefined) {
|
|
return process.env.PR_BODY
|
|
}
|
|
|
|
const prNumber = process.env.PR_NUMBER ?? process.env.GITHUB_PR_NUMBER
|
|
if (!prNumber) return ''
|
|
|
|
try {
|
|
const json = execFileSync('gh', ['pr', 'view', String(prNumber), '--json', 'body'], {
|
|
encoding: 'utf8',
|
|
})
|
|
const parsed = JSON.parse(json)
|
|
return typeof parsed.body === 'string' ? parsed.body : ''
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error)
|
|
die(`failed to read PR #${prNumber} body via gh: ${detail}`)
|
|
}
|
|
}
|
|
|
|
const diffLines = listChangedPaths()
|
|
if (diffLines.length === 0) {
|
|
console.log('[check-changelog-append-only] ok — no changelog diff')
|
|
process.exit(0)
|
|
}
|
|
|
|
// R (rename): status + old path + new path — use the new path.
|
|
const changelogDiff = diffLines.map((line) => {
|
|
const [status, ...rest] = line.split('\t')
|
|
return { status, path: status.startsWith('R') ? rest[rest.length - 1] : rest.join('\t') }
|
|
})
|
|
|
|
const body = readPrBody()
|
|
if (CHANGELOG_REWRITE_ESCAPE_RE.test(body)) {
|
|
console.log('[check-changelog-append-only] ok — changelog-rewrite escape in PR body')
|
|
process.exit(0)
|
|
}
|
|
|
|
function blob(ref, path) {
|
|
return execFileSync('git', ['show', `${ref}:${path}`], { encoding: 'utf8' })
|
|
}
|
|
|
|
const aggregateDiff = changelogDiff.filter(({ path }) => path === CHANGELOG_AGGREGATE)
|
|
const oldAggregate = aggregateDiff.length > 0 ? blob(mergeBase, CHANGELOG_AGGREGATE) : ''
|
|
const newAggregate =
|
|
aggregateDiff.length > 0 ? readFileSync(join(process.cwd(), CHANGELOG_AGGREGATE), 'utf8') : ''
|
|
|
|
const result = assertChangelogAppendOnly({
|
|
oldAggregate,
|
|
newAggregate,
|
|
changelogDiff: changelogDiff.filter(({ path }) => path.startsWith(`${CHANGELOG_DIR}/`)),
|
|
})
|
|
|
|
if (!result.ok) {
|
|
die(result.message)
|
|
}
|
|
|
|
console.log('[check-changelog-append-only] ok — changelog is append-only')
|