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:
-
Merge time — the
main-protectionbranch ruleset requires the per-PR check contexts, so a failing gate cannot reachmainin the first place (the merge-time counterpart is #508). -
Cut time —
release.yml(REL-06, #499) is the single human action that cuts a release. Itspreflightjob runsgo run ./cmd/citecheckandgo run ./cmd/releasegatecheckdirectly; itsvalidatejob then composes every gate through the reusablerelease-validate.yml— therelease_gate: trueper-PR checks and the release-tier suites are invoked withneeds: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 thereleasejob before the tag: the ASVS/MASVS re-verification changelog row (#608) and the per-release adversarial delta (#953), each with a recordedack_*dispatch escape.dry_run: trueruns the whole battery and the fixture regeneration but makes no commit, push, or tag, and is exercised weekly byrelease-dry-run.yml(#929). -
Publication time —
docker-publish.yml’srelease-gatejob calls the samerelease-validate.ymlbefore 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, andschema-fixture-gateneeds:the gate (or the siblingrelease-gate-overridejob), so a failed gate blocks publication. The only way past it is to rundocker-publish.ymlfrom the Actions tab (Run workflow) with a non-emptyoverride_reason; the gate is skipped,release-gate-overriderecordsRELEASE GATE OVERRIDDEN by <actor>: <reason>to the run summary, and publication proceeds — explicit, logged, attributed. A tag push has no override path. -
The
needs:graph — therelease-internalgates enforce themselves:build-and-pushneeds: schema-fixture-gate+build-android-apk,create-releaseneeds: build-android-apk,verify-release-assetsis the final belt-and-suspenders check that the APK, its cosign bundle, the SLSA provenance,release-metadata.jsonandSHA256SUMSactually 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-certsand fails if the APK is not signed (v2/v3 scheme). When the repo variableANDROID_SIGNING_CERT_SHA256is 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
versionCodeequals 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.apkandmycorrhizal-apk.sigstore.jsonare 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/ | 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.