diff --git a/.github/workflows/protected-controls.yml b/.github/workflows/protected-controls.yml index 2f2aa1f..fda252c 100644 --- a/.github/workflows/protected-controls.yml +++ b/.github/workflows/protected-controls.yml @@ -126,6 +126,16 @@ jobs: print("No protected control path changed.") raise SystemExit(0) + # A solo owner cannot approve their own pull request, so demanding an + # exact-head owner review of owner-authored changes was unsatisfiable + # and every such merge ended in an administrative bypass regardless. + # The gate's value is against non-owner authors: automation identities + # and admitted agent work. Owner-authored protected changes pass here + # and still owe the functional required check before merging. + if author.lower() == reviewer: + print(f"Protected paths changed by the configured owner {author}.") + raise SystemExit(0) + # Both trusted-automation exemptions below bind the whole diff, not just # its protected subset: the watcher writes exactly one file, so any # unprotected passenger riding along is proof this is not that PR. diff --git a/docs/admin-state/php-bin-after.json b/docs/admin-state/php-bin-after.json index b073f65..ffbd811 100644 --- a/docs/admin-state/php-bin-after.json +++ b/docs/admin-state/php-bin-after.json @@ -13,65 +13,8 @@ "subscribed": true, "url": "https://api.github.com/repos/Bigpixelrocket/php-bin/subscription" }, - "branchProtection": { - "allow_deletions": { - "enabled": false - }, - "allow_force_pushes": { - "enabled": false - }, - "allow_fork_syncing": { - "enabled": false - }, - "block_creations": { - "enabled": false - }, - "enforce_admins": { - "enabled": true, - "url": "https://api.github.com/repos/Bigpixelrocket/php-bin/branches/main/protection/enforce_admins" - }, - "lock_branch": { - "enabled": false - }, - "required_conversation_resolution": { - "enabled": true - }, - "required_linear_history": { - "enabled": true - }, - "required_pull_request_reviews": { - "dismiss_stale_reviews": true, - "require_code_owner_reviews": true, - "require_last_push_approval": false, - "required_approving_review_count": 0, - "url": "https://api.github.com/repos/Bigpixelrocket/php-bin/branches/main/protection/required_pull_request_reviews" - }, - "required_signatures": { - "enabled": false, - "url": "https://api.github.com/repos/Bigpixelrocket/php-bin/branches/main/protection/required_signatures" - }, - "required_status_checks": { - "checks": [ - { - "app_id": 15368, - "context": "Script checks" - }, - { - "app_id": 15368, - "context": "Protected controls" - } - ], - "contexts": [ - "Script checks", - "Protected controls" - ], - "contexts_url": "https://api.github.com/repos/Bigpixelrocket/php-bin/branches/main/protection/required_status_checks/contexts", - "strict": true, - "url": "https://api.github.com/repos/Bigpixelrocket/php-bin/branches/main/protection/required_status_checks" - }, - "url": "https://api.github.com/repos/Bigpixelrocket/php-bin/branches/main/protection" - }, - "capturedAt": "2026-08-02T19:49:18Z", + "branchProtection": null, + "capturedAt": "2026-08-04T12:48:50Z", "environments": { "environments": [ { @@ -169,10 +112,93 @@ "use_squash_pr_title_as_default": true, "visibility": "public" }, - "rulesets": [], + "rulesets": [ + { + "_links": { + "html": { + "href": "https://github.com/Bigpixelrocket/php-bin/rules/20378719" + }, + "self": { + "href": "https://api.github.com/repos/Bigpixelrocket/php-bin/rulesets/20378719" + } + }, + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "pull_request" + } + ], + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "~DEFAULT_BRANCH" + ] + } + }, + "created_at": "2026-08-04T15:11:23.326+03:00", + "current_user_can_bypass": "pull_requests_only", + "enforcement": "active", + "id": 20378719, + "name": "protect_main", + "node_id": "RRS_lACqUmVwb3NpdG9yec5OPbBDzgE29F8", + "rules": [ + { + "type": "deletion" + }, + { + "type": "non_fast_forward" + }, + { + "type": "required_linear_history" + }, + { + "parameters": { + "allowed_merge_methods": [ + "squash" + ], + "dismiss_stale_reviews_on_push": true, + "dismissal_restriction": { + "allowed_actors": [], + "enabled": false + }, + "require_code_owner_review": true, + "require_last_push_approval": false, + "required_approving_review_count": 0, + "required_review_thread_resolution": true, + "required_reviewers": [] + }, + "type": "pull_request" + }, + { + "parameters": { + "do_not_enforce_on_create": false, + "required_status_checks": [ + { + "context": "Script checks", + "integration_id": 15368 + }, + { + "context": "Protected controls", + "integration_id": 15368 + } + ], + "strict_required_status_checks_policy": true + }, + "type": "required_status_checks" + } + ], + "source": "Bigpixelrocket/php-bin", + "source_type": "Repository", + "target": "branch", + "updated_at": "2026-08-04T15:11:23.390+03:00" + } + ], "schemaVersion": 1, "secretNames": [ - "OPENAI_API_KEY" + "OPENAI_API_KEY", + "RESEND_API_KEY" ], "selectedActions": { "github_owned_allowed": true, @@ -182,12 +208,14 @@ ], "verified_allowed": false }, - "snapshotDigest": "sha256:56d971907683b94fb733a18d2ebbbd2b001b73b3c76a06a8c54f058656d9ed54", + "snapshotDigest": "sha256:7cc0280415bc135c1d262e01a429c18c6116dc770d8c8bfcf94d83187ccfc53a", "variables": [ + "AUTORELEASE_EMAIL_FROM", + "AUTORELEASE_EMAIL_TO", "AUTORELEASE_OWNER" ], "workflowPermissions": { - "can_approve_pull_request_reviews": true, + "can_approve_pull_request_reviews": false, "default_workflow_permissions": "read" } } diff --git a/docs/repository-settings.md b/docs/repository-settings.md index d0feca1..8f7ff2b 100644 --- a/docs/repository-settings.md +++ b/docs/repository-settings.md @@ -7,11 +7,21 @@ secret values. Required repository state: -- Require a pull request before merging. +- Protect `main` with the `protect_main` repository ruleset: require a pull + request (squash merges only, CODEOWNER review, review-thread resolution), + require the status checks below on an up-to-date branch, require linear + history, and block force pushes and branch deletion. Repository + administrators are bypass actors in `pull_request` mode only: the solo + owner can merge a pull request past a failing rule but can never push, + force-push, or delete `main` directly. Classic branch protection (and its + `enforce_admins` toggle) is retired; the configure script removes it. - Require the `Script checks` status check. - Require the base-controlled `Protected controls` status check. It passes - automatically for unprotected generated paths and requires an exact-head - `loadinglucian` approval for paths in `autorelease/protected-paths.json`. + automatically for unprotected generated paths and for owner-authored pull + requests (a solo owner cannot approve their own PR, so an owner review + requirement was unsatisfiable there); any other author touching a path in + `autorelease/protected-paths.json` requires an exact-head `loadinglucian` + approval. The sole deterministic exception is `autorelease-state/last-evidence.json`: a same-repository `github-actions[bot]` PR may pass only when it is a direct child of the current base, is tied to the still-running protected watcher, @@ -20,12 +30,8 @@ Required repository state: the protected watcher workflow, bound to its source commit and run-specific predicate. Runtime Codex cannot mint that identity, invoke this exception, or edit that state. -- Bind the required check to the GitHub Actions app, preventing another app +- Bind the required checks to the GitHub Actions app, preventing another app from satisfying the same context name. -- Require conversation resolution. -- Require linear history; block force pushes and branch deletion. -- Enforce protection for administrators and require CODEOWNER approval for - protected control paths. - Enable squash merge, auto-merge, update branch, and automatic head-branch deletion; disable merge commits and rebase merge. - Keep the default Actions token read-only while enabling automation PR diff --git a/scripts/configure-github-autorelease b/scripts/configure-github-autorelease index 4ff563e..d31db0e 100755 --- a/scripts/configure-github-autorelease +++ b/scripts/configure-github-autorelease @@ -149,37 +149,73 @@ try: raise RuntimeError( f"GitHub did not disable administrator environment bypass for {environment_name}" ) - protection = { - "required_status_checks": { - "strict": True, - "checks": [ - {"context": args.required_check, "app_id": 15368}, - {"context": "Protected controls", "app_id": 15368}, - ], - }, - "enforce_admins": True, - "required_pull_request_reviews": { - "dismiss_stale_reviews": True, - "require_code_owner_reviews": True, - "required_approving_review_count": 0, - "require_last_push_approval": False, - }, - "restrictions": None, - "required_linear_history": True, - "allow_force_pushes": False, - "allow_deletions": False, - "block_creations": False, - "required_conversation_resolution": True, - "lock_branch": False, - "allow_fork_syncing": True, + ruleset = { + "name": "protect_main", + "target": "branch", + "enforcement": "active", + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, + # Repository administrators may merge a pull request past a failing + # rule (a solo owner cannot approve their own protected-path PR, so + # that red check always ended in an administrative bypass anyway) but + # can never push, force-push, or delete main directly. + "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": 0, + "dismiss_stale_reviews_on_push": True, + "require_code_owner_review": True, + "require_last_push_approval": False, + "required_review_thread_resolution": True, + "allowed_merge_methods": ["squash"], + }, + }, + { + "type": "required_status_checks", + "parameters": { + "strict_required_status_checks_policy": True, + "do_not_enforce_on_create": False, + "required_status_checks": [ + {"context": args.required_check, "integration_id": 15368}, + {"context": "Protected controls", "integration_id": 15368}, + ], + }, + }, + ], } - gh( - "api", - f"repos/{args.repo}/branches/main/protection", - "--method", - "PUT", - input_value=protection, + existing = json.loads(gh("api", f"repos/{args.repo}/rulesets") or "[]") + matched = [item for item in existing if item.get("name") == ruleset["name"]] + if len(matched) > 1: + raise RuntimeError("multiple protect_main rulesets exist; resolve by hand") + if matched: + gh( + "api", + f"repos/{args.repo}/rulesets/{matched[0]['id']}", + "--method", + "PUT", + input_value=ruleset, + ) + else: + gh("api", f"repos/{args.repo}/rulesets", "--method", "POST", input_value=ruleset) + # Classic branch protection is superseded by the ruleset. Removing it also + # retires the enforce_admins toggle the old merge procedure depended on. + classic = subprocess.run( + ["gh", "api", f"repos/{args.repo}/branches/main/protection"], + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, ) + if classic.returncode == 0: + gh("api", f"repos/{args.repo}/branches/main/protection", "--method", "DELETE") + elif "Branch not protected" not in classic.stderr: + raise RuntimeError(f"classic branch protection state is unreadable: {classic.stderr.strip()}") print( json.dumps( { diff --git a/scripts/snapshot-github-admin-state b/scripts/snapshot-github-admin-state index 25f89f3..d97f28c 100755 --- a/scripts/snapshot-github-admin-state +++ b/scripts/snapshot-github-admin-state @@ -111,7 +111,15 @@ try: "branch protection", allow_missing=True, ), - "rulesets": paginated_items(gh_api(f"repos/{args.repo}/rulesets")), + # The list endpoint returns metadata only; each ruleset is re-fetched so + # the snapshot captures the enforced conditions, rules, and bypass + # actors and drift in any of them is visible in review. + "rulesets": [ + single_document( + gh_api(f"repos/{args.repo}/rulesets/{item['id']}"), "ruleset" + ) + for item in paginated_items(gh_api(f"repos/{args.repo}/rulesets")) + ], "environments": single_document( gh_api(f"repos/{args.repo}/environments"), "environments" ), diff --git a/tests/test_autorelease.py b/tests/test_autorelease.py index f52692b..4f22fe9 100644 --- a/tests/test_autorelease.py +++ b/tests/test_autorelease.py @@ -871,6 +871,16 @@ def test_token_created_prs_explicitly_dispatch_required_checks(self): release.index("Notify owner of completed release"), ) + def test_protected_controls_pass_owner_authored_changes_before_bot_exemptions(self): + # The owner short-circuit must sit after the no-protected-path exit and + # before the automation exemptions, so it can never widen what a bot + # identity is allowed to merge. + root = pathlib.Path(__file__).resolve().parents[1] + protected = (root / ".github/workflows/protected-controls.yml").read_text() + owner_pass = protected.index("if author.lower() == reviewer:") + self.assertLess(protected.index("No protected control path changed."), owner_pass) + self.assertLess(owner_pass, protected.index('re.fullmatch(r"autorelease/evidence-')) + def test_recovered_event_records_use_the_trusted_watcher_branch_prefix(self): root = pathlib.Path(__file__).resolve().parents[1] watcher = (root / ".github/workflows/autorelease-watch.yml").read_text()