⚙️

Nx monorepoでのnpmパッケージ管理とGitHub Actions

7 min read

はじめましてlacolacoです!この記事はClassi Advent Calendar 2020 12/5担当の記事です。

本稿ではNxとGitHub Actionsを使ったmonorepoのCI/CD構築について、誰かの役に立ちそうなTipsと、誰か助けてほしい失敗談を簡単にまとめます。

Nxを使ったnpm package monorepo管理

今回は複数のnpmパッケージの開発を1つのリポジトリで管理するためにNxを利用しています。
Lernaを使う選択肢もありましたが、管理したいものがAngularアプリケーション用のライブラリだったので、親和性が高いNxを採用しています。

# ワークスペースの作成
$ npx create-nx-workspace my-awesome-libs --preset oss --no-nx-cloud
$ cd libs-monorepo
# ライブラリプロジェクトの追加
$ yarn add -D @nrwl/angular
$ yarn nx generate @nrwl/angular:library --name=foo --publishable

このあたりは公式ドキュメントで事足りる話なので、実際の運用で工夫しているところを紹介します。

CHANGELOG.mdの管理

今回はそれぞれのライブラリが独立してバージョン管理、公開されるようにしています。(Lernaでいうところのindependentモード)
そのため、stardard-versionによる CHANGELOG.md の生成もライブラリごとに独立するようにしました。

.versionrc ファイルの配置

各プロジェクトのルートディレクトリ(README.mdと同じ階層)に、standard-version用の設定ファイルを作成します。
path は指定したディレクトリ内部のファイルが変更されたコミットだけをこのスコープの CHANGELOG.md に含めるための設定です。
tag-prefixはプロジェクト個別のCI/CDをするときに、ライブラリごとにリリースを区別するための重要な設定項目です。

packages/foo/.versionrc
{
  "path": ".",
  "tag-prefix": "foo-lib-v",
  "releaseCommitMessageFormat": "chore(release): foo-lib@{{currentTag}}"
}

nx release --project=foo の設定

このステップは他にもやりようがあると思いますが、standard-versionをNxのビルダー経由で実行できるように設定しています。
@nrwl/workspace:run-commands ビルダーは任意のコマンドをNx経由で実行できます。
npx standard-versionコマンドをpackages/fooディレクトリで実行することで、さきほど追加した .versionrcファイルに沿って CHANGELOG.md が生成されます。
下記のようにworkspace.jsonを設定すると、次のコマンドで CHANGELOG.md を生成できます。

$ yarn nx run foo:release --dry-run
# あるいは
$ yarn nx release foo --dry-run
workspace.json
{
  "version": 1,
  "projects": {
    "foo": {
      "projectType": "library",
      "root": "packages/foo",
      "sourceRoot": "packages/foo/src",
      "prefix": "foo",
      "architect": {
        //...
        "release": {
          "builder": "@nrwl/workspace:run-commands",
          "options": {
            "command": "npx standard-version",
            "cwd": "packages/foo",
            "parallels": false
          }
        }
      }
    }
  }
}

このようにすることで、ライブラリプロジェクト個別のタイミングで、任意のコミット時点でバージョニングできるようになりました。

GitHub Actionsでの自動publish

standard-versionのバージョニングで foo-lib@v1.0.0 のような形式のGitタグが付与されるようになったので、これをトリガーにしてGitHub Actionsでnpmへの自動publishをおこないます。

タグトリガーでのワークフロー定義

.github/workflows/publish-latest.yml と名付けたGitHub Actionsのワークフローファイルに、次のように記述します。
ワークフローのトリガーはpush/tagsを使い、.versionrcファイルで設定したtag-prefixと一致するようにタグ名のパターンを指定します。
複数パッケージになったらこのパターンを増やしていきます。

name: publish (latest)

on:
  push:
    tags:
      - foo-lib-v*
      - bar-lib-v*

jobs:
  publish-latest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2

どのタグでトリガーされたかを取得する

このアプローチだと、foo-libをリリースするときもbar-libをリリースするときも同じアクションが実行されるため、選択的にpublishするにはどのタグでトリガーされたかを取得する必要があります。
GitHub Actionsではタグトリガーで起動したワークフローの GITHUB_REF 環境変数はタグへの参照になっているため、ここから取得できます。
次のように::set-output機能を使って環境変数から得たタグ名を後続のジョブで利用できるようにします。

