Treasure Data では4月に新しいサービス Treasure AI Studio を発表しました*1。この製品の開発は迅速に行う必要があり、monorepo 構成にして FE 側と BE 側を同一リポジトリにおくことで AI Agent がシームレスに開発できることを狙いました。この構成は AI Agent の面ではよかったものの、CI/CD については課題に直面しました。対応策を考えて現在ではある程度の解決を見ているのでここに詳細を記そうと思いますが、改善アイデアなどありましたら教えてください。
問題定義
前提
- monorepo (Rails + 複数 Go の web API 複数 + FE など)
- かなり簡略化していますが詳細は sada の発表をお待ちください https://treasure-data.connpass.com/event/391551/
- 簡単のため、component は A と B があって、
test-A,deploy-$stage-A,test-B,deploy-$stage-Bがあるとしましょう。$stage は development, staging, production です。 - 型定義などの共有をしないタイプ。 polyrepo をくっつけただけの monorepo です
- GitHub
- CircleCI
やりたいこと
- 必要なテストだけ走らせること、不要なテストが走らないこと
- PR ごとに健全性の確認ができること、すなわち必要なテストが通ったときだけ PR をマージできること
解決策
棄却されたアプローチ1: naive 版
GitHub の status check に circleci/test-A, circleci/test-B と書く。
これでは component A の変更だけの場合に B のテストが走ってしまいます。
棄却されたアプローチ2: path-filtering
https://circleci.com/developer/orbs/orb/circleci/path-filtering
CircleCI では公式 orb の path-filtering によって、特定パスの変更に基づいて後続の WF を変更することができます。すなわち componentA/ 配下の変更があるときのみ test-A だけを走らせることができます。
しかし、これではPRの健全性の担保はできません。GitHub の required status check は動的には変更できず、また複雑な条件も設定できません。そのため、 status check で test-A, test-B の両方を書かなければ片方のテストが落ちても通ってしまうし、両方を書いてしまうと test-A だけしか走らない PR は永遠に status check が成功しません。
棄却されたアプローチ3: generator + continue + dynamic require
path-filtering だけでは status check を突破することはできませんでした。 test-A が必要なのか、test-B が必要なのか、あるいは両方とも必要なのか、両方ともいらないのか。そういったことを判断したうえで CI 全体としての red/green を代表させるジョブがなくては status check を突破することはできません。
一つは polling というアイデアになるでしょう。test-A, test-B というジョブ名がわかっているので CircleCI の API を叩くことで success, failure (あるいは待ち)を決定すればよいわけです。
一方で、条件に応じて後続 WF を変更するという path-filtering のアイデアを突き詰めることもできそうです。path-filtering は内部的には continuation orb を使っていて、これが後続 WF を変更させるものになります。
https://circleci.com/developer/orbs/orb/circleci/continuation
continuation orb で指定する後続 WF 定義は git 管理されている必要はなく continue する瞬間にファイルとして存在すればよいことになっています。つまり動的に生成することが可能です。 WF 定義の動的生成と聞くと黒魔術じゃんと思う方も多いと思いますが、個人的には慣れたものです*2。
まあたかだか CI 定義くらいなら多少黒魔術を使っても問題ないでしょう。要は ci-success ジョブを定義しその require に test-A を設定するか、test-B を設定するかを判断して宣言すればよいのです。
またこの手法ならば polling とは違ってジョブ自体が走らないため無駄に computing cost がかかりません。
workflows: my-workflow: jobs: - test-A # ここがあったりなかったりする - test-B # ここがあったりなかったりする - ci-success: requires: # ここが [test-A], [test-B], [test-A, test-B] のいずれかをとる、あるいは丸ごとなくなる
これはかなりうまく動き、実際 main にもマージされました。しかし運用していく中で新たな制約が判明します。
ci-success は echo "ok" するだけなので常に success します。一方でトリガーされるまでは not_run のままです。test-B を require している ci-success は test-B が失敗したときには not_run のままになってしまうのです。これは PR の status check としてはうまく機能します。PR の status check は green を要求するから not_run ならば status check が通らずマージできません。しかし、Merge Queue を有効化している場合に Merge Queue から取り除かれるためには、not_run ではなく failed として明示的に red なステータスにしなければなりません。そうでないと Merge Queue 内でタイムアウトするまで待ち続けてしまいます。
いかに丁寧にPRを作成しようと外部サーバーの不安定さなどで CI が落ちることはよくあるので、この Merge Queue でこっそりタイムアウトするまで待ってしまうまま放置するわけにはいきません。
採用されたアプローチ: generator + continue + polling
結局、ci-success は polling することにしました。コストが無駄にかかっているような気はしますが許容範囲でしょう。
WF 生成スクリプトの実装は極力シンプルに保ちました。実際に走る定義ファイルがコミットされていないという状態なので、スクリプトのシンプルさは重要です。またテストも書いており、最初の WF 実行を test → 生成 → 生成物の print → continue という構成にしてテストが毎回実行されることと、生成結果を少なくとも目で確認できる状態を保っています。また、実際のジョブ名から定義が見えないと面倒なため、ジョブ定義自体は yaml ファイルに書いてコミットすることで最低限 grep でひっかけられることを担保しています。
基本構成は .circleci/templates/ 配下にコンポーネント毎の定義ファイルをもっておき(.circleci/templates/componentA.yaml)*3、全コンポーネントの定義ファイルをまとめたものから枝刈りをしていくことになります。枝刈りの方式は branch の種別ごとに変わります。branch 種別は、3つあり、main (test + hold 付きでstaging, production deployment), development (test + development 環境への自動デプロイ) *4, topic (test のみ)となっています。main ならば development のデプロイを削り、development ならば staging, production を削り、topic ならば deploy 系を全部削るような感じです。
ここまで読んで、結局 ci-success で polling するならば動的生成は不要では?と思った方もいるかもしれません。一理あります。しかし、実際には 10 コンポーネントあること、また development ブランチでは自動デプロイをしたいこと、development 環境が実は2つあることなどを考えると、動的生成によって定義ファイルが簡単になったことにも価値があるかなと思って戻す検討はしていません。また、例えば自動デプロイをする development ブランチにおいて CI の成功とは test だけでなく deploy も含むでしょう。 test-A 以外に deploy-development-A も ci-success の監視対象にするべき、というロジックをスクリプトとして表現できるのは利点ともいえるでしょう。
どうしてこうなった?
ここまでいろいろと対応してきたわけですが。PR 単位で必要なテストを走らせたいしその結果をまとめて PR のマージ可否に使いたい、というとても普通のことを実現するためにいろいろやる必要があるのでしょうか。
一つは、GitHub の status check に自由度が足りないことが主な面倒さの原因といえるでしょう。test-A, test-B が走りそうなら require というのがやりたいことなのですが、そういった表現ができない。これはまあ GitHub が CircleCI の実行グラフを管理していないので仕方ないところではあります。
monorepo 運用されている皆さんはどうしているのでしょうか。うちのリポジトリが polyrepo をただまとめただけなので nx などの既存ツールの恩恵がないという指摘はあるかもしれません。コメントなどいただけるとうれしいです。また、後述する未解決問題についてもなにかありましたら教えてください。
その他の未解決問題
リリース前の確認で diff が見えない問題
デプロイ時には当然「今デプロイしたら何がでるのか」を把握しておきたいわけです。意図した変更であるか、影響がある箇所はどこだろうかということを念頭において作業をしたいというのは自然な欲求でしょう。
現在動いている component のリビジョンを取ってくることを可能にしておくことで(ECS タスク定義から取ってきたり /revision endpoint があったりするでしょう)、runtime の c0 と、main の c1 という2つのコミットの差分をとってリリース対象を特定することができます。
しかし monorepo で異なるコンポーネントの commit も含まれてしまうのでこの手法が使えません。仕方ないので git log --oneline --merges --first-parent {deployed_revision}..origin/main -- {path_pattern} というコマンドから merge commit を取ってきて PR を一覧にすることで代用としています。
hold クリックするの大変問題
結局 main からデプロイをするときには staging, production 入り混じったグラフから正しい component を選んでデプロイする必要があります。これがオペレーションミスを誘発するのでなんとかしたいと思っています。

