🎉

npm workspaceを使ったモノレポでのtrusted publishによる自動リリース

に公開

npmで2FA(二要素認証)が必須になりました。単一のパッケージをpublishする場合は、npm publishで表示されるURLにアクセスして2FAするだけなので、さほど問題にはなりません。

しかし、npm workspaceを使ったモノレポで複数のパッケージを管理している場合、それぞれのパッケージに対して2FAを行う必要があり、手作業での対応は現実的ではありません。

この問題を解決するために、trusted publish(provenance)の仕組みを使った自動化を実現しました。

困りごと

npm workspaceでの複数パッケージのpublish

npm workspaceを使ったモノレポでは、複数のパッケージを管理できます。例えば、以下のような構成の場合:

my-monorepo/
├── package.json
├── packages/
│   ├── identity/
│   │   └── package.json
│   ├── payment/
│   │   └── package.json
│   └── terminal/
│       └── package.json

各パッケージをpublishする場合、以下のような手順が必要になります:

  1. packages/package-aディレクトリでnpm publishを実行
  2. 表示されるURLにアクセスして2FAを完了
  3. packages/package-bディレクトリでnpm publishを実行
  4. 再度2FAを完了

パッケージの数だけこの作業を繰り返す必要があり、ミスが発生しやすく、時間もかかります。特に、すべてのパッケージを同じバージョンでリリースしたい場合、この手作業は非常に煩雑になります。

trusted publish(provenance)とは

trusted publishは、GitHub ActionsなどのCI環境から、2FAなしでnpmにpublishできる仕組みです。最新のnpmを使用すると、CI環境から自動的にprovenanceが有効になり、パッケージの出所を証明できます。

provenanceを使うことで、パッケージがどのリポジトリから、どのコミットから、どのワークフローから公開されたかを証明できます。これにより、セキュリティが向上し、かつCIから自動的にpublishできるようになります。

なお、provenanceの自動生成には以下の条件をすべて満たす必要があります:

  • trusted publishing(OIDC)を使用してpublishすること
  • 公開リポジトリからpublishすること
  • 公開パッケージをpublishすること

プライベートリポジトリからは、公開パッケージをpublishしてもprovenanceは生成されません(公式ドキュメント参照)。

必要な設定

trusted publishを使うには、以下の設定が必要です:

  1. GitHub Actionsのpermissionsにid-token: writeを追加
  2. npmのバージョンが最新であること(最新のnpmではCI環境から自動的にprovenanceが有効になる)
  3. npm上でGitHubのレポジトリを信頼できるように登録すること
  4. 各パッケージのpackage.jsonにrepositoryプロパティがあること

repositoryプロパティは、以下のように設定します:

{
  "repository": {
    "type": "git",
    "url": "git@github.com:your-org/your-monorepo.git"
  }
}

npm上でGitHubのレポジトリを信頼できるように登録するには、npmのウェブサイトから、各パッケージの設定ページで「Trusted publishers」セクションから以下の情報を登録する必要があります:

  • Repository owner(リポジトリのオーナー)
  • Repository name(リポジトリ名)
  • Workflow filename(ワークフローファイル名、.yml拡張子を含む、例:release.yml)

ワークフローファイルは.github/workflows/ディレクトリに配置されている必要があり、ファイル名は大文字小文字を区別します。詳細は公式ドキュメントを参照してください。

実装方法

npm workspaceの設定

まず、ルートのpackage.jsonでnpm workspaceを設定します。ワークフローで動的にworkspace一覧を取得するため、globパターンではなく、各パッケージのパスを個別に配列で指定します:

{
  "name": "my-monorepo",
  "workspaces": [
    "packages/package-a",
    "packages/package-b"
  ]
}

これにより、指定したパッケージがworkspaceとして認識されます。新しいパッケージを追加する場合は、この配列にパッケージのパスを追加します。

タグベースでのリリーストリガー

リリースのトリガーは、Gitのタグを使用します。v*パターンのタグがプッシュされると、自動的にリリースワークフローが実行されます。

タグの発行には、npを使うこともできますが、手動でタグを作成しても構いません。重要なのは、タグがプッシュされたときにワークフローが実行されることです。

GitHub Actionsワークフローの構成

npm workspaceを使ったモノレポのリリースワークフローの全体像は以下の通りです:

  1. タグからバージョン情報を抽出
  2. package.jsonのworkspacesフィールドからパッケージ一覧を取得
  3. 各workspaceのpackage.jsonのバージョンを更新
  4. 全workspaceをビルド
  5. バージョン更新をコミット
  6. 各workspaceをpublish

以下、各ステップの詳細を説明します。

必要な権限

permissions:
  id-token: write
  contents: write

id-token: writeは、trusted publishでOIDCトークンを取得するために必要です。contents: writeは、バージョン更新をコミットするために必要です。

npmのバージョン更新

actions/setup-node@v4で提供されるnpmは、古いバージョンの場合があります。最新のnpmを使用することで、CI環境から自動的にprovenanceが有効になります。そのため、最新のnpmをインストールするステップを追加しています:

