💪

GitHub ActionsのMatrix Jobsの結果を受け取るワークアラウンド

2021/08/22に公開

実はもっといい方法があるという場合はコメントで教えてください :pray:

Background

GitHub ActionsではMatrix Jobsがサポートされており、例えばOSや言語バージョンなどの組み合わせに対するJobを容易に記述することが出来ます。非常に便利ですね。

jobs:
  matrix-job:
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        os: [ubuntu-latest, macos-latest]
        rbi-version: ['2.7', '3.0']
    steps:
      - run: Runs on ${{ matrix.os }}
      - uses: actions/setup-ruby@v1
        with:
          ruby-version: ${{ matrix.rbi-version }}

ただそんな便利なMatrix Jobsですが、その結果(outputs, status etc.)の全てを子孫Jobで参照する構文は提供されていません。しかし、だからといって結果の取得を諦められない事情もあるかと思います。

jobs:
  matrix-job:
    matrix: ...
    steps: ...
  descendants-job:
    needs: [matrix-job] # matrix-job の全ての job が終了してから呼ばれるが・・・
    steps:
      - run: echo ${{ toJson(needs.matrix-job) }} # ただ1つのjobしか含まれない

そこで

  • 特定の入力組み合わせに相当するJobの結果を知りたい
  • 成功・失敗したJobの入力値を知りたい
  • 各Jobの出力を受け取りたい

といった要望を腕力で満たしましょう。

実装方針

各Jobの入力値、Job status、Job#outputs 相当のものを artifact を経由して、Workflow 内で受け渡します。

  • ${{ artifact.name }}/${{ artifact.path }} の形式に被りがある場合、Job は失敗せずに単に artifact はいずれか1つしか保存されません(上書き or 先勝ちは未調査)
  • 上記問題点を解決するため、処理系に依らない一意なID生成を用意する必要があります

といった点に注意しつつ、以下の流れを実現します

  • 各 Matrix Job 内で一意なIDを生成する
  • 上記 ID を artifact 名 (またはファイル名) に利用して、入力値などを含んだファイルをアップロード
  • 子孫 Job で artifact を全てダウンロード
  • 各ファイルの中身を参照し、期待する組み合わせなどにより希望するファイルを引き当てる
  • よしなにする

まず Matrix Jobs ( matrix-job ) とそれを使う子孫 Job ( after-matrix-job ) を以下の関係で定義します。これで matrix-job の中の 1 Job が失敗しようとも after-matrix-job は実行されます。

jobs:
  matrix-job:
    continue-on-error: true # 自身が失敗しても Workflow を中断しない
    strategy:
      fail-fast: false # どれかか失敗しても完走させる
      matrix: ...
  after-matrix-job:
    needs: [matrix-job]

matrix-job を設計しますが、今回は何かしらの本処理を行ったあとに実行されることを想定しており、全て if: always() を指定していることに注意してください[1]。まずランダムIDを生成します。ここでは hashFiles 関数に委ねます。

# jobs/matrix-job/steps
- if: always()
  run: echo '${{ toJSON(matrix) }}' > ./matrix.txt
- if: always()
  id: generate-unique-id
  run: echo '::set-output name=result::${{ hashFiles('./matrix.txt') }}'

入力や Job#status をファイルに出力します。

# jobs/matrix-job/steps
- if: always()
  id: generate-outputs
  uses: actions/github-script@v4
  with:
    script: |
      const fs = require('fs')

      const id = "${{ steps.generate-unique-id.outputs.result }}"
      const matrix = ${{ toJSON(matrix) }}
      const status = "${{ job.status }}"
      const exportedValues = { /* any Job's outputs */ } 

      const outputs = { matrix, status, id, exportedValues }

      fs.writeFileSync("${{ github.workspace }}/outputs.txt", JSON.stringify(outputs))

上記で出力したファイルを、先程生成した ID に紐付けつつ、アップロードします。

# jobs/matrix-job/steps
- if: always()
  uses: actions/upload-artifact@v2
  with:
    name: outputs-${{ steps.generate-unique-id.outputs.result }}
    path: ${{ github.workspace }}/outputs.txt

あとは after-matrix-job 側で各 artifact をダウンロードし、条件に一致するファイルを拾い集めるだけです。

# jobs
  after-matrix-job:
    needs: [matrix-job]
    steps:
      - uses: actions/download-artifact@v2
        with:
          path: matrix-job-outputs
      - id: search-files
        ...

actions/download-artifactname を省略することで全ての artifact のダウンロードができます。またファイルの中身を見て検索する部分は要件によって異なるため、必要なものを実装しましょう。

全体像 (例)

検索実装として、「成功した Job のうち、指定した要素を含む入力で実行された Job」を取り出す簡易な方法を選んだものが以下です。

