Skip to content

Commit 7988a76

Browse files
committed
Merge remote-tracking branch 'origin/main' into fix-thought-signature-pruning
2 parents 2dc70ee + 4d8fbf7 commit 7988a76

2 files changed

Lines changed: 233 additions & 0 deletions

File tree

.github/workflows/release-cut.yml

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,9 +102,54 @@ jobs:
102102

103103
# Run Release Please
104104
- name: Run Release Please
105+
id: release_please
105106
uses: googleapis/release-please-action@v4
106107
with:
107108
token: ${{ secrets.RELEASE_PAT }}
108109
config-file: ${{ steps.config.outputs.config_file }}
109110
manifest-file: ${{ steps.config.outputs.manifest_file }}
110111
target-branch: ${{ steps.config.outputs.candidate_branch }}
112+
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
115+
# script falls back to an empty Highlights template if drafting fails, so
116+
# this step never blocks the release.
117+
- name: Set up Python
118+
if: steps.release_please.outputs.prs_created == 'true'
119+
uses: actions/setup-python@v6
120+
with:
121+
python-version: '3.11'
122+
123+
- name: Install changelog curation dependencies
124+
if: steps.release_please.outputs.prs_created == 'true'
125+
run: pip install --upgrade google-genai
126+
127+
- name: Curate changelog Highlights
128+
if: steps.release_please.outputs.prs_created == 'true'
129+
# Curation is a nice-to-have layered on top of the release PR; never let
130+
# it turn the release run red (e.g. a push race or a transient error).
131+
continue-on-error: true
132+
env:
133+
RELEASE_PR: ${{ steps.release_please.outputs.pr }}
134+
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
135+
GOOGLE_GENAI_USE_VERTEXAI: '0'
136+
GH_TOKEN: ${{ secrets.RELEASE_PAT }}
137+
run: |
138+
set -euo pipefail
139+
PR_BRANCH=$(echo "$RELEASE_PR" | jq -r '.headBranchName')
140+
echo "Curating changelog on release PR branch: $PR_BRANCH"
141+
git fetch origin "$PR_BRANCH"
142+
git checkout -B "$PR_BRANCH" FETCH_HEAD
143+
python scripts/curate_changelog.py --changelog CHANGELOG.md
144+
if git diff --quiet -- CHANGELOG.md; then
145+
echo "No changelog changes to commit."
146+
exit 0
147+
fi
148+
USER_JSON=$(gh api user)
149+
git config user.name "$(echo "$USER_JSON" | jq -r '.login')"
150+
git config user.email "$(echo "$USER_JSON" | jq -r '.id')+$(echo "$USER_JSON" | jq -r '.login')@users.noreply.github.com"
151+
git add CHANGELOG.md
152+
git commit -m "chore: add curated highlights to changelog"
153+
# Rebase onto any concurrent PR-branch updates so the push doesn't fail on a stale ref.
154+
git pull --rebase origin "$PR_BRANCH"
155+
git push origin "$PR_BRANCH"

