🐱

PolyrepoからMonorepoへ移行する

2023/05/25に公開

今までPolyrepoによるクライアントやバックエンドの開発を行ってきましたが、
規模が大きくなるにつれて問題が発生しやすくなったり、作業効率に影響が出るようになってしまったため、この度Monorepo構成へ移行しました。
そのときの手順について紹介したいと思います。

Polyrepoの課題とMonorepoへ移行する目的

Polyrepo運用時の一番の課題はリポジトリ間の依存関係を合わせづらいことにあります。

例えばクライアントの開発をするにあたってAPIが必要となった場合、
バックエンド側の対応が先に終わってる必要があるといった一種の依存関係があります。
この1点だけならPolyrepoでもMonorepoでも大きな違いはないかと思いますが、
iOS、Android、Webといったようにクライアントが複数ある場合や、
Protocol Buffersのような定義ファイルを別リポジトリにしてクライアントとバックエンドで共有(git submodule)している場合があると、どのバージョンを参照すればいいのか、どれが先に終わっているといったタスクの依存関係に伴う調整が複雑になってきます。
アプリケーション規模が小さいうちはなんとかなることが多いですが、開発メンバーが増えたり、各担当領域を別々のメンバーが担っている場合は輪をかけて面倒になってきます。
また、これはgit submodule特有の問題ですが、git submoduleはコミットハッシュを参照するため、マージを忘れてしまったとしても参照元のクライアントやバックエンドはそのまま動くため、マージ漏れがあった場合にも気付きにくかったりします。

そして、この依存関係というのはPull Requestを使ったレビューにも関係してきます。
例えばAPIの開発を行う場合にはバックエンドのコードだけでなくProtocol Buffersの定義も該当しますし、またもしデータベース定義を別リポジトリで管理していればそれも対象になるかと思います。
要はPull Requestをリポジトリ分出す必要が出てくるので、コード量によってはレビューイーもレビューワーにも影響範囲の把握をリポジトリを跨いで確認する必要が出てきて、ある程度負荷がかかる故に見逃しやすくもなることがあります。

開発環境においてもこれが言えます。
複数リポジトリで開発をしていると、ローカルマシンで開発をする際に
各リポジトリの依存関係のバージョン同期が上手くされずに動かない、という事態が起こることもあります。
もちろんすぐに気づくことが大半ですが、経験の浅いメンバーの場合は気づかないこともあり、その解決に時間を取られるケースも増えてきました。

これらの問題は、Monorepo構成へ変更することで解決できると考えたため、この度移行をすることにしました。

移行前のリポジトリと移行後のディレクトリ構成

まず移行前のリポジトリを紹介します。
移行前は以下のリポジトリ構成になっていました。

移行前のリポジトリとその概要

・manifest
Kubernetesマニフェストを管理するリポジトリ。
マニフェスト以外にもローカルマシンにおける統合管理を行うリポジトリ。

・react-native
iOS/Androidの開発を行うReact Nativeのリポジトリ。

・admin
AdminJSを使った管理ツール。

・web
Next.jsを使ったWebアプリケーション。

・backend
GoによるgRPCを使ったAPIやバッチ。

・database
DBのテーブル定義。goose にてマイグレーションを管理する。

・protobuf
Protocol Buffersの定義。各リポジトリから git submodule にて参照する。

・server
TerraformやAnsibleなどインフラ構築におけるスクリプトを管理する。

移行後のディレクトリとファイル構成

最終的には以下のような構成にしました。(一部ファイルは省略)

.
├── bin
├── container
├── manifest
├── packages
│   ├── admin
│   ├── backend
│   ├── database
│   ├── native
│   ├── proto
│   ├── server
│   └── web
├── scripts
├── package.json
├── changelog.config.js
├── commitlint.config.js
├── pnpm-workspace.yaml
├── lefthook.yaml
├── release-please-config.json
└── setup.sh

各ディレクトリやファイルについての概要は以下の通りです。

