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:
committed by
Copybara-Service
parent
1ac68752ad
commit
2de8a53525
@@ -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
@@ -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 ``<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) # >= -> >=, & -> &, 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:
|
||||
|
||||
Reference in New Issue
Block a user