Skip to content

ci: gate PRs on 100% patch coverage + 95% project floor #36

ci: gate PRs on 100% patch coverage + 95% project floor

ci: gate PRs on 100% patch coverage + 95% project floor #36

Workflow file for this run

name: coverage
on:
pull_request:
branches: [master, main]
push:
branches: [master, main]
permissions:
contents: read
jobs:
coverage:
runs-on: ubuntu-latest
timeout-minutes: 15
# Service containers mirror ci.yml's build-and-test job. Without these
# `go test ./...` ran with no DB and coverage measured only the handful
# of pure-unit packages — every integration-style test (the
# propagation_runner live-DB walk, anything that pings TEST_DATABASE_URL)
# skipped or returned 0% coverage for the touched lines. See CLAUDE.md
# rule 23 (the local gate must equal CI) — coverage.yml must run the
# same hermetic suite ci.yml does.
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: instant_dev_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
mongo:
image: mongo:6
ports:
- 27017:27017
options: >-
--health-cmd "mongosh --quiet --eval 'db.adminCommand({ ping: 1 }).ok' | grep -q 1"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
# Worker tests read TEST_DATABASE_URL (propagation_runner integration
# walk) and skip when unset. CUSTOMER_DATABASE_URL / MONGO_ADMIN_URI /
# CUSTOMER_REDIS_URL are read by internal/config at boot — wire them
# too so config-walk tests don't no-op.
TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/instant_dev_test?sslmode=disable
TEST_REDIS_URL: redis://localhost:6379/15
# Customer DB admin target — provisioner-style integration tests
# CREATE a customer database on this connection; matches ci.yml.
TEST_POSTGRES_CUSTOMERS_URL: postgres://postgres:postgres@localhost:5432/instant_customers?sslmode=disable
# Mongo admin URI — nosql provider tests skip cleanly when unset;
# wire it so the nosql packages contribute to coverage too.
TEST_MONGO_URI: mongodb://localhost:27017
# Cross-repo registry-iterating gate (CLAUDE.md rule 18). Tells
# coverage_registry_test.go's findApiRepoRoot() exactly where the api
# sibling is — without it the cross-repo tests skip in CI and we lose
# the drift-detection coverage. Mirrors ci.yml.
INSTANT_API_REPO: ${{ github.workspace }}/../api
steps:
- uses: actions/checkout@v4
with:
path: worker
# Full history so diff-cover can resolve origin/<base_ref>.
fetch-depth: 0
- name: Checkout proto sibling (for go.mod replace ../proto)
uses: actions/checkout@v4
with:
repository: ${{ vars.PROTO_REPO || format('{0}/proto', github.repository_owner) }}
token: ${{ secrets.REPO_ACCESS_TOKEN }}
path: proto
- name: Checkout common sibling (for go.mod replace ../common)
uses: actions/checkout@v4
with:
repository: ${{ vars.COMMON_REPO || format('{0}/common', github.repository_owner) }}
token: ${{ secrets.REPO_ACCESS_TOKEN }}
path: common
# Cross-repo registry-iterating tests (CLAUDE.md rule 18) text-walk
# api/internal/models/audit_kinds.go to assert worker auditKind* wire
# values match the api source. Without this checkout the tests SKIP
# in CI, leaving cross-repo drift detection to developer machines
# only. INSTANT_API_REPO env (set above) locates this checkout.
- name: Checkout api sibling (for cross-repo registry-iterating tests)
uses: actions/checkout@v4
with:
repository: ${{ vars.API_REPO || format('{0}/api', github.repository_owner) }}
token: ${{ secrets.REPO_ACCESS_TOKEN }}
path: _api_ci
fetch-depth: 1
- run: mv _api_ci ../api
- uses: actions/setup-go@v5
with:
go-version-file: worker/go.mod
- name: Apply DB migrations to the test database
# Worker has its own SQL files in worker/sql/ (forwarder_sent +
# resource_heartbeat + backups). The bulk of the schema the worker
# reads (teams / resources / pending_propagations / audit_log) is
# owned by api/internal/db/migrations/ — apply those first, then
# the worker-local files. Mirrors what `make test-db-up` does for
# an integrated worker+api test run. Creates instant_customers too
# since some integration tests touch the customer DB.
env:
PGPASSWORD: postgres
run: |
# api-owned schema (canonical platform_db source of truth)
if [ -d ../api/internal/db/migrations ]; then
for f in $(ls ../api/internal/db/migrations/*.sql | sort); do
echo "→ applying api migration $(basename "$f")"
psql -h localhost -U postgres -d instant_dev_test -f "$f" >/dev/null
done
echo "all api migrations applied to instant_dev_test"
else
echo "::warning::no api migrations directory found at ../api/internal/db/migrations — registry-iterating tests will skip"
fi
# worker-owned schema (forwarder_sent enrich + delivered_at columns,
# resource_heartbeat, backups). Applied on top — IF NOT EXISTS guards
# keep these idempotent against api-side counterparts.
if [ -d worker/sql ]; then
for f in $(ls worker/sql/*.sql | sort); do
echo "→ applying worker migration $(basename "$f")"
psql -h localhost -U postgres -d instant_dev_test -f "$f" >/dev/null || echo "::warning::worker migration $(basename "$f") failed — likely overlap with api schema; continuing"
done
fi
psql -h localhost -U postgres -d postgres -c "CREATE DATABASE instant_customers" >/dev/null
echo "created instant_customers (customer DB admin target)"
- name: Generate coverage
working-directory: worker
# `-p 1` matches ci.yml's build-and-test invocation — every package
# shares the single instant_dev_test DB + redis/15; default
# parallelism corrupts shared state mid-test. `-short` matches the
# hermetic suite (e2e/chaos tests are tagged and excluded from
# ./... anyway). continue-on-error so a single flaky test doesn't
# drop the entire coverage artifact — codecov still ingests cov.out.
continue-on-error: true
run: go test ./... -short -count=1 -p 1 -coverprofile=coverage.out -covermode=atomic
- uses: codecov/codecov-action@v4
with:
files: worker/coverage.out
flags: worker
fail_ci_if_error: false
# ------------------------------------------------------------------
# Org patch-coverage mandate: every changed line in a PR diff must be
# covered by a test (100%), and the project floor stays >=95%.
# Tool: diff-cover (https://github.com/Bachmann1234/diff-cover). The
# "Generate coverage" step is `|| true` so it still produces
# coverage.out even on a flaky test — the gate reads that file.
# ------------------------------------------------------------------
- uses: actions/setup-python@v5
if: github.event_name == 'pull_request'
with:
python-version: '3.12'
- name: Install diff-cover + cobertura converter
if: github.event_name == 'pull_request'
run: |
pip install diff-cover
go install github.com/boumenot/gocover-cobertura@latest
- name: Convert coverage to Cobertura
if: github.event_name == 'pull_request'
working-directory: worker
run: $(go env GOPATH)/bin/gocover-cobertura < coverage.out > coverage.xml
- name: Patch coverage gate (100% of changed lines)
if: github.event_name == 'pull_request'
working-directory: worker
run: |
git fetch origin "${{ github.base_ref }}" --depth=1 || true
diff-cover coverage.xml \
--compare-branch="origin/${{ github.base_ref }}" \
--fail-under=100
- name: Project coverage floor (>=95% production code)
if: github.event_name == 'pull_request'
working-directory: worker
# The >=95% floor is measured over PRODUCTION code only. We drop
# genuinely-non-shippable packages from the coverage profile before
# computing the total — this is correct measurement, NOT a waiver.
# No internal/<domain> production package is ever excluded here.
#
# Excluded (and why):
# cmd/smoke-buildinfo — diagnostic/smoke binary, not shipped logic.
# cmd/* — pure diagnostic/smoke binaries.
# internal/testhelpers — test-DB/setup harness (none today; future-proof).
# e2e/ — black-box E2E suite (//go:build e2e; none today).
# proto/gen, *_pb.go — generated protobuf code.
# Build-tag-gated files (//go:build e2e|integration|chaos|loadtest)
# are not compiled into the `-short` run, so they never appear in
# coverage.out — the path filter below is belt-and-suspenders.
run: |
# Keep the `mode:` header line; drop excluded package paths.
grep -vE '(/internal/testhelpers/|/cmd/|/e2e/|/proto/gen/|_pb\.go:)' \
coverage.out > coverage.prod.out
total=$(go tool cover -func=coverage.prod.out | tail -1 | awk '{print $3}' | tr -d '%')
echo "Total project coverage: ${total}%"
awk -v t="$total" 'BEGIN { exit (t+0 >= 95) ? 0 : 1 }' \
|| { echo "::error::Production coverage ${total}% is below the 95% floor"; exit 1; }