# CI runs first; build only proceeds if all checks pass. # # Push to dev: typecheck + lint + test + build :dev + : # Push to main: typecheck + lint + test + build :latest + : # Tag v* (release): typecheck + lint + test + build :latest + : + : # # main is the production line, so :latest tracks main's tip. Every push also # gets an immutable :, the rollback point. A release (a v* tag created on # main in the Forgejo UI) adds the dated :. # # A newer push to the same ref cancels the older run; dev and main never cancel # each other. Pull requests don't trigger runs: branch pushes already gate. # # Forgejo Actions doesn't reliably honour `on.push.branches` as a filter, so # every job repeats the ref check in its `if:`. # # Required secrets (repo → Settings → Secrets → Actions): # REGISTRY_USER — your Forgejo username # REGISTRY_TOKEN — Forgejo PAT with write:packages scope name: CI & Build on: push: branches: [dev, main] tags: ["v*"] paths: - "src/**" - "frontend/**" - "tests/**" - "pyproject.toml" # The lock decides what is installed, so a lock-only change must run CI. - "uv.lock" - "alembic/**" - "alembic.ini" - "Dockerfile" # The plugin ships straight from git (via .claude-plugin/marketplace.json), # not in the image, so a push here is its release. See the `plugin` job. - "plugin/**" - ".claude-plugin/**" - "scripts/check_plugin.py" - ".forgejo/workflows/ci.yml" # Manual run from the Forgejo Actions UI, e.g. to rebuild an image. workflow_dispatch: {} # A newer push cancels the older run on the same branch. Tag runs are never # cancelled, so a release build can't be cut off mid-flight. concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} # Least-privilege default. Jobs that need more (build pushes to the # registry) upgrade explicitly. permissions: contents: read env: REGISTRY: git.fabledsword.com IMAGE: git.fabledsword.com/bvandeusen/fabledscribe jobs: typecheck: name: TypeScript typecheck # Gate dev, main, and v* tags; reject any other ref (see header note). if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 steps: - uses: actions/checkout@v6 - name: Cache npm download cache uses: actions/cache@v4 # Non-fatal: a cache-backend error just means installing without it. continue-on-error: true with: path: ~/.npm key: npm-cache-${{ hashFiles('frontend/package-lock.json') }} restore-keys: npm-cache- - name: Install dependencies run: npm ci working-directory: frontend - name: Type check run: npx vue-tsc --noEmit working-directory: frontend # Guards the one part of this repo that ships to users without a build step. # scripts/check_plugin.py says what it checks and what it can't. plugin: name: Plugin hooks if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 steps: # Keep this a bare `uses:`: with a `with:` block the action fails to # extract on act_runner ("Cannot find module .../dist/index.js"). The # version check diffs two trees, so a depth-1 fetch of main is enough. - uses: actions/checkout@v6 # Per-job rather than in the image: Scribe is shellcheck's only consumer. # Install nothing else here; the hooks use only POSIX tools, and the # smoke test runs on a bare image to prove it. - name: Install shell tooling run: | apt-get update -qq apt-get install -y -qq --no-install-recommends shellcheck # On main the comparison would be against itself, so only the syntax and # pattern checks mean anything there. - name: Check plugin hooks and manifest run: | if [ "${{ github.ref }}" = "refs/heads/main" ]; then python3 scripts/check_plugin.py --no-version else git fetch --no-tags --depth=1 origin main:refs/remotes/origin/main python3 scripts/check_plugin.py fi lint: name: Python lint if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 steps: - uses: actions/checkout@v6 # ruff is pre-installed in the ci-python image. - name: Lint run: ruff check src/ scripts/ # Checks the frontend's CSS against the committed, generated theme.css. # Fails only on an unresolvable var() reference; hard-coded literals are # reported, not gated. - name: Design token check run: python3 scripts/check_design_tokens.py --report-literals # Reports elements whose classes have modifier rules but no base rule, # which vue-tsc can't see. Not gated: a bare wrapper is legitimate, so # the signal is the count growing. - name: Dangling style check run: python3 scripts/check_dangling_styles.py test: name: Python tests if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 steps: - uses: actions/checkout@v6 - name: Cache uv packages uses: actions/cache@v4 with: path: ~/.cache/uv # Keyed on the lock, which decides the installed set. key: uv-${{ hashFiles('uv.lock') }} restore-keys: uv- # Installs exactly what uv.lock pins, so green means these exact versions # passed. `--locked` fails if the lock is stale against pyproject, so a # dependency change needs a deliberate `uv lock`. - name: Install locked dependencies env: UV_PROJECT_ENVIRONMENT: /opt/venv run: uv sync --locked --extra dev # The hook execution tests run on a bare image (no jq), like a user's # machine might be. - name: Run tests # Integration tests (real Postgres) run in the `integration` job below. run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration" # Real Postgres, built by the real migrations: covers the async SQLAlchemy # paths unit stubs can't reach. # # Service hostnames aren't routable on this runner, so the step finds the # Postgres container's bridge IP. The job has no `name:` and a separator-free # key because act_runner names service containers after it. integration: if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 env: # Read at import time. DATABASE_URL is built from the discovered IP below. SECRET_KEY: ci_integration_placeholder services: postgres: # Same image as the shipped compose files: Postgres 17 with pgvector. image: pgvector/pgvector:pg17 env: POSTGRES_USER: scribe POSTGRES_PASSWORD: ci_integration POSTGRES_DB: scribe_test options: >- --health-cmd "pg_isready -U scribe" --health-interval 10s --health-timeout 5s --health-retries 10 steps: - uses: actions/checkout@v6 # Same locked install as the unit lane, so both test the same versions. - name: Install locked dependencies env: UV_PROJECT_ENVIRONMENT: /opt/venv run: uv sync --locked --extra dev # Prints two facts about the runner that the steps below depend on. # Every command is guarded so the diagnostic can't fail the lane. - name: Runner facts (shell, service hostnames) run: | echo "--- which shell runs a run: step ---" readlink -f /bin/sh || echo "/bin/sh: not a symlink" ps -p $$ -o comm= || true echo "--- is a service reachable by its hostname? ---" getent hosts postgres \ || echo "no — 'postgres' does not resolve; the bridge-IP lookup is still required" - name: Integration suite (resolve service IP, migrate, test) run: | set -eux echo "=== container landscape (diagnostic for the name filter) ===" docker ps -a --format '{{.ID}} {{.Image}} -> {{.Names}}' # Only this job's services: the docker daemon is shared, so a job-name # filter can match another repo's Postgres. act names a task's containers # GITEA-ACTIONS-TASK--...; read from our own container (its id is in # the /etc/hostname bind mount) and require exactly 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=pgvector/pgvector:pg17" -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 DATABASE_URL="postgresql+asyncpg://scribe:ci_integration@${PG_IP}:5432/scribe_test" # Wait for Postgres. The run: shell is dash (no bash /dev/tcp), so use Python. /opt/venv/bin/python - "$PG_IP" <<'PY' import socket, sys, time for _ in range(30): try: socket.create_connection((sys.argv[1], 5432), timeout=2).close() break except OSError: time.sleep(1) else: sys.exit("postgres did not become reachable") PY # Real migrations build the schema; the maintenance tests then run # VACUUM (ANALYZE) and read pg_stat_user_tables against it. /opt/venv/bin/alembic upgrade head /opt/venv/bin/python -m pytest tests/ -v -m integration build: name: Build & push image # `plugin` is not in needs: the plugin ships from git, not in the image, so # holding the image back wouldn't stop a bad hook. A red `plugin` job still # turns the run red. `integration` is: the image carries the migrations and # queries that lane tests. needs: [typecheck, lint, test, integration] # dev → :dev, main → :latest, tag → :latest + :; all get :. if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v') runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 permissions: contents: read packages: write steps: - uses: actions/checkout@v6 with: # Full history for the version derivation. Today it needs only HEAD's # commit time, but a shallow clone fails silently (a too-low value) if # the derivation ever reads further back. fetch-depth: 0 - name: Generate image tags and version id: tags # POSIX `case`, not bash `[[ ]]`: the run: shell is dash, where `[[` # fails silently and only the : tag would be pushed. run: | TAGS="${{ env.IMAGE }}:${{ github.sha }}" # Three values kept separate; the Dockerfile describes each. # 1. Ordering key: build time in minutes since 2020-01-01, so it only # goes up. Commit time would go down when an older commit is # rebuilt; a commit count isn't monotonic across branches. BUILD_KEY=$(( ( $(date -u +%s) - 1577836800 ) / 60 )) # 2. Name: commit time, so the same source reads the same on every lane. COMMIT_TS=$(git log --format=%ct -1 HEAD) BUILD_NAME=$(date -u -d "@$COMMIT_TS" +%Y.%m.%d.%H%M) # 3. Channel: its own value, never part of the name. CHANNEL="dev" case "${{ github.ref }}" in refs/heads/dev) TAGS="$TAGS,${{ env.IMAGE }}:dev" ;; refs/heads/main) # main is the production line, so it publishes :latest. TAGS="$TAGS,${{ env.IMAGE }}:latest" CHANNEL="stable" ;; refs/tags/*) TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}" CHANNEL="stable" ;; esac echo "value=$TAGS" >> $GITHUB_OUTPUT echo "build_name=$BUILD_NAME" >> $GITHUB_OUTPUT echo "build_key=$BUILD_KEY" >> $GITHUB_OUTPUT echo "channel=$CHANNEL" >> $GITHUB_OUTPUT - name: Free disk space # Prunes unused containers and images, and caps the BuildKit cache at # 5 GB so the package caches survive but old layers don't pile up. run: | docker system prune -af || true docker builder prune --keep-storage 5g -f || true - name: Set up Docker Buildx uses: docker/setup-buildx-action@v4 - name: Log in to Forgejo registry uses: docker/login-action@v4 with: registry: ${{ env.REGISTRY }} username: ${{ secrets.REGISTRY_USER }} password: ${{ secrets.REGISTRY_TOKEN }} - name: Build and push uses: docker/build-push-action@v7 with: context: . push: true provenance: false tags: ${{ steps.tags.outputs.value }} # The commit lets the image's self-report be checked against :. build-args: | BUILD_VERSION=${{ steps.tags.outputs.build_name }} BUILD_KEY=${{ steps.tags.outputs.build_key }} BUILD_CHANNEL=${{ steps.tags.outputs.channel }} BUILD_COMMIT=${{ github.sha }} # Layer cache kept in the registry, so a build starts warm even after # the runner prunes. `mode=max` caches intermediate layers too. cache-from: type=registry,ref=${{ env.IMAGE }}:cache cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true