Skip to content

Commit bf3d857

Browse files
dogmarclaude
andcommitted
ci: write stable release notes by hand on the promotion PR
Assembling a stable release's notes from per-PR notes asked each PR to judge whether it would matter against the last stable release, and still split a feature built over several PRs into several lines. Prerelease testers, on the other hand, want exactly that per-PR detail. So the two now differ: - Alpha and beta releases are generated: one line per feat, fix and perf PR since the last prerelease, from the PR's note or else its title. Notes are optional on PRs into alpha and beta; "None" leaves a PR out. - A promotion into main publishes the promotion PR's "## Release notes" as written, against the last stable release. The PR check blocks promoting until they're there, and the preview comment shows them as published, with every change since the last stable release listed to write from and a warning when the release is a major. - A fix straight into main is still generated from its own note, which PRs into main still need, as do breaking changes anywhere. AGENTS.md (imported by CLAUDE.md) tells agents how to write titles, notes and promotion notes, and how to tell whether something shipped in the last stable release. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 5d06f17 commit bf3d857

7 files changed

Lines changed: 500 additions & 241 deletions

File tree

‎.github/pull_request_template.md‎

Lines changed: 11 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -24,16 +24,15 @@
2424
## Release note
2525

2626
<!--
27-
One or two sentences for people using the package: what's new or fixed, and
28-
how to use it. Its first paragraph becomes this PR's line in the release
29-
notes, as written, so leave how it works to the rest of the description.
30-
31-
Into alpha or beta: optional. Leave it empty for a change that only matters
32-
while a feature is being built; it's left out of the release notes.
33-
Into main: required for feat, fix and perf PRs ("None" if users won't
34-
notice). Breaking changes: always required; say what to change.
35-
On a promotion PR: the summary at the top of the release.
36-
37-
Example: Add `squircle-pill-ease-*` to stretch a pill's easing along its
38-
straight edges.
27+
This PR's line in the alpha and beta release notes, which are generated
28+
from the PRs since the last one. Without a note, the PR's title is used.
29+
One or two sentences for someone trying the prerelease: what changed and
30+
what to use. Write "None" to leave the PR out.
31+
32+
Optional on PRs into alpha and beta. Required on feat, fix and perf PRs
33+
into main, which are released straight away ("None" will do), and on
34+
breaking changes anywhere (say what to change).
35+
36+
A promotion into main instead carries the stable release's notes, written
37+
by hand under "## Release notes". See AGENTS.md.
3938
-->

