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
4 changes: 2 additions & 2 deletions .github/config/required-contexts.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"required_contexts": [
"Gate / gate"
"summary"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align the weekly auditor with the new required context

Once Health 44 applies this configuration, the scheduled Health 41 workflow still hard-codes expectedContexts = ['Gate / gate'] in .github/workflows/health-41-repo-health.yml:411 and fails on missing or unexpected contexts at lines 485-530. Consequently, either the intended summary-only rule or a transitional --no-clean rule containing both contexts causes the weekly health workflow to fail and instructs operators to restore the context rejected here. Update that auditor and the branch-protection contract documentation alongside this switch.

AGENTS.md reference: AGENTS.md:L62-L65

Useful? React with 👍 / 👎.

],
"_note": "Only universally-posted contexts belong here: health-44 can pass this file to enforce_gate_branch_protection.py --apply, so any context listed becomes a REQUIRED status check. 'Health 45 Agents Guard / guard' is deliberately absent: agents-guard.yml posts that status only when the PR carries an agent label (agent:codex, agents:auto-pilot, ...), so requiring it would leave every other PR permanently un-mergeable. See issue #2858."
"_note": "`summary` is the Gate workflow's own aggregate job (pr-00-gate.yml): it `needs` every other Gate job with `if: always()`, so requiring it gates all of Gate. Only universally-posted, self-healing contexts belong here, because health-44 can pass this file to enforce_gate_branch_protection.py --apply and anything listed becomes a REQUIRED check. Deliberately absent: 'Gate / gate' (a commit STATUS posted by that same summary job — a duplicate verdict that can be left `pending` or stale-`failure` after cancelled concurrent runs, blocking merges with no real defect) and 'Health 45 Agents Guard / guard' (posted only for agent-labelled PRs). See issue #2858."
}
7 changes: 4 additions & 3 deletions .github/workflows/health-40-repo-selfcheck.yml
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,8 @@ jobs:
python tools/enforce_gate_branch_protection.py \
--apply \
--branch "${DEFAULT_BRANCH}" \
--context "Gate / gate" \
--context "summary" \
--allow-non-strict \
--no-clean

- name: Snapshot branch protection state
Expand All @@ -130,9 +131,9 @@ jobs:
run: |
python tools/enforce_gate_branch_protection.py \
--check \
--require-strict \
--branch "${DEFAULT_BRANCH}" \
--context "Gate / gate" \
--context "summary" \
--allow-non-strict \
--no-clean \
--snapshot repo-health-branch-protection.json

Expand Down
4 changes: 3 additions & 1 deletion .github/workflows/health-44-gate-branch-protection.yml
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ jobs:
python tools/enforce_gate_branch_protection.py \
--apply \
--config .github/config/required-contexts.json \
--allow-non-strict \
--no-clean \
--snapshot "${SNAPSHOT_DIR}/enforcement.json"

Expand All @@ -236,8 +237,8 @@ jobs:
run: |
timeout 5m python tools/enforce_gate_branch_protection.py \
--check \
--require-strict \
--config .github/config/required-contexts.json \
--allow-non-strict \
--no-clean \
--snapshot "${SNAPSHOT_DIR}/verification.json"

Expand All @@ -255,6 +256,7 @@ jobs:
python tools/enforce_gate_branch_protection.py \
--check \
--config .github/config/required-contexts.json \
--allow-non-strict \
--no-clean \
--snapshot "${SNAPSHOT_DIR}/verification.json"

Expand Down
52 changes: 52 additions & 0 deletions tests/tools/test_enforce_gate_branch_protection.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from types import SimpleNamespace
from typing import Any

import pytest
Expand Down Expand Up @@ -256,3 +257,54 @@ def test_state_from_branch_payload_preserves_explicit_strict_value() -> None:
)

assert state == gate.StatusCheckState(strict=False, contexts=["Gate / gate"])


def test_allow_non_strict_accepts_a_deliberately_non_strict_policy(monkeypatch, capsys):
"""A non-strict ruleset is not drift when the reviewed policy is non-strict."""
monkeypatch.setenv("GITHUB_TOKEN", "token")
monkeypatch.setattr(
gate,
"fetch_status_checks",
lambda *a, **k: gate.StatusCheckState(strict=False, contexts=["summary"]),
)
monkeypatch.setattr(gate, "_build_session", lambda token: SimpleNamespace())

exit_code = gate.main(
[
"--repo",
"octo/repo",
"--check",
"--allow-non-strict",
"--no-clean",
"--context",
"summary",
]
)

out = capsys.readouterr().out
assert exit_code == 0
assert "No changes required." in out
assert "Desired 'require up to date': False" in out


def test_without_allow_non_strict_a_non_strict_policy_is_still_drift(monkeypatch, capsys):
"""The default is unchanged: non-strict counts as drift unless opted out."""
monkeypatch.setenv("GITHUB_TOKEN", "token")
monkeypatch.setattr(
gate,
"fetch_status_checks",
lambda *a, **k: gate.StatusCheckState(strict=False, contexts=["summary"]),
)
monkeypatch.setattr(gate, "_build_session", lambda token: SimpleNamespace())

exit_code = gate.main(["--repo", "octo/repo", "--check", "--no-clean", "--context", "summary"])

out = capsys.readouterr().out
assert exit_code == 1
assert "Would enable 'require branches to be up to date'." in out


