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
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:
@@ -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 }}."
|
||||
|
||||
@@ -27,6 +27,10 @@ on:
|
||||
- "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.
|
||||
@@ -42,6 +46,98 @@ env:
|
||||
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')
|
||||
@@ -95,8 +191,8 @@ jobs:
|
||||
# Build gates on lint + typecheck. The `test` job runs in parallel for
|
||||
# visibility but does not block dev image builds (DB-backed integration
|
||||
# testing happens against the dev image manually, not on every push).
|
||||
needs: [typecheck, lint]
|
||||
if: github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')
|
||||
needs: [gate, typecheck, lint]
|
||||
if: needs.gate.outputs.build == 'true'
|
||||
runs-on: python-ci
|
||||
container:
|
||||
image: git.fabledsword.com/bvandeusen/ci-python:3.14
|
||||
|
||||
Reference in New Issue
Block a user