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
6 changes: 6 additions & 0 deletions .config/lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ pre-commit:
glob: '.config/**'
run: task check:config

# Both halves of the review gate, plus the workflows whose job names its
# required checks name -- renaming a job is the quiet way to break it.
- name: rulesets
glob: '.github/{rulesets/**,CODEOWNERS,workflows/*.yml}'
run: task check:rulesets

- name: markdown
glob: '**/*.md'
run: task check:md
Expand Down
60 changes: 43 additions & 17 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
@@ -1,24 +1,50 @@
# See https://docs.github.com/en/repositories/managing-your-repositories-settings-and-features/customizing-your-repository/about-code-owners
# CODEOWNERS — the ownership half of the selective code-owner review gate.
#
# Pattern matching is gitignore-style and the last matching entry wins. The
# `*` catch-all covers everything; the entries below restate ownership for the
# areas where a change alters a public contract, so the intent survives a
# future split of ownership.
# Docs: https://docs.github.com/en/repositories/managing-your-repositories-settings-and-features/customizing-your-repository/about-code-owners
# Why it is selective: docs/adr/0015-selective-code-owner-review.md
# The other half: .github/rulesets/main-branch.json
#
# How review works on this repository:
#
# A pull request that touches NO path listed below merges on green CI alone.
# The required checks are the gate; no human approval is requested or needed.
# A pull request that touches a path listed below additionally requires an
# approving review from that path's owner, with every review thread resolved.
#
# The ruleset sets `required_approving_review_count: 0`, which is the sanctioned
# "no blanket reviewers" value, and `require_code_owner_review: true`, which is
# evaluated per changed file. Listing a path here is therefore a commitment to
# review every change to it — not a label.
#
# Invariants. Each one silently breaks the gate rather than failing loudly, so
# `task check:rulesets` enforces all of them:
#
# - NEVER add a `*` catch-all. It makes every pull request code-owned and
# re-imposes the blanket gate this file exists to retire.
# - LAST matching pattern wins. Order broad to specific.
# - ALL owners for a pattern go on ONE line. A second line for the same
# pattern silently replaces the first rather than adding to it.
# - Owners need EXPLICIT write access to this repository. An owner without it
# is ignored with no error, and the path quietly becomes unowned.
# - `!` negation and `[ ]` ranges do not work here. A path listed with no
# owner is the supported way to carve out an explicitly unowned exception.
# - An owner's own pull requests are exempt: GitHub cannot request a review
# from the author, so authorship waives the requirement for the patterns
# that author owns. The `codeowners-notice` workflow posts a comment on
# those pull requests so a self-owned edit is at least visible.
#
# NOTE: musher-dev/platform's CODEOWNERS names `@merrelljustin`, which is not a
# real GitHub account (the API returns 404). Do not copy that handle here — a
# CODEOWNERS entry naming a nonexistent user silently disables the review gate
# it appears to configure.
#
# What is deliberately NOT owned: `specifications/` and `conformance/` carry the
# normative contract, and GOVERNANCE.md still requires maintainer approval for a
# breaking change to either. That obligation is now social rather than
# mechanical — ADR 0015 records the trade and why it was accepted.

* @justinmerrell

# The normative surface. A change here obligates the CLI, the API, and every
# SDK, and a compatibility break requires a new major directory.
/specifications/ @justinmerrell
/conformance/ @justinmerrell

# Architecture decisions are immutable once accepted; supersede, never rewrite.
/docs/adr/ @justinmerrell

# Release, publication, and branch-protection configuration.
/.github/ @justinmerrell
# The gate cannot be changed unreviewed. GitHub's own recommendation: protect
# CODEOWNERS with CODEOWNERS, and protect the ruleset the same way — between
# them these two paths are the whole control.
/.github/CODEOWNERS @justinmerrell
/.github/rulesets/ @justinmerrell
14 changes: 14 additions & 0 deletions .github/CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,13 +88,27 @@ case for it.
`check:conformance` fails otherwise, and the only way out is an entry in the
runner's `UNCOVERED` list saying why the code cannot be exercised.

### How your pull request merges

There is no blanket review requirement. A pull request that touches no path
listed in [`.github/CODEOWNERS`](CODEOWNERS) — which today is only the review
gate's own two configuration files — merges once the required checks are green:
`Lint`, `Schema`, `Site Build`, and `Signed off`. Nobody has to approve it.