jobs:
  matrix-job:
    runs-on: ${{ matrix.os }}
    continue-on-error: true
    strategy:
      fail-fast: false
      matrix:
        os: [ubuntu-latest, macos-latest]
        rbi-version: ['2.7', '3.0']
        exit-code: [0, 1]
    steps:
      - run: exit ${{ matrix.exit-code }}
      - if: always()
        run: echo '${{ toJSON(matrix) }}' > ./matrix.txt
      - if: always()
        id: generate-unique-id
        run: echo '::set-output name=result::${{ hashFiles('./matrix.txt') }}'
      - if: always()
        id: generate-outputs
        uses: actions/github-script@v4
        with:
          script: |
            const fs = require('fs')

            const id = "${{ steps.generate-unique-id.outputs.result }}"
            const matrix = ${{ toJSON(matrix) }}
            const status = "${{ job.status }}"
	    const exportedValues = {}

            const outputs = { matrix, status, id, exportedValues }

            fs.writeFileSync("${{ github.workspace }}/outputs.txt", JSON.stringify(outputs))
      - if: always()
        uses: actions/upload-artifact@v2
        with:
          name: outputs-${{ steps.generate-unique-id.outputs.result }}
          path: ${{ github.workspace }}/outputs.txt

  after-matrix-job:
    runs-on: ubuntu-latest
    needs: [matrix-job]
    steps:
      - uses: actions/download-artifact@v2
        with:
          path: matrix-job-outputs
      - uses: actions/github-script@v4
        with:
          script: |
            const fs = require('fs')
            const path = require('path')

            const isSubset = (parent, child) => {
              return Object.keys(child)
                .every(k => (k in parent) && (child[k] === parent[k]))
            }

            const query = {
              matrix: {
                os: 'ubuntu-latest',
                "rbi-version": '3.0'
              },
              status: 'success'
            }

            const directory = path.resolve('./matrix-job-outputs')
            const matchedOutputs = []

            for (const dirName of fs.readdirSync(directory)) {
              const filePath = path.join(directory, dirName, 'outputs.txt')
              const outputs = JSON.parse(fs.readFileSync(filePath))

              if (outputs.status === query.status && isSubset(outputs.matrix, query.matrix)) {
                matchedOutputs.push(outputs)
              }
            }

            console.log(matchedOutputs)
// matchedOutputs
[
  {
    matrix: { os: 'ubuntu-latest', 'rbi-version': '3.0', 'exit-code': 0 },
    status: 'success',
    id: '3b05121aa87977997c8263b15cc40ea2eef916c3532bbfd0923bc362c52e484e'
  }
]

注意点

一意なIDの生成

GitHub Actions(SHA2)に頼っていますが、一意でかつ filesystem friendly ならなんでもよいです。ただしbase64やmd5はGNUとBSDでコマンド名やオプションが異なるため注意が必要です。

e.g. base64 を使うなら xargs | tr -d '[:space:]' といった後処理が必要です。GNU base64はオプションで可変な値を使って一定の文字数ごとに折り返しますが、サンプル中の set-output では1行目しか outputs 判定されません。

artifact のダウンロード

選択的にダウンロードしたい場合、actions/download-artifact では実現出来ません。fork して改造するなどして対応しましょう。

常に決まった 1Job だけでよい場合

他にやり方があると思いますが、少なくとも言えることは matrix をいじって upload/download する artifact を制限した方がどう考えても楽です。

# jobs
matrix-job
  strategy:
    matrix:
      arg1: ['a', ...]
      arg2: ['x', ...]
      include:
        - arg1: 'a'
          arg2: 'x'
          capture: true
  steps:
    - if: matrix.capture == 'true'
      uses: actions/upload-artifact@v2
      with:
        name: foo
after-matrix-job:
  steps:
    - uses: actions/dowload-artifact@v2
      with:
        name: foo

他の手法

記事の執筆時点(2021/08/21)での仕様です。将来変わる可能性があります。

artifact 以外で、ある単一のWorkflow内でデータを渡す方法は

  • needs + Job#outputs
  • actions/cache
  • 外部ストレージ e.g. S3, Git Repo

が真っ先に思いつきます。

needs + Job#outputs

利用出来ません。

  • needs.<matrix-job> は配列で返ってきません
  • Job#outputs の名称は動的に作成出来ません

actions/cache

Workflowのスコープと合わせるには key に run_id を使えば良く、試してはいませんが[2]今回のワークアラウンドに似た動きを実現することができるかもしれません。

ただしキャッシュサイズにはリポジトリごとに上限(5GB)があり、Matrix Jobsの出力をどうにかするためだけにビルドに利用するキャッシュを追い出す恐れを受容できるような利点が思い浮かばなかったため選択しませんでした。

https://github.com/actions/cache/blob/6bbe742add91b3db4abf110e742a967ec789958f/README.md#cache-limits

外部ストレージ

S3やGit Repoに保存する方法で、確実に実現可能です。setup/cleanupなどのハードルをクリアしていればこの方法で良いでしょう。

脚注
  1. Composite Runsに閉じ込めることも可能です。Job#outputs の渡し方などが人それぞれなので、今回は inline で記述しています。 ↩︎

  2. 面倒くさくて・・・ ↩︎

Discussion