🍱

pnpm + Github Actionsだけでモノレポパッケージ配信を始める

2024/12/09に公開

はじめに

これは STORES Advent Calendar 2024 の6日目の記事です。

こんにちは。STORES で働いているエンジニアの野老です。
今は社内でのプロジェクト横断的なフロントエンド領域に関わるプロジェクトに関わっています。

それぞれ独立して運用されていた複数のprivateパッケージを、一つのモノレポから配信するようにするということについて、実際にその環境を構築した体験を交えつつ解説します。

背景

関連性が高いコードがnpmパッケージ単位で4つほどに分けられており、それぞれの個別のリポジトリでパッケージ配信の仕組みが実装と運用されていました。

これらのライブラリを一つのモノレポで管理下に置くことでパッケージ配信の仕組みを一本化して、運用の管理を楽にできるようにしたいというモチベーションがありました。

今回関連ライブラリを新しく追加する必要が出たタイミングということでその移行に着手しました。

モノレポ化に向けたツール選定

社内パッケージ管理という限られたスコープであるということもあり、とにかく早くモノレポ化してpackage配信したいという観点から、高機能なモノレポ系のツール(Lernaなど)は利用せず、pnpm + github actionsだけでいけるところまでやるという方針になりました。

pnpmはパッケージマネジャーながらworkspaceという機能もありモノレポツールとしても相当に有能です。
Github Actionsはとても便利なCI環境でサクッと構築できます。また既存のパッケージの配信の仕組みもGithub Actionsを利用していたものだったため移行が容易になることも想像できました。

モノレポのセットアップ

最終的なファイル構成は以下のようになります。

/packages
  /package-a
    package.json
    /src
    ...
  /package-b
    package.json
    /src
    ...
pnpm-workspace.yaml
package.json

pnpmのセットアップ

pnpmをドキュメントに従いセットアップします。

rootのpackage.jsonはこのようになります。

{
  "private": true,
  "name": "multi-package-example",
  "version": "0.0.0",
  "description": "",
  "keywords": [],
  "author": "",
  "license": "ISC",
  "packageManager": "pnpm@version",
  "scripts": {
    "build": "pnpm -r run build"
  }
}

モノレポでrootのpackage.jsonはパッケージとして配布する意図がないという意味もつけてprivateをつけておきます。

唯一のscriptであるpnpm -r run buildは、それぞれのpackageでbuildコマンドを実行します。

また今回はpnpm workspaceを利用しますので、.pnpm-workspace.yamlも準備します

packages:
  - "packages/*"

pnpmではこのように記述することで、packages配下に各パッケージがあるということを設定できます。

また合わせてGithubにもモノレポ用のリポジトリを作成しておきます。

ソースコードの移管

既存のリポジトリからソースコードを移管する必要があります。

単純にコードをコピー/ペーストすることもできますが、過去のcommitヒストリーも消えてしまうのは惜しかったので、Githubのcommitヒストリーもmergeする方法が必要でした。

これはgit-filter-repoなどを利用することで可能です。

ただし、このツールはgitのcommitヒストリーは移管するのには便利ですが、Githubがcommitに付与するPRの参照などは残されません。

これに対してのワークアラウンドは見つからなかったので自作ツールを作りました。
gh-monorepo-migrator
詳細についてはリポジトリのREADMEをご確認ください。

上記のプロセスを経て、最終的にpackages配下にそれぞれ既存のソースコードを移管します。

各パッケージの対応

移管した各パッケージについて、モノレポに合うように必要であれば変更を加えます。

{
  "name": "@orgname/package-a",
  "repository": "github:orgname/multi-package-example",
  "version": "1.0.0",
  "license": "UNLICENSED",
  "publishConfig": {
    "access": "restricted",
    "registry": "https://npm.pkg.github.com/"
  },
  "main": "index.ts",
  "scripts": {
    "version:ci": "pnpm version --no-git-tag-version",
    "build": "build.sh"
  },
  "dependencies": {
    ....
  }
}

