STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

動的にCIの定義を生成する

この記事はSTORES Advent Calendar 2022の9日目の記事です。

こんにちは、@tomorrowkey です。
STORES CRMモバイルチームでSTORES ブランドアプリの開発しています。
STORES ブランドアプリとは、お商売をしているオーナーさんごとにオリジナルアプリを作り、お客様へのクーポンの配布やお店からのご案内をとおして、双方にとってよい関係性を築いていくためのサービスです。

STORES ブランドアプリ

開発している側の視点からみると、日々の開発であたらしい機能を作るたびに、アプリをビルド・リリースしているのですが、すべてを手作業でやっていてはスピード感を持ってサービス開発をすすめることはできません。
そこでCRMモバイルチームでは、BitriseをモバイルアプリのCIとして活用し、複数のアプリを自動的にビルド、リリースできる仕組みを構築しています。
CRMチームの面白いところは、サービス開発だけではなくモバイル開発基盤の改善も事業上同じくらい重要な点です。いかに手を動かさずに各オーナーさまへ向けたアプリを提供できるか、日々考えて改善を繰り返しています。

今日はそんなモバイル開発基盤の改善のなかでも一部をご紹介いたします。
なお、BitriseをCIサービスとして利用している事情からサンプルはすべてBitriseを前提としたものになっていますが、このテクニックは他のCIサービスでも応用が効くので、その他サービスを利用されている方もぜひ参考にしてみてください。

どうやってCIのジョブをスケールさせるのか

さきほどお話したとおり、CRMチームではCIを使い複数のアプリを並列ビルドしています。
近年のCIサービスでは並列ビルドをするための仕組みが用意されていますが、いずれもその定義を静的に用意しておくことでしか実現できません。
つまり愚直に運用するならば、あたらしいアプリの開発が増えたときに、CIの定義を書き換える必要があるのです。
いちいちCIの定義を更新していては面倒です。エンジニアなので技術で解決しましょう。

それってどんな定義なの

具体的な定義としては次のとおりです。

---
format_version: '11'
default_step_lib_source: https://github.com/bitrise-io/bitrise-steplib.git
project_type: android
trigger_map:
- push_branch: "*"
  workflow: build-alpha
- push_branch: "*"
  workflow: build-bravo
- push_branch: "*"
  workflow: build-charlie

workflows:
  build-alpha:
    envs:
    - FASTLANE_LANE: build-alpha
    steps:
    - activate-ssh-key@4: {}
    - git-clone@6: {}
    - bundler@0: {}
    - install-missing-android-tools@3: {}
    - fastlane@3:
        inputs:
        - lane: "$FASTLANE_LANE"
  build-bravo:
    envs:
    - FASTLANE_LANE: build-bravo
    steps:
    - activate-ssh-key@4: {}
    - git-clone@6: {}
    - bundler@0: {}
    - install-missing-android-tools@3: {}
    - fastlane@3:
        inputs:
        - lane: "$FASTLANE_LANE"
  build-charlie:
    envs:
    - FASTLANE_LANE: build-charlie
    steps:
    - activate-ssh-key@4: {}
    - git-clone@6: {}
    - bundler@0: {}
    - install-missing-android-tools@3: {}
    - fastlane@3:
        inputs:
        - lane: "$FASTLANE_LANE"

例では任意のブランチにコミットが追加されたときに alpha, bravo, charlie という3つのアプリをfastlaneを使ってビルドするといった定義になっています。 *1 workflow のなかでタスクを定義していくのですが、それぞれのタスクのほとんどは繰り返しで、環境変数を変えてfastlaneをタスクを呼び出しているだけです。*2. 繰り返しがあって冗長ですね。
Bitriseでは、プロジェクトルートに bitrise.yml ファイルを置くことで、CIの定義と認識されます。

この冗長な定義を改善していきます。

YAMLの記法を使って定義を減らす

まずはYAMLそのものの記法で繰り返しを減らすことができないか検討してみます。
YAMLには anchor と alias という記法があり、定義を参照できます。
この2つの記法を使って設定をリファクタリングします。

---
format_version: '11'
default_step_lib_source: https://github.com/bitrise-io/bitrise-steplib.git
project_type: android
trigger_map:
- push_branch: "*"
  workflow: build-alpha
- push_branch: "*"
  workflow: build-bravo
- push_branch: "*"
  workflow: build-charlie

workflows:
  build:
    steps: &build_steps
    - activate-ssh-key@4: {}
    - git-clone@6: {}
    - bundler@0: {}
    - install-missing-android-tools@3: {}
    - fastlane@3:
        inputs:
        - lane: "$FASTLANE_LANE"
  build-alpha:
    envs:
    - FASTLANE_LANE: build-alpha
    steps: *build_steps
  build-bravo:
    envs:
    - FASTLANE_LANE: build-bravo
    steps: *build_steps
  build-charlie:
    envs:
    - FASTLANE_LANE: build-charlie
    steps: *build_steps

それぞれの workflow環境変数が違うだけで steps の内容は同じなので、定義を1つにまとめてそれぞれから参照する形に変更しました。 build ワークフローの stepsbuild_steps という名前で anchor をつけ、それぞれのアプリビルドのワークフローで alias により再利用しています。
これでさきほどのYAMLファイルと同義です。だいぶスッキリさせることができました。

定義を動的に生成したい

alpha, bravo, charlie の配列があって、それをもとに定義できればもっとスッキリさせることができそうですが、繰り返しによって定義できないでしょうか。
残念ながら、YAML自身に繰り返しを表現する記法はないですし、CI側にもそう解釈できるような機能もありません。 *3
しかし、発想を変えれば、同等のことを実現できます。

