本記事は「Haskell Advent Calendar 2019」の2日目の記事です．

2019/11/13 に GA された GitHub Actions を使って，Haskell プロジェクト，とりわけ Haskell Stack を使ったプロジェクトを CI/CD します．

ちなみに，試すために導入した PR はこれです:

これは適当な設定ファイルから GitHub の Organization や Organization の Team 機能にユーザーを招待したりキックしたりするための CLI ツールです．

Cabal の場合

はわりかし簡単． Haskell のセットアップは公式がすでに用意してくれてるのでこれを使えば良い:

こんな感じ:

jobs : build : name : ghc ${{ matrix.ghc }} runs-on : ubuntu-16.04 strategy : matrix : ghc : [ "8.2.2" , "8.4.4" , "8.6.5" , "8.8.1" ] cabal : [ "3.0" ] steps : - uses : actions/checkout@master with : fetch-depth : 1 - uses : actions/setup-haskell@v1 name : Setup Haskell with : ghc-version : ${{ matrix.ghc }} cabal-version : ${{ matrix.cabal }}

Haskell パッケージ系のリポジトリなら，こんな感じに matrix の設定をすると良い． で，キャッシュする場合は，この matrix ごとに ~/.cabal/store だけをキャッシュすれば十分らしい（教えてもらった）:

steps : ... - name : Cache .cabal uses : actions/cache@preview with : path : ~/.cabal/store key : ${{ matrix.ghc }}-cabal-${{ hashFiles('**/fallible.cabal') }} restore-keys : | ${{ matrix.ghc }}-cabal- - uses : actions/setup-haskell@v1 name : Setup Haskell with : ghc-version : ${{ matrix.ghc }} cabal-version : ${{ matrix.cabal }} - name : Install dependencies run : | cabal v2-update cabal v2-build --only-dependencies - name : Build & test run : | cabal v2-build cabal v2-test

Cabal の方はちゃんと調査してないのでこんでお終い（すいません）．

Stack の場合

こっからが本題．

Stack はキャッシュすべきディレクトリ ~/.stack がでかすぎる． 下記は試しに GitHub Actions 上で du して見た結果だ:

