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 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / integration (push) Successful in 17s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 6m36s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 9m24s
Step 3 of M314. Note 3127 §0's diagnostic is "is `main` publishing sufficient for a user to receive the build" — and here it was not. The desktop and Android lanes BUILT on main and published nothing: `Publish release` was gated on `refs/tags/v*`, the channel publishes on `refs/heads/dev`, the manifest job on dev-or-tag. So the stable channel moved only when somebody cut a tag, which made a `v*` tag load-bearing rather than the optional bookmark the model wants. Both channels are rolling fixed-tag releases now. `dev` from dev, `stable` from main, same machinery — `publish-release.sh` already took RELEASE_TAG, `write-manifest.sh` already pruned, and both already PATCHed a stale description on 409 (#2182). This is wiring, not new mechanism. ## The defect this carried `ci.yml`'s "Fetch the Android client to bake in" read `releases/download/dev` UNCONDITIONALLY, on every branch. Every image baked in the dev APK — `:latest` included — so a stable server served a dev-channel client to anyone who downloaded it from there. That has nothing to do with versioning; it is fixed here because this is the step that finally gives `stable` an APK to point at. It also means Android needs no channel machinery of its own. The APK is served FROM the image, so the channel is already a property of which image you run — note 3127 §7's "nothing to hand off" shape, arrived at here by accident. One branch-conditional line, not a second channel in `client_dist.py` as this milestone first assumed. ## The break this nearly shipped `install.sh --channel stable` read the version out of `stable/latest.json` and then fetched `releases/tags/v<version>` for the bundles — correct while stable was a manifest-only pointer, and broken the moment stable holds its own. Stable is the DEFAULT channel, so `curl … | sh` would have failed for everyone between this commit and the first merge to main. Both channels are one lookup now: fetch the fixed-tag release, install what is on it. A transitional fallback covers the window where `stable` still has no bundles, marked for deletion in step 7 — without it the default channel is broken for however long it takes to merge, and that window is gated on an operator request rather than on this lane. ## The two writers problem `stable`'s manifest was written by tag builds. It is written by main now, and the tag path stops writing it — two writers for one channel is a race with no winner worth having. A `v*` tag still writes its own versioned manifest; its build consequence goes entirely in step 7. Also corrected: `update.rs`'s header still described stable as following `v*` tags. Nothing in that file moved — it only ever read `<channel>/latest.json` — but the comment was a lie, and it is the file somebody reads to understand the feed. #3143 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
381 lines
16 KiB
YAML
381 lines
16 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
|
|
# 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
|