1つの案は staging を auto deploy 扱いにすることです。そうすればとりあえず node の数は約半分になります。しかし、staging の auto deploy にはそれはそれで問題がありそうです。例えば 1 PR の中で複数コンポーネントをいじる場合に、auto deploy ではリリース順序が指定できないため、依存があるならば別PRにしなければなりません。とてもクリーンではありますが厳密にやると面倒なところではあります。
これを解決するために、例えばリリース手順を明示するような手法があるでしょう。 releases/2026-05-01-001.yaml に target: [componentA, componentB] と定義し、動的生成で A → B のリリース順序を保証するなど。しかし複数コンポーネントの順次リリースの場合、不具合発生時の abort flow *5を考慮しなければなりません。また yaml に書き忘れてデプロイされないことの検知などを考えると運用までにはまだまだ詰めるところは多そうです。
Agent が自分でサブディレクトリに cd したのに相対パス指定間違えまくる問題
AI Agent は componentA/ ディレクトリに cd して bin/rspec などを実行するが、その後 git add するときに cd したことを認識せずに誤ったパスを指定してしまいます。だいたい自分で気づくのでクリティカルな問題ではないがターンが増えるので鬱陶しいです。
*1:https://www.treasuredata.co.jp/product/treasure-ai-studio/
*2:私は TD の cdp-api というところで動的 WF 生成で飯を食べている時期がありました https://speakerdeck.com/aamine/treasure-data-techtalk-2022-td-cdp-in-30-minutes?slide=29
*3:別に componentA/circleci.yaml でもよい、というかそっちのが変更検知の観点からはよい気がしてきました
*4:development ブランチ運用については良い点悪い点両方あるのですがまた別の機会に。
*5:ウチには巨大すぎると思って避けていましたが k8s などはこういったものが得意かもしれません