$ du -sh ~/.stack/* 4.0K /home/runner/.stack/config.yaml 1.3G /home/runner/.stack/pantry 553M /home/runner/.stack/pantry.sqlite3 0 /home/runner/.stack/pantry.sqlite3.pantry-write-lock 1.8G /home/runner/.stack/programs 16M /home/runner/.stack/setup-exe-cache 64K /home/runner/.stack/setup-exe-src 462M /home/runner/.stack/snapshots 192K /home/runner/.stack/stack.sqlite3 0 /home/runner/.stack/stack.sqlite3.pantry-write-lock

現在， actions/cache@v1 では一度にキャッシュできるディレクトリの最大サイズは400MBしかない（ 今後緩和される可能性はあるが 2020年1月7日ごろにリリースされた v1.1.0 より上限が2GBに緩和されたので、以降の涙ぐましい努力をする必要はなくなった笑）． actions/cache は内部で gzip かなんかで圧縮しているので，この数字まんまではない． 試しに，このまんまキャッシュしてみたら次のような警告が出た:

Post job cleanup. /bin/tar -cz -f /home/runner/work/_temp/2706cc23-8789-4ed4-b4ec-4e7143b1cc98/cache.tgz -C /home/runner/.stack . ##[warning]Cache size of 814014541 bytes is over the 400MB limit, not saving cache.

800MB強，意外と少ない！

余談だが， そのうち v1.0.2 から毎回キャッシュサイズが見れるようになるはず（今でも ACTIONS_STEP_DEBUG を Secret に設定すると見れる）．

system-ghc を使う

stack は --system-ghc オプションを使うことで stack がインストールした GHC の代わりに，ホストマシンの GHC を直接使ってくれる:

jobs : build : runs-on : ubuntu-18.04 strategy : matrix : ghc : [ "8.6.5" ] cabal : [ "3.0" ] cache-version : [ "v4" ] steps : - uses : actions/checkout@v1 with : fetch-depth : 1 - name : Setup Haskell uses : actions/setup-haskell@v1 with : ghc-version : ${{ matrix.ghc }} cabal-version : ${{ matrix.cabal }} - uses : mstksg/setup-stack@v1 - name : Install dependencies run : stack --system-ghc build --only-dependencies - name : Build binary run : stack --system-ghc install --local-bin-path=./bin

なんと system-ghc を使うことで ~/.stack/programs が保存されなくなった（ここには stack がインストールした GHC が保存されてた）． これで，半分弱の削減に成功．残り約500MB．

必殺奥義: 分割キャッシュ

実は，キャッシュの最大サイズ 400MB は 一つのディレクトリ毎の最大サイズ だ． なので，別々のディレクトリを別々にキャッシュすれば，最大 2GB までキャッシュできる（これがリポジトリ単位での最大サイズ）．

~/.stack/pantry というのが単体で 1.3GB ある． なので，これだけとそれ以外をキャッシュするようにしてみる． ちなみに，Pantry というのが Stack の依存パッケージのキャッシュシステムだ．

ここで問題が1つ． actions/cache はディレクトリを1つ指定して，それを圧縮しキャッシュしする． 複数のディレクトリを指定したり，中の一部のファイルだけを除外したりなどはできない（少なくとも現在のバージョンでは）． もちろん，一旦 mv してキャッシュし，restore したら mv し直せば良い． が，めんどいね． 単純なことはソフトウェアで解決しよう． ソフトウェアエンジニアの精神です（？）．

ということで，それをやってくれるアクションがこちら:

ついに TypeScript デビューした． はい，actions/cache を参考にしてきていい感じに書き直しただけです． mkdir や mv は actions/toolkit にあるので簡単に実装できた:

import * as core from "@actions/core" ; core from import * as io from "@actions/io" ; io from import * as utils from "./utils/actionUtils" ; utils from function run () : Promise < void > { async() try { = utils . resolvePath ( const sourceutils . getInput ( "source_dir" , { required : true }) core{ requiredtrue }) ) ; // 残念ながら inputs は文字列しか渡せないので改行で分割してる = core const filescore . getInput ( "source_files" , { required : true }) { requiredtrue }) . split ( / \r?

/ ) . filter (pat => pat) (patpat) . map (pat => pat . trim ()) ; (patpat()) = utils . resolvePath ( const targetutils . getInput ( "target_dir" , { required : true }) core{ requiredtrue }) ) ; . mkdirP (target) ; await io(target) . debug ( `mkdir -p ${ target } ` ) ; coretarget . forEach (async function (file) { files(async(file) { try { = source . concat ( "/" , file) ; const pathsourcefile) . mv (path , target) ; await io(pathtarget) . debug ( `mv ${ path } to ${ target } ` ) ; corepathtarget } catch (error) { . warning (error . message ) ; core(error } ; }) } catch (error) { . warning (error . message ) ; core(error } } ; run() ; export default run

使うときはこんな感じ:

- name : Move .stack/pantry to temp uses : matsubara0507/actions/move-files@master with : source_dir : ~/.stack-temp/pantry source_files : | pantry target_dir : ~/.stack

実は，GitHub Actions には隠し機能（現状ドキュメントには書いてない）として post と post-if というのがある（ actions.yml に設定できる）:

name : 'Move Files' description : 'move files to other direcotory' inputs : source_dir : required : true source_files : required : true target_dir : require : true runs : using : 'node12' main : 'dist/move/index.js' post : 'dist/restore/index.js' # move.ts とは全く逆のことをするだけ post-if : 'success()' # move が成功したときにだけ

これは actions/cache や actions/checkout がやっているやつで，ジョブステップの最後にデストラクタのように指定したアクションを実行してくれる機能だ． ちなみに，実行したステップとは逆順にポストステップは実行する．

これと actions/cache を組み合わせることで，自由にキャッシュしたいディレクトリを分割してキャッシュすることができるようになった！

steps : - uses : actions/checkout@v1 with : fetch-depth : 1 - name : Cache .stack id : cache-stack uses : actions/cache@v1 with : path : ~/.stack key : ${{ runner.os }}-stack-${{ hashFiles('**/stack.yaml.lock') }} restore-keys : | ${{ runner.os }}-stack- - name : Cache .stack/pantry id : cache-pantry uses : actions/cache@v1 with : path : ~/.stack-temp/pantry key : ${{ runner.os }}-pantry-${{ hashFiles('**/stack.yaml.lock') }} restore-keys : | ${{ runner.os }}-pantry- - name : Move .stack/pantry to temp uses : matsubara0507/actions/move-files@master with : source_dir : ~/.stack-temp/pantry source_files : | pantry target_dir : ~/.stack - uses : actions/setup-haskell@v1 ...

