GitHub Actions ワークフロー実装の小技7選

meviy形状処理チームの佐藤智哉です。 形状認識アルゴリズムを自動テストするCI構築を担当しています。 その過程で得たGitHub Actionsのtipsの紹介です。

1. まずworkflow_dispatchから実装する

CIワークフローの構築を始める際、最初にworkflow_dispatch(手動トリガ)から実装することをおすすめします。 その理由は主に以下の3点です。

  1. ワークフローの基本動作から確認できる
    まずはワークフローを立ち上げるところから始まります。ランナーの起動、コードのチェックアウト、AWS環境への接続、そしてビルド・テストの実行など。試行錯誤には手動トリガが欠かせません。

  2. 必要なパラメータを整理できる
    workflow_dispatchinputs:キーを使ってパラメータを受け取ることができます。 これによりブランチ名や環境名・テストデータなどが指定でき、同じワークフローを様々な条件で使い回せるようになります。 またパラメータの整理は自動テストを賢く運営・拡張するための要素解明にも重要です。

  3. 今後の拡張に柔軟に対応できる
    手動トリガにより動作確立したワークフローは、後からon: pushなどの自動トリガに切り替えたり、workflow_callを使って子ワークフロー化したりと、用途に応じた拡張が可能です。 2のパラメータ整理と併せることで、後に夜間実行やテスト並列化などが実現できます。

下記サンプルコードは1~3.の要素を詰め込んだ一番最初のワークフローです。

name: Hello workflow
on:
  # 起動方法は手動トリガを指定
  workflow_dispatch:
    inputs:
      # 適当な入力パラメータを定義
      greeting:
        type: string

jobs:
  hello_job:
    name: hello job
    # AWS CodeBuild による Self-hosted runner の指定はこんな感じ
    runs-on: codebuild-testmachine-${{ github.run_id }}-${{ github.run_attempt }}
    # GHA が管理する TO 時間。CodeBuild 側にも別にあるので要確認
    timeout-minutes: 30
    # GitHub の操作や AWS credentials に必要
    permissions:
      contents: read
      id-token: write

    steps:
      - name: Repository checkout
        uses: actions/checkout@v4
        with:
          token: ${{ secrets.PAT }}
          submodules: recursive

      - name: Hello greeting
        run: echo "Hello ${{ inputs.greeting }}!"

こんなシンプルなワークフローも、いずれはリッチでスマートな自動テストの基礎部品になれます。 まずは手動で確実に動かし、後から少しずつ拡張していきましょう。

2. コンテキストを利用して適切なrun-name:をつける

ワークフローにはname:およびrun-name:が指定できます。

  • name:はUI一覧上のワークフロー名として機能
  • run-name:は実行されたワークフローの名前
    • run-name:が未指定の場合、実行されたワークフローの名前はon: pushであればコミットコメントを、on: pull_requestであればPRタイトルを、それ以外ではname:を参照

workflow_dispatchでの起動時にはname:が参照されるため、何を実行したワークフローなのか一覧から読み取ることができません。 そこでrun-name:の出番です。

run-name:の指定にはgithubおよびinputsコンテキストが利用可能です。 すなわち、どのブランチ・どの条件でワークフロー実行したのかは、workflow_dispatchが作れていれば簡単に表現できます。

BEFORE:

name: Regression test

BEFORE

AFTER:

name: Regression test
run-name: (${{ inputs.dataset }}) ${{ github.ref_name }} vs ${{ inputs.benchmark_ref }}

AFTER

run-name:をたった一行加えるだけで、何のテストを実行したのか一目で読み取れるようになりました。 ワークフローをチームメンバーに公開する際には、ぜひ分かりやすいrun-name:を指定しましょう。

3. continue-on-error:if:キーを用いてステップの中断・継続を柔軟に制御する

テストワークフローをCI化した場合、テストコマンドはテスト結果をexit 0/1で伝えることがあります。 ワークフローのデフォルトはexit 1の検出時に停止してしまうため、テスト失敗後でもステップ継続するにはエラー制御が必要になります。

    steps:
      - name: Error step
        run: exit 1

      - name: Unreachable step
        run: echo "直前のステップがエラー終了するためこのステップは実行されません"

そこでcontinue-on-error: trueの登場です。 上記設定をステップに与えることで、エラーを検出しつつ次ステップへの処理継続が可能です。 他にもステップにはif:キーを与えることができ、envinputs, stepsコンテキストを用いてステップ実行の制御ができます。

下記サンプルはテストコマンド実行後、終了ステータスを次ステップに表示させるワークフローです。

name: Regression test
run-name: ${{ github.ref_name }} vs ${{ inputs.benchmark }}
on:
  workflow_dispatch:
    inputs:
      benchmark:
        description: "回帰テストのベンチマークブランチ"
        required: true
        type: string
        default: "develop"

