ADR-0003 — CI gating & branch-protection standard
ADR-0003 — CI gating & branch-protection standard
Section titled “ADR-0003 — CI gating & branch-protection standard”Status: Accepted — 2026-06-14 Context: P2 SP3 of the 2026-06-13 methodology audit. Builds on ADR-0001 (methodology home) and the P0 CI/security baseline.
Context
Section titled “Context”P0 gave every repo CI workflows, but the audit’s follow-up (P2 SP3) found the gates were not actually enforced:
- No coverage gate on the 3 .NET repos.
coverlet.collectorwas present in every test project but CI never collected coverage — a refactor could delete tested behaviour with no red. OnlyVerbara.Platform.Webhad a coverage ratchet (vitest thresholds). mainunprotected on Platform, Web, and Pro (verified viarepos/{r}/rules/branches/main): CI ran on PRs but nothing blocked merging a red PR. OnlyVerbara.Sdkwas protected.- Double CI run per PR on Platform/Pro/Web:
on: pull_request + push:[main]ran CI on the PR (against the simulated mergerefs/pull/N/merge) and again on the post-merge push to main. For serial (solo) merging the second run re-tested the exact tree the first already validated — pure redundancy. (concurrencycannot dedup it: the two runs have differentgithub.ref.)Verbara.Sdkalready avoided this (merge queue, nopush:[main]).
Decision
Section titled “Decision”1. Coverage ratchet (every shippable repo)
Section titled “1. Coverage ratchet (every shippable repo)”A blocking CI job that fails when line coverage drops below a committed floor.
- .NET (
Sdk,Pro,Platform):coverlet.collector(already present) + a repo-rootcoverlet.runsettings+ ReportGenerator merge +scripts/check-coverage-floor.pyreading a committedcoverage-floor.json. ReportGenerator is a pinned local tool (.config/dotnet-tools.json). - Web: vitest
coverage.thresholdsinvitest.config.ts(the original pattern this mirrors). coverlet.runsettingsexcludes: test assemblies, generated code ([GeneratedCode]), migrations, and anysrc/assembly whose only tests are container-backed (Testcontainers Postgres/Redis) — those sit at 0% in the unit subset and would dilute the number and mask regressions. Each repo lists its own (e.g. Platform:Storage.Postgres,Identity.Redis).- Floor = ⌊measured baseline⌋ − 2 (≈2-point slack so the first green is stable). Line is the only blocking metric; branch/method are reported but advisory. Manual ratchet: the floor is raised by hand in a normal PR when coverage improves — no CI write-back to the repo.
- Measure the same subset CI runs (reuse the unit-test filter verbatim) — the floor must track the population the gate evaluates.
Baselines / floors (2026-06-14): Platform line 77.4% → floor 75; Sdk 80.4% → 78;
Pro 74.4% → 72; Web per vitest.config.ts. Per-repo detail in each repo’s
docs/research/2026-06-14-*-coverage-baseline.md.
2. CI trigger policy (kill the double-run)
Section titled “2. CI trigger policy (kill the double-run)”ci.yml:on: pull_request + merge_group— droppush:[main]. The merge queue validates the tentative merge result once before it lands; the PR run validates each push. No redundant post-merge run.codeql.yml: keeppush:[main](pluspull_request). The security baseline must live on realmain, and themerge_groupref is notmain, so CodeQL cannot maintain it from the queue.- Merge-queue required-check rule: a check is only available on the
merge_groupref if its workflow triggers onmerge_group. Required-for-queue checks therefore = theci.ymljobs only (build, tests, coverage). CodeQL / dependency-review run on their own triggers and gate the PR, but are not queue-required — making them required would hang the queue waiting for a check that never reports on the merge group. (To make CodeQL queue-blocking, addmerge_groupto its workflow, asVerbara.Sdkdoes.)
3. Branch protection
Section titled “3. Branch protection”A main-protection ruleset per repo (target = default branch, enforcement = active):
require a PR (0 approvals — solo), required status checks (the merge_group-compatible ci.yml
jobs), merge queue (SQUASH, group 5, 60-min timeout), block force-push + deletion.
4. Known limitation — private repos on the free plan
Section titled “4. Known limitation — private repos on the free plan”GitHub rulesets and branch protection require a paid plan (Pro/Team) for private repositories.
Verbara.Sdk.Pro is private + closed-source, so it cannot enforce protection on the current
plan (POST /rulesets → 403 “Upgrade to GitHub Pro or make this repository public”). Decision:
accept manual discipline for Pro — its CI + coverage run and are visible on every PR; do not
merge red. Revisit (GitHub Team, ~$4/user/mo) if a second contributor joins. Public repos
(Sdk, Platform, Web) are fully enforced.
Consequences
Section titled “Consequences”- Gates now block on Sdk, Platform, Web (Pro: visible-but-advisory by plan limitation).
- One CI run per PR (no double-run); merge queue gives “main green” without redundancy.
- All four repos converge on one model (Sdk was the template).
- A new metric/floor is a small, reviewable, manual change — no bot commits.
Replication runbook (adding the standard to a new .NET repo)
Section titled “Replication runbook (adding the standard to a new .NET repo)”- Add
dotnet-reportgenerator-globaltoolto.config/dotnet-tools.json(pinned). - Add repo-root
coverlet.runsettings(excludes above + the repo’s container-only src assemblies),coverage-floor.json(placeholder),scripts/check-coverage-floor.py; gitignorecoverage/. - Measure locally (build +
dotnet test <filter> --collect:"XPlat Code Coverage" --settings coverlet.runsettings+ ReportGenerator merge); set floor = ⌊baseline⌋ − 2; record indocs/research/. - Add a
Coverage Ratchetjob toci.yml(same unit filter + collect + merge + floor check + artifact). Setci.ymltriggers topull_request + merge_group; leavecodeql.ymlonpush. - Create the
main-protectionruleset (public repos) with required checks = theci.ymljobs. Private repos: paid plan required, else manual discipline.
Status (2026-06-14)
Section titled “Status (2026-06-14)”| Repo | Coverage floor | ci.yml triggers | main protected |
|---|---|---|---|
| Verbara.Sdk | 78 | pull_request + merge_group | ✅ (classic + merge-queue ruleset) |
| Verbara.Platform | 75 | pull_request + merge_group | ✅ (main-protection ruleset) |
| Verbara.Platform.Web | vitest | pull_request + merge_group | ✅ (main-protection ruleset) |
| Verbara.Sdk.Pro | 72 | pull_request + merge_group | ⚠️ none — private+free plan limit (manual discipline) |