CircleCI で npm publish を自動化した
ブランチをプッシュするとnpm上にタグ作ってpublishするところまで自動化したやつ。
背景
会社で書いたので備忘録としてここにポストする。 npm でモジュール管理するときにはsemverを使うことが一般的である。こんな感じ。
semver
そして、開発段階にlatestバージョンをアップデートしてしまうとあらゆるところに影響が出てしまうため、開発中はprereleaseとしてバージョンを付与し、リリース時にlatestへマージしてバージョンを更新するという手はずになる。 バージョンは並べるとこんな感じ。
- 1.0.0 / latest - 1.0.0-foo-rc.0 / foo - 1.0.0-bar-rc.0 / bar
上の latest
が stable となり、 foo
・ bar
のタグが付いているのが開発用。
フロントエンドを例を出すと、 yarn でパッケージをインストールするときはタグを使って指定する。
yarn add xxxxxx@latest -S // installed "1.0.0" yarn add xxxxxx@foo -S // installed "1.0.0-foo-rc.0" yarn add xxxxxx@bar -S // installed "1.0.0-bar-rc.0"
開発中バージョンの作成
ここでもフロントエンドを例に出すが、上に出てきた foo・bar といったタグを npm
に publish
する場合、コマンドは下のようになる。
npm publish --tag foo
このコマンドを実行すると npm にタグ foo
として、 publish
することができる。このとき、バージョンは package.json
の version
で指定されたものが publish
される。
問題
ここからが改修に至った経緯になる。
まず、今まで開発時の publish
は全て手作業で行っていた。作業フローとしては以下のようになる。
1. git checkout feature/** 2. package.jsonのバージョンを修正(手作業) 3. npm publish --tag xxxx(手作業)
そして、このような問題が発生していた。
- typo で不要なバージョンやタグが生成されてしまう恐れ - semver の認識がずれるとバージョンもずれる(暗黙知になりえる) - pacakge.jsonの修正がgitのdiffで上がってくる - そもそも手作業がめんどう
というわけで自動化してしまおう!となったのが経緯。
解決策
要件をまとめるとこうなる。
git push されたら CircleCI 上で npm publish まで実行されるようにする
作業しているブランチが master か feature/** によってコンテキストが異なるため、フローは少し変わる。
図にすると下のようになる。
master
master はローカルで npm version
を叩けるほうがいい。これは major | minor | patch
を分ける必要があるからだ。CIrcleCI ではバージョンで条件分岐を行い、 npm publish
が必要なときだけ実行するようにすればいい。
config.yml
masterのconfig.yml の設定はこんな感じ。
publish-prod: <<: *general_options steps: - attach_workspace: at: ~/repo - run: name: Install can-npm-publish command: sudo npm i -g can-npm-publish - run: name: Authenticate with registry command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc - deploy: name: Publish package command: | if can-npm-publish; then npm publish else echo "skipped" fi
can-npm-publish
をインストールして、 deploy
のstepsで使用した。バージョンが更新されていれば npm publish
が実行されるし、更新されていなければskipで何もせずに完了する処理になっている。
feature/**
開発中は一貫してrcバージョンをインクリメントすればよいため、 master とは違って git push
だけを行えばよい。npm view
でバージョンを取得し、pacakge.json
を書き換えてから npm publish —tag xxxxx
を実行する仕組みにした。
また、なんでもかんでもバージョンを自動生成されると困るため、動作するのは feature/** 内だけにする。これは後述の CircleCI の workflows で実現できる。
config.yml
publish-dev: <<: *general_options steps: - attach_workspace: at: ~/repo - run: name: Install ts-node command: sudo npm i -g ts-node typescript - run: name: Authenticate with registry command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc - deploy: name: Publish package command: ts-node ./scripts/override-dev-version.ts ${CIRCLE_BRANCH/\//--} && npm publish --tag ${CIRCLE_BRANCH/\//--}
package.json
を更新するスクリプトは override-dev-version.ts
とした。TypeScript なので ts-node
を事前にインストールし、実行している。
引数として渡している ${CIRCLE_BRANCH/\//--}
の部分、まず CIRCLE_BRANCH
はCircleCIが提供してくれる変数であり、ブランチ名が格納されている。これを ${CIRCLE_BRANCH/\//--}
のようにして /
を —
に置換処理を入れた。(例えば feature/foo
の場合、 feature—foo
に変換される。
override-dev-version.ts
package.json
の更新はこのスクリプトで行っている。
import { readFileSync, writeFileSync } from 'fs'; import { exec, execSync } from 'child_process'; import { inc } from 'semver'; const packageJson = JSON.parse( readFileSync('package.json', { encoding: 'utf8', }), ); const packageName = packageJson.name; const tag = process.argv[2]; exec(`npm view ${packageName}@${tag} version`, (err, version) => { if (err) { throw err; } let previousVersion = null; let nextVersion = null; if (version) { previousVersion = version.replace(/\n/, ''); nextVersion = inc(previousVersion, 'prerelease', false, `${tag}-rc`); } else { previousVersion = execSync(`npm view ${packageName} version`) .toString() .replace(/\n/, ''); nextVersion = `${previousVersion}-${tag}-rc.0`; } packageJson.version = nextVersion; writeFileSync('package.json', JSON.stringify(packageJson), 'utf8'); });
バージョンを生成して、package.json
を更新していることがわかる。semver
の inc関数が無駄な文字列置換を入れずに済むので、今回非常に便利だった。
config.yml
今回の master と feature/** に関わる部分を抜粋した config.yml
は以下のようになる。
version: 2 general_options: &general_options docker: - image: circleci/node:10.4.0 working_directory: ~/repo jobs: ... publish-prod: <<: *general_options steps: - attach_workspace: at: ~/repo - run: name: Install can-npm-publish command: sudo npm i -g can-npm-publish - run: name: Authenticate with registry command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc - deploy: name: Publish package command: | if can-npm-publish; then npm publish else echo "skipped" fi publish-dev: <<: *general_options steps: - attach_workspace: at: ~/repo - run: name: Install ts-node command: sudo npm i -g ts-node typescript - run: name: Authenticate with registry command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > ~/repo/.npmrc - deploy: name: Publish package command: ts-node ./scripts/override-dev-version.ts ${CIRCLE_BRANCH/\//--} && npm publish --tag ${CIRCLE_BRANCH/\//--} workflows: version: 2 check-and-publish: jobs: ... - publish-prod: filters: branches: only: master - publish-dev: filters: branches: only: /feature\/.*/
workflows のところでbranches の filters にブランチ名を指定することで、該当しないブランチでjob自体を実行しないようにできる。
結果
これらをデプロイすることで、git push をトリガーにして自動的にjobが実行され、以下のようにリスクを回避することができた。
- 手打ちによる typo のリスク回避
- バージョンのルールが共有不要
- 手作業不要によるストレス低減
で、実際に使ってみたら思ったより便利。npm publish はやはり自動化されているべきだと再認識できた。おわり。