Renovateのようにnpmパッケージを更新するためのツールをつくった話

Renovateのようにnpmパッケージを更新するためのツールを開発して使っています。

https://github.com/npm-update-package/npm-update-package

ここでは、開発に至った経緯、開発したツール、利用に関する工夫、今後の展望について記します。

## 開発に至った経緯

まず、私がRenovateのようなツールを開発するに至った経緯について説明しましょう。

### 依存ライブラリの更新に関する課題

2021年4月にヤフーに入社して以来、依存ライブラリの更新について課題を感じていました。
特にフロントエンド界隈は潮流が速く、アクティブにメンテナンスされているライブラリは、日々新たなバージョンがリリースされ続けています。
ライブラリのバージョンが古いことに起因する問題に遭遇したときや脆弱性が検知されたときなど、必要に迫られてから更新するのでは遅れをとってしまいます。
いざ更新するとなったときにはバージョンがいくつも上がっていて、影響調査やコードの修正に多くの時間を要するような事態になりかねません。
かといって、人間が手作業でせっせと更新していくのは、それはそれでつらいものがあります。
ここは、[プログラマーの三大美徳](https://moneyforward.com/engineers_blog/2016/02/08/engineers-virtue/)にのっとって自動化したいところです。

### 「無ければ作る」の精神

OSS界隈では、この手のライブラリの更新はRenovateやDependabotなどのサービスを使って自動化するのがセオリーとなっています。
とりわけRenovateは人気が高く、東京都 新型コロナウイルス感染症対策サイトのリポジトリでも使われています。

https://qiita.com/munieru_jp/items/3b1edceadb3990a901c8

私自身GitHubではよく利用していることもあり、まずはRenovateの導入を検討しました。
ところが、RenovateやDependabotは種々の事情により社内では使用できないとのことでした。
他にもいくつかのツールの利用を検討しましたが、メンテナンスが滞っていたり、つくられるプルリクエストの仕様が微妙だったりと、残念ながら採用に至るようなものはありませんでした。
そういうわけで、私がエンジニアとして大事にしている「[無ければ作る](https://dic.nicovideo.jp/a/%E7%84%A1%E3%81%91%E3%82%8C%E3%81%B0%E4%BD%9C%E3%82%8B)」の精神のもと、Renovateのようなツールをつくることにしたのです。

## 開発したツール

前述の経緯を受け、[**npm-update-package**](https://github.com/npm-update-package/npm-update-package)というツールを開発しました。

https://github.com/npm-update-package/npm-update-package

npm-update-packageは、npmパッケージを更新するプルリクエストを作成するためのCLIツールです。
MIT LicenseでOSSとして提供されています。

### 使い方

npm-update-packageのもっともシンプルな使い方は、次のコマンドを実行するだけです。

```sh
npx npm-update-package --github-token <github-token>
```

これをCI/CDツール上で定期的に実行することで、Renovateのようにプルリクエストが自動的につくられるようになります。
たとえば、GitHub Actions上でGitHub Appのトークンを使う場合の設定ファイルは次のようになります。

```yaml
name: npm-update-package
on:
  schedule:
    - cron: '0 0 * * *'
jobs:
  npm-update-package:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      - uses: actions/setup-node@v2
      - name: Generate token
        id: generate_token
        uses: tibdex/github-app-token@v1
        with:
          app_id: ${{ secrets.APP_ID }}
          private_key: ${{ secrets.PRIVATE_KEY }}
      - run: |
          npx npm-update-package \
            --github-token $GITHUB_TOKEN \
            --git-user-name npm-update-package[bot] \
            --git-user-email 97396142+npm-update-package[bot]@users.noreply.github.com
        env:
          GITHUB_TOKEN: ${{ steps.generate_token.outputs.token }}
```

npm-update-packageは単なるCLIツールなので、GitHub Actionsに限らず任意のCI/CDツール上で動かすことができます。
たとえば、米Yahooが開発しヤフーも開発に携わっている[Screwdriver](https://screwdriver.cd/)においても問題なく動作します。[^1]

### プルリクエストの仕様

npm-update-packageによってつくられるプルリクエストは、次のようなものです。

https://github.com/npm-update-package/npm-update-package/pull/1009

![プルリクエストのスクリーンショット](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132608/6dc5b6f7-73f1-ce91-91bb-e19d7daf145f.png)

プルリクエストの本文には、次のような情報が記載されています。

- パッケージ名
- 依存の種類
    - `dependencies`
    - `devDependencies`
    - `peerDependencies`
    - `bundledDependencies`
    - `optionalDependencies`
- レベル
    - `major`
    - `minor`
    - `patch`
- 更新前のバージョン
- 更新後のバージョン
- パッケージの差分へのリンク
- リリースノートへのリンク
- メタデータ
- 使用されたnpm-update-packageのバージョン

Renovateを参考にしつつ、依存の種類やレベルを明記するなど、更新内容が一目でわかりやすいような工夫を施しています。
ただし、Renovateのようにリリースノートを埋め込むとなるとMarkdownまわりで泥臭い処理が必要になってくるので、リンクを載せるのみにとどめています。

### オプション

npm-update-packageは標準設定でもそれなりに使えるようにデザインされていますが、必要に応じてCLIオプションで挙動をカスタマイズすることもできます。
v1.5.0時点で20個のオプションがありますが、その中からいくつかピックアップしてご紹介しましょう。

- `--assignees`

プルリクエストに割り当てるアサイニーを指定します。

```sh
npx npm-update-package \
  --github-token <github-token> \
  --assignees alice bob
```

- `--assignees-sample-size`

プルリクエストに割り当てるアサイニーの人数を指定します。
割り当てられるアサイニーは、`--assignees`オプションで指定した中からランダムで選ばれます。

```sh
npx npm-update-package \
  --github-token <github-token> \
  --assignees alice bob \
  --assignees-sample-size 1
```

- `--additional-labels`

プルリクエストに追加するラベルを指定します。
プロジェクト内でBOTによるプルリクエストや依存パッケージに関するプルリクエストに対して付与すべきラベルが決められている場合などに使えます。

```sh
npx npm-update-package \
  --github-token <github-token> \
  --additional-labels bot dependencies
```

- `--commit-message`

コミットメッセージを指定します。
プロジェクト内でコミットメッセージのフォーマットが定められている場合などに使えます。
Mustache記法によって変数を埋め込むことができるので、ある程度柔軟に設定できます。

```sh
npx npm-update-package \
  --github-token <github-token> \
  --commit-message "chore({{{dependencyType}}}): {{{level}}} update {{{packageName}}} from {{{currentVersion}}} to v{{{newVersion}}}"
```

- `--dependency-types`

更新する依存の種類を指定します。
デフォルトではすべてが対象ですが、特定の種類の依存のみを対象とすることができます。

```sh
npx npm-update-package \
  --github-token <github-token> \
  --dependency-types dependencies devDependencies
```

- `--draft-pr`

プルリクエストをドラフトとして作成するかどうかを指定します。
人間が目視確認して初めてドラフトを解除（レビュー開始）するような運用にする場合に使えます。

```sh
npx npm-update-package \
  --github-token <github-token> \
  --draft-pr true
```

- `--fetch-release-notes`

リリースノートを取得するかどうかを指定します。
デフォルトでは有効ですが、リリースノートの取得にはHTTPリクエストやスリープなどの処理をともなうので、無効化すれば実行時間の短縮が期待できます。

```sh
npx npm-update-package \
  --github-token <github-token> \
  --fetch-release-notes false
```

- `--ignore-packages`

更新対象から除外するパッケージを指定します。
あえて古いバージョンに留まっておきたいパッケージがある場合などに使えます。

```sh
npx npm-update-package \
  --github-token <github-token> \
  --ignore-packages @types/jest jest
```

- `--outdated-pr-strategy`

実行時に同じパッケージに対する古いバージョンのプルリクエストが存在する場合、どのように処理するのかを指定します。
指定できる値は、`create`（新たなプルリクエストを作成）、`recreate`（古いプルリクエストをクローズし新たなプルリクエストを作成）、`skip`（プルリクエストの作成をスキップ）のいずれかです。

```sh
npx npm-update-package \
  --github-token <github-token> \
  --outdated-pr-strategy create
```

- `--pr-body-notes`

プルリクエストの本文に追加するメモを指定します。
レビュアーに向けた注意文や、npm-update-packageの運用方針をまとめたドキュメントへのリンクを載せるなどの使い方ができます。

```sh
npx npm-update-package \
  --github-token <github-token> \
  --pr-body-notes "**:warning: Please see diff and release notes before merging.**"
```

### アーキテクチャー

npm-update-packageのアーキテクチャーは非常にシンプルです。
以下に、処理のフローを示します。

```plantuml
@startuml
start
group bin
:Initialize options;
group main
:Get outdated packages;

if (All packages are up-to-date) then (yes)
  end
else (no)
endif

:Fetch remote branches;
:Fetch pull requests;

if (Label does not exist) then (yes)
  :Create label;
else (no)
endif

:Initialize Git config;
group OutdatedPackagesProcessor

while (Package exists) is (yes)
  group OutdatedPackageProcessor
  if (Remote branch does not exist) then (yes)
    switch (outdatedPrStrategy)
    case (create)
      :Create branch;
      :Update package;
      :Create pull request;
      :Remove branch;
    case (recreate) 
      :Create branch;
      :Update package;
      :Create pull request;
      :Close outdated pull requests;
      :Remove branch;
    case (skip)
      if (Outdated pull requests don't exist) then (yes)
        :Create branch;
        :Update package;
        :Create pull request;
        :Remove branch;
      else (no)
      endif
    endswitch
  else (no)
  endif
  end group
endwhile (no)

end group  /' OutdatedPackagesProcessor '/
:Show results;
end group  /' main '/
end group  /' bin '/
end
@enduml
```

出典：https://github.com/npm-update-package/npm-update-package/blob/v1.5.0/README.md

オプションによって処理が分岐するので一見複雑に見えますが、基本的には人間が手動でやるのと同じような手順を踏んでいます。
パッケージに対する一連の処理はファイル操作をともなうので、並列化することはできません。
そのため、更新されるパッケージの数が多ければ多いほど実行時間が長くなるのが難点ではあります。
とはいえ、開発者が活動していない時間帯に実行されるようにしておけば、実用上は問題ないでしょう。

## 利用するうえでの工夫

ここからは、実際にnpm-update-packageを使ううえでおこなった工夫について記します。

### プルリクエストの分担

プルリクエストの対応をメンバー間で分担するために、ランダムで1人のアサイニーが割り当てられるようにしました。
アサイニーの役割は、割り当てられたプルリクエストを見てマージできればApproveをつけ、できなければその理由をコメントするといったものです。
プルリクエストがマージできるかどうかの判断基準について共通認識を持つために、次のような判定テーブルを用意しました。

![判定テーブル](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/132608/d696b45d-90ca-7e53-bac9-c4a20a4d7b1a.png)

これは、私が普段Renovateによるプルリクエストをさばく際の基準を簡略化したものです。
基本的には[Semantic Versioning](https://semver.org/lang/ja/)に準拠している前提で、メジャーアップデートであれば破壊的変更が入りうるものとしています。
ただし、メジャーバージョンが0のパッケージやそもそもSemantic Versioningに準拠していないパッケージについては、マイナーアップデートであろうが破壊的変更が入りうるものとして扱っています。
また、型定義パッケージはなるべく本体のパッケージとバージョンを合わせるようにしています。

https://qiita.com/munieru_jp/items/f2fec5580f53044a8496

### プルリクエストの仕分けツールを開発

npm-update-packageを導入してからしばらくの間は、上記の判定テーブルをもとに各自がプルリクエストを分類し、しかるべき対応をおこなっていました。
しかしながら、プルリクエストの種類はある程度機械的に判定可能であるにもかかわらず、人間が分類するとなるとそれなりの労力を要します。
そこで、npm-update-packageによるプルリクエストを仕分けるためのCLIツールを別途開発しました。
そのツールはプルリクエストをトリガーに実行され、その内容に応じて自動的にクローズしたり、アサイニーがどのように対応すればいいのかメンションつきでコメントしたりするものです。
こちらは社内、とりわけ所属チームでの利用に特化したものなので、社外には公開していません。

## 今後の展望

npm-update-packageには、まだまだ欲しいけど実装されていない機能がたくさんあります。

- [設定ファイルのサポート](https://github.com/npm-update-package/npm-update-package/issues/18)
- [型定義パッケージのグルーピング](https://github.com/npm-update-package/npm-update-package/issues/51)
- [バージョンの固定](https://github.com/npm-update-package/npm-update-package/issues/295)
- [パッケージのグルーピング](https://github.com/npm-update-package/npm-update-package/issues/366)
- [`--assignees-routing-algorithm`オプション](https://github.com/npm-update-package/npm-update-package/issues/515)
- [`--reviewers-routing-algorithm`オプション](https://github.com/npm-update-package/npm-update-package/issues/516)
- [`--outdated-pr-strategy`オプションに`update`を追加](https://github.com/npm-update-package/npm-update-package/issues/532)
- [リベース/リトライ用のチェックボックス](https://github.com/npm-update-package/npm-update-package/issues/582)
- [モノレポのサポート](https://github.com/npm-update-package/npm-update-package/issues/871)

これらのタスクの進捗は、GitHub Projectsで管理されています。

https://github.com/orgs/npm-update-package/projects/1

特に設定ファイルのサポートは、今後CLIオプションでは扱いづらいような複雑なオプションを提供するためにも、ぜひとも対応しておきたいところです。
長期的な目標としては、「Renovateが使えないから仕方なく使う」のではなく、「Renovateより使い勝手がいいから使う」と思えるような存在を目指しています。

## おわりに

もし私と同じような課題を抱えている人がいれば、npm-update-packageが解決のための一助となりうるかもしれません。
もちろん、Renovateは使えるけどあえてnpm-update-packageを使うという選択もアリです。
npm-update-packageはOSSなので、バグ報告や機能提案など、みなさまのコントリビューションをお待ちしています。

[^1]: [CI/CDを通じて日本のソフトウェア開発を変えていくために今何が必要か？|CodeZine（コードジン）](https://codezine.jp/article/detail/11674)