Repository governance
This is the canonical statement of the repository’s protection settings (#508). Branch and tag rulesets live in GitHub’s UI, where they are invisible to review and can be changed silently. This page — plus the committed desired-state in .github/rulesets/ — is the source of truth an admin applies from, and .github/workflows/governance-drift.yml diffs the live rulesets against it weekly.
v0.7.0’s premise is that mandatory gates cannot be bypassed. A gate enforced only inside a workflow can be bypassed by pushing around the workflow, so these settings are part of release integrity, not a separate administrative concern.
This page is contributor- and maintainer-facing; the go run / gh api blocks assume a repo checkout and the GitHub CLI.
The main branch
Two rulesets, both enforcement: active, on refs/heads/main:
main-hard-checks(.github/rulesets/main-hard-checks.json) —deletionandnon_fast_forwardblocked, no bypass actors:maincannot be deleted or force-pushed by anyone.main-protection(.github/rulesets/main-protection.json) — every change tomaingoes through a pull request (squashorrebasemerge only), withrequired_linear_history,dismiss_stale_reviews_on_push,require_extra_approval_for_unattributed_changes, and the required status checks below. Bypass: the repo Admin role and the release GitHub App (which pushes the release-registration commit).
required_approving_review_count is 0 — this is a solo project; the value of the PR rule here is the required checks and the linear-history / squash constraints, not human review count.
Required status checks
These are generated from .github/release-gates.json (REL-03, #447): every gate that is tier: per-pr, mandatory: true, and has a stable check context. go run ./cmd/governancecheck fails the build if main-protection.json and the gate registry disagree, so the branch-protection list can never drift from the gate list.
| Check | Reports from |
|---|---|
Detect Changes | unit-tests.yml (path-filter job; always green, required so path-skipped suites can be required) |
Backend (Go) | unit-tests.yml |
Frontend (Vitest) | unit-tests.yml |
Frontend bundle-size budget | unit-tests.yml (path-gated; fails on yarn build && yarn budget growth past frontend/bundle-budget.json) |
Run E2E Tests | e2e-tests.yml |
Android (Gradle) | android-tests.yml |
Android E2E (emulator) | android-tests.yml |
Android scan (mobsfscan) | sast.yml |
Scan workflows (zizmor) | zizmor.yml |
CIS container hardening scan | container-hardening.yml |
Docs & security-doc citations | unit-tests.yml (typos + citecheck + depexceptions + deprecations + docscheck + releasegatecheck + governancecheck; app-token full-history gitleaks scan (rule-only base config, #1877); path-gated third-party notices drift on dependency changes, #1944) |
Go server binary is byte-reproducible | reproducibility.yml |
codecov/patch/backend | Codecov |
codecov/patch/frontend | Codecov |
codecov/patch/android | Codecov |
Alerting on a red main (bypass / early merge)
The required checks above are what should gate a merge, but two paths leave a change on main that they never actually verified:
- A bypass merge. The repo Admin role holds a
bypass_mode: alwaysbypass (above), so an admin can merge with required checks still running or red. - A merge before the checks finish.
reap-branch-runs.yml’s “Cancel remaining runs for the branch” job cancels a merged branch’s still-in-flight workflow runs (GitHub does not cancel them on its own), so the required jobs a PR was waiting on can be killed at merge — the gate job then reportsfailure, but the merge already landed.
In both cases the post-merge push run of the workflow on main is the only verification the commit gets, and until issue #1568 nothing watched it: nightly-failure-alert.yml opens issues only for schedule runs, so a red main was visible only to whoever opened the Actions tab. .github/workflows/main-failure-alert.yml closes that gap. On a failed push: main completion it opens (or comments on) a nightly-failure+p1-labelled Main failure: <workflow name> issue carrying the run URL, the head SHA, and the originating PR; on the next green completion it comments and closes the issue. Its registration list must cover every workflow that carries a mandatory per-pr gate — go run ./cmd/mainalertcheck fails the build, in the same docs-citations job and pre-commit block as nightlyalertcheck, when one is missing.
Merge-bypass alert (issue #1661)
main-failure-alert.yml watches main after the merge; it does not say that a required check was not green when the PR merged — PR #1642 landed with Backend (Go) red and the drift went unnoticed for days. .github/workflows/bypassed-check-alert.yml closes that: on every push to main it resolves the merged PR (commits/{sha}/pulls; a direct push with no PR is skipped), runs go run ./cmd/bypassedcheck against the PR head SHA, and — if any context in .github/rulesets/main-protection.json required_status_checks was not green — opens (or comments on) a ci+p1 issue Merged with non-green required checks: PR #N naming the PR, the merger and each context.
- Advisory, not a gate. It cannot block a merge that already happened, so it is deliberately not in
.github/release-gates.jsonor the ruleset, andgovernancecheck,mainalertcheckandnightlyalertcheckdo not apply (it has noschedule:trigger and carries no per-pr gate). - Why
push, notpull_request_target. The push event runs the workflow frommainwith no PR-controlled context, so there is no privileged-trigger surface for zizmor to flag and PR code can never reach the job (token permissions:contents/checks/statuses/pull-requests: read,issues: write). - What “green” means (
backend/internal/bypassedcheck, table-tested): mirrors GitHub’s required-check semantics — a check run passes onsuccess,neutralorskipped(a path-filtered skip is not a bypass); a commit status (e.g.codecov/patch/*) passes onsuccess. Check runs and statuses of one context are pooled and the latest wins, so a green re-run supersedes an earlier red. Every other result is reported asfailed(includingcancelled, which is what the branch reaper produces for jobs still running at merge); a run still queued/in progress or apendingstatus isincomplete(merged before the check finished); no result at all ismissing. - Racing the checks (alerts #1676/#1681). The push event fires the instant the PR merges, so required checks are routinely still queued/in progress; evaluating once reported those as
incompleteand opened a p1 for a PR that went green minutes later. Nowfailed/missingalert immediately (the wait ends at once), and when the only non-green contexts are unfinishedcmd/bypassedcheckre-evaluates every 2 min for up to 40 (-poll/-wait-max; the job timeout is 55 min) and the alert reports the settled state: “merged before X finished; it later finished red” and “still incomplete after 40 min” open the issue; “merged before X finished; it later finished green” is a job-summary line only, with no issue. Reasoning: the alert exists for the “gates are not silently weakened” case, a required gate that did not hold. A check that finished green did hold, the fact is still recorded on the run (not silent), and a p1 issue per early-but-green merge would train the maintainer to ignore the alert that matters. A check that never finishes or finishes red keeps the full p1. A queued re-run with nostarted_atsorts as the newest result for its context, so it is not hidden behind an older completed one. - Manual test: dispatch the workflow with a merged
pr_number;dry_run(default true) logs the issue body without writing it.
Code ownership
.github/CODEOWNERS routes review of the paths a quality gate derives its verdict from — the per-file coverage ratchets, the gate registry/ruleset/path-filter, the analyzer and budget configs, and the generated contracts/schema dumps (#1243). It is routing, not access control: main-protection leaves require_code_owner_review at false (see above), so the requested review is visible but not merge-blocking and the solo maintainer still self-merges. That is the deliberate decision, not an omission — the ruleset already grants the repo Admin role a bypass_mode: always bypass, so enabling require_code_owner_review today would be a no-op for the only person who merges and would bind no one else; it only ever bites a future non-admin contributor. Flip require_code_owner_review to true in main-protection.json (and release-branches.json) and apply it live when a second maintainer appears, since GitHub never counts the PR author’s own approval toward a required code-owner review. The gate-adding procedure and rationale are in .github/AGENTS.md “Adding a quality gate”.
Release tags
v-tag-protection (.github/rulesets/tags-v.json) — a new ruleset, target: tag, refs/tags/v*, enforcement: active: update, deletion, and non_fast_forward blocked. Once a v* tag exists it is immutable. A v* tag is what triggers docker-publish.yml, which makes it a higher-value target than the branch. Creation is not restricted (that is release.yml’s job, via the release App); the App is the sole bypass actor so an emergency retag is possible through the one-dispatch release path and nothing else.
Release branches
release-branch-protection (.github/rulesets/release-branches.json) — a new ruleset, target: branch, refs/heads/release/*, enforcement: active. A release-candidate series (v1.0.0-rc.N) is cut from release/vX.Y.0 and iterated there while main keeps moving (RC-02, issue #446; full policy in docs/release-candidate-process.md). The ruleset carries every required status check main-protection has, plus the RC-only checks in backend/internal/governance.ReleaseOnlyRequiredChecks — currently just RC fix is traceable to a finding (rc-fix.yml, RC-02 action 4, issue #925), which has no main counterpart because it only applies to PRs targeting release/**. backend/internal/governance.CheckReleaseBranchesMatchMain fails the build if release-branches.json ever requires less than main-protection plus that declared extra, or requires anything else undeclared, so “RC gates match release gates” (#446 action 7) is enforced, not aspirational — plus required_linear_history, deletion, and non_fast_forward (a published RC’s history is immutable). Bypass actors: the repo Admin role and the release GitHub App (release.yml cuts RC tags from release/*; promote-rc.yml commits the final schema fixture there and merges the branch back into main).
Protected release environment
docker-publish.yml’s publishing jobs (create-release, build-and-push, build-android-apk) declare environment: release. Add required reviewers to that environment in Settings → Environments → release to gate publication behind a human approval, so a compromised PR cannot reach the release path even if it reaches CI (#508 action 5, #513). Until reviewers are configured the environment reference is a no-op — the jobs run unchanged.
Commit signing
The decision, deliberately (#508 action 3):
- DCO on every commit. Every commit merged here carries a
Signed-off-by:line certifying origin under the project licence; theCheck commit sign-offstatus check (dco.yml) enforces it on every PR. This is attribution, not a cryptographic signature. - The load-bearing paths are already GitHub-Verified. A squash-merge to
mainis signed by GitHub’s web-flow key and shows Verified. The release-registration commit and everyv*tag are pushed by the release GitHub App, which GitHub also marks Verified. - Per-commit GPG/SSH signature is NOT required. Requiring every contributor commit to carry a verified signature is friction disproportionate to a solo hobby project when (1) and (2) already cover the paths that reach a release. Revisit this if the project gains multiple maintainers or an external contributor base — a
required_signaturesrule onmain-protectionis the switch.
Workflow token permissions
Continuing what is already in place (#315):
- Every workflow sets
permissions: contents: readat the top level (all 32; a change that adds a workflow without one should be caught in review). A job elevates only what it needs. docker-publish.ymlis the only workflow that elevates meaningfully:create-releasegetscontents: write(the Release),build-and-pushgetspackages: write+id-token: write+attestations: write(push + keyless sign + provenance),build-android-apkgetscontents: write+id-token: write+attestations: write.validate-tag,release-gate,scan, andverify-release-assetsstay read-only.- No long-lived credential except
RELEASE_APP_PRIVATE_KEY, which mints a token scoped tocontents: writeonly, used by oneworkflow_dispatch-only workflow with no pull-request path. Every signing operation uses OIDC-federated Sigstore (id-token: write), never a stored key.
Applying and verifying
Apply the branch rulesets (find each <id> with gh api /repos/DrewBrunning/mycorrhizal-crm/rulesets):
# assumes: GitHub CLI, admin on the repo
gh api --method PUT /repos/DrewBrunning/mycorrhizal-crm/rulesets/<main-protection-id> \
--input .github/rulesets/main-protection.json
gh api --method PUT /repos/DrewBrunning/mycorrhizal-crm/rulesets/<main-hard-checks-id> \
--input .github/rulesets/main-hard-checks.json
# tags-v and release-branches do not exist yet -- create them:
gh api --method POST /repos/DrewBrunning/mycorrhizal-crm/rulesets \
--input .github/rulesets/tags-v.json
gh api --method POST /repos/DrewBrunning/mycorrhizal-crm/rulesets \
--input .github/rulesets/release-branches.json
Then verify the protections actually hold (#508 action 7) — each of these must be refused:
# assumes: a clean checkout of main, GitHub CLI
git commit --allow-empty -m "probe" && git push origin main # -> rejected (PR required)
git push --force origin main # -> rejected (non_fast_forward)
git tag -f v0.6.12 && git push --force origin refs/tags/v0.6.12 # -> rejected (tag update blocked)
and confirm a PR whose required checks are red cannot be merged.
governance-drift.yml is the standing alarm if any of the live rulesets later diverge from .github/rulesets/. Reading rulesets over the API needs Administration: read, which the built-in GITHUB_TOKEN cannot be granted — set an optional repo secret GOVERNANCE_READ_TOKEN (a fine-grained PAT scoped to this repo with Administration: read) to enable the ruleset-drift job; without it that job records a note and exits 0 (the one deliberately non-failing path — the token is optional operator configuration, not a repository-state problem), and the cosign-identity-probe job still runs regardless.
With the token present, both jobs fail on real drift (issue #916 / #502 finding F4 — before that fix, both jobs were continue-on-error with no failing exit path at all, so a live protection weakened in the GitHub UI, or a released image that no longer verified against the pinned cosign identity, produced only an ignorable job-summary warning). Neither job is in the main-protection required-check list, so a failure here does not block a PR merge; it shows as a red job on the weekly schedule run (which triggers GitHub’s workflow-failure notification) and as a visible, non-blocking check on a PR that touches the paths this workflow watches. ruleset-drift’s per-ruleset decision (cmd/rulesetdrift) treats a committed ruleset with no live counterpart the same as a normalized-JSON mismatch — both are drift. A maintainer who is deliberately adjusting a setting mid-reconciliation records that in docs/security/governance-drift.ignore (one <ruleset name> # <reason> line) rather than by reintroducing continue-on-error; the ignore file is bidirectional the same way crypto-surface.ignore is — an entry for a ruleset that is back in sync, or that names no committed ruleset, fails the job too, so it cannot accumulate dead suppressions.