GitHub Actionsにキャッシュを入れたのに、毎回npm ciが動いている。これは設定ミスとは限りません。setup-nodeのcache: npmが再利用するのは、npmが取得したパッケージのキャッシュであり、node_modulesそのものではないからです。
Node.jsのテスト環境を整えるときは、cacheで再取得を減らす、matrixで対応環境を確かめる、concurrencyで古い実行を止めるという3つの役割を分けます。どれも同じ「高速化スイッチ」ではありません。特にmatrixは検証の組み合わせを増やすため、総実行量も増えます。
この記事ではnpmを使うリポジトリ向けに、ルート構成とapps/web構成のYAMLを示します。2026年9月12日に公式仕様とActionの参照先を確認し、actionlint 1.7.12で両方の構文・式を検証しました。GitHub上での実行、キャッシュのヒット、処理時間の短縮は未測定です。
cache: npmを指定しても、npm ciとテストは毎回実行する
setup-nodeには、パッケージマネージャーのキャッシュを復元・保存する機能があります。公式のキャッシュ説明では、ロックファイルのハッシュをキーの一部として使い、node_modulesはキャッシュしないとされています。
そのため、キャッシュが見つかった場合もnpm ciを省きません。npm ciはロックファイルに基づいて依存関係を用意し、その後のテストが使う環境を作ります。テスト結果もキャッシュしたことにはならないので、cache-hitを条件にnpm testをスキップしないようにします。
| 処理 | キャッシュで変わり得ること | 残る処理 |
|---|---|---|
| パッケージの取得 | 保存済みのデータを再利用できる | 未取得分のダウンロードなど |
| npm ci | 取得待ちが減る場合がある | 依存関係の展開や必要なインストール処理 |
| npm test | この設定だけではテスト自体は速くならない | テストの実行 |
| matrix | 別の組み合わせを並行して検証できる | 組み合わせごとのセットアップ・テスト |
テストが全体の大半を占めるなら、依存取得だけを改善しても変化は小さくなります。まずジョブのログで、どの工程を待っているのかを分けて見てください。
ルートにpackage-lock.jsonがある場合の完成YAML
リポジトリのルートにpackage.jsonとpackage-lock.jsonがあり、npm ciとnpm testが実行できる構成を想定します。npm testには、watchで待ち続けず1回で終了するコマンドを設定してください。たとえばVitestならvitest runです。
次の内容を.github/workflows/node-tests.ymlとして保存します。例ではNode 22・24とUbuntu・Windowsの4組を検証します。自分のプロジェクトのenginesや対応方針に含まれる組み合わせへ変更してから使ってください。Node 24専用のアプリへ22の検査を機械的に追加する必要はありません。
name: Node tests
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Node ${{ matrix.node }} / ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 15
strategy:
fail-fast: false
max-parallel: 2
matrix:
node: ['22', '24']
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci
- name: Run tests once
run: npm test
checkoutとsetup-nodeは、確認時点のv7タグが指すコミットSHAに固定しています。Actionを更新するときは公式リリースの変更点と参照SHAを確認します。これはAction自身の版であり、matrix.nodeで選ぶプロジェクトのNode.jsの版とは別です。
この例はGitHubホストランナーを想定し、contents: readとpersist-credentials: falseでテストに不要な書き込みを持たせていません。公開・デプロイやAPIキーを使う処理は含みません。イベントもpull_requestとmainへのpushに限定しています。
| 設定 | この例で選んだ理由 |
|---|---|
| node: [’22’, ’24’] | 2つのNodeメジャーで互換性を見る。パッチ版は各範囲から選ばれる |
| os: [ubuntu-latest, windows-latest] | パス・改行・シェルなどOSによる差も検査対象にする |
| fail-fast: false | 1組が失敗しても、ほかの組み合わせの結果を集める |
| max-parallel: 2 | このmatrixで同時に動かすジョブを最大2つにする |
| timeout-minutes: 15 | 終わらないテストなどでジョブが待ち続ける範囲を制限する |
15分は例の上限であり、全プロジェクトの適正値ではありません。実測した通常の所要時間と変動幅に合わせます。max-parallelも、低くすれば速くなる設定ではなく、同時実行を抑える設定です。matrixの失敗制御と並行数はGitHubのmatrixガイドで確認できます。
apps/webに置く場合は、ロックファイルと実行場所の両方を変える
リポジトリの中にアプリを置くとき、cache-dependency-pathだけを直してもnpm ciの実行場所は変わりません。逆に、working-directoryだけを変えてもsetup-nodeが探すロックファイルの指定にはなりません。
次の例は、apps/web自身がpackage.jsonとpackage-lock.jsonを持つ独立したnpmプロジェクトです。npm workspacesでルートのロックファイルを共有する構成とは分けて考えてください。
name: Web app tests
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
test:
name: Node ${{ matrix.node }} / ${{ matrix.os }}
runs-on: ${{ matrix.os }}
timeout-minutes: 15
defaults:
run:
working-directory: apps/web
strategy:
fail-fast: false
max-parallel: 2
matrix:
node: ['22', '24']
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
with:
persist-credentials: false
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7
with:
node-version: ${{ matrix.node }}
cache: npm
cache-dependency-path: apps/web/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run tests once
run: npm test
defaults.run.working-directoryはrunステップに適用されます。usesステップのsetup-nodeへは適用されないため、cache-dependency-pathにはリポジトリのルートからのパスを別途書いています。
| リポジトリ構成 | キャッシュのロックファイル | インストール場所 |
|---|---|---|
| ルートの単一アプリ | package-lock.json | ルート |
| apps/webが独立したnpmプロジェクト | apps/web/package-lock.json | apps/web |
| npm workspacesでルートのロックを共有 | ルートのpackage-lock.json | ルートでnpm ciを実行し、テストのworkspaceを指定する |
通常のnpm依存キャッシュなら、まずsetup-nodeへ正しいロックファイルを渡せば足ります。ビルド出力など別の保存対象が必要になった場合に、actions/cacheと独自キーを検討します。依存キャッシュとビルド結果を同じものとして扱わず、ビルド結果ならソースや環境など、結果を変える条件もキーに含める必要があります。
pnpmを使うプロジェクトでは、npmの例をそのまま使わず、pnpmの導入、pnpm-lock.yaml、インストールコマンドを一緒に変えます。本記事の検証対象はnpm版です。
fail-fastは今のmatrix、concurrencyは同じ系列の古い実行を扱う
4組のうちWindowsだけが失敗した場合、fail-fast: falseなら残りも進めて結果を集めます。これは1回の実行の中で、ほかのmatrixジョブを途中で止めるかどうかの設定です。
一方、concurrencyは同じPRやブランチへ次の変更が来たときの整理に使っています。github.workflowとgithub.refを組み合わせたグループで、同じワークフロー・同じrefの古い実行をキャンセルする形です。この設定と適用範囲はGitHubのconcurrency構文を確認してください。
ここではconcurrencyをjobsの外側に置いています。各matrixジョブの内側へ同じグループ名を置くと、NodeやOSが違う兄弟ジョブ同士まで競合させてしまいます。ジョブ単位で管理する必要があるなら、そのジョブを区別できる条件をグループへ含めます。
この例は最新の変更をテストするためのCIです。途中で止めると外部環境が中途半端になるデプロイなどへ、cancel-in-progress: trueをそのまま流用しないようにします。また、mainへのpushとPRのイベントはrefが異なるため、同じコミットに対するすべての実行を1つにまとめる設定ではありません。
短縮できたかは、同じ条件で工程別に記録する
最初のキャッシュ保存前と、保存後の実行を比べるだけでは、ランナーの待ち時間やネットワークの変動も混ざります。同じコミット・Node・OS・ロックファイルを基準に、setup-node、npm ci、テストの時間を分けて記録します。
- 比較対象の1組を決め、コミットSHA・実際のNodeバージョン・OSを記録する。
- キャッシュなしの比較ではcache指定を外し、setup-nodeにpackage-manager-cache: falseも指定する。自動キャッシュを残したまま比較しない。
- キャッシュありでは復元結果をログで確認し、最初の未保存状態と、保存済みデータを使えた状態を分ける。
- 同じ条件で複数回確認し、待ち時間・各ステップ・ジョブ全体を区別する。
| 記録する項目 | 見たいこと |
|---|---|
| setup-nodeの時間とキャッシュ復元結果 | 復元自体にかかる時間も含めて比較する |
| npm ciの時間 | 取得待ちの削減がインストール工程に効いたか |
| テストの時間 | 変化がキャッシュ由来なのか、テスト側の変動なのか |
| 各ジョブの時間とrun全体の経過時間 | ジョブ単位と、並行数・待ち行列を含む全体を分ける |
「キャッシュが見つかった」ことと「CIが速くなった」ことは別の結果です。npm ciは短くなっても、matrixの組み合わせを増やせば全体の終了時刻は遅くなる場合があります。変更を一度に重ねず、速くしたい工程と残すべき検証範囲を決めてから比較してください。
AIによる差分レビューを加える場合は、Codex GitHub Actionの構成へ進めます。通常のテストはこのCIで実行し、レビューの役割とは分けておくと、何が失敗したのかを追いやすくなります。