ディレクトリ/ファイル名 説明
bin リポジトリ内で利用するバイナリファイルをここに配置します。(kubectl, helmなど)。必要なバイナリは setup.sh を実行してインストールされ、direnvにて bin ディレクトリへパスを通すようにしています。
container Containerfile (Dockerfile) を入れるためのディレクトリ
manifest Kubernetesマニフェストを管理するディレクトリ。Argo CDで監視する
packages 移行前の各アプリケーションや定義ファイルのリポジトリの内容
scripts Kubernetesやコンテナ関連の操作を行う際に利用するスクリプト
package.json Monorepo管理におけるタスクスクリプトの定義
pnpm-workspace.yaml Node.jsプロジェクトの Workspace 定義
lefthook.yaml lefthookの定義ファイル
release-please-config.json Release Pleaseの定義ファイル
setup.sh ローカル開発環境のセットアップスクリプト

アプリケーションは packages ディレクトリ配下に置くようにしました。
これは役割や好みに応じて apps 等の名前にしたり、別途ディレクトリを設けても良いかと思います。

では、実際にPolyrepoからMonorepoへ移行する際の手順および各ファイルの解説などを行っていきます。

1. 各リポジトリを packages 配下へコミット履歴を含めて移行する

上記のMonorepo構成は、大雑把に言えばPolyrepoだった際の各リポジトリが packages 配下にそのまま移行されたという感じです。
そうなると構成時にはまず各リポジトリのファイルを packages ディレクトリ配下へ移動するという手順が発生しますが、
以前のリポジトリのコミット履歴も保持しておきたいというケースは多いかと思われます。

これらを同時に行うためには git subtree を利用します。
git subtree を使うと特定のディレクトリに対してファイル内容をコミット履歴を含めて移行することが可能です。
具体的な手順については GitHub の解説記事がわかりやすいので、こちらを参照してみてください。

Gitのサブツリーのマージについて

PolyrepoからMonorepoへ移行するまでの期間にPolyrepoに何かしら変更が生じたとしても、
git subtreeを使っていれば再度変更を取り込むことも出来るため、この方法での移行がおすすめです。

2. Workspaceの設定や参照パス(importのパスなど)の変更を行う

今回のMonorepo対応においても重要なポイントの1つとなるのが、Protocol Buffersの定義や自動生成されたソースコードの参照部分です。
今まではgit submoduleを使って各リポジトリで参照していたのですが、今回からは packages 配下の proto ディレクトリ配下に移行しています。
この proto の内容は backend (grpc-go), native (React Native), web (Next.js) の 3 つのリポジトリが参照しています。

packages/proto ディレクトリ配下は以下のような構成になっています。

packages/proto/
├── android             # Protocol Buffersを参照するためのAndroidプロジェクト
├── bin
├── buf.gen.yaml
├── buf.work.yaml
├── Example.podspec     # iOSからProtocol Buffersを参照するための podspec 定義
├── generated           # Protocol Buffersから自動生成された言語別のソースコード
│   ├── es
│   ├── go
│   └── objc
├── go.mod              # Goのモジュール定義
├── go.sum
├── package.json        # Node.jsのパッケージファイル
├── scripts
├── setup.sh
├── src                 # Protocol Buffersの定義ファイル (*.protoファイル)
└── work

以下に変更手順を紹介します。

2-1. Node.jsプロジェクトの設定と変更

Node.jsパッケージは、package.jsonのあるディレクトリのサブディレクトリを参照する分には特別な設定は不要なのですが、
親ディレクトリを跨がる別のディレクトリにあるパッケージを参照する場合には、Workspaceの設定を行わないと正常に動作しません。
そのため、Monorepoリポジトリのルートに package.json と pnpm-workspace.yaml をおいて、Workspace化します。

この場合に1点注意するのは、React Nativeのプロジェクトは除外する必要があるということです。
React Nativeは pnpm を使った Workspace 機能だけではMonorepoに上手く対応できないので除外し、別Workspaceとして扱う必要があります。
具体的な設定については以下の通りになります。

pnpm-workspace.yaml

pnpm-workspace.yaml
packages:
  - "packages/**"
  - "!packages/native/**"

packages/native/pnpm-workspace.yaml

packages/native/pnpm-workspace.yaml
packages:
  - "."

