# CI runs first; build only proceeds if lint + typecheck 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 a merge to main rebuilds and moves :latest to its # tip (family rule 46) — no version release required. The : image is the # immutable rollback unit for every build. # # Required secret (repo -> Settings -> Secrets -> Actions): # REGISTRY_TOKEN -- Forgejo PAT with write:packages scope # The registry username is derived from github.repository_owner (public — it's in # the image path), so no REGISTRY_USER secret is needed. name: CI & Build on: push: branches: [dev, main] tags: ["v*"] paths: - "src/**" - "frontend/**" - "tests/**" - "pyproject.toml" - "alembic/**" - "alembic.ini" - "Dockerfile" - ".forgejo/workflows/ci.yml" # Dispatched by the Android lane once it has published a client, so the image # that bakes it in is built AFTER the APK exists rather than racing it. See the # `gate` job below for the other half. workflow_dispatch: # Cancel older runs on the same branch when a newer push lands. Tag runs get their # own group implicitly and are never cancelled. concurrency: group: ci-${{ github.ref }} cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }} permissions: contents: read env: REGISTRY: git.fabledsword.com IMAGE: git.fabledsword.com/bvandeusen/thoughtsync jobs: # Should this push build an image now, or is the Android lane about to publish a # client that the image ought to contain? # # A push touching the Android app runs BOTH workflows at once. Building here # would bake in the PREVIOUS client and then, when the new one landed, there # would be no second build — `:` is the immutable rollback unit (rule 46) # and rebuilding it with different content would make it neither. # # So on such a push this workflow stands down, and the Android lane dispatches it # when it is finished. Exactly one image per commit, containing the client from # that commit. # # The path list below MUST match android.yml's trigger. Two places holding one # decision is the recurring failure in this repo (issues 2181-2183); it is here # because a workflow cannot read another's filters, and it is a `git diff` rather # than a config so at least it is inspectable in the log. gate: name: Build now, or wait for Android? 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 outputs: build: ${{ steps.decide.outputs.build }} steps: - uses: actions/checkout@v6 with: # Full history: the diff below spans the whole PUSHED RANGE, not just the # tip. A push of three commits whose Android change sits in the first # would otherwise look Android-free, and the race this job exists to # prevent would happen anyway — silently, which is the worst version. fetch-depth: 0 - name: Decide id: decide run: | # A dispatched run IS the Android lane calling back. Always build. if [ "${{ github.event_name }}" != "push" ]; then echo "Dispatched by the Android lane — building." echo "build=true" >> $GITHUB_OUTPUT exit 0 fi # A tag. The Android lane does not run on tags, so nothing would ever # call back — standing down here would mean a release tag that never # produces an image at all. case "${{ github.ref }}" in refs/tags/*) echo "Tag build — the Android lane does not run on tags. Building." echo "build=true" >> $GITHUB_OUTPUT exit 0 ;; esac # No parent (first commit, or a force-push that orphaned it) — nothing to # compare, so build rather than stall. if ! git rev-parse --verify -q HEAD^ >/dev/null; then echo "No parent commit to diff against — building." echo "build=true" >> $GITHUB_OUTPUT exit 0 fi # The whole push, not just its tip. `before` is what the ref pointed at # beforehand; it is absent or all-zeros for a brand-new branch, and may # be unreachable after a force-push — fall back to the tip commit then. before="${{ github.event.before }}" if [ -n "$before" ] \ && [ "$before" != "0000000000000000000000000000000000000000" ] \ && git cat-file -e "$before^{commit}" 2>/dev/null; then range="$before..HEAD" else range="HEAD^..HEAD" fi echo "Comparing $range" changed="$(git diff --name-only $range)" echo "Changed in this push:" echo "$changed" | sed 's/^/ /' if echo "$changed" | grep -qE '^(android/|core/|Cargo\.toml$|Cargo\.lock$|\.forgejo/workflows/android\.yml$)'; then echo "" echo "This push also changes the Android client. Standing down: the" echo "Android lane will publish a new APK and dispatch this workflow," echo "so the image is built once, with the client from this commit." echo "build=false" >> $GITHUB_OUTPUT else echo "" echo "No Android change — the newest published client is already the" echo "right one to bake in. Building." echo "build=true" >> $GITHUB_OUTPUT fi typecheck: name: TypeScript typecheck 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: 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-runner base image. - 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: Create virtual environment run: uv venv /opt/venv - name: Install package with dev deps run: 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 # Build gates on lint + typecheck. The `test` job runs in parallel for # visibility but does not block dev image builds (DB-backed integration # testing happens against the dev image manually, not on every push). needs: [gate, typecheck, lint] if: needs.gate.outputs.build == 'true' 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: steps execute under busybox sh (family rule 81), so use POSIX `case`, # NOT bash `[[ ]]`. run: | TAGS="${{ env.IMAGE }}:${{ github.sha }}" BUILD_VERSION="dev" case "${{ github.ref }}" in refs/heads/dev) TAGS="$TAGS,${{ env.IMAGE }}:dev" ;; refs/heads/main) # Production line: :latest tracks main's tip (rule 46). No :main tag; # the : above is the rollback unit. Version label = short sha. TAGS="$TAGS,${{ env.IMAGE }}:latest" BUILD_VERSION="$(echo ${{ github.sha }} | cut -c1-7)" ;; 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 run: | docker system prune -af || true docker builder prune --keep-storage 5g -f || true # Bake the Android client in, on EVERY image build, so :dev, :latest and # : all carry one and a `docker compose pull` delivers a new client # along with the new server. # # Always the rolling `dev` release — the newest build there is. A versioned # image therefore carries the newest client rather than one pinned to that # version; the two negotiate a sync protocol version before linking, so # "newest" is safe in a way "matching" would not buy anything over. # # Fetched by the JOB, not by the Dockerfile: the release is private, and a # token used inside a build lands in the context or a layer. # # NEVER fails the build. An image with no Android client advertises none and # hides the download — a supported state, and the only one available before # the first Android build has ever published. - name: Fetch the Android client to bake in env: GITHUB_TOKEN: ${{ github.token }} run: | mkdir -p client base="${{ github.server_url }}/${{ github.repository }}/releases/download/dev" ok=1 for f in thoughtsync.apk thoughtsync-android.json; do curl -fsSL -H "Authorization: token $GITHUB_TOKEN" -o "client/$f" "$base/$f" || ok=0 done if [ "$ok" = 1 ]; then echo "Baking in:" cat client/thoughtsync-android.json ls -l client/thoughtsync.apk else # Both or neither. Half a pair is worse than none: the server would # read a sidecar describing an APK that isn't there, or an APK it # cannot state a version for. echo "::warning::No Android client on the dev release — this image ships without one." rm -f client/thoughtsync.apk client/thoughtsync-android.json fi - 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: ${{ github.repository_owner }} 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 }} cache-from: type=registry,ref=${{ env.IMAGE }}:cache cache-to: type=registry,ref=${{ env.IMAGE }}:cache,mode=max,ignore-error=true