# 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 + : # # THAT IS THE COMPLETE TAG SET (rule 145). No version-shaped image tag in any lane: # nothing pins one — verified by looking for a consumer, not for whether one is # imaginable — and the git release tag is a different object in a different system # (step 7). The image is addressed by CHANNEL or by COMMIT; the release by date. # # A `v*` tag builds nothing at all. The merge to main already published everything, # so a tag rebuilding that same source would re-push : with different bytes, # which rule 145 forbids even when they match. # # main is the production line, so a merge moves :latest to its tip (family rule 46) # — no version release required. : is the immutable rollback unit, and it is # on main ONLY: a sha tag per dev push is a rollback target nobody has ever pulled, # accumulating forever, for a channel whose entire contract is that it moves. # # 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: # NO `paths:` FILTER, and unlike the client lanes this one does not skip either — # the image ALWAYS builds. Two reasons: # # * Rule 145 promises that every push to `main` publishes a `:`, so any # production commit is addressable. A path filter quietly broke that promise # for a docs-only merge: no trigger, no image, no sha tag for that commit. # * It is the artifact most exposed to base-image staleness (`python:3.12-slim` # is a floating tag and this can face the internet), and building every push # picks those updates up. That is why note 3127 §4's base tension does not # bite here — the one artifact it would apply to never skips. # # Affordable because it is the cheap one: ~15 seconds, against 6 and 9 minutes # for the clients, which is why THEY skip and this does not. branches: [dev, main] # 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. concurrency: group: ci-${{ github.ref }} cancel-in-progress: true 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' 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 # 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/^/ /' # MUST match android's file set in packaging/version.sh. `packaging/` was # missing here after step 4 added it there — so a packaging-only push had # the Android lane rebuild and dispatch while this gate ALSO let the image # build, producing two images for one commit and, on main, a second push of # the same : with different bytes. Rule 145's exact prohibition. if echo "$changed" | grep -qE '^(android/|core/|packaging/|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' 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' 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' 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 # DB-free by design. Anything needing a real Postgres is marked `integration` # and runs in the job below. run: /opt/venv/bin/python -m pytest tests/ -q -m "not integration" # Real-Postgres lane (family rule 6). Until this existed, `alembic upgrade head` ran # for the first time when the operator's container started — 26 revisions, none of # them ever executed by CI — and the schema the migrations build had never been # checked against the models that read it. # # Gates the build, along with every other lane — see the `build` job's `needs`. # # Job key stays separator-free ("integration") with no `name:` — rule 80. act_runner # derives the service-container name from the truncated job display name, and the # discovery step below filters `docker ps` by it. Service hostnames are not routable # on this runner (rule 79), so the step resolves the container's bridge IP. integration: if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' runs-on: python-ci container: image: git.fabledsword.com/bvandeusen/ci-python:3.14 services: postgres: # Same image the production compose runs, so the schema is proven against the # Postgres it will actually meet. image: postgres:16-alpine env: POSTGRES_USER: thoughtsync POSTGRES_PASSWORD: ci_integration POSTGRES_DB: thoughtsync_test options: >- --health-cmd "pg_isready -U thoughtsync" --health-interval 10s --health-timeout 5s --health-retries 10 steps: - uses: actions/checkout@v6 - name: Create virtual environment run: uv venv /opt/venv # Same install as the unit lane — the two must agree on versions, or # "unit green, integration red" stops being a signal about the code. - name: Install package with dev deps run: uv pip install --python /opt/venv/bin/python -e ".[dev]" - 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}}' PG=$(docker ps --filter "name=integration" --filter "ancestor=postgres:16-alpine" -q | head -n1) test -n "$PG" PG_IP=$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' "$PG") test -n "$PG_IP" export THOUGHTSYNC_DATABASE_URL="postgresql+asyncpg://thoughtsync:ci_integration@${PG_IP}:5432/thoughtsync_test" # Wait for Postgres to accept connections. `run:` is busybox sh (rule 81) — # no bash /dev/tcp — so use the Python that is always present here. /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, never metadata.create_all (rule 82) — # testing a schema no deployment has ever seen would prove nothing. This # step IS the migration test: a broken revision fails the job here. /opt/venv/bin/alembic upgrade head /opt/venv/bin/python -m pytest tests/ -v -m integration build: name: Build & push image # Every lane gates the build. This once stopped at lint + typecheck, on the # reasoning that DB-backed testing happened manually against the dev image # rather than on every push — true until 6f21db8 added the integration lane, # and false since. # # What that gap cost: run 4293 failed `test` and published :dev and : # anyway, so the deployed server ran a build whose test lane was red. An image # tag is the rollback substrate (family rule 46); one that can be published # from a failing run is not a substrate you can roll back TO. needs: [gate, typecheck, lint, test, integration] 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 with: # Derives a version — see the note in desktop.yml. Depth-1 sees one commit # and produces a too-low value silently, with the lane green (§6.1). fetch-depth: 0 - name: Generate image tags and version id: tags # run: steps execute under busybox sh (family rule 81), so use POSIX `case`, # NOT bash `[[ ]]`. run: | # The image's version is DERIVED from its own shipped files — including the # Android client it bakes in, which is why an APK-only change re-versions # it. One value and no ordering key: nothing compares a server image, so # §2 says do not invent one just because the other artifacts have one. # # This was a short sha on main and the literal "dev" elsewhere, which could # not answer "how old is this instance?" — the question that actually gets # asked of a self-hosted app running in several places. BUILD_VERSION="$(sh packaging/version.sh display server)" case "${{ github.ref }}" in refs/heads/dev) TAGS="${{ env.IMAGE }}:dev" ;; refs/heads/main) TAGS="${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }}" ;; *) echo "::error::This lane builds images for dev and main only." exit 1 ;; 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 EVERY client in, on every image build, so a self-hoster gets a working # app for their machine from the server holding their notes — without an # account on this forge, which is private (issue 2091) and is why serving them # from a release page was never an option for anybody but the operator. # # ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is # the price of the product being complete (rule 23), and the AppImage is not # optional within it: it is the ONLY bundle that can replace itself in place, # so a server without one cannot serve in-app updates to anyone. # # Fetched by the JOB, not by the Dockerfile: the releases are private, and a # token used inside a build lands in the context or a layer. # # NEVER fails the build — see the script. A platform with nothing published # means the server advertises nothing for it and the UI hides that download, # which is a supported state and the only one available before that platform's # first build has ever published. - name: Fetch the clients to bake in env: GITHUB_TOKEN: ${{ github.token }} GITHUB_SERVER_URL: ${{ github.server_url }} GITHUB_REPOSITORY: ${{ github.repository }} run: | # THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves dev clients; # :latest serves stable ones. This read `download/dev` unconditionally # until M314 step 3, on every branch — so every stable server shipped a # dev-channel APK to anyone who downloaded the client from it. Not a # versioning gap; a plain defect, and the reason the channel is chosen here # rather than inside the script: the caller is what knows which image it is # building. case "${{ github.ref_name }}" in main) channel=stable ;; *) channel=dev ;; esac sh packaging/fetch-clients.sh "$channel" client - 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