⚙️

HaskellでGitHub Actionsのワークフローを書きたい

2023/12/09に公開

この記事はHaskell Advent Calendar 2023の9日目の記事です。


Dhallがちょっとつらくなってきた

ひとりでちまちまと作り続けているPeridotのCIなどでGitHub Actionsを使っています。
このワークフローの定義を、以前はDhallで書いてyamlに変換するという形で運用していました。Dhallを使うことで生のyamlを触るよりはるかに安全かつ簡単にワークフローの記述/修正を行うことができていましたが、次のようなつらみがあって少し前にHaskell製のお手製ワークフロージェネレータに一気に鞍替えしました。

型推論が限定的

ほぼこれがすべてな気がしますが、Dhallは型推論をしてくれる範囲が全コードではなく、一部は明示的に与えてあげる必要があります。

より具体的には関数適用時に型変数を自動で推論してくれないので、ここを毎回明示する必要があります。設定ファイル内で関数を使って内容を一部共通化できるのがDhallの強みの一つですが、毎回型引数を明示しないといけないという一点でだいぶ関数化するモチベーションがなくなってしまいます。

たとえばList/mapを使って複数のStepに同じ変換を与える場合、次のような記載になります。

let steps = List/map Step.Type Step.Type (\s -> ...) ...

この場合のmapに限定して言えばそれ専用のエイリアス的な定義を作ればある程度手間は減らせますが、依然として型引数が必須という点に変わりはないためあまり本質的な解決策とは言えません。

let endomap = \(a : Type) -> \(f : a -> a) -> \(xs : List a) -> List/map a a f xs

インデントが深すぎる

基本的に全体が巨大なlet..inの形になるので必要な字下げの量が多くなります。

DhallはHaskellと同じインデントルールを採用しているので、let x = ...の式の途中で改行を行う場合は、次の行以降はxより右から始まる必要があります。CIのような設定ファイルは1オブジェクトが大きくなりがちなので1行で収められるケースというのは非常に稀で、改行は必須なので字下げの量というのはだいぶ影響が大きいです。

まあフォーマッタで自動フォーマットできるのでそれに任せればTab入力する手間はなくなりますが、字下げが深いぶん自由に使える横幅が少なくなるので頻繁に改行が発生するようになり、可読性にやや難があります。

もうHaskellで書いたらいいじゃん

GitHub Actionsのワークフロー定義は種も仕掛けもないただのyamlファイルです。そのため、基盤さえ整備すればDhall/Haskellに限らずありとあらゆるプログラミング言語での記述が実質可能です。
今回Haskellを選択したのは、もともとDhallで書いていたからという点もありますが、ワークフロー定義というのは定義の集まりなのでそれを自然に表現できる言語であるのが望ましいでしょうというのが主な理由になっています。

Haskellでyamlを生成する

Haskellでワークフローを書けるようにする場合も、基本的にはDhallと同じようにワークフローの各構成要素をそのまま型として定義して、それに当てはまるようにデータを用意する形をとります。ひとつだけ違うところといえば、Dhallでは処理系がyamlへの書き出しを行っていたのに対してHaskellなどではここのシリアライズ処理も実装する必要がある点です。

今回はyamlへのシリアライズにもはやデファクトスタンダードともなっているaeson(+aeson-yaml)を利用します。
aeson自体の解説はたくさんあるので細かい部分は省略しますが、ToJSONのインスタンスを定義することで任意の型をシリアライズできるようになります。ToJSONのインスタンスの定義は次のいずれかの形で行えます。

  1. Genericを経由して完全に全自動で導出する
    • 追加でaeson-casingを使ってフィールド名の変換を行う
  2. toJSONもしくはtoEncodingを手動で定義する

今回はフィールドごとに個別に値の変換処理を与えたいため、2. の手動導出で定義しています。たとえばStepの定義は次のとおりになります。

src/Workflow/GitHub/Actions/Step.hs(一部)
data Step = Step
  { stepId :: Maybe String,
    stepIf :: Maybe String,
    stepName :: Maybe String,
    stepUses :: Maybe String,
    stepRun :: Maybe String,
    stepShell :: Maybe String,
    stepWith :: Map String Value,
    stepEnv :: Map String String,
    stepWorkingDirectory :: Maybe String
  }

instance ToJSON Step where
  toJSON Step {..} =
    object $
      catMaybes
        [ ("id" .=) <$> stepId,
          ("if" .=) <$> stepIf,
          ("name" .=) <$> stepName,
          ("uses" .=) <$> stepUses,
          ("run" .=) <$> stepRun,
          ("shell" .=) <$> stepShell,
          ("with" .=) <$> maybeNonEmptyMap stepWith,
          ("env" .=) <$> maybeNonEmptyMap stepEnv,
          ("working-directory" .=) <$> stepWorkingDirectory
        ]

基本的にはobject [...]の形でオブジェクト型の要素を生成します。オブジェクト内の各エントリはkey .= valueの形で記述します。
ここで、withおよびenvは内容物がない場合は出力しないようにしたいため、空のときNothingになるヘルパ関数を作って潰しています。