jobs:
  regression_test:
    env:
      DEV: ${{ github.ref_name }}
      BENCHMARK: ${{ inputs.benchmark }}
    steps:
      - name: Run test
        id: run_test
        run: my_test "${DEV}" "${BENCHMARK}"
        # my_test に失敗してエラー終了しても後続ステップを実行したい!
        continue-on-error: true

      - name: Report test
        # step.<step_id>.outcome は success, failure, canceled, skipped の4状態がある
        run: echo "my_test is ${{ steps.run_test.outcome }}!"

他にもS3上に資材を発見したためビルドを省略する、キャッシュなしでテスト実行した場合にベンチマークデータをアップロードする、といった高速化・省力化にも便利ですので、ぜひ利用してみて下さい。

シェルスクリプトの力業による実現も可能ですが、実装やステップ間依存関係の複雑化が懸念されます。ぜひワークフロー構文を活用しましょう。

4. データ共有にaction/upload-artifactを利用する

自動テストは、テストの成否を得るだけでなく、エラーケースやエビデンスの収集も重要な仕事です。 検出された非期待値やデグレの報告だけでは済まさずに調査用ワークファイルを手早く提供する機能も、テスト自動化を成功させる鍵となります。

ファイルの共有方法は色々ありますが、まずはGHA Artifactsを用いるactions/upload-artifactを試してみて下さい。 GitHubのプラン次第ではアップロード上限容量が厳しいかもしれませんが、実装方法とUIからのアクセス性に優れています。

jobs:
  regression_test:
    steps:
      - name: Run test
        id: run_test
        run: |
          my_test run
          echo "work_file_path=$(pwd)/result/" >> $GITHUB_OUTPUT

      - name: Upload work file to GHA artifact
        id: upload_work_file
        uses: actions/upload-artifact@v4
        with:
            name: ${{ github.ref_name }}_work_file
            # path はディレクトリも指定可能。どちらの場合でも ZIP 圧縮される
            path: ${{ steps.run_test.outputs.work_file_path }}
            retention-days: 1
        continue-on-error: true

CIの構築や提供速度に貢献してくれること間違いなしなので、ぜひ利用してみて下さい。

5. テストレポートはさくっとSummaryに出力しよう

先のデータ共有と併せて、稼働を始めたCIにはスマートなレポート機能を追加したくなりますよね!

ですがCI/CD通知はコミュニケーションアプリのノイズと化していませんか? 発展途上の自動テストなら、作り込む前にGHA UIへのSummary出力から始めることをお勧めします。

方法は簡単、$GITHUB_STEP_SUMMARYに追記リダイレクトするだけです。

name: Regression test
run-name: ${{ github.ref_name }} vs ${{ inputs.benchmark }}
on:
  workflow_dispatch:
    inputs:
      benchmark:
        description: "回帰テストのベンチマークブランチ"
        required: true
        type: string
        default: "develop"

jobs:
  regression_test:
    env:
      DEV: ${{ github.ref_name }}
      BENCHMARK: ${{ inputs.benchmark }}
    steps:
      - name: Run test
        id: run_test
        run: my_test "${DEV}" "${BENCHMARK}"
        continue-on-error: true

      - name: Report test
        env:
          RESULT: ${{ steps.run_test.outcome }}
        run: |
          echo "### Test Report" >> $GITHUB_STEP_SUMMARY
          echo "| Branch | Benchmark | Result |" >> $GITHUB_STEP_SUMMARY
          echo "| --- | --- | --- |" >> $GITHUB_STEP_SUMMARY
          echo "| ${DEV} | ${BENCHMARK} | ${RESULT} |" >> $GITHUB_STEP_SUMMARY

Markdown形式をサポートしているため、先のワークフロー制御と併せてテスト結果や実行パラメータを乗せるだけでも十分なサマリが作れます。

6. $GITHUB_ENVを使うか$GITHUB_OUTPUTを使うか

ジョブやステップ間のデータ受け渡しには$GITHUB_ENVおよび$GITHUB_OUTPUTが利用できます。使い分けの指針は下記の通り。

  • $GITHUB_ENV: 同一ジョブ内での環境変数として利用
  • $GITHUB_OUTPUT: ジョブ/ステップ間での値受け渡し、構造化されたデータ
name: Env or output
jobs:
  env_or_output:
    steps:
      - name: Export
        id: export
        run: |
          echo "AS_ENV=val" >> $GITHUB_ENV
          echo "as_output=val" >> $GITHUB_OUTPUT

      - name: Import
        run: |
          echo "${AS_ENV}"                              # 環境変数として参照
          echo "${{ steps.export.outputs.as_output }}"  # outputs として参照

後続ステップに値を渡す程度であれば$GITHUB_ENVにも担えますが、長く利用して依存関係を作りこむ事はおすすめしません。

ワークフロー開発初期では$GITHUB_ENVによる平易な実装で事足りましたが、機能や制御が増えるにつれて状態管理や保守性の悪化を経験しており、$GITHUB_OUTPUTへの移行リファクタを検討中です。

