Skip to content

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.

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.collector was present in every test project but CI never collected coverage — a refactor could delete tested behaviour with no red. Only Verbara.Platform.Web had a coverage ratchet (vitest thresholds).
  • main unprotected on Platform, Web, and Pro (verified via repos/{r}/rules/branches/main): CI ran on PRs but nothing blocked merging a red PR. Only Verbara.Sdk was protected.
  • Double CI run per PR on Platform/Pro/Web: on: pull_request + push:[main] ran CI on the PR (against the simulated merge refs/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. (concurrency cannot dedup it: the two runs have different github.ref.) Verbara.Sdk already avoided this (merge queue, no push:[main]).

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-root coverlet.runsettings + ReportGenerator merge + scripts/check-coverage-floor.py reading a committed coverage-floor.json. ReportGenerator is a pinned local tool (.config/dotnet-tools.json).
  • Web: vitest coverage.thresholds in vitest.config.ts (the original pattern this mirrors).
  • coverlet.runsettings excludes: test assemblies, generated code ([GeneratedCode]), migrations, and any src/ 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_groupdrop push:[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: keep push:[main] (plus pull_request). The security baseline must live on real main, and the merge_group ref is not main, so CodeQL cannot maintain it from the queue.
  • Merge-queue required-check rule: a check is only available on the merge_group ref if its workflow triggers on merge_group. Required-for-queue checks therefore = the ci.yml jobs 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, add merge_group to its workflow, as Verbara.Sdk does.)

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.

  • 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)”
  1. Add dotnet-reportgenerator-globaltool to .config/dotnet-tools.json (pinned).
  2. Add repo-root coverlet.runsettings (excludes above + the repo’s container-only src assemblies), coverage-floor.json (placeholder), scripts/check-coverage-floor.py; gitignore coverage/.
  3. Measure locally (build + dotnet test <filter> --collect:"XPlat Code Coverage" --settings coverlet.runsettings + ReportGenerator merge); set floor = ⌊baseline⌋ − 2; record in docs/research/.
  4. Add a Coverage Ratchet job to ci.yml (same unit filter + collect + merge + floor check + artifact). Set ci.yml triggers to pull_request + merge_group; leave codeql.yml on push.
  5. Create the main-protection ruleset (public repos) with required checks = the ci.yml jobs. Private repos: paid plan required, else manual discipline.
RepoCoverage floorci.yml triggersmain protected
Verbara.Sdk78pull_request + merge_group✅ (classic + merge-queue ruleset)
Verbara.Platform75pull_request + merge_group✅ (main-protection ruleset)
Verbara.Platform.Webvitestpull_request + merge_group✅ (main-protection ruleset)
Verbara.Sdk.Pro72pull_request + merge_group⚠️ none — private+free plan limit (manual discipline)