Node.js プロジェクト向け GitHub Actions による CI

1. 概要

誰かがコードを push したり pull request を開いたりするたびに、そのコードがまだビルドでき、テストがまだ pass しているかどうかを即座に知る必要があります。これは自動化されるべきで、個人のマシンの状態に依存しません。このガイドでは、GitHub Actions を使用して Node.js プロジェクト向けの CI パイプラインを構築する方法を説明します。読み終わると、キャッシュ付きの依存関係インストール、lint の実行、複数の Node バージョンでのテスト実行、coverage レポートの生成、および期限切れの実行の自動キャンセルを備えた完全なワークフローを手に入れることができます。

2. サンプル プロジェクトの準備

注文の合計金額を計算する小さなモジュールを使用します。まず package.json です:

{
  "name": "order-service",
  "version": "1.0.0",
  "type": "module",
  "engines": {
    "node": ">=22"
  },
  "scripts": {
    "lint": "eslint src test",
    "test": "node --test",
    "test:coverage": "node --test --experimental-test-coverage --test-reporter=spec --test-reporter-destination=stdout --test-reporter=lcov --test-reporter-destination=lcov.info"
  }
}

このプロジェクトは Node に組み込みのテストランナー(node:test)を使用しているので、Jest や Mocha をインストールする必要がありません。"type": "module" は import/export 構文の使用を可能にします。test:coverage スクリプトは 2 つの reporter を同時に使用します。1 つの reporter はコンソールに結果を出力します。もう 1 つの reporter は lcov.info ファイルに書き込んで、後で保存します。

ビジネスロジックは src/order.js に配置されます:

export function calculateTotal(items, discountPercent = 0) {
  if (discountPercent < 0 || discountPercent > 100) {
    throw new RangeError('discountPercent は 0~100 の範囲にある必要があります');
  }
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  return Math.round(subtotal * (100 - discountPercent)) / 100;
}

この関数は各アイテムの price * quantity を合計し、割引分を差し引きます。割引率が 0~100 の範囲外の場合、関数は RangeError をスローします。

対応するテストは test/order.test.js に配置されます:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { calculateTotal } from '../src/order.js';

test('割引付きの注文合計を計算する', () => {
  const items = [
    { price: 100000, quantity: 2 },
    { price: 50000, quantity: 1 },
  ];
  assert.equal(calculateTotal(items, 10), 225000);
});

test('無効な割引時にエラーをスロー', () => {
  assert.throws(() => calculateTotal([], 120), RangeError);
});

node --test コマンドは自動的に .test.js 拡張子を持つファイルを探します。ローカルマシンでテストしてください:

npm install --save-dev eslint @eslint/js
npm test
✔ 割引付きの注文合計を計算する (0.9ms)
✔ 無効な割引時にエラーをスロー (0.3ms)
ℹ tests 2
ℹ pass 2
ℹ fail 0

ESLint 9 は flat config を使用します。ルートディレクトリに eslint.config.js ファイルを作成します:

import js from '@eslint/js';

export default [js.configs.recommended];

最後に、package-lock.json ファイルも必ずコミットしてください。後のセクションで説明するように、CI にはこのファイルが必要です。

3. 最初の CI ワークフロー

GitHub Actions は .github/workflows/ ディレクトリ内の YAML ファイルを読み込みます。.github/workflows/ci.yml ファイルを作成します:

name: CI

on:
  push:
    branches: [main]
  pull_request:

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - run: npm ci
      - run: npm test

ファイルの各部分は以下のように機能します:

  • on:ワークフローは main への push と任意のブランチへの pull request で実行されます。
  • runs-on: ubuntu-latest:ジョブは GitHub が提供する新しい Linux 仮想マシンで実行されます。
  • actions/checkout@v4:ソースコードを仮想マシンにクローンします。
  • actions/setup-node@v4:Node 22 をインストールします。cache: npm オプションは npm のキャッシュディレクトリを保存し、package-lock.json のハッシュを key として使用します。その後の実行では、パッケージはレジストリから再度ダウンロードするのではなく、キャッシュから取得されます。
  • npm ci:lockfile に正確に従って依存関係をインストールします。
  • npm test:テストを実行します。コマンドが 0 以外の exit code を返すと、ジョブは fail となり、pull request は赤でマークされます。

このファイルを push した後、リポジトリの Actions タブに移動してログを確認します。2 回目の実行では、setup-node ステップは以下のような行を出力します:

Cache restored successfully
Cache restored from key: node-cache-Linux-x64-npm-3f9a1c...

