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>
This commit is contained in:
Anas Nashif 2025-06-14 06:20:40 -04:00
commit 43971ea0d5
3 changed files with 265 additions and 0 deletions

43
.github/workflows/maintainer_check.yml vendored Normal file
View file

@ -0,0 +1,43 @@
name: Maintainer file check
on:
pull_request:
branches:
- main
paths:
- MAINTAINERS.yml
permissions:
contents: read
jobs:
assignment:
name: Check MAINTAINERS.yml changes
runs-on: ubuntu-24.04
steps:
- name: Check out source code
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Set up Python
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0
with:
python-version: 3.12
cache: pip
cache-dependency-path: scripts/requirements-actions.txt
- name: Install Python packages
run: |
pip install -r scripts/requirements-actions.txt --require-hashes
- name: Fetch MAINTAINERS.yml from mainline
run: |
git fetch origin main
git show origin/main:MAINTAINERS.yml > mainline_MAINTAINERS.yml
- name: Check maintainer file changes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python ./scripts/ci/check_maintainer_changes.py \
--repo zephyrproject-rtos/zephyr mainline_MAINTAINERS.yml MAINTAINERS.yml

View file

@ -0,0 +1,220 @@
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
# Copyright The Zephyr Project Contributors
import argparse
import os
import sys
import yaml
from github import Github
def load_areas(filename: str):
with open(filename) as f:
doc = yaml.safe_load(f)
return {
k: v for k, v in doc.items() if isinstance(v, dict) and ("files" in v or "files-regex" in v)
}
def set_or_empty(d, key):
return set(d.get(key, []) or [])
def check_github_access(usernames, repo_fullname, token):
"""Check if each username has at least Triage access to the repo."""
gh = Github(token)
repo = gh.get_repo(repo_fullname)
missing_access = set()
for username in usernames:
try:
collab = repo.get_collaborator_permission(username)
# Permissions: admin, maintain, write, triage, read
if collab not in ("admin", "maintain", "write", "triage"):
missing_access.add(username)
except Exception:
missing_access.add(username)
return missing_access
def compare_areas(old, new, repo_fullname=None, token=None):
old_areas = set(old.keys())
new_areas = set(new.keys())
added_areas = new_areas - old_areas
removed_areas = old_areas - new_areas
common_areas = old_areas & new_areas
all_added_maintainers = set()
all_added_collaborators = set()
print("=== Areas Added ===")
for area in sorted(added_areas):
print(f"+ {area}")
entry = new[area]
all_added_maintainers.update(set_or_empty(entry, "maintainers"))
all_added_collaborators.update(set_or_empty(entry, "collaborators"))
print("\n=== Areas Removed ===")
for area in sorted(removed_areas):
print(f"- {area}")
print("\n=== Area Changes ===")
for area in sorted(common_areas):
changes = []
old_entry = old[area]
new_entry = new[area]
# Compare maintainers
old_maint = set_or_empty(old_entry, "maintainers")
new_maint = set_or_empty(new_entry, "maintainers")
added_maint = new_maint - old_maint
removed_maint = old_maint - new_maint
if added_maint:
changes.append(f" Maintainers added: {', '.join(sorted(added_maint))}")
all_added_maintainers.update(added_maint)
if removed_maint:
changes.append(f" Maintainers removed: {', '.join(sorted(removed_maint))}")
# Compare collaborators
old_collab = set_or_empty(old_entry, "collaborators")
new_collab = set_or_empty(new_entry, "collaborators")
added_collab = new_collab - old_collab
removed_collab = old_collab - new_collab
if added_collab:
changes.append(f" Collaborators added: {', '.join(sorted(added_collab))}")
all_added_collaborators.update(added_collab)
if removed_collab:
changes.append(f" Collaborators removed: {', '.join(sorted(removed_collab))}")
# Compare status
old_status = old_entry.get("status")
new_status = new_entry.get("status")
if old_status != new_status:
changes.append(f" Status changed: {old_status} -> {new_status}")
# Compare labels
old_labels = set_or_empty(old_entry, "labels")
new_labels = set_or_empty(new_entry, "labels")
added_labels = new_labels - old_labels
removed_labels = old_labels - new_labels
if added_labels:
changes.append(f" Labels added: {', '.join(sorted(added_labels))}")
if removed_labels:
changes.append(f" Labels removed: {', '.join(sorted(removed_labels))}")
# Compare files
old_files = set_or_empty(old_entry, "files")
new_files = set_or_empty(new_entry, "files")
added_files = new_files - old_files
removed_files = old_files - new_files
if added_files:
changes.append(f" Files added: {', '.join(sorted(added_files))}")
if removed_files:
changes.append(f" Files removed: {', '.join(sorted(removed_files))}")
# Compare files-regex
old_regex = set_or_empty(old_entry, "files-regex")
new_regex = set_or_empty(new_entry, "files-regex")
added_regex = new_regex - old_regex
removed_regex = old_regex - new_regex
if added_regex:
changes.append(f" files-regex added: {', '.join(sorted(added_regex))}")
if removed_regex:
changes.append(f" files-regex removed: {', '.join(sorted(removed_regex))}")
if changes:
print(f"* {area}")
for c in changes:
print(c)
print("\n=== Summary ===")
print(f"Total areas added: {len(added_areas)}")
print(f"Total maintainers added: {len(all_added_maintainers)}")
if all_added_maintainers:
print(" Added maintainers: " + ", ".join(sorted(all_added_maintainers)))
print(f"Total collaborators added: {len(all_added_collaborators)}")
if all_added_collaborators:
print(" Added collaborators: " + ", ".join(sorted(all_added_collaborators)))
# Check GitHub access if repo and token are provided
print("\n=== GitHub Access Check ===")
missing_maint = check_github_access(all_added_maintainers, repo_fullname, token)
missing_collab = check_github_access(all_added_collaborators, repo_fullname, token)
if missing_maint:
print("Maintainers without at least triage access:")
for u in sorted(missing_maint):
print(f" - {u}")
if missing_collab:
print("Collaborators without at least triage access:")
for u in sorted(missing_collab):
print(f" - {u}")
if not missing_maint and not missing_collab:
print("All added maintainers and collaborators have required access.")
else:
print("Some added maintainers or collaborators do not have sufficient access.")
# --- GitHub Actions inline annotation ---
# Try to find the line number in the new file for each missing user
def find_line_for_user(yaml_file, user_set):
"""Return a dict of user -> line number in yaml_file for missing users."""
user_lines = {}
try:
with open(yaml_file) as f:
lines = f.readlines()
for idx, line in enumerate(lines, 1):
for user in user_set:
if user in line:
user_lines[user] = idx
return user_lines
except Exception:
return {}
all_missing_users = missing_maint | missing_collab
user_lines = find_line_for_user(args.new, all_missing_users)
for user, line in user_lines.items():
print(
f"::error file={args.new},line={line},title=User lacks access::"
f"{user} does not have needed access level to {repo_fullname}"
)
# For any missing users not found in the file, print a general error
for user in sorted(all_missing_users - set(user_lines)):
print(
f"::error title=User lacks access::{user} does not have needed "
f"access level to {repo_fullname}"
)
sys.exit(1)
def main():
parser = argparse.ArgumentParser(
description="Compare two MAINTAINERS.yml files and show changes in areas, "
"maintainers, collaborators, etc.",
allow_abbrev=False,
)
parser.add_argument("old", help="Old MAINTAINERS.yml file")
parser.add_argument("new", help="New MAINTAINERS.yml file")
parser.add_argument("--repo", help="GitHub repository in org/repo format for access check")
parser.add_argument("--token", help="GitHub token for API access (required for access check)")
global args
args = parser.parse_args()
old_areas = load_areas(args.old)
new_areas = load_areas(args.new)
token = os.environ.get("GITHUB_TOKEN") or args.token
if not token or not args.repo:
print("GitHub token and repository are required for access check.")
sys.exit(1)
compare_areas(old_areas, new_areas, repo_fullname=args.repo, token=token)
if __name__ == "__main__":
main()

View file

@ -55,3 +55,5 @@ scripts/make_bugs_pickle.py
scripts/set_assignees.py
scripts/gitlint/zephyr_commit_rules.py
scripts/west_commands/runners/canopen_program.py
scripts/ci/check_maintainer_changes.py
.github/workflows/maintainer_check.yml