mirror of
https://github.com/glittercowboy/get-shit-done
synced 2026-05-13 10:36:38 +02:00
* 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).
192 lines
6.4 KiB
JavaScript
Executable File
192 lines
6.4 KiB
JavaScript
Executable File
#!/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 };
|