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