Release gates

This is the canonical mandatory-gate statement (REL-03, issue #447). It defines which checks must pass before a v* tag is allowed to publish, at which tier, with what explicit pass/fail criterion, and how a failure actually blocks publication rather than being noticed afterward.

The machine-readable twin is .github/release-gates.json. cd backend && go run ./cmd/releasegatecheck (a Docs & security-doc citations step, every PR) fails the build if the JSON is malformed, if any gate names a workflow that does not exist, or if this table and the JSON disagree. It also asserts every workflow a release_gate:true or release-tier gate names is composable (declares workflow_call) and that the composer release-validate.yml calls exactly that set — no omission, no extra — so the release orchestrator of ADR 0021 cannot drift from the registry and needs no dispatch-and-poll. The release-gate job in docker-publish.yml reads the same JSON.

This page is contributor- and maintainer-facing; it assumes a repo checkout and the Go toolchain for the go run command above.

Tiers

Tier Meaning
per-pr Fast enough for every pull request. Blocks merge via the main-protection ruleset, and is re-checked on the release commit by the release-gate job (for the subset marked release_gate in the JSON).
release-internal A job inside docker-publish.yml. Enforced by that workflow’s needs: graph — if it fails, no release, no images, no APK.
release-tier Too slow for every PR (nightly / push: main / on-dispatch). The REL-06 release workflow (#499), release.yml, triggers the ones with no push: main trigger (min-version-tests, zap-dast) and waits on every release-tier run for the release commit before it pushes the tag — an observed failure means the tag is never pushed, a 75-minute deadline with a run still going is a ::warning:: and the tag proceeds. Because a failure at this stage leaves the fixture commit on main with no tag, release.yml is re-entrant there: re-dispatching the same version resumes at the new main tip (#1142).
advisory Runs and is visible, but a failure does not block a release. A regression is triaged, not gating.

How publication is blocked

Four mechanisms, in order of when they fire:

  1. Merge time — the main-protection branch ruleset requires the per-PR check contexts, so a failing gate cannot reach main in the first place (the merge-time counterpart is #508).

  2. Cut timerelease.yml (REL-06, #499) is the single human action that cuts a release. Its preflight job runs go run ./cmd/citecheck and go run ./cmd/releasegatecheck directly; its validate job then composes every gate through the reusable release-validate.yml — the release_gate: true per-PR checks and the release-tier suites are invoked with needs: dependencies as the same workflow files the per-PR/push: main/schedule paths run, so there is one definition of each check and nothing to poll (ADR 0021, issue #1162). A failure stops the run before the tag. Two release-only obligations are enforced in the release job before the tag: the ASVS/MASVS re-verification changelog row (#608) and the per-release adversarial delta (#953), each with a recorded ack_* dispatch escape. dry_run: true runs the whole battery and the fixture regeneration but makes no commit, push, or tag, and is exercised weekly by release-dry-run.yml (#929).

  3. Publication timedocker-publish.yml’s release-gate job calls the same release-validate.yml before anything publishes (ADR 0021, issue #1165). Because the tag-triggered path and the cut-time path compose one workflow, they cannot drift. create-release, build-android-apk, and schema-fixture-gate needs: the gate (or the sibling release-gate-override job), so a failed gate blocks publication. The only way past it is to run docker-publish.yml from the Actions tab (Run workflow) with a non-empty override_reason; the gate is skipped, release-gate-override records RELEASE GATE OVERRIDDEN by <actor>: <reason> to the run summary, and publication proceeds — explicit, logged, attributed. A tag push has no override path.

  4. The needs: graph — the release-internal gates enforce themselves: build-and-push needs: schema-fixture-gate + build-android-apk, create-release needs: build-android-apk, verify-release-assets is the final belt-and-suspenders check that the APK, its cosign bundle, the SLSA provenance, release-metadata.json and SHA256SUMS actually landed on the Release and every image tag resolves.

No gate is polled. ADR 0021 replaced the dispatch-and-poll layer (and its release-gate-state.sh / release-gate-decide.sh special cases for missing check-runs, stale re-dispatches, and needs:-gated fan-in jobs) with workflow_call composition. See ADR 0021 for why.

The Android decision (issue #527)

A release hard-blocks on a green, keystore-signed, apksigner-verified APK that lands on the GitHub Release. create-release and build-and-push both needs: build-android-apk, so a signing or build failure blocks the entire release — Docker images included. This deliberately reverses the earlier “a signing problem here shouldn’t block the Docker images” decoupling: v0.7.0’s premise is that a release is one artifact set, and the Android client is part of it.

build-android-apk now, after assembleRelease:

  • runs apksigner verify --print-certs and fails if the APK is not signed (v2/v3 scheme). When the repo variable ANDROID_SIGNING_CERT_SHA256 is set, it also asserts the signer certificate SHA-256 matches; otherwise it warns that the cert is not pinned. Set that variable to the release keystore’s cert fingerprint to enforce.
  • asserts the built APK’s versionCode equals the value the workflow computed (1000 + GITHUB_RUN_NUMBER, strictly increasing per the versioning policy) and is > 1 — a light pin on the plugin’s override wiring so an in-place upgrade keeps working. It does not install anything.
  • asserts app-release.apk and mycorrhizal-apk.sigstore.json are present on the Release.

Known gap, accepted (issue #994): no gate here, or anywhere in CI, drives the release-signed/R8-minified APK through an instrumented test, and none installs release N then N+1 over it to confirm the offline mirror survives — the “full emulator test” #480 originally named. #480 (ANDROID-03) landed RoomMigrationEncryptedTest, which proves the Room migration chain against a real SQLCipher-encrypted file, and MigrationVersionCoverageTest, which guards that every version pair has a registered migration or a recorded destructive-fallback decision — but both run on the debug variant via the android-e2e job, not the minified release artifact, and neither performs a real two-APK install. PR #804 deliberately did not build the literal install-N-then-install-N+1 harness: versionCode monotonicity (asserted above) is what makes Android accept the install, which is orthogonal to whether the database migrates correctly once the new code runs — the thing the JVM/instrumented migration suite already proves directly. Disposition: accept, not built — see the “E2E Android (instrumented)” section of testing.md for where this is tracked.

PKCS12 keystore note: SIGNING_KEY_PASSWORD must equal SIGNING_STORE_PASSWORD for this keystore. A mismatch fails assembleRelease with an opaque padding error, not a clear message.

The gates

Generated view of .github/release-gates.json. releasegatecheck asserts every row here has a matching registry entry (name, tier, mandatory) and every workflow file exists.

Gate Tier Mandatory Pass criterion Workflow
Backend (Go) per-pr yes go build + go vet + gofmt clean and go test ./... -race passes; no package exceeds its -timeout. unit-tests.yml
Frontend (Vitest) per-pr yes tsc --noEmit and vitest run both pass. unit-tests.yml
Run E2E Tests per-pr yes the route-stubbed Playwright suite (including @perf specs) passes. e2e-tests.yml
Android (Gradle) per-pr yes testDebugUnitTest, lintDebug, detekt, and assembleDebug all pass. android-tests.yml
Android E2E (emulator) per-pr yes the instrumented suite passes against the docker-compose.test.yml backend on an API-35 emulator. android-tests.yml
Android scan (mobsfscan) per-pr yes mobsfscan reports no new high-severity finding on the Android sources. sast.yml
Scan workflows (zizmor) per-pr yes zizmor exits 0 — no finding above what zizmor.yml’s ignore list accepts. zizmor.yml
CIS container hardening scan per-pr yes the all-in-one image passes docker/cis-hardening.sh and the Trivy misconfig/secret scan with no CRITICAL/HIGH. container-hardening.yml
codecov/patch/backend per-pr yes changed Go lines are >= 95% covered (codecov.yml). Merge-time only: a PR-diff concept, not re-polled at publication. unit-tests.yml
codecov/patch/frontend per-pr yes changed TypeScript lines are >= 90% covered. Merge-time only. unit-tests.yml
codecov/patch/android per-pr yes changed Kotlin lines are >= 80% covered. Merge-time only. android-tests.yml
Detect Changes per-pr yes the shared path-filter job completes; always green (structural). Required so path-skipped suites can be required checks (#264). unit-tests.yml
Docs & security-doc citations per-pr yes citecheck + depexceptions + deprecations + docscheck + releasegatecheck all exit 0. Runs on every PR and nightly. release_gate: true — composed by the release gate and re-asserted at cut time, where release.yml also runs citecheck directly as a hard gate (#608). unit-tests.yml
Migration Tests per-pr yes every supported-release upgrade leg, adjacent hop, and down round-trip passes. Per-leg check names make polling impractical; the release commit only adds a frozen schema dump, which schema-fixture-gate verifies, and the push:main run covers the chain. migration-tests.yml
Go binary reproducible per-pr yes two builds from different paths are byte-identical (REL-04). Runs on the release commit’s push:main; not in the ruleset. reproducibility.yml
validate-tag release-internal yes the pushed tag matches the versioning-policy pattern (REL-01, backend/internal/versionpolicy). Blocks every downstream job. docker-publish.yml
release-gate release-internal yes calls the reusable release-validate.yml and blocks publication unless it passes: every release_gate:true per-PR check and every release-tier suite is composed with needs: (ADR 0021, issue #1165), so the tag-triggered path cannot drift from release.yml’s cut-time composition. A workflow_dispatch run with a non-empty override_reason skips the composed gate and the sibling release-gate-override job records the override with the actor. docker-publish.yml
schema-fixture-gate release-internal yes a committed backend/database/testdata/schemas/.sql exists for a mycorrhizal-supported-series tag (MIG-01, #436/#529). docker-publish.yml
build-and-push release-internal yes the multi-arch images build and push; each digest gets a cosign keyless signature, an SBOM, and SLSA build provenance. docker-publish.yml
build-android-apk release-internal yes the release APK assembles, is keystore-signed, apksigner verify passes (and matches ANDROID_SIGNING_CERT_SHA256 when set), its versionCode equals the computed value and is > 1, a GH build-provenance attestation + a cosign bundle are produced and attached to the Release, and its sha256 subject is exported for the SLSA generator. docker-publish.yml
apk-provenance release-internal yes the slsa-github-generator reusable workflow signs the APK subject and emits mycorrhizal-apk.intoto.jsonl (SLSA build provenance) as a workflow artifact (issue #355). docker-publish.yml
verify-release-assets release-internal yes attaches mycorrhizal-apk.intoto.jsonl and a SHA256SUMS manifest to the Release, then asserts the Release carries app-release.apk, mycorrhizal-apk.sigstore.json, mycorrhizal-apk.intoto.jsonl and SHA256SUMS, and every published image tag resolves in the registry. docker-publish.yml
Test minimum supported versions release-tier yes the app builds and the suite passes against each declared minimum runtime (COMPAT-02, #473). min-version-tests.yml
Android E2E (emulator, minSdk 26) release-tier yes the same instrumented suite as Android E2E (emulator) passes against the docker-compose.test.yml backend on an API-26 emulator — the declared minSdk floor (COMPAT-02, #473). Runs on push:main, nightly, and dispatch so the floor is exercised pre-tag, not just a number in a build file (issue #927). android-tests.yml
Migration at scale (large dataset) release-tier yes with MYCORRHIZAL_LARGE_TESTS=1, every supported release migrates to current at ~134x the canonical manifest with row counts and integrity intact (#495). migration-tests.yml
CardDAV real-server E2E release-tier yes a full round trip against the real reference servers matches the divergence register (#496). carddav-e2e.yml
Constrained-resource chaos release-tier yes the disk-full / mem-limited / cpu-limited jobs fail closed with no corruption (#498). chaos-tests.yml
Schemathesis API fuzzing release-tier yes no unhandled 500, no response that violates backend/openapi.yaml, no BOLA finding. schemathesis.yml
Differential E2E (calcard) release-tier yes our JSContact / iCalendar output matches the pinned reference implementations (#680). differential-e2e.yml
Reference-clients E2E release-tier yes vdirsyncer round-trips against our server with no data loss (#681); a real DAVx5 client discovers, syncs, and lands the canonical pathological fixture in Android’s ContactsContract with no divergence (#917). reference-clients-e2e.yml
ZAP DAST release-tier yes the baseline scan raises no new high-risk dynamic finding. zap-dast.yml
OpenSSF Scorecard advisory no informational supply-chain posture; a score drop is reviewed, never release-blocking. scorecard.yml
CodeQL advisory no SARIF is uploaded; a new alert is triaged in the Security tab, not release-blocking. codeql.yml
Grype vulnerability scan advisory no second-opinion CVE scan; the critical/high hard gate is on main + nightly, advisory at release time. grype.yml
TruffleHog secret scan advisory no verified-secret git-history scan; a hit is investigated immediately but is not a release job. trufflehog.yml
Stryker mutation testing advisory no frontend/stryker.conf.json’s thresholds.break fails the nightly run itself on a mutation-score drop below the ratchet (issue #915); still nightly-only and not release-blocking, so tier stays advisory. stryker.yml
Go mutation testing advisory no gremlins mutation testing against the safety-critical Go paths (migration/upgrade, backup/restore, delete cascade, import/export, data-integrity invariants — backend/internal/mutationscope.Scopes); each matrix leg’s generated config fails the nightly run itself below its recorded threshold (issue #915). Per-leg check names make polling impractical; nightly-only and not release-blocking, so tier stays advisory. go-mutation.yml
Android macrobenchmark advisory no cold/warm/hot startup and dashboard frame timing trend; continue-on-error by design (emulator variance). android-tests.yml
Reproducible image (all-in-one) advisory no double-build config + layer digest compare for the linux/amd64 image; continue-on-error until reliably green on main (REL-04, #448). reproducibility.yml
Release dry-run rehearsal advisory no weekly + on-demand: dispatches release.yml with dry_run:true against the last SupportedReleases entry and fails if that dry run fails; exercises the final-release path’s gate battery + fixture regeneration without cutting a release (issue #929). Not release-blocking – a failure means the automation regressed, triaged like any other advisory gate. release-dry-run.yml
Docker apk pin check advisory no nightly + on-demand: builds all three Dockerfiles with caching disabled, so a pinned Alpine apk version that upstream has pruned fails here days before it fails a release image build (the #1062/#1131 recurrence guard). Advisory because it is a time-based upstream-drift signal, not a repository change. docker-pin-check.yml

Adding or changing a gate

Edit .github/release-gates.json and the table above in the same PR (releasegatecheck fails otherwise). A new release_gate: true entry must be per-pr, have a real check_run context, and be added to the main-protection ruleset separately (that config lives in GitHub, tracked by #508). Moving a suite from release-tier to per-pr means it is now fast enough to poll — say so in the PR.


Back to top

Mycorrhizal CRM - Self-hosted personal contact management

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