# 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. No version-shaped image tag in any lane: nothing # pins one, and the git release tag is a different object in a different system. # 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, # and a tag rebuilding that same source would re-push :, which a published tag # never allows. # # main is the production line, so a merge moves :latest to its tip — 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 pulls, accumulating forever, for # a channel whose entire contract is that it moves. # # Required secret (repo -> Settings -> Secrets -> Actions): # REGISTRY_TOKEN -- forge 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: # # * Every push to `main` publishes a `:`, so any production commit is # addressable. A path filter would break that 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. # # 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/inkwell 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 # 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's file set in packaging/version.sh. Two # places holding one decision drift apart; it is here because this gate needs the # answer before the Android lane has decided, 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. A path missing here # lets a push touching only it rebuild and dispatch from the Android lane # while this gate ALSO lets the image build: two images for one commit and, # on main, a second push of the same : with different bytes. 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 # The web's whole verification: the type check, then its unit tests. Both in the # job `build` already needs, so a red web test blocks the image like any other # lane. Among other things they guard the web copy of the note grammar, which is # mirrored in Rust and Python. typecheck: name: Web typecheck and unit 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: Install dependencies run: npm ci working-directory: frontend # tsconfig.test.json, not the default: the default leaves the unit tests out # so the Docker build (which has only frontend/) can type-check the app. - name: Type check run: npx vue-tsc --noEmit -p tsconfig.test.json working-directory: frontend - name: Unit tests run: npm test 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: `alembic upgrade head` runs here before any deployment runs # it, and the schema the migrations build is 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:`. 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, 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: inkwell POSTGRES_PASSWORD: ci_integration POSTGRES_DB: inkwell_test options: >- --health-cmd "pg_isready -U inkwell" --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}}' # Only THIS job's services. The runner's docker daemon is shared, so another # repo's job can be running beside this one, and a job-name filter matches its # containers too, and would take another repo's Postgres. act names every # container of a task 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=postgres:16-alpine" -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 INKWELL_DATABASE_URL="postgresql+asyncpg://inkwell:ci_integration@${PG_IP}:5432/inkwell_test" # Wait for Postgres to accept connections. `run:` is busybox sh — # 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 — # 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. An image tag is the rollback substrate, and 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. fetch-depth: 0 - name: Generate image tags and version id: tags # run: steps execute under busybox sh, 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. # # Date-shaped, because "how old is this instance?" is 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 # No disk-space prune step: the runner's Docker daemon is shared, and # `docker system prune -af` here deleted images other repos' jobs had # just pulled and were about to start (CI-runner #16). The runner host # prunes nightly instead. # 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, with no trip to a # release page on the forge. # # ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is # the price of the product being complete, 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, so the token it may use never lands # in the build 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. The channel is chosen here rather than inside # the script because 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