Skip to content

Commit 91378fe

Browse files
mcowgeratsigmaclaudemcowger
authored
feat: AI-generated release notes after each CalVer release (#353)
## Summary - Adds a `generate-release-notes` job to the release pipeline that runs after the GitHub release is published - Uses the same `mcowger/pi-coding-agent-action` as the pi-assistant, with a dedicated prompt tuned for release notes writing - Collects PRs and commits using **tag-push timestamps** (not git ancestry) to correctly handle CalVer releases from separate branches ## How it works ### CalVer-aware data collection (`.github/workflows/release-notes.yml`) 1. Resolves the **current tag's date**: for annotated tags, uses `tagger.date`; for the lightweight tags this project uses (created via GitHub API `POST /git/refs`), uses the tagged commit's `committer.date` — the closest proxy the API exposes for "when was this pushed" 2. Finds the **previous release** by querying `listReleases` (not `listTags`) and sorting by CalVer parts descending — this ensures stray tags and pre-releases are never mistaken for the prior version boundary 3. Queries PRs filtered by `merged_at` strictly between the two tag dates 4. Queries commits on `main` filtered by `committer.date` in the same window 5. Writes everything to `release-data.json` in the workspace (avoids env-var size limits) ### Agent prompt (`.github/prompts/release-notes.md`) - Instructs the agent to read `release-data.json`, classify changes, and write a structured Markdown body - Sections: Overview, ✨ New Features, 🐛 Bug Fixes, 🔧 Improvements — sections with no qualifying items are omitted - Explicitly excludes test, CI/CD, internal tooling, and dependency-bump changes - After writing the notes, the agent calls the GitHub API to update the release body for the current tag ### Pipeline integration (`.github/workflows/release.yml`) ``` setup → quality-gates → build-binaries + build-docker → release → generate-release-notes ``` The `generate-release-notes` job runs after `release` (so the release object already exists to be updated) with `secrets: inherit` so LLM credentials pass through. ## Test plan - [ ] Trigger a manual release via `workflow_dispatch` and verify the `generate-release-notes` job runs after `Create Release` - [ ] Confirm `release-data.json` is populated with the correct PRs and commits (check job logs) - [ ] Verify the GitHub release body is updated with formatted notes - [ ] Verify that CI/test-only PRs are excluded from the notes - [ ] Verify that if no user-facing changes exist, a maintenance note is written instead https://claude.ai/code/session_019kX25Wbs6mxMGyB1kaZxoM --- _Generated by [Claude Code](https://claude.ai/code/session_019kX25Wbs6mxMGyB1kaZxoM)_ --------- Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: Matt Cowger <matt@cowger.us>
1 parent cafc48d commit 91378fe

4 files changed

Lines changed: 279 additions & 1 deletion

File tree

.github/prompts/release-notes.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
You are the Plexus release notes writer. This is an automated pipeline step — there is no user comment to respond to. Execute the task below completely, then stop.
2+
3+
**Do not perform any research — do not call any external tools or search for any information. Use only the provided release data file to complete this task.**
4+
5+
## YOUR TASK
6+
7+
Generate polished, user-friendly release notes for **{{env.RELEASE_TAG}}** and write them to the file `release-notes.md` in the repository root.
8+
9+
## STEP 1: Read the release data
10+
11+
Read the file `release-data.json` from the repository root. It is a JSON object with:
12+
13+
- `currentTag` — the version being released (e.g. `2026.05.06.1`)
14+
- `previousTag` — the previous release version, or `null` if this is the first release
15+
- `currentDate` / `previousDate` — ISO timestamps bounding this release window
16+
- `pullRequests` — array of PRs merged in this window, each with:
17+
- `number`, `title`, `author`, `labels`, `merged_at`, `body`
18+
- `commits` — array of commits pushed to `main` in this window, each with:
19+
- `sha`, `message`, `author`, `date`
20+
21+
## STEP 2: Write the release notes
22+
23+
Use the PR titles, bodies, and labels to determine the nature of each change. Use commit messages only to fill gaps where a commit has no associated PR.
24+
25+
**Format:**
26+
27+
```markdown
28+
## Overview
29+
30+
<2–3 sentence summary highlighting the most significant or interesting changes>
31+
32+
### ✨ New Features
33+
34+
- **Short feature name**: What it does and why it matters. ([#NNN](https://github.com/mcowger/plexus/pull/NNN))
35+
36+
### 🐛 Bug Fixes
37+
38+
- What was broken and how it was fixed. ([#NNN](https://github.com/mcowger/plexus/pull/NNN))
39+
40+
### 🔧 Improvements
41+
42+
- What was improved and the benefit. ([#NNN](https://github.com/mcowger/plexus/pull/NNN))
43+
```
44+
45+
**Rules:**
46+
47+
- Include only **user-facing changes**. Skip anything that is purely:
48+
- Test changes (`test/`, `*.test.ts`, `*.spec.ts`, labels like `test`, `testing`)
49+
- CI/CD changes (`.github/`, workflow files, labels like `ci`, `infrastructure`)
50+
- Internal tooling (`scripts/`, build tooling, labels like `tooling`, `chore`)
51+
- Dependency bumps with no visible effect on users
52+
- Documentation-only changes
53+
- If a section has no qualifying items, **omit that section entirely**
54+
- If all changes in the release are internal/infrastructure, write a brief maintenance-only note instead of the full format
55+
- Group closely related PRs into a single bullet when they address the same feature or fix
56+
- Use friendly, non-technical language — avoid internal code names or abbreviations
57+
- Link each item to its PR: `([#NNN](https://github.com/mcowger/plexus/pull/NNN))`
58+
- Do not add a title line like `# 2026.05.06.1` — the tag name is already the release title on GitHub
59+
60+
## STEP 3: Write the output file
61+
62+
Write the completed Markdown to `release-notes.md` in the repository root. Do **not** create any other files, open any PRs, post any comments, or call any GitHub API. Just write the file and finish.
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
name: Generate Release Notes
2+
on:
3+
workflow_call:
4+
inputs:
5+
tag:
6+
required: true
7+
type: string
8+
description: "The CalVer release tag to generate notes for"
9+
outputs:
10+
notes:
11+
description: "Generated release notes body (Markdown)"
12+
value: ${{ jobs.generate-notes.outputs.notes }}
13+
14+
jobs:
15+
generate-notes:
16+
name: Generate Release Notes
17+
runs-on: ubuntu-latest
18+
permissions:
19+
contents: read
20+
pull-requests: read
21+
outputs:
22+
notes: ${{ steps.read_notes.outputs.notes }}
23+
24+
steps:
25+
- name: Checkout repository
26+
uses: actions/checkout@v6
27+
with:
28+
fetch-depth: 0
29+
30+
- name: Gather release data
31+
id: gather
32+
uses: actions/github-script@v9
33+
with:
34+
script: |
35+
const currentTag = '${{ inputs.tag }}';
36+
const calVerRegex = /^\d{4}\.\d{2}\.\d{2}\.\d+$/;
37+
const fs = require('fs');
38+
39+
// Returns the timestamp that best represents when this tag was pushed.
40+
// For annotated tags: the tagger.date embedded in the tag object.
41+
// For lightweight tags (used by this project): the committer.date of the
42+
// commit the tag points to, which is the closest proxy available via the
43+
// GitHub API for when the tag was pushed.
44+
async function getTagDate(tag) {
45+
const ref = await github.rest.git.getRef({
46+
owner: context.repo.owner,
47+
repo: context.repo.repo,
48+
ref: `tags/${tag}`,
49+
});
50+
51+
if (ref.data.object.type === 'tag') {
52+
const tagObj = await github.rest.git.getTag({
53+
owner: context.repo.owner,
54+
repo: context.repo.repo,
55+
tag_sha: ref.data.object.sha,
56+
});
57+
return new Date(tagObj.data.tagger.date);
58+
}
59+
60+
const commit = await github.rest.git.getCommit({
61+
owner: context.repo.owner,
62+
repo: context.repo.repo,
63+
commit_sha: ref.data.object.sha,
64+
});
65+
return new Date(commit.data.committer.date);
66+
}
67+
68+
const currentDate = await getTagDate(currentTag);
69+
core.info(`Current tag ${currentTag} date: ${currentDate.toISOString()}`);
70+
71+
// Use listReleases (not listTags) so we only compare against actual published
72+
// releases, not stray tags or pre-releases that were never shipped.
73+
const releases = await github.paginate(github.rest.repos.listReleases, {
74+
owner: context.repo.owner,
75+
repo: context.repo.repo,
76+
per_page: 100,
77+
});
78+
79+
// Find the most-recent CalVer release that comes before the current one.
80+
// Sort descending by CalVer parts so [0] is the newest prior release.
81+
const prevRelease = releases
82+
.filter(r => !r.prerelease && calVerRegex.test(r.tag_name) && r.tag_name !== currentTag)
83+
.sort((a, b) => {
84+
const pa = a.tag_name.split('.').map(Number);
85+
const pb = b.tag_name.split('.').map(Number);
86+
for (let i = 0; i < 4; i++) {
87+
if (pa[i] !== pb[i]) return pb[i] - pa[i];
88+
}
89+
return 0;
90+
})[0];
91+
92+
let prevDate = null;
93+
let prevTag = null;
94+
95+
if (prevRelease) {
96+
prevTag = prevRelease.tag_name;
97+
prevDate = await getTagDate(prevTag);
98+
core.info(`Previous release ${prevTag} date: ${prevDate.toISOString()}`);
99+
} else {
100+
core.info('No previous CalVer release found — this appears to be the first release');
101+
}
102+
103+
// Collect PRs whose merged_at falls strictly after prevDate and on/before currentDate.
104+
const allPRs = await github.paginate(github.rest.pulls.list, {
105+
owner: context.repo.owner,
106+
repo: context.repo.repo,
107+
state: 'closed',
108+
per_page: 100,
109+
});
110+
111+
const relevantPRs = allPRs
112+
.filter(pr => {
113+
if (!pr.merged_at) return false;
114+
const mergedAt = new Date(pr.merged_at);
115+
if (prevDate && mergedAt <= prevDate) return false;
116+
return mergedAt <= currentDate;
117+
})
118+
.map(pr => ({
119+
number: pr.number,
120+
title: pr.title,
121+
author: pr.user?.login ?? 'unknown',
122+
labels: pr.labels.map(l => l.name),
123+
merged_at: pr.merged_at,
124+
body: (pr.body ?? '').substring(0, 1000),
125+
}));
126+
127+
core.info(`Found ${relevantPRs.length} PRs in this release window`);
128+
129+
// Collect commits pushed to main between the two tag dates.
130+
const allCommits = await github.paginate(github.rest.repos.listCommits, {
131+
owner: context.repo.owner,
132+
repo: context.repo.repo,
133+
sha: 'main',
134+
since: prevDate ? prevDate.toISOString() : undefined,
135+
until: currentDate.toISOString(),
136+
per_page: 100,
137+
});
138+
139+
const relevantCommits = allCommits.map(c => ({
140+
sha: c.sha.substring(0, 7),
141+
message: c.commit.message.split('\n')[0],
142+
author: c.commit.author?.name ?? c.author?.login ?? 'unknown',
143+
date: c.commit.committer?.date ?? null,
144+
}));
145+
146+
core.info(`Found ${relevantCommits.length} commits in this release window`);
147+
148+
const releaseData = {
149+
currentTag,
150+
previousTag: prevTag,
151+
currentDate: currentDate.toISOString(),
152+
previousDate: prevDate ? prevDate.toISOString() : null,
153+
pullRequests: relevantPRs,
154+
commits: relevantCommits,
155+
};
156+
157+
fs.writeFileSync('release-data.json', JSON.stringify(releaseData, null, 2));
158+
core.info('Wrote release-data.json');
159+
160+
- name: Run release notes agent
161+
id: release_notes_agent
162+
uses: mcowger/pi-coding-agent-action@main
163+
with:
164+
github_token: ${{ secrets.GITHUB_TOKEN }}
165+
provider: openrouter
166+
model: ${{ vars.LLM_MODEL_ID }}
167+
token: ${{ secrets.LLM_API_KEY }}
168+
base_url: ${{ secrets.LLM_API_HOST }}
169+
trigger: generate-release-notes
170+
load_builtin_extensions: true
171+
suppress_final_comment: true
172+
export_session_html: true
173+
prompt_file: .github/prompts/release-notes.md
174+
env:
175+
RELEASE_TAG: ${{ inputs.tag }}
176+
177+
- name: Read agent-generated notes
178+
id: read_notes
179+
if: always()
180+
run: |
181+
if [ -f release-notes.md ]; then
182+
{
183+
echo 'notes<<RELEASE_NOTES_EOF'
184+
cat release-notes.md
185+
echo 'RELEASE_NOTES_EOF'
186+
} >> "$GITHUB_OUTPUT"
187+
else
188+
echo '::warning::Agent did not write release-notes.md — release will have no body'
189+
echo 'notes=' >> "$GITHUB_OUTPUT"
190+
fi
191+
192+
- name: Upload agent session
193+
if: always()
194+
uses: actions/upload-artifact@v7
195+
with:
196+
name: release-notes-session
197+
path: ${{ steps.release_notes_agent.outputs.session_html_path }}
198+
retention-days: 7
199+
if-no-files-found: ignore
200+
archive: false

.github/workflows/release-publish.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ on:
66
required: true
77
type: string
88
description: "The release tag"
9+
release_notes:
10+
required: false
11+
type: string
12+
description: "Release notes body (Markdown)"
13+
default: ""
914

1015
jobs:
1116
release:
@@ -30,6 +35,7 @@ jobs:
3035
uses: softprops/action-gh-release@v3
3136
with:
3237
tag_name: ${{ inputs.tag }}
38+
body: ${{ inputs.release_notes }}
3339
files: |
3440
release/plexus-linux
3541
release/plexus-macos

.github/workflows/release.yml

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ jobs:
2424
needs: setup
2525
uses: ./.github/workflows/release-quality-gates.yml
2626

27+
generate-release-notes:
28+
name: Generate Release Notes
29+
needs: setup
30+
uses: ./.github/workflows/release-notes.yml
31+
with:
32+
tag: ${{ needs.setup.outputs.tag }}
33+
secrets: inherit
34+
continue-on-error: true
35+
2736
build-binaries:
2837
name: Build Binaries
2938
needs: [setup, quality-gates]
@@ -44,9 +53,10 @@ jobs:
4453

4554
release:
4655
name: Create Release
47-
needs: [setup, build-binaries, build-docker]
56+
needs: [setup, build-binaries, build-docker, generate-release-notes]
4857
uses: ./.github/workflows/release-publish.yml
4958
with:
5059
tag: ${{ needs.setup.outputs.tag }}
60+
release_notes: ${{ needs.generate-release-notes.outputs.notes }}
5161
permissions:
5262
contents: write

0 commit comments

Comments
 (0)