このように別Workspaceとして分離しておかないと、(pakcages/native 配下に pnpm-workspace.yamlを配置しないと)
ルートの pnpm-workspace.yaml で除外設定をしているにもかかわらず、packages/native 配下で pnpm install を行った際に、親ディレクトリにpackages/native の依存パッケージがインストールされてしまいます。
React Nativeの詳しい設定方法(Monorepo時の設定含む)はyarnをpnpmへ移行するの記事でも紹介していますので、そちらを参照してみてください。

また、Protocol Buffersで自動生成されたソースコードの import パスも今回のディレクトリ構成に基づいて変更しておきます。

2-2. Goプロジェクトの設定と変更

backend (Go) のプロジェクトでも Protocol Buffers を参照しているため、こちらもMonorepo構成に対応できるように変更する必要があります。
Goでは 1.18 から Workspace 機能があるので、この機能を使うという方法を最初は取ろうとしたのですが、
ProposalにはGoの Workspace の設定ファイルである go.work ファイルをコミット対象から除外するということを強く推奨していたりします。(ビルド構成が変更されて混乱を招くためらしいです)
これに従うなら、従来からある方法である go.mod ファイル内に replace を記述して、proto ディレクトリを参照するといった方法をとる必要が出てきます。
以下の記述をbackend の go.mod に追加します。

replace github.com/kkoudev/example/packages/proto => ../proto

あとはソースファイルの import のパスをMonorepo構成にパスに変更し、
packages/backend 配下で go mod tidy を実行することで参照できるようになります。

3. lefthook を使ってパッケージ別の pre-commit 時の動作を設定する

Node.jsのプロジェクトで pre-commit 時にスクリプトを実行する際は
huskysimple-git-hooks などを lint-staged と合わせてよく使う例がありますが、
今回のMonorepo化にあたっては Node.js ではないプロジェクトも管理することになります。
そのため、Monorepoに対応していて、かつ特に利用する言語を問わず気軽に使える pre-commit を管理できるツールとして lefthook を採用することにしました。
これを使うことで、各アプリケーションのLintをpre-commit時に実行し、Lint済みのコードのみをcommitすることを強制できるようになります。
lefthookはLinterの並列実行も可能なので、速度的にも高速です。
但し注意点として、lint-stagedを使っている場合は lint-staged が git stashなどのgit操作を行う仕様上、並列実行時に問題が起きるので、各アプリケーションでのLinter実行時にはlint-stagedを使わないのがおすすめです。

定義ファイルの内容としては、以下のようになっています。

lefthook.yaml
# pre-commit時のタスク実行順を定義します
pre-commit:
  parallel: false
  piped: true
  commands:
    1_build:
      run: LEFTHOOK_QUIET=meta lefthook run build
    2_lint:
      run: LEFTHOOK_QUIET=meta lefthook run lint

build:
  parallel: true
  piped: false
  commands:
    # Protocol Buffersの定義ファイルが更新されたら、各言語のコード生成を行い、差分があればエラーにする
    # (その後、git addすればエラーにはならない)
    proto:
      root: packages/proto
      files: git diff --name-only HEAD | grep "packages/proto" || true
      glob: "**/*.proto"
      run: ./lang/build.sh && ./lang/diff.sh

# 各アプリケーションのタスクスクリプト(npm scriptsやMakefile)にコードチェック用のタスクを用意しておき、それを実行する
lint:
  parallel: true
  piped: false
  commands:
    admin:
      root: packages/admin
      files: git diff --name-only HEAD | grep "packages/admin" || true
      glob: "**/*.{ts,tsx}"
      run: pnpm check
    backend:
      root: packages/backend
      files: git diff --name-only HEAD | grep "packages/backend" || true
      glob: "**/*.go"
      run: make check
    native:
      root: packages/native
      files: git diff --name-only HEAD | grep "packages/native" || true
      glob: "**/*.{ts,tsx}"
      run: pnpm check
    web:
      root: packages/web
      files: git diff --name-only HEAD | grep "packages/web" || true
      glob: "**/*.{ts,tsx,css}"
      run: pnpm check

# commitメッセージが Conventional Commits の形式になっているかどうかをチェックする
commit-msg:
  scripts:
    "commitlint.sh":
      runner: bash

