GitHub ActionsでHaskellプロジェクトのstack/cabal混在CI
これまで自分が公開しているHaskellのライブラリのCIはTravis CIを使っていたが、GitHub Actionsに移行することにした。一番大きな理由は、haskell/actions/setupを使うと最新のghcとcabalを使ったビルドが比較的簡単に実現できるから。
これを機に、手元のビルドをstackからcabalに移行することも考えている。stackに比べたcabalのメリットはcabal-install "v2" ビルド メモにも書いたが、そもそもstackという追加のツールを使わずに済むというのもメリットだと思う。
一方で、stackを使うメリットも依然として存在する。
- snapshotを参照することで、依存関係の古いバージョンと組み合わせたときのテストを実施できる。
- これは要はdependencyのlower-boundテストである。cabalのdependency resolverは基本的にdependencyの一番新しいバージョンをビルドプランとして作るので、あえて古いバージョンと組み合わせるテストはやりにくい。
- ただ、これはツールとしてのstackのメリットというよりもsnapshot repositoryであるStackage Serverのメリットな気もする。Stackage Serverから直接snapshotのconstraintファイルをダウンロードしてcabalでビルドする、というやり方もできなくはない。
stack new
でモジュールを新規作成するときのテンプレートをカスタマイズできる。- stackではstack-templatesレポジトリを立てておくことで
stack new
で作るテンプレートを自由に作ることができる。 - cabalでも同等のカスタマイズができるのかざっと調べてみたが、どうもできなさそう?
- stackではstack-templatesレポジトリを立てておくことで
ということで、CIをする上では、dependencyのupper-bound testをcabalで、lower-bound testをstackでやる、という感じになると思う。
Workflowファイル
それを踏まえて、GitHub Actionsのworkflowファイル(.github/workflows/haskell.yml)を書いた。試行錯誤の結果、以下のような感じになった。
name: Haskell CI on: [push, pull_request] jobs: stack: strategy: fail-fast: false matrix: os: [ubuntu-latest, macOS-latest] plan: - ghc: '8.10.3' resolver: 'lts-17.2' - ghc: '8.8.3' resolver: 'lts-16.11' - ghc: '8.6.5' resolver: 'lts-14.27' - ghc: '8.4.4' resolver: 'lts-12.26' ## include: ## - os: macOS-latest ## flags: '--flag greskell:-hint-test' runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.plan.allow-fail == true }} env: STACK: stack --no-terminal --system-ghc --resolver ${{ matrix.plan.resolver }} FLAGS: ${{ matrix.flags }} steps: - uses: actions/checkout@v2 - uses: haskell/actions/setup@v1 id: cabal-setup-haskell with: ghc-version: ${{ matrix.plan.ghc }} enable-stack: true - uses: actions/cache@v2 ## Looks like caching in macOS causes weird failures in the steps later. if: runner.os != 'macOS' with: path: ~/.stack key: ${{ runner.os }}-stack-${{ hashFiles('stack.yaml') }}-${{ matrix.plan.resolver }} - name: Configure run: | set -ex rm -f stack.yaml.lock $STACK clean - name: Install dependencies run: $STACK test --bench --only-dependencies $FLAGS - name: Build run: $STACK build --bench --haddock --no-haddock-deps $FLAGS - name: Test run: $STACK -j 1 test --bench --no-run-benchmarks --haddock --no-haddock-deps $FLAGS cabal: strategy: matrix: os: [ubuntu-latest, macOS-latest] plan: - ghc: latest allow-fail: true - ghc: '8.10' runs-on: ${{ matrix.os }} continue-on-error: ${{ matrix.plan.allow-fail == true }} steps: - uses: actions/checkout@v2 - uses: haskell/actions/setup@v1 id: cabal-setup-haskell with: ghc-version: ${{ matrix.plan.ghc }} - name: Configure and freeze run: | set -ex rm -f cabal.project.freeze cabal v2-update cabal v2-configure --enable-tests --enable-benchmarks --test-show-details streaming cabal v2-freeze test -f cabal.project.freeze - uses: actions/cache@v2 with: path: ${{ steps.cabal-setup-haskell.outputs.cabal-store }} key: ${{ runner.os }}-cabal-${{ hashFiles('cabal.project.freeze') }} restore-keys: | ${{ runner.os }}-cabal- - name: Install dependencies run: cabal v2-build --only-dependencies all - name: Build run: cabal v2-build all - name: Haddock run: cabal v2-haddock all - name: Test run: cabal v2-test --jobs=1 all
ポイントは以下の通り。
- stackによるビルドとcabalによるビルドを別のjobとして定義。
- いずれのjobでも、matrix.plan.ghcでghcのバージョンを指定。
- いずれのjobでもテストは並列度1 (
-j 1
)で実行する。テストを並列実行すると特にdoctest周りでエラーが起きることが多いので。 - matrix.plan.allow-failをtrueにセットしたケースはfailしても仕方ないとする。
さらにstackビルドでは、
- matrix.plan.resolverでsnapshotを指定するが、この際、snapshotのGHCバージョンをmatrix.plan.ghcで示したバージョンと一致させないといけない。
- コメントアウトにあるように、matrix.flagsフィールドにstackコマンド実行時のフラグオプションを追加設定できる。
- stackを実行するときは
--system-ghc
オプションを使う。これはstackによるGHCのインストール時間を省くとともに、キャッシュサイズを小さくするため。 - stack.yaml.lockがレポジトリにコミットされている場合でも削除してビルドを進める。
キャッシュの設定
基本的なビルド手順はわりと簡単に書き下すことができたが、キャッシュの設定にわりと手こずった。
cabalの場合、キャッシュは比較的簡単。haskell/actions/setup
のcabal-store
というoutputに.cabal/storeディレクトリへのパスがセットされる。このディレクトリの下に、external packageのビルドキャッシュがGHCバージョンごとに構築されるので、これをactions/cache
のpathに指定すればいい。external packageの完全なリストはcabal.project.freezeファイルに示されるはずなので、これをcache keyに指定する。
stackの場合は~/.stackディレクトリをキャッシュするが、どうもこれが厄介だった。~/.stackにはsnapshotのビルドキャッシュの他にもsnapshot自体のメタデータやGHCバイナリに関する情報など、いろいろなメタデータが突っ込まれていて、下手にキャッシュで使い回すと不整合状態になるようだ。なのでstack jobでは、actions/cache
をstackコマンドの実行前に実施するようにした。また、actions/cache
のrestore-key
も設定しないようにした。このようにすると、key
がexact matchしない限りcache restoreをしないようになる。
当初は(cabalでcabal.project.freezeを使ったのと同様)、stack.yaml.lockファイルを使ってstack用のcache keyを作ろうとしたが、どうやら--resolver
オプションを使うとstack.yaml.lockが生成されないらしい(これが期待された挙動なのか、とりあえず質問しておいた)。なので代わりにstack.yamlとresolverをcache keyとして使うこととした。(--resolver
オプションを使う代わりに、各resolverに対応したstack.yamlファイルを用意すればstack.yaml.lockを使えたかも)
ただ、それでもどうしてもmacOSについてはキャッシュを有効にしたstackビルドが失敗し続けたので、macOSについてはactions/cache
を無効化することにした。
キャッシュ使用時のstackビルドにおけるエラー
キャッシュを有効にした場合、stackビルドでは以下のようなエラーが見られた。
まず、"Install dependencies"ステップでは以下の例外が出てビルドが停止しているようだった。(これはubuntuでもmacOSでも観測された)
Trouble loading CompilerPaths cache: Control.Exception.Safe.throwString called with: Compiler file metadata mismatch, ignoring cache Called from: throwString (src/Stack/Storage/User.hs:275:8 in stack-2.5.1-6DM1RsturFMC73zSqWnQ8j:Stack.Storage.User)
ただし、どうもこれはexitcodeとして0を返しているのか、jobは引き続き実行されていた。
一応、issueとして報告されている(が、現時点で進捗はない)
この例外を投げているコードを読むと、なんとなくcompiler executable fileのメタデータをstackのメタデータDBの内容と照合して、ミスマッチするようなら例外を吐いているように見える。
で、macOSの場合はさらに後続のBuildステップで以下のようなエラーが出てjobがfailする。
greskell-core > /Users/runner/.stack/setup-exe-cache/x86_64-osx/Cabal-simple_mPHDZzAJ_2.4.0.1_ghc-8.6.5: /Users/runner/.stack/setup-exe-cache/x86_64-osx/Cabal-simple_mPHDZzAJ_2.4.0.1_ghc-8.6.5: cannot execute binary file
Cabal-simpleのexecutable fileが存在しない?
これもissueは上がっているが、原因や解決法はハッキリ分かってないようだ。
ということで、macOSについてはstackのキャッシュを諦めることにした。
思うに、stackはかなりモノリシックにいろいろやるツールなので、そのメタデータディレクトリである~/.stackもモノリシックに複雑な構造になっている印象がある。この場合、actions/cache
のような外部ツールが中身に手を出すのはかなり難しくなる。
一方、cabalは基本的にビルドプランの作成と実行、およびexternal packageのキャッシュ管理に特化しているので、~/.cabal内の構造も比較的シンプルに見える。外部ツールと組み合わせる上ではこのほうが都合がいいのかもしれない。
参考
- actions/setup at main · haskell/actions: haskell/actions/setupのページ。GHCとstackをセットアップするGitHub Actionを提供する。workflow設定のサンプルもある。
- Stack Project Github Action Template: stackが公式でサンプル提供しているTravis CI向けスクリプトをほぼそのまんまGitHub Workflowに移植したもの。作者独自のActionを使っているのでその点は要注意。
- Haskell で GitHub Actions する: 主にstackビルド向けのGitHub Action設定例。キャッシュの構築に関する注意点がいろいろ参考になる。
- Haskell AntennaのCI/CDをGitHub Actionsに移行する - Haskell-jp: haskell.jpにおける実践。これもstackビルドをGitHub Actionで実施している。Dockerイメージをpushしたりサイトコンテンツの更新処理もGitHub Actionsで自動化している。
- hint/ci.yml at 0e6f8e31696efe88d48de9568b177c94a6ad78af · haskell-hint/hint: stackとcabalを別のjobでビルドする設定例。今回自分でworkflowを書く上で最も参考にした。