That is a deliberate trade, not an oversight. It puts the weight on the checks
below, which is where it belongs for a repository whose contract is machine
verifiable. [ADR 0015](../docs/adr/0015-selective-code-owner-review.md) explains
it, and GOVERNANCE.md still asks for a maintainer's eyes on a change of
consequence even where nothing blocks the merge.

`task check` runs, in order:

| Step | What it enforces |
|---|---|
| `check:format` | Biome formatting and lint of `tools/` |
| `check:types` | TypeScript typecheck of `tools/` |
| `check:config` | The `.config/` layout: every file indexed, reachable, and a declaration (CFG-01..CFG-08) |
| `check:rulesets` | The two halves of the review gate agree, and no required status check can hang a pull request (RUL-01..RUL-09) |
| `check:schema` | Every `src/` module is valid JSON Schema 2020-12; `$id`s are unique and canonical; no remote `$ref` |
| `check:drift` | The committed `dist/` bundle matches a fresh compile of `src/` |
| `check:examples` | Every file in `examples/` validates against its family's bundle |
Expand Down
113 changes: 104 additions & 9 deletions .github/rulesets/RULESETS.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,18 +33,104 @@ gh api -X PUT repos/musher-dev/spec/rulesets/<id> \

Protects the default branch:

- **Pull request required**, one approving review, **code-owner review
required**. Every change here alters a public contract; CODEOWNERS review is
the compatibility gate described in GOVERNANCE.md.
- **Stale reviews dismissed on push**, so an approval cannot survive a rewrite.
- **Pull request required**, with **no blanket approval** and **code-owner
review required** — see the next section, which is the whole point of the
file.
- **Linear history**, squash-merge only. The specification's history should read
as a sequence of deliberate changes.
- **Required status checks**: `Lint`, `Schema`, `Site Build`, `Signed off`.
`Schema` is the one that matters most — it carries the bundle drift gate.
`Site Build` carries the publication-ledger gates. `Signed off` is the DCO
check CONTRIBUTING.md requires.
- **Required status checks**: `Lint`, `Schema`, `Site Build`, `Signed off`,
each pinned to `integration_id: 15368` so only the GitHub Actions app can
satisfy them. `Schema` is the one that matters most — it carries the bundle
drift gate. `Site Build` carries the publication-ledger gates. `Signed off`
is the DCO check CONTRIBUTING.md requires.
- **Deletion and force-push blocked.**

### The selective code-owner review gate

Two parameters are paired deliberately, and neither means anything alone:

```json
"required_approving_review_count": 0,
"require_code_owner_review": true
```

A pull request touching no path in `.github/CODEOWNERS` merges on green CI; one
touching an owned path also needs that owner's approval, with every review
thread resolved. `0` is GitHub's sanctioned "no blanket reviewers" value, and
the code-owner requirement is evaluated per changed file.
[ADR 0015](../../docs/adr/0015-selective-code-owner-review.md) says why.

Six invariants keep the mechanism working. Each one breaks it *silently* — the
first four are checked by `task check:rulesets` (RUL-05..RUL-09); the last two
are only visible against live GitHub state:

1. **No `*` catch-all in `.github/CODEOWNERS`.** It makes every pull request
code-owned, which is the blanket gate again wearing a different hat.
2. **`require_last_push_approval` stays `false`.** With zero required approvals
it produces a self-contradictory, unmergeable state.
3. **Every required context must name a job some workflow publishes**, and that
workflow must not filter on `paths:`. Either mistake yields a context that
never reports and a pull request that hangs forever. This is why the
validator runs inside the existing `Lint` job rather than as a workflow of
its own.
4. **Every root-anchored CODEOWNERS pattern must resolve to a real path.**
CODEOWNERS fails open: a stale entry reads as ownership and grants none. So
does an owner without explicit write access, or an invisible team.
5. **No classic branch protection rule may coexist on `main`.** Classic rules
and rulesets aggregate most-restrictive, so a leftover rule requiring one
approval silently restores blanket review. `gh api
repos/musher-dev/spec/branches/main/protection` must return `404`.
6. **No org-level ruleset may impose an approval count on this repository.**
Same aggregation. `spec` is deliberately absent from the org `pr-workflow`
ruleset's include list; do not add it.

**An owner's own pull requests are exempt.** GitHub cannot request a review from
the author, so authorship waives the requirement for the patterns that author
owns. The gate protects owned paths from *other* contributors, not from their
owner. `.github/workflows/codeowners-notice.yml` posts a sticky comment on
self-owned edits so the waiver is at least visible.

