fix(#690): promote CHANGELOG 1.3.x + gate release-notes promotion (#694)

* fix(#690): promote CHANGELOG 1.3.x + gate release-notes promotion

/gsd:update showed an empty "What's New" preview after updating to 1.3.1
because CHANGELOG.md's 1.3.x content was never promoted out of [Unreleased]
into dated sections, so `scripts/changeset/cli.cjs extract` returned exit 2
("no releases in range").

- CHANGELOG.md: split [Unreleased] into dated [1.3.0] and [1.3.1] sections
  (1.3.1 = hono advisory bump + installer-migration checksum self-heal, #670;
  1.3.0 = the feature release), restoring an empty [Unreleased].
- scripts/changeset/cli.cjs: new `verify` subcommand that exits non-zero when
  CHANGELOG has no dated `## [x.y.z]` heading for a version; hoist shared
  stripV/resolveChangelogPath helpers used by extract + verify.
- .github/workflows/release.yml: gate the finalize job on `verify` (after the
  build, before tag/publish) so an unpromoted CHANGELOG can never ship again.
- gsd-core/workflows/update.md: move `rm -f $CHANGELOG_TMP` after the
  human-readable extract re-run so the preview no longer degrades to
  "(changelog unavailable)".
- tests: regression guard for the 1.3.x headings + extract range + verify
  command coverage (present/absent/undated/v-prefixed/--json/prerelease).

Closes #690

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* chore(#690): add changeset fragment for #694

Fixed-type fragment for the user-facing /gsd:update preview fix and the
release-notes promotion gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-04 22:48:21 -04:00
committed by GitHub
parent 801ea5aae4
commit 3042b79178
6 changed files with 303 additions and 7 deletions

View File

@@ -188,6 +188,14 @@ function cmdRender(opts) {
};
}
function stripV(v) { return typeof v === 'string' ? v.replace(/^v/, '') : v; }
function resolveChangelogPath(opts) {
return opts.changelog
? path.resolve(opts.changelog)
: path.join(path.resolve(opts.repo), 'CHANGELOG.md');
}
/**
* extract subcommand: extracts all changelog release blocks strictly after
* `--from` (exclusive) up to and including `--to` (inclusive). Both
@@ -203,7 +211,6 @@ function cmdRender(opts) {
* vague/manual extraction that can silently skip intermediate versions.
*/
function cmdExtract(opts) {
const stripV = (v) => (typeof v === 'string' ? v.replace(/^v/, '') : v);
const from = stripV(opts.fromRef);
const to = stripV(opts.toRef);
@@ -225,9 +232,7 @@ function cmdExtract(opts) {
};
}
const changelogPath = opts.changelog
? path.resolve(opts.changelog)
: path.join(path.resolve(opts.repo), 'CHANGELOG.md');
const changelogPath = resolveChangelogPath(opts);
if (!fs.existsSync(changelogPath)) {
return {
@@ -282,6 +287,61 @@ function cmdExtract(opts) {
};
}
function cmdVerify(opts) {
const version = stripV(opts.version);
if (!isStableTripletSemver(version)) {
return {
exitCode: 1,
report: { error: `invalid semver for --version: "${version}" (expected N.N.N)`, ok: false },
textOutput: null,
};
}
const changelogPath = resolveChangelogPath(opts);
if (!fs.existsSync(changelogPath)) {
return {
exitCode: 1,
report: { error: `CHANGELOG not found: ${changelogPath}`, ok: false },
textOutput: null,
};
}
const text = fs.readFileSync(changelogPath, 'utf8');
const { releases } = parseChangelog(text);
const match = releases.find((r) => r.version === version);
if (!match) {
return {
exitCode: 1,
report: {
error: `CHANGELOG.md has no \`## [${version}]\` release heading — promote [Unreleased] into a dated section before releasing (see #690)`,
ok: false,
},
textOutput: null,
};
}
if (!match.date) {
return {
exitCode: 1,
report: {
error: `CHANGELOG.md heading \`## [${version}]\` has no date — expected \`## [${version}] - YYYY-MM-DD\``,
ok: false,
},
textOutput: null,
};
}
return {
exitCode: 0,
report: { ok: true, version, date: match.date },
textOutput: `CHANGELOG.md has a dated heading for ${version} (${match.date})`,
};
}
function cmdGithubReleaseNotes(opts) {
const repo = path.resolve(opts.repo);
const report = renderGithubReleaseNotes({
@@ -328,6 +388,7 @@ function usage() {
' Extracts changelog entries strictly after --from (exclusive) and up to',
' and including --to (inclusive). Accepts v-prefixed versions.',
' Exit 2 when no releases fall in range.',
' changeset/cli.cjs verify --version <X.Y.Z> [--changelog <path>] Exit non-zero if CHANGELOG.md has no dated `## [X.Y.Z]` heading (release gate, #690)',
'',
].join('\n');
}
@@ -340,7 +401,7 @@ function main() {
process.exit(2);
}
const { opts } = parsed;
if (opts.cmd !== 'render' && opts.cmd !== 'github-release-notes' && opts.cmd !== 'extract') {
if (opts.cmd !== 'render' && opts.cmd !== 'github-release-notes' && opts.cmd !== 'extract' && opts.cmd !== 'verify') {
process.stderr.write(usage());
process.exit(1);
}
@@ -357,6 +418,10 @@ function main() {
process.stderr.write(usage());
process.exit(1);
}
if (opts.cmd === 'verify' && !opts.version) {
process.stderr.write('--version is required for verify\n');
process.exit(2);
}
if (opts.cmd === 'extract') {
const { exitCode, report, textOutput } = cmdExtract(opts);
@@ -370,6 +435,18 @@ function main() {
process.exit(exitCode);
}
if (opts.cmd === 'verify') {
const { exitCode, report, textOutput } = cmdVerify(opts);
if (opts.json) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
} else if (textOutput) {
process.stdout.write(textOutput + '\n');
} else {
process.stderr.write(report.error + '\n');
}
process.exit(exitCode);
}
const { exitCode, report } = opts.cmd === 'render' ? cmdRender(opts) : cmdGithubReleaseNotes(opts);
if (opts.json) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
@@ -389,4 +466,4 @@ function main() {
if (require.main === module) main();
module.exports = { cmdRender, cmdExtract, cmdGithubReleaseNotes, parseArgs, splitChangelog, listFragmentFiles, usage };
module.exports = { cmdRender, cmdExtract, cmdVerify, cmdGithubReleaseNotes, parseArgs, splitChangelog, listFragmentFiles, usage };