From fd496e60c60f9c797db4e24f9a83e84402df6cb6 Mon Sep 17 00:00:00 2001 From: "vitor.guttler" Date: Thu, 27 Aug 2026 12:02:41 -0300 Subject: [PATCH 1/2] feat: add GitHub App auth and ruleset reconciliation --- .env.example | 14 +++- .github/workflows/pr-sync.yml | 10 +++ .github/workflows/project-setup.yml | 26 ++++--- .github/workflows/rulesets.yml | 47 ++++++++++++ README.md | 6 +- config/governance/rulesets.json | 17 +++++ project_setup.json | 3 + project_setup/cli.py | 45 +++++++++--- project_setup/github.py | 80 +++++++++++++++++++-- project_setup/installer.py | 2 + project_setup/rulesets.py | 106 ++++++++++++++++++++++++++++ pyproject.toml | 1 + tests/test_github_client.py | 8 ++- tests/test_rulesets.py | 68 ++++++++++++++++++ 14 files changed, 403 insertions(+), 30 deletions(-) create mode 100644 .github/workflows/rulesets.yml create mode 100644 config/governance/rulesets.json create mode 100644 project_setup/rulesets.py create mode 100644 tests/test_rulesets.py diff --git a/.env.example b/.env.example index 4bda7ef..f910279 100644 --- a/.env.example +++ b/.env.example @@ -13,9 +13,17 @@ GITHUB_REPOSITORY=owner/repository # Leave empty to auto-detect the owner type during authenticated Project v2 operations. PROJECT_SETUP_OWNER_TYPE= -# Required for GitHub Projects v2 creation and synchronization. -# Create a personal access token (classic) with `repo` and `project` scopes. -# Leave empty when you only use dry-run or repository-scoped GitHub Actions. +# Authentication mode: auto (recommended), app, or token. +PROJECT_SETUP_AUTH=auto + +# Recommended: GitHub App credentials. Store the multiline private key in a +# secret in Actions; locally prefer PROJECT_SETUP_APP_PRIVATE_KEY_FILE. +PROJECT_SETUP_APP_ID= +PROJECT_SETUP_APP_PRIVATE_KEY_FILE= +PROJECT_SETUP_APP_INSTALLATION_ID= + +# Compatibility fallback for GitHub Projects v2 and local use. Create a PAT +# with only the necessary access; leave empty when the GitHub App is configured. PROJECT_SETUP_PAT= # Optional path to the local setup configuration. diff --git a/.github/workflows/pr-sync.yml b/.github/workflows/pr-sync.yml index 1562efc..7928711 100644 --- a/.github/workflows/pr-sync.yml +++ b/.github/workflows/pr-sync.yml @@ -54,10 +54,20 @@ jobs: with: python-version: "3.11" + - name: Create GitHub App installation token + id: app-token + if: ${{ vars.PROJECT_SETUP_APP_ID != '' }} + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.PROJECT_SETUP_APP_ID }} + private-key: ${{ secrets.PROJECT_SETUP_APP_PRIVATE_KEY }} + - name: Synchronize live pull request context env: GITHUB_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }} + PROJECT_SETUP_AUTH: ${{ vars.PROJECT_SETUP_APP_ID != '' && 'app' || 'token' }} + PROJECT_SETUP_TOKEN: ${{ steps.app-token.outputs.token }} PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} PROJECT_SETUP_PROJECT_NUMBER: ${{ vars.PROJECT_SETUP_PROJECT_NUMBER }} PROJECT_SETUP_OWNER_TYPE: ${{ vars.PROJECT_SETUP_OWNER_TYPE }} diff --git a/.github/workflows/project-setup.yml b/.github/workflows/project-setup.yml index 653f47f..c843e86 100644 --- a/.github/workflows/project-setup.yml +++ b/.github/workflows/project-setup.yml @@ -46,6 +46,14 @@ jobs: with: persist-credentials: false + - name: Create GitHub App installation token + id: app-token + if: ${{ vars.PROJECT_SETUP_APP_ID != '' }} + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.PROJECT_SETUP_APP_ID }} + private-key: ${{ secrets.PROJECT_SETUP_APP_PRIVATE_KEY }} + - name: Set up Python uses: actions/setup-python@v6 with: @@ -54,32 +62,32 @@ jobs: - name: Validate embedded setup package and configuration env: GITHUB_TOKEN: ${{ github.token }} + PROJECT_SETUP_AUTH: ${{ vars.PROJECT_SETUP_APP_ID != '' && 'app' || 'token' }} + PROJECT_SETUP_TOKEN: ${{ steps.app-token.outputs.token }} PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} run: | set -euo pipefail python -m compileall -q project_setup python -m project_setup doctor --config project_setup.json - - name: Require PAT for live Project v2 creation + - name: Require App token or PAT for live Project v2 creation if: ${{ inputs.run_project_creation && !inputs.dry_run }} env: PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} + PROJECT_SETUP_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -euo pipefail - if [ -z "${PROJECT_SETUP_PAT:-}" ]; then - echo "::error title=PROJECT_SETUP_PAT is required::GitHub's repository-scoped token cannot create or synchronize Projects v2." - echo "Create a personal access token (classic):" - echo " GitHub profile picture > Settings > Developer settings" - echo " Personal access tokens > Tokens (classic) > Generate new token (classic)" - echo " Select scopes: repo and project" - echo "Save it as the repository Actions secret PROJECT_SETUP_PAT, then run this workflow again." + if [ -z "${PROJECT_SETUP_TOKEN:-}" ] && [ -z "${PROJECT_SETUP_PAT:-}" ]; then + echo "::error title=Project credential required::Configure the recommended GitHub App or the PROJECT_SETUP_PAT fallback." exit 1 fi - echo "PROJECT_SETUP_PAT is configured for the requested Project v2 operation." + echo "A Project credential is configured for the requested live operation." - name: Apply project setup env: GITHUB_TOKEN: ${{ github.token }} + PROJECT_SETUP_AUTH: ${{ vars.PROJECT_SETUP_APP_ID != '' && 'app' || 'token' }} + PROJECT_SETUP_TOKEN: ${{ steps.app-token.outputs.token }} PROJECT_SETUP_PAT: ${{ secrets.PROJECT_SETUP_PAT }} run: | set -euo pipefail diff --git a/.github/workflows/rulesets.yml b/.github/workflows/rulesets.yml new file mode 100644 index 0000000..f7ccd9c --- /dev/null +++ b/.github/workflows/rulesets.yml @@ -0,0 +1,47 @@ +name: GPA rulesets + +on: + workflow_dispatch: + inputs: + mode: + description: "plan is read-only; apply requires the matching plan ID" + required: true + type: choice + default: plan + options: [plan, apply] + confirmation: + description: "Required only for apply: the plan-id printed by a previous plan" + required: false + type: string + +permissions: + contents: read + +jobs: + reconcile: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + persist-credentials: false + - id: app-token + if: ${{ vars.PROJECT_SETUP_APP_ID != '' }} + uses: actions/create-github-app-token@v2 + with: + app-id: ${{ vars.PROJECT_SETUP_APP_ID }} + private-key: ${{ secrets.PROJECT_SETUP_APP_PRIVATE_KEY }} + - uses: actions/setup-python@v6 + with: + python-version: "3.11" + - name: Plan or apply declared rulesets + env: + PROJECT_SETUP_AUTH: ${{ vars.PROJECT_SETUP_APP_ID != '' && 'app' || 'token' }} + PROJECT_SETUP_TOKEN: ${{ steps.app-token.outputs.token || secrets.PROJECT_SETUP_PAT }} + GITHUB_REPOSITORY: ${{ github.repository }} + run: | + set -euo pipefail + if [ "${{ inputs.mode }}" = plan ]; then + python -m project_setup rulesets plan --repo "$GITHUB_REPOSITORY" + else + python -m project_setup rulesets apply --repo "$GITHUB_REPOSITORY" --live --confirm "${{ inputs.confirmation }}" + fi diff --git a/README.md b/README.md index 850d25a..bae24b2 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ The project focuses on safe setup of labels, milestones, issues, sub-issues, pull-request guardrails, **PR Sync**, repository discovery, and GitHub Projects v2. Remote mutating commands default to dry-run and require an explicit live mode before writing to GitHub. +GitHub App authentication is the recommended production credential for organization Projects, cross-repository automation, and optional native rulesets. PATs and `gh auth` remain compatibility fallbacks. Rulesets are never applied automatically: run `project-setup rulesets plan`, then explicitly confirm the returned plan ID with `rulesets apply --live --confirm `. + +For Actions, store the numeric App ID in `PROJECT_SETUP_APP_ID` and the entire private key in `PROJECT_SETUP_APP_PRIVATE_KEY`; installed workflows mint a short-lived installation token. For the local CLI, set the same App ID and `PROJECT_SETUP_APP_PRIVATE_KEY_FILE`, then install `github-project-setup[app]`. The App needs only the permissions required by enabled features; repository administration is required only for rulesets. + If an AI assistant will perform or guide the setup, give it [`AI_SETUP_GUIDE.md`](AI_SETUP_GUIDE.md). That file tells the agent to inspect existing repository conventions before asking questions, pause at manual/credential/live checkpoints, re-verify user changes before continuing, and avoid duplicate resources. ## 1. Overview @@ -276,4 +280,4 @@ GitHub Project Setup is licensed under the [Apache License 2.0](LICENSE). **Created and originally developed by [v-Kaefer](https://github.com/v-Kaefer).** The repository includes a [`NOTICE`](NOTICE) file carrying the project's attribution notice. Apache-2.0 requires distributed derivative works that include the relevant code to preserve applicable attribution notices from that NOTICE in a readable form. -When `project_setup` is embedded into another repository by the installer, its license and attribution files are installed under `licenses/project_setup/` so the target repository can retain its own top-level licensing model while still preserving this project's notices. \ No newline at end of file +When `project_setup` is embedded into another repository by the installer, its license and attribution files are installed under `licenses/project_setup/` so the target repository can retain its own top-level licensing model while still preserving this project's notices. diff --git a/config/governance/rulesets.json b/config/governance/rulesets.json new file mode 100644 index 0000000..0fd8cda --- /dev/null +++ b/config/governance/rulesets.json @@ -0,0 +1,17 @@ +{ + "rulesets": [ + { + "name": "GPA example: protected main", + "enforcement": "active", + "refName": {"include": ["refs/heads/main"], "exclude": []}, + "rules": [ + {"type": "pull_request", "parameters": {"required_approving_review_count": 1, "dismiss_stale_reviews_on_push": true, "require_code_owner_review": false, "require_last_push_approval": false, "required_review_thread_resolution": false}}, + {"type": "non_fast_forward"}, + {"type": "deletion"} + ], + "bypassActors": [ + {"type": "team", "slug": "maintainers", "mode": "pull_request"} + ] + } + ] +} diff --git a/project_setup.json b/project_setup.json index d5d354c..08d2ddf 100644 --- a/project_setup.json +++ b/project_setup.json @@ -5,6 +5,9 @@ "projectDefinitionFile": "config/project/project-definition.json", "backlogManifestFile": "config/stories/backlog-manifest.json", "secretName": "PROJECT_SETUP_PAT", + "governance": { + "rulesetsFile": "config/governance/rulesets.json" + }, "prAutomation": { "relatedPrs": { "enabled": true, diff --git a/project_setup/cli.py b/project_setup/cli.py index c182692..c6ef93e 100644 --- a/project_setup/cli.py +++ b/project_setup/cli.py @@ -33,6 +33,7 @@ ) from .pr_validation import upsert_validation_comment, validate_pull_request from .runner import load_project_setup_config, run_project_setup +from .rulesets import apply_rulesets, plan_rulesets def repo_arg(value: str | None) -> str: @@ -50,6 +51,20 @@ def optional_client() -> GitHubClient | None: return GitHubClient(token) if (token := get_token()) else None +def cmd_rulesets_plan(args: argparse.Namespace) -> int: + repository = repo_arg(args.repo) + plan_rulesets(require_client(repository), repository, args.file) + return 0 + + +def cmd_rulesets_apply(args: argparse.Namespace) -> int: + if not args.live: + raise SystemExit("Rulesets are never applied by default. Use --live --confirm after reviewing `rulesets plan`.") + repository = repo_arg(args.repo) + apply_rulesets(require_client(repository), repository, args.file, args.confirm or "") + return 0 + + def cmd_init(args: argparse.Namespace) -> int: install_repository( args.target, @@ -65,7 +80,7 @@ def cmd_doctor(args: argparse.Namespace) -> int: config_path = Path(args.config) environment_path = load_env_file() project_pat = get_project_pat() - github_token, token_source = get_token_source() + github_token, token_source = get_token_source(os.getenv("GITHUB_REPOSITORY")) gh_status = get_gh_auth_status() failures = 0 owner_type_error: str | None = None @@ -85,6 +100,7 @@ def cmd_doctor(args: argparse.Namespace) -> int: print(f"github_repository={os.getenv('GITHUB_REPOSITORY') or 'missing'}") print(f"project_owner_type={project_owner_type or 'auto-detect'}") print(f"github_token={'configured' if github_token else 'missing'} source={token_source}") + print(f"github_app={'configured' if os.getenv('PROJECT_SETUP_APP_ID') else 'missing'}") print(f"project_setup_pat={'configured' if project_pat else 'missing'}") print(f"gh_cli={'installed' if gh_status.installed else 'missing'}") print(f"gh_auth={'valid' if gh_status.authenticated else 'invalid' if gh_status.installed else 'not-installed'}") @@ -103,11 +119,9 @@ def cmd_doctor(args: argparse.Namespace) -> int: print(" Fix: run `gh auth login`, or continue with a valid token configured in .env.") if not github_token: print("WARNING: no GitHub authentication is available for live repository operations.") - print(" Fix: set PROJECT_SETUP_PAT in .env, set GITHUB_TOKEN/GH_TOKEN, or run `gh auth login`.") + print(" Fix: configure the recommended GitHub App, set PROJECT_SETUP_PAT/GITHUB_TOKEN/GH_TOKEN, or run `gh auth login`.") if not project_pat: - print("INFO: PROJECT_SETUP_PAT is required only for GitHub Projects v2 creation or synchronization.") - print(" Setup: Settings > Developer settings > Personal access tokens > Tokens (classic).") - print(" Required scopes: repo and project. Save the token as PROJECT_SETUP_PAT in .env.") + print("INFO: GitHub App authentication is recommended for Projects v2 and governance. PROJECT_SETUP_PAT remains a fallback.") print("==> Configuration") print(f"config={config_path.resolve()}") @@ -168,7 +182,7 @@ def cmd_issues_generate(args: argparse.Namespace) -> int: def cmd_project_create(args: argparse.Namespace) -> int: - client = GitHubClient("") if args.dry_run else require_project_client() + client = GitHubClient("") if args.dry_run else require_project_client(repo_arg(args.repo)) create_project( client, repo_arg(args.repo), @@ -181,7 +195,7 @@ def cmd_project_create(args: argparse.Namespace) -> int: def cmd_project_sync(args: argparse.Namespace) -> int: repository = repo_arg(args.repo) - if args.dry_run and not get_project_pat(): + if args.dry_run and not (get_project_pat() or os.getenv("PROJECT_SETUP_APP_ID")): definition = load_project_definition(args.file) target_owner = args.owner or repository.split("/", 1)[0] owner_type = configured_owner_type(args.owner_type) @@ -197,7 +211,7 @@ def cmd_project_sync(args: argparse.Namespace) -> int: print("Fix: configure PROJECT_SETUP_PAT to run a remote dry-run comparison.") return 0 sync_project( - require_project_client(), + require_project_client(repository), repository, args.file, args.project_number, @@ -403,6 +417,19 @@ def build_parser() -> argparse.ArgumentParser: validate_pr.add_argument("--comment", action="store_true") validate_pr.set_defaults(func=cmd_validate_pr) + rulesets = subcommands.add_parser("rulesets", help="Plan or reconcile explicitly declared native repository rulesets") + rulesets_sub = rulesets.add_subparsers(dest="rulesets_command", required=True) + rulesets_plan = rulesets_sub.add_parser("plan", help="Read and compare rulesets without changes") + rulesets_plan.add_argument("--repo") + rulesets_plan.add_argument("--file", default="config/governance/rulesets.json") + rulesets_plan.set_defaults(func=cmd_rulesets_plan) + rulesets_apply = rulesets_sub.add_parser("apply", help="Apply a reviewed ruleset plan") + rulesets_apply.add_argument("--repo") + rulesets_apply.add_argument("--file", default="config/governance/rulesets.json") + rulesets_apply.add_argument("--confirm", help="Plan ID printed by `rulesets plan`") + rulesets_apply.add_argument("--live", action="store_true", help="Authorize the reviewed ruleset changes") + rulesets_apply.set_defaults(func=cmd_rulesets_apply) + apply = subcommands.add_parser("apply", help="Apply configured repository setup") add_apply_arguments(apply) return parser @@ -422,4 +449,4 @@ def main(argv: list[str] | None = None) -> int: if __name__ == "__main__": - raise SystemExit(main()) \ No newline at end of file + raise SystemExit(main()) diff --git a/project_setup/github.py b/project_setup/github.py index 6aef7f5..d10b535 100644 --- a/project_setup/github.py +++ b/project_setup/github.py @@ -12,6 +12,7 @@ import urllib.error import urllib.parse import urllib.request +from datetime import UTC, datetime, timedelta API_BASE = "https://api.github.com" @@ -98,8 +99,70 @@ def get_gh_auth_status() -> GhAuthStatus: return GhAuthStatus(True, result.returncode == 0, detail) -def get_token_source() -> tuple[str | None, str]: +def _app_private_key() -> str | None: + key = os.environ.get("PROJECT_SETUP_APP_PRIVATE_KEY") + if key and key.strip(): + return key.strip() + filename = os.environ.get("PROJECT_SETUP_APP_PRIVATE_KEY_FILE") + if filename and filename.strip(): + return Path(filename).expanduser().read_text(encoding="utf-8").strip() + return None + + +def app_is_configured() -> bool: + load_env_file() + return bool(os.environ.get("PROJECT_SETUP_APP_ID", "").strip() and _app_private_key()) + + +def _app_installation_token(repo: str | None) -> str | None: + if not app_is_configured(): + return None + app_id = os.environ["PROJECT_SETUP_APP_ID"].strip() + try: + import jwt + except ImportError as exc: + raise SystemExit( + "GitHub App authentication requires the optional dependency. " + "Install GPA with `pip install github-project-setup[app]`." + ) from exc + now = datetime.now(UTC) + app_jwt = jwt.encode( + {"iat": int((now - timedelta(seconds=60)).timestamp()), "exp": int((now + timedelta(minutes=9)).timestamp()), "iss": app_id}, + _app_private_key(), + algorithm="RS256", + ) + app_client = GitHubClient(app_jwt) + installation_id = os.environ.get("PROJECT_SETUP_APP_INSTALLATION_ID", "").strip() + if not installation_id: + if not repo: + raise SystemExit( + "GitHub App authentication needs --repo/GITHUB_REPOSITORY or PROJECT_SETUP_APP_INSTALLATION_ID." + ) + installation = app_client.request_json("GET", f"{API_BASE}/repos/{repo}/installation") + installation_id = str(installation.get("id") or "") + if not installation_id: + raise SystemExit("Could not resolve the GitHub App installation. Check App installation access.") + response = app_client.request_json("POST", f"{API_BASE}/app/installations/{installation_id}/access_tokens", {}) + token = str(response.get("token") or "") + if not token: + raise SystemExit("GitHub App did not return an installation access token.") + return token + + +def get_token_source(repo: str | None = None) -> tuple[str | None, str]: load_env_file() + mode = os.environ.get("PROJECT_SETUP_AUTH", "auto").strip().lower() or "auto" + if mode not in {"auto", "app", "token"}: + raise ValueError("PROJECT_SETUP_AUTH must be auto, app, or token") + workflow_token = os.environ.get("PROJECT_SETUP_TOKEN", "").strip() + if workflow_token: + return workflow_token, "github-app" if mode == "app" else "PROJECT_SETUP_TOKEN" + if mode in {"auto", "app"} and app_is_configured(): + token = _app_installation_token(repo) + if token: + return token, "github-app" + if mode == "app": + return None, "github-app-missing" for variable in ("GITHUB_TOKEN", "GH_TOKEN", "PROJECT_SETUP_PAT"): token = os.environ.get(variable) if token and token.strip(): @@ -115,8 +178,8 @@ def get_token_source() -> tuple[str | None, str]: return (token, "gh") if token else (None, "gh-invalid") -def get_token() -> str | None: - return get_token_source()[0] +def get_token(repo: str | None = None) -> str | None: + return get_token_source(repo)[0] def get_project_pat() -> str | None: @@ -237,8 +300,8 @@ def delete_issue_comment(self, repo: str, comment_id: int) -> dict[str, Any]: return self.request_json("DELETE", f"{API_BASE}/repos/{repo}/issues/comments/{comment_id}") -def require_client() -> GitHubClient: - token, source = get_token_source() +def require_client(repo: str | None = None) -> GitHubClient: + token, source = get_token_source(repo) if not token: gh = get_gh_auth_status() gh_guidance = ( @@ -247,7 +310,7 @@ def require_client() -> GitHubClient: raise SystemExit( "No GitHub token is available.\n" f"{gh_guidance}" - "Fix: copy .env.example to .env and set PROJECT_SETUP_PAT, set GITHUB_TOKEN/GH_TOKEN, " + "Fix: configure PROJECT_SETUP_APP_ID and a private key (recommended), set PROJECT_SETUP_PAT, set GITHUB_TOKEN/GH_TOKEN, " "or repair the CLI session with `gh auth login`." ) if source == "gh-invalid": @@ -255,7 +318,10 @@ def require_client() -> GitHubClient: return GitHubClient(token) -def require_project_client() -> GitHubClient: +def require_project_client(repo: str | None = None) -> GitHubClient: + token, source = get_token_source(repo) + if source == "github-app" and token: + return GitHubClient(token) token = get_project_pat() if not token: raise SystemExit( diff --git a/project_setup/installer.py b/project_setup/installer.py index 203ad05..1536cfc 100644 --- a/project_setup/installer.py +++ b/project_setup/installer.py @@ -20,9 +20,11 @@ ".github/workflows/pr-metadata.yml", ".github/workflows/pr-sync.yml", ".github/workflows/project-setup.yml", + ".github/workflows/rulesets.yml", "config/project/labels.json", "config/project/milestones.json", "config/project/project-definition.json", + "config/governance/rulesets.json", "config/stories/backlog-manifest.json", "project_setup.json", "scripts/validation/repo_quality.py", diff --git a/project_setup/rulesets.py b/project_setup/rulesets.py new file mode 100644 index 0000000..746c649 --- /dev/null +++ b/project_setup/rulesets.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import hashlib +import json +from pathlib import Path +from typing import Any + +from .github import API_BASE, GitHubClient, split_repo + + +SUPPORTED_RULE_TYPES = { + "pull_request", "required_status_checks", "non_fast_forward", "deletion", + "required_linear_history", "required_deployments", +} + + +def load_rulesets(path: str) -> list[dict[str, Any]]: + data = json.loads(Path(path).read_text(encoding="utf-8")) + rulesets = data.get("rulesets") if isinstance(data, dict) else None + if not isinstance(rulesets, list): + raise ValueError("rulesets manifest must contain a rulesets list") + names: set[str] = set() + for item in rulesets: + if not isinstance(item, dict) or not isinstance(item.get("name"), str) or not item["name"].strip(): + raise ValueError("each ruleset needs a non-empty name") + if item["name"] in names: + raise ValueError(f"duplicate ruleset name: {item['name']}") + names.add(item["name"]) + if item.get("target", "branch") != "branch": + raise ValueError("this GPA release supports branch rulesets only") + for rule in item.get("rules", []): + if rule.get("type") not in SUPPORTED_RULE_TYPES: + raise ValueError(f"unsupported ruleset rule: {rule.get('type')}") + return rulesets + + +def _actor(client: GitHubClient, owner: str, actor: dict[str, Any]) -> dict[str, Any]: + actor_type = actor.get("type") + mode = actor.get("mode", "pull_request") + if actor_type == "team": + slug = actor.get("slug") + if not slug: + raise ValueError("team bypass actor requires slug") + item = client.request_json("GET", f"{API_BASE}/orgs/{owner}/teams/{slug}") + return {"actor_id": item["id"], "actor_type": "Team", "bypass_mode": mode} + if actor_type == "user": + login = actor.get("login") + if not login: + raise ValueError("user bypass actor requires login") + item = client.request_json("GET", f"{API_BASE}/users/{login}") + return {"actor_id": item["id"], "actor_type": "User", "bypass_mode": mode} + if actor_type == "organization_admin": + return {"actor_id": None, "actor_type": "OrganizationAdmin", "bypass_mode": mode} + if actor_type == "repository_role": + role_id = actor.get("id") + if role_id is None: + raise ValueError("repository_role bypass actor requires id") + return {"actor_id": role_id, "actor_type": "RepositoryRole", "bypass_mode": mode} + raise ValueError(f"unsupported bypass actor type: {actor_type}") + + +def desired_ruleset(client: GitHubClient, repo: str, definition: dict[str, Any]) -> dict[str, Any]: + owner, _ = split_repo(repo) + return { + "name": definition["name"], + "target": "branch", + "enforcement": definition.get("enforcement", "active"), + "conditions": {"ref_name": definition.get("refName", {"include": ["~DEFAULT_BRANCH"]})}, + "rules": definition.get("rules", []), + "bypass_actors": [_actor(client, owner, item) for item in definition.get("bypassActors", [])], + } + + +def _comparison(value: dict[str, Any]) -> dict[str, Any]: + return {key: value.get(key) for key in ("name", "target", "enforcement", "conditions", "rules", "bypass_actors")} + + +def plan_rulesets(client: GitHubClient, repo: str, path: str) -> tuple[str, list[tuple[str, dict[str, Any], int | None]]]: + desired = [desired_ruleset(client, repo, item) for item in load_rulesets(path)] + current = client.request_json("GET", f"{API_BASE}/repos/{repo}/rulesets") + by_name = {item.get("name"): item for item in current} + actions: list[tuple[str, dict[str, Any], int | None]] = [] + for item in desired: + existing = by_name.get(item["name"]) + if not existing: + actions.append(("create", item, None)) + continue + full = client.request_json("GET", f"{API_BASE}/repos/{repo}/rulesets/{existing['id']}") + actions.append(("unchanged" if _comparison(full) == _comparison(item) else "update", item, int(existing["id"]))) + plan_id = hashlib.sha256(json.dumps([_comparison(item) for item in desired], sort_keys=True).encode()).hexdigest()[:12] + for action, item, identifier in actions: + print(f"{action}: {item['name']}" + (f" (id={identifier})" if identifier else "")) + print(f"plan-id={plan_id}") + return plan_id, actions + + +def apply_rulesets(client: GitHubClient, repo: str, path: str, confirmation: str) -> None: + plan_id, actions = plan_rulesets(client, repo, path) + if confirmation != plan_id: + raise ValueError(f"ruleset changes require --confirm {plan_id}") + for action, item, identifier in actions: + if action == "create": + client.request_json("POST", f"{API_BASE}/repos/{repo}/rulesets", item) + elif action == "update": + client.request_json("PUT", f"{API_BASE}/repos/{repo}/rulesets/{identifier}", item) + print("Ruleset reconciliation completed. No undeclared rulesets were deleted.") diff --git a/pyproject.toml b/pyproject.toml index 8715328..99015c8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,6 +18,7 @@ classifiers = [ [project.optional-dependencies] dev = ["pytest>=8"] +app = ["PyJWT[crypto]>=2.8"] [project.scripts] project-setup = "project_setup.cli:main" diff --git a/tests/test_github_client.py b/tests/test_github_client.py index e6bdb32..b0635ef 100644 --- a/tests/test_github_client.py +++ b/tests/test_github_client.py @@ -5,7 +5,7 @@ import unittest from unittest.mock import patch -from project_setup.github import API_BASE, HTTP_TIMEOUT_SECONDS, GitHubClient, GitHubRequestError +from project_setup.github import API_BASE, HTTP_TIMEOUT_SECONDS, GitHubClient, GitHubRequestError, get_token_source class _Response: @@ -43,6 +43,12 @@ def test_non_github_api_url_is_rejected_before_opening_connection(self): client.request_json("GET", "https://example.com/resource") urlopen.assert_not_called() + def test_workflow_app_token_is_preferred_without_private_key_material(self): + with patch.dict("os.environ", {"PROJECT_SETUP_AUTH": "app", "PROJECT_SETUP_TOKEN": "short-lived-token"}, clear=True): + token, source = get_token_source("owner/repository") + self.assertEqual(token, "short-lived-token") + self.assertEqual(source, "github-app") + if __name__ == "__main__": unittest.main() diff --git a/tests/test_rulesets.py b/tests/test_rulesets.py new file mode 100644 index 0000000..e3418ea --- /dev/null +++ b/tests/test_rulesets.py @@ -0,0 +1,68 @@ +from __future__ import annotations + +import json +from pathlib import Path +import tempfile +import unittest + +from project_setup.rulesets import apply_rulesets, plan_rulesets + + +class _Client: + def __init__(self): + self.requests: list[tuple[str, str, object]] = [] + self.rulesets: list[dict] = [] + + def request_json(self, method, url, payload=None): + self.requests.append((method, url, payload)) + if url.endswith("/teams/reviewers"): + return {"id": 42} + if url.endswith("/rulesets") and method == "GET": + return self.rulesets + if "/rulesets/" in url and method == "GET": + return self.rulesets[0] + if method == "POST": + return {} + if method == "PUT": + return {} + raise AssertionError((method, url, payload)) + + +class RulesetTests(unittest.TestCase): + def manifest(self) -> tuple[tempfile.TemporaryDirectory, Path]: + directory = tempfile.TemporaryDirectory() + path = Path(directory.name) / "rulesets.json" + path.write_text(json.dumps({"rulesets": [{ + "name": "GPA: main", "refName": {"include": ["refs/heads/main"]}, + "rules": [{"type": "non_fast_forward"}], + "bypassActors": [{"type": "team", "slug": "reviewers"}], + }]}), encoding="utf-8") + return directory, path + + def test_plan_is_read_only_and_resolves_team_slug(self): + directory, path = self.manifest() + self.addCleanup(directory.cleanup) + client = _Client() + plan_id, actions = plan_rulesets(client, "owner/repo", str(path)) + self.assertEqual(len(plan_id), 12) + self.assertEqual(actions[0][0], "create") + self.assertFalse(any(method in {"POST", "PUT"} for method, _, _ in client.requests)) + + def test_apply_requires_matching_plan_id_and_never_deletes(self): + directory, path = self.manifest() + self.addCleanup(directory.cleanup) + client = _Client() + plan_id, _ = plan_rulesets(client, "owner/repo", str(path)) + apply_rulesets(client, "owner/repo", str(path), plan_id) + self.assertTrue(any(method == "POST" for method, _, _ in client.requests)) + self.assertFalse(any(method == "DELETE" for method, _, _ in client.requests)) + + def test_apply_rejects_stale_or_missing_confirmation(self): + directory, path = self.manifest() + self.addCleanup(directory.cleanup) + with self.assertRaisesRegex(ValueError, "require --confirm"): + apply_rulesets(_Client(), "owner/repo", str(path), "wrong") + + +if __name__ == "__main__": + unittest.main() From 79e8d8f4c4eb710f7648fd3ba7cf741f93119ce0 Mon Sep 17 00:00:00 2001 From: Vitor Guttler Date: Sat, 29 Aug 2026 18:50:59 -0300 Subject: [PATCH 2/2] fix: address CodeRabbit review on PR #89 - rulesets.yml: pass workflow_dispatch inputs via env vars instead of interpolating them into the shell (template-injection / CWE-78) - rulesets.py: bind plan-id fingerprint to repo + resolved actions/IDs so a confirmation cannot be reused across repos or after create->update drift - config/governance/rulesets.json: drop hard-coded `maintainers` team from the default template; ship empty bypassActors with a substitution note - cli.py: resolve repository before client creation and pass it to require_client / require_project_client in all live handlers so GitHub App installation resolution works without PROJECT_SETUP_APP_INSTALLATION_ID - README: reconcile GitHub App auth boundary for Projects v2 and drop the obsolete "rulesets are not created" limitation - tests: cover cross-repo confirmation reuse and create-to-update drift Co-Authored-By: Claude Sonnet 5 --- .github/workflows/rulesets.yml | 6 ++++-- README.md | 12 ++++++------ config/governance/rulesets.json | 5 ++--- project_setup/cli.py | 23 ++++++++++++++--------- project_setup/rulesets.py | 9 ++++++++- tests/test_rulesets.py | 17 +++++++++++++++++ 6 files changed, 51 insertions(+), 21 deletions(-) diff --git a/.github/workflows/rulesets.yml b/.github/workflows/rulesets.yml index f7ccd9c..04a03c9 100644 --- a/.github/workflows/rulesets.yml +++ b/.github/workflows/rulesets.yml @@ -38,10 +38,12 @@ jobs: PROJECT_SETUP_AUTH: ${{ vars.PROJECT_SETUP_APP_ID != '' && 'app' || 'token' }} PROJECT_SETUP_TOKEN: ${{ steps.app-token.outputs.token || secrets.PROJECT_SETUP_PAT }} GITHUB_REPOSITORY: ${{ github.repository }} + RULESETS_MODE: ${{ inputs.mode }} + RULESETS_CONFIRMATION: ${{ inputs.confirmation }} run: | set -euo pipefail - if [ "${{ inputs.mode }}" = plan ]; then + if [ "$RULESETS_MODE" = plan ]; then python -m project_setup rulesets plan --repo "$GITHUB_REPOSITORY" else - python -m project_setup rulesets apply --repo "$GITHUB_REPOSITORY" --live --confirm "${{ inputs.confirmation }}" + python -m project_setup rulesets apply --repo "$GITHUB_REPOSITORY" --live --confirm "$RULESETS_CONFIRMATION" fi diff --git a/README.md b/README.md index bae24b2..cf7e64f 100644 --- a/README.md +++ b/README.md @@ -129,8 +129,8 @@ make setup TARGET=../other-project REPO=owner/other-project OWNER_TYPE=organizat | --- | --- | --- | | Repository operations inside GitHub Actions | `${{ github.token }}` exposed as `GITHUB_TOKEN` | [Automatic — no custom secret](#automatic-repository-token) | | Local labels, milestones, issues, comments, and similar repository operations | valid `gh auth`, `GITHUB_TOKEN`, `GH_TOKEN`, or `PROJECT_SETUP_PAT` | [Manual/configured](#local-authentication) | -| Local GitHub Projects v2 | `PROJECT_SETUP_PAT` in `.env` | [Manual/configured PAT](#projects-v2-authentication) | -| GitHub Projects v2 from Actions / PR Sync | repository secret `PROJECT_SETUP_PAT` plus repository variable `PROJECT_SETUP_PROJECT_NUMBER` | [Manual/configured PAT + Actions configuration](#projects-v2-authentication) | +| Local GitHub Projects v2 | GitHub App credentials (recommended) or `PROJECT_SETUP_PAT` in `.env` | [Manual/configured](#projects-v2-authentication) | +| GitHub Projects v2 from Actions / PR Sync | GitHub App credentials (recommended) or repository secret `PROJECT_SETUP_PAT`, plus repository variable `PROJECT_SETUP_PROJECT_NUMBER` | [Manual/configured + Actions configuration](#projects-v2-authentication) | ### Automatic repository token @@ -155,9 +155,9 @@ or a supported token in the environment file. `make doctor` reports which source ### Projects v2 authentication -Live Project v2 creation/synchronization requires an explicit `PROJECT_SETUP_PAT`; the repository-scoped Actions token is not used as a silent fallback. +Live Project v2 creation/synchronization requires either GitHub App credentials (`PROJECT_SETUP_APP_ID` plus a private key) or an explicit `PROJECT_SETUP_PAT`. The repository-scoped Actions token is never used as a silent fallback. When App credentials are configured, the minted installation token is used for Projects v2 and no PAT is needed. -For the current GraphQL implementation, create a **personal access token (classic)**: +If you prefer a PAT, create a **personal access token (classic)**: 1. GitHub profile picture → **Settings**; 2. **Developer settings** → **Personal access tokens** → **Tokens (classic)**; @@ -247,7 +247,7 @@ The tool is intentionally conservative because repository setup mixes local file - **Persistent location, explicit mutation:** target/repository identity and Project owner type may live in `.env`, but `LIVE=1` and `FORCE=1` are deliberately not persistent defaults. - **Preserve target files:** the installer skips existing files unless overwrite is explicitly requested. Existing Makefiles, environment templates, and AI instructions should be reviewed and merged rather than blindly replaced. - **No filesystem side effect during install preview:** `init --dry-run` does not create the target directory. -- **Explicit Project v2 boundary:** live Project v2 operations require `PROJECT_SETUP_PAT`; they do not silently fall back to `github.token`. +- **Explicit Project v2 boundary:** live Project v2 operations require GitHub App credentials or `PROJECT_SETUP_PAT`; they do not silently fall back to `github.token`. - **Project owner namespace safety:** Project v2 operations query only the resolved `user` or `organization` GraphQL namespace instead of querying both for one login. - **No credential logging:** diagnostics show credential source/status, never token values. - **Safe HTTP behavior:** GitHub requests have a finite timeout and are restricted to `https://api.github.com`. @@ -256,7 +256,7 @@ The tool is intentionally conservative because repository setup mixes local file - **Explicit source identity:** `.project-setup-source` identifies this tool's source repository and is intentionally not installed into target repositories, preventing embedded targets from inheriting source-only validation contracts. - **Cross-platform entry points:** `.env` is parsed by Python rather than directly included by Make, keeping quoting and Windows behavior aligned with the CLI. -Current intentional limits: generated issues are not idempotent yet, Project v2 views remain manual, rulesets/branch protection are not created, milestone synchronization inspects at most the first 100 existing milestones, and PR Sync label synchronization is additive rather than destructive. PR Sync Project updates remain optional when their PAT/Project number are not configured. +Current intentional limits: generated issues are not idempotent yet, Project v2 views remain manual, native rulesets are reconciled only through the explicit `rulesets plan` / `rulesets apply --live --confirm ` workflow (never automatically) and classic branch protection is not managed, milestone synchronization inspects at most the first 100 existing milestones, and PR Sync label synchronization is additive rather than destructive. PR Sync Project updates remain optional when their PAT/Project number are not configured. ## 6. Documentation diff --git a/config/governance/rulesets.json b/config/governance/rulesets.json index 0fd8cda..18ac3f0 100644 --- a/config/governance/rulesets.json +++ b/config/governance/rulesets.json @@ -9,9 +9,8 @@ {"type": "non_fast_forward"}, {"type": "deletion"} ], - "bypassActors": [ - {"type": "team", "slug": "maintainers", "mode": "pull_request"} - ] + "_comment_bypassActors": "Optional. Leave empty for personal repos. For orgs, add e.g. {\"type\": \"team\", \"slug\": \"\", \"mode\": \"pull_request\"} after confirming the team exists.", + "bypassActors": [] } ] } diff --git a/project_setup/cli.py b/project_setup/cli.py index c6ef93e..7ac97f0 100644 --- a/project_setup/cli.py +++ b/project_setup/cli.py @@ -161,19 +161,22 @@ def cmd_doctor(args: argparse.Namespace) -> int: def cmd_labels_sync(args: argparse.Namespace) -> int: - sync_labels(GitHubClient("") if args.dry_run else require_client(), repo_arg(args.repo), args.file, args.dry_run) + repository = repo_arg(args.repo) + sync_labels(GitHubClient("") if args.dry_run else require_client(repository), repository, args.file, args.dry_run) return 0 def cmd_milestones_sync(args: argparse.Namespace) -> int: - sync_milestones(GitHubClient("") if args.dry_run else require_client(), repo_arg(args.repo), args.file, args.dry_run) + repository = repo_arg(args.repo) + sync_milestones(GitHubClient("") if args.dry_run else require_client(repository), repository, args.file, args.dry_run) return 0 def cmd_issues_generate(args: argparse.Namespace) -> int: + repository = repo_arg(args.repo) generate_issues( - None if args.dry_run else require_client(), - repo_arg(args.repo), + None if args.dry_run else require_client(repository), + repository, args.file, args.dry_run, args.link_subissues, @@ -224,7 +227,8 @@ def cmd_project_sync(args: argparse.Namespace) -> int: def cmd_issue_milestones_sync(args: argparse.Namespace) -> int: - sync_issue_milestones(require_client(), repo_arg(args.repo), args.clear_not_planned, args.dry_run) + repository = repo_arg(args.repo) + sync_issue_milestones(require_client(repository), repository, args.clear_not_planned, args.dry_run) return 0 @@ -252,7 +256,7 @@ def cmd_validate_pr(args: argparse.Namespace) -> int: repository = repo_arg(args.repo) if not args.pr_number: raise SystemExit("--comment requires --pr-number") - upsert_validation_comment(require_client(), repository, args.pr_number, findings) + upsert_validation_comment(require_client(repository), repository, args.pr_number, findings) return 1 if findings else 0 @@ -268,13 +272,14 @@ def cmd_apply(args: argparse.Namespace) -> int: "link_subissues": args.link_subissues if args.link_subissues is not None else defaults.get("linkSubissues", False), "owner_type": args.owner_type, } + repository = repo_arg(args.repo) if values["dry_run"]: client = GitHubClient("") elif values["run_project_creation"]: - client = require_project_client() + client = require_project_client(repository) else: - client = require_client() - run_project_setup(client, repo_arg(args.repo), config, **values) + client = require_client(repository) + run_project_setup(client, repository, config, **values) return 0 diff --git a/project_setup/rulesets.py b/project_setup/rulesets.py index 746c649..1f69260 100644 --- a/project_setup/rulesets.py +++ b/project_setup/rulesets.py @@ -87,7 +87,14 @@ def plan_rulesets(client: GitHubClient, repo: str, path: str) -> tuple[str, list continue full = client.request_json("GET", f"{API_BASE}/repos/{repo}/rulesets/{existing['id']}") actions.append(("unchanged" if _comparison(full) == _comparison(item) else "update", item, int(existing["id"]))) - plan_id = hashlib.sha256(json.dumps([_comparison(item) for item in desired], sort_keys=True).encode()).hexdigest()[:12] + fingerprint = { + "repo": repo, + "actions": [ + {"action": action, "id": identifier, "ruleset": _comparison(item)} + for action, item, identifier in actions + ], + } + plan_id = hashlib.sha256(json.dumps(fingerprint, sort_keys=True).encode()).hexdigest()[:12] for action, item, identifier in actions: print(f"{action}: {item['name']}" + (f" (id={identifier})" if identifier else "")) print(f"plan-id={plan_id}") diff --git a/tests/test_rulesets.py b/tests/test_rulesets.py index e3418ea..cbaa018 100644 --- a/tests/test_rulesets.py +++ b/tests/test_rulesets.py @@ -63,6 +63,23 @@ def test_apply_rejects_stale_or_missing_confirmation(self): with self.assertRaisesRegex(ValueError, "require --confirm"): apply_rulesets(_Client(), "owner/repo", str(path), "wrong") + def test_plan_id_differs_across_repositories(self): + directory, path = self.manifest() + self.addCleanup(directory.cleanup) + plan_a, _ = plan_rulesets(_Client(), "owner/repo-a", str(path)) + plan_b, _ = plan_rulesets(_Client(), "owner/repo-b", str(path)) + self.assertNotEqual(plan_a, plan_b) + + def test_plan_id_differs_when_create_becomes_update(self): + directory, path = self.manifest() + self.addCleanup(directory.cleanup) + create_plan, _ = plan_rulesets(_Client(), "owner/repo", str(path)) + drifted = _Client() + drifted.rulesets = [{"name": "GPA: main", "id": 7}] + update_plan, actions = plan_rulesets(drifted, "owner/repo", str(path)) + self.assertEqual(actions[0][0], "update") + self.assertNotEqual(create_plan, update_plan) + if __name__ == "__main__": unittest.main()