CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Successful in 9s
CI & Build / integration (push) Successful in 15s
CI & Build / Build & push image (push) Successful in 16s
build gated on lint + typecheck only, so run 4293 failed its test lane and
pushed :dev and :09b5f87 regardless — the deployed server was running a
build whose tests were red.
The comment justified this by saying DB-backed testing happened manually
against the dev image rather than on every push. That was true when it was
written and stopped being true at 6f21db8, which added the integration
lane. The reason went away; the exception didn't.
Gate on test and integration too. A :<sha> image is the rollback unit for
its commit (family rule 46) — one publishable from a failing run is not
something you can roll back to.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
370 lines
15 KiB
YAML
370 lines
15 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"
|
|
- ".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
|
|
|
|
- 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 }}"
|
|
BUILD_VERSION="dev"
|
|
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. Version label = short sha.
|
|
TAGS="$TAGS,${{ env.IMAGE }}:latest"
|
|
BUILD_VERSION="$(echo ${{ github.sha }} | cut -c1-7)"
|
|
;;
|
|
refs/tags/*)
|
|
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
|
|
BUILD_VERSION="${{ 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
|
|
base="${{ github.server_url }}/${{ github.repository }}/releases/download/dev"
|
|
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
|