12
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

東芝Advent Calendar 2024

Day 9

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

Last updated at Posted at 2024-12-08

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

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

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

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

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というツールを見つけました。
これは要は、CIなどで実行した複数のビルドプラン(実際のビルドで使ったパッケージとそのバージョンのリスト)を集めて、その和集合としてbuild-dependsを自動生成する、というものです。

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

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に渡され、当該バージョンのGHCをセットアップします。

また、matrixfreezeオプションを設定すると、予めfreezesディレクトリにコミットしておいたfreezeファイル1を使ってビルドします。
古いバージョンの依存パッケージを使ってビルドしたいときに有効です。

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

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

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

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のものです)。

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パッケージの依存バージョンも増えます2
見た目の印象がややこしくなるのが玉にキズですが、build-dependsは機械が読み書きするものと開き直ってしまえばよいように思います。

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

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

  • 検証用のブランチを作る。
  • そのブランチのcabal.projectファイルにallow-newer: all設定を追加してcommit, pushする。
  • CIが走るので、結果を確認する。build-dependsstm ^>=2.5.0 || ^>=2.6.0となっていたら成功。
  • cabal.projectファイルのallow-newer: allを削除してcommitする。
  • パッケージのバージョンを上げたりChangeLogを書いたりして、リリースする。

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

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

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

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

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

  2. 書き換え後のbuild-dependsにある^>=は"caret operator"と呼ばれるもので、major version upを伴わないバージョン範囲を示します。詳細はCabalのドキュメントを参照。
    npmやPythonのPoetryにも類似の記法がありますが、Haskellのバージョン番号はSemVerと微妙に異なるPackage Versioning Policy(PVP)というルールに従うので要注意です。平たく言うと、PVPでは先頭の2桁がmajor versionです。例えば、version 1.2.3.4 なら 1.2 の部分がmajor versionになります。

12
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
12
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?