🤖 Android Build Pipeline · Ready for NOVA
Only what is needed for NOVA to build the Android app: build environment, dependencies, credentials, permissions, signing, and verification. Everything was run on the build Mac, including a full end-to-end build.

Verdict

The pipeline is ready. NOVA can build the Android app today. Proven by an actual end-to-end build from a clean checkout, not by inspection — see the proof at the bottom.
RequirementStateEvidence
Build environmentREADYJDK 17 and Android SDK present and correctly selected. §1
DependenciesREADYResolved by the build itself. §2
Credentials (signing key)READYKey exists outside the repo and is picked up automatically. §3
PermissionsREADYNOVA runs as the same macOS user, so it can read the key. §4
SigningREADYA real build was signed with our key and verified from the artifact. §6
VerificationREADYEight gates, each of which stops the build. §5
Key custodyOWED BY YOUDoes not block NOVA, but see §3 — it is the one irreversible risk left.

Not covered here (not part of building): installing on a phone, the Google OAuth client for sign-in, Play Console upload, and the package size question. Those are now their own step-by-step guide — Google Cloud & Play Console setup. Background in the pipeline wireframe.

1Build environment

Machine-wide, already correct. To re-check:

flutter config --list | grep jdk
  jdk-dir: /opt/homebrew/opt/openjdk@17

ls ~/Library/Android/sdk
  build-tools  cmdline-tools  platform-tools  platforms  licenses

Confirmed during the real build:

[12:35:10] 编包用的 JDK 来自 flutter config --jdk-dir:/opt/homebrew/opt/openjdk@17
[12:35:10] JDK: openjdk version "17.0.20.1" 2026-08-18
[12:35:10] SDK: .../Android/sdk(platforms: android-33 android-34 android-35 android-36)
Three things that will break a NOVA build if they drift:
① The JDK must be exactly 17. JDK 21 fails 5½ minutes in, at compileReleaseKotlin, because a third-party plugin pins its Java half to 17 while its Kotlin half follows the JDK.
flutter config --jdk-dir overrides JAVA_HOME, and it is machine-global — changing it also affects the iOS line on this Mac.
③ Missing cmdline-tools makes the build fail but still leave a .aab behind. Never treat the file existing as success.

2Dependencies

Nothing to pre-install. The build runs flutter pub get itself and fails the whole run if resolution fails. Native dependencies (CameraX and the Guava compile-time shim) are declared in app/android/app/build.gradle.kts and need no manual step.

The only dependency fact that matters operationally is the one in §1: a third-party plugin is what pins the JDK to exactly 17.

3Credentials — the signing key

FileWhat
~/.hoop-keys/hoop-upload.jksThe upload key. Outside the repo.
~/.hoop-keys/key.propertiesAuthoritative signing config. The build copies it into whatever tree it is building.
~/.hoop-keys/.pwThe password.

SHA-1 7A:03:8F:8C:9A:FA:D9:26:A5:62:CD:B9:3E:77:29:E2:25:E7:91:CA · valid to 2054.

NOVA does not need to configure anything. A fresh checkout never contains key.properties — it is gitignored and lives per working tree. The build now detects that and copies the authoritative one in. Observed in the real build, in a tree that had never seen it:
🔑 这棵树本来没有,已从仓库外那份权威配置取用:/Users/jeffchoong/.hoop-keys/key.properties
[12:35:10] 🔑 用真钥匙签:/Users/jeffchoong/.hoop-keys/hoop-upload.jks
The one thing still owed, and it does not block NOVA:
  1. cat ~/.hoop-keys/.pw → into your password manager. That file is the only copy on earth.
  2. Back up hoop-upload.jks somewhere that is not this Mac.
Before the first Play upload, a lost key is a nuisance. After it, Android only accepts updates signed with the same key — lose it and the app can never be updated, with no appeal. Every build NOVA makes from now on increases what that loss would cost.

4Permissions

This was the one thing that could have silently broken NOVA, so it was checked rather than assumed.

QuestionAnswer
Where does NOVA run?This same Mac. launchctl shows com.hoop.nova.ws plus nova1–nova5 live here.
As which user?jeffchoong — the only real user account on this machine.
Can it read the key?Yes. ~/.hoop-keys/ is mode 700 and the files are 600, owned by jeffchoong. Same user, so readable.
Does it inherit the JDK setting?Yes. flutter config is machine-global.
Why this matters: the keys are 600, readable only by their owner. Had NOVA run under a different macOS account — which is the setup on the mac mini — it would have been unable to read the signing key, and the build would have fallen back or stopped. The pipeline would have been broken for NOVA while looking perfectly fine when I ran it. If Android builds ever move to another machine or user, re-check this first.

