The repo had no tests, no lint config, and a CI that went straight from push
to docker push. That is the reason F1 could ship: authorization lives in the
routers, each of 171 routes independently picks require_admin or
get_current_user, and nothing checked the choice was right.
670 tests, no Docker daemon needed. The app is driven through TestClient
without entering it as a context manager, which skips the lifespan — no
background loops, no socket — and conftest points DATA_DIR/STACKS_DIR at a
temp directory before anything is imported.
test_route_authorization.py is the load-bearing one. Rather than 171 implied
decisions it states the policy once — every route requires admin unless it is
listed in USER_READABLE or PUBLIC — and fails on any route that disagrees. A
new route defaults to admin, which is the safe direction; what it catches is a
route written with get_current_user that nobody weighed against "can this
return a credential". Writing the allowlist meant auditing all 53 user-readable
routes, which turned up one more leak: GET /api/templates/{id} returns a
template's env, and "save stack as template" snapshots the stack's real .env
into it. Now admin-only; the listing stays open.
test_agent_authorization.py pins the same invariant on the agent, where the
whole access model is one shared token declared per route and a single
forgotten Depends(verify_token) would hand over the host.
Both were checked by reintroducing the bug: re-opening /api/files/read fails
three tests with actionable messages, dropping a token guard fails two.
test_bundled_templates.py covers the 83 templates — parse, image per service,
.env.example in sync with what compose reads, every bind-mounted file actually
shipped, and no working default password. It found one on its first run:
authentik shipped PG_PASS=change-me and AUTHENTIK_SECRET_KEY=change-me against
a compose that marks both required, so the stack would have come up with a
known password instead of refusing to start. Fixed.
The rest ports the ad-hoc harnesses from 0.44.0 into permanent tests (crypto
round-trip incl. plaintext passthrough and key-loss handling, the browse
sandbox) and covers compose_service's slug/status/file handling and
secret_service's name validation.
ruff is configured as a floor, not a style bar: F, E9 and B only. Import
sorting is deliberately out — it is style, and enabling it would rewrite the
imports of nine files that have nothing else wrong. The 12 findings it did have
are fixed here (unused imports, an unused local, four raise-without-from that
were swallowing exception context).
CI now runs check (ruff, pytest, tsc) and only builds if it passes.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dk43rmEeRfYi5wsLDbmfyG
120 lines
4.3 KiB
YAML
120 lines
4.3 KiB
YAML
# Continuous integration on git.menzel.center (Gitea Actions).
|
|
#
|
|
# Two jobs: `check` runs the test suite, the linter and the frontend
|
|
# typecheck; `build-and-push` only starts once `check` is green, so a red
|
|
# suite never reaches the registry (and never reaches the self-update
|
|
# checker, which would happily offer a broken release).
|
|
#
|
|
# Builds and pushes the three images to this instance's container registry
|
|
# on every push to main: backend, frontend, and agent (which is built FROM
|
|
# the backend image - see agent/Dockerfile - so it has to come after). Each
|
|
# image gets both a ":latest" tag and a ":{APP_VERSION}" tag, the latter read
|
|
# from backend/version.py (the single source of truth for the release
|
|
# version) - self_update_service compares registry version *tags* against the
|
|
# running APP_VERSION to decide whether an update is available, so without a
|
|
# version tag it would never see one, no matter how far behind :latest is.
|
|
#
|
|
# Runs on ubuntu-latest, not the docker label: that label's image is a bare
|
|
# docker:24-dind with no Node/bash, which breaks actions/checkout (a JS
|
|
# action). ubuntu-latest has both a shell and the Docker CLI, talking to the
|
|
# host daemon through the socket the runner passes in.
|
|
|
|
name: CI
|
|
|
|
on:
|
|
push:
|
|
branches: [main]
|
|
|
|
env:
|
|
REGISTRY: git.menzel.center/menzeljonas
|
|
|
|
jobs:
|
|
check:
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
token: ${{ secrets.CI_TOKEN }}
|
|
|
|
- uses: actions/setup-python@v5
|
|
with:
|
|
python-version: "3.12" # matches backend/Dockerfile
|
|
|
|
- name: Install backend + test dependencies
|
|
working-directory: backend
|
|
run: pip install -r requirements-dev.txt
|
|
|
|
- name: Lint (ruff)
|
|
working-directory: backend
|
|
run: ruff check .
|
|
|
|
# The suite runs without a Docker daemon on purpose: it drives the app
|
|
# through TestClient without the lifespan, so no background loops and no
|
|
# socket. See backend/tests/conftest.py.
|
|
- name: Test (pytest)
|
|
working-directory: backend
|
|
run: pytest
|
|
|
|
- uses: actions/setup-node@v4
|
|
with:
|
|
node-version: "20"
|
|
cache: npm
|
|
cache-dependency-path: frontend/package-lock.json
|
|
|
|
- name: Install frontend dependencies
|
|
working-directory: frontend
|
|
run: npm ci
|
|
|
|
- name: Typecheck (tsc)
|
|
working-directory: frontend
|
|
run: npx tsc --noEmit -p tsconfig.json
|
|
|
|
build-and-push:
|
|
needs: check
|
|
runs-on: ubuntu-latest
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
with:
|
|
token: ${{ secrets.CI_TOKEN }}
|
|
|
|
- name: Read app version
|
|
id: version
|
|
run: |
|
|
VERSION=$(grep -oP '(?<=APP_VERSION = ")[^"]+' backend/version.py)
|
|
echo "Building version $VERSION"
|
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
|
|
|
- name: Log in to the registry
|
|
run: |
|
|
echo "${{ secrets.CI_TOKEN }}" | docker login git.menzel.center -u menzeljonas --password-stdin
|
|
|
|
- name: Build and push backend
|
|
run: |
|
|
docker build \
|
|
-t "$REGISTRY/stackpilot-backend:latest" \
|
|
-t "$REGISTRY/stackpilot-backend:${{ steps.version.outputs.version }}" \
|
|
./backend
|
|
docker push "$REGISTRY/stackpilot-backend:latest"
|
|
docker push "$REGISTRY/stackpilot-backend:${{ steps.version.outputs.version }}"
|
|
|
|
- name: Build and push frontend
|
|
run: |
|
|
docker build \
|
|
-t "$REGISTRY/stackpilot-frontend:latest" \
|
|
-t "$REGISTRY/stackpilot-frontend:${{ steps.version.outputs.version }}" \
|
|
./frontend
|
|
docker push "$REGISTRY/stackpilot-frontend:latest"
|
|
docker push "$REGISTRY/stackpilot-frontend:${{ steps.version.outputs.version }}"
|
|
|
|
# Uses the backend image just pushed above as its base (already in the
|
|
# local Docker cache from that step, so this doesn't need to pull it).
|
|
- name: Build and push agent
|
|
run: |
|
|
docker build \
|
|
--build-arg "BACKEND_IMAGE=$REGISTRY/stackpilot-backend:latest" \
|
|
-t "$REGISTRY/stackpilot-agent:latest" \
|
|
-t "$REGISTRY/stackpilot-agent:${{ steps.version.outputs.version }}" \
|
|
./agent
|
|
docker push "$REGISTRY/stackpilot-agent:latest"
|
|
docker push "$REGISTRY/stackpilot-agent:${{ steps.version.outputs.version }}"
|