‎AGENTS.md‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
# Notes for agents
2+
3+
## Pull requests
4+
5+
PRs are squash-merged, so the title becomes the commit subject and the description becomes its body. Both feed the release.
6+
7+
### Title
8+
9+
[Conventional Commits](https://www.conventionalcommits.org), checked in CI: `type(scope): description`, with `feat`, `fix`, `perf`, `docs`, `chore`, `test`, `ci`, `style` or `revert` (`refactor` and `build` are rejected). The type sets the next version: `feat` is a minor release, `fix` and `perf` a patch, and `!` after the type a major. Mark a change `!` only if it breaks something in the last stable release.
10+
11+
A `feat`, `fix` or `perf` title is also the PR's line in the alpha and beta release notes when it has no release note, so write it to read well there.
12+
13+
### Description
14+
15+
Keep it short: what changed and why, then a test plan. Detail for reviewers goes here, not in the release notes.
16+
17+
Don't start a line with `BREAKING CHANGE:` unless the PR is one. The description is the squashed commit's body, and that line alone makes the next release a major.
18+
19+
## Release notes
20+
21+
Both kinds are read from PR descriptions when the release is made (`release/notes.mjs`), so they can still be fixed after merging.
22+
23+
### Alpha and beta releases: generated, one line per PR
24+
25+
Each `feat`, `fix` and `perf` PR since the previous alpha or beta gets a line, breaking changes first. The line is the first paragraph of the PR's `## Release note`, or its title without one. Write the note for someone trying the prerelease: what changed and what to use, in one or two sentences (at most 300 characters, no list).
26+
27+
- **Into alpha or beta:** a note is optional. Write one when the title alone wouldn't tell a tester what changed. `None` leaves the PR out, for changes nobody using the package would notice, like the demos and site.
28+
- **Into main** (released straight away): `feat`, `fix` and `perf` PRs need a note; `None` will do.
29+
- **Breaking changes**, into any branch: a real note, saying what changed and what to do about it.
30+
31+
### Stable releases: written by hand on the promotion PR
32+
33+
A promotion into main (from alpha or beta) carries the stable release's notes under `## Release notes`, published as written. Promoting is blocked until they're there.
34+
35+
**Write them against the last stable release, not the last alpha or beta.** Someone upgrading from it never saw the prereleases in between.
36+
37+
```sh
38+
gh release list --exclude-pre-releases --limit 1
39+
git describe --tags --abbrev=0 --exclude='*-*' origin/main
40+
```
41+
42+
- Cover what's new or different since that release: one entry per change a user would notice, however many PRs it took.
43+
- Leave out what only mattered while building it: fixes to bugs no stable release had, and renames or tweaks to options that never shipped. Leave out changes to the demos and site too. To tell whether something shipped, look at the tag: `git grep <name> <tag>`, `git show <tag>:<path>`.
44+
- Open with a sentence or two on what the release is about, then `### Breaking changes` (with what to do), `### Features` and `### Fixes` as needed, as short bullets. Name the utility, option or export to use; leave how it works to the docs.
45+
- The section ends at the next `##` heading or a `---` line, so put anything else, like a footer, after one.
46+
- The PR's "Release notes preview" comment shows the version, warns when it's a major, lists every change since the last stable release to write from, and shows the notes exactly as they'll be published.
47+
48+
```md
49+
## Release notes
50+
51+
Pills now animate between sizes, and work in Panda as well as Tailwind.
52+
53+
### Fixes
54+
55+
- Borders no longer clip on pills under 24px tall.
56+
57+
---
58+
```
59+
60+
## Branches and merging
61+
62+
- Branch names need a folder (`claude/…`, `fix/…`); the repo rejects branches without one.
63+
- Feature work goes into `alpha` and is squash-merged.
64+
- Promotions (alpha → beta → main) and syncs back down are merged by adding the `promote` label, never the merge button (a required check keeps it shut). After each release, the release workflow merges main into beta and alpha by itself.

‎CLAUDE.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@AGENTS.md

‎release/notes.mjs‎

Lines changed: 62 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -2,34 +2,31 @@
22
* semantic-release plugin that writes the release notes, in place of
33
* @semantic-release/release-notes-generator.
44
*
5-
* A feature, fix or performance change gets one line when its PR has a
6-
* release note (the paragraph under `## Release note`, see release-note.mjs),
7-
* read from the PR as it is now, so a note can still be added or edited after
8-
* merging; failing that, from its squashed commit. A PR without one is left
9-
* out: on alpha and beta that's a change that only mattered while a feature
10-
* was being built. Breaking changes come first, whatever their type, and are
11-
* never left out; without a note, their title stands in. A promotion PR's own
12-
* release note becomes the summary at the top of the release it makes.
13-
* Everything else (chores, docs, CI, the bot's merges) is left out too.
5+
* A stable release made by promoting alpha or beta into main publishes the
6+
* promotion PR's `## Release notes` as written. Someone wrote them by hand,
7+
* against the last stable release (see AGENTS.md), so a feature built over
8+
* several PRs reads as one change, and what only mattered while building it
9+
* is left out.
10+
*
11+
* Every other release (alpha and beta, or a fix straight into main) is
12+
* generated: one line per feature, fix and performance change since the last
13+
* release on that branch, breaking changes first. A line is the PR's
14+
* `## Release note`, read from the PR as it is now so it can be fixed after
15+
* merging; failing that, the note in its squashed commit; failing that, its
16+
* title. "None" leaves a PR out, and so do chores, docs, CI and the bot's
17+
* merges.
1418
*/
1519
import {
1620
SECTIONS,
1721
extractReleaseNote,
22+
extractReleaseNotes,
1823
isNone,
1924
normalizeNote,
2025
parseCommit,
2126
} from "./release-note.mjs";
2227

23-
/** The bot's merge commit for a promotion PR (.github/scripts/promote.sh). */
24-
const PROMOTION = /^Merge (?:alpha|beta) into (?:beta|main) \(#(\d+)\)$/;
25-
26-
/** "#40", "#38 and #40", "#1, #2 and abc1234": PR numbers and short hashes as prose. */
27-
export function refList(refs) {
28-
const names = refs.map((ref) => (typeof ref === "number" ? `#${ref}` : ref));
29-
return names.length > 1
30-
? `${names.slice(0, -1).join(", ")} and ${names.at(-1)}`
31-
: (names[0] ?? "");
32-
}
28+
/** The bot's merge commit for a promotion into main (.github/scripts/promote.sh). */
29+
const STABLE_PROMOTION = /^Merge (?:alpha|beta) into main \(#(\d+)\)$/;
3330

3431
/** `owner/repo` on GitHub, from Actions' environment or the repository URL. */
3532
export function repoSlug(repositoryUrl, env = process.env) {
@@ -58,9 +55,7 @@ export function prBodyFetcher({ slug, token, logger = console }) {
5855
})
5956
.then((pr) => pr.body ?? "")
6057
.catch((error) => {
61-
logger.warn(
62-
`Couldn't read PR #${number}, so its note comes from its commit: ${error.message}`,
63-
);
58+
logger.warn(`Couldn't read PR #${number}: ${error.message}`);
6459
return null;
6560
}),
6661
);
@@ -69,73 +64,80 @@ export function prBodyFetcher({ slug, token, logger = console }) {
6964
};
7065
}
7166

67+
/** The title line: the version, linked to the comparison with the last release. */
68+
export function releaseTitle({ version, previousTag, tag, date, slug }) {
69+
return slug && previousTag
70+
? `## [${version}](https://github.com/${slug}/compare/${previousTag}...${tag}) (${date})`
71+
: `## ${version} (${date})`;
72+
}
73+
7274
/**
73-
* The release notes for `commits` (`{ message, hash }`, as git log lists them).
74-
* Returns the Markdown; `leftOut`, the features, fixes and performance changes
75-
* with no release note; and `untitled`, the breaking changes whose title
76-
* stood in for a note. Each is a PR number, or a short hash without a PR.
75+
* Generated notes for `commits` (`{ message, hash }`, as git log lists them):
76+
* the `###` sections, joined, or "" when no commit makes a line.
7777
*/
78-
export async function renderNotes({ version, previousTag, tag, date, slug, commits, fetchPrBody }) {
78+
export async function generateSections({ commits, fetchPrBody, slug }) {
7979
const repoUrl = slug ? `https://github.com/${slug}` : null;
80-
const summary = [];
8180
const breaking = [];
8281
const sections = new Map(SECTIONS.map(([type]) => [type, []]));
83-
const leftOut = [];
84-
const untitled = [];
8582

8683
for (const { message, hash } of commits) {
87-
const promotion = PROMOTION.exec(message.split("\n")[0]);
88-
if (promotion) {
89-
const note = extractReleaseNote(await fetchPrBody(Number(promotion[1])));
90-
if (note && !isNone(note)) summary.push(normalizeNote(note));
91-
continue;
92-
}
93-
9484
const commit = parseCommit(message);
9585
const list = commit.breaking ? breaking : sections.get(commit.type);
9686
if (!list) continue;
9787

98-
const ref = commit.pr ?? hash.slice(0, 7);
9988
const prBody = commit.pr ? await fetchPrBody(commit.pr) : null;
10089
let note = extractReleaseNote(prBody) || extractReleaseNote(commit.body);
10190
if (note && isNone(note)) {
10291
if (!commit.breaking) continue;
10392
note = null;
10493
}
105-
if (!note) {
106-
if (!commit.breaking) {
107-
leftOut.push(ref);
108-
continue;
109-
}
110-
note = commit.subject;
111-
untitled.push(ref);
112-
}
11394

11495
const link = commit.pr
11596
? `[#${commit.pr}](${repoUrl}/pull/${commit.pr})`
11697
: `[${hash.slice(0, 7)}](${repoUrl}/commit/${hash})`;
117-
list.push(`- ${normalizeNote(note)}${repoUrl ? ` (${link})` : ""}`);
98+
list.push(`- ${normalizeNote(note || commit.subject)}${repoUrl ? ` (${link})` : ""}`);
11899
}
119100

120-
const title =
121-
repoUrl && previousTag
122-
? `## [${version}](${repoUrl}/compare/${previousTag}...${tag}) (${date})`
123-
: `## ${version} (${date})`;
124-
const blocks = [title, ...summary];
101+
const blocks = [];
125102
if (breaking.length) blocks.push(`### Breaking changes\n\n${breaking.join("\n")}`);
126103
for (const [type, heading] of SECTIONS) {
127104
const lines = sections.get(type);
128105
if (lines.length) blocks.push(`### ${heading}\n\n${lines.join("\n")}`);
129106
}
130-
if (blocks.length === 1) blocks.push("No user-facing changes.");
131-
return { markdown: `${blocks.join("\n\n")}\n`, leftOut, untitled };
107+
return blocks.join("\n\n");
108+
}
109+
110+
/**
111+
* The release notes. On a stable release with a promotion PR whose
112+
* `## Release notes` are written, those; otherwise generated ones. Returns
113+
* the Markdown, whether it was `written`, and the promotion PR, if any.
114+
*/
115+
export async function renderNotes({ stable, commits, fetchPrBody, slug, ...release }) {
116+
const title = releaseTitle({ slug, ...release });
117+
let promotion = null;
118+
if (stable) {
119+
for (const { message } of commits) {
120+
const match = STABLE_PROMOTION.exec(message.split("\n")[0]);
121+
if (!match) continue;
122+
promotion = Number(match[1]);
123+
const written = extractReleaseNotes(await fetchPrBody(promotion));
124+
if (written) return { markdown: `${title}\n\n${written}\n`, written: true, promotion };
125+
}
126+
}
127+
const sections = await generateSections({ commits, fetchPrBody, slug });
128+
return {
129+
markdown: `${title}\n\n${sections || "No user-facing changes."}\n`,
130+
written: false,
131+
promotion,
132+
};
132133
}
133134

134135
/** semantic-release's generateNotes step. */
135136
export async function generateNotes(_pluginConfig, context) {
136-
const { commits, lastRelease, nextRelease, options, env, logger } = context;
137+
const { branch, commits, lastRelease, nextRelease, options, env, logger } = context;
137138
const slug = repoSlug(options.repositoryUrl, env);
138-
const { markdown, leftOut, untitled } = await renderNotes({
139+
const { markdown, written, promotion } = await renderNotes({
140+
stable: branch.type !== "prerelease",
139141
version: nextRelease.version,
140142
previousTag: lastRelease?.gitTag,
141143
tag: nextRelease.gitTag,
@@ -144,10 +146,11 @@ export async function generateNotes(_pluginConfig, context) {
144146
commits: commits.map(({ message, hash }) => ({ message, hash })),
145147
fetchPrBody: prBodyFetcher({ slug, token: env.GITHUB_TOKEN || env.GH_TOKEN, logger }),
146148
});
147-
if (leftOut.length) logger.log(`Left out, with no release note: ${refList(leftOut)}.`);
148-
if (untitled.length) {
149+
if (written) {
150+
logger.log(`Release notes written on #${promotion}.`);
151+
} else if (promotion) {
149152
logger.warn(
150-
`Breaking changes with no release note, so their titles stand in: ${refList(untitled)}.`,
153+
`#${promotion} has no "## Release notes" to publish, so they're generated instead.`,
151154
);
152155
}
153156
return markdown;

0 commit comments

Comments
 (0)