開発効率化

GitHub Actions self-hosted runner更新義務化入門——CI停止を防ぐ棚卸し

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

2026年6月21日
GitHub ActionsCI/CDself-hosted runnerFirestore運用設計
GitHub Actions self-hosted runner更新義務化入門——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 Residency2026-07-312026-06-29から段階実施
GitHub Enterprise Cloud2026-09-252026-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-billingfirestore-emulatorrelease-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 runner1ジョブごとに破棄し、最新イメージから再作成するログを外部保存しないと障害調査が難しい

GitHub Docsでは、self-hosted runnerはGitHub-hosted runnerより自由度が高い一方、OSや周辺ソフトウェアの更新責任は利用者側にあると説明されています。また、ephemeral runnerを使う場合は、runnerログを外部ストレージに転送しておくことが推奨されています。

OrderDockのようなWebアプリ開発では、次の3段階に分けると進めやすいです。

  1. すべてのrunnerの version、ラベル、担当チームをFirestoreに保存する
  2. 2.329.0 未満、または version 不明のrunnerをGitHub Issuesへ起票する
  3. 月次ではなく週次でrunnerイメージを再ビルドし、yarn build とFirestore Emulatorの結合テストを通す

大事なのは「古いrunnerを直す」だけではありません。どのCIがどのrunnerに依存しているかを見える化し、Next.jsのPR検証、Firestoreルール検証、デプロイ準備を別々に復旧できる状態にすることです。

参考リンク

まとめ

今回の変更は、GitHub Actionsの信頼性向上に伴う運用ルールの明確化です。self-hosted runnerを使っているチームは、登録最低バージョンの 2.329.0 だけを見て終わらせず、30日以内に更新し続ける仕組みまで作る必要があります。

Next.js + Firestoreの開発では、CIが止まると型チェック、ビルド、セキュリティルール検証、デプロイ準備が連鎖して止まります。まずはREST APIでrunner一覧を取得し、Firestoreに監査スナップショットを残すところから始めるのが現実的です。