特定のファイルが変更された時のみ Circle CI のジョブを実行する

Circle CI を使用していると、このジョブは特定のファイルが変更されて時だけ実行したいなと思う時がありませんか？ありますよね？そう、あるんですよ。

そこで、特定のファイルが変更された時のみ Circle CI のジョブを実行する方法「[Dinamic Configuration](https://circleci.com/docs/ja/dynamic-config/)」を紹介します。

## Dynamic Configration とは

プロジェクトで一つのコンフィグファイルを使用して CI の設定をするのではなく、特定のパイプラインパラメーターやファイルパスに応じて、設定ファイルを作成・実行する方法です。

詳細は[公式ドキュメント](https://circleci.com/docs/ja/dynamic-config/)を参照してください。

## Dynamic Configration の使用方法

主に以下の方法を使用して Dynamic Configration を使用することができます。

- [continuePipelin API](https://circleci.com/docs/api/v2/index.html#operation/continuePipeline) の呼びだし
- [circleci/continuation orb](https://circleci.com/developer/orbs/orb/circleci/continuation) の使用
- [circleci/path-filtering orb](https://circleci.com/developer/orbs/orb/circleci/path-filtering) の使用


continuePipelin API はリクエストボディに Circle CI のパイプライン設定および、パイプラインパラメーターをセットしてリクエストを送ることで、動的に Circle CI を実行することができる機能です。

<details>
<summary>continuePipelin API の簡単な例</summary>

```bash
continuation_key=<continuation key>

configration=<<-YAML
version: 2.1

parameters:
  test-param:
    type: string

jobs:
  print-test-param:
    steps:
      run: echo "<< parameters.test-param >>"

workflows:
  version: 2
  test-workflow:
    - print-test-param
YAML

parameters='{"test-param": "テストパラメーターです"}'

curl --request POST \
  --url https://circleci.com/api/v2/pipeline/continue \
  --header 'authorization: Basic REPLACE_BASIC_AUTH' \
  --header 'content-type: application/json' \
  --data '{"continuation-key":"$continuation_key","configuration":"$configration","parameters":"$parameters"'

# Circle CI で test-workflow が実行され、「テストパラメーターです」と出力される。
```

</details>

`circleci/continuation` orb は continuePipeline API の呼び出し処理を簡単にできるようにした Orb です。
また、`circleci/path-filtering` orb は `circleci/continuation` orb を特定のファイルに変更があった際に、そのことがわかるパイプラインパラメーターをセットすることで、ジョブの実行をコントロールすることを目的とした Orb です。

<details>
<summary>circleci/path-filtering orb の簡単な例</summary>

`circleci/continuation` orb と `circleci/path-filtering` orb の使用方法はほぼ同じです。
以下のような設定ファイルを作成することで、main ブランチと比較して `**/__test__/*.spec.js` にマッチするファイルが変更された時のみ JEST が実行されるようにできます。

```yaml:.circleci/config.yaml
version: 2.1

# Dynamic Configration の使用に必須
setup: true

orbs:
  path-filtering: circleci/path-filtering@1.0.0

workflows:
  always-run:
    jobs:
      - path-filtering/filter:
          name: check-updated-files
          mapping: |
            **/__test__/*.spec.js run-jest true
          # 変更の比較対象となるブランチ名
          base-revision: main
          config-path: .circleci/continue.yaml
```

```yaml:.circleci/continue.yaml
version: 2.1

parameters:
  run-jest:
    type: boolean
    default: false

jobs:
  run-jest:
    when: <<parameters.run-jest >>
    steps:
      - run: npm run jest
    
workflows:
  version: 2
  test:
    - run-jest
```

また、以下のようなコンフィグダイルにすることで一つのファイルで完結させることもできます。

```yaml:.circleci/config.yaml
version: 2.1

# Dynamic Configration の使用に必須
setup: true

orbs:
  path-filtering: circleci/path-filtering@1.0.0

parameters:
  continuation:
    type: boolean
    default: false
  run-jest:
    type: boolean
    defailt: false

jobs:
  run-jest:
    when: <<parameters.run-jest >>
    steps:
      - run: npm run jest

workflows:
  always-run:
    when:
      not: << parameters.continuation >>
    jobs:
      - path-filtering/filter:
          name: check-updated-files
          mapping: |
            **/__test__/*.spec.js run-jest true
          # 変更の比較対象となるブランチ名
          base-revision: main
          config-path: .circleci/continue.yaml
  continuation-run:
    when: << parameters.continuation >>
    jobs:
      - run-jest
```

</details>

### circleci/path-filtering orb の問題点

circleci/path-filtering orb はどのブランチと比較して、ファイルが変更されたかというのを動的に設定することができません。
プロジェクト毎にルートブランチを切って、そのブランチに PR を作成してマージしていくような方法をとっている場合には、せっかく特定のファイルが変更された時にのみジョブが実行されるようにできたのに、恩恵をほとんど受けることができません。

動的に基準となるブランチを変更するには、continuePipeline API を使用します。Orb を使用する場合とくらべメンテするコストが増えますがより柔軟に設定を行うことができるようになります。

まず、以下のようなコンフィグファイルを作成します。また、Circle CI のコンテキストに GitHub のアクセストークンを登録しておき、環境変数として値を参照できるようにします。

```yaml:.circleci/config.yaml
version: 2.1

# Dynamic Configration の使用に必須
setup: true

jobs:
  path-filtering:
    description: ベースブランチを決定し、パラメータを引き継いでジョブを継続するコマンド
    parameters:
      config-path:
        description: continuePipeline API で使用するコンフィグファイルのパス
        type: string
      mapping:
        description: ファイルとパラメータのマッピング設定
        type: string

    steps:
      - run:
          command: bash .circleci/scripts/path-filtering.sh
          environment:
            CONFIG_PATH: << parameters.config-path >>
            MAPPING: << parameters.mapping >>

workflows:
  version: 2
  continuation:
    jobs:
      - path-filtering:
      　　 context: global
          config-path: .circleci/continue.yaml
          mapping: |
            **/__test__/*.spec.js run-jest true
```

```yaml:.circleci/continue.yaml
version: 2.1

parameters:
  run-jest:
    type: boolean
    default: false

jobs:
  run-jest:
    when: <<parameters.run-jest >>
    steps:
      - run: npm run jest
    
workflows:
  version: 2
  test:
    - run-jest
```

次に、continuePipeline API にリクエスト処理を行う一連の処理を行うシェルスクリプトファイルを作成します。コンフィグファイルの job.steps.run に直接することも可能です。また、Python スクリプトファイルを Python コンテナ上で実行するといった方法も可能です。
（必要最低限の処理しか記述していないので適宜環境に併せて編集してください。）

```bash:path-filtering.sh
#!/bin/bash

set -euo pipefail

continue_parameters="{}"

get_pr_base_branch() {
  if [[ ! -v GITHUB_TOKEN ]]; then
    return
  fi

  pr_url="${1}"
  gh_repo_slug="$(awk -F/ '{ print $4 "/" $5; }' <<<"${pr_url}")"
  gh_pr_id="$(cut -d/ -f7 <<<"${pr_url}")"
  gh_api_endpoint="https://api.github.com/repos/${gh_repo_slug}/pulls/${gh_pr_id}"

  api_resp="$(curl -L -H "Authorization: Bearer ${GITHUB_TOKEN}" -H "Accept: application/vnd.github+json" -H "X-GitHub-Api-Version: 2022-11-28" "${gh_api_endpoint}" | tee /dev/stderr)"
  base_branch="$(jq -r .base.ref <<<"${api_resp}")"
}

examine_diffs_and_generate_params() {
  diffs=$(
    cat <<EOD
$(git diff --name-only "origin/${base_branch}")
$(git diff --name-only HEAD~1 || git ls-tree -r --name-only HEAD)
EOD
  )

  continue_parameters="$(jq --arg base_branch "$base_branch" '.["base-branch"] = $base_branch' <<<"${continue_parameters}")"

  while read -r cond; do
    read -r pattern param_name <<<"${cond}"

    if [[ -z "${param_name}" ]]; then
      continue
    fi

    if [[ "${FORCE_ALL}" == 'true' ]] || grep -E "^${pattern}\$" <<<"${diffs}"; then
      export param_name
      continue_parameters="$(jq '.[$ENV.param_name] = true' <<<"${continue_parameters}")"
      unset param_name
    fi
  done <<<"${PARAMETER_CONDITIONS}"
}

if [[ ! -v CIRCLE_CONTINUATION_KEY ]]; then
    exit
fi

if [[ -v CIRCLE_PULL_REQUEST ]]; then
    get_pr_base_branch "${CIRCLE_PULL_REQUEST}"
fi

git fetch origin "${base_branch}"

examine_diffs_and_generate_params

continue_body="$(jq \
    --rawfile config "${CONTINUE_CONFIG_PATH}" \
    -s '{
        "continuation-key": $ENV.CIRCLE_CONTINUATION_KEY,
        configuration: $config,
        parameters: .[0]
    }' <<<"${continue_parameters}" | tee /dev/stderr)"

[[ $(curl \
    -X POST \
    -H "Content-Type: application/json" \
    --data-binary "${continue_body}" \
    -w '\n%{http_code}\n' \
    "https://circleci.com/api/v2/pipeline/continue" |& tee /dev/stderr | tail -n 1) == "200" ]]
```

## まとめ

Orb または API を使用することで特定のファイルが変更された時のみジョブを実行する方法を紹介しました。
Orb を使用する場合は、continuePipeline API がラップされており、メンテナンスは Ciecle CI がしてくれるので楽ですが、柔軟性に欠けます。continuePipeline API を直接利用することで特定のファイルの変更を検知してパイプラインパラメーターを変更するだけでなくさまざまな状況に併せて実行するコンフィグファイルを切り替える、パイプラインパラメーターの値を変更するといったことができますが、メンテナンスコストが増えてしまします。
ここはトレードオフなので環境に応じて使い分ける必要があります。