# 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 + : + : # # Both dev and main are gated AND built. dev pushes move :dev; main pushes move # :latest — main IS the production line, so :latest tracks main's tip and there # is no separate :main tag. Every push also gets an immutable : (the # rollback point). A v* release tag additionally publishes the dated :; # since main already moved :latest, the release tag's distinct job is that # : marker (it refreshes :latest too, harmlessly). # # Successive pushes to the SAME ref supersede each other (see concurrency # below), so rapid pushes don't stack identical work; dev and main runs are # independent refs and never cancel one another. # # 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, so every job repeats the ref check # explicitly — permitting dev, main, and v* tags, rejecting anything else. # # 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" - "alembic/**" - "alembic.ini" - "Dockerfile" - "assets/**" - "fable-mcp/**" - ".forgejo/workflows/ci.yml" # Manual trigger from the Forgejo Actions UI. Useful when an image has # been built but the deployment didn't pick it up, or when re-running # against the same source produces different upstream behaviour # (e.g. a transient HF download flake during the voice-bundle step). workflow_dispatch: {} # 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 # 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 transient cache-backend hiccup must NOT fail the whole # typecheck job (it was skipping install + type check and reporting red # on backend-only pushes — see issue task #828). On cache miss/error the # job just installs without the cache. 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 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 — no install # step needed, lint runs in ~2s. - name: Lint run: ruff check src/ 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 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, main, and v* tag pushes. dev → :dev, main → :latest, # tag → :latest + :; every build also gets an immutable :. 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 - name: Generate image tags and version id: tags # POSIX `case` instead of bash `[[ ]]` because act_runner invokes # `sh -e` (dash on the ci-python:3.14 image, which has no bash on # the default PATH for /bin/sh). Previous `[[ ]]` form failed # silently — only the SHA tag got appended, so :dev / :latest # never updated in the registry and the deployed stack kept # pulling stale images. Verified via `[[: not found` lines in # the runner log on commit 2a374d9. run: | TAGS="${{ env.IMAGE }}:${{ github.sha }}" BUILD_VERSION="dev" case "${{ github.ref }}" in refs/heads/dev) TAGS="$TAGS,${{ env.IMAGE }}:dev" ;; refs/heads/main) # main IS the production line: publish :latest (plus the : # set above). No separate :main tag. TAGS="$TAGS,${{ env.IMAGE }}:latest" BUILD_VERSION="main" ;; refs/tags/*) TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}" BUILD_VERSION="${{ github.ref_name }}" ;; esac 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