デプロイステップで再ビルドされないコミット済みビルド成果物
gitで追跡され、バイナリに埋め込まれた生成ファイルは、再ビルドされずにシップされることがあります。エラーはなく、CSSルールが紐付いていないクラスが存在するだけです。
ビルド成果物の中には、意図的にリポジトリにコミットされるものがあります。コンパイル済みのスタイルシート、生成されたAPIクライアント、バンドルされた翻訳ファイルなどです。このパターンは、ファイルを再生成するコマンドが一方のスクリプトにあり、デプロイパスが別のスクリプトを実行していることに気づくまで、うまく機能します。その結果、新しい入力が再ビルドされることなくシップされ、そのことに誰も気づきません。
私が遭遇した状況は、CSSフレームワークのCLIによって生成され、gitにコミットされ、Goバイナリに埋め込まれたスタイルシートでした。デプロイスクリプトはコンテナイメージをビルドするだけで、CSS CLIを呼び出すことはありませんでした。最後のローカルビルドの後に書かれた新しいユーティリティクラスは、対応するルールがないままシップされることになったでしょう。
ギャップを隠す構成
3つの決定は、個々には合理的ですが、組み合わせると危険です。
第一に、生成されたファイルをコミットします。これは、ビルドが遅いアセットや、ランタイムイメージが持っていないツールチェーンを必要とするアセットでは一般的です。これにより、再現可能なサービングが実現でき、コンテナ内の依存関係が1つ減ります。
第二に、バイナリに埋め込みます。Goでは、これは //go:embed であり、これが、ファイルが起動時にフェッチされるのではなくコンパイル時に存在しなければならない理由です。
第三に、再生成コマンドを、署名、公証、デスクトップアーティファクトのアップロードといった他のことも行うビルドスクリプトに入れます。そのスクリプトはクレデンシャルとVPNを必要とするため、CIが実行するものではありません。
ここで、ファイルを追跡してみましょう。それはスクリプトAによって生成されます。それはスクリプトBの実行中にコンパイラによって消費されます。スクリプトBには、最後の入力変更以降にスクリプトAが実行されたかどうかをチェックする仕組みがありません。
なぜ失敗がサイレントなのか
深刻度は、コンシューマーが欠落したエントリにどう反応するかに全面的に依存します。
メソッドを失った、生成されたAPIクライアントはラウドに失敗します。呼び出しサイトはコンパイルできず、CIは赤くなり、マージ前に修正することになります。生成されたデータベースクエリコードやprotobufスタブについても同様です。型システムがあなたに代わってチェックを行ってくれるのです。
スタイルシートはその逆です。CSSには未定義のクラスという概念がありません。一致するルールなしに class="items-center" と書くと、ブラウザはそれを何もないものとして解決します。要素はレンダリングされ、ページはロードされ、コンソールに警告は表示されません。本番環境では微妙に間違っているが、CLIが最後に実行されたマシンでは正しいというレイアウトになってしまいます。
翻訳バンドルは、ルックアップがキーにフォールバックする場合に同様に振る舞います。機能フラグのマニフェストは、未知のフラグがfalseとして読み取られる場合に同様に振る舞います。いずれの場合も、欠落したエントリにはもっともらしいデフォルト値があり、そのもっともらしいデフォルト値こそが、バグをサイレントなものにしているのです。
したがって、経験則はこうです。生成されたアーティファクトがスキーマのない何かによって利用される場合、確認するまでギャップが存在すると想定してください。
どのパスがそれをリビルドするかを突き止める
これには1分ほどかかり、その答えは往々にして不快なものです。
まず、ファイルが無視されているのではなく、本当に追跡されていることを確認します:
git ls-files --error-unmatch path/to/generated.css
git check-ignore -v path/to/generated.css # expect no output
次に、デプロイに関与するすべてのファイルで、ジェネレーターコマンドを grep します:
grep -rn "tailwindcss\|protoc\|sqlc\|openapi-generator" \
Dockerfile* .github/workflows/ script/ Makefile cloudbuild.yaml 2>/dev/null
私の場合、ヒットはすべて1か所、デスクトップビルドスクリプトにありました。そして、Dockerfileとデプロイスクリプトにはヒットがゼロでした。これが診断のすべてです。コンテナビルドはリポジトリをコピーしてコンパイルするため、gitにあるものがそのままシップされます。
もう1つ、逆の間違いを検出するためのチェックです。これは、ファイルがデプロイ中に生成されると同時にコミットもされてしまい、すべてのデプロイで偽の差分が生成されるというものです:
git status --porcelain path/to/generated.css # after a local build
入力に変更がないのに新規ビルドでファイルが変更される場合、そのジェネレーターは非決定論的であり、コミットすると永遠にあなたを悩ませ続けることになります。
ギャップを埋める2つの方法
どちらも有効です。ランタイムイメージがツールチェーンをホストできるかどうかに基づいて選択してください。
生成をデプロイパスに移動する。 これが正攻法です。ジェネレーターをコンテナビルドか、イメージを生成するCIワークフローに追加し、出力のコミットを停止します。コストは、ビルドイメージが重くなることと、ピン留めするツールチェーンが1つ増えることです。ジェネレーターが認証情報や署名キーを必要とする場合、この選択肢は利用できません。そもそも、これがまさにギャップが生まれた経緯です。
コミットを続け、差分が空であることを検証する。 アーティファクトをgitに残しておく必要がある場合は、不変条件を明示的にします。つまり、通常の編集後、生成されたファイルは変更されてはなりません。ジェネレーターを実行し、ダーティツリーで失敗するチェックは、静かなギャップをビルドの失敗(レッドビルド)に変えます:
make generate
git diff --exit-code path/to/generated.css \
|| { echo "generated asset is stale, run make generate and commit"; exit 1; }
ローカルフックではなく、CIにそれを組み込んでください。ローカルフックは急いでいる人にはスキップされてしまいますが、その当人こそが新しいクラスを追加する可能性が最も高いのです。
どちらも適さない場合に知っておく価値のある、より簡易的な代替案があります。アーティファクトが現在サポートしているトークンのハードコードされたリストを保持し、新しいソースをそれと照合するというものです。私はこれをレビューのステップとして行いました:コンパイルされたスタイルシートに存在するすべてのクラス名を抽出し、変更によって追加されたすべてのclass属性を抽出し、そして2つのセットの差分を取ります。これにより、コードがリリースされる前に、候補となるクラスが生成されたセットに含まれていないことが判明しました。これはゲートというよりはチェックリストの項目ですが、記憶に頼るよりはましです。
他にこの形を持つものは
このパターンは「デプロイへの入力でありながら、何か別のものからの出力であるアーティファクト」です。一度そのように名付けると、多くの場所で現れます。
フレームワークのCLIがランタイムイメージに含まれていない場合の、コンパイルされたスタイルシートとJSバンドル。OpenAPIスペックから生成されたAPIクライアント、特にスペックが別のリポジトリにある場合。SQLジェネレーターからのデータベースクエリコード。ProtobufとgRPCのスタブ。コンパイルされた翻訳カタログ。埋め込まれたマイグレーションバンドル。ベンダリングされた依存関係ツリー。Terraformのロックファイル。
エントリの欠落がコンパイルを破壊するような「騒がしい」ものは、ほとんどが自己解決します。「静かな」ものは、意図的に監査する価値があります。スタイルシート、翻訳カタログ、フラグマニフェスト、アイコンスプライトシートなど、ルックアップミスに妥当なフォールバックがあるものは何でもです。
注目すべき2つ目のシグナルがあります。生成されたファイルがバイナリにも埋め込まれている場合、コンシューマーは実行時に新しいコピーを取得できないため、キャッシュのパージや設定のリロードでそのギャップを埋め合わせることはできません。埋め込みは、あなたの逃げ道をなくします。
FAQ
生成されたファイルのコミットをやめるべきですか? この種のバグはなくなりますが、ツールチェーンをイメージに移動させることになり、ビルドが遅く、重くなります。広く利用可能なツールで数秒でビルドできるアセットの場合は、デプロイ中に生成するのがよいでしょう。認証情報、ネイティブコンパイル、または遅いツールチェーンが必要なものについては、コミットに加えてCIでの陳腐化チェックを行うのが合理的なトレードオフです。
pre-commitフックで十分ですか? いいえ。フックはローカルのものであり、スキップ可能で、誰かが新しくクローンしたときに失われやすいものです。速度のためにフックを使用するのは構いませんが、信頼できるチェックは、誰もバイパスできない場所で実行する必要があります。
クラスがスペルミスではなく、実際に欠落していることをどうやって知ることができますか?
生成されたアーティファクトから識別子を抽出し、その正確なトークンを検索します。ここではエスケープが問題になります。例えば、py-2.5という名前のユーティリティは、コンパイルされたCSS内では.py-2\.5として現れることがあり、.py-2.5を単純にgrepすると、見つからないと報告されます。エスケープされていない形式でマッチさせるか、あるいは両方をチェックしてください。
クリーンなツリーでdiffチェックが失敗し続ける場合はどうすればよいですか? その場合、ジェネレーターが決定論的ではありません。一般的な原因は、埋め込まれたタイムスタンプ、バージョンバナー、またはファイルシステムに依存するファイルの順序です。チェックをブロッキングにする前に、バージョンを固定したり、バナーを削除したり、入力をソートしたりしてください。不安定なゲートは1週間以内に無効にされてしまいます。
これはロックファイルにも当てはまりますか? 部分的には当てはまります。ロックファイルもコミットされるアーティファクトですが、パッケージマネージャーは通常、インストール時にそれらを検証するため、陳腐化したロックファイルは明確に失敗します。ロックファイルに関するリスクはその逆です。つまり、デプロイがロックファイルをサイレントに再生成し、誰もレビューしていない依存関係のバージョンをシップしてしまうことです。