Engineering practice
Releasing
A merge to main is a release: what that automates, what it refuses to automate, and the trap that makes a test build unshippable.
# What ships
Two APKs per release — the phone build and the television build — published to GitHub
Releases, each with a .sha256 beside it. There is no store listing.
Those asset names are a contract rather than a convention: the app reads the same releases
page to find out whether it is out of date, matches quiblo-tv-v or
quiblo-v to pick its own build, and verifies the published checksum before the
television hands anything to the installer. A release that renamed an asset would break the
update path silently.
Both are R8-minified and both are size-budgeted. The television APK being smaller than the phone's, despite depending on every feature module, is the standing confirmation that R8 really does strip the phone UI it never references.
# Signing, and the one-way door
A release APK signed with the Android debug key installs fine and can never be upgraded over a properly-signed install.
Debug-key signing is genuinely useful for testing a minified build — R8 changes behaviour, so a debug build is not the same artefact. But an APK signed that way must never reach a user, because the day a real release is signed, everyone holding a debug-signed build has to uninstall and lose their configuration.
Anything handed to a tester should be labelled with which key signed it.
Published releases are signed with the real key, which lives as an encrypted CI secret and nowhere else. The workflow fails rather than falling back when the secret is absent: an unsigned or debug-signed artefact reaching the releases page is the one mistake here that cannot be undone by publishing a corrected build afterwards.
# Getting a binary secret into CI intact
A keystore is binary, and a CI secret is text, so it travels base64-encoded and is decoded on the runner. That is unremarkable except for one way it goes wrong that costs an afternoon.
Do not pipe a secret through PowerShell. It will re-encode it. The upload succeeds, the value looks right, and the failure surfaces inside CI as a corrupt keystore — a long way from the command that broke it.
Encode from a POSIX shell, and verify the round trip before uploading: decode your own encoded copy and compare checksums with the original file. A minute of checking against a failure that appears to be about signing, in a place you cannot easily inspect, is a good trade.
The general lesson is broader than keystores: any pipeline that rewrites text is a hazard for data that only happens to look like text.
# A merge to main is a release
Three jobs in a line, and the order is the whole point: nothing is versioned until the gate is green, and nothing is published until it is versioned.
- Gate. Exactly what a pull request has to pass — the same workflow file, reused rather than copied. A second copy of a build is a second thing to keep true.
- Version. Work out whether this merge releases at all, and as what. Bump, commit, tag.
- Publish. Build, sign, check the size budget, write checksums, upload.
The run is serialised and never cancelled. Two merges landing a minute apart must not both bump from the same version, and a release cancelled halfway leaves a tag with no build behind it — the one state on a releases page that cannot be fixed by running something again.
# What counts as a release
A merge to main releases when something released changed. A typo in a README, a new test, a CI tweak — none of those alter a byte a user runs, and shipping them as versions teaches people that release notes are not worth reading.
The commit types already say which is which, so the decision needs no new tool and no new discipline:
| In the range since the last tag | Result |
|---|---|
feat: | Minor |
fix: | Patch |
| Anything else | No release. Main moves, nothing publishes |
! or BREAKING | Stop and ask a human |
Majors are deliberately not inferred. What makes a release major here is a
consequence a prefix cannot see — an export format that stops loading, a raised minimum
Android version, a withdrawn feature. A ! somebody typed is a reason to stop and
decide, not a licence to spend the first number.
Merge commits are skipped when reading the range: a merge subject is prose, and the commits it brings in carry the types on their own.
# Prose does not pay for an Android build
Every pull request used to run ten to eighteen minutes of Android build, including the ones that only fixed a typo. Now the build is skipped when nothing but prose changed — around thirty seconds instead — while the checks that prose can break keep running on everything: the leaked-playlist and forbidden-brand greps, the licence headers, and a parse of every workflow file.
Two decisions are worth copying. The filter lives inside a job, not on the trigger
— a workflow filtered out by paths: never reports its check at all, so a branch rule
requiring that check waits forever for a run that will never happen. A Gate job
always reports instead, failing when a needed job failed and passing when one was skipped, and
that is the name to require. And prose is an allowlist: anything unnamed counts
as code, so a new directory nobody thought about gets the full build and a wrong guess costs a
slow pull request rather than an unbuilt one.
On a merge the same rule applies, with one extra condition that is load-bearing: the gate is skipped only when nothing will publish and the change was prose. The release class is read from every commit since the last tag, so a feature commit whose own run was cancelled can be published by the next merge along — skipping on "this push was prose" alone would ship a binary no gate had seen.
It also arrived with a bug worth knowing: git diff --name-only quotes any path
containing a non-ASCII byte, so every document whose filename carried an em dash read as code
until the classifier was told core.quotepath=false.
# The gate that is deliberately not run twice
The publish job can run the full gate before signing, and whether it does depends on how the release started.
- A tag pushed by hand — gate runs. Nothing has checked that tree.
- The merge-to-main path — gate skipped. It has just run on the same tree; the only difference between the two commits is two version numbers.
Paying ten minutes of a runner to re-prove that is the sort of cost nobody notices until releases take half an hour and people start avoiding them.
Everything after the gate — the signing, the size budget, the checksums — lives in one file regardless of which of the three ways a release was started, precisely so the procedure cannot differ between them.
# What is not automated, and will not be
The acceptance sweep. A release can be built, signed and published without anybody having watched the app run on a television, and that is a real gap rather than an oversight — the sweep needs three physical devices and a remote control, and no runner has those.
So the automation covers what a machine can honestly assert: it compiles, the tests pass, detekt and Lint are clean, the artefacts are signed and within budget. It cannot assert that the thing works, and the version number it produces should not be read as claiming so.
The upgrade half of the definition of done also stays live: each release must be installable over the previous one, which is a check that only became possible once there was a previous one.