diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index bc2494f99..52145edee 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -370,6 +370,29 @@ jobs: node scripts/check-npm-integrity.cjs npm run test:coverage:unit + - name: Preview CHANGELOG (non-destructive) + env: + VERSION: ${{ inputs.version }} + run: | + # Non-destructive CHANGELOG preview for the version under test (#759): + # renders the curated section finalize will promote, without writing + # CHANGELOG.md or consuming .changeset fragments. Surfaced in the job + # summary so RC testers see the upcoming release notes. + # + # Render to a file as a standalone command so a non-zero exit (e.g. a + # malformed fragment) fails the step. The default GitHub Linux shell + # is `bash -e` without pipefail, so a `node | tee` pipeline would mask + # a node failure behind tee's exit 0. + PREVIEW_FILE="${RUNNER_TEMP:-/tmp}/changelog-preview.md" + node scripts/changeset/cli.cjs render \ + --version "$VERSION" --date "$(date -u +%F)" --preview > "$PREVIEW_FILE" + { + echo "### CHANGELOG preview for v${VERSION} (not yet promoted)" + echo '' + cat "$PREVIEW_FILE" + } >> "${GITHUB_STEP_SUMMARY:-/dev/null}" + cat "$PREVIEW_FILE" + - name: Commit pre-release version bump env: PRE_VERSION: ${{ steps.prerelease.outputs.pre_version }} diff --git a/docs/branching.md b/docs/branching.md index 2f1edd960..22398239f 100644 --- a/docs/branching.md +++ b/docs/branching.md @@ -167,6 +167,7 @@ When `next` has accumulated enough work to ship a new minor/major. - Each fix should also be PR'd to `next` so the next release has it too (or wait for the auto-back-merge after finalize) - Trigger `rc` action to publish RC builds: 1.28.0-rc.1, rc.2, ... + - Each `rc` run also prints a non-destructive preview of the curated `## [X.Y.0]` CHANGELOG section in the Actions job summary — rendered from the `.changeset/` fragments without consuming them — so you can review the upcoming release notes during RC testing. 4. When stable: trigger `finalize`. - Publishes to npm @latest diff --git a/scripts/changeset/cli.cjs b/scripts/changeset/cli.cjs index 283120679..92fc65af8 100755 --- a/scripts/changeset/cli.cjs +++ b/scripts/changeset/cli.cjs @@ -42,6 +42,7 @@ function parseArgs(argv) { installCommand: `npx ${packageName}@latest`, json: false, allowEmpty: false, + preview: false, }; if (argv.length === 0) return { ok: true, opts }; opts.cmd = argv[0]; @@ -62,6 +63,7 @@ function parseArgs(argv) { const a = argv[i]; if (a === '--json') { opts.json = true; continue; } if (a === '--allow-empty') { opts.allowEmpty = true; continue; } + if (a === '--preview') { opts.preview = true; continue; } if ( a === '--repo' || a === '--version' || @@ -133,6 +135,19 @@ function assembleChangelog(lead, releaseBlock) { ].join('\n'); } +// Insert a "_No notable changes._" placeholder after the dated release heading +// of an otherwise-empty release block. serializeChangelog with no sections +// yields just "## [v] - d\n"; we expand the trailing newline into a blank line +// + placeholder + blank line so parseChangelog still sees the dated heading +// first and the output is human-readable. Shared by the --allow-empty and +// --preview zero-fragment paths so they can never drift. +function injectEmptyPlaceholder(headerOnlyBlock) { + return headerOnlyBlock.replace( + /^(##\s+\[[^\]]+\][^\n]*)\n+/, + '$1\n\n_No notable changes._\n\n', + ); +} + function cmdRender(opts) { const repo = path.resolve(opts.repo); const changesetDir = path.join(repo, '.changeset'); @@ -156,6 +171,38 @@ function cmdRender(opts) { // 2. Read priorText once; reuse in all subsequent branches. const priorText = fs.existsSync(changelogPath) ? fs.readFileSync(changelogPath, 'utf8') : ''; + // Preview mode (#759): render the dated release section WITHOUT writing + // CHANGELOG.md and WITHOUT consuming .changeset fragments. Used by the rc + // release job to surface the curated notes for the version under test while + // leaving the fragment set intact for the eventual finalize render. + if (opts.preview) { + // priorChangelog is intentionally null: a preview shows ONLY the new dated + // section for the version under test, not the full file history. + // serializeChangelog appends priorChangelog verbatim, so passing the prior + // text here would dump every past release into the rc job summary. + const ir = renderChangelog({ + fragments, + version: opts.version, + date: opts.date, + priorChangelog: null, + }); + let releaseBlock = serializeChangelog(ir); + if (fragments.length === 0) { + // Mirror --allow-empty: a no-fragment release still shows a dated heading + // with a placeholder rather than an empty block. + releaseBlock = injectEmptyPlaceholder(releaseBlock); + } + return { + exitCode: 0, + report: { + consumed: 0, + failures: [], + preview: releaseBlock, + fragmentCount: fragments.length, + }, + }; + } + // 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: @@ -201,15 +248,7 @@ function cmdRender(opts) { 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', - ); + const releaseBlock = injectEmptyPlaceholder(headerOnlyBlock); // FIX 2: use shared assembleChangelog helper. const out = assembleChangelog(lead, releaseBlock); fs.writeFileSync(changelogPath, out); @@ -460,7 +499,8 @@ function cmdGithubReleaseNotes(opts) { function usage() { return [ 'usage:', - ' changeset/cli.cjs render --repo --version V --date D [--allow-empty] [--json]', + ' changeset/cli.cjs render --repo --version V --date D [--allow-empty] [--preview] [--json]', + ' --preview renders the dated section to stdout without writing CHANGELOG.md or consuming fragments.', ' changeset/cli.cjs github-release-notes --repo --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 ] [--json]', ' Extracts changelog entries strictly after --from (exclusive) and up to', @@ -525,6 +565,9 @@ function main() { const { exitCode, report } = opts.cmd === 'render' ? cmdRender(opts) : cmdGithubReleaseNotes(opts); if (opts.json) { process.stdout.write(JSON.stringify(report, null, 2) + '\n'); + } else if (opts.cmd === 'render' && opts.preview) { + // render --preview: emit the rendered section verbatim (no mutation occurred). + process.stdout.write(report.preview); } else if (opts.cmd === 'github-release-notes' && report.body) { process.stdout.write(report.body); } else { diff --git a/tests/changeset-cli.test.cjs b/tests/changeset-cli.test.cjs index 03ed3b5e5..72b80c91e 100644 --- a/tests/changeset-cli.test.cjs +++ b/tests/changeset-cli.test.cjs @@ -1,7 +1,7 @@ 'use strict'; process.env.GSD_TEST_MODE = '1'; -const { test, describe, before, after } = require('node:test'); +const { test, describe, before, after, beforeEach } = require('node:test'); const assert = require('node:assert/strict'); const fs = require('node:fs'); const os = require('node:os'); @@ -36,6 +36,11 @@ function runRender(args = []) { }; } +function runRenderRaw(args = []) { + const r = cp.spawnSync(process.execPath, [SCRIPT, 'render', '--repo', tmp, ...args], { encoding: 'utf8' }); + return { status: r.status, stdout: r.stdout || '', stderr: r.stderr || '' }; +} + before(() => { tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-changeset-')); }); after(() => { cleanup(tmp); }); @@ -928,3 +933,107 @@ describe('changeset cli render --allow-empty', () => { } }); }); + +// --------------------------------------------------------------------------- +// GROUP D — render --preview (#759) +// --------------------------------------------------------------------------- + +describe('changeset cli render --preview (#759)', () => { + // Helper: reset the shared tmp dir to a clean state for preview tests. + function resetTmp() { + // Remove CHANGELOG.md if present. + const changelogPath = path.join(tmp, 'CHANGELOG.md'); + if (fs.existsSync(changelogPath)) { + fs.unlinkSync(changelogPath); + } + // Remove any leftover .changeset/*.md fragments. + const changesetDir = path.join(tmp, '.changeset'); + if (fs.existsSync(changesetDir)) { + for (const f of fs.readdirSync(changesetDir)) { + if (f.endsWith('.md') && f !== 'README.md') { + fs.unlinkSync(path.join(changesetDir, f)); + } + } + } + } + + // Reset state before each preview test so a new test added to this group + // can never inherit a CHANGELOG.md or fragments left by the previous one. + beforeEach(resetTmp); + + test('render --preview prints the section to stdout and mutates nothing', () => { + writeFragment('preview-frag-one', 'Added', 900, 'preview-added-feature'); + + const fragmentPath = path.join(tmp, '.changeset', 'preview-frag-one.md'); + const r = runRenderRaw(['--version', '9.9.0', '--date', '2026-01-02', '--preview']); + + assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`); + assert.ok(r.stdout.includes('## [9.9.0]'), `stdout must contain ## [9.9.0]; got: ${r.stdout}`); + assert.ok(r.stdout.includes('### Added'), `stdout must contain ### Added; got: ${r.stdout}`); + assert.ok(r.stdout.includes('preview-added-feature'), `stdout must contain fragment body; got: ${r.stdout}`); + + // CHANGELOG.md must NOT have been created. + assert.ok(!fs.existsSync(path.join(tmp, 'CHANGELOG.md')), 'CHANGELOG.md must NOT be created by --preview'); + + // Fragment file must still exist. + assert.ok(fs.existsSync(fragmentPath), 'fragment file must still exist after --preview'); + }); + + test('render --preview with zero fragments emits the placeholder and writes nothing', () => { + // No fragments written — zero-fragment scenario. + + const r = runRenderRaw(['--version', '9.9.0', '--date', '2026-01-02', '--preview']); + + assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`); + assert.ok(r.stdout.includes('## ['), `stdout must contain a release heading; got: ${r.stdout}`); + assert.ok(r.stdout.includes('_No notable changes._'), `stdout must contain placeholder; got: ${r.stdout}`); + + // CHANGELOG.md must NOT have been created. + assert.ok(!fs.existsSync(path.join(tmp, 'CHANGELOG.md')), 'CHANGELOG.md must NOT be created by zero-fragment --preview'); + }); + + test('render --preview --json returns preview text in report with consumed 0', () => { + writeFragment('preview-frag-two', 'Fixed', 901, 'preview-fixed-bug'); + + const fragmentPath = path.join(tmp, '.changeset', 'preview-frag-two.md'); + // runRender appends --json automatically. + const r = runRender(['--version', '9.9.0', '--date', '2026-01-02', '--preview']); + + assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`); + assert.ok(r.report, 'report must be parseable JSON'); + assert.strictEqual(r.report.consumed, 0, 'consumed must be 0 for preview'); + assert.strictEqual(r.report.fragmentCount, 1, 'fragmentCount must be 1'); + assert.ok(typeof r.report.preview === 'string', 'report.preview must be a string'); + assert.ok(r.report.preview.includes('## [9.9.0]'), `report.preview must contain ## [9.9.0]; got: ${r.report.preview}`); + + // Fragment file must still exist. + assert.ok(fs.existsSync(fragmentPath), 'fragment file must still exist after --preview --json'); + }); + + test('render --preview with an existing CHANGELOG.md leaves it byte-identical and shows only the new section', () => { + // Seed an existing CHANGELOG with a prior dated release. + const changelogPath = path.join(tmp, 'CHANGELOG.md'); + const existing = + '# Changelog\n\n## [1.0.0] - 2020-01-01\n\n### Added\n\n- old prior feature (#1)\n'; + fs.writeFileSync(changelogPath, existing); + writeFragment('preview-frag-three', 'Added', 902, 'brand-new-thing'); + const before = fs.readFileSync(changelogPath, 'utf8'); + + const r = runRenderRaw(['--version', '9.9.0', '--date', '2026-01-02', '--preview']); + + assert.equal(r.status, 0, `expected exit 0; stderr=${r.stderr}`); + // Output shows the new section... + assert.ok(r.stdout.includes('## [9.9.0]'), `stdout must contain new heading; got: ${r.stdout}`); + assert.ok(r.stdout.includes('brand-new-thing'), `stdout must contain new fragment body; got: ${r.stdout}`); + // ...and NOT the prior release history (preview is the new section only). + assert.ok(!r.stdout.includes('## [1.0.0]'), `preview must NOT include prior releases; got: ${r.stdout}`); + assert.ok(!r.stdout.includes('old prior feature'), `preview must NOT include prior bullets; got: ${r.stdout}`); + + // Existing CHANGELOG.md must be byte-identical — preview mutates nothing. + assert.strictEqual( + fs.readFileSync(changelogPath, 'utf8'), + before, + 'CHANGELOG.md must be byte-identical after --preview', + ); + }); +});