Skip to content

Commit 61ae5e9

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 <anas.nashif@intel.com>
1 parent ea29907 commit 61ae5e9

File tree

2 files changed

+284
-0
lines changed

2 files changed

+284
-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 mainline_MAINTAINERS.yml MAINTAINERS.yml
Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
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, key):
163+
"""Return a dict of user -> line number in yaml_file for the given key."""
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+
# Look for the key (maintainers/collaborators) and user on the same
171+
# or next lines
172+
if key in line:
173+
# Check next few lines for the user
174+
for look_ahead in range(1, 5):
175+
line_idx = idx + look_ahead - 1
176+
if (
177+
line_idx < len(lines)
178+
and user in lines[line_idx]
179+
):
180+
user_lines[user] = idx + look_ahead
181+
return user_lines
182+
except Exception:
183+
return {}
184+
185+
maint_lines = find_line_for_user(args.new, missing_maint, "maintainers")
186+
collab_lines = find_line_for_user(args.new, missing_collab, "collaborators")
187+
188+
for user, line in maint_lines.items():
189+
print(
190+
f"::error file={args.new},line={line},title=Maintainer lacks access::"
191+
f"{user} does not have at least triage access to {repo_fullname}"
192+
)
193+
for user, line in collab_lines.items():
194+
print(
195+
f"::error file={args.new},line={line},title=Collaborator lacks access::"
196+
f"{user} does not have at least triage access to {repo_fullname}"
197+
)
198+
199+
# For any missing users not found in the file, print a general error
200+
for user in sorted(missing_maint - set(maint_lines)):
201+
print(
202+
f"::error title=Maintainer lacks access::{user} does not have at least "
203+
f"triage access to {repo_fullname}"
204+
)
205+
for user in sorted(missing_collab - set(collab_lines)):
206+
print(
207+
f"::error title=Collaborator lacks access::{user} does not have at least "
208+
f"triage access to {repo_fullname}"
209+
)
210+
211+
sys.exit(1)
212+
213+
214+
def main():
215+
parser = argparse.ArgumentParser(
216+
description="Compare two MAINTAINERS.yml files and show changes in areas, "
217+
"maintainers, collaborators, etc.",
218+
allow_abbrev=False,
219+
)
220+
parser.add_argument("old", help="Old MAINTAINERS.yml file")
221+
parser.add_argument("new", help="New MAINTAINERS.yml file")
222+
parser.add_argument("--repo", help="GitHub repository in org/repo format for access check")
223+
parser.add_argument("--token", help="GitHub token for API access (required for access check)")
224+
global args
225+
args = parser.parse_args()
226+
227+
old_areas = load_areas(args.old)
228+
new_areas = load_areas(args.new)
229+
token = os.environ.get("GITHUB_TOKEN") or args.token
230+
231+
if not token or not args.repo:
232+
print("GitHub token and repository are required for access check.")
233+
sys.exit(1)
234+
235+
compare_areas(old_areas, new_areas, repo_fullname=args.repo, token=token)
236+
237+
238+
if __name__ == "__main__":
239+
main()

0 commit comments

Comments
 (0)