Monorepo構成の場合、lefthookの files の部分がポイントです。
これにより、変更のあったファイルがどのパッケージに該当するものかをチェックできるようになっており、
該当するパッケージの変更に対応するコマンドのみが実行されるようになります。

4. CIをMonorepo対応する

CIにはGitHub Actionsを利用しています。
GitHub ActionsでMonorepo対応を行う際は、 paths を使って該当するパッケージディレクトリ配下が変更された場合に
ワークフローが実行されるようにするといった工夫を行う必要があります。
その部分だけ例として抜粋すると、以下のような記載となります。

on:
  push:
    paths:
      - ".github/workflows/build_web.yml"
      - "packages/web/**"
      - "!packages/web/**.md"

各ワークフロー内のstepでも、必要に応じてworking-directoryを指定し、
コマンドを実行するディレクトリを変更する必要があります。

また、Containerfile (Dockerfile)を使ってコンテナビルドをしているケースでは、
2で行ったWorkspace化を考慮してビルド設定を変更する必要があります。

例として、packages/web にあるNext.jsプロジェクトをMonorepo対応した際の Containerfile を紹介します。

packages/web/Containerfile
ARG NODE_VERSION

# ------------------------------
# Builder stage
# ------------------------------
FROM node:${NODE_VERSION}-bullseye as builder

WORKDIR /var/opt/app
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY packages/web ./packages/web
COPY packages/proto/generated/es ./packages/proto/generated/es
COPY packages/proto/package.json ./packages/proto/
RUN corepack enable \
  && pnpm i --frozen-lockfile --filter ./packages/web --filter ./packages/proto
RUN pnpm --filter ./packages/web build
RUN rm -rf node_modules \
  && pnpm i --frozen-lockfile --prod --ignore-scripts --filter ./packages/web --filter ./packages/proto

# ------------------------------
# Execution image
# ------------------------------
FROM node:${NODE_VERSION}-bullseye-slim

WORKDIR /var/opt/app

# workspace root
COPY --from=builder /var/opt/app/node_modules ./node_modules
COPY --from=builder /var/opt/app/package.json ./
COPY --from=builder /var/opt/app/pnpm-lock.yaml ./
COPY --from=builder /var/opt/app/pnpm-workspace.yaml ./

# packages/web
COPY --from=builder /var/opt/app/packages/web/node_modules ./packages/web/node_modules
COPY --from=builder /var/opt/app/packages/web/package.json ./packages/web/
COPY --from=builder /var/opt/app/packages/web/next.config.js ./packages/web/
COPY --from=builder /var/opt/app/packages/web/.next ./packages/web/.next
COPY --from=builder /var/opt/app/packages/web/dist ./packages/web/dist
COPY --from=builder /var/opt/app/packages/web/public ./packages/web/public

RUN corepack enable

CMD ["pnpm", "--filter", "./packages/web", "start"]

ビルドする際は GitHub Actions のワークフロー内で以下のようなコマンドでビルドします。

# ${NODE_VERSION}には利用するNode.jsバージョンが入る
cat packages/web/Containerfile | buildah bud --layers=true -t kkoudev/example-packages-web --build-arg NODE_VERSION=${NODE_VERSION} -f - .

これでCIについても無事移行ができました。

5. リリースノート作成やセマンティックバージョンの自動算出をMonorepo対応する

Polyrepo時のリリースノート作成やバージョンの算出には semantic-release によるConventional Commitsルールに則ったコミットメッセージを元に、セマンティックバージョンを自動算出&リリースノートを自動生成するということを行っていました。
semantic-release は npm でライブラリをリリースする際に利用されているケースが多いですが、Node.js以外のプロジェクトでも .releaserc.yaml を配置して semantic-release のアクションを実行することバージョンの算出に利用できるので利用していました。

しかし、semantic-release はMonorepoに対応していないため、
同じように Conventional Commits のコミットメッセージからセマンティックバージョン算出が可能で、かつMonorepoに対応した Release Please を採用することにしました。

5-1. Release Pleaseの設定について

具体的には以下のような設定ファイルを記述します。

