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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 66 additions & 16 deletions .github/rulesets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ setting, which is the owner's call, not a pull request's.

## What is true today

Measured 2026-08-15:
**No ruleset is applied on this repository.** Measured 2026-08-15, re-read
2026-08-28 and again 2026-08-29, every time with the same answer:

| Question | Answer |
|---|---|
Expand All @@ -20,26 +21,51 @@ red. `verify`, `secret-scan`, `sast`, `zizmor` and `codeql` run and report;
nothing blocks on them. The header of `.github/workflows/ci.yml` used to call
them merge-blocking, which was the opposite of what the server enforces.

The other half of that, worth knowing before running the command below:
`main.json` has never been applied to anything, so no live ruleset has ever
corrected it. Whatever it gets wrong, it has been getting wrong unopposed.

## Apply it in this order, or every pull request deadlocks

A required status check that never reports is a check that never turns green.
Two of the five contexts in `main.json` do not exist on `main` yet.

1. Land the workflow-SAST change, so `zizmor` and
`codeql (actions · python · javascript)` exist on `main` and run on pull
requests. `codeql.yml` triggers on `pull_request: branches: [main]`, so it
reports on any PR targeting `main`.
2. Drain the five open dependabot pull requests. `strict_required_status_checks_policy`
requires a branch to be up to date with `main` before it merges, so each one
needs a rebase after the one before it lands. Doing this first is cheaper
than doing it under the ruleset.
3. Then apply the ruleset, owner-only:
This section used to open by saying that two of the five contexts in
`main.json` did not exist on `main` yet, and listed landing them as step 1.
That was true when it was written on 2026-08-15 and stopped being true the same
day: `zizmor` arrived on `main` with #16 (`839557e`), and `sast` had been there
since `ea06580`. Measured 2026-08-29, all five contexts exist and report.
`verify`, `secret-scan`, `sast` and `zizmor` are jobs in
`.github/workflows/ci.yml`; `codeql (actions · python · javascript)` is the
`name:` of the `analyze` job in `.github/workflows/codeql.yml`, which triggers
on `pull_request: branches: [main]` and so reports on any pull request
targeting `main`. Read off the two most recent pull request head commits,
`62c406e` (#30) and `ae0e2d6` (#31), each reported all five.

The first two steps this list used to carry are done, and are kept here as
numbered history rather than deleted, so the order is still legible: landing
the workflow-SAST change, and draining the dependabot queue, which was empty
when measured on 2026-08-29. What remains before an apply:

1. Check `bypass_actors` in `main.json` before you post it. It must hold
`{ "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always" }`.
This file said `"bypass_actors": []` until 2026-08-28, and posting that
version is how the owner gets locked out of the repository; see
"`bypass_actors`: the repository owner, and nobody else" below. Note that
POST adds a ruleset rather than replacing one, and rules from every
applicable ruleset combine while bypass actors are per-ruleset, so posting
a second time without deleting the first leaves an empty-bypass ruleset
over `main` that blocks the owner whatever the first one allows.
2. Then apply the ruleset, owner-only:

```sh
gh api -X POST repos/ChelseaKR/perimeter/rulesets \
--input .github/rulesets/main.json
```

3. Confirm the owner's bypass came through:
`gh api repos/ChelseaKR/perimeter/rulesets/<id> --jq .current_user_can_bypass`
must read `"always"`, and `--jq .bypass_actors` must hold exactly the one
actor `main.json` names. An apply that lands every rule and loses that
actor returns 201 like any other, and it is the lockout described below.
4. Re-export after any UI edit, so this file stays the source of truth:
`gh api repos/ChelseaKR/perimeter/rulesets/<id>`.

Expand Down Expand Up @@ -91,7 +117,31 @@ not enforceable by one person and is not enforced by this profile either. The
PR requirement, the strict up-to-date policy, thread resolution and stale-review
dismissal are what remain of it.

**`bypass_actors: []`.** No break-glass path. CICD-15 permits one designated
maintainer with `bypass_mode: pull_request`; the empty list is the stricter
reading and can be relaxed deliberately if a required check ever proves
unrunnable.
**`bypass_actors`: the repository owner, and nobody else.** This file carries
exactly one bypass actor, `RepositoryRole` 5 with `bypass_mode: always`,
deliberately and permanently: an agent once applied a ruleset with no bypass
and locked the owner out of their own repository, and restoring access took a
sweep across eighteen repositories. An empty list here is not a stricter gate,
it is the lockout.

This bullet used to say the opposite. It read "`bypass_actors: []`. No
break-glass path. CICD-15 permits one designated maintainer with
`bypass_mode: pull_request`; the empty list is the stricter reading", and that
is the reasoning being reversed, not an oversight being tidied. It was not
wrong about the risk an admin bypass carries; it was wrong about which risk is
larger, and the larger one has already happened elsewhere in this portfolio.
Note also what this repository is asking the profile to enforce:
`required_signatures`, `required_linear_history`, a strict up-to-date policy
and five required contexts. That is a lot of ways for a first application to
wedge, on a repository that has never had a ruleset at all, and the empty list
would have removed the only way back in that does not go through GitHub
support. When this bullet was first written two of those contexts did not yet
exist on `main`, which made the wedge likelier still; all five report as of
2026-08-29, and `required_signatures` is now the rule most likely to surprise a
first apply.

`bypass_mode: always` rather than CICD-15's `pull_request`, because a bypass
that only works inside a pull request is no use when the thing that is wedged
is the pull request. One actor, and a repository role rather than a team or a
GitHub App: a second entry in this list would be a real finding, and this one
is not.
4 changes: 3 additions & 1 deletion .github/rulesets/main.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
"conditions": {
"ref_name": { "include": ["refs/heads/main"], "exclude": [] }
},
"bypass_actors": [],
"bypass_actors": [
{ "actor_id": 5, "actor_type": "RepositoryRole", "bypass_mode": "always" }
],
"rules": [
{ "type": "deletion" },
{ "type": "non_fast_forward" },
Expand Down
37 changes: 37 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,43 @@ Format: [Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project a

## [Unreleased]

### Fixed, the committed ruleset would have locked the owner out on first apply

- **`.github/rulesets/main.json` carried `"bypass_actors": []`.** No ruleset has ever
been applied to this repository (`gh api repos/ChelseaKR/perimeter/rulesets` returned
`[]` on 2026-08-15 and again on 2026-08-28), so nothing was broken yet; what was
committed was a first application that would have broken it. The file now carries
exactly the repository owner's standing bypass, `RepositoryRole` 5 with
`bypass_mode: always`, deliberately and permanently: an agent once applied a ruleset
with no bypass and locked the owner out of their own repository, and restoring access
took a sweep across eighteen repositories. An empty list there is not a stricter gate,
it is the lockout. `.github/rulesets/README.md` argued the other way, calling the empty
list "the stricter reading" of CICD-15; that bullet is replaced with the reversal and
the reason, the apply procedure gains a step that checks the bypass actually came
through, and the "what is true today" section now says plainly that no ruleset is
applied and that this file has therefore never been corrected by a live one.
Re-measured 2026-08-29: still `[]`, still `"protected": false`, and the branch
protection endpoint still 404s.
- **Nothing read that file, so correcting it once would not have held.** Measured before
writing the check: no test and no module in this repository referenced `ruleset` or
`bypass_actors` anywhere, so the value could regress to the empty list in a single edit
with every gate still green. `tests/test_ruleset.py` makes the empty list a test
failure. `lockout_risk` is a pure function of a parsed document, exercised against the
five shapes that lose the bypass (empty list, absent key, wrong type, a different
actor, and the owner downgraded to `bypass_mode: pull_request`) with a positive control
so it is not passing by refusing everything, and `load_ruleset` fails on a missing or
unparseable file rather than returning an empty document that the assertions would read
as nothing wrong.
- **The apply order named two required contexts that did not exist. They exist.**
`.github/rulesets/README.md` said "Two of the five contexts in `main.json` do not exist
on `main` yet" and made landing them step 1. That was written 2026-08-15 and was false
within the day: `zizmor` arrived with #16 (`839557e`) and `sast` had been in `ci.yml`
since `ea06580`. Re-measured 2026-08-29 off the two most recent pull request head
commits, `62c406e` (#30) and `ae0e2d6` (#31), all five contexts report. The completed
steps are kept as numbered history rather than deleted and the rest are renumbered.
- **Still not applied.** Landing the corrected file changes nothing on the server.
Applying the ruleset is a live repository setting and remains the owner's call.

### Added, a retrieval that goes stale now fails the build

- **A data card per source, under `docs/data/`.** DG-01 asks for one per ingest source
Expand Down
160 changes: 160 additions & 0 deletions tests/test_ruleset.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
"""CICD-15: the committed ruleset must not be a lockout waiting to be applied.

`.github/rulesets/main.json` is committed but never applied, and that combination is
what made this file dangerous. Nothing applies it, so no live ruleset has ever contradicted
it; nothing read it either, so nothing in this repository could contradict it on its own.
It carried `"bypass_actors": []` from the day it was written, and the documented apply
command in `.github/rulesets/README.md` posts the file as it stands. Following this
repository's own instructions was therefore enough to lock the owner out of it, which is
not hypothetical: an agent applied a no-bypass ruleset elsewhere in this portfolio and
restoring access took a sweep across eighteen repositories.

Correcting the file once is not the fix, because the file can regress. This module is the
fix: the empty list is now a test failure, so the regression cannot land quietly.

Every check here is written to fail closed, per ADR-0004. The predicates are pure
functions of a parsed document, run against documents they must reject as well as the
committed one, and `load_ruleset` refuses a missing or unparseable file rather than
returning something empty that the assertions below would then read as "nothing wrong".
A guard that passes when its subject is absent is the defect it exists to catch.
"""

from __future__ import annotations

import json
from pathlib import Path
from typing import Any

import pytest

ROOT = Path(__file__).resolve().parents[1]
RULESET = ROOT / ".github" / "rulesets" / "main.json"
RULESET_DOC = ROOT / ".github" / "rulesets" / "README.md"

OWNER_BYPASS = {
"actor_id": 5,
"actor_type": "RepositoryRole",
"bypass_mode": "always",
}
"""The repository owner's standing bypass, and the only entry this file may carry.

`RepositoryRole` 5 is admin. `bypass_mode: always` rather than CICD-15's suggested
`pull_request` because a bypass that only works inside a pull request is no use when the
thing that is wedged is the pull request itself.
"""


def load_ruleset() -> dict[str, Any]:
"""The committed ruleset, or a failure. Never a silent empty document.

The two ways a check like this passes vacuously are a missing file and an
unparseable one, so both are failures here rather than defaults.
"""
if not RULESET.is_file():
pytest.fail(f"{RULESET} is missing; the committed ruleset is what this checks")
try:
loaded = json.loads(RULESET.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
pytest.fail(
f"{RULESET} is not parseable JSON, so nothing can vouch for it: {exc}"
)
if not isinstance(loaded, dict):
pytest.fail(f"{RULESET} is not a JSON object")
return loaded


def lockout_risk(ruleset: dict[str, Any]) -> str | None:
"""Why applying this ruleset would lock the owner out, or ``None`` if it would not.

A pure function of a document so it can be run against documents it must reject,
which is what ADR-0004 asks of a gate.
"""
if "bypass_actors" not in ruleset:
return "no bypass_actors key at all, which GitHub reads as an empty list"
actors = ruleset["bypass_actors"]
if not isinstance(actors, list):
return f"bypass_actors is {type(actors).__name__}, not a list"
if not actors:
return (
"bypass_actors is empty, so applying this leaves no break-glass path and "
"the owner cannot merge, push or delete the ruleset that is blocking them"
)
if OWNER_BYPASS not in actors:
return (
f"bypass_actors does not carry the owner's standing bypass {OWNER_BYPASS}; "
f"it carries {actors}"
)
return None


def test_applying_the_committed_ruleset_would_not_lock_the_owner_out() -> None:
"""The whole point. This is the assertion the empty list has to fail."""
risk = lockout_risk(load_ruleset())
assert risk is None, (
"applying .github/rulesets/main.json as committed would lock the repository "
f"owner out: {risk}. See .github/rulesets/README.md, 'bypass_actors: the "
"repository owner, and nobody else'."
)


def test_the_owner_is_the_only_bypass_actor() -> None:
"""One actor. A second entry is a real finding; this one is not."""
actors = load_ruleset()["bypass_actors"]
assert actors == [OWNER_BYPASS], (
"the owner's standing bypass is the only entry this file may carry, and a "
f"second one is a widening of who can skip every rule: {actors}"
)


@pytest.mark.parametrize(
("document", "expected"),
[
({"bypass_actors": []}, "empty"),
({}, "no bypass_actors key"),
({"bypass_actors": {}}, "not a list"),
(
{
"bypass_actors": [
{
"actor_id": 1,
"actor_type": "Integration",
"bypass_mode": "always",
}
]
},
"does not carry the owner",
),
(
{"bypass_actors": [dict(OWNER_BYPASS, bypass_mode="pull_request")]},
"does not carry the owner",
),
],
ids=["empty", "absent", "wrong-type", "wrong-actor", "wrong-mode"],
)
def test_the_lockout_check_rejects_the_documents_it_must_reject(
document: dict[str, Any], expected: str
) -> None:
"""Five ways to lose the bypass, each of which returns 201 from GitHub like any other apply.

The empty list is the one that was committed. The rest are the shapes an edit to fix
it could plausibly land in.
"""
risk = lockout_risk(document)
assert risk is not None, f"{document} should be refused"
assert expected in risk


def test_the_lockout_check_accepts_the_shape_it_should() -> None:
"""A positive control, so the check above is not passing because it refuses everything."""
assert lockout_risk({"bypass_actors": [OWNER_BYPASS]}) is None


def test_the_documentation_names_the_bypass_the_file_carries() -> None:
"""The README talks a reader through checking this before posting. It must name the same actor.

The apply procedure is prose and prose drifts. If the file and the instructions for
reading it disagree, the instructions are the ones a person follows.
"""
doc = RULESET_DOC.read_text(encoding="utf-8")
for fragment in ('"actor_id": 5', "RepositoryRole", "always"):
assert fragment in doc, f"{RULESET_DOC} does not name {fragment!r}"
Loading