注意: CI では npm install を使用しないでください。npm install は lockfile を更新し、マシンとは異なるバージョンをインストール可能です。npm ci は node_modules を削除し、lockfile に正確に従ってインストールし、package.json と package-lock.json が一致しない場合は直ちにエラーを報告します。この動作のおかげで、lockfile をコミット忘れしたことを早期に発見できます。

4. 複数の Node バージョンでテストを実行する

ライブラリまたはサービスが複数の Node バージョンで実行する必要がある場合があります。matrix ストラテジーは各バージョンに対してジョブを複製するのに役立ちます:

jobs:
  test:
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node-version: [22, 24]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
      - run: npm ci
      - run: npm test

GitHub は 2 つのジョブを並列に作成し、test (22) と test (24) という名前が付けます。式 ${{ matrix.node-version }} は各ジョブの対応する値に置き換えられます。

デフォルトでは fail-fast は true です。1 つのジョブが fail するだけで、残りのジョブはすぐにキャンセルされます。fail-fast: false に設定すると、全体的な状況が見えます。例えば、エラーが Node 24 でのみ発生し、Node 22 は引き続き pass しているかどうかがわかります。

行列を複数のオペレーティングシステムに拡張できます:

    strategy:
      matrix:
        os: [ubuntu-latest, windows-latest]
        node-version: [22, 24]
    runs-on: ${{ matrix.os }}

この設定は 2 × 2 = 4 つのジョブを作成します。

注意: 行列に追加する次元が増えるほど、ジョブの数が増えます。サービスが Linux のみと Node 24 にのみ deploy される場合、大きな行列は CI 実行時間を無駄にするだけで利益がありません。複数の環境を本当にサポートしている場合にのみ行列を使用してください。例えば、npm パッケージをリリースしている場合です。

プロジェクトが単一の Node バージョンのみを使用する場合は、CI とローカル開発が同じソースから読み取るようにしてください。.nvmrc ファイルを作成して 24 を含め、次のように宣言します:

      - uses: actions/setup-node@v4
        with:
          node-version-file: .nvmrc
          cache: npm

5. Lint ジョブを分離し、ジョブ間の依存関係を作成する

Lint はテストより高速で、各 Node バージョンで実行する必要がありません。したがって、lint を独立したジョブに分離し、test ジョブが lint pass を待つようにします:

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run lint

  test:
    needs: lint
    runs-on: ubuntu-latest
    strategy:
      fail-fast: false
      matrix:
        node-version: [22, 24]
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: npm
      - run: npm ci
      - run: npm test

needs: lint によって、test ジョブは lint が成功した後にのみ開始されます。例えば、未使用の変数が残されている場合:

const unusedTax = 0.1;

Lint ジョブはこのメッセージで fail します:

/home/runner/work/order-service/src/order.js
  1:7  error  'unusedTax' is assigned a value but never used  no-unused-vars

✖ 1 problem (1 error, 0 warnings)

Test ジョブは実行されないため、行列全体のかかった時間を節約できます。

ここでトレードオフを検討する必要があります。needs を使用すると、すべてが pass した場合、総実行時間が長くなります。2 つのジョブが順序に実行されるためです。高速なフィードバックを優先する場合は、needs を削除して、lint とテストを並列に実行してください。

6. Coverage レポートと artifact の保存

仮想マシンで生成されたすべてのファイルはジョブ終了時に失われます。Coverage レポートを保持するには、actions/upload-artifact を使用します:

  coverage:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: npm
      - run: npm ci
      - run: npm run test:coverage
      - uses: actions/upload-artifact@v4
        if: always()
        with:
          name: coverage-report
          path: lcov.info
          retention-days: 7

test:coverage スクリプトはジョブのログに coverage テーブルを直接出力します:

ℹ start of coverage report
ℹ file           | line % | branch % | funcs %
ℹ src/order.js   | 100.00 |   100.00 |  100.00
ℹ all files      | 100.00 |   100.00 |  100.00
ℹ end of coverage report

if: always() 条件は、テストが fail した場合でも artifact がアップロードされることを保証します。この場合、レポートは最も必要とされます。lcov.info ファイルはワークフロー実行の概要ページの末尾に表示され、7 日後に削除されます。

Coverage が低下した場合にジョブを fail にしたい場合は、Node 22.8 以降、スクリプトに threshold フラグを追加できます:

node --test --experimental-test-coverage --test-coverage-lines=80

このコマンドは、行カバー率が 80% 未満の場合、exit code 1 を返します。

7. ワークフローの最適化