jobs:
  publish-latest:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v2
      # == region variables
      - run: echo "::set-output name=result::${GITHUB_REF/refs\/tags\//}"
        id: targetTag
      - run: echo "targetTag:${{steps.targetTag.outputs.result}}"
      # == endregion

if による選択的ビルド/publish

あとはタグ名によってどのライブラリプロジェクトをビルドしてpublishするかを制御するだけです。
GitHub Actionsにはジョブに if フィールドがあり、式の評価結果が true であるときだけ実行されます。falseの場合は次のジョブへ進みます。

ifでは組み込みの startsWith関数を使い、タグ名がライブラリプロジェクトごとのprefixから始まっているかをチェックしています。
ライブラリが増えるごとにymlファイルが伸びていく課題がありますが、無限に増えていくわけではないのと、この中に複雑なシェルスクリプトを直接書かなければそれほど複雑になることはないと判断しています。

      # scoped packageをpublishするために必要な設定。GitHub Actionsのドキュメントを参照。
      - uses: actions/setup-node@v1
        with:
          node-version: 12.x
          registry-url: 'https://registry.npmjs.org'
          scope: '@classi'
      - run: yarn install

      - name: publish foo
        if: ${{ startsWith(steps.targetTag.outputs.result, 'foo-lib-v') }}
        run: ./tools/scripts/publish-latest.sh "foo"
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
      - name: publish bar
        if: ${{ startsWith(steps.targetTag.outputs.result, 'bar-lib-v') }}
        run: ./tools/scripts/publish-latest.sh "bar"
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}

実際のpublish処理は次のような簡単なスクリプトです。念のためcan-npm-publishを使い、バージョン重複がないことを確認してからpublishしています。

tools/scripts/publish-latest.sh
#!/bin/bash -eux

project=$1

echo "Publishing ${project}"

yarn build ${project} --prod --with-deps
yarn can-npm-publish dist/packages/${project} && yarn --cwd dist/packages/${project} publish --access public

echo "Done"

うまくいかなかったこと: ブランチ制約

現在は上述のワークフローとほぼ同じものを運用していますが、本当はもうひとつやりたかったことがありました。
GitHub Actionsのpush/tagsトリガーでは、マッチするタグがコミットされればどのブランチに対するコミットでもトリガーされます。
これを「mainブランチへpushされたコミットにリリースタグが付いているとき」という複合条件にしたかったのですが、かなり格闘した末に諦めました。

途中までうまくいくと思っていたアプローチは、アクション内でgit branch --containsコマンドを使うものです。任意のコミットを含んでいるブランチ一覧を取得できるこのコマンドで、push/tagsでの GITHUB_SHAからブランチを調べてその中にmainが含まれればよい、という判定を試みました。

      - uses: actions/checkout@v2
      - run: git fetch origin main
      # == region variables
      - run: echo "::set-output name=result::$(git branch -r --contains ${GITHUB_SHA} --format "%(refname:lstrip=2)" | grep "main")"
        id: validBranch
      - run: echo "::set-output name=result::${GITHUB_REF/refs\/tags\//}"
        id: targetTag
      - run: echo "validBranch:${{steps.validBranch.outputs.result==true}}"
      - run: echo "targetTag:${{steps.targetTag.outputs.result}}"
      # == endregion

ローカルで実行すればうまくいくのですが、GitHub Actions上ではうまくいかず、どのブランチにも含まれていないコミットになっていました。
原因はおそらく actions/checkout@v2 がチェックアウトする際にHEADをデタッチしてしまうことだと思うのですが、 -rオプションを付けてリモートのブランチを調べさせてもダメなのでいよいよわかりません。

もし誰かうまくいく方法を知っていたら教えて下さい。

まとめ

  • Nxでライブラリmonorepoを作ってindependentモード風に管理している話
  • standard-versionを使ってライブラリ個別の CHANGELOG.mdを生成している話
  • GitHub Actionsを使ってライブラリごとに自動publishできるようにしている話
  • タグトリガーのアクション内でブランチ制約を付ける方法がわからない話

12/6はonigraさんが何か書くらしいです。お楽しみに!

Classi Advent Calendar 2020 - Qiita