release-please-config.json
{
  "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
  "packages": {
    ".": {
      "release-type": "simple",
      "package-name": "example",
      "exclude-paths": [
        "packages"
      ]
    },
    "packages/admin": {
      "release-type": "node",
      "package-name": "admin",
      "changelog-path": "CHANGELOG.md"
    },
    "packages/backend": {
      "release-type": "go",
      "package-name": "backend",
      "changelog-path": "CHANGELOG.md"
    },
    "packages/database": {
      "release-type": "simple",
      "package-name": "database",
      "changelog-path": "CHANGELOG.md"
    },
    "packages/native": {
      "release-type": "node",
      "package-name": "native",
      "changelog-path": "CHANGELOG.md",
      "extra-files": [
        {
          "type": "xml",
          "path": "ios/example/Info.plist",
          "xpath": "//plist/dict/string[1]"
        }
      ]
    },
    "packages/web": {
      "release-type": "node",
      "package-name": "web",
      "changelog-path": "CHANGELOG.md"
    },
    "packages/server": {
      "release-type": "simple",
      "package-name": "server",
      "changelog-path": "CHANGELOG.md"
    }
  },
  "last-release-sha": "xxxxx"
}

設定の詳細は公式ドキュメントの解説を参照してみて欲しいのですが、
ポイントとしては packages/native の項目にある React Native のリポジトリの設定部分です。
React Nativeは内部的にはiOS/Androidのアプリケーションであるため、
ネイティブ側の設定を変更しなければアプリケーションのバージョンを変更することはできません。

Androidでは build.gradle で package.json をパースし、バージョン番号を取得することでバージョンコードを変更するという対応が可能ですが、iOSについてはバージョン番号が Info.plist へ記述されているためこの Info.plist を直接変更する必要があります。

Release Pleaseでは算出したバージョン番号を反映する仕組みとして、 extra-files という任意のファイルを更新する機能があり、
ここで指定したファイルフォーマットと、ファイル内の指定位置や定数文字列をバージョン番号へ置き換えるといった対応が可能です。

Info.plistは内部的には XML なので、XMLフォーマットとして解釈させ、XPathを使って更新箇所を明示しています。
該当箇所を一部抜粋すると、Info.plistの中身は以下のようになっています。

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>CFBundleShortVersionString</key>
	<string>1.0.0</string>
	<key>CFBundleDevelopmentRegion</key>
	<string>en</string>
	<key>CFBundleDisplayName</key>
	<string>$(PRODUCT_NAME)</string>
...(以下省略)...
</dict>

