product flavor を足したら、Gradle は成功したまま 9 時間前の APK が署名された
Android アプリに product flavor(prod / dev)を足した日、ビルドパイプラインが成功したまま 9 時間前の APK に署名した。Gradle は成功し、ログに異常はない。原因は flavor で APK の出力パスが変わることだが、直接の犯人は差分ビルドのために残していた前回の生成物のほうだった。前もって入れておいた存在チェックは、古いファイルがそこに実在したので素通りした。
Gradle は成功し、9 時間前の APK が署名された
対象は「ねこ勤怠」(com.ccya.nekokintai)という個人プロダクトの Android 版。検証用に配ったビルドがストア公開版と同じバンドル ID で、端末上で上書きインストールされてしまうため、product flavor を prod と dev の2つに分けた。
ビルドは自作 CLI の nr build が担う。指定したコミットを Mac mini のビルドノードへ1つだけ push し、そこで Gradle を回し、zipalign と apksigner を通して自宅サーバーの配布ページに上げる。成果物のファイル名は <repo>-<sha7>.apk、つまり今ビルドしたコミットの SHA が入る。
flavor を入れた直後、動作確認のつもりでアップロードだけ抑止して1本ビルドした。成功した。手が空いていたので APK の中身を aapt2 dump badging で覗いたところ、そこで初めて断面が合っていないことに気づいた。配布を止めていたのはたまたまで、 止めていなければそのまま配っていた。
# apksigner に渡ったファイル app/build/outputs/apk/release/app-release-unsigned.apk mtime Aug 18 01:40 versionCode 3 versionName 1.1.0 # ビルドを起動した時刻と、ビルドしたはずの断面 実行 Aug 18 11:06 versionCode 1 versionName 1.0.0
署名されたのは 9 時間前に作られたファイルで、バージョンも合っていない。それでもファイル名には新しいコミットの SHA が入る。配布ページに並べてしまえば、名前を見るかぎり正しい断面にしか見えない。
flavor を足すと APK の出力先が1階層ずれる
Gradle は product flavor があると、タスク名にも出力パスにも flavor 名を挟む。assembleRelease は消えるわけではなく、全 flavor の release を組むタスクとして残る。だから --variantを指定せずに従来どおり呼んでもビルドは成功する。ただし APK が出るのは flavor ごとのディレクトリのほうで、従来のパスには何も置かれない。
# flavor を入れる前 ./gradlew assembleRelease app/build/outputs/apk/release/app-release-unsigned.apk # flavor(prod / dev)を入れたあと、--variant dev ./gradlew assembleDevRelease app/build/outputs/apk/dev/release/app-dev-release-unsigned.apk
つまり nr build 側が見ていた apk/release/app-release-unsigned.apk は、この日以降どのビルドでも更新されなくなった。それでも消えはしない。
存在チェックは、古いファイルが実在したので素通りした
ここが今回の芯になる。flavor を入れる前に書いた計画書には、この落とし穴の予測がすでに入っていた。flavor を入れると --variant 省略のビルドは従来の APK パスが消えて zipalign が file-not-found で死ぬ、という予測だ。
これを「あとで踏む地雷」にしないため、パイプライン側に fail-fast を先に入れた。期待するパスに APK が無ければ、意味の分かるメッセージを出して exit 1 する、というものだ。zipalign の file-not-found で落ちるより親切になる、はずだった。
実際には死ななかった。予測は「従来の APK パスが消える」だったが、消えていたのは APK を作る経路のほうで、ファイルそのものは前回ビルドの残骸として居座っていたからだ。ビルドノードの作業ツリーは毎回 git clean -qfd -e node_modules で掃除しているが、-x を付けていないので gitignore 済みの android/app/build は残る。Gradle の差分ビルドを効かせるための意図的な設計で、そこに前回の app-release-unsigned.apk がそのまま置かれていた。
書いたつもりの検査は「Gradle が今回出した APK があるか」だった。実際に書けていたのは 「そのパスにファイルがあるか」でしかない。
この2つが同じ意味になるのは、そのディレクトリの中身が今回のビルドの出力だけだと保証されているときに限る。その保証はどこにも無かった。
Gradle を回す前に出力先を消す
直したのは検査ではなく、検査したい命題が真になる状態のほうだった。Gradle を回す前に出力先を rm -rf する。これで「そこにファイルがある」と「今回の Gradle が出した」が同値になり、直前に足した存在チェックが初めて機能するようになる。
cd android
# app/build は差分ビルドのために残す (git clean -x していない) ので、今回の Gradle が
# APK を出さなくても前回の APK がそのまま残る。それを署名すると「古い断面を新しい
# コミットとして配る」事故になるため、出力先だけ先に消して実在を保証する
rm -rf "app/build/outputs/apk/release"
./gradlew assembleRelease
# ここに無い = Gradle が別パスに出した。product flavor があるリポジトリを --variant
# なしでビルドすると Gradle 自体は成功するので、理由を出して止める
[ -f "app/build/outputs/apk/release/app-release-unsigned.apk" ] || { echo "app/build/outputs/apk/release/app-release-unsigned.apk が見つからない。product flavor があるリポジトリは --variant <flavor 名> の指定が要る" >&2; exit 1; }検討して捨てた案が1つある。git clean -x にして app/build ごと毎回消してしまう、というものだ。残骸が原理的に存在しなくなるので確実ではあるが、android/.gradle も一緒に消えるため Gradle の差分ビルドが毎回ゼロからになる。ビルドはノードに集約しているので、その分はそのまま待ち時間として返ってくる。消したいのは出力先1つで、そのために作業ツリーの掃除範囲を広げるのは割に合わない。
再発防止としては、生成されるスクリプトに rm -rf の行が含まれることを回帰テストで固定した。flavor の有無それぞれについて、Gradle を呼ぶ前に正しい出力先が消えることを検査している。
あわせて認識を1つ改めた。この穴は flavor とは無関係に前から開いていた。「今回の Gradle が APK を出さなかったのに、前回の APK がそこにある」という状況は、タスク名の変更でもモジュール構成の変更でも作れる。flavor の導入はそれをたまたま最初に引き当てただけで、原因ではない。
まとめ
存在チェックは「あるか」にしか答えない。知りたかったのは「今回のビルドが作ったか」で、これは別の質問だ。ファイルの存在で新鮮さを判定したいなら、検査を1行足すのではなく、検査の前に存在そのものを今回の出力だけに絞る。fail-fast は検査を足す作業ではなく、検査したい命題が真になる状態を先に作る作業になる。
もう1つ。ビルドが成功したことは、成果物が正しいことの証明にはならない。差分ビルドを効かせているパイプラインでは、 「前回の成果物が残っている」ことが常に前提として効いている。速度のために残しているものが、いつ正しさの前提を壊しうるのかは、残すと決めた時点で1回考えておく価値がある。