こんにちは。(株)東芝 研究開発センターの伊藤です。
業務では主にIoTの通信セキュリティに関する研究開発に取り組んでいます。
OAuth 2.0ベースのセキュリティプロトコル実装をHaskellで試作したりしています。
自作のHaskellパッケージもいくつか公開しているのですが、仕事やプライベートが忙しくなり、なかなかメンテする時間を取れなかったりします。
中でも面倒だと感じていたのがbuild-depends
の更新です。
build-depends
とは、.cabalファイル(Haskellパッケージのメタデータを記述するファイル)内のセクションの一つで、そのパッケージの依存パッケージとそのバージョン範囲を示したものです。
例えば、拙作のfold-debounce-0.2.0.11のbuild-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をセットアップします。
また、matrix
でfreeze
オプションを設定すると、予め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-depends
がstm ^>=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ファイルを取得できます。
余力があれば(あるいは、必要に迫られたら)こういったツールも導入しようかと考えています。
※本記事に掲載の商品、機能等の名称は、それぞれ各社が商標として使用している場合があります。
-
freezeファイル: パッケージをビルドする際に必要な全依存パッケージのバージョンを特定の値に固定するための設定ファイル。他のプログラミング言語ではlockファイルなどとも呼ばれます。 ↩
-
書き換え後の
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になります。 ↩