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でも同等のカスタマイズができるのかざっと調べてみたが、どうもできなさそう?

ということで、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.ghcghcのバージョンを指定。
  • いずれの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/setupcabal-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/cacherestore-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内の構造も比較的シンプルに見える。外部ツールと組み合わせる上ではこのほうが都合がいいのかもしれない。

参考