GitHub Actions self-hosted runner更新義務化入門——CI停止を防ぐ棚卸し
GitHub Actionsのself-hosted runner最小バージョン enforcement 再開を題材に、Next.jsとFirestoreでCI基盤の棚卸しと更新監視を作る実践手順を解説します。

はじめに
GitHub Actionsのself-hosted runnerを使っていて、こんな状態になっていませんか?
| 状況 | 起きやすい問題 |
|---|---|
| 社内ネットワークへ接続するためにrunnerを固定VMで運用している | OSやrunnerアプリの更新が後回しになる |
| Dockerイメージにrunnerを焼き込んでいる | 古いイメージから再作成したrunnerが登録できない |
self-hostedラベルだけでworkflowを分けている | どのチームのCIが止まるか事前に見えない |
2026年6月12日、GitHubはGitHub Actionsのself-hosted runnerに対するバージョン要件 enforcement の再開タイムラインを発表しました。ポイントは2つです。runnerの登録・再登録には 2.329.0 以上が必要になり、ジョブ実行については新しいrunnerリリースから30日以内に更新し続ける必要があります。
本記事では、架空の販売管理SaaS「OrderDock」を題材に、Next.js + Firestore + GitHub Actions + TypeScriptの現場でrunner棚卸しを作る手順を解説します。読み終えると、CIが突然queueに積まれる前に、古いrunnerと影響範囲を見つけられるようになります。
何が変わるのか
GitHub公式Changelogによると、対象はgithub.comのGitHub Enterprise CloudとGitHub Enterprise Cloud with Data Residencyです。GitHub Enterprise Serverはこの発表時点では対象外とされています。
| 対象 | フルenforcement開始日 | 事前brownout |
|---|---|---|
| GitHub Enterprise Cloud with Data Residency | 2026-07-31 | 2026-06-29から段階実施 |
| GitHub Enterprise Cloud | 2026-09-25 | 2026-08-24から段階実施 |
注意したいのは、2.329.0 が「永久に動く最低バージョン」ではない点です。これは新アーキテクチャへ登録するための最低ラインであり、ジョブ実行に必要な実効バージョンはrunnerの新リリースに合わせて前へ進みます。自動更新を切っているrunnerは、30日以内に手動更新しないとジョブを受け取れなくなります。
OrderDockでは、請求PDF生成やFirestore Emulatorを使う結合テストを社内runnerで動かしています。このrunnerが止まると、Next.jsの通常PRだけでなく、月次請求に関わる検証も詰まります。つまりrunner更新はインフラ担当だけの話ではなく、アプリ開発チームのリリースリスクです。
ハンズオン1: runner一覧をAPIで取得する
まずは棚卸しです。GitHub DocsのREST APIには、organization配下のself-hosted runnerを取得するエンドポイントがあります。レスポンス例には version フィールドが含まれているため、これを使って古いrunnerを検出します。
curl -L \
-H "Accept: application/vnd.github+json" \
-H "Authorization: Bearer $GITHUB_TOKEN" \
-H "X-GitHub-Api-Version: 2026-03-10" \
"https://api.github.com/orgs/$GITHUB_ORG/actions/runners"
トークンにはorganizationのself-hosted runnerを読む権限が必要です。Fine-grained personal access tokenやGitHub Appを使う場合は、GitHub Docsの「Self-hosted runners」権限を確認してから最小権限で発行します。
次に、取得結果をTypeScriptで扱いやすい形にします。ここでは 2.329.0 未満を登録リスク、30日更新は別監視として扱います。
type Runner = {
id: number;
name: string;
os: string;
status: "online" | "offline";
busy: boolean;
version?: string;
labels: { name: string; type: string }[];
};
function compareVersion(left: string, right: string): number {
const a = left.split(".").map(Number);
const b = right.split(".").map(Number);
for (let i = 0; i < 3; i += 1) {
const diff = (a[i] ?? 0) - (b[i] ?? 0);
if (diff !== 0) return diff;
}
return 0;
}
function classifyRunner(runner: Runner) {
if (!runner.version) return "unknown";
if (compareVersion(runner.version, "2.329.0") < 0) return "registration-risk";
return "registered-baseline-ok";
}
version が空の場合は安全扱いにしません。古いAPIレスポンス、権限不足、未登録状態などの可能性を分けて調査するため、unknown として残します。
ハンズオン2: Firestoreへ監査スナップショットを保存する
棚卸し結果は、毎回Actionsログを読むだけでは運用に乗りません。OrderDockではFirestoreの ciRunnerAudits コレクションにスナップショットを保存し、後からダッシュボードや週次レポートで見られるようにします。
// scripts/audit-self-hosted-runners.ts
import { applicationDefault, getApps, initializeApp } from "firebase-admin/app";
import { FieldValue, getFirestore } from "firebase-admin/firestore";
if (getApps().length === 0) {
initializeApp({ credential: applicationDefault() });
}
const db = getFirestore();
type RunnerResponse = {
total_count: number;
runners: {
id: number;
name: string;
os: string;
status: "online" | "offline";
busy: boolean;
version?: string;
labels: { name: string; type: string }[];
}[];
};
const org = process.env.GITHUB_ORG;
const token = process.env.GITHUB_TOKEN;
if (!org || !token) {
throw new Error("GITHUB_ORG and GITHUB_TOKEN are required");
}
function compareVersion(left: string, right: string): number {
const a = left.split(".").map(Number);
const b = right.split(".").map(Number);
for (let i = 0; i < 3; i += 1) {
const diff = (a[i] ?? 0) - (b[i] ?? 0);
if (diff !== 0) return diff;
}
return 0;
}
function getRisk(version: string | undefined) {
if (!version) return "unknown";
return compareVersion(version, "2.329.0") < 0
? "registration-risk"
: "registered-baseline-ok";
}
const response = await fetch(`https://api.github.com/orgs/${org}/actions/runners`, {
headers: {
Accept: "application/vnd.github+json",
Authorization: `Bearer ${token}`,
"X-GitHub-Api-Version": "2026-03-10",
},
});
if (!response.ok) {
throw new Error(`GitHub API failed: ${response.status} ${response.statusText}`);
}
const data = (await response.json()) as RunnerResponse;
const batch = db.batch();
const auditRef = db.collection("ciRunnerAudits").doc();
batch.set(auditRef, {
org,
totalCount: data.total_count,
createdAt: FieldValue.serverTimestamp(),
});
for (const runner of data.runners) {
const runnerRef = auditRef.collection("runners").doc(String(runner.id));
batch.set(runnerRef, {
name: runner.name,
os: runner.os,
status: runner.status,
busy: runner.busy,
version: runner.version ?? null,
labels: runner.labels.map((label) => label.name),
risk: getRisk(runner.version),
});
}
await batch.commit();
実運用では、runner名にチーム名や用途が入っていないことも多いです。その場合はラベルを設計します。たとえば orderdock-billing、firestore-emulator、release-candidate のように、止まったときの影響が読めるラベルを追加します。
ハンズオン3: GitHub Actionsで毎朝チェックする
最後に、棚卸しスクリプトをGitHub Actionsから定期実行します。監視自体はGitHub-hosted runnerで動かすのが無難です。監視対象のself-hosted runnerが止まっているときでも、検出ジョブは走る必要があるためです。
name: Audit self-hosted runners
on:
schedule:
- cron: "0 23 * * *" # 08:00 JST
workflow_dispatch:
permissions:
contents: read
jobs:
audit:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
cache: "yarn"
- run: yarn install --frozen-lockfile
- name: Prepare Google credentials
run: |
printf '%s' "$GOOGLE_APPLICATION_CREDENTIALS_JSON" > "$RUNNER_TEMP/google-credentials.json"
env:
GOOGLE_APPLICATION_CREDENTIALS_JSON: ${{ secrets.GOOGLE_APPLICATION_CREDENTIALS_JSON }}
- run: yarn tsx scripts/audit-self-hosted-runners.ts
env:
GITHUB_ORG: ${{ vars.GITHUB_ORG }}
GITHUB_TOKEN: ${{ secrets.RUNNER_AUDIT_TOKEN }}
GOOGLE_APPLICATION_CREDENTIALS: ${{ runner.temp }}/google-credentials.json
この例の tsx はTypeScriptスクリプト実行用のnpmパッケージです。プロジェクトに入っていない場合は yarn add -D tsx で追加します。既存のビルド基盤で ts-node や別の実行方法を使っているなら、そこに合わせてください。
GOOGLE_APPLICATION_CREDENTIALS をSecretに直接JSON文字列として入れる運用では、実行前に一時ファイルへ書き出す必要があります。Google CloudのWorkload Identity Federationを使える環境なら、長期鍵を置かない構成に寄せるほうが安全です。
更新戦略を決める
self-hosted runnerの更新方針は、runnerの作り方によって変わります。
| runner方式 | 更新方針 | 注意点 |
|---|---|---|
| 長期稼働VM | 自動更新を有効にし、OS更新も別途管理する | ジョブ間で状態が残るため掃除が必要 |
| コンテナ化runner | --disableupdate を使うならベースイメージを定期再ビルドする | 30日以内の更新をCIで担保する |
| ephemeral runner | 1ジョブごとに破棄し、最新イメージから再作成する | ログを外部保存しないと障害調査が難しい |
GitHub Docsでは、self-hosted runnerはGitHub-hosted runnerより自由度が高い一方、OSや周辺ソフトウェアの更新責任は利用者側にあると説明されています。また、ephemeral runnerを使う場合は、runnerログを外部ストレージに転送しておくことが推奨されています。
OrderDockのようなWebアプリ開発では、次の3段階に分けると進めやすいです。
- すべてのrunnerの
version、ラベル、担当チームをFirestoreに保存する 2.329.0未満、またはversion不明のrunnerをGitHub Issuesへ起票する- 月次ではなく週次でrunnerイメージを再ビルドし、
yarn buildとFirestore Emulatorの結合テストを通す
大事なのは「古いrunnerを直す」だけではありません。どのCIがどのrunnerに依存しているかを見える化し、Next.jsのPR検証、Firestoreルール検証、デプロイ準備を別々に復旧できる状態にすることです。
参考リンク
- GitHub Changelog: Minimum version enforcement timeline for self-hosted runners
- GitHub Docs: Self-hosted runners
- GitHub Docs: REST API endpoints for self-hosted runners
- actions/runner releases
まとめ
今回の変更は、GitHub Actionsの信頼性向上に伴う運用ルールの明確化です。self-hosted runnerを使っているチームは、登録最低バージョンの 2.329.0 だけを見て終わらせず、30日以内に更新し続ける仕組みまで作る必要があります。
Next.js + Firestoreの開発では、CIが止まると型チェック、ビルド、セキュリティルール検証、デプロイ準備が連鎖して止まります。まずはREST APIでrunner一覧を取得し、Firestoreに監査スナップショットを残すところから始めるのが現実的です。