5The build command, and what verifies it

cd <a clean checkout of master>
bash tools/tf-build/cut_android.sh <versionCode> <that same path>

NOVA's existing convention for iOS applies unchanged: one fresh worktree per build (~/.hoop/wt-nova-build<N>), path passed as the second argument. The script does not read any config for the tree — it builds exactly what it is pointed at.

  • versionCode must increase with every Play upload, and a used number is spent forever.
  • Do not redirect the output. The script owns /tmp/cutandroid<N>.log and its own checks read that path.
  • It does not touch pubspec.yaml — the build number comes from the command line, so an Android build cannot disturb the iOS line.

The eight gates — every one stops the build

#ChecksWhy it is there
1JDK is exactly 17Checks flutter config first, then JAVA_HOME, then PATH — in that precedence order.
2Android SDK presentFails early with a clear message instead of deep inside Gradle.
3A real signing key is availableOtherwise a fresh checkout silently produced a debug-signed package that looks identical to a good one. Now it adopts the real key or stops.
4-5AAB and APK buildThe two artifacts.
6versionCode read back out of the AABPassing --build-number in and it landing in the package are different events; the log only proves the command did not error.
7versionCode read back out of the APKChecked separately — the two artifacts come from two separate Flutter invocations.
8Signature fingerprint matches our keyPositive comparison against the keystore's own fingerprint.
Gate 8 replaced a check that returned a green tick for every release package ever given to it. The old one used keytool -printcert -jarfile and passed if the output was non-empty and lacked CN=Android Debug. Packages with minSdk ≥ 24 carry only v2/v3 signatures, so keytool printed Not a signed jar file — to stdout — and both conditions held. The general lesson: ask "is this our key" (positive), never "is this not the wrong key" (negative). A negative test silently becomes a pass when the tool beneath it breaks.

6Verifying a build afterwards

The script does this, but these are the commands to confirm independently:

# package name and versionCode, read out of the artifact
~/Library/Android/sdk/build-tools/36.0.0/aapt2 dump badging <apk> | head -1
# → package: name='com.hooptech.hoop' versionCode='N' ...

# who signed it
JAVA_HOME=/opt/homebrew/opt/openjdk@17 \
~/Library/Android/sdk/build-tools/36.0.0/apksigner verify --print-certs -v <apk>

Three things must hold: package name is com.hooptech.hoop, versionCode is what was requested, and the signer digest matches the keystore.

The log's final line is the summary. ✅ … 真钥匙签的 means signed with the real key. ⚠️ … debug 签名 means it is a throwaway and must not be uploaded.

Two standalone test suites can be run at any time, without building:

bash tools/tf-build/ensure_key_properties_test.sh   # 8 checks
bash tools/tf-build/apk_signer_check_test.sh       # 7 checks, needs an APK

The proof — a real build, today

Rather than inspecting configuration and declaring it ready, I ran the pipeline exactly as NOVA would: a brand-new worktree at origin/master, zero commits behind, no key.properties in it.

WhatResult
SourceFresh worktree at origin/master (1ef444f78), 0 commits behind
key.properties in that treeAbsent — adopted automatically from ~/.hoop-keys/
Build time5 minutes (12:35:09 → 12:40:05)
versionCode in the AAB1 — read back out of the artifact
versionCode in the APK1 — read back separately
Signer fingerprintMatches our keystore ✅
Gates passed8 of 8

Artifacts produced:

FileSizeSHA-256
app-release.aab531Mc9640fedd94d24f6…
app-release.apk594Mf0ef872388135ce0…

✅ 签名者指纹和 key.properties 那把钥匙对得上:2d71b3184c4aa64316bdae1f6dd0ea15…
✅ versionCode 1,真钥匙签的。
What this proves: a machine with nothing but a clean checkout produced a correctly-versioned, correctly-signed package with no human configuring anything. That is the definition of ready for NOVA.
One number worth flagging, since it was measured here for the first time: the AAB is 531M. Play's base-module limit is 500 MB of compressed download size, which is computed from the bundle after splitting by CPU, density and language — so this file being larger does not by itself mean rejection, and I am not going to claim it does. Only Play can compute that number, and it reports it at upload. It is close enough that it should be settled by uploading, not by arguing.

