CI のランナー次第で壊れる成果物を作っていた

同じコミットから、起動できる DLL とできない DLL が生まれていた

俺はGhostty の Windows 版として GhosttyWin32 というターミナルエミュレータを作っている。 中心となる処理に本家の libghostty をそのまま使っていて、Zig で書かれた libghostty を CI で毎回ビルドし、成果物の ghostty.dll をC++/WinRT のアプリから共有ライブラリとして読んでいる。 アプリは起動すると、まず ghostty_init を呼んで libghostty を初期化する。

あるとき、v0.6.0-rc1 を公開したところ、自分の環境では起動すらしなかった。

同じコミットから作った成果物なのに、CI がどのマシンでビルドしたかによって、特定の環境で実行できない成果物になっていた。 それは、成果物のバイナリが AVX-512 を持つ CPU でしか実行できないようになっていたことで起きた。

Zig で書かれたソースを CI でビルドしていて -Dcpubaseline を設定しておらず、作られる成果物の内容がビルドマシンの CPU に依存していたこと。 GitHub Actions のランナーは AVX-512 を持つマシンと持たないマシンが混ざっていること。 この 2 つが合わさって本事象が発生した。

解消するために、ビルドコマンドに -Dcpu=baseline を追加した。 これでどのマシンでビルドされても、成果物はすべての x86-64 CPU で動くようになった。

ここから下は調査の記録になる。どうやって混入に気づいたか、なぜ手元では再現しなかったか、そして過去のリリースを調べ直したら何が出てきたかを書く。

ダンプが指した命令

クラッシュダンプを cdb で開いて .ecxr で例外発生時のコンテキストに移ると、落ちている命令はこれだった。

vmovups zmmword ptr [...], zmm0

zmm は AVX-512 の 512 ビットレジスタで、zmmword ptr を触る命令は AVX-512 を持つ CPU でしか実行できない。落ちた場所は ghostty_init のグローバル初期化中で、ライブラリを初期化した瞬間に踏んでいた。

実行していたマシンの CPU は Intel Core i9-10850K で、AVX2 はあるが AVX-512 は無い。

Add-Type -Namespace W -Name N -MemberDefinition '[DllImport("kernel32.dll")] public static extern bool IsProcessorFeaturePresent(int f);'
[W.N]::IsProcessorFeaturePresent(41)  # PF_AVX512F -> False
[W.N]::IsProcessorFeaturePresent(40)  # PF_AVX2    -> True

無い命令が埋まっていたので、落ちるのは当然だった。ここで少し意外だったのは、DLL のロード自体は成功することだ。LoadLibrary は通り、DLL はメモリに載る。死ぬのは ghostty_init を呼んだ瞬間で、そこで初めて未対応の命令をデコードして 0xc000001d になる。

手元では再現しない

リリース前の検証は手元でビルドしたもので済ませていて、そちらは問題なく動いていた。理由は単純で、ビルドしたマシンと実行するマシンが同じだからだ。native 最適化はそのマシンが持つ命令セットに合わせるので、i9-10850K でビルドする限り AVX-512 が混ざりようがない。

CI 側にも気づく手段は無かった。ビルドは成功し、成果物も生成され、署名も通り、インストールも完了する。壊れていることが分かるのは、AVX-512 を持たない CPU の上で起動したときだけだった。

開発環境が最も安全な場所になっていて、そこだけが安全だった。

過去のリリースにも混入していた

今回だけ運が悪かったのかを確かめたくて、公開済みの MSIX から ghostty.dll を取り出して二つの方法で調べた。

静的に見る方法として dumpbin /disasm に通して zmm への参照を数える。動かす方法として、使い捨てのプロセスで DLL を LoadLibrary して ghostty_init(0, NULL) を直接呼ぶ。落ちるなら終了コードに 0xC000001D が出る。

i9-10850K 上での結果はこうなった。

リリースzmm 参照数ghostty_init
v0.4.248,728即死 0xC000001D
v0.5.041生存 (rc=0)
v0.6.0-rc148,728即死
v0.6.0-rc241生存 (rc=0)

48,728 と 41 の間に中間の値が無い。AVX-512 を持つマシンで作られたか、そうでないかで、はっきり二つに分かれている。41 の方は呼んでも落ちないので、実行経路に乗らない参照だった。

つまり壊れた成果物はとっくに出ていた。v0.4.2 は、このマシンでは最初から一秒も動けないバイナリだった。気づかなかったのは、それをこのマシンに入れて起動する機会が無かったからにすぎない。v0.5.0 はたまたま AVX-512 を持たないマシンに当たっていた。

過去のリリースが動いていたのは、正しかったからではなく運が良かったからだ。

CI はどのマシンで動いたかを記録しない

どのランナーに当たったのかを後から特定しようとしたが、これは取れなかった。ジョブのログに残るのはここまでで、プロセッサに関する記述は一行も無い。

Current runner version: '2.336.0'
Operating System: Microsoft Windows Server 2025
Image: windows-2025-vs2026 / Version: 20260810.198.2

API から取れるジョブのメタデータも同じで、runner_namerunner_id は使い捨て VM の識別子でしかなく、CPU とは紐づかない。GitHub は windows-latest の CPU 型番を公表していない。ビルドログに残る Zig の情報もターゲットのトリプルまでで、解決された -mcpu は出てこない。

だから、どのマシンに当たったかを示す証拠は成果物そのものしかない。上の表が唯一の記録になる。

修正は 1 行

書き換えたのは composite action 一箇所だけで済んだ。 CI・開発ビルド・リリースの三つの workflow が同じ action を通っているので、そこを直せば作られる MSIX がすべて覆われる。差分はコメントを含めて 8 行の追加と 1 行の削除だった。

zig build -Doptimize=ReleaseSafe -Drenderer=directx -Dcpu=baseline

命令セットの基準を下げれば、新しい命令が使えなくなる分だけ処理が遅くなる可能性はある。互換性と速度のトレードオフになることは Zig 側でも言われている。とはいえ、速いが一部のマシンで起動しない成果物より、すべてのマシンで動く成果物の方が配布物としては正しいと思う。 だけど、速度差は実測していない。

rc1 を公開してから rc2 を出し直すまで、およそ二時間半だった(壊れていると気づくまでが 2 時間 5 分、気づいてから rc2 を公開するまでが 31 分)。

参照