Skip to content

ci: stop a hung build from stranding a tag with no APK - #94

Merged
vitofico merged 1 commit into
mainfrom
ci/issue-93-release-recovery
Jul 29, 2026
Merged

ci: stop a hung build from stranding a tag with no APK#94
vitofico merged 1 commit into
mainfrom
ci/issue-93-release-recovery

Conversation

@vitofico

Copy link
Copy Markdown
Owner

Fixes #93.

What happened

The Dark sepia merge (#92) landed on main at 10:43 UTC on 2026-07-28. The android-ci build job's very first step bumped the version, committed, tagged v2026.07.28.223 and pushed all of it — then the unit-test step hung. Gradle logged :data:sync:test at 10:50:18 and never printed another line. GitHub killed the job at its 6-hour ceiling at 16:44 UTC. The release job, which is what actually builds the signed APK and publishes the GitHub Release, has needs: 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 :app module, 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 bare LinkedBlockingQueue.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 in runCatching and is documented as "silent on failure". A call that dies before its request reaches the wire — an OkHttp callTimeout expiring on a contended runner, a connect-time reset — leaves refresh() 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 runTest runs inside runBlocking and 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's callTimeout does 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-second callTimeout these suites configure, so a merely slow runner still passes. Thirteen call sites in the AI suites, plus LibraryMirrorPushWorkerTest, which has the same defect reached through a worker that reports trouble by returning Result.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 test takes 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 --atomic so 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: 30 now, and the release step gets fail_on_unmatched_files so a glob that stops matching fails loudly instead of publishing an empty Release green.

release-apk.yaml is a new workflow_dispatch lever that rebuilds and publishes the signed APK for a tag that already exists. It never computes a version, never touches gradle.properties and never creates a tag, so it repairs a release without burning a version number. It mirrors android-ci's release job 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.kts fall back to the debug signing config and :app:assembleRelease then succeeds, producing a debug-signed app-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 with main would mint a refs/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:testDebugUnitTest and :data:library:testDebugUnitTest both execute and pass locally. Both workflows parse; every pinned action SHA in the new file already appears in android-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 Test tasks, lazily, and assembleRelease never 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.yaml gets dispatched against v2026.07.28.223 to publish the APK for the tag F-Droid already has.

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.
@vitofico
vitofico merged commit cdfcb9c into main Jul 29, 2026
5 checks passed
@vitofico
vitofico deleted the ci/issue-93-release-recovery branch July 29, 2026 07:52
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

F-Droid can't build

1 participant