また、このとき各パッケージの.github配下の古いaction類は利用できなくなるので消してしまいます。

今回のセットアップでは、各パッケージは以下の2つのnpm scriptが必要です。

version:ci

このコマンドは後で設定するバージョン更新を発火するGithub Actionによって実行され、1つのパッケージのpackage.jsonのバージョンを更新するのに使われます。

build

このコマンドは後で設定するパッケージをpublishするGithub Actionによってpublishする前に実行されます。

これでモノレポのソースコード自体のセットアップは完了です。

GitHub ActionsでのCI/CDの設定

以下の2ファイルを作成します。

  • commit-and-push-version-bump.yml
  • publish-package.yml

リリースフローの概要

リリースフローの概要を説明します。
リリースはGithub Actionsのworkflow dispatchから手動で実行します。
この時以下の入力を要求します。

bump_type

major, minor, patch のいずれか

package

commaで分離されたパッケージ名
ex. package-a,package-b

このactionが手動実行されると、入力したパッケージを該当のバージョンにあげmainブランチにpushします。
ここでバージョンのtagは付与されず、ただpackage.jsonのversionが更新されるだけというのがポイントになります。

その後mainへのpushをトリガーにpublish-package.ymlが実行されます。
このactionは存在するtagと各package.jsonのversionを比較し、新しいバージョンを検知するとそのpackageをpublishし、git tagを付与します。

commit-and-push-version-bump.yml

一部省略しています。

name: Commit And Push Version Bump
on:
  workflow_dispatch:
    inputs:
      bump_type:
        description: "Type of version bump"
        required: true
        type: choice
        options:
          - patch
          - minor
          - major
      package:
        description: "Package(s) to update (e.g., 'package-a' or 'package-a,package-b' etc.)"
        required: true
        type: string

jobs:
  version-bump:
    name: Version Bump
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - checkout, install node, pnpm etc..

      - name: Determine bump type and packages
        run: |
          BUMP_TYPE="${{ github.event.inputs.bump_type }}"
          PACKAGE="${{ github.event.inputs.package }}"

          echo "Bump type: $BUMP_TYPE"
          echo "Package(s) to update: $PACKAGE"

          declare -A PACKAGE_MAP=(
            [package-a]='@orgname/package-a'
            [package-b]='@orgname/package-b'
          )

          EXPANDED_PACKAGE=""
          IFS=',' read -ra SHORTHAND_ARRAY <<< "$PACKAGE"
          for shorthand in "${SHORTHAND_ARRAY[@]}"; do
            if [ -n "${PACKAGE_MAP[$shorthand]}" ]; then
              EXPANDED_PACKAGE+="${PACKAGE_MAP[$shorthand]},"
            else
              echo "Error: No mapping found for shorthand '$shorthand'. Exiting."
              exit 1
            fi
          done
          EXPANDED_PACKAGE="${EXPANDED_PACKAGE%,}"

          echo "EXPANDED_PACKAGE=$EXPANDED_PACKAGE" >> $GITHUB_ENV
          echo "BUMP_TYPE=$BUMP_TYPE" >> $GITHUB_ENV

      - name: Bump version in specified packages
        env:
          BUMP_TYPE: ${{ env.BUMP_TYPE }}
          PACKAGE: ${{ env.EXPANDED_PACKAGE }}
        run: |
          IFS=',' read -ra PKG_ARRAY <<< "$PACKAGE"
          for pkg in "${PKG_ARRAY[@]}"; do
            echo "Bumping version in $pkg"
            pnpm --filter $pkg version:ci $BUMP_TYPE
          done

      - name: Commit and push changes to main
        env:
          BUMP_TYPE: ${{ env.BUMP_TYPE }}
          PACKAGE_NAMES: ${{ env.EXPANDED_PACKAGE }}
        run: |
          git config user.name github-actions
          git config user.email github-actions@github.com
          git commit -am "Bump version(s) for: $PACKAGE_NAMES to $BUMP_TYPE"
          git push origin main

