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 |
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 (citecheck + depexceptions + deprecations + docscheck + releasegatecheck + governancecheck) |
Go server binary is byte-reproducible | reproducibility.yml |
codecov/patch/backend | Codecov |
codecov/patch/frontend | Codecov |
codecov/patch/android | Codecov |
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 citation-drift.ignore and crypto-surface.ignore are — 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.