Skip to content

Commit f05e4bc

Browse files
[Infra] Replace Danger with Python script for changelog checks (#8470)
Replace the Ruby-based Danger changelog verification setup with a custom Python script (`ci/changelog_check.py`). * Update: `.github/workflows/changelog.yml` to run `actions/setup-python` with Python 3.12 instead of Ruby setup and Bundler steps. * Grant: `pull-requests: write` and `issues: write` permissions in the GitHub Actions workflow to enable PR comment management. * Add: `ci/changelog_check.py` to check for changelog updates when SDK source files are modified, with support for PR comments via the GitHub API and skipping options (`no-changelog` label or `NO_RELEASE_CHANGE` description). * Remove: Danger files including `Dangerfile`, `Gemfile`, `Gemfile.lock`, and `setup_bundler.sh`. --------- Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com>
1 parent 1103e89 commit f05e4bc

6 files changed

Lines changed: 298 additions & 219 deletions

File tree

.github/workflows/changelog.yml

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,22 @@ concurrency:
99

1010
permissions:
1111
contents: read
12+
pull-requests: write
13+
issues: write
1214

1315
jobs:
1416
changelog-check:
1517
runs-on: ubuntu-22.04
16-
env:
17-
BUNDLE_GEMFILE: ./ci/danger/Gemfile
1818
steps:
1919
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
2020
with:
2121
fetch-depth: 100
2222
submodules: true
2323
persist-credentials: false
24-
- uses: ruby/setup-ruby@12fd324f1d0b43274fdc8130f6980590a667c455 # v1.312.0
24+
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
2525
with:
26-
ruby-version: '3.4'
27-
- name: Setup Bundler
28-
run: ./ci/danger/setup_bundler.sh
29-
- name: Danger CHANGELOG verifier
26+
python-version: '3.12'
27+
- name: Verify CHANGELOG update
3028
env:
31-
DANGER_GITHUB_API_TOKEN: ${{ secrets.GOOGLE_OSS_BOT_TOKEN }}
32-
run:
33-
'[ ! -z $DANGER_GITHUB_API_TOKEN ] && bundle exec danger --dangerfile=./ci/danger/Dangerfile || echo "Skipping Danger for External Contributor"'
29+
GITHUB_TOKEN: ${{ secrets.GOOGLE_OSS_BOT_TOKEN || secrets.GITHUB_TOKEN }}
30+
run: python3 ci/changelog_check.py

ci/changelog_check.py

Lines changed: 291 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,291 @@
1+
#!/usr/bin/env python3
2+
# Copyright 2026 Google LLC
3+
#
4+
# Licensed under the Apache License, Version 2.0 (the "License");
5+
# you may not use this file except in compliance with the License.
6+
# You may obtain a copy of the License at
7+
#
8+
# http://www.apache.org/licenses/LICENSE-2.0
9+
#
10+
# Unless required by applicable law or agreed to in writing, software
11+
# distributed under the License is distributed on an "AS IS" BASIS,
12+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
# See the License for the specific language governing permissions and
14+
# limitations under the License.
15+
16+
import os
17+
import sys
18+
import subprocess
19+
import json
20+
import re
21+
import urllib.request
22+
23+
# Ignore changes in these directories
24+
EXCLUDE_DIRECTORIES = [
25+
'.github/',
26+
'plugins/',
27+
'ci/',
28+
'encoders/',
29+
'firebase-annotations/',
30+
'firebase-components/',
31+
'firebase-datatransport/',
32+
'gradle/',
33+
'health-metrics/',
34+
'integ-testing/',
35+
'protolite-well-known-types/',
36+
'smoke-tests/',
37+
'third_party/',
38+
'tools/',
39+
'transport/'
40+
]
41+
42+
SIGNATURE = "<!-- python-changelog-warning -->"
43+
44+
def should_file_be_excluded(file, exclude_paths):
45+
return any(dir_path in file for dir_path in exclude_paths)
46+
47+
def has_changes_in(paths, modified_files):
48+
for path in paths:
49+
regex = re.compile(path)
50+
if any(regex.search(file) for file in modified_files):
51+
return True
52+
return False
53+
54+
def has_sdk_changes(modified_files):
55+
return any(not should_file_be_excluded(f, EXCLUDE_DIRECTORIES) for f in modified_files)
56+
57+
def get_modified_files():
58+
# If running in GitHub Actions, try to use the GitHub API to avoid shallow clone issues
59+
repo = os.environ.get('GITHUB_REPOSITORY')
60+
token = os.environ.get('GITHUB_TOKEN')
61+
event_path = os.environ.get('GITHUB_EVENT_PATH')
62+
if repo and token and event_path:
63+
try:
64+
with open(event_path, 'r') as f:
65+
event_data = json.load(f)
66+
pull_request = event_data.get('pull_request')
67+
if pull_request:
68+
pr_number = pull_request.get('number')
69+
headers = {
70+
"Authorization": f"token {token}",
71+
"Accept": "application/vnd.github.v3+json",
72+
"User-Agent": "Python-urllib"
73+
}
74+
files = []
75+
page = 1
76+
while True:
77+
url = f"https://api.github.com/repos/{repo}/pulls/{pr_number}/files?per_page=100&page={page}"
78+
req = urllib.request.Request(url, headers=headers)
79+
with urllib.request.urlopen(req) as response:
80+
page_files = json.loads(response.read().decode('utf-8'))
81+
if not page_files:
82+
break
83+
files.extend([f['filename'] for f in page_files])
84+
if len(page_files) < 100:
85+
break
86+
page += 1
87+
return files
88+
except Exception as e:
89+
print(f"Warning: Failed to fetch modified files via GitHub API: {e}", file=sys.stderr)
90+
print("Falling back to git diff...", file=sys.stderr)
91+
92+
print("Attempting fallback to diff against origin/main", file=sys.stderr)
93+
# Fallback for local testing or different checkout configurations
94+
for base in ['origin/main', 'main']:
95+
try:
96+
result = subprocess.run(
97+
['git', 'diff', '--name-only', f'{base}...HEAD'],
98+
stdout=subprocess.PIPE,
99+
stderr=subprocess.PIPE,
100+
text=True,
101+
check=True
102+
)
103+
files = result.stdout.strip().split('\n')
104+
return [f for f in files if f]
105+
except subprocess.CalledProcessError:
106+
continue
107+
return []
108+
109+
110+
def get_modified_files():
111+
try:
112+
# Compare HEAD with its first parent.
113+
# In GitHub Actions pull_request event, HEAD is the merge commit,
114+
# and HEAD^1 is the base branch (target of PR).
115+
result = subprocess.run(
116+
['git', 'diff', '--name-only', 'HEAD^1'],
117+
stdout=subprocess.PIPE,
118+
stderr=subprocess.PIPE,
119+
text=True,
120+
check=True
121+
)
122+
files = result.stdout.strip().split('\n')
123+
return [f for f in files if f]
124+
except subprocess.CalledProcessError as e:
125+
print(f"Warning: git diff HEAD^1 failed: {e.stderr.strip()}", file=sys.stderr)
126+
print("Attempting fallback to diff against origin/main", file=sys.stderr)
127+
# Fallback for local testing or different checkout configurations
128+
for base in ['origin/main', 'origin/master', 'main', 'master']:
129+
try:
130+
result = subprocess.run(
131+
['git', 'diff', '--name-only', f'{base}...HEAD'],
132+
stdout=subprocess.PIPE,
133+
stderr=subprocess.PIPE,
134+
text=True,
135+
check=True
136+
)
137+
files = result.stdout.strip().split('\n')
138+
return [f for f in files if f]
139+
except subprocess.CalledProcessError:
140+
continue
141+
print("Error: Could not determine modified files via git.", file=sys.stderr)
142+
return []
143+
144+
def get_pr_details():
145+
event_path = os.environ.get('GITHUB_EVENT_PATH')
146+
if not event_path:
147+
print("GITHUB_EVENT_PATH not set. Running in non-CI mode?", file=sys.stderr)
148+
return None, [], ""
149+
150+
try:
151+
with open(event_path, 'r') as f:
152+
event_data = json.load(f)
153+
pull_request = event_data.get('pull_request')
154+
if pull_request:
155+
pr_number = pull_request.get('number')
156+
labels = [l['name'] for l in pull_request.get('labels', [])]
157+
body = pull_request.get('body') or ""
158+
return pr_number, labels, body
159+
else:
160+
print("Event is not a pull request.", file=sys.stderr)
161+
return None, [], ""
162+
except Exception as e:
163+
print(f"Error reading GITHUB_EVENT_PATH: {e}", file=sys.stderr)
164+
return None, [], ""
165+
166+
def get_pr_comments(repo, pr_number, headers):
167+
comments = []
168+
page = 1
169+
while True:
170+
url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments?per_page=100&page={page}"
171+
req = urllib.request.Request(url, headers=headers)
172+
try:
173+
with urllib.request.urlopen(req) as response:
174+
page_comments = json.loads(response.read().decode('utf-8'))
175+
if not page_comments:
176+
break
177+
comments.extend(page_comments)
178+
if len(page_comments) < 100:
179+
break
180+
page += 1
181+
except Exception as e:
182+
print(f"Error fetching comments page {page}: {e}", file=sys.stderr)
183+
break
184+
return comments
185+
186+
def post_or_update_comment(repo, pr_number, token, message):
187+
comments_url = f"https://api.github.com/repos/{repo}/issues/{pr_number}/comments"
188+
headers = {
189+
"Authorization": f"token {token}",
190+
"Accept": "application/vnd.github.v3+json",
191+
"User-Agent": "Python-urllib"
192+
}
193+
194+
comments = get_pr_comments(repo, pr_number, headers)
195+
196+
existing_comment = None
197+
for comment in comments:
198+
if SIGNATURE in comment.get('body', ''):
199+
existing_comment = comment
200+
break
201+
202+
body = f"{message}\n{SIGNATURE}"
203+
data = json.dumps({"body": body}).encode('utf-8')
204+
205+
if existing_comment:
206+
comment_id = existing_comment['id']
207+
# If the message is the same, do nothing
208+
if existing_comment.get('body') == body:
209+
print(f"Comment {comment_id} is up to date.")
210+
return
211+
212+
update_url = f"https://api.github.com/repos/{repo}/issues/comments/{comment_id}"
213+
req = urllib.request.Request(update_url, data=data, headers=headers, method='PATCH')
214+
action = f"updating existing comment {comment_id}"
215+
else:
216+
req = urllib.request.Request(comments_url, data=data, headers=headers, method='POST')
217+
action = "creating new comment"
218+
219+
print(f"Action: {action}")
220+
try:
221+
with urllib.request.urlopen(req) as response:
222+
print("Comment posted/updated successfully.")
223+
except Exception as e:
224+
print(f"Error posting/updating comment: {e}", file=sys.stderr)
225+
226+
def delete_comment_if_exists(repo, pr_number, token):
227+
headers = {
228+
"Authorization": f"token {token}",
229+
"Accept": "application/vnd.github.v3+json",
230+
"User-Agent": "Python-urllib"
231+
}
232+
233+
comments = get_pr_comments(repo, pr_number, headers)
234+
235+
for comment in comments:
236+
if SIGNATURE in comment.get('body', ''):
237+
comment_id = comment['id']
238+
delete_url = f"https://api.github.com/repos/{repo}/issues/comments/{comment_id}"
239+
req = urllib.request.Request(delete_url, headers=headers, method='DELETE')
240+
try:
241+
with urllib.request.urlopen(req) as response:
242+
print(f"Deleted existing comment {comment_id}")
243+
except Exception as e:
244+
print(f"Error deleting comment: {e}", file=sys.stderr)
245+
246+
247+
def main():
248+
modified_files = get_modified_files()
249+
print(f"Modified files: {modified_files}")
250+
251+
pr_number, labels, body = get_pr_details()
252+
print(f"PR number: {pr_number}, Labels: {labels}")
253+
if body:
254+
print(f"PR body preview: {body[:100]}...")
255+
else:
256+
print("PR body is empty or not available.")
257+
258+
declared_trivial = "no-changelog" in labels or "NO_RELEASE_CHANGE" in body
259+
has_changelog_changes = has_changes_in(["CHANGELOG"], modified_files)
260+
sdk_changes = has_sdk_changes(modified_files)
261+
262+
print(f"SDK changes: {sdk_changes}")
263+
print(f"Changelog changes: {has_changelog_changes}")
264+
print(f"Declared trivial: {declared_trivial}")
265+
266+
warning_needed = sdk_changes and not has_changelog_changes and not declared_trivial
267+
268+
token = os.environ.get('GITHUB_TOKEN')
269+
repo = os.environ.get('GITHUB_REPOSITORY')
270+
271+
warning_message = (
272+
"Did you forget to add a changelog entry? "
273+
"(Add the 'no-changelog' label to the PR or 'NO_RELEASE_CHANGE' to the description to silence this warning.)"
274+
)
275+
276+
if warning_needed:
277+
print(f"WARNING: {warning_message}")
278+
# Also output as GitHub Actions warning annotation
279+
print(f"::warning::{warning_message}")
280+
281+
if token and repo and pr_number:
282+
post_or_update_comment(repo, pr_number, token, warning_message)
283+
else:
284+
print("Missing token, repo, or PR number. Skipping PR comment.", file=sys.stderr)
285+
else:
286+
print("No warning needed.")
287+
if token and repo and pr_number:
288+
delete_comment_if_exists(repo, pr_number, token)
289+
290+
if __name__ == "__main__":
291+
main()

0 commit comments

Comments
 (0)