src/Workflow/GitHub/Actions/InternalHelpers.hs(一部)
import Data.Map qualified as M

maybeNonEmptyMap :: Map k v -> Maybe (Map k v)
maybeNonEmptyMap m
  | M.null m = Nothing
  | otherwise = Just m

このような形で、GitHub Actionsのワークフロー定義に登場するデータ型を宣言して変換処理を書くことで静的型でのワークフロー定義を構築していきます。

出力時はワークフロー定義データをData.Aeson.Yaml.encodeに適用することでyaml形式の内容を生成できます。ここまでの定義内容に間違いがなければ、得られた出力を.github/workflows内にymlファイルとして書き出せばGitHubに認識されます。注意点として、Data.Aeson.Yaml.encodeの出力はLazyなByteStringなので、IO関数もそれにあったものを使う必要があります。

import Data.ByteString.Lazy.Char8 qualified as LBS8
import Data.Aeson.Yaml (encode)

workflow :: Workflow

main = LBS8.writeFile ".github/workflows/workflow.yml" $ encode workflow

もっと書きやすくする

ここまででもすでにワークフローとしては書ける形になっていますが、せっかくHaskellという汎用言語を使用しているので、設定ファイルと言う軸から外れてもう少し書きやすいように整備をしていきたいと思います。

ボイラープレート的定義

たとえばStepは次の2つのユースケースのどちらかになる場合がほとんどです。

  • シェルスクリプトを動かす場合
  • 他のActionを使う場合

これらを関数として置いておくことで大きく記述量を減らしつつ、そのStepがどういうものかを宣言的に記述できます。

src/Workflow/GitHub/Actions/Step.hs(一部)
-- emptyStepはすべてのフィールドがmemptyであるStepのことです

runStep :: String -> Step
runStep command = emptyStep {stepRun = Just command}

actionStep :: String -> Map String Value -> Step
actionStep name withArgs = emptyStep {stepUses = Just name, stepWith = withArgs}
こう使える
checkoutStep = actionStep "actions/checkout@v4" mempty
echoStep = runStep "echo aaa"

ネストしたyaml形式をスマートに取り扱う

pnpm/action-setuprun_installなど、別途JSONやyaml形式の文字列を引数に取るActionがたまに存在します。
Dhallでは任意のシリアライズ処理を挟むといったことはできないのでこういった定義は残念ながら文字列型にするしかありませんが、汎用言語のHaskellであればそういった処理も挟むことができるため、より型安全な定義が可能になります。

data RunInstallOption = RunInstallOption
  { runInstallArgs :: [String],
    runInstallCwd :: Maybe String
  }

instance ToJSON RunInstallOption where
  toJSON RunInstallOption {..} =
    object $
      catMaybes
        [ Just ("args" .= runInstallArgs),
          ("cwd" .=) <$> runInstallCwd
        ]

runInstallOption :: [String] -> RunInstallOption
runInstallOption args =
  RunInstallOption {runInstallArgs = args, runInstallCwd = Nothing}

step :: [RunInstallOption] -> GHA.Step
step =
  actionStep "pnpm/action-setup@v2"
    . M.singleton "run_install"
    . toJSON
    . toString
    . encode

更新内容の関数化

レコードの更新処理を関数化することで、更新内容に別名を付けたり複数の更新内容を合成したりと柔軟に取り回すことができるようになります。

src/Workflow/GitHub/Actions/Step.hs(一部)
-- stepModifyWith :: (Map String Value -> Map String Value) -> Step -> Step

-- | Stepのwithパラメータのエントリを追加/更新する
stepSetWithParam :: (ToJSON v) => String -> v -> Step -> Step
stepSetWithParam k = stepModifyWith . M.insert k . toJSON

-- | Stepのshellパラメータを置き換え
stepUseShell :: String -> Step -> Step
stepUseShell name self = self {stepShell = Just name}

複数の関数の適用には$を用いる以外にも、Data.Function.(&)を使うことで関数を後置で適用できてより読みやすい形にできます。

-- step1とstep2はおなじ
step1 = stepUseShell "bash" $ runStep "echo a"
step2 = runStep "echo a" & stepUseShell "bash"

-- 適用する関数が多いと後置のほうが有利
-- (RustToolchainAction.step :: Step
-- RustToolchainAction.useStable :: Step -> Step
-- RustToolchainAction.forTarget :: String -> Step -> Step)
step3 =
  RustToolchainAction.step
    & RustToolchainAction.useStable
    & RustToolchainAction.forTarget "aarch64-linux-android",

型クラスを用いたワークフロー構成要素の特性抽象化

更新内容の関数化から発展して、別々の型に対する同じ内容の更新に対して同じ名前を付与することができればさらに簡潔かつ読みやすい定義ができるようになります。

たとえばStepJobはどちらも実行条件を持つことが可能です。
そこで、実行条件を持つものをConditionalElementのように型クラスでグルーピングすることで、対象の詳細な型がなんであるかを意識せず「実行条件の設定」を行うことができます。

class ConditionalElement a where
  withCondition :: String -> a -> a
  
