diff --git a/.github/workflows/release-cut.yml b/.github/workflows/release-cut.yml index c957ce38..2e9fc55c 100644 --- a/.github/workflows/release-cut.yml +++ b/.github/workflows/release-cut.yml @@ -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) diff --git a/scripts/curate_changelog.py b/scripts/curate_changelog.py index f84d29e7..25af1b85 100644 --- a/scripts/curate_changelog.py +++ b/scripts/curate_changelog.py @@ -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 + (``>=`` -> ``>=``), 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 ``
`` + 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 = "All changes" # 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\s*\* (?:\*\*[^*]+\*\* )?)(?P\w+)(?P.*)", 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) # >= -> >=, & -> &, 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
fold.""" + inner = "".join(body_lines).strip("\n") + return f"
\n{_DETAILS_SUMMARY}\n\n{inner}\n\n
\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
.") + 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
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: