Hermetic CI Pinning¶
AutoMobile is made of several components that must agree on one version:
| Component | What it is |
|---|---|
| Daemon | The @kaeawc/auto-mobile npm package (stdio/MCP + Unix-socket server) |
| Android CtrlProxy APK | On-device accessibility service, downloaded by the daemon |
| iOS CtrlProxy IPA | On-device XCUITest runner bundle, downloaded by the daemon |
| Android junit-runner | Kotlin runner that spawns the daemon during tests |
| iOS XCTestRunner | Swift runner that autostarts the daemon during tests |
The repo’s own CI is hermetic by construction — every component comes from a single
checkout — so a version knob is not needed there. External CI consumers don’t have
that luxury: their inputs are independent, and without pinning they can silently drift
(a @latest daemon pulling one APK while a runner expects another). This page documents
how to pin everything to one coherent version and, optionally, mirror the release
assets for offline builds.
The knobs¶
AUTOMOBILE_VERSION — the single-version knob¶
Set AUTOMOBILE_VERSION to a released version (e.g. 0.0.40) to pin the daemon package,
the Android APK, the iOS IPA, and their expected SHA-256 checksums together. The daemon
resolves all four from one value, so there is no way for them to disagree.
export AUTOMOBILE_VERSION=0.0.40
- Unset (or
latest, case-insensitive) resolves to the newest entry in the daemon’s baked release registry — a concrete version, never the floating@latesttag. - A version in the daemon’s baked registry is downloaded and its SHA-256 is verified.
- A version not in the registry (a typo, or a real release newer than the daemon’s
baked registry) has no checksum to verify against, so the download fails closed —
the daemon refuses to install an unverifiable asset. To use a not-yet-baked release,
either upgrade the daemon to one whose registry includes it, or take the explicit
escape hatch:
AUTOMOBILE_SKIP_ACCESSIBILITY_CHECKSUM=1(Android) / vendor a trusted bundle viaAUTOMOBILE_CTRL_PROXY_IOS_IPA_PATH(iOS). - The Android Kotlin runner reads
AUTOMOBILE_VERSIONas a fallback for the daemon package version, so a daemon this runner spawns lines up. A pre-existing shared daemon does not re-read it — see Shared daemon caveat.
AUTOMOBILE_ASSET_BASE_URL — the mirror knob¶
By default the daemon downloads the APK/IPA from GitHub Releases. For hermetic, offline-capable CI, mirror the assets and point the daemon at your mirror:
export AUTOMOBILE_ASSET_BASE_URL=https://artifacts.internal/automobile
The daemon fetches ${AUTOMOBILE_ASSET_BASE_URL}/${version}/control-proxy-debug.apk
(and .../control-proxy.ipa). Lay your mirror out with a directory per version:
https://artifacts.internal/automobile/
0.0.40/
control-proxy-debug.apk
control-proxy.ipa
For any version in the daemon’s baked registry, the downloaded asset’s SHA-256 is
verified against that registry, so a mirror cannot serve a tampered asset for a
registry-known version. For a version not in the registry there is nothing to verify
against, so the download fails closed (see the AUTOMOBILE_VERSION notes above) — a
mirror can only serve unverified assets if you deliberately opt out of verification.
Android hermetic recipe¶
- Pin the runner + daemon:
In Gradle, pin the Maven coordinate of
export AUTOMOBILE_VERSION=0.0.40junit-runnerto the matching release and set-Dautomobile.daemon.package.version=0.0.40(or rely onAUTOMOBILE_VERSION). - Provision the APK out-of-band so no network fetch happens at test time:
(or set
export AUTOMOBILE_CTRL_PROXY_APK_PATH=/opt/automobile/control-proxy-debug.apkAUTOMOBILE_ASSET_BASE_URLto your mirror for a checksummed download). - Force a clean daemon so a stale reused daemon can’t serve a different build:
export AUTOMOBILE_DAEMON_PACKAGE_VERSION=0.0.40 # explicit runner-side pin # -Dautomobile.daemon.force.restart=true - Gate the job on doctor so drift is a hard failure:
bunx @kaeawc/auto-mobile@0.0.40 --cli doctor
iOS hermetic recipe¶
- Pin the runner + daemon:
Pin the XCTestRunner SPM dependency to the matching git tag. Note: the iOS XCTestRunner autostart currently spawns a bare-PATH
export AUTOMOBILE_VERSION=0.0.40auto-mobile --daemon start(AutoMobileEnvironment.swift) — it does not yet pin the daemon package version itself. Pin it by installing the exact@kaeawc/auto-mobile@0.0.40on the runner’s PATH, or start the daemon yourself before the test job. (Baking the pin into the Swift runner is tracked as follow-up work for #2746.) - Vendor the IPA so nothing is fetched or compiled at test time:
The daemon copies (and extracts) this local bundle directly — no network fetch, no Xcode build. Do not also set
export AUTOMOBILE_CTRL_PROXY_IOS_IPA_PATH=/opt/automobile/control-proxy.ipaAUTOMOBILE_SKIP_CTRL_PROXY_IOS_BUILD=1: that flag short-circuitsneedsRebuild()and returns before the vendored IPA is consumed, so on a fresh host CtrlProxy would never be installed. UseAUTOMOBILE_SKIP_CTRL_PROXY_IOS_BUILD=1only when the extracted xctestrun artifacts are already present on the host. Alternatively, setAUTOMOBILE_ASSET_BASE_URLto your mirror for a checksummed download of a registry-known version (leave the IPA path unset). - Gate the job on doctor:
bunx @kaeawc/auto-mobile@0.0.40 --cli doctor --ios
Shared daemon caveat¶
The pin is a property of the daemon’s launch environment, resolved where the download
happens. AutoMobile runs one daemon per UID, shared across worktrees via a single
socket + PID file, and runners reuse an already-running daemon rather than restart it.
So if a daemon is already up with a different AUTOMOBILE_VERSION (or none), a second job
that exports a new pin will silently be served by the existing daemon — the pin is
ignored until the daemon is restarted.
For hermetic CI, force a clean daemon so the pin actually takes effect:
- Android:
-Dautomobile.daemon.force.restart=true(already in the recipe above). - Any platform:
bunx @kaeawc/auto-mobile@<version> --daemon restartbefore the job, then confirm viaide/status(below) thatreleaseVersionmatches your pin.
Verifying the pin¶
Ask the running daemon what it will actually fetch — the ide/status handler reports the
concrete resolved version plus the exact (mirror-aware) URLs and checksums:
{
"releaseVersion": "0.0.40",
"android": {
"ctrlProxy": {
"url": "https://artifacts.internal/automobile/0.0.40/control-proxy-debug.apk",
"expectedSha256": "…"
}
},
"ios": {
"xcTestService": {
"url": "https://artifacts.internal/automobile/0.0.40/control-proxy.ipa",
"expectedSha256": "…"
}
}
}
If releaseVersion is anything other than the version you pinned, the environment is not
hermetic — either AUTOMOBILE_VERSION was not exported into the daemon’s process, or a
pre-existing daemon with a different pin is still serving (see
Shared daemon caveat). Restart the daemon and re-check.