Verdict
| Requirement | State | Evidence |
|---|---|---|
| Build environment | READY | JDK 17 and Android SDK present and correctly selected. §1 |
| Dependencies | READY | Resolved by the build itself. §2 |
| Credentials (signing key) | READY | Key exists outside the repo and is picked up automatically. §3 |
| Permissions | READY | NOVA runs as the same macOS user, so it can read the key. §4 |
| Signing | READY | A real build was signed with our key and verified from the artifact. §6 |
| Verification | READY | Eight gates, each of which stops the build. §5 |
| Key custody | OWED BY YOU | Does 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)
① 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
| File | What |
|---|---|
~/.hoop-keys/hoop-upload.jks | The upload key. Outside the repo. |
~/.hoop-keys/key.properties | Authoritative signing config. The build copies it into whatever tree it is building. |
~/.hoop-keys/.pw | The 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.
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.jkscat ~/.hoop-keys/.pw→ into your password manager. That file is the only copy on earth.- Back up
hoop-upload.jkssomewhere that is not this Mac.
4Permissions
This was the one thing that could have silently broken NOVA, so it was checked rather than assumed.
| Question | Answer |
|---|---|
| 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. |
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>.logand 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
| # | Checks | Why it is there |
|---|---|---|
| 1 | JDK is exactly 17 | Checks flutter config first, then JAVA_HOME, then PATH — in that precedence order. |
| 2 | Android SDK present | Fails early with a clear message instead of deep inside Gradle. |
| 3 | A real signing key is available | Otherwise 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-5 | AAB and APK build | The two artifacts. |
| 6 | versionCode read back out of the AAB | Passing --build-number in and it landing in the package are different events; the log only proves the command did not error. |
| 7 | versionCode read back out of the APK | Checked separately — the two artifacts come from two separate Flutter invocations. |
| 8 | Signature fingerprint matches our key | Positive comparison against the keystore's own fingerprint. |
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.
✅ … 真钥匙签的 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.
| What | Result |
|---|---|
| Source | Fresh worktree at origin/master (1ef444f78), 0 commits behind |
key.properties in that tree | Absent — adopted automatically from ~/.hoop-keys/ |
| Build time | 5 minutes (12:35:09 → 12:40:05) |
| versionCode in the AAB | 1 — read back out of the artifact |
| versionCode in the APK | 1 — read back separately |
| Signer fingerprint | Matches our keystore ✅ |
| Gates passed | 8 of 8 |
Artifacts produced:
| File | Size | SHA-256 |
|---|---|---|
app-release.aab | 531M | c9640fedd94d24f6… |
app-release.apk | 594M | f0ef872388135ce0… |
… ✅ 签名者指纹和 key.properties 那把钥匙对得上:2d71b3184c4aa64316bdae1f6dd0ea15… ✅ versionCode 1,真钥匙签的。
If a build fails
| Message | Cause and fix |
|---|---|
Needs JDK 17 / dies ~5 min in at compileReleaseKotlin | flutter config --jdk-dir /opt/homebrew/opt/openjdk@17. Setting JAVA_HOME will not help. |
Fails at the strip step but a .aab exists | cmdline-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 missing | Someone 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.
结论
| 要求 | 状态 | 凭什么这么说 |
|---|---|---|
| 编译环境 | 就绪 | 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)
① 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 年。
key.properties —— 它被 gitignore 挡着,而且是每棵树各一份。现在脚本会发现这件事,并把权威那份拷进来。这是真跑那一次在一棵从没见过它的树里的输出:
🔑 这棵树本来没有,已从仓库外那份权威配置取用:/Users/jeffchoong/.hoop-keys/key.properties
[12:35:10] 🔑 用真钥匙签:/Users/jeffchoong/.hoop-keys/hoop-upload.jkscat ~/.hoop-keys/.pw→ 粘进你的密码管理器。那个文件是世界上唯一一份。- 把
hoop-upload.jks备份到这台机器以外的地方。
4权限
这是唯一一件可能无声地把 NOVA 卡死的事,所以我去查了,没有想当然。
| 问题 | 答案 |
|---|---|
| NOVA 跑在哪台机器上? | 就是这台。launchctl 里 com.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 那条线。
八道闸,每一道都是直接停
| # | 核什么 | 为什么要有它 |
|---|---|---|
| 1 | JDK 正好是 17 | 先看 flutter config,再看 JAVA_HOME,最后看 PATH —— 按它们真实的优先级顺序。 |
| 2 | Android SDK 在不在 | 当场报清楚,而不是埋到 Gradle 深处才炸。 |
| 3 | 有没有真的签名密钥 | 以前新树会静默出一个调试签名的包,长得和好包一模一样。现在要么取到真密钥,要么停。 |
| 4-5 | 编出 AAB 和 APK | 两个产物。 |
| 6 | 从 AAB 里把版本号读回来 | 「把 --build-number 递进去」和「它真写进包里了」是两件事;日志那行绿字只证明命令没报错。 |
| 7 | 从 APK 里把版本号读回来 | 单独再核一遍 —— 两个产物是两次独立的 Flutter 调用出来的。 |
| 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.aab | 531M | c9640fedd94d24f6… |
app-release.apk | 594M | f0ef872388135ce0… |
… ✅ 签名者指纹和 key.properties 那把钥匙对得上:2d71b3184c4aa64316bdae1f6dd0ea15… ✅ versionCode 1,真钥匙签的。
出不来包时查这张表
| 报的是什么 | 原因和处理 |
|---|---|
说要 JDK 17,或者跑到五分钟左右死在 compileReleaseKotlin | flutter 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 收不收这个体积)按你这次的要求都不在这一页里。