ちょっとわかりにくいですが，別々にキャッシュしたディレクトリを move-files で合体させるイメージ．

キャッシュバージョンを付ける

今回の PR のコミット履歴を見るとわかるのだが迷走してる． なぜかというと，actions/cache の「cache save は cache key が ヒットしなかったときにだけ 行う」という性質に気づくのに時間がかかったから． key にはヒットせず restore-keys でヒットしたときには restore をして更にキャッシュを更新する． しかし，key に変更が無いとズーーーット古いキャッシュを使い続けてしまった． 変だと思った．

現状キャッシュを手動でクリアする方法が無い． まぁなんでも良かったので cache-version というサフィックスを付けることにした笑:

strategy : matrix : ghc : [ "8.6.5" ] cabal : [ "3.0" ] cache-version : [ "v4" ] steps : - uses : actions/checkout@v1 with : fetch-depth : 1 - name : Cache .stack id : cache-stack uses : actions/cache@v1 with : path : ~/.stack key : ${{ runner.os }}-stack-${{ hashFiles('**/stack.yaml.lock') }}-${{ matrix.cache-version }} restore-keys : | ${{ runner.os }}-stack-

少なくとも，キャッシュを試行錯誤してるときには便利だ．

ビフォーアフター

もともと25分以上かかかっていたビルドが:

なんと2分まで減った！

おまけ: GitHub Packages

Haskell プログラムのバイナリを配布するために，僕は普段 Docker Image にして Docker Hub に置いてた． しかし，先日 GitHub の 2019年のもう一つの目玉機能「GitHub Packages」も GA されたので，こっちに置いてみることにした（なんと Docker レジストリにもなる）．

ちなみに，現状パブリックリポジトリのパッケージであっても docker pull するのに認証が必要である． その点がとても残念(改善されることを祈る)．

ログイン

意外と手間取った． どうやら MFA 設定してるとトークンを使う他ないらしい． しかも，新しく（？）追加された write:packages というスコープをオンしないとダメっぽい．

GitHub Actions からプッシュ

こんな感じ

- name : Build binary run : stack --system-ghc install --local-bin-path=./bin - name : Build Docker Image run : docker build -t octbook . --build-arg local_bin_path=./bin - name : Push Docker Image if : github.ref == 'refs/heads/master' run : | echo ${{ secrets.GITHUB_TOKEN }} | docker login docker.pkg.github.com -u matsubara0507 --password-stdin docker tag octbook docker.pkg.github.com/matsubara0507/octbook/cli docker push docker.pkg.github.com/matsubara0507/octbook/cli:latest - name : Push Docker Image (tag) if : startsWith(github.ref, 'refs/tags/') run : | echo ${{ secrets.GITHUB_TOKEN }} | docker login docker.pkg.github.com -u matsubara0507 --password-stdin docker tag octbook docker.pkg.github.com/matsubara0507/octbook/cli:${GITHUB_REF#refs/tags/} docker push docker.pkg.github.com/matsubara0507/octbook/cli:${GITHUB_REF#refs/tags/}

if: github.ref == 'refs/heads/master' とすることで master ブランチのときだけ， if: startsWith(github.ref, 'refs/tags/') とすることで tag のときだけ，それぞれのステップを評価させることができる．

ちなみに， GITHUB_TOKEN という Secret はデフォルトで用意されてる． スコープについてはここに書いてある． packages の read/write があるのでそのまま利用できるね．

おしまい

まぁきっと数ヶ月後ぐらいにはキャッシュ容量の制限が緩和されてこんなことしなくても良くなると思うけど．