diff --git a/.changeset/README.md b/.changeset/README.md new file mode 100644 index 000000000..23e550926 --- /dev/null +++ b/.changeset/README.md @@ -0,0 +1,44 @@ +# Changeset Fragments + +This directory holds **per-PR CHANGELOG fragments**. Every PR with user-facing changes drops one (or more) `.md` files here describing its CHANGELOG entry. Fragments are consolidated into the top-level `CHANGELOG.md` at release time. + +## Why + +Two PRs that both edit the `### Fixed` block of `CHANGELOG.md` always conflict on merge — git can't pick a serialization order without human input. Two PRs that each add a fresh `.changeset/.md` never conflict because they don't share lines. + +See [#2975](https://github.com/gsd-build/get-shit-done/issues/2975) for the full rationale. + +## Adding a fragment + +```bash +node scripts/changeset/new.cjs \ + --type Fixed \ + --pr 1234 \ + --body "fix the thing — explain the user-visible change in one sentence" +``` + +This writes `.changeset/--.md` with frontmatter and a body. Three random words → concurrent PRs don't collide. + +## Format + +```md +--- +type: Fixed +pr: 1234 +--- +**`/gsd-foo` no longer drops trailing slashes** — explain the user-visible change. +``` + +Allowed `type:` values follow [Keep a Changelog](https://keepachangelog.com/): `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. + +## Opting out + +PRs that legitimately have no user-facing impact can add the `no-changelog` label. CI honors it. When unsure, add the fragment. + +## At release time + +```bash +node scripts/changeset/cli.cjs render --version vX.Y.Z --date YYYY-MM-DD +``` + +Reads every fragment, groups bullets by `type:`, replaces `## [Unreleased]` with a new `## [vX.Y.Z] - YYYY-MM-DD` block, opens a fresh `## [Unreleased]` above, deletes consumed fragments. Idempotent. diff --git a/.changeset/eager-hawks-rally.md b/.changeset/eager-hawks-rally.md new file mode 100644 index 000000000..589c8cb63 --- /dev/null +++ b/.changeset/eager-hawks-rally.md @@ -0,0 +1,5 @@ +--- +type: Added +pr: 2975 +--- +**Changeset-fragment workflow** — eliminates CHANGELOG.md merge conflicts. Each PR drops `.changeset/.md` with frontmatter (`type:`, `pr:`) plus a markdown body; the release-time `npm run changelog:render` consolidates fragments into `CHANGELOG.md` and deletes them. CI lint (`npm run lint:changeset`) requires a fragment on any PR touching user-facing files (`bin/`, `get-shit-done/`, `agents/`, `commands/`, `hooks/`, `sdk/src/`); contributors can opt out via the `no-changelog` label for purely internal changes. See [.changeset/README.md](.changeset/README.md) and CONTRIBUTING.md for the workflow. diff --git a/.github/workflows/changeset-required.yml b/.github/workflows/changeset-required.yml new file mode 100644 index 000000000..5baacee97 --- /dev/null +++ b/.github/workflows/changeset-required.yml @@ -0,0 +1,24 @@ +name: Changeset Required + +on: + pull_request: + types: [opened, synchronize, reopened, labeled, unlabeled] + +permissions: + contents: read + pull-requests: read + +jobs: + changeset-lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: '24' + - name: Run changeset lint + env: + GITHUB_BASE_REF: ${{ github.base_ref }} + run: node scripts/changeset/lint.cjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9502633da..862221517 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -91,6 +91,23 @@ PRs that arrive without a properly-labeled linked issue are closed automatically - **CI must pass** — all matrix jobs (Ubuntu × Node 22, 24; macOS × Node 24) must be green - **Scope matches the approved issue** — if your PR does more than what the issue describes, the extra changes will be asked to be removed or moved to a new issue +## CHANGELOG Entries — Drop a Fragment + +**Do not edit `CHANGELOG.md` directly.** Two PRs that both append to a `### Fixed` block always conflict on merge — git can't pick a serialization order without a human. Instead, every PR with user-facing changes drops a fragment file in `.changeset/`. + +```bash +npm run changeset -- --type Fixed --pr \ + --body "**\`/gsd-foo\` no longer drops trailing slashes** — explain the user-visible change." +``` + +This writes `.changeset/--.md`. Three random words → concurrent PRs never collide. Allowed `type:` values follow [Keep a Changelog](https://keepachangelog.com/): `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`. + +Fragments are consolidated into `CHANGELOG.md` at release time by the release workflow. See [`.changeset/README.md`](.changeset/README.md) for the format spec and [#2975](https://github.com/gsd-build/get-shit-done/issues/2975) for the rationale. + +**CI enforcement:** the `Changeset Required` workflow (`scripts/changeset/lint.cjs`) fails any PR that touches `bin/`, `get-shit-done/`, `agents/`, `commands/`, `hooks/`, or `sdk/src/` without a `.changeset/*.md` fragment. + +**Opt-out:** PRs with no user-facing impact (test refactors, lint config changes, CI tweaks, formatting-only changes) can add the `no-changelog` label. The lint honors it. When unsure whether a change is user-facing, **add the fragment**. + ## Testing Standards All tests use Node.js built-in test runner (`node:test`) and assertion library (`node:assert`). **Do not use Jest, Mocha, Chai, or any external test framework.** diff --git a/package.json b/package.json index 4d3156ab9..edd7209b7 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,9 @@ "pretest:coverage": "npm run build:sdk", "lint:descriptions": "node scripts/lint-descriptions.cjs", "lint:tests": "node scripts/lint-no-source-grep.cjs", + "lint:changeset": "node scripts/changeset/lint.cjs", + "changeset": "node scripts/changeset/new.cjs", + "changelog:render": "node scripts/changeset/cli.cjs render", "test": "node scripts/run-tests.cjs", "test:coverage": "c8 --check-coverage --lines 70 --reporter text --include 'get-shit-done/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs" } diff --git a/scripts/changeset/cli.cjs b/scripts/changeset/cli.cjs new file mode 100755 index 000000000..0d136a3a7 --- /dev/null +++ b/scripts/changeset/cli.cjs @@ -0,0 +1,191 @@ +#!/usr/bin/env node +'use strict'; + +/** + * CLI wrapper for the changeset-fragment workflow (#2975). + * + * Subcommands: + * render --repo --version V --date D [--json] Fold .changeset/*.md + * into CHANGELOG.md; + * delete consumed fragments. + * + * `--json` emits a structured report on stdout — the only contract tests + * assert against. Per CONTRIBUTING.md "Prohibited: Raw Text Matching on + * Test Outputs", the human formatter is operator-only. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +const { parseFragment, FRAGMENT_ERROR } = require('./parse.cjs'); +const { renderChangelog } = require('./render.cjs'); +const { serializeChangelog } = require('./serialize.cjs'); + +function parseArgs(argv) { + const opts = { cmd: null, repo: process.cwd(), version: null, date: null, json: false }; + if (argv.length === 0) return { ok: true, opts }; + opts.cmd = argv[0]; + + // Pull a value for a value-taking flag, validating that the next token + // exists and is not itself another flag (which is the silently-misparsed + // case CR called out: e.g. `--repo --json` would consume `--json` as the + // repo path). + const requireValue = (flag, i) => { + const v = argv[i + 1]; + if (v === undefined || v.startsWith('--')) { + return { ok: false, error: `missing value for ${flag}` }; + } + return { ok: true, value: v }; + }; + + for (let i = 1; i < argv.length; i++) { + const a = argv[i]; + if (a === '--json') { opts.json = true; continue; } + if (a === '--repo' || a === '--version' || a === '--date') { + const r = requireValue(a, i); + if (!r.ok) return { ok: false, error: r.error }; + if (a === '--repo') opts.repo = r.value; + else if (a === '--version') opts.version = r.value; + else if (a === '--date') opts.date = r.value; + i++; + continue; + } + return { ok: false, error: `unknown argument: ${a}` }; + } + return { ok: true, opts }; +} + +function listFragmentFiles(changesetDir) { + if (!fs.existsSync(changesetDir)) return []; + return fs.readdirSync(changesetDir) + .filter((f) => f.endsWith('.md') && f !== 'README.md') + .map((f) => path.join(changesetDir, f)); +} + +function splitChangelog(text) { + // Split off the top-level "# Changelog" heading + lead matter (everything + // before the first "## [version]" block) from the rest. The rest is the + // priorChangelog passed into renderChangelog. The "## [Unreleased]" block, + // if present, is dropped (the new release replaces it). + const lines = text.split(/\r?\n/); + const firstReleaseIdx = lines.findIndex((l) => /^##\s+\[/.test(l)); + if (firstReleaseIdx === -1) { + return { lead: text.replace(/\s+$/, ''), prior: '' }; + } + const lead = lines.slice(0, firstReleaseIdx).join('\n').replace(/\s+$/, ''); + let priorStart = firstReleaseIdx; + // Skip the [Unreleased] block if present — it's a placeholder, not a release. + if (/^##\s+\[Unreleased\]/i.test(lines[firstReleaseIdx])) { + let j = firstReleaseIdx + 1; + while (j < lines.length && !/^##\s+\[/.test(lines[j])) j++; + priorStart = j; + } + const prior = lines.slice(priorStart).join('\n').trimStart(); + return { lead, prior }; +} + +function cmdRender(opts) { + const repo = path.resolve(opts.repo); + const changesetDir = path.join(repo, '.changeset'); + const changelogPath = path.join(repo, 'CHANGELOG.md'); + const fragmentFiles = listFragmentFiles(changesetDir); + + const fragments = []; + const failures = []; + for (const file of fragmentFiles) { + const src = fs.readFileSync(file, 'utf8'); + const r = parseFragment(src); + if (r.ok) fragments.push({ ...r.fragment, file }); + else failures.push({ file: path.relative(repo, file), reason: r.reason, detail: r.detail || null }); + } + + if (failures.length > 0) { + return { exitCode: 1, report: { consumed: 0, failures } }; + } + if (fragments.length === 0) { + return { exitCode: 0, report: { consumed: 0, failures: [] } }; + } + + const priorText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : ''; + const { lead, prior } = splitChangelog(priorText); + + const ir = renderChangelog({ + fragments, + version: opts.version, + date: opts.date, + priorChangelog: prior || null, + }); + const releaseBlock = serializeChangelog(ir); + const out = [ + lead || '# Changelog', + '', + '## [Unreleased]', + '', + releaseBlock.replace(/\s+$/, ''), + '', + ].join('\n'); + + fs.writeFileSync(changelogPath, out); + + // Delete consumed fragments. If any unlink fails the changelog is written + // but the fragment is still on disk, so a re-run would double-consume it. + // Surface the partial-failure as exitCode=1 with structured detail so the + // operator can manually clean up before retrying. + const deleteFailures = []; + for (const f of fragments) { + try { + fs.unlinkSync(f.file); + } catch (e) { + deleteFailures.push({ + file: path.relative(repo, f.file), + reason: 'fail_fragment_delete', + detail: e.code || e.message, + }); + } + } + + return { + exitCode: deleteFailures.length > 0 ? 1 : 0, + report: { + consumed: fragments.length - deleteFailures.length, + failures: deleteFailures, + release: { version: opts.version, date: opts.date }, + }, + }; +} + +function main() { + const parsed = parseArgs(process.argv.slice(2)); + if (!parsed.ok) { + process.stderr.write(`${parsed.error}\n`); + process.stderr.write('usage: changeset/cli.cjs render --repo --version V --date D [--json]\n'); + process.exit(2); + } + const { opts } = parsed; + if (opts.cmd !== 'render') { + process.stderr.write('usage: changeset/cli.cjs render --repo --version V --date D [--json]\n'); + process.exit(2); + } + if (!opts.version || !opts.date) { + process.stderr.write('--version and --date are required for render\n'); + process.exit(2); + } + + const { exitCode, report } = cmdRender(opts); + if (opts.json) { + process.stdout.write(JSON.stringify(report, null, 2) + '\n'); + } else { + process.stdout.write(`Consumed: ${report.consumed} fragment(s)\n`); + if (report.failures.length > 0) { + process.stdout.write(`Failures: ${report.failures.length}\n`); + for (const f of report.failures) { + process.stdout.write(` ${f.file}: ${f.reason}${f.detail ? ` (${f.detail})` : ''}\n`); + } + } + } + process.exit(exitCode); +} + +if (require.main === module) main(); + +module.exports = { cmdRender, parseArgs, splitChangelog, listFragmentFiles }; diff --git a/scripts/changeset/lint.cjs b/scripts/changeset/lint.cjs new file mode 100755 index 000000000..51b54e3fe --- /dev/null +++ b/scripts/changeset/lint.cjs @@ -0,0 +1,110 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Changeset-fragment lint (#2975). + * + * Pure verdict function evaluateLint({ changedFiles, labels }) returns + * { ok, reason } using the LINT_REASON enum. The CLI wrapper calls it with + * the PR diff (via `git diff --name-only origin/main...HEAD` or the GitHub + * Actions event payload) and the labels list (via the GitHub event). + * + * Tests assert on the typed verdict, never on free text. + */ + +const LINT_REASON = Object.freeze({ + OK_FRAGMENT_PRESENT: 'ok_fragment_present', + OK_OPT_OUT_LABEL: 'ok_opt_out_label', + OK_NO_USER_FACING_CHANGES: 'ok_no_user_facing_changes', + FAIL_MISSING_FRAGMENT: 'fail_missing_fragment', +}); + +const OPT_OUT_LABEL = 'no-changelog'; + +// Files counted as "user-facing" — touching any of these requires either a +// fragment or an explicit opt-out label. Test/CI/docs/lock files do not. +const USER_FACING_PREFIXES = [ + 'bin/', + 'get-shit-done/', + 'agents/', + 'commands/', + 'hooks/', + 'sdk/src/', + 'sdk/prompts/', +]; + +// Exact-match user-facing files. Any direct edit to one of these without a +// fragment also fails the lint — closes the bypass where a contributor edits +// CHANGELOG.md directly to sneak past the new workflow. +const USER_FACING_FILES = new Set(['CHANGELOG.md']); + +function isUserFacing(file) { + if (USER_FACING_FILES.has(file)) return true; + return USER_FACING_PREFIXES.some((p) => file.startsWith(p)); +} + +function isFragment(file) { + return /^\.changeset\/[^/]+\.md$/.test(file) && !file.endsWith('/README.md'); +} + +function evaluateLint({ changedFiles, labels }) { + if (changedFiles.some(isFragment)) { + return { ok: true, reason: LINT_REASON.OK_FRAGMENT_PRESENT }; + } + if (labels.includes(OPT_OUT_LABEL)) { + return { ok: true, reason: LINT_REASON.OK_OPT_OUT_LABEL }; + } + if (!changedFiles.some(isUserFacing)) { + return { ok: true, reason: LINT_REASON.OK_NO_USER_FACING_CHANGES }; + } + return { ok: false, reason: LINT_REASON.FAIL_MISSING_FRAGMENT }; +} + +function main() { + const fs = require('node:fs'); + const cp = require('node:child_process'); + // GitHub Actions event payload path + const eventPath = process.env.GITHUB_EVENT_PATH; + let labels = []; + if (eventPath && fs.existsSync(eventPath)) { + try { + const event = JSON.parse(fs.readFileSync(eventPath, 'utf8')); + labels = (event.pull_request?.labels || []).map((l) => l.name); + } catch { /* fall through */ } + } + const base = process.env.GITHUB_BASE_REF || 'main'; + let changedFiles = []; + try { + // Use execFileSync with an argv array — the base ref is interpolated + // into a refspec argument, but execFileSync does not invoke a shell, so + // even a malicious GITHUB_BASE_REF cannot inject shell syntax. The + // refspec-bound metacharacters that git itself rejects (e.g. spaces in + // ref names) are caught by git's own arg parser. + const out = cp.execFileSync( + 'git', + ['diff', '--name-only', `origin/${base}...HEAD`], + { encoding: 'utf8' }, + ); + changedFiles = out.split('\n').filter(Boolean); + } catch (e) { + process.stderr.write(`could not compute diff: ${e.message}\n`); + process.exit(2); + } + + const verdict = evaluateLint({ changedFiles, labels }); + if (process.argv.includes('--json')) { + process.stdout.write(JSON.stringify({ ...verdict, changedFiles, labels }, null, 2) + '\n'); + } else if (verdict.ok) { + process.stdout.write(`ok changeset-lint: ${verdict.reason}\n`); + } else { + process.stderr.write(`\nERROR changeset-lint: ${verdict.reason}\n`); + process.stderr.write(`PR touches user-facing files but does not include a .changeset/*.md fragment.\n`); + process.stderr.write(`Run \`npm run changeset\` to create one, or add the \`${OPT_OUT_LABEL}\` label\n`); + process.stderr.write(`if this PR genuinely has no user-facing impact (test refactor, CI tweak, etc.).\n`); + } + process.exit(verdict.ok ? 0 : 1); +} + +if (require.main === module) main(); + +module.exports = { evaluateLint, LINT_REASON, OPT_OUT_LABEL, isUserFacing, isFragment }; diff --git a/scripts/changeset/new.cjs b/scripts/changeset/new.cjs new file mode 100755 index 000000000..3df75d023 --- /dev/null +++ b/scripts/changeset/new.cjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +'use strict'; + +/** + * Scaffolds a new changeset fragment (#2975). + * + * npm run changeset -- --type Fixed --pr 1234 --body "fix the thing" + * + * Writes `.changeset/--.md` with frontmatter + * + body. The random three-word filename minimizes filename collision + * across concurrent PRs. + */ + +const fs = require('node:fs'); +const path = require('node:path'); + +// Small word lists — keep the function simple and dependency-free. +// Together this gives ~40 * 40 * 40 = 64,000 distinct names. The lint +// rejects any duplicate filename, so collisions are caught even when +// the random draw repeats. +const ADJECTIVES = [ + 'silly', 'brave', 'calm', 'eager', 'gentle', 'happy', 'jolly', 'kind', + 'lively', 'merry', 'nimble', 'plucky', 'quick', 'sturdy', 'witty', 'zesty', + 'bold', 'clever', 'daring', 'fierce', 'graceful', 'humble', 'lucky', 'noble', + 'proud', 'rapid', 'sharp', 'tidy', 'vivid', 'wise', 'agile', 'curious', + 'eager', 'gallant', 'mellow', 'patient', 'serene', 'steady', 'sturdy', 'sunny', +]; +const NOUNS_A = [ + 'bears', 'birds', 'cats', 'dogs', 'elks', 'foxes', 'goats', 'hawks', + 'ibex', 'jays', 'koalas', 'lynx', 'moles', 'newts', 'otters', 'pumas', + 'quails', 'rams', 'seals', 'tigers', 'voles', 'wolves', 'yaks', 'zebras', + 'badgers', 'cranes', 'deer', 'eagles', 'finches', 'geese', 'herons', 'jaguars', + 'lemurs', 'mice', 'orcas', 'pandas', 'ravens', 'sloths', 'tunas', 'wasps', +]; +const NOUNS_B = [ + 'dance', 'sing', 'leap', 'run', 'jump', 'climb', 'fly', 'swim', + 'rest', 'wake', 'roam', 'greet', 'wander', 'gather', 'forage', 'travel', + 'glide', 'sprint', 'tumble', 'wave', 'cheer', 'rally', 'parade', 'march', + 'hop', 'frolic', 'caper', 'romp', 'zip', 'dart', 'snooze', 'munch', + 'chatter', 'squeak', 'howl', 'bark', 'purr', 'roar', 'hum', 'click', +]; + +function pick(arr) { + return arr[Math.floor(Math.random() * arr.length)]; +} + +function generateFragmentName() { + return `${pick(ADJECTIVES)}-${pick(NOUNS_A)}-${pick(NOUNS_B)}`; +} + +// Allowed Keep-a-Changelog section types. Used by both scaffoldFragment +// (sanitization at write time) and parse.cjs (validation at consume time). +const ALLOWED_TYPES = new Set(['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']); + +function scaffoldFragment({ repo, type, pr, body }) { + // Sanitize: reject any type value not on the allowlist BEFORE embedding it + // in frontmatter. A newline in `type` would corrupt the fragment; an + // unrecognized value would be rejected later by parse.cjs but with a + // confusing diagnostic. Catch both at the write boundary. + if (!ALLOWED_TYPES.has(type)) { + throw new Error( + `scaffoldFragment: type=${JSON.stringify(type)} is not one of [${[...ALLOWED_TYPES].join(', ')}]`, + ); + } + const dir = path.join(repo, '.changeset'); + fs.mkdirSync(dir, { recursive: true }); + const content = `---\ntype: ${type}\npr: ${pr}\n---\n${body}\n`; + // Atomic create: writeFileSync with `flag: 'wx'` fails (EEXIST) when the + // file already exists, so concurrent invocations can't race past + // `existsSync` and overwrite each other. Re-roll the random name on + // collision; fail loudly after exhausting the retry budget. + for (let i = 0; i < 16; i++) { + const name = generateFragmentName(); + const target = path.join(dir, `${name}.md`); + try { + fs.writeFileSync(target, content, { flag: 'wx' }); + return target; + } catch (e) { + if (e.code !== 'EEXIST') throw e; + // collision — try another random draw + } + } + throw new Error( + 'scaffoldFragment: 16 random filename draws all collided; ' + + 'expand the word lists or investigate corrupted .changeset/ state', + ); +} + +function parseArgs(argv) { + const opts = { type: null, pr: null, body: null, repo: process.cwd() }; + // Validate flag values: argv[++i] could be undefined (flag with no value) + // or another flag (silently misparsed). Match the cli.cjs convention: return + // { ok: true, opts } on success, { ok: false, error } on malformed input. + const requireValue = (flag, i) => { + const v = argv[i + 1]; + if (v === undefined || v.startsWith('--')) { + return { ok: false, error: `missing value for ${flag}` }; + } + return { ok: true, value: v }; + }; + + for (let i = 0; i < argv.length; i++) { + const a = argv[i]; + if (a === '--type' || a === '--pr' || a === '--body' || a === '--repo') { + const r = requireValue(a, i); + if (!r.ok) return { ok: false, error: r.error }; + if (a === '--type') opts.type = r.value; + else if (a === '--pr') opts.pr = Number(r.value); + else if (a === '--body') opts.body = r.value; + else if (a === '--repo') opts.repo = r.value; + i++; + continue; + } + return { ok: false, error: `unknown argument: ${a}` }; + } + return { ok: true, opts }; +} + +function main() { + const parsed = parseArgs(process.argv.slice(2)); + if (!parsed.ok) { + process.stderr.write(`${parsed.error}\n`); + process.stderr.write('usage: changeset/new.cjs --type --pr NNNN --body "..."\n'); + process.exit(2); + } + const { opts } = parsed; + if (!opts.type || !opts.pr || !opts.body) { + process.stderr.write('usage: changeset/new.cjs --type --pr NNNN --body "..."\n'); + process.exit(2); + } + const file = scaffoldFragment(opts); + process.stdout.write(`${path.relative(process.cwd(), file)}\n`); +} + +if (require.main === module) main(); + +module.exports = { generateFragmentName, scaffoldFragment, parseArgs, ALLOWED_TYPES }; diff --git a/scripts/changeset/parse.cjs b/scripts/changeset/parse.cjs new file mode 100644 index 000000000..b35ceacff --- /dev/null +++ b/scripts/changeset/parse.cjs @@ -0,0 +1,60 @@ +'use strict'; + +/** + * Parses a changeset fragment file (text → typed record). + * + * --- + * type: Fixed + * pr: 2975 + * --- + * + * + * Returns { ok: true, fragment: { type, pr, body } } on success, + * { ok: false, reason: FRAGMENT_ERROR.X, detail } on failure. + * + * The reason field is a frozen enum so tests assert on stable codes, + * not free-text error messages (CONTRIBUTING.md: "Prohibited: Raw + * Text Matching on Test Outputs"). + */ +const FRAGMENT_ERROR = Object.freeze({ + MISSING_FRONTMATTER: 'missing_frontmatter', + MISSING_TYPE: 'missing_type', + INVALID_TYPE: 'invalid_type', + MISSING_PR: 'missing_pr', + INVALID_PR: 'invalid_pr', + EMPTY_BODY: 'empty_body', +}); + +const ALLOWED_TYPES = new Set(['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']); + +function parseFragment(src) { + const fmMatch = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/); + if (!fmMatch) return { ok: false, reason: FRAGMENT_ERROR.MISSING_FRONTMATTER }; + const [, fmBlock, body] = fmMatch; + + const fields = {}; + for (const line of fmBlock.split(/\r?\n/)) { + const m = line.match(/^([a-zA-Z0-9_-]+):\s*(.*)$/); + if (m) fields[m[1]] = m[2].trim(); + } + + if (!fields.type) return { ok: false, reason: FRAGMENT_ERROR.MISSING_TYPE }; + if (!ALLOWED_TYPES.has(fields.type)) { + return { ok: false, reason: FRAGMENT_ERROR.INVALID_TYPE, detail: fields.type }; + } + if (!fields.pr) return { ok: false, reason: FRAGMENT_ERROR.MISSING_PR }; + const pr = Number(fields.pr); + if (!Number.isInteger(pr) || pr <= 0) { + return { ok: false, reason: FRAGMENT_ERROR.INVALID_PR, detail: fields.pr }; + } + // Use trim() only for the emptiness check; preserve the body verbatim + // (including significant leading/trailing whitespace, code blocks, etc.) + // so render → serialize round-trips exactly. Strip only a single trailing + // newline added by editors so byte-equality holds for typical fragments. + if (!body.trim()) return { ok: false, reason: FRAGMENT_ERROR.EMPTY_BODY }; + const verbatimBody = body.endsWith('\n') ? body.slice(0, -1) : body; + + return { ok: true, fragment: { type: fields.type, pr, body: verbatimBody } }; +} + +module.exports = { parseFragment, FRAGMENT_ERROR, ALLOWED_TYPES }; diff --git a/scripts/changeset/render.cjs b/scripts/changeset/render.cjs new file mode 100644 index 000000000..babcab34e --- /dev/null +++ b/scripts/changeset/render.cjs @@ -0,0 +1,34 @@ +'use strict'; + +/** + * Pure renderer for the changeset-fragment workflow (#2975). + * + * Returns a typed Changelog IR — no file I/O. The IR is the contract that + * tests assert on; the markdown serializer is a separate concern. + * + * IR shape: { + * releaseHeader: { version: string, date: string }, + * sections: [{ type: string, bullets: [{ pr: number, body: string }] }], + * priorChangelog: string | null, + * } + */ +// Keep a Changelog (https://keepachangelog.com) standard section order. +const SECTION_ORDER = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']; + +function renderChangelog({ fragments, version, date, priorChangelog }) { + const byType = new Map(); + for (const f of fragments) { + if (!byType.has(f.type)) byType.set(f.type, []); + byType.get(f.type).push({ pr: f.pr, body: f.body }); + } + const sections = SECTION_ORDER + .filter((type) => byType.has(type)) + .map((type) => ({ type, bullets: byType.get(type) })); + return { + releaseHeader: { version, date }, + sections, + priorChangelog: priorChangelog || null, + }; +} + +module.exports = { renderChangelog }; diff --git a/scripts/changeset/serialize.cjs b/scripts/changeset/serialize.cjs new file mode 100644 index 000000000..a70265e72 --- /dev/null +++ b/scripts/changeset/serialize.cjs @@ -0,0 +1,74 @@ +'use strict'; + +/** + * Markdown serializer + parser for the changelog IR. The two are inverses + * over the well-formed subset; tests assert via round-trip (parse(serialize(ir))) + * rather than by inspecting serialized text — see CONTRIBUTING.md + * "Prohibited: Raw Text Matching on Test Outputs". + * + * Serialized form (Keep a Changelog): + * + * ## [1.42.0] - 2026-05-01 + * + * ### Fixed + * + * - body of the bullet (#NNNN) + * + * + */ + +function serializeChangelog(ir) { + const lines = []; + const { version, date } = ir.releaseHeader; + lines.push(`## [${version}] - ${date}`); + lines.push(''); + for (const section of ir.sections) { + lines.push(`### ${section.type}`); + lines.push(''); + for (const b of section.bullets) { + lines.push(`- ${b.body} (#${b.pr})`); + } + lines.push(''); + } + let out = lines.join('\n'); + if (ir.priorChangelog) { + out += '\n' + ir.priorChangelog; + } + return out; +} + +/** + * Inverse parser: extracts the structured releases from a CHANGELOG.md + * text. Returns { releases: [{ version, date, sections: [{ type, bullets: + * [{ pr, body }] }] }] }. Tolerates the actual repo's CHANGELOG dialect. + */ +function parseChangelog(text) { + const releases = []; + const lines = text.split(/\r?\n/); + let cur = null; + let curSection = null; + for (const line of lines) { + const releaseMatch = line.match(/^##\s+\[([^\]]+)\](?:\s*-\s*(\S+))?/); + if (releaseMatch) { + cur = { version: releaseMatch[1], date: releaseMatch[2] || null, sections: [] }; + curSection = null; + releases.push(cur); + continue; + } + if (!cur) continue; + const sectionMatch = line.match(/^###\s+(.+?)\s*$/); + if (sectionMatch) { + curSection = { type: sectionMatch[1], bullets: [] }; + cur.sections.push(curSection); + continue; + } + if (!curSection) continue; + const bulletMatch = line.match(/^-\s+(.*?)\s*\(#(\d+)\)\s*$/); + if (bulletMatch) { + curSection.bullets.push({ body: bulletMatch[1], pr: Number(bulletMatch[2]) }); + } + } + return { releases }; +} + +module.exports = { serializeChangelog, parseChangelog }; diff --git a/tests/changeset-cli.test.cjs b/tests/changeset-cli.test.cjs new file mode 100644 index 000000000..f4579e064 --- /dev/null +++ b/tests/changeset-cli.test.cjs @@ -0,0 +1,74 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const cp = require('node:child_process'); + +const ROOT = path.join(__dirname, '..'); +const SCRIPT = path.join(ROOT, 'scripts', 'changeset', 'cli.cjs'); +const { parseChangelog } = require(path.join(ROOT, 'scripts', 'changeset', 'serialize.cjs')); + +let tmp; + +function writeFragment(name, type, pr, body) { + fs.mkdirSync(path.join(tmp, '.changeset'), { recursive: true }); + fs.writeFileSync( + path.join(tmp, '.changeset', `${name}.md`), + `---\ntype: ${type}\npr: ${pr}\n---\n${body}\n`, + ); +} + +function runRender(args = []) { + const r = cp.spawnSync( + process.execPath, + [SCRIPT, 'render', '--repo', tmp, ...args, '--json'], + { encoding: 'utf8' }, + ); + return { + status: r.status, + report: r.stdout && r.stdout.length ? JSON.parse(r.stdout) : null, + stderr: r.stderr || '', + }; +} + +before(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-changeset-')); }); +after(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + +describe('changeset cli render: file-I/O wrapper (#2975)', () => { + test('exits 0 with consumed=N when N fragments are folded into CHANGELOG.md and deleted', () => { + fs.rmSync(path.join(tmp, '.changeset'), { recursive: true, force: true }); + fs.writeFileSync( + path.join(tmp, 'CHANGELOG.md'), + '# Changelog\n\n## [Unreleased]\n\n## [1.0.0] - 2026-01-01\n\n### Fixed\n\n- prior fix (#1)\n', + ); + writeFragment('aaa-bbb-ccc', 'Fixed', 100, 'fragment-driven fix.'); + writeFragment('ddd-eee-fff', 'Added', 101, 'fragment-driven feature.'); + + const r = runRender(['--version', '1.1.0', '--date', '2026-05-01']); + assert.equal(r.status, 0, `stderr=${r.stderr}`); + assert.equal(r.report.consumed, 2); + assert.equal(r.report.failures.length, 0); + + // Round-trip: parsing the resulting CHANGELOG must reflect the new release + // and preserve the prior one. + const text = fs.readFileSync(path.join(tmp, 'CHANGELOG.md'), 'utf8'); + const parsed = parseChangelog(text); + const v110 = parsed.releases.find((r) => r.version === '1.1.0'); + assert.ok(v110, 'new 1.1.0 release present'); + assert.deepEqual( + v110.sections.map((s) => ({ type: s.type, prs: s.bullets.map((b) => b.pr) })), + [{ type: 'Added', prs: [101] }, { type: 'Fixed', prs: [100] }], + ); + const v100 = parsed.releases.find((r) => r.version === '1.0.0'); + assert.ok(v100, 'prior 1.0.0 release preserved'); + assert.equal(v100.sections[0].bullets[0].pr, 1); + + // Fragments deleted after consumption. + const remaining = fs.readdirSync(path.join(tmp, '.changeset')); + assert.deepEqual(remaining.filter((f) => f.endsWith('.md')), []); + }); +}); diff --git a/tests/changeset-lint.test.cjs b/tests/changeset-lint.test.cjs new file mode 100644 index 000000000..6979c7ca3 --- /dev/null +++ b/tests/changeset-lint.test.cjs @@ -0,0 +1,68 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const { evaluateLint, LINT_REASON } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'lint.cjs')); + +// evaluateLint is a pure function over file lists + label list — no fs, no git. +// Tests assert on the structured verdict: { ok: bool, reason: LINT_REASON.X }. + +describe('changeset lint: pure verdict (#2975)', () => { + test('LINT_REASON enum exposes the documented codes', () => { + assert.deepEqual( + Object.keys(LINT_REASON).sort(), + ['OK_FRAGMENT_PRESENT', 'OK_NO_USER_FACING_CHANGES', 'OK_OPT_OUT_LABEL', 'FAIL_MISSING_FRAGMENT'].sort(), + ); + }); + + test('OK_FRAGMENT_PRESENT when the diff includes a new .changeset/*.md', () => { + const verdict = evaluateLint({ + changedFiles: ['bin/install.js', '.changeset/silly-bears-dance.md'], + labels: [], + }); + assert.deepEqual(verdict, { ok: true, reason: LINT_REASON.OK_FRAGMENT_PRESENT }); + }); + + test('FAIL_MISSING_FRAGMENT when user-facing files change without a fragment', () => { + const verdict = evaluateLint({ + changedFiles: ['bin/install.js', 'tests/foo.test.cjs'], + labels: [], + }); + assert.deepEqual(verdict, { ok: false, reason: LINT_REASON.FAIL_MISSING_FRAGMENT }); + }); + + test('OK_OPT_OUT_LABEL when no-changelog label present, even with user-facing changes', () => { + const verdict = evaluateLint({ + changedFiles: ['bin/install.js'], + labels: ['no-changelog'], + }); + assert.deepEqual(verdict, { ok: true, reason: LINT_REASON.OK_OPT_OUT_LABEL }); + }); + + test('OK_NO_USER_FACING_CHANGES when only test/ci/doc files change', () => { + const verdict = evaluateLint({ + changedFiles: ['tests/foo.test.cjs', '.github/workflows/x.yml', 'docs/x.md'], + labels: [], + }); + assert.deepEqual(verdict, { ok: true, reason: LINT_REASON.OK_NO_USER_FACING_CHANGES }); + }); + + test('FAIL_MISSING_FRAGMENT when CHANGELOG.md is edited directly (closes the workflow bypass)', () => { + const verdict = evaluateLint({ + changedFiles: ['CHANGELOG.md'], + labels: [], + }); + assert.deepEqual(verdict, { ok: false, reason: LINT_REASON.FAIL_MISSING_FRAGMENT }); + }); + + test('a fragment alone (no source change) is OK_FRAGMENT_PRESENT — fragment-only PR is allowed', () => { + const verdict = evaluateLint({ + changedFiles: ['.changeset/silly-bears-dance.md'], + labels: [], + }); + assert.deepEqual(verdict, { ok: true, reason: LINT_REASON.OK_FRAGMENT_PRESENT }); + }); +}); diff --git a/tests/changeset-new.test.cjs b/tests/changeset-new.test.cjs new file mode 100644 index 000000000..9bc3bd5ce --- /dev/null +++ b/tests/changeset-new.test.cjs @@ -0,0 +1,95 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe, before, after } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); + +const ROOT = path.join(__dirname, '..'); +const { generateFragmentName, scaffoldFragment, parseFragment } = (() => { + const newCs = require(path.join(ROOT, 'scripts', 'changeset', 'new.cjs')); + const parse = require(path.join(ROOT, 'scripts', 'changeset', 'parse.cjs')); + return { ...newCs, parseFragment: parse.parseFragment }; +})(); + +let tmp; +before(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-new-changeset-')); }); +after(() => { fs.rmSync(tmp, { recursive: true, force: true }); }); + +describe('changeset new: name generator + scaffold writer (#2975)', () => { + test('generateFragmentName returns three lowercase words separated by hyphens', () => { + const name = generateFragmentName(); + const parts = name.split('-'); + assert.equal(parts.length, 3); + for (const p of parts) { + assert.match(p, /^[a-z]+$/); + } + }); + + test('scaffoldFragment writes a parseable fragment file with the supplied type and pr', () => { + const file = scaffoldFragment({ + repo: tmp, + type: 'Fixed', + pr: 9999, + body: 'this is a placeholder body that the contributor will replace.', + }); + + // Filesystem facts: file exists in .changeset/, is a regular file, is non-empty. + const stat = fs.statSync(file); + assert.ok(stat.isFile()); + assert.ok(stat.size > 0); + assert.equal(path.dirname(file), path.join(tmp, '.changeset')); + + // Content fact: the file is a valid fragment per the parser. We do NOT + // substring-match the file text; we round-trip it through parseFragment + // and assert on the typed result. + const src = fs.readFileSync(file, 'utf8'); + const parsed = parseFragment(src); + assert.equal(parsed.ok, true); + assert.deepEqual(parsed.fragment, { + type: 'Fixed', + pr: 9999, + body: 'this is a placeholder body that the contributor will replace.', + }); + }); + + test('two consecutive scaffoldFragment calls in the same dir produce different filenames (no collisions in normal use)', () => { + const a = scaffoldFragment({ repo: tmp, type: 'Added', pr: 1, body: 'aaa.' }); + const b = scaffoldFragment({ repo: tmp, type: 'Added', pr: 2, body: 'bbb.' }); + assert.notEqual(path.basename(a), path.basename(b)); + }); + + test('rejects type values not on the Keep-a-Changelog allowlist (sanitization)', () => { + // Includes the newline-injection case from the CR finding. + for (const badType of ['Refactored', 'fixed', 'Fixed\ntype: Added', 'Fixed; rm -rf /', '']) { + assert.throws( + () => scaffoldFragment({ repo: tmp, type: badType, pr: 1, body: 'x.' }), + /not one of \[Added, Changed, Deprecated, Removed, Fixed, Security\]/, + `bad type ${JSON.stringify(badType)} should be rejected`, + ); + } + }); + + test('parseArgs returns { ok: false, error } when --repo is missing its value', () => { + const { parseArgs } = require(path.join(ROOT, 'scripts', 'changeset', 'new.cjs')); + const r = parseArgs(['--type', 'Fixed', '--pr', '1', '--body', 'x.', '--repo']); + assert.equal(r.ok, false); + assert.equal(r.error, 'missing value for --repo'); + }); + + test('parseArgs returns { ok: false, error } when a flag value is itself another flag', () => { + const { parseArgs } = require(path.join(ROOT, 'scripts', 'changeset', 'new.cjs')); + const r = parseArgs(['--type', 'Fixed', '--repo', '--pr', '1', '--body', 'x.']); + assert.equal(r.ok, false); + assert.equal(r.error, 'missing value for --repo'); + }); + + test('parseArgs returns { ok: true, opts } on a well-formed argv', () => { + const { parseArgs } = require(path.join(ROOT, 'scripts', 'changeset', 'new.cjs')); + const r = parseArgs(['--type', 'Fixed', '--pr', '42', '--body', 'a body', '--repo', '/tmp/x']); + assert.equal(r.ok, true); + assert.deepEqual(r.opts, { type: 'Fixed', pr: 42, body: 'a body', repo: '/tmp/x' }); + }); +}); diff --git a/tests/changeset-parse.test.cjs b/tests/changeset-parse.test.cjs new file mode 100644 index 000000000..ff3642d41 --- /dev/null +++ b/tests/changeset-parse.test.cjs @@ -0,0 +1,56 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const { parseFragment, FRAGMENT_ERROR } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'parse.cjs')); + +describe('changeset parse: fragment file → typed record (#2975)', () => { + test('returns { ok: true, fragment } for a well-formed fragment', () => { + const src = '---\ntype: Fixed\npr: 2975\n---\nfix the thing.\n'; + const result = parseFragment(src); + assert.equal(result.ok, true); + assert.deepEqual(result.fragment, { + type: 'Fixed', + pr: 2975, + body: 'fix the thing.', + }); + }); + + test('preserves verbatim body content (e.g. code blocks) — does not trim significant whitespace', () => { + const src = '---\ntype: Fixed\npr: 1\n---\n```js\nlet x = 1;\n```\n'; + const r = parseFragment(src); + assert.equal(r.ok, true); + assert.equal(r.fragment.body, '```js\nlet x = 1;\n```'); + }); + + test('exposes a frozen FRAGMENT_ERROR enum with the documented codes', () => { + assert.deepEqual( + Object.keys(FRAGMENT_ERROR).sort(), + ['EMPTY_BODY', 'INVALID_PR', 'INVALID_TYPE', 'MISSING_FRONTMATTER', 'MISSING_PR', 'MISSING_TYPE'], + ); + }); + + for (const [label, src, expectedReason] of [ + ['fails MISSING_FRONTMATTER when no frontmatter block present', + 'just a body, no frontmatter\n', 'MISSING_FRONTMATTER'], + ['fails MISSING_TYPE when frontmatter omits type:', + '---\npr: 2975\n---\nfix.\n', 'MISSING_TYPE'], + ['fails INVALID_TYPE for a type not in the Keep-a-Changelog set', + '---\ntype: Refactored\npr: 2975\n---\nfix.\n', 'INVALID_TYPE'], + ['fails MISSING_PR when frontmatter omits pr:', + '---\ntype: Fixed\n---\nfix.\n', 'MISSING_PR'], + ['fails INVALID_PR when pr: is not a positive integer', + '---\ntype: Fixed\npr: 0\n---\nfix.\n', 'INVALID_PR'], + ['fails EMPTY_BODY when the body is whitespace-only', + '---\ntype: Fixed\npr: 2975\n---\n \n', 'EMPTY_BODY'], + ]) { + test(label, () => { + const r = parseFragment(src); + assert.equal(r.ok, false); + assert.equal(r.reason, FRAGMENT_ERROR[expectedReason]); + }); + } +}); diff --git a/tests/changeset-render.test.cjs b/tests/changeset-render.test.cjs new file mode 100644 index 000000000..dfb7c15ea --- /dev/null +++ b/tests/changeset-render.test.cjs @@ -0,0 +1,48 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const { renderChangelog } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'render.cjs')); + +describe('changeset render: pure renderer (#2975)', () => { + test('returns a structured Changelog object with a single Fixed bullet for one fragment', () => { + const fragments = [{ type: 'Fixed', pr: 2975, body: 'fix the thing.' }]; + const result = renderChangelog({ + fragments, + version: '1.42.0', + date: '2026-05-01', + priorChangelog: null, + }); + + assert.deepEqual(result.releaseHeader, { version: '1.42.0', date: '2026-05-01' }); + assert.equal(result.sections.length, 1); + assert.deepEqual(result.sections[0], { + type: 'Fixed', + bullets: [{ pr: 2975, body: 'fix the thing.' }], + }); + }); + + test('groups multiple fragments by type with deterministic section order (Keep a Changelog convention)', () => { + const fragments = [ + { type: 'Fixed', pr: 1, body: 'fix A' }, + { type: 'Added', pr: 2, body: 'add B' }, + { type: 'Fixed', pr: 3, body: 'fix C' }, + { type: 'Changed', pr: 4, body: 'change D' }, + ]; + const result = renderChangelog({ fragments, version: '1.0.0', date: '2026-01-01' }); + + assert.deepEqual( + result.sections.map((s) => s.type), + ['Added', 'Changed', 'Fixed'], + ); + const fixed = result.sections.find((s) => s.type === 'Fixed'); + assert.deepEqual( + fixed.bullets.map((b) => b.pr), + [1, 3], + 'bullets within a section preserve fragment order', + ); + }); +}); diff --git a/tests/changeset-serialize.test.cjs b/tests/changeset-serialize.test.cjs new file mode 100644 index 000000000..1d73917b1 --- /dev/null +++ b/tests/changeset-serialize.test.cjs @@ -0,0 +1,72 @@ +'use strict'; +process.env.GSD_TEST_MODE = '1'; + +const { test, describe } = require('node:test'); +const assert = require('node:assert/strict'); +const path = require('node:path'); + +const { serializeChangelog, parseChangelog } = require(path.join(__dirname, '..', 'scripts', 'changeset', 'serialize.cjs')); + +// Round-trip property: serialize(IR) → parse(text) → IR equals original. +// Tests assert on the parsed IR shape, not the serialized text contents. + +describe('changeset serialize: IR → markdown round-trip (#2975)', () => { + test('a single-section IR round-trips through serialize → parse', () => { + const ir = { + releaseHeader: { version: '1.0.0', date: '2026-01-01' }, + sections: [ + { type: 'Fixed', bullets: [{ pr: 1, body: 'fix something.' }] }, + ], + priorChangelog: null, + }; + const text = serializeChangelog(ir); + const back = parseChangelog(text); + + assert.equal(back.releases[0].version, '1.0.0'); + assert.equal(back.releases[0].date, '2026-01-01'); + assert.equal(back.releases[0].sections.length, 1); + assert.equal(back.releases[0].sections[0].type, 'Fixed'); + assert.equal(back.releases[0].sections[0].bullets.length, 1); + assert.equal(back.releases[0].sections[0].bullets[0].pr, 1); + }); +}); + +describe('changeset serialize: multi-section + prior content (#2975)', () => { + const { serializeChangelog, parseChangelog } = require(require('node:path').join(__dirname, '..', 'scripts', 'changeset', 'serialize.cjs')); + + test('round-trips an IR with three section types and multiple bullets per section', () => { + const ir = { + releaseHeader: { version: '1.42.0', date: '2026-05-01' }, + sections: [ + { type: 'Added', bullets: [{ pr: 1, body: 'add A' }, { pr: 2, body: 'add B' }] }, + { type: 'Changed', bullets: [{ pr: 3, body: 'change C' }] }, + { type: 'Fixed', bullets: [{ pr: 4, body: 'fix D' }, { pr: 5, body: 'fix E' }] }, + ], + priorChangelog: null, + }; + const back = parseChangelog(serializeChangelog(ir)); + assert.equal(back.releases.length, 1); + assert.deepEqual( + back.releases[0].sections.map((s) => ({ type: s.type, prs: s.bullets.map((b) => b.pr) })), + [ + { type: 'Added', prs: [1, 2] }, + { type: 'Changed', prs: [3] }, + { type: 'Fixed', prs: [4, 5] }, + ], + ); + }); + + test('prior CHANGELOG content survives serialize → parse as a separate release block', () => { + const priorText = '## [0.9.0] - 2025-12-01\n\n### Fixed\n\n- old fix (#100)\n'; + const ir = { + releaseHeader: { version: '1.0.0', date: '2026-01-01' }, + sections: [{ type: 'Added', bullets: [{ pr: 200, body: 'new feature' }] }], + priorChangelog: priorText, + }; + const back = parseChangelog(serializeChangelog(ir)); + assert.equal(back.releases.length, 2); + assert.equal(back.releases[0].version, '1.0.0'); + assert.equal(back.releases[1].version, '0.9.0'); + assert.equal(back.releases[1].sections[0].bullets[0].pr, 100); + }); +});