The rc action publishes a release candidate to @next for testing but never surfaces the curated CHANGELOG section for the version under test — render only runs destructively at finalize (#715), so there was no safe way to preview the upcoming notes during the RC window. Add a --preview mode to scripts/changeset/cli.cjs cmdRender: it renders the dated release section to stdout via the existing renderChangelog/ serializeChangelog path (with priorChangelog: null, so only the new section is emitted), reuses the shared injectEmptyPlaceholder helper for zero-fragment releases, and returns WITHOUT writing CHANGELOG.md or deleting any .changeset fragment. Wire a "Preview CHANGELOG" step into the rc job that renders to a file (standalone command, so a malformed fragment fails the step) and cats it to the job summary and log. Closes #759 Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
591 lines
20 KiB
JavaScript
Executable File
591 lines
20 KiB
JavaScript
Executable File
#!/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 { ExitError, runMain } = require('../lib/cli-exit.cjs');
|
|
const { parseFragment } = require('./parse.cjs');
|
|
const { renderChangelog } = require('./render.cjs');
|
|
const { serializeChangelog, parseChangelog } = require('./serialize.cjs');
|
|
const { renderGithubReleaseNotes } = require('./github-release-notes.cjs');
|
|
const {
|
|
compareSemverCore,
|
|
isStableTripletSemver,
|
|
} = require('../../gsd-core/bin/lib/semver-compare.cjs');
|
|
const { packageName, repoSlug: defaultRepoSlug } = require('../../gsd-core/bin/lib/package-identity.cjs');
|
|
|
|
function parseArgs(argv) {
|
|
const opts = {
|
|
cmd: null,
|
|
repo: process.cwd(),
|
|
version: null,
|
|
date: null,
|
|
fromRef: null,
|
|
toRef: null,
|
|
changelog: null,
|
|
output: null,
|
|
repoSlug: defaultRepoSlug,
|
|
installCommand: `npx ${packageName}@latest`,
|
|
json: false,
|
|
allowEmpty: false,
|
|
preview: 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 === '--allow-empty') { opts.allowEmpty = true; continue; }
|
|
if (a === '--preview') { opts.preview = true; continue; }
|
|
if (
|
|
a === '--repo' ||
|
|
a === '--version' ||
|
|
a === '--date' ||
|
|
a === '--from' ||
|
|
a === '--to' ||
|
|
a === '--changelog' ||
|
|
a === '--output' ||
|
|
a === '--repo-slug' ||
|
|
a === '--install-command'
|
|
) {
|
|
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;
|
|
else if (a === '--from') opts.fromRef = r.value;
|
|
else if (a === '--to') opts.toRef = r.value;
|
|
else if (a === '--changelog') opts.changelog = r.value;
|
|
else if (a === '--output') opts.output = r.value;
|
|
else if (a === '--repo-slug') opts.repoSlug = r.value;
|
|
else if (a === '--install-command') opts.installCommand = 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 };
|
|
}
|
|
|
|
// 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');
|
|
}
|
|
|
|
// 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');
|
|
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 });
|
|
}
|
|
|
|
// 1. parse-failure → exitCode 1 (unchanged).
|
|
if (failures.length > 0) {
|
|
return { exitCode: 1, report: { consumed: 0, failures } };
|
|
}
|
|
|
|
// 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:
|
|
// • 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 },
|
|
};
|
|
}
|
|
|
|
// 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);
|
|
const releaseBlock = injectEmptyPlaceholder(headerOnlyBlock);
|
|
// 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({
|
|
fragments,
|
|
version: opts.version,
|
|
date: opts.date,
|
|
priorChangelog: prior || null,
|
|
});
|
|
const releaseBlock = serializeChangelog(ir);
|
|
// FIX 2: use shared assembleChangelog helper.
|
|
const out = assembleChangelog(lead, releaseBlock);
|
|
|
|
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 stripV(v) { return typeof v === 'string' ? v.replace(/^v/, '') : v; }
|
|
|
|
function resolveChangelogPath(opts) {
|
|
return opts.changelog
|
|
? path.resolve(opts.changelog)
|
|
: path.join(path.resolve(opts.repo), 'CHANGELOG.md');
|
|
}
|
|
|
|
/**
|
|
* extract subcommand: extracts all changelog release blocks strictly after
|
|
* `--from` (exclusive) up to and including `--to` (inclusive). Both
|
|
* arguments accept `v`-prefixed semver (e.g. `v1.5.13`).
|
|
*
|
|
* Exit codes:
|
|
* 0 — one or more releases matched, output written.
|
|
* 2 — no releases fall in the specified range (matches nothing).
|
|
* 1 — I/O error or missing required flags.
|
|
*
|
|
* Fix for #3496: provides a deterministic range-aware helper so the
|
|
* `/gsd:update` show_changes_and_confirm step no longer relies on
|
|
* vague/manual extraction that can silently skip intermediate versions.
|
|
*/
|
|
function cmdExtract(opts) {
|
|
const from = stripV(opts.fromRef);
|
|
const to = stripV(opts.toRef);
|
|
|
|
// Validate that both bounds are strict semver (N.N.N, digits only).
|
|
// Coercing a malformed bound like "1.41.x" to "1.41.0" makes range
|
|
// selection silently wrong; reject early with a structured error.
|
|
if (!isStableTripletSemver(from)) {
|
|
return {
|
|
exitCode: 1,
|
|
report: { error: `invalid semver for --from: "${from}" (expected N.N.N)`, releases: [] },
|
|
textOutput: null,
|
|
};
|
|
}
|
|
if (!isStableTripletSemver(to)) {
|
|
return {
|
|
exitCode: 1,
|
|
report: { error: `invalid semver for --to: "${to}" (expected N.N.N)`, releases: [] },
|
|
textOutput: null,
|
|
};
|
|
}
|
|
|
|
const changelogPath = resolveChangelogPath(opts);
|
|
|
|
if (!fs.existsSync(changelogPath)) {
|
|
return {
|
|
exitCode: 1,
|
|
report: { error: `CHANGELOG not found: ${changelogPath}`, releases: [] },
|
|
textOutput: null,
|
|
};
|
|
}
|
|
|
|
const text = fs.readFileSync(changelogPath, 'utf8');
|
|
const { releases } = parseChangelog(text);
|
|
|
|
const matched = releases.filter((rel) => {
|
|
if (rel.version === 'Unreleased') return false;
|
|
// Extract mode intentionally operates on stable releases only.
|
|
if (!isStableTripletSemver(rel.version)) {
|
|
process.stderr.write(`[extract] skipping pre-release/non-semver entry: ${rel.version}\n`);
|
|
return false;
|
|
}
|
|
// from is exclusive: cmp > 0 means rel.version > from
|
|
const afterFrom = compareSemverCore(rel.version, from) > 0;
|
|
// to is inclusive: cmp <= 0 means rel.version <= to
|
|
const upToTo = compareSemverCore(rel.version, to) <= 0;
|
|
return afterFrom && upToTo;
|
|
});
|
|
|
|
if (matched.length === 0) {
|
|
return {
|
|
exitCode: 2,
|
|
report: { releases: [], from, to },
|
|
textOutput: null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
exitCode: 0,
|
|
report: { releases: matched, from, to },
|
|
textOutput: matched
|
|
.map((rel) => {
|
|
const header = `## [${rel.version}]${rel.date ? ` - ${rel.date}` : ''}`;
|
|
const sections = (rel.sections || [])
|
|
.map((s) => {
|
|
const bullets = s.bullets
|
|
.map((b) => (b.pr !== null ? `- ${b.body} (#${b.pr})` : `- ${b.body}`))
|
|
.join('\n');
|
|
return `### ${s.type}\n\n${bullets}`;
|
|
})
|
|
.join('\n\n');
|
|
return sections ? `${header}\n\n${sections}` : header;
|
|
})
|
|
.join('\n\n'),
|
|
};
|
|
}
|
|
|
|
function cmdVerify(opts) {
|
|
const version = stripV(opts.version);
|
|
|
|
if (!isStableTripletSemver(version)) {
|
|
return {
|
|
exitCode: 1,
|
|
report: { error: `invalid semver for --version: "${version}" (expected N.N.N)`, ok: false },
|
|
textOutput: null,
|
|
};
|
|
}
|
|
|
|
const changelogPath = resolveChangelogPath(opts);
|
|
|
|
if (!fs.existsSync(changelogPath)) {
|
|
return {
|
|
exitCode: 1,
|
|
report: { error: `CHANGELOG not found: ${changelogPath}`, ok: false },
|
|
textOutput: null,
|
|
};
|
|
}
|
|
|
|
const text = fs.readFileSync(changelogPath, 'utf8');
|
|
const { releases } = parseChangelog(text);
|
|
|
|
const match = releases.find((r) => r.version === version);
|
|
|
|
if (!match) {
|
|
return {
|
|
exitCode: 1,
|
|
report: {
|
|
error: `CHANGELOG.md has no \`## [${version}]\` release heading — promote [Unreleased] into a dated section before releasing (see #690)`,
|
|
ok: false,
|
|
},
|
|
textOutput: null,
|
|
};
|
|
}
|
|
|
|
if (!match.date) {
|
|
return {
|
|
exitCode: 1,
|
|
report: {
|
|
error: `CHANGELOG.md heading \`## [${version}]\` has no date — expected \`## [${version}] - YYYY-MM-DD\``,
|
|
ok: false,
|
|
},
|
|
textOutput: null,
|
|
};
|
|
}
|
|
|
|
return {
|
|
exitCode: 0,
|
|
report: { ok: true, version, date: match.date },
|
|
textOutput: `CHANGELOG.md has a dated heading for ${version} (${match.date})`,
|
|
};
|
|
}
|
|
|
|
function cmdGithubReleaseNotes(opts) {
|
|
const repo = path.resolve(opts.repo);
|
|
const report = renderGithubReleaseNotes({
|
|
repo,
|
|
fromRef: opts.fromRef,
|
|
toRef: opts.toRef,
|
|
repoSlug: opts.repoSlug,
|
|
installCommand: opts.installCommand,
|
|
});
|
|
|
|
if (!report.ok) {
|
|
return {
|
|
exitCode: 1,
|
|
report: {
|
|
consumed: 0,
|
|
failures: report.failures,
|
|
release: { from: opts.fromRef, to: opts.toRef },
|
|
},
|
|
};
|
|
}
|
|
|
|
if (opts.output) {
|
|
fs.writeFileSync(path.resolve(opts.output), report.body);
|
|
}
|
|
|
|
return {
|
|
exitCode: 0,
|
|
report: {
|
|
consumed: report.fragments.length,
|
|
failures: [],
|
|
release: { from: opts.fromRef, to: opts.toRef },
|
|
output: opts.output || null,
|
|
body: opts.output ? null : report.body,
|
|
},
|
|
};
|
|
}
|
|
|
|
function usage() {
|
|
return [
|
|
'usage:',
|
|
' changeset/cli.cjs render --repo <dir> --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 <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',
|
|
' and including --to (inclusive). Accepts v-prefixed versions.',
|
|
' Exit 2 when no releases fall in range.',
|
|
' changeset/cli.cjs verify --version <X.Y.Z> [--changelog <path>] Exit non-zero if CHANGELOG.md has no dated `## [X.Y.Z]` heading (release gate, #690)',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
function main() {
|
|
const parsed = parseArgs(process.argv.slice(2));
|
|
if (!parsed.ok) {
|
|
process.stderr.write(`${parsed.error}\n`);
|
|
process.stderr.write(usage());
|
|
throw new ExitError(2);
|
|
}
|
|
const { opts } = parsed;
|
|
if (opts.cmd !== 'render' && opts.cmd !== 'github-release-notes' && opts.cmd !== 'extract' && opts.cmd !== 'verify') {
|
|
process.stderr.write(usage());
|
|
throw new ExitError(1);
|
|
}
|
|
if (opts.cmd === 'render' && (!opts.version || !opts.date)) {
|
|
throw new ExitError(2, '--version and --date are required for render');
|
|
}
|
|
if (opts.cmd === 'github-release-notes' && (!opts.fromRef || !opts.toRef)) {
|
|
throw new ExitError(2, '--from and --to are required for github-release-notes');
|
|
}
|
|
if (opts.cmd === 'extract' && (!opts.fromRef || !opts.toRef)) {
|
|
process.stderr.write('--from and --to are required for extract\n');
|
|
process.stderr.write(usage());
|
|
throw new ExitError(1);
|
|
}
|
|
if (opts.cmd === 'verify' && !opts.version) {
|
|
throw new ExitError(2, '--version is required for verify');
|
|
}
|
|
|
|
if (opts.cmd === 'extract') {
|
|
const { exitCode, report, textOutput } = cmdExtract(opts);
|
|
if (opts.json) {
|
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
} else if (textOutput) {
|
|
process.stdout.write(textOutput + '\n');
|
|
} else if (exitCode === 2) {
|
|
process.stderr.write(`no releases found in range (from=${report.from}, to=${report.to})\n`);
|
|
}
|
|
return exitCode;
|
|
}
|
|
|
|
if (opts.cmd === 'verify') {
|
|
const { exitCode, report, textOutput } = cmdVerify(opts);
|
|
if (opts.json) {
|
|
process.stdout.write(JSON.stringify(report, null, 2) + '\n');
|
|
} else if (textOutput) {
|
|
process.stdout.write(textOutput + '\n');
|
|
} else {
|
|
process.stderr.write(report.error + '\n');
|
|
}
|
|
return exitCode;
|
|
}
|
|
|
|
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 {
|
|
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`);
|
|
for (const f of report.failures) {
|
|
process.stdout.write(` ${f.file}: ${f.reason}${f.detail ? ` (${f.detail})` : ''}\n`);
|
|
}
|
|
}
|
|
}
|
|
return exitCode;
|
|
}
|
|
|
|
if (require.main === module) runMain(main);
|
|
|
|
module.exports = { cmdRender, cmdExtract, cmdVerify, cmdGithubReleaseNotes, parseArgs, splitChangelog, assembleChangelog, listFragmentFiles, usage };
|