cabal-plan-boundsを使ってHaskellパッケージのbuild-dependsをCI実行結果から自動生成する

こんにちは。(株)東芝 研究開発センターの伊藤です。

業務では主にIoTの通信セキュリティに関する研究開発に取り組んでいます。
OAuth 2.0ベースのセキュリティプロトコル実装をHaskellで試作したりしています。

[自作のHaskellパッケージ](https://hackage.haskell.org/user/debugito)もいくつか公開しているのですが、仕事やプライベートが忙しくなり、なかなかメンテする時間を取れなかったりします。
中でも面倒だと感じていたのが`build-depends`の更新です。

`build-depends`とは、.cabalファイル(Haskellパッケージのメタデータを記述するファイル)内のセクションの一つで、そのパッケージの依存パッケージとそのバージョン範囲を示したものです。
例えば、拙作の[fold-debounce-0.2.0.11](https://hackage.haskell.org/package/fold-debounce-0.2.0.11)の`build-depends`は以下のようになっています。

```cabal
build-depends: base >= 4.6.0 && <5.0,
               data-default-class >=0.0.1 && <0.2,
               stm >=2.4.2 && <2.6,
               time >=1.4.0 && <1.13,
               stm-delay >=0.1.1 && <0.2
```

他のプログラミング言語でも同様の記述があると思うので、皆さんおなじみだと思います。

この`build-depends`、厳しめに設定すると依存パッケージのバージョンアップに追随するのが面倒で、かといって緩くしすぎるとビルド時にエラーが出ることがあり、
その場合は原因究明と解決に時間がかかる、という問題があります。

どうしたものかと考えていたところ、Joachim Breitnerさんという方が作った[cabal-plan-bounds](https://hackage.haskell.org/package/cabal-plan-bounds)というツールを見つけました。
これは要は、CIなどで実行した複数のビルドプラン(実際のビルドで使ったパッケージとそのバージョンのリスト)を集めて、その和集合として`build-depends`を自動生成する、というものです。

cabal-plan-boundsをGitHub Workflowに組み込んでみると、以下のようになりました([fold-debounceパッケージの .github/workflows/haskell.yml](https://github.com/debug-ito/fold-debounce/blob/e9e17c6e5e2981c6005ddc25d1fd5874fd3ce467/.github/workflows/haskell.yml))。

```yaml
name: Haskell CI

on: [push, pull_request]

jobs:
  build:
    strategy:
      matrix:
        os: [ubuntu-latest]
        plan:
          - ghc: latest
            allow-fail: true
          - ghc: '9.10.1'
          - ghc: '9.8.1'
          - ghc: '9.6.1'
            freeze: '20240913-ghc-9.6.1.freeze'
          - ghc: '9.4.1'
          - ghc: '9.2.5'
            freeze: '20221124-ghc-9.2.5.freeze'
          - ghc: '9.2.1'
          - ghc: '9.0.1'
          - ghc: '8.10.1'

    runs-on: ${{ matrix.os }}
    continue-on-error: ${{ matrix.plan.allow-fail == true }}
    env:
      FREEZE: ${{ matrix.plan.freeze }}

    steps:
    - uses: actions/checkout@v4
      
    - uses: haskell-actions/setup@v2
      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
        if [ "x" == "x$FREEZE" ]; then cabal v2-freeze; else cp freezes/$FREEZE cabal.project.freeze; fi
        cat cabal.project.freeze

    - uses: actions/cache@v4
      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: Test
      run: cabal v2-test --jobs=1 all

    - name: Prepare artifacts
      run: |
        mkdir output-artifacts
        cp dist-newstyle/cache/plan.json output-artifacts/
        cp cabal.project.freeze output-artifacts/
    - uses: actions/upload-artifact@v4
      if: ${{ matrix.os == 'ubuntu-latest' }}
      with:
        name: plans-${{ matrix.plan.ghc }}
        path: output-artifacts

  bounds:
    runs-on: ubuntu-latest
    needs: build
    steps:
    - uses: actions/checkout@v4
    - name: Fetch cabal-plan-bounds
      run: |
        curl -L https://github.com/nomeata/cabal-plan-bounds/releases/latest/download/cabal-plan-bounds.linux.gz | gunzip > /usr/local/bin/cabal-plan-bounds
        chmod +x /usr/local/bin/cabal-plan-bounds
    - name: Make directories for work
      run: mkdir -p input-artifacts output-artifacts/plans output-artifacts/freezes output-artifacts/cabals
    - uses: actions/download-artifact@v4
      with:
        path: input-artifacts/
    - name: Aggregate build plans
      run: |
        for d in input-artifacts/*; do
          echo $d
          plan_id=${d#input-artifacts/plans-}
          echo $plan_id
          mv $d/plan.json output-artifacts/plans/${plan_id}.json
          mv $d/cabal.project.freeze output-artifacts/freezes/${plan_id}.freeze
        done

    - name: Modify cabals
      run: |
        p=fold-debounce
        echo modify $p.cabal
        cabal-plan-bounds -c $p.cabal output-artifacts/plans/*.json
        git diff $p.cabal
        cp $p.cabal output-artifacts/cabals/

    - uses: actions/upload-artifact@v4
      with:
        name: aggregated-plans
        path: output-artifacts
```

ジョブ`build`では、`matrix`を使うことでGHCコンパイラの複数のバージョンについてビルドとテストを実行します。
`matrix`で指定したGHCバージョンは後のステップで[haskell-actions/setup](https://github.com/haskell-actions/setup)に渡され、当該バージョンのGHCをセットアップします。

また、`matrix`で`freeze`オプションを設定すると、予め[freezesディレクトリ](https://github.com/debug-ito/fold-debounce/tree/e9e17c6e5e2981c6005ddc25d1fd5874fd3ce467/freezes)にコミットしておいたfreezeファイル[^freeze-file]を使ってビルドします。
古いバージョンの依存パッケージを使ってビルドしたいときに有効です。

[^freeze-file]: freezeファイル: パッケージをビルドする際に必要な全依存パッケージのバージョンを特定の値に固定するための設定ファイル。他のプログラミング言語ではlockファイルなどとも呼ばれます。

ジョブ`build`はビルドとテストを完了すると、
そのビルドで使われたビルドプランファイル(`dist-newstyle/cache/plan.json`)とfreezeファイル(`cabal.project.freeze`)をartifactとしてGitHub上にアップロードします([actions/upload-artifact](https://github.com/actions/upload-artifact)を使います)。

その後実行されるジョブ`bounds`では、ジョブ`build`の実行結果に基づいて`cabal-plan-bounds`を実行し、`build-depends`を自動生成します。
まずは`cabal-plan-bounds`のビルド済みバイナリをダウンロードします。
その後、[actions/download-artifact](https://github.com/actions/download-artifact)を使ってジョブ`build`がアップロードしたartifactを一括ダウンロードします。
"Aggregate build plans"ステップでは、ダウンロードしたartifactからビルドプランファイルとfreezeファイルを仕分けて保存します。

最後に`cabal-plan-bounds`を実行します。

```sh
cabal-plan-bounds -c $p.cabal output-artifacts/plans/*.json
```

`cabal-plan-bounds`は引数で与えられたビルドプランファイルを参照し、`-c`オプションで与えられた.cabalファイルの`build-depends`セクションを直接書き換えます。どのように書き換えたかは`git diff`で確認できます。

書き換え後の.cabalファイルをartifactとしてアップロードして、ジョブ`bounds`は終了します。

最終的なartifactは全ビルドプランファイル、全freezeファイル、書き換え後の.cabalファイルとなり、これはGitHub WorkflowのWeb UIでダウンロードできます。
書き換え後のbuild-dependsは以下のようになりました([fold-debounce-0.2.0.12](https://hackage.haskell.org/package/fold-debounce-0.2.0.12)のものです)。

```cabal
build-depends: base ^>=4.14.0 || ^>=4.15.0 || ^>=4.16.0 || ^>=4.17.0 || ^>=4.18.0 || ^>=4.19.0 || ^>=4.20.0,
               data-default-class ^>=0.1.2,
               stm ^>=2.5.0,
               time ^>=1.9.3 || ^>=1.11.1 || ^>=1.12.2,
               stm-delay ^>=0.1.1
```

このように、CIで使うGHCのバージョンが増えると、それだけ`base`パッケージの依存バージョンも増えます[^caret-op]。
見た目の印象がややこしくなるのが玉にキズですが、`build-depends`は機械が読み書きするものと開き直ってしまえばよいように思います。

[^caret-op]: 書き換え後の`build-depends`にある`^>=`は"caret operator"と呼ばれるもので、major version upを伴わないバージョン範囲を示します。詳細は[Cabalのドキュメント](https://cabal.readthedocs.io/en/stable/cabal-package-description-file.html#pkg-field-build-depends)を参照。
[npm](https://docs.npmjs.com/cli/v6/using-npm/semver/#caret-ranges-123-025-004)やPythonの[Poetry](https://python-poetry.org/docs/dependency-specification/)にも類似の記法がありますが、Haskellのバージョン番号は[SemVer](https://semver.org/)と微妙に異なる[Package Versioning Policy(PVP)](https://pvp.haskell.org)というルールに従うので要注意です。平たく言うと、PVPでは先頭の2桁がmajor versionです。例えば、version 1.2.3.4 なら 1.2 の部分がmajor versionになります。

なお、今回の手法では、書き換え後の.cabalファイルをダウンロードしてレポジトリに反映させる作業は人力でやることにしています。
[cabal-plan-boundsのGitHub Workflow](https://github.com/nomeata/cabal-plan-bounds/tree/master/.github/workflows)では
書き換え後の.cabalファイルをレポジトリにコミットするところも自動化しています。
興味のある方はご覧ください。

さて、このCIをセットアップしたところで、outdated dependencyが発生したらどういう作業をするでしょうか？
例えば、`stm-2.6.0`がリリースされたら以下のような作業をします。

- 検証用のブランチを作る。
- そのブランチの`cabal.project`ファイルに[`allow-newer: all`](https://cabal.readthedocs.io/en/stable/cabal-project-description-file.html#cfg-field-allow-newer)設定を追加してcommit, pushする。
- CIが走るので、結果を確認する。`build-depends`が`stm ^>=2.5.0 || ^>=2.6.0`となっていたら成功。
- `cabal.project`ファイルの`allow-newer: all`を削除してcommitする。
- パッケージのバージョンを上げたりChangeLogを書いたりして、リリースする。

という感じで、まだまだ面倒な部分が残っています。特に`allow-newer`を設定しないと`stm`の新しいバージョンがビルドで使われないので要注意です。

実はJoachim Breitnerさんは上記の作業もほぼ自動化するツールを公開しています。[haskell-bounds-bump-action](https://github.com/nomeata/haskell-bounds-bump-action)というGitHub Actionです。
このActionは、outdated dependencyを検出すると、その最新バージョンを含めるように.cabalファイルの`build-depends`を修正したブランチを作り、そのpull-requestを立てます。
このpull-requestに対してCIが走るはずなので、その結果から更新された.cabalファイルを取得できます。

余力があれば(あるいは、必要に迫られたら)こういったツールも導入しようかと考えています。

※本記事に掲載の商品、機能等の名称は、それぞれ各社が商標として使用している場合があります。