If a build fails

MessageCause and fix
Needs JDK 17 / dies ~5 min in at compileReleaseKotlinflutter config --jdk-dir /opt/homebrew/opt/openjdk@17. Setting JAVA_HOME will not help.
Fails at the strip step but a .aab existscmdline-tools missing. Install from Android Studio → SDK Tools. Do not trust the leftover file.
🛑 没有 key.properties~/.hoop-keys/key.properties is missing or unreadable. On a different machine or user, see §4.
号对不上The versionCode in the package is not what was requested. Do not upload. Rebuild from a clean tree.
签名核对没过The signer is not our key. Do not upload. Check key.properties.
Log file is empty or missingSomeone redirected the output. The script owns that path and its gates read it.

What I verified, and what I did not

Verified by running it today: a full end-to-end build from a clean checkout, the JDK and SDK selection, the key being adopted automatically into a tree that lacked it, all eight gates passing, the artifacts existing, and the signature and versionCode read back out of the package. Plus the permission question in §4 — which user NOVA runs as and whether it can read the key.

Not verified: that NOVA itself has been asked to run this yet — I proved the pipeline works under NOVA's conditions, not that NOVA has executed it. Also unverified: anything beyond building (phone install, Google sign-in, Play upload, whether Play accepts the size), which is out of scope here by your instruction.

结论

流水线已经就绪,NOVA 今天就能出 Android 包。 这个结论不是看配置看出来的,是真跑了一次端到端出包跑出来的 —— 证据在最后一节
要求状态凭什么这么说
编译环境就绪JDK 17 和 Android SDK 都在,而且选中的就是对的那个。见第 1 节
依赖就绪出包脚本自己会拉。见第 2 节
凭据(签名密钥)就绪密钥在仓库外面,脚本会自动取用。见第 3 节
权限就绪NOVA 和我是同一个系统账号,读得到密钥。见第 4 节
签名就绪真包已经用我们那把密钥签出来,并且是从产物里反读指纹核的。见第 6 节
验证就绪八道闸,每一道都是直接停,不是打一行警告。见第 5 节
密钥保管你还欠着不挡 NOVA,但见第 3 节 —— 这是目前唯一一件撤不回来的风险。

这一页不写的(都不属于「出包」这件事):装到手机上、建 Google 登录用的 OAuth 客户端、传 Play、以及包体积那个问题。那几件现在单独成了一份分步指南 —— Google 两个控制台的配置。背景在出包通道线框图里。

1编译环境

是整台机器共用的设置,现在就是对的。想复核:

flutter config --list | grep jdk
  jdk-dir: /opt/homebrew/opt/openjdk@17

ls ~/Library/Android/sdk
  build-tools  cmdline-tools  platform-tools  platforms  licenses

真跑那一次里它自己报的:

[12:35:10] 编包用的 JDK 来自 flutter config --jdk-dir:/opt/homebrew/opt/openjdk@17
[12:35:10] JDK: openjdk version "17.0.20.1" 2026-08-18
[12:35:10] SDK: .../Android/sdk(platforms: android-33 android-34 android-35 android-36)
这三件一旦飘了,NOVA 的包就出不来:
① JDK 必须正好是 17。用 21 会跑满五分半钟,死在 compileReleaseKotlin —— 因为有个第三方插件把自己的 Java 那半边钉死在 17,而 Kotlin 那半边跟着 JDK 走。
flutter config --jdk-dir 压过 JAVA_HOME,而且它是整台机器共用的 —— 改它,这台机器上的 iOS 那条线也跟着变。
③ 缺 cmdline-tools 时编译会失败,但照样留下一个 .aab 文件。永远别把「文件在」当成「编成功了」。

2依赖

不用提前装任何东西。脚本自己会跑 flutter pub get,拉不下来就整轮失败。原生那几个依赖(CameraX、以及给 Guava 补的那个只在编译期用的壳)都写在 app/android/app/build.gradle.kts 里,不需要人工干预。

依赖这件事上真正会影响操作的只有第 1 节那条:把 JDK 钉死在 17 的,正是一个第三方插件。

3凭据 —— 签名密钥

