Files
inkwell/.forgejo/workflows/ci.yml
T
bvandeusenandClaude Opus 5.5 5780220ff1
CI & Build / Build now, or wait for Android? (push) Successful in 2s
Android / Build, or is the channel already serving this? (push) Successful in 2s
Android / Core and FFI clippy and tests (push) Skipped
Android / Kotlin + Rust (APK) (push) Skipped
Android / Build the server image (push) Skipped
CI & Build / Python lint (push) Successful in 2s
CI & Build / Web typecheck and unit tests (push) Successful in 8s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Python tests (push) Successful in 12s
CI & Build / integration (push) Successful in 1m25s
CI & Build / Build & push image (push) Successful in 34s
The image build stops pruning the runner's shared Docker daemon
The build job ran `docker system prune -af` on the runner's shared Docker
daemon. That deletes every image no container is using at that moment,
including images other repos' jobs had just pulled and were about to start.
Jobs with a service container wait ~14s for it to become healthy between
pull and create, and fail with "No such image" when a prune lands inside
that wait (CI-runner #16). The runner host prunes nightly instead.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 10:20:33 -04:00

385 lines
16 KiB
YAML

# 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 + :<sha>
#
# 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 :<sha>, which a published tag
# never allows.
#
# main is the production line, so a merge moves :latest to its tip — no version
# release required. :<sha> 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 `:<sha>`, 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 — `:<sha>` 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 :<sha> 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-<n>-...: read
# <n> 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