CI/CD
リポジトリ: vercel/chat 分析日: 2026-02-25
概要
vercel/chat は GitHub Actions + Changesets + Turborepo を組み合わせたモノレポ CI/CD パイプラインを構築している。CI ワークフローは lint・typecheck・build-and-test の 3 ジョブを並列実行し、Release ワークフローは CI 成功後に workflow_run トリガーで Changesets による自動バージョニング・npm パブリッシュを行う。特筆すべきは、本番 webhook トラフィックをプレビューブランチにプロキシする Preview Branch Testing の仕組みと、本番インタラクションを録画してテストフィクスチャに変換する Recording & Replay テスト戦略の存在である。
背景にある原則
- 段階的品質ゲート: CI を lint → typecheck → build-and-test の独立ジョブに分割し、失敗の特定を迅速化しつつ並列実行で全体の待ち時間を最小化している。さらに
validateスクリプトは knip → check → typecheck → test → build:validate の順序で網羅的に検証する(package.json:22) - ワークフロー連鎖による関心の分離: CI とリリースを別ワークフローに分離し、
workflow_runイベントで連鎖させることで、CI は品質保証に、Release はパブリッシュに専念できる構造を作っている(.github/workflows/release.yml:4-5) - 本番トラフィックによる検証: Webhook 統合のようにモックでは再現しにくいシナリオに対し、本番トラフィックのプロキシ(Preview Branch Testing)と録画・再生(Recording & Replay)の二段構えで品質を担保している
- 依存関係グラフの宣言的定義: Turborepo の
dependsOnでタスク間の依存を明示し、ビルド順序の誤りを防止している。testはbuildに依存し、typecheckは^build(依存パッケージのビルド完了)に依存する(turbo.json:17-29)
実例と分析
CI ワークフローの並列ジョブ設計
CI ワークフローは 4 つのジョブで構成される。
- lint: Ultracite(Biome ベース)によるフォーマットチェック + Knip による不要依存・未使用エクスポート検出
- typecheck: TypeScript の型チェック
- build-and-test: ビルド後のテスト実行(モック環境変数で外部サービス依存を排除)
- preview-comment: PR 時のみ実行。テスト手順をコメントとして投稿(冪等 -- 既存コメントがあればスキップ)
4 ジョブはすべて互いに依存関係がなく並列実行される。これにより、lint のみの失敗でもビルドを待つ必要がなく、フィードバックが速い。
Concurrency 制御
# .github/workflows/ci.yml:9-11
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: trueブランチ単位で重複実行をキャンセルする。push を連続して行った場合、古い CI ランが自動的に中止され、リソースの浪費を防ぐ。
Release ワークフローでも同様の concurrency 制御を行うが、cancel-in-progress を省略(デフォルト false)することでリリースの途中キャンセルを防止している。
モック環境変数によるビルド独立性
# .github/workflows/ci.yml:62-69
env:
SLACK_BOT_TOKEN: xoxb-mock
SLACK_SIGNING_SECRET: mock
TEAMS_APP_ID: mock
TEAMS_APP_PASSWORD: mock
TEAMS_APP_TENANT_ID: mock
GOOGLE_CHAT_CREDENTIALS: '{"type":"service_account",...}'
REDIS_URL: redis://localhost:6379
RECORDING_ENABLED: "false"外部サービスへの実際の接続なしにビルドとテストが完結するよう、すべての必須環境変数にモック値を設定している。turbo.json の globalEnv で Turborepo にもこれらの環境変数を認識させ、キャッシュの整合性を保っている。
Changesets によるバージョン管理戦略
.changeset/config.json の fixed 設定により、chat と @chat-adapter/* のすべてのパッケージが同一バージョンで固定リリースされる。
// .changeset/config.json:5
"fixed": [["chat", "@chat-adapter/*"]]ignoreでサンプルアプリ(example-nextjs-chat)と統合テスト(@chat-adapter/integration-tests)をリリース対象から除外commit: falseで changeset 消費時の自動コミットを無効化し、Changesets Action に委任updateInternalDependencies: "patch"でモノレポ内の依存パッケージを自動バンプ
workflow_run によるリリースチェーン
# .github/workflows/release.yml:3-9
on:
workflow_run:
workflows: ["CI"]
types:
- completed
branches:
- mainworkflow_run は CI ワークフローの完了をトリガーとするが、完了は成功・失敗両方を含む。そのため、ジョブレベルで明示的に成功判定を行っている。
# .github/workflows/release.yml:18
if: ${{ github.event.workflow_run.conclusion == 'success' }}この二段構えにより、CI 失敗時のリリースを確実に防止している。
Preview Branch Testing アーキテクチャ
webhook のような外部プラットフォームからのコールバックは、URL を変更できないため通常のプレビューデプロイでは検証が困難である。vercel/chat はこの問題を Next.js middleware によるリバースプロキシで解決している。
[Slack/Teams/GChat] → Production → middleware → Preview Branch
↑
Redis に保存された
プレビュー URL で判定- 本番デプロイの
/settingsページでプレビュー URL を Redis に保存 - middleware が
/api/webhooks/*へのリクエストを検知 - Redis にプレビュー URL が設定されていれば
NextResponse.rewrite()でプロキシ - 本番環境でのみ動作(
NODE_ENV === "production"&&VERCEL_ENV === "production")
Recording & Replay テスト戦略
本番の webhook インタラクションを Redis に録画し、テストフィクスチャとして再利用する仕組みが構築されている。
RECORDING_ENABLED=trueで本番デプロイ- セッション ID に
VERCEL_GIT_COMMIT_SHAを組み込み、デプロイとの紐付けを容易化 - CLI ツール(
recorder.ts)で録画をエクスポートし、JSON フィクスチャに変換 replay.test.ts等の統合テストで再生
この戦略により、外部プラットフォームの実際の webhook フォーマット変更を検出でき、モックの乖離リスクを排除している。
Dependabot の運用戦略
# .github/dependabot.yml:17-23
groups:
minor-and-patch:
update-types:
- "minor"
- "patch"GitHub Actions と npm の両方を monthly で更新しつつ、npm の minor/patch 更新をグループ化して PR ノイズを削減している。
Vitest Workspace による統合テスト実行
vitest.workspace.ts で 11 パッケージのテストを一括実行する test:workspace コマンドを用意し、CI パイプラインとは別にローカルでの高速なフィードバックループを実現している。統合テストパッケージ(@chat-adapter/integration-tests)は外部認証情報が必要なためワークスペースから除外されている。
Knip による不要コード検出
CI の lint ジョブで Knip を実行し、不要な依存関係・未使用エクスポートを検出している。knip.json で @biomejs/biome を ignoreDependencies に指定し、Ultracite 経由で間接利用される依存の誤検出を回避している。
コード例
# .github/workflows/ci.yml:92-131
# PR へのプレビューテスト手順コメント(冪等な投稿)
preview-comment:
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
permissions:
pull-requests: write
steps:
- name: Post preview testing instructions
uses: actions/github-script@v8
with:
script: |
const body = `## Preview Branch Testing
...`;
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
});
const botComment = comments.find(comment =>
comment.user.type === 'Bot' &&
comment.body.includes('## Preview Branch Testing')
);
if (!botComment) {
await github.rest.issues.createComment({...});
}// examples/nextjs-chat/src/middleware.ts:57-86
// 本番 webhook リクエストをプレビューブランチへプロキシ
export async function middleware(request: NextRequest) {
if (process.env.NODE_ENV !== "production") {
return NextResponse.next();
}
if (process.env.VERCEL_ENV !== "production") {
return NextResponse.next();
}
const { pathname } = request.nextUrl;
const previewBranchUrl = await getPreviewBranchUrl();
if (!previewBranchUrl) {
return NextResponse.next();
}
const targetUrl = new URL(
pathname + request.nextUrl.search,
previewBranchUrl,
);
return NextResponse.rewrite(targetUrl);
}# .github/workflows/release.yml:14-18
# CI 成功時のみリリースを実行
jobs:
release:
name: Release
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}// turbo.json:17-28
// タスク依存グラフの宣言
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**", "docs/**", ".next/**", "!.next/cache/**"]
},
"test": {
"dependsOn": ["build"],
"outputs": []
},
"typecheck": {
"dependsOn": ["^build"],
"outputs": []
}
}// examples/nextjs-chat/src/lib/recorder.ts:108-121
// 録画セッション ID に git SHA を組み込み
constructor() {
this.enabled = process.env.RECORDING_ENABLED === "true";
this.sessionId =
process.env.RECORDING_SESSION_ID ||
`session-${process.env.VERCEL_GIT_COMMIT_SHA || "local"}`;
if (this.enabled && process.env.REDIS_URL) {
this.redis = createClient({ url: process.env.REDIS_URL });
this.redis.on("error", (err) =>
console.error("[recorder] Redis error:", err)
);
}
}パターンカタログ
Proxy パターン (構造)
- 解決する問題: 外部プラットフォームからの webhook コールバック URL を変更できないため、プレビュー環境での動作検証ができない
- 適用条件: 本番のみが外部サービスからの受信エンドポイントを持ち、プレビュー環境が独立した URL で動作する場合
- コード例:
examples/nextjs-chat/src/middleware.ts:57-86 - 注意点: プロキシ先の障害が本番の webhook 処理をブロックしうる。本番のフォールバックを検討すること
Record & Replay パターン (振る舞い)
- 解決する問題: 外部プラットフォームの webhook フォーマットは文書化が不十分で頻繁に変更されるため、モックベースのテストが実態と乖離する
- 適用条件: 外部サービスの実際のペイロードをテストに組み込みたいが、CI でそのサービスを利用できない場合
- コード例:
examples/nextjs-chat/src/lib/recorder.ts,packages/integration-tests/src/replay.test.ts - 注意点: 録画データの鮮度管理が必要。プラットフォームの API 変更時はフィクスチャの更新が必要
Pipeline パターン (振る舞い)
- 解決する問題: CI の品質チェックとリリースを密結合すると、片方の変更がもう片方に波及する
- 適用条件: 品質ゲートの通過をリリースの前提条件としたいモノレポ
- コード例:
.github/workflows/release.yml:3-9,.github/workflows/release.yml:18 - 注意点:
workflow_runのcompletedは成功・失敗両方を含むため、ジョブレベルのifによる成功判定が必須
Good Patterns
- CI ジョブの並列独立実行: lint・typecheck・build-and-test が互いに依存せず並列実行されるため、lint エラーのフィードバックがビルド完了を待たずに得られる。各ジョブの失敗原因も明確になる
# .github/workflows/ci.yml:14, 38, 59
# 3つの独立ジョブが並列実行
jobs:
lint: ...
typecheck: ...
build-and-test: ...- 冪等な PR コメント:
preview-commentジョブは既存のボットコメントを検索し、存在しなければ投稿する。これにより PR の再実行で重複コメントが発生しない
// .github/workflows/ci.yml:119-121
const botComment = comments.find(comment =>
comment.user.type === "Bot"
&& comment.body.includes("## Preview Branch Testing")
);- validate スクリプトによるローカル再現性: CI のチェック項目をローカルで一括実行できる
pnpm validateを用意し、CI とローカルの検証内容を同期させている
// package.json:22
"validate": "pnpm knip && pnpm check && turbo typecheck && turbo test && pnpm build:validate"Changesets の fixed バージョニング: モノレポ内の全パブリックパッケージを同一バージョンで管理し、バージョンの不整合によるランタイムエラーを防止
Dependabot のグループ化: minor/patch 更新を1つの PR にまとめ、レビュー負担を軽減しつつ依存関係の鮮度を維持
Anti-Patterns / 注意点
- セットアップステップの重複: 4 つの CI ジョブすべてで checkout → pnpm setup → node setup → install を繰り返している。Composite Action や Reusable Workflow で共通化すれば保守性が向上する
# Bad: 各ジョブで同一のセットアップを繰り返す
lint:
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
- run: pnpm install --frozen-lockfile
typecheck:
steps:
- uses: actions/checkout@v6 # 同じ
- uses: pnpm/action-setup@v4 # 同じ
- uses: actions/setup-node@v6 # 同じ
- run: pnpm install --frozen-lockfile # 同じ# Better: Composite Action で共通化
# .github/actions/setup/action.yml
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 20
cache: "pnpm"
- run: pnpm install --frozen-lockfile- workflow_run の暗黙的な制約:
workflow_runはcompletedタイプで成功・失敗の両方をトリガーする。ifでの成功判定を忘れると、CI 失敗時にもリリースが実行される。vercel/chat では正しく対処されているが、この暗黙のセマンティクスは見落としやすい
# Bad: workflow_run だけでは CI 失敗時もトリガーされる
on:
workflow_run:
workflows: ["CI"]
types: [completed]
jobs:
release:
runs-on: ubuntu-latest
# ← if が無いと CI 失敗でもリリースが走る# Better: ジョブレベルで成功を明示的に確認
jobs:
release:
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}導出ルール
[MUST]CI ワークフローではpnpm install --frozen-lockfile(またはnpm ci)を使い、ロックファイルとの不一致でビルドを失敗させる- 根拠:
ci.ymlの全ジョブで--frozen-lockfileを指定し、CI とローカルの依存バージョンの一致を保証している
- 根拠:
[MUST]workflow_runトリガーで連鎖するワークフローでは、ジョブレベルのifで前ワークフローのconclusion == 'success'を必ず検証する- 根拠:
release.yml:18で明示的に成功判定を行っている。completedタイプは成功と失敗の両方を含むため、省略するとCI失敗時にもリリースが実行される
- 根拠:
[MUST]モノレポの CI は Turborepo 等のタスクランナーでビルド順序の依存関係を宣言的に定義し、test タスクが build 完了後に実行されるようにする- 根拠:
turbo.jsonでtestがbuildに依存し、typecheckが^buildに依存する宣言的グラフを構築している
- 根拠:
[SHOULD]CI で検証する品質チェック項目をローカルで一括実行できるvalidateスクリプトを用意し、CI とローカルの検証内容を同期させる- 根拠:
package.jsonのvalidateスクリプトが knip → lint → typecheck → test → build の全チェックをカバーしている
- 根拠:
[SHOULD]PR に自動コメントを投稿するジョブは冪等にする(既存コメントの存在チェック後に投稿)- 根拠:
ci.ymlのpreview-commentジョブがcomments.find()で既存コメントを検索し、重複投稿を防止している
- 根拠:
[SHOULD]CI ジョブで外部サービスの認証情報が必要な場合、モック値を環境変数として渡し、外部依存なしにビルド・テストが完結するようにする- 根拠:
ci.ymlのbuild-and-testジョブですべてのプラットフォーム認証情報にモック値を設定し、外部サービスへの接続なしにテストを実行している
- 根拠:
[SHOULD]ブランチ単位の concurrency 制御でcancel-in-progress: trueを設定し、同一ブランチへの連続 push で古い CI ランをキャンセルする。ただしリリースワークフローではcancel-in-progressを省略して途中キャンセルを防ぐ- 根拠:
ci.ymlはcancel-in-progress: true、release.ymlはデフォルトのfalseで使い分けている
- 根拠:
[SHOULD]Changesets のfixed設定で関連パッケージ群のバージョンを揃え、ignoreで非公開パッケージ(サンプルアプリ・テスト)をリリース対象から除外する- 根拠:
.changeset/config.jsonで全パブリックパッケージを固定バージョンとし、example-nextjs-chatとintegration-testsを除外している
- 根拠:
[SHOULD]Dependabot の minor/patch 更新はグループ化して PR ノイズを削減し、更新頻度は monthly に設定してレビュー負担を軽減する- 根拠:
dependabot.ymlでgroups.minor-and-patchを設定し、interval: monthlyで運用している
- 根拠:
[AVOID]CI ワークフローの各ジョブにセットアップステップ(checkout, パッケージマネージャー, Node.js, install)をコピー&ペーストで複製すること。Composite Action か Reusable Workflow で共通化する- 根拠:
ci.ymlでは 4 ジョブに同一の 4 ステップが重複しており、バージョン更新時に 4 箇所の修正が必要になる
- 根拠:
適用チェックリスト
- [ ] CI ワークフローの依存インストールに
--frozen-lockfile(またはnpm ci)を使用しているか - [ ] CI ジョブが lint・typecheck・build-and-test に分離され、並列実行されているか
- [ ] ブランチ単位の
concurrency+cancel-in-progressが CI ワークフローに設定されているか - [ ] リリースワークフローが CI 成功の明示的な判定を持っているか(
workflow_run使用時) - [ ] 外部サービス依存のビルド・テストがモック環境変数で自己完結しているか
- [ ] CI の品質チェック項目をローカルで一括実行できる
validateスクリプトが存在するか - [ ] Turborepo 等のタスクランナーでビルド→テストの依存関係が宣言されているか
- [ ] モノレポのバージョン管理戦略(fixed / independent)が決定・設定されているか
- [ ] PR への自動コメント投稿が冪等に実装されているか
- [ ] Dependabot の更新がグループ化され、適切な頻度で設定されているか
- [ ] Webhook 等の外部コールバックを受けるアプリにプレビューブランチでの検証手段があるか