* feat(#690): wire CHANGELOG render into release finalize job CHANGELOG promotion has always been a manual operator step, which is why 1.3.0/1.3.1 shipped unpromoted (#690). PR #694 added a `verify` latch that fails a release lacking a dated heading, but nothing performed the promotion. Wire `changeset render` into the finalize job, after build/test and before the verify gate, committing the promoted CHANGELOG so it ships with the release. Add a `--allow-empty` flag to cmdRender so a zero-fragment release still emits a dated heading (with a '_No notable changes._' placeholder) instead of writing nothing and tripping the verify gate. Note: requires the changeset-archive cleanup (separate PR) to land first, so the first render consumes only genuinely-unreleased fragments. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * chore(#713): set changeset pr number to 715 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -37,11 +37,13 @@ PRs that legitimately have no user-facing impact can add the `no-changelog` labe
|
||||
|
||||
## At release time
|
||||
|
||||
Promotion is **automatic**. The release workflow's `finalize` job runs:
|
||||
|
||||
```bash
|
||||
node scripts/changeset/cli.cjs render --version vX.Y.Z --date YYYY-MM-DD
|
||||
node scripts/changeset/cli.cjs render --version vX.Y.Z --date YYYY-MM-DD --allow-empty
|
||||
```
|
||||
|
||||
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.
|
||||
This reads every fragment, groups bullets by `type:`, replaces `## [Unreleased]` with a new `## [vX.Y.Z] - YYYY-MM-DD` block, opens a fresh `## [Unreleased]` above, and deletes consumed fragments. The `--allow-empty` flag ensures a no-change release still gets a dated heading (with a `_No notable changes._` placeholder). A subsequent `verify` step confirms the promotion landed correctly. Maintainers do **not** run this by hand.
|
||||
|
||||
## Archived fragments
|
||||
|
||||
|
||||
6
.changeset/wire-changelog-render.md
Normal file
6
.changeset/wire-changelog-render.md
Normal file
@@ -0,0 +1,6 @@
|
||||
---
|
||||
type: Changed
|
||||
pr: 715
|
||||
---
|
||||
The release pipeline now automatically runs `changeset render` during the finalize job, promoting `.changeset/` fragments into a dated `CHANGELOG.md` section before publishing — previously a manual step that was routinely skipped (leaving v1.3.0 and v1.3.1 unpromoted, #690). A new `--allow-empty` flag prevents the verify gate from hard-failing on no-change releases by emitting a dated heading with a `_No notable changes._` placeholder when there are zero fragments.
|
||||
<!-- docs-exempt: release-pipeline/CI infrastructure; operator-facing render docs live in .changeset/README.md (updated in this PR), not docs/ -->
|
||||
18
.github/workflows/release.yml
vendored
18
.github/workflows/release.yml
vendored
@@ -495,6 +495,24 @@ jobs:
|
||||
node scripts/check-npm-integrity.cjs
|
||||
npm run test:coverage:unit
|
||||
|
||||
- name: Promote CHANGELOG (render fragments)
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
node scripts/changeset/cli.cjs render \
|
||||
--version "$VERSION" --date "$(date -u +%F)" --allow-empty
|
||||
git add -A .changeset CHANGELOG.md
|
||||
# Diff-preview guard: surface exactly what was promoted, in the log and
|
||||
# the job summary, before the commit lands.
|
||||
{
|
||||
echo "### CHANGELOG promotion for v${VERSION}"
|
||||
echo '```diff'
|
||||
git diff --cached -- CHANGELOG.md
|
||||
echo '```'
|
||||
} >> "${GITHUB_STEP_SUMMARY:-/dev/null}"
|
||||
git --no-pager diff --cached -- CHANGELOG.md
|
||||
git diff --cached --quiet || git commit -m "chore: promote CHANGELOG for v${VERSION}"
|
||||
|
||||
- name: Verify CHANGELOG promoted
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
|
||||
@@ -40,6 +40,7 @@ function parseArgs(argv) {
|
||||
repoSlug: defaultRepoSlug,
|
||||
installCommand: `npx ${packageName}@latest`,
|
||||
json: false,
|
||||
allowEmpty: false,
|
||||
};
|
||||
if (argv.length === 0) return { ok: true, opts };
|
||||
opts.cmd = argv[0];
|
||||
@@ -59,6 +60,7 @@ function parseArgs(argv) {
|
||||
for (let i = 1; i < argv.length; i++) {
|
||||
const a = argv[i];
|
||||
if (a === '--json') { opts.json = true; continue; }
|
||||
if (a === '--allow-empty') { opts.allowEmpty = true; continue; }
|
||||
if (
|
||||
a === '--repo' ||
|
||||
a === '--version' ||
|
||||
@@ -118,6 +120,18 @@ function splitChangelog(text) {
|
||||
return { lead, prior };
|
||||
}
|
||||
|
||||
// FIX 2: tiny local helper so both render paths share identical assembly logic.
|
||||
function assembleChangelog(lead, releaseBlock) {
|
||||
return [
|
||||
lead || '# Changelog',
|
||||
'',
|
||||
'## [Unreleased]',
|
||||
'',
|
||||
releaseBlock.replace(/\s+$/, ''),
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function cmdRender(opts) {
|
||||
const repo = path.resolve(opts.repo);
|
||||
const changesetDir = path.join(repo, '.changeset');
|
||||
@@ -133,14 +147,83 @@ function cmdRender(opts) {
|
||||
else failures.push({ file: path.relative(repo, file), reason: r.reason, detail: r.detail || null });
|
||||
}
|
||||
|
||||
// 1. parse-failure → exitCode 1 (unchanged).
|
||||
if (failures.length > 0) {
|
||||
return { exitCode: 1, report: { consumed: 0, failures } };
|
||||
}
|
||||
if (fragments.length === 0) {
|
||||
return { exitCode: 0, report: { consumed: 0, failures: [] } };
|
||||
|
||||
// 2. Read priorText once; reuse in all subsequent branches.
|
||||
const priorText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '';
|
||||
|
||||
// 3. FIX 1: idempotency guard — if the version is already promoted (a dated
|
||||
// release heading for this version already exists in CHANGELOG), split on
|
||||
// whether fragments are still present:
|
||||
// • alreadyPromoted + zero fragments → legitimate CI-retry no-op (the prior
|
||||
// render commit already deleted fragments and wrote the heading).
|
||||
// • alreadyPromoted + fragments present → inconsistent state: the heading
|
||||
// was written out-of-band but fragments were never consumed. Fail loudly
|
||||
// so the operator resolves it manually rather than silently leaving stale
|
||||
// fragments to be re-consumed in a later release.
|
||||
const version = stripV(opts.version);
|
||||
const { releases: existingReleases } = parseChangelog(priorText);
|
||||
const alreadyPromoted = existingReleases.some(
|
||||
(rel) => rel.version === version && rel.date,
|
||||
);
|
||||
if (alreadyPromoted) {
|
||||
if (fragments.length === 0) {
|
||||
return { exitCode: 0, report: { consumed: 0, failures: [], alreadyPromoted: true } };
|
||||
}
|
||||
const errMsg =
|
||||
`CHANGELOG.md already has a dated heading for ${version} but ` +
|
||||
`${fragments.length} unconsumed fragment(s) remain in .changeset/ — ` +
|
||||
`resolve manually (the version was likely promoted out-of-band).`;
|
||||
return {
|
||||
exitCode: 1,
|
||||
report: { consumed: 0, failures: [], alreadyPromoted: true, error: errMsg },
|
||||
};
|
||||
}
|
||||
|
||||
const priorText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : '';
|
||||
// 4. Zero-fragment + !allowEmpty early-exit: write nothing.
|
||||
if (fragments.length === 0) {
|
||||
if (!opts.allowEmpty) {
|
||||
return { exitCode: 0, report: { consumed: 0, failures: [] } };
|
||||
}
|
||||
// --allow-empty: emit a dated heading with a placeholder even though there
|
||||
// are no fragments. This lets the render→verify CI chain succeed when a
|
||||
// release contains no user-visible changes.
|
||||
const { lead, prior } = splitChangelog(priorText);
|
||||
// Build a header-only release block and inject the placeholder line.
|
||||
const ir = renderChangelog({
|
||||
fragments: [],
|
||||
version: opts.version,
|
||||
date: opts.date,
|
||||
priorChangelog: prior || null,
|
||||
});
|
||||
const headerOnlyBlock = serializeChangelog(ir);
|
||||
// Insert placeholder after the release header line.
|
||||
// serializeChangelog with no sections yields just "## [v] - d\n" (single
|
||||
// trailing newline, no blank line). We replace that trailing newline with
|
||||
// a blank line + placeholder + blank line so parseChangelog still sees the
|
||||
// dated heading first and the file is human-readable.
|
||||
const releaseBlock = headerOnlyBlock.replace(
|
||||
/^(##\s+\[[^\]]+\][^\n]*)\n+/,
|
||||
'$1\n\n_No notable changes._\n\n',
|
||||
);
|
||||
// FIX 2: use shared assembleChangelog helper.
|
||||
const out = assembleChangelog(lead, releaseBlock);
|
||||
fs.writeFileSync(changelogPath, out);
|
||||
return {
|
||||
exitCode: 0,
|
||||
report: {
|
||||
consumed: 0,
|
||||
failures: [],
|
||||
written: true,
|
||||
release: { version: opts.version, date: opts.date },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// 5. Normal render path: fragments present — reuse priorText already read above.
|
||||
const { lead, prior } = splitChangelog(priorText);
|
||||
|
||||
const ir = renderChangelog({
|
||||
@@ -150,14 +233,8 @@ function cmdRender(opts) {
|
||||
priorChangelog: prior || null,
|
||||
});
|
||||
const releaseBlock = serializeChangelog(ir);
|
||||
const out = [
|
||||
lead || '# Changelog',
|
||||
'',
|
||||
'## [Unreleased]',
|
||||
'',
|
||||
releaseBlock.replace(/\s+$/, ''),
|
||||
'',
|
||||
].join('\n');
|
||||
// FIX 2: use shared assembleChangelog helper.
|
||||
const out = assembleChangelog(lead, releaseBlock);
|
||||
|
||||
fs.writeFileSync(changelogPath, out);
|
||||
|
||||
@@ -382,7 +459,7 @@ function cmdGithubReleaseNotes(opts) {
|
||||
function usage() {
|
||||
return [
|
||||
'usage:',
|
||||
' changeset/cli.cjs render --repo <dir> --version V --date D [--json]',
|
||||
' changeset/cli.cjs render --repo <dir> --version V --date D [--allow-empty] [--json]',
|
||||
' changeset/cli.cjs github-release-notes --repo <dir> --from REF --to REF [--output FILE] [--repo-slug OWNER/REPO] [--install-command CMD] [--json]',
|
||||
' changeset/cli.cjs extract --from VERSION --to VERSION [--changelog FILE] [--repo <dir>] [--json]',
|
||||
' Extracts changelog entries strictly after --from (exclusive) and up to',
|
||||
@@ -453,6 +530,9 @@ function main() {
|
||||
} else if (opts.cmd === 'github-release-notes' && report.body) {
|
||||
process.stdout.write(report.body);
|
||||
} else {
|
||||
if (report.error) {
|
||||
process.stderr.write(`${report.error}\n`);
|
||||
}
|
||||
process.stdout.write(`Consumed: ${report.consumed} fragment(s)\n`);
|
||||
if (report.failures.length > 0) {
|
||||
process.stdout.write(`Failures: ${report.failures.length}\n`);
|
||||
@@ -466,4 +546,4 @@ function main() {
|
||||
|
||||
if (require.main === module) main();
|
||||
|
||||
module.exports = { cmdRender, cmdExtract, cmdVerify, cmdGithubReleaseNotes, parseArgs, splitChangelog, listFragmentFiles, usage };
|
||||
module.exports = { cmdRender, cmdExtract, cmdVerify, cmdGithubReleaseNotes, parseArgs, splitChangelog, assembleChangelog, listFragmentFiles, usage };
|
||||
|
||||
@@ -577,3 +577,354 @@ describe('changeset cli verify subcommand (not yet implemented — TDD red step)
|
||||
assert.equal(r.status, 1, `expected exit 1 for pre-release version; stderr=${r.stderr}`);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// GROUP C — render --allow-empty
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('changeset cli render --allow-empty', () => {
|
||||
// Helper: run render for a specific test-local tmp directory.
|
||||
function runRenderIn(dir, args = []) {
|
||||
const r = cp.spawnSync(
|
||||
process.execPath,
|
||||
[SCRIPT, 'render', '--repo', dir, ...args, '--json'],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
return {
|
||||
status: r.status,
|
||||
report: r.stdout && r.stdout.length ? JSON.parse(r.stdout) : null,
|
||||
stderr: r.stderr || '',
|
||||
};
|
||||
}
|
||||
|
||||
function runVerifyIn(dir, version) {
|
||||
const changelogPath = path.join(dir, 'CHANGELOG.md');
|
||||
const r = cp.spawnSync(
|
||||
process.execPath,
|
||||
[SCRIPT, 'verify', '--version', version, '--changelog', changelogPath],
|
||||
{ encoding: 'utf8' },
|
||||
);
|
||||
return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' };
|
||||
}
|
||||
|
||||
// Test 1: --allow-empty with zero fragments writes a dated heading +
|
||||
// placeholder, and subsequent verify exits 0.
|
||||
test('--allow-empty with zero fragments writes dated heading + placeholder; verify exits 0', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true });
|
||||
// Only a README — no fragment files.
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.changeset', 'README.md'),
|
||||
'# Changesets\n\nThis folder holds changeset fragments.\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'CHANGELOG.md'),
|
||||
'# Changelog\n\n## [Unreleased]\n',
|
||||
);
|
||||
|
||||
const r = runRenderIn(dir, ['--version', '9.9.9', '--date', '2026-06-05', '--allow-empty']);
|
||||
assert.equal(r.status, 0, `render exited non-zero; stderr=${r.stderr}`);
|
||||
assert.equal(r.report.consumed, 0, 'consumed must be 0 for empty run');
|
||||
assert.equal(r.report.failures.length, 0, 'no failures expected');
|
||||
assert.strictEqual(r.report.written, true, 'report.written must be true');
|
||||
|
||||
// The CHANGELOG must now contain a dated heading for 9.9.9.
|
||||
const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
const parsed = parseChangelog(text);
|
||||
const rel = parsed.releases.find((r) => r.version === '9.9.9');
|
||||
assert.ok(rel, 'release 9.9.9 must appear in CHANGELOG after --allow-empty render');
|
||||
assert.equal(rel.date, '2026-06-05', 'release date must be 2026-06-05');
|
||||
|
||||
// The placeholder line must be present in the raw text.
|
||||
assert.ok(
|
||||
text.includes('_No notable changes._'),
|
||||
'CHANGELOG must contain _No notable changes._ placeholder',
|
||||
);
|
||||
|
||||
// verify must exit 0 for this version.
|
||||
const vr = runVerifyIn(dir, '9.9.9');
|
||||
assert.equal(vr.status, 0, `verify must exit 0 after --allow-empty render; stderr=${vr.stderr}`);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 2: render WITHOUT --allow-empty and zero fragments early-exits —
|
||||
// CHANGELOG is not created / not modified. Regression lock.
|
||||
test('render without --allow-empty and zero fragments does NOT write CHANGELOG', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true });
|
||||
// No fragment files, only README.
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.changeset', 'README.md'),
|
||||
'# Changesets\n',
|
||||
);
|
||||
// Write a known CHANGELOG so we can verify it is unchanged.
|
||||
const originalChangelog = '# Changelog\n\n## [Unreleased]\n';
|
||||
fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), originalChangelog);
|
||||
|
||||
const r = runRenderIn(dir, ['--version', '9.9.8', '--date', '2026-06-05']);
|
||||
assert.equal(r.status, 0, `render without --allow-empty exited non-zero; stderr=${r.stderr}`);
|
||||
assert.equal(r.report.consumed, 0, 'consumed must be 0');
|
||||
assert.equal(r.report.failures.length, 0, 'no failures expected');
|
||||
// report.written must NOT be set (undefined or absent).
|
||||
assert.ok(
|
||||
r.report.written === undefined || r.report.written === null || r.report.written === false,
|
||||
'report.written must not be true in the no-op path',
|
||||
);
|
||||
|
||||
// CHANGELOG must be byte-identical to the original.
|
||||
const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
assert.equal(text, originalChangelog, 'CHANGELOG must be unchanged when no fragments and no --allow-empty');
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 3: render WITH fragments + --allow-empty behaves exactly like normal
|
||||
// render (consumes/deletes fragments, NO placeholder injected).
|
||||
test('render --allow-empty with fragments behaves like normal render (no placeholder)', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.changeset', 'fix-one.md'),
|
||||
'---\ntype: Fixed\npr: 200\n---\nFragment-driven fix.\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'CHANGELOG.md'),
|
||||
'# Changelog\n\n## [Unreleased]\n',
|
||||
);
|
||||
|
||||
const r = runRenderIn(dir, ['--version', '9.9.7', '--date', '2026-06-05', '--allow-empty']);
|
||||
assert.equal(r.status, 0, `render with fragments exited non-zero; stderr=${r.stderr}`);
|
||||
assert.equal(r.report.consumed, 1, 'one fragment must be consumed');
|
||||
assert.equal(r.report.failures.length, 0, 'no failures expected');
|
||||
|
||||
const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
// Placeholder must NOT appear when there are real fragments.
|
||||
assert.ok(
|
||||
!text.includes('_No notable changes._'),
|
||||
'placeholder must NOT appear when fragments were consumed',
|
||||
);
|
||||
// The fragment bullet must be present.
|
||||
const parsed = parseChangelog(text);
|
||||
const rel = parsed.releases.find((r) => r.version === '9.9.7');
|
||||
assert.ok(rel, 'release 9.9.7 must appear in CHANGELOG');
|
||||
assert.equal(rel.date, '2026-06-05', 'release date must be 2026-06-05');
|
||||
const prs = rel.sections.flatMap((s) => s.bullets.map((b) => b.pr));
|
||||
assert.ok(prs.includes(200), 'pr=200 bullet must be present in the release');
|
||||
|
||||
// Fragment file must be deleted.
|
||||
const remaining = fs.readdirSync(path.join(dir, '.changeset'))
|
||||
.filter((f) => f.endsWith('.md') && f !== 'README.md');
|
||||
assert.deepEqual(remaining, [], 'fragment must be deleted after consumption');
|
||||
|
||||
// verify must exit 0.
|
||||
const vr = runVerifyIn(dir, '9.9.7');
|
||||
assert.equal(vr.status, 0, `verify must exit 0 after render with fragments; stderr=${vr.stderr}`);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 4 (idempotency): after a fragmentful render, a second render
|
||||
// --allow-empty for the SAME version is now a NO-OP (FIX 1 guard). It must
|
||||
// NOT add a second heading, must exit 0, and must report alreadyPromoted:true.
|
||||
test('idempotency: second --allow-empty render for same version is a no-op (no duplicate heading)', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(dir, '.changeset', 'fix-two.md'),
|
||||
'---\ntype: Fixed\npr: 300\n---\nIdempotency test fix.\n',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(dir, 'CHANGELOG.md'),
|
||||
'# Changelog\n\n## [Unreleased]\n',
|
||||
);
|
||||
|
||||
// First render: with a fragment.
|
||||
const r1 = runRenderIn(dir, ['--version', '9.9.6', '--date', '2026-06-05']);
|
||||
assert.equal(r1.status, 0, `first render exited non-zero; stderr=${r1.stderr}`);
|
||||
assert.equal(r1.report.consumed, 1, 'first render must consume 1 fragment');
|
||||
|
||||
// verify after first render must succeed.
|
||||
const vr1 = runVerifyIn(dir, '9.9.6');
|
||||
assert.equal(vr1.status, 0, `verify after first render must exit 0; stderr=${vr1.stderr}`);
|
||||
|
||||
// Second render --allow-empty: zero fragments now, same version.
|
||||
// This is now a NO-OP due to the already-promoted guard (FIX 1).
|
||||
const r2 = runRenderIn(dir, ['--version', '9.9.6', '--date', '2026-06-05', '--allow-empty']);
|
||||
assert.equal(r2.status, 0, `second render exited non-zero; stderr=${r2.stderr}`);
|
||||
assert.equal(r2.report.consumed, 0, 'second render must consume 0 fragments');
|
||||
assert.strictEqual(r2.report.alreadyPromoted, true, 'second render must report alreadyPromoted:true');
|
||||
|
||||
// Exactly ONE ## [9.9.6] heading must be present — no duplicate.
|
||||
const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
const parsed = parseChangelog(text);
|
||||
const all996 = parsed.releases.filter((r) => r.version === '9.9.6');
|
||||
assert.equal(
|
||||
all996.length, 1,
|
||||
`exactly one 9.9.6 heading must be present after two renders; found ${all996.length}`,
|
||||
);
|
||||
|
||||
// verify must still exit 0.
|
||||
const vr2 = runVerifyIn(dir, '9.9.6');
|
||||
assert.equal(vr2.status, 0, `verify after second render must still exit 0; stderr=${vr2.stderr}`);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 4b (stale fragments): alreadyPromoted + fragments present → render exits
|
||||
// NON-ZERO, CHANGELOG is NOT modified, and the fragment file is NOT deleted.
|
||||
// This guards the inconsistent-state scenario identified in the adversarial review:
|
||||
// a manual/partial promotion wrote the heading but left fragments unconsumed.
|
||||
test('alreadyPromoted + fragments present: exits non-zero, CHANGELOG unchanged, fragment survives', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true });
|
||||
|
||||
// CHANGELOG already has a dated heading for 3.3.3 (out-of-band promotion).
|
||||
const originalChangelog = [
|
||||
'# Changelog',
|
||||
'',
|
||||
'## [Unreleased]',
|
||||
'',
|
||||
'## [3.3.3] - 2026-01-01',
|
||||
'',
|
||||
'### Fixed',
|
||||
'',
|
||||
'- x (#1)',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), originalChangelog);
|
||||
|
||||
// A fragment file is still present — unconsumed.
|
||||
const fragmentPath = path.join(dir, '.changeset', 'leftover.md');
|
||||
fs.writeFileSync(fragmentPath, '---\ntype: Fixed\npr: 9\n---\nleftover fragment\n');
|
||||
|
||||
// Run render for the same version.
|
||||
const r = runRenderIn(dir, ['--version', '3.3.3', '--date', '2026-06-05', '--allow-empty']);
|
||||
|
||||
// Must exit non-zero.
|
||||
assert.notEqual(r.status, 0, `expected non-zero exit for alreadyPromoted+fragments; got ${r.status}`);
|
||||
|
||||
// report.alreadyPromoted must be true and report.error must be present.
|
||||
assert.strictEqual(r.report.alreadyPromoted, true, 'report.alreadyPromoted must be true');
|
||||
assert.ok(typeof r.report.error === 'string' && r.report.error.length > 0, 'report.error must be a non-empty string');
|
||||
assert.ok(r.report.error.includes('3.3.3'), 'report.error must mention the version');
|
||||
|
||||
// Fragment file must still exist — render must NOT have deleted it.
|
||||
assert.ok(fs.existsSync(fragmentPath), 'leftover fragment must still exist after non-zero exit');
|
||||
|
||||
// CHANGELOG must be byte-identical to the original — render must NOT have modified it.
|
||||
const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
assert.equal(text, originalChangelog, 'CHANGELOG must be unchanged');
|
||||
|
||||
// Exactly one ## [3.3.3] heading must be present (no duplicate added).
|
||||
const parsed = parseChangelog(text);
|
||||
const all333 = parsed.releases.filter((rel) => rel.version === '3.3.3');
|
||||
assert.equal(all333.length, 1, `exactly one 3.3.3 heading must be present; found ${all333.length}`);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 5 (cold start): --allow-empty when CHANGELOG.md does NOT exist creates
|
||||
// the file with # Changelog, ## [Unreleased], and a dated release with placeholder.
|
||||
test('--allow-empty cold start (no CHANGELOG.md) creates file with expected structure', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true });
|
||||
// No CHANGELOG.md, no fragments.
|
||||
|
||||
const r = runRenderIn(dir, ['--version', '8.8.8', '--date', '2026-06-05', '--allow-empty']);
|
||||
assert.equal(r.status, 0, `render exited non-zero; stderr=${r.stderr}`);
|
||||
assert.equal(r.report.consumed, 0, 'consumed must be 0');
|
||||
assert.strictEqual(r.report.written, true, 'report.written must be true');
|
||||
|
||||
const changelogPath = path.join(dir, 'CHANGELOG.md');
|
||||
assert.ok(fs.existsSync(changelogPath), 'CHANGELOG.md must be created');
|
||||
|
||||
const text = fs.readFileSync(changelogPath, 'utf8');
|
||||
// Must contain a top-level heading.
|
||||
assert.ok(text.includes('# Changelog'), 'must contain # Changelog heading');
|
||||
// Must contain an ## [Unreleased] block.
|
||||
assert.ok(text.includes('## [Unreleased]'), 'must contain ## [Unreleased] block');
|
||||
// Must contain a dated release heading for 8.8.8.
|
||||
assert.ok(text.includes('## [8.8.8] - 2026-06-05'), 'must contain dated release heading for 8.8.8');
|
||||
// Must contain the placeholder.
|
||||
assert.ok(text.includes('_No notable changes._'), 'must contain _No notable changes._ placeholder');
|
||||
|
||||
// parseChangelog must see the release with the correct date.
|
||||
const parsed = parseChangelog(text);
|
||||
const rel = parsed.releases.find((r) => r.version === '8.8.8');
|
||||
assert.ok(rel, 'release 8.8.8 must appear in parsed CHANGELOG');
|
||||
assert.equal(rel.date, '2026-06-05', 'release date must be 2026-06-05');
|
||||
|
||||
// verify must exit 0.
|
||||
const vr = runVerifyIn(dir, '8.8.8');
|
||||
assert.equal(vr.status, 0, `verify must exit 0 after cold-start --allow-empty render; stderr=${vr.stderr}`);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
|
||||
// Test 6: --allow-empty (zero fragments) on a CHANGELOG that already has a
|
||||
// prior dated release PRESERVES that prior release in the output.
|
||||
test('--allow-empty preserves prior dated release below new section', () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-ae-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(dir, '.changeset'), { recursive: true });
|
||||
// Pre-existing CHANGELOG with a prior dated release.
|
||||
const priorChangelog = [
|
||||
'# Changelog',
|
||||
'',
|
||||
'## [Unreleased]',
|
||||
'',
|
||||
'## [1.0.0] - 2026-01-01',
|
||||
'',
|
||||
'### Fixed',
|
||||
'',
|
||||
'- Old fix. (#1)',
|
||||
'',
|
||||
].join('\n');
|
||||
fs.writeFileSync(path.join(dir, 'CHANGELOG.md'), priorChangelog);
|
||||
|
||||
const r = runRenderIn(dir, ['--version', '2.0.0', '--date', '2026-06-05', '--allow-empty']);
|
||||
assert.equal(r.status, 0, `render exited non-zero; stderr=${r.stderr}`);
|
||||
assert.equal(r.report.consumed, 0, 'consumed must be 0');
|
||||
assert.strictEqual(r.report.written, true, 'report.written must be true');
|
||||
|
||||
const text = fs.readFileSync(path.join(dir, 'CHANGELOG.md'), 'utf8');
|
||||
|
||||
// New release must appear.
|
||||
assert.ok(text.includes('## [2.0.0]'), 'new 2.0.0 heading must be present');
|
||||
assert.ok(text.includes('_No notable changes._'), 'placeholder must be present');
|
||||
|
||||
// Prior release must survive.
|
||||
assert.ok(text.includes('## [1.0.0] - 2026-01-01'), 'prior 1.0.0 heading must be preserved');
|
||||
assert.ok(text.includes('- Old fix. (#1)'), 'prior release bullet must be preserved');
|
||||
|
||||
// Parse and assert both releases are present.
|
||||
const parsed = parseChangelog(text);
|
||||
const rel200 = parsed.releases.find((r) => r.version === '2.0.0');
|
||||
assert.ok(rel200, '2.0.0 must appear in parsed CHANGELOG');
|
||||
assert.equal(rel200.date, '2026-06-05', '2.0.0 date must be 2026-06-05');
|
||||
const rel100 = parsed.releases.find((r) => r.version === '1.0.0');
|
||||
assert.ok(rel100, '1.0.0 must still appear in parsed CHANGELOG');
|
||||
assert.equal(rel100.date, '2026-01-01', '1.0.0 date must be preserved');
|
||||
assert.equal(rel100.sections[0].bullets[0].pr, 1, 'pr=1 bullet in 1.0.0 must be preserved');
|
||||
|
||||
// verify for the new version must exit 0.
|
||||
const vr = runVerifyIn(dir, '2.0.0');
|
||||
assert.equal(vr.status, 0, `verify must exit 0 after --allow-empty on existing CHANGELOG; stderr=${vr.stderr}`);
|
||||
} finally {
|
||||
cleanup(dir);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user