ci: stop a hung build from stranding a tag with no APK - #94
Merged
Conversation
A merge to main on 2026-07-28 tagged v2026.07.28.223 and then hung in the unit-test step until GitHub killed the job at its 6-hour ceiling. The version bump and tag are pushed by the first step of the build job, so they were already public; the release job that builds the signed APK never started. F-Droid verifies its reproducible builds against the upstream APK, so a tag with nothing attached stops them dead. The same stall had happened once before on a feature branch, where it cost nothing but six hours of runner time. Four changes, addressing the hang, the blast radius, and the recovery: Bound the hang. MockWebServer's no-argument takeRequest() is a bare LinkedBlockingQueue.take(), so draining one request more than actually reached the server parks the thread with no deadline. AiRepository.refresh() wraps both its HTTP calls in runCatching and is documented "silent on failure", so a call that dies before hitting the wire returns normally having recorded one request while the test drains two. Blocking is what makes that fatal rather than flaky: runTest schedules its 60-second guard on the same runBlocking event loop the test body occupies, so blocking that thread means the timeout can never fire. awaitRequest() waits with a 10-second deadline instead, well past the 5-second callTimeout these suites configure. LibraryMirrorPushWorkerTest had the same defect, reached via a worker returning Result.retry() instead of throwing. Cap every test task at 10 minutes from the root build script, so a future wedge anywhere fails the build in minutes rather than idling. A whole ./gradlew test is about 35 seconds, so the margin is large. Push the bump and tag only after the tests and lint pass, with --atomic so the branch and the tag land together or not at all. A run that fails or hangs now leaves origin untouched. The old retry-on-rejection loop is gone: resetting onto a moved main and re-applying the bump would publish a tag whose tree the run never built, which trades a visible failure for a silently unverified release. Both jobs also carry timeout-minutes now. Add release-apk.yaml, a workflow_dispatch lever that rebuilds and publishes the signed APK for an existing tag. It never computes a version or creates a tag, so it repairs a release without burning a version number, and it refuses to run without the signing secrets — a debug-signed APK is worse than none, since F-Droid rejects the signature and Android then blocks any upgrade over it.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #93.
What happened
The Dark sepia merge (#92) landed on main at 10:43 UTC on 2026-07-28. The
android-cibuild job's very first step bumped the version, committed, taggedv2026.07.28.223and pushed all of it — then the unit-test step hung. Gradle logged:data:sync:testat 10:50:18 and never printed another line. GitHub killed the job at its 6-hour ceiling at 16:44 UTC. Thereleasejob, which is what actually builds the signed APK and publishes the GitHub Release, hasneeds: build, so it never started.End state: a public tag with no Release and no APK behind it. F-Droid verifies its reproducible builds against the upstream signed APK, so that stops them dead — hence the issue.
This was not a one-off. Run 29008715355 on 2026-07-09 hung identically: same step, same
:appmodule, same 6-hour kill. It was on a feature branch, so it cost runner time and nothing else. A healthy run takes about six minutes.Why it hung
MockWebServer.takeRequest()with no arguments is a bareLinkedBlockingQueue.take()— no deadline. Draining one request more than actually reached the server parks the calling thread forever.That is a live hazard in the AI repository suites because
AiRepository.refresh()wraps both of its HTTP calls inrunCatchingand is documented as "silent on failure". A call that dies before its request reaches the wire — an OkHttpcallTimeoutexpiring on a contended runner, a connect-time reset — leavesrefresh()returning normally with one recorded request, while the test drains two. The second drain never comes back.Blocking the thread is what turns that from flaky into fatal. On the JVM
runTestruns insiderunBlockingand schedules its 60-second wall-clock guard on that same event loop, so a test that blocks the loop's own thread can never be timed out. Nothing else was in a position to save it either: OkHttp'scallTimeoutdoes not apply to a queue take, MockWebServer has no watchdog, and neither the job nor the Gradle tasks had a timeout. The only ceiling left was GitHub's six hours.That mechanism fits the evidence —
:app-only (these drain-after-a-failure-swallowing-call patterns exist only there), both build variants (src/test/compiles into each), and intermittent at roughly 2-in-12 (about 36 unbounded drains per run needs only a sub-1% per-drain loss rate).What changed
Bound the wait.
awaitRequest()waits with a 10-second deadline and fails with a diagnostic instead of parking — comfortably past the 5-secondcallTimeoutthese suites configure, so a merely slow runner still passes. Thirteen call sites in the AI suites, plusLibraryMirrorPushWorkerTest, which has the same defect reached through a worker that reports trouble by returningResult.retry()rather than throwing.Cap every test task at 10 minutes from the root build script, so the next wedge — wherever it is — fails the build in minutes. A whole
./gradlew testtakes about 35 seconds, so there is a lot of room before this bites.Push the bump and tag only after tests and lint pass, with
--atomicso the branch and the tag land together or not at all. Without--atomic, a rejected non-fast-forward branch update still lets a brand-new tag through, which is this same bug by a shorter route. A run that fails or hangs now leaves origin untouched.The retry-on-rejection loop is deliberately gone rather than moved. Resetting onto a moved main and re-applying the bump would publish a tag whose tree the run never assembled or tested — trading a visible failure for a silently unverified release. main can only move under us via a push that misses this workflow's path filters, since android-ci's own main runs are serialised by the concurrency group; if that happens the job fails, nothing is stranded, and a re-run bumps cleanly from the new tip.
Both jobs also carry
timeout-minutes: 30now, and the release step getsfail_on_unmatched_filesso a glob that stops matching fails loudly instead of publishing an empty Release green.release-apk.yamlis a newworkflow_dispatchlever that rebuilds and publishes the signed APK for a tag that already exists. It never computes a version, never touchesgradle.propertiesand never creates a tag, so it repairs a release without burning a version number. It mirrors android-ci'sreleasejob step for step — same pinned action SHAs, same JDK, same SDK packages, same signing env, same Gradle task.It also refuses to publish without the signing secrets. A blank keystore makes
app/build.gradle.ktsfall back to the debug signing config and:app:assembleReleasethen succeeds, producing a debug-signedapp-release.apk. Publishing that would be worse than publishing nothing: F-Droid rejects the signature outright, and anyone who installed it could never upgrade, because Android refuses to install over a changed signature. Two smaller guards for the same reason — it verifies the input really is a tag (checkout accepts a branch name or a bare SHA just as happily, and the release API creates a missing ref, so a mis-dispatch withmainwould mint arefs/tags/main), and it works out whether the tag is the newest one before deciding the "Latest" badge, so repairing an old release does not demote the current one.Verification
:app:testDebugUnitTestand:data:library:testDebugUnitTestboth execute and pass locally. Both workflows parse; every pinned action SHA in the new file already appears inandroid-ci.yaml; no dispatch input is interpolated into a shell command.Nothing here changes what is built or what lands in the APK — the Gradle change only configures
Testtasks, lazily, andassembleReleasenever realises them — so F-Droid reproducibility is untouched.After merge
Merging cuts a new version, which usefully exercises the new ordering end to end. Then
release-apk.yamlgets dispatched againstv2026.07.28.223to publish the APK for the tag F-Droid already has.