【2023Q1】pnpm+Changesets+GitHub Actionsでmonorepo内のnpmパッケージのリリースとCodecov連携を自動化する

## 概要

[pnpm Workspace](https://pnpm.io/workspaces), [Changesets](https://github.com/changesets/changesets), [GitHub Actions](https://docs.github.com/ja/actions)を組み合わせて以下のリリースフローを構築する。

1. 開発の区切り毎に `$ pnpm changeset` でChangesetsの対話式CLIからバージョンアップ種別の選択とCHANGELOGの入力を行う
    - 一時ファイル（メタデータ入りの `CHANGELOG.md` の断片）が生成される
2. 一時ファイルをソースと一緒にGitHubにPushする
3. 一時ファイルがGitHubのmainブランチに到達すると [Changesets Release Action](https://github.com/changesets/action) が「その時点でリリース対象となるnpmパッケージを一括リリースするためのPR」を自動生成する
    - PRの内容は「 `CHANGESET.md` の更新」「 `package.json` の `version` フィールドのインクリメント」「一時ファイルの削除」
    - PRをマージする前に別の一時ファイルを追加するとリリース用PRの内容は自動で更新される
    - PRには全ての一時ファイルの内容がパッケージ毎に整理されcommitハッシュと紐付けて表示される
4. 任意のタイミングでリリース用PRをマージすると [npmjs.com](https://www.npmjs.com/) に対象パッケージがデプロイされる
5. デプロイに成功したパッケージのカバレッジデータを [Codecov GitHub Action](https://github.com/codecov/codecov-action) でCodecovにアップロードする

<details><summary>自動生成されるリリース用PRのサンプル（画像）</summary>

![image](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63934/05afdce6-fe14-3da5-f3d4-795667ff3d68.png)

</details>

### 要件

- pnpm Workspaceによるmonorepo環境
- リポジトリ内の複数のnpmパッケージをnpmjs.comにデプロイする
    - デプロイにはChangesets Release Actionを使用する
    - デプロイ対象のパッケージはサブプロジェクトとして `packages/*` に配置する
    - パッケージ名は名前空間の有無を問わない
- パッケージのバージョンは個別管理とし、CommitやPRには紐付けない
    - パッケージ間の相互依存はないものとする
- `CHANGELOG.md` の更新と `package.json` 上のバージョンのインクリメントはChangesetsに一任する
- GitHub ActionsでChangesets Release Actionを使用してリリース用PRを自動生成する
- リリース用PRをマージしたら自動でnpmjs.comにデプロイする
    - デプロイは内部的には `npm publish` で行われる
- カバレッジデータを [Codecov GitHub Action](https://github.com/codecov/codecov-action) を使用して [Codecov](https://about.codecov.io/) にアップロードする
    - パッケージ毎にflagに紐付ける

※本稿では [changeset-bot](https://github.com/apps/changeset-bot) および [Codecov GitHub App](https://github.com/apps/codecov) は扱わない

ベースとなる開発環境は以下の記事の通り。

https://qiita.com/macropygia/items/7d9a06cf33ec4997dcfa

## 構築

### 1. ルートプロジェクト

npm-scriptsにChangesets Release Actionが使用するタスクを追加し、必要なパッケージをインストールする。

```jsonc:package.json
// ※抜粋
{
  "scripts": {
    "ci:version": "changeset version",
    "ci:publish": "changeset publish"
  },
  "devDependencies": {
    "@changesets/cli": "^2.26.0",
    "@vitest/coverage-c8": "^0.28.3",
    "c8": "^7.12.0",
    "tsup": "^6.5.0",
    "typescript": "^4.9.5",
    "vitest": "^0.28.3"
   }
}
```

- `ci:version`
    - リリース用PRを作成する際に使用するコマンド
    - 現時点ではChangesetsの標準コマンドとする
        - CHANGELOGの更新やバージョンのインクリメントを行う
    - デプロイ用ビルドの前にリポジトリ内のファイルを編集する必要がある場合はここで行う
- `ci:publish`
    - npmへのデプロイ時に使用するコマンド
    - 現時点ではChangesetsの標準コマンドとする
        - 内部的には `npm publish` を使用する
        - ここを `pnpm -r publish` で置き換えてもデプロイ自体は行われるが、デプロイしたパッケージの情報をChangesets Release Actionが取得できなくなる
- タスク名は任意
    - GitHub Actions側の指定と一致していればよい
- この例の `devDependencies` はTypeScriptを使用し [Vitest](https://vitest.dev/) で [C8](https://github.com/bcoe/c8#readme) を使ってcoverageデータを生成する場合の構成
    - サブプロジェクトから利用する

### 2. サブプロジェクト

npm-scriptsにビルド及びcoverageデータ生成のタスクを追加し、publish時に実行されるように設定する。  
例として以下の2つのパッケージが含まれているものとする。

```jsonc:packages/first-package/package.json
// ※抜粋
{
  "name": "@namespace/first-package",
  "scripts": {
    "clean": "rimraf ./dist",
    "tsc": "tsc -p tsconfig.build.json",
    "coverage": "vitest run --coverage",
    "build": "pnpm clean && pnpm tsc",
    "prepublishOnly": "pnpm coverage && pnpm build"
  },
}
```

```jsonc:packages/second-package/package.json
// ※抜粋
{
  "name": "second-package",
  "scripts": {
    "clean": "rimraf ./dist",
    "tsup": "tsup src/index.ts src/cli.ts --dts --shims --format cjs,esm",
    "coverage": "vitest run --coverage",
    "build": "pnpm clean && pnpm tsup",
    "prepublishOnly": "pnpm coverage && pnpm build"
  },
}
```

- パッケージ名は名前空間（ `name` の `@namespace/` の部分）の有無が混在していても問題ない
- `prepublishOnly` はChangesets Release Actionが `npm publish` を実行した際に自動で実行されるタスク
    - coverageデータの生成とビルドが行われるように設定する
    - 参考: [Life Cycle Scripts - scripts | npm Docs](https://docs.npmjs.com/cli/v9/using-npm/scripts#life-cycle-scripts)
- Codecovにアップロードするデータとして `packages/<package_name>/coverage/coverage-final.json` にCodecovの対応する形式のJSONが出力されるように設定する
- Vitestは指定しない場合 `./coverage` にcoverageデータを出力する
    - つまり `packages/<package_name>/coverage` に出力される
    - `@vitest/coverage-c8` を使用する場合、標準で `coverage-final.json` も出力される
    - 参考: [Coverage | Guide | Vitest](https://vitest.dev/guide/coverage.html)

### 3. Changesets

#### 3-1. 初期設定

`changeset init` コマンドで `.changeset/config.json` を生成し、必要に応じて内容を編集する。

```json:.changeset/config.json
{
  "$schema": "https://unpkg.com/@changesets/config@2.3.0/schema.json",
  "changelog": "@changesets/cli/changelog",
  "commit": false,
  "fixed": [],
  "linked": [],
  "access": "public",
  "baseBranch": "main",
  "updateInternalDependencies": "patch",
  "ignore": []
}
```

- 公開パッケージの場合は `access` キーを `public` とする

#### 3-2. 使用手順

[開発環境構築記事のChangesetsの節](https://qiita.com/macropygia/items/7d9a06cf33ec4997dcfa#13-changesets)を参照のこと。

- 当該記事における「アップデート対象パッケージの選択とサマリーの入力」を実施した後、生成された一時ファイルのMarkdownをそのままリポジトリにCommitする。
- 「CHANGELOG.mdの更新とバージョンのインクリメント」相当の手順はChangesets Release ActionがGitHub上で自動実行する。

##### Tips

概要に載せたスクリーンショットの通り、自動生成されるリリース用PRには「一時ファイルを処理したコミットへのリンク」が設置される。そのため、個別の開発用ブランチ（いわゆるfeatureブランチ）からリリース用のブランチに直接マージするGitHub Flow的なブランチモデルとの親和性が高い。
開発用ブランチ毎に `$ pnpm changeset` を実行することでCHANGELOGの項目とマージコミットが紐付く形になるため、後から「GitHub上で `パッケージ名@バージョン` で検索 → PR → ソース/差分/Issue」と簡単に辿ることができる。

### 4. Codecov

リポジトリのルートに設定ファイルを設置する。

```yaml:codecov.yml
flag_management:
  default_rules:
    carryforward: true
  individual_flags:
    - name: first-package
      paths:
        - packages/first-package
    - name: second-package
      paths:
        - packages/second-package

ignore:
  - '**/coverage'
  - '**/__tests__'
  - '**/dist'
  - '**/docs'
```

- 各パッケージ用のflagを定義する
- flag毎にcoverageデータを更新するため `carryforward: true` としておく
- ignoreは適宜

サインアップや連携については本稿では扱わないが、GitHubの公開リポジトリで使う分には、GitHubアカウントでサインアップするだけで他の操作は必要ないと思われる。

### 5. GitHub

#### 5-1. Secretsの登録

npmjs.comにデプロイする際に必要なアクセストークンをGitHubのSecretsに追加する。

- npmjs.comにログインしユーザーメニューから「Access tokens」に移動
- 「Generate new token」ボタンから「Automation」トークンを作成する
    - 生成画面を離れると再表示できない点に留意する
- GitHubのリポジトリで `Settings -> Secrets -> Actions -> New repository secrets` と移動
- トークンを `NPM_TOKEN` として追加する

※公開リポジトリの場合Codecovのtokenは登録不要

#### 5-2. Workflow permissionsの設定

- GitHubでリポジトリを開き `Settings -> Actions -> General`
- 最下部の `Workflow permissions` に移動
- ラジオボタンで `Read and write permissions` を選択
- `Allow GitHub Actions to create and approve pull requests` チェックボックスをON
- `Save` で適用する

### 6. GitHub Actions

リポジトリのルートに `.github/workflows` ディレクトリを作成して設定用のYAMLを配置する。

```yaml:.github/workflows/release.yml
name: Release

# mainブランチに変更が発生したら動作する
on:
  push:
    branches:
      - main

# 標準でbashを使用する
defaults:
  run:
    shell: bash
jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      # [1] リポジトリをチェックアウト
      - name: Checkout
        uses: actions/checkout@v3

      # [2] Node.js 18系を使用
      - name: Setup Node.js
        uses: actions/setup-node@v3
        with:
          node-version: 18

      # [3] pnpm 7系を使用
      - name: Setup pnpm
        uses: pnpm/action-setup@v2
        with:
          version: 7

      # [4] pnpmのストアディレクトリを取得
      # - pnpmは依存関係の実体をnode_modulesではなく独自のストアで管理している
      - name: Get pnpm store directory
        id: pnpm-cache
        run: |
          echo "PNPM_CACHE_DIR=$(pnpm store path)" >> $GITHUB_OUTPUT

      # [5] Github Actionsのキャッシュとpnpmのキャッシュの繋ぎ込み
      # - pnpm-lock.yamlのハッシュが一致するものが存在していれば復元する
      # - 存在していなければ直近のものを復元する
      - name: Setup pnpm cache
        uses: actions/cache@v3
        with:
          path: ${{ steps.pnpm-cache.outputs.PNPM_CACHE_DIR }}
          key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
          restore-keys: |
            ${{ runner.os }}-pnpm-store-

      # [6] 依存関係のインストール
      - name: Install dependencies
        run: pnpm install

      # [7] npmjs.comのデプロイ用トークンをgithubのsecretsから取得して `.npmrc` に追記
      - name: Setup npmrc
        env:
          NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
        run: |
          cat << EOF > "$HOME/.npmrc"
            //registry.npmjs.org/:_authToken=$NPM_TOKEN
          EOF

      # [8] Changesets Release Action
      - name: Create release PR or publish to npm
        id: changesets
        uses: changesets/action@v1
        with:
          # Changesets標準のコマンドの代わりに
          # ルートプロジェクトのpackage.jsonのnpm-scriptsを実行する
          version: pnpm ci:version
          publish: pnpm ci:publish
          # PRのタイトルを指定
          title: '[ci] release'
          # PRのコミットメッセージを指定
          commit: '[ci] release'
          # GitHubのReleaseを作成しない（任意）
          createGithubReleases: false
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      # [9] jqでChangesets Release Actionの出力を加工する
      - name: Convert publishedPackages to comma separated string using jq
        id: jq
        if: steps.changesets.outputs.published == 'true'
        run: |
          flags=`echo '${{ steps.changesets.outputs.publishedPackages }}' | jq '.[].name' | jq '. | if(startswith("@")) then split("/") | .[1] else . end' | jq -s -j 'join(",")'`
          echo "CODECOV_FLAGS=$flags" >> $GITHUB_OUTPUT
          files=`echo '${{ steps.changesets.outputs.publishedPackages }}' | jq '.[].name' | jq '. | if(startswith("@")) then split("/") | .[1] else . end' | jq -s -j 'map("packages/" + . + "/coverage/coverage-final.json") | join(",")'`
          echo "CODECOV_FILES=$files" >> $GITHUB_OUTPUT

      # [10] Codecov GitHub Action
      - name: Upload coverage to Codecov
        if: steps.changesets.outputs.published == 'true'
        uses: codecov/codecov-action@v3
        with:
          flags: ${{ steps.jq.outputs.CODECOV_FLAGS }}
          files: ${{ steps.jq.outputs.CODECOV_FILES }}
          # verbose: true
          # dry_run: true
```

- ファイル名・Workflow名・Job名・Step名は適宜変更のこと
- pnpm Workspace+Changesets構成のリポジトリとして [Astro](https://github.com/withastro/astro/blob/main/.github/workflows/release.yml), [SvelteKit](https://github.com/sveltejs/kit/blob/master/.github/workflows/release.yml), [Chakra UI](https://github.com/chakra-ui/chakra-ui/blob/main/.github/workflows/release.yml) などが参考になる

#### jqの用途

Changesets Relase Actionはnpmjs.comにデプロイしたパッケージの情報を以下のフォーマットで `publishedPackages` に入れて返す。

```json:publishedPackages
[
{ "name": "@nameaspace/first-package", "version": "0.1.0" },
{ "name": "second-package", "version": "0.1.0" }
]
```

一方Codecov GitHub Actionは更新されたflagとアップロードするファイルの情報を「カンマ区切りテキスト」で要求する。  
つまり上記のオブジェクト配列を以下の形式に変換する必要があり、jqでこれを行っている。

```yaml
with:
  flags: 'first-package,second-package'
  files: 'packages/first-package/coverage/coverage-final.json,packages/second-package/coverage/coverage-final.json'
```

なお、本稿の設定ではパッケージ名は「 `@` から始まる場合は `/` で分割して2個目の要素を使用」「 `@` から始まらない場合はそのまま使用」となっている。  
パッケージ名に `/` が2個以上含まれる場合は適宜修正のこと。

#### Linter

基本的な文法チェックには [actionlint](https://github.com/rhysd/actionlint) が便利。

https://rhysd.github.io/actionlint/

#### 動作テスト

環境変数やoutputsの処理などは [nektos/act](https://github.com/nektos/act) を使ってローカルでテストできる。

https://github.com/nektos/act

PRの生成などGitHubの機能を使用するものは、テスト用のブランチないしリポジトリを作成し、デプロイ系の動作をdry-runに設定して確認する。

## 実行

基本的に概要の通り。  
PushないしPRでGitHub上のmainブランチにコミットが到達するとChangesets Release Actionが作動し、デプロイが必要かつ可能な状態であればリリース用PRが作成される。  
PRをマージするとnpmjs.comへのデプロイとCodecovへのアップロードが行われる。

実際に得られる出力をサンプルとして掲載する。

1. `pnpm changset` を実行
    - [参考画像](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63934/4959d326-bd64-d5cb-8169-9f8bc09325bf.png)
    - この例ではPatchレベルのサマリーを2件入力している
2. 手順1で生成された一時ファイルをCommit/Push
    - [参考リンク](https://github.com/macropygia/static-site-stack/commit/c60881e569a4d9ebc2a70cb53d303a713d051d59)
    - 他のファイルと一緒でも構わない
3. GitHub Actionsが2を処理してPRを生成する
    - [参考画像](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63934/c1e5b0cb-21fd-3141-c15c-c66535df5413.png)
    - [参考リンク](https://github.com/macropygia/static-site-stack/pull/3)
4. 手順3のPRをマージするとGitHub Actionsが自動でnpmjs.comにデプロイする
    - [参考画像](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/63934/1a1bcb5b-6ace-8021-929e-9438b714d78c.png)
5. デプロイに成功したパッケージのカバレッジデータがCodecovにアップロードされる
    - 手順5の参考画像の下の方

# 留意事項

Changeset Releace Actionの動作対象となるリモートブランチ（本稿では `main` ）に上記構成のWorkflowのYAMLがPushされた時点から、各パッケージがデプロイ可能な状態の場合には即座にデプロイされるようになる。

「パッケージがデプロイ可能な状態」とは以下の通り

- `.changeset` ディレクトリ内にCHANGELOG.mdの断片が存在しない
- 各種tokenが正しく設定されている
- リリース済のバージョンよりpackage.json上のバージョンが新しい
    - パッケージがnpmjs.com上に存在しない場合は無条件[^1]で「新しい」と判定される
- ビルドが通る

よって、リポジトリ立ち上げ直後や `package.json` を手作業で編集する場合は予期しないリリースが行われないよう注意が必要になる。

逆にこの仕様を利用して `$ pnpm changeset version` コマンドをローカルで実行する運用にすることも可能。

[^1]: 少なくとも `0.0.1` は対象になる（ `0.0.0` は未確認だが判定ロジックが存在しないため恐らくこちらも対象になる）

# 参考リンク

- [Use cache to reduce installation time - Setup pnpm · Actions · GitHub Marketplace](https://github.com/marketplace/actions/setup-pnpm#use-cache-to-reduce-installation-time)
- [Registry & Authentication Settings - .npmrc | pnpm](https://pnpm.io/npmrc#registry--authentication-settings)
- [Using changesets with pnpm - pnpm](https://pnpm.io/using-changesets)
- [Changesets Release Action - GitHub](https://github.com/changesets/action)