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
`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>
393 lines
17 KiB
YAML
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
|