bitrise.yml を常に最新に維持しよう

YAML単体で動的な定義を生成できないのであれば、スクリプト側でどうにかするしかありません。
Fastlaneで動的に bitrise.yml ファイルを生成します。
すべてをコードで書くのも面倒なので、erbを使ってできるだけYAMLの書き味をそのままに生かしていきます。*4

require "erb"
require "yaml"

def app_names
  [ 'alpha',
    'bravo',
    'charlie',
  ]
end

lane :'update-bitrise-yml' do
  BITRISE_YAML_TEMPLATE = <<~YAML
  ---
  format_version: '11'
  default_step_lib_source: https://github.com/bitrise-io/bitrise-steplib.git
  project_type: android
  trigger_map:
  <% app_names.each do |app_name| %>
  - push_branch: "*"
    workflow: build-<%= app_name %>
  <% end %>

  workflows:
    build:
      steps: &build_steps
      - activate-ssh-key@4: {}
      - git-clone@6: {}
      - bundler@0: {}
      - install-missing-android-tools@3: {}
      - fastlane@3:
          inputs:
          - lane: "$FASTLANE_LANE"
    
    <% app_names.each do |app_name| %>
      build-<%= app_name %>:
        envs:
        - FASTLANE_LANE: build-<%= app_name %>
        steps: *build_steps
    <% end %>
  YAML

  bitrise_yaml = YAML.load(ERB.new(BITRISE_YAML_TEMPLATE, aliases: true).result(binding))  
  File.write("bitrise.yml", bitrise_yaml.to_yaml)
end

app_names の配列内の要素によって bitrise.yml の内容を書き換えることができました。
あとは、app_names にあたらしいビルド対象が追加されるたびに update-bitrise-yml lane を忘れずに実行し、bitrise.yml を生成しつづけるだけです。

ちょっとまってください。こういう「忘れずに実行するべき」というものこそ、CIが力を発揮するところではないでしょうか。
要はCIで bitrise.yml が最新かどうかチェックすればいいのです。

require "erb"
require "yaml"

def app_names
  [ 'alpha',
    'bravo',
    'charlie',
  ]
end

lane :'update-bitrise-yml' do
  BITRISE_YAML_TEMPLATE = <<~YAML
  ---
  format_version: '11'
  default_step_lib_source: https://github.com/bitrise-io/bitrise-steplib.git
  project_type: android
  trigger_map:
  <% app_names.each do |app_name| %>
  - push_branch: "*"
    workflow: build-<%= app_name %>
  <% end %>

  workflows:
    build:
      steps: &build_steps
      - activate-ssh-key@4: {}
      - git-clone@6: {}
      - bundler@0: {}
      - fastlane@3:
          inputs:
          - lane: update-bitrise-yml
      - script@1:
          inputs:
          - content: |-
              #!/usr/bin/env bash
              set -eo pipefail
      
              diff_of_yaml="$(git diff bitrise.yml 2>/dev/null)"
              if [ ! -z "${diff_of_yaml}" ] ; then
                echo "bitrise.yml is not updated:" 1>&2
                echo "${diff_of_yaml}" 1>&2
                exit 1
              fi
      
              echo "bitrise.yml is the latest"
      - install-missing-android-tools@3: {}
      - fastlane@3:
          inputs:
          - lane: "$FASTLANE_LANE"
    
    <% app_names.each do |app_name| %>
      build-<%= app_name %>:
        envs:
        - FASTLANE_LANE: build-<%= app_name %>
        steps: *build_steps
    <% end %>
  YAML

  bitrise_yaml = YAML.load(ERB.new(BITRISE_YAML_TEMPLATE, aliases: true).result(binding))  
  File.write("bitrise.yml", bitrise_yaml.to_yaml)
end

なんだか間違い探しみたいになってきましたが、アプリをビルドするステップの前に update-bitrise-yml lane を実行して、Gitにコミットされている内容との差分があれば、それは最新の生成された bitrise.yml ではないので、ワークフローを中止するようになっています。
このステップのおかげで bitrise.yml は常に最新の動的に生成されたものが保証されるので、少ない変更でビルドするアプリを追加していくことができました。 *5

さいごに

アプリの数が増えていくこと、それを如何にCIにフィットさせるか、という題材でテクニックを紹介しました。
この内容は、スクリプトを使ってCI定義を動的に生成するといった内容なので、繰り返しに使えるだけではなく、他の動的な要素によってCIの定義を作ることができるということです。
この世のすべての開発に利用できるものだとは思いませんが、頻繁にCIの定義を変更しなくてはならないプロダクトがありましたら、このテクニックが力を発揮するのではないかと思います。ぜひご活用ください。

STORES CRMモバイルチームでは、サービス開発だけでなく開発基盤の改善も大好物だというエンジニアを募集しています。 誌面には書くことができない話もできるかと思いますので、まずはTwitterにてご連絡 お待ちしています 😉
まずは気軽にお話しましょう〜。

*1:タスクをまとめるためにもPipelineを使ったほうがよいですが、例をシンプルにするためにWorkflowだけで並列ビルドしています。

*2:例をシンプルにするためにキャッシュの設定は抜いています。

*3:「fastlaneのなかで順番にビルドすればいいじゃない!」という意見もでてきそうですが、それでは並列ビルドができません。

*4:実際にはYAMLファイルは別ファイルにして読み込むようにすると、もっとスッキリした見た目になります。

*5:さらにapp_names をディレクトリ構造やその他のデータから抽出できるようするなど、改善点はまだまだあります。