From e9172710a2fa04618dfe4d242f9cbd4e69acf58a Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Tue, 4 Aug 2026 14:49:53 +0300 Subject: [PATCH 1/3] ci: replace classic branch protection with a bypassable ruleset The configure script now installs a protect_main ruleset (same PR, check, and history rules) with repository administrators as bypass actors in pull_request mode, and removes classic protection along with the enforce_admins toggle the old merge procedure depended on. Protected controls now passes owner-authored PRs: a solo owner cannot approve their own PR, so that requirement was unsatisfiable and every such merge ended in an administrative bypass regardless. The gate keeps its full strength against non-owner authors. --- .github/workflows/protected-controls.yml | 10 +++ docs/repository-settings.md | 22 ++++-- scripts/configure-github-autorelease | 94 ++++++++++++++++-------- tests/test_autorelease.py | 10 +++ 4 files changed, 99 insertions(+), 37 deletions(-) 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/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/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() From d5f0aac5bd6a6558354a9ee4240d9aab74890ba7 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Tue, 4 Aug 2026 15:13:39 +0300 Subject: [PATCH 2/3] chore: refresh php-bin admin state snapshot --- docs/admin-state/php-bin-after.json | 92 +++++++++-------------------- 1 file changed, 29 insertions(+), 63 deletions(-) diff --git a/docs/admin-state/php-bin-after.json b/docs/admin-state/php-bin-after.json index b073f65..bd1fd11 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:13:03Z", "environments": { "environments": [ { @@ -169,10 +112,31 @@ "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" + } + }, + "created_at": "2026-08-04T15:11:23.326+03:00", + "enforcement": "active", + "id": 20378719, + "name": "protect_main", + "node_id": "RRS_lACqUmVwb3NpdG9yec5OPbBDzgE29F8", + "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 +146,14 @@ ], "verified_allowed": false }, - "snapshotDigest": "sha256:56d971907683b94fb733a18d2ebbbd2b001b73b3c76a06a8c54f058656d9ed54", + "snapshotDigest": "sha256:e2dfc2beedaa1f7ccf1d46c27f32b66becc17825fa87582cbaa147234e09c134", "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" } } From f71e13c99bd403beed6de97920a058a7ce3cec05 Mon Sep 17 00:00:00 2001 From: Lucian Behind The Scenes Date: Tue, 4 Aug 2026 15:49:42 +0300 Subject: [PATCH 3/3] fix: capture full ruleset configuration in admin snapshots The ruleset list endpoint returns metadata only, so the snapshot could not show drift in conditions, rules, or bypass actors now that the ruleset is the merge protection. Each ruleset is re-fetched individually and both snapshots are regenerated. --- docs/admin-state/php-bin-after.json | 66 ++++++++++++++++++++++++++++- scripts/snapshot-github-admin-state | 10 ++++- 2 files changed, 73 insertions(+), 3 deletions(-) diff --git a/docs/admin-state/php-bin-after.json b/docs/admin-state/php-bin-after.json index bd1fd11..ffbd811 100644 --- a/docs/admin-state/php-bin-after.json +++ b/docs/admin-state/php-bin-after.json @@ -14,7 +14,7 @@ "url": "https://api.github.com/repos/Bigpixelrocket/php-bin/subscription" }, "branchProtection": null, - "capturedAt": "2026-08-04T12:13:03Z", + "capturedAt": "2026-08-04T12:48:50Z", "environments": { "environments": [ { @@ -122,11 +122,73 @@ "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", @@ -146,7 +208,7 @@ ], "verified_allowed": false }, - "snapshotDigest": "sha256:e2dfc2beedaa1f7ccf1d46c27f32b66becc17825fa87582cbaa147234e09c134", + "snapshotDigest": "sha256:7cc0280415bc135c1d262e01a429c18c6116dc770d8c8bfcf94d83187ccfc53a", "variables": [ "AUTORELEASE_EMAIL_FROM", "AUTORELEASE_EMAIL_TO", 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" ),