文件是什么
~/.hoop-keys/hoop-upload.jks上传密钥本体,在仓库外面。
~/.hoop-keys/key.properties权威的签名配置。脚本会把它拷进当次要编的那棵树。
~/.hoop-keys/.pw密码。

SHA-1 7A:03:8F:8C:9A:FA:D9:26:A5:62:CD:B9:3E:77:29:E2:25:E7:91:CA · 有效期到 2054 年。

NOVA 什么都不用配。 新取的树里一定没有 key.properties —— 它被 gitignore 挡着,而且是每棵树各一份。现在脚本会发现这件事,并把权威那份拷进来。这是真跑那一次在一棵从没见过它的树里的输出:
🔑 这棵树本来没有,已从仓库外那份权威配置取用:/Users/jeffchoong/.hoop-keys/key.properties
[12:35:10] 🔑 用真钥匙签:/Users/jeffchoong/.hoop-keys/hoop-upload.jks
还欠着的只有一件,它不挡 NOVA:
  1. cat ~/.hoop-keys/.pw → 粘进你的密码管理器。那个文件是世界上唯一一份。
  2. hoop-upload.jks 备份到这台机器以外的地方。
第一次传上 Play 之前,密钥丢了只是麻烦。传过之后,安卓只认同一把密钥签的更新包 —— 丢了就永远发不了新版,没有补救。而 NOVA 从现在起每出一个包,这件事一旦发生的代价就更高一分。

4权限

这是唯一一件可能无声地把 NOVA 卡死的事,所以我去查了,没有想当然。

问题答案
NOVA 跑在哪台机器上?就是这台。launchctlcom.hoop.nova.ws 连同 nova1 到 nova5 都在这儿。
用的哪个系统账号?jeffchoong —— 这台机器上唯一一个真人账号。
它读得到密钥吗?读得到。 ~/.hoop-keys/ 是 700,里面的文件是 600,属主是 jeffchoong。同一个账号,所以读得到。
JDK 那个设置它继承得到吗?继承得到。 flutter config 是整台机器共用的。
为什么专门查这一条:密钥是 600,只有属主读得到。假如 NOVA 跑在另一个系统账号下 —— mac mini 上就是那种四个账号的布置 —— 它会根本读不到这把密钥,于是出包要么退回调试签名、要么直接停。而那种情况下,我自己跑一切正常,NOVA 那边却是坏的。 以后 Android 出包要是挪到别的机器或别的账号上,第一件事就是重查这一条。

5出包命令,以及它拿什么保证

cd <一棵干净的 master 检出>
bash tools/tf-build/cut_android.sh <版本号> <同一个路径>

NOVA 出 iOS 包的那套习惯原样适用:每出一次包开一棵新树(~/.hoop/wt-nova-build<号>),路径当第二个参数传进去。脚本不去读任何配置文件来猜要编哪棵树 —— 你指哪棵它编哪棵。

  • 版本号每次传 Play 都必须变大,用过的号永远作废。
  • 别把输出重定向到别处。 脚本自己占着 /tmp/cutandroid<号>.log,它自己的闸门也在读那个文件。
  • 它不碰 pubspec.yaml —— 构建号从命令行进来,所以出 Android 包不会顺手动到 iOS 那条线。

八道闸,每一道都是直接停

#核什么为什么要有它
1JDK 正好是 17先看 flutter config,再看 JAVA_HOME,最后看 PATH —— 按它们真实的优先级顺序。
2Android SDK 在不在当场报清楚,而不是埋到 Gradle 深处才炸。
3有没有真的签名密钥以前新树会静默出一个调试签名的包,长得和好包一模一样。现在要么取到真密钥,要么停。
4-5编出 AAB 和 APK两个产物。
6从 AAB 里把版本号读回来「把 --build-number 递进去」和「它真写进包里了」是两件事;日志那行绿字只证明命令没报错。
7从 APK 里把版本号读回来单独再核一遍 —— 两个产物是两次独立的 Flutter 调用出来的。
8签名指纹是不是我们那把拿密钥库自己的指纹做肯定式比对
第 8 道闸换掉了一个「对每个正式包都打绿灯」的旧检查。 旧的用 keytool -printcert -jarfile,判据是「输出非空 不含 CN=Android Debug」。而 minSdk ≥ 24 的包只有 v2/v3 签名,keytool 读不了,只印一句 Not a signed jar file —— 还是印在 stdout 上,于是那两条全都满足。通用的教训:要问「这是不是我们那把」(肯定式),别问「这是不是不对的那把」(否定式)。 否定式的判据在底下工具坏掉的那一刻,会自动变成「通过」。

