From ae8bc2175b93099cb1b9d75009c3a0a7c2f63503 Mon Sep 17 00:00:00 2001 From: show <10173746+showxu@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:24:58 +0800 Subject: [PATCH] Declare GitHub settings and check them against the organization MAINTENANCE.md lists the expected repository settings, rulesets, GitHub Apps and Actions credentials; Scripts/check-settings.py compares them with the live organization through the owner's gh login. The Apps take names that say what they do: Computer MCP Release Notifier and Computer MCP Updater. --- AGENTS.md | 2 + MAINTENANCE.md | 30 ++++++- README.md | 2 + Scripts/check-settings.py | 159 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 2 deletions(-) create mode 100755 Scripts/check-settings.py diff --git a/AGENTS.md b/AGENTS.md index 07c27c1..e4dc9b8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -70,6 +70,8 @@ Use this check: - For the public organization entry point, edit `profile/README.md`. - For conventions shared by every repository, read `MAINTENANCE.md`. +- For repository settings, GitHub Apps and Actions credentials, change + `MAINTENANCE.md` with the setting, then run `python3 Scripts/check-settings.py`. - For visual identity, read `DESIGN.md`; the renderer, its configuration and the rendered exports live in `Brand/`, and `README.md` describes rendering and delivery. diff --git a/MAINTENANCE.md b/MAINTENANCE.md index d9c4da9..00131e6 100644 --- a/MAINTENANCE.md +++ b/MAINTENANCE.md @@ -17,7 +17,7 @@ only what they share. commits, and accepts only squash-merged pull requests whose required checks pass. Automation proposes its changes as pull requests that merge automatically once those checks pass. It acts through the Computer MCP - Automation GitHub App, installed only where automation proposes changes: the + Updater GitHub App, installed only where automation proposes changes: the App's installation token has GitHub create each proposal commit through the API, which GitHub signs, and opens the pull request, whose checks then run on their own. A commit made with `git commit` in a workflow is unsigned, and a @@ -62,7 +62,33 @@ Versions follow [Semantic Versioning 2.0.0](https://semver.org/): Each plugin declares `minimum_host` in `computer-mcp-plugin.toml`. After a release, its `notify-catalog.yml` workflow asks the website to refresh the -plugin catalog. +plugin catalog through the Computer MCP Release Notifier GitHub App. + +## GitHub settings + +`python3 Scripts/check-settings.py` compares the organization's live settings +with this section and the rulesets above, and lists every difference. Run it +with an organization owner's `gh` login after changing a repository setting, +an App or an Actions credential, and update this section in the same change. + +- Every repository allows only squash merges and deletes merged branches. Its + workflows, like the organization default, get a read-only token that cannot + approve pull requests. +- Each public repository's `default branch` ruleset requires at least one + check; each repository's CI owns which. +- Only these GitHub Apps are installed. Each has exactly the listed + permissions, is installed only on the listed repositories and subscribes to + no events: + +| App | Permissions | Installed on | Credentials | Stored in | +| --- | --- | --- | --- | --- | +| `computer-mcp-release-notifier` | `actions: write` | `computer-mcp.github.io` | `CATALOG_APP` | `plugin-claude`, `plugin-codex`, `plugin-computer-use`, `plugin-cursor`, `plugin-swift-format` | +| `computer-mcp-updater` | `contents: write`, `pull_requests: write` | `computer-mcp.github.io`, `homebrew-tap` | `AUTOMATION_APP` | `computer-mcp.github.io`, `homebrew-tap` | + +- A repository that uses an App keeps the App's client ID in the + `_CLIENT_ID` variable and its private key in the + `_PRIVATE_KEY` secret. No repository and not the organization + holds any other Actions secret or variable. ## Repository layout diff --git a/README.md b/README.md index 81529d4..c52f0e1 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ conventions every Computer MCP repository shares. (`CODE_OF_CONDUCT.md`, `CONTRIBUTING.md`, `GOVERNANCE.md`, `SECURITY.md`, `SUPPORT.md`, `.github/ISSUE_TEMPLATE/` and the pull request template) apply to every repository without its own version. +- `Scripts/check-settings.py` compares the organization's live GitHub settings, + Apps and Actions credentials with `MAINTENANCE.md`. - `DESIGN.md` is the design system: tokens, components, the icon family and composition rules. - `Brand/` holds the renderer, its configuration and the rendered `Exports/` diff --git a/Scripts/check-settings.py b/Scripts/check-settings.py new file mode 100755 index 0000000..bcf4c2f --- /dev/null +++ b/Scripts/check-settings.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Compare the organization's live GitHub settings with MAINTENANCE.md. + + python3 Scripts/check-settings.py list every difference, using an organization owner's gh login +""" + +import json +import re +import subprocess +import sys +from pathlib import Path + +ORG = "computer-mcp" +MAINTENANCE = Path(__file__).resolve().parent.parent / "MAINTENANCE.md" +SECTION = "## GitHub settings" +COLUMNS = ["App", "Permissions", "Installed on", "Credentials", "Stored in"] +READ_ONLY = {"default_workflow_permissions": "read", "can_approve_pull_request_reviews": False} +MERGES = { + "allow_squash_merge": True, + "allow_merge_commit": False, + "allow_rebase_merge": False, + "delete_branch_on_merge": True, +} +RULES = {"deletion", "non_fast_forward", "required_signatures", "pull_request", "required_status_checks"} + + +class ApiError(Exception): + pass + + +def gh(path): + result = subprocess.run(["gh", "api", path], capture_output=True, text=True) + if result.returncode: + raise ApiError(f"gh api {path}: {result.stderr.strip() or result.stdout.strip()}") + return json.loads(result.stdout) + + +def expected_apps(): + text = MAINTENANCE.read_text() + if SECTION not in text: + raise SystemExit(f"MAINTENANCE.md has no {SECTION!r} section") + section = text.split(SECTION, 1)[1].split("\n## ", 1)[0] + rows = [line.strip().strip("|").split("|") for line in section.splitlines() if line.startswith("|")] + if not rows or [cell.strip() for cell in rows[0]] != COLUMNS: + raise SystemExit(f"MAINTENANCE.md: the GitHub Apps table must have the columns {', '.join(COLUMNS)}") + apps = {} + for row in rows[2:]: + cells = [re.findall(r"`([^`]+)`", cell) for cell in row] + try: + (slug,), permissions, installed, (prefix,), stored = cells + permissions = dict(permission.split(": ") for permission in permissions) + except ValueError: + raise SystemExit(f"MAINTENANCE.md: malformed GitHub Apps row: |{'|'.join(row)}|") + apps[slug] = {"permissions": permissions, "installed": sorted(installed), "prefix": prefix, "stored": stored} + return apps + + +def ruleset_problems(repo): + ids = [ruleset["id"] for ruleset in gh(f"repos/{ORG}/{repo}/rulesets?includes_parents=false") if ruleset["name"] == "default branch"] + if not ids: + return [f"{repo}: no default branch ruleset"] + ruleset = gh(f"repos/{ORG}/{repo}/rulesets/{ids[0]}") + rules = {rule["type"]: rule.get("parameters", {}) for rule in ruleset["rules"]} + problems = [] + if ruleset["enforcement"] != "active": + problems.append(f"{repo}: default branch ruleset is {ruleset['enforcement']}") + if ruleset["conditions"]["ref_name"]["include"] != ["~DEFAULT_BRANCH"]: + problems.append(f"{repo}: default branch ruleset targets {ruleset['conditions']['ref_name']['include']}") + if ruleset["bypass_actors"]: + problems.append(f"{repo}: default branch ruleset has bypass actors") + if missing := RULES - rules.keys(): + problems.append(f"{repo}: default branch ruleset lacks {', '.join(sorted(missing))}") + else: + if rules["pull_request"]["allowed_merge_methods"] != ["squash"]: + problems.append(f"{repo}: default branch ruleset allows {rules['pull_request']['allowed_merge_methods']}") + if not rules["required_status_checks"]["required_status_checks"]: + problems.append(f"{repo}: default branch ruleset requires no checks") + return problems + + +def app_problems(apps, notes): + problems = [] + installed = {installation["app_slug"]: installation for installation in gh(f"orgs/{ORG}/installations?per_page=100")["installations"]} + for slug in sorted(installed.keys() - apps.keys()): + problems.append(f"{slug}: installed but not listed in MAINTENANCE.md") + for slug, app in apps.items(): + installation = installed.get(slug) + if not installation: + problems.append(f"{slug}: not installed") + continue + permissions = {name: access for name, access in installation["permissions"].items() if name != "metadata"} + if permissions != app["permissions"]: + problems.append(f"{slug}: permissions {permissions}, expected {app['permissions']}") + if installation["events"]: + problems.append(f"{slug}: subscribes to {', '.join(installation['events'])}") + if installation["repository_selection"] != "selected": + problems.append(f"{slug}: installed on all repositories") + continue + try: + listed = gh(f"user/installations/{installation['id']}/repositories?per_page=100")["repositories"] + except ApiError: + notes.append( + f"{slug}: installed repositories not checked; GitHub lists them only to a personal access token, " + "so compare them on the App's installation page" + ) + continue + repositories = sorted(repository["name"] for repository in listed) + if repositories != app["installed"]: + problems.append(f"{slug}: installed on {', '.join(repositories)}, expected {', '.join(app['installed'])}") + return problems + + +def main(): + apps = expected_apps() + credentials = {} + for app in apps.values(): + for repo in app["stored"]: + secrets, variables = credentials.setdefault(repo, (set(), set())) + secrets.add(f"{app['prefix']}_PRIVATE_KEY") + variables.add(f"{app['prefix']}_CLIENT_ID") + + try: + problems, notes = [], [] + if (flow := gh(f"orgs/{ORG}/actions/permissions/workflow")) != READ_ONLY: + problems.append(f"organization: workflow token {flow}") + for kind in ("secrets", "variables"): + if names := [item["name"] for item in gh(f"orgs/{ORG}/actions/{kind}?per_page=100")[kind]]: + problems.append(f"organization: Actions {kind} {', '.join(names)}") + + repos = {repo["name"]: repo for repo in gh(f"orgs/{ORG}/repos?type=all&per_page=100") if not repo["archived"]} + listed = set(credentials).union(*(app["installed"] for app in apps.values())) + for repo in sorted(listed - repos.keys()): + problems.append(f"{repo}: listed in MAINTENANCE.md but not in the organization") + for repo in sorted(repos): + detail = gh(f"repos/{ORG}/{repo}") + if (merges := {name: detail[name] for name in MERGES}) != MERGES: + problems.append(f"{repo}: merge settings {merges}") + if (flow := gh(f"repos/{ORG}/{repo}/actions/permissions/workflow")) != READ_ONLY: + problems.append(f"{repo}: workflow token {flow}") + for kind, expected in zip(("secrets", "variables"), credentials.get(repo, (set(), set()))): + names = {item["name"] for item in gh(f"repos/{ORG}/{repo}/actions/{kind}?per_page=100")[kind]} + if names != expected: + problems.append(f"{repo}: Actions {kind} {sorted(names)}, expected {sorted(expected)}") + if not detail["private"]: + problems += ruleset_problems(repo) + problems += app_problems(apps, notes) + except ApiError as error: + raise SystemExit(str(error)) + + print("\n".join(problems + notes) or "GitHub settings match MAINTENANCE.md", flush=True) + if problems: + return 1 + if notes: + print("Other GitHub settings match MAINTENANCE.md", flush=True) + return 0 + + +if __name__ == "__main__": + sys.exit(main())