# CI runs first; build only proceeds if all checks pass. # # Push to dev: typecheck + lint + test + build :dev + : # Tag v* (release): typecheck + lint + test + build :latest + : + : # # main pushes are NOT gated here: a merge to main only happens after # dev has already passed CI, and the release tag is the sole trigger # for a production image. Re-running CI on the merge commit just burns # runner time without changing the outcome. # # To cut a release: # Create a release via the Forgejo UI on main with a v* tag name. # The tag push triggers this workflow; build job pushes :latest + :. # # PRs aren't triggered on purpose — this is a solo dev→main flow, so # gating on branch push is already enough. # # NOTE on the `if:` guards below: Forgejo Actions does not consistently # honor `on.push.branches` as a filter — merge commits landing on main # still trigger the workflow, producing redundant runs on the same SHA # that was already gated on dev. Every job therefore repeats the ref # check so main pushes trigger the workflow but every job skips # immediately (no runner time, no duplicate work). # # 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] tags: ["v*"] paths: - "src/**" - "frontend/**" - "tests/**" - "pyproject.toml" - "alembic/**" - "alembic.ini" - "Dockerfile" - "assets/**" - "fable-mcp/**" - ".forgejo/workflows/ci.yml" # Cancel older runs on the same branch when a newer push lands. Tag runs # get their own group implicitly (refs/tags/v1.2.3 ≠ refs/heads/dev) and # are never cancelled, so a release build can't kill itself 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 # Skip on main merge-commit pushes — see workflow header comment. if: github.ref == 'refs/heads/dev' || 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 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 lint: name: Python lint if: github.ref == 'refs/heads/dev' || 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 — no install # step needed, lint runs in ~2s. - name: Lint run: ruff check src/ test: name: Python tests if: github.ref == 'refs/heads/dev' || 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 key: uv-${{ hashFiles('pyproject.toml') }} restore-keys: uv- - name: Create virtual environment run: uv venv /opt/venv - name: Install package with dev deps run: | # http-ece doesn't declare setuptools as a build dep, and uv # creates bare venvs without it. Install setuptools first so # --no-build-isolation can find it. uv pip install --python /opt/venv/bin/python setuptools wheel uv pip install --python /opt/venv/bin/python --no-build-isolation http-ece uv pip install --python /opt/venv/bin/python -e ".[dev]" - name: Run tests run: /opt/venv/bin/python -m pytest tests/ -q build: name: Build & push image needs: [typecheck, lint, test] # Build on dev branch pushes and version tag pushes only. # Mirrors the ref guard on the gate jobs above — main merge-commit # pushes skip here too, so no production image is ever built from a # raw main push (only from the v* tag the release creates). if: github.ref == 'refs/heads/dev' || 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 - name: Generate image tags and version id: tags run: | TAGS="${{ env.IMAGE }}:${{ github.sha }}" BUILD_VERSION="dev" if [[ "${{ github.ref }}" == "refs/heads/dev" ]]; then TAGS="$TAGS,${{ env.IMAGE }}:dev" elif [[ "${{ github.ref }}" == refs/tags/* ]]; then TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}" BUILD_VERSION="${{ github.ref_name }}" fi echo "value=$TAGS" >> $GITHUB_OUTPUT echo "build_version=$BUILD_VERSION" >> $GITHUB_OUTPUT - name: Free disk space # Self-hosted runner housekeeping. Two-step cleanup: # 1. Prune dangling containers/images globally (stops the runner # from accumulating cruft from past failed builds). # 2. Trim the BuildKit layer cache to a 5GB ceiling so the pip # mount cache survives but old intermediate layers don't # accumulate indefinitely. 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 }} build-args: BUILD_VERSION=${{ steps.tags.outputs.build_version }} # Registry-backed layer cache. Pull from :cache to prime # BuildKit, push updated layers back to :cache so the next # build starts warm even if the runner's local cache was # pruned. `mode=max` exports all intermediate layers, not # just the final image, which is what gives the ~80% speedup. cache-from: type=registry,ref=${{ env.IMAGE }}:cache cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true