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
This commit is contained in:
George Weale
2026-07-08 20:35:22 -07:00
committed by Copybara-Service
parent 1ac68752ad
commit 2de8a53525
2 changed files with 183 additions and 34 deletions
+19 -9
View File
@@ -110,22 +110,24 @@ jobs:
manifest-file: ${{ steps.config.outputs.manifest_file }}
target-branch: ${{ steps.config.outputs.candidate_branch }}
# Curate the changelog: draft a Highlights section on top of the
# release-please output and commit it back to the release PR branch. The
# Curate the changelog: clean up and (for large releases) fold the
# release-please output, draft a Highlights section on top, commit it back
# to the release PR branch, and sync the PR description to match. The
# script falls back to an empty Highlights template if drafting fails, so
# this step never blocks the release.
# this step never blocks the release. Guarded on `pr` (not `prs_created`)
# so it also runs when release-please updates an existing PR (regenerate).
- name: Set up Python
if: steps.release_please.outputs.prs_created == 'true'
if: steps.release_please.outputs.pr != ''
uses: actions/setup-python@v6
with:
python-version: '3.11'
- name: Install changelog curation dependencies
if: steps.release_please.outputs.prs_created == 'true'
if: steps.release_please.outputs.pr != ''
run: pip install --upgrade google-genai
- name: Curate changelog Highlights
if: steps.release_please.outputs.prs_created == 'true'
if: steps.release_please.outputs.pr != ''
# Curation is a nice-to-have layered on top of the release PR; never let
# it turn the release run red (e.g. a push race or a transient error).
continue-on-error: true
@@ -137,12 +139,20 @@ jobs:
run: |
set -euo pipefail
PR_BRANCH=$(echo "$RELEASE_PR" | jq -r '.headBranchName')
echo "Curating changelog on release PR branch: $PR_BRANCH"
PR_NUMBER=$(echo "$RELEASE_PR" | jq -r '.number')
echo "Curating changelog on release PR #$PR_NUMBER (branch: $PR_BRANCH)"
git fetch origin "$PR_BRANCH"
git checkout -B "$PR_BRANCH" FETCH_HEAD
python scripts/curate_changelog.py --changelog CHANGELOG.md
python scripts/curate_changelog.py --changelog CHANGELOG.md \
--section-out /tmp/pr_body.md
# Mirror the curated notes into the PR description so reviewers read
# the same thing that ships in CHANGELOG.md. Done regardless of whether
# the file changed, since the body is regenerated by release-please.
if [ -s /tmp/pr_body.md ]; then
gh pr edit "$PR_NUMBER" --body-file /tmp/pr_body.md
fi
if git diff --quiet -- CHANGELOG.md; then
echo "No changelog changes to commit."
echo "No changelog file changes to commit."
exit 0
fi
USER_JSON=$(gh api user)
+164 -25
View File
@@ -12,28 +12,56 @@
# See the License for the specific language governing permissions and
# limitations under the License.
"""Insert a Highlights section into the newest CHANGELOG.md release.
"""Curate the newest CHANGELOG.md release section during a release cut.
Run as a post-step after release-please in the "Release: Cut" workflow. It finds
the newest version section, drafts a Highlights block from that version's commit
entries with Gemini, and inserts it at the top of the section.
Runs as a post-step after release-please in the "Release: Cut" workflow and
commits the result back to the release PR branch. It does three things to the
newest version section, in order:
If no API key is available or the model call fails, it inserts an empty
Highlights template instead, so the release flow never hard-fails on curation.
The release manager edits the result in the PR before merging.
1. Deterministic cleanup of the entries (no model): unescape HTML entities
(``&gt;=`` -> ``>=``), de-link accidental ``@mentions`` that release-please
auto-linked from a commit subject, drop duplicate entries (the same change
landed under several commits), and lowercase the leading word so entries
read as consistent imperative phrases.
2. Draft a short "Highlights" block with Gemini and place it above the fold, so
a reader grasps the release in a handful of bullets.
3. For large releases, collapse the full categorized list under a ``<details>``
fold so the notes read short while remaining a complete record.
Every step is best-effort. If the model is unavailable the Highlights fall back
to a template; the deterministic passes never call the network. The file is only
rewritten when something changed, so it is safe to re-run on each release-please
regenerate (idempotent). The release manager edits the result in the PR before
merging.
"""
from __future__ import annotations
import argparse
import html
import os
import re
import sys
_HIGHLIGHTS_HEADER = "### Highlights"
_DETAILS_SUMMARY = "<summary>All changes</summary>"
# Matches a release header line, e.g. "## [2.4.0](https://...) (2026-06-29)".
_VERSION_RE = re.compile(r"^## \[")
# Matches a category header, e.g. "### Features", "### Bug Fixes".
_SUBSECTION_RE = re.compile(r"^### ")
# Matches a changelog entry bullet.
_ENTRY_RE = re.compile(r"^\s*\* ")
# Trailing " ([abc1234](url))..." on an entry; stripped only to build the dedupe
# key so two commits with the same subject collapse to one.
_TRAILER_RE = re.compile(r"\s*\(\[[0-9a-f]{6,}\]\(.*$")
# An accidental "[@name](https://github.com/name)" auto-link, produced when a
# commit subject contained a bare "@name" (e.g. "... in @node decorator").
_MENTION_RE = re.compile(r"\[@([\w-]+)\]\(https://github\.com/\1\)")
# "* " then an optional bold "**scope:** " prefix, then the first word and rest.
_LEAD_RE = re.compile(
r"(?P<head>\s*\* (?:\*\*[^*]+\*\* )?)(?P<first>\w+)(?P<rest>.*)", re.S
)
# Inserted verbatim when the model is unavailable, so the release manager has a
# 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:
return start, end
def _latest_section_text(text: str) -> str | None:
"""Returns the text of the newest release section, or None if absent."""
lines = text.splitlines(keepends=True)
span = _find_latest_section(lines)
if span is None:
return None
start, end = span
return "".join(lines[start:end]).strip("\n") + "\n"
def _normalize_entry(line: str) -> str:
"""Applies deterministic, meaning-preserving fixes to a single entry line."""
s = html.unescape(line) # &gt;= -> >=, &amp; -> &, etc.
s = _MENTION_RE.sub(r"`@\1`", s) # de-link an accidental @mention
m = _LEAD_RE.match(s)
if m:
first = m.group("first")
# Lowercase a plain leading word ("Fix" -> "fix") but leave acronyms and
# camelCase/proper nouns intact ("OAuth", "GPU", "iOS", "A2A").
if not any(c.isupper() for c in first[1:]):
first = first[0].lower() + first[1:]
s = f"{m.group('head')}{first}{m.group('rest')}"
return s
def _dedupe_key(line: str) -> str:
"""Key for detecting the same change landed under multiple commits."""
core = _TRAILER_RE.sub("", line) # drop the "([hash](url))" trailer
return re.sub(r"\s+", " ", core).strip().lower()
def _normalize_body(lines: list[str]) -> list[str]:
"""Normalizes and de-duplicates entry bullets; passes other lines through."""
seen: set[str] = set()
out: list[str] = []
for line in lines:
if _ENTRY_RE.match(line):
norm = _normalize_entry(line)
key = _dedupe_key(norm)
if key in seen:
continue
seen.add(key)
out.append(norm)
else:
out.append(line)
return out
def _count_entries(lines: list[str]) -> int:
return sum(1 for line in lines if _ENTRY_RE.match(line))
def _wrap_in_details(body_lines: list[str]) -> str:
"""Collapses the categorized list under a <details> fold."""
inner = "".join(body_lines).strip("\n")
return f"<details>\n{_DETAILS_SUMMARY}\n\n{inner}\n\n</details>\n"
def _draft_highlights(section_text: str, *, model: str) -> str | None:
"""Drafts the Highlights body with Gemini, or None if unavailable."""
api_key = os.environ.get("GOOGLE_API_KEY")
@@ -128,35 +214,62 @@ def _build_block(body: str) -> str:
return f"{_HIGHLIGHTS_HEADER}\n\n{body}\n"
def curate(text: str, *, model: str) -> str:
"""Returns CHANGELOG text with Highlights inserted into the newest release."""
def curate(text: str, *, model: str, fold_threshold: int) -> str:
"""Returns CHANGELOG text with the newest release section curated."""
lines = text.splitlines(keepends=True)
span = _find_latest_section(lines)
if span is None:
print("No release section found; leaving CHANGELOG unchanged.")
return text
start, end = span
if any(line.strip() == _HIGHLIGHTS_HEADER for line in lines[start:end]):
print("Highlights already present; leaving CHANGELOG unchanged.")
section = lines[start:end]
if any(line.strip() == _HIGHLIGHTS_HEADER for line in section) or any(
_DETAILS_SUMMARY in line for line in section
):
print("Section already curated; leaving CHANGELOG unchanged.")
return text
body = _draft_highlights("".join(lines[start:end]), model=model)
if body is None:
block = _TEMPLATE
# Split the section into its header (## [..] + blank lines) and the
# categorized body (### Features ... through the end of the section).
first_sub = None
for i in range(start + 1, end):
if _SUBSECTION_RE.match(lines[i]):
first_sub = i
break
if first_sub is None:
# No categorized entries (rare): only add Highlights.
header = section
body_norm: list[str] = []
model_input = ""
else:
header = lines[start:first_sub]
body_norm = _normalize_body(lines[first_sub:end])
model_input = "".join(body_norm)
drafted = _draft_highlights(model_input, model=model) if model_input else None
if drafted is None:
highlights = _TEMPLATE
print("Inserted Highlights template.")
else:
block = _build_block(body)
highlights = _build_block(drafted)
print("Inserted model-drafted Highlights.")
# Insert before the first "### " subsection (Features, Bug Fixes, ...), or at
# the end of the section if it has no categorized entries.
insert_at = end
for i in range(start + 1, end):
if lines[i].startswith("### "):
insert_at = i
break
block_text = block.rstrip("\n") + "\n\n"
return "".join(lines[:insert_at] + [block_text] + lines[insert_at:])
parts: list[str] = list(header)
if parts and parts[-1].strip():
parts.append("\n")
parts.append(highlights.rstrip("\n") + "\n\n")
if body_norm:
if _count_entries(body_norm) > fold_threshold:
parts.append(_wrap_in_details(body_norm))
print(f"Folded {_count_entries(body_norm)} entries under <details>.")
else:
parts.append("".join(body_norm).strip("\n") + "\n")
new_section = "".join(parts).rstrip("\n") + "\n\n"
return "".join(lines[:start]) + new_section + "".join(lines[end:])
def main() -> int:
@@ -171,11 +284,37 @@ def main() -> int:
default=os.environ.get("CHANGELOG_CURATION_MODEL", "gemini-2.5-flash"),
help="Gemini model used to draft the Highlights.",
)
parser.add_argument(
"--fold-threshold",
type=int,
default=int(os.environ.get("CHANGELOG_FOLD_THRESHOLD", "12")),
help=(
"Collapse the full list under a <details> fold when the release has"
" more than this many entries. Set very high to never fold."
),
)
parser.add_argument(
"--section-out",
default=None,
help=(
"If set, write the curated newest release section to this path, for"
" use as the PR description body. Written even when the changelog"
" file is otherwise unchanged."
),
)
args = parser.parse_args()
with open(args.changelog, encoding="utf-8") as f:
text = f.read()
updated = curate(text, model=args.model)
updated = curate(text, model=args.model, fold_threshold=args.fold_threshold)
if args.section_out:
section = _latest_section_text(updated)
if section is not None:
with open(args.section_out, "w", encoding="utf-8") as f:
f.write(section)
print(f"Wrote latest section to {args.section_out}.")
if updated == text:
return 0
with open(args.changelog, "w", encoding="utf-8") as f: