feat(#2975): adopt changeset-fragment workflow to eliminate CHANGELOG conflicts (#2978)

* feat(#2975): adopt changeset-fragment workflow to eliminate CHANGELOG conflicts

Two PRs that both edit `### Fixed` in CHANGELOG.md always conflict on merge.
Recently bit on #2960/#2972 in the same session — fix-the-conflict-and-rebase
tax. Replace the shared-file model with per-PR fragment files that never
share lines.

Implementation built TDD per #2975, vertical slices with structured-IR
assertions throughout:

  scripts/changeset/parse.cjs       - fragment text → typed record + frozen
                                      FRAGMENT_ERROR enum (8 tests)
  scripts/changeset/render.cjs      - fragments → structured IR with
                                      Keep-a-Changelog section ordering
                                      (2 tests)
  scripts/changeset/serialize.cjs   - IR ↔ markdown round-trip pair
                                      (parse(serialize(ir)) === ir,
                                      3 tests)
  scripts/changeset/cli.cjs         - file-I/O wrapper with --json mode;
                                      reads .changeset/, folds into
                                      CHANGELOG.md, deletes consumed
                                      fragments. Idempotent. (1 test)
  scripts/changeset/lint.cjs        - pure verdict (changedFiles, labels)
                                      → { ok, reason } via LINT_REASON
                                      enum. Honors `no-changelog` label.
                                      (5 tests)
  scripts/changeset/new.cjs         - fragment scaffolder with random
                                      adjective-noun-noun filename. Tests
                                      assert via parseFragment round-trip.
                                      (3 tests)

Total: 22 tests, all assertions on typed structured fields. No regex on
text, no String#includes on file content. Lint clean across 356 test files.

Supporting:

  .changeset/README.md              - format spec + workflow docs
  .changeset/eager-hawks-rally.md   - dogfood fragment for THIS PR (will
                                      be the first thing the new release
                                      tool consumes)
  .github/workflows/changeset-required.yml
                                    - CI: every PR runs lint.cjs
  package.json                      - npm run changeset, changelog:render,
                                      lint:changeset
  CONTRIBUTING.md                   - new "CHANGELOG Entries — Drop a
                                      Fragment" section between PR
                                      Guidelines and Testing Standards

Closes #2975

* fix(#2975): address CodeRabbit findings on changeset workflow

7 valid findings (4 Major, 3 Minor); all addressed:

scripts/changeset/parse.cjs
  - Preserve fragment body verbatim. Previously body.trim() ate
    intentional leading whitespace (code blocks, etc.); now trim() is
    used only for the emptiness check, and a single trailing newline
    is stripped (the editor-added one) so well-formed fragments
    round-trip byte-for-byte. Added a regression test asserting a
    code-block-leading body is preserved.

scripts/changeset/cli.cjs
  - Validate flag values during argument parsing. parseArgs now returns
    { ok, opts | error }; rejects `--repo` etc. with no following value
    or with another flag as the value. main() surfaces the error
    message before exiting 2.
  - Handle post-write fragment-deletion failures. After CHANGELOG.md
    is written, any unlink failure is captured into a structured
    deleteFailures list with reason 'fail_fragment_delete'; cmdRender
    returns exitCode=1 with the partial-failure detail instead of
    leaving the changelog updated and fragments behind (which would
    cause double-consumption on rerun).

scripts/changeset/lint.cjs
  - Treat CHANGELOG.md as a linted user-facing path. Direct edits to
    CHANGELOG.md (the bypass route around the new workflow) now fail
    the lint with FAIL_MISSING_FRAGMENT. Added a regression test for
    that case.
  - Use cp.execFileSync instead of cp.execSync for the git diff call.
    Eliminates the shell-interpolation surface on GITHUB_BASE_REF;
    git's own arg parser remains the validator.

scripts/changeset/new.cjs
  - Atomic fragment creation. existsSync() + writeFileSync was racy
    under concurrent invocations. Now writeFileSync uses { flag: 'wx' }
    which fails EEXIST on collision; the random-name retry loop
    catches EEXIST and re-rolls. Throws explicitly after 16 attempts
    rather than silently overwriting.

.changeset/README.md
  - Add language tag `md` to the format example fence (markdownlint
    MD040).

All 25 changeset tests pass; lint clean (356 test files, 0 violations).

* fix(#2975): sanitize --type and validate flag values in new.cjs (CR fixes)

Two CR findings on scripts/changeset/new.cjs:

1. (Minor) `type` was embedded in frontmatter without sanitization. A
   newline in the value (e.g. `--type 'Fixed\ntype: Added'`) would
   corrupt the fragment. scaffoldFragment now validates `type` against
   the Keep-a-Changelog ALLOWED_TYPES set BEFORE writing — same set
   parse.cjs uses on consume. Throws with a typed error referencing
   the allowed values; tests cover the newline case + 4 other
   non-allowed values.

2. (Minor) `--repo` (and other value-taking flags) without a value
   silently set opts.repo to undefined, which produced a cryptic
   ERR_INVALID_ARG_TYPE deep inside path.join. parseArgs now mirrors
   the cli.cjs convention: returns { ok, opts | error }, validates
   that the next token exists and is not itself another flag, and
   surfaces a precise "missing value for --repo" message before exit.
   Added 3 tests: missing-trailing-value, flag-as-value, well-formed.

29 tests pass across the changeset suite (4 new regression tests).
This commit is contained in:
Tom Boucher
2026-05-01 18:12:20 -04:00
committed by GitHub
parent cb98a88139
commit 9d5db87249
17 changed files with 1112 additions and 0 deletions

191
scripts/changeset/cli.cjs Executable file
View File

@@ -0,0 +1,191 @@
#!/usr/bin/env node
'use strict';
/**
* CLI wrapper for the changeset-fragment workflow (#2975).
*
* Subcommands:
* render --repo <dir> --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 <dir> --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 <dir> --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 };

110
scripts/changeset/lint.cjs Executable file
View File

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

137
scripts/changeset/new.cjs Executable file
View File

@@ -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/<adjective>-<noun>-<noun>.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 <Fixed|Added|...> --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 <Fixed|Added|...> --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 };

View File

@@ -0,0 +1,60 @@
'use strict';
/**
* Parses a changeset fragment file (text → typed record).
*
* ---
* type: Fixed
* pr: 2975
* ---
* <markdown body>
*
* 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 };

View File

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

View File

@@ -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)
*
* <priorChangelog appended verbatim>
*/
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 };