### Changing the review gate

The aggregation traps in invariants 5 and 6 are undetectable offline, so a
change to the `pull_request` rule runs this sequence rather than just an apply:

1. Reconcile any drift first (see below); do not layer a change on top of one.
2. `gh api repos/musher-dev/spec/branches/main/protection` — a `404` is the
desired answer.
3. `gh api repos/musher-dev/spec/rules/branches/main` lists every rule that
actually applies, whatever its source. Confirm no org-sourced `pull_request`
rule carries a nonzero `required_approving_review_count`.
4. Apply via the `PUT` recipe above.
5. Verify **both directions** with two throwaway pull requests: one touching no
owned path must show zero approvals required and merge on green CI; one
touching `.github/rulesets/` must block awaiting a code owner. Open the
second from a non-owner account, since an owner's own pull request is waived
by design.

### Detecting drift

Drift is the live ruleset diverging from these files because someone edited it
in the UI. `task check:rulesets` cannot see it: the default `GITHUB_TOKEN` lacks
`administration: read`, so a workflow-based detector would need a long-lived
secret or would fail open. It is an operator check — run it during a security
review, or whenever review behaviour surprises you:

```sh
for pair in "20585885:main-branch.json" "20585889:release-tags.json"; do
id="${pair%%:*}"; file=".github/rulesets/${pair##*:}"
diff -u <(jq -S '{name,target,enforcement,bypass_actors,conditions,rules}' "$file") <(gh api "repos/musher-dev/spec/rulesets/$id" --jq '{name,target,enforcement,bypass_actors,conditions,rules}' | jq -S .) && echo "in step: $file" || echo "DRIFT: $file"
done
```

The live response also carries `dismissal_restriction`, which GitHub supplies
and `PUT` does not require; it is the one expected difference.

If they diverge, decide which side wins. **File wins** — reapply with the `PUT`
recipe. **Live wins** — re-export into the file and open a pull request
explaining the change. Do not leave it unresolved: a ruleset nobody can predict
from the repository is a ruleset nobody reviews.

## `release-tags.json`

Makes releases immutable:
Expand All @@ -67,7 +153,16 @@ become a published version. See
## Bypass

`main-branch` allows `OrganizationAdmin` bypass, matching the org-level
convention. That is an escape hatch for incident response, not a workflow.
convention. That is an escape hatch for incident response, not a workflow — and
until 2026-08-25 it was the workflow, spent on eight consecutive merges because
the blanket approval requirement could not be satisfied by the only maintainer.
The selective gate above exists so that a bypass in the audit log is a signal
again.

**`release-tags` declared `bypass_actors: []` and the live ruleset granted
`OrganizationAdmin` bypass anyway** — the file had never been re-applied. It was
reconciled in the file's favour on 2026-08-25, which is what the paragraph below
has always claimed.

**`release-tags` allows no bypass at all.** Using a bypass there means mutating
a published artifact, which is the one thing this repository promises never
Expand Down
16 changes: 9 additions & 7 deletions .github/rulesets/main-branch.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
},
"bypass_actors": [
{
"actor_id": 1,
"actor_id": null,
"actor_type": "OrganizationAdmin",
"bypass_mode": "always"
}
Expand All @@ -22,11 +22,13 @@
{
"type": "pull_request",
"parameters": {
"required_approving_review_count": 1,
"dismiss_stale_reviews_on_push": true,
"required_approving_review_count": 0,
"dismiss_stale_reviews_on_push": false,
"required_reviewers": [],
"require_code_owner_review": true,
"require_last_push_approval": false,
"required_review_thread_resolution": true,
"require_extra_approval_for_unattributed_changes": true,
"allowed_merge_methods": ["squash"]
}
},
Expand All @@ -36,10 +38,10 @@
"strict_required_status_checks_policy": true,
"do_not_enforce_on_create": false,
"required_status_checks": [
{ "context": "Lint" },
{ "context": "Schema" },
{ "context": "Site Build" },
{ "context": "Signed off" }
{ "context": "Lint", "integration_id": 15368 },
{ "context": "Schema", "integration_id": 15368 },
{ "context": "Site Build", "integration_id": 15368 },
{ "context": "Signed off", "integration_id": 15368 }
]
}
}
Expand Down
Loading