GitLab CI/CD の rules を部品化する

この記事は [GitLab Advent Calendar 2022](https://qiita.com/advent-calendar/2022/gitlab) の 11 日目の記事です。

# 3行で
- ジョブを 処理 と 起動条件 の2要素に分解して考える。
- 起動条件を実装する `rules` 部分だけを hidden ジョブとして外部化し、それを継承してジョブを作成する。
- hidden ジョブを定義した yaml ファイルを持つリポジトリを `include` する形式にすることで、部品として管理できるようになる。

# `rules` について
`.gitlab-ci.yml` には、パイプラインにジョブを含めるか否かを制御するための `rules` キーワード があります。リポジトリへのプッシュ時やタグ作成時などのタイミングで、パイプラインを作成する際に `rules` で指定した条件が評価され、パイプラインで実行するジョブが決まります。

:::note info
GitLab 11 系までは `only`、`except` キーワードを使ってジョブを制御していましたが、GitLab 12 系（12.3）以降では、`rules` の利用が推奨されています。
:::

`rules` 配下には、次のルールの配列を定義できます。
|ルール|説明|
|---|---|
|if|指定した条件式に一致するか？|
|changes|特定のファイルの変更があるか？|
|exists|特定のファイルがリポジトリに存在するか？|
|allow_failure|ジョブが追加された場合、パイプラインを止めずにジョブの失敗を許す。|
|variables|条件に合致した場合、ジョブに変数を追加する。|
|when|ジョブをどのタイミングで実行するか制御する。[^1]|

[^1]: デフォルトは `when: on_success` であり、前のステージのすべてのジョブについて、成功するか あるいは `allow_failure: true` の場合に ジョブが実行されます。

この中でも、使用頻度が高いのは `if` かと思います。特定のブランチへpushされた場合や、特定の名前のタグが作成された場合、特定の変数が設定された場合等にジョブを起動するように制御することができます。

[GitLab の公式ドキュメントにある `rules:if` の例](https://docs.gitlab.com/ee/ci/yaml/#rulesif) は次のとおりです。

```yml: rules:if の例
job:
  script: echo "Hello, Rules!"
  rules:
    - if: $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^feature/ && $CI_MERGE_REQUEST_TARGET_BRANCH_NAME != $CI_DEFAULT_BRANCH
      when: never
    - if: $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME =~ /^feature/
      when: manual
      allow_failure: true
    - if: $CI_MERGE_REQUEST_SOURCE_BRANCH_NAME
```

この `job` ジョブの条件は次のようになります。
- マージリクエストのマージ元ブランチ名が `feature` から始まり、マージ先ブランチがリポジトリのデフォルトブランチの場合は、ジョブを追加しない。
- マージリクエストのマージ元ブランチ名が `feature` から始まる場合は、手動起動のジョブを追加し、ジョブの失敗を許す。
- マージリクエストのマージ元ブランチ名がある場合（＝マージリクエストにより作成されたパイプラインの場合）は、ジョブを追加する。

このように、複数の条件式を組み合わせて、ジョブの起動条件を細かく指定することができます。

# ジョブの処理と起動条件を分離する

ジョブの起動条件は、開発プロセスの中でのブランチやマージリクエスト、タグの使われ方に密接に関連しています。したがって、システム全体の開発プロセスを定め、それに合わせて `rules` を実装していくことが多いと思います。つまり、一つのプロジェクト（≠ GitLab の Project）の中で複数のリポジトリを持つ場合にも、同じようなルールを使って CI/CD を構成していくことになります。

通常、GitLab ではリポジトリ単位の `.gitlab-ci.yml` でパイプラインを構成するため、複数のリポジトリを持つ場合はそれぞれ別の `.gitlab-ci.yml` が作られることになりますが、先に述べたとおり、ルールの部分については実装が重複しがちです。

そこで、ジョブの構成要素を `処理` と `起動条件` の2つに分離して考えてみます。例えば、**`feature` ブランチへのpush時に、テスト環境へデプロイするジョブ** は次の2つの要素に分離します。
- 処理：テスト環境へデプロイする
- 条件：feature ブランチへのpush時にジョブを起動する

処理の部分は、開発する言語やリポジトリの用途によって変わるものであり、カスタマイズが必要な部分です。一方で、条件の部分は他の処理の場合にも使い回しが利くものです。例えば、`feature` ブランチへのpush時にビルドするジョブ であれば、条件については全く同じものを利用できます。このように、処理と条件を分離することで、条件部分を部品的に使い回すことができ、メンテナンス性を高めることができます。

処理と条件を分離した場合の、ジョブの構成のイメージを図示するとこうなります。

<img width="500" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229637/d2aeda7d-4bad-3baf-b294-8705d5832117.png">

`.gitlab-ci.yml` は `extends` キーワード[^2]を使ってジョブを継承できるため、`rules` だけを定義した hidden ジョブ[^3] を用意し、処理を記載するジョブ側でそれを継承することで、上記のような構成を実現できます。

[^2]: https://docs.gitlab.com/ee/ci/yaml/#extends
[^3]: https://docs.gitlab.com/ee/ci/jobs/index.html#hide-jobs

継承を使った処理と条件の分離の例を次に示します。

```yml:.gitlab-ci.yml
# . から始まるジョブは hidden ジョブであり、ジョブとして評価されない
.rules-push-feature:
  rules:
    - if: "$CI_COMMIT_BRANCH =~ /^feature/"

stages:
  - build

job:
  stage: build
  # hidden ジョブを継承して rules 部分を取り込む
  extends:
    - .rules-push-feature
  script:
    - echo "job-feature"
```

# rules の部品化

さて、上のイメージ図を見ても分かるとおり、条件部分を複数のリポジトリ間で共有する必要があります。これは `include` キーワード[^4]を使って実現できます。

[^4]: https://docs.gitlab.com/ee/ci/yaml/#includeproject


まず、`rules` 用の hidden ジョブを実装した yaml ファイルを別のリポジトリに切り出します。そして、パイプラインを動かす側のリポジトリでは、その yaml ファイルを `include` で取り込みます。すると、`extends` で hidden ジョブを継承できるようになるので、`rules` の部分は 継承元をそのまま利用し、処理など必要な箇所のみを実装するようにします。
このように、`rules` を部品のように扱うことで、ジョブの起動条件だけをプロジェクト全体で一律に管理できるようになります。ブランチ名やタグ名のルールに修正が入る場合には部品を修正するだけで済むので、修正コストも小さくすることができます。

先ほどのイメージ図を少し修正し、わかりやすくしましょう。

<img width="800" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/229637/a3cf7a55-e152-111b-b73f-4d3e9d7d2a70.png">

`.gitlab-ci.yml` の構成例は以下のとおりです。

```yml:部品用リポジトリの rules.yml
.rules-push-feature:
  rules:
    - if: "$CI_COMMIT_BRANCH =~ /^feature/"
```

同じ GitLab インスタンス 内のプロジェクトから参照する場合は `include:project` を使うことで読み込むことができます。
```yml:パイプラインを動かすリポジトリの .gitlab-ci.yml
include:
  project: <some-group>/<project-name>
  file: rules.yml
  
stages:
  - build

job-feature:
  stage: build
  extends:
    - .rules-push-feature
  script:
    - echo "job-feature"
```

上記のような構成で、`rules` 以外の部分も部品化することができ、共通的な処理やvariableを一括で管理することができたりします。小規模なプロジェクトで小回りが効くケースでは、このような部品化のメリットは少ないかもしれませんが、中〜大規模なプロジェクトでは一度採用を検討してみてもいいかもしれません。

# まとめ
ジョブを 処理と起動条件に分離して考え、`rules` 部分を部品化する方法について説明しました。 

rules は少し癖のある書き方が必要なため、有識者がまとめて実装し、その部品を使うだけにしたほうが安心かと思います。[^5] 
本記事で紹介したやり方を参考に、皆さんのプロジェクトでも `include` と `extends` を活用してみてください。

[^5]: 例えば、`$CI_PIPELINE_SOURCE == "push"` という条件はブランチまたはタグがpushされたときにジョブが稼働します。ブランチだけを対象とする場合は `$CI_PIPELINE_SOURCE == "branch"` ではなく、`$CI_COMMIT_BRANCH` を使う必要があります。しかし、`$CI_COMMIT_BRANCH` は スケジュールビルドでも可動をするため、いわゆる「コミット」を契機として起動するわけではありません。厳密に「ブランチへのpush時だけ」にするには、複合条件を書く必要があります。