windyakinってなんて読む

うぃんぢゃきんではない

GitHub Actions の strategy matrix の内容を他のジョブから動的に定義する

こんにちは。突然ですが GitHub Actions 使っていますか?

自分は GitHub Actions を使い始めて1年ぐらいになりますが、最近ペパボの GitHub Enterprise 上でも GitHub Actions が使えるようになったので、既存の CI の移行や業務効率の改善がはかれるような Workflow をゴリゴリ量産してチームメンバーにも布教している真っ最中です。

それはさておき、今日は個人的に GitHub Actions を書いてきた中で、一番ハックしてるなあ〜と思った実装を公開しておこうと思います。

別のジョブの結果をつかった動的な strategy-matrix を定義したい

jobs.<job_id>.strategy.matrixGitHub Actions の中でも最も強力な構文機能の1つで、主に CI 上で利用するライブラリのバージョンを複数選択して動作させたいような場合に使われます。

例えば Node.js の v12 と v14 で同じ内容の動作チェックしたいときなどは以下のように定義することで、それぞれのバージョンの環境を用意して並列に実行することができるので、同じコードで複数バージョンへの対応ができているかというチェックに非常に役立ちます。

jobs:
  eslint:
    runs-on: ubuntu-latest
    strategy:
      matrix:
        node-version:
          - 12
          - 14
    steps:
      - uses: actions/checkout@v2

      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v1
        with:
          node-version: ${{ matrix.node-version }}

      - run: npm install

      - run: npm run test

この例のようにライブラリのバージョンぐらいであれば指定したい要素の数はかなり限られており、そうそう変更することも少ないため Workflow の YAML にベタ書きすることも違和感はないのですが、この Matrix 上に使う内容をリポジトリの状態などから動的に定義したいような場合があります。

動的に Matrix を指定したい具体例

自分が実際に今回動的な Matrix を作ることになったリポジトリを例に挙げると、「他の CI 上のタスクで envsubst を利用するために alpine に envsubst コマンドをインストールしただけの Docker イメージ」といったベースとなるイメージから数手の変更のみだけで実現するけどわざわざアプリケーションコードのリポジトリで管理するほどでもないような Docker イメージの管理を行うためのリポジトリで、ここに置かれた Dockerfile たちをちまちまとビルドして GitHub Packages に上げる CD が必要になったためでした。ちなみにリポジトリディレクトリ構造は以下のような形。

.
├─ curl-alpine
│   ├─ Dockerfile
│   └─ entrypoint.sh
├─ envsubst-alpine
│   └─ Dockerfile
:

この場合はディレクトリは異なっても1つのディレクトリごとに実行される処理内容は同じになるので、 Matrix などを使うことでそれぞれのディレクトリごとに同じビルドジョブが実行されるようにするのが理想でしょう。しかし GitHub Actions では定義ファイルをあらかじめ YAML で定義しておく必要があるので、新しいイメージが追加されたときにそのディレクトリを処理に追加するために GitHub Actions の YAML の Matrix を編集しなければなりません。このような管理であると間違いなくいずれ Dockerfile のみ追加して YAML の更新を忘れてしまうというオチが目に見えています。なのでできれば対象のディレクトリの探索も自動化したいので GitHub Actions のジョブの結果を使って GitHub Actions の Matrix を定義したいのです。

ではどうやって動的に追加するか

というわけで Matrix を動的に定義したいために今回使うのは、公式に提供されている以下の2つの機能です。

…というかよく見ると fromJson のほうに同じようなことが書いてありますね… まあ今回実際に先ほど紹介したサンプルのリポジトリをもとに動的に Matrix を定義できるようにするのはどうすればよいか考えてみましょう。

今回肝になるのは fromJson という関数で YAML にはアイテムとして JSON をそのまま書くことができるので、この関数に JSON を渡すといい感じにシリアライズしてくれて YAML 上に展開ができるというわけですね。なので今回はビルドしたいディレクトリの一覧を JSON で出力するジョブをつくることができれば当初思い描いていた動的な Matrix の動的定義が実現できそうです。

というわけで早速作っていきましょう。ディレクトリ探索の要件としては以下の通り。

Node.js や Ruby などでスクリプトを作ってもいいのですが、ライブラリを読み込むと保守作業が発生してしまうので、ここはシェルスクリプトだけで実現したいと思います。

まずは条件に当てはまるディレクトリの一覧を取り出すところだけ。これはおそらく find コマンドを使えばうまくできそうです。

% find $(pwd) -type f -name Dockerfile | sed -e "s|^$(pwd)/||g" -e "s|/Dockerfile$||g"
curl-alpine
envsubst-alpine

find の探索ディレクトリの指定をフルパスにすると出力もフルパスになってしまったので無理やり sed で対応したりしていますが、ここでの出力結果はなんとなくよさそうです。

さらにこれを JSON のフォーマットにしていきます。どうすれば実現できるか色々悩んだ挙げ句最終的に awksed で無理やり実現しました。

% find $(pwd) -type f -name Dockerfile | \
sed -e "s|^$(pwd)/||g" -e "s|/Dockerfile$||g" | \
awk -v ORS="" 'BEGIN { print "::set-output name=matrix::{\"image\":[" } { print "\"" $0 "\"," } END { print "]}"}' | \
sed -e "s|\,\]}$|\]}|"
::set-output name=matrix::{"image":["curl-alpine","envsubst-alpine"]}

既にシェル芸の様相ですが、一つ一つ紐解いていくと割と簡単なことをしている(つもり)なのですがどうでしょうか…(この場で詳しい解説は控えますが、 awkJSON の出力をするのですがケツカンマがついてしまうので、最後の行の sed でそれを取り払うようにしています)

さらにこれをジョブとして定義して後続のジョブで使用できるように Workflow を書くと以下のようになります。

jobs:
  pickup:
    runs-on: ubuntu-latest
    outputs:
      matrix: ${{ steps.pickup.outputs.matrix }}
    steps:
    - uses: actions/checkout@v2

    - name: pickup
      id: pickup
      run: |
        find ${GITHUB_WORKSPACE} -type f -name Dockerfile | \
          sed -e "s|^${GITHUB_WORKSPACE}/||g" -e "s|/Dockerfile$||g" | \
          awk -v ORS="" 'BEGIN { print "::set-output name=matrix::{\"image\":[" } { print "\"" $0 "\"," } END { print "]}"}' | \
          sed -e "s|\,\]}$|\]}|"

  build:
    needs: pickup
    runs-on: ubuntu-latest
    strategy:
      matrix: ${{ fromJSON(needs.pickup.outputs.matrix) }}
      fail-fast: false
    steps:
      : (後略 Dockerfile をビルドする処理)

YAML 上には一度も直接ビルドを行うディレクトリを指定するような箇所はありませんが、この Workflow の実行結果ではそれぞれのディレクトリを自動で見つけてきてジョブが複数作成されたのでうまくうごいていそうです。

f:id:windyakin:20201216213152p:plain

反省点としてはやはり pickup と呼んでいるディレクトリ探索用のジョブがシェル芸っぽくなってしまったことでしょうか。いい方法があれば教えて下さい。