XPathによる位置指定は順番による指定しか出来ないため、
もし誰かが編集して位置が変わったときに検知しやすいよう、バージョン番号に当たる CFBundleShortVersionString をわかりやすく一番上に配置するようにしています。
(上記のバージョン番号箇所に当たるXPathが //plist/dict/string[1] となる)

5-2. hotfix対応したリリース用ワークフローの作成

【2023/11/16に修正】
Polyrepoのときからブランチモデルには git-flow を採用していました。
git-flowを採用している理由としては、hotfix に対応するためと、Argo CDで環境別のマニフェスト監視(developブランチはステージング、mainブランチは本番)を行うためといった理由があります。

そしてここで問題となったのは、Release Please は前回リリース時のコミットメッセージからリリース内容を判断するため、
hotfixでリリースしてしまうと、まだリリースされていない develop にマージされた修正をリリース内容として判定しなくなるという問題があります。

これを解決するには少々複雑で、ハックっぽいことをする必要があります。

Relase Pleaseでは前回リリース時にタグ付けしたバージョンやコミット位置を見て、
次のリリース対象のコミットの収集やバージョニングを行います。
なので、簡単にいえば hotfix のリリースをしたときに、通常リリースとは別のタグをつければ別物として扱うことができるようになるので、developの修正やリリースには干渉しないようにすることが出来ます。

リリース時のタグ名は release-please-config.json に記載の package-name と tag-separator を元に決められるので、
hotfixリリース時にデフォルトのハイフン(-)ではなく、-hotfix のようなセパレータに変更することでタグ名を変更します。

とはいえ、これを毎回手動で処理するわけにはいかないのでこれも自動化します。
以下のようなGitHub Actionsのワークフローを作成しておきリリース時に手動実行します。
実行するときに develop ブランチを main ブランチへマージしてリリースするのか、hotfixブランチを main ブランチへマージしてリリースするのかを最初にプルダウンで選択できるようにしておきます。

.github/workflows/release.yml
name: 001-Release # GitHubで閲覧した時にわかりやすく一番上にきて欲しいのでワークフロー名の頭に数字を付与している

on:
  workflow_dispatch:
    inputs:
      target_branch:
        type: choice
        description: Release Target branch
        default: develop
        options: 
        - develop
        - hotfix

permissions:
  contents: write
  pull-requests: write

jobs:
  releaseMerge:
    name: Release Merge
    runs-on: ubuntu-latest
    steps:
    - name: Checkout
      uses: actions/checkout@v3
      with:
        ref: ${{ inputs.target_branch }}
        fetch-depth: 0

    - name: Merge
      run: |
        .github/workflows/scripts/merge.sh release ${{ inputs.target_branch }}
.github/workflows/release_note.yml
name: Create Release Note

on:
  push:
    branches:
      - main
  workflow_run:
    workflows: ["001-Release"]
    types:
      - completed

permissions:
  contents: write
  pull-requests: write
  actions: read

jobs:
  create-release-note:
    if: ${{ github.event.workflow_run.conclusion != 'failure' }}
    runs-on: ubuntu-latest
    steps:
      - name: Generate token
        id: generate_token
        uses: tibdex/github-app-token@v1
        with:
          app_id: ${{ secrets.EXAMPLE_GITHUB_APPS_APP_ID }}
          private_key: ${{ secrets.EXAMPLE_GITHUB_APPS_PRIVATE_KEY }}

      - name: Release Please
        uses: google-github-actions/release-please-action@v3
        id: release
        with:
          token: ${{ steps.generate_token.outputs.token }}
          default-branch: main
          monorepo-tags: true
          command: manifest

      - name: Checkout
        if: ${{ steps.release.outputs.releases_created }}
        uses: actions/checkout@v3

      # mainブランチへマージされた新しいバージョン情報を develop ブランチへマージする
      - name: Back Merge
        if: ${{ steps.release.outputs.releases_created }}
        env:
          GH_TOKEN: ${{ steps.generate_token.outputs.token }}
        run: |
          .github/workflows/scripts/merge.sh backmerge

      - name: Slack Notification
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          fields: all
          username: ${{ secrets.SLACK_USERNAME }}
          icon_emoji: ${{ secrets.SLACK_ICON_EMOJI }}
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
          MATRIX_CONTEXT: ${{ toJson(matrix) }}
        if: failure()
.github/workflows/scripts/merge.sh
#!/bin/bash

set -e

readonly COMMAND="$1"
readonly TARGET_BRANCH="$2"

git config user.email "actions@github.com"
git config user.name "GitHub Actions"

case "$1" in
release)
  git fetch
  git checkout -f ${TARGET_BRANCH}
  git pull

  readonly COMMIT_MESSAGE="chore: Auto-merge release ${TARGET_BRANCH} to main"
  readonly SEARCH_COMMIT_MESSAGE="chore: Auto-merge release develop to main"
  readonly WORK_CONFIG_PATH=".work.release-please-config.json"
  readonly LAST_RELEASE_SHA=$(git log --grep="${SEARCH_COMMIT_MESSAGE}" -n 1 --pretty=%H | tr -d '\n')

  case "${TARGET_BRANCH}" in
  develop)
    cat "${GITHUB_WORKSPACE}/release-please-config.json" | jq ".+ { \"last-release-sha\": \"${LAST_RELEASE_SHA}\" }" > "${WORK_CONFIG_PATH}"
    mv -f "${WORK_CONFIG_PATH}" "${GITHUB_WORKSPACE}/release-please-config.json"
    git add "${GITHUB_WORKSPACE}/release-please-config.json"

    if [[ $(git status -s | wc -l) -ne 0 ]]; then
      git commit -m "chore: update last-release-sha"
    fi
    ;;

  hotfix)
    cat "${GITHUB_WORKSPACE}/release-please-config.json" | jq ".+ { \"bootstrap-sha\": \"${LAST_RELEASE_SHA}\", \"tag-separator\": \"-hotfix-\" }" > "${WORK_CONFIG_PATH}"
    mv -f "${WORK_CONFIG_PATH}" "${GITHUB_WORKSPACE}/release-please-config.json"
    git add "${GITHUB_WORKSPACE}/release-please-config.json"

    if [[ $(git status -s | wc -l) -ne 0 ]]; then
      git commit -m "chore: update bootstrap-sha"
    fi
    ;;

  *)
    echo "Unsupported target branch [${TARGET_BRANCH}]"
    exit 1
    ;;
  esac

  git checkout -f main
  git pull

  git merge --no-ff ${TARGET_BRANCH} -m "${COMMIT_MESSAGE}"
  git push

  if [[ "${TARGET_BRANCH}" == "hotfix" ]]; then
    git push origin -d hotfix
  fi
  ;;