instance ConditionalElement Step where
  withCondition cond self = self {stepIf = Just cond}
instance ConditionalElement Job where
  withCondition cond self = self {jobIf = Just cond}
こう使える
job :: Job
step :: Step

job' = withCondition "failure()" job
step' = withCondition "failure()" step

ジョブ間の依存を表現する演算子

GitHub ActionsのワークフローにおけるJobは、何も指定しなければ基本的にはすべてのJobが並行して動作します。並行して動作してほしくない場合はJob定義でneedsの指定を与えることで、指定したJobが完了してから開始されるようにできます。
数個のJobの依存性であればなんとか補助なしでもメンテできるとは思いますが、ある程度の規模になってくるとneedsを直接管理する方法では限界が来ます。そこで、Haskellの表現力を活かして視覚的にわかりやすくJob間依存の定義をできるような仕組みをPeridotのワークフロージェネレータに作りました。この仕組みを使うと、Job間の依存を次のように記述できます。

app/IntegrityTest/PerPullRequestTriggered.hs(一部)
[ checkFormats',
  checkBaseLayer'
    ~=> [checkTools', checkModules' ~=> checkExamples']
    ~=> [checkCradleWindows', checkCradleMacos', checkCradleLinux', checkCradleAndroid']
]

ここで、checkHogehoge'という変数の型はすべてMap String Jobです。

~=>という奇妙な演算子[1]で実行順依存を定義できるようになっています。a ~=> bで「aにあるすべてのJobの完了を待ってbのJobが走るようにする」という表現になります。同一リスト内のJobは並行して走ると解釈されます。
また、この演算子は左結合なのでa ~=> b ~=> c(a ~=> b) ~=> cとなり、「aが終わってからb、それが終わってからc」と左から右へ自然と読める形になっています。

この仕組みは、JobGroupという概念(型クラス)をベースとして作られています。ここに出てくるrequireBeforeがそのまま~=>の実装になっています。

src/Workflow/GitHub/Actions/JobGroupComposer.hs(一部)
-- Note: Workflow中のworkflowJobsと同じ型
type JobMap = Map String Job

-- ジョブグループ ジョブグループとは、端的には「複数個のStringとJobのペア」のことです
class JobGroup a where
  -- グループbの実行にグループa全部の完了が必要
  requireBefore :: (JobGroup b) => a -> b -> JobMap
  -- グループ内のJobが並行して動く形で一つのJobMapにまとめる
  concurrent :: a -> JobMap

-- 単一のJobMapのグループ
instance JobGroup JobMap where
  -- 後続のJobGroupを並行動作として単一グループに潰して、自身の全Jobが先に必要という形の関係を組む
  requireBefore self = requireJobsBefore self . concurrent
  -- すでに単一のグループなのでそのまま
  concurrent = id

-- 複数のJobMapのグループ
instance JobGroup [JobMap] where
  -- 後続と自身のJobGroupを並行動作として単一グループに潰して、自身の全Jobが先に必要という形の関係を組む
  requireBefore self = requireJobsBefore (concurrent self) . concurrent
  -- 全部並行動作させる
  concurrent = mconcat

先の実装中に登場するrequireJobsBeforeは次のような実装になっています。

src/Workflow/GitHub/Actions/JobGroupComposer.hs(一部)
-- preJobs全部の完了がafterJobs全部の実行に必要であるという関係を構築する
-- afterJobs全部のneedsにpreJobsのkeysを追加することで依存性ができる
requireJobsBefore :: JobMap -> JobMap -> JobMap
requireJobsBefore preJobs afterJobs =
  (depends requiredJobNames <$> afterJobs) <> preJobs
    where
      requiredJobNames = M.keys preJobs

-- これは単純にneedsにdepsを追加しているだけ
depends :: [String] -> Job -> Job
depends deps x = x {jobNeeds = Just $ fromMaybe [] (jobNeeds x) <> deps}

おわり

以上がPeridotにおけるHaskellでのワークフロージェネレータの実装です。
設定ファイルの記述に汎用言語をつかうという若干オーバーエンジニアリングみのある内容ですが、GitHub Actionsのワークフローは比較的設定ファイルとしては大きく複雑になりがちだと思うのでこういうやり方もなしではないんじゃないかなと思います。

Dhallから移行してみて、やはり本家Haskellの表現力の高さには改めて驚かされます。演算子の定義とかはDhallではできないですし。

付録

今回作ったもののうち汎用的な定義の部分を試験的に別リポジトリとして切り出しました。

https://github.com/Pctg-x8/haskell-github-actions-workflow-generator

単純に切り出しただけなのでWorkflow.GitHub.Actions.Predefinedactions/checkoutなど、よく使うactionsの各種定義をまとめたもの)はあまりユースケースをカバーできていませんが、このリポジトリをdependenciesとして指定することで今回説明したようなものを試しに使うことができます[2]

脚注
  1. いい感じの見た目の演算子作るのむずかしい ↩︎

  2. Stackをご使用の場合はextra-depsにgitリポジトリを追加することでdependenciesにhaskell-github-actions-workflow-generatorとして依存を指定できます ↩︎

Discussion