diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 0f200aea84..cd1300bc62 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1,31 +1,34 @@ -# Default: catch-all -* @garrett4wade +# Default: catch-all (lead + two active maintainers as backups). +# Last matching rule wins, so this is the fallback for any path +# not covered by a more specific rule below. +* @garrett4wade @sitabulaixizawaluduo @fishcrap # Core package -/areal/api/ @garrett4wade -/areal/engine/ @rchardx -/areal/experimental/inference_service @nuzant -/areal/experimental/agent_service/ @CormickKneey -/areal/infra/ @garrett4wade -/areal/infra/scheduler/ray.py @HwVanICI -/areal/models/ @rchardx -/areal/trainer/ @garrett4wade +/areal/api/ @garrett4wade @rchardx @sitabulaixizawaluduo +/areal/engine/ @rchardx @nuzant @garrett4wade @geshi001 +/areal/experimental/inference_service/ @nuzant @guozhihao-224 @TaoZex +/areal/experimental/agent_service/ @CormickKneey @nuzant +/areal/experimental/training_service/ @sitabulaixizawaluduo @garrett4wade +/areal/experimental/weight_update/ @TaoZex @garrett4wade @sitabulaixizawaluduo +/areal/infra/ @HwVanICI @garrett4wade @guozhihao-224 +/areal/models/ @rchardx @nuzant @geshi001 @PrometheusComing +/areal/trainer/ @garrett4wade @rchardx @fishcrap # Tests & Examples -/tests/ @garrett4wade @rchardx @nuzant -/examples/ @garrett4wade +/tests/ @garrett4wade @sitabulaixizawaluduo +/examples/ @zhenanf @HwVanICI @CormickKneey @PrometheusComing # Documentation -/docs/ @garrett4wade +/docs/ @garrett4wade @nuzant # CI/CD & infrastructure -/.github/ @garrett4wade @nuzant -/Dockerfile @garrett4wade @fishcrap -pyproject.toml @garrett4wade @fishcrap -pyproject.vllm.toml @garrett4wade @fishcrap -uv.lock @garrett4wade @fishcrap -uv.vllm.lock @garrett4wade @fishcrap +/.github/ @garrett4wade @nuzant @sitabulaixizawaluduo +/Dockerfile @garrett4wade @fishcrap @sitabulaixizawaluduo +pyproject.toml @garrett4wade @fishcrap @sitabulaixizawaluduo +pyproject.vllm.toml @garrett4wade @fishcrap @sitabulaixizawaluduo +uv.lock @garrett4wade @fishcrap @sitabulaixizawaluduo +uv.vllm.lock @garrett4wade @fishcrap @sitabulaixizawaluduo # Governance & community -GOVERNANCE.md @garrett4wade -CONTRIBUTING.md @garrett4wade +GOVERNANCE.md @garrett4wade @sitabulaixizawaluduo +CONTRIBUTING.md @garrett4wade @sitabulaixizawaluduo diff --git a/.github/ruleset.json b/.github/ruleset.json new file mode 100644 index 0000000000..1ecd2978d4 --- /dev/null +++ b/.github/ruleset.json @@ -0,0 +1,42 @@ +{ + "name": "main-protection", + "target": "branch", + "enforcement": "active", + "conditions": { + "ref_name": { + "include": ["~DEFAULT_BRANCH"], + "exclude": [] + } + }, + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "pull_request" + } + ], + "rules": [ + { "type": "deletion" }, + { "type": "non_fast_forward" }, + { "type": "required_linear_history" }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 2, + "dismiss_stale_reviews_on_push": true, + "require_code_owner_review": true, + "require_last_push_approval": true, + "required_review_thread_resolution": true + } + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": false, + "required_status_checks": [ + { "context": "pre-commit" } + ] + } + } + ] +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ffde12834a..27bd0c8040 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -110,6 +110,18 @@ repos: files: ^areal/.*\.py$ types: [python] + # Format and lint .github/CODEOWNERS + - repo: local + hooks: + - id: format-codeowners + name: Format CODEOWNERS + entry: python3 areal/tools/format_codeowners.py + language: system + files: ^\.github/CODEOWNERS$ + pass_filenames: false + always_run: false + require_serial: true + # Conventional Commits message check - repo: https://github.com/compilerla/conventional-pre-commit rev: v4.4.0 diff --git a/GOVERNANCE.md b/GOVERNANCE.md index c1a0d55819..133962b020 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -12,9 +12,10 @@ considered a contributor. All contributors are expected to follow the ### Maintainers -Maintainers have write access to the repository and are responsible for reviewing and -merging pull requests, triaging issues, and guiding the technical direction of the -project. +Maintainers have write access to the repository and are responsible for reviewing pull +requests, triaging issues, and guiding the technical direction of the project. Two +maintainer approvals are required to merge a pull request (see +[Decision-Making](#decision-making)). | Name | Organization | GitHub | | ------------ | ------------------------- | --------------------- | @@ -36,7 +37,11 @@ project. Wei Fu ([@garrett4wade](https://github.com/garrett4wade)) serves as the lead maintainer. The lead maintainer has final authority on technical decisions when maintainers cannot -reach consensus. +reach consensus, and holds the repository **administrator** role on GitHub. As an +administrator, the lead maintainer may bypass the two-approval requirement to merge +trivial changes (typo fixes, documentation-only edits, dependency bumps verified by CI) +or time-sensitive hotfixes. Bypasses must still go through a pull request and should be +disclosed in the PR description. ### Community Moderators @@ -51,9 +56,15 @@ cannot be reached, the lead maintainer makes the final decision. Pull request approval policy: -- Bug fixes or minor improvements: approved by at least one maintainer. -- New features, architectural changes, or API modifications: approved by at least two - maintainers or the lead maintainer. +- All pull requests require approval from at least **two maintainers** before they can + be merged. At least one approval must come from a code owner of the modified paths + (see [`.github/CODEOWNERS`](.github/CODEOWNERS)). +- The **lead maintainer**, acting as repository administrator, may bypass the + two-approval requirement for trivial or time-sensitive changes as described in the + [Lead Maintainer](#lead-maintainer-bdfl) section. +- The branch protection rules on `main` are documented in + [`.github/ruleset.json`](.github/ruleset.json) and are the source of truth for + mechanical enforcement of this policy. ## Becoming a Maintainer diff --git a/areal/tools/format_codeowners.py b/areal/tools/format_codeowners.py new file mode 100644 index 0000000000..f7275b1a43 --- /dev/null +++ b/areal/tools/format_codeowners.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +# SPDX-License-Identifier: Apache-2.0 + +"""Pre-commit hook that formats and lints .github/CODEOWNERS. + +Behavior: +- Strips trailing whitespace and ensures a single trailing newline. +- Re-aligns the owners column so every rule line has the owners list starting + at the same column (computed from the longest path in the file). +- Preserves trailing inline comments (e.g. ``/path @owner @owner2 # note``) + verbatim, separated from the owners by two spaces. +- Validates that every owner token starts with '@' and contains only valid + GitHub username/team characters. +- Errors on duplicate path patterns. +- Warns (does not fail) on rules with fewer than two owners, since the + governance policy expects ownership to degrade gracefully when a single + owner is unavailable. + +Following the repo's other local hooks, the script exits non-zero if it had +to rewrite the file so CI flags the diff and the developer commits the fix. +""" + +from __future__ import annotations + +import re +import sys +from pathlib import Path + +CODEOWNERS_PATH = Path(".github/CODEOWNERS") +OWNER_RE = re.compile( + r"^@[A-Za-z0-9](?:[A-Za-z0-9._-]*[A-Za-z0-9])?(?:/[A-Za-z0-9._-]+)?$" +) +INLINE_COMMENT_RE = re.compile(r"\s#.*$") +MIN_COLUMN = 32 + + +def format_codeowners(path: Path) -> int: + """Return 0 if no changes needed, 1 if the file was rewritten, 2 on error.""" + if not path.is_file(): + print(f"ERROR: {path} not found", file=sys.stderr) + return 2 + + original = path.read_text(encoding="utf-8") + raw_lines = original.splitlines() + + parsed: list[tuple[str, ...]] = [] + longest_path = 0 + errors: list[str] = [] + + for lineno, line in enumerate(raw_lines, 1): + stripped = line.rstrip() + if not stripped or stripped.lstrip().startswith("#"): + parsed.append(("raw", stripped)) + continue + + # Split off any trailing inline comment (whitespace then '#'); the + # comment is preserved verbatim and re-emitted after the owners. + m = INLINE_COMMENT_RE.search(stripped) + if m: + inline_comment = m.group(0).strip() + rule_part = stripped[: m.start()].rstrip() + else: + inline_comment = "" + rule_part = stripped + + tokens = rule_part.split() + if len(tokens) < 2: + errors.append(f"line {lineno}: rule has no owners: {stripped!r}") + continue + + path_pat, *owners = tokens + for owner in owners: + if not OWNER_RE.match(owner): + errors.append(f"line {lineno}: invalid owner token {owner!r}") + + parsed.append(("rule", path_pat, tuple(owners), inline_comment, lineno)) + longest_path = max(longest_path, len(path_pat)) + + if errors: + for err in errors: + print(f"ERROR: {err}", file=sys.stderr) + return 2 + + column = max(MIN_COLUMN, ((longest_path + 5) // 4) * 4) + + seen_paths: dict[str, int] = {} + rendered: list[str] = [] + duplicate_errors: list[str] = [] + single_owner_warnings: list[str] = [] + + for entry in parsed: + if entry[0] == "raw": + rendered.append(entry[1]) + continue + + _, path_pat, owners, inline_comment, lineno = entry + if path_pat in seen_paths: + duplicate_errors.append( + f"line {lineno}: duplicate path pattern {path_pat!r} " + f"(first defined on line {seen_paths[path_pat]})" + ) + continue + seen_paths[path_pat] = lineno + + if len(owners) < 2: + single_owner_warnings.append( + f"line {lineno}: {path_pat} has only one owner ({owners[0]}); " + f"governance expects >=2 to avoid single points of failure" + ) + + rule_line = f"{path_pat.ljust(column)}{' '.join(owners)}" + if inline_comment: + rule_line = f"{rule_line} {inline_comment}" + rendered.append(rule_line) + + if duplicate_errors: + for err in duplicate_errors: + print(f"ERROR: {err}", file=sys.stderr) + return 2 + + for warn in single_owner_warnings: + print(f"WARN: {warn}", file=sys.stderr) + + new = "\n".join(rendered).rstrip("\n") + "\n" + if new != original: + path.write_text(new, encoding="utf-8") + print(f"Rewrote {path} (column {column})", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(format_codeowners(CODEOWNERS_PATH))