backmerge)
  git fetch
  git checkout -f main
  git pull --rebase

  # commit & pull request
  readonly CREATE_HEAD_BRANCH="backmerge/${GITHUB_SHA}"
  git checkout -b "${CREATE_HEAD_BRANCH}"

  readonly WORK_CONFIG_PATH=".work.release-please-config.json"
  cat "${GITHUB_WORKSPACE}/release-please-config.json" | jq 'del(.["bootstrap-sha", "tag-separator"])' > "${WORK_CONFIG_PATH}"
  mv -f "${WORK_CONFIG_PATH}" "${GITHUB_WORKSPACE}/release-please-config.json"
  git add "${GITHUB_WORKSPACE}/release-please-config.json" || true
  git commit -m "chore: remove bootstrap-sha" || true

  git push origin "${CREATE_HEAD_BRANCH}"

  # create pull request
  gh pr create \
  --base "develop" \
  --head "${CREATE_HEAD_BRANCH}" \
  --title "Auto-merge back main to develop" \
  --body ""
  ;;
*)
  echo "Unknown command. [$1]"
  exit 1
  ;;
esac
.github/workflows/merge_tag.yml
name: Merge Tags

on:
  pull_request:
    paths:
      - manifest/app/overlays/development/images/application/**/kustomization.yaml
      - manifest/app/overlays/production/images/application/**/kustomization.yaml
      - packages/**/CHANGELOG.md
      - CHANGELOG.md
    branches:
      - develop
      - main

permissions:
  contents: write
  pull-requests: write
  actions: read

jobs:
  auto-merge-tags:
    runs-on: ubuntu-latest
    if: ${{ github.actor == 'example-ci[bot]' && !contains(github.head_ref, 'release-please--branches--main') }}
    steps:
      - name: Checkout
        uses: actions/checkout@v3

      - name: Generate token
        id: generate_token
        uses: tibdex/github-app-token@v1
        with:
          app_id: ${{ secrets.EXAMPLE_GITHUB_APPS_APP_ID }}
          private_key: ${{ secrets.EXAMPLE_GITHUB_APPS_PRIVATE_KEY }}

      - name: Approve PR
        env:
          PR_URL: ${{ github.event.pull_request.html_url }}
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh pr review "$PR_URL" --approve

      - name: Auto Merge PR
        env:
          PR_URL: ${{ github.event.pull_request.html_url }}
          GH_TOKEN: ${{ steps.generate_token.outputs.token }}
        run: |
          gh pr merge --auto --merge "$PR_URL"

      - name: Slack Notification
        uses: 8398a7/action-slack@v3
        with:
          status: ${{ job.status }}
          fields: all
          username: ${{ secrets.SLACK_USERNAME }}
          icon_emoji: ${{ secrets.SLACK_ICON_EMOJI }}
        env:
          SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK }}
          MATRIX_CONTEXT: ${{ toJson(matrix) }}
        if: failure()

これらのワークフローでやっていることとリリースフローをまとめると以下のとおりになります。

