Skip to content

Commit 2de8a53

Browse files
GWealecopybara-github
authored andcommitted
chore: clean, fold, and sync the release changelog PR
Expands the release-cut changelog curation so the release PR looks good by default. curate_changelog.py now normalizes the newest section (unescape HTML entities, de-link stray @mentions, drop duplicate entries from the same change, lowercase each entry's leading word), folds large releases under a <details> block (sized by --fold-threshold / CHANGELOG_FOLD_THRESHOLD, default 12), and can emit the curated section via --section-out. The release-cut workflow now mirrors that section into the PR description, and its curation steps run whenever a release PR exists (not only when newly created), so regenerate runs stay curated too. Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 944860270
1 parent 1ac6875 commit 2de8a53

2 files changed

Lines changed: 185 additions & 36 deletions

File tree

.github/workflows/release-cut.yml

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -110,22 +110,24 @@ jobs:
110110
manifest-file: ${{ steps.config.outputs.manifest_file }}
111111
target-branch: ${{ steps.config.outputs.candidate_branch }}
112112

113-
# Curate the changelog: draft a Highlights section on top of the
114-
# release-please output and commit it back to the release PR branch. The
113+
# Curate the changelog: clean up and (for large releases) fold the
114+
# release-please output, draft a Highlights section on top, commit it back
115+
# to the release PR branch, and sync the PR description to match. The
115116
# script falls back to an empty Highlights template if drafting fails, so
116-
# this step never blocks the release.
117+
# this step never blocks the release. Guarded on `pr` (not `prs_created`)
118+
# so it also runs when release-please updates an existing PR (regenerate).
117119
- name: Set up Python
118-
if: steps.release_please.outputs.prs_created == 'true'
120+
if: steps.release_please.outputs.pr != ''
119121
uses: actions/setup-python@v6
120122
with:
121123
python-version: '3.11'
122124

123125
- name: Install changelog curation dependencies
124-
if: steps.release_please.outputs.prs_created == 'true'
126+
if: steps.release_please.outputs.pr != ''
125127
run: pip install --upgrade google-genai
126128

127129
- name: Curate changelog Highlights
128-
if: steps.release_please.outputs.prs_created == 'true'
130+
if: steps.release_please.outputs.pr != ''
129131
# Curation is a nice-to-have layered on top of the release PR; never let
130132
# it turn the release run red (e.g. a push race or a transient error).
131133
continue-on-error: true
@@ -137,12 +139,20 @@ jobs:
137139
run: |
138140
set -euo pipefail
139141
PR_BRANCH=$(echo "$RELEASE_PR" | jq -r '.headBranchName')
140-
echo "Curating changelog on release PR branch: $PR_BRANCH"
142+
PR_NUMBER=$(echo "$RELEASE_PR" | jq -r '.number')
143+
echo "Curating changelog on release PR #$PR_NUMBER (branch: $PR_BRANCH)"
141144
git fetch origin "$PR_BRANCH"
142145
git checkout -B "$PR_BRANCH" FETCH_HEAD
143-
python scripts/curate_changelog.py --changelog CHANGELOG.md
146+
python scripts/curate_changelog.py --changelog CHANGELOG.md \
147+
--section-out /tmp/pr_body.md
148+
# Mirror the curated notes into the PR description so reviewers read
149+
# the same thing that ships in CHANGELOG.md. Done regardless of whether
150+
# the file changed, since the body is regenerated by release-please.
151+
if [ -s /tmp/pr_body.md ]; then
152+
gh pr edit "$PR_NUMBER" --body-file /tmp/pr_body.md
153+
fi
144154
if git diff --quiet -- CHANGELOG.md; then
145-
echo "No changelog changes to commit."
155+
echo "No changelog file changes to commit."
146156
exit 0
147157
fi
148158
USER_JSON=$(gh api user)

scripts/curate_changelog.py

Lines changed: 166 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -12,28 +12,56 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
"""Insert a Highlights section into the newest CHANGELOG.md release.
16-
17-
Run as a post-step after release-please in the "Release: Cut" workflow. It finds
18-
the newest version section, drafts a Highlights block from that version's commit
19-
entries with Gemini, and inserts it at the top of the section.
20-
21-
If no API key is available or the model call fails, it inserts an empty
22-
Highlights template instead, so the release flow never hard-fails on curation.
23-
The release manager edits the result in the PR before merging.
15+
"""Curate the newest CHANGELOG.md release section during a release cut.
16+
17+
Runs as a post-step after release-please in the "Release: Cut" workflow and
18+
commits the result back to the release PR branch. It does three things to the
19+
newest version section, in order:
20+
21+
1. Deterministic cleanup of the entries (no model): unescape HTML entities
22+
(``&gt;=`` -> ``>=``), de-link accidental ``@mentions`` that release-please
23+
auto-linked from a commit subject, drop duplicate entries (the same change
24+
landed under several commits), and lowercase the leading word so entries
25+
read as consistent imperative phrases.
26+
2. Draft a short "Highlights" block with Gemini and place it above the fold, so
27+
a reader grasps the release in a handful of bullets.
28+
3. For large releases, collapse the full categorized list under a ``<details>``
29+
fold so the notes read short while remaining a complete record.
30+
31+
Every step is best-effort. If the model is unavailable the Highlights fall back
32+
to a template; the deterministic passes never call the network. The file is only
33+
rewritten when something changed, so it is safe to re-run on each release-please
34+
regenerate (idempotent). The release manager edits the result in the PR before
35+
merging.
2436
"""
2537

2638
from __future__ import annotations
2739

2840
import argparse
41+
import html
2942
import os
3043
import re
3144
import sys
3245

3346
_HIGHLIGHTS_HEADER = "### Highlights"
47+
_DETAILS_SUMMARY = "<summary>All changes</summary>"
3448

3549
# Matches a release header line, e.g. "## [2.4.0](https://...) (2026-06-29)".
3650
_VERSION_RE = re.compile(r"^## \[")
51+
# Matches a category header, e.g. "### Features", "### Bug Fixes".
52+
_SUBSECTION_RE = re.compile(r"^### ")
53+
# Matches a changelog entry bullet.
54+
_ENTRY_RE = re.compile(r"^\s*\* ")
55+
# Trailing " ([abc1234](url))..." on an entry; stripped only to build the dedupe
56+
# key so two commits with the same subject collapse to one.
57+
_TRAILER_RE = re.compile(r"\s*\(\[[0-9a-f]{6,}\]\(.*$")
58+
# An accidental "[@name](https://github.com/name)" auto-link, produced when a
59+
# commit subject contained a bare "@name" (e.g. "... in @node decorator").
60+
_MENTION_RE = re.compile(r"\[@([\w-]+)\]\(https://github\.com/\1\)")
61+
# "* " then an optional bold "**scope:** " prefix, then the first word and rest.
62+
_LEAD_RE = re.compile(
63+
r"(?P<head>\s*\* (?:\*\*[^*]+\*\* )?)(?P<first>\w+)(?P<rest>.*)", re.S
64+
)
3765

3866
# Inserted verbatim when the model is unavailable, so the release manager has a
3967
# scaffold to fill in by hand. Mirrors the format the model is asked to produce.
@@ -97,6 +125,64 @@ def _find_latest_section(lines: list[str]) -> tuple[int, int] | None:
97125
return start, end
98126

99127

128+
def _latest_section_text(text: str) -> str | None:
129+
"""Returns the text of the newest release section, or None if absent."""
130+
lines = text.splitlines(keepends=True)
131+
span = _find_latest_section(lines)
132+
if span is None:
133+
return None
134+
start, end = span
135+
return "".join(lines[start:end]).strip("\n") + "\n"
136+
137+
138+
def _normalize_entry(line: str) -> str:
139+
"""Applies deterministic, meaning-preserving fixes to a single entry line."""
140+
s = html.unescape(line) # &gt;= -> >=, &amp; -> &, etc.
141+
s = _MENTION_RE.sub(r"`@\1`", s) # de-link an accidental @mention
142+
m = _LEAD_RE.match(s)
143+
if m:
144+
first = m.group("first")
145+
# Lowercase a plain leading word ("Fix" -> "fix") but leave acronyms and
146+
# camelCase/proper nouns intact ("OAuth", "GPU", "iOS", "A2A").
147+
if not any(c.isupper() for c in first[1:]):
148+
first = first[0].lower() + first[1:]
149+
s = f"{m.group('head')}{first}{m.group('rest')}"
150+
return s
151+
152+
153+
def _dedupe_key(line: str) -> str:
154+
"""Key for detecting the same change landed under multiple commits."""
155+
core = _TRAILER_RE.sub("", line) # drop the "([hash](url))" trailer
156+
return re.sub(r"\s+", " ", core).strip().lower()
157+
158+
159+
def _normalize_body(lines: list[str]) -> list[str]:
160+
"""Normalizes and de-duplicates entry bullets; passes other lines through."""
161+
seen: set[str] = set()
162+
out: list[str] = []
163+
for line in lines:
164+
if _ENTRY_RE.match(line):
165+
norm = _normalize_entry(line)
166+
key = _dedupe_key(norm)
167+
if key in seen:
168+
continue
169+
seen.add(key)
170+
out.append(norm)
171+
else:
172+
out.append(line)
173+
return out
174+
175+
176+
def _count_entries(lines: list[str]) -> int:
177+
return sum(1 for line in lines if _ENTRY_RE.match(line))
178+
179+
180+
def _wrap_in_details(body_lines: list[str]) -> str:
181+
"""Collapses the categorized list under a <details> fold."""
182+
inner = "".join(body_lines).strip("\n")
183+
return f"<details>\n{_DETAILS_SUMMARY}\n\n{inner}\n\n</details>\n"
184+
185+
100186
def _draft_highlights(section_text: str, *, model: str) -> str | None:
101187
"""Drafts the Highlights body with Gemini, or None if unavailable."""
102188
api_key = os.environ.get("GOOGLE_API_KEY")
@@ -128,35 +214,62 @@ def _build_block(body: str) -> str:
128214
return f"{_HIGHLIGHTS_HEADER}\n\n{body}\n"
129215

130216

131-
def curate(text: str, *, model: str) -> str:
132-
"""Returns CHANGELOG text with Highlights inserted into the newest release."""
217+
def curate(text: str, *, model: str, fold_threshold: int) -> str:
218+
"""Returns CHANGELOG text with the newest release section curated."""
133219
lines = text.splitlines(keepends=True)
134220
span = _find_latest_section(lines)
135221
if span is None:
136222
print("No release section found; leaving CHANGELOG unchanged.")
137223
return text
138224
start, end = span
139-
if any(line.strip() == _HIGHLIGHTS_HEADER for line in lines[start:end]):
140-
print("Highlights already present; leaving CHANGELOG unchanged.")
225+
226+
section = lines[start:end]
227+
if any(line.strip() == _HIGHLIGHTS_HEADER for line in section) or any(
228+
_DETAILS_SUMMARY in line for line in section
229+
):
230+
print("Section already curated; leaving CHANGELOG unchanged.")
141231
return text
142232

143-
body = _draft_highlights("".join(lines[start:end]), model=model)
144-
if body is None:
145-
block = _TEMPLATE
233+
# Split the section into its header (## [..] + blank lines) and the
234+
# categorized body (### Features ... through the end of the section).
235+
first_sub = None
236+
for i in range(start + 1, end):
237+
if _SUBSECTION_RE.match(lines[i]):
238+
first_sub = i
239+
break
240+
241+
if first_sub is None:
242+
# No categorized entries (rare): only add Highlights.
243+
header = section
244+
body_norm: list[str] = []
245+
model_input = ""
246+
else:
247+
header = lines[start:first_sub]
248+
body_norm = _normalize_body(lines[first_sub:end])
249+
model_input = "".join(body_norm)
250+
251+
drafted = _draft_highlights(model_input, model=model) if model_input else None
252+
if drafted is None:
253+
highlights = _TEMPLATE
146254
print("Inserted Highlights template.")
147255
else:
148-
block = _build_block(body)
256+
highlights = _build_block(drafted)
149257
print("Inserted model-drafted Highlights.")
150258

151-
# Insert before the first "### " subsection (Features, Bug Fixes, ...), or at
152-
# the end of the section if it has no categorized entries.
153-
insert_at = end
154-
for i in range(start + 1, end):
155-
if lines[i].startswith("### "):
156-
insert_at = i
157-
break
158-
block_text = block.rstrip("\n") + "\n\n"
159-
return "".join(lines[:insert_at] + [block_text] + lines[insert_at:])
259+
parts: list[str] = list(header)
260+
if parts and parts[-1].strip():
261+
parts.append("\n")
262+
parts.append(highlights.rstrip("\n") + "\n\n")
263+
264+
if body_norm:
265+
if _count_entries(body_norm) > fold_threshold:
266+
parts.append(_wrap_in_details(body_norm))
267+
print(f"Folded {_count_entries(body_norm)} entries under <details>.")
268+
else:
269+
parts.append("".join(body_norm).strip("\n") + "\n")
270+
271+
new_section = "".join(parts).rstrip("\n") + "\n\n"
272+
return "".join(lines[:start]) + new_section + "".join(lines[end:])
160273

161274

162275
def main() -> int:
@@ -171,11 +284,37 @@ def main() -> int:
171284
default=os.environ.get("CHANGELOG_CURATION_MODEL", "gemini-2.5-flash"),
172285
help="Gemini model used to draft the Highlights.",
173286
)
287+
parser.add_argument(
288+
"--fold-threshold",
289+
type=int,
290+
default=int(os.environ.get("CHANGELOG_FOLD_THRESHOLD", "12")),
291+
help=(
292+
"Collapse the full list under a <details> fold when the release has"
293+
" more than this many entries. Set very high to never fold."
294+
),
295+
)
296+
parser.add_argument(
297+
"--section-out",
298+
default=None,
299+
help=(
300+
"If set, write the curated newest release section to this path, for"
301+
" use as the PR description body. Written even when the changelog"
302+
" file is otherwise unchanged."
303+
),
304+
)
174305
args = parser.parse_args()
175306

176307
with open(args.changelog, encoding="utf-8") as f:
177308
text = f.read()
178-
updated = curate(text, model=args.model)
309+
updated = curate(text, model=args.model, fold_threshold=args.fold_threshold)
310+
311+
if args.section_out:
312+
section = _latest_section_text(updated)
313+
if section is not None:
314+
with open(args.section_out, "w", encoding="utf-8") as f:
315+
f.write(section)
316+
print(f"Wrote latest section to {args.section_out}.")
317+
179318
if updated == text:
180319
return 0
181320
with open(args.changelog, "w", encoding="utf-8") as f:

0 commit comments

Comments
 (0)