name: CI # CI lanes per FabledRulebook forgejo.md "CI philosophy": # - lint: ruff only, no dep install — fast-fail for the common lint bounce. # - unit: pytest -m "not integration"; no service containers, no DB. # - integration: postgres service container; boots the real app + runs all # (core + bundled-plugin) migrations. # Single repo: first-party plugins are bundled in-tree under plugins/, so the # unit lane imports them directly — no cross-repo checkout. on: push: branches: [dev, main] # pull_request intentionally absent — push on [dev, main] already fires CI for # every dev commit and dev→main PRs. Single-operator repo, no fork PRs. # Serialize CI runs per ref so the publish lane never shares the runner's docker # daemon with another run. Two publishes racing on one daemon evict each other's # freshly-built image mid-push, breaking the post-build tag/push (issue #1093). # cancel-in-progress:false is deliberate — rule 46 requires EVERY dev/main push to # publish its own immutable : image, so a superseded run must still run to # completion (never cancelled) to emit its SHA. (Forgejo honors `concurrency:` — # CI-runner's build workflows use it.) concurrency: group: ci-${{ github.ref }} cancel-in-progress: false jobs: # Fast-fail lint lane. ruff is pre-installed in the ci-python image, so this # runs with NO dependency install and surfaces lint bounces in seconds. lint: runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 steps: - uses: actions/checkout@v4 - name: Ruff lint run: ruff check steward/ plugins/ tests/ unit: runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 steps: - uses: actions/checkout@v4 - name: Install Steward + test deps # uv: 5-10x faster wheel resolve than pip on cold caches. Falls back to # pip on uv-missing runners. Steward declares its deps in pyproject; the # [dev] extra adds pytest + pytest-asyncio. run: | if command -v uv >/dev/null 2>&1; then uv pip install --system -e '.[dev]' else pip install -e '.[dev]' fi - name: Pytest (unit only — integration runs in its own lane) run: pytest tests/ -v -m "not integration" integration: runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 env: STEWARD_SECRET_KEY: ci_integration_placeholder services: postgres: image: postgres:16-alpine env: POSTGRES_USER: steward POSTGRES_PASSWORD: steward POSTGRES_DB: steward # The data directory lives in RAM (#5746): postgres images declare # VOLUME for it, so without a tmpfs each run left an anonymous volume # that act_runner never removes, and 2,488 of them used up the runner # host's inodes. options: >- --tmpfs /var/lib/postgresql/data --health-cmd "pg_isready -U steward" --health-interval 10s --health-timeout 5s --health-retries 10 steps: - uses: actions/checkout@v4 - name: Boot + migrate against Postgres # act_runner (swarm-runner) puts service containers on the default bridge # with no embedded DNS, so the hostname `postgres` won't resolve — we # discover the service container's bridge IP via the docker socket. The # job container and its Postgres share a task-number name prefix. run: | set -euxo pipefail echo "=== container landscape (diagnostic for filter scoping) ===" docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}' echo "=== end landscape ===" # The runner's daemon is shared: another repo's integration job can be # running beside this one, and `name=integration` matches its Postgres # too (run 8358 took the wrong one and every test failed to connect). # Scope to this job's own task: act_runner names every container of a # task GITEA-ACTIONS-TASK--…, so read from our own container # (its id is in the /etc/hostname bind mount) and require one match. SELF=$(grep -o '/containers/[0-9a-f]\{64\}/' /proc/self/mountinfo | head -n1 | cut -d/ -f3 || true) SELF=${SELF:-$(hostname)} TASK=$(docker inspect -f '{{.Name}}' "$SELF" | grep -o 'GITEA-ACTIONS-TASK-[0-9]*-') test -n "$TASK" PG=$(docker ps --filter "name=$TASK" --filter "ancestor=postgres:16-alpine" -q) test "$(printf '%s\n' "$PG" | grep -c .)" -eq 1 PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG") test -n "$PG_IP" export STEWARD_DATABASE_URL="postgresql+asyncpg://steward:steward@$PG_IP:5432/steward" for i in $(seq 1 60); do (echo > "/dev/tcp/$PG_IP/5432") >/dev/null 2>&1 && break sleep 2 done if command -v uv >/dev/null 2>&1; then uv pip install --system -e '.[dev,ansible]' else pip install -e '.[dev,ansible]' fi pytest tests/integration -v -m integration --durations=10 # Image-publish lane — FabledRulebook rule 46: every push to dev/main publishes # an immutable commit-SHA image (the rollback unit); dev moves :dev, main moves # :latest. Gated on the three test lanes via `needs:` so a red commit never # produces a :dev image. Mechanics mirror CI-runner's build-ci-python.yml. publish: needs: [lint, unit, integration] runs-on: python-ci container: # ci-builder carries docker + buildx + git. The docker socket is # auto-mounted by act_runner (valid_volumes) — do NOT add a # container.options "-v /var/run/docker.sock:..." mount; that duplicate # mount fails the job at "Set up job". image: git.fabledsword.com/bvandeusen/ci-builder:latest steps: - uses: actions/checkout@v4 - name: Registry login # The injected GITHUB_TOKEN lacks write:package scope, so docker push # 401s with it. REGISTRY_TOKEN is a repo Actions secret holding a # Forgejo token scoped read:package + write:package. run: echo "${{ secrets.REGISTRY_TOKEN }}" | docker login git.fabledsword.com -u bvandeusen --password-stdin - name: Build (commit-SHA always; :dev on dev, :latest on main) run: | set -eux IMAGE=git.fabledsword.com/bvandeusen/steward # The moving tag for this ref: dev→:dev, main→:latest (rule 46). main IS # the production line, so :latest tracks main's tip; there is no :main. MOVING="" if [ "${{ github.ref }}" = "refs/heads/dev" ]; then MOVING="dev" elif [ "${{ github.ref }}" = "refs/heads/main" ]; then MOVING="latest" fi # Tag BOTH targets at build time (mirrors CI-runner's build workflows): # one `docker build -t : -t :` points both tags at the image # atomically, so we never run a post-push `docker tag` of an image a # concurrent run could have evicted from the shared daemon (issue #1093). if [ -n "$MOVING" ]; then docker build -t "$IMAGE:${{ github.sha }}" -t "$IMAGE:$MOVING" . else docker build -t "$IMAGE:${{ github.sha }}" . fi # Rule 164's check, BETWEEN build and push (rule 177): an image that # can't boot, migrate and serve with no internet never reaches a tag. # An --internal network lets it reach its own Postgres and nothing else; # --network none can't work for an app that needs a database to start. # Embedded DNS works on a user-defined network, unlike the services: # containers above (rule 79), so the app finds Postgres by name. - name: The image boots with no internet env: SMOKE: steward-smoke-${{ github.run_id }} run: | set -eux IMAGE=git.fabledsword.com/bvandeusen/steward:${{ github.sha }} docker network create --internal "$SMOKE-net" docker run -d --name "$SMOKE-pg" --network "$SMOKE-net" \ --tmpfs /var/lib/postgresql/data \ -e POSTGRES_USER=steward -e POSTGRES_PASSWORD=steward -e POSTGRES_DB=steward \ postgres:16-alpine # Waits with deadlines (rule 156); pg_isready, not /dev/tcp, which is # a bash builtin and never succeeds under sh. ready="" for _ in $(seq 1 60); do if docker exec "$SMOKE-pg" pg_isready -U steward -d steward -q; then ready=1; break; fi sleep 2 done test -n "$ready" || { echo "FATAL: postgres not ready within 120s"; exit 1; } docker run -d --name "$SMOKE-app" --network "$SMOKE-net" \ -e "STEWARD_DATABASE_URL=postgresql+asyncpg://steward:steward@$SMOKE-pg:5432/steward" \ -e STEWARD_SECRET_KEY=smoke-test-placeholder \ "$IMAGE" # The image has no curl; its own Python fetches. Prints the body. fetch() { docker exec "$SMOKE-app" python -c 'import sys, urllib.request r = urllib.request.urlopen("http://127.0.0.1:5000" + sys.argv[1], timeout=5) sys.stdout.write(r.read().decode())' "$1" } # Migrations run before the server starts, so /health answering # means the schema applied against a real database. up="" for _ in $(seq 1 60); do if fetch /health >/dev/null 2>&1; then up=1; break; fi sleep 2 done if [ -z "$up" ]; then echo "FATAL: Steward never answered /health with no internet access" docker logs "$SMOKE-app" 2>&1 | tail -80 exit 1 fi fetch /health | grep -q '"ok"' # tini is PID 1, so zombies are reaped and SIGTERM reaches Steward. test "$(docker exec "$SMOKE-app" cat /proc/1/comm)" = "tini" # The zone database is in the image, so a reader's zone resolves # instead of quietly falling back to UTC (milestone 490). docker exec "$SMOKE-app" python -c 'from zoneinfo import ZoneInfo; ZoneInfo("America/New_York")' # A fresh database lands on first-run setup: a real page, its assets # served by Steward itself, and no third-party host in it (rule 155). fetch / > /tmp/smoke-page.html grep -qi "/dev/null 2>&1; then up=1; break; fi sleep 2 done if [ -z "$up" ]; then echo "FATAL: Steward never started on the restored database" docker logs "$SMOKE-restored" 2>&1 | tail -80 exit 1 fi # A stop is a clean shutdown: SIGTERM through tini to Hypercorn, which # finishes within its graceful timeout and exits 0. docker stop -t 30 "$SMOKE-app" code="$(docker inspect -f '{{.State.ExitCode}}' "$SMOKE-app")" test "$code" = "0" || { echo "FATAL: Steward exited $code on SIGTERM"; exit 1; } - name: Push run: | set -eux IMAGE=git.fabledsword.com/bvandeusen/steward docker push "$IMAGE:${{ github.sha }}" if [ "${{ github.ref }}" = "refs/heads/dev" ]; then docker push "$IMAGE:dev" elif [ "${{ github.ref }}" = "refs/heads/main" ]; then docker push "$IMAGE:latest" fi - name: Smoke logs if: always() run: docker logs "steward-smoke-${{ github.run_id }}-app" 2>&1 | tail -60 || true # A failed smoke must not leave a Postgres and an app on the shared daemon. - name: Smoke clean-up if: always() run: | docker rm -fv "steward-smoke-${{ github.run_id }}-app" "steward-smoke-${{ github.run_id }}-restored" \ "steward-smoke-${{ github.run_id }}-pg" >/dev/null 2>&1 || true docker network rm "steward-smoke-${{ github.run_id }}-net" >/dev/null 2>&1 || true - name: Prune dangling layers if: always() run: docker image prune -f