🔸通常リリース時 (developブランチをmainブランチへマージしてリリースするとき)

  1. Releaseワークフロー実行時に「develop」を選択して実行する (last-release-sha が更新される)
  2. Release Pleaseによってリリース項目とバージョン情報がまとめられた Pull Requestが作成されるので、内容を確認してGitHub上の「Merge Pull request」ボタンを押下して手動マージする
  3. Release Pleaseが各パッケージごとのリリースノート作成(タグPush)を行う。(例:webであれば web-v8.0.0 といったタグが作成される)
  4. 各packagesのワークフロー設定で 3 のタグがPushされたときに本番ビルドが実行されるようにしておく
  5. リリースノート作成後に作成されたCHANGELOG.mdや新しいバージョンの内容を develop ブランチへマージするためのPRを作成し、自動的にApproveしてマージする

🔸hotfixリリース時 (hotfixブランチをmainブランチへマージしてリリースするとき)

  1. hotfixブランチをmainブランチをベースにして作成し、developで(ステージングで)修正確認したのちにhotfixブランチへ対象の修正のみをCherry-pickする
  2. Releaseワークフロー実行時に「hotfix」を選択して実行する (bootstrap-sha が更新され、tag-separatorに -hotfix- がセットされる)。マージしたhotfixブランチは自動削除される
  3. Release Pleaseによってリリース項目とバージョン情報がまとめられた Pull Requestが作成されるので、内容を確認してGitHub上の「Merge Pull request」ボタンを押下して手動マージする
  4. Release Pleaseが各パッケージごとのリリースノート作成(タグPush)を行う。(例:webであれば web-hotfix-v8.0.0 といったタグが作成される)
  5. 各packagesのワークフロー設定で 4 のタグがPushされたときに本番ビルドが実行されるようにしておく
  6. リリースノート作成後に作成されたCHANGELOG.mdや新しいバージョンの内容を develop ブランチへマージするためのPRを作成し、自動的にApproveしてマージする。このとき、bootstrap-shatag-separator を削除してマージします

last-release-shabootstrap-sha を使っている理由としては、収集対象となるコミットを限定するために行っています。
hotfixであればCherry-pickしたコミットのみを対象としたいので、bootstrap-shaを指定することで、余計なコミットの収集をしないようにしています。

以上の手順にて、hotfixにも対応したMonorepo対応したリリースフローを作成することができました。

Monorepo移行した結果やその感想

Monorepoへ移行したことで、冒頭で紹介した問題点については狙い通り解消されました。
ただ一方で、バックエンドだけをリリースしたい、クライアントだけをリリースしたい、といったケースに
複数リポジトリの変更が既に開発ブランチにマージされていたりすると、対応しづらいケースもありました。
とはいえ、そのようなケースは現状殆どないのと、もしあった場合でもhotfixを駆使して対応することで問題なく対処することができましたので、全体的な体験としては現状プラスに働いています。

一方で、ここまで読んで感じた方もいらっしゃるとは思いますが、Monorepoは対応ツールが少ない・設定が複雑なのでメンテが難しいというのも問題点として挙げられます。
Monorepo化を行う上ではメンテナンスを続けられるような構成になっているかどうか(また、メンテナーがいるかどうか)が一番重要なポイントであり、
メンテナンスを続けることが難しい構成であれば無理にMonorepo化を行ったところで、依存関係問題が解消してもMonorepo構成の維持に時間を取られてしまうといった新たな課題を生み出すだけになることが懸念されます。

そのため、Monorepo対応を行う場合は依存関係を把握しやすいメリットと設定の複雑さを天秤にかけた上で、メリットがあると感じた場合にのみ移行することをおすすめします。
単なるソースコードの依存関係管理だけならまだしも、CIまわりの設定も含めるとPolyrepoに比べると考慮するポイントが非常に多いためです。
(もっとも今回に関しては、hotfixに対応させたことが複雑化の主要因であったとは考えています)

まとめ

そんなわけで、PolyrepoからMonorepoへ移行した際の手順について紹介させていただきました。
設定は複雑になる傾向はあるものの、リポジトリが増えすぎて依存関係を把握するのが大変になってきたというケースにおいては非常に効果的であるため、Monorepo構成に興味のある方の参考になれば幸いです。

Discussion