diff --git a/.github/workflows/audit-central-ruleset.yml b/.github/workflows/audit-central-ruleset.yml index a17811a1d3..fda7914c93 100644 --- a/.github/workflows/audit-central-ruleset.yml +++ b/.github/workflows/audit-central-ruleset.yml @@ -42,9 +42,11 @@ jobs: ORG_LOGIN: ContextualWisdomLab RULESET_ID: "18156473" STACKED_RULESET_ID: "21732164" + REPOSITORY_RULESET_ID: "17921150" RULESET_SENTINEL_REPOSITORY: naruon run: | set -euo pipefail + audit_status=0 ruleset_json="$RUNNER_TEMP/central-required-workflow-ruleset.json" ruleset_with_scope_json="$RUNNER_TEMP/central-required-workflow-ruleset-with-scope.json" ruleset_error="$RUNNER_TEMP/central-required-workflow-ruleset.error" @@ -90,7 +92,21 @@ jobs: jq --slurpfile scope "$scope_json" \ '. + {"_audit_repository_scope": $scope[0]}' \ "$ruleset_json" >"$ruleset_with_scope_json" - python3 scripts/ci/audit_central_required_workflows.py "$ruleset_with_scope_json" + if ! python3 scripts/ci/audit_central_required_workflows.py "$ruleset_with_scope_json"; then + audit_status=1 + fi + + repository_ruleset_json="$RUNNER_TEMP/owner-repository-ruleset.json" + repository_ruleset_error="$RUNNER_TEMP/owner-repository-ruleset.error" + repository_ruleset_endpoint="repos/${ORG_LOGIN}/.github/rulesets/${REPOSITORY_RULESET_ID}?includes_parents=true" + if ! gh api "$repository_ruleset_endpoint" >"$repository_ruleset_json" 2>"$repository_ruleset_error"; then + echo "::error::Ruleset audit could not read owner repository ruleset ${REPOSITORY_RULESET_ID}." + sed 's/^/ /' "$repository_ruleset_error" + exit 1 + fi + if ! python3 scripts/ci/audit_central_required_workflows.py --repository "$repository_ruleset_json"; then + audit_status=1 + fi stacked_ruleset_json="$RUNNER_TEMP/stacked-opencode-ruleset.json" stacked_ruleset_error="$RUNNER_TEMP/stacked-opencode-ruleset.error" @@ -100,7 +116,14 @@ jobs: sed 's/^/ /' "$stacked_ruleset_error" exit 1 fi - python3 scripts/ci/audit_central_required_workflows.py --stacked "$stacked_ruleset_json" + if ! python3 scripts/ci/audit_central_required_workflows.py --stacked "$stacked_ruleset_json"; then + audit_status=1 + fi + + if [[ "$audit_status" -ne 0 ]]; then + echo "::error::One or more fetched rulesets drift from the declared governance contract." + exit "$audit_status" + fi - name: Audit organization CodeQL coverage env: diff --git a/.github/workflows/ruleset-governance-reconcile.yml b/.github/workflows/ruleset-governance-reconcile.yml new file mode 100644 index 0000000000..7fb7480922 --- /dev/null +++ b/.github/workflows/ruleset-governance-reconcile.yml @@ -0,0 +1,181 @@ +name: Ruleset Governance Reconcile + +on: + pull_request: + paths: + - "config/ruleset-governance.json" + - "scripts/ci/audit_central_required_workflows.py" + - "scripts/ci/reconcile_ruleset_governance.py" + - "tests/test_ruleset_governance_reconciliation.py" + - "tests/test_ruleset_governance_review_regressions.py" + - "tests/test_ruleset_governance_review_round2.py" + - "tests/test_ruleset_governance_review_round3.py" + - "tests/test_ruleset_governance_timeout_regression.py" + - "tests/test_ruleset_governance_delayed_recovery_regression.py" + - "tests/test_ruleset_governance_runtime_budget_regression.py" + - "tests/test_ruleset_governance_post_put_cleanup_regression.py" + - "tests/test_central_required_workflow_ruleset_audit.py" + - "tests/test_ruleset_audit_completeness_regression.py" + - "tests/test_ruleset_merge_method_shape_regression.py" + - "tests/test_solo_maintainer_ruleset_policy.py" + - "docs/doctoring/ruleset-owner-plane-reconciliation.md" + - ".github/workflows/ruleset-governance-reconcile.yml" + push: + branches: + - main + paths: + - "config/ruleset-governance.json" + - "scripts/ci/audit_central_required_workflows.py" + - "scripts/ci/reconcile_ruleset_governance.py" + - "tests/test_ruleset_governance_reconciliation.py" + - "tests/test_ruleset_governance_review_regressions.py" + - "tests/test_ruleset_governance_review_round2.py" + - "tests/test_ruleset_governance_review_round3.py" + - "tests/test_ruleset_governance_timeout_regression.py" + - "tests/test_ruleset_governance_delayed_recovery_regression.py" + - "tests/test_ruleset_governance_runtime_budget_regression.py" + - "tests/test_ruleset_governance_post_put_cleanup_regression.py" + - "tests/test_central_required_workflow_ruleset_audit.py" + - "tests/test_ruleset_audit_completeness_regression.py" + - "tests/test_ruleset_merge_method_shape_regression.py" + - "tests/test_solo_maintainer_ruleset_policy.py" + - "docs/doctoring/ruleset-owner-plane-reconciliation.md" + - ".github/workflows/ruleset-governance-reconcile.yml" + schedule: + - cron: "31 * * * *" + +permissions: + contents: read + +concurrency: + group: ruleset-governance-reconcile-${{ github.event_name == 'pull_request' && github.event.pull_request.number || 'owner-plane' }} + # PR validation may safely supersede itself. Privileged owner-plane runs must + # finish their PUT + immutable-history verification/recovery critical section; + # cancelling them after PUT can strand an unverified overwrite. + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + report-disabled: + if: >- + github.event_name == 'schedule' && + vars.CWL_RULESET_RECONCILE_ENABLED != 'true' + runs-on: ubuntu-slim + timeout-minutes: 2 + steps: + - name: Report disabled owner-plane state + shell: bash + run: >- + echo "Ruleset reconciliation is disabled; provision the protected owner-plane credential and enable variable before live mutation." >> "$GITHUB_STEP_SUMMARY" + + validate: + # Source/regression drift is already validated on PR, push, and manual runs. + # A disabled schedule emits only the cheap report-disabled signal above. + if: >- + github.event_name != 'schedule' || + vars.CWL_RULESET_RECONCILE_ENABLED == 'true' + runs-on: ubuntu-slim + timeout-minutes: 20 + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + - name: Check out exact revision + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false + - name: Verify exact revision + shell: bash + run: test "$(git rev-parse HEAD)" = "${{ github.event.pull_request.head.sha || github.sha }}" + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Install hash-locked test tooling + run: >- + python -m pip install --disable-pip-version-check --require-hashes + --only-binary=:all: -r requirements-opencode-review-ci-hashes.txt + - name: Validate reviewed target manifest + run: >- + python scripts/ci/reconcile_ruleset_governance.py + --manifest config/ruleset-governance.json + --validate-only + - name: Prove reconciliation contract at repository quality gates + env: + COVERAGE_RCFILE: /dev/null + run: | + set -euo pipefail + python -m coverage run \ + --branch \ + --include=scripts/ci/reconcile_ruleset_governance.py \ + -m pytest -q \ + tests/test_ruleset_governance_reconciliation.py \ + tests/test_ruleset_governance_review_regressions.py \ + tests/test_ruleset_governance_review_round2.py \ + tests/test_ruleset_governance_review_round3.py \ + tests/test_ruleset_governance_timeout_regression.py \ + tests/test_ruleset_governance_delayed_recovery_regression.py \ + tests/test_ruleset_governance_runtime_budget_regression.py \ + tests/test_ruleset_governance_post_put_cleanup_regression.py \ + tests/test_central_required_workflow_ruleset_audit.py \ + tests/test_ruleset_audit_completeness_regression.py \ + tests/test_ruleset_merge_method_shape_regression.py \ + tests/test_solo_maintainer_ruleset_policy.py + python -m coverage report \ + --fail-under=100 \ + --show-missing \ + --include=scripts/ci/reconcile_ruleset_governance.py + python -m interrogate \ + --fail-under 100 \ + scripts/ci/reconcile_ruleset_governance.py + git diff --check + + apply: + if: >- + github.event_name != 'pull_request' && + github.ref == 'refs/heads/main' && + vars.CWL_RULESET_RECONCILE_ENABLED == 'true' + needs: validate + runs-on: ubuntu-24.04 + # Use the full documented GitHub-hosted job execution limit so this workflow + # cannot impose an earlier timeout on the 128-minute mutation/recovery bound. + timeout-minutes: 360 + environment: ruleset-governance-maintenance + steps: + - name: Harden runner + uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 # v2.20.0 + with: + egress-policy: audit + - name: Check out trusted default branch + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + ref: ${{ github.sha }} + persist-credentials: false + - name: Verify exact trusted revision + shell: bash + run: test "$(git rev-parse HEAD)" = "${GITHUB_SHA}" + - name: Set up Python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: "3.12" + - name: Require dedicated ruleset administration credential + env: + GH_TOKEN: ${{ secrets.CWL_RULESET_ADMIN_TOKEN }} + shell: bash + run: test -n "${GH_TOKEN}" + - name: Reconcile reviewed ruleset governance + env: + GH_TOKEN: ${{ secrets.CWL_RULESET_ADMIN_TOKEN }} + EXPECTED_MAIN_SHA: ${{ github.sha }} + run: >- + python scripts/ci/reconcile_ruleset_governance.py + --manifest config/ruleset-governance.json + --expected-main-sha "$EXPECTED_MAIN_SHA" + - name: Verify post-change convergence from GitHub + env: + GH_TOKEN: ${{ secrets.CWL_RULESET_ADMIN_TOKEN }} + run: >- + python scripts/ci/reconcile_ruleset_governance.py + --manifest config/ruleset-governance.json + --verify-only diff --git a/config/ruleset-governance.json b/config/ruleset-governance.json new file mode 100644 index 0000000000..9a315c98c1 --- /dev/null +++ b/config/ruleset-governance.json @@ -0,0 +1,20 @@ +{ + "schema_version": 1, + "organization": "ContextualWisdomLab", + "targets": [ + { + "scope": "repository", + "owner": "ContextualWisdomLab", + "repository": ".github", + "ruleset_id": 17921150, + "name": "Lock default branch" + }, + { + "scope": "organization", + "owner": "ContextualWisdomLab", + "repository": null, + "ruleset_id": 18156473, + "name": "CWL Central required workflows" + } + ] +} diff --git a/docs/doctoring/ruleset-owner-plane-reconciliation.md b/docs/doctoring/ruleset-owner-plane-reconciliation.md new file mode 100644 index 0000000000..3e746d0671 --- /dev/null +++ b/docs/doctoring/ruleset-owner-plane-reconciliation.md @@ -0,0 +1,77 @@ +# Ruleset owner-plane reconciliation + +Date reviewed: 2026-09-02 + +## Incident and product impact + +Orgmetra's protected `develop` is currently governed by organization ruleset `18156473`, while the central `.github` default branch also has repository ruleset `17921150`. Live reads on 2026-09-02 showed two policy drifts that block a defensible ordinary merge path: the organization ruleset still requires one generic approval even though the current operating model has one human maintainer and it retains `OrganizationAdmin/always`; the `.github` repository ruleset already has approval count zero but still permits rebase and also retains `OrganizationAdmin/always`. + +The application connector can read those settings but does not expose ruleset mutation. Administrator bypass is not an acceptable substitute because it would destroy the canary needed to prove the normal path. The owner-plane repair therefore needs reviewed configuration-as-code plus a separately provisioned, narrowly scoped credential rather than an application-side shim. + +## Design decision + +`config/ruleset-governance.json` is not merely shape-validated. Production code pins the privileged manifest to exactly repository ruleset `17921150` (`ContextualWisdomLab/.github`, `Lock default branch`) and organization ruleset `18156473` (`ContextualWisdomLab`, `CWL Central required workflows`). A structurally valid manifest cannot redirect Administration-write authority to another positive ruleset ID, repository, or name. `scripts/ci/reconcile_ruleset_governance.py` then reads the full live object and refuses to act if the ID, name, source, source type, branch target, or active enforcement state has changed. It preserves all unrelated conditions and rules while changing only these reviewed governance fields: + +- remove routine bypass actors; +- set generic approving-review count to zero; +- keep same-author CODEOWNER review disabled; +- keep last-push approval disabled; +- require no synthetic reviewer identities; and +- allow merge and squash only, removing rebase. + +GitHub's current REST contract does not support conditional unsafe REST updates such as `If-Match` on ruleset `PUT`; GitHub's general REST guidance explicitly says conditional unsafe methods are unsupported unless a specific endpoint documents otherwise, and the repository/organization ruleset update endpoints document no such precondition. The second full ruleset read is therefore a drift detector, **not** compare-and-swap. It catches edits visible before the final read but cannot make the final GET-to-PUT interval atomic. A successful HTTP update is also not completion: the reconciler performs a post-write full read and requires the complete editable payload to equal the reviewed update body. + +Any `PUT` result that does not prove server-side rejection is treated as an ambiguous commit outcome. This includes the existing subprocess timeout path, a nonzero `gh` transport result such as connection loss after request transmission, and a malformed response after an otherwise successful invocation. Reads remain ordinary fail-closed errors because they cannot have changed server state. The desired mutation is never blindly retried after an ambiguous result. + +Ambiguous initial writes enter a bounded settlement protocol tied to the same 30-second client timeout used by GitHub REST calls. Immutable history and live state are observed at the start, midpoint, and end of one additional full client-timeout horizon. A first baseline-only history observation is explicitly **not** represented as rejection because a server-side commit may still become visible after the client has timed out. If the reviewed version appears with the sampled baseline as predecessor, exact acceptance is proven; if an intervening predecessor appears, the displaced-administrator recovery contract runs; if the transition remains baseline-only through the settlement horizon, the run fails as **unresolved** rather than claiming the write was rejected. This is a conservative terminal failure, not authorization to retry or report convergence. + +Recovery writes use the same ambiguity principle. After a recovery PUT has an ambiguous transport outcome, immutable history is observed across the same full client-timeout settlement horizon before any additional recovery write can occur. If history remains at the pre-write version for the entire window, recovery fails closed as unresolved rather than resending the same restore body. If the restore becomes visible, that version must match the exact intended predecessor payload and expose its predecessor; an unexpected newer state is preserved and causes failure. Historical validation distinguishes immutable target provenance (`id`, branch target, source type, and source) from editable state (`name`, `enforcement`, bypass actors, conditions, and rules), so a legitimate administrator rename or enforcement change can be restored rather than rejected as foreign history. + +The privileged apply path records the latest history version before its final live-state read. After PUT, it requires the newest history state to equal the reviewed body and requires that version's immediate predecessor to be the recorded baseline. If another administrator version intervened between the baseline sample and our PUT, the reconciler has proof that its write displaced a newer administrator state. Recovery re-reads live state before every recovery PUT and refuses to overwrite it if it has already advanced. After each recovery PUT, immutable history is read again: the newest version must equal the restore body, and its immediate predecessor must be the version that recovery intended to replace. If a second administrator version slipped between the recovery GET and PUT, that newly displaced predecessor becomes the next recovery target. The bounded recovery chain therefore restores the newest displaced administrator state rather than silently losing it; ambiguous or non-convergent histories fail closed. + +Protected-main freshness and post-write compensation have deliberately different boundaries. The exact protected `main` SHA is checked before history sampling and again immediately before the **new reviewed PUT**, so a stale source revision cannot start another desired mutation. Once that PUT has been issued, however, source freshness can no longer safely cancel settlement: the server may already have accepted the request and may have displaced an administrator version. Immutable-history settlement therefore finishes even if protected `main` advances, and any compensating recovery needed to restore the exact displaced predecessor also completes without a stale-main veto. A clean, collision-free settlement then re-checks protected `main` and fails the stale run rather than reporting convergence. The same compensation boundary applies after a transport-confirmed successful PUT: history verification may restore a displaced administrator predecessor before the final stale-main failure. This preserves both source freshness for **new** mutations and lossless cleanup for an **already-issued** mutation. + +Because the API still cannot make the final GET-to-PUT interval atomic, privileged mutation is serialized in one shared non-PR owner-plane concurrency group **without cancellation**. A run must finish its PUT plus immutable-history verification/recovery critical section; cancelling a predecessor after PUT could strand an unverified overwrite. Pull-request validation may supersede older pull-request validation runs because those runs are read-only. Every mutation, including an operator-invoked manual execution, must supply the exact protected `main` SHA. Read-only `--verify-only` remains available without a mutation SHA guard. + +The Actions runtime contract now separates the source-derived reconciliation critical section from platform bootstrap/teardown time instead of pretending those are the same bound. For each target the source budgets the normal guarded mutation operations, three ambiguous-initial-write settlement observations, all eight seven-operation collision-recovery attempts, the two additional history-list polls **and all three possible history-state reads** that each ambiguous recovery settlement can add, post-confirmation checks, the final verify-only pass, the initial 30-second settlement horizon, and up to one 30-second recovery-settlement horizon per recovery attempt. At two reviewed targets this conservative source-derived critical-section budget is **7,680 seconds (128 minutes)**. GitHub's current hosted-runner limits document a six-hour job-execution ceiling and `timeout-minutes` supports 360 minutes. The privileged `apply` job therefore uses the full 360-minute hosted-runner limit rather than imposing a smaller self-cancellation boundary. That leaves 232 minutes between the modeled 128-minute reconciliation bound and the platform hard limit for runner hardening, checkout, Python setup, credential validation, process startup, post-run work, and other platform-controlled execution overhead without falsely claiming those external actions have a mathematically bounded duration. The regression suite derives the 128-minute critical section from `worst_case_apply_seconds(target_count=2)` and separately enforces the 360-minute platform-limit workflow contract. + +The hourly schedule remains queue-conscious while making disabled state visible. Source, manifest, runtime-auditor, and regression changes are validated on pull-request, push, and manual triggers. When `CWL_RULESET_RECONCILE_ENABLED` is false, the expensive validation/apply path is skipped, but a two-minute `ubuntu-slim` status job writes `Ruleset reconciliation is disabled` to the workflow summary so operators can distinguish deliberate disablement from a missing run. When privileged reconciliation is enabled, the scheduled path performs the full reviewed validation before mutation. + +Repository-local strengthening is attempted before the organization change so a cross-scope partial failure cannot first weaken the central repository surface. The focused workflow now includes `scripts/ci/audit_central_required_workflows.py` itself in both PR and protected-main path filters because that auditor executes inside the reconciliation process; auditor-only changes therefore cannot bypass the owner-plane regression gate. The same focused gate includes `tests/test_ruleset_governance_post_put_cleanup_regression.py` in both PR/push path filters and in the executed pytest set so the protected-main-advance compensation contract cannot exist as an unexecuted test artifact. + +Pull requests execute only offline manifest validation plus 100% statement/branch/docstring contract tests. Mutation can run only from trusted `main`, in the `ruleset-governance-maintenance` protected environment, when repository variable `CWL_RULESET_RECONCILE_ENABLED=true` is explicitly set, using dedicated secret `CWL_RULESET_ADMIN_TOKEN`. The normal workflow token remains `contents: read`, checkout credentials are not persisted, and API errors never echo subprocess output that could contain sensitive context. + +`CWL_RULESET_ADMIN_TOKEN` is intentionally distinct from `CWL_REPOSITORY_METADATA_TOKEN`. GitHub documents organization ruleset update/history and repository ruleset update/history as requiring the corresponding **Administration (write)** permission. The credential must carry only those permissions needed for the two declared targets; it must not be repurposed as a general development, merge, release, or metadata token. The protected environment secret is bootstrap transport into the `gh` REST client for this owner-plane process; it is not exposed to pull-request code or model processes. The environment and secret must be provisioned independently before `CWL_RULESET_RECONCILE_ENABLED` is set. Source integration alone does not prove that provisioning exists. + +## Verification sequence + +1. PR validation proves exact privileged-target pinning, mutation projection, pre-write drift refusal, stale-protected-main refusal before a new desired mutation, post-PUT settlement despite later protected-main advancement, displaced-administrator compensation for ambiguous and transport-confirmed PUTs, ruleset-history collision detection/recovery, ambiguous timeout and non-timeout PUT settlement, delayed history visibility, bounded unresolved settlement, historical editable-identity recovery, the second recovery GET-to-PUT race, redacted failures, post-write convergence, exact target provenance, guarded manual mutation, runtime-auditor path coverage, derived critical-section and hosted-job-limit coverage, visible disabled-state reporting, trusted-main workflow gating, and 100% owned statement/branch/docstring coverage. +2. After source reaches protected `main`, provision the protected environment and dedicated credential, then enable the repository variable only for an exclusive owner-plane maintenance interval. +3. Run the reconciler and require one exact new ruleset-history version per uncontended target whose predecessor is the sampled pre-write baseline. If an intervening version exists, require recovery to follow immutable predecessor evidence until the newest displaced administrator state is restored or fail closed without claiming success. +4. Re-read both complete live ruleset payloads using the independently authorized credential. +5. Require organization ruleset approval count 0, last-push false, code-owner false, empty required reviewers, merge/squash only, no routine bypass, unchanged required workflows/conditions/deletion/non-fast-forward/thread-resolution controls. +6. Require repository ruleset approval count 0, last-push false, code-owner false, empty required reviewers, merge/squash only, no routine bypass, unchanged deletion/non-fast-forward/thread-resolution controls. +7. Revalidate the canonical audit writer and unchanged downstream deterministic-GREEN `ContextualWisdomLab/Orgmetra#88`; ordinary protected merge must work without self-approval, synthetic approval, or administrator bypass. +8. Keep the reconciler enabled for drift repair only if the protected owner-plane environment and history evidence remain available. If the API identity, editable schema, history contract, or modeled runtime boundary changes, it fails closed and requires a reviewed source update. + +## Standards and research basis + +GitHub's current REST documentation makes ruleset mutation and ruleset-history access Administration-authorized operations and describes bypass actors as explicit ruleset state; this is why the repair is placed in a separate owner plane instead of expanding the ordinary repository metadata token. GitHub also documents that organization owners or users with the dedicated organization-rules permission manage organization rulesets. Its REST best-practices guidance distinguishes safe conditional `GET` requests from unsafe methods and states that conditional `POST`/`PUT`/`PATCH`/`DELETE` requests are unsupported unless the endpoint explicitly says otherwise; the current ruleset update documentation does not provide a conditional-write precondition. The history endpoints provide the exact version sequence and version state used here to detect an otherwise invisible intervening update. GitHub's current Actions documentation gives GitHub-hosted jobs a six-hour execution limit; the owner-plane workflow deliberately uses that platform ceiling so its own timeout cannot pre-empt the source-derived 128-minute recovery critical section. NIST SP 800-53 Rev. 5 AC-6 supports least privilege, while CM-3 and its testing/validation enhancement support controlled, reviewed, verified configuration changes. Recent systematic-review evidence likewise identifies automated security controls, compliance-as-code, continuous feedback, access control, and protected credentials as central DevSecOps practices rather than relying on informal operator steps. + +### References (APA 7th) + +GitHub. (2026). *Actions limits*. GitHub Docs. https://docs.github.com/en/actions/reference/limits + +GitHub. (2026). *Best practices for using the REST API*. GitHub Docs. https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api + +GitHub. (2026). *REST API endpoints for rules: Organizations*. GitHub Docs. https://docs.github.com/en/rest/orgs/rules + +GitHub. (2026). *REST API endpoints for rules: Repositories*. GitHub Docs. https://docs.github.com/en/rest/repos/rules + +GitHub. (2026). *Managing rulesets for repositories in your organization*. GitHub Docs. https://docs.github.com/en/organizations/managing-organization-settings/managing-rulesets-for-repositories-in-your-organization + +GitHub. (2026). *Workflow syntax for GitHub Actions*. GitHub Docs. https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax + +National Institute of Standards and Technology. (2020). *Security and privacy controls for information systems and organizations* (NIST Special Publication 800-53 Rev. 5). U.S. Department of Commerce. https://doi.org/10.6028/NIST.SP.800-53r5 + +Sinan, M., Shahin, M., & Gondal, I. (2025). Integrating security controls in DevSecOps: Challenges, solutions, and future research directions. *Journal of Software: Evolution and Process, 37*, e70029. https://doi.org/10.1002/smr.70029 diff --git a/docs/org-required-workflow-rollout.md b/docs/org-required-workflow-rollout.md index 476425beb2..92bde525a1 100644 --- a/docs/org-required-workflow-rollout.md +++ b/docs/org-required-workflow-rollout.md @@ -223,8 +223,15 @@ Do not centralize the scheduler by running a `.github` scheduled job against oth ## Second-reviewer (Noema) posture -The org's two-reviewer merge rule needs a second approving-review identity -independent of OpenCode. That identity is `cwl-noema-review[bot]`, supplied by +The active ruleset does not require a second human approving review — +`required_approving_review_count = 0` and `require_last_push_approval = false`, +since this is a solo-maintained organization with no genuine second human +maintainer to require an approval from (see the "Canonical organization +ruleset" bypass_actors/solo-maintainer note above and `.github#772`). Noema +instead supplies a second, model-authored review identity independent of +OpenCode — not a substitute for a fictional second human approver, but +defense-in-depth review evidence distinct from OpenCode's own judgement. +That identity is `cwl-noema-review[bot]`, supplied by the organization-owned `cwl-noema-review` GitHub App. The central workflow is an active organization required workflow. It runs the centrally versioned `noema_review_gate.py` judgement path and @@ -305,6 +312,16 @@ non-fork inventory snapshot and rollout ledger, not the ruleset target list. ## Evidence from this rollout +- `.github#1644` ("ruleset owner-plane reconciler") extended `scripts/ci/audit_central_required_workflows.py`'s + `audit_ruleset` to validate two new structural properties the prior audit was silent on: the ruleset + must not configure `bypass_actors` (routine bypass actors on the central required-workflow ruleset are + forbidden — an actor with bypass rights could satisfy every other check while still skipping the + workflows/review requirements this audit exists to enforce), and — because this is a solo-maintained + organization — a `central solo-maintainer ruleset must not require approving reviews`, configure + required reviewers, require code-owner review, or require last-push approval; the audit fails closed + on each of those individually with a dedicated message. The same solo-maintainer check set was added + for the per-repository `Lock default branch` ruleset audit path (`repository solo-maintainer ruleset + must not ...`). See `.github#772` for the solo-maintainer protected-PR policy decision this codifies. - On 2026-09-02 KST, live verification via `gh api repos///rules/branches/` against six repositories (`aFIPC`, `bandscope`, `newsdom-api`, `naruon`, `xtrmLLMBatchPython`, `pg-erd-cloud`) found ruleset `18156473`'s `workflows` diff --git a/scripts/ci/audit_central_required_workflows.py b/scripts/ci/audit_central_required_workflows.py index 8b3d07b406..f8f290f0cf 100755 --- a/scripts/ci/audit_central_required_workflows.py +++ b/scripts/ci/audit_central_required_workflows.py @@ -5,15 +5,17 @@ import argparse import json -from pathlib import Path import sys +from pathlib import Path from typing import Any, TextIO - RULESET_ID = 18156473 RULESET_NAME = "CWL Central required workflows" STACKED_RULESET_ID = 21732164 STACKED_RULESET_NAME = "CWL Stacked OpenCode required workflow" +REPOSITORY_RULESET_ID = 17921150 +REPOSITORY_RULESET_NAME = "Lock default branch" +REPOSITORY_RULESET_SOURCE = "ContextualWisdomLab/.github" SOURCE_REPOSITORY_ID = 1274066402 SOURCE_REF = "refs/heads/main" SOURCE_ORGANIZATION = "ContextualWisdomLab" @@ -34,6 +36,17 @@ ".github/workflows/osv-scanner-pr.yml", ".github/workflows/scorecard-pr.yml", ) +CENTRAL_ALLOWED_RULE_TYPES = { + "workflows", + "pull_request", + "deletion", + "non_fast_forward", +} +REPOSITORY_ALLOWED_RULE_TYPES = { + "pull_request", + "deletion", + "non_fast_forward", +} STACKED_WORKFLOW_PATH = ".github/workflows/opencode-review.yml" @@ -49,6 +62,26 @@ def _typed_rules(payload: dict[str, Any], rule_type: str) -> list[dict[str, Any] ] +def _forbidden_rule_types( + payload: dict[str, Any], allowed_rule_types: set[str] +) -> list[str]: + """Return undeclared or malformed rule types from a ruleset payload.""" + rules = payload.get("rules") + if not isinstance(rules, list): + return [] + forbidden: set[str] = set() + for rule in rules: + if not isinstance(rule, dict): + forbidden.add("") + continue + rule_type = rule.get("type") + if not isinstance(rule_type, str) or not rule_type: + forbidden.add("") + elif rule_type not in allowed_rule_types: + forbidden.add(rule_type) + return sorted(forbidden) + + def audit_ruleset(payload: dict[str, Any]) -> list[str]: """Return explicit drift reasons for a live organization ruleset payload.""" errors: list[str] = [] @@ -61,6 +94,8 @@ def audit_ruleset(payload: dict[str, Any]) -> list[str]: errors.append("central ruleset target is not branch") if payload.get("enforcement") != "active": errors.append("central ruleset enforcement is not active") + if payload.get("bypass_actors") != []: + errors.append("central ruleset must not configure bypass actors") conditions = payload.get("conditions") conditions = conditions if isinstance(conditions, dict) else {} @@ -75,7 +110,9 @@ def audit_ruleset(payload: dict[str, Any]) -> list[str]: ) if is_inherited_org_payload: malformed_scope = sorted( - name for name, inherited in inherited_scope.items() if not isinstance(inherited, bool) + name + for name, inherited in inherited_scope.items() + if not isinstance(inherited, bool) ) if malformed_scope: errors.append( @@ -117,19 +154,40 @@ def audit_ruleset(payload: dict[str, Any]) -> list[str]: ref_names = conditions.get("ref_name") ref_names = ref_names if isinstance(ref_names, dict) else {} - if "~DEFAULT_BRANCH" not in (ref_names.get("include") or []): - errors.append("central ruleset does not target every default branch") + if ( + ref_names.get("include") != ["~DEFAULT_BRANCH"] + or ref_names.get("exclude") != [] + ): + errors.append("central ruleset ref scope must be exactly the default branch") workflow_rules = _typed_rules(payload, "workflows") + workflow_parameters: dict[str, Any] = {} if len(workflow_rules) != 1: errors.append(f"expected one workflows rule, found {len(workflow_rules)}") workflows: list[Any] = [] else: parameters = workflow_rules[0].get("parameters") - parameters = parameters if isinstance(parameters, dict) else {} - workflows = parameters.get("workflows") + workflow_parameters = parameters if isinstance(parameters, dict) else {} + workflows = workflow_parameters.get("workflows") workflows = workflows if isinstance(workflows, list) else [] + if ( + len(workflow_rules) == 1 + and workflow_parameters.get("do_not_enforce_on_create") is not True + ): + errors.append("central required workflows block the branch create transition") + + malformed_workflows = sum( + 1 + for workflow in workflows + if not isinstance(workflow, dict) or not isinstance(workflow.get("path"), str) + ) + if malformed_workflows: + suffix = "entry" if malformed_workflows == 1 else "entries" + errors.append( + f"central required workflows contain {malformed_workflows} malformed {suffix}" + ) + workflows_by_path: dict[str, list[dict[str, Any]]] = {} for index, workflow in enumerate(workflows): if not isinstance(workflow, dict) or not isinstance(workflow.get("path"), str): @@ -143,7 +201,9 @@ def audit_ruleset(payload: dict[str, Any]) -> list[str]: errors.append(f"missing central required workflow {path}") continue if len(matches) != 1: - errors.append(f"central required workflow {path} is configured {len(matches)} times") + errors.append( + f"central required workflow {path} is configured {len(matches)} times" + ) if not any( workflow.get("repository_id") == SOURCE_REPOSITORY_ID and workflow.get("ref") == SOURCE_REF @@ -165,23 +225,45 @@ def audit_ruleset(payload: dict[str, Any]) -> list[str]: parameters = review_rules[0].get("parameters") parameters = parameters if isinstance(parameters, dict) else {} approving_reviews = parameters.get("required_approving_review_count") - if approving_reviews != 2: - errors.append("exactly two approving reviews are not required") + if approving_reviews != 0: + errors.append( + "central solo-maintainer ruleset must not require approving reviews" + ) + if parameters.get("required_reviewers") not in (None, []): + errors.append( + "central solo-maintainer ruleset must not configure required reviewers" + ) + if parameters.get("require_code_owner_review") is not False: + errors.append( + "central solo-maintainer ruleset must not require code-owner review" + ) if parameters.get("dismiss_stale_reviews_on_push") is not True: errors.append("stale-review dismissal on push is disabled") - if parameters.get("require_last_push_approval") is not True: - errors.append("last-push approval protection is disabled") + if parameters.get("require_last_push_approval") is not False: + errors.append( + "central solo-maintainer ruleset must not require last-push approval" + ) if parameters.get("required_review_thread_resolution") is not True: errors.append("review-thread resolution protection is disabled") - allowed_methods = set(parameters.get("allowed_merge_methods") or []) - if not {"merge", "squash"}.issubset(allowed_methods): - errors.append("merge and squash are not both allowed merge methods") + raw_allowed_methods = parameters.get("allowed_merge_methods") + allowed_methods = ( + set(raw_allowed_methods) + if isinstance(raw_allowed_methods, list) + and all(isinstance(method, str) for method in raw_allowed_methods) + else set() + ) + if allowed_methods != {"merge", "squash"}: + errors.append("only merge and squash may be allowed merge methods") if not _typed_rules(payload, "deletion"): errors.append("default-branch deletion protection is missing") if not _typed_rules(payload, "non_fast_forward"): errors.append("default-branch non-fast-forward protection is missing") + forbidden_rule_types = _forbidden_rule_types(payload, CENTRAL_ALLOWED_RULE_TYPES) + if forbidden_rule_types: + errors.append(f"central ruleset has forbidden rule types: {forbidden_rule_types}") + return errors @@ -244,6 +326,86 @@ def audit_stacked_ruleset(payload: dict[str, Any]) -> list[str]: return errors +def audit_repository_ruleset(payload: dict[str, Any]) -> list[str]: + """Return drift reasons for the owner repository's default-branch policy.""" + + errors: list[str] = [] + if payload.get("id") != REPOSITORY_RULESET_ID: + errors.append(f"expected repository ruleset id {REPOSITORY_RULESET_ID}") + if payload.get("name") != REPOSITORY_RULESET_NAME: + errors.append(f"expected repository ruleset name {REPOSITORY_RULESET_NAME}") + if ( + payload.get("source_type") != "Repository" + or payload.get("source") != REPOSITORY_RULESET_SOURCE + ): + errors.append("repository ruleset source is not ContextualWisdomLab/.github") + if payload.get("target") != "branch": + errors.append("repository ruleset target is not branch") + if payload.get("enforcement") != "active": + errors.append("repository ruleset enforcement is not active") + if payload.get("bypass_actors") != []: + errors.append("repository ruleset must not configure bypass actors") + + conditions = payload.get("conditions") + conditions = conditions if isinstance(conditions, dict) else {} + ref_names = conditions.get("ref_name") + ref_names = ref_names if isinstance(ref_names, dict) else {} + if ref_names != {"include": ["~DEFAULT_BRANCH"], "exclude": []}: + errors.append("repository ruleset ref scope must be exactly the default branch") + + review_rules = _typed_rules(payload, "pull_request") + if len(review_rules) != 1: + errors.append(f"expected one repository pull_request rule, found {len(review_rules)}") + else: + raw_parameters = review_rules[0].get("parameters") + parameters = raw_parameters if isinstance(raw_parameters, dict) else {} + if parameters.get("required_approving_review_count") != 0: + errors.append( + "repository solo-maintainer ruleset must not require approving reviews" + ) + if parameters.get("required_reviewers") not in (None, []): + errors.append( + "repository solo-maintainer ruleset must not configure required reviewers" + ) + if parameters.get("require_code_owner_review") is not False: + errors.append( + "repository solo-maintainer ruleset must not require code-owner review" + ) + if parameters.get("dismiss_stale_reviews_on_push") is not True: + errors.append("repository ruleset stale-review dismissal on push is disabled") + if parameters.get("require_last_push_approval") is not False: + errors.append( + "repository solo-maintainer ruleset must not require last-push approval" + ) + if parameters.get("required_review_thread_resolution") is not True: + errors.append( + "repository ruleset review-thread resolution protection is disabled" + ) + raw_allowed_methods = parameters.get("allowed_merge_methods") + allowed_methods = ( + set(raw_allowed_methods) + if isinstance(raw_allowed_methods, list) + and all(isinstance(method, str) for method in raw_allowed_methods) + else set() + ) + if allowed_methods != {"merge", "squash"}: + errors.append("repository ruleset must allow only merge and squash") + + if not _typed_rules(payload, "deletion"): + errors.append("repository default-branch deletion protection is missing") + if not _typed_rules(payload, "non_fast_forward"): + errors.append("repository default-branch non-fast-forward protection is missing") + + forbidden_rule_types = _forbidden_rule_types( + payload, REPOSITORY_ALLOWED_RULE_TYPES + ) + if forbidden_rule_types: + errors.append( + f"repository ruleset has forbidden rule types: {forbidden_rule_types}" + ) + return errors + + def load_payload(path: Path | None, stdin: TextIO) -> dict[str, Any]: """Load a ruleset object from ``path`` or standard input.""" if path is None: @@ -259,7 +421,9 @@ def load_payload(path: Path | None, stdin: TextIO) -> dict[str, Any]: def parse_args(argv: list[str] | None = None) -> argparse.Namespace: """Parse the optional ruleset JSON path.""" parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--stacked", action="store_true") + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--stacked", action="store_true") + mode.add_argument("--repository", action="store_true") parser.add_argument("ruleset_json", nargs="?", type=Path) return parser.parse_args(argv) @@ -273,9 +437,18 @@ def main(argv: list[str] | None = None) -> int: print(f"ERROR: unable to load ruleset JSON: {exc}", file=sys.stderr) return 2 - auditor = audit_stacked_ruleset if args.stacked else audit_ruleset - ruleset_id = STACKED_RULESET_ID if args.stacked else RULESET_ID - workflow_count = 1 if args.stacked else len(REQUIRED_WORKFLOW_PATHS) + if args.repository: + auditor = audit_repository_ruleset + ruleset_id = REPOSITORY_RULESET_ID + workflow_count = 0 + elif args.stacked: + auditor = audit_stacked_ruleset + ruleset_id = STACKED_RULESET_ID + workflow_count = 1 + else: + auditor = audit_ruleset + ruleset_id = RULESET_ID + workflow_count = len(REQUIRED_WORKFLOW_PATHS) errors = auditor(payload) if errors: for error in errors: @@ -286,7 +459,9 @@ def main(argv: list[str] | None = None) -> int: ) return 1 - if args.stacked: + if args.repository: + print(f"PASS: repository ruleset {ruleset_id} protects the default branch") + elif args.stacked: print( f"PASS: ruleset {ruleset_id} audits {workflow_count} " "central required workflows in evaluate mode" diff --git a/scripts/ci/reconcile_ruleset_governance.py b/scripts/ci/reconcile_ruleset_governance.py new file mode 100644 index 0000000000..1f8eb7d61e --- /dev/null +++ b/scripts/ci/reconcile_ruleset_governance.py @@ -0,0 +1,905 @@ +#!/usr/bin/env python3 +"""Reconcile reviewed GitHub ruleset governance with fail-closed verification. + +The reconciler manages only the two rulesets pinned by the reviewed manifest. It +preserves live conditions and non-governance rules while canonicalizing the +reviewed pull-request controls. GitHub does not provide conditional PUT/PATCH +semantics for this endpoint, so the second live read is a drift detector rather +than a compare-and-swap guarantee. Privileged mutation is additionally bound to +the exact protected-main revision, serialized owner-plane execution, and the +immutable ruleset-history surface. If history proves a hidden pre-PUT edit was +overwritten, the reconciler restores the newest displaced administrator state +before failing. Protected-main freshness gates every new reviewed mutation; +once a PUT has been issued, immutable-history settlement and compensating +collision recovery finish even if protected main advances so an already-issued +write cannot strand an overwritten administrator state. Ambiguous mutation and +recovery results are settled from live state plus immutable history instead of +being treated as ordinary request failures or retried blindly. The canonical +audit is executed against projected and live state so this narrow reconciler +never reports broader policy drift as converged. +""" + +from __future__ import annotations + +import argparse +import copy +import json +import os +import re +import subprocess +import sys +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +API_VERSION = "2026-03-10" +ORGANIZATION = "ContextualWisdomLab" +CONTROL_REPOSITORY = "ContextualWisdomLab/.github" +DESIRED_MERGE_METHODS = ["merge", "squash"] +GIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$") +API_REQUEST_TIMEOUT_SECONDS = 30 +COLLISION_RECOVERY_LIMIT = 8 +AMBIGUOUS_WRITE_SETTLEMENT_POLLS = 3 +AMBIGUOUS_WRITE_SETTLEMENT_WINDOW_SECONDS = API_REQUEST_TIMEOUT_SECONDS +AMBIGUOUS_WRITE_SETTLEMENT_INTERVAL_SECONDS = ( + AMBIGUOUS_WRITE_SETTLEMENT_WINDOW_SECONDS + / (AMBIGUOUS_WRITE_SETTLEMENT_POLLS - 1) +) + +# Conservative blocking-operation budget for one target. The count includes the +# normal guarded mutation path, start/mid/end ambiguous-result observations, the +# full bounded collision-recovery chain, post-confirmation checks, and the final +# workflow verify-only pass. Each blocking API/auditor operation is bounded by +# API_REQUEST_TIMEOUT_SECONDS; every ambiguous recovery may consume one full +# additional settlement horizon before it is declared unresolved. The workflow +# contract test derives its minimum job timeout from this function rather than +# maintaining a second independent estimate. +BASE_MUTATION_BLOCKING_OPERATIONS_PER_TARGET = 7 +AMBIGUOUS_SETTLEMENT_BLOCKING_OPERATIONS_PER_TARGET = 12 +RECOVERY_BLOCKING_OPERATIONS_PER_ATTEMPT = 7 +# An ambiguous recovery settlement can add two extra history-list polls after +# the initial poll, and every one of the three polls can require a history-state +# GET when an intervening version is visible before the delayed restore appears. +RECOVERY_AMBIGUOUS_EXTRA_BLOCKING_OPERATIONS_PER_ATTEMPT = ( + (AMBIGUOUS_WRITE_SETTLEMENT_POLLS - 1) + + AMBIGUOUS_WRITE_SETTLEMENT_POLLS +) +POST_CONFIRM_BLOCKING_OPERATIONS_PER_TARGET = 2 +FINAL_VERIFY_BLOCKING_OPERATIONS_PER_TARGET = 2 + + +class RulesetGovernanceError(RuntimeError): + """Raised when desired-state validation or live reconciliation is unsafe.""" + + +class AmbiguousRulesetWriteError(RulesetGovernanceError): + """Raised when a PUT transport result cannot prove server-side rejection.""" + + +class RulesetMutationNotVisibleError(RulesetGovernanceError): + """Raised while an ambiguous PUT has not yet appeared in immutable history.""" + + +class RulesetMutationStillSettlingError(RulesetGovernanceError): + """Raised when history changed but the ambiguous reviewed PUT is not visible yet.""" + + +@dataclass(frozen=True) +class RulesetTarget: + """Describe one exact organization- or repository-owned ruleset.""" + + scope: str + owner: str + repository: str | None + ruleset_id: int + name: str + + @property + def endpoint(self) -> str: + """Return the GitHub REST endpoint for this exact ruleset.""" + + if self.scope == "organization": + return f"orgs/{self.owner}/rulesets/{self.ruleset_id}" + return f"repos/{self.owner}/{self.repository}/rulesets/{self.ruleset_id}" + + @property + def history_endpoint(self) -> str: + """Return the immutable history endpoint for this exact ruleset.""" + + return f"{self.endpoint}/history" + + def history_version_endpoint(self, version_id: int) -> str: + """Return one exact immutable ruleset-history version endpoint.""" + + if type(version_id) is not int or version_id <= 0: + raise RulesetGovernanceError("ruleset history version identity is malformed") + return f"{self.history_endpoint}/{version_id}" + + @property + def source(self) -> str: + """Return the exact source identity GitHub must report for this ruleset.""" + + if self.scope == "organization": + return self.owner + return f"{self.owner}/{self.repository}" + + @property + def source_type(self) -> str: + """Return GitHub's expected source type for this ruleset scope.""" + + return "Organization" if self.scope == "organization" else "Repository" + + +EXPECTED_MANIFEST_TARGETS = frozenset( + { + ( + "repository", + ORGANIZATION, + ".github", + 17921150, + "Lock default branch", + ), + ( + "organization", + ORGANIZATION, + None, + 18156473, + "CWL Central required workflows", + ), + } +) + + +def worst_case_apply_seconds(*, target_count: int) -> int: + """Return the conservative critical-section budget for reviewed targets.""" + + blocking_operations = ( + BASE_MUTATION_BLOCKING_OPERATIONS_PER_TARGET + + AMBIGUOUS_SETTLEMENT_BLOCKING_OPERATIONS_PER_TARGET + + COLLISION_RECOVERY_LIMIT + * ( + RECOVERY_BLOCKING_OPERATIONS_PER_ATTEMPT + + RECOVERY_AMBIGUOUS_EXTRA_BLOCKING_OPERATIONS_PER_ATTEMPT + ) + + POST_CONFIRM_BLOCKING_OPERATIONS_PER_TARGET + + FINAL_VERIFY_BLOCKING_OPERATIONS_PER_TARGET + ) + settlement_seconds = ( + AMBIGUOUS_WRITE_SETTLEMENT_WINDOW_SECONDS + + COLLISION_RECOVERY_LIMIT * AMBIGUOUS_WRITE_SETTLEMENT_WINDOW_SECONDS + ) + per_target_seconds = blocking_operations * API_REQUEST_TIMEOUT_SECONDS + settlement_seconds + return target_count * per_target_seconds + + +def _plain_dict(value: Any, *, field: str) -> dict[str, Any]: + """Return a plain dictionary or reject behavior-bearing mapping objects.""" + + if type(value) is not dict: + raise RulesetGovernanceError(f"{field} must be an object") + return value + + +def _plain_list(value: Any, *, field: str) -> list[Any]: + """Return a plain list or reject behavior-bearing sequence objects.""" + + if type(value) is not list: + raise RulesetGovernanceError(f"{field} must be an array") + return value + + +def load_manifest(path: Path) -> tuple[RulesetTarget, ...]: + """Load and strictly validate the two reviewed privileged ruleset targets.""" + + root = _plain_dict(json.loads(path.read_text(encoding="utf-8")), field="manifest") + if set(root) != {"schema_version", "organization", "targets"}: + raise RulesetGovernanceError("manifest has an unexpected key set") + if root["schema_version"] != 1 or root["organization"] != ORGANIZATION: + raise RulesetGovernanceError("manifest schema or organization is unsupported") + + raw_targets = _plain_list(root["targets"], field="targets") + if len(raw_targets) != 2: + raise RulesetGovernanceError("manifest must declare exactly two governance targets") + + targets: list[RulesetTarget] = [] + identities: set[tuple[str, int]] = set() + for index, raw in enumerate(raw_targets): + item = _plain_dict(raw, field=f"targets[{index}]") + if set(item) != {"scope", "owner", "repository", "ruleset_id", "name"}: + raise RulesetGovernanceError(f"targets[{index}] has an unexpected key set") + scope = item["scope"] + owner = item["owner"] + repository = item["repository"] + ruleset_id = item["ruleset_id"] + name = item["name"] + if scope not in {"organization", "repository"}: + raise RulesetGovernanceError(f"targets[{index}].scope is unsupported") + if owner != ORGANIZATION or type(ruleset_id) is not int or ruleset_id <= 0: + raise RulesetGovernanceError(f"targets[{index}] identity is invalid") + if type(name) is not str or not name.strip(): + raise RulesetGovernanceError(f"targets[{index}].name is invalid") + if scope == "organization" and repository is not None: + raise RulesetGovernanceError("organization target repository must be null") + if scope == "repository" and (type(repository) is not str or not repository): + raise RulesetGovernanceError("repository target repository must be non-empty") + identity = (scope, ruleset_id) + if identity in identities: + raise RulesetGovernanceError("manifest contains a duplicate ruleset target") + identities.add(identity) + targets.append(RulesetTarget(scope, owner, repository, ruleset_id, name)) + + if {target.scope for target in targets} != {"organization", "repository"}: + raise RulesetGovernanceError("manifest must contain one target per supported scope") + actual_targets = frozenset( + ( + target.scope, + target.owner, + target.repository, + target.ruleset_id, + target.name, + ) + for target in targets + ) + if actual_targets != EXPECTED_MANIFEST_TARGETS: + raise RulesetGovernanceError( + "manifest must contain the exact reviewed governance targets" + ) + return tuple(targets) + + +def _gh_command(method: str, endpoint: str) -> list[str]: + """Build one versioned GitHub CLI command for the reviewed REST boundary.""" + + return [ + "gh", + "api", + "--method", + method, + "-H", + f"X-GitHub-Api-Version: {API_VERSION}", + endpoint, + ] + + +def _run_gh_json( + method: str, + endpoint: str, + *, + body: dict[str, Any] | None = None, +) -> Any: + """Call GitHub REST and decode JSON without exposing credential diagnostics.""" + + command = _gh_command(method, endpoint) + if body is not None: + command.extend(["--input", "-"]) + try: + completed = subprocess.run( + command, + check=False, + input=None if body is None else json.dumps(body, separators=(",", ":")), + capture_output=True, + text=True, + timeout=API_REQUEST_TIMEOUT_SECONDS, + ) + except subprocess.TimeoutExpired as exc: + if method == "PUT": + raise + raise RulesetGovernanceError( + f"GitHub API request timed out for {endpoint}" + ) from exc + if completed.returncode != 0: + if method == "PUT": + raise AmbiguousRulesetWriteError( + f"GitHub PUT outcome is ambiguous for {endpoint}" + ) + raise RulesetGovernanceError(f"GitHub API request failed for {endpoint}") + try: + return json.loads(completed.stdout) + except json.JSONDecodeError as exc: + if method == "PUT": + raise AmbiguousRulesetWriteError( + f"GitHub PUT response is ambiguous for {endpoint}" + ) from exc + raise RulesetGovernanceError( + f"GitHub API returned invalid JSON for {endpoint}" + ) from exc + + +def _gh_api( + method: str, + endpoint: str, + *, + body: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Call GitHub REST and require an object response.""" + + return _plain_dict( + _run_gh_json(method, endpoint, body=body), + field=f"GitHub response for {endpoint}", + ) + + +def _gh_api_list(method: str, endpoint: str) -> list[Any]: + """Call GitHub REST and require an array response.""" + + return _plain_list( + _run_gh_json(method, endpoint), + field=f"GitHub response for {endpoint}", + ) + + +def _current_main_sha() -> str: + """Return the exact protected control-repository main SHA from GitHub.""" + + payload = _gh_api("GET", f"repos/{CONTROL_REPOSITORY}/git/ref/heads/main") + object_data = _plain_dict(payload.get("object"), field="main ref object") + sha = str(object_data.get("sha") or "").lower() + if not GIT_SHA_RE.fullmatch(sha): + raise RulesetGovernanceError("protected main returned a malformed SHA") + return sha + + +def _assert_current_main(expected_main_sha: str) -> None: + """Fail closed when the privileged run no longer represents current main.""" + + if not GIT_SHA_RE.fullmatch(expected_main_sha): + raise RulesetGovernanceError("expected protected main SHA is malformed") + if _current_main_sha() != expected_main_sha: + raise RulesetGovernanceError( + "protected main advanced; refusing stale governance mutation" + ) + + +def _history_version_id(entry: Any) -> int: + """Return one positive ruleset-history version ID or fail closed.""" + + item = _plain_dict(entry, field="ruleset history entry") + version_id = item.get("version_id") + if type(version_id) is not int or version_id <= 0: + raise RulesetGovernanceError("ruleset history version identity is malformed") + return version_id + + +def _latest_history_version(target: RulesetTarget) -> int: + """Return the newest immutable history version before mutation.""" + + history = _gh_api_list("GET", f"{target.history_endpoint}?per_page=1") + if not history: + raise RulesetGovernanceError("ruleset history is empty") + return _history_version_id(history[0]) + + +def _assert_target_provenance(live: dict[str, Any], target: RulesetTarget) -> None: + """Require immutable target provenance while allowing editable history fields.""" + + expected = { + "id": target.ruleset_id, + "target": "branch", + "source_type": target.source_type, + "source": target.source, + } + mismatches = [key for key, value in expected.items() if live.get(key) != value] + if mismatches: + raise RulesetGovernanceError( + f"{target.scope} ruleset identity drift: {', '.join(sorted(mismatches))}" + ) + + +def _history_version_state(target: RulesetTarget, version_id: int) -> dict[str, Any]: + """Return one historical state after proving it belongs to the exact target.""" + + payload = _gh_api("GET", target.history_version_endpoint(version_id)) + state = _plain_dict(payload.get("state"), field="ruleset history version state") + _assert_target_provenance(state, target) + return state + + +def _assert_identity(live: dict[str, Any], target: RulesetTarget) -> None: + """Require current live state to retain the reviewed editable identity too.""" + + _assert_target_provenance(live, target) + expected = {"name": target.name, "enforcement": "active"} + mismatches = [key for key, value in expected.items() if live.get(key) != value] + if mismatches: + raise RulesetGovernanceError( + f"{target.scope} ruleset identity drift: {', '.join(sorted(mismatches))}" + ) + + +def _editable_projection(live: dict[str, Any]) -> dict[str, Any]: + """Return exactly the fields accepted by GitHub's ruleset update endpoint.""" + + required = { + "name", + "target", + "enforcement", + "bypass_actors", + "conditions", + "rules", + } + missing = sorted(required.difference(live)) + if missing: + raise RulesetGovernanceError( + f"ruleset payload misses editable fields: {', '.join(missing)}" + ) + projection = {key: copy.deepcopy(live[key]) for key in required} + _plain_list(projection["bypass_actors"], field="bypass_actors") + _plain_dict(projection["conditions"], field="conditions") + _plain_list(projection["rules"], field="rules") + return projection + + +def _canonical_governance_errors( + live: dict[str, Any], target: RulesetTarget +) -> list[str]: + """Run the canonical audit against one complete live-shaped ruleset payload.""" + + auditor = Path(__file__).with_name("audit_central_required_workflows.py") + mode = {"repository": ["--repository"], "organization": []}[target.scope] + completed = subprocess.run( + [sys.executable, str(auditor), *mode], + check=False, + input=json.dumps(live, separators=(",", ":")), + capture_output=True, + text=True, + timeout=API_REQUEST_TIMEOUT_SECONDS, + ) + return [] if completed.returncode == 0 else [completed.stderr.strip()] + + +def _assert_canonical_governance( + live: dict[str, Any], target: RulesetTarget +) -> None: + """Fail closed when canonical audit policy still reports any governance drift.""" + + errors = _canonical_governance_errors(live, target) + if errors: + raise RulesetGovernanceError( + f"{target.scope} canonical governance drift remains: {errors[0]}" + ) + + +def _desired_payload(live: dict[str, Any], target: RulesetTarget) -> dict[str, Any]: + """Build the exact safe update body while preserving unrelated live controls.""" + + _assert_identity(live, target) + desired = _editable_projection(live) + desired["bypass_actors"] = [] + + pull_request_rules = [ + rule + for rule in desired["rules"] + if type(rule) is dict and rule.get("type") == "pull_request" + ] + if len(pull_request_rules) != 1: + raise RulesetGovernanceError("ruleset must contain exactly one pull_request rule") + parameters = _plain_dict( + pull_request_rules[0].get("parameters"), field="pull_request.parameters" + ) + required_parameters = { + "required_approving_review_count": int, + "require_code_owner_review": bool, + "require_last_push_approval": bool, + "dismiss_stale_reviews_on_push": bool, + "required_review_thread_resolution": bool, + "allowed_merge_methods": list, + } + for field, expected_type in required_parameters.items(): + if type(parameters.get(field)) is not expected_type: + raise RulesetGovernanceError( + f"pull_request.parameters.{field} has invalid type" + ) + if "required_reviewers" in parameters and type(parameters["required_reviewers"]) is not list: + raise RulesetGovernanceError( + "pull_request.parameters.required_reviewers has invalid type" + ) + + parameters["required_approving_review_count"] = 0 + parameters["require_code_owner_review"] = False + parameters["require_last_push_approval"] = False + parameters["required_reviewers"] = [] + parameters["dismiss_stale_reviews_on_push"] = True + parameters["required_review_thread_resolution"] = True + parameters["allowed_merge_methods"] = list(DESIRED_MERGE_METHODS) + return desired + + +def _settle_ambiguous_recovery_history( + target: RulesetTarget, + *, + current_version: int, + expected_payload: dict[str, Any], +) -> list[Any]: + """Observe the full bounded horizon until the ambiguous recovery PUT appears. + + An administrator version can become visible before a delayed recovery request. + That intervening version is evidence of concurrency, not evidence that the + delayed request was rejected. Settlement therefore waits for the exact restore + payload and lets the caller recover its actual immutable predecessor. + """ + + observed_unexpected_newer_state = False + for poll_index in range(AMBIGUOUS_WRITE_SETTLEMENT_POLLS): + history = _gh_api_list("GET", f"{target.history_endpoint}?per_page=2") + if not history: + raise RulesetGovernanceError( + "ambiguous ruleset recovery PUT exposed no history" + ) + newest_version = _history_version_id(history[0]) + if newest_version != current_version: + newest_state = _history_version_state(target, newest_version) + if _editable_projection(newest_state) == expected_payload: + return history + observed_unexpected_newer_state = True + if poll_index < AMBIGUOUS_WRITE_SETTLEMENT_POLLS - 1: + time.sleep(AMBIGUOUS_WRITE_SETTLEMENT_INTERVAL_SECONDS) + if observed_unexpected_newer_state: + raise RulesetGovernanceError( + "ambiguous ruleset recovery PUT left a newer state after settlement window; refusing overwrite" + ) + raise RulesetGovernanceError( + "ambiguous ruleset recovery PUT outcome remains unresolved after settlement window" + ) + + +def _recover_displaced_history_state( + target: RulesetTarget, + *, + current_version: int, + current_payload: dict[str, Any], + displaced_version: int, + expected_main_sha: str | None = None, +) -> None: + """Restore the newest state displaced by our unsafe PUT without hiding races. + + GitHub offers no conditional ruleset PUT. Each recovery write therefore + verifies immutable history immediately afterward. If an administrator write + slipped between the recovery GET and PUT, that displaced history version + becomes the next recovery target. A newer live state observed before a + recovery write is never overwritten. When ``expected_main_sha`` is supplied, + recovery revalidates protected main before every PUT; post-write compensation + deliberately omits that source-freshness guard because its sole purpose is to + undo state displaced by an already-issued write using immutable predecessor + evidence. Ambiguous recovery results settle across one complete client-timeout + horizon before any later recovery write is considered, so a delayed request + cannot be duplicated. The recovery chain remains bounded and fails closed. + """ + + for _attempt in range(COLLISION_RECOVERY_LIMIT): + displaced_state = _history_version_state(target, displaced_version) + displaced_payload = _editable_projection(displaced_state) + live = _gh_api("GET", target.endpoint) + _assert_target_provenance(live, target) + if _editable_projection(live) != current_payload: + raise RulesetGovernanceError( + "concurrent ruleset history detected but live state advanced again; refusing recovery" + ) + if expected_main_sha is not None: + _assert_current_main(expected_main_sha) + + try: + _gh_api("PUT", target.endpoint, body=displaced_payload) + except (AmbiguousRulesetWriteError, subprocess.TimeoutExpired): + history = _settle_ambiguous_recovery_history( + target, + current_version=current_version, + expected_payload=displaced_payload, + ) + recovery_version = _history_version_id(history[0]) + if len(history) < 2: + raise RulesetGovernanceError( + "ambiguous ruleset recovery PUT exposed no predecessor" + ) + recovery_predecessor = _history_version_id(history[1]) + recovery_state = _history_version_state(target, recovery_version) + if _editable_projection(recovery_state) != displaced_payload: + raise RulesetGovernanceError( + "ambiguous ruleset recovery PUT left a newer state; refusing overwrite" + ) + else: + history = _gh_api_list("GET", f"{target.history_endpoint}?per_page=2") + if len(history) < 2: + raise RulesetGovernanceError( + "ruleset collision recovery history did not expose a predecessor" + ) + recovery_version = _history_version_id(history[0]) + recovery_predecessor = _history_version_id(history[1]) + recovery_state = _history_version_state(target, recovery_version) + if _editable_projection(recovery_state) != displaced_payload: + raise RulesetGovernanceError( + "ruleset collision recovery latest history does not match restore write" + ) + + restored = _gh_api("GET", target.endpoint) + _assert_target_provenance(restored, target) + if _editable_projection(restored) != displaced_payload: + raise RulesetGovernanceError( + "concurrent ruleset collision rollback did not converge" + ) + if recovery_predecessor == current_version: + return + + current_version = recovery_version + current_payload = displaced_payload + displaced_version = recovery_predecessor + + raise RulesetGovernanceError( + "ruleset collision recovery exceeded bounded attempts under concurrent writes" + ) + + +def _verify_ruleset_history_transition( + target: RulesetTarget, + baseline_version: int, + desired: dict[str, Any], + *, + expected_main_sha: str | None = None, +) -> None: + """Detect hidden pre-PUT edits and restore the newest displaced state safely. + + The newest history state must equal our reviewed body and its immediate + predecessor must be the version sampled before the final live read. If a + version intervened, recovery follows immutable history and verifies every + restore write so a second administrator edit cannot be silently overwritten. + ``expected_main_sha`` guards recovery only when the caller is still before a + new mutation boundary; callers validating an already-issued PUT omit it so + compensating restoration cannot be stranded by a later main advance. + """ + + history = _gh_api_list("GET", f"{target.history_endpoint}?per_page=3") + if len(history) < 2: + raise RulesetGovernanceError("ruleset history did not expose a predecessor") + newest_id = _history_version_id(history[0]) + predecessor_id = _history_version_id(history[1]) + if newest_id == baseline_version: + raise RulesetMutationNotVisibleError("ruleset mutation is not visible in history") + + newest_state = _history_version_state(target, newest_id) + if _editable_projection(newest_state) != desired: + raise RulesetMutationStillSettlingError( + "latest ruleset history does not match reviewed mutation; history changed before the reviewed mutation became visible" + ) + if predecessor_id == baseline_version: + return + + _recover_displaced_history_state( + target, + current_version=newest_id, + current_payload=desired, + displaced_version=predecessor_id, + expected_main_sha=expected_main_sha, + ) + raise RulesetGovernanceError( + "concurrent ruleset history detected; restored newest displaced administrator state" + ) + + +def _confirm_ambiguous_put( + target: RulesetTarget, + *, + baseline_version: int, + desired: dict[str, Any], + expected_main_sha: str | None, +) -> dict[str, Any]: + """Settle an ambiguous PUT from immutable history and exact live convergence. + + A timeout, connection loss, nonzero transport result, or malformed successful + response can occur after GitHub accepted the update. Three observations span + one additional full client timeout horizon: start, midpoint, and end. Neither + a baseline-only observation nor an intervening administrator version proves + rejection of the delayed reviewed PUT. Acceptance must become visible as the + exact reviewed payload in immutable history and live state; collision + recovery follows the same predecessor contract. Once the PUT is in flight, + settlement and any compensating restoration finish even when protected main + advances; source freshness is checked only after a clean, collision-free + settlement. If no decisive transition is visible by the end of the bounded + window, the run fails as unresolved and never retries the desired mutation + blindly. + """ + + if expected_main_sha is None: + raise RulesetGovernanceError( + "ambiguous ruleset PUT requires protected-main history guard" + ) + + for poll_index in range(AMBIGUOUS_WRITE_SETTLEMENT_POLLS): + try: + _verify_ruleset_history_transition( + target, + baseline_version, + desired, + expected_main_sha=None, + ) + except (RulesetMutationNotVisibleError, RulesetMutationStillSettlingError): + live = _gh_api("GET", target.endpoint) + _assert_target_provenance(live, target) + if poll_index == AMBIGUOUS_WRITE_SETTLEMENT_POLLS - 1: + raise RulesetGovernanceError( + "ambiguous ruleset PUT outcome remains unresolved after settlement window" + ) + time.sleep(AMBIGUOUS_WRITE_SETTLEMENT_INTERVAL_SECONDS) + continue + + after = _gh_api("GET", target.endpoint) + _assert_identity(after, target) + if _editable_projection(after) != desired: + raise RulesetGovernanceError( + f"{target.scope} ambiguous ruleset mutation did not converge" + ) + _assert_canonical_governance(after, target) + _assert_current_main(expected_main_sha) + return after + + raise RulesetGovernanceError( + "ambiguous ruleset PUT outcome remains unresolved after settlement window" + ) + + +def _reconcile_target( + target: RulesetTarget, + *, + verify_only: bool, + expected_main_sha: str | None = None, +) -> bool: + """Verify or reconcile one target; return whether a mutation was performed.""" + + first = _gh_api("GET", target.endpoint) + desired = _desired_payload(first, target) + projected = copy.deepcopy(first) + projected.update(desired) + _assert_canonical_governance(projected, target) + if _editable_projection(first) == desired: + return False + if verify_only: + raise RulesetGovernanceError( + f"{target.scope} ruleset governance drift remains" + ) + + baseline_version: int | None = None + if expected_main_sha is not None: + _assert_current_main(expected_main_sha) + baseline_version = _latest_history_version(target) + second = _gh_api("GET", target.endpoint) + if _editable_projection(second) != _editable_projection(first): + raise RulesetGovernanceError( + f"{target.scope} ruleset changed concurrently; refusing to overwrite" + ) + _assert_identity(second, target) + if expected_main_sha is not None: + _assert_current_main(expected_main_sha) + + history_verified = False + try: + _gh_api("PUT", target.endpoint, body=desired) + except (AmbiguousRulesetWriteError, subprocess.TimeoutExpired): + if baseline_version is None: + raise RulesetGovernanceError( + "ambiguous ruleset PUT requires protected-main history guard" + ) + after = _confirm_ambiguous_put( + target, + baseline_version=baseline_version, + desired=desired, + expected_main_sha=expected_main_sha, + ) + history_verified = True + else: + after = _gh_api("GET", target.endpoint) + + _assert_identity(after, target) + if _editable_projection(after) != desired: + raise RulesetGovernanceError(f"{target.scope} ruleset did not converge") + _assert_canonical_governance(after, target) + if baseline_version is not None and not history_verified: + # The write already happened. History verification may need to compensate + # a displaced administrator version even if protected main advanced in the + # meantime; final source freshness is checked only after clean settlement. + _verify_ruleset_history_transition( + target, + baseline_version, + desired, + expected_main_sha=None, + ) + if expected_main_sha is not None: + _assert_current_main(expected_main_sha) + return True + + +def reconcile( + targets: tuple[RulesetTarget, ...], + *, + verify_only: bool, + expected_main_sha: str | None = None, +) -> int: + """Reconcile all targets and return the number of successful mutations.""" + + if expected_main_sha is not None and ( + type(expected_main_sha) is not str or not GIT_SHA_RE.fullmatch(expected_main_sha) + ): + raise RulesetGovernanceError("expected protected main SHA is malformed") + if not verify_only and expected_main_sha is None: + raise RulesetGovernanceError( + "expected protected main SHA is required for mutation" + ) + + mutations = 0 + for target in sorted(targets, key=lambda item: item.scope == "organization"): + if expected_main_sha is None: + mutated = _reconcile_target(target, verify_only=verify_only) + else: + mutated = _reconcile_target( + target, + verify_only=verify_only, + expected_main_sha=expected_main_sha, + ) + mutations += int(mutated) + return mutations + + +def _parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse command-line arguments for validation, apply, or verification.""" + + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--manifest", + type=Path, + default=Path("config/ruleset-governance.json"), + help="Reviewed desired-state target manifest.", + ) + parser.add_argument( + "--expected-main-sha", + help="Exact trusted protected-main SHA for privileged mutation.", + ) + mode = parser.add_mutually_exclusive_group() + mode.add_argument("--validate-only", action="store_true") + mode.add_argument("--verify-only", action="store_true") + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Validate the manifest or reconcile exact live ruleset governance.""" + + args = _parse_args(argv) + targets = load_manifest(args.manifest) + if args.validate_only: + print(f"validated {len(targets)} ruleset governance targets") + return 0 + if not os.environ.get("GH_TOKEN"): + raise RulesetGovernanceError( + "GH_TOKEN is required for live ruleset governance" + ) + if not args.verify_only and not args.expected_main_sha: + raise RulesetGovernanceError( + "expected protected main SHA is required for mutation" + ) + if args.expected_main_sha is None: + mutations = reconcile(targets, verify_only=args.verify_only) + else: + mutations = reconcile( + targets, + verify_only=args.verify_only, + expected_main_sha=args.expected_main_sha, + ) + verb = "verified" if args.verify_only else "reconciled" + print(f"{verb} {len(targets)} ruleset governance targets; mutations={mutations}") + return 0 + + +def cli() -> None: + """Execute the command-line boundary with concise fail-closed diagnostics.""" + + try: + raise SystemExit(main()) + except ( + OSError, + json.JSONDecodeError, + subprocess.TimeoutExpired, + RulesetGovernanceError, + ) as exc: + print(f"ruleset governance reconciliation failed: {exc}", file=sys.stderr) + raise SystemExit(1) from exc + + +if __name__ == "__main__": # pragma: no cover - exercised by subprocess smoke test + cli() diff --git a/tests/test_central_required_workflow_exact_inventory.py b/tests/test_central_required_workflow_exact_inventory.py index d21c145864..e7d5b5230d 100644 --- a/tests/test_central_required_workflow_exact_inventory.py +++ b/tests/test_central_required_workflow_exact_inventory.py @@ -25,6 +25,7 @@ def _ruleset_payload() -> dict: "name": audit.RULESET_NAME, "target": "branch", "enforcement": "active", + "bypass_actors": [], "conditions": { "repository_name": { "include": ["~ALL"], @@ -36,6 +37,7 @@ def _ruleset_payload() -> dict: { "type": "workflows", "parameters": { + "do_not_enforce_on_create": True, "workflows": [ { "repository_id": audit.SOURCE_REPOSITORY_ID, @@ -49,9 +51,10 @@ def _ruleset_payload() -> dict: { "type": "pull_request", "parameters": { - "required_approving_review_count": 2, + "required_approving_review_count": 0, "dismiss_stale_reviews_on_push": True, - "require_last_push_approval": True, + "require_code_owner_review": False, + "require_last_push_approval": False, "required_review_thread_resolution": True, "allowed_merge_methods": ["merge", "squash"], }, diff --git a/tests/test_central_required_workflow_ruleset_audit.py b/tests/test_central_required_workflow_ruleset_audit.py index 7f3cc01397..6182333c8c 100644 --- a/tests/test_central_required_workflow_ruleset_audit.py +++ b/tests/test_central_required_workflow_ruleset_audit.py @@ -1,12 +1,13 @@ +import json from copy import deepcopy from io import StringIO -import json from pathlib import Path from scripts.ci import audit_central_required_workflows as audit - REPO_ROOT = Path(__file__).resolve().parents[1] + + def ruleset_payload() -> dict: """Return the expected live central required-workflow ruleset shape.""" workflow_paths = ( @@ -25,6 +26,7 @@ def ruleset_payload() -> dict: "name": "CWL Central required workflows", "target": "branch", "enforcement": "active", + "bypass_actors": [], "conditions": { "repository_name": { "include": ["~ALL"], @@ -36,7 +38,7 @@ def ruleset_payload() -> dict: { "type": "workflows", "parameters": { - "do_not_enforce_on_create": False, + "do_not_enforce_on_create": True, "workflows": [ { "repository_id": 1274066402, @@ -50,10 +52,10 @@ def ruleset_payload() -> dict: { "type": "pull_request", "parameters": { - "required_approving_review_count": 2, + "required_approving_review_count": 0, "dismiss_stale_reviews_on_push": True, "require_code_owner_review": False, - "require_last_push_approval": True, + "require_last_push_approval": False, "required_review_thread_resolution": True, "required_reviewers": [], "allowed_merge_methods": ["merge", "squash"], @@ -110,6 +112,38 @@ def stacked_ruleset_payload() -> dict: } +def repository_ruleset_payload() -> dict: + """Return the expected strong default-branch policy for the owner repo.""" + + return { + "id": 17921150, + "name": "Lock default branch", + "target": "branch", + "source_type": "Repository", + "source": "ContextualWisdomLab/.github", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}, + }, + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": True, + "require_code_owner_review": False, + "require_last_push_approval": False, + "required_review_thread_resolution": True, + "allowed_merge_methods": ["merge", "squash"], + }, + }, + {"type": "deletion"}, + {"type": "non_fast_forward"}, + ], + } + + def test_expected_central_ruleset_passes(monkeypatch, capsys) -> None: monkeypatch.setattr(audit.sys, "stdin", StringIO(json.dumps(ruleset_payload()))) @@ -120,10 +154,229 @@ def test_expected_central_ruleset_passes(monkeypatch, capsys) -> None: ) +def test_central_ruleset_rejects_unexpected_and_malformed_workflows() -> None: + payload = ruleset_payload() + workflow_rule = next(rule for rule in payload["rules"] if rule["type"] == "workflows") + workflow_rule["parameters"]["workflows"].extend( + [ + { + "repository_id": 1274066402, + "path": ".github/workflows/unexpected.yml", + "ref": "refs/heads/main", + }, + {"repository_id": 1274066402, "path": 42, "ref": "refs/heads/main"}, + ] + ) + + errors = audit.audit_ruleset(payload) + + assert "unexpected workflow present in required set: .github/workflows/unexpected.yml" in errors + assert "central required workflows contain 1 malformed entry" in errors + + +def test_central_ruleset_rejects_rebase_merge_method() -> None: + payload = ruleset_payload() + review_rule = next(rule for rule in payload["rules"] if rule["type"] == "pull_request") + review_rule["parameters"]["allowed_merge_methods"].append("rebase") + + assert "only merge and squash may be allowed merge methods" in audit.audit_ruleset(payload) + + +def test_central_ruleset_rejects_bypass_actors() -> None: + payload = ruleset_payload() + payload["bypass_actors"] = [ + { + "actor_id": None, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always", + } + ] + + assert audit.audit_ruleset(payload) == [ + "central ruleset must not configure bypass actors", + ] + + +def test_central_ruleset_rejects_missing_bypass_evidence() -> None: + payload = ruleset_payload() + del payload["bypass_actors"] + + assert audit.audit_ruleset(payload) == [ + "central ruleset must not configure bypass actors", + ] + + def test_inherited_ruleset_and_organization_scope_probes_pass() -> None: assert audit.audit_ruleset(inherited_ruleset_payload()) == [] +def test_expected_repository_ruleset_passes() -> None: + assert hasattr(audit, "audit_repository_ruleset"), ( + "the central audit must inspect the repository ruleset that protects .github" + ) + assert audit.audit_repository_ruleset(repository_ruleset_payload()) == [] + + +def test_repository_ruleset_rejects_unsatisfiable_review_controls() -> None: + assert hasattr(audit, "audit_repository_ruleset"), ( + "the central audit must inspect the repository ruleset that protects .github" + ) + payload = repository_ruleset_payload() + review_rule = next(rule for rule in payload["rules"] if rule["type"] == "pull_request") + review_rule["parameters"]["required_approving_review_count"] = 1 + review_rule["parameters"]["require_last_push_approval"] = True + + assert audit.audit_repository_ruleset(payload) == [ + "repository solo-maintainer ruleset must not require approving reviews", + "repository solo-maintainer ruleset must not require last-push approval", + ] + + +def test_repository_ruleset_rejects_rebase_merge_method() -> None: + payload = repository_ruleset_payload() + review_rule = next(rule for rule in payload["rules"] if rule["type"] == "pull_request") + review_rule["parameters"]["allowed_merge_methods"].append("rebase") + + assert audit.audit_repository_ruleset(payload) == [ + "repository ruleset must allow only merge and squash", + ] + + +def test_repository_ruleset_rejects_bypass_actors() -> None: + payload = repository_ruleset_payload() + payload["bypass_actors"] = [ + { + "actor_id": None, + "actor_type": "OrganizationAdmin", + "bypass_mode": "always", + } + ] + + assert audit.audit_repository_ruleset(payload) == [ + "repository ruleset must not configure bypass actors", + ] + + +def test_repository_ruleset_rejects_missing_bypass_evidence() -> None: + payload = repository_ruleset_payload() + del payload["bypass_actors"] + + assert audit.audit_repository_ruleset(payload) == [ + "repository ruleset must not configure bypass actors", + ] + + +def test_repository_ruleset_reports_structural_and_protection_drift() -> None: + payload = { + "id": 0, + "name": "drifted", + "source_type": "Organization", + "source": "ContextualWisdomLab", + "target": "tag", + "enforcement": "disabled", + "conditions": None, + "rules": "not-a-list", + } + + assert audit.audit_repository_ruleset(payload) == [ + "expected repository ruleset id 17921150", + "expected repository ruleset name Lock default branch", + "repository ruleset source is not ContextualWisdomLab/.github", + "repository ruleset target is not branch", + "repository ruleset enforcement is not active", + "repository ruleset must not configure bypass actors", + "repository ruleset ref scope must be exactly the default branch", + "expected one repository pull_request rule, found 0", + "repository default-branch deletion protection is missing", + "repository default-branch non-fast-forward protection is missing", + ] + + +def test_repository_ruleset_rejects_malformed_review_parameters() -> None: + payload = repository_ruleset_payload() + review_rule = next(rule for rule in payload["rules"] if rule["type"] == "pull_request") + review_rule["parameters"] = None + + assert audit.audit_repository_ruleset(payload) == [ + "repository solo-maintainer ruleset must not require approving reviews", + "repository solo-maintainer ruleset must not require code-owner review", + "repository ruleset stale-review dismissal on push is disabled", + "repository solo-maintainer ruleset must not require last-push approval", + "repository ruleset review-thread resolution protection is disabled", + "repository ruleset must allow only merge and squash", + ] + + +def test_repository_ruleset_cli_reports_passing_policy(monkeypatch, capsys) -> None: + monkeypatch.setattr( + audit.sys, + "stdin", + StringIO(json.dumps(repository_ruleset_payload())), + ) + + assert audit.main(["--repository"]) == 0 + assert ( + "PASS: repository ruleset 17921150 protects the default branch" + in capsys.readouterr().out + ) + + +def test_ref_scope_rejects_all_branch_and_extra_proposal_branch_targets() -> None: + for include in ( + ["~ALL"], + ["~DEFAULT_BRANCH", "~ALL"], + ["~DEFAULT_BRANCH", "refs/heads/feature/*"], + ): + payload = ruleset_payload() + payload["conditions"]["ref_name"]["include"] = include + + assert audit.audit_ruleset(payload) == [ + "central ruleset ref scope must be exactly the default branch" + ] + + +def test_ref_scope_rejects_branch_exclusions() -> None: + """The strict default-branch ruleset must not hide excluded refs.""" + + payload = ruleset_payload() + payload["conditions"]["ref_name"]["exclude"] = ["refs/heads/release/*"] + + assert audit.audit_ruleset(payload) == [ + "central ruleset ref scope must be exactly the default branch" + ] + + +def test_ref_scope_rejects_string_include() -> None: + """The ruleset API contract requires an exact include list.""" + payload = ruleset_payload() + payload["conditions"]["ref_name"]["include"] = "~ALL" + + assert audit.audit_ruleset(payload) == [ + "central ruleset ref scope must be exactly the default branch" + ] + + +def test_workflows_must_not_block_branch_create_transition() -> None: + payload = ruleset_payload() + workflow_rule = next(rule for rule in payload["rules"] if rule["type"] == "workflows") + workflow_rule["parameters"]["do_not_enforce_on_create"] = False + + assert audit.audit_ruleset(payload) == [ + "central required workflows block the branch create transition" + ] + + +def test_multiple_workflow_rules_do_not_invent_create_transition_drift() -> None: + """Report structural multiplicity without attributing a missing flag to it.""" + payload = ruleset_payload() + payload["rules"].append(payload["rules"][0].copy()) + + errors = audit.audit_ruleset(payload) + + assert "expected one workflows rule, found 2" in errors + assert "central required workflows block the branch create transition" not in errors + + def test_expected_stacked_ruleset_passes(monkeypatch, capsys) -> None: payload = stacked_ruleset_payload() payload["rules"][0]["parameters"]["workflows"][0]["sha"] = "a" * 40 @@ -341,17 +594,17 @@ def test_wrong_workflow_ref_reports_exact_drift() -> None: ) -def test_review_policy_weakening_reports_exact_drift() -> None: +def test_unsatisfiable_review_policy_reports_exact_drift() -> None: payload = ruleset_payload() review_rule = next(rule for rule in payload["rules"] if rule["type"] == "pull_request") review_rule["parameters"]["required_approving_review_count"] = 1 - review_rule["parameters"]["require_last_push_approval"] = False + review_rule["parameters"]["require_last_push_approval"] = True review_rule["parameters"]["required_review_thread_resolution"] = False errors = audit.audit_ruleset(payload) - assert "exactly two approving reviews are not required" in errors - assert "last-push approval protection is disabled" in errors + assert "central solo-maintainer ruleset must not require approving reviews" in errors + assert "central solo-maintainer ruleset must not require last-push approval" in errors assert "review-thread resolution protection is disabled" in errors @@ -372,9 +625,10 @@ def test_audit_reports_all_structural_and_protection_drift() -> None: "expected ruleset name CWL Central required workflows", "central ruleset target is not branch", "central ruleset enforcement is not active", + "central ruleset must not configure bypass actors", "central ruleset does not include all repositories", "central ruleset repository exclusions drifted: expected ['.github', 'IRT-bibliography-set', 'noema'], got []", - "central ruleset does not target every default branch", + "central ruleset ref scope must be exactly the default branch", "expected one workflows rule, found 0", "missing central required workflow .github/workflows/close-empty-pr.yml", "missing central required workflow .github/workflows/noema-review.yml", @@ -391,7 +645,7 @@ def test_audit_reports_all_structural_and_protection_drift() -> None: ] -def test_audit_reports_malformed_duplicate_workflows_and_weak_review_parameters() -> None: +def test_audit_handles_duplicate_workflows_and_unsatisfiable_review_parameters() -> None: payload = ruleset_payload() workflow_rule = next(rule for rule in payload["rules"] if rule["type"] == "workflows") workflows = workflow_rule["parameters"]["workflows"] @@ -400,9 +654,9 @@ def test_audit_reports_malformed_duplicate_workflows_and_weak_review_parameters( workflows.append(deepcopy(workflows[-1])) review_rule = next(rule for rule in payload["rules"] if rule["type"] == "pull_request") review_rule["parameters"] = { - "required_approving_review_count": 0, + "required_approving_review_count": 1, "dismiss_stale_reviews_on_push": False, - "require_last_push_approval": False, + "require_last_push_approval": True, "required_review_thread_resolution": False, "allowed_merge_methods": ["squash"], } @@ -412,11 +666,11 @@ def test_audit_reports_malformed_duplicate_workflows_and_weak_review_parameters( assert "central required workflow entry 0 is malformed" in errors assert "central required workflow entry 1 is malformed" in errors assert "central required workflow .github/workflows/scorecard-pr.yml is configured 2 times" in errors - assert "exactly two approving reviews are not required" in errors + assert "central solo-maintainer ruleset must not require approving reviews" in errors assert "stale-review dismissal on push is disabled" in errors - assert "last-push approval protection is disabled" in errors + assert "central solo-maintainer ruleset must not require last-push approval" in errors assert "review-thread resolution protection is disabled" in errors - assert "merge and squash are not both allowed merge methods" in errors + assert "only merge and squash may be allowed merge methods" in errors def test_audit_reports_each_malformed_workflow_entry_by_index() -> None: @@ -445,7 +699,8 @@ def test_audit_handles_malformed_rule_parameter_shapes() -> None: errors = audit.audit_ruleset(payload) assert "missing central required workflow .github/workflows/sast-semgrep.yml" in errors - assert "exactly two approving reviews are not required" in errors + assert "central solo-maintainer ruleset must not require approving reviews" in errors + assert "central solo-maintainer ruleset must not require last-push approval" in errors def test_load_payload_rejects_non_object_and_main_logs_load_reason(monkeypatch, capsys) -> None: @@ -475,6 +730,12 @@ def test_scheduled_audit_and_rollout_document_semgrep_and_noema_requirements() - assert "Ruleset audit could not read inherited organization ruleset" in workflow assert 'STACKED_RULESET_ID: "21732164"' in workflow assert "audit_central_required_workflows.py --stacked" in workflow + assert 'REPOSITORY_RULESET_ID: "17921150"' in workflow + assert ( + "repos/${ORG_LOGIN}/.github/rulesets/${REPOSITORY_RULESET_ID}" + in workflow + ) + assert "audit_central_required_workflows.py --repository" in workflow assert "CWL Stacked OpenCode required workflow" in rollout assert 'ref_name.exclude=["~DEFAULT_BRANCH"]' in rollout assert "- `.github/workflows/noema-review.yml`" in rollout diff --git a/tests/test_ruleset_audit_completeness_regression.py b/tests/test_ruleset_audit_completeness_regression.py new file mode 100644 index 0000000000..ed6f03b541 --- /dev/null +++ b/tests/test_ruleset_audit_completeness_regression.py @@ -0,0 +1,149 @@ +"""Regression tests for complete ruleset drift evidence and rollout policy.""" + +from pathlib import Path + +from scripts.ci import audit_central_required_workflows as audit + +REPO_ROOT = Path(__file__).resolve().parents[1] + + +def _central_payload() -> dict: + """Return a minimal payload satisfying the declared central policy.""" + return { + "id": audit.RULESET_ID, + "name": audit.RULESET_NAME, + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "repository_name": { + "include": ["~ALL"], + "exclude": [".github", "IRT-bibliography-set", "noema"], + }, + "ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}, + }, + "rules": [ + { + "type": "workflows", + "parameters": { + "do_not_enforce_on_create": True, + "workflows": [ + { + "repository_id": audit.SOURCE_REPOSITORY_ID, + "path": path, + "ref": audit.SOURCE_REF, + } + for path in audit.REQUIRED_WORKFLOW_PATHS + ], + }, + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "required_reviewers": [], + "dismiss_stale_reviews_on_push": True, + "require_last_push_approval": False, + "required_review_thread_resolution": True, + "allowed_merge_methods": ["merge", "squash"], + }, + }, + {"type": "deletion"}, + {"type": "non_fast_forward"}, + ], + } + + +def _repository_payload() -> dict: + """Return a minimal payload satisfying the owner-repository policy.""" + return { + "id": audit.REPOSITORY_RULESET_ID, + "name": audit.REPOSITORY_RULESET_NAME, + "source_type": "Repository", + "source": audit.REPOSITORY_RULESET_SOURCE, + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}, + }, + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "required_reviewers": [], + "dismiss_stale_reviews_on_push": True, + "require_last_push_approval": False, + "required_review_thread_resolution": True, + "allowed_merge_methods": ["merge", "squash"], + }, + }, + {"type": "deletion"}, + {"type": "non_fast_forward"}, + ], + } + + +def test_central_ruleset_rejects_creation_or_other_undeclared_rule_types() -> None: + """A creation rule must not silently defeat the branch-create transition.""" + payload = _central_payload() + payload["rules"].extend([{"type": "creation"}, {"type": "required_signatures"}]) + + assert "central ruleset has forbidden rule types: ['creation', 'required_signatures']" in audit.audit_ruleset(payload) + + +def test_repository_ruleset_rejects_undeclared_rule_types() -> None: + """The repository pass result must describe the complete protected policy.""" + payload = _repository_payload() + payload["rules"].append({"type": "creation"}) + + assert "repository ruleset has forbidden rule types: ['creation']" in audit.audit_repository_ruleset(payload) + + +def test_central_ruleset_rejects_malformed_and_typeless_rule_entries() -> None: + """A non-dict or type-less rule entry must not silently pass rule-type validation.""" + payload = _central_payload() + payload["rules"].extend(["not-a-rule", {"type": ""}]) + + assert ( + "central ruleset has forbidden rule types: ['', '']" + in audit.audit_ruleset(payload) + ) + + +def test_repository_ruleset_rejects_malformed_and_typeless_rule_entries() -> None: + """A non-dict or type-less rule entry must not silently pass rule-type validation.""" + payload = _repository_payload() + payload["rules"].extend([123, {"type": None}]) + + assert ( + "repository ruleset has forbidden rule types: ['', '']" + in audit.audit_repository_ruleset(payload) + ) + + +def test_live_audit_collects_all_available_ruleset_drift_before_failing() -> None: + """One ruleset failure must not suppress other already-fetched audit results.""" + workflow = (REPO_ROOT / ".github/workflows/audit-central-ruleset.yml").read_text(encoding="utf-8") + + assert "audit_status=0" in workflow + assert workflow.count("if ! python3 scripts/ci/audit_central_required_workflows.py") == 3 + assert 'if [[ "$audit_status" -ne 0 ]]; then' in workflow + + +def test_disposable_focused_contract_is_removed_after_terminal_proof() -> None: + """The temporary proof workflow must not survive its proven source-fix lifecycle.""" + proof_workflow = REPO_ROOT / ".github/workflows/solo-maintainer-ruleset-contract.yml" + + assert not proof_workflow.exists() + + +def test_rollout_guide_declares_solo_maintainer_review_policy() -> None: + """Operator documentation must not reintroduce a fictional second human approval.""" + rollout = (REPO_ROOT / "docs/org-required-workflow-rollout.md").read_text(encoding="utf-8") + + assert "required_approving_review_count = 0" in rollout + assert "require_last_push_approval = false" in rollout + assert "The org's two-reviewer merge rule" not in rollout + assert "two distinct approvals" not in rollout diff --git a/tests/test_ruleset_governance_delayed_recovery_regression.py b/tests/test_ruleset_governance_delayed_recovery_regression.py new file mode 100644 index 0000000000..0ae9e0f3c4 --- /dev/null +++ b/tests/test_ruleset_governance_delayed_recovery_regression.py @@ -0,0 +1,328 @@ +"""Regress delayed ambiguous ruleset recovery without duplicate privileged writes.""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "scripts" / "ci" / "reconcile_ruleset_governance.py" + + +def load_module(): + """Load the exact production ruleset reconciler from the checkout.""" + + spec = importlib.util.spec_from_file_location( + "ruleset_governance_delayed_recovery_regression", SOURCE + ) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def repository_target(module): + """Return the exact central repository ruleset target.""" + + return module.RulesetTarget( + scope="repository", + owner="ContextualWisdomLab", + repository=".github", + ruleset_id=17921150, + name="Lock default branch", + ) + + +def live_payload() -> dict: + """Return one live-shaped ruleset state used by the recovery regression.""" + + return { + "id": 17921150, + "name": "Lock default branch", + "target": "branch", + "source_type": "Repository", + "source": "ContextualWisdomLab/.github", + "enforcement": "active", + "bypass_actors": [], + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, + "rules": [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": True, + "required_reviewers": [], + "require_code_owner_review": False, + "require_last_push_approval": False, + "required_review_thread_resolution": True, + "allowed_merge_methods": ["merge", "squash"], + }, + }, + ], + } + + +def test_delayed_recovery_acceptance_settles_before_any_second_put(monkeypatch) -> None: + """A delayed recovery commit must become visible before another PUT is attempted.""" + + module = load_module() + target = repository_target(module) + current = live_payload() + displaced = {**current, "name": "Administrator predecessor", "enforcement": "evaluate"} + history_reads = 0 + put_count = 0 + sleep_calls = 0 + + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + + def fake_sleep(_seconds): + nonlocal sleep_calls + sleep_calls += 1 + + monkeypatch.setattr(module.time, "sleep", fake_sleep) + monkeypatch.setattr(module, "_history_version_state", lambda *_args: displaced) + + def fake_history(*_args): + nonlocal history_reads + history_reads += 1 + if history_reads == 1: + return [{"version_id": 10}] + return [{"version_id": 11}, {"version_id": 10}] + + def fake_api(method, endpoint, **_kwargs): + nonlocal put_count + if method == "GET" and endpoint == target.endpoint: + return displaced if history_reads >= 2 else current + if method == "PUT" and endpoint == target.endpoint: + put_count += 1 + raise subprocess.TimeoutExpired(cmd=["gh", "api"], timeout=30) + raise AssertionError((method, endpoint)) + + monkeypatch.setattr(module, "_gh_api_list", fake_history) + monkeypatch.setattr(module, "_gh_api", fake_api) + + module._recover_displaced_history_state( + target, + current_version=10, + current_payload=module._editable_projection(current), + displaced_version=9, + expected_main_sha="a" * 40, + ) + + assert history_reads == 2 + assert sleep_calls == 1 + assert put_count == 1 + + +def test_initial_ambiguous_put_waits_through_intervening_admin_version(monkeypatch) -> None: + """An administrator version appearing first cannot end initial PUT settlement.""" + + module = load_module() + target = repository_target(module) + drifted = live_payload() + drifted["bypass_actors"] = [ + {"actor_id": None, "actor_type": "OrganizationAdmin", "bypass_mode": "always"} + ] + desired = module._desired_payload(drifted, target) + desired_state = {**drifted, **desired} + administrator_state = { + **drifted, + "name": "Administrator intervening state", + "enforcement": "evaluate", + } + history_reads = 0 + recovered: list[tuple[int, int, dict]] = [] + + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + monkeypatch.setattr(module.time, "sleep", lambda *_args: None) + + def fake_history(*_args): + nonlocal history_reads + history_reads += 1 + if history_reads == 1: + return [{"version_id": 11}, {"version_id": 10}] + return [ + {"version_id": 12}, + {"version_id": 11}, + {"version_id": 10}, + ] + + def fake_history_state(_target, version): + return {11: administrator_state, 12: desired_state}[version] + + def fake_recovery(_target, *, current_version, current_payload, displaced_version, **_kwargs): + recovered.append((current_version, displaced_version, current_payload)) + + monkeypatch.setattr(module, "_gh_api_list", fake_history) + monkeypatch.setattr(module, "_history_version_state", fake_history_state) + monkeypatch.setattr(module, "_recover_displaced_history_state", fake_recovery) + monkeypatch.setattr(module, "_gh_api", lambda *_args, **_kwargs: desired_state) + + with pytest.raises(module.RulesetGovernanceError, match="concurrent ruleset history detected"): + module._confirm_ambiguous_put( + target, + baseline_version=10, + desired=desired, + expected_main_sha="a" * 40, + ) + + assert history_reads == 2 + assert recovered == [(12, 11, desired)] + + +def test_recovery_ambiguous_put_waits_through_intervening_admin_version(monkeypatch) -> None: + """A delayed recovery PUT tracks an administrator predecessor before restoring it.""" + + module = load_module() + target = repository_target(module) + current = live_payload() + restore_state = { + **current, + "name": "Original administrator state", + "enforcement": "evaluate", + } + intervening_state = { + **current, + "name": "Intervening administrator state", + "enforcement": "evaluate", + } + live_state = current + history_reads = 0 + put_bodies: list[dict] = [] + + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + monkeypatch.setattr(module.time, "sleep", lambda *_args: None) + + def fake_history_state(_target, version): + return { + 9: restore_state, + 11: intervening_state, + 12: restore_state, + 13: intervening_state, + }[version] + + def fake_history(*_args): + nonlocal history_reads, live_state + history_reads += 1 + if history_reads == 1: + live_state = intervening_state + return [{"version_id": 11}, {"version_id": 10}] + if history_reads == 2: + live_state = restore_state + return [{"version_id": 12}, {"version_id": 11}] + return [{"version_id": 13}, {"version_id": 12}] + + def fake_api(method, endpoint, *, body=None): + nonlocal live_state + if method == "GET" and endpoint == target.endpoint: + return live_state + if method == "PUT" and endpoint == target.endpoint: + assert body is not None + put_bodies.append(body) + if len(put_bodies) == 1: + raise subprocess.TimeoutExpired(cmd=["gh", "api"], timeout=30) + live_state = {**live_state, **body} + return {} + raise AssertionError((method, endpoint)) + + monkeypatch.setattr(module, "_history_version_state", fake_history_state) + monkeypatch.setattr(module, "_gh_api_list", fake_history) + monkeypatch.setattr(module, "_gh_api", fake_api) + + module._recover_displaced_history_state( + target, + current_version=10, + current_payload=module._editable_projection(current), + displaced_version=9, + expected_main_sha="a" * 40, + ) + + assert history_reads == 3 + assert put_bodies == [ + module._editable_projection(restore_state), + module._editable_projection(intervening_state), + ] + + +def test_recovery_chain_exhaustion_fails_closed_after_bounded_attempts(monkeypatch) -> None: + """A perpetual collision chain reaches the bounded terminal error, never an unbounded loop.""" + + module = load_module() + target = repository_target(module) + live_state = live_payload() + history_reads = 0 + put_count = 0 + + def payload_for(version: int) -> dict: + return { + **live_payload(), + "name": f"Administrator predecessor {version}", + "enforcement": "evaluate", + } + + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + + def fake_history_state(_target, version): + if version >= 100: + return live_state + return payload_for(version) + + def fake_history(*_args): + nonlocal history_reads + index = history_reads + history_reads += 1 + return [ + {"version_id": 100 + index}, + {"version_id": 8 - index}, + ] + + def fake_api(method, endpoint, *, body=None): + nonlocal live_state, put_count + if method == "GET" and endpoint == target.endpoint: + return live_state + if method == "PUT" and endpoint == target.endpoint: + assert body is not None + put_count += 1 + live_state = {**live_state, **body} + return {} + raise AssertionError((method, endpoint)) + + monkeypatch.setattr(module, "_history_version_state", fake_history_state) + monkeypatch.setattr(module, "_gh_api_list", fake_history) + monkeypatch.setattr(module, "_gh_api", fake_api) + + with pytest.raises(module.RulesetGovernanceError, match="exceeded bounded attempts"): + module._recover_displaced_history_state( + target, + current_version=10, + current_payload=module._editable_projection(live_state), + displaced_version=9, + expected_main_sha="a" * 40, + ) + + assert history_reads == module.COLLISION_RECOVERY_LIMIT + assert put_count == module.COLLISION_RECOVERY_LIMIT + + +def test_verify_only_reconcile_dispatches_without_mutation_sha(monkeypatch) -> None: + """Read-only reconciliation keeps its documented no-mutation-SHA path covered.""" + + module = load_module() + target = repository_target(module) + calls: list[tuple[str, bool, str | None]] = [] + + def fake_reconcile_target(seen_target, *, verify_only, expected_main_sha=None): + calls.append((seen_target.scope, verify_only, expected_main_sha)) + return False + + monkeypatch.setattr(module, "_reconcile_target", fake_reconcile_target) + + assert module.reconcile((target,), verify_only=True) == 0 + assert calls == [("repository", True, None)] diff --git a/tests/test_ruleset_governance_post_put_cleanup_regression.py b/tests/test_ruleset_governance_post_put_cleanup_regression.py new file mode 100644 index 0000000000..b48f508a95 --- /dev/null +++ b/tests/test_ruleset_governance_post_put_cleanup_regression.py @@ -0,0 +1,328 @@ +"""Regress post-PUT cleanup when protected main advances after mutation starts.""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "scripts" / "ci" / "reconcile_ruleset_governance.py" + + +def load_module(): + """Load the production reconciler from the exact checkout.""" + + spec = importlib.util.spec_from_file_location("ruleset_governance_post_put_cleanup", SOURCE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def repository_target(module): + """Return the exact central repository ruleset target.""" + + return module.RulesetTarget( + scope="repository", + owner="ContextualWisdomLab", + repository=".github", + ruleset_id=17921150, + name="Lock default branch", + ) + + +def live_payload() -> dict: + """Return one live-shaped repository ruleset with reviewed governance drift.""" + + return { + "id": 17921150, + "name": "Lock default branch", + "target": "branch", + "source_type": "Repository", + "source": "ContextualWisdomLab/.github", + "enforcement": "active", + "bypass_actors": [ + {"actor_id": None, "actor_type": "OrganizationAdmin", "bypass_mode": "always"} + ], + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, + "rules": [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": True, + "required_reviewers": [], + "require_code_owner_review": False, + "require_last_push_approval": False, + "required_review_thread_resolution": True, + "allowed_merge_methods": ["merge", "squash", "rebase"], + }, + }, + ], + } + + +def test_ambiguous_put_settles_before_stale_main_failure(monkeypatch) -> None: + """Main advancement after PUT cannot abort immutable-history settlement.""" + + module = load_module() + target = repository_target(module) + first = live_payload() + desired = module._desired_payload(first, target) + converged = {**first, **desired} + main_checks = 0 + history_reads = 0 + get_count = 0 + put_bodies: list[dict] = [] + + monkeypatch.setattr(module, "_assert_canonical_governance", lambda *_args: None) + monkeypatch.setattr(module, "_latest_history_version", lambda *_args: 41) + monkeypatch.setattr(module, "_history_version_state", lambda *_args: converged) + + def assert_main(_expected_sha: str) -> None: + nonlocal main_checks + main_checks += 1 + if main_checks > 2: + raise module.RulesetGovernanceError("protected main advanced") + + def history(*_args): + nonlocal history_reads + history_reads += 1 + return [{"version_id": 42}, {"version_id": 41}] + + def api(method, endpoint, *, body=None): + nonlocal get_count + if method == "GET" and endpoint == target.endpoint: + get_count += 1 + return first if get_count <= 2 else converged + if method == "PUT" and endpoint == target.endpoint: + assert body is not None + put_bodies.append(body) + raise module.AmbiguousRulesetWriteError("accepted before transport loss") + raise AssertionError((method, endpoint)) + + monkeypatch.setattr(module, "_assert_current_main", assert_main) + monkeypatch.setattr(module, "_gh_api_list", history) + monkeypatch.setattr(module, "_gh_api", api) + + with pytest.raises(module.RulesetGovernanceError, match="protected main advanced"): + module._reconcile_target( + target, + verify_only=False, + expected_main_sha="a" * 40, + ) + + assert history_reads == 1 + assert main_checks == 3 + assert put_bodies == [desired] + + +def test_ambiguous_put_restores_displaced_admin_after_main_advances(monkeypatch) -> None: + """Cleanup restores the displaced predecessor even after source freshness changes.""" + + module = load_module() + target = repository_target(module) + first = live_payload() + desired = module._desired_payload(first, target) + converged = {**first, **desired} + administrator = { + **first, + "name": "Administrator intervening state", + "enforcement": "evaluate", + } + main_checks = 0 + get_count = 0 + history_reads = 0 + put_bodies: list[dict] = [] + + monkeypatch.setattr(module, "_assert_canonical_governance", lambda *_args: None) + monkeypatch.setattr(module, "_latest_history_version", lambda *_args: 41) + + def assert_main(_expected_sha: str) -> None: + nonlocal main_checks + main_checks += 1 + if main_checks > 2: + raise module.RulesetGovernanceError("protected main advanced") + + def history(*_args): + nonlocal history_reads + history_reads += 1 + if history_reads == 1: + return [ + {"version_id": 43}, + {"version_id": 42}, + {"version_id": 41}, + ] + return [{"version_id": 44}, {"version_id": 43}] + + def history_state(_target, version_id): + return {42: administrator, 43: converged, 44: administrator}[version_id] + + def api(method, endpoint, *, body=None): + nonlocal get_count + if method == "GET" and endpoint == target.endpoint: + get_count += 1 + if get_count <= 2: + return first + if get_count == 3: + return converged + return administrator + if method == "PUT" and endpoint == target.endpoint: + assert body is not None + put_bodies.append(body) + if len(put_bodies) == 1: + raise module.AmbiguousRulesetWriteError("accepted before transport loss") + return {} + raise AssertionError((method, endpoint)) + + monkeypatch.setattr(module, "_assert_current_main", assert_main) + monkeypatch.setattr(module, "_gh_api_list", history) + monkeypatch.setattr(module, "_history_version_state", history_state) + monkeypatch.setattr(module, "_gh_api", api) + + with pytest.raises( + module.RulesetGovernanceError, + match="restored newest displaced administrator state", + ): + module._reconcile_target( + target, + verify_only=False, + expected_main_sha="a" * 40, + ) + + assert main_checks == 2 + assert history_reads == 2 + assert put_bodies == [desired, module._editable_projection(administrator)] + + +def test_successful_put_restores_displaced_admin_after_main_advances(monkeypatch) -> None: + """A successful PUT also finishes predecessor restoration before stale-main failure.""" + + module = load_module() + target = repository_target(module) + first = live_payload() + desired = module._desired_payload(first, target) + converged = {**first, **desired} + administrator = { + **first, + "name": "Administrator intervening state", + "enforcement": "evaluate", + } + main_checks = 0 + get_count = 0 + history_reads = 0 + put_bodies: list[dict] = [] + + monkeypatch.setattr(module, "_assert_canonical_governance", lambda *_args: None) + monkeypatch.setattr(module, "_latest_history_version", lambda *_args: 41) + + def assert_main(_expected_sha: str) -> None: + nonlocal main_checks + main_checks += 1 + if main_checks > 2: + raise module.RulesetGovernanceError("protected main advanced") + + def history(*_args): + nonlocal history_reads + history_reads += 1 + if history_reads == 1: + return [ + {"version_id": 43}, + {"version_id": 42}, + {"version_id": 41}, + ] + return [{"version_id": 44}, {"version_id": 43}] + + def history_state(_target, version_id): + return {42: administrator, 43: converged, 44: administrator}[version_id] + + def api(method, endpoint, *, body=None): + nonlocal get_count + if method == "GET" and endpoint == target.endpoint: + get_count += 1 + if get_count <= 2: + return first + if get_count <= 4: + return converged + return administrator + if method == "PUT" and endpoint == target.endpoint: + assert body is not None + put_bodies.append(body) + return {} + raise AssertionError((method, endpoint)) + + monkeypatch.setattr(module, "_assert_current_main", assert_main) + monkeypatch.setattr(module, "_gh_api_list", history) + monkeypatch.setattr(module, "_history_version_state", history_state) + monkeypatch.setattr(module, "_gh_api", api) + + with pytest.raises( + module.RulesetGovernanceError, + match="restored newest displaced administrator state", + ): + module._reconcile_target( + target, + verify_only=False, + expected_main_sha="a" * 40, + ) + + assert main_checks == 2 + assert history_reads == 2 + assert put_bodies == [desired, module._editable_projection(administrator)] + + +def test_ambiguous_recovery_rejects_history_rewrite_after_settlement(monkeypatch) -> None: + """A history rewrite after settlement is detected before restored state is trusted.""" + + module = load_module() + target = repository_target(module) + current = live_payload() + displaced = {**current, "name": "Administrator predecessor", "enforcement": "evaluate"} + rewritten = {**current, "name": "Newer administrator state"} + put_count = 0 + + def history_state(_target, version_id): + if version_id == 9: + return displaced + if version_id == 11: + return rewritten + raise AssertionError(version_id) + + def api(method, endpoint, *, body=None): + nonlocal put_count + if method == "GET" and endpoint == target.endpoint: + return current + if method == "PUT" and endpoint == target.endpoint: + put_count += 1 + raise subprocess.TimeoutExpired(cmd=["gh", "api"], timeout=30) + raise AssertionError((method, endpoint)) + + monkeypatch.setattr(module, "_history_version_state", history_state) + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + monkeypatch.setattr(module, "_gh_api", api) + monkeypatch.setattr( + module, + "_settle_ambiguous_recovery_history", + lambda *_args, **_kwargs: [{"version_id": 11}, {"version_id": 10}], + ) + + with pytest.raises( + module.RulesetGovernanceError, + match="ambiguous ruleset recovery PUT left a newer state; refusing overwrite", + ): + module._recover_displaced_history_state( + target, + current_version=10, + current_payload=module._editable_projection(current), + displaced_version=9, + expected_main_sha="a" * 40, + ) + + assert put_count == 1 diff --git a/tests/test_ruleset_governance_reconciliation.py b/tests/test_ruleset_governance_reconciliation.py new file mode 100644 index 0000000000..626b98f63d --- /dev/null +++ b/tests/test_ruleset_governance_reconciliation.py @@ -0,0 +1,530 @@ +"""Regression tests for ruleset owner-plane reconciliation.""" + +from __future__ import annotations + +import importlib.util +import json +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +ROOT = Path(__file__).parents[1] +SCRIPT = ROOT / "scripts" / "ci" / "reconcile_ruleset_governance.py" +SPEC = importlib.util.spec_from_file_location("ruleset_governance", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +module = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = module +SPEC.loader.exec_module(module) + + +def target(scope: str = "repository"): + """Build one valid target for unit tests.""" + + if scope == "organization": + return module.RulesetTarget( + "organization", + "ContextualWisdomLab", + None, + 18156473, + "CWL Central required workflows", + ) + return module.RulesetTarget( + "repository", + "ContextualWisdomLab", + ".github", + 17921150, + "Lock default branch", + ) + + +def live_payload(scope: str = "repository") -> dict[str, object]: + """Build a realistic live ruleset payload with deliberate governance drift.""" + + item = target(scope) + return { + "id": item.ruleset_id, + "name": item.name, + "target": "branch", + "source_type": item.source_type, + "source": item.source, + "enforcement": "active", + "bypass_actors": [ + {"actor_id": None, "actor_type": "OrganizationAdmin", "bypass_mode": "always"} + ], + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, + "rules": [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 1 if scope == "organization" else 0, + "dismiss_stale_reviews_on_push": True, + "required_reviewers": [], + "require_code_owner_review": False, + "require_last_push_approval": False, + "required_review_thread_resolution": True, + "require_extra_approval_for_unattributed_changes": True, + "allowed_merge_methods": ["merge", "squash", "rebase"], + }, + }, + ], + "node_id": "server-managed", + } + + +def write_manifest(tmp_path: Path, payload: dict[str, object]) -> Path: + """Write one manifest payload for validation tests.""" + + path = tmp_path / "manifest.json" + path.write_text(json.dumps(payload), encoding="utf-8") + return path + + +def valid_manifest() -> dict[str, object]: + """Return the reviewed two-target manifest shape.""" + + return { + "schema_version": 1, + "organization": "ContextualWisdomLab", + "targets": [ + { + "scope": "repository", + "owner": "ContextualWisdomLab", + "repository": ".github", + "ruleset_id": 17921150, + "name": "Lock default branch", + }, + { + "scope": "organization", + "owner": "ContextualWisdomLab", + "repository": None, + "ruleset_id": 18156473, + "name": "CWL Central required workflows", + }, + ], + } + + +def test_target_endpoints_and_identity_properties() -> None: + """Endpoint/source derivation is exact for both supported ownership scopes.""" + + repo = target() + org = target("organization") + assert repo.endpoint == "repos/ContextualWisdomLab/.github/rulesets/17921150" + assert repo.source == "ContextualWisdomLab/.github" + assert repo.source_type == "Repository" + assert org.endpoint == "orgs/ContextualWisdomLab/rulesets/18156473" + assert org.source == "ContextualWisdomLab" + assert org.source_type == "Organization" + + +def test_load_manifest_accepts_only_exact_reviewed_shape(tmp_path: Path) -> None: + """A valid manifest yields exactly one repository and one organization target.""" + + targets = module.load_manifest(write_manifest(tmp_path, valid_manifest())) + assert [item.scope for item in targets] == ["repository", "organization"] + + +@pytest.mark.parametrize( + ("mutator", "message"), + [ + (lambda payload: payload.update(extra=True), "unexpected key set"), + (lambda payload: payload.update(schema_version=2), "schema or organization"), + (lambda payload: payload.update(organization="other"), "schema or organization"), + (lambda payload: payload.update(targets={}), "targets must be an array"), + (lambda payload: payload.update(targets=[]), "exactly two"), + (lambda payload: payload["targets"][0].update(extra=True), "unexpected key set"), + (lambda payload: payload["targets"][0].update(scope="enterprise"), "scope is unsupported"), + (lambda payload: payload["targets"][0].update(owner="other"), "identity is invalid"), + (lambda payload: payload["targets"][0].update(ruleset_id=True), "identity is invalid"), + (lambda payload: payload["targets"][0].update(ruleset_id=0), "identity is invalid"), + (lambda payload: payload["targets"][0].update(name=""), "name is invalid"), + (lambda payload: payload["targets"][1].update(repository="oops"), "repository must be null"), + (lambda payload: payload["targets"][0].update(repository=None), "must be non-empty"), + ( + lambda payload: payload["targets"].__setitem__(1, dict(payload["targets"][0])), + "duplicate ruleset target", + ), + ( + lambda payload: payload["targets"].__setitem__( + 1, + { + **payload["targets"][0], + "ruleset_id": 999, + "name": "Other repo rule", + }, + ), + "one target per supported scope", + ), + ], +) +def test_load_manifest_rejects_unsafe_shapes(tmp_path: Path, mutator, message: str) -> None: + """Malformed or ambiguously owned ruleset manifests fail closed.""" + + payload = valid_manifest() + mutator(payload) + with pytest.raises(module.RulesetGovernanceError, match=message): + module.load_manifest(write_manifest(tmp_path, payload)) + + +def test_plain_helpers_reject_behavior_bearing_containers() -> None: + """Only built-in JSON container types cross the trust boundary.""" + + class DictSubclass(dict): + pass + + class ListSubclass(list): + pass + + with pytest.raises(module.RulesetGovernanceError, match="object"): + module._plain_dict(DictSubclass(), field="x") + with pytest.raises(module.RulesetGovernanceError, match="array"): + module._plain_list(ListSubclass(), field="x") + + +def test_desired_payload_preserves_unrelated_controls_and_removes_drift() -> None: + """Only governance fields change while every unrelated live rule survives.""" + + live = live_payload("organization") + desired = module._desired_payload(live, target("organization")) + assert desired["bypass_actors"] == [] + assert desired["conditions"] == live["conditions"] + assert desired["rules"][0] == {"type": "deletion"} + params = desired["rules"][2]["parameters"] + assert params["required_approving_review_count"] == 0 + assert params["require_code_owner_review"] is False + assert params["require_last_push_approval"] is False + assert params["required_reviewers"] == [] + assert params["allowed_merge_methods"] == ["merge", "squash"] + assert params["dismiss_stale_reviews_on_push"] is True + assert live["bypass_actors"] + assert "node_id" not in desired + + +@pytest.mark.parametrize("field", ["id", "name", "target", "source_type", "source", "enforcement"]) +def test_desired_payload_rejects_identity_drift(field: str) -> None: + """A renamed, re-scoped, disabled, or replaced live ruleset is never overwritten.""" + + live = live_payload() + live[field] = "wrong" + with pytest.raises(module.RulesetGovernanceError, match="identity drift"): + module._desired_payload(live, target()) + + +def test_projection_requires_all_editable_fields_and_container_shapes() -> None: + """Missing or behavior-bearing update fields fail before an API mutation.""" + + live = live_payload() + del live["rules"] + with pytest.raises(module.RulesetGovernanceError, match="misses editable fields"): + module._editable_projection(live) + live = live_payload() + live["bypass_actors"] = {} + with pytest.raises(module.RulesetGovernanceError, match="bypass_actors must be an array"): + module._editable_projection(live) + live = live_payload() + live["conditions"] = [] + with pytest.raises(module.RulesetGovernanceError, match="conditions must be an object"): + module._editable_projection(live) + live = live_payload() + live["rules"] = {} + with pytest.raises(module.RulesetGovernanceError, match="rules must be an array"): + module._editable_projection(live) + + +def test_desired_payload_requires_exactly_one_pull_request_rule() -> None: + """Absent or duplicate pull-request controls cannot be guessed during reconciliation.""" + + live = live_payload() + live["rules"] = [{"type": "deletion"}] + with pytest.raises(module.RulesetGovernanceError, match="exactly one"): + module._desired_payload(live, target()) + live = live_payload() + live["rules"].append(copy_rule := dict(live["rules"][2])) + assert copy_rule["type"] == "pull_request" + with pytest.raises(module.RulesetGovernanceError, match="exactly one"): + module._desired_payload(live, target()) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("required_approving_review_count", True), + ("require_code_owner_review", 0), + ("require_last_push_approval", 0), + ("required_reviewers", {}), + ("allowed_merge_methods", {}), + ], +) +def test_desired_payload_rejects_malformed_pull_request_parameters(field: str, value) -> None: + """Typed GitHub pull-request parameters are validated before modification.""" + + live = live_payload() + live["rules"][2]["parameters"][field] = value + with pytest.raises(module.RulesetGovernanceError, match=field): + module._desired_payload(live, target()) + + +def test_desired_payload_rejects_non_object_parameters() -> None: + """The pull-request parameter object cannot be replaced by another JSON type.""" + + live = live_payload() + live["rules"][2]["parameters"] = [] + with pytest.raises(module.RulesetGovernanceError, match="must be an object"): + module._desired_payload(live, target()) + + +def test_reconcile_target_skips_already_converged_state(monkeypatch) -> None: + """An already compliant live ruleset performs no write in either mode.""" + + live = live_payload() + live = {**live, **module._desired_payload(live, target())} + calls = [] + + def fake_api(method, endpoint, *, body=None): + calls.append((method, endpoint, body)) + return live + + monkeypatch.setattr(module, "_gh_api", fake_api) + assert module._reconcile_target(target(), verify_only=False) is False + assert calls == [("GET", target().endpoint, None)] + + +def test_verify_only_fails_on_drift_without_writing(monkeypatch) -> None: + """Verification mode reports drift and never sends an update request.""" + + calls = [] + + def fake_api(method, endpoint, *, body=None): + calls.append((method, endpoint, body)) + return live_payload() + + monkeypatch.setattr(module, "_gh_api", fake_api) + with pytest.raises(module.RulesetGovernanceError, match="governance drift remains"): + module._reconcile_target(target(), verify_only=True) + assert [item[0] for item in calls] == ["GET"] + + +def test_apply_rechecks_for_concurrent_drift_before_put(monkeypatch) -> None: + """A settings race aborts rather than overwriting another administrator's change.""" + + first = live_payload() + second = live_payload() + second["conditions"] = {"ref_name": {"include": ["refs/heads/reviewed"], "exclude": []}} + replies = iter([first, second]) + monkeypatch.setattr(module, "_gh_api", lambda *args, **kwargs: next(replies)) + with pytest.raises(module.RulesetGovernanceError, match="changed concurrently"): + module._reconcile_target(target(), verify_only=False) + + +def test_apply_mutates_once_and_verifies_exact_convergence(monkeypatch) -> None: + """A stable target is updated once and post-write live state must equal the reviewed body.""" + + first = live_payload() + desired = module._desired_payload(first, target()) + converged = {**first, **desired} + calls = [] + replies = iter([first, first, {}, converged]) + + def fake_api(method, endpoint, *, body=None): + calls.append((method, endpoint, body)) + return next(replies) + + monkeypatch.setattr(module, "_gh_api", fake_api) + assert module._reconcile_target(target(), verify_only=False) is True + assert [item[0] for item in calls] == ["GET", "GET", "PUT", "GET"] + assert calls[2][2] == desired + + +def test_apply_rejects_post_write_nonconvergence(monkeypatch) -> None: + """A successful HTTP update is not accepted until the full editable payload converges.""" + + first = live_payload() + replies = iter([first, first, {}, first]) + monkeypatch.setattr(module, "_gh_api", lambda *args, **kwargs: next(replies)) + with pytest.raises(module.RulesetGovernanceError, match="did not converge"): + module._reconcile_target(target(), verify_only=False) + + +def test_apply_rejects_post_write_identity_replacement(monkeypatch) -> None: + """A ruleset replacement after PUT fails the exact-identity verification.""" + + first = live_payload() + replaced = live_payload() + replaced["name"] = "replacement" + replies = iter([first, first, {}, replaced]) + monkeypatch.setattr(module, "_gh_api", lambda *args, **kwargs: next(replies)) + with pytest.raises(module.RulesetGovernanceError, match="identity drift"): + module._reconcile_target(target(), verify_only=False) + + +def test_reconcile_mutation_requires_exact_protected_main_sha(monkeypatch) -> None: + """The callable mutation boundary cannot bypass the protected-main guard.""" + + monkeypatch.setattr( + module, + "_reconcile_target", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("must not mutate")), + ) + targets = (target("repository"),) + with pytest.raises(module.RulesetGovernanceError, match="required for mutation"): + module.reconcile(targets, verify_only=False) + with pytest.raises(module.RulesetGovernanceError, match="malformed"): + module.reconcile(targets, verify_only=False, expected_main_sha="main") + + +def test_reconcile_orders_repository_before_organization(monkeypatch) -> None: + """The strengthening repository mutation precedes the organization approval change.""" + + seen = [] + monkeypatch.setattr( + module, + "_reconcile_target", + lambda item, verify_only, expected_main_sha=None: seen.append( + (item.scope, verify_only, expected_main_sha) + ) + or True, + ) + targets = (target("organization"), target("repository")) + expected_main_sha = "a" * 40 + assert ( + module.reconcile( + targets, + verify_only=False, + expected_main_sha=expected_main_sha, + ) + == 2 + ) + assert seen == [ + ("repository", False, expected_main_sha), + ("organization", False, expected_main_sha), + ] + + +def test_gh_api_uses_versioned_stdin_body_and_redacts_failure(monkeypatch) -> None: + """REST calls pin the API version, send JSON on stdin, and hide subprocess diagnostics.""" + + observed = {} + + def fake_run(command, **kwargs): + observed["command"] = command + observed.update(kwargs) + return SimpleNamespace(returncode=0, stdout='{"ok":true}', stderr="") + + monkeypatch.setattr(subprocess, "run", fake_run) + assert module._gh_api("PUT", "endpoint", body={"x": 1}) == {"ok": True} + assert f"X-GitHub-Api-Version: {module.API_VERSION}" in observed["command"] + assert observed["input"] == '{"x":1}' + assert "--input" in observed["command"] + + monkeypatch.setattr( + subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + returncode=1, stdout="token-like-output", stderr="secret-like-error" + ), + ) + with pytest.raises(module.RulesetGovernanceError, match="GitHub API request failed") as caught: + module._gh_api("GET", "endpoint") + assert "secret-like-error" not in str(caught.value) + + +def test_main_validation_needs_no_token_and_live_modes_do(tmp_path: Path, monkeypatch, capsys) -> None: + """PR validation is offline; live mutation additionally requires an exact main guard.""" + + manifest = write_manifest(tmp_path, valid_manifest()) + monkeypatch.delenv("GH_TOKEN", raising=False) + assert module.main(["--manifest", str(manifest), "--validate-only"]) == 0 + assert "validated 2" in capsys.readouterr().out + with pytest.raises(module.RulesetGovernanceError, match="GH_TOKEN is required"): + module.main(["--manifest", str(manifest)]) + + monkeypatch.setenv("GH_TOKEN", "not-printed") + monkeypatch.setattr( + module, + "reconcile", + lambda targets, verify_only, expected_main_sha=None: 0, + ) + assert module.main(["--manifest", str(manifest), "--verify-only"]) == 0 + assert "verified 2" in capsys.readouterr().out + with pytest.raises(module.RulesetGovernanceError, match="expected protected main SHA"): + module.main(["--manifest", str(manifest)]) + assert ( + module.main( + [ + "--manifest", + str(manifest), + "--expected-main-sha", + "a" * 40, + ] + ) + == 0 + ) + assert "reconciled 2" in capsys.readouterr().out + + +def test_cli_entrypoint_reports_success_and_failure(tmp_path: Path) -> None: + """The executable entry point maps validation success and unsafe input to exit status.""" + + valid = write_manifest(tmp_path, valid_manifest()) + success = subprocess.run( + [sys.executable, str(SCRIPT), "--manifest", str(valid), "--validate-only"], + check=False, + capture_output=True, + text=True, + ) + assert success.returncode == 0 + assert "validated 2" in success.stdout + + invalid = tmp_path / "invalid.json" + invalid.write_text("{", encoding="utf-8") + failure = subprocess.run( + [sys.executable, str(SCRIPT), "--manifest", str(invalid), "--validate-only"], + check=False, + capture_output=True, + text=True, + ) + assert failure.returncode == 1 + assert "ruleset governance reconciliation failed" in failure.stderr + + +def test_cli_function_maps_main_result_and_expected_failures(monkeypatch, capsys) -> None: + """The testable CLI boundary exits cleanly and redacts expected failures.""" + + monkeypatch.setattr(module, "main", lambda: 0) + with pytest.raises(SystemExit) as success: + module.cli() + assert success.value.code == 0 + + def fail(): + raise module.RulesetGovernanceError("unsafe") + + monkeypatch.setattr(module, "main", fail) + with pytest.raises(SystemExit) as failure: + module.cli() + assert failure.value.code == 1 + assert "ruleset governance reconciliation failed: unsafe" in capsys.readouterr().err + + +def test_workflow_separates_unprivileged_validation_from_owner_plane_apply() -> None: + """Only trusted main plus an explicit enable flag can enter the privileged environment.""" + + workflow = (ROOT / ".github" / "workflows" / "ruleset-governance-reconcile.yml").read_text( + encoding="utf-8" + ) + assert "pull_request:" in workflow + assert "schedule:" in workflow + assert 'github.ref == \'refs/heads/main\'' in workflow + assert "vars.CWL_RULESET_RECONCILE_ENABLED == 'true'" in workflow + assert "environment: ruleset-governance-maintenance" in workflow + assert "secrets.CWL_RULESET_ADMIN_TOKEN" in workflow + assert "--validate-only" in workflow + assert "--verify-only" in workflow + assert "permissions:\n contents: read" in workflow + assert "persist-credentials: false" in workflow + assert "actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0" in workflow + assert "actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97" in workflow diff --git a/tests/test_ruleset_governance_review_regressions.py b/tests/test_ruleset_governance_review_regressions.py new file mode 100644 index 0000000000..a17fa80c69 --- /dev/null +++ b/tests/test_ruleset_governance_review_regressions.py @@ -0,0 +1,564 @@ +"""Regressions for adversarial review findings on ruleset reconciliation.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).parents[1] +SCRIPT = ROOT / "scripts" / "ci" / "reconcile_ruleset_governance.py" +WORKFLOW = ROOT / ".github" / "workflows" / "ruleset-governance-reconcile.yml" +DOCTORING = ROOT / "docs" / "doctoring" / "ruleset-owner-plane-reconciliation.md" +SPEC = importlib.util.spec_from_file_location("ruleset_governance_review", SCRIPT) +assert SPEC is not None and SPEC.loader is not None +module = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = module +SPEC.loader.exec_module(module) + + +def _target(): + """Return the reviewed repository ruleset target used by race tests.""" + + return module.RulesetTarget( + "repository", + "ContextualWisdomLab", + ".github", + 17921150, + "Lock default branch", + ) + + +def _live() -> dict[str, object]: + """Return one live ruleset with only the reviewed merge-method drift.""" + + target = _target() + return { + "id": target.ruleset_id, + "name": target.name, + "target": "branch", + "source_type": target.source_type, + "source": target.source, + "enforcement": "active", + "bypass_actors": [ + {"actor_id": None, "actor_type": "OrganizationAdmin", "bypass_mode": "always"} + ], + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, + "rules": [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": True, + "required_reviewers": [], + "require_code_owner_review": False, + "require_last_push_approval": False, + "required_review_thread_resolution": True, + "require_extra_approval_for_unattributed_changes": True, + "allowed_merge_methods": ["merge", "squash", "rebase"], + }, + }, + ], + } + + +def _converged() -> dict[str, object]: + """Return the reviewed desired state for the repository target.""" + + live = _live() + return {**live, **module._desired_payload(live, _target())} + + +def _desired() -> dict[str, object]: + """Return exactly the editable payload submitted to GitHub PUT.""" + + return module._desired_payload(_live(), _target()) + + +def _manifest(tmp_path: Path) -> Path: + """Write the exact two-target manifest accepted by the production parser.""" + + path = tmp_path / "manifest.json" + path.write_text( + json.dumps( + { + "schema_version": 1, + "organization": "ContextualWisdomLab", + "targets": [ + { + "scope": "repository", + "owner": "ContextualWisdomLab", + "repository": ".github", + "ruleset_id": 17921150, + "name": "Lock default branch", + }, + { + "scope": "organization", + "owner": "ContextualWisdomLab", + "repository": None, + "ruleset_id": 18156473, + "name": "CWL Central required workflows", + }, + ], + } + ), + encoding="utf-8", + ) + return path + + +def test_stale_main_revision_fails_before_any_ruleset_mutation(monkeypatch) -> None: + """A resumed owner-plane run cannot apply policy from an obsolete main SHA.""" + + calls: list[str] = [] + monkeypatch.setattr( + module, + "_gh_api", + lambda method, endpoint, **kwargs: calls.append(method) or _live(), + ) + monkeypatch.setattr(module, "_current_main_sha", lambda: "b" * 40) + + with pytest.raises(module.RulesetGovernanceError, match="protected main advanced"): + module._reconcile_target( + _target(), + verify_only=False, + expected_main_sha="a" * 40, + ) + assert calls == ["GET"] + + +def test_current_main_guard_covers_live_success_and_malformed_evidence(monkeypatch) -> None: + """The live ref reader accepts one exact SHA and rejects malformed ref evidence.""" + + monkeypatch.setattr( + module, + "_gh_api", + lambda *_args, **_kwargs: {"object": {"sha": "A" * 40}}, + ) + assert module._current_main_sha() == "a" * 40 + module._assert_current_main("a" * 40) + + with pytest.raises(module.RulesetGovernanceError, match="expected protected main SHA"): + module._assert_current_main("BAD") + + monkeypatch.setattr( + module, + "_gh_api", + lambda *_args, **_kwargs: {"object": {"sha": "not-a-sha"}}, + ) + with pytest.raises(module.RulesetGovernanceError, match="malformed SHA"): + module._current_main_sha() + + monkeypatch.setattr(module, "_gh_api", lambda *_args, **_kwargs: {"object": []}) + with pytest.raises(module.RulesetGovernanceError, match="main ref object"): + module._current_main_sha() + + +def test_current_main_is_rechecked_around_put_and_after_convergence(monkeypatch) -> None: + """Stable protected main is checked before final read, PUT, and completion.""" + + first = _live() + desired = _converged() + replies = iter([first, first, {}, desired]) + checks: list[str] = [] + monkeypatch.setattr(module, "_gh_api", lambda *args, **kwargs: next(replies)) + monkeypatch.setattr(module, "_current_main_sha", lambda: checks.append("main") or "a" * 40) + monkeypatch.setattr(module, "_latest_history_version", lambda _target: 1) + monkeypatch.setattr( + module, + "_verify_ruleset_history_transition", + lambda _target, _baseline, _desired, **_kwargs: None, + ) + + assert module._reconcile_target( + _target(), + verify_only=False, + expected_main_sha="a" * 40, + ) is True + assert checks == ["main", "main", "main"] + + +def test_ruleset_target_exposes_history_endpoints() -> None: + """Collision evidence is fetched from the exact target's immutable history surface.""" + + target = _target() + assert target.history_endpoint == "repos/ContextualWisdomLab/.github/rulesets/17921150/history" + assert target.history_version_endpoint(7).endswith("/history/7") + with pytest.raises(module.RulesetGovernanceError, match="version identity is malformed"): + target.history_version_endpoint(0) + + +def test_history_transport_and_version_state_are_strict(monkeypatch) -> None: + """History arrays and version states cross typed, exact-identity trust boundaries.""" + + monkeypatch.setattr(module, "_run_gh_json", lambda *_args, **_kwargs: [{"version_id": 1}]) + assert module._gh_api_list("GET", "history") == [{"version_id": 1}] + monkeypatch.setattr(module, "_run_gh_json", lambda *_args, **_kwargs: {}) + with pytest.raises(module.RulesetGovernanceError, match="must be an array"): + module._gh_api_list("GET", "history") + + monkeypatch.setattr(module, "_gh_api", lambda *_args, **_kwargs: {"state": _live()}) + assert module._history_version_state(_target(), 4) == _live() + monkeypatch.setattr(module, "_gh_api", lambda *_args, **_kwargs: {"state": []}) + with pytest.raises(module.RulesetGovernanceError, match="version state must be an object"): + module._history_version_state(_target(), 4) + + +def test_latest_history_version_rejects_missing_or_malformed_history(monkeypatch) -> None: + """Mutation never begins without one trustworthy pre-write history version.""" + + monkeypatch.setattr(module, "_gh_api_list", lambda *_args, **_kwargs: []) + with pytest.raises(module.RulesetGovernanceError, match="ruleset history is empty"): + module._latest_history_version(_target()) + + for invalid in (True, 0): + monkeypatch.setattr( + module, + "_gh_api_list", + lambda *_args, invalid=invalid, **_kwargs: [{"version_id": invalid}], + ) + with pytest.raises(module.RulesetGovernanceError, match="version identity is malformed"): + module._latest_history_version(_target()) + + monkeypatch.setattr(module, "_gh_api_list", lambda *_args, **_kwargs: [[]]) + with pytest.raises(module.RulesetGovernanceError, match="history entry must be an object"): + module._latest_history_version(_target()) + + monkeypatch.setattr(module, "_gh_api_list", lambda *_args, **_kwargs: [{"version_id": 12}]) + assert module._latest_history_version(_target()) == 12 + + +def test_history_transition_accepts_exactly_one_new_reviewed_version(monkeypatch) -> None: + """One new version whose predecessor is the baseline proves no hidden pre-PUT edit.""" + + monkeypatch.setattr( + module, + "_gh_api_list", + lambda *_args, **_kwargs: [{"version_id": 8}, {"version_id": 7}], + ) + monkeypatch.setattr(module, "_history_version_state", lambda _target, version: _converged()) + module._verify_ruleset_history_transition(_target(), 7, _desired()) + + +def test_history_transition_rejects_incomplete_or_inconsistent_evidence(monkeypatch) -> None: + """Missing predecessor, absent version advance, or mismatched latest state fail closed.""" + + desired = _desired() + monkeypatch.setattr(module, "_gh_api_list", lambda *_args, **_kwargs: [{"version_id": 8}]) + with pytest.raises(module.RulesetGovernanceError, match="did not expose a predecessor"): + module._verify_ruleset_history_transition(_target(), 7, desired) + + monkeypatch.setattr( + module, + "_gh_api_list", + lambda *_args, **_kwargs: [{"version_id": 7}, {"version_id": 6}], + ) + with pytest.raises(module.RulesetGovernanceError, match="not visible in history"): + module._verify_ruleset_history_transition(_target(), 7, desired) + + monkeypatch.setattr( + module, + "_gh_api_list", + lambda *_args, **_kwargs: [{"version_id": 8}, {"version_id": 7}], + ) + monkeypatch.setattr(module, "_history_version_state", lambda *_args, **_kwargs: _live()) + with pytest.raises( + module.RulesetMutationStillSettlingError, + match="latest ruleset history does not match reviewed mutation; history changed before the reviewed mutation became visible", + ): + module._verify_ruleset_history_transition(_target(), 7, desired) + + +def test_history_collision_restores_immediate_predecessor_before_failing(monkeypatch) -> None: + """A hidden pre-PUT administrator edit is restored and history-proven before failing.""" + + desired_state = _converged() + desired = _desired() + external = _live() + external["conditions"] = {"ref_name": {"include": ["refs/heads/reviewed"], "exclude": []}} + histories = iter( + [ + [{"version_id": 10}, {"version_id": 9}, {"version_id": 7}], + [{"version_id": 11}, {"version_id": 10}], + ] + ) + monkeypatch.setattr(module, "_gh_api_list", lambda *_args, **_kwargs: next(histories)) + monkeypatch.setattr( + module, + "_history_version_state", + lambda _target, version: desired_state if version == 10 else external, + ) + calls: list[tuple[str, object | None]] = [] + replies = iter([desired_state, {}, external]) + + def fake_api(method, endpoint, *, body=None): + calls.append((method, body)) + return next(replies) + + monkeypatch.setattr(module, "_gh_api", fake_api) + with pytest.raises(module.RulesetGovernanceError, match="restored newest displaced administrator state"): + module._verify_ruleset_history_transition(_target(), 7, desired) + assert [method for method, _body in calls] == ["GET", "PUT", "GET"] + assert calls[1][1] == module._editable_projection(external) + + +def test_history_collision_does_not_overwrite_a_newer_post_put_admin_edit(monkeypatch) -> None: + """If live state advanced again after our PUT, collision recovery preserves that newer state.""" + + desired_state = _converged() + desired = _desired() + external = _live() + newer = _live() + newer["conditions"] = {"ref_name": {"include": ["refs/heads/newer"], "exclude": []}} + monkeypatch.setattr( + module, + "_gh_api_list", + lambda *_args, **_kwargs: [{"version_id": 10}, {"version_id": 9}], + ) + monkeypatch.setattr( + module, + "_history_version_state", + lambda _target, version: desired_state if version == 10 else external, + ) + monkeypatch.setattr(module, "_gh_api", lambda *_args, **_kwargs: newer) + with pytest.raises(module.RulesetGovernanceError, match="advanced again"): + module._verify_ruleset_history_transition(_target(), 7, desired) + + +def test_history_collision_requires_rollback_convergence(monkeypatch) -> None: + """A failed predecessor restore is surfaced rather than treated as collision recovery.""" + + desired_state = _converged() + desired = _desired() + external = _live() + external["conditions"] = {"ref_name": {"include": ["refs/heads/external"], "exclude": []}} + histories = iter( + [ + [{"version_id": 10}, {"version_id": 9}], + [{"version_id": 11}, {"version_id": 10}], + ] + ) + monkeypatch.setattr(module, "_gh_api_list", lambda *_args, **_kwargs: next(histories)) + monkeypatch.setattr( + module, + "_history_version_state", + lambda _target, version: desired_state if version == 10 else external, + ) + replies = iter([desired_state, {}, desired_state]) + monkeypatch.setattr(module, "_gh_api", lambda *_args, **_kwargs: next(replies)) + with pytest.raises(module.RulesetGovernanceError, match="rollback did not converge"): + module._verify_ruleset_history_transition(_target(), 7, desired) + + +def test_history_collision_recovers_admin_write_between_recovery_get_and_put(monkeypatch) -> None: + """A second administrator version displaced by rollback becomes the next restore target.""" + + desired_state = _converged() + desired = _desired() + first_admin = _live() + first_admin["conditions"] = {"ref_name": {"include": ["refs/heads/first-admin"], "exclude": []}} + second_admin = _live() + second_admin["conditions"] = {"ref_name": {"include": ["refs/heads/second-admin"], "exclude": []}} + histories = iter( + [ + [{"version_id": 10}, {"version_id": 9}, {"version_id": 7}], + [{"version_id": 12}, {"version_id": 11}], + [{"version_id": 13}, {"version_id": 12}], + ] + ) + monkeypatch.setattr(module, "_gh_api_list", lambda *_args, **_kwargs: next(histories)) + + states = { + 9: first_admin, + 10: desired_state, + 11: second_admin, + 12: first_admin, + 13: second_admin, + } + monkeypatch.setattr(module, "_history_version_state", lambda _target, version: states[version]) + + calls: list[tuple[str, object | None]] = [] + replies = iter([desired_state, {}, first_admin, first_admin, {}, second_admin]) + + def fake_api(method, endpoint, *, body=None): + calls.append((method, body)) + return next(replies) + + monkeypatch.setattr(module, "_gh_api", fake_api) + with pytest.raises(module.RulesetGovernanceError, match="restored newest displaced administrator state"): + module._verify_ruleset_history_transition(_target(), 7, desired) + + put_bodies = [body for method, body in calls if method == "PUT"] + assert put_bodies == [ + module._editable_projection(first_admin), + module._editable_projection(second_admin), + ] + + +def test_history_collision_with_bad_predecessor_metadata_fails_before_restore(monkeypatch) -> None: + """Malformed collision history cannot be interpreted as a safe rollback target.""" + + desired_state = _converged() + desired = _desired() + monkeypatch.setattr( + module, + "_gh_api_list", + lambda *_args, **_kwargs: [{"version_id": 10}, {"version_id": True}], + ) + monkeypatch.setattr(module, "_history_version_state", lambda *_args, **_kwargs: desired_state) + monkeypatch.setattr( + module, + "_gh_api", + lambda *_args, **_kwargs: (_ for _ in ()).throw(AssertionError("no live mutation expected")), + ) + with pytest.raises(module.RulesetGovernanceError, match="version identity is malformed"): + module._verify_ruleset_history_transition(_target(), 7, desired) + + +def test_history_collision_aborts_if_main_advances_before_recovery(monkeypatch) -> None: + """Before rollback writes, a protected-main advance still vetoes privileged recovery.""" + + desired_state = _converged() + desired = _desired() + external = _live() + monkeypatch.setattr( + module, + "_gh_api_list", + lambda *_args, **_kwargs: [{"version_id": 10}, {"version_id": 9}, {"version_id": 7}], + ) + monkeypatch.setattr( + module, + "_history_version_state", + lambda _target, version: desired_state if version == 10 else external, + ) + monkeypatch.setattr( + module, + "_assert_current_main", + lambda _expected: (_ for _ in ()).throw( + module.RulesetGovernanceError("protected main advanced") + ), + ) + + api_calls: list[str] = [] + + def fake_api(method, endpoint, *, body=None): + api_calls.append(method) + if method == "GET" and endpoint == _target().endpoint: + return desired_state + raise AssertionError("no recovery write expected") + + monkeypatch.setattr(module, "_gh_api", fake_api) + with pytest.raises(module.RulesetGovernanceError, match="protected main advanced"): + module._verify_ruleset_history_transition( + _target(), + 7, + desired, + expected_main_sha="a" * 40, + ) + assert api_calls == ["GET"] + + +def test_history_collision_after_our_write_ignores_stale_main_guard_during_recovery(monkeypatch) -> None: + """Once the reviewed PUT exists, recovery must settle collision history even if main moved.""" + + desired_state = _converged() + desired = _desired() + external = _live() + histories = iter( + [ + [{"version_id": 10}, {"version_id": 9}, {"version_id": 7}], + [{"version_id": 11}, {"version_id": 10}], + ] + ) + monkeypatch.setattr(module, "_gh_api_list", lambda *_args, **_kwargs: next(histories)) + monkeypatch.setattr( + module, + "_history_version_state", + lambda _target, version: desired_state if version == 10 else external, + ) + monkeypatch.setattr( + module, + "_assert_current_main", + lambda _expected: (_ for _ in ()).throw(AssertionError("stale-main guard must not run")), + ) + replies = iter([desired_state, {}, external]) + monkeypatch.setattr(module, "_gh_api", lambda *_args, **_kwargs: next(replies)) + + with pytest.raises(module.RulesetGovernanceError, match="restored newest displaced administrator state"): + module._verify_ruleset_history_transition( + _target(), + 7, + desired, + expected_main_sha=None, + ) + + +def test_reconcile_forwards_expected_main_sha_to_each_target(monkeypatch) -> None: + """The multi-target coordinator preserves the protected-main guard on every mutation.""" + + seen: list[tuple[str, bool, str | None]] = [] + + def fake_target(item, *, verify_only, expected_main_sha=None): + seen.append((item.scope, verify_only, expected_main_sha)) + return True + + monkeypatch.setattr(module, "_reconcile_target", fake_target) + assert module.reconcile( + (_target(),), verify_only=False, expected_main_sha="a" * 40 + ) == 1 + assert seen == [("repository", False, "a" * 40)] + + +def test_actions_apply_requires_and_forwards_expected_main_sha(tmp_path, monkeypatch) -> None: + """The privileged Actions CLI path cannot silently omit its protected-main identity.""" + + manifest = _manifest(tmp_path) + monkeypatch.setenv("GH_TOKEN", "protected") + monkeypatch.setenv("GITHUB_ACTIONS", "true") + with pytest.raises(module.RulesetGovernanceError, match="expected protected main SHA"): + module.main(["--manifest", str(manifest)]) + + seen: list[tuple[bool, str | None]] = [] + + def fake_reconcile(targets, *, verify_only, expected_main_sha=None): + assert len(targets) == 2 + seen.append((verify_only, expected_main_sha)) + return 0 + + monkeypatch.setattr(module, "reconcile", fake_reconcile) + assert module.main( + ["--manifest", str(manifest), "--expected-main-sha", "a" * 40] + ) == 0 + assert seen == [(False, "a" * 40)] + + +def test_owner_plane_workflow_serializes_mutation_and_quotes_main_sha() -> None: + """PR validation may supersede itself but owner-plane mutation is non-cancellable.""" + + text = WORKFLOW.read_text(encoding="utf-8") + assert "cancel-in-progress: ${{ github.event_name == 'pull_request' }}" in text + assert "EXPECTED_MAIN_SHA: ${{ github.sha }}" in text + assert '--expected-main-sha "$EXPECTED_MAIN_SHA"' in text + assert "github.event_name != 'pull_request'" in text + + +def test_docs_state_github_has_no_ruleset_put_compare_and_swap() -> None: + """Doctoring must not claim an atomic compare-and-swap GitHub does not provide.""" + + text = DOCTORING.read_text(encoding="utf-8") + assert "does not support conditional unsafe REST updates" in text + assert "ruleset-history" in text + assert "restores the newest displaced administrator state" in text + assert "cannot make the final GET-to-PUT interval atomic" in text + + +def test_module_docstring_does_not_promise_impossible_atomicity() -> None: + """Production documentation must describe best-effort drift checks, not CAS semantics.""" + + assert "never silently overwritten" not in (module.__doc__ or "") \ No newline at end of file diff --git a/tests/test_ruleset_governance_review_round2.py b/tests/test_ruleset_governance_review_round2.py new file mode 100644 index 0000000000..1f5dd740ad --- /dev/null +++ b/tests/test_ruleset_governance_review_round2.py @@ -0,0 +1,349 @@ +"""Second-round and follow-up regressions for ruleset owner-plane reconciliation.""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "scripts" / "ci" / "reconcile_ruleset_governance.py" +WORKFLOW = ROOT / ".github" / "workflows" / "ruleset-governance-reconcile.yml" + + +def load_module(): + """Load the production reconciler from the exact checkout.""" + spec = importlib.util.spec_from_file_location("ruleset_governance_round2", SOURCE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def repository_target(module): + """Return the exact owner-repository target used by the reviewed manifest.""" + return module.RulesetTarget( + scope="repository", + owner="ContextualWisdomLab", + repository=".github", + ruleset_id=17921150, + name="Lock default branch", + ) + + +def repository_payload(*, include_deletion: bool = True) -> dict: + """Return a canonical owner-repository ruleset payload for focused regressions.""" + rules = [] + if include_deletion: + rules.append({"type": "deletion"}) + rules.extend( + [ + {"type": "non_fast_forward"}, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": True, + "required_reviewers": [], + "require_code_owner_review": False, + "require_last_push_approval": False, + "required_review_thread_resolution": True, + "allowed_merge_methods": ["merge", "squash"], + }, + }, + ] + ) + return { + "id": 17921150, + "name": "Lock default branch", + "target": "branch", + "source_type": "Repository", + "source": "ContextualWisdomLab/.github", + "enforcement": "active", + "bypass_actors": [], + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, + "rules": rules, + } + + +def historical_state(*, name: str = "Admin renamed", enforcement: str = "evaluate") -> dict: + """Return a predecessor whose editable identity differs but provenance is unchanged.""" + return { + "id": 17921150, + "name": name, + "target": "branch", + "source_type": "Repository", + "source": "ContextualWisdomLab/.github", + "enforcement": enforcement, + "bypass_actors": [{"actor_id": 5, "actor_type": "Team", "bypass_mode": "pull_request"}], + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, + "rules": [{"type": "non_fast_forward"}], + } + + +def test_history_predecessor_allows_editable_name_and_enforcement(monkeypatch) -> None: + """Collision recovery must be able to restore a legitimate administrator predecessor.""" + module = load_module() + target = repository_target(module) + predecessor = historical_state() + monkeypatch.setattr( + module, + "_gh_api", + lambda method, endpoint, **_kwargs: {"state": predecessor} + if method == "GET" and endpoint == target.history_version_endpoint(7) + else (_ for _ in ()).throw(AssertionError((method, endpoint))), + ) + + assert module._history_version_state(target, 7) == predecessor + + +def test_history_predecessor_still_rejects_wrong_ruleset_provenance(monkeypatch) -> None: + """Relaxing editable fields must never allow a history record from another ruleset.""" + module = load_module() + target = repository_target(module) + predecessor = historical_state() + predecessor["id"] = 999 + monkeypatch.setattr( + module, + "_gh_api", + lambda *_args, **_kwargs: {"state": predecessor}, + ) + + try: + module._history_version_state(target, 7) + except module.RulesetGovernanceError as exc: + assert "identity drift" in str(exc) + else: + raise AssertionError("wrong ruleset provenance was accepted") + + +def test_owner_plane_is_serial_and_disabled_schedule_does_not_consume_runner() -> None: + """Mutation is non-cancellable while disabled hourly validation skips shared capacity.""" + text = WORKFLOW.read_text(encoding="utf-8") + assert "cancel-in-progress: ${{ github.event_name == 'pull_request' }}" in text + validate_block = text.split("\n validate:\n", 1)[1].split("\n apply:\n", 1)[0] + assert "github.event_name != 'schedule'" in validate_block + assert "vars.CWL_RULESET_RECONCILE_ENABLED == 'true'" in validate_block + assert "runs-on: ubuntu-slim" in validate_block + apply_block = text.split("\n apply:\n", 1)[1] + assert "vars.CWL_RULESET_RECONCILE_ENABLED == 'true'" in apply_block + assert "runs-on: ubuntu-24.04" in apply_block + + +def test_missing_empty_required_reviewers_is_normalized_to_declared_empty_list() -> None: + """GitHub may omit an empty reviewer list; desired state must add it instead of aborting.""" + module = load_module() + live = repository_payload() + parameters = live["rules"][-1]["parameters"] + del parameters["required_reviewers"] + + desired = module._desired_payload(live, repository_target(module)) + + assert desired["rules"][-1]["parameters"]["required_reviewers"] == [] + + +def test_desired_payload_forces_stale_review_and_thread_resolution_guards() -> None: + """Live review-safety drift is repaired instead of copied into the update payload.""" + module = load_module() + live = repository_payload() + parameters = live["rules"][-1]["parameters"] + parameters["dismiss_stale_reviews_on_push"] = False + parameters["required_review_thread_resolution"] = False + + desired = module._desired_payload(live, repository_target(module)) + desired_parameters = desired["rules"][-1]["parameters"] + + assert desired_parameters["dismiss_stale_reviews_on_push"] is True + assert desired_parameters["required_review_thread_resolution"] is True + + +def test_recovery_revalidates_protected_main_before_every_recovery_put(monkeypatch) -> None: + """A stale run must stop before a recovery PUT after protected main advances.""" + module = load_module() + target = repository_target(module) + current_state = repository_payload() + current_payload = module._editable_projection(current_state) + displaced_payload = historical_state() + puts: list[dict] = [] + main_checks: list[str] = [] + + monkeypatch.setattr(module, "_history_version_state", lambda *_args: displaced_payload) + + def fake_api(method, endpoint, *, body=None): + if method == "GET" and endpoint == target.endpoint: + return current_state + if method == "PUT" and endpoint == target.endpoint: + puts.append(body) + return {} + raise AssertionError((method, endpoint)) + + def stale_main(expected_sha: str) -> None: + main_checks.append(expected_sha) + raise module.RulesetGovernanceError("protected main advanced") + + monkeypatch.setattr(module, "_gh_api", fake_api) + monkeypatch.setattr(module, "_assert_current_main", stale_main) + + with pytest.raises(module.RulesetGovernanceError, match="protected main advanced"): + module._recover_displaced_history_state( + target, + current_version=10, + current_payload=current_payload, + displaced_version=9, + expected_main_sha="a" * 40, + ) + + assert main_checks == ["a" * 40] + assert puts == [] + + +def test_invalid_json_transport_is_typed_for_read_and_ambiguous_put(monkeypatch) -> None: + """Malformed GitHub JSON is a read error but an ambiguous mutation outcome for PUT.""" + module = load_module() + + def invalid_json(*_args, **_kwargs): + return subprocess.CompletedProcess(args=["gh", "api"], returncode=0, stdout="{", stderr="") + + monkeypatch.setattr(module.subprocess, "run", invalid_json) + with pytest.raises(module.RulesetGovernanceError, match="invalid JSON"): + module._run_gh_json("GET", "repos/ContextualWisdomLab/.github/rulesets/17921150") + with pytest.raises(module.AmbiguousRulesetWriteError, match="response is ambiguous"): + module._run_gh_json( + "PUT", + "repos/ContextualWisdomLab/.github/rulesets/17921150", + body={"name": "Lock default branch"}, + ) + + +def test_successful_recovery_requires_history_predecessor(monkeypatch) -> None: + """A successful recovery PUT is not trusted without an immutable predecessor record.""" + module = load_module() + target = repository_target(module) + current = repository_payload() + displaced = historical_state() + monkeypatch.setattr(module, "_history_version_state", lambda *_args: displaced) + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + monkeypatch.setattr( + module, + "_gh_api", + lambda method, _endpoint, **_kwargs: current if method == "GET" else {}, + ) + monkeypatch.setattr(module, "_gh_api_list", lambda *_args: [{"version_id": 11}]) + + with pytest.raises(module.RulesetGovernanceError, match="history did not expose a predecessor"): + module._recover_displaced_history_state( + target, + current_version=10, + current_payload=module._editable_projection(current), + displaced_version=9, + expected_main_sha="a" * 40, + ) + + +def test_successful_recovery_rejects_mismatched_latest_history_state(monkeypatch) -> None: + """A recovery PUT fails closed when immutable history records a different newest state.""" + module = load_module() + target = repository_target(module) + current = repository_payload() + displaced = historical_state() + unrelated = historical_state(name="Concurrent administrator state") + + def history_state(_target, version_id): + return unrelated if version_id == 11 else displaced + + monkeypatch.setattr(module, "_history_version_state", history_state) + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + monkeypatch.setattr( + module, + "_gh_api", + lambda method, _endpoint, **_kwargs: current if method == "GET" else {}, + ) + monkeypatch.setattr( + module, + "_gh_api_list", + lambda *_args: [{"version_id": 11}, {"version_id": 10}], + ) + + with pytest.raises(module.RulesetGovernanceError, match="latest history does not match restore write"): + module._recover_displaced_history_state( + target, + current_version=10, + current_payload=module._editable_projection(current), + displaced_version=9, + expected_main_sha="a" * 40, + ) + + +def test_ambiguous_put_rejects_history_success_without_live_convergence(monkeypatch) -> None: + """History acceptance alone cannot promote an ambiguous PUT whose live state still differs.""" + module = load_module() + target = repository_target(module) + live = repository_payload() + live["bypass_actors"] = [ + {"actor_id": None, "actor_type": "OrganizationAdmin", "bypass_mode": "always"} + ] + desired = module._desired_payload(live, target) + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + monkeypatch.setattr(module, "_verify_ruleset_history_transition", lambda *_args, **_kwargs: None) + monkeypatch.setattr(module, "_gh_api", lambda *_args, **_kwargs: live) + + with pytest.raises(module.RulesetGovernanceError, match="ambiguous ruleset mutation did not converge"): + module._confirm_ambiguous_put( + target, + baseline_version=10, + desired=desired, + expected_main_sha="a" * 40, + ) + + +def test_ambiguous_put_zero_poll_configuration_still_fails_closed(monkeypatch) -> None: + """A misconfigured empty settlement loop must fail closed rather than imply success.""" + module = load_module() + target = repository_target(module) + desired = module._desired_payload(repository_payload(), target) + monkeypatch.setattr(module, "AMBIGUOUS_WRITE_SETTLEMENT_POLLS", 0) + + with pytest.raises(module.RulesetGovernanceError, match="remains unresolved"): + module._confirm_ambiguous_put( + target, + baseline_version=10, + desired=desired, + expected_main_sha="a" * 40, + ) + + +def test_verify_only_rejects_drift_outside_reconciler_projection(monkeypatch) -> None: + """Canonical audit drift cannot be reported as converged merely because review fields match.""" + module = load_module() + target = repository_target(module) + live = repository_payload(include_deletion=False) + monkeypatch.setattr(module, "_gh_api", lambda *_args, **_kwargs: live) + + with pytest.raises(module.RulesetGovernanceError, match="canonical governance drift"): + module._reconcile_target(target, verify_only=True) + + +def test_focused_workflow_runs_every_permanent_governance_regression_suite() -> None: + """Every permanent audit/reconciler regression participates in path and pytest coverage.""" + text = WORKFLOW.read_text(encoding="utf-8") + expected = ( + "tests/test_ruleset_governance_reconciliation.py", + "tests/test_ruleset_governance_review_regressions.py", + "tests/test_ruleset_governance_review_round2.py", + "tests/test_ruleset_governance_delayed_recovery_regression.py", + "tests/test_ruleset_governance_runtime_budget_regression.py", + "tests/test_ruleset_governance_post_put_cleanup_regression.py", + "tests/test_central_required_workflow_ruleset_audit.py", + "tests/test_ruleset_audit_completeness_regression.py", + "tests/test_ruleset_merge_method_shape_regression.py", + "tests/test_solo_maintainer_ruleset_policy.py", + ) + test_command = text.split("-m pytest -q \\\n", 1)[1].split("\n python -m coverage report", 1)[0] + for path in expected: + assert text.count(f'- "{path}"') >= 2 + assert path in test_command diff --git a/tests/test_ruleset_governance_review_round3.py b/tests/test_ruleset_governance_review_round3.py new file mode 100644 index 0000000000..e52b68cd9a --- /dev/null +++ b/tests/test_ruleset_governance_review_round3.py @@ -0,0 +1,246 @@ +"""Adversarial regressions for the third ruleset-governance review round.""" + +from __future__ import annotations + +import importlib.util +import json +import math +import re +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "scripts" / "ci" / "reconcile_ruleset_governance.py" +WORKFLOW = ROOT / ".github" / "workflows" / "ruleset-governance-reconcile.yml" + + +def load_module(): + """Load the production reconciler from the exact checkout.""" + + spec = importlib.util.spec_from_file_location("ruleset_governance_round3", SOURCE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def repository_target(module): + """Return the documented repository-owned governance target.""" + + return module.RulesetTarget( + scope="repository", + owner="ContextualWisdomLab", + repository=".github", + ruleset_id=17921150, + name="Lock default branch", + ) + + +def live_payload() -> dict: + """Return one live-shaped repository ruleset with deliberate governance drift.""" + + return { + "id": 17921150, + "name": "Lock default branch", + "target": "branch", + "source_type": "Repository", + "source": "ContextualWisdomLab/.github", + "enforcement": "active", + "bypass_actors": [ + {"actor_id": None, "actor_type": "OrganizationAdmin", "bypass_mode": "always"} + ], + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, + "rules": [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": True, + "required_reviewers": [], + "require_code_owner_review": False, + "require_last_push_approval": False, + "required_review_thread_resolution": True, + "allowed_merge_methods": ["merge", "squash", "rebase"], + }, + }, + ], + } + + +def test_nonzero_put_transport_result_is_ambiguous_but_read_failure_is_not(monkeypatch) -> None: + """A failed PUT transport cannot prove server rejection, while read failures stay ordinary errors.""" + + module = load_module() + monkeypatch.setattr( + subprocess, + "run", + lambda *_args, **_kwargs: SimpleNamespace( + returncode=1, + stdout="possibly accepted before connection loss", + stderr="transport failed", + ), + ) + + with pytest.raises(module.AmbiguousRulesetWriteError): + module._run_gh_json("PUT", "repos/ContextualWisdomLab/.github/rulesets/17921150", body={"x": 1}) + with pytest.raises(module.RulesetGovernanceError, match="GitHub API request failed"): + module._run_gh_json("GET", "repos/ContextualWisdomLab/.github/rulesets/17921150") + + +def test_non_timeout_ambiguous_put_routes_through_history_confirmation(monkeypatch) -> None: + """A failed gh PUT result uses the same protected history path as a timeout.""" + + module = load_module() + target = repository_target(module) + first = live_payload() + desired = module._desired_payload(first, target) + converged = {**first, **desired} + get_count = 0 + confirmed: list[tuple] = [] + + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + monkeypatch.setattr(module, "_assert_canonical_governance", lambda *_args: None) + monkeypatch.setattr(module, "_latest_history_version", lambda *_args: 41) + + def fake_api(method, endpoint, *, body=None): + nonlocal get_count + if method == "GET" and endpoint == target.endpoint: + get_count += 1 + return first if get_count <= 2 else converged + if method == "PUT": + raise module.AmbiguousRulesetWriteError("ambiguous transport result") + raise AssertionError((method, endpoint, body)) + + def fake_confirm(seen_target, *, baseline_version, desired, expected_main_sha): + confirmed.append((seen_target, baseline_version, desired, expected_main_sha)) + return converged + + monkeypatch.setattr(module, "_gh_api", fake_api) + monkeypatch.setattr(module, "_confirm_ambiguous_put", fake_confirm) + assert module._reconcile_target( + target, + verify_only=False, + expected_main_sha="a" * 40, + ) is True + assert confirmed == [(target, 41, desired, "a" * 40)] + + +def test_delayed_ambiguous_write_is_observed_after_baseline_only_first_poll(monkeypatch) -> None: + """A first baseline-only history observation cannot be treated as definitive rejection.""" + + module = load_module() + target = repository_target(module) + first = live_payload() + desired = module._desired_payload(first, target) + converged = {**first, **desired} + history_reads = iter( + [ + [{"version_id": 41}, {"version_id": 40}], + [{"version_id": 42}, {"version_id": 41}], + ] + ) + sleeps: list[float] = [] + + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + monkeypatch.setattr(module, "_assert_canonical_governance", lambda *_args: None) + monkeypatch.setattr(module, "_gh_api_list", lambda *_args: next(history_reads)) + monkeypatch.setattr(module, "_history_version_state", lambda _target, version: converged if version == 42 else first) + monkeypatch.setattr(module, "_gh_api", lambda method, endpoint, **_kwargs: converged) + monkeypatch.setattr(module.time, "sleep", lambda seconds: sleeps.append(seconds)) + + observed = module._confirm_ambiguous_put( + target, + baseline_version=41, + desired=desired, + expected_main_sha="a" * 40, + ) + assert module._editable_projection(observed) == desired + assert sleeps + + +def test_baseline_only_settlement_exhaustion_fails_as_unresolved_not_rejected(monkeypatch) -> None: + """A bounded settlement horizon may fail closed, but cannot claim a delayed write was rejected.""" + + module = load_module() + target = repository_target(module) + first = live_payload() + desired = module._desired_payload(first, target) + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + monkeypatch.setattr( + module, + "_gh_api_list", + lambda *_args: [{"version_id": 41}, {"version_id": 40}], + ) + monkeypatch.setattr(module, "_gh_api", lambda method, endpoint, **_kwargs: first) + monkeypatch.setattr(module.time, "sleep", lambda _seconds: None) + + with pytest.raises(module.RulesetGovernanceError, match="outcome remains unresolved"): + module._confirm_ambiguous_put( + target, + baseline_version=41, + desired=desired, + expected_main_sha="a" * 40, + ) + + +def test_manifest_is_pinned_to_the_two_documented_privileged_targets(tmp_path: Path) -> None: + """Reviewed manifest structure cannot redirect Administration-write authority to another target.""" + + module = load_module() + manifest = { + "schema_version": 1, + "organization": "ContextualWisdomLab", + "targets": [ + { + "scope": "repository", + "owner": "ContextualWisdomLab", + "repository": "another-repository", + "ruleset_id": 99999999, + "name": "Another privileged ruleset", + }, + { + "scope": "organization", + "owner": "ContextualWisdomLab", + "repository": None, + "ruleset_id": 18156473, + "name": "CWL Central required workflows", + }, + ], + } + path = tmp_path / "manifest.json" + path.write_text(json.dumps(manifest), encoding="utf-8") + + with pytest.raises(module.RulesetGovernanceError, match="exact reviewed governance targets"): + module.load_manifest(path) + + +def test_workflow_covers_runtime_auditor_round3_suite_and_disabled_status() -> None: + """Runtime dependencies trigger the focused gate and disabled schedules remain visible cheaply.""" + + workflow = WORKFLOW.read_text(encoding="utf-8") + assert workflow.count('"scripts/ci/audit_central_required_workflows.py"') >= 2 + assert workflow.count('"tests/test_ruleset_governance_review_round3.py"') >= 2 + assert "tests/test_ruleset_governance_review_round3.py" in workflow.split("python -m coverage run", 1)[1] + assert "report-disabled:" in workflow + assert "CWL_RULESET_RECONCILE_ENABLED != 'true'" in workflow + assert "Ruleset reconciliation is disabled" in workflow + + +def test_apply_timeout_covers_derived_worst_case_critical_section() -> None: + """Actions cannot terminate the owner-plane process before its derived critical-section budget.""" + + module = load_module() + workflow = WORKFLOW.read_text(encoding="utf-8") + apply_section = workflow.split(" apply:\n", 1)[1] + timeout_match = re.search(r"timeout-minutes:\s*(\d+)", apply_section) + assert timeout_match is not None + configured_minutes = int(timeout_match.group(1)) + required_minutes = math.ceil(module.worst_case_apply_seconds(target_count=2) / 60) + assert configured_minutes >= required_minutes diff --git a/tests/test_ruleset_governance_runtime_budget_regression.py b/tests/test_ruleset_governance_runtime_budget_regression.py new file mode 100644 index 0000000000..7dd6bcb1e6 --- /dev/null +++ b/tests/test_ruleset_governance_runtime_budget_regression.py @@ -0,0 +1,74 @@ +"""Regress the owner-plane runtime budget against ambiguous recovery request counts.""" + +from __future__ import annotations + +import importlib.util +import math +import re +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "scripts" / "ci" / "reconcile_ruleset_governance.py" +WORKFLOW = ROOT / ".github" / "workflows" / "ruleset-governance-reconcile.yml" +GITHUB_HOSTED_JOB_EXECUTION_LIMIT_MINUTES = 360 + + +def load_module(): + """Load the production reconciler from the exact checkout.""" + + spec = importlib.util.spec_from_file_location("ruleset_governance_runtime_budget", SOURCE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_ambiguous_recovery_budget_counts_every_history_state_read() -> None: + """Every settlement poll may add one history-state GET and must be budgeted.""" + + module = load_module() + expected_recovery_operations = ( + module.RECOVERY_BLOCKING_OPERATIONS_PER_ATTEMPT + + (module.AMBIGUOUS_WRITE_SETTLEMENT_POLLS - 1) + + module.AMBIGUOUS_WRITE_SETTLEMENT_POLLS + ) + expected_blocking_operations = ( + module.BASE_MUTATION_BLOCKING_OPERATIONS_PER_TARGET + + module.AMBIGUOUS_SETTLEMENT_BLOCKING_OPERATIONS_PER_TARGET + + module.COLLISION_RECOVERY_LIMIT * expected_recovery_operations + + module.POST_CONFIRM_BLOCKING_OPERATIONS_PER_TARGET + + module.FINAL_VERIFY_BLOCKING_OPERATIONS_PER_TARGET + ) + expected_settlement_seconds = ( + module.AMBIGUOUS_WRITE_SETTLEMENT_WINDOW_SECONDS + + module.COLLISION_RECOVERY_LIMIT + * module.AMBIGUOUS_WRITE_SETTLEMENT_WINDOW_SECONDS + ) + expected_seconds = 2 * ( + expected_blocking_operations * module.API_REQUEST_TIMEOUT_SECONDS + + expected_settlement_seconds + ) + + assert module.worst_case_apply_seconds(target_count=2) == expected_seconds + assert expected_seconds == 7_680 + + +def test_workflow_uses_full_hosted_job_limit_for_critical_section_headroom() -> None: + """Do not self-terminate before GitHub's hosted-runner execution hard limit.""" + + module = load_module() + workflow = WORKFLOW.read_text(encoding="utf-8") + regression = "tests/test_ruleset_governance_runtime_budget_regression.py" + assert workflow.count(f'"{regression}"') >= 2 + assert regression in workflow.split("python -m coverage run", 1)[1] + + apply_section = workflow.split(" apply:\n", 1)[1] + timeout_match = re.search(r"timeout-minutes:\s*(\d+)", apply_section) + assert timeout_match is not None + configured_minutes = int(timeout_match.group(1)) + required_minutes = math.ceil(module.worst_case_apply_seconds(target_count=2) / 60) + assert required_minutes == 128 + assert configured_minutes == GITHUB_HOSTED_JOB_EXECUTION_LIMIT_MINUTES + assert configured_minutes - required_minutes == 232 diff --git a/tests/test_ruleset_governance_timeout_regression.py b/tests/test_ruleset_governance_timeout_regression.py new file mode 100644 index 0000000000..ce8b8dc1ec --- /dev/null +++ b/tests/test_ruleset_governance_timeout_regression.py @@ -0,0 +1,486 @@ +"""Regress ambiguous ruleset mutation timeouts and manual owner-plane safeguards.""" + +from __future__ import annotations + +import importlib.util +import subprocess +import sys +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] +SOURCE = ROOT / "scripts" / "ci" / "reconcile_ruleset_governance.py" + + +def load_module(): + """Load the production reconciler from the exact checkout.""" + + spec = importlib.util.spec_from_file_location("ruleset_governance_timeout_regression", SOURCE) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def repository_target(module): + """Return the exact central owner-repository target used by the manifest.""" + + return module.RulesetTarget( + scope="repository", + owner="ContextualWisdomLab", + repository=".github", + ruleset_id=17921150, + name="Lock default branch", + ) + + +def live_payload() -> dict: + """Return one live-shaped repository ruleset with deliberate governance drift.""" + + return { + "id": 17921150, + "name": "Lock default branch", + "target": "branch", + "source_type": "Repository", + "source": "ContextualWisdomLab/.github", + "enforcement": "active", + "bypass_actors": [ + {"actor_id": None, "actor_type": "OrganizationAdmin", "bypass_mode": "always"} + ], + "conditions": {"ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}}, + "rules": [ + {"type": "deletion"}, + {"type": "non_fast_forward"}, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": True, + "required_reviewers": [], + "require_code_owner_review": False, + "require_last_push_approval": False, + "required_review_thread_resolution": True, + "allowed_merge_methods": ["merge", "squash", "rebase"], + }, + }, + ], + } + + +def exercise_ambiguous_put(monkeypatch, *, history_outcome: str) -> tuple[object, list[tuple]]: + """Prepare one privileged mutation whose PUT times out after an unknown server outcome.""" + + module = load_module() + target = repository_target(module) + first = live_payload() + desired = module._desired_payload(first, target) + converged = {**first, **desired} + calls: list[tuple] = [] + get_count = 0 + + monkeypatch.setattr(module, "_assert_canonical_governance", lambda *_args: None) + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + monkeypatch.setattr(module, "_latest_history_version", lambda *_args: 41) + + def fake_api(method, endpoint, *, body=None): + nonlocal get_count + calls.append((method, endpoint, body)) + if method == "GET" and endpoint == target.endpoint: + get_count += 1 + if get_count <= 2: + return first + return converged + if method == "PUT" and endpoint == target.endpoint: + raise subprocess.TimeoutExpired(cmd=["gh", "api"], timeout=30) + raise AssertionError((method, endpoint)) + + def verify_history( + seen_target, + baseline_version, + seen_desired, + *, + expected_main_sha=None, + ): + calls.append(("HISTORY", baseline_version, seen_desired, expected_main_sha)) + assert seen_target == target + assert baseline_version == 41 + assert seen_desired == desired + # Once a PUT is issued, history settlement must finish without a stale-main veto. + assert expected_main_sha is None + if history_outcome == "not-accepted": + raise module.RulesetGovernanceError("ruleset mutation is not visible in history") + if history_outcome == "collision": + raise module.RulesetGovernanceError( + "concurrent ruleset history detected; restored newest displaced administrator state" + ) + + monkeypatch.setattr(module, "_gh_api", fake_api) + monkeypatch.setattr(module, "_verify_ruleset_history_transition", verify_history) + return module, calls + + +def test_put_timeout_before_acceptance_enters_history_path_and_fails_closed(monkeypatch) -> None: + """An unaccepted ambiguous PUT must be disproved from history rather than leak TimeoutExpired.""" + + module, calls = exercise_ambiguous_put(monkeypatch, history_outcome="not-accepted") + with pytest.raises(module.RulesetGovernanceError, match="not visible in history"): + module._reconcile_target( + repository_target(module), + verify_only=False, + expected_main_sha="a" * 40, + ) + assert any(call[0] == "HISTORY" for call in calls) + + +def test_put_timeout_after_acceptance_without_collision_verifies_and_converges(monkeypatch) -> None: + """A committed timed-out PUT succeeds only after immutable history and live convergence agree.""" + + module, calls = exercise_ambiguous_put(monkeypatch, history_outcome="accepted") + assert ( + module._reconcile_target( + repository_target(module), + verify_only=False, + expected_main_sha="a" * 40, + ) + is True + ) + assert sum(call[0] == "HISTORY" for call in calls) == 1 + assert sum(call[0] == "PUT" for call in calls) == 1 + + +def test_put_timeout_after_acceptance_with_collision_preserves_recovery_failure(monkeypatch) -> None: + """A timed-out PUT that displaced another version must run recovery and remain fail-closed.""" + + module, calls = exercise_ambiguous_put(monkeypatch, history_outcome="collision") + with pytest.raises(module.RulesetGovernanceError, match="restored newest displaced"): + module._reconcile_target( + repository_target(module), + verify_only=False, + expected_main_sha="a" * 40, + ) + assert any(call[0] == "HISTORY" for call in calls) + + +def test_ambiguous_put_without_history_guard_is_rejected(monkeypatch) -> None: + """Internal callers cannot turn a timed-out unguarded mutation into a claimed success.""" + + module = load_module() + target = repository_target(module) + desired = module._desired_payload(live_payload(), target) + with pytest.raises(module.RulesetGovernanceError, match="requires protected-main history guard"): + module._confirm_ambiguous_put( + target, + baseline_version=1, + desired=desired, + expected_main_sha=None, + ) + + +def test_reconcile_timeout_without_history_baseline_fails_closed(monkeypatch) -> None: + """A direct unguarded internal mutation still rejects an ambiguous PUT timeout.""" + + module = load_module() + target = repository_target(module) + first = live_payload() + get_count = 0 + monkeypatch.setattr(module, "_assert_canonical_governance", lambda *_args: None) + + def fake_api(method, endpoint, *, body=None): + nonlocal get_count + if method == "GET" and endpoint == target.endpoint: + get_count += 1 + return first + if method == "PUT" and endpoint == target.endpoint: + raise subprocess.TimeoutExpired(cmd=["gh", "api"], timeout=30) + raise AssertionError((method, endpoint, body)) + + monkeypatch.setattr(module, "_gh_api", fake_api) + with pytest.raises(module.RulesetGovernanceError, match="requires protected-main history guard"): + module._reconcile_target(target, verify_only=False) + assert get_count == 2 + + +def test_transport_timeout_distinguishes_reads_from_ambiguous_puts(monkeypatch) -> None: + """Read timeouts become redacted domain errors while PUT timeouts remain distinguishable.""" + + module = load_module() + + def timeout_run(*_args, **_kwargs): + raise subprocess.TimeoutExpired(cmd=["gh", "api"], timeout=30) + + monkeypatch.setattr(subprocess, "run", timeout_run) + with pytest.raises(module.RulesetGovernanceError, match="request timed out"): + module._run_gh_json("GET", "repos/ContextualWisdomLab/.github/rulesets/17921150") + with pytest.raises(subprocess.TimeoutExpired): + module._run_gh_json( + "PUT", + "repos/ContextualWisdomLab/.github/rulesets/17921150", + body={"name": "Lock default branch"}, + ) + + +def test_recovery_timeout_without_history_fails_closed(monkeypatch) -> None: + """An ambiguous recovery timeout cannot proceed when immutable history is unavailable.""" + + module = load_module() + target = repository_target(module) + current = live_payload() + displaced = {**current, "name": "Admin predecessor"} + monkeypatch.setattr(module, "_history_version_state", lambda *_args: displaced) + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + monkeypatch.setattr( + module, + "_gh_api", + lambda method, endpoint, **_kwargs: current + if method == "GET" + else (_ for _ in ()).throw(subprocess.TimeoutExpired(cmd=["gh", "api"], timeout=30)), + ) + monkeypatch.setattr(module, "_gh_api_list", lambda *_args: []) + + with pytest.raises(module.RulesetGovernanceError, match="exposed no history"): + module._recover_displaced_history_state( + target, + current_version=10, + current_payload=module._editable_projection(current), + displaced_version=9, + expected_main_sha="a" * 40, + ) + + +def test_recovery_timeout_before_acceptance_fails_after_settlement_without_retry(monkeypatch) -> None: + """An absent recovery transition fails closed after settlement without issuing another PUT.""" + + module = load_module() + target = repository_target(module) + current = live_payload() + displaced = {**current, "name": "Admin predecessor"} + put_count = 0 + history_reads = 0 + sleep_calls = 0 + monkeypatch.setattr(module, "_history_version_state", lambda *_args: displaced) + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + + def fake_sleep(_seconds): + nonlocal sleep_calls + sleep_calls += 1 + + def fake_api(method, endpoint, **_kwargs): + nonlocal put_count + if method == "GET": + return current + if method == "PUT": + put_count += 1 + raise subprocess.TimeoutExpired(cmd=["gh", "api"], timeout=30) + raise AssertionError((method, endpoint)) + + def fake_history(*_args): + nonlocal history_reads + history_reads += 1 + return [{"version_id": 10}] + + monkeypatch.setattr(module.time, "sleep", fake_sleep) + monkeypatch.setattr(module, "_gh_api", fake_api) + monkeypatch.setattr(module, "_gh_api_list", fake_history) + + with pytest.raises( + module.RulesetGovernanceError, + match="ambiguous ruleset recovery PUT outcome remains unresolved after settlement window", + ): + module._recover_displaced_history_state( + target, + current_version=10, + current_payload=module._editable_projection(current), + displaced_version=9, + expected_main_sha="a" * 40, + ) + assert put_count == 1 + assert history_reads == module.AMBIGUOUS_WRITE_SETTLEMENT_POLLS + assert sleep_calls == module.AMBIGUOUS_WRITE_SETTLEMENT_POLLS - 1 + + +def test_recovery_timeout_with_new_version_requires_predecessor(monkeypatch) -> None: + """A visible timed-out recovery write still needs its immutable predecessor proof.""" + + module = load_module() + target = repository_target(module) + current = live_payload() + displaced = {**current, "name": "Admin predecessor"} + monkeypatch.setattr(module, "_history_version_state", lambda *_args: displaced) + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + monkeypatch.setattr( + module, + "_gh_api", + lambda method, endpoint, **_kwargs: current + if method == "GET" + else (_ for _ in ()).throw(subprocess.TimeoutExpired(cmd=["gh", "api"], timeout=30)), + ) + monkeypatch.setattr(module, "_gh_api_list", lambda *_args: [{"version_id": 11}]) + + with pytest.raises(module.RulesetGovernanceError, match="exposed no predecessor"): + module._recover_displaced_history_state( + target, + current_version=10, + current_payload=module._editable_projection(current), + displaced_version=9, + expected_main_sha="a" * 40, + ) + + +def test_recovery_timeout_refuses_unexpected_newer_history_state(monkeypatch) -> None: + """A timed-out recovery never overwrites a newer state that is not its intended restore body.""" + + module = load_module() + target = repository_target(module) + current = live_payload() + displaced = {**current, "name": "Admin predecessor"} + unrelated = {**current, "name": "Newer administrator state"} + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + + def history_state(_target, version_id): + return unrelated if version_id == 11 else displaced + + monkeypatch.setattr(module, "_history_version_state", history_state) + monkeypatch.setattr( + module, + "_gh_api", + lambda method, endpoint, **_kwargs: current + if method == "GET" + else (_ for _ in ()).throw(subprocess.TimeoutExpired(cmd=["gh", "api"], timeout=30)), + ) + monkeypatch.setattr( + module, + "_gh_api_list", + lambda *_args: [{"version_id": 11}, {"version_id": 10}], + ) + + with pytest.raises( + module.RulesetGovernanceError, + match="ambiguous ruleset recovery PUT left a newer state after settlement window; refusing overwrite", + ): + module._recover_displaced_history_state( + target, + current_version=10, + current_payload=module._editable_projection(current), + displaced_version=9, + expected_main_sha="a" * 40, + ) + + +def test_recovery_timeout_after_acceptance_without_collision_converges(monkeypatch) -> None: + """A timed-out recovery accepted by GitHub completes only with matching history and live state.""" + + module = load_module() + target = repository_target(module) + current = live_payload() + displaced = {**current, "name": "Admin predecessor", "enforcement": "evaluate"} + get_count = 0 + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + monkeypatch.setattr(module, "_history_version_state", lambda *_args: displaced) + + def fake_api(method, endpoint, **_kwargs): + nonlocal get_count + if method == "GET": + get_count += 1 + return current if get_count == 1 else displaced + if method == "PUT": + raise subprocess.TimeoutExpired(cmd=["gh", "api"], timeout=30) + raise AssertionError((method, endpoint)) + + monkeypatch.setattr(module, "_gh_api", fake_api) + monkeypatch.setattr( + module, + "_gh_api_list", + lambda *_args: [{"version_id": 11}, {"version_id": 10}], + ) + + module._recover_displaced_history_state( + target, + current_version=10, + current_payload=module._editable_projection(current), + displaced_version=9, + expected_main_sha="a" * 40, + ) + + +def test_recovery_timeout_after_acceptance_with_collision_continues_chain(monkeypatch) -> None: + """A timed-out restore that displaced another version continues to the newest predecessor.""" + + module = load_module() + target = repository_target(module) + current = live_payload() + first_restore = {**current, "name": "First restore", "enforcement": "evaluate"} + second_restore = {**current, "name": "Newest displaced", "enforcement": "evaluate"} + get_states = iter([current, first_restore, first_restore, second_restore]) + put_count = 0 + list_count = 0 + monkeypatch.setattr(module, "_assert_current_main", lambda *_args: None) + + def history_state(_target, version_id): + return {9: first_restore, 8: second_restore, 11: first_restore, 12: second_restore}[version_id] + + def fake_api(method, endpoint, **_kwargs): + nonlocal put_count + if method == "GET": + return next(get_states) + if method == "PUT": + put_count += 1 + if put_count == 1: + raise subprocess.TimeoutExpired(cmd=["gh", "api"], timeout=30) + return {} + raise AssertionError((method, endpoint)) + + def fake_history(*_args): + nonlocal list_count + list_count += 1 + if list_count == 1: + return [{"version_id": 11}, {"version_id": 8}] + return [{"version_id": 12}, {"version_id": 11}] + + monkeypatch.setattr(module, "_history_version_state", history_state) + monkeypatch.setattr(module, "_gh_api", fake_api) + monkeypatch.setattr(module, "_gh_api_list", fake_history) + + module._recover_displaced_history_state( + target, + current_version=10, + current_payload=module._editable_projection(current), + displaced_version=9, + expected_main_sha="a" * 40, + ) + assert put_count == 2 + + +def test_manual_mutation_requires_exact_protected_main_guard(monkeypatch) -> None: + """Non-Actions operators cannot invoke the weaker mutation mode without a main SHA guard.""" + + module = load_module() + target = repository_target(module) + monkeypatch.setenv("GH_TOKEN", "redacted-test-token") + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + monkeypatch.setattr(module, "load_manifest", lambda _path: (target,)) + monkeypatch.setattr(module, "reconcile", lambda *_args, **_kwargs: 0) + + with pytest.raises(module.RulesetGovernanceError, match="expected protected main SHA"): + module.main([]) + + +def test_verify_only_remains_available_without_mutation_main_guard(monkeypatch, capsys) -> None: + """Read-only live verification remains usable without granting mutation authority.""" + + module = load_module() + target = repository_target(module) + observed: list[tuple] = [] + monkeypatch.setenv("GH_TOKEN", "redacted-test-token") + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + monkeypatch.setattr(module, "load_manifest", lambda _path: (target,)) + + def fake_reconcile(targets, *, verify_only, expected_main_sha=None): + observed.append((targets, verify_only, expected_main_sha)) + return 0 + + monkeypatch.setattr(module, "reconcile", fake_reconcile) + assert module.main(["--verify-only"]) == 0 + assert observed == [((target,), True, None)] + assert "verified 1 ruleset governance targets" in capsys.readouterr().out \ No newline at end of file diff --git a/tests/test_ruleset_merge_method_shape_regression.py b/tests/test_ruleset_merge_method_shape_regression.py new file mode 100644 index 0000000000..02dd3d52b1 --- /dev/null +++ b/tests/test_ruleset_merge_method_shape_regression.py @@ -0,0 +1,124 @@ +"""Fail-closed regression for malformed ruleset merge-method payloads.""" + +from __future__ import annotations + +from copy import deepcopy + +import pytest + +from scripts.ci import audit_central_required_workflows as audit + + +def _review_parameters() -> dict[str, object]: + return { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": True, + "require_code_owner_review": False, + "require_last_push_approval": False, + "required_review_thread_resolution": True, + "required_reviewers": [], + "require_extra_approval_for_unattributed_changes": True, + "allowed_merge_methods": ["merge", "squash"], + } + + +def _central_payload() -> dict[str, object]: + return { + "id": audit.RULESET_ID, + "name": audit.RULESET_NAME, + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "repository_name": { + "include": ["~ALL"], + "exclude": sorted(audit.EXPECTED_EXCLUSIONS), + }, + "ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}, + }, + "rules": [ + { + "type": "workflows", + "parameters": { + "do_not_enforce_on_create": True, + "workflows": [ + { + "repository_id": audit.SOURCE_REPOSITORY_ID, + "path": path, + "ref": audit.SOURCE_REF, + } + for path in audit.REQUIRED_WORKFLOW_PATHS + ], + }, + }, + {"type": "pull_request", "parameters": _review_parameters()}, + {"type": "deletion"}, + {"type": "non_fast_forward"}, + ], + } + + +def _repository_payload() -> dict[str, object]: + return { + "id": audit.REPOSITORY_RULESET_ID, + "name": audit.REPOSITORY_RULESET_NAME, + "target": "branch", + "source_type": "Repository", + "source": audit.REPOSITORY_RULESET_SOURCE, + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}, + }, + "rules": [ + {"type": "pull_request", "parameters": _review_parameters()}, + {"type": "deletion"}, + {"type": "non_fast_forward"}, + ], + } + + +def _set_allowed_merge_methods(payload: dict[str, object], value: object) -> None: + rules = payload["rules"] + assert isinstance(rules, list) + review_rule = next( + rule for rule in rules if isinstance(rule, dict) and rule.get("type") == "pull_request" + ) + parameters = review_rule["parameters"] + assert isinstance(parameters, dict) + parameters["allowed_merge_methods"] = value + + +@pytest.mark.parametrize( + "malformed", + [None, 7, "merge", {"merge": True}, ("merge", "squash")], +) +def test_central_audit_reports_malformed_merge_method_shape_without_raising( + malformed: object, +) -> None: + payload = deepcopy(_central_payload()) + _set_allowed_merge_methods(payload, malformed) + + errors = audit.audit_ruleset(payload) + + assert "only merge and squash may be allowed merge methods" in errors + + +@pytest.mark.parametrize( + "malformed", + [None, 7, "merge", {"merge": True}, ("merge", "squash")], +) +def test_repository_audit_reports_malformed_merge_method_shape_without_raising( + malformed: object, +) -> None: + payload = deepcopy(_repository_payload()) + _set_allowed_merge_methods(payload, malformed) + + errors = audit.audit_repository_ruleset(payload) + + assert "repository ruleset must allow only merge and squash" in errors + + +def test_valid_merge_method_list_remains_accepted() -> None: + assert audit.audit_ruleset(_central_payload()) == [] + assert audit.audit_repository_ruleset(_repository_payload()) == [] diff --git a/tests/test_solo_maintainer_ruleset_policy.py b/tests/test_solo_maintainer_ruleset_policy.py new file mode 100644 index 0000000000..d11a7675a0 --- /dev/null +++ b/tests/test_solo_maintainer_ruleset_policy.py @@ -0,0 +1,163 @@ +"""Regression contract for solo-maintainer protected-branch governance.""" + +from scripts.ci import audit_central_required_workflows as audit + + +def _central_ruleset_payload() -> dict: + """Return the desired organization ruleset for a one-human-maintainer fleet.""" + return { + "id": audit.RULESET_ID, + "name": audit.RULESET_NAME, + "target": "branch", + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "repository_name": { + "include": ["~ALL"], + "exclude": [".github", "IRT-bibliography-set", "noema"], + }, + "ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}, + }, + "rules": [ + { + "type": "workflows", + "parameters": { + "do_not_enforce_on_create": True, + "workflows": [ + { + "repository_id": audit.SOURCE_REPOSITORY_ID, + "path": path, + "ref": audit.SOURCE_REF, + } + for path in audit.REQUIRED_WORKFLOW_PATHS + ], + }, + }, + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": True, + "require_code_owner_review": False, + "require_last_push_approval": False, + "required_review_thread_resolution": True, + "required_reviewers": [], + "require_extra_approval_for_unattributed_changes": True, + "allowed_merge_methods": ["merge", "squash"], + }, + }, + {"type": "deletion"}, + {"type": "non_fast_forward"}, + ], + } + + +def _repository_ruleset_payload() -> dict: + """Return the desired .github repository ruleset under the same model.""" + return { + "id": audit.REPOSITORY_RULESET_ID, + "name": audit.REPOSITORY_RULESET_NAME, + "target": "branch", + "source_type": "Repository", + "source": audit.REPOSITORY_RULESET_SOURCE, + "enforcement": "active", + "bypass_actors": [], + "conditions": { + "ref_name": {"include": ["~DEFAULT_BRANCH"], "exclude": []}, + }, + "rules": [ + { + "type": "pull_request", + "parameters": { + "required_approving_review_count": 0, + "dismiss_stale_reviews_on_push": True, + "require_code_owner_review": False, + "require_last_push_approval": False, + "required_review_thread_resolution": True, + "required_reviewers": [], + "require_extra_approval_for_unattributed_changes": True, + "allowed_merge_methods": ["merge", "squash"], + }, + }, + {"type": "deletion"}, + {"type": "non_fast_forward"}, + ], + } + + +def _review_parameters(payload: dict) -> dict: + """Return the unique pull-request rule parameters from ``payload``.""" + review_rule = next(rule for rule in payload["rules"] if rule["type"] == "pull_request") + return review_rule["parameters"] + + +def test_central_ruleset_accepts_zero_approvals_without_last_push_approval() -> None: + """A one-human fleet must not require an approval its sole author cannot give.""" + assert audit.audit_ruleset(_central_ruleset_payload()) == [] + + +def test_repository_ruleset_accepts_zero_approvals_without_last_push_approval() -> None: + """The control-plane repository must use the same satisfiable admission model.""" + assert audit.audit_repository_ruleset(_repository_ruleset_payload()) == [] + + +def test_central_ruleset_rejects_synthetic_required_reviewer() -> None: + """A named reviewer cannot manufacture independence in a one-human fleet.""" + payload = _central_ruleset_payload() + _review_parameters(payload)["required_reviewers"] = [ + {"reviewer_id": 1234, "reviewer_type": "User"} + ] + + assert audit.audit_ruleset(payload) == [ + "central solo-maintainer ruleset must not configure required reviewers" + ] + + +def test_repository_ruleset_rejects_synthetic_required_reviewer() -> None: + """The owner repository cannot reintroduce the same deadlock by reviewer identity.""" + payload = _repository_ruleset_payload() + _review_parameters(payload)["required_reviewers"] = [ + {"reviewer_id": 1234, "reviewer_type": "User"} + ] + + assert audit.audit_repository_ruleset(payload) == [ + "repository solo-maintainer ruleset must not configure required reviewers" + ] + + +def test_central_ruleset_rejects_code_owner_review_deadlock() -> None: + """Code-owner approval cannot be mandatory when the only owner authors the change.""" + payload = _central_ruleset_payload() + _review_parameters(payload)["require_code_owner_review"] = True + + assert audit.audit_ruleset(payload) == [ + "central solo-maintainer ruleset must not require code-owner review" + ] + + +def test_repository_ruleset_rejects_code_owner_review_deadlock() -> None: + """The control plane cannot reintroduce independence through CODEOWNERS.""" + payload = _repository_ruleset_payload() + _review_parameters(payload)["require_code_owner_review"] = True + + assert audit.audit_repository_ruleset(payload) == [ + "repository solo-maintainer ruleset must not require code-owner review" + ] + + +def test_central_ruleset_rejects_malformed_allowed_merge_methods() -> None: + """Malformed API payloads must report drift rather than abort central auditing.""" + expected = ["only merge and squash may be allowed merge methods"] + for malformed in (None, 7, "merge", {"merge": True, "squash": True}): + payload = _central_ruleset_payload() + _review_parameters(payload)["allowed_merge_methods"] = malformed + assert audit.audit_ruleset(payload) == expected + + +def test_repository_ruleset_rejects_malformed_allowed_merge_methods() -> None: + """Malformed API payloads must report drift rather than abort repository auditing.""" + expected = ["repository ruleset must allow only merge and squash"] + for malformed in (None, 7, "merge", {"merge": True, "squash": True}): + payload = _repository_ruleset_payload() + _review_parameters(payload)["allowed_merge_methods"] = malformed + assert audit.audit_repository_ruleset(payload) == expected