Skip to content

Commit ce665ce

Browse files
committed
ci: add member check
Verify that collaborators/maintainers added to the MAINTAINERS.yml file actually have access to the project and are members. Only those who already gained access following the process shall be added to the file. Signed-off-by: Anas Nashif <[email protected]>
1 parent ea29907 commit ce665ce

File tree

3 files changed

+267
-0
lines changed

3 files changed

+267
-0
lines changed
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
name: Maintainer file check
2+
3+
on:
4+
pull_request:
5+
branches:
6+
- main
7+
- collab-*
8+
- v*-branch
9+
paths:
10+
- MAINTAINERS.yml
11+
12+
permissions:
13+
contents: read
14+
15+
jobs:
16+
assignment:
17+
name: Check MAINTAINERS.yml changes
18+
runs-on: ubuntu-24.04
19+
20+
steps:
21+
- name: Check out source code
22+
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
23+
24+
- name: Set up Python
25+
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
26+
with:
27+
python-version: 3.12
28+
cache: pip
29+
cache-dependency-path: scripts/requirements-actions.txt
30+
31+
- name: Install Python packages
32+
run: |
33+
pip install -r scripts/requirements-actions.txt --require-hashes
34+
35+
- name: Fetch MAINTAINERS.yml from mainline
36+
run: |
37+
git fetch origin main
38+
git show origin/main:MAINTAINERS.yml > mainline_MAINTAINERS.yml
39+
40+
- name: Check maintainer file changes
41+
env:
42+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
43+
run: |
44+
python ./scripts/ci/check_maintainer_changes.py \
45+
--repo zephyrproject-rtos/zephyr-testing mainline_MAINTAINERS.yml MAINTAINERS.yml
Lines changed: 220 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,220 @@
1+
#!/usr/bin/env python3
2+
3+
# SPDX-License-Identifier: Apache-2.0
4+
# Copyright The Zephyr Project Contributors
5+
6+
import argparse
7+
import os
8+
import sys
9+
10+
import yaml
11+
from github import Github
12+
13+
14+
def load_areas(filename: str):
15+
with open(filename) as f:
16+
doc = yaml.safe_load(f)
17+
return {
18+
k: v for k, v in doc.items() if isinstance(v, dict) and ("files" in v or "files-regex" in v)
19+
}
20+
21+
22+
def set_or_empty(d, key):
23+
return set(d.get(key, []) or [])
24+
25+
26+
def check_github_access(usernames, repo_fullname, token):
27+
"""Check if each username has at least Triage access to the repo."""
28+
gh = Github(token)
29+
repo = gh.get_repo(repo_fullname)
30+
missing_access = set()
31+
for username in usernames:
32+
try:
33+
collab = repo.get_collaborator_permission(username)
34+
# Permissions: admin, maintain, write, triage, read
35+
if collab not in ("admin", "maintain", "write", "triage"):
36+
missing_access.add(username)
37+
except Exception:
38+
missing_access.add(username)
39+
return missing_access
40+
41+
42+
def compare_areas(old, new, repo_fullname=None, token=None):
43+
old_areas = set(old.keys())
44+
new_areas = set(new.keys())
45+
46+
added_areas = new_areas - old_areas
47+
removed_areas = old_areas - new_areas
48+
common_areas = old_areas & new_areas
49+
50+
all_added_maintainers = set()
51+
all_added_collaborators = set()
52+
53+
print("=== Areas Added ===")
54+
for area in sorted(added_areas):
55+
print(f"+ {area}")
56+
entry = new[area]
57+
all_added_maintainers.update(set_or_empty(entry, "maintainers"))
58+
all_added_collaborators.update(set_or_empty(entry, "collaborators"))
59+
60+
print("\n=== Areas Removed ===")
61+
for area in sorted(removed_areas):
62+
print(f"- {area}")
63+
64+
print("\n=== Area Changes ===")
65+
for area in sorted(common_areas):
66+
changes = []
67+
old_entry = old[area]
68+
new_entry = new[area]
69+
70+
# Compare maintainers
71+
old_maint = set_or_empty(old_entry, "maintainers")
72+
new_maint = set_or_empty(new_entry, "maintainers")
73+
added_maint = new_maint - old_maint
74+
removed_maint = old_maint - new_maint
75+
if added_maint:
76+
changes.append(f" Maintainers added: {', '.join(sorted(added_maint))}")
77+
all_added_maintainers.update(added_maint)
78+
if removed_maint:
79+
changes.append(f" Maintainers removed: {', '.join(sorted(removed_maint))}")
80+
81+
# Compare collaborators
82+
old_collab = set_or_empty(old_entry, "collaborators")
83+
new_collab = set_or_empty(new_entry, "collaborators")
84+
added_collab = new_collab - old_collab
85+
removed_collab = old_collab - new_collab
86+
if added_collab:
87+
changes.append(f" Collaborators added: {', '.join(sorted(added_collab))}")
88+
all_added_collaborators.update(added_collab)
89+
if removed_collab:
90+
changes.append(f" Collaborators removed: {', '.join(sorted(removed_collab))}")
91+
92+
# Compare status
93+
old_status = old_entry.get("status")
94+
new_status = new_entry.get("status")
95+
if old_status != new_status:
96+
changes.append(f" Status changed: {old_status} -> {new_status}")
97+
98+
# Compare labels
99+
old_labels = set_or_empty(old_entry, "labels")
100+
new_labels = set_or_empty(new_entry, "labels")
101+
added_labels = new_labels - old_labels
102+
removed_labels = old_labels - new_labels
103+
if added_labels:
104+
changes.append(f" Labels added: {', '.join(sorted(added_labels))}")
105+
if removed_labels:
106+
changes.append(f" Labels removed: {', '.join(sorted(removed_labels))}")
107+
108+
# Compare files
109+
old_files = set_or_empty(old_entry, "files")
110+
new_files = set_or_empty(new_entry, "files")
111+
added_files = new_files - old_files
112+
removed_files = old_files - new_files
113+
if added_files:
114+
changes.append(f" Files added: {', '.join(sorted(added_files))}")
115+
if removed_files:
116+
changes.append(f" Files removed: {', '.join(sorted(removed_files))}")
117+
118+
# Compare files-regex
119+
old_regex = set_or_empty(old_entry, "files-regex")
120+
new_regex = set_or_empty(new_entry, "files-regex")
121+
added_regex = new_regex - old_regex
122+
removed_regex = old_regex - new_regex
123+
if added_regex:
124+
changes.append(f" files-regex added: {', '.join(sorted(added_regex))}")
125+
if removed_regex:
126+
changes.append(f" files-regex removed: {', '.join(sorted(removed_regex))}")
127+
128+
if changes:
129+
print(f"* {area}")
130+
for c in changes:
131+
print(c)
132+
133+
print("\n=== Summary ===")
134+
print(f"Total areas added: {len(added_areas)}")
135+
print(f"Total maintainers added: {len(all_added_maintainers)}")
136+
if all_added_maintainers:
137+
print(" Added maintainers: " + ", ".join(sorted(all_added_maintainers)))
138+
print(f"Total collaborators added: {len(all_added_collaborators)}")
139+
if all_added_collaborators:
140+
print(" Added collaborators: " + ", ".join(sorted(all_added_collaborators)))
141+
142+
# Check GitHub access if repo and token are provided
143+
144+
print("\n=== GitHub Access Check ===")
145+
missing_maint = check_github_access(all_added_maintainers, repo_fullname, token)
146+
missing_collab = check_github_access(all_added_collaborators, repo_fullname, token)
147+
if missing_maint:
148+
print("Maintainers without at least triage access:")
149+
for u in sorted(missing_maint):
150+
print(f" - {u}")
151+
if missing_collab:
152+
print("Collaborators without at least triage access:")
153+
for u in sorted(missing_collab):
154+
print(f" - {u}")
155+
if not missing_maint and not missing_collab:
156+
print("All added maintainers and collaborators have required access.")
157+
else:
158+
print("Some added maintainers or collaborators do not have sufficient access.")
159+
160+
# --- GitHub Actions inline annotation ---
161+
# Try to find the line number in the new file for each missing user
162+
def find_line_for_user(yaml_file, user_set):
163+
"""Return a dict of user -> line number in yaml_file for missing users."""
164+
user_lines = {}
165+
try:
166+
with open(yaml_file) as f:
167+
lines = f.readlines()
168+
for idx, line in enumerate(lines, 1):
169+
for user in user_set:
170+
if user in line:
171+
user_lines[user] = idx
172+
return user_lines
173+
except Exception:
174+
return {}
175+
176+
all_missing_users = missing_maint | missing_collab
177+
user_lines = find_line_for_user(args.new, all_missing_users)
178+
179+
for user, line in user_lines.items():
180+
print(
181+
f"::error file={args.new},line={line},title=User lacks access::"
182+
f"{user} does not have needed to {repo_fullname}"
183+
)
184+
185+
# For any missing users not found in the file, print a general error
186+
for user in sorted(all_missing_users - set(user_lines)):
187+
print(
188+
f"::error title=User lacks access::{user} does not have needed "
189+
f"access to {repo_fullname}"
190+
)
191+
192+
sys.exit(1)
193+
194+
195+
def main():
196+
parser = argparse.ArgumentParser(
197+
description="Compare two MAINTAINERS.yml files and show changes in areas, "
198+
"maintainers, collaborators, etc.",
199+
allow_abbrev=False,
200+
)
201+
parser.add_argument("old", help="Old MAINTAINERS.yml file")
202+
parser.add_argument("new", help="New MAINTAINERS.yml file")
203+
parser.add_argument("--repo", help="GitHub repository in org/repo format for access check")
204+
parser.add_argument("--token", help="GitHub token for API access (required for access check)")
205+
global args
206+
args = parser.parse_args()
207+
208+
old_areas = load_areas(args.old)
209+
new_areas = load_areas(args.new)
210+
token = os.environ.get("GITHUB_TOKEN") or args.token
211+
212+
if not token or not args.repo:
213+
print("GitHub token and repository are required for access check.")
214+
sys.exit(1)
215+
216+
compare_areas(old_areas, new_areas, repo_fullname=args.repo, token=token)
217+
218+
219+
if __name__ == "__main__":
220+
main()

scripts/ci/twister_ignore.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,5 @@ scripts/make_bugs_pickle.py
5555
scripts/set_assignees.py
5656
scripts/gitlint/zephyr_commit_rules.py
5757
scripts/west_commands/runners/canopen_program.py
58+
scripts/ci/check_maintainer_changes.py
59+
.github/workflows/maintainer_check.yml

0 commit comments

Comments
 (0)