diff --git a/.changeset/tidy-otters-glide.md b/.changeset/tidy-otters-glide.md new file mode 100644 index 000000000..700e9210b --- /dev/null +++ b/.changeset/tidy-otters-glide.md @@ -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. diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1ac2ca9a6..f0dd401dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -495,6 +495,12 @@ jobs: node scripts/check-npm-integrity.cjs 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) - name: Dry-run publish validation diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f25bcfef..913a58ac8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [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 - **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) diff --git a/gsd-core/workflows/update.md b/gsd-core/workflows/update.md index 31e69d098..77692917e 100644 --- a/gsd-core/workflows/update.md +++ b/gsd-core/workflows/update.md @@ -174,7 +174,6 @@ EXTRACT_JSON=$(node "$GSD_DIR/gsd-core/scripts/changeset/cli.cjs" extract \ --changelog "$CHANGELOG_TMP" \ --json 2>/dev/null) EXTRACT_EXIT=$? -rm -f "$CHANGELOG_TMP" if [ "$EXTRACT_EXIT" -eq 2 ]; then # Exit 2 = no releases in range (e.g. versions are equal or changelog is sparse) @@ -188,6 +187,8 @@ else --to "$LATEST_VERSION" \ --changelog "$CHANGELOG_TMP" 2>/dev/null || echo "(changelog unavailable)") 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: diff --git a/scripts/changeset/cli.cjs b/scripts/changeset/cli.cjs index cf2823233..097e06a04 100755 --- a/scripts/changeset/cli.cjs +++ b/scripts/changeset/cli.cjs @@ -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 [--changelog ] 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 }; diff --git a/tests/changeset-cli.test.cjs b/tests/changeset-cli.test.cjs index 789785f44..0e777eb2a 100644 --- a/tests/changeset-cli.test.cjs +++ b/tests/changeset-cli.test.cjs @@ -382,3 +382,198 @@ describe('changeset cli render: file-I/O wrapper (#2975)', () => { 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}`); + }); +});