diff --git a/.forgejo/workflows/android.yml b/.forgejo/workflows/android.yml index dfe0f68..e7e922b 100644 --- a/.forgejo/workflows/android.yml +++ b/.forgejo/workflows/android.yml @@ -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 }}." diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index 82811eb..575b1f0 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -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 — `:` 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