Skip to content

Commit 64fc75c

Browse files
alichherawallaUserclaude
authored
chore(ci): generate consumer-facing release notes automatically (#17)
* chore(ci): generate consumer-facing release notes automatically Mirror mobile's CI release-notes flow on desktop, and add a human-readable summary on top. - scripts/release-notes.mjs: deterministic (no network/API key) generator that turns conventional-commit subjects into a "## Highlights" summary (feat -> New, fix/perf -> Fixes), with prefixes stripped, PR refs removed, em dashes/curly quotes normalized to brand voice, de-duped. Internal commits (chore/ci/docs/ test/refactor) stay out of the summary. Honest fallback for internal-only builds. The raw "## What's Changed" commit list and Full Changelog link follow. Reads prevTag..HEAD by default, optional end-ref so the same logic does CI and backfill. - release.yml: the Generate-release-notes step calls the script; electron-builder creates the release with empty notes, so an Attach-release-notes step sets them via `gh release edit --notes-file` after publish. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * chore(ci): address review - execFileSync for git, fix first-release changelog link - Use execFileSync('git', [...]) instead of execSync with a shell command, so the range/refs are passed as args, never interpreted as command text. - First release (no prior tag) links to the release tag instead of a v0.0.0 compare URL that would 404. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: User <user@Users-MacBook-Pro-174.local> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 643b8db commit 64fc75c

2 files changed

Lines changed: 131 additions & 0 deletions

File tree

.github/workflows/release.yml

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,20 @@ jobs:
9494
run: npm ci
9595
- name: Build (typecheck + bundle)
9696
run: npm run build
97+
# Same approach as mobile (.github/workflows/release.yml): collect the commits
98+
# since the previous release tag into release-notes.md. Generated BEFORE publish
99+
# so the tag electron-builder is about to create (v$VERSION) is not yet the
100+
# "last tag" — git describe resolves to the PREVIOUS release. We drop the
101+
# bot version-bump commit ([skip ci]) so notes are only real changes.
102+
- name: Generate release notes
103+
run: |
104+
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
105+
node scripts/release-notes.mjs \
106+
"$LAST_TAG" \
107+
"${{ needs.version.outputs.version }}" \
108+
"${{ github.repository }}" \
109+
> release-notes.md
110+
cat release-notes.md
97111
- name: Package, sign, notarize & publish (DMG + auto-update metadata)
98112
env:
99113
CSC_LINK: ${{ secrets.CSC_LINK }}
@@ -126,3 +140,11 @@ jobs:
126140
# they follow latest-mac.yml. One-time bridge so no Pro user is orphaned.
127141
cp dist/latest-mac.yml dist/pro-mac.yml
128142
gh release upload "v$VERSION" dist/pro-mac.yml --clobber
143+
# electron-builder creates the GitHub release with empty notes; attach the
144+
# notes generated above (mobile sets them at create time via --notes-file; the
145+
# desktop release is created by electron-builder, so we set them after).
146+
- name: Attach release notes
147+
env:
148+
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
149+
VERSION: ${{ needs.version.outputs.version }}
150+
run: gh release edit "v$VERSION" --notes-file release-notes.md

scripts/release-notes.mjs

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
#!/usr/bin/env node
2+
// Build consumer-facing release notes from conventional-commit subjects.
3+
//
4+
// Usage:
5+
// node scripts/release-notes.mjs <prevTag> <version> <repo> [toRef]
6+
// - prevTag: previous release tag, or "" for the first release
7+
// - version: the version being released, WITHOUT the leading "v" (e.g. 0.0.33)
8+
// - repo: owner/name (for the Full Changelog compare link)
9+
// - toRef: end of the range (default HEAD). CI omits it; backfill passes the
10+
// tag being documented (e.g. v0.0.25) so it reads prevTag..v0.0.25.
11+
//
12+
// Reads `git log <prevTag>..HEAD` itself, so it is the single source of truth for
13+
// both the CI step and the backfill. Output goes to stdout (the workflow redirects
14+
// it to release-notes.md). No network, no API key, deterministic.
15+
//
16+
// Shape:
17+
// ## Highlights <- the human-readable summary (grouped, plain language)
18+
// **New** / **Fixes**
19+
// ## What's Changed <- the full commit list (same as before)
20+
// **Full Changelog**: …
21+
22+
import { execFileSync } from 'node:child_process'
23+
24+
const [prevTag = '', version = '0.0.0', repo = '', toRef = 'HEAD'] = process.argv.slice(2)
25+
26+
// Commit types that mean something to a person using the app. Everything else
27+
// (chore, ci, docs, test, refactor, style, build, wip, snapshot) is internal and
28+
// stays out of the human summary - it still shows in the raw commit list below.
29+
const TYPE_GROUP = { feat: 'New', fix: 'Fixes', perf: 'Fixes' }
30+
31+
// Pass args to git directly (no shell) so the range/refs are never interpreted as
32+
// command text. %s subject, %h short hash, tab-separated. Skip the bot version bump.
33+
const range = prevTag ? `${prevTag}..${toRef}` : toRef
34+
const raw = execFileSync(
35+
'git',
36+
['log', range, '--no-merges', '--invert-grep', '--grep=\\[skip ci\\]', '--pretty=format:%s\t%h'],
37+
{ encoding: 'utf8' },
38+
).trim()
39+
40+
const commits = raw ? raw.split('\n').map((l) => {
41+
const [subject, hash] = l.split('\t')
42+
return { subject, hash }
43+
}) : []
44+
45+
// Turn a conventional-commit subject into a plain sentence a user can read.
46+
function humanize(subject) {
47+
const m = subject.match(/^(\w+)(?:\(([^)]+)\))?!?:\s*(.+)$/)
48+
let text = m ? m[3] : subject
49+
text = text
50+
.replace(/\s*\(#\d+\)\s*$/, '') // drop trailing PR refs: "(#11)"
51+
.replace(/[]/g, ' - ') // em/en dash -> " - " (brand voice)
52+
.replace(/[]/g, "'") // curly -> straight
53+
.replace(/[]/g, '"')
54+
.replace(/\s+/g, ' ')
55+
.trim()
56+
return text.charAt(0).toUpperCase() + text.slice(1)
57+
}
58+
59+
function typeOf(subject) {
60+
const m = subject.match(/^(\w+)(?:\([^)]+\))?!?:/)
61+
return m ? m[1] : null
62+
}
63+
64+
// Group the user-facing commits.
65+
const groups = { New: [], Fixes: [] }
66+
for (const c of commits) {
67+
const group = TYPE_GROUP[typeOf(c.subject)]
68+
if (group) groups[group].push(humanize(c.subject))
69+
}
70+
71+
// De-dupe within a group (snapshots/rebases can repeat a subject).
72+
for (const k of Object.keys(groups)) groups[k] = [...new Set(groups[k])]
73+
74+
const out = []
75+
out.push('## Highlights', '')
76+
77+
const hasHighlights = groups.New.length || groups.Fixes.length
78+
if (hasHighlights) {
79+
if (groups.New.length) {
80+
out.push('**New**', '')
81+
for (const line of groups.New) out.push(`- ${line}`)
82+
out.push('')
83+
}
84+
if (groups.Fixes.length) {
85+
out.push('**Fixes**', '')
86+
for (const line of groups.Fixes) out.push(`- ${line}`)
87+
out.push('')
88+
}
89+
} else {
90+
// Only internal commits (chore/ci/docs/test). Be honest about it.
91+
out.push('Maintenance release. No user-facing changes this build.', '')
92+
}
93+
94+
out.push('## What\'s Changed', '')
95+
if (commits.length) {
96+
for (const c of commits) out.push(`- ${c.subject} (${c.hash})`)
97+
} else {
98+
out.push('- No changes since the previous release.')
99+
}
100+
out.push('')
101+
// On the first release there's no prior tag to compare against (a v0.0.0 compare
102+
// link would 404), so point at the release tag itself.
103+
if (prevTag) {
104+
out.push(`**Full Changelog**: https://github.com/${repo}/compare/${prevTag}...v${version}`)
105+
} else {
106+
out.push(`**Full Changelog**: https://github.com/${repo}/releases/tag/v${version}`)
107+
}
108+
109+
process.stdout.write(out.join('\n') + '\n')

0 commit comments

Comments
 (0)