🙈

pnpm workspace を利用したモノレポで「この PR の影響を受けるパッケージ」をフィルタする

2025/01/24に公開

3行まとめ

  • pnpm の --filter オプションにはパッケージ名だけでなく git の commit や branch も渡すことができる
  • pnpm ls --filter をうまく使えば「main branch からの diff の影響を受けるパッケージ」の一覧を取り出せる
  • ドキュメントや help をちゃんと見ると、意外と知らないことが書いてある

モチベーション

LayerX のバクラク事業部では Webapp(Web Frontend アプリケーション)のモノレポ化を進めており、1つのリポジトリに複数の Next.js アプリケーションが存在します。
そこで悩ましいのが CI でのテストなどの実行です。

  • そのまま全件実行すると時間が長くなっていく
    • e.g. アプリAのコードしか変更してないのに、アプリBのテストも実行されてしまうと時間もお金も無駄にかかる
    • 何もしないとプロダクト・事業が拡大すればするほど悪化していく
  • とはいえ変更ファイルだけを見た実行制御だと漏れが発生する
    • e.g. パッケージCが変更されたとき、パッケージCだけではなくそれに依存するアプリAとアプリDのテストも実行すべき

ユニットテストだけならまだしも、時間がかかる Storybook のビルドやブラウザを利用したテストなどが入ってくると流石に無視できなくなってきます。

pnpm の --filter オプションを使う

当初はこの問題のために Turborepo などのモノレポ向けタスクランナー側での解決を模索していました。これらは主に賢いキャッシュの仕組みを使って必要なタスクのみを実行する仕組みを持ちます。

しかし、そのような外部ツールを使わずとも、pnpm の機能だけで・シンプルな仕組みで「単純に変更内容をもとに(依存関係的に)影響を受けそうなパケージに絞ってタスクを実行する」ということは実現可能です。それが pnpm の --filter オプションです。

pnpm では --filter オプションを指定することで、 workspace 内の条件にマッチするパッケージに絞ってコマンドを実行することが可能です

# 以下の例はドキュメントからの引用
pnpm --filter "@babel/core" test
pnpm --filter "@babel/*" test
pnpm --filter "*core" test

この --filter は主にパッケージ名やディレクトリを指定することが多いでしょう。
しかし、実は git の commit や branch も指定することができます。

# https://pnpm.io/filtering#--filter-since
pnpm --filter "...[origin/master]" test

この機能をうまく利用し、たとえば GitHub Actions であれば以下のように base_ref などを渡すことで、変更の影響を受けたパッケージに絞ってテストが実行されます。

on:
  pull_request:
    branches:
      - "**"
  push:
    branches:
      - main

jobs:
  test:
    # ...
    steps:
      # ...
      # pull request では base branch からの差分の影響を受けるパッケージのみテストする
      - name: Test
        run: |
          pnpm --filter "${TURBO_FILTER}" test
        env:
          TURBO_FILTER: "${{ github.event_name == 'pull_request' && format('...[origin/{0}]', github.base_ref) || '*' }}"

自分は --filter が git の commit や branch を取れるのを知らなかったのですが、公式ドキュメントに書いてるし、よく見たら pnpm run --help も言及があります。

      --filter "[<since>]"                      Includes all packages changed since the specified commit/branch. E.g.: "[master]", "[HEAD~2]". It may be used together with "...". So, for instance, "...[HEAD~1]" selects all packages changed in the last commit and their dependents

応用: pnpm ls --filter で変更影響があったパッケージを抜き出し、job の起動制御に利用する

--filterpnpm run 以外のコマンドでも有効です。たとえば pnpm ls --filter '...[origin/main]' --depth 1 のようにすれば、main ブランチからの diff の影響を受けるパッケージの一覧を抜き出すことができます。

# 変更影響を受けるパッケージ名の一覧を JSON 配列にする
pnpm ls --filter="...[origin/main]" --depth -1 --json \
  | jq -r "map(.path | ltrimstr(\"$PWD/\")) | @json"

これを GitHub Actions の output などに詰め込んで fromJSONcontains と組み合わせることで柔軟な実行制御が可能になります。

以下の例では、比較的重くなりがちである Storybook のビルド・デプロイ・テストを必要なときだけ実行するようなワークフローを記述しています。

jobs:
  changes:
    runs-on: ubuntu-latest
    outputs:
      packages-json: ${{ steps.changed.outputs.packages-json }}
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: "0" # 変更影響を見るために履歴が必要
      # ...
      - run: |
          packagesJson=$(pnpm ls --filter="${TURBO_FILTER}" --depth -1 --json | jq -r "map(.path | ltrimstr(\"$PWD/\")) | @json")
          echo "packages-json=${packagesJson}"
          echo "packages-json=${packagesJson}" >> "$GITHUB_OUTPUT"
        env:
          TURBO_FILTER: ${{ github.event_name == 'push' && github.ref_name == 'main' && '*' || format('...[origin/{0}]', github.base_ref) }}
        id: changed

  storybook:
    strategy:
      matrix:
        package:
          - path: apps/iikanji-no-webapp
          - path: packages/iikanjini-benri-na-ui-components
      fail-fast: false
    name: Storybook - ${{ matrix.package.path }}
    needs: [changes]
    uses: ./.github/workflows/_storybook.yml
    with:
      sha: ${{ github.event.pull_request.head.sha || github.sha }}
      ref_name: ${{ github.head_ref || github.ref_name }}
      package_path: ${{ matrix.package.path }}
      skip: ${{ !contains(fromJSON(needs.changes.outputs.packages-json), matrix.package.path) }}
LayerX

Discussion