Files
thoughtsync/.forgejo/workflows/ci.yml
T
bvandeusenandClaude Opus 5 6e891357ff
CI & Build / Build now, or wait for Android? (push) Successful in 4s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 16s
CI & Build / integration (push) Successful in 22s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m58s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m23s
Desktop (Tauri) / Update manifest (push) Successful in 6s
Android / Kotlin + Rust (APK) (push) Successful in 7m59s
ci: the deriver is in the file sets but was not in the path filters
`85ead4d` changed `packaging/version.sh` — the script that decides what every
artifact claims to be — and the desktop and Android lanes did not run at all.
Only CI & Build fired, and only because it happens to watch `tests/**`.

So the fix in that commit is unverified on exactly the two lanes whose bug it
was fixing.

`version.sh` lists `packaging` in all three file sets; the workflows' `paths:`
filters did not. Two places holding one decision, with one of them updated —
the failure this subsystem keeps producing (#2181-2183, and again in step 3
where `install.sh` still expected stable's bundles on a versioned release).

The script's own header already warned about this: "a change here that is not
mirrored there means a lane that does not fire — check both." Written, then
not followed, in the same commit.

Step 6 removes the duplication for real by replacing these filters with
skip-if-exists. This is the stopgap until then, and it says so at each site.

#3144

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:11:26 -04:00

393 lines
17 KiB
YAML

# CI runs first; build only proceeds if lint + typecheck pass.
#
# Push to dev: typecheck + lint + test + build :dev + :<sha>
# Push to main: typecheck + lint + test + build :latest + :<sha>
# Tag v* (release): typecheck + lint + test + build :latest + :<version> + :<sha>
#
# 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 :<sha> 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"
# The version deriver — see the note in desktop.yml.
- "packaging/**"
- ".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 — `:<sha>` 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
# 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' || startsWith(github.ref, 'refs/tags/v')
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 :<sha>
# 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: |
TAGS="${{ env.IMAGE }}:${{ github.sha }}"
# 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="$TAGS,${{ env.IMAGE }}:dev"
;;
refs/heads/main)
# Production line: :latest tracks main's tip (rule 46). No :main tag;
# the :<sha> above is the rollback unit.
TAGS="$TAGS,${{ env.IMAGE }}:latest"
;;
refs/tags/*)
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ 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
# :<version> 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
# THE CHANNEL IS A PROPERTY OF THE IMAGE. A :dev image serves the dev
# client; :latest serves the stable one. 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, fixed here because this
# is the step that gave `stable` an APK to point at.
case "${{ github.ref_name }}" in
main|v*) channel=stable ;;
*) channel=dev ;;
esac
echo "Baking in the $channel client."
base="${{ github.server_url }}/${{ github.repository }}/releases/download/$channel"
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