Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 6s
CI & Build / Python tests (push) Failing after 15s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m10s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m19s
Desktop (Tauri) / Update manifest (push) Successful in 10s
Android / Kotlin + Rust (APK) (push) Successful in 8m3s
~104 MB on top of ~85 MB, almost all of it the AppImage. That is what the product being complete costs (rule 23): a self-hoster gets a working app for their machine from the server holding their notes, with no account on a forge that is private. The AppImage is not optional within that — it is the only bundle that can replace itself in place, so a server without one cannot serve in-app updates to anybody. `packaging/fetch-clients.sh` replaces the inline fetch and writes the fixed names and sidecars `client_dist.py` reads. It never fails: a platform with nothing published means the server advertises nothing for it and the UI hides that download, and eight fetches must not become eight ways to redden a green lane. THE VERSION IS FETCHED, NOT DERIVED, and this is the part that would have been wrong the easy way. The obvious shortcut is `version.sh display desktop` in the image job — it has the checkout. But this commit may not be the commit the channel is serving: a push touching only `src/` does not rebuild the desktop, so the channel still holds an older build and a locally-derived version would describe those bytes with this commit's number. `client_dist.py`'s size check could not catch it, because size IS measured from the real file — it would sail through and lie about the version alone. So `write-manifest.sh` now publishes `thoughtsync-desktop.json` beside `latest.json`, from the same two values in the same breath, and only size/sha256 are measured at bake time. Which needed the prune's keep-list, or the sidecar would have been uploaded and deleted again in the same run — a fixed name is self-limiting, which is exactly why that list exists. `version_code` is NOT uniformly an integer, and coercing it was a leftover from the days when Android was the only platform. Android's must stay a JSON number: `ClientRelease` in core declares it `i64` and a string fails to deserialize on every phone in the field. The desktop's is Tauri's semver key `1.0.<minutes>` — the value its updater actually compares — and `int()` would have rejected every desktop sidecar CI writes. The table now says which is which, and tests pin both directions. Also retires the comment above the fetch step, which claimed the APK came from "always the rolling dev release" and mentioned `:<version>` images. M314 step 3 made the channel conditional in the code directly below it, and step 6 removed version-shaped image tags entirely. Verified against the live dev channel before pushing: the Android half resolves and exits 0, the desktop half degrades with a warning because the sidecar does not exist yet, and all five constructed bundle filenames return 200.
382 lines
17 KiB
YAML
382 lines
17 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 (rule 145). No version-shaped image tag in any lane:
|
|
# nothing pins one — verified by looking for a consumer, not for whether one is
|
|
# imaginable — and the git release tag is a different object in a different system
|
|
# (step 7). 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,
|
|
# so a tag rebuilding that same source would re-push :<sha> with different bytes,
|
|
# which rule 145 forbids even when they match.
|
|
#
|
|
# main is the production line, so a merge moves :latest to its tip (family rule 46)
|
|
# — 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 has ever pulled,
|
|
# accumulating forever, for a channel whose entire contract is that it moves.
|
|
#
|
|
# 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:
|
|
# NO `paths:` FILTER, and unlike the client lanes this one does not skip either —
|
|
# the image ALWAYS builds. Two reasons:
|
|
#
|
|
# * Rule 145 promises that every push to `main` publishes a `:<sha>`, so any
|
|
# production commit is addressable. A path filter quietly broke that promise
|
|
# 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. That is why note 3127 §4's base tension does not
|
|
# bite here — the one artifact it would apply to never skips.
|
|
#
|
|
# 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/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'
|
|
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. `packaging/` was
|
|
# missing here after step 4 added it there — so a packaging-only push had
|
|
# the Android lane rebuild and dispatch while this gate ALSO let the image
|
|
# build, producing two images for one commit and, on main, a second push of
|
|
# the same :<sha> with different bytes. Rule 145's exact prohibition.
|
|
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
|
|
|
|
typecheck:
|
|
name: TypeScript typecheck
|
|
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
|
|
|
|
- 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'
|
|
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 (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'
|
|
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: |
|
|
# 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="${{ 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
|
|
|
|
- name: Free disk space
|
|
run: |
|
|
docker system prune -af || true
|
|
docker builder prune --keep-storage 5g -f || true
|
|
|
|
# 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 — without an
|
|
# account on this forge, which is private (issue 2091) and is why serving them
|
|
# from a release page was never an option for anybody but the operator.
|
|
#
|
|
# ~104 MB on top of the ~85 MB image, almost all of it the AppImage. That is
|
|
# the price of the product being complete (rule 23), 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: the releases are private, and a
|
|
# token used inside a build lands in the 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. 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, and the reason the channel is chosen here
|
|
# rather than inside the script: 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
|