From cac71b0645090131ac8a4eee2fee1daee09a81be Mon Sep 17 00:00:00 2001 From: Josh Mabry Date: Sat, 6 Jun 2026 20:23:16 -0700 Subject: [PATCH 1/2] feat(release-notes): expose notes as outputs + optional changelog writer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The generator produced themed notes but only printed/Discord'd them, so each repo re-typed the same text into its GitHub release body and its changelog (and they drifted). Make one generation drive every changelog surface — opt-in and backward compatible. - Action outputs `notes` (markdown) + `highlights` (JSON array of the bullet lines) via $GITHUB_OUTPUT — wire to the release body / a marketing changelog. - `--out ` writes the notes; `--changelog ` + `--changelog-format md|json` prepends a dated entry (md: "## " under a leading "# Changelog"; json: { version, date, notes, highlights }, newest-first, replacing a re-run of the same version). `--date` overrides the entry date. - `--notes-file ` reuses a prior job's notes instead of calling the LLM — so the release body + changelog can't disagree, and the file paths run without a gateway key. Short-circuits before any git work (no range needed). - action.yml: `out-file` / `changelog-file` / `changelog-format` inputs + `notes` / `highlights` outputs. Existing callers are unaffected (no new flags → same behavior). New tests cover --out, both changelog formats, newest-first/replace, and the $GITHUB_OUTPUT contract; docs + CLI reference updated. v2.2.0 → v2.3.0. --- action.yml | 30 ++++ bin/rewrite-release-notes.mjs | 200 ++++++++++++++++++++++---- docs/how-to/generate-release-notes.md | 47 ++++++ docs/reference/cli.md | 26 +++- package.json | 2 +- test/rewrite-release-notes.test.mjs | 114 ++++++++++++++- 6 files changed, 382 insertions(+), 37 deletions(-) diff --git a/action.yml b/action.yml index 487cc3d..4360dba 100644 --- a/action.yml +++ b/action.yml @@ -38,6 +38,28 @@ inputs: description: 'Override the Discord embed footer text. Default: "protoLabs · ".' required: false default: '' + out-file: + description: 'Write the generated notes (markdown) to this path.' + required: false + default: '' + changelog-file: + description: | + Prepend a dated entry to this changelog file. Committing it is the caller's + job (release jobs run on a tag, so write it back to the right branch). + required: false + default: '' + changelog-format: + description: 'Changelog format when changelog-file is set: md (default) | json.' + required: false + default: 'md' + +outputs: + notes: + description: 'The generated release notes (markdown) — e.g. for the GitHub release body.' + value: ${{ steps.generate.outputs.notes }} + highlights: + description: 'JSON array of the bullet lines from the notes, for structured changelogs.' + value: ${{ steps.generate.outputs.highlights }} # Secrets are read from the calling job's env via standard env-var passthrough: # env: @@ -53,6 +75,7 @@ runs: node-version: '20' - name: Run release-notes generator + id: generate shell: bash env: RELEASE_NOTES_MODEL: ${{ inputs.model }} @@ -66,5 +89,12 @@ runs: elif [ "${{ inputs.post-discord }}" = "true" ]; then FLAGS+=("--post-discord") fi + if [ -n "${{ inputs.out-file }}" ]; then + FLAGS+=("--out" "${{ inputs.out-file }}") + fi + if [ -n "${{ inputs.changelog-file }}" ]; then + FLAGS+=("--changelog" "${{ inputs.changelog-file }}" \ + "--changelog-format" "${{ inputs.changelog-format }}") + fi npx --yes -p '@protolabsai/release-tools@latest' rewrite-release-notes \ "${{ inputs.version }}" "${{ inputs.previous-version }}" "${FLAGS[@]}" diff --git a/bin/rewrite-release-notes.mjs b/bin/rewrite-release-notes.mjs index 01e0e6d..8e58bd5 100755 --- a/bin/rewrite-release-notes.mjs +++ b/bin/rewrite-release-notes.mjs @@ -13,9 +13,21 @@ * rewrite-release-notes [version] [prev-version] [flags] * * Flags: - * --post-discord Post the generated notes to DISCORD_RELEASE_WEBHOOK. - * --dry-run Print the prompt that would be sent and exit. - * --help Show this help and exit. + * --post-discord Post the generated notes to DISCORD_RELEASE_WEBHOOK. + * --out Write the notes (markdown) to . + * --changelog Prepend a dated entry to a changelog file. + * --changelog-format md (default) | json. md prepends a + * "## " section; json prepends + * { version, date, notes, highlights } to a JSON array. + * --date Changelog entry date. Default: today (UTC). + * --notes-file Use notes from instead of calling the LLM + * (reuse a prior job's notes; also makes the file + * outputs runnable without a gateway key). + * --dry-run Print the prompt that would be sent and exit. + * --help Show this help and exit. + * + * GitHub Actions: when $GITHUB_OUTPUT is set, the generated `notes` (markdown) + * and `highlights` (JSON array of the bullet lines) are exposed as step outputs. * * Environment: * GATEWAY_API_KEY (required for non-dry-run) Bearer token for the gateway. @@ -37,6 +49,12 @@ */ import { execSync } from 'node:child_process'; +import { + appendFileSync, + existsSync, + readFileSync, + writeFileSync, +} from 'node:fs'; // ─── Help ──────────────────────────────────────────────────────────────────── @@ -267,27 +285,167 @@ async function postToDiscord(repoSlug, version, notes) { } } +// ─── Changelog + Action outputs ─────────────────────────────────────────────── +// +// The notes the LLM produces are useful beyond Discord: a repo's GitHub release +// body, a CHANGELOG.md, or a marketing changelog all want the same polished +// text. These helpers expose it (as Action outputs + optional files) so a single +// generation drives every changelog surface — see docs/how-to/generate-release-notes.md. + +const today = () => new Date().toISOString().slice(0, 10); + +/** + * Flatten the generated markdown into a list of user-facing change lines — the + * bullet items, with the `- `/`* ` marker stripped. Section headers + the intro + * sentence are dropped. Repos that keep a structured changelog (one entry = an + * array of change strings) build their entry from this. + */ +function extractHighlights(notes) { + return notes + .split('\n') + .map((l) => l.trim()) + .filter((l) => /^[-*]\s+/.test(l)) + .map((l) => l.replace(/^[-*]\s+/, '').trim()) + .filter(Boolean); +} + +/** Prepend a dated section, keeping a leading `# H1` title at the very top. */ +function prependMarkdownChangelog(existing, version, date, notes) { + const entry = `## ${version} — ${date}\n\n${notes.trim()}\n`; + const text = existing ?? ''; + const head = text.match(/^(#[^\n]*\n+)/); // a leading "# Changelog" title + if (head) { + return `${head[1]}${entry}\n${text.slice(head[1].length)}`; + } + return text.trim() ? `${entry}\n${text}` : `# Changelog\n\n${entry}`; +} + +/** Prepend (or replace the same version) in a JSON-array changelog file. */ +function upsertJsonChangelog(existingText, entry) { + let arr = []; + if (existingText?.trim()) { + try { + const parsed = JSON.parse(existingText); + if (Array.isArray(parsed)) arr = parsed; + } catch { + // Malformed — start fresh rather than drop the release; the caller commits + // the result, so a bad file surfaces in review instead of silently losing. + } + } + const rest = arr.filter((e) => e?.version !== entry.version); + return `${JSON.stringify([entry, ...rest], null, 2)}\n`; +} + +/** Expose the notes + highlights as GitHub Actions step outputs, if running there. */ +function writeGithubOutputs(notes, highlights) { + const out = process.env.GITHUB_OUTPUT; + if (!out) return; + const D = 'RELNOTES_EOF'; + appendFileSync( + out, + `notes<<${D}\n${notes}\n${D}\nhighlights=${JSON.stringify(highlights)}\n`, + ); + console.log('Wrote `notes` + `highlights` to $GITHUB_OUTPUT'); +} + // ─── Main ──────────────────────────────────────────────────────────────────── const args = process.argv.slice(2); -const postDiscord = args.includes('--post-discord'); -const dryRun = args.includes('--dry-run'); -const positional = args.filter((a) => !a.startsWith('--')); +// Flags that take a value (the next token), so it isn't mistaken for a +// positional version arg. +const VALUE_FLAGS = new Set([ + '--out', + '--changelog', + '--changelog-format', + '--notes-file', + '--date', +]); +const opts = {}; +const positional = []; +for (let i = 0; i < args.length; i++) { + const a = args[i]; + if (VALUE_FLAGS.has(a)) opts[a] = args[++i]; + else if (a.startsWith('--')) opts[a] = true; + else positional.push(a); +} +const postDiscord = !!opts['--post-discord']; +const dryRun = !!opts['--dry-run']; +const outFile = opts['--out']; +const changelogFile = opts['--changelog']; +const changelogFormat = opts['--changelog-format'] || 'md'; +const notesFile = opts['--notes-file']; +const entryDate = opts['--date'] || today(); let version = positional[0]; let previousVersion = positional[1]; -if (!version || !previousVersion) { - const tags = getTags(); - version = version ?? tags.latest; - previousVersion = previousVersion ?? tags.previous; +// Version is always needed; the commit range (previousVersion) only when we +// generate from git. Resolve from tags lazily so the --notes-file path works in +// a plain (non-git) checkout too. +if (!version) { + version = getTags().latest; } - if (!version) { console.error('Could not determine the release version. Pass it explicitly.'); process.exit(1); } +const repoSlug = process.env.RELEASE_NOTES_REPO || detectRepoSlug(); +if (postDiscord && !repoSlug) { + console.error( + 'Could not determine repo owner/name. Set RELEASE_NOTES_REPO=owner/name.', + ); + process.exit(1); +} + +// Emit the notes to every requested sink: stdout, Action outputs, a notes file, +// a changelog file, and/or Discord. Shared by the reuse path and the LLM path. +async function emitNotes(notes) { + console.log('\n── Release Notes ──\n'); + console.log(notes); + + const highlights = extractHighlights(notes); + writeGithubOutputs(notes, highlights); + + if (outFile) { + writeFileSync(outFile, `${notes.trim()}\n`); + console.log(`Wrote notes → ${outFile}`); + } + + if (changelogFile) { + const existing = existsSync(changelogFile) + ? readFileSync(changelogFile, 'utf8') + : ''; + const updated = + changelogFormat === 'json' + ? upsertJsonChangelog(existing, { + version, + date: entryDate, + notes: notes.trim(), + highlights, + }) + : prependMarkdownChangelog(existing, version, entryDate, notes); + writeFileSync(changelogFile, updated); + console.log(`Updated changelog → ${changelogFile} (${changelogFormat})`); + } + + if (postDiscord) { + await postToDiscord(repoSlug, version, notes); + } +} + +// Reuse pre-generated notes (a prior job's output; also lets the file/changelog +// paths run without a gateway key) — no git range or LLM call needed. +if (notesFile) { + console.log(`Using notes from ${notesFile}`); + await emitNotes(readFileSync(notesFile, 'utf8').trim()); + process.exit(0); +} + +if (!previousVersion) { + previousVersion = getTags().previous; +} + // First release: there's no previous tag, so diff from the repo's root commit // (the whole history is the range) instead of erroring out. Every consuming // repo's first `v*` tag would otherwise fail here. @@ -305,14 +463,6 @@ if (!previousVersion) { process.exit(1); } -const repoSlug = process.env.RELEASE_NOTES_REPO || detectRepoSlug(); -if (postDiscord && !repoSlug) { - console.error( - 'Could not determine repo owner/name. Set RELEASE_NOTES_REPO=owner/name.', - ); - process.exit(1); -} - console.log(`Generating release notes: ${previousVersion} → ${version}`); const commits = getCommitsBetween(previousVersion, version); @@ -350,16 +500,10 @@ if (dryRun) { process.exit(0); } -// Skip if all commits were filtered out (maintenance releases, CI-only changes) +// Skip when every commit was filtered out (maintenance / CI-only releases). if (filteredCount === 0) { - console.log('No user-facing commits — skipping Discord post.'); + console.log('No user-facing commits — nothing to generate.'); process.exit(0); } -const notes = await callLLM(userPrompt); -console.log('\n── Release Notes ──\n'); -console.log(notes); - -if (postDiscord) { - await postToDiscord(repoSlug, version, notes); -} +await emitNotes(await callLLM(userPrompt)); diff --git a/docs/how-to/generate-release-notes.md b/docs/how-to/generate-release-notes.md index e28741f..0c7a2a8 100644 --- a/docs/how-to/generate-release-notes.md +++ b/docs/how-to/generate-release-notes.md @@ -32,3 +32,50 @@ npx @protolabsai/release-tools rewrite-release-notes v0.34.0 v0.33.0 --dry-run Required env: `GATEWAY_API_KEY` (LLM gateway), `DISCORD_RELEASE_WEBHOOK` (with `--post-discord`). See the [CLI reference](../reference/cli.md#rewrite-release-notes) for all flags. + +## Drive the GitHub release body + a changelog from the same notes + +The notes the LLM produces are the same text your GitHub release body and your +changelog want. Expose them once and fan them out — no second hand-written copy +to drift. + +The action sets two step outputs: `notes` (markdown) and `highlights` (a JSON +array of the bullet lines, for structured changelogs). + +```yaml +- id: notes + uses: protoLabsAI/release-tools@v2 + with: + version: ${{ steps.version.outputs.tag }} + previous-version: ${{ steps.version.outputs.prev_tag }} + # Optional: maintain a CHANGELOG.md (md) or a changelog.json (json) in-repo. + changelog-file: CHANGELOG.md + changelog-format: md + env: + GATEWAY_API_KEY: ${{ secrets.GATEWAY_API_KEY }} + DISCORD_RELEASE_WEBHOOK: ${{ secrets.DISCORD_RELEASE_WEBHOOK }} + +# Use the polished notes for the GitHub release body too. +- run: gh release edit "${{ steps.version.outputs.tag }}" --notes "${{ steps.notes.outputs.notes }}" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} +``` + +For a bespoke changelog (e.g. a marketing site's `changelog.json` with extra +fields), consume `steps.notes.outputs.highlights` and build the entry yourself. + +### One generation, reused across jobs + +Generate once and reuse the exact text in a later job (so the changelog entry and +the release body can never disagree): write it with `--out`, commit it, then pass +it back with `--notes-file` instead of re-running the LLM. + +```bash +# job A (e.g. when the release PR is prepared) +npx @protolabsai/release-tools rewrite-release-notes v0.34.0 v0.33.0 \ + --out .release-notes/v0.34.0.md --changelog CHANGELOG.md + +# job B (on the tag) — reuse, no second LLM call +npx @protolabsai/release-tools rewrite-release-notes v0.34.0 \ + --notes-file .release-notes/v0.34.0.md --post-discord +``` diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 75708c5..f8bdc4d 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -20,18 +20,30 @@ via `npx @protolabsai/release-tools `. Auto-detects the two most recent semver tags when positionals are omitted. -| Flag | Default | Description | -| --------------------- | ------------------------------ | ----------------------------------------------- | -| `--post-discord` | off | Post the embed to `DISCORD_RELEASE_WEBHOOK` | -| `--dry-run` | off | Print the prompt and exit; no LLM call, no post | -| `--model ` | `protolabs/fast` | Gateway model alias | -| `--base-url ` | `https://api.proto-labs.ai/v1` | Gateway base URL | -| `--repo ` | from git remote | Repo for the release URL + footer | +| Flag | Default | Description | +| -------------------------- | ------------------------------ | ------------------------------------------------ | +| `--post-discord` | off | Post the embed to `DISCORD_RELEASE_WEBHOOK` | +| `--out ` | — | Write the notes (markdown) to `` | +| `--changelog ` | — | Prepend a dated entry to a changelog file | +| `--changelog-format ` | `md` | `md` section, or `json` array entry | +| `--date ` | today (UTC) | Changelog entry date | +| `--notes-file ` | — | Use notes from `` instead of the LLM | +| `--dry-run` | off | Print the prompt and exit; no LLM call, no post | +| `--model ` | `protolabs/fast` | Gateway model alias | +| `--base-url ` | `https://api.proto-labs.ai/v1` | Gateway base URL | +| `--repo ` | from git remote | Repo for the release URL + footer | Env: `GATEWAY_API_KEY` (required for non-dry-run), `OPENAI_BASE_URL`, `RELEASE_NOTES_MODEL`, `DISCORD_RELEASE_WEBHOOK`, `RELEASE_NOTES_REPO`, `RELEASE_NOTES_FOOTER`. +In GitHub Actions (`$GITHUB_OUTPUT` set), the generated `notes` (markdown) and +`highlights` (JSON array of the bullet lines) are exposed as step outputs — wire +them to the GitHub release body and/or a marketing changelog. `--changelog-format +json` writes `{ version, date, notes, highlights }`; `md` writes a +`## ` section under a leading `# Changelog` title. `--notes-file` +reuses a prior job's notes so one generation can drive every changelog surface. + --- ## build-updater-manifest diff --git a/package.json b/package.json index 712796a..681db50 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@protolabsai/release-tools", - "version": "2.2.0", + "version": "2.3.0", "description": "Release-notes generator + GitHub Action for protoLabs repos. Rewrites raw git commits into themed release notes via the protoLabs LLM gateway and posts a Discord embed.", "type": "module", "license": "Apache-2.0", diff --git a/test/rewrite-release-notes.test.mjs b/test/rewrite-release-notes.test.mjs index 3a155a4..f02adc9 100644 --- a/test/rewrite-release-notes.test.mjs +++ b/test/rewrite-release-notes.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { execFileSync, spawnSync } from 'node:child_process'; -import { mkdtempSync, rmSync } from 'node:fs'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import test from 'node:test'; @@ -63,3 +63,115 @@ test('no version at all still errors clearly', () => { rmSync(dir, { recursive: true, force: true }); } }); + +// ─── Notes reuse + changelog/outputs ─────────────────────────────────────────── +// --notes-file supplies notes directly, so these exercise the output/changelog +// paths with no git range and no gateway key. + +const FIXTURE = + 'Sharpens the first-run flow.\n\n**Setup**\n- remembers you are set up\n- downloads voice models with progress\n'; + +/** A plain (non-git) temp dir — the --notes-file path needs no git range. */ +function tmp() { + return mkdtempSync(join(tmpdir(), 'reltools-')); +} + +test('--notes-file + --out writes the notes to a file, no gateway key', () => { + const dir = tmp(); + try { + writeFileSync(join(dir, 'notes.md'), FIXTURE); + const r = spawnSync( + 'node', + [CLI, 'v1.2.3', '--notes-file', join(dir, 'notes.md'), '--out', join(dir, 'out.md')], + { cwd: dir, encoding: 'utf8', env: { ...process.env, GATEWAY_API_KEY: '' } }, + ); + assert.equal(r.status, 0, r.stderr); + assert.match( + readFileSync(join(dir, 'out.md'), 'utf8'), + /downloads voice models with progress/, + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('--changelog json prepends a structured entry with extracted highlights', () => { + const dir = tmp(); + try { + writeFileSync(join(dir, 'notes.md'), FIXTURE); + const cl = join(dir, 'changelog.json'); + const r = spawnSync( + 'node', + [CLI, 'v2.0.0', '--notes-file', join(dir, 'notes.md'), '--changelog', cl, '--changelog-format', 'json', '--date', '2026-01-02'], + { cwd: dir, encoding: 'utf8' }, + ); + assert.equal(r.status, 0, r.stderr); + const entries = JSON.parse(readFileSync(cl, 'utf8')); + assert.equal(entries[0].version, 'v2.0.0'); + assert.equal(entries[0].date, '2026-01-02'); + assert.deepEqual(entries[0].highlights, [ + 'remembers you are set up', + 'downloads voice models with progress', + ]); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('--changelog json is newest-first and replaces a re-run of the same version', () => { + const dir = tmp(); + try { + writeFileSync(join(dir, 'notes.md'), FIXTURE); + const cl = join(dir, 'changelog.json'); + writeFileSync( + cl, + JSON.stringify([{ version: 'v1.0.0', date: '2025-01-01', highlights: ['old'] }], null, 2), + ); + const run = (v) => + spawnSync('node', [CLI, v, '--notes-file', join(dir, 'notes.md'), '--changelog', cl, '--changelog-format', 'json'], { cwd: dir, encoding: 'utf8' }); + assert.equal(run('v2.0.0').status, 0); + assert.deepEqual(JSON.parse(readFileSync(cl, 'utf8')).map((e) => e.version), ['v2.0.0', 'v1.0.0']); + // Re-running the same version replaces it rather than duplicating. + assert.equal(run('v2.0.0').status, 0); + assert.deepEqual(JSON.parse(readFileSync(cl, 'utf8')).map((e) => e.version), ['v2.0.0', 'v1.0.0']); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('--changelog md prepends a dated section under the # title', () => { + const dir = tmp(); + try { + writeFileSync(join(dir, 'notes.md'), FIXTURE); + const cl = join(dir, 'CHANGELOG.md'); + writeFileSync(cl, '# Changelog\n\n## v1.0.0 — 2025-01-01\n\nOld stuff.\n'); + const r = spawnSync('node', [CLI, 'v2.0.0', '--notes-file', join(dir, 'notes.md'), '--changelog', cl, '--date', '2026-01-02'], { cwd: dir, encoding: 'utf8' }); + assert.equal(r.status, 0, r.stderr); + const md = readFileSync(cl, 'utf8'); + assert.match(md, /^# Changelog/); + assert.ok(md.indexOf('## v2.0.0 — 2026-01-02') < md.indexOf('## v1.0.0')); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test('exposes notes + highlights on $GITHUB_OUTPUT', () => { + const dir = tmp(); + try { + writeFileSync(join(dir, 'notes.md'), FIXTURE); + const ghOut = join(dir, 'gh_output'); + writeFileSync(ghOut, ''); + const r = spawnSync('node', [CLI, 'v3.0.0', '--notes-file', join(dir, 'notes.md')], { + cwd: dir, + encoding: 'utf8', + env: { ...process.env, GITHUB_OUTPUT: ghOut }, + }); + assert.equal(r.status, 0, r.stderr); + const out = readFileSync(ghOut, 'utf8'); + assert.match(out, /notes< Date: Sat, 6 Jun 2026 20:55:35 -0700 Subject: [PATCH 2/2] review: warn instead of silently dropping a malformed JSON changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quinn (QA) flagged that upsertJsonChangelog() recovered from an unparseable changelog by starting an empty array — losing prior entries with no signal. Keep the don't-fail-the-release behavior but console.warn loudly (parse error or non-array) so the data loss shows up in CI output. Adds a test. (The LOW encoding note is already satisfied — every readFileSync passes 'utf8'.) --- bin/rewrite-release-notes.mjs | 18 ++++++++++++++---- test/rewrite-release-notes.test.mjs | 21 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 4 deletions(-) diff --git a/bin/rewrite-release-notes.mjs b/bin/rewrite-release-notes.mjs index 8e58bd5..8ae12fa 100755 --- a/bin/rewrite-release-notes.mjs +++ b/bin/rewrite-release-notes.mjs @@ -326,10 +326,20 @@ function upsertJsonChangelog(existingText, entry) { if (existingText?.trim()) { try { const parsed = JSON.parse(existingText); - if (Array.isArray(parsed)) arr = parsed; - } catch { - // Malformed — start fresh rather than drop the release; the caller commits - // the result, so a bad file surfaces in review instead of silently losing. + if (Array.isArray(parsed)) { + arr = parsed; + } else { + console.warn( + 'WARNING: existing changelog is not a JSON array — starting fresh; prior content is dropped.', + ); + } + } catch (err) { + // Don't drop the release over a bad file, but make the data loss LOUD so + // it shows up in CI output instead of silently wiping history. + console.warn( + `WARNING: could not parse the existing changelog as JSON (${err.message}) — ` + + 'starting fresh; prior entries are dropped.', + ); } } const rest = arr.filter((e) => e?.version !== entry.version); diff --git a/test/rewrite-release-notes.test.mjs b/test/rewrite-release-notes.test.mjs index f02adc9..1e1eb48 100644 --- a/test/rewrite-release-notes.test.mjs +++ b/test/rewrite-release-notes.test.mjs @@ -175,3 +175,24 @@ test('exposes notes + highlights on $GITHUB_OUTPUT', () => { rmSync(dir, { recursive: true, force: true }); } }); + +test('--changelog json warns loudly (not silently) when the existing file is malformed', () => { + const dir = tmp(); + try { + writeFileSync(join(dir, 'notes.md'), FIXTURE); + const cl = join(dir, 'changelog.json'); + writeFileSync(cl, '{ not valid json ['); // corrupt + const r = spawnSync( + 'node', + [CLI, 'v9.9.9', '--notes-file', join(dir, 'notes.md'), '--changelog', cl, '--changelog-format', 'json'], + { cwd: dir, encoding: 'utf8' }, + ); + assert.equal(r.status, 0, r.stderr); + // The data loss is surfaced, not silent. + assert.match(`${r.stdout}${r.stderr}`, /could not parse the existing changelog as JSON/i); + // The release still lands rather than failing the job. + assert.equal(JSON.parse(readFileSync(cl, 'utf8'))[0].version, 'v9.9.9'); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +});