Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,28 @@ inputs:
description: 'Override the Discord embed footer text. Default: "protoLabs · <repo-name>".'
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:
Expand All @@ -53,6 +75,7 @@ runs:
node-version: '20'

- name: Run release-notes generator
id: generate
shell: bash
env:
RELEASE_NOTES_MODEL: ${{ inputs.model }}
Expand All @@ -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[@]}"
210 changes: 182 additions & 28 deletions bin/rewrite-release-notes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file> Write the notes (markdown) to <file>.
* --changelog <file> Prepend a dated entry to a changelog file.
* --changelog-format <fmt> md (default) | json. md prepends a
* "## <version> — <date>" section; json prepends
* { version, date, notes, highlights } to a JSON array.
* --date <YYYY-MM-DD> Changelog entry date. Default: today (UTC).
* --notes-file <file> Use notes from <file> 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.
Expand All @@ -37,6 +49,12 @@
*/

import { execSync } from 'node:child_process';
import {
appendFileSync,
existsSync,
readFileSync,
writeFileSync,
} from 'node:fs';

// ─── Help ────────────────────────────────────────────────────────────────────

Expand Down Expand Up @@ -267,27 +285,177 @@ 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;
} 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);
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.
Expand All @@ -305,14 +473,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);
Expand Down Expand Up @@ -350,16 +510,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));
47 changes: 47 additions & 0 deletions docs/how-to/generate-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
26 changes: 19 additions & 7 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,30 @@ via `npx @protolabsai/release-tools <command>`.

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 <alias>` | `protolabs/fast` | Gateway model alias |
| `--base-url <url>` | `https://api.proto-labs.ai/v1` | Gateway base URL |
| `--repo <owner/name>` | from git remote | Repo for the release URL + footer |
| Flag | Default | Description |
| -------------------------- | ------------------------------ | ------------------------------------------------ |
| `--post-discord` | off | Post the embed to `DISCORD_RELEASE_WEBHOOK` |
| `--out <file>` | — | Write the notes (markdown) to `<file>` |
| `--changelog <file>` | — | Prepend a dated entry to a changelog file |
| `--changelog-format <fmt>` | `md` | `md` section, or `json` array entry |
| `--date <YYYY-MM-DD>` | today (UTC) | Changelog entry date |
| `--notes-file <file>` | — | Use notes from `<file>` instead of the LLM |
| `--dry-run` | off | Print the prompt and exit; no LLM call, no post |
| `--model <alias>` | `protolabs/fast` | Gateway model alias |
| `--base-url <url>` | `https://api.proto-labs.ai/v1` | Gateway base URL |
| `--repo <owner/name>` | 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
`## <version> — <date>` 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
Expand Down
Loading
Loading