ci: build the server image after the Android lane, not alongside it
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 2s
CI & Build / TypeScript typecheck (push) Successful in 8s
CI & Build / Build & push image (push) Skipped
CI & Build / Python tests (push) Successful in 10s
Android / Kotlin + Rust (APK) (push) Successful in 7m19s

Baking the newest client into every image left two holes, both raised by the
operator.

**An Android-only push never rebuilt the image.** `ci.yml` does not trigger on
`android/**`, so a new APK could be published and no image would ever pick it up
until some unrelated server change came along.

**A push touching both raced.** Both workflows start at once; the image build
would fetch the PREVIOUS client and there would be no second build to correct it
— `:<sha>` is the immutable rollback unit (rule 46), so rebuilding it with
different content would make it neither immutable nor a rollback unit.

Ordering now runs the other way: the Android lane finishes, then calls the image
build. `ci.yml` gains a `gate` job that stands down on any push touching the
Android app, and `android.yml` dispatches `ci.yml` when it is done. One image per
commit, containing the client from that commit.

Cases:

- **server only** — ci builds immediately; the newest published client is already
  the right one.
- **Android only** — ci does not trigger at all; the Android lane dispatches it
  afterwards.
- **both** — ci's push run stands down, the Android lane dispatches it. Exactly
  one image.
- **tag** — always builds. The Android lane does not run on tags, so waiting for
  a call that never comes would mean a release tag with no image.

The dispatch is `always()`, so a FAILED Android build still lets the server image
through with the previous client. The alternative is a broken Android lane
silently blocking server delivery, which is a worse failure than a slightly old
APK.

Two details that would each have made this quietly wrong:

The gate diffs the whole PUSHED RANGE (`event.before..HEAD`, full fetch), not
`HEAD^..HEAD`. A three-commit push whose Android change sat in the first would
otherwise have looked Android-free and raced anyway — silently, which is the
worst version of this bug.

The dispatch is `curl -fsS`, not `|| true`. If that call ever stops working the
symptom is server images silently never being built for Android pushes, which
nobody would notice until wondering why the app stopped updating.

The gate's path list has to match android.yml's trigger, and two places holding
one decision is the recurring failure in this repo (issues 2181-2183). It is a
`git diff` rather than a config precisely so the decision is visible in the log,
and both sides carry a comment pointing at the other.
This commit is contained in:
2026-08-20 21:46:05 -04:00
parent 010e9a2f85
commit 0cf77336d4
2 changed files with 131 additions and 2 deletions
+33
View File
@@ -52,6 +52,11 @@ jobs:
# Android ABIs + cargo-ndk + SDK/NDK + JDK 25 + ktlint + detekt.
image: git.fabledsword.com/bvandeusen/ci-rust-android:1.97
permissions:
contents: write
# For the dispatch at the end: this lane starts the server image build.
actions: write
defaults:
run:
working-directory: android
@@ -222,3 +227,31 @@ jobs:
name: thoughtsync-android-${{ steps.build.outputs.label }}-${{ github.sha }}
path: ${{ steps.build.outputs.apk }}
if-no-files-found: error
# The server image bakes in whatever client the dev release holds, so it has
# to be built AFTER this lane, not alongside it. `ci.yml` stands down on any
# push that touches the Android app (its `gate` job) and waits to be called
# from here — that is the other half of this.
#
# `always()`: a FAILED Android build must still let the server image through.
# There is no new client in that case, so it bakes in the previous one, which
# is exactly right — the alternative is a broken Android lane silently
# blocking server delivery.
#
# Not `if: success()` and not skipped on tags either: every ref that builds an
# image needs the call, or nothing builds one at all.
- name: Build the server image now the client is published
if: always() && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
working-directory: .
env:
GITHUB_TOKEN: ${{ github.token }}
run: |
# Loud on failure rather than `|| true`: if this call stops working, the
# symptom is server images silently never being built for Android pushes,
# which is invisible until someone wonders why the app never updates.
curl -fsS -X POST \
-H "Authorization: token $GITHUB_TOKEN" \
-H "Content-Type: application/json" \
-d '{"ref":"${{ github.ref_name }}"}' \
"${{ github.server_url }}/api/v1/repos/${{ github.repository }}/actions/workflows/ci.yml/dispatches"
echo "Dispatched ci.yml on ${{ github.ref_name }}."