diff --git a/.github/workflows/protected-controls.yml b/.github/workflows/protected-controls.yml index 0d1ad7c..1846eab 100644 --- a/.github/workflows/protected-controls.yml +++ b/.github/workflows/protected-controls.yml @@ -117,6 +117,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) + readiness_run = re.fullmatch(r"autorelease/readiness-(\d+)", head_ref) if ( len(files) == 1 diff --git a/docs/admin-state/mise-php-after.json b/docs/admin-state/mise-php-after.json index a22762e..3d3d5f8 100644 --- a/docs/admin-state/mise-php-after.json +++ b/docs/admin-state/mise-php-after.json @@ -13,65 +13,8 @@ "subscribed": true, "url": "https://api.github.com/repos/Bigpixelrocket/mise-php/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/mise-php/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/mise-php/branches/main/protection/required_pull_request_reviews" - }, - "required_signatures": { - "enabled": false, - "url": "https://api.github.com/repos/Bigpixelrocket/mise-php/branches/main/protection/required_signatures" - }, - "required_status_checks": { - "checks": [ - { - "app_id": 15368, - "context": "Plugin contract" - }, - { - "app_id": 15368, - "context": "Protected controls" - } - ], - "contexts": [ - "Plugin contract", - "Protected controls" - ], - "contexts_url": "https://api.github.com/repos/Bigpixelrocket/mise-php/branches/main/protection/required_status_checks/contexts", - "strict": true, - "url": "https://api.github.com/repos/Bigpixelrocket/mise-php/branches/main/protection/required_status_checks" - }, - "url": "https://api.github.com/repos/Bigpixelrocket/mise-php/branches/main/protection" - }, - "capturedAt": "2026-08-02T19:49:28Z", + "branchProtection": null, + "capturedAt": "2026-08-04T12:48:57Z", "environments": { "environments": [ { @@ -148,7 +91,89 @@ "use_squash_pr_title_as_default": true, "visibility": "public" }, - "rulesets": [], + "rulesets": [ + { + "_links": { + "html": { + "href": "https://github.com/Bigpixelrocket/mise-php/rules/20378767" + }, + "self": { + "href": "https://api.github.com/repos/Bigpixelrocket/mise-php/rulesets/20378767" + } + }, + "bypass_actors": [ + { + "actor_id": 5, + "actor_type": "RepositoryRole", + "bypass_mode": "pull_request" + } + ], + "conditions": { + "ref_name": { + "exclude": [], + "include": [ + "~DEFAULT_BRANCH" + ] + } + }, + "created_at": "2026-08-04T15:12:16.343+03:00", + "current_user_can_bypass": "pull_requests_only", + "enforcement": "active", + "id": 20378767, + "name": "protect_main", + "node_id": "RRS_lACqUmVwb3NpdG9yec5OPbJszgE29I8", + "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": "Plugin contract", + "integration_id": 15368 + }, + { + "context": "Protected controls", + "integration_id": 15368 + } + ], + "strict_required_status_checks_policy": true + }, + "type": "required_status_checks" + } + ], + "source": "Bigpixelrocket/mise-php", + "source_type": "Repository", + "target": "branch", + "updated_at": "2026-08-04T15:12:16.433+03:00" + } + ], "schemaVersion": 1, "secretNames": [ "OPENAI_API_KEY" @@ -161,12 +186,12 @@ ], "verified_allowed": false }, - "snapshotDigest": "sha256:fcdf015fdf96ade8375f29367251f35fec22e14952fd9abe46fecb63ec05f8c9", + "snapshotDigest": "sha256:3fa95b31e4ca35cae67c7bdc8186f5e2eca04d4ad6472f7358c3fed99a5afbe0", "variables": [ "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 66dbb11..d91acc1 100644 --- a/docs/repository-settings.md +++ b/docs/repository-settings.md @@ -7,17 +7,25 @@ secret names are retained. 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 `Plugin contract` 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 any path in `autorelease/protected-paths.json`. -- Bind the required check to the GitHub Actions app, preventing another app + 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 a single `readiness/*.json` + record authored by `github-actions[bot]` on the trusted consumer run's own + branch, validated against the exact run that wrote it. +- 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 workflow token read-only; deterministic downstream jobs diff --git a/test/test_autorelease.py b/test/test_autorelease.py index 6feba0d..d839e41 100644 --- a/test/test_autorelease.py +++ b/test/test_autorelease.py @@ -588,6 +588,16 @@ def test_policy_lua_diff_requires_matching_snapshot(self): seal(repo, base, **arguments) self.assertIn("policy.lua", str(ctx.exception)) + 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 exemption, so it can never widen what a bot + # identity is allowed to merge. + root = pathlib.Path(__file__).resolve().parents[1] + protected_workflow = (root / ".github/workflows/protected-controls.yml").read_text() + owner_pass = protected_workflow.index("if author.lower() == reviewer:") + self.assertLess(protected_workflow.index("No protected control path changed."), owner_pass) + self.assertLess(owner_pass, protected_workflow.index('re.fullmatch(r"autorelease/readiness-')) + def test_token_created_prs_explicitly_dispatch_required_checks(self): root = pathlib.Path(__file__).resolve().parents[1] ci = (root / ".github/workflows/ci.yml").read_text()