scripts/curate_changelog.py

Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
# Copyright 2026 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
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.
24+
"""
25+
26+
from __future__ import annotations
27+
28+
import argparse
29+
import os
30+
import re
31+
import sys
32+
33+
_HIGHLIGHTS_HEADER = "### Highlights"
34+
35+
# Matches a release header line, e.g. "## [2.4.0](https://...) (2026-06-29)".
36+
_VERSION_RE = re.compile(r"^## \[")
37+
38+
# Inserted verbatim when the model is unavailable, so the release manager has a
39+
# scaffold to fill in by hand. Mirrors the format the model is asked to produce.
40+
_TEMPLATE = """### Highlights
41+
42+
<one sentence describing the theme of this release>
43+
44+
* **<Feature>**: <what it unlocks for the user, in one line>. (<commit>)
45+
* **<Feature>**: <user benefit>. (<commit>)
46+
47+
#### Breaking changes
48+
49+
* **<what changed>**: <how to migrate, in one line>. (<commit>)
50+
"""
51+
52+
_PROMPT = """\
53+
You are drafting the "Highlights" section of an ADK (Agent Development Kit)
54+
Python release changelog.
55+
56+
Below is the auto-generated changelog for the new version, grouped by type
57+
(Features, Bug Fixes, etc.). Each entry ends with a commit hash link.
58+
59+
Write a short Highlights section so a reader can grasp the release at a glance:
60+
- Start with ONE sentence describing the theme of the release.
61+
- Then 2-5 bullets, each leading with the user-facing benefit rather than the
62+
implementation, formatted as
63+
"* **<Area>**: <benefit in one line>. (<commit link>)".
64+
- Reuse the exact commit links from the entries you summarize.
65+
- Pick only the few changes that matter most to users. Ignore pure refactors,
66+
chores, and trivial docs.
67+
- If there are breaking changes, add a "#### Breaking changes" subsection after
68+
the bullets, each with a one-line migration note.
69+
70+
Output ONLY the markdown body. Do NOT include the "### Highlights" header and do
71+
NOT wrap the output in code fences.
72+
73+
Changelog for the new version:
74+
75+
{changelog}
76+
"""
77+
78+
79+
def _find_latest_section(lines: list[str]) -> tuple[int, int] | None:
80+
"""Returns the [start, end) line span of the newest release section.
81+
82+
start is the index of the "## [" header; end is the index of the next "## ["
83+
header or len(lines). Returns None if no release header is present.
84+
"""
85+
start = None
86+
for i, line in enumerate(lines):
87+
if _VERSION_RE.match(line):
88+
start = i
89+
break
90+
if start is None:
91+
return None
92+
end = len(lines)
93+
for j in range(start + 1, len(lines)):
94+
if _VERSION_RE.match(lines[j]):
95+
end = j
96+
break
97+
return start, end
98+
99+
100+
def _draft_highlights(section_text: str, *, model: str) -> str | None:
101+
"""Drafts the Highlights body with Gemini, or None if unavailable."""
102+
api_key = os.environ.get("GOOGLE_API_KEY")
103+
if not api_key:
104+
print("GOOGLE_API_KEY not set; skipping model drafting.")
105+
return None
106+
try:
107+
from google import genai
108+
109+
client = genai.Client(api_key=api_key)
110+
response = client.models.generate_content(
111+
model=model,
112+
contents=_PROMPT.format(changelog=section_text),
113+
)
114+
body = (response.text or "").strip()
115+
return body or None
116+
# The release must never fail because drafting failed (missing dependency,
117+
# network/API error, quota); fall back to the template in every case.
118+
except Exception as e: # pylint: disable=broad-exception-caught
119+
print(f"Highlights drafting failed ({e!r}); falling back to template.")
120+
return None
121+
122+
123+
def _build_block(body: str) -> str:
124+
"""Wraps a model-drafted body in the Highlights header."""
125+
body = body.strip()
126+
if body.startswith(_HIGHLIGHTS_HEADER):
127+
body = body[len(_HIGHLIGHTS_HEADER) :].lstrip("\n")
128+
return f"{_HIGHLIGHTS_HEADER}\n\n{body}\n"
129+
130+
131+
def curate(text: str, *, model: str) -> str:
132+
"""Returns CHANGELOG text with Highlights inserted into the newest release."""
133+
lines = text.splitlines(keepends=True)
134+
span = _find_latest_section(lines)
135+
if span is None:
136+
print("No release section found; leaving CHANGELOG unchanged.")
137+
return text
138+
start, end = span
139+
if any(line.strip() == _HIGHLIGHTS_HEADER for line in lines[start:end]):
140+
print("Highlights already present; leaving CHANGELOG unchanged.")
141+
return text
142+
143+
body = _draft_highlights("".join(lines[start:end]), model=model)
144+
if body is None:
145+
block = _TEMPLATE
146+
print("Inserted Highlights template.")
147+
else:
148+
block = _build_block(body)
149+
print("Inserted model-drafted Highlights.")
150+
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:])
160+
161+
162+
def main() -> int:
163+
parser = argparse.ArgumentParser(description=__doc__)
164+
parser.add_argument(
165+
"--changelog",
166+
default="CHANGELOG.md",
167+
help="Path to the changelog file to curate.",
168+
)
169+
parser.add_argument(
170+
"--model",
171+
default=os.environ.get("CHANGELOG_CURATION_MODEL", "gemini-2.5-flash"),
172+
help="Gemini model used to draft the Highlights.",
173+
)
174+
args = parser.parse_args()
175+
176+
with open(args.changelog, encoding="utf-8") as f:
177+
text = f.read()
178+
updated = curate(text, model=args.model)
179+
if updated == text:
180+
return 0
181+
with open(args.changelog, "w", encoding="utf-8") as f:
182+
f.write(updated)
183+
print(f"Updated {args.changelog}.")
184+
return 0
185+
186+
187+
if __name__ == "__main__":
188+
sys.exit(main())

0 commit comments

Comments
 (0)