svcforge: reference implementation
ci / lint (push) Successful in 1m19s
ci / unit (push) Failing after 1m2s
ci / integration (push) Has been skipped
ci / types (push) Successful in 1m37s
ci / security (push) Failing after 38s
ci / dockerfile (push) Successful in 14s
ci / image (api) (push) Has been skipped
ci / image (reconciler) (push) Has been skipped
ci / image (worker) (push) Has been skipped
ci / bump (push) Has been skipped
ci / lint (push) Successful in 1m19s
ci / unit (push) Failing after 1m2s
ci / integration (push) Has been skipped
ci / types (push) Successful in 1m37s
ci / security (push) Failing after 38s
ci / dockerfile (push) Successful in 14s
ci / image (api) (push) Has been skipped
ci / image (reconciler) (push) Has been skipped
ci / image (worker) (push) Has been skipped
ci / bump (push) Has been skipped
Complete working build of the system learn-python/ teaches. 164 tests, mypy --strict clean, domain coverage 99%.
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
# 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.
|
||||
run: uv run pytest tests/unit --cov=libs/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.
|
||||
run: uv run --with bandit bandit -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.
|
||||
run: uv run --with pip-audit pip-audit --strict
|
||||
|
||||
# --- 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 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]
|
||||
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
|
||||
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
|
||||
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/<svc>/, 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.
|
||||
run: |
|
||||
docker buildx build \
|
||||
-f services/${{ matrix.svc }}/Dockerfile \
|
||||
--build-arg BUILD_SHA=${{ github.sha }} \
|
||||
--cache-from type=gha,scope=${{ matrix.svc }} \
|
||||
--cache-to type=gha,mode=max,scope=${{ matrix.svc }} \
|
||||
--load \
|
||||
-t svcforge/${{ matrix.svc }}:ci \
|
||||
.
|
||||
|
||||
- name: trivy
|
||||
uses: aquasecurity/trivy-action@18f2510ee396bbf400402947b394f2dd8c87dbb0 # v0.29.0
|
||||
with:
|
||||
image-ref: svcforge/${{ matrix.svc }}:ci
|
||||
severity: HIGH,CRITICAL
|
||||
# A CVE with no fix available is not something this PR can act on; failing on it
|
||||
# only teaches people to add ignore entries. Rebuilding on a new base image picks
|
||||
# the fix up the day it exists.
|
||||
ignore-unfixed: true
|
||||
exit-code: "1"
|
||||
format: table
|
||||
|
||||
- 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=gha,scope=${{ matrix.svc }} \
|
||||
--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. Note what is
|
||||
# absent: 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
|
||||
Reference in New Issue
Block a user