GitHub ActionsでTerraformのCIを改善する
はじめに
これまで、GitHub Actionsを使ってTerraformの品質チェックやセキュリティチェックを自動化する方法を試して見ました!
これまでに書いた記事は以下の2つです。
GitHub ActionsとTrivyでTerraformの設定ミスをPR時に検出する
GitHub ActionsでTerraformの品質チェックを自動化する
これまで作成したWorkflowをさらに改善するために、以下の3つに取り組みました。
Reusable WorkflowsによるWorkflowの再利用
Path FilterによるWorkflowの実行条件の制御
Terraform Provider CacheによるProviderのキャッシュ
1. Reusable WorkflowsでWorkflowを再利用する
これまでのWorkflowでは、Terraformの品質チェックを実行する処理を1つのYAMLファイルにまとめていました。
しかし、今後セキュリティチェックやテストなどを追加していくことを考えると、Workflowごとに処理を分離しておく方が管理しやすいかなと思いました。
そこで、Reusable Workflowsを利用してみました!
Reusable Workflowsは、別のWorkflowから呼び出して再利用できる仕組みです。
今回の構成では、以下のように役割を分けています。
呼び出し側:Workflowを実行する条件を定義
呼び出される側:Terraformの品質チェックを実行
呼び出し側のWorkflow
.github/workflows/pr-check.yml
name: Terraform PR Check
on:
pull_request:
paths:
- '**/*.tf'
permissions:
contents: read
jobs:
terraform-quality:
uses: ./.github/workflows/terraform-quality.yaml
uses に呼び出したいWorkflowのパスを指定します。
これにより、Terraformの品質チェック処理を別のWorkflowに切り出すことができます。
呼び出される側のWorkflow
.github/workflows/terraform-quality.yaml
name: Terraform Quality
on:
workflow_call:
permissions:
contents: read
jobs:
terraform-quality:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
- name: Check formatting
run: terraform fmt -check -recursive
- name: Initialize Terraform
run: terraform init -backend=false
- name: Validate Terraform
run: terraform validate
呼び出される側では、on: workflow_call を指定します。
これにより、別のWorkflowから呼び出せるようになります。
Terraformの品質チェック処理を独立させることで、今後ほかのWorkflowからも再利用しやすい構成にしました。
2. Path FilterでWorkflowの実行条件を制御する
次に、Workflowが実行される条件を見直しました。
Pull Requestを作成するたびにTerraformの品質チェックを実行するのではなく、Terraformファイルに変更があった場合だけ実行するようにします。
そこで、呼び出し側のWorkflowに以下を設定しました。
on:
pull_request:
paths:
- '**/*.tf'
paths を指定することで、対象となるファイルに変更があった場合のみWorkflowが実行されます。
今回の設定では、.tf ファイルが変更されたPull Requestが対象になります。
実際に動作を確認したところ、WorkflowのYAMLファイルだけを変更した場合は実行されず、Terraformファイルを変更した場合は実行されることを確認できました。
これにより、Terraformに関係のない変更で不要なCIが実行されることを抑えられます。
3. Terraform Providerをキャッシュする
次に、Terraform Providerのキャッシュを導入してみました。
Terraformでは、terraform init を実行すると、必要なProviderがダウンロードされます。
毎回ダウンロードが発生するため、Providerをキャッシュして再利用できるようにします。
今回は、Terraformの環境変数 TF_PLUGIN_CACHE_DIR とGitHub Actionsの actions/cache を利用しました。
Provider Cacheの設定
まず、Jobの環境変数にキャッシュ先を指定します。
env:
TF_PLUGIN_CACHE_DIR: ${{ github.workspace }}/.terraform-plugin-cache
TF_PLUGIN_CACHE_DIR は、Terraform Providerのキャッシュ先を指定するための環境変数です。
次に、WorkflowのStepにキャッシュ処理を追加します。
- name: Create Terraform plugin cache directory
run: mkdir -p "$TF_PLUGIN_CACHE_DIR"
- name: Cache Terraform providers
uses: actions/cache@v6
with:
path: ${{ github.workspace }}/.terraform-plugin-cache
key: ${{ runner.os }}-terraform-providers-${{ hashFiles('**/.terraform.lock.hcl') }}
restore-keys: |
${{ runner.os }}-terraform-providers-
それぞれの設定について説明します。
path
キャッシュ対象のディレクトリを指定します。
今回は、TF_PLUGIN_CACHE_DIR で指定したディレクトリと同じ場所にしています。
key
キャッシュを識別するためのキーです。
今回は、実行環境のOSと .terraform.lock.hcl のハッシュ値を組み合わせています。
ロックファイルの内容が変わるとハッシュ値も変わるため、Providerの依存関係に変更があった場合に、別のキャッシュキーを利用できます。
restore-keys
完全に一致するキャッシュが見つからなかった場合に、前方一致するキャッシュを探すための設定です。
今回はOSとProviderキャッシュのプレフィックスを指定しています。
Workflow全体
最終的なReusable Workflowは以下のようになりました。
name: Terraform Quality
on:
workflow_call:
permissions:
contents: read
jobs:
terraform-quality:
runs-on: ubuntu-latest
env:
TF_PLUGIN_CACHE_DIR: ${{ github.workspace }}/.terraform-plugin-cache
steps:
- name: Checkout repository
uses: actions/checkout@v7
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
- name: Create Terraform plugin cache directory
run: mkdir -p "$TF_PLUGIN_CACHE_DIR"
- name: Cache Terraform providers
uses: actions/cache@v6
with:
path: ${{ github.workspace }}/.terraform-plugin-cache
key: ${{ runner.os }}-terraform-providers-${{ hashFiles('**/.terraform.lock.hcl') }}
restore-keys: |
${{ runner.os }}-terraform-providers-
- name: Check formatting
run: terraform fmt -check -recursive
- name: Initialize Terraform
run: terraform init -backend=false
- name: Validate Terraform
run: terraform validate
キャッシュの復元処理は terraform init より前に実行する必要があります。
これにより、Terraformの初期化時にキャッシュされたProviderを利用できるようになります。
4. Provider Cacheの動作を確認する
設定したキャッシュが実際に動作するか、GitHub Actions上で確認しました。
今回は、同じPull RequestでWorkflowを2回実行し、キャッシュの保存と復元を確認しています。
1回目の実行
初回実行ではキャッシュが存在しないため、Providerのダウンロードが行われます。
実行ログでは、キャッシュが見つからないことを示すメッセージが出力されました。
Cache not found for input keys:
その後、Workflowの最後に以下のログが出力されました。
Cache saved with key: ...
これにより、Providerのキャッシュが保存されたことを確認できました。
2回目の実行
次に、同じPull Requestで Re-run all jobs を実行しました。
2回目の実行では、以下のログが出力されました。
Cache restored successfully
これにより、1回目の実行で保存したキャッシュを復元できることを確認できました。
今回の検証では、キャッシュの保存と復元の両方を確認できました。
なお、キャッシュの復元によってProviderの再ダウンロードを抑制できる構成にはなりましたが、実行時間がどの程度短縮されたかについては、まだ比較検証できていません。ただ、Providerのダウンロードを省略できる分、実行時間の短縮につながると考えています!
また、GitHub ActionsのキャッシュはPull Requestの実行スコープに関係するため、今回は同じPull Requestで再実行して確認しています。
5. まとめ
今回は、GitHub ActionsでTerraformのCIを改善するために、以下の3つを実装しました。
Reusable WorkflowsによるWorkflowの共通化
Path Filterによる実行条件の制御
Terraform Provider CacheによるProviderのキャッシュ
実際に設定して動作を確認することで、それぞれの仕組みについて理解を深めることができました。
特に、Provider Cacheについては、設定するだけでなく、GitHub Actionsの実行ログからキャッシュの保存と復元まで確認できたのがよかったです。
引き続き、GitHub ActionsやTerraformを使いながら、CI/CDの改善について学んでいきたいと思います。
参考
GitHub Actionsの再利用可能なワークフロー
GitHub Actionsのワークフロー構文
GitHub Actionsの依存関係キャッシュ
actions/cache
Terraform fmt
Terraform init
Terraform validate
hashicorp/setup-terraform
Playwright再入門:Claude Codeでテストを残し、増やし、GitHub Actionsで回し、MCP・CLIと使い分ける
Claude Code で画面を作っていて、ブラウザを動かすテストをまだ持っていないなら、この記事はその入口です。
Playwright(ブラウザを自動で動かす Microsoft の OSS)で書かせて残したテストは、AI なしで1秒前後で回ります。画面が変わると赤くなり、止まった所まで出ます。
AI に頼むのは、テストを書く・直すときと、テストの無い画面をその場で見るときだけで足ります。
テストを残す・増やす・GitHub Actions で回す・MCP と CLI で見させる、の4つを1画面の小さなアプリで順に試します。どの段にも公開サンプルがあります。
!
この記事の内容は動画(全4本)でも解説しています。下は1本目です。
目次:目的から各回を選ぶ
目的から、見たい回へ飛んでください。
テストを作って保つ
#1 テストを1本書かせて残す:画面が変わると AI なしで赤くなる
#2 Test Agents で増やす:計画を絞って書かせ、6本の赤を1行で直させる
人が走らせなくても回す
#3 GitHub Actions で回す:push だけでテストが走り、トレースで原因が分かる
テスト以外の使い方を選ぶ
#4 MCP と CLI:同じ確認を4通りで比べ、残すか見させるかを選ぶ
前提と試した環境
要る物は次のとおりです。
Node.js(公式の導入ページの要件は「latest 22.x, 24.x or 26.x」)
Claude Code(#1・#2・#4)
GitHub のアカウント(#3)
確認環境と、試していないこと
#1・#2・#4 は、Linux arm64・Node.js 22.22.1・Playwright 1.64.0 で試しました。
回
確認日
試した環境
#1
2026-10-10
Chrome for Testing 156・Claude Code 2.1.296
#2
2026-10-11
Claude Code 2.1.296
#3
2026-10-18
GitHub Actions の ubuntu-24.04 ランナー(手元は Linux)
#4
2026-10-18
Chrome なし・画面なし・Claude Code 2.1.302
秒数は途中の許可を聞かれない auto mode の値で、頼むたびに変わります。Mac と Windows では試していません。#4 は本体同梱の npx playwright mcp だけで、@playwright/mcp@latest と Chrome のある端末は試していません。
#1 テストを1本書かせて残す:画面が変わると AI なしで赤くなる
Playwright には 1.56 から Playwright Test Agents が入っています。計画を書く planner、テストを書く generator、失敗したテストを直す healer の3つです。Claude Code からはサブエージェントとして呼べます。私が試したかったのは、AI の確認を使い捨てにせず、テストとして残す形です。
実験台の「買い物メモ」。品物を足してチェックすると、下の「残り N 品」が減る
サンプルでテスト1本を走らせる
zun-tools/playwright-sample は、設定まで済ませた完成形です。
git clone zun-tools/playwright-sample
cd playwright-sample
npm install
npx playwright install chromium
npx playwright test
1 passed になります。走ったテストの全文です。
tests/shopping.spec.js
// @ts-check
import { test, expect } from '@playwright/test';
test('品物を足してチェックすると、残りが減る', async ({ page }) => {
await page.goto('/');
await page.getByLabel('品物').fill('牛乳');
await page.getByRole('button', { name: '追加' }).click();
await page.getByLabel('品物').fill('卵');
await page.getByRole('button', { name: '追加' }).click();
await expect(page.getByRole('listitem')).toHaveCount(2);
await expect(page.getByRole('status')).toHaveText('残り 2 品');
await page.getByRole('checkbox', { name: '牛乳' }).check();
await expect(page.getByRole('status')).toHaveText('残り 1 品');
});
操作は fill・click・check、確かめは expect です。
空のフォルダに2コマンドで入れ、設定を足す
npm init playwright@latest -- --quiet --browser=chromium --lang=js --no-examples
npx playwright init-agents --loop=claude
2つ目で .claude/agents/ に3つのエージェントが、.mcp.json に MCP サーバ(playwright-test)が入ります。サンプルの index.html を置き、設定に3か所足します。
playwright.config.js(足した所)
reporter: [['list'], ['html', { open: 'never' }]],
use: {
baseURL: 'localhost',
},
webServer: {
command: 'npx http-server -p 8080 -s .',
url: 'localhost',
reuseExistingServer: true,
},
tests/seed.spec.ts の // generate code here. は、await page.goto('/'); に替えます。seed は、エージェントが最初に走らせるテストです。設定を足さずに頼んだ回は、generator がページを開けず、画面を操作しないままテストを書きました。
generator を名指しして書かせる
フォルダで claude を開くと MCP サーバを使うか聞かれます。カーソルは最初「Continue without using this MCP server」にあります。上へ2回動かし、「Use this MCP server」を選びます。
playwright-test-generator エージェントを使って、買い物メモ(index.html)のテストを書いて。計画書は無いので、品物を2つ足して、チェックすると残りが減ることを確かめるテストにして
generator はブラウザで操作を通してから、テストを1本書き出しました(51秒〜1分3秒・4回)。読んで合っていれば、そのまま残します。名指しせず「テストを書いて」と頼んだ回は、サブエージェントが使われませんでした。
ボタン名を変えて、AI なしで走らせる
index.html のボタン名を「追加」から「入れる」に変え、npx playwright test だけを打ちます。
✘ 2 …s/shopping-memo/add-and-check-items.spec.ts:7:7 › 買い物メモ › 品物を2つ追加してチェックすると残りが減る (30.0s)
Error: locator.click: Test timeout of 30000ms exceeded.
Call log:
- waiting for getByRole('button', { name: '追加' })
赤になるまで30秒かかりました。Playwright は操作できるまで待ち、テスト1本の時間切れ(既定30秒)で赤にします。use に actionTimeout: 5000 を足すと、5.1〜5.3秒で赤になりました(既定では30.0〜30.1秒・各3回)。
止まった瞬間の画面は、トレースで見られます。
npx playwright test --trace on
npx playwright show-report
actionTimeout: 5000 で走らせたトレースの一部。「追加」を探して5秒待った Click が赤。そのときのページのボタンは「入れる」
healer にテストを直させる
ボタン名をわざと変えたのなら、直すのはテストの側です。
テストが失敗してる。playwright-test-healer エージェントで直して
テストのボタン名2か所が「入れる」に変わり、再実行は緑でした(42〜58秒・4回)。コメントに古い「追加」が残った回もあります。アプリの1行を消して頼んだ別の日の1回(2026-10-09・Claude Code 2.1.295)は、test.fixme を足して止まりました。スキップは結果の上では緑に見えるので、直した後も読みます。
#2 Test Agents で増やす:計画を絞って書かせ、6本の赤を1行で直させる
#1 の設定を済ませた所から始めます。増やした後の状態は zun-tools/playwright-agents-sample にあり、AI なしで赤まで試せます。
planner に計画を5つまで書かせる
playwright-test-planner エージェントで、買い物メモのテスト計画を作って。seed は tests/seed.spec.ts。tests/shopping.spec.js にテストが1本あるので、それと同じ筋書きは除いて、大事な順に5つまで
約1分で specs/ に日本語の計画(Markdown)ができました。テストを書く前に、何を確かめるかを読めます。絞らずに頼むと、21 と 23 シナリオでした(2回)。
generator に計画から書かせる
playwright-test-generator エージェントで、specs/shopping-memo.plan.md の計画からテストを書いて
計画の1項目がテスト1本になり、5本で2分半〜3分半でした。AI なしで走らせると 7 passed、約1秒です。手書きの1本と seed を足した7本になります。緑になったら、計画とテストをコミットしておきます。
表示を1か所変えると、6本がまとめて赤
表示を「残り 1 / 2 品」に変えます。playwright-agents-sample の display-change.patch を git apply すると当たります。自分で変えるなら、index.html の次の3行です。
index.html
- <p id="left" role="status">残り 0 品</p>
- <p id="left" role="status">残り 0 / 0 品</p>
function updateLeft() {
const n = list.querySelectorAll('input[type=checkbox]:not(:checked)').length;
- left.textContent = 残り ${n} 品;
- const all = list.querySelectorAll('input[type=checkbox]').length;
- left.textContent = 残り ${n} / ${all} 品;
}
緑は seed だけ。「残り」を確かめる6本が、1本あたり5秒ちょっとで赤
エラーには Expected: "残り 0 品" と Received: "残り 0 / 0 品"、テストの行番号が出ます。1本が5秒ちょっとで赤になるのは、expect が既定で5秒まで待つからです。
1行頼んで直させ、差分を読む
テストが失敗してる。playwright-test-healer エージェントで直して
約1分で、テスト6ファイルの期待値が新しい表示に直りました。index.html は触られず、再実行は 7 passed です(2回)。
全体の数が行ごとに違うので、一括の置き換えでは書けない
赤にして期待と実際を言葉で出すまでが Playwright で、直したのは Claude Code です。
計画は古いまま残る。 直した後も specs/ は旧表示のままでした。「計画(specs/)も今の表示に合わせて直して」と頼むと、19〜31秒で直りました(3回)
アプリ側の変更が戻されることがある。 アプリを壊して頼んだ4回で、テストを壊れた値に合わせた回は0回でした。ただし3回は、聞かれないまま未コミットの変更が元に戻され、1回は戻してよいか聞いて止まりました。頼む前にコミットしておけば、git diff で取り返せます
#3 GitHub Actions で回す:push だけでテストが走り、トレースで原因が分かる
打ち忘れた日も赤が出るよう、push のたびに GitHub Actions で走らせます。サンプル zun-tools/playwright-ci は、#1 のサンプルに package-lock.json と workflow を足した物です。雛形からは、Chromium だけ入れ、アクションの版を v6 に上げました。
.github/workflows/playwright.yml
name: Playwright Tests
on:
push:
branches: [ main, master ]
pull_request:
branches: [ main, master ]
jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Install Playwright Browsers
run: npx playwright install --with-deps chromium
- name: Run Playwright tests
run: npx playwright test
- uses: actions/upload-artifact@v6
if: ${{ !cancelled() }}
with:
name: playwright-report
path: playwright-report/
retention-days: 30
自分のリポへ push して緑を見る
git clone zun-tools/playwright-ci
cd playwright-ci
npm ci
gh repo create <自分の名前>/playwright-ci --public
git remote set-url origin github.com<自分の名前>/playwright-ci.git
git push
Actions のタブに緑が付きます。push から緑まで55秒と38秒でした。ubuntu-latest は 2026-10-19 から Ubuntu 26.04 へ順に移る、と GitHub が告知しています。ubuntu-26.04 を指定しても、緑2回・赤1回で同じ形でした。
ボタン名を変え、テストを打たずに push する
index.html のボタン名を「入れる」に変えて、そのまま push します。
git commit -am "ボタン名を変える"
git push
テストのコマンドは打たず、push から130秒で ✗ が付きました。
Actions の一覧。ボタン名を変えたコミットだけが ✗ になり、戻すと緑に戻った
赤が長いのは、CI では再試行が2回入る(retries: process.env.CI ? 2 : 0)からです。30秒待つ赤が3回続きます。gh run view <番号> --log-failed でも、待っていた操作とテストの8行目が出ます。
知らせは設定しだいです。試し用の公開リポでは、失敗のときだけ通知とメールの両方が届きました(2026-10-11)。サンプルの上の赤では、メールは届いていません(2026-10-18)。
自分のリポで受け取るなら、Settings → Notifications の Actions の欄で Email を確かめます。一覧の ✗ は、知らせが無くても残ります。
レポートを落とし、再試行1回目のタブからトレースを開く
gh run list
gh run download <番号> -n playwright-report -D ci-report
npx playwright show-report ci-report
-D を付けないと gh run download は失敗します。
失敗したテストのページには Run・Retry #1・Retry #2 の3つのタブがあり、トレースは Retry #1 にだけあります(雛形の trace: 'on-first-retry')。
最初の Run のタブにはトレースが無い。Retry #1 を開く
GitHub の上で走ったテストの、止まった瞬間の画面。ボタンは「入れる」になっている
うっかりならボタン名を戻し、わざとならテストを直します。レポートには画面がそのまま写るので、秘密の入る画面は扱いに気をつけてください。
#4 MCP と CLI:同じ確認を4通りで比べ、残すか見させるかを選ぶ
Playwright の README は、使い道に「テスト・スクリプト・AI エージェントの道具」の3つを挙げています。AI 向けには、本体に MCP サーバと CLI(playwright-cli)が入っています。同じ確認を4通りでやって並べました。確認は「牛乳と卵を足し、牛乳にチェックすると『残り 1 品』になるか」です。サンプルは zun-tools/playwright-scripts-sample です。
git clone zun-tools/playwright-scripts-sample
cd playwright-scripts-sample
npm install
npx playwright install chromium
npm run serve # 別の端末で開いたままにする
テストとスクリプト:先に書いた物を AI なしで走らせる
1通り目は npx playwright test で、#1 と同じテスト1本です。2通り目は、テストの枠と expect を外した12行のスクリプトです。
scripts/check.mjs
// 買い物メモの定型確認(テストランナーを使わない Playwright ライブラリのスクリプト)
import { chromium } from 'playwright';
const browser = await chromium.launch();
const page = await browser.newPage();
await page.goto('localhost');
for (const name of ['牛乳', '卵']) {
await page.getByLabel('品物').fill(name);
await page.getByRole('button', { name: '追加' }).click();
}
await page.getByRole('checkbox', { name: '牛乳' }).check();
console.log(await page.getByRole('status').textContent());
await browser.close();
node scripts/check.mjs は、合否ではなく読んだ表示(「残り 1 品」)を出します。スクショや PDF にも同じ書き方が使えます。
MCP と CLI:何も書かずに AI に見させる
既定では手元の Chrome を使いますが、Chrome の無い Linux では入れ方を案内されて止まりました。そこで Playwright が入れた Chromium を指しました。MCP は flag を2つ、CLI はサンプルの .playwright/cli.config.json で指しています。
MCP(Chrome の無い Linux で足した形)
claude mcp add --scope project playwright -- npx playwright mcp --headless --executable-path <Chromium の場所>
CLI(使い方を教えるスキルをフォルダに入れる)
npx playwright cli install --skills
<Chromium の場所> は、npx playwright install --dry-run chromium の「Install location」の下の chrome-linux*/chrome です。
公式の説明では、1.62 から MCP サーバと playwright-cli は本体に同梱されています。
頼む文は次のとおりです。MCP のときは、最後の1文を「ブラウザの操作には playwright MCP のツールを使うこと。」に変えます。
localhost の買い物メモで、品物「牛乳」と「卵」を追加してから「牛乳」にチェックを入れると、画面の表示が「残り 1 品」になるか確かめてください。最後に「はい/いいえ」と根拠を1行で答えてください。ブラウザの操作には playwright-cli(このフォルダでは npx playwright cli)を使うこと。
CLI で頼んだ1回目。スキルを読んでからコマンドを打ち、28秒で「はい」と答えた
答えは4回とも「はい」でした。MCP は道具を1つずつ呼び、CLI はスキルを読んでからコマンドをまとめて打ちます。
4通りを並べて選ぶ
npm run serve のページは開いたままにしました。AI の2つは1回ごとに Claude Code を開き直し、「AI の往復」は道具やコマンドを呼んだ回数です。
やり方
先に書く物
出る物
かかった時間
AI の往復
テスト
テスト1本
合否(1 passed)
0.92・0.87秒
0回
スクリプト
12行
読んだ表示「残り 1 品」
0.43・0.41秒
0回
MCP
なし
「はい」と根拠
18・25秒
6・7回
CLI
なし
「はい」と根拠
28・26秒
5・6回
表の秒数は、ほかの作業が走っていないときの値です。選ぶ基準は桁の差で、残す2つは1秒かからず AI 0回。見させる2つは、頼むたびに20秒前後かかります。
MCP と CLI の間は、速さでは選びません。公式は、playwright-cli は Claude Code などのコーディングエージェントに向き、MCP は状態を持ち続ける長い作業に向くと書いています。
使い所は、表から次のように選べます。
明日も同じ確認をする → テストに残す。2回目も1秒かからず、変わったら赤で知らせる
確かめではない作業(PDF・定型の入力) → スクリプト
テストのまだ無い画面を、いま一度だけ見る → AI に見させる。Claude Code なら、まず CLI
見させた確認を何度も使う → テストへ移す(#1 の generator)
まとめ
generator は操作を通してテストを書き、残したテストは AI なしで回る。画面が変わると止まった所を出して赤になる(既定30秒・actionTimeout で約5秒)
計画を絞って書かせればテストは増え、6本の赤も1行の頼みで直る。頼む前にコミットし、後で差分と計画を読む
workflow を足せば push のたびに走り、赤は約2分で ✗ になる。トレースは再試行1回目のタブにある
MCP と CLI は書かずに見させる道具で、毎回時間がかかる。繰り返す確認はテストに残す
まずは #1 のサンプルで緑を見て、「追加」を「入れる」に変えてもう一度走らせてください。
動画で見たい方はこちら(全4本):
#1 テストを書かせて残す
#2 Test Agents で増やし、直させる
#3 GitHub Actions で回す
#4 MCP と CLI を使い分ける
参考リンク
microsoft/playwright(公式リポジトリ)
Playwright 公式 docs: Playwright Test Agents
Playwright 公式 docs: Installation
Playwright 公式 docs: Locators
Playwright 公式 docs: Auto-waiting
Playwright 公式 docs: Timeouts
Playwright 公式 docs: Trace viewer
Playwright 公式 docs: Continuous Integration
Playwright 公式 docs: Trace viewer intro
Playwright 公式 docs: Library
Playwright 公式 docs: Getting started - MCP
Playwright 公式 docs: Getting started - CLI
GitHub Docs: GitHub Actions の料金
GitHub Docs: Actions の通知の受け取り方
Claude Code 公式 docs: Permission modes
Fix "APNS token has not been set yet" in Flutter on iOS
---
title: Fix "APNS token has not been set yet" in Flutter on iOS
published: true
date: 2026-11-05 00:00:00 UTC
tags: flutter,ios,firebase,dart
canonical_url: saymyframe.com
---
On iOS, Firebase delivers push notifications through Apple's push service (APNs), so FirebaseMessaging.instance.getToken() refuses to run until Apple has given the device its APNs token. Called earlier, it throws apns-token-not-set, which older versions of firebase_messaging describe as "APNS token has not been set yet". Usually the app just asked too early. How it should wait depends on the version: firebase_messaging 17 tells the app when Firebase is ready, and 16.7 doesn't. In a few setups, though, Apple never answers, however long the app waits.
I checked everything below on an iPhone 17 simulator with iOS 26.5, Xcode 27.2 and Flutter 3.44.2, with firebase_messaging 16.7.1 (Firebase iOS SDK 12.19.3) and 17.0.1 (Firebase iOS SDK 13.1.1).
APNS token has not been set yet, or has not been received on the device yet
The error comes in two wordings. Older versions say:
[firebase_messaging/apns-token-not-set] APNS token has not been set yet. Please ensure the APNS token is available by calling `getAPNSToken()`.
Since October 2025 (firebase_messaging 16.0.3, with firebase_messaging_platform_interface 4.7.3), the same error says:
[firebase_messaging/apns-token-not-set] APNS token has not been received on the device yet. Please ensure the APNS token is available before calling `getAPNSToken()`.
Both are a FirebaseException with the code apns-token-not-set, so check the code rather than the text. The hint to call getAPNSToken() is misleading, because that call never throws. It returns null until Apple has answered, which makes it the thing to wait on.
The check runs in Dart, before anything reaches iOS. getToken(), deleteToken(), subscribeToTopic() and unsubscribeFromTopic() all start with it. So a topic subscription at startup fails with the same error, while requestPermission() and onMessage work. If you use Firebase from Swift, the native SDK reports the same situation as No APNS token specified before fetching FCM Token.
Why getToken() fails right after the app starts
At launch, the plugin asks iOS to register the app for remote notifications, and iOS answers later, in application:didRegisterForRemoteNotificationsWithDeviceToken:. The Firebase iOS SDK keeps that answer in memory only. So an early getToken() fails on every launch, not just the first one, even when Firebase gave the app its FCM token on the previous launch.
How long the answer takes varies: about half a second on a simulator that had been running for a while, and about 30 seconds right after the simulator booted. On a phone, it depends on the network.
I checked this with a small app that calls getToken() right after Firebase.initializeApp(), after a fresh install and after a restart, with both versions. The call failed every time, although Apple answered within a second. What differs between the versions is whether the app learns that Firebase is ready.
firebase_messaging 16: wait for getAPNSToken()
With 16.7.1, the Firebase iOS SDK requests the FCM token by itself as soon as Apple answers (the log shows the app connecting to fcmtoken.googleapis.com), but the plugin doesn't pass it on to Dart: onTokenRefresh stayed silent for the 20 seconds I waited, after a fresh install and after a restart. So the app has to wait for getAPNSToken() itself, then call getToken():
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
/// The FCM token of this device once Apple has answered, or `null` when
/// Apple hasn't within [limit].
Future<String?> pushTokenWhenReady({
Duration limit = const Duration(minutes: 2),
}) async {
final messaging = FirebaseMessaging.instance;
if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) {
var wait = const Duration(milliseconds: 500);
var waited = Duration.zero;
while (await messaging.getAPNSToken() == null) {
if (waited >= limit) return null;
await Future<void>.delayed(wait);
waited += wait;
if (wait < const Duration(seconds: 8)) wait *= 2;
}
}
return messaging.getToken();
}
Don't await it before runApp(). I found apps on GitHub that opened to a white screen on iOS for exactly this reason: main() waited, getToken() threw, and runApp() never ran. Start the app first and send the result to your server when it arrives:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
runApp(const MyApp());
final token = await pushTokenWhenReady();
if (token != null) await sendTokenToServer(token);
}
If the wait runs out, try again when the user comes back to the app, for example from an AppLifecycleListener with onResume.
firebase_messaging 17: listen to onTokenRefresh
Since 17.0.0, released in October 2026, the plugin becomes the delegate of the Firebase iOS SDK during Firebase.initializeApp(), so onTokenRefresh gets two events that 16 lost. After a fresh install, it fires as soon as the SDK has the FCM token, in my runs 1.4 seconds after Apple answered. On every later launch, it fires right at startup with the value that the SDK saved last time: 0.2 seconds after main() started, before Apple had answered and while getToken() still threw.
So on 17, subscribe to onTokenRefresh right after initializeApp(), and keep the getToken() call for Android, where it doesn't depend on Apple:
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform);
final messaging = FirebaseMessaging.instance;
messaging.onTokenRefresh.listen(sendTokenToServer);
runApp(const MyApp());
try {
final token = await messaging.getToken();
if (token != null) await sendTokenToServer(token);
} on FirebaseException catch (e) {
// On iOS before Apple has answered: onTokenRefresh delivers it.
if (e.code != 'apns-token-not-set') rethrow;
}
}
Your server may get the same value twice this way, so it should accept one that it already has. Subscribe early, because onTokenRefresh is a broadcast stream and a listener that comes after the startup event never sees it. If your code subscribes later, after a sign-in for example, wait for getAPNSToken() as with 16.
Firebase is also moving Cloud Messaging from registration tokens to the Firebase Installation ID (FID). Version 17 deprecates getToken(), onTokenRefresh and deleteToken() in favor of register(), onRegistered and unregister(), and flutter analyze exits with code 1 on the code above because of the deprecation infos. I haven't tried the FID path yet, so this article stays with getToken().
When the APNs token never comes
If getAPNSToken() still returns null after a minute, waiting longer won't help. These are the cases I know of.
No aps-environment entitlement
iOS registers an app for remote notifications only when the build is signed with the aps-environment entitlement, which Xcode adds with the Push Notifications capability in Signing & Capabilities. Without it, the Xcode console shows:
[FirebaseMessaging][I-FCM012002] Error in application:didFailToRegisterForRemoteNotificationsWithError: no valid "aps-environment" entitlement string found
The simulator follows the same rule. A build made with flutter build ios --simulator --no-codesign doesn't get the entitlement, and mine waited two minutes for Apple in vain.
A free Apple team
A personal team can't sign aps-environment. To run the app on an iPhone, you have to remove the capability, and then Apple never answers there. The simulator doesn't need a team, since Xcode signs simulator builds ad hoc.
The iOS simulator on GitHub Actions
The iOS simulator on a Mac gets a real APNs token. On a macOS virtual machine it never does, and the macOS runners of GitHub Actions are virtual machines. I tried a macos-26 runner: the app was built with the entitlement, nothing came in four minutes, and the push daemon logged no token info found in keychain before it disconnected. actions/runner-images#12747 reports the same for macos-15. A CI job on a hosted runner can check that the app starts without push notifications, but not that it gets them.
firebase_messaging 16.5.0 and 16.6.0
These two versions skipped the registration for remote notifications at launch in two common setups: when Firebase is initialized from Dart, as with Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform) (flutterfire#18643), and in apps with scenes (flutterfire#18555), which the iOS template of current Flutter creates with SceneDelegate.swift. getAPNSToken() then stayed null on every launch. Version 16.7.0 fixed both. Check which version your app has:
flutter pub deps | grep firebase_messaging
Auto-init is off
With FirebaseMessagingAutoInitEnabled set to NO in Info.plist, or after setAutoInitEnabled(false), the plugin doesn't register for remote notifications. Call setAutoInitEnabled(true) before you start waiting.
Not the APNs key
The APNs key in the Firebase console doesn't belong on this list. Firebase needs it to send messages through Apple, so without it nothing arrives, but getAPNSToken() and getToken() work all the same.
How SMF handles push notifications on iOS
The firebase_messaging module of SMF, a free, open-source Flutter project generator, gives the app the address of the device as appPush.address, which stays null until Firebase has one. The first frame never waits for it. On iOS, the module polls getAPNSToken() as above, waiting from half a second up to eight seconds between tries, for at most two minutes, and asks again whenever the user comes back to the app. It adds aps-environment and the background mode remote-notification, and the README of the app explains what to do with a free team. Uploading the APNs key stays a step you do by hand. SMF's CI starts the apps on the iOS simulator of a hosted runner, where they have to start and work without an address.
初めてのCI/CD。チェックが失敗したままマージしても、公開は止められた(GitHub Actions)
1. はじめに
前回の記事では、GitHubにpushすると、GitHub ActionsがCloudflare Workersへ自動で公開するしくみを作りました。
ただ、このしくみには弱点があります。mainにpushすると、中身に間違いがあっても、そのまま公開されてしまいます。
今回の検証は小さな静的ページなので、それでも大きな問題にはなりません。でも、仕事で扱う大きな動的ページでは、そうはいきません。公開する前に、自動で確かめるしくみが必要だと思いました。
そこで今回は、公開の前にHTMLをチェックして、間違いがあれば公開しないようにしました。さらに、わざとHTMLを壊して、本当に公開が止まるのかを確かめています。
イベント・on:・ランナー・既定値の説明は、前回の記事に書いたので省略します。この記事では、前回と違うところだけを説明します。
なお、記事の内容は、断りがない限り自分の環境で動かして確かめていますが、CI/CDは初心者です。間違いなどがあれば、コメントで教えていただけるとうれしいです。
2. 作ったもの
プルリクエストを作るとチェックだけが動き、mainにマージするとチェックのあとに公開されるしくみです。
flowchart TD
PR["プルリクエストを作る<br>ブランチにpushする"] --> E1["pull_request イベント"]
E1 --> C1["check.yml が単独で動く"]
C1 --> R1["緑か赤を見せるだけ<br>公開はしない"]
M["マージする<br>mainにpushする"] --> E2["mainへのpush イベント"]
E2 --> D["deploy.yml が動く"]
D --> C2["check ジョブ<br>check.yml を呼ぶ"]
C2 -- "成功" --> S["deploy ジョブ<br>Cloudflareへ公開"]
C2 -- "失敗" --> X["deploy は実行されない<br>公開中のページはそのまま"]
check.ymlには入口が2つあります。プルリクエストの道は、マージする前に結果を知るためのものです。mainの道は、公開の前の関所です。
3. 手順
環境は前回と同じです。無料プランのPrivateリポジトリを使っています。チェックはGitHub Actionsの中で動くので、自分のPCにNode.jsは入れていません。
ファイルは2つ足し、1つ変えました。
tobaru-net-test/
├─ .htmlvalidate.json … チェックのルール(新規)
└─ .github/
└─ workflows/
├─ check.yml … チェックの指示書(新規)
└─ deploy.yml … チェックを呼ぶように変更
(1) チェックのルールを決める
HTMLのチェックにはhtml-validateを使いました。ルールを自分で作らなくても、できあいのrecommendedがあります。
.htmlvalidate.jsonです。
{
"extends": ["html-validate:recommended"]
}
(2) check.ymlを書く
name: Check
on:
# mainに向けたプルリクエスト
pull_request:
branches: [main]
# ほかのワークフローから uses: で呼ばれたとき
workflow_call:
jobs:
html:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
# ランナーにファイルを取り出す
- uses: actions/checkout@v5
# public/ の中の .html を調べる
- name: Validate HTML
run: npx --yes html-validate@11.18.0 public/
(3) deploy.ymlを変える
変えたのは、checkジョブの追加とneeds: checkだけです。
jobs:
# 公開の前の関所。中身は check.yml
check:
uses: ./.github/workflows/check.yml
deploy:
# check が成功したときだけ動く
needs: check
runs-on: ubuntu-latest
# これ以降は前回と同じ
4. 試してみた
結果を先に書くと、チェックが赤のとき、マージは止まりませんでしたが、公開は止まりました。
| やったこと | 結果 |
|---|---|
| そのままのページでプルリクエストを作る | 赤 |
| 指摘されたところを直して、同じブランチにpush | 緑 |
| 緑のままマージ | check→deployと動き、公開された |
| </main>を消して、赤のままマージ | マージはできたが、deployは実行されず、公開中のページは変わらなかった |
(1) 壊す前に、もう赤になった
ブランチをpushしただけでは、何も動きません。check.ymlの条件はプルリクエストで、deploy.ymlの条件はmainへのpushだからです。プルリクエストを作ると、Check / htmlが動き始めました。
!01_pr-running.png
わざと壊すのは、この次の予定でした。ところが、何も壊していないのに赤になりました。
!02_check-failed.png
ログを開くと、原因が書いてあります。
!03_validate-log.png
index.htmlの1行目の<!doctype html>が、小文字だと指摘されています。ブラウザでは正しく表示されていますが、recommendedのdoctype-styleというルールは大文字を求めます。
(2) 直してpushすると、緑になった
<!DOCTYPE html>に直して、同じブランチにpushしました。プルリクエストを作り直さなくても、チェックは自動で動き直します。
!04_check-passed.png
(3) 緑のままマージすると、公開された
マージするとmainへのpushが起きて、deploy.ymlが動きます。checkのあとにdeployが動き、公開中のページも<!DOCTYPE html>に変わりました。
!05_graph-success.png
(4) 赤のままマージしても、公開は止まった
いよいよ本番です。別のブランチで</main>を消して、プルリクエストを作りました。チェックは赤になりましたが、Merge pull requestのボタンは押せます。
!06_failed-but-mergeable.png
あえてマージすると、checkが赤になり、deployはスキップされました。0秒なのは、一度も始まっていないからです。
!07_graph-skipped.png
このときmainのコードからは</main>が消えていましたが、https://test.tobaru.netには</main>が残っていました。コードは壊れたが、公開中のページは守られたということです。
検証のあとは、壊した変更をgit revertで取り消して、もう一度プルリクエストからマージしました。チェックは緑で、公開も通っています。
5. なぜ公開が止まるのか
公開を止めているのは、deploy.ymlのneeds: checkです。画面の設定は何も変えていません。
flowchart TD
A["html-validate が間違いを見つける"] --> B["終了コード1で終わる"]
B --> C["check ジョブが失敗になる"]
C --> D["needs: check の条件を満たさない"]
D --> E["deploy ジョブはスキップ"]
E --> F["wrangler が動かない"]
F --> G["Cloudflare に何も届かない"]
G --> H["公開中のページはそのまま"]
(1) 赤と緑は、何で決まるの?
コマンドの終了コードで決まります。0なら成功、0以外なら失敗です。
4章のログの最後にも、Process completed with exit code 1.とあります。Actionsは「DOCTYPE should be uppercase」という文章を読んでいるのではありません。公式ドキュメントにも、ステップの成功と失敗は終了コードに基づいて報告されると書かれています(Workflow syntax)。
つまり、間違いがあるときに0以外で終わるコマンドなら、何でもチェックとして使えます。
(2) needs:がないと、どうなるの?
checkとdeployが同時に動くので、チェックが赤でも公開されるはずです(ここは試していません)。
ジョブは既定で並列に動きます。上から順に書いてあっても、順番はつきません。needs:を書くと、次の決まりが効きます。
> If a job fails or is skipped, all jobs that need it are skipped
必要としているジョブが失敗すると、そのジョブはスキップされます。この決まりのおかげで、deployが赤を判断する処理は一切書いていません。
(3) なぜcheck.ymlを別のファイルにしたの?
プルリクエストとmainの両方で、同じチェックを使いたかったからです。
ただ、needs:は同じファイルの中のジョブしか指定できません。そこでcheck.ymlのon:にworkflow_callを書いて、ほかのワークフローから呼べるようにしました(公式ドキュメント)。deploy.ymlのcheckジョブは、それをuses:で呼んでいるだけです。
deploy.ymlの中にジョブとしてcheckがあるので、needs: checkと書けます。
プルリクエストのときのチェックと、マージのときのチェックは別の実行です。公開を止めているのは、マージのときの結果です。
(4) なぜマージは止まらなかったの?
無料プランのPrivateリポジトリでは、マージを止めるルールが効かないからです。
GitHubには、チェックが緑にならないとマージできなくする設定があります。ただ、SettingsのRulesetsを開くと、次の警告が出ていました。
!08_rulesets-warning.png
古い形式のBranch protection ruleも同じです。ルールは作れますが、リポジトリをTeamなどの組織アカウントに移すまで適用されません。
!09_branch-protection-warning.png
だから、止めているものを分けて考える必要があります。
| しくみ | 止めるもの | 今回の環境 |
|---|---|---|
| Rulesets・Branch protection rule | マージ | 効かない |
| needs: check | 公開 | 効く |
needs:では、mainのコードが壊れることは防げません。防げるのは公開だけです。それでも、ワークフローのファイルに書くだけなので、無料プランでも使えます。
6. 前回と違うところ
しくみ自体は前回と同じで、イベントが起きたらon:に合うワークフローが動きます。初めて出てきたものだけを説明します。
(1) pull_requestは、いつ動くの?
types:を書かなければ、opened・synchronize・reopenedの3つで動きます(公式ドキュメント)。
| 名前 | いつ |
|---|---|
| opened | プルリクエストを作った |
| synchronize | ブランチに新しいcommitをpushした |
| reopened | 閉じたプルリクエストを開き直した |
4章の(2)で、pushしただけでチェックが動き直したのはsynchronizeのおかげです。これも前回と同じ、書かなかったときの既定値です。
(2) uses:のactions/checkout@v5は何?
持ち主/リポジトリ@バージョンの形で、GitHub上で公開されている部品を借りる書き方です。actions/checkoutは、GitHubが公開しているactions/checkoutのことです。
前回のcloudflare/wrangler-action@v4も、同じ形です。
(3) なぜcheckoutが必要なの?
ランナーは毎回新しく用意され、リポジトリのファイルは入っていないからです。この行がないと、調べるpublic/がありません。
プルリクエストのときに取り出されるのは、ブランチの先頭ではありません。actions/checkoutは既定で、イベントの対象のcommitを取り出します(action.yml)。pull_requestの場合、それはrefs/pull/番号/mergeです。
つまり、「mainにマージしたらこうなる」という仮のcommitを調べています。ブランチ単体では問題がなくても、マージ後に壊れる場合に気づけます。
(4) run:は何?
ランナーのシェルでコマンドを実行する書き方です。今回のコマンドはこれだけです。
npx --yes html-validate@11.18.0 public/
| 部分 | 意味 |
|---|---|
| npx | npmのパッケージを取ってきて実行する。package.jsonがなくても動く |
| --yes | インストールの確認に答える人がいないので、先に「はい」と渡す |
| @11.18.0 | バージョンを固定する。新版のルール追加で、ある日突然赤にならないように |
| public/ | 調べるフォルダ。中の.htmlをすべて調べる |
プルリクエストで変更したファイルだけを調べるのではありません。実際、4章の(1)の赤は、プルリクエストで変更していないindex.htmlで出たものです。
今回は小さなページ1つなので、全部調べても問題ありません。規模が大きくなって時間がかかるようになったら、変更したファイルだけに絞ることも検討が必要かもしれません。
Node.jsを入れる手順はありません。GitHubのランナーには最初からNode.jsが入っています。
7. ほかの言語のチェックツール
今回はHTMLだけですが、言語が変わってもチェックの入れ方は同じです。run:に書くコマンドが変わるだけです。
| 対象 | ツール | チェックするもの |
|---|---|---|
| HTML | html-validate(今回使った) | 文法・書き方 |
| HTML | HTMLHint | 書き方 |
| HTML | lychee | リンク切れ |
| JavaScript / TypeScript | ESLint | 書き方 |
| TypeScript | tsc | 型 |
| Python | Ruff | 書き方 |
| Python | mypy | 型 |
| 共通 | Prettier | 整形 |
| 共通 | gitleaks | コードに混ざったパスワードなど |
こうしたツールには、今回のrecommendedのようにできあいのルールが用意されているものが多くあります。自分で作り込む必要があるのは、アプリの動きを確かめるテストの中身くらいです。
8. まとめ
プルリクエストの時点で間違いに気づけて、うっかりマージしてしまっても、公開中のページは守られます。この2つを実際に確かめられたので、今回の検証は成功だと思います。
今回分かったことを並べると、次の3つです。
- 赤と緑を決めるのは、コマンドの終了コード。Actionsは出力の文章を読んでいない
- ジョブは既定で並列に動く。needs:を書くと、相手が失敗したときにスキップされる
- 無料プランのPrivateリポジトリではマージは止められない。それでも、公開はneeds:で止められる
次は、プルリクエストを作るとAIがレビューしてくれるしくみを試す予定です。
@edgarrmondragon And thank you for the PR that unblocked 3.16! 🚀
1 like
CI for an ASP.NET MVC 5 app on .NET Framework 4.8 with GitHub Actions
An ASP.NET MVC 5 application on .NET Framework 4.8 needs a repeatable build long before it needs automated production deployment. A good first workflow restores packages, compiles the solution, runs the existing tests and produces a web deployment package someone can inspect.
This is a setup guide built from official documentation that I re-read on November 2, 2026, and the workflow below is not just a sketch: I ran it unchanged against a minimal public sample, legacy-mvc48-ci-sample, an MVC 5 app on .NET Framework 4.8 with two MSTest tests. That run on windows-2022 restored, built, passed 2 of 2 tests, produced the package and uploaded both artifacts. A real application will have more projects and dependencies, so the paths and names are still yours to adapt.
The Windows runner
GitHub's .NET build and test guide is written around the dotnet CLI, which fits SDK-style projects. A classic MVC 5 web project with packages.config is usually built with Visual Studio's MSBuild instead, so the runner matters.
The Windows Server 2022 runner inventory, image version 20261026.347.1 when I checked, lists Visual Studio Enterprise 2022, the MSBuild component, vswhere and .NET Framework 4.7.2, 4.8 and 4.8.1. That inventory changes over time, so record the image version shown in each workflow run.
Write down the repository contract first
Before touching YAML, list the solution path, the web project, the test projects, the build configuration and what the package should be called. Also list everything that only exists on a developer's machine: private NuGet feeds, DLLs referenced from a local folder, generated files, custom build tasks. Each one either gets an authorized way into CI or becomes an explicit blocker. That list is usually where legacy builds fail, not in the YAML.
Restore packages.config dependencies
Microsoft's nuget restore reference accepts a solution file, so nuget restore LegacyMvc.sln restores every packages.config in it. Keep it as its own step so a restore failure is not confused with a compile error.
NuGet/setup-nuget puts nuget.exe on the runner; its latest release on November 2 was v4.0. nuget-version: latest is convenient for a first run, and the README also accepts version ranges such as 5.x, so pin one once the build works. If you use private feeds, supply credentials through secrets and the feed's documented method, never by committing a developer's NuGet.Config.
Build, test, package
microsoft/setup-msbuild finds MSBuild with vswhere and adds it to PATH. Its README is explicit that it "does NOT add other Visual Studio tools (like VSTest…)" to PATH, so the test step below locates vstest.console.exe itself. The MSBuild switches used here (/m, /t, /p) are in the MSBuild command-line reference.
For packaging, Microsoft's guide to building and packaging web application projects shows DeployOnBuild=true with DeployTarget=Package: after the build, the Web Publishing Pipeline runs its Package target and produces [project name].zip, plus a .deploy.cmd and a SetParameters.xml next to it. The template searches for that zip instead of assuming a folder.
Tested on the sample repository above, on November 2, 2026. Save an adapted copy as .github/workflows/legacy-mvc.yml. The action versions are the current major tags from each project's README or releases page; for stricter supply-chain control, pin reviewed commit SHAs.
name: Legacy MVC CI
on:
pull_request:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: read
jobs:
build:
runs-on: windows-2022
defaults:
run:
shell: pwsh
steps:
- uses: actions/checkout@v7
- uses: microsoft/setup-msbuild@v3
- uses: NuGet/setup-nuget@v4
with:
nuget-version: latest
- name: Restore packages.config dependencies
run: |
nuget restore LegacyMvc.sln -NonInteractive
if ($LASTEXITCODE -ne 0) { throw 'Restore failed' }
- name: Build solution
run: |
msbuild LegacyMvc.sln /m /t:Build /p:Configuration=Release '/p:Platform=Any CPU'
if ($LASTEXITCODE -ne 0) { throw 'Build failed' }
- name: Run existing tests
run: |
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
$vs = & $vswhere -latest -products '*' -property installationPath
$vstest = Join-Path $vs 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe'
$tests = 'tests\LegacyMvc.Tests\bin\Release\LegacyMvc.Tests.dll'
if (!(Test-Path $vstest)) { throw 'vstest.console.exe not found' }
if (!(Test-Path $tests)) { throw 'Test assembly not found' }
& $vstest $tests '/Logger:trx;LogFileName=tests.trx' '/ResultsDirectory:TestResults'
if ($LASTEXITCODE -ne 0) { throw 'Tests failed' }
[xml]$trx = Get-Content 'TestResults\tests.trx' -Raw
if ([int]$trx.TestRun.ResultSummary.Counters.total -lt 1) { throw 'No tests were discovered' }
- name: Create web deployment package
run: |
msbuild src\LegacyMvc\LegacyMvc.csproj /p:Configuration=Release /p:DeployOnBuild=true /p:DeployTarget=Package
if ($LASTEXITCODE -ne 0) { throw 'Packaging failed' }
$zip = Get-ChildItem src\LegacyMvc -Recurse -Filter 'LegacyMvc.zip' | Select-Object -First 1
if (!$zip) { throw 'LegacyMvc.zip not found' }
New-Item -ItemType Directory -Force artifacts | Out-Null
Get-ChildItem $zip.DirectoryName | Copy-Item -Destination artifacts
- name: Upload package
uses: actions/upload-artifact@v7
with:
name: legacy-mvc-package
path: artifacts/
if-no-files-found: error
- name: Upload test results
if: always()
uses: actions/upload-artifact@v7
with:
name: legacy-mvc-test-results
path: TestResults/
if-no-files-found: warn
Make "tests ran" an actual check
The VSTest.Console options cover the test assembly arguments, /Logger and /TestAdapterPath. MSTest, NUnit and xUnit each need their adapter available to the runner; if discovery fails on CI but works locally, /TestAdapterPath is the first thing to look at. In the sample, MSTest 4.5.2 installed through packages.config was discovered without it.
The template reads the TRX file and fails when zero tests were reported, because a test step that exits cleanly after discovering nothing looks green and proves nothing. If the repository has no tests, say so in the handover instead of advertising coverage. And remember what unit tests don't cover: IIS configuration, the real database and browser behavior need their own checks.
Inspect the package before trusting it
Download the artifact and open it. The same Microsoft guide explains that SetParameters.xml is generated from the project and can include connection strings found in web.config, so this is exactly where a production connection string can leak into an artifact. A zip that exists is a good sign, not proof that it deploys.
If the project already has reviewed publish settings, you can keep them in a profile under Properties/PublishProfiles/ and select it with PublishProfile, as Microsoft's command-line deployment tutorial does. That tutorial lists three command-line publish methods, MSDeploy, Package and FileSystem; for CI you want the profile to use Package, not publish straight to a server. It was written for Visual Studio 2012 and notes that building a single web project then also needed /p:VisualStudioVersion, so if MSBuild cannot find the web application targets, that property is worth checking.
Keep production behind a separate gate
No passwords, tokens or production connection strings in the YAML or in committed publish profiles. When you add a deployment job later, point it at a protected environment with required reviewers, which GitHub documents in its deployments and environments reference. Naming an environment in YAML does not create the approval rule by itself. Check your plan, too: the same page says that on GitHub Free, Pro and Team, required reviewers "are only available for public repositories", and most legacy business apps live in private ones.
The first milestone is modest and useful: a package you inspected and test results you can read. Production deployment comes after staging works, access is approved and there is a rollback plan.
If you have an older ASP.NET MVC or .NET Framework app that needs a build like this, or a focused fix, I offer a fixed-scope service on Upwork: upwork.com
初めてのCI/CD。pushしただけで公開まで流れる理由を、図で理解した(GitHub Actions + Cloudflare Workers)
1. はじめに
CI/CDという言葉は知っていましたが、なかなか勉強するまでには至りませんでした。それが仕事で使うことになり、とうとう勉強を始めることにしました。
とはいえ、どこから手をつければいいのか分かりません。調べると記事はたくさんあり、手順も丁寧に書かれています。ただ、どれも「こう書けば動く」が中心で、なぜpushしただけで最後まで流れるのかは、読んでもいまいち分かりませんでした。
幸い、今はAIという優秀なパートナーがいます。そこで今回は、AI(Claude Code)に助けてもらいながら、自分の環境で1つずつ検証していきました。
まず作ったのは、GitHubにpushすると、GitHub ActionsがCloudflare Workersへ自動で公開してくれるしくみです。画面で操作したのは3つ、書いたファイルも3つだけで、pushすると公開まで勝手に進んでいきます。実際に動かしてみても、やはり疑問は「何で流れるの?」、これだけでした。
この記事では、手順に加えて、なぜ最後まで自動で流れるのかを図で説明します。同じところでもやもやしている人の参考になればうれしいです。
なお、記事の内容はすべて自分の環境で動かして確かめていますが、CI/CDは初心者です。間違いなどがあれば、コメントで教えていただけるとうれしいです。
2. 作ったもの
GitHubのmainブランチにpushすると、約30秒で https://test.tobaru.net のページが更新されるしくみです。ページはHTMLファイル1つだけの、いちばん小さなものにしました。
ひとことで言うと、GitHubに置いたファイルを、GitHubのロボット(Actions)がCloudflareまで運んでくれます。運ぶときにCloudflareのドアを開けるので、ロボットに合鍵(APIトークン)を預けてあります。
!04_overview.png
自分で用意したサーバーはありません。DNSのレコードとhttpsの証明書も、手では作っていません。
3. 環境
| 項目 | 内容 |
|---|---|
| GitHub | Freeプラン、Privateリポジトリ |
| Cloudflare | Freeプラン |
| ドメイン | Cloudflare Registrarで取得したtobaru.net(DNSもCloudflare) |
| デプロイの道具 | cloudflare/wrangler-action@v4、wrangler 4 |
| 作業したPC | Windows 11、VS Code、Git |
自分のPCにNode.jsやwranglerは入れていません。wranglerはGitHub Actionsの中でだけ動きます。
ドメインのDNSがCloudflareにあることが、後で出てくる「1行でDNSと証明書ができる」の条件になります(5章の(5))。
4. 手順
画面で操作したのは(1)〜(3)の3つだけです。(4)はファイルを書くだけ、(5)はpushするだけです。
(1) GitHubにリポジトリを作る
- 名前はtobaru-net-test、Privateにしました
- README・.gitignore・ライセンスは付けず、空のリポジトリにします(ローカルで作ったファイルをそのままpushするため)
(2) CloudflareでAPIトークンを作る
1. 右上の人のアイコン → プロフィール → APIトークン → トークンを作成する
2. テンプレート「Cloudflare Workers を編集する」を使う
3. 範囲を絞る
- アカウントリソース: 自分のアカウントだけ
- ゾーンリソース: 特定のゾーン → tobaru.netだけ
4. 作成する。トークンは一度しか表示されないので、そのまま(3)に貼る
範囲を絞っておくと、万一トークンが漏れても、できることは「Workersの更新」と「tobaru.netの設定」だけです。
(3) GitHubのSecretsに登録する
リポジトリの Settings → Secrets and variables → Actions → New repository secret で、2つ登録します。
| Name | 値 |
|---|---|
| CLOUDFLARE_API_TOKEN | (2)で作ったトークン |
| CLOUDFLARE_ACCOUNT_ID | CloudflareのアカウントID |
(4) ファイルを3つ書く
tobaru-net-test/
├─ public/
│ └─ index.html … 見せるページそのもの
├─ wrangler.jsonc … Cloudflareへの注文書(何を・どこで出すか)
└─ .github/
└─ workflows/
└─ deploy.yml … Actionsへの指示書(いつ・何をするか)
public/index.htmlは、ふつうのHTMLです(中身は省略)。
wrangler.jsoncです。ふつうのJSONはコメントが書けませんが、jsonc(JSON with Comments)は//で書けます。
{
// Cloudflare側でのWorkerの名前(名札)。
// この名前のWorkerがなければ新しく作り、あれば中身を置き換える
"name": "tobaru-net-test",
// Workersの動き方の基準日
"compatibility_date": "2026-10-01",
// 静的なファイル(HTML・画像など)の置き場所。この中身がそのままサイトに並ぶ
"assets": {
"directory": "./public"
},
// どのURLで出すか。custom_domain: true でDNSレコードと証明書も作ってくれる
"routes": [
{ "pattern": "test.tobaru.net", "custom_domain": true }
],
// xxx.workers.dev というCloudflareのURLでは公開しない
"workers_dev": false
}
.github/workflows/deploy.ymlです。
name: Deploy
# いつ動くか(イベントの条件)
on:
push:
branches: [main] # mainブランチにpushされたとき
workflow_dispatch: # Actionsタブの「Run workflow」ボタンで手動でも動かせる
jobs:
deploy:
runs-on: ubuntu-latest # GitHubが数十秒だけ貸してくれるLinux
permissions:
contents: read
steps:
# 1. このcommitのファイル一式をランナーに取り出す
- uses: actions/checkout@v5
# 2. wranglerをインストールして動かす(commandを書かなければ wrangler deploy)
- name: Deploy to Cloudflare Workers
uses: cloudflare/wrangler-action@v4
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} # 金庫の合鍵
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} # どのアカウントに送るか
wranglerVersion: "4"
(5) pushする
git init -b main
git add -A
git commit -m "最初のページとデプロイ設定"
git remote add origin https://github.com/<ユーザー名>/tobaru-net-test.git
git push -u origin main
pushしてから約30秒で、https://test.tobaru.netが開けるようになりました。10秒おきに確かめると、最初の3回はつながらず、4回目で200が返ってきました。
5. なぜ最後まで自動で流れるのか
答えを先に書くと、何かがずっと監視しているわけではありません。「イベントが起きたら、こう動く」という小さな決まりが、前の結果を受け取ってつながっているだけです。
pushしてから公開されるまでを、5つの段階に分けました。
!05_sequence.png
画像をクリックすると、大きく表示されます。
ここからは、私が分からなかったところを順に説明します。
(1) GitHubはフォルダを監視しているの?
監視はしていません。pushやプルリクエストなどのイベントが起きたときにだけ、GitHubがそのcommitの.github/workflows/を開きます。中のYAMLを全部読んで、on:の条件に合うものだけを動かします。
それが分かる例です。
| こんなとき | デプロイは動く? |
|---|---|
| README.mdだけ変えてpush | 動く。deploy.ymlは変わっていないが、「mainへのpush」というイベントが起きたから |
| deploy.ymlを書き換えたが、pushしていない | 動かない。GitHubは自分のPCの中を見ていない |
| deploy.ymlを書き換えてpush | 書き換えた後の内容で動く。そのcommitのファイルを読むから |
| 別のブランチにpush | 動かない。on:がbranches: [main]だから |
| プルリクエストをmainにマージ | 動く。マージするとmainにcommitが入り、mainへのpushと同じになるから |
「受付の人がマニュアルをずっと見張っている」のではありません。「呼び鈴が鳴ったらマニュアル棚を開いて、今回の用件に当てはまるページを探す」イメージです。マニュアル棚が.github/workflows/、ページの見出しがon:です。
(2) ファイルを指定していないのに、なぜwrangler.jsoncが使われるの?
deploy.ymlのデプロイの手順には、「どのファイルを送るか」を書いていません。それでも動くのは、書かなかったときの決まり(既定値)が2段階あるからです。
1. wrangler-actionの決まり: commandを書かなければdeployを実行する(action.ymlに「Defaults to "deploy"」とあります)。workingDirectoryも書かなければ、リポジトリのいちばん上で実行する
2. wranglerの決まり: wrangler deployは、実行したフォルダでwrangler.jsonc / wrangler.json / wrangler.tomlを探して読む
しかも、wrangler.jsoncそのものは送られません。「何を・どこに・どんな名前で」の指示として読まれ、送られるのはassets.directoryに書いたpublic/の中身です。
決まった名前・決まった場所に置けば何も書かなくても動き、変えたいときだけ書く。この考え方を「設定より規約(Convention over Configuration)」と呼ぶそうです。(1)の.github/workflows/も同じです。
(3) Cloudflareで何も作っていないのに、なぜWorkerができるの?
wrangler.jsoncの"name": "tobaru-net-test"で、「この名前のWorkerを、この中身にして」と頼んでいるからです。Cloudflareは、その名前のWorkerがなければ新しく作り、あれば中身を置き換えます。上の図のaltのところです。
どのアカウントに作るかはaccountId、入っていいかはapiTokenで伝えています。どちらもSecretsから渡しているので、deploy.ymlには値そのものを書いていません。
(4) Secretsは何のため?
Secretsは合鍵を入れておく金庫です。deploy.ymlには${{ secrets.CLOUDFLARE_API_TOKEN }}と、「金庫のこの引き出しを使って」とだけ書きます。合鍵そのものはどのファイルにも書かないので、コードを人に見せても漏れません。
(5) 1行でDNSと証明書までできるの?
できました。routesに"custom_domain": trueと書くと、「このホスト名をまるごとこのWorkerにする」意味になり、CloudflareがDNSのレコードとhttpsの証明書を作ってくれます。
ただし、次の2つがそろっているからできることです。
- tobaru.netのDNSが、同じCloudflareアカウントにある(Cloudflareが自分でレコードを書き込める)
- APIトークンにtobaru.netを触る権限がある(4章の(2)でゾーンを絞って許可した)
DNSが別の会社にあるドメインでは、この1行だけでは済みません。
custom_domainを付けずに"pattern": "test.tobaru.net/api/*"のように書くと、「このURLに来たアクセスだけWorkerが受け取る」という別の使い方になります。この場合、DNSのレコードは自分で作ります。
6. 警告が出たところ
最初は、他の記事と同じくcloudflare/wrangler-action@v3を使っていました。デプロイは成功しましたが、Actionsの結果のAnnotationsに、警告1件とお知らせ1件が出ました。
!01_annotations.png
(1) Node.js 20 is deprecated(警告)
> Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: cloudflare/wrangler-action@v3.
wrangler-action@v3はNode.js 20向けに作られていて、GitHub側がNode.js 20を廃止していく途中なので、Node.js 24で動かしているという知らせです(GitHubのお知らせ)。今は動いていますが、将来動かなくなるおそれがあります。
調べるとv4が出ていて、action.ymlにusing: "node24"とありました。入力の項目(apiToken・accountIdなど)は同じなので、@v3を@v4に変えるだけで警告が消えました。
この修正もdeploy.ymlを1文字直してpushしただけです。しくみそのものの設定を変えた場合も、ページを変えた場合と同じ流れで反映されます(5章の(1)の表の3行目)。
(2) ubuntu-latestがUbuntu 26に移る(お知らせ)
> The ubuntu-latest label will migrate to Ubuntu 26 beginning October 19, 2026.
runs-on: ubuntu-latestの中身が、順に新しいUbuntuに切り替わるという案内です(runner-imagesのIssue)。今回のワークフローはwranglerを動かすだけなので、そのままにしています。
(3) nameを変えるときの注意
つまずいたわけではありませんが、しくみから考えると注意が必要だと分かったところです。
nameを変えてpushすると、Cloudflareは名前が変わったとは受け取りません。「その名前のWorkerがないので新しく作る」と受け取ります(5章の(3))。古いWorkerは残ったままになるので、変えるときは古いほうの片付けも必要です。
7. Cloudflareの画面で確かめる
Cloudflareの画面では何も作っていませんが、pushしたあとに開くと、次の2つができていました。
(1) Workersの一覧
「Workers & Pages」を開くと、wrangler.jsoncのnameで付けたtobaru-net-testがあり、routesで頼んだtest.tobaru.netがつながっていました。
!02_workers.png
「デプロイメントを表示」を開くと、pushした回数ぶんの履歴が並んでいます。GitHubのcommitと1回ずつ対応しています。
(2) DNSのレコード
tobaru.netのDNSのページには、自動で作られたtestのレコードがありました。自分では1つも作っていないのに、本当にレコードが出ていてびっくりしました。
!03_dns.png
| 見えているもの | 意味 |
|---|---|
| 左端の鍵のアイコン | 自動で作られたレコードなので、ここからは編集できない |
| タイプ「Worker」 | AやCNAMEではなく、「このWorkerに届けて」という特別なレコード |
| コンテンツ「tobaru-net-test」 | wrangler.jsoncのname。名札でつながっている |
| 「プロキシ済み」 | アクセスはいったんCloudflareが受け取る |
外のDNS(1.1.1.1)にtest.tobaru.netを聞いてみると、返ってくるのはCloudflareのIPアドレス(104.21.x.xなど)でした。「プロキシ済み」なので、サーバーの場所ではなくCloudflareのアドレスが返ります。応答のヘッダーにはServer: cloudflareとCF-RAY: …-NRTがあり、東京の拠点から返っていることも分かりました。
8. まとめ
最初は、なぜ自動で最後まで流れるのかが、いまいち理解できませんでした。でも、監視という大掛かりな仕組みではありませんでした。イベントが起きたときの動きが決まっていて、その決まりどおりに動くと、流れるように最後までつながる。それが分かったとき、美しいなーと思いました。DNSの画面に、自分では作っていないレコードが出ていたときも感動しました。
今回分かったことを並べると、次の3つです。
- GitHubは監視していない。イベントが起きたときに.github/workflows/を読み、on:に合うものだけを動かす
- 書いていないことは既定値で動く。wrangler-actionはdeployを実行し、wranglerは決まった名前のwrangler.jsoncを探す
- CloudflareはnameでWorkerを見分け、なければ作り、あれば置き換える。custom_domain: trueならDNSと証明書も作る
次は、プルリクエストを作ったらチェックだけを動かし、mainにマージしたらデプロイする形(CIとCDの分離)を試す予定です。チェックも、pull_requestというイベントに決まりを足すだけで作れるはずです。
1 like
CIを最大4.6倍高速化する方法
!
この記事は2023年11月1日に執筆されたものです。
こんにちは、InflabのDevOpsエンジニアのJakeです。
皆さんは、CIパイプラインの実行に時間がかかりすぎて、PRレビューに影響が出たことはありませんか?
プロジェクトの規模が大きくなるにつれて長くなっていくCI/CDパイプラインの所要時間に、頭を悩ませたことはありませんか?
本記事では、CIを最大4.6倍まで高速化できたノウハウを共有します。
依存関係とビルドのキャッシュがヒットした場合の数値
最適化の結果
この記事のポイント
CI(継続的インテグレーション)の所要時間を最大79%削減
Jenkins Pipelineの最適化:
ビルド、テストを実行するDockerコンテナの依存関係キャッシュのパスを設定
モノレポのCIにインクリメンタルビルドを適用: turborepo remote cache
シンプルながら非常に効果的で、簡単に適用できるCI/CDパイプライン最適化のノウハウを共有します。
最適化の背景
Inflabの開発チームではモノレポを導入し、パッケージ単位でコードを管理しています。プロジェクトの規模が大きくなるにつれてコードベースとパッケージが増え、CIの所要時間が急増しました。
プロジェクト初期: パッケージ1個、4分14秒
プロジェクト後期: パッケージ9個、12分4秒
プロジェクトの成長とともにコードで使うライブラリがどんどん増え、依存関係(dependency)のインストール時間が長くなりました。
また、ビルドとテストを順番に実行していたため、所要時間はどんどん積み上がっていきました。ビルドパイプライン側で直接並列実行させるなどの手も試しましたが、依存するパッケージ間の複雑な関係や並行性の問題があり、別の解決策を探す必要がありました。
そこで pnpm と turborepo を導入し、モノレポプロジェクトの生産性を改善することになりました。
Yarnとpnpmの比較
次のグラフは、npm、pnpm、yarn、yarn PnPのベンチマークです。
画像出典: pnpm.io
グラフのとおり、依存関係のインストールでは条件によって約2倍速くなっています。私たちがまとめたそれぞれの特徴は次のとおりです。
yarn
依存関係のインストール時間: 最大120秒
複雑な node_modules の構造により、ディスクI/OとCPUリソースを消費
pnpm
依存関係のインストール時間: 最大63秒
symlinkでグローバルキャッシュのパス(.pnpm-store)にリンク -> 不要なファイルコピーが減少 -> ディスクI/Oが減少
node_modules
└── .pnpm
├── bar@1.0.0
│ └── node_modules
│ └── bar -> <store>/bar
│ ├── index.js
│ └── package.json
└── foo@1.0.0
└── node_modules
└── foo -> <store>/foo
├── index.js
└── package.json
これまで使っていた yarn には、依存関係をインストールすると複雑な node_modules ディレクトリ構造のせいでディスクI/OとCPUリソースが無駄に消費されるという欠点がありました。そのため、より高スペックなビルドサーバーが必要になり、所要時間も長くなっていました。
pnpm の導入後は、すべての依存パッケージを .pnpm-store にインストールし、各パッケージ内の node_modules ディレクトリにはsymlinkを張るだけの形になりました。これによりディスクへの書き込みが減り、依存関係のインストール時間を約60秒短縮できました。
Turborepo
Turborepoを説明する前に、モノレポについて少しおさらいしておきます。
Monorepoとは?
画像出典: monorepo.tools
モノレポ(Monorepo)とは?
A monorepo is a single repository containing multiple distinct projects, with well-defined relationships.
モノレポとは、関係が明確に定義された複数の個別プロジェクトを含むリポジトリを指す。
モノレポを導入すると、毎回多数のリポジトリを作成・管理する手間を減らせます。詳しくはリンク先をご参照ください。
関係が明確に定義されておらず、カプセル化もされていない単なるコードの寄せ集めはモノレポではなく、Monolithとも異なると説明されています。
このようにモノレポでは、複数のアプリやライブラリを1つのリポジトリで管理するため、同じ作業を多数のリポジトリに繰り返し適用する必要がありません。
しかし、モノレポのコードベースの規模が大きくなるとどうなるでしょうか?
ビルドとテストを順次実行することによる所要時間の増加
変更されていない部分まで、不要なビルド & テストを繰り返し実行
次の例は、モノレポのCIパイプラインで、変更されたパッケージのビルドだけを並列実行するよう構成したものです。
stage('Docker Build') {
parallel {
stage('Package A') {
when {
anyOf {
changeset "cmd/package-alpha//*"
changeset "docker/package-alpha//*"
}
}
steps {
sh 'docker build -t package-alpha -f docker/package-alpha/Dockerfile .'
}
}
stage('Package B') {
when {
anyOf {
changeset "cmd/package-beta//*"
changeset "docker/package-beta//*"
}
}
steps {
sh "docker build -t package-beta -f docker/package-beta/Dockerfile ."
}
}
...(中略)...
プロダクトや組織の規模に応じて適切にリポジトリを分ける方法もありますが、結局のところ、モノレポを専用のツールなしで上記の例のようにCIパイプラインで直接管理しようとすると、かなりの工数がかかるでしょう。
パッケージが追加されるたびに手作業でCIパイプラインを修正するのは、本当に大変です。(とにかく手間がかかります)
パッケージ同士が複雑な依存関係で絡み合っている場合、それをすべてCIパイプラインで実装するのは困難です。
こんなときに必要になるのが、turborepo や nx のようなモノレポツールです。
さまざまなモノレポツールがありますが、本記事では私たちが導入した turborepo を中心に説明します。
主な機能
turborepo は、JavaScriptおよびTypeScriptのコード向けの高性能なビルドシステムです。公式ページで説明されている特徴は次のとおりです。
Incremental Builds
Content-aware hashing
Parallel execution
Remote Caching
Zero runtime overhead
Pruned subsets
Task pipelines
Meets you where you’re at
Profile in your browser
ここで強調したいのは、Incremental Builds、Content-aware hashing、Parallel execution、Remote Cachingです。順番に見ていきましょう。
Incremental Builds
日本語に直訳すると「増分ビルド」です。
追加・変更された部分だけをテスト & ビルドすることで、大規模なモノレポのCI所要時間を大幅に短縮します。
Detecting affected projects/packages
Content-aware hashing
コードの内容からハッシュ値を計算し、変更を検知します。
Gitに依存するchangesetとは対照的です。
Parallel execution
従来はビルドを順番に実行していたため、CPUリソースに無駄がありました。
ビルドを並列で実行することで、CPUコアを効率的に使い、ビルドをより早く終わらせることができます。
Local Task Orchestration
Remote Caching
Local Computation Caching
taskの処理結果をローカルに保存して再利用する機能です。同じマシンでは、同じ状態のコードに対するビルドやテストを2回以上行いません。
Distributed Computation Caching
Local Computation Cachingを拡張した概念で、taskの処理結果を1台のマシンではなく複数の環境にまたがって共有します。
CIエージェントのいずれかがすでにそのコードの状態をビルド・テストしていれば、ほかのエージェントがそれを再実行することは決してありません。
Remote Cachingは、Distributed Computation Cachingと似た概念です。
同じコードベースを修正する作業者が複数人いると仮定しましょう。
作業者Aが先にビルドとテストを実行していた場合、その結果は共用のキャッシュサーバーに保存されます。
続いて作業者Bがビルドとテストを実行するときは、共用キャッシュサーバーにキャッシュされた作業者Aの処理結果を読み込み、不要なビルドの実行をスキップします。
共用キャッシュサーバーにはビルドの成果物ファイルもあわせて保存されるため、それをすぐにデプロイに活用できます。同じ作業が繰り返し実行されるCIパイプラインを効果的に改善できます。詳しい数値は後ほど共有します。
上記の4つの機能をうまく活用すれば、大規模なプロジェクトもぐっと楽に管理できます。
ところで、なぜ本記事のタイトルはTurborepoの導入ではなく、CIの改善なのでしょうか?
いくつか見落としていた点があり、これらのメリットを活かしきれていなかったからです。ここから説明していきます。
CI所要時間の最適化
Inflabでは、Jenkinsを使ってCI/CDパイプラインを構築しています。
CIジョブがトリガーされると、JenkinsのEC2エージェント上でDockerイメージを起動し、次の順序でジョブを実行します。
依存関係のインストール(Install Dependencies)
テスト(Test)
ビルド(Build)
静的解析(Static Analysis)
依存関係のインストール
依存関係のインストール処理をまとめると、次のとおりです。
pnpm i --frozen-lockfile
必要な依存ライブラリのバージョンを確認
--frozen-lockfile: 必要な依存関係のマニフェストファイル(pnpm-lock.yaml)が存在するかを確認し、記載されたバージョンに従って依存関係をインストール
npmjs.com、GitHub Registry のようなパッケージリポジトリから依存関係をダウンロードしてインストール
post-installスクリプトを実行
先ほど、pnpm を導入しただけで依存関係のインストール時間を約60秒短縮できたとお伝えしました。
実行環境が毎回変わるCI環境では、依存関係のキャッシュを導入すれば、さらに多くの時間を節約できます。
環境ごとの依存関係インストールの結果
ローカル環境では、pnpm i を実行するとグローバルな依存関係キャッシュのディレクトリからキャッシュを読み込み、不要なインストールをスキップします。一方、CI環境では実行サーバーが毎回変わる可能性があるため、依存関係のキャッシュを別途設定する必要があります。
ローカル環境: インストールしたすべての依存関係はグローバルキャッシュのディレクトリ(~/Library/pnpm/store/*)に蓄積
一度インストールした依存関係をほかのプロジェクトでも参照し、不要なインストールを省略(キャッシュヒット時は3秒前後)
CI環境: 実行サーバーが毎回変わる可能性があるため、依存関係のキャッシュを別途設定しなければ毎回インストールが走る(約60秒)
CI環境での依存関係のキャッシュ
CI環境で依存関係をキャッシュする方法には、主に次のようなものがあります。
圧縮: node_modules のパスをtar & gzipで圧縮
CI用のキャッシュイメージを作る: 依存関係をあらかじめインストールしたDockerイメージを作成し、それをビルドに活用する方法
キャッシュボリュームのマウント: build agentに残っているglobal cacheを活用する方法
圧縮
GitHub Actions actions/cache@v3 の例
...
- id: cache
uses: actions/cache@v3
with:
path: '/node_modules'
key: ${{ runner.os }}-node-${{ hashFiles(format('/{0}', steps.hash-file.outputs.hash-file)) }}
...
Jenkins Job Cacherプラグインの活用例
cache(maxCacheSize: 250, defaultBranch: 'develop', caches: [
arbitraryFileCache(path: 'node_modules', cacheValidityDecidingFile: 'package-lock.json')
]) {
// ...
}
依存関係が入った node_modules ディレクトリをtarballにまとめ、gzipやxzなどで圧縮する方法です。
キャッシュは pnpm-lock.yaml のようなマニフェストファイルのハッシュで区別されるため、必要な依存関係が変わるたびにインストールし直す必要があるのが欠点です。
また、node_modules ディレクトリの圧縮は基本的にシングルスレッドで行われるため、時間がかかります。
プロジェクトの規模が大きくなると node_modules ディレクトリも大きく複雑になるため、キャッシュファイルのダウンロードと展開だけで2分以上かかることもあります。
これでは本末転倒なので、いっそ圧縮しないほうが良い場合もあります。
プロジェクトの規模が大きい場合
pnpm install の所要時間: 60秒
キャッシュファイルのダウンロードと展開の所要時間: 120秒以上
場合によっては、実際に所要時間を計測・比較したうえで判断する必要あり
GitHub Actionsを使う場合は、pnpmの公式ドキュメントのガイドに従い、cache: 'pnpm' を設定するだけで簡単に適用できます。
pnpm公式ドキュメントで推奨されているGitHub Actionsパイプラインの例
name: pnpm Example Workflow
on:
push:
jobs:
build:
runs-on: ubuntu-20.04
strategy:
matrix:
node-version: [15]
steps:
- uses: actions/checkout@v3
- uses: pnpm/action-setup@v2
with:
version: 8
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'pnpm'
- name: Install dependencies
run: pnpm install
Jenkinsパイプラインを使う場合は、次のように設定することが推奨されています。
pipeline {
agent {
docker {
image 'node:lts-bullseye-slim'
args '-p 3000:3000'
}
}
stages {
stage('Build') {
steps {
sh 'corepack enable'
sh 'corepack prepare pnpm@latest-8 --activate'
sh 'pnpm install'
}
}
}
}
上記の例ではpnpmのstore pathを別途指定していないため、JENKINS_WORKSPACE の配下に .pnpm-store のパスを作成して依存関係をキャッシュします。
しかし、Jenkins Multibranch Pipelineの場合は、ブランチやPull Requestごとに別々のWorkspaceのパスを持ちます。
つまり、同じプロジェクトであっても、異なるブランチやPR間で依存関係のキャッシュを共有できません。新しいPRが作成されるたびに、依存関係を最初からインストールし直すことになります。
これを解決する方法は、キャッシュボリュームのマウントで後ほど説明します。
CI用のキャッシュイメージを作る
次に紹介するのは、CI用のキャッシュイメージを作る方法です。
次の例は、pnpm fetch を使って依存関係のキャッシュイメージを作る Dockerfile です。
FROM node:18-slim
pnpmのインストール手順は省略
pnpm fetch does require only lockfile
COPY pnpm-lock.yaml ./
RUN pnpm fetch --prod
上記のイメージをpullしてから依存関係をインストールすれば、キャッシュを活用してすぐにビルドできます。
イメージタグにはマニフェストファイル(pnpm-lock.yaml)のハッシュ値を指定します。そのため先ほどの方法と同じく、このファイルが変更されたらイメージを再ビルドする必要があります。
when cache miss, build and push into image registry like ECR
docker build -t rallit-frontend-dep:02d93ab24 -f dep.Dockerfile .
docker push rallit-frontend-dep:02d93ab24
when build
docker run -i --rm -v $WORKSPACE:/workspace rallit-frontend-dep:02d93ab24 'pnpm i --frozen-lockfile'
ただし、pnpm fetch --prod コマンドでインストールした依存関係をイメージとしてビルドしてpullするため、その過程でディスクのオーバーヘッドが発生します。
社内で所要時間を計測した結果、実際のCI環境ではイメージをpullして実行するのに約168秒かかることが分かりました。
キャッシュイメージを作る方法も、プロジェクトの規模が大きいほど時間がかかり、通常の pnpm install より約108秒も遅くなりました。
pnpm-lock.yaml が変更されるたびにこうしたキャッシュイメージをビルドし、コンテナイメージレジストリにアップロードするのは非効率だと判断しました。
Dockerのマルチステージビルドを使ってイメージをビルドしている方は、pnpmの公式ドキュメントを参考にcache type mountingを適用することをおすすめします。
FROM base AS prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
DockerのRUN専用キャッシュを活用する
パッケージをインストールするたびに、毎回インターネットからすべてのパッケージを取得する必要はありません。
--mount フラグとともに明示的なキャッシュを使うと、ビルド間で対象ディレクトリの内容が保持されます。
このレイヤーを再ビルドする必要がある場合は、そのパスにあるキャッシュを活用します。
私たちはDockerのマルチステージビルドを使わず、ビルドの段階ごとに個別に最適化したイメージを使っているため、この方法は適用できませんでした。
続いて、依存関係のインストール時間を大きく短縮できた、キャッシュボリュームのマウントについて説明します。
キャッシュボリュームのマウント
先ほど、Jenkinsパイプラインでpnpmを使って依存関係をインストールする場合は、キャッシュを別途設定する必要があると述べました。
つまり、同じプロジェクトであっても、異なるブランチやPR間で依存関係のキャッシュを共有できません。新しいPRが作成されるたびに、依存関係を最初からインストールし直すことになります。
実際の .pnpm-store のパスを比較しながら見ていきましょう。
従来(AS-IS)
CACHE_PATH のパスが JENKINS_WORKSPACE の中にある
異なるプロジェクト、ブランチ、PR間で依存関係のキャッシュを共有できない
パスの比較
JENKINS_WORKSPACE=/workspace/rallit_frontend_PR_15242
CACHE_PATH=/workspace/rallit_frontend_PR_15242/.pnpm-store
例えば、rallit_frontendのPR#15242のCIジョブが完了したとしましょう。
/workspace/rallit_frontend_PR_15242/.pnpm-store には、先ほどインストールした依存関係が残っています。
続いてPR#15243が作成されると、どうなるでしょうか?
PR#15243は /workspace/rallit_frontend_PR_15243/.pnpm-store を参照することになります。
このフォルダは実際には存在しないので、依存関係を最初からインストールすることになり、その分時間がかかります。
先にインストールしたPR#15242の依存関係キャッシュは、まったく使えません。
たった1行の変更でも約60秒余計に待たされるので、かなり不便です。
変更後(TO-BE)
エージェント内にGlobal Dependency Cacheのパスを設定
同じエージェントで実行するビルドはDependency Cacheを活用
パスの比較
JENKINS_WORKSPACE=/workspace/rallit_frontend_PR_15242
CACHE_PATH=/tmp/.pnpm-store
CIジョブを実行するすべてのコンテナで、pnpm store-path オプションに同じパスを指定しました。
これにより、rallit_frontendのPR#15242、PR#15243はどちらも同じパス(/tmp/.pnpm-store)を参照するようになります。
PR#15243のCIジョブでは、PR#15242ですでにインストールされた依存関係を再利用することになり、約60秒かかっていた依存関係のインストール時間をわずか3秒に短縮できました。
pnpm store-path オプションは、ビルド用のベースイメージを作る Dockerfile で次のように指定しました。
set global node cache directory
ARG CACHE_PATH=/cache/node
RUN yarn config set cache-folder ${CACHE_PATH}/.yarn
RUN pnpm config set store-dir ${CACHE_PATH}/.pnpm-store
パッケージマネージャーにYarnを使っているプロジェクトでも依存関係のキャッシュパスを共有できるよう、Yarnにも同様の設定を入れています。
続いて、このDockerイメージを実行する Jenkinsfile 側でも CACHE_PATH 環境変数を設定します。
env.DOCKER_OPTION = '-v /tmp:/cache/node -e CI=1'
(中略)
stage('Install') {
steps {
script {
sh "docker run --rm -i -v $WORKSPACE:/workspace $DOCKER_OPTION $NODE_IMAGE 'pnpm i --frozen-lockfile'"
}
}
}
JenkinsfileのDocker拡張機能を使わず、コマンドで直接Dockerコンテナを実行する場合
pnpmがCI環境かどうかの判定に使う環境変数の1つである BUILD_NUMBER が渡されないため、CI=1 のデフォルト値が適用されません。
-e BUILD_NUMBER=$BUILD_NUMBER で渡すか、-e CI=1 環境変数を直接設定するのがおすすめです。
詳しくは公式ドキュメントをご参照ください。
CI=trueの場合、pnpmはロックファイル(pnpm-lock.yaml)を生成せず、ロックファイルがマニフェストと同期していない場合、更新が必要な場合、または存在しない場合はインストールに失敗します。
この対応によって、直前のビルドで使われた依存関係のキャッシュを、ほかのリポジトリのCIでも再利用できるようになりました。
適用前
- pnpm i --frozen-lockfile
Scope: all 7 workspace projects
Lockfile is up to date, resolution step is skipped
Progress: resolved 1, reused 0, downloaded 0, added 0
Packages: +3357
(中略)
Progress: resolved 3357, reused 1253, downloaded 2025, added 3357, done
Done in 1m 28.6s
適用後
- pnpm i --frozen-lockfile
Scope: all 7 workspace projects
Lockfile is up to date, resolution step is skipped
Packages: -11
-----------
Done in 2.6s
依存関係インストール処理の最適化結果
依存関係キャッシュMiss: 1分28秒
依存関係キャッシュHit: 2.6秒
依存関係のインストール時間を最大97%短縮
依存関係のインストールは従来の88秒から2.6秒へと85.4秒減り、約97%短縮できました。
もちろん、ビルドエージェントが入れ替わって依存関係のキャッシュパスが初期化されると、最初の1回だけは1分28秒のインストールが必要です。
それでも、ビルドのたびに依存関係をインストールしていた従来とは違い、エージェント内で依存関係のキャッシュを共有できるので、後続のビルドではインストール時間を97%以上短縮できます。
複数のリポジトリが同じライブラリを使っていても、それぞれバージョンが異なる場合があります。
この場合、使用するバージョンを1つに統一するとキャッシュヒット率(Cache Hit Rate)が上がり、上記と同様に依存関係のインストール時間を大幅に短縮できました。
例: inflearn-frontend と rallit-frontend で使っている Datadog RUM(Real User Monitoring) エージェントのバージョンが一致している場合、npm registryからの依存関係のダウンロードとインストールをスキップし、ビルドエージェントにあるグローバルな依存関係キャッシュを活用して時間を短縮できる。
(rallit-frontend で先にインストールしていれば inflearn-frontend でそれを利用でき、その逆も可能)
テストとビルドの所要時間の短縮
先に述べたとおり、モノレポでは一部のパッケージを修正しただけでも、すべてのパッケージをテスト・ビルドしなければなりません。
Turborepoを使えば、変更されたパッケージと、それに依存するパッケージだけをテスト・ビルドするので、CIの所要時間を大幅に短縮できます。これをTask Cacheと呼びます。
Inflabのフロントエンドチームは早くからTurborepoをローカル開発環境に導入していたため、実際に変更されたコードだけをテスト・ビルドして、作業内容を素早く検証できていました。
これはコンテキストスイッチの頻度を減らし、生産性の向上につながりました。(実験:遅いビルド時間の隠れたコスト in GeekNews)
Turborepo Task Caching
package.json にある build、test、lint などをtaskと呼びます。
Turborepoはtaskの実行結果とログをキャッシュし、遅いtaskを高速化します。
Content-aware hashingでソースコードの変更を検知し、変更されたパッケージに対してのみ build、test、lint などのtaskを実行します。
テストとビルドの結果は、まずLocal Filesystem Cacheとして ./node_modules/.cache/turbo/78awdk123.tar.zst のようなパスに保存されます。
Taskキャッシュがヒット(hit)したパッケージはビルドをスキップし、変更されたパッケージだけを新たにテスト・ビルドします。
実行結果をもとにTaskキャッシュを更新し、常に最新の状態に保ちます。
Taskキャッシュがミス(miss)した場合は、次のようにtaskを実行し、その結果でLocal Filesystem cacheを更新します。
/lib:build: cache miss, executing 86fb10d5583f361c
/app:build: cache miss, executing 7804de963ab93a0e
(中略)
Tasks: 5 successful, 5 total
Cached: 0 cached, 5 total
Time: 4m36.208s
...writing to cache...
Taskキャッシュがヒット(hit)した場合は、キャッシュ作成時のログを再生(replay)し、ビルド結果をキャッシュサーバーからダウンロードします。
/lib:build: cache hit, replaying logs 86fb10d5583f361c
/ds:build: cache hit, replaying logs 7804de963ab93a0e
/a:build: cache hit, replaying logs 8be7f0056074cbc3
/b:build: cache hit, replaying logs d01733e33b71a814
Tasks: 5 successful, 5 total
Cached: 5 cached, 5 total
Time: 4.114s >>> FULL TURBO
Taskキャッシュの適用結果
TaskキャッシュMiss: 4分36秒
TaskキャッシュHit: 4.114秒
Taskキャッシュの導入で所要時間を約98.5%改善
Taskキャッシュも依存関係のキャッシュと同じく、キャッシュがない初回は時間がかかります。しかしそれ以降のビルドでは変更されたパッケージに対してのみTaskを実行するため、かなりの時間を節約できました。
問題は、別途設定をしなければLocal Filesystem Cacheしか使えないことです。
Jenkinsエージェントではworkspaceをブランチ・PR単位で分けているため、検証済みのPRを develop ブランチにマージすると、テストとビルドを最初からやり直す必要がありました。
CIパイプラインで一度ビルドしたものは再ビルドせずに済ませたいところです。どうすれば改善できるでしょうか?
Turborepo Remote Caching
Turborepo Remote Caching機能を使えば、この問題を解決できます。
Taskキャッシュを複数のビルドエージェント間で共有でき、インクリメンタルビルドを積極的に活用できるようになります。
Remote Cachingを使うには、追加費用が発生するVercelのクラウドを使うか、Vercelが公開しているAPI仕様に従ってTurborepo custom remote cache serverを自前で実装する必要があります。
私たちは、オープンソースとして公開されているducktors/turborepo-remote-cacheを使いました。
ducktors/turborepo-remote-cache
Turborepo custom remote cache serverのオープンソース実装で、self-hosted環境で使うのに適しています。
シンプルなWebサーバーアプリケーションで、次の実行方法をサポートしています。
Dockerコンテナとして実行
AWS Lambdaなどのサーバーレス関数として実行
ストレージについては、次のようなさまざまなオプションが用意されています。
Amazon S3(または互換ストレージ)
ファイルシステム
Lambda + S3の組み合わせで完全にサーバーレスで構築することもできます。それぞれのメリット・デメリットを踏まえて、環境に合った構成を選べば良いでしょう。
ここでは、Docker + ファイルシステムでカスタムキャッシュサーバーを手軽に構築する手順を紹介します。
次のように compose.yaml ファイルを作成します。${} の中に入る変数は .env ファイルで定義します。
services:
turborepo-remote-cache:
image: ducktors/turborepo-remote-cache:1.14.2
ports:
- 3000:3000
volumes:
- ./tmp:/tmp
environment:
- NODE_ENV=production
- PORT=3000
- TURBO_TOKEN=${TURBO_TOKEN}
- LOG_LEVEL=info
- STORAGE_PROVIDER=local
- AWS_REGION=${AWS_REGION}
続いてガイドに従い、Turborepoのカスタムキャッシュサーバーのアドレスといくつかのパラメーターを、Dockerの環境変数として渡します。
TURBO_API: キャッシュサーバーのアドレス
TURBO_TOKEN: 認証トークン
TURBO_TEAM: キャッシュを使うteam(主にプロジェクト単位で指定)
stage('Build') {
steps {
script {
env.DOCKER_OPTION = "$DOCKER_OPTION -e TURBO_API=$TURBO_API -e TURBO_TOKEN=$TURBO_TOKEN -e TURBO_TEAM=$TURBO_TEAM"
sh "docker run --rm -i -v $WORKSPACE:/workspace $DOCKER_OPTION $NODE_IMAGE 'pnpm build'"
}
}
}
Dockerの環境変数ではなくturbo.jsonなどの設定ファイルに直接書く方法もありますが、今回はCI専用のキャッシュサーバーとの連携なので、上記のようにパイプライン側に記述しました。
適用後にビルドを何度か実行すると Remote caching enabled というログが出力され、Taskキャッシュが蓄積・再利用されていることが確認できます。
- pnpm build:development
• Packages in scope: ......
• Running build:development in 6 packages
• Remote caching enabled
a:build: cache hit, replaying logs 83da8d8fb24dd1a9
b:build: cache hit, replaying logs 249bb66c4e03301b
c:build: cache hit, replaying logs 8be7f0056074cbc3
(中略)
Tasks: 5 successful, 5 total
Cached: 5 cached, 5 total
Time: 4.114s >>> FULL TURBO
ハッシュはコードの状態をもとに計算されるので、ブランチやPRが違っても、コードを修正していなければTaskが無駄に再実行されることはありません。
では、Continuous Deploymentのパイプラインでビルドキャッシュを使う場合、ビルド成果物はどうデプロイすれば良いでしょうか?
turbo.jsonでコマンドごとにoutputsのパスを設定すると、そのパスがビルド成果物として扱われ、Turborepoのリモートキャッシュサーバーに保存されます。キャッシュヒット時には、それがダウンロードされます。
そのため、デプロイ時の混乱を防ぐには、outputs オプションを正しく設定しておく必要があります。(関連する公式ドキュメント)
turbo.json
{
"$schema": "turbo.build",
"pipeline": {
"app#build": {
"outputs": ["dist/"]
},
"util#build": {
"outputs": ["lib/"]
}
}
}
このオプションを正しく設定していないと、デプロイパイプラインを経由しない緊急ロールバックのあとにパイプラインを再実行しても、本番環境に反映されないことがあるので注意してください。
便利さの裏には慎重さと正確さが欠かせないことを、心に留めておきましょう。
ソースコードの静的解析
依存関係のインストールと、テスト・ビルド。この2つを最適化した結果、すべてのキャッシュがヒットすれば、ここまでの処理は10秒前後で終わります。
静的解析では、コードのセキュリティ脆弱性から、重複やパフォーマンス上の問題が起こりうる箇所まで見つけられるため、SonarQubeを積極的に導入・活用しています。
しかし、CIのたびに静的解析を実行していたため、コードの規模によっては40秒から最大2分以上かかることもありました。
これを短縮するため、解析が不要なファイルを sonar-project.properties で除外しました。
sonar.exclusions=packages//public/mockServiceWorker.js, packages//src/@types//, packages//src/mocks//, packages//vite.config.ts,
sonar.analysis.mode=incrementalは使用不可
以前は -Dsonar.analysis.mode=incremental パラメーターを追加して変更箇所だけを解析する機能がありましたが、現在はサポートが終了しています。
代わりに、変更されたディレクトリだけを解析する仕組みをパイプラインに自前で実装する方法や、CIのポリシーを変更して develop ブランチへのマージ時にのみ解析する方法があります。
銀の弾丸(silver-bullet)は存在しないので、皆さんの環境に合った方法を選んで改善してください。
詳しくはSonarQubeのドキュメントをご参照ください。
私たちのContinuous Deploymentパイプラインには、コンテナのデプロイ完了を待つ処理がありました。その待ち時間にSonarQubeの解析を並行して実行することで、約40秒〜最大2分かかっていた静的解析の時間を短縮できました。
CI最適化の結果
キャッシュHitを想定(install、buildがともにヒットした場合)
pnpmの依存関係キャッシュとTurborepoのTaskキャッシュを適用したことで、CIの所要時間を最大79%短縮できました。
モノレポの中でも、パッケージ数とコード量が多いプロジェクトほど最適化の効果が際立ちました。パッケージが多いほど、修正による影響範囲が小さくなるためです。
さらに、turborepo もモノレポも導入していないプロジェクトE、Fでも、依存関係のインストールを最適化しただけでCIが約1.6〜2倍速くなりました。
調査対象の平均CI所要時間は6分3秒から2分4秒になり、最大66%改善できました。静的解析まで最適化すれば、さらに時間を節約できると期待しています。
おわりに
以上、簡単な修正でCIの所要時間を劇的に改善した経験を共有しました。皆さんもぜひ試してみて、得られた成果やより良い方法をコメントで教えていただけるとうれしいです。
最適化を進めるなかで私を信頼し、込み入った質問にも丁寧に答えてくださったInflab開発チームの皆さんのおかげで、無事に最適化を終えることができました。ありがとうございました。
次回の記事では、JenkinsのEC2エージェントのコンピューティングコストを最大70%節約できたスポットインスタンスの導入をテーマにお届けします。
Inflab DevOpsチームの最適化はまだまだ続きます。今後もご期待ください!
参考資料
monorepo.tools
pnpm.io
macOS runners are vulnerable to the eMudhra intermediate CA compromise · Issue #15228 · actions/runner-images : 👀
---
runner-images#15228
macOS + GitHub Actions: eMudhra fixes require ongoing curation. Runner images can retain vulnerable CA bundles; updating Homebrew doesn't fix Apple's curl/Python. Apple Python can also ignore CA env overrides. Test each client--and recheck after OS updates. runner-images#15228
3 likes · 2 reposts
Mentions
GitHub ActionsでTerraformのCIを改善する はじめに これまで、GitHub Actionsを使ってTerraformの品質チェックやセキュリティチェックを自動化する方法を試して見ました! これまでに書いた記事は以下の2つです。 GitHub ActionsとTrivyでTerraformの設定ミスをPR時に検出する GitHub ActionsでTerraformの品質チェックを自動化する これまで作成したWorkflowをさらに改善するために、以下の3つに取り組みました。 Reusable WorkflowsによるWorkflowの再利用 Path FilterによるWorkflowの実行条件の制御 Terraform Provider CacheによるProviderのキャッシュ 1. Reusable WorkflowsでWorkflowを再利用する これまでのWorkflowでは、Terraformの品質チェックを実行する処理を1つのYAMLファイルにまとめていました。 しかし、今後セキュリティチェックやテストなどを追加していくことを考えると、Workflowごとに処理を分離しておく方が管理しやすいかなと思いました。 そこで、Reusable Workflowsを利用してみました! Reusable Workflowsは、別のWorkflowから呼び出して再利用できる仕組みです。 今回の構成では、以下のように役割を分けています。 呼び出し側:Workflowを実行する条件を定義 呼び出される側:Terraformの品質チェックを実行 呼び出し側のWorkflow .github/workflows/pr-check.yml name: Terraform PR Check on: pull_request: paths: - '**/*.tf' permissions: contents: read jobs: terraform-quality: uses: ./.github/workflows/terraform-quality.yaml uses に呼び出したいWorkflowのパスを指定します。 これにより、Terraformの品質チェック処理を別のWorkflowに切り出すことができます。 呼び出される側のWorkflow .github/workflows/terraform-quality.yaml name: Terraform Quality on: workflow_call: permissions: contents: read jobs: terraform-quality: runs-on: ubuntu-latest steps: - name: Checkout repository uses: actions/checkout@v7 - name: Setup Terraform uses: hashicorp/setup-terraform@v4 - name: Check formatting run: terraform fmt -check -recursive - name: Initialize Terraform run: terraform init -backend=false - name: Validate Terraform run: terraform validate 呼び出される側では、on: workflow_call を指定します。 これにより、別のWorkflowから呼び出せるようになります。 Terraformの品質チェック処理を独立させることで、今後ほかのWorkflowからも再利用しやすい構成にしました。 2. Path FilterでWorkflowの実行条件を制御する 次に、Workflowが実行される条件を見直しました。 Pull Requestを作成するたびにTerraformの品質チェックを実行するのではなく、Terraformファイルに変更があった場合だけ実行するようにします。 そこで、呼び出し側のWorkflowに以下を設定しました。 on: pull_request: paths: - '**/*.tf' paths を指定することで、対象となるファイルに変更があった場合のみWorkflowが実行されます。 今回の設定では、.tf ファイルが変更されたPull Requestが対象になります。 実際に動作を確認したところ、WorkflowのYAMLファイルだけを変更した場合は実行されず、Terraformファイルを変更した場合は実行されることを確認できました。 これにより、Terraformに関係のない変更で不要なCIが実行されることを抑えられます。 3. Terraform Providerをキャッシュする 次に、Terraform Providerのキャッシュを導入してみました。 Terraformでは、terraform init を実行すると、必要なProviderがダウンロードされます。 毎回ダウンロードが発生するため、Providerをキャッシュして再利用できるようにします。 今回は、Terraformの環境変数 TF_PLUGIN_CACHE_DIR とGitHub Actionsの actions/cache を利用しました。 Provider Cacheの設定 まず、Jobの環境変数にキャッシュ先を指定します。 env: TF_PLUGIN_CACHE_DIR: ${{ github.workspace }}/.terraform-plugin-cache TF_PLUGIN_CACHE_DIR は、Terraform Providerのキャッシュ先を指定するための環境変数です。 次に、WorkflowのStepにキャッシュ処理を追加します。 - name: Create Terraform plugin cache directory run: mkdir -p "$TF_PLUGIN_CACHE_DIR" - name: Cache Terraform providers uses: actions/cache@v6 with: path: ${{ github.workspace }}/.terraform-plugin-cache key: ${{ runner.os }}-terraform-providers-${{ hashFiles('**/.terraform.lock.hcl') }} restore-keys: | ${{ runner.os }}-terraform-providers- それぞれの設定について説明します。 path キャッシュ対象のディレクトリを指定します。 今回は、TF_PLUGIN_CACHE_DIR で指定したディレクトリと同じ場所にしています。 key キャッシュを識別するためのキーです。 今回は、実行環境のOSと .terraform.lock.hcl のハッシュ値を組み合わせています。 ロックファイルの内容が変わるとハッシュ値も変わるため、Providerの依存関係に変更があった場合に、別のキャッシュキーを利用できます。 restore-keys 完全に一致するキャッシュが見つからなかった場合に、前方一致するキャッシュを探すための設定です。 今回はOSとProviderキャッシュのプレフィックスを指定しています。 Workflow全体 最終的なReusable Workflowは以下のようになりました。 name: Terraform Quality on: workflow_call: permissions: contents: read jobs: terraform-quality: runs-on: ubuntu-latest env: TF_PLUGIN_CACHE_DIR: ${{ github.workspace }}/.terraform-plugin-cache steps: - name: Checkout repository uses: actions/checkout@v7 - name: Setup Terraform uses: hashicorp/setup-terraform@v4 - name: Create Terraform plugin cache directory run: mkdir -p "$TF_PLUGIN_CACHE_DIR" - name: Cache Terraform providers uses: actions/cache@v6 with: path: ${{ github.workspace }}/.terraform-plugin-cache key: ${{ runner.os }}-terraform-providers-${{ hashFiles('**/.terraform.lock.hcl') }} restore-keys: | ${{ runner.os }}-terraform-providers- - name: Check formatting run: terraform fmt -check -recursive - name: Initialize Terraform run: terraform init -backend=false - name: Validate Terraform run: terraform validate キャッシュの復元処理は terraform init より前に実行する必要があります。 これにより、Terraformの初期化時にキャッシュされたProviderを利用できるようになります。 4. Provider Cacheの動作を確認する 設定したキャッシュが実際に動作するか、GitHub Actions上で確認しました。 今回は、同じPull RequestでWorkflowを2回実行し、キャッシュの保存と復元を確認しています。 1回目の実行 初回実行ではキャッシュが存在しないため、Providerのダウンロードが行われます。 実行ログでは、キャッシュが見つからないことを示すメッセージが出力されました。 Cache not found for input keys: その後、Workflowの最後に以下のログが出力されました。 Cache saved with key: ... これにより、Providerのキャッシュが保存されたことを確認できました。 2回目の実行 次に、同じPull Requestで Re-run all jobs を実行しました。 2回目の実行では、以下のログが出力されました。 Cache restored successfully これにより、1回目の実行で保存したキャッシュを復元できることを確認できました。 今回の検証では、キャッシュの保存と復元の両方を確認できました。 なお、キャッシュの復元によってProviderの再ダウンロードを抑制できる構成にはなりましたが、実行時間がどの程度短縮されたかについては、まだ比較検証できていません。ただ、Providerのダウンロードを省略できる分、実行時間の短縮につながると考えています! また、GitHub ActionsのキャッシュはPull Requestの実行スコープに関係するため、今回は同じPull Requestで再実行して確認しています。 5. まとめ 今回は、GitHub ActionsでTerraformのCIを改善するために、以下の3つを実装しました。 Reusable WorkflowsによるWorkflowの共通化 Path Filterによる実行条件の制御 Terraform Provider CacheによるProviderのキャッシュ 実際に設定して動作を確認することで、それぞれの仕組みについて理解を深めることができました。 特に、Provider Cacheについては、設定するだけでなく、GitHub Actionsの実行ログからキャッシュの保存と復元まで確認できたのがよかったです。 引き続き、GitHub ActionsやTerraformを使いながら、CI/CDの改善について学んでいきたいと思います。 参考 GitHub Actionsの再利用可能なワークフロー GitHub Actionsのワークフロー構文 GitHub Actionsの依存関係キャッシュ actions/cache Terraform fmt Terraform init Terraform validate hashicorp/setup-terraform
Playwright再入門:Claude Codeでテストを残し、増やし、GitHub Actionsで回し、MCP・CLIと使い分ける Claude Code で画面を作っていて、ブラウザを動かすテストをまだ持っていないなら、この記事はその入口です。 Playwright(ブラウザを自動で動かす Microsoft の OSS)で書かせて残したテストは、AI なしで1秒前後で回ります。画面が変わると赤くなり、止まった所まで出ます。 AI に頼むのは、テストを書く・直すときと、テストの無い画面をその場で見るときだけで足ります。 テストを残す・増やす・GitHub Actions で回す・MCP と CLI で見させる、の4つを1画面の小さなアプリで順に試します。どの段にも公開サンプルがあります。 ! この記事の内容は動画(全4本)でも解説しています。下は1本目です。 目次:目的から各回を選ぶ 目的から、見たい回へ飛んでください。 テストを作って保つ #1 テストを1本書かせて残す:画面が変わると AI なしで赤くなる #2 Test Agents で増やす:計画を絞って書かせ、6本の赤を1行で直させる 人が走らせなくても回す #3 GitHub Actions で回す:push だけでテストが走り、トレースで原因が分かる テスト以外の使い方を選ぶ #4 MCP と CLI:同じ確認を4通りで比べ、残すか見させるかを選ぶ 前提と試した環境 要る物は次のとおりです。 Node.js(公式の導入ページの要件は「latest 22.x, 24.x or 26.x」) Claude Code(#1・#2・#4) GitHub のアカウント(#3) 確認環境と、試していないこと #1・#2・#4 は、Linux arm64・Node.js 22.22.1・Playwright 1.64.0 で試しました。 回 確認日 試した環境 #1 2026-10-10 Chrome for Testing 156・Claude Code 2.1.296 #2 2026-10-11 Claude Code 2.1.296 #3 2026-10-18 GitHub Actions の ubuntu-24.04 ランナー(手元は Linux) #4 2026-10-18 Chrome なし・画面なし・Claude Code 2.1.302 秒数は途中の許可を聞かれない auto mode の値で、頼むたびに変わります。Mac と Windows では試していません。#4 は本体同梱の npx playwright mcp だけで、@playwright/mcp@latest と Chrome のある端末は試していません。 #1 テストを1本書かせて残す:画面が変わると AI なしで赤くなる Playwright には 1.56 から Playwright Test Agents が入っています。計画を書く planner、テストを書く generator、失敗したテストを直す healer の3つです。Claude Code からはサブエージェントとして呼べます。私が試したかったのは、AI の確認を使い捨てにせず、テストとして残す形です。 実験台の「買い物メモ」。品物を足してチェックすると、下の「残り N 品」が減る サンプルでテスト1本を走らせる zun-tools/playwright-sample は、設定まで済ませた完成形です。 git clone zun-tools/playwright-sample cd playwright-sample npm install npx playwright install chromium npx playwright test 1 passed になります。走ったテストの全文です。 tests/shopping.spec.js // @ts-check import { test, expect } from '@playwright/test'; test('品物を足してチェックすると、残りが減る', async ({ page }) => { await page.goto('/'); await page.getByLabel('品物').fill('牛乳'); await page.getByRole('button', { name: '追加' }).click(); await page.getByLabel('品物').fill('卵'); await page.getByRole('button', { name: '追加' }).click(); await expect(page.getByRole('listitem')).toHaveCount(2); await expect(page.getByRole('status')).toHaveText('残り 2 品'); await page.getByRole('checkbox', { name: '牛乳' }).check(); await expect(page.getByRole('status')).toHaveText('残り 1 品'); }); 操作は fill・click・check、確かめは expect です。 空のフォルダに2コマンドで入れ、設定を足す npm init playwright@latest -- --quiet --browser=chromium --lang=js --no-examples npx playwright init-agents --loop=claude 2つ目で .claude/agents/ に3つのエージェントが、.mcp.json に MCP サーバ(playwright-test)が入ります。サンプルの index.html を置き、設定に3か所足します。 playwright.config.js(足した所) reporter: [['list'], ['html', { open: 'never' }]], use: { baseURL: 'localhost', }, webServer: { command: 'npx http-server -p 8080 -s .', url: 'localhost', reuseExistingServer: true, }, tests/seed.spec.ts の // generate code here. は、await page.goto('/'); に替えます。seed は、エージェントが最初に走らせるテストです。設定を足さずに頼んだ回は、generator がページを開けず、画面を操作しないままテストを書きました。 generator を名指しして書かせる フォルダで claude を開くと MCP サーバを使うか聞かれます。カーソルは最初「Continue without using this MCP server」にあります。上へ2回動かし、「Use this MCP server」を選びます。 playwright-test-generator エージェントを使って、買い物メモ(index.html)のテストを書いて。計画書は無いので、品物を2つ足して、チェックすると残りが減ることを確かめるテストにして generator はブラウザで操作を通してから、テストを1本書き出しました(51秒〜1分3秒・4回)。読んで合っていれば、そのまま残します。名指しせず「テストを書いて」と頼んだ回は、サブエージェントが使われませんでした。 ボタン名を変えて、AI なしで走らせる index.html のボタン名を「追加」から「入れる」に変え、npx playwright test だけを打ちます。 ✘ 2 …s/shopping-memo/add-and-check-items.spec.ts:7:7 › 買い物メモ › 品物を2つ追加してチェックすると残りが減る (30.0s) Error: locator.click: Test timeout of 30000ms exceeded. Call log: - waiting for getByRole('button', { name: '追加' }) 赤になるまで30秒かかりました。Playwright は操作できるまで待ち、テスト1本の時間切れ(既定30秒)で赤にします。use に actionTimeout: 5000 を足すと、5.1〜5.3秒で赤になりました(既定では30.0〜30.1秒・各3回)。 止まった瞬間の画面は、トレースで見られます。 npx playwright test --trace on npx playwright show-report actionTimeout: 5000 で走らせたトレースの一部。「追加」を探して5秒待った Click が赤。そのときのページのボタンは「入れる」 healer にテストを直させる ボタン名をわざと変えたのなら、直すのはテストの側です。 テストが失敗してる。playwright-test-healer エージェントで直して テストのボタン名2か所が「入れる」に変わり、再実行は緑でした(42〜58秒・4回)。コメントに古い「追加」が残った回もあります。アプリの1行を消して頼んだ別の日の1回(2026-10-09・Claude Code 2.1.295)は、test.fixme を足して止まりました。スキップは結果の上では緑に見えるので、直した後も読みます。 #2 Test Agents で増やす:計画を絞って書かせ、6本の赤を1行で直させる #1 の設定を済ませた所から始めます。増やした後の状態は zun-tools/playwright-agents-sample にあり、AI なしで赤まで試せます。 planner に計画を5つまで書かせる playwright-test-planner エージェントで、買い物メモのテスト計画を作って。seed は tests/seed.spec.ts。tests/shopping.spec.js にテストが1本あるので、それと同じ筋書きは除いて、大事な順に5つまで 約1分で specs/ に日本語の計画(Markdown)ができました。テストを書く前に、何を確かめるかを読めます。絞らずに頼むと、21 と 23 シナリオでした(2回)。 generator に計画から書かせる playwright-test-generator エージェントで、specs/shopping-memo.plan.md の計画からテストを書いて 計画の1項目がテスト1本になり、5本で2分半〜3分半でした。AI なしで走らせると 7 passed、約1秒です。手書きの1本と seed を足した7本になります。緑になったら、計画とテストをコミットしておきます。 表示を1か所変えると、6本がまとめて赤 表示を「残り 1 / 2 品」に変えます。playwright-agents-sample の display-change.patch を git apply すると当たります。自分で変えるなら、index.html の次の3行です。 index.html - <p id="left" role="status">残り 0 品</p> - <p id="left" role="status">残り 0 / 0 品</p> function updateLeft() { const n = list.querySelectorAll('input[type=checkbox]:not(:checked)').length; - left.textContent = 残り ${n} 品; - const all = list.querySelectorAll('input[type=checkbox]').length; - left.textContent = 残り ${n} / ${all} 品; } 緑は seed だけ。「残り」を確かめる6本が、1本あたり5秒ちょっとで赤 エラーには Expected: "残り 0 品" と Received: "残り 0 / 0 品"、テストの行番号が出ます。1本が5秒ちょっとで赤になるのは、expect が既定で5秒まで待つからです。 1行頼んで直させ、差分を読む テストが失敗してる。playwright-test-healer エージェントで直して 約1分で、テスト6ファイルの期待値が新しい表示に直りました。index.html は触られず、再実行は 7 passed です(2回)。 全体の数が行ごとに違うので、一括の置き換えでは書けない 赤にして期待と実際を言葉で出すまでが Playwright で、直したのは Claude Code です。 計画は古いまま残る。 直した後も specs/ は旧表示のままでした。「計画(specs/)も今の表示に合わせて直して」と頼むと、19〜31秒で直りました(3回) アプリ側の変更が戻されることがある。 アプリを壊して頼んだ4回で、テストを壊れた値に合わせた回は0回でした。ただし3回は、聞かれないまま未コミットの変更が元に戻され、1回は戻してよいか聞いて止まりました。頼む前にコミットしておけば、git diff で取り返せます #3 GitHub Actions で回す:push だけでテストが走り、トレースで原因が分かる 打ち忘れた日も赤が出るよう、push のたびに GitHub Actions で走らせます。サンプル zun-tools/playwright-ci は、#1 のサンプルに package-lock.json と workflow を足した物です。雛形からは、Chromium だけ入れ、アクションの版を v6 に上げました。 .github/workflows/playwright.yml name: Playwright Tests on: push: branches: [ main, master ] pull_request: branches: [ main, master ] jobs: test: timeout-minutes: 60 runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 - uses: actions/setup-node@v6 with: node-version: lts/* - name: Install dependencies run: npm ci - name: Install Playwright Browsers run: npx playwright install --with-deps chromium - name: Run Playwright tests run: npx playwright test - uses: actions/upload-artifact@v6 if: ${{ !cancelled() }} with: name: playwright-report path: playwright-report/ retention-days: 30 自分のリポへ push して緑を見る git clone zun-tools/playwright-ci cd playwright-ci npm ci gh repo create <自分の名前>/playwright-ci --public git remote set-url origin github.com<自分の名前>/playwright-ci.git git push Actions のタブに緑が付きます。push から緑まで55秒と38秒でした。ubuntu-latest は 2026-10-19 から Ubuntu 26.04 へ順に移る、と GitHub が告知しています。ubuntu-26.04 を指定しても、緑2回・赤1回で同じ形でした。 ボタン名を変え、テストを打たずに push する index.html のボタン名を「入れる」に変えて、そのまま push します。 git commit -am "ボタン名を変える" git push テストのコマンドは打たず、push から130秒で ✗ が付きました。 Actions の一覧。ボタン名を変えたコミットだけが ✗ になり、戻すと緑に戻った 赤が長いのは、CI では再試行が2回入る(retries: process.env.CI ? 2 : 0)からです。30秒待つ赤が3回続きます。gh run view <番号> --log-failed でも、待っていた操作とテストの8行目が出ます。 知らせは設定しだいです。試し用の公開リポでは、失敗のときだけ通知とメールの両方が届きました(2026-10-11)。サンプルの上の赤では、メールは届いていません(2026-10-18)。 自分のリポで受け取るなら、Settings → Notifications の Actions の欄で Email を確かめます。一覧の ✗ は、知らせが無くても残ります。 レポートを落とし、再試行1回目のタブからトレースを開く gh run list gh run download <番号> -n playwright-report -D ci-report npx playwright show-report ci-report -D を付けないと gh run download は失敗します。 失敗したテストのページには Run・Retry #1・Retry #2 の3つのタブがあり、トレースは Retry #1 にだけあります(雛形の trace: 'on-first-retry')。 最初の Run のタブにはトレースが無い。Retry #1 を開く GitHub の上で走ったテストの、止まった瞬間の画面。ボタンは「入れる」になっている うっかりならボタン名を戻し、わざとならテストを直します。レポートには画面がそのまま写るので、秘密の入る画面は扱いに気をつけてください。 #4 MCP と CLI:同じ確認を4通りで比べ、残すか見させるかを選ぶ Playwright の README は、使い道に「テスト・スクリプト・AI エージェントの道具」の3つを挙げています。AI 向けには、本体に MCP サーバと CLI(playwright-cli)が入っています。同じ確認を4通りでやって並べました。確認は「牛乳と卵を足し、牛乳にチェックすると『残り 1 品』になるか」です。サンプルは zun-tools/playwright-scripts-sample です。 git clone zun-tools/playwright-scripts-sample cd playwright-scripts-sample npm install npx playwright install chromium npm run serve # 別の端末で開いたままにする テストとスクリプト:先に書いた物を AI なしで走らせる 1通り目は npx playwright test で、#1 と同じテスト1本です。2通り目は、テストの枠と expect を外した12行のスクリプトです。 scripts/check.mjs // 買い物メモの定型確認(テストランナーを使わない Playwright ライブラリのスクリプト) import { chromium } from 'playwright'; const browser = await chromium.launch(); const page = await browser.newPage(); await page.goto('localhost'); for (const name of ['牛乳', '卵']) { await page.getByLabel('品物').fill(name); await page.getByRole('button', { name: '追加' }).click(); } await page.getByRole('checkbox', { name: '牛乳' }).check(); console.log(await page.getByRole('status').textContent()); await browser.close(); node scripts/check.mjs は、合否ではなく読んだ表示(「残り 1 品」)を出します。スクショや PDF にも同じ書き方が使えます。 MCP と CLI:何も書かずに AI に見させる 既定では手元の Chrome を使いますが、Chrome の無い Linux では入れ方を案内されて止まりました。そこで Playwright が入れた Chromium を指しました。MCP は flag を2つ、CLI はサンプルの .playwright/cli.config.json で指しています。 MCP(Chrome の無い Linux で足した形) claude mcp add --scope project playwright -- npx playwright mcp --headless --executable-path <Chromium の場所> CLI(使い方を教えるスキルをフォルダに入れる) npx playwright cli install --skills <Chromium の場所> は、npx playwright install --dry-run chromium の「Install location」の下の chrome-linux*/chrome です。 公式の説明では、1.62 から MCP サーバと playwright-cli は本体に同梱されています。 頼む文は次のとおりです。MCP のときは、最後の1文を「ブラウザの操作には playwright MCP のツールを使うこと。」に変えます。 localhost の買い物メモで、品物「牛乳」と「卵」を追加してから「牛乳」にチェックを入れると、画面の表示が「残り 1 品」になるか確かめてください。最後に「はい/いいえ」と根拠を1行で答えてください。ブラウザの操作には playwright-cli(このフォルダでは npx playwright cli)を使うこと。 CLI で頼んだ1回目。スキルを読んでからコマンドを打ち、28秒で「はい」と答えた 答えは4回とも「はい」でした。MCP は道具を1つずつ呼び、CLI はスキルを読んでからコマンドをまとめて打ちます。 4通りを並べて選ぶ npm run serve のページは開いたままにしました。AI の2つは1回ごとに Claude Code を開き直し、「AI の往復」は道具やコマンドを呼んだ回数です。 やり方 先に書く物 出る物 かかった時間 AI の往復 テスト テスト1本 合否(1 passed) 0.92・0.87秒 0回 スクリプト 12行 読んだ表示「残り 1 品」 0.43・0.41秒 0回 MCP なし 「はい」と根拠 18・25秒 6・7回 CLI なし 「はい」と根拠 28・26秒 5・6回 表の秒数は、ほかの作業が走っていないときの値です。選ぶ基準は桁の差で、残す2つは1秒かからず AI 0回。見させる2つは、頼むたびに20秒前後かかります。 MCP と CLI の間は、速さでは選びません。公式は、playwright-cli は Claude Code などのコーディングエージェントに向き、MCP は状態を持ち続ける長い作業に向くと書いています。 使い所は、表から次のように選べます。 明日も同じ確認をする → テストに残す。2回目も1秒かからず、変わったら赤で知らせる 確かめではない作業(PDF・定型の入力) → スクリプト テストのまだ無い画面を、いま一度だけ見る → AI に見させる。Claude Code なら、まず CLI 見させた確認を何度も使う → テストへ移す(#1 の generator) まとめ generator は操作を通してテストを書き、残したテストは AI なしで回る。画面が変わると止まった所を出して赤になる(既定30秒・actionTimeout で約5秒) 計画を絞って書かせればテストは増え、6本の赤も1行の頼みで直る。頼む前にコミットし、後で差分と計画を読む workflow を足せば push のたびに走り、赤は約2分で ✗ になる。トレースは再試行1回目のタブにある MCP と CLI は書かずに見させる道具で、毎回時間がかかる。繰り返す確認はテストに残す まずは #1 のサンプルで緑を見て、「追加」を「入れる」に変えてもう一度走らせてください。 動画で見たい方はこちら(全4本): #1 テストを書かせて残す #2 Test Agents で増やし、直させる #3 GitHub Actions で回す #4 MCP と CLI を使い分ける 参考リンク microsoft/playwright(公式リポジトリ) Playwright 公式 docs: Playwright Test Agents Playwright 公式 docs: Installation Playwright 公式 docs: Locators Playwright 公式 docs: Auto-waiting Playwright 公式 docs: Timeouts Playwright 公式 docs: Trace viewer Playwright 公式 docs: Continuous Integration Playwright 公式 docs: Trace viewer intro Playwright 公式 docs: Library Playwright 公式 docs: Getting started - MCP Playwright 公式 docs: Getting started - CLI GitHub Docs: GitHub Actions の料金 GitHub Docs: Actions の通知の受け取り方 Claude Code 公式 docs: Permission modes
Fix "APNS token has not been set yet" in Flutter on iOS --- title: Fix "APNS token has not been set yet" in Flutter on iOS published: true date: 2026-11-05 00:00:00 UTC tags: flutter,ios,firebase,dart canonical_url: saymyframe.com --- On iOS, Firebase delivers push notifications through Apple's push service (APNs), so FirebaseMessaging.instance.getToken() refuses to run until Apple has given the device its APNs token. Called earlier, it throws apns-token-not-set, which older versions of firebase_messaging describe as "APNS token has not been set yet". Usually the app just asked too early. How it should wait depends on the version: firebase_messaging 17 tells the app when Firebase is ready, and 16.7 doesn't. In a few setups, though, Apple never answers, however long the app waits. I checked everything below on an iPhone 17 simulator with iOS 26.5, Xcode 27.2 and Flutter 3.44.2, with firebase_messaging 16.7.1 (Firebase iOS SDK 12.19.3) and 17.0.1 (Firebase iOS SDK 13.1.1). APNS token has not been set yet, or has not been received on the device yet The error comes in two wordings. Older versions say: [firebase_messaging/apns-token-not-set] APNS token has not been set yet. Please ensure the APNS token is available by calling `getAPNSToken()`. Since October 2025 (firebase_messaging 16.0.3, with firebase_messaging_platform_interface 4.7.3), the same error says: [firebase_messaging/apns-token-not-set] APNS token has not been received on the device yet. Please ensure the APNS token is available before calling `getAPNSToken()`. Both are a FirebaseException with the code apns-token-not-set, so check the code rather than the text. The hint to call getAPNSToken() is misleading, because that call never throws. It returns null until Apple has answered, which makes it the thing to wait on. The check runs in Dart, before anything reaches iOS. getToken(), deleteToken(), subscribeToTopic() and unsubscribeFromTopic() all start with it. So a topic subscription at startup fails with the same error, while requestPermission() and onMessage work. If you use Firebase from Swift, the native SDK reports the same situation as No APNS token specified before fetching FCM Token. Why getToken() fails right after the app starts At launch, the plugin asks iOS to register the app for remote notifications, and iOS answers later, in application:didRegisterForRemoteNotificationsWithDeviceToken:. The Firebase iOS SDK keeps that answer in memory only. So an early getToken() fails on every launch, not just the first one, even when Firebase gave the app its FCM token on the previous launch. How long the answer takes varies: about half a second on a simulator that had been running for a while, and about 30 seconds right after the simulator booted. On a phone, it depends on the network. I checked this with a small app that calls getToken() right after Firebase.initializeApp(), after a fresh install and after a restart, with both versions. The call failed every time, although Apple answered within a second. What differs between the versions is whether the app learns that Firebase is ready. firebase_messaging 16: wait for getAPNSToken() With 16.7.1, the Firebase iOS SDK requests the FCM token by itself as soon as Apple answers (the log shows the app connecting to fcmtoken.googleapis.com), but the plugin doesn't pass it on to Dart: onTokenRefresh stayed silent for the 20 seconds I waited, after a fresh install and after a restart. So the app has to wait for getAPNSToken() itself, then call getToken(): import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; /// The FCM token of this device once Apple has answered, or `null` when /// Apple hasn't within [limit]. Future<String?> pushTokenWhenReady({ Duration limit = const Duration(minutes: 2), }) async { final messaging = FirebaseMessaging.instance; if (!kIsWeb && defaultTargetPlatform == TargetPlatform.iOS) { var wait = const Duration(milliseconds: 500); var waited = Duration.zero; while (await messaging.getAPNSToken() == null) { if (waited >= limit) return null; await Future<void>.delayed(wait); waited += wait; if (wait < const Duration(seconds: 8)) wait *= 2; } } return messaging.getToken(); } Don't await it before runApp(). I found apps on GitHub that opened to a white screen on iOS for exactly this reason: main() waited, getToken() threw, and runApp() never ran. Start the app first and send the result to your server when it arrives: Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); runApp(const MyApp()); final token = await pushTokenWhenReady(); if (token != null) await sendTokenToServer(token); } If the wait runs out, try again when the user comes back to the app, for example from an AppLifecycleListener with onResume. firebase_messaging 17: listen to onTokenRefresh Since 17.0.0, released in October 2026, the plugin becomes the delegate of the Firebase iOS SDK during Firebase.initializeApp(), so onTokenRefresh gets two events that 16 lost. After a fresh install, it fires as soon as the SDK has the FCM token, in my runs 1.4 seconds after Apple answered. On every later launch, it fires right at startup with the value that the SDK saved last time: 0.2 seconds after main() started, before Apple had answered and while getToken() still threw. So on 17, subscribe to onTokenRefresh right after initializeApp(), and keep the getToken() call for Android, where it doesn't depend on Apple: Future<void> main() async { WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform); final messaging = FirebaseMessaging.instance; messaging.onTokenRefresh.listen(sendTokenToServer); runApp(const MyApp()); try { final token = await messaging.getToken(); if (token != null) await sendTokenToServer(token); } on FirebaseException catch (e) { // On iOS before Apple has answered: onTokenRefresh delivers it. if (e.code != 'apns-token-not-set') rethrow; } } Your server may get the same value twice this way, so it should accept one that it already has. Subscribe early, because onTokenRefresh is a broadcast stream and a listener that comes after the startup event never sees it. If your code subscribes later, after a sign-in for example, wait for getAPNSToken() as with 16. Firebase is also moving Cloud Messaging from registration tokens to the Firebase Installation ID (FID). Version 17 deprecates getToken(), onTokenRefresh and deleteToken() in favor of register(), onRegistered and unregister(), and flutter analyze exits with code 1 on the code above because of the deprecation infos. I haven't tried the FID path yet, so this article stays with getToken(). When the APNs token never comes If getAPNSToken() still returns null after a minute, waiting longer won't help. These are the cases I know of. No aps-environment entitlement iOS registers an app for remote notifications only when the build is signed with the aps-environment entitlement, which Xcode adds with the Push Notifications capability in Signing & Capabilities. Without it, the Xcode console shows: [FirebaseMessaging][I-FCM012002] Error in application:didFailToRegisterForRemoteNotificationsWithError: no valid "aps-environment" entitlement string found The simulator follows the same rule. A build made with flutter build ios --simulator --no-codesign doesn't get the entitlement, and mine waited two minutes for Apple in vain. A free Apple team A personal team can't sign aps-environment. To run the app on an iPhone, you have to remove the capability, and then Apple never answers there. The simulator doesn't need a team, since Xcode signs simulator builds ad hoc. The iOS simulator on GitHub Actions The iOS simulator on a Mac gets a real APNs token. On a macOS virtual machine it never does, and the macOS runners of GitHub Actions are virtual machines. I tried a macos-26 runner: the app was built with the entitlement, nothing came in four minutes, and the push daemon logged no token info found in keychain before it disconnected. actions/runner-images#12747 reports the same for macos-15. A CI job on a hosted runner can check that the app starts without push notifications, but not that it gets them. firebase_messaging 16.5.0 and 16.6.0 These two versions skipped the registration for remote notifications at launch in two common setups: when Firebase is initialized from Dart, as with Firebase.initializeApp(options: DefaultFirebaseOptions.currentPlatform) (flutterfire#18643), and in apps with scenes (flutterfire#18555), which the iOS template of current Flutter creates with SceneDelegate.swift. getAPNSToken() then stayed null on every launch. Version 16.7.0 fixed both. Check which version your app has: flutter pub deps | grep firebase_messaging Auto-init is off With FirebaseMessagingAutoInitEnabled set to NO in Info.plist, or after setAutoInitEnabled(false), the plugin doesn't register for remote notifications. Call setAutoInitEnabled(true) before you start waiting. Not the APNs key The APNs key in the Firebase console doesn't belong on this list. Firebase needs it to send messages through Apple, so without it nothing arrives, but getAPNSToken() and getToken() work all the same. How SMF handles push notifications on iOS The firebase_messaging module of SMF, a free, open-source Flutter project generator, gives the app the address of the device as appPush.address, which stays null until Firebase has one. The first frame never waits for it. On iOS, the module polls getAPNSToken() as above, waiting from half a second up to eight seconds between tries, for at most two minutes, and asks again whenever the user comes back to the app. It adds aps-environment and the background mode remote-notification, and the README of the app explains what to do with a free team. Uploading the APNs key stays a step you do by hand. SMF's CI starts the apps on the iOS simulator of a hosted runner, where they have to start and work without an address.
初めてのCI/CD。チェックが失敗したままマージしても、公開は止められた(GitHub Actions) 1. はじめに 前回の記事では、GitHubにpushすると、GitHub ActionsがCloudflare Workersへ自動で公開するしくみを作りました。 ただ、このしくみには弱点があります。mainにpushすると、中身に間違いがあっても、そのまま公開されてしまいます。 今回の検証は小さな静的ページなので、それでも大きな問題にはなりません。でも、仕事で扱う大きな動的ページでは、そうはいきません。公開する前に、自動で確かめるしくみが必要だと思いました。 そこで今回は、公開の前にHTMLをチェックして、間違いがあれば公開しないようにしました。さらに、わざとHTMLを壊して、本当に公開が止まるのかを確かめています。 イベント・on:・ランナー・既定値の説明は、前回の記事に書いたので省略します。この記事では、前回と違うところだけを説明します。 なお、記事の内容は、断りがない限り自分の環境で動かして確かめていますが、CI/CDは初心者です。間違いなどがあれば、コメントで教えていただけるとうれしいです。 2. 作ったもの プルリクエストを作るとチェックだけが動き、mainにマージするとチェックのあとに公開されるしくみです。 flowchart TD PR["プルリクエストを作る<br>ブランチにpushする"] --> E1["pull_request イベント"] E1 --> C1["check.yml が単独で動く"] C1 --> R1["緑か赤を見せるだけ<br>公開はしない"] M["マージする<br>mainにpushする"] --> E2["mainへのpush イベント"] E2 --> D["deploy.yml が動く"] D --> C2["check ジョブ<br>check.yml を呼ぶ"] C2 -- "成功" --> S["deploy ジョブ<br>Cloudflareへ公開"] C2 -- "失敗" --> X["deploy は実行されない<br>公開中のページはそのまま"] check.ymlには入口が2つあります。プルリクエストの道は、マージする前に結果を知るためのものです。mainの道は、公開の前の関所です。 3. 手順 環境は前回と同じです。無料プランのPrivateリポジトリを使っています。チェックはGitHub Actionsの中で動くので、自分のPCにNode.jsは入れていません。 ファイルは2つ足し、1つ変えました。 tobaru-net-test/ ├─ .htmlvalidate.json … チェックのルール(新規) └─ .github/ └─ workflows/ ├─ check.yml … チェックの指示書(新規) └─ deploy.yml … チェックを呼ぶように変更 (1) チェックのルールを決める HTMLのチェックにはhtml-validateを使いました。ルールを自分で作らなくても、できあいのrecommendedがあります。 .htmlvalidate.jsonです。 { "extends": ["html-validate:recommended"] } (2) check.ymlを書く name: Check on: # mainに向けたプルリクエスト pull_request: branches: [main] # ほかのワークフローから uses: で呼ばれたとき workflow_call: jobs: html: runs-on: ubuntu-latest permissions: contents: read steps: # ランナーにファイルを取り出す - uses: actions/checkout@v5 # public/ の中の .html を調べる - name: Validate HTML run: npx --yes html-validate@11.18.0 public/ (3) deploy.ymlを変える 変えたのは、checkジョブの追加とneeds: checkだけです。 jobs: # 公開の前の関所。中身は check.yml check: uses: ./.github/workflows/check.yml deploy: # check が成功したときだけ動く needs: check runs-on: ubuntu-latest # これ以降は前回と同じ 4. 試してみた 結果を先に書くと、チェックが赤のとき、マージは止まりませんでしたが、公開は止まりました。 | やったこと | 結果 | |---|---| | そのままのページでプルリクエストを作る | 赤 | | 指摘されたところを直して、同じブランチにpush | 緑 | | 緑のままマージ | check→deployと動き、公開された | | </main>を消して、赤のままマージ | マージはできたが、deployは実行されず、公開中のページは変わらなかった | (1) 壊す前に、もう赤になった ブランチをpushしただけでは、何も動きません。check.ymlの条件はプルリクエストで、deploy.ymlの条件はmainへのpushだからです。プルリクエストを作ると、Check / htmlが動き始めました。 !01_pr-running.png わざと壊すのは、この次の予定でした。ところが、何も壊していないのに赤になりました。 !02_check-failed.png ログを開くと、原因が書いてあります。 !03_validate-log.png index.htmlの1行目の<!doctype html>が、小文字だと指摘されています。ブラウザでは正しく表示されていますが、recommendedのdoctype-styleというルールは大文字を求めます。 (2) 直してpushすると、緑になった <!DOCTYPE html>に直して、同じブランチにpushしました。プルリクエストを作り直さなくても、チェックは自動で動き直します。 !04_check-passed.png (3) 緑のままマージすると、公開された マージするとmainへのpushが起きて、deploy.ymlが動きます。checkのあとにdeployが動き、公開中のページも<!DOCTYPE html>に変わりました。 !05_graph-success.png (4) 赤のままマージしても、公開は止まった いよいよ本番です。別のブランチで</main>を消して、プルリクエストを作りました。チェックは赤になりましたが、Merge pull requestのボタンは押せます。 !06_failed-but-mergeable.png あえてマージすると、checkが赤になり、deployはスキップされました。0秒なのは、一度も始まっていないからです。 !07_graph-skipped.png このときmainのコードからは</main>が消えていましたが、https://test.tobaru.netには</main>が残っていました。コードは壊れたが、公開中のページは守られたということです。 検証のあとは、壊した変更をgit revertで取り消して、もう一度プルリクエストからマージしました。チェックは緑で、公開も通っています。 5. なぜ公開が止まるのか 公開を止めているのは、deploy.ymlのneeds: checkです。画面の設定は何も変えていません。 flowchart TD A["html-validate が間違いを見つける"] --> B["終了コード1で終わる"] B --> C["check ジョブが失敗になる"] C --> D["needs: check の条件を満たさない"] D --> E["deploy ジョブはスキップ"] E --> F["wrangler が動かない"] F --> G["Cloudflare に何も届かない"] G --> H["公開中のページはそのまま"] (1) 赤と緑は、何で決まるの? コマンドの終了コードで決まります。0なら成功、0以外なら失敗です。 4章のログの最後にも、Process completed with exit code 1.とあります。Actionsは「DOCTYPE should be uppercase」という文章を読んでいるのではありません。公式ドキュメントにも、ステップの成功と失敗は終了コードに基づいて報告されると書かれています(Workflow syntax)。 つまり、間違いがあるときに0以外で終わるコマンドなら、何でもチェックとして使えます。 (2) needs:がないと、どうなるの? checkとdeployが同時に動くので、チェックが赤でも公開されるはずです(ここは試していません)。 ジョブは既定で並列に動きます。上から順に書いてあっても、順番はつきません。needs:を書くと、次の決まりが効きます。 > If a job fails or is skipped, all jobs that need it are skipped 必要としているジョブが失敗すると、そのジョブはスキップされます。この決まりのおかげで、deployが赤を判断する処理は一切書いていません。 (3) なぜcheck.ymlを別のファイルにしたの? プルリクエストとmainの両方で、同じチェックを使いたかったからです。 ただ、needs:は同じファイルの中のジョブしか指定できません。そこでcheck.ymlのon:にworkflow_callを書いて、ほかのワークフローから呼べるようにしました(公式ドキュメント)。deploy.ymlのcheckジョブは、それをuses:で呼んでいるだけです。 deploy.ymlの中にジョブとしてcheckがあるので、needs: checkと書けます。 プルリクエストのときのチェックと、マージのときのチェックは別の実行です。公開を止めているのは、マージのときの結果です。 (4) なぜマージは止まらなかったの? 無料プランのPrivateリポジトリでは、マージを止めるルールが効かないからです。 GitHubには、チェックが緑にならないとマージできなくする設定があります。ただ、SettingsのRulesetsを開くと、次の警告が出ていました。 !08_rulesets-warning.png 古い形式のBranch protection ruleも同じです。ルールは作れますが、リポジトリをTeamなどの組織アカウントに移すまで適用されません。 !09_branch-protection-warning.png だから、止めているものを分けて考える必要があります。 | しくみ | 止めるもの | 今回の環境 | |---|---|---| | Rulesets・Branch protection rule | マージ | 効かない | | needs: check | 公開 | 効く | needs:では、mainのコードが壊れることは防げません。防げるのは公開だけです。それでも、ワークフローのファイルに書くだけなので、無料プランでも使えます。 6. 前回と違うところ しくみ自体は前回と同じで、イベントが起きたらon:に合うワークフローが動きます。初めて出てきたものだけを説明します。 (1) pull_requestは、いつ動くの? types:を書かなければ、opened・synchronize・reopenedの3つで動きます(公式ドキュメント)。 | 名前 | いつ | |---|---| | opened | プルリクエストを作った | | synchronize | ブランチに新しいcommitをpushした | | reopened | 閉じたプルリクエストを開き直した | 4章の(2)で、pushしただけでチェックが動き直したのはsynchronizeのおかげです。これも前回と同じ、書かなかったときの既定値です。 (2) uses:のactions/checkout@v5は何? 持ち主/リポジトリ@バージョンの形で、GitHub上で公開されている部品を借りる書き方です。actions/checkoutは、GitHubが公開しているactions/checkoutのことです。 前回のcloudflare/wrangler-action@v4も、同じ形です。 (3) なぜcheckoutが必要なの? ランナーは毎回新しく用意され、リポジトリのファイルは入っていないからです。この行がないと、調べるpublic/がありません。 プルリクエストのときに取り出されるのは、ブランチの先頭ではありません。actions/checkoutは既定で、イベントの対象のcommitを取り出します(action.yml)。pull_requestの場合、それはrefs/pull/番号/mergeです。 つまり、「mainにマージしたらこうなる」という仮のcommitを調べています。ブランチ単体では問題がなくても、マージ後に壊れる場合に気づけます。 (4) run:は何? ランナーのシェルでコマンドを実行する書き方です。今回のコマンドはこれだけです。 npx --yes html-validate@11.18.0 public/ | 部分 | 意味 | |---|---| | npx | npmのパッケージを取ってきて実行する。package.jsonがなくても動く | | --yes | インストールの確認に答える人がいないので、先に「はい」と渡す | | @11.18.0 | バージョンを固定する。新版のルール追加で、ある日突然赤にならないように | | public/ | 調べるフォルダ。中の.htmlをすべて調べる | プルリクエストで変更したファイルだけを調べるのではありません。実際、4章の(1)の赤は、プルリクエストで変更していないindex.htmlで出たものです。 今回は小さなページ1つなので、全部調べても問題ありません。規模が大きくなって時間がかかるようになったら、変更したファイルだけに絞ることも検討が必要かもしれません。 Node.jsを入れる手順はありません。GitHubのランナーには最初からNode.jsが入っています。 7. ほかの言語のチェックツール 今回はHTMLだけですが、言語が変わってもチェックの入れ方は同じです。run:に書くコマンドが変わるだけです。 | 対象 | ツール | チェックするもの | |---|---|---| | HTML | html-validate(今回使った) | 文法・書き方 | | HTML | HTMLHint | 書き方 | | HTML | lychee | リンク切れ | | JavaScript / TypeScript | ESLint | 書き方 | | TypeScript | tsc | 型 | | Python | Ruff | 書き方 | | Python | mypy | 型 | | 共通 | Prettier | 整形 | | 共通 | gitleaks | コードに混ざったパスワードなど | こうしたツールには、今回のrecommendedのようにできあいのルールが用意されているものが多くあります。自分で作り込む必要があるのは、アプリの動きを確かめるテストの中身くらいです。 8. まとめ プルリクエストの時点で間違いに気づけて、うっかりマージしてしまっても、公開中のページは守られます。この2つを実際に確かめられたので、今回の検証は成功だと思います。 今回分かったことを並べると、次の3つです。 - 赤と緑を決めるのは、コマンドの終了コード。Actionsは出力の文章を読んでいない - ジョブは既定で並列に動く。needs:を書くと、相手が失敗したときにスキップされる - 無料プランのPrivateリポジトリではマージは止められない。それでも、公開はneeds:で止められる 次は、プルリクエストを作るとAIがレビューしてくれるしくみを試す予定です。
@edgarrmondragon And thank you for the PR that unblocked 3.16! 🚀
CI for an ASP.NET MVC 5 app on .NET Framework 4.8 with GitHub Actions An ASP.NET MVC 5 application on .NET Framework 4.8 needs a repeatable build long before it needs automated production deployment. A good first workflow restores packages, compiles the solution, runs the existing tests and produces a web deployment package someone can inspect. This is a setup guide built from official documentation that I re-read on November 2, 2026, and the workflow below is not just a sketch: I ran it unchanged against a minimal public sample, legacy-mvc48-ci-sample, an MVC 5 app on .NET Framework 4.8 with two MSTest tests. That run on windows-2022 restored, built, passed 2 of 2 tests, produced the package and uploaded both artifacts. A real application will have more projects and dependencies, so the paths and names are still yours to adapt. The Windows runner GitHub's .NET build and test guide is written around the dotnet CLI, which fits SDK-style projects. A classic MVC 5 web project with packages.config is usually built with Visual Studio's MSBuild instead, so the runner matters. The Windows Server 2022 runner inventory, image version 20261026.347.1 when I checked, lists Visual Studio Enterprise 2022, the MSBuild component, vswhere and .NET Framework 4.7.2, 4.8 and 4.8.1. That inventory changes over time, so record the image version shown in each workflow run. Write down the repository contract first Before touching YAML, list the solution path, the web project, the test projects, the build configuration and what the package should be called. Also list everything that only exists on a developer's machine: private NuGet feeds, DLLs referenced from a local folder, generated files, custom build tasks. Each one either gets an authorized way into CI or becomes an explicit blocker. That list is usually where legacy builds fail, not in the YAML. Restore packages.config dependencies Microsoft's nuget restore reference accepts a solution file, so nuget restore LegacyMvc.sln restores every packages.config in it. Keep it as its own step so a restore failure is not confused with a compile error. NuGet/setup-nuget puts nuget.exe on the runner; its latest release on November 2 was v4.0. nuget-version: latest is convenient for a first run, and the README also accepts version ranges such as 5.x, so pin one once the build works. If you use private feeds, supply credentials through secrets and the feed's documented method, never by committing a developer's NuGet.Config. Build, test, package microsoft/setup-msbuild finds MSBuild with vswhere and adds it to PATH. Its README is explicit that it "does NOT add other Visual Studio tools (like VSTest…)" to PATH, so the test step below locates vstest.console.exe itself. The MSBuild switches used here (/m, /t, /p) are in the MSBuild command-line reference. For packaging, Microsoft's guide to building and packaging web application projects shows DeployOnBuild=true with DeployTarget=Package: after the build, the Web Publishing Pipeline runs its Package target and produces [project name].zip, plus a .deploy.cmd and a SetParameters.xml next to it. The template searches for that zip instead of assuming a folder. Tested on the sample repository above, on November 2, 2026. Save an adapted copy as .github/workflows/legacy-mvc.yml. The action versions are the current major tags from each project's README or releases page; for stricter supply-chain control, pin reviewed commit SHAs. name: Legacy MVC CI on: pull_request: push: branches: [main] workflow_dispatch: permissions: contents: read jobs: build: runs-on: windows-2022 defaults: run: shell: pwsh steps: - uses: actions/checkout@v7 - uses: microsoft/setup-msbuild@v3 - uses: NuGet/setup-nuget@v4 with: nuget-version: latest - name: Restore packages.config dependencies run: | nuget restore LegacyMvc.sln -NonInteractive if ($LASTEXITCODE -ne 0) { throw 'Restore failed' } - name: Build solution run: | msbuild LegacyMvc.sln /m /t:Build /p:Configuration=Release '/p:Platform=Any CPU' if ($LASTEXITCODE -ne 0) { throw 'Build failed' } - name: Run existing tests run: | $vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe' $vs = & $vswhere -latest -products '*' -property installationPath $vstest = Join-Path $vs 'Common7\IDE\Extensions\TestPlatform\vstest.console.exe' $tests = 'tests\LegacyMvc.Tests\bin\Release\LegacyMvc.Tests.dll' if (!(Test-Path $vstest)) { throw 'vstest.console.exe not found' } if (!(Test-Path $tests)) { throw 'Test assembly not found' } & $vstest $tests '/Logger:trx;LogFileName=tests.trx' '/ResultsDirectory:TestResults' if ($LASTEXITCODE -ne 0) { throw 'Tests failed' } [xml]$trx = Get-Content 'TestResults\tests.trx' -Raw if ([int]$trx.TestRun.ResultSummary.Counters.total -lt 1) { throw 'No tests were discovered' } - name: Create web deployment package run: | msbuild src\LegacyMvc\LegacyMvc.csproj /p:Configuration=Release /p:DeployOnBuild=true /p:DeployTarget=Package if ($LASTEXITCODE -ne 0) { throw 'Packaging failed' } $zip = Get-ChildItem src\LegacyMvc -Recurse -Filter 'LegacyMvc.zip' | Select-Object -First 1 if (!$zip) { throw 'LegacyMvc.zip not found' } New-Item -ItemType Directory -Force artifacts | Out-Null Get-ChildItem $zip.DirectoryName | Copy-Item -Destination artifacts - name: Upload package uses: actions/upload-artifact@v7 with: name: legacy-mvc-package path: artifacts/ if-no-files-found: error - name: Upload test results if: always() uses: actions/upload-artifact@v7 with: name: legacy-mvc-test-results path: TestResults/ if-no-files-found: warn Make "tests ran" an actual check The VSTest.Console options cover the test assembly arguments, /Logger and /TestAdapterPath. MSTest, NUnit and xUnit each need their adapter available to the runner; if discovery fails on CI but works locally, /TestAdapterPath is the first thing to look at. In the sample, MSTest 4.5.2 installed through packages.config was discovered without it. The template reads the TRX file and fails when zero tests were reported, because a test step that exits cleanly after discovering nothing looks green and proves nothing. If the repository has no tests, say so in the handover instead of advertising coverage. And remember what unit tests don't cover: IIS configuration, the real database and browser behavior need their own checks. Inspect the package before trusting it Download the artifact and open it. The same Microsoft guide explains that SetParameters.xml is generated from the project and can include connection strings found in web.config, so this is exactly where a production connection string can leak into an artifact. A zip that exists is a good sign, not proof that it deploys. If the project already has reviewed publish settings, you can keep them in a profile under Properties/PublishProfiles/ and select it with PublishProfile, as Microsoft's command-line deployment tutorial does. That tutorial lists three command-line publish methods, MSDeploy, Package and FileSystem; for CI you want the profile to use Package, not publish straight to a server. It was written for Visual Studio 2012 and notes that building a single web project then also needed /p:VisualStudioVersion, so if MSBuild cannot find the web application targets, that property is worth checking. Keep production behind a separate gate No passwords, tokens or production connection strings in the YAML or in committed publish profiles. When you add a deployment job later, point it at a protected environment with required reviewers, which GitHub documents in its deployments and environments reference. Naming an environment in YAML does not create the approval rule by itself. Check your plan, too: the same page says that on GitHub Free, Pro and Team, required reviewers "are only available for public repositories", and most legacy business apps live in private ones. The first milestone is modest and useful: a package you inspected and test results you can read. Production deployment comes after staging works, access is approved and there is a rollback plan. If you have an older ASP.NET MVC or .NET Framework app that needs a build like this, or a focused fix, I offer a fixed-scope service on Upwork: upwork.com
初めてのCI/CD。pushしただけで公開まで流れる理由を、図で理解した(GitHub Actions + Cloudflare Workers) 1. はじめに CI/CDという言葉は知っていましたが、なかなか勉強するまでには至りませんでした。それが仕事で使うことになり、とうとう勉強を始めることにしました。 とはいえ、どこから手をつければいいのか分かりません。調べると記事はたくさんあり、手順も丁寧に書かれています。ただ、どれも「こう書けば動く」が中心で、なぜpushしただけで最後まで流れるのかは、読んでもいまいち分かりませんでした。 幸い、今はAIという優秀なパートナーがいます。そこで今回は、AI(Claude Code)に助けてもらいながら、自分の環境で1つずつ検証していきました。 まず作ったのは、GitHubにpushすると、GitHub ActionsがCloudflare Workersへ自動で公開してくれるしくみです。画面で操作したのは3つ、書いたファイルも3つだけで、pushすると公開まで勝手に進んでいきます。実際に動かしてみても、やはり疑問は「何で流れるの?」、これだけでした。 この記事では、手順に加えて、なぜ最後まで自動で流れるのかを図で説明します。同じところでもやもやしている人の参考になればうれしいです。 なお、記事の内容はすべて自分の環境で動かして確かめていますが、CI/CDは初心者です。間違いなどがあれば、コメントで教えていただけるとうれしいです。 2. 作ったもの GitHubのmainブランチにpushすると、約30秒で https://test.tobaru.net のページが更新されるしくみです。ページはHTMLファイル1つだけの、いちばん小さなものにしました。 ひとことで言うと、GitHubに置いたファイルを、GitHubのロボット(Actions)がCloudflareまで運んでくれます。運ぶときにCloudflareのドアを開けるので、ロボットに合鍵(APIトークン)を預けてあります。 !04_overview.png 自分で用意したサーバーはありません。DNSのレコードとhttpsの証明書も、手では作っていません。 3. 環境 | 項目 | 内容 | |---|---| | GitHub | Freeプラン、Privateリポジトリ | | Cloudflare | Freeプラン | | ドメイン | Cloudflare Registrarで取得したtobaru.net(DNSもCloudflare) | | デプロイの道具 | cloudflare/wrangler-action@v4、wrangler 4 | | 作業したPC | Windows 11、VS Code、Git | 自分のPCにNode.jsやwranglerは入れていません。wranglerはGitHub Actionsの中でだけ動きます。 ドメインのDNSがCloudflareにあることが、後で出てくる「1行でDNSと証明書ができる」の条件になります(5章の(5))。 4. 手順 画面で操作したのは(1)〜(3)の3つだけです。(4)はファイルを書くだけ、(5)はpushするだけです。 (1) GitHubにリポジトリを作る - 名前はtobaru-net-test、Privateにしました - README・.gitignore・ライセンスは付けず、空のリポジトリにします(ローカルで作ったファイルをそのままpushするため) (2) CloudflareでAPIトークンを作る 1. 右上の人のアイコン → プロフィール → APIトークン → トークンを作成する 2. テンプレート「Cloudflare Workers を編集する」を使う 3. 範囲を絞る - アカウントリソース: 自分のアカウントだけ - ゾーンリソース: 特定のゾーン → tobaru.netだけ 4. 作成する。トークンは一度しか表示されないので、そのまま(3)に貼る 範囲を絞っておくと、万一トークンが漏れても、できることは「Workersの更新」と「tobaru.netの設定」だけです。 (3) GitHubのSecretsに登録する リポジトリの Settings → Secrets and variables → Actions → New repository secret で、2つ登録します。 | Name | 値 | |---|---| | CLOUDFLARE_API_TOKEN | (2)で作ったトークン | | CLOUDFLARE_ACCOUNT_ID | CloudflareのアカウントID | (4) ファイルを3つ書く tobaru-net-test/ ├─ public/ │ └─ index.html … 見せるページそのもの ├─ wrangler.jsonc … Cloudflareへの注文書(何を・どこで出すか) └─ .github/ └─ workflows/ └─ deploy.yml … Actionsへの指示書(いつ・何をするか) public/index.htmlは、ふつうのHTMLです(中身は省略)。 wrangler.jsoncです。ふつうのJSONはコメントが書けませんが、jsonc(JSON with Comments)は//で書けます。 { // Cloudflare側でのWorkerの名前(名札)。 // この名前のWorkerがなければ新しく作り、あれば中身を置き換える "name": "tobaru-net-test", // Workersの動き方の基準日 "compatibility_date": "2026-10-01", // 静的なファイル(HTML・画像など)の置き場所。この中身がそのままサイトに並ぶ "assets": { "directory": "./public" }, // どのURLで出すか。custom_domain: true でDNSレコードと証明書も作ってくれる "routes": [ { "pattern": "test.tobaru.net", "custom_domain": true } ], // xxx.workers.dev というCloudflareのURLでは公開しない "workers_dev": false } .github/workflows/deploy.ymlです。 name: Deploy # いつ動くか(イベントの条件) on: push: branches: [main] # mainブランチにpushされたとき workflow_dispatch: # Actionsタブの「Run workflow」ボタンで手動でも動かせる jobs: deploy: runs-on: ubuntu-latest # GitHubが数十秒だけ貸してくれるLinux permissions: contents: read steps: # 1. このcommitのファイル一式をランナーに取り出す - uses: actions/checkout@v5 # 2. wranglerをインストールして動かす(commandを書かなければ wrangler deploy) - name: Deploy to Cloudflare Workers uses: cloudflare/wrangler-action@v4 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} # 金庫の合鍵 accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} # どのアカウントに送るか wranglerVersion: "4" (5) pushする git init -b main git add -A git commit -m "最初のページとデプロイ設定" git remote add origin https://github.com/<ユーザー名>/tobaru-net-test.git git push -u origin main pushしてから約30秒で、https://test.tobaru.netが開けるようになりました。10秒おきに確かめると、最初の3回はつながらず、4回目で200が返ってきました。 5. なぜ最後まで自動で流れるのか 答えを先に書くと、何かがずっと監視しているわけではありません。「イベントが起きたら、こう動く」という小さな決まりが、前の結果を受け取ってつながっているだけです。 pushしてから公開されるまでを、5つの段階に分けました。 !05_sequence.png 画像をクリックすると、大きく表示されます。 ここからは、私が分からなかったところを順に説明します。 (1) GitHubはフォルダを監視しているの? 監視はしていません。pushやプルリクエストなどのイベントが起きたときにだけ、GitHubがそのcommitの.github/workflows/を開きます。中のYAMLを全部読んで、on:の条件に合うものだけを動かします。 それが分かる例です。 | こんなとき | デプロイは動く? | |---|---| | README.mdだけ変えてpush | 動く。deploy.ymlは変わっていないが、「mainへのpush」というイベントが起きたから | | deploy.ymlを書き換えたが、pushしていない | 動かない。GitHubは自分のPCの中を見ていない | | deploy.ymlを書き換えてpush | 書き換えた後の内容で動く。そのcommitのファイルを読むから | | 別のブランチにpush | 動かない。on:がbranches: [main]だから | | プルリクエストをmainにマージ | 動く。マージするとmainにcommitが入り、mainへのpushと同じになるから | 「受付の人がマニュアルをずっと見張っている」のではありません。「呼び鈴が鳴ったらマニュアル棚を開いて、今回の用件に当てはまるページを探す」イメージです。マニュアル棚が.github/workflows/、ページの見出しがon:です。 (2) ファイルを指定していないのに、なぜwrangler.jsoncが使われるの? deploy.ymlのデプロイの手順には、「どのファイルを送るか」を書いていません。それでも動くのは、書かなかったときの決まり(既定値)が2段階あるからです。 1. wrangler-actionの決まり: commandを書かなければdeployを実行する(action.ymlに「Defaults to "deploy"」とあります)。workingDirectoryも書かなければ、リポジトリのいちばん上で実行する 2. wranglerの決まり: wrangler deployは、実行したフォルダでwrangler.jsonc / wrangler.json / wrangler.tomlを探して読む しかも、wrangler.jsoncそのものは送られません。「何を・どこに・どんな名前で」の指示として読まれ、送られるのはassets.directoryに書いたpublic/の中身です。 決まった名前・決まった場所に置けば何も書かなくても動き、変えたいときだけ書く。この考え方を「設定より規約(Convention over Configuration)」と呼ぶそうです。(1)の.github/workflows/も同じです。 (3) Cloudflareで何も作っていないのに、なぜWorkerができるの? wrangler.jsoncの"name": "tobaru-net-test"で、「この名前のWorkerを、この中身にして」と頼んでいるからです。Cloudflareは、その名前のWorkerがなければ新しく作り、あれば中身を置き換えます。上の図のaltのところです。 どのアカウントに作るかはaccountId、入っていいかはapiTokenで伝えています。どちらもSecretsから渡しているので、deploy.ymlには値そのものを書いていません。 (4) Secretsは何のため? Secretsは合鍵を入れておく金庫です。deploy.ymlには${{ secrets.CLOUDFLARE_API_TOKEN }}と、「金庫のこの引き出しを使って」とだけ書きます。合鍵そのものはどのファイルにも書かないので、コードを人に見せても漏れません。 (5) 1行でDNSと証明書までできるの? できました。routesに"custom_domain": trueと書くと、「このホスト名をまるごとこのWorkerにする」意味になり、CloudflareがDNSのレコードとhttpsの証明書を作ってくれます。 ただし、次の2つがそろっているからできることです。 - tobaru.netのDNSが、同じCloudflareアカウントにある(Cloudflareが自分でレコードを書き込める) - APIトークンにtobaru.netを触る権限がある(4章の(2)でゾーンを絞って許可した) DNSが別の会社にあるドメインでは、この1行だけでは済みません。 custom_domainを付けずに"pattern": "test.tobaru.net/api/*"のように書くと、「このURLに来たアクセスだけWorkerが受け取る」という別の使い方になります。この場合、DNSのレコードは自分で作ります。 6. 警告が出たところ 最初は、他の記事と同じくcloudflare/wrangler-action@v3を使っていました。デプロイは成功しましたが、Actionsの結果のAnnotationsに、警告1件とお知らせ1件が出ました。 !01_annotations.png (1) Node.js 20 is deprecated(警告) > Node.js 20 is deprecated. The following actions target Node.js 20 but are being forced to run on Node.js 24: cloudflare/wrangler-action@v3. wrangler-action@v3はNode.js 20向けに作られていて、GitHub側がNode.js 20を廃止していく途中なので、Node.js 24で動かしているという知らせです(GitHubのお知らせ)。今は動いていますが、将来動かなくなるおそれがあります。 調べるとv4が出ていて、action.ymlにusing: "node24"とありました。入力の項目(apiToken・accountIdなど)は同じなので、@v3を@v4に変えるだけで警告が消えました。 この修正もdeploy.ymlを1文字直してpushしただけです。しくみそのものの設定を変えた場合も、ページを変えた場合と同じ流れで反映されます(5章の(1)の表の3行目)。 (2) ubuntu-latestがUbuntu 26に移る(お知らせ) > The ubuntu-latest label will migrate to Ubuntu 26 beginning October 19, 2026. runs-on: ubuntu-latestの中身が、順に新しいUbuntuに切り替わるという案内です(runner-imagesのIssue)。今回のワークフローはwranglerを動かすだけなので、そのままにしています。 (3) nameを変えるときの注意 つまずいたわけではありませんが、しくみから考えると注意が必要だと分かったところです。 nameを変えてpushすると、Cloudflareは名前が変わったとは受け取りません。「その名前のWorkerがないので新しく作る」と受け取ります(5章の(3))。古いWorkerは残ったままになるので、変えるときは古いほうの片付けも必要です。 7. Cloudflareの画面で確かめる Cloudflareの画面では何も作っていませんが、pushしたあとに開くと、次の2つができていました。 (1) Workersの一覧 「Workers & Pages」を開くと、wrangler.jsoncのnameで付けたtobaru-net-testがあり、routesで頼んだtest.tobaru.netがつながっていました。 !02_workers.png 「デプロイメントを表示」を開くと、pushした回数ぶんの履歴が並んでいます。GitHubのcommitと1回ずつ対応しています。 (2) DNSのレコード tobaru.netのDNSのページには、自動で作られたtestのレコードがありました。自分では1つも作っていないのに、本当にレコードが出ていてびっくりしました。 !03_dns.png | 見えているもの | 意味 | |---|---| | 左端の鍵のアイコン | 自動で作られたレコードなので、ここからは編集できない | | タイプ「Worker」 | AやCNAMEではなく、「このWorkerに届けて」という特別なレコード | | コンテンツ「tobaru-net-test」 | wrangler.jsoncのname。名札でつながっている | | 「プロキシ済み」 | アクセスはいったんCloudflareが受け取る | 外のDNS(1.1.1.1)にtest.tobaru.netを聞いてみると、返ってくるのはCloudflareのIPアドレス(104.21.x.xなど)でした。「プロキシ済み」なので、サーバーの場所ではなくCloudflareのアドレスが返ります。応答のヘッダーにはServer: cloudflareとCF-RAY: …-NRTがあり、東京の拠点から返っていることも分かりました。 8. まとめ 最初は、なぜ自動で最後まで流れるのかが、いまいち理解できませんでした。でも、監視という大掛かりな仕組みではありませんでした。イベントが起きたときの動きが決まっていて、その決まりどおりに動くと、流れるように最後までつながる。それが分かったとき、美しいなーと思いました。DNSの画面に、自分では作っていないレコードが出ていたときも感動しました。 今回分かったことを並べると、次の3つです。 - GitHubは監視していない。イベントが起きたときに.github/workflows/を読み、on:に合うものだけを動かす - 書いていないことは既定値で動く。wrangler-actionはdeployを実行し、wranglerは決まった名前のwrangler.jsoncを探す - CloudflareはnameでWorkerを見分け、なければ作り、あれば置き換える。custom_domain: trueならDNSと証明書も作る 次は、プルリクエストを作ったらチェックだけを動かし、mainにマージしたらデプロイする形(CIとCDの分離)を試す予定です。チェックも、pull_requestというイベントに決まりを足すだけで作れるはずです。
CIを最大4.6倍高速化する方法 ! この記事は2023年11月1日に執筆されたものです。 こんにちは、InflabのDevOpsエンジニアのJakeです。 皆さんは、CIパイプラインの実行に時間がかかりすぎて、PRレビューに影響が出たことはありませんか? プロジェクトの規模が大きくなるにつれて長くなっていくCI/CDパイプラインの所要時間に、頭を悩ませたことはありませんか? 本記事では、CIを最大4.6倍まで高速化できたノウハウを共有します。 依存関係とビルドのキャッシュがヒットした場合の数値 最適化の結果 この記事のポイント CI(継続的インテグレーション)の所要時間を最大79%削減 Jenkins Pipelineの最適化: ビルド、テストを実行するDockerコンテナの依存関係キャッシュのパスを設定 モノレポのCIにインクリメンタルビルドを適用: turborepo remote cache シンプルながら非常に効果的で、簡単に適用できるCI/CDパイプライン最適化のノウハウを共有します。 最適化の背景 Inflabの開発チームではモノレポを導入し、パッケージ単位でコードを管理しています。プロジェクトの規模が大きくなるにつれてコードベースとパッケージが増え、CIの所要時間が急増しました。 プロジェクト初期: パッケージ1個、4分14秒 プロジェクト後期: パッケージ9個、12分4秒 プロジェクトの成長とともにコードで使うライブラリがどんどん増え、依存関係(dependency)のインストール時間が長くなりました。 また、ビルドとテストを順番に実行していたため、所要時間はどんどん積み上がっていきました。ビルドパイプライン側で直接並列実行させるなどの手も試しましたが、依存するパッケージ間の複雑な関係や並行性の問題があり、別の解決策を探す必要がありました。 そこで pnpm と turborepo を導入し、モノレポプロジェクトの生産性を改善することになりました。 Yarnとpnpmの比較 次のグラフは、npm、pnpm、yarn、yarn PnPのベンチマークです。 画像出典: pnpm.io グラフのとおり、依存関係のインストールでは条件によって約2倍速くなっています。私たちがまとめたそれぞれの特徴は次のとおりです。 yarn 依存関係のインストール時間: 最大120秒 複雑な node_modules の構造により、ディスクI/OとCPUリソースを消費 pnpm 依存関係のインストール時間: 最大63秒 symlinkでグローバルキャッシュのパス(.pnpm-store)にリンク -> 不要なファイルコピーが減少 -> ディスクI/Oが減少 node_modules └── .pnpm ├── bar@1.0.0 │ └── node_modules │ └── bar -> <store>/bar │ ├── index.js │ └── package.json └── foo@1.0.0 └── node_modules └── foo -> <store>/foo ├── index.js └── package.json これまで使っていた yarn には、依存関係をインストールすると複雑な node_modules ディレクトリ構造のせいでディスクI/OとCPUリソースが無駄に消費されるという欠点がありました。そのため、より高スペックなビルドサーバーが必要になり、所要時間も長くなっていました。 pnpm の導入後は、すべての依存パッケージを .pnpm-store にインストールし、各パッケージ内の node_modules ディレクトリにはsymlinkを張るだけの形になりました。これによりディスクへの書き込みが減り、依存関係のインストール時間を約60秒短縮できました。 Turborepo Turborepoを説明する前に、モノレポについて少しおさらいしておきます。 Monorepoとは? 画像出典: monorepo.tools モノレポ(Monorepo)とは? A monorepo is a single repository containing multiple distinct projects, with well-defined relationships. モノレポとは、関係が明確に定義された複数の個別プロジェクトを含むリポジトリを指す。 モノレポを導入すると、毎回多数のリポジトリを作成・管理する手間を減らせます。詳しくはリンク先をご参照ください。 関係が明確に定義されておらず、カプセル化もされていない単なるコードの寄せ集めはモノレポではなく、Monolithとも異なると説明されています。 このようにモノレポでは、複数のアプリやライブラリを1つのリポジトリで管理するため、同じ作業を多数のリポジトリに繰り返し適用する必要がありません。 しかし、モノレポのコードベースの規模が大きくなるとどうなるでしょうか? ビルドとテストを順次実行することによる所要時間の増加 変更されていない部分まで、不要なビルド & テストを繰り返し実行 次の例は、モノレポのCIパイプラインで、変更されたパッケージのビルドだけを並列実行するよう構成したものです。 stage('Docker Build') { parallel { stage('Package A') { when { anyOf { changeset "cmd/package-alpha//*" changeset "docker/package-alpha//*" } } steps { sh 'docker build -t package-alpha -f docker/package-alpha/Dockerfile .' } } stage('Package B') { when { anyOf { changeset "cmd/package-beta//*" changeset "docker/package-beta//*" } } steps { sh "docker build -t package-beta -f docker/package-beta/Dockerfile ." } } ...(中略)... プロダクトや組織の規模に応じて適切にリポジトリを分ける方法もありますが、結局のところ、モノレポを専用のツールなしで上記の例のようにCIパイプラインで直接管理しようとすると、かなりの工数がかかるでしょう。 パッケージが追加されるたびに手作業でCIパイプラインを修正するのは、本当に大変です。(とにかく手間がかかります) パッケージ同士が複雑な依存関係で絡み合っている場合、それをすべてCIパイプラインで実装するのは困難です。 こんなときに必要になるのが、turborepo や nx のようなモノレポツールです。 さまざまなモノレポツールがありますが、本記事では私たちが導入した turborepo を中心に説明します。 主な機能 turborepo は、JavaScriptおよびTypeScriptのコード向けの高性能なビルドシステムです。公式ページで説明されている特徴は次のとおりです。 Incremental Builds Content-aware hashing Parallel execution Remote Caching Zero runtime overhead Pruned subsets Task pipelines Meets you where you’re at Profile in your browser ここで強調したいのは、Incremental Builds、Content-aware hashing、Parallel execution、Remote Cachingです。順番に見ていきましょう。 Incremental Builds 日本語に直訳すると「増分ビルド」です。 追加・変更された部分だけをテスト & ビルドすることで、大規模なモノレポのCI所要時間を大幅に短縮します。 Detecting affected projects/packages Content-aware hashing コードの内容からハッシュ値を計算し、変更を検知します。 Gitに依存するchangesetとは対照的です。 Parallel execution 従来はビルドを順番に実行していたため、CPUリソースに無駄がありました。 ビルドを並列で実行することで、CPUコアを効率的に使い、ビルドをより早く終わらせることができます。 Local Task Orchestration Remote Caching Local Computation Caching taskの処理結果をローカルに保存して再利用する機能です。同じマシンでは、同じ状態のコードに対するビルドやテストを2回以上行いません。 Distributed Computation Caching Local Computation Cachingを拡張した概念で、taskの処理結果を1台のマシンではなく複数の環境にまたがって共有します。 CIエージェントのいずれかがすでにそのコードの状態をビルド・テストしていれば、ほかのエージェントがそれを再実行することは決してありません。 Remote Cachingは、Distributed Computation Cachingと似た概念です。 同じコードベースを修正する作業者が複数人いると仮定しましょう。 作業者Aが先にビルドとテストを実行していた場合、その結果は共用のキャッシュサーバーに保存されます。 続いて作業者Bがビルドとテストを実行するときは、共用キャッシュサーバーにキャッシュされた作業者Aの処理結果を読み込み、不要なビルドの実行をスキップします。 共用キャッシュサーバーにはビルドの成果物ファイルもあわせて保存されるため、それをすぐにデプロイに活用できます。同じ作業が繰り返し実行されるCIパイプラインを効果的に改善できます。詳しい数値は後ほど共有します。 上記の4つの機能をうまく活用すれば、大規模なプロジェクトもぐっと楽に管理できます。 ところで、なぜ本記事のタイトルはTurborepoの導入ではなく、CIの改善なのでしょうか? いくつか見落としていた点があり、これらのメリットを活かしきれていなかったからです。ここから説明していきます。 CI所要時間の最適化 Inflabでは、Jenkinsを使ってCI/CDパイプラインを構築しています。 CIジョブがトリガーされると、JenkinsのEC2エージェント上でDockerイメージを起動し、次の順序でジョブを実行します。 依存関係のインストール(Install Dependencies) テスト(Test) ビルド(Build) 静的解析(Static Analysis) 依存関係のインストール 依存関係のインストール処理をまとめると、次のとおりです。 pnpm i --frozen-lockfile 必要な依存ライブラリのバージョンを確認 --frozen-lockfile: 必要な依存関係のマニフェストファイル(pnpm-lock.yaml)が存在するかを確認し、記載されたバージョンに従って依存関係をインストール npmjs.com、GitHub Registry のようなパッケージリポジトリから依存関係をダウンロードしてインストール post-installスクリプトを実行 先ほど、pnpm を導入しただけで依存関係のインストール時間を約60秒短縮できたとお伝えしました。 実行環境が毎回変わるCI環境では、依存関係のキャッシュを導入すれば、さらに多くの時間を節約できます。 環境ごとの依存関係インストールの結果 ローカル環境では、pnpm i を実行するとグローバルな依存関係キャッシュのディレクトリからキャッシュを読み込み、不要なインストールをスキップします。一方、CI環境では実行サーバーが毎回変わる可能性があるため、依存関係のキャッシュを別途設定する必要があります。 ローカル環境: インストールしたすべての依存関係はグローバルキャッシュのディレクトリ(~/Library/pnpm/store/*)に蓄積 一度インストールした依存関係をほかのプロジェクトでも参照し、不要なインストールを省略(キャッシュヒット時は3秒前後) CI環境: 実行サーバーが毎回変わる可能性があるため、依存関係のキャッシュを別途設定しなければ毎回インストールが走る(約60秒) CI環境での依存関係のキャッシュ CI環境で依存関係をキャッシュする方法には、主に次のようなものがあります。 圧縮: node_modules のパスをtar & gzipで圧縮 CI用のキャッシュイメージを作る: 依存関係をあらかじめインストールしたDockerイメージを作成し、それをビルドに活用する方法 キャッシュボリュームのマウント: build agentに残っているglobal cacheを活用する方法 圧縮 GitHub Actions actions/cache@v3 の例 ... - id: cache uses: actions/cache@v3 with: path: '/node_modules' key: ${{ runner.os }}-node-${{ hashFiles(format('/{0}', steps.hash-file.outputs.hash-file)) }} ... Jenkins Job Cacherプラグインの活用例 cache(maxCacheSize: 250, defaultBranch: 'develop', caches: [ arbitraryFileCache(path: 'node_modules', cacheValidityDecidingFile: 'package-lock.json') ]) { // ... } 依存関係が入った node_modules ディレクトリをtarballにまとめ、gzipやxzなどで圧縮する方法です。 キャッシュは pnpm-lock.yaml のようなマニフェストファイルのハッシュで区別されるため、必要な依存関係が変わるたびにインストールし直す必要があるのが欠点です。 また、node_modules ディレクトリの圧縮は基本的にシングルスレッドで行われるため、時間がかかります。 プロジェクトの規模が大きくなると node_modules ディレクトリも大きく複雑になるため、キャッシュファイルのダウンロードと展開だけで2分以上かかることもあります。 これでは本末転倒なので、いっそ圧縮しないほうが良い場合もあります。 プロジェクトの規模が大きい場合 pnpm install の所要時間: 60秒 キャッシュファイルのダウンロードと展開の所要時間: 120秒以上 場合によっては、実際に所要時間を計測・比較したうえで判断する必要あり GitHub Actionsを使う場合は、pnpmの公式ドキュメントのガイドに従い、cache: 'pnpm' を設定するだけで簡単に適用できます。 pnpm公式ドキュメントで推奨されているGitHub Actionsパイプラインの例 name: pnpm Example Workflow on: push: jobs: build: runs-on: ubuntu-20.04 strategy: matrix: node-version: [15] steps: - uses: actions/checkout@v3 - uses: pnpm/action-setup@v2 with: version: 8 - name: Use Node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} cache: 'pnpm' - name: Install dependencies run: pnpm install Jenkinsパイプラインを使う場合は、次のように設定することが推奨されています。 pipeline { agent { docker { image 'node:lts-bullseye-slim' args '-p 3000:3000' } } stages { stage('Build') { steps { sh 'corepack enable' sh 'corepack prepare pnpm@latest-8 --activate' sh 'pnpm install' } } } } 上記の例ではpnpmのstore pathを別途指定していないため、JENKINS_WORKSPACE の配下に .pnpm-store のパスを作成して依存関係をキャッシュします。 しかし、Jenkins Multibranch Pipelineの場合は、ブランチやPull Requestごとに別々のWorkspaceのパスを持ちます。 つまり、同じプロジェクトであっても、異なるブランチやPR間で依存関係のキャッシュを共有できません。新しいPRが作成されるたびに、依存関係を最初からインストールし直すことになります。 これを解決する方法は、キャッシュボリュームのマウントで後ほど説明します。 CI用のキャッシュイメージを作る 次に紹介するのは、CI用のキャッシュイメージを作る方法です。 次の例は、pnpm fetch を使って依存関係のキャッシュイメージを作る Dockerfile です。 FROM node:18-slim pnpmのインストール手順は省略 pnpm fetch does require only lockfile COPY pnpm-lock.yaml ./ RUN pnpm fetch --prod 上記のイメージをpullしてから依存関係をインストールすれば、キャッシュを活用してすぐにビルドできます。 イメージタグにはマニフェストファイル(pnpm-lock.yaml)のハッシュ値を指定します。そのため先ほどの方法と同じく、このファイルが変更されたらイメージを再ビルドする必要があります。 when cache miss, build and push into image registry like ECR docker build -t rallit-frontend-dep:02d93ab24 -f dep.Dockerfile . docker push rallit-frontend-dep:02d93ab24 when build docker run -i --rm -v $WORKSPACE:/workspace rallit-frontend-dep:02d93ab24 'pnpm i --frozen-lockfile' ただし、pnpm fetch --prod コマンドでインストールした依存関係をイメージとしてビルドしてpullするため、その過程でディスクのオーバーヘッドが発生します。 社内で所要時間を計測した結果、実際のCI環境ではイメージをpullして実行するのに約168秒かかることが分かりました。 キャッシュイメージを作る方法も、プロジェクトの規模が大きいほど時間がかかり、通常の pnpm install より約108秒も遅くなりました。 pnpm-lock.yaml が変更されるたびにこうしたキャッシュイメージをビルドし、コンテナイメージレジストリにアップロードするのは非効率だと判断しました。 Dockerのマルチステージビルドを使ってイメージをビルドしている方は、pnpmの公式ドキュメントを参考にcache type mountingを適用することをおすすめします。 FROM base AS prod-deps RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile FROM base AS build RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile RUN pnpm run build DockerのRUN専用キャッシュを活用する パッケージをインストールするたびに、毎回インターネットからすべてのパッケージを取得する必要はありません。 --mount フラグとともに明示的なキャッシュを使うと、ビルド間で対象ディレクトリの内容が保持されます。 このレイヤーを再ビルドする必要がある場合は、そのパスにあるキャッシュを活用します。 私たちはDockerのマルチステージビルドを使わず、ビルドの段階ごとに個別に最適化したイメージを使っているため、この方法は適用できませんでした。 続いて、依存関係のインストール時間を大きく短縮できた、キャッシュボリュームのマウントについて説明します。 キャッシュボリュームのマウント 先ほど、Jenkinsパイプラインでpnpmを使って依存関係をインストールする場合は、キャッシュを別途設定する必要があると述べました。 つまり、同じプロジェクトであっても、異なるブランチやPR間で依存関係のキャッシュを共有できません。新しいPRが作成されるたびに、依存関係を最初からインストールし直すことになります。 実際の .pnpm-store のパスを比較しながら見ていきましょう。 従来(AS-IS) CACHE_PATH のパスが JENKINS_WORKSPACE の中にある 異なるプロジェクト、ブランチ、PR間で依存関係のキャッシュを共有できない パスの比較 JENKINS_WORKSPACE=/workspace/rallit_frontend_PR_15242 CACHE_PATH=/workspace/rallit_frontend_PR_15242/.pnpm-store 例えば、rallit_frontendのPR#15242のCIジョブが完了したとしましょう。 /workspace/rallit_frontend_PR_15242/.pnpm-store には、先ほどインストールした依存関係が残っています。 続いてPR#15243が作成されると、どうなるでしょうか? PR#15243は /workspace/rallit_frontend_PR_15243/.pnpm-store を参照することになります。 このフォルダは実際には存在しないので、依存関係を最初からインストールすることになり、その分時間がかかります。 先にインストールしたPR#15242の依存関係キャッシュは、まったく使えません。 たった1行の変更でも約60秒余計に待たされるので、かなり不便です。 変更後(TO-BE) エージェント内にGlobal Dependency Cacheのパスを設定 同じエージェントで実行するビルドはDependency Cacheを活用 パスの比較 JENKINS_WORKSPACE=/workspace/rallit_frontend_PR_15242 CACHE_PATH=/tmp/.pnpm-store CIジョブを実行するすべてのコンテナで、pnpm store-path オプションに同じパスを指定しました。 これにより、rallit_frontendのPR#15242、PR#15243はどちらも同じパス(/tmp/.pnpm-store)を参照するようになります。 PR#15243のCIジョブでは、PR#15242ですでにインストールされた依存関係を再利用することになり、約60秒かかっていた依存関係のインストール時間をわずか3秒に短縮できました。 pnpm store-path オプションは、ビルド用のベースイメージを作る Dockerfile で次のように指定しました。 set global node cache directory ARG CACHE_PATH=/cache/node RUN yarn config set cache-folder ${CACHE_PATH}/.yarn RUN pnpm config set store-dir ${CACHE_PATH}/.pnpm-store パッケージマネージャーにYarnを使っているプロジェクトでも依存関係のキャッシュパスを共有できるよう、Yarnにも同様の設定を入れています。 続いて、このDockerイメージを実行する Jenkinsfile 側でも CACHE_PATH 環境変数を設定します。 env.DOCKER_OPTION = '-v /tmp:/cache/node -e CI=1' (中略) stage('Install') { steps { script { sh "docker run --rm -i -v $WORKSPACE:/workspace $DOCKER_OPTION $NODE_IMAGE 'pnpm i --frozen-lockfile'" } } } JenkinsfileのDocker拡張機能を使わず、コマンドで直接Dockerコンテナを実行する場合 pnpmがCI環境かどうかの判定に使う環境変数の1つである BUILD_NUMBER が渡されないため、CI=1 のデフォルト値が適用されません。 -e BUILD_NUMBER=$BUILD_NUMBER で渡すか、-e CI=1 環境変数を直接設定するのがおすすめです。 詳しくは公式ドキュメントをご参照ください。 CI=trueの場合、pnpmはロックファイル(pnpm-lock.yaml)を生成せず、ロックファイルがマニフェストと同期していない場合、更新が必要な場合、または存在しない場合はインストールに失敗します。 この対応によって、直前のビルドで使われた依存関係のキャッシュを、ほかのリポジトリのCIでも再利用できるようになりました。 適用前 - pnpm i --frozen-lockfile Scope: all 7 workspace projects Lockfile is up to date, resolution step is skipped Progress: resolved 1, reused 0, downloaded 0, added 0 Packages: +3357 (中略) Progress: resolved 3357, reused 1253, downloaded 2025, added 3357, done Done in 1m 28.6s 適用後 - pnpm i --frozen-lockfile Scope: all 7 workspace projects Lockfile is up to date, resolution step is skipped Packages: -11 ----------- Done in 2.6s 依存関係インストール処理の最適化結果 依存関係キャッシュMiss: 1分28秒 依存関係キャッシュHit: 2.6秒 依存関係のインストール時間を最大97%短縮 依存関係のインストールは従来の88秒から2.6秒へと85.4秒減り、約97%短縮できました。 もちろん、ビルドエージェントが入れ替わって依存関係のキャッシュパスが初期化されると、最初の1回だけは1分28秒のインストールが必要です。 それでも、ビルドのたびに依存関係をインストールしていた従来とは違い、エージェント内で依存関係のキャッシュを共有できるので、後続のビルドではインストール時間を97%以上短縮できます。 複数のリポジトリが同じライブラリを使っていても、それぞれバージョンが異なる場合があります。 この場合、使用するバージョンを1つに統一するとキャッシュヒット率(Cache Hit Rate)が上がり、上記と同様に依存関係のインストール時間を大幅に短縮できました。 例: inflearn-frontend と rallit-frontend で使っている Datadog RUM(Real User Monitoring) エージェントのバージョンが一致している場合、npm registryからの依存関係のダウンロードとインストールをスキップし、ビルドエージェントにあるグローバルな依存関係キャッシュを活用して時間を短縮できる。 (rallit-frontend で先にインストールしていれば inflearn-frontend でそれを利用でき、その逆も可能) テストとビルドの所要時間の短縮 先に述べたとおり、モノレポでは一部のパッケージを修正しただけでも、すべてのパッケージをテスト・ビルドしなければなりません。 Turborepoを使えば、変更されたパッケージと、それに依存するパッケージだけをテスト・ビルドするので、CIの所要時間を大幅に短縮できます。これをTask Cacheと呼びます。 Inflabのフロントエンドチームは早くからTurborepoをローカル開発環境に導入していたため、実際に変更されたコードだけをテスト・ビルドして、作業内容を素早く検証できていました。 これはコンテキストスイッチの頻度を減らし、生産性の向上につながりました。(実験:遅いビルド時間の隠れたコスト in GeekNews) Turborepo Task Caching package.json にある build、test、lint などをtaskと呼びます。 Turborepoはtaskの実行結果とログをキャッシュし、遅いtaskを高速化します。 Content-aware hashingでソースコードの変更を検知し、変更されたパッケージに対してのみ build、test、lint などのtaskを実行します。 テストとビルドの結果は、まずLocal Filesystem Cacheとして ./node_modules/.cache/turbo/78awdk123.tar.zst のようなパスに保存されます。 Taskキャッシュがヒット(hit)したパッケージはビルドをスキップし、変更されたパッケージだけを新たにテスト・ビルドします。 実行結果をもとにTaskキャッシュを更新し、常に最新の状態に保ちます。 Taskキャッシュがミス(miss)した場合は、次のようにtaskを実行し、その結果でLocal Filesystem cacheを更新します。 /lib:build: cache miss, executing 86fb10d5583f361c /app:build: cache miss, executing 7804de963ab93a0e (中略) Tasks: 5 successful, 5 total Cached: 0 cached, 5 total Time: 4m36.208s ...writing to cache... Taskキャッシュがヒット(hit)した場合は、キャッシュ作成時のログを再生(replay)し、ビルド結果をキャッシュサーバーからダウンロードします。 /lib:build: cache hit, replaying logs 86fb10d5583f361c /ds:build: cache hit, replaying logs 7804de963ab93a0e /a:build: cache hit, replaying logs 8be7f0056074cbc3 /b:build: cache hit, replaying logs d01733e33b71a814 Tasks: 5 successful, 5 total Cached: 5 cached, 5 total Time: 4.114s >>> FULL TURBO Taskキャッシュの適用結果 TaskキャッシュMiss: 4分36秒 TaskキャッシュHit: 4.114秒 Taskキャッシュの導入で所要時間を約98.5%改善 Taskキャッシュも依存関係のキャッシュと同じく、キャッシュがない初回は時間がかかります。しかしそれ以降のビルドでは変更されたパッケージに対してのみTaskを実行するため、かなりの時間を節約できました。 問題は、別途設定をしなければLocal Filesystem Cacheしか使えないことです。 Jenkinsエージェントではworkspaceをブランチ・PR単位で分けているため、検証済みのPRを develop ブランチにマージすると、テストとビルドを最初からやり直す必要がありました。 CIパイプラインで一度ビルドしたものは再ビルドせずに済ませたいところです。どうすれば改善できるでしょうか? Turborepo Remote Caching Turborepo Remote Caching機能を使えば、この問題を解決できます。 Taskキャッシュを複数のビルドエージェント間で共有でき、インクリメンタルビルドを積極的に活用できるようになります。 Remote Cachingを使うには、追加費用が発生するVercelのクラウドを使うか、Vercelが公開しているAPI仕様に従ってTurborepo custom remote cache serverを自前で実装する必要があります。 私たちは、オープンソースとして公開されているducktors/turborepo-remote-cacheを使いました。 ducktors/turborepo-remote-cache Turborepo custom remote cache serverのオープンソース実装で、self-hosted環境で使うのに適しています。 シンプルなWebサーバーアプリケーションで、次の実行方法をサポートしています。 Dockerコンテナとして実行 AWS Lambdaなどのサーバーレス関数として実行 ストレージについては、次のようなさまざまなオプションが用意されています。 Amazon S3(または互換ストレージ) ファイルシステム Lambda + S3の組み合わせで完全にサーバーレスで構築することもできます。それぞれのメリット・デメリットを踏まえて、環境に合った構成を選べば良いでしょう。 ここでは、Docker + ファイルシステムでカスタムキャッシュサーバーを手軽に構築する手順を紹介します。 次のように compose.yaml ファイルを作成します。${} の中に入る変数は .env ファイルで定義します。 services: turborepo-remote-cache: image: ducktors/turborepo-remote-cache:1.14.2 ports: - 3000:3000 volumes: - ./tmp:/tmp environment: - NODE_ENV=production - PORT=3000 - TURBO_TOKEN=${TURBO_TOKEN} - LOG_LEVEL=info - STORAGE_PROVIDER=local - AWS_REGION=${AWS_REGION} 続いてガイドに従い、Turborepoのカスタムキャッシュサーバーのアドレスといくつかのパラメーターを、Dockerの環境変数として渡します。 TURBO_API: キャッシュサーバーのアドレス TURBO_TOKEN: 認証トークン TURBO_TEAM: キャッシュを使うteam(主にプロジェクト単位で指定) stage('Build') { steps { script { env.DOCKER_OPTION = "$DOCKER_OPTION -e TURBO_API=$TURBO_API -e TURBO_TOKEN=$TURBO_TOKEN -e TURBO_TEAM=$TURBO_TEAM" sh "docker run --rm -i -v $WORKSPACE:/workspace $DOCKER_OPTION $NODE_IMAGE 'pnpm build'" } } } Dockerの環境変数ではなくturbo.jsonなどの設定ファイルに直接書く方法もありますが、今回はCI専用のキャッシュサーバーとの連携なので、上記のようにパイプライン側に記述しました。 適用後にビルドを何度か実行すると Remote caching enabled というログが出力され、Taskキャッシュが蓄積・再利用されていることが確認できます。 - pnpm build:development • Packages in scope: ...... • Running build:development in 6 packages • Remote caching enabled a:build: cache hit, replaying logs 83da8d8fb24dd1a9 b:build: cache hit, replaying logs 249bb66c4e03301b c:build: cache hit, replaying logs 8be7f0056074cbc3 (中略) Tasks: 5 successful, 5 total Cached: 5 cached, 5 total Time: 4.114s >>> FULL TURBO ハッシュはコードの状態をもとに計算されるので、ブランチやPRが違っても、コードを修正していなければTaskが無駄に再実行されることはありません。 では、Continuous Deploymentのパイプラインでビルドキャッシュを使う場合、ビルド成果物はどうデプロイすれば良いでしょうか? turbo.jsonでコマンドごとにoutputsのパスを設定すると、そのパスがビルド成果物として扱われ、Turborepoのリモートキャッシュサーバーに保存されます。キャッシュヒット時には、それがダウンロードされます。 そのため、デプロイ時の混乱を防ぐには、outputs オプションを正しく設定しておく必要があります。(関連する公式ドキュメント) turbo.json { "$schema": "turbo.build", "pipeline": { "app#build": { "outputs": ["dist/"] }, "util#build": { "outputs": ["lib/"] } } } このオプションを正しく設定していないと、デプロイパイプラインを経由しない緊急ロールバックのあとにパイプラインを再実行しても、本番環境に反映されないことがあるので注意してください。 便利さの裏には慎重さと正確さが欠かせないことを、心に留めておきましょう。 ソースコードの静的解析 依存関係のインストールと、テスト・ビルド。この2つを最適化した結果、すべてのキャッシュがヒットすれば、ここまでの処理は10秒前後で終わります。 静的解析では、コードのセキュリティ脆弱性から、重複やパフォーマンス上の問題が起こりうる箇所まで見つけられるため、SonarQubeを積極的に導入・活用しています。 しかし、CIのたびに静的解析を実行していたため、コードの規模によっては40秒から最大2分以上かかることもありました。 これを短縮するため、解析が不要なファイルを sonar-project.properties で除外しました。 sonar.exclusions=packages//public/mockServiceWorker.js, packages//src/@types//, packages//src/mocks//, packages//vite.config.ts, sonar.analysis.mode=incrementalは使用不可 以前は -Dsonar.analysis.mode=incremental パラメーターを追加して変更箇所だけを解析する機能がありましたが、現在はサポートが終了しています。 代わりに、変更されたディレクトリだけを解析する仕組みをパイプラインに自前で実装する方法や、CIのポリシーを変更して develop ブランチへのマージ時にのみ解析する方法があります。 銀の弾丸(silver-bullet)は存在しないので、皆さんの環境に合った方法を選んで改善してください。 詳しくはSonarQubeのドキュメントをご参照ください。 私たちのContinuous Deploymentパイプラインには、コンテナのデプロイ完了を待つ処理がありました。その待ち時間にSonarQubeの解析を並行して実行することで、約40秒〜最大2分かかっていた静的解析の時間を短縮できました。 CI最適化の結果 キャッシュHitを想定(install、buildがともにヒットした場合) pnpmの依存関係キャッシュとTurborepoのTaskキャッシュを適用したことで、CIの所要時間を最大79%短縮できました。 モノレポの中でも、パッケージ数とコード量が多いプロジェクトほど最適化の効果が際立ちました。パッケージが多いほど、修正による影響範囲が小さくなるためです。 さらに、turborepo もモノレポも導入していないプロジェクトE、Fでも、依存関係のインストールを最適化しただけでCIが約1.6〜2倍速くなりました。 調査対象の平均CI所要時間は6分3秒から2分4秒になり、最大66%改善できました。静的解析まで最適化すれば、さらに時間を節約できると期待しています。 おわりに 以上、簡単な修正でCIの所要時間を劇的に改善した経験を共有しました。皆さんもぜひ試してみて、得られた成果やより良い方法をコメントで教えていただけるとうれしいです。 最適化を進めるなかで私を信頼し、込み入った質問にも丁寧に答えてくださったInflab開発チームの皆さんのおかげで、無事に最適化を終えることができました。ありがとうございました。 次回の記事では、JenkinsのEC2エージェントのコンピューティングコストを最大70%節約できたスポットインスタンスの導入をテーマにお届けします。 Inflab DevOpsチームの最適化はまだまだ続きます。今後もご期待ください! 参考資料 monorepo.tools pnpm.io
macOS runners are vulnerable to the eMudhra intermediate CA compromise · Issue #15228 · actions/runner-images : 👀 --- runner-images#15228
macOS + GitHub Actions: eMudhra fixes require ongoing curation. Runner images can retain vulnerable CA bundles; updating Homebrew doesn't fix Apple's curl/Python. Apple Python can also ignore CA env overrides. Test each client--and recheck after OS updates. runner-images#15228