def test_allow_non_strict_conflicts_with_require_strict(monkeypatch):
monkeypatch.setenv("GITHUB_TOKEN", "token")
with pytest.raises(SystemExit):
gate.main(["--repo", "octo/repo", "--check", "--allow-non-strict", "--require-strict"])
37 changes: 26 additions & 11 deletions tools/enforce_gate_branch_protection.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@ def resolve_api_root(explicit: str | None = None) -> str:


DEFAULT_CONTEXTS = (
# Only universally-posted contexts may be required. "Health 45 Agents Guard /
# guard" is posted by agents-guard.yml ONLY for agent-labelled PRs, so requiring
# it would block every other PR forever (issue #2858).
"Gate / gate",
# `summary` is the Gate workflow's aggregate job: it needs every other Gate
# job, so requiring it gates all of Gate. Do NOT add "Gate / gate" — that is a
# commit status posted by the same job (duplicate verdict) which can be left
# pending or stale-failure after cancelled runs, and "Health 45 Agents Guard /
# guard" is only posted for agent-labelled PRs. Both would block PRs with no
# real defect (issue #2858).
"summary",
)

DEFAULT_CONFIG_PATH = Path(".github/config/required-contexts.json")
Expand Down Expand Up @@ -675,6 +678,15 @@ def main(argv: Sequence[str] | None = None) -> int:
action="store_true",
help="Exit with a non-zero status if changes would be required without applying them.",
)
parser.add_argument(
"--allow-non-strict",
action="store_true",
help=(
"Do not treat a disabled 'require branches to be up to date' setting as"
" drift. Use when the reviewed policy is deliberately non-strict (e.g. a"
" repo with no merge queue, where strict would strand PRs behind base)."
),
)
parser.add_argument(
"--require-strict",
action="store_true",
Expand All @@ -694,6 +706,8 @@ def main(argv: Sequence[str] | None = None) -> int:
)

args = parser.parse_args(argv)
if args.allow_non_strict and args.require_strict:
parser.error("--allow-non-strict and --require-strict are contradictory")

if args.apply and args.check:
parser.error("--check cannot be combined with --apply.")
Expand All @@ -719,6 +733,7 @@ def main(argv: Sequence[str] | None = None) -> int:
api_root = resolve_api_root(args.api_url)
token = require_token(args.token)
session = _build_session(token)
desired_strict = not args.allow_non_strict

try:
current_state = fetch_status_checks(session, args.repo, args.branch, api_root=api_root)
Expand All @@ -735,13 +750,13 @@ def main(argv: Sequence[str] | None = None) -> int:
label = "Target contexts" if args.no_clean else "Desired contexts"
print(f"{label}: {format_contexts(desired_contexts)}")
print("Current 'require up to date': False")
print("Desired 'require up to date': True")
print(f"Desired 'require up to date': {desired_strict}")

if snapshot is not None:
snapshot.update(
{
"current": None,
"desired": {"strict": True, "contexts": list(desired_contexts)},
"desired": {"strict": desired_strict, "contexts": list(desired_contexts)},
"changes_required": True,
"require_strict": bool(args.require_strict),
"strict_unknown": False,
Expand All @@ -757,7 +772,7 @@ def main(argv: Sequence[str] | None = None) -> int:
args.repo,
args.branch,
contexts=desired_contexts,
strict=True,
strict=desired_strict,
api_root=api_root,
)
except BranchProtectionError as exc:
Expand Down Expand Up @@ -803,7 +818,7 @@ def main(argv: Sequence[str] | None = None) -> int:

to_add, to_remove = diff_contexts(current_state.contexts, target_contexts)
strict_is_unknown = current_state.strict is None
strict_change = current_state.strict is False
strict_change = desired_strict and current_state.strict is False

if snapshot is not None:
snapshot["desired"] = {"strict": True, "contexts": list(target_contexts)}
Comment on lines +821 to 824
Comment on lines +821 to 824

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply one consistent non-strict target to existing rules

When --allow-non-strict encounters an existing rule with strict=True, strict_change is false: if contexts already match, the tool reports no changes and preserves strict mode, but if a context also needs updating, update_status_checks(..., strict=False) disables it. The snapshot simultaneously hard-codes the desired value as True. Thus the resulting policy depends on unrelated context drift and the audit artifact contradicts the printed target; compare the known strict state with desired_strict and record that same target in the snapshot.

Useful? React with 👍 / 👎.

Expand All @@ -821,7 +836,7 @@ def main(argv: Sequence[str] | None = None) -> int:
print("Current 'require up to date': (unknown - supply BRANCH_PROTECTION_TOKEN to verify)")
else:
print(f"Current 'require up to date': {current_state.strict}")
print("Desired 'require up to date': True")
print(f"Desired 'require up to date': {desired_strict}")

if strict_is_unknown:
if args.require_strict:
Expand All @@ -836,7 +851,7 @@ def main(argv: Sequence[str] | None = None) -> int:
"The check will pass, but rerun with BRANCH_PROTECTION_TOKEN to audit."
)

if args.require_strict and strict_is_unknown:
if args.require_strict and strict_is_unknown and desired_strict:
strict_change = True

no_changes_required = not to_add and (args.no_clean or not to_remove) and not strict_change
Expand Down Expand Up @@ -874,7 +889,7 @@ def main(argv: Sequence[str] | None = None) -> int:
args.repo,
args.branch,
contexts=target_contexts,
strict=True,
strict=desired_strict,
api_root=api_root,
)
except BranchProtectionError as exc:
Expand Down
Loading