Reusable Workflowsを用いたCIの実行制御

# はじめに

本記事は[QualiArts Advent Calender 2024](https://qiita.com/advent-calendar/2024/qualiarts)の22日目の記事になります。

バックエンドエンジニアの水村です。
今回は、私が開発に関わっているプロジェクトで使用しているGitHub ActionsにReusable Workflowsを導入した事例を記事にさせていただきました。

# 概要

私が開発に関わっているプロジェクトでは、lintやtestをGitHub ActionsのCIによって実行しています。
これによって、コードの記法統一や処理の正しさを担保していますが、これらが失敗した時に迅速に気づけるようSlackに通知を送りたいという動機がありました。
特に、実装した機能をプルリクエストにする時には、開発者がそのブランチをマージする際にCIが通ってないことは気づけますが、開発ブランチへマージした時にlintやtestが失敗した時には気付きづらいので、この通知を導入することでマージ後のCI状態を監視しやすくなります。

また、本プロジェクトでは、複数の開発ブランチを用意して並行に開発を進めている時、下のバージョンの開発ブランチに差分が発生したら、上のブランチにも差分を反映するワークフローを使用していますが、lintやtestが失敗した状態で反映しにいってしまい、上のブランチにも波及してしまう問題があったため、これを防止するために、lintとtestが両方成功したら、上のバージョンのブランチに差分を反映する制御を加えたいという動機もありました。

つまり、以下の要件を達成したいという動機で、今回Reusable Workflowsを導入しました。
- lintやtestがそれぞれ失敗したら、Slackに失敗通知を送るワークフローを実行したい
- lintやtestが両方成功したら、上のバージョンのブランチに差分を反映するワークフローを実行したい

# Reusable Workflowsとは

[Reusable Workflows](https://docs.github.com/ja/actions/sharing-automations/reusing-workflows)とは、公式ドキュメントの日本語でそのまま「再利用可能なワークフロー」という訳され方をされています。その名の通り、ワークフローをモジュール化し、別のワークフローから呼び出し可能にすることです。

例えば、`.github/workflows/` に、`main.yml` `job1.yml` `job2.yml` が存在し、以下のように記述されているとします。

```yaml
# .github/workflows/main.yml
on:
  pull_request:
    branches:
      - '**'

jobs:
  job1:
    uses: ./.github/workflows/job1.yml
  job2:
    uses: ./.github/workflows/job2.yml
```

```yaml
# .github/workflows/job1.yml
on:
  workflow_call:

jobs:
  job1:
    # 処理
```

```yaml
# .github/workflows/job2.yml
on:
  workflow_call:

jobs:
  job2:
    # 処理
```

この処理では、すべてのブランチでプルリクエスト作成などのアクションがあった時に、`main.yml` が実行され、この中で呼び出している`job1.yml`と`job2.yml`のワークフローが実行されます。
それぞれのファイルには`workflow_call`というトリガーが設定されており、これを設定されているワークフローはReusable Workflowsとして認識されます。

## 実際の例

基本的は記法を示したところで、早速動機に沿った内容のワークフローを記載してみます

```yaml
# .github/workflows/main.yml
on:
  pull_request:
    branches:
      - '**'
    push:
    branches:
      - 'develop/*'
      - 'main'

jobs:
  linter:
    uses: ./.github/workflows/linter.yml
  
  tester:
    uses: ./.github/workflows/tester.yml
  
  merge:
    needs: [ linter, tester ]
    if: ${{ github.event_name == 'push' }}
    permissions:
      contents: write
    uses: ./.github/workflows/merge.yml
    secrets:
      TOKEN: ${{ secrets.GITHUB_TOKEN }}
  
  linter-fail:
    needs: [ linter ]
    if: ${{ failure() }}
    uses: ./.github/workflows/slack.yml
    secrets:
      SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
    with:
      job_name: 'Linter'
  
  tester-fail:
    needs: [ tester ]
    if: ${{ failure() }}
    uses: ./.github/workflows/slack.yml
    secrets:
      SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
    with:
      job_name: 'Tester'
```

```yaml
# .github/workflows/slack.yml

on:
  workflow_call:
    secrets:
      SLACK_TOKEN:
        required: true
    inputs:
      job_name:
        required: true
        type: string

jobs:
  slack:
    # 処理
```

条件の細かい部分や権限・渡すsecretsなどは一部省略しています。
一気に情報量が増えましたが、上から解説していきます。

### トリガー

```yaml
on:
  pull_request:
    branches:
      - '**'
    push:
    branches:
      - 'develop/*'
      - 'main'
```

まずこの部分でActionsが発火する条件を記載しています。
今回は、lintとtestはあらゆるプルリクエストで実行されて欲しいが、差分を反映するワークフローは開発ブランチやメインブランチへのpushのみで実行して欲しいので、上記の条件をつけて、ワークフローの呼び出し部分でそれぞれ制御します。

### Linter/Tester

```yaml
  linter:
    uses: ./.github/workflows/linter.yml
  
  tester:
    uses: ./.github/workflows/tester.yml
```

それぞれlint/testのワークフローを実行する部分です。
`needs`によって実行制御を行っていないので、このワークフローはそれぞれ並行で実行されます。

### 差分反映

```yaml
  merge:
    needs: [ linter, tester ]
    if: ${{ github.event_name == 'push' }}
    permissions:
      contents: write
    uses: ./.github/workflows/merge.yml
    secrets:
      TOKEN: ${{ secrets.GITHUB_TOKEN }}
```

まず、注目する部分は`needs`です。
これは、どのjobの実行後に実行されるかという制御を行うための部分で、この記法では「linterとtesterが両方終わったら」を意味します。orではなくandであることが注目ポイントです。

また、上述の通り差分を反映するワークフローは開発ブランチやメインブランチへのpushのみで実行して欲しいので、pushイベントである場合のみ実行されます。

差分を反映するためには、他のブランチへの書き込みの権限が必要なので、別途権限を渡しています。Reusable Workflowの権限は呼び出し元に依存するので、呼び出し先で権限を設定しただけでは正確に設定されません。

また、Reusable Workflowは呼び出し先でsecretsコンテキストを呼び出せないので、呼び出し元で渡してあげる必要があります。
本当は`GITHUB_TOKEN`という名前で渡したいのですが、予約語のため仕方なく`TOKEN`で渡しています。

### Linter/Tester失敗通知

```yaml
  linter-fail:
    needs: [ linter ]
    if: ${{ failure() }}
    uses: ./.github/workflows/slack.yml
    secrets:
      SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
    with:
      job_name: 'Linter'
  
  tester-fail:
    needs: [ tester ]
    if: ${{ failure() }}
    uses: ./.github/workflows/slack.yml
    secrets:
      SLACK_TOKEN: ${{ secrets.SLACK_TOKEN }}
    with:
      job_name: 'Tester'
```

```yaml
# .github/workflows/slack.yml

on:
  workflow_call:
    secrets:
      SLACK_TOKEN:
        required: true
    inputs:
      job_name:
        required: true
        type: string
```

lintとtestがそれぞれ失敗した時に、Slackに通知を送るワークフローを呼び出している部分です。
どれが失敗したか通知上でわかるように、呼び出すワークフローは共通ですが、`job_name`で何が失敗したのかを渡してあげています。

また、先ほどの差分反映と同じように、secretsやwithで値を渡していますが、呼び出し先では`workflow_call`以下で設定することで、必須の値かどうかや、型などを指定して受け取ることができます。

### 実行結果

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3635269/e786f162-30af-00b7-dcbc-469bf0716b04.png)

最終的に、成功した場合は以上のようになります。
実際の運用とjob名などが若干違うので、名前のみ変更していますが、linter/testerが両方成功した時に差分反映も実行され、逆に失敗していないのでSlack通知のワークフローは実行されていないことがわかります。

# 終わりに

Reusable Workflowsはワークフローの使い回しや、両方成功したらなどの実行制御を行うために導入しました。
また、GitHub ActionsのGUI上でどのように実行されているのか視覚的にわかりやすくなるため、この記事を読んでくださった方も導入を検討してみてはいかがでしょうか。