- name: Update npm
  run: npm install -g npm@latest

バージョン情報の抽出

タグからバージョン情報を抽出します。v1.2.3のようなタグから、1.2.3というバージョン文字列を取得します:

- name: Extract version from tag
  id: tag_version
  run: |
    TAG_NAME=${GITHUB_REF#refs/tags/}
    VERSION=${TAG_NAME#v}
    echo "version=$VERSION" >> $GITHUB_OUTPUT
    echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT

workspace一覧の取得

package.jsonのworkspacesフィールドから、workspaceの一覧を取得します:

- name: Read workspaces
  id: workspaces
  run: |
    WORKSPACES=$(node -e "const fs=require('fs'); const ws=JSON.parse(fs.readFileSync('package.json','utf8')).workspaces||[]; console.log(ws.join(' '));")
    echo "list=$WORKSPACES" >> $GITHUB_OUTPUT

この処理により、package.jsonに新しいworkspaceを追加しても、ワークフローを修正する必要がありません。動的にworkspaceの一覧を取得できるため、モノレポの拡張性が向上します。

workspacesフィールドは配列形式(["packages/*"])と文字列形式("packages/*")の両方に対応できるよう、実装を調整する必要がある場合もありますが、多くの場合は上記のコードで動作します。

デフォルトブランチへの切り替え

ワークフローでは、タグが付けられたコミットがデフォルトブランチにあることを前提としていますが、実際にはタグが他のブランチに付けられる可能性もあります。そのため、デフォルトブランチに切り替えて、タグのコミットをマージする処理を追加しています:

- name: Switch to default branch and align with tag commit
  run: |
    DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
    git fetch origin "$DEFAULT_BRANCH"
    git checkout "$DEFAULT_BRANCH"
    git merge --ff-only "$GITHUB_SHA"

--ff-onlyフラグにより、fast-forwardマージが可能な場合のみマージが成功します。これにより、タグのコミットがデフォルトブランチの履歴に含まれていない場合、ワークフローが失敗して不正なリリースを防ぐことができます。

また、fetch-depth: 0を指定することで、GitHub Actionsのデフォルトのshallow cloneではなく、フルヒストリーを取得しています。これにより、ブランチの切り替えやマージが正常に動作します。

バージョンの更新とコミット

各workspaceのpackage.jsonのバージョンを更新し、変更をコミットします。npm workspaceの--workspaceフラグを使うことで、各workspaceのバージョンを個別に更新できます:

- name: Update workspace package.json versions
  run: |
    VERSION="${{ steps.tag_version.outputs.version }}"
    for workspace in ${{ steps.workspaces.outputs.list }}; do
      npm version "$VERSION" --no-git-tag-version --workspace "$workspace"
    done

- name: Commit changes
  run: |
    git config --local user.email "action@github.com"
    git config --local user.name "GitHub Action"
    git add packages/*/package.json
    git commit -m "chore: update workspace versions to ${{ steps.tag_version.outputs.version }}" || exit 0
    git push origin HEAD

npm versionの--no-git-tag-versionフラグにより、gitタグは作成せず、各workspaceのpackage.jsonのみを更新します。--workspaceフラグにより、指定したworkspaceのバージョンのみを更新できます。

コミットコマンドに|| exit 0を付けているのは、バージョンが既に更新済みの場合(同じタグで再度ワークフローが実行された場合など)に、コミットが空で失敗することを許容するためです。この場合もワークフローは続行され、ビルドとpublish処理が実行されます。

全workspaceのビルド

全てのworkspaceをビルドします:

- name: Build all workspaces
  run: npm run build -ws --if-present

-ws(または--workspaces)フラグにより、全てのworkspaceでコマンドを実行します。--if-presentフラグにより、buildスクリプトが存在しないworkspaceはスキップされます。

パッケージのpublish

各workspaceをpublishします。バージョンがセマンティックバージョニングの正式版(1.2.3形式)かどうかで、nextタグを使うかどうかを切り替えます:

- name: Publish packages
  run: |
    VERSION="${{ steps.tag_version.outputs.version }}"
    IS_STABLE=$(echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' && echo true || echo false)
    
    for workspace in ${{ steps.workspaces.outputs.list }}; do
      if [ "$IS_STABLE" = "true" ]; then
        npm publish --provenance --access public --workspace "$workspace"
      else
        npm publish --provenance --access public --tag next --workspace "$workspace"
      fi
    done

IS_STABLE変数は、正規表現でバージョンが正式版(1.2.3のような3つの数字で構成される)かどうかを判定しています。正式版以外(例:1.2.3-beta.1、1.2.3-alpha.0など)は--tag nextを指定することで、npm installでデフォルトでインストールされないようにしています。これにより、プリリリース版が誤って本番環境で使われることを防げます。

--workspaceフラグにより、各workspaceを個別にpublishできます。npm workspaceを使う場合、各workspaceは独立したパッケージとしてpublishされるため、それぞれに対してpublishコマンドを実行する必要があります。

最新のnpmを使用している場合、CI環境から自動的にprovenanceが有効になるため、--provenanceフラグは省略することもできますが、明示的に指定しても問題ありません。

完全なワークフローファイル

以下が完全なワークフローファイルです。このファイルを.github/workflows/release.ymlとして保存した場合、npm上でのtrusted publisher設定時の「Workflow filename」にはrelease.ymlを指定します(.yml拡張子を含めてください)。

name: Release

on:
  push:
    tags:
      - 'v*'

permissions:
  id-token: write
  contents: write

jobs:
  release:
    runs-on: ubuntu-latest

    steps:
      - name: Checkout code
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 22
          registry-url: 'https://registry.npmjs.org'

      - name: Update npm
        run: npm install -g npm@latest

      - name: Extract version from tag
        id: tag_version
        run: |
          TAG_NAME=${GITHUB_REF#refs/tags/}
          VERSION=${TAG_NAME#v}
          echo "version=$VERSION" >> $GITHUB_OUTPUT
          echo "tag=$TAG_NAME" >> $GITHUB_OUTPUT
          echo "Extracted version: $VERSION from tag: $TAG_NAME"

      - name: Install dependencies
        run: npm install

      - name: Read workspaces
        id: workspaces
        run: |
          WORKSPACES=$(node -e "const fs=require('fs'); const ws=JSON.parse(fs.readFileSync('package.json','utf8')).workspaces||[]; console.log(ws.join(' '));")
          echo "list=$WORKSPACES" >> $GITHUB_OUTPUT
          echo "Workspaces: $WORKSPACES"

      - name: Switch to default branch and align with tag commit
        run: |
          DEFAULT_BRANCH="${{ github.event.repository.default_branch }}"
          git fetch origin "$DEFAULT_BRANCH"
          git checkout "$DEFAULT_BRANCH"
          git merge --ff-only "$GITHUB_SHA"

      - name: Update workspace package.json versions
        run: |
          VERSION="${{ steps.tag_version.outputs.version }}"
          echo "Setting workspace versions to $VERSION"
          for workspace in ${{ steps.workspaces.outputs.list }}; do
            echo "Updating $workspace/package.json..."
            npm version "$VERSION" --no-git-tag-version --workspace "$workspace"
          done

      - name: Build all workspaces
        run: npm run build -ws --if-present

      - name: Commit changes
        run: |
          git config --local user.email "action@github.com"
          git config --local user.name "GitHub Action"
          git add packages/*/package.json
          git commit -m "chore: update workspace versions to ${{ steps.tag_version.outputs.version }}" || exit 0
          git push origin HEAD

      - name: Publish packages
        run: |
          VERSION="${{ steps.tag_version.outputs.version }}"
          IS_STABLE=$(echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' && echo true || echo false)
          
          for workspace in ${{ steps.workspaces.outputs.list }}; do
            echo "Publishing $workspace..."
            if [ "$IS_STABLE" = "true" ]; then
              npm publish --provenance --access public --workspace "$workspace"
            else
              npm publish --provenance --access public --tag next --workspace "$workspace"
            fi
          done

どうらくになるか

この自動化により、npm workspaceを使ったモノレポのリリース作業が大幅に簡素化されます:

  1. 手作業での2FAが不要:CIが自動的に各workspaceをpublishするため、パッケージごとに2FAを行う必要がなくなります
  2. 複数パッケージの一括リリース:モノレポで複数のパッケージがあっても、1回のタグ発行で全てのパッケージをリリースできます
  3. バージョン管理の統一:タグからバージョンを取得し、全てのworkspaceに統一されたバージョンを自動設定します
  4. セキュリティの向上:provenanceにより、各パッケージの出所が証明されます
  5. プリリリースの対応:セマンティックバージョニングに基づいて、プリリリース版は自動的にnextタグでpublishされます
  6. 新規パッケージの自動対応:package.jsonのworkspacesフィールドに新しいパッケージを追加するだけで、自動的にリリース対象に含まれます

リリース作業が、タグを発行するだけの作業になります。タグを発行すれば、あとはCIが全てのworkspaceをビルドしてpublishしてくれます。

まとめ

npmで2FAが必須になったことで、npm workspaceを使ったモノレポで複数のパッケージを管理している場合、各パッケージごとに2FAを行う必要があり、リリース作業が非常に煩雑になりました。trusted publish(provenance)を使うことで、CIから2FAなしで自動的に各workspaceをpublishできるようになり、リリース作業を大幅に簡素化できました。

GitHub Actionsとtrusted publishを組み合わせることで、タグの発行だけで複数のパッケージを自動的にビルドしてリリースできるようになります。package.jsonのworkspacesフィールドから動的にパッケージ一覧を取得するため、新しいパッケージを追加してもワークフローの修正は不要です。npm workspaceを使ったモノレポで複数のパッケージを管理している場合は、ぜひ導入を検討してみてください。

それではまた。

Discussion