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+ (``>=`` -> ``>=``), 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
2638from __future__ import annotations
2739
2840import argparse
41+ import html
2942import os
3043import re
3144import 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 ) # >= -> >=, & -> &, 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+
100186def _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
162275def 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