* 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:
44
.changeset/README.md
Normal file
44
.changeset/README.md
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
# Changeset Fragments
|
||||||
|
|
||||||
|
This directory holds **per-PR CHANGELOG fragments**. Every PR with user-facing changes drops one (or more) `<random-name>.md` files here describing its CHANGELOG entry. Fragments are consolidated into the top-level `CHANGELOG.md` at release time.
|
||||||
|
|
||||||
|
## Why
|
||||||
|
|
||||||
|
Two PRs that both edit the `### Fixed` block of `CHANGELOG.md` always conflict on merge — git can't pick a serialization order without human input. Two PRs that each add a fresh `.changeset/<unique-name>.md` never conflict because they don't share lines.
|
||||||
|
|
||||||
|
See [#2975](https://github.com/gsd-build/get-shit-done/issues/2975) for the full rationale.
|
||||||
|
|
||||||
|
## Adding a fragment
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/changeset/new.cjs \
|
||||||
|
--type Fixed \
|
||||||
|
--pr 1234 \
|
||||||
|
--body "fix the thing — explain the user-visible change in one sentence"
|
||||||
|
```
|
||||||
|
|
||||||
|
This writes `.changeset/<adjective>-<noun>-<noun>.md` with frontmatter and a body. Three random words → concurrent PRs don't collide.
|
||||||
|
|
||||||
|
## Format
|
||||||
|
|
||||||
|
```md
|
||||||
|
---
|
||||||
|
type: Fixed
|
||||||
|
pr: 1234
|
||||||
|
---
|
||||||
|
**`/gsd-foo` no longer drops trailing slashes** — explain the user-visible change.
|
||||||
|
```
|
||||||
|
|
||||||
|
Allowed `type:` values follow [Keep a Changelog](https://keepachangelog.com/): `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`.
|
||||||
|
|
||||||
|
## Opting out
|
||||||
|
|
||||||
|
PRs that legitimately have no user-facing impact can add the `no-changelog` label. CI honors it. When unsure, add the fragment.
|
||||||
|
|
||||||
|
## At release time
|
||||||
|
|
||||||
|
```bash
|
||||||
|
node scripts/changeset/cli.cjs render --version vX.Y.Z --date YYYY-MM-DD
|
||||||
|
```
|
||||||
|
|
||||||
|
Reads every fragment, groups bullets by `type:`, replaces `## [Unreleased]` with a new `## [vX.Y.Z] - YYYY-MM-DD` block, opens a fresh `## [Unreleased]` above, deletes consumed fragments. Idempotent.
|
||||||
5
.changeset/eager-hawks-rally.md
Normal file
5
.changeset/eager-hawks-rally.md
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
type: Added
|
||||||
|
pr: 2975
|
||||||
|
---
|
||||||
|
**Changeset-fragment workflow** — eliminates CHANGELOG.md merge conflicts. Each PR drops `.changeset/<random-name>.md` with frontmatter (`type:`, `pr:`) plus a markdown body; the release-time `npm run changelog:render` consolidates fragments into `CHANGELOG.md` and deletes them. CI lint (`npm run lint:changeset`) requires a fragment on any PR touching user-facing files (`bin/`, `get-shit-done/`, `agents/`, `commands/`, `hooks/`, `sdk/src/`); contributors can opt out via the `no-changelog` label for purely internal changes. See [.changeset/README.md](.changeset/README.md) and CONTRIBUTING.md for the workflow.
|
||||||
24
.github/workflows/changeset-required.yml
vendored
Normal file
24
.github/workflows/changeset-required.yml
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: Changeset Required
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
types: [opened, synchronize, reopened, labeled, unlabeled]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
pull-requests: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
changeset-lint:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
- uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '24'
|
||||||
|
- name: Run changeset lint
|
||||||
|
env:
|
||||||
|
GITHUB_BASE_REF: ${{ github.base_ref }}
|
||||||
|
run: node scripts/changeset/lint.cjs
|
||||||
@@ -91,6 +91,23 @@ PRs that arrive without a properly-labeled linked issue are closed automatically
|
|||||||
- **CI must pass** — all matrix jobs (Ubuntu × Node 22, 24; macOS × Node 24) must be green
|
- **CI must pass** — all matrix jobs (Ubuntu × Node 22, 24; macOS × Node 24) must be green
|
||||||
- **Scope matches the approved issue** — if your PR does more than what the issue describes, the extra changes will be asked to be removed or moved to a new issue
|
- **Scope matches the approved issue** — if your PR does more than what the issue describes, the extra changes will be asked to be removed or moved to a new issue
|
||||||
|
|
||||||
|
## CHANGELOG Entries — Drop a Fragment
|
||||||
|
|
||||||
|
**Do not edit `CHANGELOG.md` directly.** Two PRs that both append to a `### Fixed` block always conflict on merge — git can't pick a serialization order without a human. Instead, every PR with user-facing changes drops a fragment file in `.changeset/`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run changeset -- --type Fixed --pr <YOUR_PR_NUMBER> \
|
||||||
|
--body "**\`/gsd-foo\` no longer drops trailing slashes** — explain the user-visible change."
|
||||||
|
```
|
||||||
|
|
||||||
|
This writes `.changeset/<adjective>-<noun>-<noun>.md`. Three random words → concurrent PRs never collide. Allowed `type:` values follow [Keep a Changelog](https://keepachangelog.com/): `Added`, `Changed`, `Deprecated`, `Removed`, `Fixed`, `Security`.
|
||||||
|
|
||||||
|
Fragments are consolidated into `CHANGELOG.md` at release time by the release workflow. See [`.changeset/README.md`](.changeset/README.md) for the format spec and [#2975](https://github.com/gsd-build/get-shit-done/issues/2975) for the rationale.
|
||||||
|
|
||||||
|
**CI enforcement:** the `Changeset Required` workflow (`scripts/changeset/lint.cjs`) fails any PR that touches `bin/`, `get-shit-done/`, `agents/`, `commands/`, `hooks/`, or `sdk/src/` without a `.changeset/*.md` fragment.
|
||||||
|
|
||||||
|
**Opt-out:** PRs with no user-facing impact (test refactors, lint config changes, CI tweaks, formatting-only changes) can add the `no-changelog` label. The lint honors it. When unsure whether a change is user-facing, **add the fragment**.
|
||||||
|
|
||||||
## Testing Standards
|
## Testing Standards
|
||||||
|
|
||||||
All tests use Node.js built-in test runner (`node:test`) and assertion library (`node:assert`). **Do not use Jest, Mocha, Chai, or any external test framework.**
|
All tests use Node.js built-in test runner (`node:test`) and assertion library (`node:assert`). **Do not use Jest, Mocha, Chai, or any external test framework.**
|
||||||
|
|||||||
@@ -62,6 +62,9 @@
|
|||||||
"pretest:coverage": "npm run build:sdk",
|
"pretest:coverage": "npm run build:sdk",
|
||||||
"lint:descriptions": "node scripts/lint-descriptions.cjs",
|
"lint:descriptions": "node scripts/lint-descriptions.cjs",
|
||||||
"lint:tests": "node scripts/lint-no-source-grep.cjs",
|
"lint:tests": "node scripts/lint-no-source-grep.cjs",
|
||||||
|
"lint:changeset": "node scripts/changeset/lint.cjs",
|
||||||
|
"changeset": "node scripts/changeset/new.cjs",
|
||||||
|
"changelog:render": "node scripts/changeset/cli.cjs render",
|
||||||
"test": "node scripts/run-tests.cjs",
|
"test": "node scripts/run-tests.cjs",
|
||||||
"test:coverage": "c8 --check-coverage --lines 70 --reporter text --include 'get-shit-done/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs"
|
"test:coverage": "c8 --check-coverage --lines 70 --reporter text --include 'get-shit-done/bin/lib/*.cjs' --exclude 'tests/**' --all node scripts/run-tests.cjs"
|
||||||
}
|
}
|
||||||
|
|||||||
191
scripts/changeset/cli.cjs
Executable file
191
scripts/changeset/cli.cjs
Executable 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
110
scripts/changeset/lint.cjs
Executable 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
137
scripts/changeset/new.cjs
Executable 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 };
|
||||||
60
scripts/changeset/parse.cjs
Normal file
60
scripts/changeset/parse.cjs
Normal 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 };
|
||||||
34
scripts/changeset/render.cjs
Normal file
34
scripts/changeset/render.cjs
Normal 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 };
|
||||||
74
scripts/changeset/serialize.cjs
Normal file
74
scripts/changeset/serialize.cjs
Normal 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 };
|
||||||
74
tests/changeset-cli.test.cjs
Normal file
74
tests/changeset-cli.test.cjs
Normal 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')), []);
|
||||||
|
});
|
||||||
|
});
|
||||||
68
tests/changeset-lint.test.cjs
Normal file
68
tests/changeset-lint.test.cjs
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
95
tests/changeset-new.test.cjs
Normal file
95
tests/changeset-new.test.cjs
Normal 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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
56
tests/changeset-parse.test.cjs
Normal file
56
tests/changeset-parse.test.cjs
Normal 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]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
48
tests/changeset-render.test.cjs
Normal file
48
tests/changeset-render.test.cjs
Normal 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',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
72
tests/changeset-serialize.test.cjs
Normal file
72
tests/changeset-serialize.test.cjs
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user