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

@@ -0,0 +1,5 @@
---
type: Fixed
pr: 694
---
**`/gsd:update` reliably previews release notes again** — promotes the 1.3.x changelog into dated `[1.3.0]`/`[1.3.1]` sections, stops deleting the temp changelog before the human-readable render (no more `(changelog unavailable)`), and adds a release gate that blocks publishing a version whose `CHANGELOG.md` section was never promoted.

View File

@@ -495,6 +495,12 @@ jobs:
node scripts/check-npm-integrity.cjs node scripts/check-npm-integrity.cjs
npm run test:coverage:unit npm run test:coverage:unit
- name: Verify CHANGELOG promoted
env:
VERSION: ${{ inputs.version }}
run: |
node scripts/changeset/cli.cjs verify --version "$VERSION" --changelog CHANGELOG.md
# npm bundled with Node 24 (pinned via setup-node) already supports trusted publishing (#318) # npm bundled with Node 24 (pinned via setup-node) already supports trusted publishing (#318)
- name: Dry-run publish validation - name: Dry-run publish validation

View File

@@ -6,6 +6,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [Unreleased] ## [Unreleased]
## [1.3.1](https://www.npmjs.com/package/@opengsd/gsd-core/v/1.3.1) - 2026-06-04
### Security
- **Bumped `hono` to clear a moderate npm advisory** carried transitively in the dependency tree. (#670)
### Fixed
- **Installer-migration checksum drift no longer blocks upgrades** — the updater now self-heals when a shipped migration's recorded checksum has drifted, reconciling the stored checksum instead of aborting. Restores upgrades across all OSes after shipped migration bodies were edited in a prior release. (#670)
## [1.3.0](https://www.npmjs.com/package/@opengsd/gsd-core/v/1.3.0) - 2026-06-04
### Added ### Added
- **Vertical MVP Slice mode** — `--mvp` flag on `/gsd-plan-phase` switches the planner from horizontal layer decomposition to vertical feature-slice decomposition (UI→API→DB in one task sequence). On Phase 1 of a new project with no prior phase summaries, also emits `SKELETON.md` via Walking Skeleton mode. Composable with `--tdd`: `--mvp --tdd` produces vertical slices where every behavior-adding task starts with a failing test. Phase-level persistence via `**Mode:** mvp` in ROADMAP.md applies `--mvp` automatically without the flag. (#78) - **Vertical MVP Slice mode** — `--mvp` flag on `/gsd-plan-phase` switches the planner from horizontal layer decomposition to vertical feature-slice decomposition (UI→API→DB in one task sequence). On Phase 1 of a new project with no prior phase summaries, also emits `SKELETON.md` via Walking Skeleton mode. Composable with `--tdd`: `--mvp --tdd` produces vertical slices where every behavior-adding task starts with a failing test. Phase-level persistence via `**Mode:** mvp` in ROADMAP.md applies `--mvp` automatically without the flag. (#78)

View File

@@ -174,7 +174,6 @@ EXTRACT_JSON=$(node "$GSD_DIR/gsd-core/scripts/changeset/cli.cjs" extract \
--changelog "$CHANGELOG_TMP" \ --changelog "$CHANGELOG_TMP" \
--json 2>/dev/null) --json 2>/dev/null)
EXTRACT_EXIT=$? EXTRACT_EXIT=$?
rm -f "$CHANGELOG_TMP"
if [ "$EXTRACT_EXIT" -eq 2 ]; then if [ "$EXTRACT_EXIT" -eq 2 ]; then
# Exit 2 = no releases in range (e.g. versions are equal or changelog is sparse) # Exit 2 = no releases in range (e.g. versions are equal or changelog is sparse)
@@ -188,6 +187,8 @@ else
--to "$LATEST_VERSION" \ --to "$LATEST_VERSION" \
--changelog "$CHANGELOG_TMP" 2>/dev/null || echo "(changelog unavailable)") --changelog "$CHANGELOG_TMP" 2>/dev/null || echo "(changelog unavailable)")
fi fi
# Clean up temp changelog now that both extract runs are done
rm -f "$CHANGELOG_TMP"
``` ```
3. Display preview and ask for confirmation, using `$CHANGELOG_PREVIEW` from the extract step above: 3. Display preview and ask for confirmation, using `$CHANGELOG_PREVIEW` from the extract step above:

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 * extract subcommand: extracts all changelog release blocks strictly after
* `--from` (exclusive) up to and including `--to` (inclusive). Both * `--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. * vague/manual extraction that can silently skip intermediate versions.
*/ */
function cmdExtract(opts) { function cmdExtract(opts) {
const stripV = (v) => (typeof v === 'string' ? v.replace(/^v/, '') : v);
const from = stripV(opts.fromRef); const from = stripV(opts.fromRef);
const to = stripV(opts.toRef); const to = stripV(opts.toRef);
@@ -225,9 +232,7 @@ function cmdExtract(opts) {
}; };
} }
const changelogPath = opts.changelog const changelogPath = resolveChangelogPath(opts);
? path.resolve(opts.changelog)
: path.join(path.resolve(opts.repo), 'CHANGELOG.md');
if (!fs.existsSync(changelogPath)) { if (!fs.existsSync(changelogPath)) {
return { 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) { function cmdGithubReleaseNotes(opts) {
const repo = path.resolve(opts.repo); const repo = path.resolve(opts.repo);
const report = renderGithubReleaseNotes({ const report = renderGithubReleaseNotes({
@@ -328,6 +388,7 @@ function usage() {
' Extracts changelog entries strictly after --from (exclusive) and up to', ' Extracts changelog entries strictly after --from (exclusive) and up to',
' and including --to (inclusive). Accepts v-prefixed versions.', ' and including --to (inclusive). Accepts v-prefixed versions.',
' Exit 2 when no releases fall in range.', ' 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'); ].join('\n');
} }
@@ -340,7 +401,7 @@ function main() {
process.exit(2); process.exit(2);
} }
const { opts } = parsed; 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.stderr.write(usage());
process.exit(1); process.exit(1);
} }
@@ -357,6 +418,10 @@ function main() {
process.stderr.write(usage()); process.stderr.write(usage());
process.exit(1); 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') { if (opts.cmd === 'extract') {
const { exitCode, report, textOutput } = cmdExtract(opts); const { exitCode, report, textOutput } = cmdExtract(opts);
@@ -370,6 +435,18 @@ function main() {
process.exit(exitCode); 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); const { exitCode, report } = opts.cmd === 'render' ? cmdRender(opts) : cmdGithubReleaseNotes(opts);
if (opts.json) { if (opts.json) {
process.stdout.write(JSON.stringify(report, null, 2) + '\n'); process.stdout.write(JSON.stringify(report, null, 2) + '\n');
@@ -389,4 +466,4 @@ function main() {
if (require.main === module) 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 };

View File

@@ -382,3 +382,198 @@ describe('changeset cli render: file-I/O wrapper (#2975)', () => {
assert.deepEqual(remaining.filter((f) => f.endsWith('.md')), []); assert.deepEqual(remaining.filter((f) => f.endsWith('.md')), []);
}); });
}); });
// ---------------------------------------------------------------------------
// GROUP A — #690 regression guard (real repo CHANGELOG.md)
// ---------------------------------------------------------------------------
describe('changeset cli #690 regression: CHANGELOG.md has 1.3.0 and 1.3.1 entries', () => {
const CHANGELOG_PATH = path.join(ROOT, 'CHANGELOG.md');
test('CHANGELOG.md has dated 1.3.0 and 1.3.1 release headings (regression #690)', () => {
const text = fs.readFileSync(CHANGELOG_PATH, 'utf8');
const { releases } = parseChangelog(text);
const stableReleases = releases.filter((r) => r.version !== 'Unreleased');
const v130 = stableReleases.find((r) => r.version === '1.3.0');
assert.ok(v130, 'CHANGELOG.md must contain a release entry for version 1.3.0');
assert.ok(v130.date !== null && v130.date !== '', '1.3.0 entry must have a non-null, non-empty date');
const v131 = stableReleases.find((r) => r.version === '1.3.1');
assert.ok(v131, 'CHANGELOG.md must contain a release entry for version 1.3.1');
assert.ok(v131.date !== null && v131.date !== '', '1.3.1 entry must have a non-null, non-empty date');
});
test('extract 1.2.0->1.3.1 against repo CHANGELOG returns both 1.3.x releases (regression #690)', () => {
const r = cp.spawnSync(
process.execPath,
[SCRIPT, 'extract', '--from', '1.2.0', '--to', '1.3.1', '--changelog', CHANGELOG_PATH, '--json'],
{ encoding: 'utf8' },
);
const json = (() => { try { return JSON.parse(r.stdout); } catch { return null; } })();
assert.equal(r.status, 0, `expected exit 0 but got ${r.status}; stderr=${r.stderr}; stdout=${r.stdout}`);
assert.ok(json, 'stdout must be valid JSON');
const versions = (json.releases || []).map((rel) => rel.version);
assert.ok(versions.includes('1.3.0'), `releases array must include 1.3.0; got: ${JSON.stringify(versions)}`);
assert.ok(versions.includes('1.3.1'), `releases array must include 1.3.1; got: ${JSON.stringify(versions)}`);
});
});
// ---------------------------------------------------------------------------
// GROUP B — unit tests for the (not-yet-implemented) `verify` subcommand
// ---------------------------------------------------------------------------
function runVerify(args, changelogText) {
const changelogFile = path.join(tmp, 'CHANGELOG-verify-test.md');
fs.writeFileSync(changelogFile, changelogText);
const r = cp.spawnSync(
process.execPath,
[SCRIPT, 'verify', '--changelog', changelogFile, ...args],
{ encoding: 'utf8' },
);
return {
status: r.status,
stdout: r.stdout || '',
stderr: r.stderr || '',
};
}
describe('changeset cli verify subcommand (not yet implemented — TDD red step)', () => {
// Fixture: inline-URL heading form ## [1.3.1](url) - 2026-06-04
const FIXTURE_URL_HEADING = [
'# Changelog',
'',
'## [1.3.1](https://www.npmjs.com/package/@opengsd/gsd-core/v/1.3.1) - 2026-06-04',
'',
'### Fixed',
'',
'- Some fix. (#42)',
'',
'## [1.2.0] - 2026-05-31',
'',
'### Added',
'',
'- Some feature. (#10)',
].join('\n');
// Fixture: plain dated heading ## [1.3.1] - 2026-06-04
const FIXTURE_PLAIN_HEADING = [
'# Changelog',
'',
'## [1.3.1] - 2026-06-04',
'',
'### Fixed',
'',
'- Some fix. (#42)',
'',
'## [1.2.0] - 2026-05-31',
'',
'### Added',
'',
'- Some feature. (#10)',
].join('\n');
// Fixture: version absent (only Unreleased + 1.2.0)
const FIXTURE_NO_131 = [
'# Changelog',
'',
'## [Unreleased]',
'',
'## [1.2.0] - 2026-05-31',
'',
'### Added',
'',
'- Some feature. (#10)',
].join('\n');
// Fixture: heading present but NO date
const FIXTURE_NO_DATE = [
'# Changelog',
'',
'## [1.3.1]',
'',
'### Fixed',
'',
'- Some fix. (#42)',
].join('\n');
test('verify --version 1.3.1 exits 0 when inline-URL dated heading is present', () => {
const r = runVerify(['--version', '1.3.1'], FIXTURE_URL_HEADING);
assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`);
});
test('verify --version 1.3.1 exits 0 when plain dated heading is present', () => {
const r = runVerify(['--version', '1.3.1'], FIXTURE_PLAIN_HEADING);
assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`);
});
test('verify --version 1.3.1 exits 1 when version heading is absent', () => {
const r = runVerify(['--version', '1.3.1'], FIXTURE_NO_131);
assert.equal(r.status, 1, `expected exit 1; stderr=${r.stderr}`);
});
test('verify --version 1.3.1 exits 1 when heading has no date', () => {
const r = runVerify(['--version', '1.3.1'], FIXTURE_NO_DATE);
assert.equal(r.status, 1, `expected exit 1; stderr=${r.stderr}`);
});
test('verify --version 1.3.1 with no match emits non-empty stderr/message', () => {
const r = runVerify(['--version', '1.3.1'], FIXTURE_NO_131);
assert.ok(
(r.stderr && r.stderr.trim().length > 0) || (r.stdout && r.stdout.trim().length > 0),
'must emit a non-empty error message to stderr or stdout when version not found',
);
});
test('verify --version 1.3.x exits 1 for invalid semver', () => {
// Use any non-empty fixture; the version check should fail before reading the file.
const r = runVerify(['--version', '1.3.x'], FIXTURE_PLAIN_HEADING);
assert.equal(r.status, 1, `expected exit 1 for invalid semver 1.3.x; stderr=${r.stderr}`);
});
test('verify --version v1.3.1 (v-prefixed) exits 0 when dated heading present', () => {
// The leading `v` must be stripped, matching extract's behavior.
const FIXTURE_DATED = [
'# Changelog',
'',
'## [1.3.1] - 2026-06-04',
'',
'### Fixed',
'',
'- Some fix. (#42)',
].join('\n');
const r = runVerify(['--version', 'v1.3.1'], FIXTURE_DATED);
assert.equal(r.status, 0, `expected exit 0 for v-prefixed version; stderr=${r.stderr}`);
});
test('verify --version 1.3.1 --json exits 0 and emits ok/version/date JSON fields', () => {
const FIXTURE_DATED = [
'# Changelog',
'',
'## [1.3.1] - 2026-06-04',
'',
'### Fixed',
'',
'- Some fix. (#42)',
].join('\n');
const changelogFile = path.join(tmp, 'CHANGELOG-verify-test.md');
fs.writeFileSync(changelogFile, FIXTURE_DATED);
const r = cp.spawnSync(
process.execPath,
[SCRIPT, 'verify', '--version', '1.3.1', '--json', '--changelog', changelogFile],
{ encoding: 'utf8' },
);
assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`);
const json = (() => { try { return JSON.parse(r.stdout); } catch { return null; } })();
assert.ok(json, 'stdout must be valid JSON');
assert.strictEqual(json.ok, true, 'json.ok must be true');
assert.strictEqual(json.version, '1.3.1', 'json.version must equal "1.3.1"');
assert.ok(json.date && json.date.length > 0, 'json.date must be non-empty');
});
test('verify --version 1.3.1-rc.1 (pre-release) exits 1', () => {
// Non-stable-triplet semver is rejected by the verify subcommand.
const r = runVerify(['--version', '1.3.1-rc.1'], FIXTURE_PLAIN_HEADING);
assert.equal(r.status, 1, `expected exit 1 for pre-release version; stderr=${r.stderr}`);
});
});