以下の 3 つの設定は、ほとんどの CI ワークフローに含まれるべきです。ファイルの最上位に配置します:

name: CI

on:
  push:
    branches: [main]
    paths-ignore:
      - '**.md'
      - 'docs/**'
  pull_request:
    paths-ignore:
      - '**.md'
      - 'docs/**'

permissions:
  contents: read

concurrency:
  group: ci-${{ github.workflow }}-${{ github.ref }}
  cancel-in-progress: true

各設定の意味:

  • paths-ignore:README またはドキュメントのみを修正するコミットは CI をトリガーしません。
  • permissions: contents: read:GITHUB_TOKEN のアクセス権限を読み取り専用に制限します。CI で悪意のある依存関係が実行される場合、コードを push または release を修正することもできません。
  • concurrency:同じ pull request に対して連続で 3 つのコミットを push する場合、最新の実行のみが保持されます。古い実行はキャンセルされます。結果が価値がないためです。

さらに、各ジョブに timeout-minutes を追加する必要があります:

  test:
    runs-on: ubuntu-latest
    timeout-minutes: 10

デフォルトでは、ジョブは最大 6 時間実行できます。単一のテストが hang している場合、例えば database 接続を閉じ忘れた場合でも、GitHub Actions の分数クォータ全体を消費する可能性があります。

注意: paths-ignore には branch protection と組み合わせるときの落とし穴があります。test ジョブが pass する必要があるという branch protection rule を設定したと仮定します。.md ファイルのみを修正する pull request はワークフローを実行しないため、そのチェックは「Expected — Waiting」状態で永遠に留まります。この場合、2 つの選択肢があります。paths-ignore を削除するか、そのチェックに対する branch protection requirement を削除するかです。

8. よくあるエラー

Lockfile が不足している。 package-lock.json がコミットされていないか、.gitignore にリストされている場合、setup-node ステップはエラーを報告します:

Error: Dependencies lock file is not found in /home/runner/work/order-service/order-service.
Supported file patterns: package-lock.json,npm-shrinkwrap.json,yarn.lock

修正方法は lockfile をコミットすることです。プロジェクトが pnpm または yarn を使用している場合は、cache の値をパッケージマネージャーに合わせて変更してください。

テストが watch モードで実行される。 Vitest や Jest などのツールが watch で設定されている場合、ファイルの変更を無期限に待ちます。GitHub Actions は自動的に環境変数 CI=true を設定し、ほとんどのツールはこれに基づいて watch を無効にします。それでも、明示的に 1 回だけ実行するコマンドを呼び出すべきです。例えば、vitest run です。

Fork からの pull request では secrets が利用できない。 Fork されたリポジトリからの pull_request イベントの場合、secrets はワークフローに渡されません。これは意図的なセキュリティ機構です。実際の API キーを必要とするテストはこの場合失敗します。ユニットテスト内で外部サービスをモックしてください。pull_request_target に切り替えて “火消し” しないでください。このイベントは未レビューのコードに対して完全な権限で実行されます。

オペレーティング システム間の差異。 macOS では pass するが Ubuntu では fail するテストは、通常、パスの大文字小文字の区別が原因です。例えば、import './Order.js' ですが、実際のファイル名は order.js です。Linux ファイルシステムは大文字小文字を区別するため、CI はコードが本番環境に到達する前にこのエラーを検出します。

このパイプライン全体が必要ない場合。 数十行のみのスクリプトの場合、単一ジョブで npm ci && npm test を実行するだけで十分です。行列、coverage、および個別ジョブはプロジェクトが大きくなるか、多くの人が貢献するようになってから追加する必要があります。

9. 結論

このガイドでは、GitHub Actions を使用して Node.js プロジェクト向けの完全な CI パイプラインを構築しました。パイプラインは npm ci と setup-node のキャッシュを使用して、依存関係を高速かつ安定的にインストールします。matrix ストラテジーは複数の Node バージョンでの検証を可能にします。Lint ジョブは別に分離され、needs を使用して test ジョブにリンクされています。Coverage レポートは artifact を使用して保存されます。最後に、ワークフローは concurrency、permissions、および timeout-minutes を使用して最適化されます。

コアとなる原則は、CI を高速で信頼性が高く保つことです。15 分間実行されるパイプライン、またはランダムに fail するパイプラインは、チーム全体にその結果を無視させるようになります。まず、セクション 3 の最小限のワークフローで始めて、プロジェクトが本当に必要とするときに部分を足してください。

github-actionsnodejscitestingdevops