ci: one definition of what ships decides both the version and whether to build
Android / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 3s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 10s
CI & Build / integration (push) Successful in 16s
CI & Build / Build & push image (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 3m5s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 5m24s
Desktop (Tauri) / Update manifest (push) Successful in 4s
Android / Kotlin + Rust (APK) (push) Successful in 8m12s

Step 6 of M314. Two changes that only make sense together.

## The image tag set rule 145 mandates

  dev push   -> :dev
  main push  -> :latest + :<sha>
  a v* tag   -> nothing; the trigger is gone

`:<sha>` was going out on EVERY branch — a rollback target nobody has ever
pulled, accumulating forever, for a channel whose entire contract is that it
moves. It is on main only now, where rollback matters and where gated merges
(rule 2) make it dozens per year rather than one per push.

No version-shaped image tag in any lane. Verified the way rule 145 asks — by
looking for a CONSUMER, not for whether one is imaginable: `docker-compose.yml`
is parameterised for a pin and the docs describe the option, but no compose
file, deploy script or CI job reads one.

## Skip-if-exists, adapted, because §4 assumes a registry §5 removed

Note 3127 §4 says to ask the registry whether that exact version exists. There
is no `:<version>` tag to ask about any more. What there IS, for both clients,
is a channel that publishes the version it serves — and that answers the same
question: if the channel already serves what this source derives, the artifact
would be byte-identical.

So the `paths:` filters are gone from the desktop and Android lanes, replaced
by a `decide` job reading the real file set. That duplication is not
theoretical: `packaging/` was added to the sets and not to the filters, so the
commit that fixed a derivation bug never ran on the two lanes it fixed
(85ead4d). One definition, one reader.

The cost is that both workflows now start on every push rather than a matching
one — a ~15s container for a decision, against a lane that cannot silently fail
to run.

## The server always builds, deliberately

Its image is ~15 seconds against 6 and 9 minutes for the clients, so there is
little to save. And always building is strictly BETTER for something that can
face the internet: it picks up `python:3.12-slim` base updates on every push.

That also dissolves §4's base-image tension for this project rather than
deciding it — the artifact most exposed to base staleness is the one that never
skips. Resolving a base digest at derive time was the alternative and it is
forbidden: §7's corollary bars an external lookup, because two lanes would then
derive different values for one source.

## The guard runs on the skip path

It moved into `decide`, ahead of the decision. §6.3 is explicit that skipping
because "this version already exists" is indistinguishable from "we derived a
stale value that happens to match" unless something checks. It also now runs
once per lane instead of once per job.

## Two defects found while wiring this

`ci.yml`'s gate greps a path list that MUST match Android's file set, and
`packaging/` was missing from it. A packaging-only push would have had the
Android lane build and dispatch while the gate ALSO let the image through —
two images for one commit, and on main a second push of the same `:<sha>` with
different bytes. Rule 145's exact prohibition.

`guard-forward.sh` ends every fetch in `|| true`, so a runner image without
curl would have read as "nothing published yet" and passed without checking
anything. Missing curl is now fatal.

#3146

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-29 00:19:16 -04:00
co-authored by Claude Opus 5
parent 0ab7d94294
commit 22a9a279b1
5 changed files with 280 additions and 153 deletions
+35 -36
View File
@@ -1,12 +1,21 @@
# 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>
# Push to dev: typecheck + lint + test + build :dev
# Push to main: typecheck + lint + test + build :latest + :<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.
# 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
@@ -17,7 +26,6 @@ name: CI & Build
on:
push:
branches: [dev, main]
tags: ["v*"]
paths:
- "src/**"
- "frontend/**"
@@ -34,11 +42,10 @@ on:
# `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.
# Cancel older runs on the same branch when a newer push lands.
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: ${{ !startsWith(github.ref, 'refs/tags/') }}
cancel-in-progress: true
permissions:
contents: read
@@ -66,7 +73,7 @@ jobs:
# 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')
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
@@ -91,17 +98,6 @@ jobs:
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
@@ -127,7 +123,12 @@ jobs:
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
# 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,"
@@ -142,7 +143,7 @@ jobs:
typecheck:
name: TypeScript typecheck
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
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
@@ -159,7 +160,7 @@ jobs:
lint:
name: Python lint
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
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
@@ -172,7 +173,7 @@ jobs:
test:
name: Python tests
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
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
@@ -202,7 +203,7 @@ jobs:
# 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')
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
@@ -291,7 +292,6 @@ jobs:
# 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
@@ -303,15 +303,14 @@ jobs:
BUILD_VERSION="$(sh packaging/version.sh display server)"
case "${{ github.ref }}" in
refs/heads/dev)
TAGS="$TAGS,${{ env.IMAGE }}:dev"
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"
TAGS="${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.sha }}"
;;
refs/tags/*)
TAGS="$TAGS,${{ env.IMAGE }}:latest,${{ env.IMAGE }}:${{ github.ref_name }}"
*)
echo "::error::This lane builds images for dev and main only."
exit 1
;;
esac
echo "value=$TAGS" >> $GITHUB_OUTPUT
@@ -349,8 +348,8 @@ jobs:
# 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 ;;
main) channel=stable ;;
*) channel=dev ;;
esac
echo "Baking in the $channel client."
base="${{ github.server_url }}/${{ github.repository }}/releases/download/$channel"