6出完之后怎么自己核一遍

脚本已经核过了,但要独立确认就用这两条:

# 从产物里读包名和版本号
~/Library/Android/sdk/build-tools/36.0.0/aapt2 dump badging <apk> | head -1
# → package: name='com.hooptech.hoop' versionCode='N' ...

# 是谁签的
JAVA_HOME=/opt/homebrew/opt/openjdk@17 \
~/Library/Android/sdk/build-tools/36.0.0/apksigner verify --print-certs -v <apk>

三件必须同时成立:包名是 com.hooptech.hoop、版本号是你要的那个、签名者指纹和密钥库对得上。

日志最后一行就是结论。✅ … 真钥匙签的 = 用真密钥签的;⚠️ … debug 签名 = 这是个试用包,不许上传。

另外有两套测试,随时能跑,不用编包:

bash tools/tf-build/ensure_key_properties_test.sh   # 8 条
bash tools/tf-build/apk_signer_check_test.sh       # 7 条,要有个 APK

证据 —— 今天真跑的那一次

我没有看着配置说「应该可以了」,而是完全照 NOVA 的方式跑了一遍:在 origin/master 上新开一棵树,落后 0 笔,里面没有 key.properties

量的是什么结果
从哪棵树编的origin/master 上新开的树(1ef444f78),落后 0 笔
那棵树里的 key.properties本来没有 —— 自动从 ~/.hoop-keys/ 取用
编了多久5 分钟(12:35:09 → 12:40:05)
AAB 里的版本号1 —— 从产物里反读出来的
APK 里的版本号1 —— 单独又读了一遍
签名者指纹和我们的密钥库对得上 ✅
过了几道闸八道全过

出来的产物:

文件大小SHA-256
app-release.aab531Mc9640fedd94d24f6…
app-release.apk594Mf0ef872388135ce0…

✅ 签名者指纹和 key.properties 那把钥匙对得上:2d71b3184c4aa64316bdae1f6dd0ea15…
✅ versionCode 1,真钥匙签的。
这证明了什么:一台机器上只有一棵干净的检出,没有任何人去配任何东西,就产出了一个版本号正确、签名正确的包。「对 NOVA 就绪」指的就是这件事。
顺带量到一个第一次有的数,值得说一句:AAB 是 531M。Play 对 base module 限的是 500 MB 的压缩下载体积 —— 那是它把包按 CPU、屏幕密度、语言拆分之后算出来的数。所以这个文件比 500MB 大,并不等于会被拒,我也不会这么讲。那个数只有 Play 自己算得出来,上传那一刻它会报。但确实已经近到「该靠上传去定,而不是靠猜」。

出不来包时查这张表

报的是什么原因和处理
说要 JDK 17,或者跑到五分钟左右死在 compileReleaseKotlinflutter config --jdk-dir /opt/homebrew/opt/openjdk@17。去设 JAVA_HOME 没有用。
死在 strip 那步,但 .aab 却在cmdline-tools,去 Android Studio 的 SDK Tools 里装。别信那个留下来的文件。
🛑 没有 key.properties~/.hoop-keys/key.properties 不在,或者读不到。换了机器或账号的话看第 4 节。
号对不上包里写的版本号不是要的那个。别上传。 换棵干净的树重出。
签名核对没过签它的不是我们那把密钥。别上传。 去查 key.properties
日志文件是空的或者根本不在有人把输出重定向走了。脚本自己占着那个路径,它的闸门也在读它。

哪些我真验过、哪些没有

今天真跑出来的:从干净检出走完的一整次出包、JDK 和 SDK 选的是哪一个、密钥被自动拷进一棵本来没有它的树、八道闸全过、两个产物真的在、以及从产物里反读出来的签名和版本号。还有第 4 节那个权限问题 —— NOVA 跑在哪个账号下、读不读得到密钥。

没验的:NOVA 本身还没有被派去跑这条线 —— 我证明的是「在 NOVA 的条件下这条线跑得通」,不是「NOVA 已经跑过了」。另外,出包之外的一切(装机、Google 登录、传 Play、Play 收不收这个体积)按你这次的要求都不在这一页里。