Files
seikiko c3a0098cd5 docs: license to GPL-3.0-or-later; drop internal Public Release section; fix issue-repo/runtime refs
- 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>
2026-06-08 02:33:38 +00:00

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
};