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).
138 lines
5.4 KiB
JavaScript
Executable File
138 lines
5.4 KiB
JavaScript
Executable File
#!/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 };
|