mirror of
https://github.com/EvoMap/evolver.git
synced 2026-09-18 21:47:53 +08:00
c3a0098cd5
- License section now states GPL-3.0-or-later (was stale MIT) in README / README.zh-CN / SKILL. - Remove the maintainer-only "Public Release" section (non-existent npm scripts + internal publish flow). - EVOLVER_ISSUE_REPO documented default -> EvoMap/evolver (matches code default). - Point auto-issue hint (index.js) and skill attribution footer (skillPublisher.js) at EvoMap/evolver. - Normalize `evolver run` -> `evolver`; rewrite Roadmap into directional items. - Drop .github/CODEOWNERS from the public mirror. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
167 lines
5.4 KiB
JavaScript
167 lines
5.4 KiB
JavaScript
'use strict';
|
|
|
|
/**
|
|
* CHANGELOG release-section integrity guard.
|
|
*
|
|
* Catches the misattribution pattern that bit us with #540 / PR #107:
|
|
* an entry filed under `## [X.Y.Z]` AFTER v1.85.0 was already published
|
|
* to npm, so the changelog claimed a fix the binary didn't contain.
|
|
*
|
|
* Algorithm: for every `## [X.Y.Z]` heading in CHANGELOG.md that has a
|
|
* matching git tag (`vX.Y.Z`), compare the section content at HEAD
|
|
* against the section content at that tag. If they differ, somebody
|
|
* edited a frozen-and-released section — fail loud.
|
|
*
|
|
* Notes:
|
|
* - `## [Unreleased]` is exempt (it's the staging area, no tag).
|
|
* - Version headings without a corresponding tag are exempt — that's
|
|
* usually the "preparing X.Y.Z" state right before the tag exists.
|
|
* - Tag lookup is local-only (`git rev-parse`); CI must `git fetch
|
|
* --tags` first if it runs on a shallow clone.
|
|
* - `repoRoot` is injectable so tests don't need to monkey-patch the
|
|
* module by re-evaluating source (see PR #115 review).
|
|
*
|
|
* Usage:
|
|
* node scripts/check-changelog.js # CLI mode, exits 0/1
|
|
* const { checkChangelogIntegrity } = require('./check-changelog');
|
|
* const result = checkChangelogIntegrity({ repoRoot });
|
|
*/
|
|
|
|
const { execFileSync } = require('child_process');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
|
|
const DEFAULT_REPO_ROOT = path.resolve(__dirname, '..');
|
|
|
|
function readChangelogAtHead(repoRoot) {
|
|
return fs.readFileSync(path.join(repoRoot, 'CHANGELOG.md'), 'utf8');
|
|
}
|
|
|
|
function readChangelogAtRef(repoRoot, ref) {
|
|
try {
|
|
return execFileSync('git', ['show', `${ref}:CHANGELOG.md`], {
|
|
cwd: repoRoot,
|
|
encoding: 'utf8',
|
|
stdio: ['ignore', 'pipe', 'ignore'],
|
|
});
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function tagExists(repoRoot, tag) {
|
|
try {
|
|
execFileSync('git', ['rev-parse', '--verify', `refs/tags/${tag}`], {
|
|
cwd: repoRoot,
|
|
stdio: ['ignore', 'ignore', 'ignore'],
|
|
});
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Pull every `## [X.Y.Z]` heading from the file, skipping `[Unreleased]`.
|
|
function listReleasedVersionHeadings(text) {
|
|
const versions = [];
|
|
const re = /^## \[(\d+\.\d+\.\d+(?:[-+][\w.]+)?)\]/gm;
|
|
let m;
|
|
while ((m = re.exec(text)) !== null) {
|
|
versions.push(m[1]);
|
|
}
|
|
return versions;
|
|
}
|
|
|
|
// Extract the body between `## [X.Y.Z]` and the next `## [` (or EOF).
|
|
// Normalises trailing whitespace and trailing blank lines so a stray
|
|
// newline doesn't fail the equality check.
|
|
//
|
|
// Heading match is line-anchored (`/^## \[X\.Y\.Z\]/m`) so a fenced
|
|
// code block or quoted text containing `## [X.Y.Z]` mid-line cannot be
|
|
// mistaken for the section start (Bugbot PR #115 review).
|
|
function extractSection(text, version) {
|
|
const escaped = version.replace(/[.+]/g, (c) => '\\' + c);
|
|
const re = new RegExp(`^## \\[${escaped}\\]`, 'm');
|
|
const match = re.exec(text);
|
|
if (!match) return null;
|
|
const after = match.index + match[0].length;
|
|
const rest = text.slice(after);
|
|
const nextRel = rest.search(/\n## \[/);
|
|
const raw = nextRel === -1 ? rest : rest.slice(0, nextRel);
|
|
return raw
|
|
.split('\n')
|
|
.map((line) => line.replace(/\s+$/, ''))
|
|
.join('\n')
|
|
.replace(/\n+$/, '');
|
|
}
|
|
|
|
function checkChangelogIntegrity(opts) {
|
|
const repoRoot = (opts && opts.repoRoot) || DEFAULT_REPO_ROOT;
|
|
const head = readChangelogAtHead(repoRoot);
|
|
const versions = listReleasedVersionHeadings(head);
|
|
|
|
const drift = [];
|
|
const skipped = [];
|
|
|
|
for (const version of versions) {
|
|
const tag = `v${version}`;
|
|
if (!tagExists(repoRoot, tag)) {
|
|
skipped.push({ version, reason: 'no matching git tag (probably preparing this release)' });
|
|
continue;
|
|
}
|
|
const tagText = readChangelogAtRef(repoRoot, tag);
|
|
if (tagText == null) {
|
|
skipped.push({ version, reason: `tag ${tag} exists but its CHANGELOG.md is unreadable` });
|
|
continue;
|
|
}
|
|
const headSection = extractSection(head, version);
|
|
const tagSection = extractSection(tagText, version);
|
|
if (headSection == null || tagSection == null) {
|
|
skipped.push({ version, reason: 'section parse failed' });
|
|
continue;
|
|
}
|
|
if (headSection !== tagSection) {
|
|
drift.push({ version, tag });
|
|
}
|
|
}
|
|
|
|
return { drift, skipped, checked: versions.length - skipped.length };
|
|
}
|
|
|
|
function main() {
|
|
const result = checkChangelogIntegrity();
|
|
|
|
process.stdout.write(`\n=== CHANGELOG release-section guard ===\n`);
|
|
process.stdout.write(`Checked ${result.checked} released version section(s); skipped ${result.skipped.length}.\n`);
|
|
|
|
for (const s of result.skipped) {
|
|
process.stdout.write(` [skip] ${s.version}: ${s.reason}\n`);
|
|
}
|
|
|
|
if (result.drift.length === 0) {
|
|
process.stdout.write(`\n[OK] No released CHANGELOG section was edited after its release tag.\n`);
|
|
return 0;
|
|
}
|
|
|
|
process.stderr.write(`\n[FAIL] ${result.drift.length} CHANGELOG section(s) diverged from their release tag:\n`);
|
|
for (const d of result.drift) {
|
|
process.stderr.write(` - ## [${d.version}] differs from ${d.tag}:CHANGELOG.md\n`);
|
|
}
|
|
process.stderr.write(
|
|
`\nReleased sections must stay frozen. Move any new entries under ## [Unreleased],\n` +
|
|
`or, if the entry was genuinely missing from the release, amend it on a hotfix\n` +
|
|
`branch and tag a patch release.\n`
|
|
);
|
|
return 1;
|
|
}
|
|
|
|
if (require.main === module) {
|
|
process.exit(main());
|
|
}
|
|
|
|
module.exports = {
|
|
checkChangelogIntegrity,
|
|
extractSection, // for tests
|
|
listReleasedVersionHeadings, // for tests
|
|
};
|