android-self-hosted-publishing
$
npx mdskill add microsoft/amplifier-bundle-android-tester/android-self-hosted-publishingGetting a build onto a tester's phone repeatedly, without a store, comes down to a small number of rules. All of these were validated by a rail that ran daily against a real device during an extended field project — each rule below fired for real at least once.
SKILL.md
.github/skills/android-self-hosted-publishingView on GitHub ↗
--- name: android-self-hosted-publishing description: | Use when building, reviewing, or debugging a self-hosted Android update rail — shipping builds to testers without a store — or when a device bug report needs its first question answered: is the fix actually on the device? Covers signed APK plus signed manifest sidecar, versionCode monotonicity enforcement, HTTPS-first with a TLS-only HTTP fallback, and installed-build verification. version: 1.0.0 license: MIT --- # Android Self-Hosted Publishing Getting a build onto a tester's phone repeatedly, without a store, comes down to a small number of rules. All of these were validated by a rail that ran daily against a real device during an extended field project — each rule below fired for real at least once. ## `versionCode` must strictly increase — refuse otherwise A publish step that would serve changed bytes under an existing `versionCode` must **refuse**, naming the collision. This is not theoretical tidiness. The refusal fired on the rail's first real use, catching a genuine attempt to republish changed bytes under the same version. Without it, that build reaches a device whose updater correctly decides it is already up to date — producing the worst possible failure shape: a tester debugging a bug that was already fixed, on a build that silently never arrived. ## Signed APK plus a signed manifest sidecar The shape that worked: | Artifact | Contents | |---|---| | `app-release.apk` | The signed release build | | `app-release.json` (sidecar, signed) | `version`, `versionCode`, `sha256` of the APK | The in-app updater verifies **both** the signature on the manifest **and** the `sha256` of the downloaded APK against the manifest before installing. Two independent checks: the signature says who published it, the hash says the bytes are intact. Two health checks are worth building in, because both failures are silent: - **published-build-is-signed** — a debug-signed artifact served from the release channel installs fine and then fails to update over the previously-installed release build. - **served-build-is-current** — the thing the server is *actually serving* matches the latest build produced. A publish step that succeeded while writing to the wrong path passes every other check. ## HTTPS first, fall back to HTTP only on a TLS failure — never on a 404 The update client tries HTTPS first, reusing **the app's own already-pinned CA and trust manager** rather than constructing a new permissive one. It falls back to plain HTTP only when the failure is TLS or connect-level. **A 404 is an answer.** Falling back to HTTP on a 404 converts "the server does not have this file" into "silently retry the same missing file with less security". The distinction is the whole point: a transport failure means *try another transport*; an HTTP status means *the server responded, and this is what it said*. Reuse the existing trust manager. An updater that builds its own TLS context is the classic place a permissive trust-all implementation gets introduced "temporarily" and stays. ## Prove the installed build is the build you just made **Symptom:** a fix that is definitely in the code is definitely not in the app. Hours disappear into debugging code that is not running. **Technique:** compare hashes, not version strings: ```bash LOCAL=$(sha256sum app-debug.apk | cut -d' ' -f1) DEV_PATH=$(adb -s "$SERIAL" shell pm path com.example.app | sed 's/package://' | tr -d '\r') DEV=$(adb -s "$SERIAL" shell sha256sum "$DEV_PATH" | cut -d' ' -f1 | tr -d '\r') [ "$LOCAL" = "$DEV" ] && echo "installed build IS the local build" || echo "MISMATCH" ``` Make this the first question of any device bug report, before reproducing anything: what build is on the device, and is it the one containing the fix? A version string can be stale (it is only correct if someone remembered to bump it); a hash cannot. ## Source Full narrative and additional context: `docs/FIELD-NOTES-2026-08.md` §5, "App publishing and self-hosted update rails".