CircleCIの設定ファイルを分割する第三の方法

## 概要
業務で[CircleCI](https://circleci.com/)を使うことになったのだが、設定ファイルをいい感じに分割する方法が見つからなかったので自作したという話。前置きに興味がない人はYAML Bundlerの紹介まで読み飛ばしてOK。

## 問題提起
CircleCIの設定は`config.yml`に記述するのだが、モノレポで開発している場合は肥大化しがち。このような場合には[Dynamic Configuration](https://circleci.com/docs/using-dynamic-configuration/)という仕組みが使える。このDynamic Configurationというのは以下のような二段構えになっていて、

1. `config.yml`の記述に従い、第二段階用の設定ファイル（ファイル名は任意だがこの記事では`main.yml`）の生成・パラメータの受け渡しを行う
1. 第一段階で生成した設定ファイルとパラメータに従って必要な処理を実行

第二段階用の設定ファイルは任意の方法で生成してよいから、**好きなだけファイルを分割しておいてこの段階で統合すればよい**という寸法。ただし任意の方法といっても、以下の二つが定番パターンのようだ。それぞれのメリット・デメリットを挙げる。

### [ciercleci config pack](https://circleci.com/docs/how-to-use-the-circleci-local-cli/#packing-a-config) 
* メリット
    * circleci/circleci-cliなどのdockerイメージにインストール済みのコマンドなので、CIの中で使いやすい[^circleci-cli]
* デメリット
    * [FYAML](https://github.com/CircleCI-Public/fyaml)という規格に従ってファイルを分割するのだが、自由度は低め[^fyaml]

[^circleci-cli]: [circleci/circleci-cli orb](https://circleci.com/developer/orbs/orb/circleci/circleci-cli)を使うのが公式推奨の方法だと思う。
[^fyaml]: たとえば単一のリポジトリで複数のアプリケーションを開発しているときに、app1のcommands・jobs・workflowsをapp1.ymlというファイルで管理したい、という要望には応えられないと思われ。

### [yq](https://mikefarah.gitbook.io/yq/) 
* メリット
    * cimg/baseなどのdockerイメージにインストール済みのコマンドなので、CIの中で使いやすい
* デメリット
    * 自由度は高いがyqコマンド自体に習熟する必要がある

## YAML Bundlerの紹介
この記事では第三の選択肢として[Yaml Bundler](https://circleci.com/developer/orbs/orb/dr666m1/yaml-bundler)というorbを紹介する。これを使うとYAMLファイルの中で`!include`というタグを使い、**他のYAMLファイルの内容を取り込むことができる**。YAMLの書き方が分かる人ならすぐ使えるし、自由度もそれなりに高いはず。

### 使い方
go・pyというディレクトリでそれぞれアプリケーションを開発中のモノレポを想定する。YAML以外のコードも[ここ](https://github.com/dr666m1/recipe-circle-ci/tree/95ea9cbb7a4df8f41cdfaa42b129e6d295689509)で見られる。

```
.
├── .circleci
│  ├── config.yml
│  ├── main.yml
│  ├── go.yml
│  └── py.yml
├── go
│  ├── go.mod
│  ├── main.go
│  └── main_test.go
└── py
   ├── main.py
   └── test.py
```
`config.yml`はこんな感じ。yaml-bundler/bundleが`!include`タグを処理するcommand。

```yaml:.circleci/config.yml
version: 2.1
setup: true
orbs:
  yaml-bundler: dr666m1/yaml-bundler@0.0.5
  continuation: circleci/continuation@0.3.1
jobs:
  setup:
    executor: yaml-bundler/default
    steps:
      - checkout
      - yaml-bundler/bundle:  # !includeタグを処理して上書き
          filepath: .circleci/main.yml
      - continuation/continue:
          configuration_path: .circleci/main.yml
workflows:
  setup:
    jobs:
      - setup
```

`main.yml`はこんな感じ。filepathとjsonpath[^jsonpath-ng]を指定して他のYAMLファイルを読み込んでいる。
[^jsonpath-ng]: [jsonpath-ng](https://github.com/h2non/jsonpath-ng)というパッケージを利用しているので、詳しい説明はそちらを見るとよさげ。

```yaml:.circleci/main.yml
version: 2.1
jobs: !include
  - filepath: ./go.yml
    jsonpath: $.jobs
  - filepath: ./py.yml
    jsonpath: $.jobs
workflows: !include
  - filepath: ./go.yml
    jsonpath: $.workflows
  - filepath: ./py.yml
    jsonpath: $.workflows
```

取り込まれる側の`go.yml` `py.yml`は以下の通り。YAMLファイルとして有効な形式であれば、どんなファイルでも取り込み可能[^pyyaml]。

[^pyyaml]: 取り込みには[PyYAML](https://github.com/yaml/pyyaml)というパッケージを活用している。

```yaml:.circleci/go.yml
jobs:
  go-test:
    docker:
      - image: cimg/go:1.18
    steps:
      - checkout
      - run:
          working_directory: go
          command: go test
workflows:
  go-test:
    jobs:
      - go-test
```

```yaml:.circleci/py.yml
jobs:
  py-test:
    docker:
      - image: cimg/python:3.11
    steps:
      - checkout
      - run:
          command: python -m unittest ./py/test.py
workflows:
  py-test:
    jobs:
      - py-test
```

最終的に`!include`タグの処理が完了した`main.yml`はこんな感じ。

```yaml:.circleci/main.yml
version: 2.1
jobs:
  go-test:
    docker:
      - image: cimg/go:1.18
    steps:
      - checkout
      - run:
          working_directory: go
          command: go test
  py-test:
    docker:
      - image: cimg/python:3.11
    steps:
      - checkout
      - run:
          command: python -m unittest ./py/test.py
workflows:
  go-test:
    jobs:
      - go-test
  py-test:
    jobs:
      - py-test
```

基本的な使い方は以上！
差分があるディレクトリに応じてworkflowを実行する応用例もあった方がよい気がするから、[ここ](https://github.com/dr666m1/recipe-circle-ci/tree/db2dcb232d2f2e4a6a54d5b64471d7d73b93c08e)にコードを置いておく。

また、ここまでで触れられなかった`!include`タグの詳しい使い方も折り畳み部分に書いておく。

<details><summary>!includeタグの詳しい使い方</summary><div>


```yaml
# main.yml (before)
version: !include # filepath・jsonpathを指定する最も基本的な使い方
  filepath: ./constants.yml
  jsonpath: '$.version'
executors: !include ./executors.yml # ファイル全体を読み込む場合は一行で書ける
jobs:
  main-job:
    executor: my-executor
    # 配列で指定すると結果が統合される（配列同士だと配列になる）
    steps: !include
      - filepath: ./steps1.yml
      - filepath: ./steps2.yml
  sub-job:
    executor: my-executor
    steps:
      - run: echo 'this is only step of sub-job'
# 配列で指定すると結果が統合される（map同士だとmapになる）
workflows: !include
  - filepath: ./main-workflow.yml
  - filepath: ./sub-workflow.yml

---
# constants.yml
version: 2.1

---
# executors.yml
my-executor:
  docker:
    - image: cimg/base:current

---
# steps1.yml
- run: echo "this is first step of steps1.yml"
- run: echo "this is second step of steps1.yml"

---
# steps2.yml
- run: echo "this is only step of steps2.yml"

---
# main-workflow.yml
main-workflow:
  jobs:
    - main-job

---
# sub-workflow.yml
sub-workflow:
  jobs:
    - sub-job

---
# main.yml (after)
version: 2.1
jobs:
  main-job:
    executor: my-executor
    # 配列の配列ではなくフラットな配列になる
    steps:
      - run: echo "this is first step of steps1.yml"
      - run: echo "this is second step of steps1.yml"
      - run: echo "this is only step of steps2.yml"
  sub-job:
    executor: my-executor
    steps:
      - run: echo "this is only step of sub-job"
workflows:
  main-workflow:
    jobs:
      - main-job
  sub-workflow:
    jobs:
      - sub-job
```
</div></details>


## 最後に
最後まで読んだけど期待していたものと違った、という方は[こちら](https://bufferings.hatenablog.com/entry/2022/07/22/225718)も読んでみてください。
自分より先に素敵なorbを作っていた方の記事です。