Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .github/workflows/protected-controls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
154 changes: 91 additions & 63 deletions docs/admin-state/php-bin-after.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": [
{
Expand Down Expand Up @@ -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"
}
],
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"schemaVersion": 1,
"secretNames": [
"OPENAI_API_KEY"
"OPENAI_API_KEY",
"RESEND_API_KEY"
],
"selectedActions": {
"github_owned_allowed": true,
Expand All @@ -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"
}
}
22 changes: 14 additions & 8 deletions docs/repository-settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down
94 changes: 65 additions & 29 deletions scripts/configure-github-autorelease
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand Down
10 changes: 9 additions & 1 deletion scripts/snapshot-github-admin-state
Original file line number Diff line number Diff line change
Expand Up @@ -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"
),
Expand Down
10 changes: 10 additions & 0 deletions tests/test_autorelease.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading