# svcforge CI. # # The contract, in one line: merge to master -> three images built and scanned -> the # chart's image digests bumped -> ArgoCD syncs. CI never touches the cluster. There is no # kubeconfig here and there must never be one; the pipeline's last act is a git commit. # # Rules this file exists to enforce: # - Build once, promote the artifact. The digest that trivy scanned is the digest that # lands in values.yaml is the digest that runs. # - Deploy by digest, never a mutable tag. # - Everything pinned: actions by SHA, tool images by digest, deps by uv.lock + --frozen. # - Every gate required. None advisory. Fail the PR, not prod. # # Stage order is deliberate and matches the module: cheapest and most likely to fail first, # so a formatting mistake costs 20 seconds instead of three minutes of image builds. name: ci on: [pull_request, push] concurrency: # A second push to the same branch makes the first run's answer irrelevant. Cancel it — # except on master, where the run ends in a commit and must not be interrupted midway. group: ci-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/master' }} env: REGISTRY: gitea.oci-oci.duckdns.org IMAGE_NS: gitea_admin UV_VERSION: "0.5.11" jobs: # --- stage 1: lint -- fast, fails first --------------------------------------------- lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0 with: version: ${{ env.UV_VERSION }} enable-cache: true # The uv store is keyed on the lockfile: same lock, same wheels, cache hit. cache-dependency-glob: uv.lock - run: uv sync --frozen - name: ruff run: uv run ruff check . && uv run ruff format --check . # --- stage 2: types -- your compiler ------------------------------------------------ types: runs-on: ubuntu-latest needs: [lint] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0 with: version: ${{ env.UV_VERSION }} enable-cache: true cache-dependency-glob: uv.lock - run: uv sync --frozen - name: mypy --strict run: uv run mypy --strict . # --- stage 3: unit -- domain only, milliseconds, coverage gate ----------------------- unit: runs-on: ubuntu-latest needs: [lint] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0 with: version: ${{ env.UV_VERSION }} enable-cache: true cache-dependency-glob: uv.lock - run: uv sync --frozen - name: pytest unit # The gate is on domain/ alone, and only domain/. It is pure, has no I/O, and needs # no mocks — there is no excuse for a gap there. Pointing this at the whole repo # would let untested SQL be paid for by well-tested pure functions. # # `--cov=svcforge_core.domain` — the MODULE, not a path. `--cov=libs/svcforge_core/domain` # is a path that does not exist (the package nests one level deeper, at # libs/svcforge_core/svcforge_core/domain), so coverage measured nothing and reported # 0.00%. A path-based --cov that misses silently reports 0 rather than erroring, so # without a --cov-fail-under this reads as a passing coverage gate over no code at all. run: uv run pytest tests/unit --cov=svcforge_core.domain --cov-fail-under=90 # --- stages 4+5: migrate, then integration against that schema ----------------------- integration: runs-on: ubuntu-latest needs: [unit] services: postgres: image: postgres:16@sha256:33f923b05f64ca54ac4401c01126a6b92afe839a0aa0a52bc5aeb5cc958e5f20 env: POSTGRES_PASSWORD: postgres POSTGRES_DB: svcforge ports: - 5432:5432 options: >- --health-cmd "pg_isready -U postgres" --health-interval 5s --health-timeout 5s --health-retries 10 env: # A scratch Postgres, so a literal password is correct here: it is not a secret, it # is a fixture. Real DSNs live in Vault and reach the pods via external-secrets. SVCFORGE_PG_DSN: postgresql://postgres:postgres@postgres:5432/svcforge SVCFORGE_PG_DSN_SESSION: postgresql://postgres:postgres@postgres:5432/svcforge # What tests/integration/conftest.py actually reads. Without it the fixture falls # back to testcontainers and starts a SECOND Postgres inside the runner's docker, # while the service container above sits unused — slower, and a different database # to the one `migrate` just ran against. SVCFORGE_TEST_DSN: postgresql://postgres:postgres@postgres:5432/svcforge steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0 with: version: ${{ env.UV_VERSION }} enable-cache: true cache-dependency-glob: uv.lock - run: uv sync --frozen - name: migrate # The same entrypoint the chart's pre-upgrade hook runs. If migrations only ever # ran under testcontainers, CI would be testing a code path production never takes. run: uv run python -m svcforge_core.migrate - name: pytest integration run: uv run pytest tests/integration # --- stages 6+7+8: SAST, secrets, dependency CVEs ------------------------------------ # One job, three independent gates. They share a checkout and nothing else; each `run` # step fails the job on its own. security: runs-on: ubuntu-latest needs: [lint] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: # gitleaks scans history, not just the tip. A secret committed and then reverted # is still a leaked secret, and a shallow clone cannot see it. fetch-depth: 0 - uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4.2.0 with: version: ${{ env.UV_VERSION }} enable-cache: true cache-dependency-glob: uv.lock - run: uv sync --frozen - name: bandit (SAST) # `--with`, not a dev dependency: bandit is a CI tool, not something the project # imports, and ruff's S ruleset already runs its checks in the lint stage. This is # the belt to that suspenders — -ll reports medium severity and above only. # # `bandit[toml]` + `-c pyproject.toml`: without the toml extra bandit cannot read # its own config and silently ignores it, which looks identical to a clean run. run: uv run --with 'bandit[toml]' bandit -c pyproject.toml -r libs services -ll - name: gitleaks (secret scan) # Pinned by digest and run directly, so the command is the documented one rather # than a marketplace action's opinion of it. run: | docker run --rm -v "$PWD:/repo" -w /repo \ ghcr.io/gitleaks/gitleaks:v8.21.2@sha256:0e99e8821643ea5b235718642b93bb32486af9c8162c8b8731f7cbdc951a7f46 \ detect --no-banner --source /repo - name: pip-audit (dependency CVEs) # --strict fails on an audit error rather than shrugging and reporting clean. # # Audits the LOCKED dependency set, not the installed environment. Auditing the env # means auditing `svcforge` and `svcforge-core` too, which are ours, are installed # editable, and are not on PyPI — under --strict that is a hard error ("distribution # marked as editable"), so the choice was to drop --strict or to stop asking PyPI # about packages it has never heard of. This asks about the 56 that actually came # from PyPI, and keeps --strict. run: | uv export --frozen --no-dev \ --no-emit-project --no-emit-package svcforge-core \ -o /tmp/requirements-audit.txt uv run --with pip-audit pip-audit --strict -r /tmp/requirements-audit.txt # --- stage 9: hadolint --------------------------------------------------------------- dockerfile: runs-on: ubuntu-latest needs: [lint] steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: hadolint uses: hadolint/hadolint-action@54c9adbab1582c2ef04b2016b760714a4bfde3cf # v3.1.0 with: recursive: true dockerfile: "services/*/Dockerfile" failure-threshold: warning # --- stage 9b: the chart must render -------------------------------------------------- # Without this, a chart that does not template reaches ArgoCD and fails in the cluster, # where the error surfaces as a sync failure with no PR attached to it. `helm template` # is the real gate: it is what ArgoCD does, and _helpers.tpl's image helper calls `fail` # on anything that is not a full sha256 digest. chart: runs-on: ubuntu-latest needs: [lint] env: # Pinned by digest like gitleaks and trivy, and the same helm the worker and # reconciler images carry — CI renders with the version that ships. HELM: alpine/helm:3.21.3@sha256:35da09ba0716fc7c3cd63b6b31ee380a9c7662e95f29ab0e4ae962420afd315b steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - name: helm lint run: | docker run --rm -v "$PWD:/repo" -w /repo "$HELM" lint deploy/chart - name: helm template (rejects unbumped digests) # values.yaml ships all-zeros placeholders, so a bare `helm template` MUST fail. # That is the guard working, not a broken chart — asserting the failure here is # what stops the guard silently regressing into a prefix check again. run: | set -euo pipefail if docker run --rm -v "$PWD:/repo" -w /repo "$HELM" \ template svcforge deploy/chart >/dev/null 2>&1; then echo "FAIL: chart rendered against the placeholder digests in values.yaml." >&2 echo "The digest guard in _helpers.tpl is not guarding." >&2 exit 1 fi echo "ok: placeholder digests rejected" - name: helm template (renders with real digests) # Dummy but well-formed digests: this checks the templates themselves render, with # the two values-gated monitoring blocks explicitly on so they are covered too. run: | set -euo pipefail A="sha256:$(printf 'a%.0s' $(seq 64))" B="sha256:$(printf 'b%.0s' $(seq 64))" C="sha256:$(printf 'c%.0s' $(seq 64))" docker run --rm -v "$PWD:/repo" -w /repo "$HELM" \ template svcforge deploy/chart \ --set image.api.digest="$A" \ --set image.worker.digest="$B" \ --set image.reconciler.digest="$C" \ --set serviceMonitor.enabled=true \ --set prometheusRule.enabled=true \ >/dev/null echo "ok: chart renders" # --- stage 10: build -> trivy -> push by digest -------------------------------------- image: runs-on: ubuntu-latest # Every gate above is required. An image is not built until all of them are green, # which is what makes "the digest CI pushed is a digest that passed everything" true. needs: [types, unit, integration, security, dockerfile, chart] permissions: contents: read strategy: fail-fast: false matrix: svc: [api, worker, reconciler] # Deliberately no `outputs:` here. Matrix legs share one outputs map and clobber each # other — the merge is not per-key and not ordered, so two of the three digests would # arrive empty or stale, intermittently. The bump job resolves the digests from the # registry instead, which is a read, not a rebuild. steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 - uses: docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349 # v3.7.1 - name: registry login # Not gated to master any more: the build step now reads AND writes the layer cache # in the registry, so every run needs credentials. Pushing the release image is # still master-only — that gate lives on the `push by digest` step, where it belongs. uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 with: registry: ${{ env.REGISTRY }} username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_TOKEN }} - name: build # Context is the repo root and the Dockerfile is addressed with -f. It cannot be # otherwise: the image needs pyproject.toml, uv.lock and libs/, all of which live # above services//, and COPY ../.. is illegal. # # Loaded locally, not pushed. Trivy scans this exact image next; only then does it # get pushed. The alternative — push, scan, and hope nobody pulled meanwhile — is # how a CRITICAL ends up in the registry with a green checkmark next to it. # # The layer cache is `type=registry`, NOT `type=gha`. Two reasons, both hard: # # 1. act_runner's cache server is backed by a 1Gi PVC that also holds `.runner`, # the runner's own registration file. `mode=max` stores every intermediate layer # of three images — several GB. Filling that volume does not merely lose the # cache: the runner cannot write its state and has to be re-registered by hand. # Trading "slow CI" for "broken CI" is not a trade. # 2. act_runner evicts by AGE, with no size cap in its config. It will fill whatever # it is given and then wedge. The registry has no such limit and already holds # the images anyway. # # The uv/pip cache still uses the runner's cache service — that one is a few hundred # MB and fits. run: | docker buildx build \ -f services/${{ matrix.svc }}/Dockerfile \ --build-arg BUILD_SHA=${{ github.sha }} \ --cache-from type=registry,ref=${REGISTRY}/${IMAGE_NS}/svcforge-${{ matrix.svc }}:buildcache \ --cache-to type=registry,ref=${REGISTRY}/${IMAGE_NS}/svcforge-${{ matrix.svc }}:buildcache,mode=max \ --load \ -t svcforge/${{ matrix.svc }}:ci \ . - name: trivy # Run trivy directly rather than via aquasecurity/trivy-action, for the same reason # gitleaks is run directly above: the command is the documented one, pinned by # digest, with no nested action resolution. # # It is also the only thing that works here. trivy-action internally does # `uses: aquasecurity/setup-trivy@v0.2.2`, and that tag no longer exists upstream — # the earliest published tag today is v0.2.6. The runner clones it and fails with # "Unable to resolve v0.2.2: reference not found". A third-party action pinned by # SHA still resolves ITS OWN dependencies by mutable tag, so pinning the outer # action bought nothing. # # --ignore-unfixed: a CVE with no fix available is not something this PR can act # on, and failing on it only teaches people to add ignore entries. Rebuilding on a # new base image picks the fix up the day it exists. # # The mounted cache dir keeps the vuln DB (~50MB) across the three matrix legs on # this runner instead of re-downloading it for each. run: | docker run --rm \ -v /var/run/docker.sock:/var/run/docker.sock \ -v "$PWD/.trivycache:/root/.cache/trivy" \ aquasec/trivy:0.58.1@sha256:ab70a02200597efa04748f210f793936eb647cbcdb0ea69cc30b226d6f5a22c7 \ image \ --severity HIGH,CRITICAL \ --ignore-unfixed \ --exit-code 1 \ --format table \ --no-progress \ svcforge/${{ matrix.svc }}:ci - name: push by digest if: github.ref == 'refs/heads/master' && github.event_name == 'push' # Re-running buildx here is a cache hit on every layer, not a second build: the # image is byte-identical to the one trivy just cleared. buildx cannot --load and # --push in one invocation, which is the only reason this step exists. # # The commit-SHA tag is a handle for the bump job to resolve, not something anything # deploys. What deploys is the digest that tag resolves to. run: | set -euo pipefail IMAGE="${REGISTRY}/${IMAGE_NS}/svcforge-${{ matrix.svc }}" docker buildx build \ -f services/${{ matrix.svc }}/Dockerfile \ --build-arg BUILD_SHA=${{ github.sha }} \ --cache-from type=registry,ref=${REGISTRY}/${IMAGE_NS}/svcforge-${{ matrix.svc }}:buildcache \ --push \ -t "${IMAGE}:${GITHUB_SHA}" \ . docker buildx imagetools inspect "${IMAGE}:${GITHUB_SHA}" \ --format '{{.Manifest.Digest}}' # --- stage 11: bump the chart's digests. CI's last act. ------------------------------ bump: runs-on: ubuntu-latest needs: [image] if: github.ref == 'refs/heads/master' && github.event_name == 'push' permissions: contents: write steps: - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: # A bot token with contents:write on this repo and nothing else: no kubeconfig, # no cluster credential, no ArgoCD API token. CI's maximum blast radius is a bad # commit, which is revertable. token: ${{ secrets.CI_BOT_TOKEN }} ref: master - uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3.3.0 with: registry: ${{ env.REGISTRY }} username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_TOKEN }} - name: bump image digests in the chart env: REGISTRY: ${{ env.REGISTRY }} IMAGE_NS: ${{ env.IMAGE_NS }} IMAGE_TAG: ${{ github.sha }} # And then it stops. No kubectl, no helm upgrade, no argocd app sync. ArgoCD is # watching master and will have this within a minute. run: ./scripts/bump-digests.sh