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

View File

@@ -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')), []);
});
});

View File

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

View File

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

View File

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

View File

@@ -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',
);
});
});

View File

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