AGENTS.mdandCLAUDE.mdare symlinks into.ai/- Why this fork exists: maintained and multi-arch
- arm64 Linux builds Android only under x86-64 emulation
- Flutter is installed by
git clone, not the release tarball - Multi-arch via native matrix + push-by-digest + manifest merge
- Publishing is gated to
masterand manual dispatch - Version tracking via Renovate, not a bespoke cron
- Quiet-by-default: telemetry off, version check skipped
- DX CLIs are compiled to native binaries, not
pub global activate - Build-env setup helpers:
ch-build-setup-android+ch-fetch-firebase-config
Consolidated source of truth for design decisions, rejected paths, and non-obvious
trade-offs. The README, .ai/AGENTS.md, and
.ai/CLAUDE.md reference sections here by anchor (e.g.
APPENDIX.md#arm64-android-build-limitation).
-
Decision: the canonical text for both files lives under
.ai/. The repo root holds symlinks (AGENTS.md → .ai/AGENTS.md,CLAUDE.md → .ai/CLAUDE.md). A sub-scope guide would follow the same pattern (<subdir>/AGENTS.md → <subdir>/.ai/AGENTS.md) if one is ever added. -
Why: Claude Code (and most coding agents) auto-discover
CLAUDE.md/AGENTS.mdat the project root, but two more loose Markdown files at the root add visual noise. Scoping the agent-guidance files under.ai/keeps them together; the root symlinks preserve auto-discovery. -
Committed vs. local: the
.ai/canonical files are committed; the root symlinks are gitignored (/AGENTS.md,/CLAUDE.mdin.gitignore), so nothing in the build pipeline depends on them. Each contributor (or their agent) recreates the symlinks locally:ln -s .ai/AGENTS.md AGENTS.md ln -s .ai/CLAUDE.md CLAUDE.md
A real file at the root beats the symlink — if a contributor prefers a committed root
AGENTS.md/CLAUDE.md, that works too; the.ai/copies stay the default. -
Cross-platform note: symlinks survive
git cloneon macOS/Linux. On Windows hosts without symlink support the file shows up as a small text file containing the link target; the fallback is real files at root, hand-synced. -
No
CODESTYLE.mdyet: the code surface (Dockerfiles, workflow YAML, one shell script) is small, so style is folded into.ai/AGENTS.mdrather than split into a speculativeCODESTYLE.md. When it grows,CODESTYLE.mdgoes at the root (not symlinked — style serves humans and agents alike).
cirruslabs/docker-images-flutterwas multi-arch but is EOL. Cirrus Labs is joining OpenAI; the images froze ~2026-05-01. They shipped genuinelinux/amd64+linux/arm64manifests (verified viadocker buildx imagetools inspect), but receive no further updates.- The
davidmartos96fork tracks the latest Flutter but is amd64-only. Its workflow hardcodesplatforms: linux/amd64and the QEMU setup step is commented out. chrysaliswants both: the latest stable Flutter and nativearm64, maintained under our own GHCR namespace (ghcr.io/lahaluhem). "Native arm64" has a sharp caveat for Android builds — see #arm64-android-build-limitation.
Decision (resolved): ship multi-arch and make the arm64 image able to build Android by
baking in the x86-64 runtime libs its (x86-64) Android tools need, so builds work on any host
that can emulate x86-64. Validated on Apple Silicon + OrbStack with a real
flutter build apk --debug that produced an APK. It is emulated, never native; do not
claim otherwise.
Google ships the Linux Android SDK tools (aapt2, aapt, zipalign, dexdump, adb,
cmake, ninja, the NDK) as x86-64 only; there are no arm64 Linux variants, and the
Android Gradle Plugin on arm64 still fetches the -linux (x86-64) maven aapt2. On a bare
arm64 host flutter build apk reaches :app:configureCMakeDebug[arm64-v8a] and dies with
Dynamic loader not found: /lib64/ld-linux-x86-64.so.2.
That loader error is the tell. It takes both:
-
Host x86-64 emulation. Verified: an x86-64 static binary runs inside an arm64 container on OrbStack with no setup. The emulation is the host's to provide.
-
x86-64 userland libs in the image. A dynamic x86-64 binary still fails until the image carries the x86-64 loader and libs.
readelfon the real tools pins the exact set:aapt2needslibc6+libgcc-s1.- NDK
clangneedslibc6+libgcc-s1+zlib1g(libz.so.1); even the default app triggers an NDK CMake configure, so this is not optional. cmakestatically links its C++ runtime (needslibc6only);aapt/zipalignuse thelibc++.sobundled inbuild-tools/lib64; nothing linkslibstdc++.so.6.
So the minimal baked set is
libc6+libgcc-s1+zlib1g(the arm64-guarded emulation-libs layer inimages/android-sdk/Dockerfile). amd64 is untouched.
| Host | x86-64 emulation | Setup |
|---|---|---|
| Apple Silicon + OrbStack | built-in | none |
| Apple Silicon + Docker Desktop | yes | enable "Use Rosetta…", else QEMU |
| arm64 Linux (Graviton, Pi, CI) | only if registered | docker run --privileged --rm tonistiigi/binfmt --install amd64 |
| arm64 Linux, nothing registered | none | builds still can't run the tools |
Most of an Android build is JVM work (Gradle, AGP, d8/r8) that runs native arm64.
Only the native tools (aapt2, zipalign, and cmake/ninja/clang for NDK steps) are
emulated, so overhead scales with how much native code you build: mild on Apple Silicon
(Rosetta-class), heavier on QEMU hosts.
- Document-only (consumer installs the libs). Clunky; pushes a setup chore onto every user when a tiny image layer removes it.
- Bundle
qemu-user-static+ per-tool wrapper scripts. Self-contained on any arm64 Linux, but fragile and high-maintenance (every native tool wrapped; AGP/NDK bumps add more). The host-emulation requirement is the honest, low-maintenance floor instead. - A separate lean vs build-capable image. The strict lib set is three packages, so the split would double the build matrix, tags, and maintenance to save almost nothing. Revisit only if a heavy x86-64 NDK is ever bundled (which isn't in scope for amd64 either).
- An iOS/macOS
flutter-macimage. Out of scope, and not a Docker image at all: iOS needs a macOS VM on Apple Silicon Mac hardware (Tart / AppleVirtualization.framework), a separate Mac-only product. Do iOS on macOS CI runners; a Tart image, if ever wanted, is a separate sibling repo. (Config fetch is the exception that stays in scope:ch-fetch-firebase-config --iospullsGoogleService-Info.plist, a platform-agnostic network call rather than a build; see #build-setup-android.)
images/android-sdk/structure-test.yaml asserts the x86-64 loader is present (deterministic,
runs on both arches in CI). scripts/test.sh (image target) additionally runs aapt2 under
emulation on arm64, skipping rather than failing where the host has no emulation registered.
cirruslabs shipped arm64 manifests with the same x86-64-tool reality but only ever smoke-tested the x86_64 emulator path, so their arm64 Android build was never actually verified. A present manifest is not a verified build.
- Decision: the
flutterimage gets its SDK fromgit clone --depth 1 --branch ${FLUTTER_VERSION} https://github.com/flutter/flutter.git(images/flutter/Dockerfile), not from the prebuiltflutter_linux_<ver>-stable.tar.xzrelease archive the manual-install docs point at. - Why (the decisive reason): there is no arm64 Linux Flutter tarball. Every stable entry in
Flutter's
releases_linux.jsonisdart_sdk_arch: x64(zero arm64). That archive bundles an x64 Dart SDK, so dropping it onto the arm64 image would runflutter/dartunder x86-64 emulation. That breaks the native-arm64 promise this fork exists for (#why-multi-arch) and contradicts AGENTS hard rule 5 ("native arm64 is fine forflutter/dart/test/analyze"). - What the clone does instead: it fetches only the framework and tooling (arch-independent
Dart source plus shell scripts); the first
flutterrun bootstraps the host-arch Dart SDK viabin/internal/update_dart_sdk.sh. So each per-arch image gets an arch-matched native Dart with no extra logic:- Flutter SDK tarball (
releases_linux.json): x64 only, no arm64 published. - Dart SDK (
dart-archive): both arches, includingdartsdk-linux-arm64-release.zip(what the bootstrap pulls on the arm64 build).
- Flutter SDK tarball (
- Why "involving git" is not actually heavy-handed:
- git is a hard Flutter dependency regardless of install method. flutter_tools shells out to git for version/channel/upgrade detection, and Flutter's manual-install page lists Git as a required prerequisite for the tarball route too, so it is in the image either way.
--depth 1 --branch <tag>is a shallow, single-branch checkout, not full history.- The heavy bytes (Android engine artifacts) are pulled by
flutter precache --androidon first run under either method, so the clone's only extra cost is a small shallow.git.
- Ties into version tracking: the pin is consumed as the clone's
--branchtag and tracked by Renovate'sflutter-versiondatasource; see #renovate-version-tracking for why that beats Dependabot. - Rejected: download the x64 tarball and hand-swap an arm64 Dart SDK. Unsupported, and the tarball's framework expects its bundled Dart; the clone's bootstrap is the maintained path that resolves the correct Dart per arch.
- Native matrix over QEMU. amd64 builds on
ubuntu-latest, arm64 onubuntu-24.04-arm(a free hosted runner for public repos). Native arm64 is far faster than emulating arm64 on an amd64 host, and avoids QEMU flakiness. The single-job QEMU approach (platforms: linux/amd64,linux/arm64) is kept in mind only as a fallback. - Push-by-digest +
imagetools create. Each matrix leg builds and pushes its image by digest (outputs: type=image,...,push-by-digest=true), uploads the digest as an artifact, and a merge job assembles the per-arch digests into one manifest list withdocker buildx imagetools create. This is the canonical Docker pattern for distributing a multi-platform build across native runners. android-sdkis published beforeflutterbuilds. Becauseimages/flutter/DockerfileisFROM ghcr.io/lahaluhem/android-sdk:latest, that tag must already be a manifest list when thefluttermatrix runs, so each per-archflutterbuild pulls the matching base automatically. Hence the job order build-android → merge-android → build-flutter → merge-flutter.provenance: false. Provenance/SBOM attestations addunknown/unknownentries to the manifest list that muddydocker manifest inspect; disabling them keeps the manifest to exactly the two platform images.
- Decision: the published images are OCI-native. Each per-arch push-by-digest build runs with
oci-mediatypes=true(outputs: type=image,...,oci-mediatypes=trueinbuild-image.yml), so the arch's manifest, config, and layers carryvnd.oci.*media types instead of Docker schema 2;docker buildx imagetools createthen assembles them into anapplication/vnd.oci.image.index.v1+jsonindex. Sodocker buildx imagetools inspect ghcr.io/lahaluhem/<img>:<tag> --rawreports the OCI index type. - Metadata is workflow-owned, not baked into the Dockerfiles.
docker/metadata-actiongenerates the OCI labels (image config) and annotations; the Dockerfiles carry noLABELs. The split build means metadata attaches in two places: the per-arch push applies labels + manifest-level annotations (DOCKER_METADATA_ANNOTATIONS_LEVELS: manifest), and the merge applies index-level annotations, fed toimagetools create --annotation "index:..."(...LEVELS: index).title/descriptionare curated per image (frombuild_and_push.yml);source/revision/created/version/licensesfill in from the repo and the build commit. - Two checks keep it honest. On publish, the merge job runs
scripts/assert_oci_registry.sh(the index, both arches, and every manifest, config, and layer) pluscrane validateon the pushed image. At PR time the amd64 build leg runsscripts/assert_oci_layout.shagainst atype=ocibuild, so a regression to Docker media types fails before it can publish. - Why not bake
LABELs in the Dockerfile? One source avoids drift (a label edited in one Dockerfile but not the other), letsmetadata-actionfill commit-derived fields (revision/created) a staticLABELcan't, and covers index-level annotations, which aren't expressible as a DockerfileLABELat all (they live on the manifest list, which exists only after the merge).
- Publish on
masterpushes andworkflow_dispatch; pull requests build-validate without pushing. This keeps the shared tags (android-sdk:latest,flutter:stable,flutter:<version>) from being clobbered by every branch, while still validating both arches on PRs and giving a deliberate manual path to publish/verify a branch (gh workflow run build_and_push.yml --ref <branch>). - Why not push on every branch: concurrent branch builds would race on the same tags.
Gating to
master+ explicit dispatch makes publishing intentional. - Verification: a publish is only "done" once
docker manifest inspect <ref>shows bothlinux/amd64andlinux/arm64. Never report success without it.
- Decision: one dedicated manager (Renovate,
config:best-practices, Mend-hosted) owns every version bump, replacing the hand-rolledupdate_flutter_versions.sh+check_flutter_versions.ymlcron. Config lives in.github/renovate.jsonc. - Why: one tool beats scattered glue (a script here, a cron there). Less bespoke code to
maintain, and Renovate opens, labels, and throttles the PRs itself.
config:best-practicesadditionally SHA-pins the GitHub Actions and digest-pins theubuntubase, which the old cron never did. Given the current wave of CI supply-chain attacks, pinning to immutable digests is worth the extra PR noise. - Why not Dependabot: Dependabot only updates dependencies inside manifests of ecosystems it
understands. The Flutter SDK pin is a bare string in
versions.env, resolved from Flutter's releases JSON and consumed as agit clone --branchtag. No Dependabot ecosystem parses that, and it has no regex/custom-manager escape hatch. Renovate's custom manager plus theflutter-versiondatasource do exactly this. - How the Flutter pin is tracked: a
customManagersregex binds the# renovate: datasource=flutter-version depName=fluttermarker aboveFLUTTER_VERSIONinversions.env. Theflutter-versiondatasource marks only thestablechannel as stable, so with Renovate's defaultignoreUnstablethe pin only ever moves to a stable release, never a.prebeta. Theversions.envlint inscripts/test.shis a backstop that rejects any non-x.y.zvalue. - One exclusion:
docker:pinDigests(pulled in bybest-practices) would pin everyFROM, including the internalghcr.io/lahaluhem/android-sdk:latestthe flutter image builds on. That tag is rebuilt and republished every run and must float, so apackageRulesetspinDigests: falsefor it.ubuntu:24.04stays digest-pinned, which is multi-arch-safe because Renovate pins the manifest-list digest. - Cadence: weekly (
schedule:weekly), down from the old every-2h cron. Stable Flutter ships roughly quarterly, so frequent polling was wasteful. - CI lint tools: hadolint and actionlint are pinned the same way. Their versions live as
# renovate:-marked env vars in.github/workflows/test.yml, tracked by a secondcustomManagersentry via thegithub-releasesdatasource. The install step downloads the exact release asset, verifies it against the publisher's.sha256/checksums.txt, then installs. This replaced a step that curledlatesthadolint and randownload-actionlint.bashfrommain: unpinned, and a corrupt download once slipped through as a valid-looking HTTP 200 and broke a run. Pinning plus checksum verification closes both the supply-chain gap and that flake. - Why not lint Actions: the standard alternative is official Actions like
hadolint/hadolint-action, whichbest-practiceswould SHA-pin automatically. We pin in the workflow instead becausescripts/test.sh lintis the single source of lint truth, run identically locally and in CI, and it invokeshadolint/actionlintas binaries onPATH. Pinning the versions in the workflow keeps CI installing the same toolstest.shruns. A lint Action runs the tool its own way, which would either split CI fromtest.shor force us to reconcile the Action's pinned version with whatevertest.shinstalls locally. One coherent system beats two kept in sync, and it reuses the custom-manager +github-releasesmechanism already in place for Flutter.
- Decision: the
flutterimage bakesFLUTTER_SUPPRESS_ANALYTICS=true+BOT=true. Together they no-op Flutter and Dart analytics and skip Flutter's version-freshness check (a per-job network hit in fresh CI containers). Overridable at runtime (-e BOT=false). - Why
BOT: the version check has no dedicated env var (--no-version-checkis per-invocation only), so bot detection is its only bakeable lever.flutteranddartread the same env list via flutter_tools'BotDetector/ dartdev'sisBot()(lib/src/base/bot_detector.dart, an internal lever, not user-facing docs);BOT=truetrips it, covering both the version check and analytics for every user (an env var, not a per-HOMEopt-out file).FLUTTER_SUPPRESS_ANALYTICSis the self-documenting belt for Flutter's side. - Why not
CI=true: redundant and too broad.BOTalready trips bot detection, soCIadds nothing for flutter/dart; meanwhileCIis honoured by many unrelated tools (npm, test runners), so baking it would force everything a consumer runs in the container into CI mode, a runtime assumption the portable-image rule avoids. Real CI setsCIitself anyway, so baking it would only surprise localdocker run.BOTis the surgical pick.
- Decision: the two Flutter DX tools (
cider,dependency_validator) are compiled to self-contained native executables withdart compile exeinto/usr/local/bin, rather than installed withdart pub global activate. - Why:
pub global activatedoesn't leave a portable binary, it leaves a precompiled snapshot keyed to the exact SDK version (the file is literally named…/global_packages/cider/bin/cider.dart-<sdk>.snapshot). That snapshot lives inPUB_CACHE, and in CIPUB_CACHEis often a shared or persisted cache (a runner-level volume mounted across jobs). The moment the SDK and that cache drift apart, the tool breaks with an SDK-mismatch error and stays broken until it's re-activated. Two ways they drift: the host SDK is upgraded under a persisted cache, or apub-cachevolume seeded once from an image outlives a rebuild of that image on a newer Flutter (the volume keeps the old snapshot and shadows the image's fresh one). A native binary in/usr/local/binneither lives in nor readsPUB_CACHE, so it survives SDK upgrades and can't be shadowed by a pub-cache volume. - How it's built: activate each tool (only to resolve, download, and generate a
package_config.json),dart compile exethe resolvedbin/<exe>.dartagainst that config, thenpub global deactivateand dropglobal_packages/bin/hostedso no snapshot or source ships in the image. The image is built natively per arch, so each arch gets its own ELF. - Trade-offs: about 15 MB for the two self-contained binaries, and
dart pub global runis no longer wired up for them (unused here). Versions stay pinned and Renovate-bumped via thedartdatasource, now as# renovate:-markedENV …_VERSIONlines (the same shape asYQ_VERSIONin the android-sdk image), since the old custom manager keyed off theactivatelines that are gone. - Scope note: these are pure-Dart tools, so this is arch-agnostic and unrelated to the arm64 Android-build limitation (#arm64-android-build-limitation).
- Decision: the
flutterimage ships an inert helper onPATH,ch-build-setup-android, that materialises the files an Androidflutter build apk|aabexpects, a--dart-define-from-filefile,android/app/google-services.json(fetched from Firebase, see below), and dev signing (a keystore plusandroid/key.properties), from namespacedCH_BUILD_*env vars. It is called explicitly in a build job and does nothing on its own. A second helper,ch-fetch-firebase-config, performs the Firebase fetch and is usable standalone. - Why it counts as in scope, though it looks like consumer logic: the portable-image rule
(#quiet-ci-defaults) forbids runtime assumptions, things that change
behaviour for every command by default (why
CI=truewas rejected). It does not forbid inert tools you opt into by name. This helper is the same shape as the DX CLIs already baked in (#dx-tools-native): onPATH, no effect until called, no CI system assumed (it reads only env vars, which every CI provides). So it sits withcider, not withCI=true. The worry about wasted file I/O on non-build jobs is moot for free: those jobs never call it. - Three orthogonal lanes; two opt-in, signing always-on. The helper runs three independent
things, dart-defines, google-services, and dev signing, in any combination. dart-defines and
google-services are opt-in: with none of their vars set they do nothing (dart-defines is a
variadic list, skip-if-empty; google-services is an all-or-nothing set of three, where a partial
set is a fail-fast error). Signing is deliberately always-on: every run generates a dev keystore
and writes
android/key.propertiesunless that file already exists. It stays always-on because, unlike the Firebase fetch (a real credential plus a network call), it only mints a local throwaway keystore with no external dependency and no secret, so the get-going convenience costs nothing and needs no opt-in. Gating it on a required keystore password (so a no-vars run produces nothing too) is a possible later mode; the asymmetry is intentional, not an oversight. - dart-defines need no invented format, and the file carries no "type". Traced through stable
flutter_tools:extractDartDefineConfigJsonMapcontent-sniffs a--dart-define-from-filefile as JSON vs.envby a leading{(configRaw.trim().startsWith('{')), i.e. by content, not the file extension; the.envbranch builds aMap<String,String>while the JSON branch keeps typed values, but both are emitted as defines via'$key=$value'(.toString()). So.envDEBUG=trueand JSON{"DEBUG":true}yield the identical defineDEBUG=true; the Dart type is chosen at the read site (String/bool/int.fromEnvironment), not in the file. So the helper writes plain per-keyKEY=valuelines verbatim. The user quotes values that need it (flutter strips wrapping quotes, treats an unquoted trailing#as a comment, and rejects multi-line values). No delimiter to invent, no JSON to assemble, and no base64 by default (base64 was only a CI-specific escaping workaround, not part of the general mechanism). The lane lives in a standalonech-write-dart-definesthatch-build-setup-androiddelegates to (parallel to the fetcher extraction below), so a job that only needs the env file, e.g. an iOS build on a macOS runner, can curl and run it alone, without the orchestrator's keytool/signing dependency. - google-services is fetched from Firebase, not pasted as a blob. An earlier
CH_BUILD_ANDROID_GOOGLE_SERVICES(the wholegoogle-services.jsonin one secret) was replaced, not complemented: the helper now fetches the config at build time from the Firebase Management API (projects/-/androidApps/<appId>/config, the same "apps getConfig" callfirebase apps:sdkconfigwraps) and writesandroid/app/google-services.json. Fetching keeps the config in sync with Firebase and a full blob out of the secret store. The Firebase CLI is deliberately not installed for this: its standalone binary is ~250 MB and x86-64-only (no arm64 build, so on the arm64 image it would run emulated for one HTTP GET), and the npm route drags the whole Node runtime plus a large dependency tree into both images. getConfig is a single authenticated request, so the helper makes it directly withcurl+jq+openssl(all small; onlyopensslhad to be added to the android-sdk image). Theprojects/-wildcard resolves the app's project from the app id, so no project id is an input. - Non-interactive auth is a self-signed JWT, and the IAM floor is one permission. The supported
CI auth for the Firebase CLI is a service-account key via
GOOGLE_APPLICATION_CREDENTIALS(the legacyFIREBASE_TOKEN/login:ci/--tokenis deprecated, and keyless WIF is buggy in firebase-tools v15). Rolling our own skips the key file: the helper mints a short-lived OAuth2 token via the JWT-bearer flow, signing the assertion withopenssl(RS256). That flow reads only two fields of a service-account key,client_emailandprivate_key(token_uriis the static Google endpoint), so those are the only credential inputs; the rest of the key JSON (project_id,private_key_id,client_id, the cert URLs,universe_domain) is never used, and reconstructing a full JSON from them would be inert filler. Empirically the service account needs exactlyfirebase.clients.get: a probe SA holding only that permission fetched the config, and five other candidate permissions it did not hold were thereby proven unnecessary. The predefined Firebase Viewer role includesfirebase.clients.get, so it is the least-privilege stock role. - The fetch lives in a shared
ch-fetch-firebase-config, and the credential is project-scoped. getConfig differs across platforms only in the resource segment (androidAppsvsiosApps) and the output file (google-services.jsonvsios/Runner/GoogleService-Info.plist); the token flow and credential are identical (verified: one service account fetched both an Android and an iOS config). So the fetch was extracted into a standalone, curl-ablech-fetch-firebase-config --android|--iosthatch-build-setup-androiddelegates to.--iosis enabled even though iOS building is out of scope (theflutter-macrejected path in #arm64-android-build-limitation): the fetch is only a network call, so it is useful standalone on a macOS runner, while nothing here builds iOS. Because the service account is project-scoped, its credential is shared across platforms:client_email/private_keyare the platform-neutralCH_BUILD_FIREBASE_*, while only the app id is per-platform (CH_BUILD_ANDROID_FIREBASE_APP_ID,CH_BUILD_IOS_FIREBASE_APP_ID), so it is never pasted twice. The private key is accepted in whatever form a CI variable can hold: a PEM (real or literal-\nnewlines), base64 of a PEM, or a bare base64 body. CI UIs such as GitLab reject the spaces in the-----BEGIN PRIVATE KEY-----header, so the helper reconstructs the PEM from a header-less body and decodes base64; whatever the form, it is normalised in-shell and piped toopensslthrough a process substitution, so it never lands on disk.--dry-runmakes no network call. - The fetch skips an existing config; the dart-define writer overwrites.
ch-fetch-firebase-configleaves the destination alone when it already exists and re-fetches only with--force. The fetch is a signed JWT plus two HTTPS round-trips against config that is stable per Firebase project, so re-fetching over a file that is already there is wasted work (network and crypto, not just I/O), and the check runs before the credentials are read, so a re-run with the file in place needs nothing else set. This matches the signing lane, which likewise leaves an existing keystore andkey.propertiesuntouched.ch-write-dart-definesdeliberately does the opposite and always writes: its input is theCH_BUILD_DEFINE_*environment, which is meant to change between builds (a dev vs staging URL, a flag), so skipping when the file exists would silently keep stale defines after you change one, and the output is a tiny local file with nothing to save by skipping. The orchestrator inherits both (it calls the fetcher without--force), so a forced refresh is run throughch-fetch-firebase-config --android --forcedirectly. - Keystore is generate-if-absent, with its path exposed for caching.
keytoolmints a fresh random keypair on every run, so regenerating the keystore each job gives an unstable signing key no matter the password. The helper writes the keystore only when it is missing and publishes its path, so a build job can cache it and keep the signing key stable across runs. A stable key on clean runners still needs a user-provided keystore (a possible later mode). The default password is a get-going convenience, not a stability lever. - PKCS12-only, one password. JKS is deprecated, so the helper always generates a PKCS12
keystore (no type knob, and keytool's migration warning is gone). PKCS12 uses one password for
the store and the key, so the two password vars were collapsed into a single
CH_BUILD_ANDROID_KEYSTORE_PASSWORD; making them unequal is now impossible, which removes the classic PKCS12 mismatch footgun. Validated empirically:apksigner, the tool Gradle invokes to sign, signed an APK with the generated keystore. A literalflutter build apk --releasecould not confirm it end to end on the arm64 host because Android release AOT needs the x86 path (#arm64-android-build-limitation), which is the image's known limitation, not a signing issue. - Why
CH_BUILD_CACHE_*names the keystore path. That file is invisible to the user except as something to cache, so it is named for that purpose, not as an internal output path. The prefix is forward-looking: other cacheable locations can join it, and the Gradle caches have (next point). The build-command path is a separate var (CH_BUILD_DART_DEFINE_FILE) because its purpose is to be passed to--dart-define-from-file. Both are baked as relative imageENVs: a child process cannot export into the parent job shell, but a constant path can be baked once and referenced by both the helper and the user. - Gradle caching is two narrow paths, not all of
~/.gradle. The family grew to cover Gradle viaCH_BUILD_CACHE_GRADLE_MODULES(caches/modules-2, dependencies) andCH_BUILD_CACHE_GRADLE_DISTS(wrapper/dists, the distribution), withGRADLE_USER_HOMEpinned to its default so both resolve deterministically. Caching the whole~/.gradlewas rejected: it also holds the daemon, lock files, and execution history, churn that bloats a cache without helping, andmodules-2alone can reach several GB. Unlike the keystore (an image-produced artifact at a project-relative path), these are absolute, standard Gradle locations the helper never touches, so they are pure cache hints for the consumer to opt into. - No direnv; a single in-script var registry instead. The
CH_BUILD_*surface will grow, but direnv (.envrc) is a per-directory env loader for interactive shells. It mismatches CI anddocker runsourcing (vars arrive from the secret store or--env-file, non-interactively) and, more to the point, manages no defaults or schema. Growth is a registry problem, so the defaults,--help, and the README var table all derive from one in-script source of truth. That stays pure shell with no new image dependency (no direnv, no YAML parser);docker run --env-filecovers local multi-var runs natively.