ワークフローのサイズに応じた保守性を維持するべく、リファクタしながらコーディングスタイルを適用するのが良いでしょう。 開発チーム内でコーディングスタイルが定義済みならば、まずはそちらが優先です。

7. gh コマンドを活用して動的なworkflow_callを実現しよう

on: pushでの単体テストworkflow_dispatchによる機能テストが充実したとき、ようやく夢の夜間テストの実装に進めます。 ワークフローにcron:トリガを仕込み、翌日に自動でテスト結果を得る仕組みです。

しかしcron:トリガはデフォルトブランチのみで動作するため、他のブランチをワークフロー構文のみで組み込むことができません。 workflow_callによるワークフロー再利用時、strategy: matrixを用いた複数パラメータでのワークフロー並列起動は可能ですが、実行対象ブランチのリスト作成など、動的データを構文中に展開することが叶いません。

そこで最後の鍵となるのがghコマンドです。 ghコマンドはGitHub APIを用いてリポジトリ情報を取得したり、PR作成やワークフロー起動などのGitHub UI操作をコマンドラインから実行できます。

ghコマンドとワークフロー構文の制御を活用して、下記のような仕組みが実現できます。

  • 親ワークフローがcron:トリガにより夜間起動
    • ghコマンドを用いて、ブランチのリストを取得
    • ghコマンドを用いて、取得したブランチを指定して中間ワークフローを順次起動
  • ghコマンドからトリガされた中間ワークフローが起動
    • workflow_callを用いて、strategy: matrixに応じた子ワークフローを並列起動
  • workflow_callされた子ワークフローが起動
    • inputsに従いワークフローを実行

下記のサンプルコードは、夜中3時に起動し、全てのPRブランチに対して、3データセットでの回帰テストを実施するワークフローです。

name: Nightly regression test
on:
  schedule:
    cron: '0 18 * * *'  # JST 3:00 (毎日)
  workflow_dispatch:
    inputs:
      benchmark:
        type: string
        required: false

jobs:
  # cron (inputs なし) から起動される親ワークフロー
  dispatch_workflow:
    name: Dispatch workflow
    # cron トリガは inputs を持たずに起動するためここでチェック
    if: ${{ inputs.benchmark == '' || inputs.benchmark == null }}
    steps:
      - name: Run workflows
        run: |
          # PR が作成されているブランチのリストを取得
          pr_branches=$(gh pr list --json headRefName --jq '.[].headRefName')
          for dev in ${pr_branches}; do
            # PR 情報から dev ブランチのマージ先を取得
            benchmark=$(gh pr view "${dev}" --json baseRefName --jq '.baseRefName')
            # dev vs benchmark ブランチで比較する回帰テストワークフローを起動
            gh workflow run nightly_regression_test.yml \
              --ref "${dev}" \
              --field "benchmark=${benchmark}"
          done
        continue-on-error: true

  # gh コマンド (inputs あり) から起動される中間ワークフロー
  call_tests:
    name: Call tests
    # workflow_call で起動される子ワークフロー
    uses: ./.github/workflows/regression_test.yml
    # gh コマンドからは --filed オプションにより inputs を与えて起動するためここでチェック
    if: ${{ inputs.benchmark != '' && inputs.benchmark != null }}
    strategy:
      # matrix に従い、workflow_call で呼び出す子ワークフローを自動で並列化
      matrix:
        datasets: ["normal", "edge_case", "high_load"]
      # 並列起動した子ワークフローのどれかが失敗しても、ジョブ全体を停止させない設定
      fail-fast: false
    with:
      benchmark: ${{ inputs.benchmark }}
      dataset: ${{ matrix.datasets }}
    # workflow_call では secrets を渡す必要あり
    secrets:
      token: ${{ secrets.token }}

最小の仕組みで例示しましたが、「テスト対象はPRラベルで絞る」「developブランチは最新のreleaseブランチと比較する」「中間ワークフローが完了したらPRにコメントする」といった機能もghコマンドとの組み合わせで実現可能です。

他にもGHAには自動コミットやPRマージなどのCI/CD機能があり、これにghコマンドを組み合わせることでより強力なワークフローの構築が可能となるでしょう。

最後に

私自身、meviy開発の中でPythonによる結合実行スクリプトやJenkinsによる自動ランナーなどを試してきましたが、現時点でのCI/CDアーキテクチャであるGHA + Amazon CodeBuildは非常に強力な手法であると感じます。 他にも素晴らしいツールやプラクティスが世の中にはたくさんあると思いますが、GitHubAWSを活用されている現場でCI/CDへの課題感が募っていましたら、ぜひこちらの方法からスタートしてみてはいかがでしょうか。

ご読了、ありがとうございました。

備考・参考書籍

GHA及びAWS CodeBuildによるCI環境構築記事

参考書