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',
+ );
+ });
+});