Yamamoto Zatsu

やまもとの雑記

CircleCI で npm publish を自動化した

f:id:yt_ymmt:20190116142807j:plain

ブランチをプッシュするとnpm上にタグ作ってpublishするところまで自動化したやつ。

背景

会社で書いたので備忘録としてここにポストする。 npm でモジュール管理するときにはsemverを使うことが一般的である。こんな感じ。

f:id:yt_ymmt:20190116143015p:plain

semver

www.atmarkit.co.jp

そして、開発段階にlatestバージョンをアップデートしてしまうとあらゆるところに影響が出てしまうため、開発中はprereleaseとしてバージョンを付与し、リリース時にlatestへマージしてバージョンを更新するという手はずになる。 バージョンは並べるとこんな感じ。

- 1.0.0 / latest
- 1.0.0-foo-rc.0 / foo
- 1.0.0-bar-rc.0 / bar

上の latest が stable となり、 foobar のタグが付いているのが開発用。 フロントエンドを例を出すと、 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 といったタグを npmpublish する場合、コマンドは下のようになる。

npm publish --tag foo

このコマンドを実行すると npm にタグ foo として、 publish することができる。このとき、バージョンは package.jsonversion で指定されたものが 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

f:id:yt_ymmt:20190116142325p:plain

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/**

f:id:yt_ymmt:20190116142331p:plain

開発中は一貫して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 はやはり自動化されているべきだと再認識できた。おわり。