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) — deletion and non_fast_forward blocked, no bypass actors: main cannot be deleted or force-pushed by anyone.
  • main-protection (.github/rulesets/main-protection.json) — every change to main goes through a pull request (squash or rebase merge only), with required_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: always bypass (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 reports failure, 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.json or the ruleset, and governancecheck, mainalertcheck and nightlyalertcheck do not apply (it has no schedule: trigger and carries no per-pr gate).
  • Why push, not pull_request_target. The push event runs the workflow from main with 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 on success, neutral or skipped (a path-filtered skip is not a bypass); a commit status (e.g. codecov/patch/*) passes on success. 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 as failed (including cancelled, which is what the branch reaper produces for jobs still running at merge); a run still queued/in progress or a pending status is incomplete (merged before the check finished); no result at all is missing.
  • 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 incomplete and opened a p1 for a PR that went green minutes later. Now failed/missing alert immediately (the wait ends at once), and when the only non-green contexts are unfinished cmd/bypassedcheck re-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 no started_at sorts 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):

  1. DCO on every commit. Every commit merged here carries a Signed-off-by: line certifying origin under the project licence; the Check commit sign-off status check (dco.yml) enforces it on every PR. This is attribution, not a cryptographic signature.
  2. The load-bearing paths are already GitHub-Verified. A squash-merge to main is signed by GitHub’s web-flow key and shows Verified. The release-registration commit and every v* tag are pushed by the release GitHub App, which GitHub also marks Verified.
  3. 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_signatures rule on main-protection is the switch.

Workflow token permissions

Continuing what is already in place (#315):

  • Every workflow sets permissions: contents: read at 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.yml is the only workflow that elevates meaningfully: create-release gets contents: write (the Release), build-and-push gets packages: write + id-token: write + attestations: write (push + keyless sign + provenance), build-android-apk gets contents: write + id-token: write + attestations: write. validate-tag, release-gate, scan, and verify-release-assets stay read-only.
  • No long-lived credential except RELEASE_APP_PRIVATE_KEY, which mints a token scoped to contents: write only, used by one workflow_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.


Back to top

Mycorrhizal CRM - Self-hosted personal contact management

This site uses Just the Docs, a documentation theme for Jekyll.