publish-package.yml

一部省略しています。private packageなどの場合はここでauthトークンが必要だったりします。

name: Publish to GitHub Packages
on:
  push:
    branches:
      - 'main'

jobs:
  get-new-version:
    name: Check new version exists
    outputs:
      new_versions: ${{ steps.check-for-new-versions.outputs.new_versions }}
    steps:
      - checkout, setup etc...
      - name: Check for new versions
        id: check-for-new-versions
        run: |
          NEW_VERSIONS=""
          for PACKAGE_DIR in packages/*; do
            PACKAGE_NAME=$(jq -r '.name' "$PACKAGE_DIR/package.json")
            PACKAGE_VERSION=$(jq -r '.version' "$PACKAGE_DIR/package.json")
            TAG="${PACKAGE_NAME}@${PACKAGE_VERSION}"

            if ! git tag | grep -q "^${TAG}$"; then
              NEW_VERSIONS+="${PACKAGE_NAME}=${PACKAGE_VERSION},"
            fi
          done
          NEW_VERSIONS="${NEW_VERSIONS%,}"
          echo "New Versions: $NEW_VERSIONS"

          echo "new_versions=$NEW_VERSIONS" >> $GITHUB_OUTPUT

  publish:
    name: Publish to GitHub Packages
    needs: get-new-version
    if: ${{ needs.get-new-version.outputs.new_versions != '' }}
    steps:
      - checkout, install node, pnpm etc..
      - name: build
        run: pnpm build
      - name: publish
        run: pnpm -r publish --access restricted
      - name: tag each packages
        run: |
          git config user.name github-actions
          git config user.email github-actions@github.com

          IFS=',' read -ra NEW_VERSIONS_ARRAY <<< "${{ needs.get-new-version.outputs.new_versions }}"
          for VERSION_INFO in "${NEW_VERSIONS_ARRAY[@]}"; do
            PACKAGE_NAME="${VERSION_INFO%%=*}"
            PACKAGE_VERSION="${VERSION_INFO##*=}"

            git tag "${PACKAGE_NAME}@${PACKAGE_VERSION}"
          done

          git push --tags

他ツールとの比較

この構成を始める際に考慮した他のツールなど紹介と比較します。

lerna-lite

歴史のあるmonorepoツールlernaのフォークです。
pnpmのworkspaceに対応していたり、機能単位だけで利用できたりでき便利そうでした。
この記事の構成のリリースフローは旧パッケージのリリースフローを一部踏襲したりしているので、そう考えるとpnpm + Github Actionで十分でその方が簡単と判断し利用しませんでした。

おそらく今回の解説している内容と同じようなことができるし多くのケースではそちらの方が向いているような気もします。

changeset

これはかなり個人的に気になるプロジェクトです。
ただしコントリビューションの方法などもchangesetに沿った方法にする必要があるため(それが良いところでもあるのですが)、今回は利用しませんでした。

実際に運用してみる

実際にこの構成で運用を始めており大きな障害は今のところありませんが、一つ気になる点は出ています。

このモノレポ構成ではあるパッケージが他のパッケージのビルド成果物に依存するような構成になっているのですが、その際単純にpnpm -r run buildをするだけでは依存関係を考慮したビルド順が担保できないのでは、という懸念があります。
いまのところ問題なくパッケージはリリースされていますが、そのうち対応が必要になるかもしれません。
ただ、これは知る限りではlerna-lite等を利用していても起こりうる問題という認識をしています。

まだ旧パッケージからの切り替えなどされていないプロジェクトも残っているので、これから利用者が増えるにあたって改善やモノレポツールの必要性などについて考えることになりそうです。

まとめ

いかがでしたでしょうか。
実際に運用してみると他ツールの良いところなどもわかりました。
ただGithub Actionsだけでも短期間で十分な結果を得ることができました。
スコープの限られるモノレポのプロジェクトではカスタマイズ性も高くおすすめできます。

読んでいただきありがとうございました🌞

Discussion