PLAID Engineer Blog

PLAID Engineer Blog


KARTEを提供する株式会社プレイドのエンジニアブログです。プレイドのエンジニアのユニークなパーソナリティを知ってもらうため、エンジニアメンバーたちが各々執筆しています。

npmのprivate registryからGitHub Packages Registryに移行する

Security TeamSecurity Team

PLAIDでは社内のNode.jsパッケージの管理にnpm private registry(npmのregistryにprivateでパッケージをpublishする形式)を利用していました。
また、npm private registryを導入する以前の古いパッケージは、GitHubのプライベートリポジトリのURLをpackage.jsonに指定しているパッケージも混在していました。

その中で、2019年5月にGitHub Packages Registryがベータ公開されました。

GitHubアカウントとの統合性、GitHubリポジトリとGitHub Actionsでの連携を考えて、
社内パッケージをGitHub Packages Registryへ移行することにしました。

移行中には、npm private registryGitHub Packages Registryで同じscopeが同時に利用できない問題が見つかりました。(詳細は後述します)
そのため、最終的にはnpm private registryで管理していたパッケージもすべてGitHub Packages Registryへ移行することになりました。

この記事では、次の2種類のパッケージをそれぞれGitHub Packages Registryに移行するまでの流れを見ていきます。

この移行が終わったタイミングで、GitHubがnpmを買収することが発表され、将来的にnpm private registryはGitHub Packages Registryへと統合される予定です。

この移行の手順はこの発表以前の取り組みなので手作業な部分がありますが、今後はもっと楽に移行ができるようになるといいですね。

目的

目的としては次の2つのことがメインとなります。

GitHubトークン(x-oauth-basicでのベーシック認証)を使ったGitHubプライベートリポジトリの読み込みをやめる

npmでは、package.jsonにGitHubトークンを使ったベーシック認証(x-oauth-basic)を利用することで、GitHubのプライベートリポジトリをパッケージとして参照できます。

package.json:

  "dependencies": {
    "package-name": "git+https://<github_token>:x-oauth-basic@github.com/<user>/<repo>.git"
  }

しかし、この方法はpackage.jsonpackage-lock.jsonにリポジトリを読み書きできるGitHubトークンが記載されるためあまり良くない方法です。
ここで利用するGitHubトークンにはrepo権限が必要ですが、repo権限にはWriteも含まれます。
そのため、パッケージを参照するだけのトークンとしてGitHubトークンを利用するには過剰な権限が付与されてしまいます。

GitHub Packages RegistryからのインストールにもGitHubトークンが必要ですが、read:packagesのようにRead Onlyの権限が分離されています。

またこのリポジトリを使った方法は、リポジトリにビルド済みファイルもコミットしないといけないため、あまり扱いやすくはありません。

これらのgit+https://で参照するパッケージを通常のnpmパッケージとして参照できるようにするため、
GitHub Packages Registryへ移行するのが目的の1つです。

npmのアカウントとGitHubのアカウントが分断されているのをGitHubに統合する

一部のパッケージはnpm private registryにpublishして利用していました。

npm private registryにはnpmのアカウントまたはnpmのトークンが必要になります。
社員はGitHubアカウントを持っていますが、npmのアカウントを持っていない人もいます。
(参照はread onlyのnpm tokenを利用する形をとっていました)

そのため、npm private registryを使うとnpmとGitHubアカウントの2重管理が必要になる問題があります。
これを解決するためにSSOがあるnpm | EnterpriseJFrog Artifactoryを検討していました。

しかし、GitHubアカウントで扱えるGitHub Packages Registryが公開されました。
GitHub Packages Registryに寄せることでこの問題を解決するのも目的の1つです。

次の文章は社内のnpm registryとGitHub Package Registryを違いを簡単に調べたドキュメントからの引用です。

## npm registryとGitHub Package Registryの違い

npm registryとは異なる部分として、パッケージを公開/インストールといった権限管理が、
GitHubのアカウントに紐付いて管理できます。
npm registryはnpmアカウントでのチーム管理が必要となり、GitHubアカウントと二重管理が発生します。

また、npm registryはユーザー単位の課金に対して、GitHub Package Registryは転送量などの従量課金です。
(GitHubアカウント分の値段は別で必要なので、追加の部分が従量課金という意味)
そのため、GitHubアカウントを持っていてOrganizationに参加していれば、アカウントの制約はありません。

- npm registry: https://www.npmjs.com/products
- GitHub Package Registry: https://github.com/features/packages#pricing

また、GitHub Package Registryはnpm以外にもDockerやMavenなどにも対応しています。

npmのメリット

- デフォルトのregistryなので設定不要

npmのデメリット

- GitHubアカウントとは別にnpmアカウントの管理が必要
- CIからパッケージを公開するためのトークンを安全に管理するのが難しい
	- [Wombat Dressing Room](https://github.com/GoogleCloudPlatform/wombat-dressing-room)のような仕組みがないと共有トークンがひつようとなってしまいがち

GitHub Package Registryのメリット

- GitHubアカウントに紐づく管理ができる
- GitHub Actionsではリポジトリに紐づく使い捨てのアクセストークンが発行できる
	- CI(GitHub Actions)からのpublishするフローがとれるため、人に依存しないパッケージ管理がしやすい
- npmパッケージ以外にも対応している

GitHub Package Registryのデメリット

- npm registryとは異なりデフォルトではない
- `npm`コマンド(クライアント)がnpm registryのみを想定しているため、意味不明なエラーメッセージに遭遇することがある

2020-03-18現在では、npmはGitHubに買収されたため、あえてnpm private registryを選ぶ理由は少なそうです。

移行の事前準備

まず最初にこの移行についてまとめるMeta Issueを社内のリポジトリに作成しました。

作成したMeta Issueのイメージ

このような複数のリポジトリ、複数のチームにまたぐような問題はMeta Issueを作成してすすめるのがやりやすいように思えます。

移行対象の抽出

次に、移行対象となるパッケージ/リポジトリの一覧を作成しました。

詳細な手順は汎用性がないため省きますが、主に次のことをして移行対象のパッケージをまとめました。

この下処理の分析作業は、分析用のリポジトリを使ってそこに色々なツールを書いていく形で進めました。
一覧自体はSpreadSheetに作っておき移行済みかを管理できるようにしてすすめました。

一覧化したSpreadSheet

次のスライドが近いことをやっています。

移行の戦略

移行対象がわかったので、次に移行の戦略を立てる必要がありました。

ライブラリ自体は複数のリポジトリがありますが、KARTEではメインの開発リポジトリがmonorepoです。
そのため、利用側のリポジトリはmonorepoのみに注目しておけばよかったので、次の2段階で移行することにしました。


Note: npmとGitHubの同名スコープ衝突の問題

最初はnpmの@plaidevとGitHub Package Registryの@plaidevは並行して利用する予定でした。
しかし、GitHub Packages Registryを使うと指定したスコープはnpm or GitHubどちらかしか参照できない問題があります。
つまり、npmに @plaidev で公開してるパッケージ(OSSも含む)とGitHub Package Registryの@plaidevのパッケージは同時には参照できないという問題があります。

たとえば、次のような.npmrcを設定した場合に、npm registryの @plaidev パッケージは参照できなくなります。
ただし、それ以外のスコープがついていないパッケージやその他の@scope などのスコープのパッケージは参照できます。

.npmrc:

registry="https://npm.pkg.github.com/plaidev"

そのため、次の2つのパッケージはどちらか片方のregistryしか参照できません。

2020-03-18時点では、GitHub Package Registryはnpm registryに対してProxy的な挙動はしてくれません。
つまり@scope/pkgname というパッケージがGitHub Package Registryに存在しない時、npm registryに対して@scope/pkgnameを探索してくれません。

VerdaccioにはUplinksという機能がありますが、
GitHub Package Registryにはこの機能がないため、npmとGitHubどちらかに集中管理する必要があります。

npmとGitHubの同名スコープ衝突の問題の解決策

最終的には、次のような形でこの問題を回避することにしました。

npm is joining GitHubではnpm private registryとGitHubの統合が発表されているため、将来はもっとうまく解決できることを期待します。


[ライブラリ側の移行手順] GitHub Action で GitHub Package Registryに登録する

移行対象のライブラリは、まだパッケージとしてpublishされていないGitHubリポジトリと既にnpm private registryにpublish済みのパッケージの2種類があります。
どちらもGitHub Package Registryにpublishしなおすだけなので、基本的な手順は変わりません。

注意点として、GitHub Package RegistryはOrganizationと同じscopeのパッケージしかpublishできません。
そのため、package.jsonnameがScope(@example/*)を含んでいない場合はパッケージ名の変更も必要です。

GitHub Package Registryにパッケージをpublishする場合にはGitHub Actionsが便利です。
なぜならGitHub Actionsは、GITHUB_TOKENというリポジトリに紐づく特殊なトークンが実行ごとに発行されます。
このGITHUB_TOKENは、GitHub Packages Registryにpublishできるwrite:packagesの権限が含まれています。

そのため、GitHub ActionsからGitHub Package Registryへpublishを行えば、
手元にGitHubトークンがなくてもパッケージをpublishできます。

ライブラリのGitHub Package Registryへの移行に合わせて、GitHub Actionsからpublishの仕組みも作ることにしました。
具体的には、次のような手順でGitHub Actions(CI)からパッケージをpublishしています。

次の順番で説明していきます。

CI(GitHub Actions)からpublishする設定

CI(GitHub Actions)からpublishするには、いくつかパッケージの設定とGitHub Actionsの設定が必要です。
次のようにGitHub Actionで GitHub Package Registry への publishフローを追加できます。

1. package.json<name>@scope/<name>に変更

パッケージ名が @scope のスコープになってない場合は変更が必要です。
GitHub Package Registryはスコープ(@scope/name形式)のパッケージのみpublishできます。

例) example-one

- "name": "example-one",
+ "name": "@scope/example-one",

2. package.jsonpublishConfigを追加する

package.jsonにpublish先の情報を追加します。
publishConfigには次のようにaccess: restricted(private的な意味)、registryにはGitHub Package Registryを指定します。

  "publishConfig": {
    "access": "restricted",
    "registry": "https://npm.pkg.github.com/"
  }

3. GitHub Action(publish.yml)を追加する

CIからpublishするGitHub Actionのワークフローの定義ファイルを追加します。

.github/workflows/publish.yml というファイルを追加します。

name: publish
env:
  CI: true
# masterブランチにpushした時のみ実行するワークフロー
on:
  push:
    branches:
      - master
    tags:
      - "!*"

jobs:
  release:
    name: Setup
    runs-on: ubuntu-latest
    steps:
      - name: checkout
        uses: actions/checkout@v1
      - name: setup Node
        uses: actions/setup-node@v1
        with:
          node-version: 12.x
          registry-url: 'https://npm.pkg.github.com'
      - name: install
        run: npm install
      - name: build
        run: npm run build
      # まだpublishされていないバージョンならpublishする
      - name: publish
        run: |
          npx can-npm-publish --verbose && npm publish || echo "Does not publish"
        env:
          NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      # まだtagがないバージョンなら、Git Tagをpushする
      - name: package-version-to-git-tag
        uses: pkgdeps/action-package-version-to-git-tag@v1
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          github_repo: ${{ github.repository }}
          git_commit_sha: ${{ github.sha }}
          git_tag_prefix: "v"

これ設定で、package.jsonversionを更新すると、自動的にGitHub ActionsがGitHub Package Registryにパッケージをpublishしてくれます。

Notes: この手順は1リポジトリ1パッケージの移行手順です。
monorepoのライブラリについては別の方法を取る必要があります。

また、GitHub Packages Registryもmonorepoに対応していますが、各パッケージのpackage.jsonにはrepository.directoryが必須です。
このフィールドを指定し忘れると全く関係ないように見えるエラーで失敗します。

Not Found - PUT https://npm.pkg.github.com/@scope/foo - The expected resource was not found.

詳細はGitHub Packagesのドキュメントに書かれています。

CI設定済みパッケージのバージョン更新の流れ

"CI(GitHub Actions)からpublishする設定"がされたリポジトリでは、
次のような流れでパッケージを公開/更新できます。

  1. [ローカル] パッケージのバージョンの更新: npm version {patch,minor,major}
  2. [ローカル] リポジトリに変更をpush: git push
  3. [CI] パッケージのpublishとgit tagのpush

1. [ローカル] パッケージバージョンの更新

npm-versionというnpmのコマンドを使うことで、package.jsonversionの更新が セマンティックバージョンで行えます。

それぞれアップデートしたいコマンドを叩くだけで、アップデートとコミットを一括で行います。

# patch アップデート (x.y.Z) バグ修正 0.0.1あがる
npm version patch
# minor アップデート (x.Y.z) 機能追加 0.1.0あがる
npm version minor
# major アップデート (X.y.z) 破壊的変更 1.0.0あがる
npm version major

2. [ローカル] リポジトリに変更をpush

GitHub ActionでのCIが走るブランチ(publish.ymlon:の項目、デフォルトはmaster)に変更をpushしてください。

git push

3. [CI] パッケージのpublishとgit tagの追加

GitHub Actionでは次のことを行います。

  1. 該当バージョンがまだ公開されていないなら npm publish する
  2. 該当バージョンのtagがGitHubリポジトリに貼られていないならgit tag {version}git push --tags

つまり、package.jsonversionにあわせて、パッケージの公開とgit tagの設定します。
人間がやるのは package.jsonversion の更新までにすることで、GitHub Tokenの設定せずにパッケージの公開ができます。
そのため、Pull Requestをマージするだけでパッケージの公開までできます。

package.jsonversionが更新されていなければ、何もせずにCIは終了します。

Note: 基本的にはCIからpublishしますが、ローカルからでも各自のGitHubアカウントに紐づくトークンを使うことで手動のpublishも可能です。(βリリースをしたい場合など)

GitHub Package Registryにpublish済みパッケージの互換チェック

既存のライブラリは手動でnpm private registryにpublishされたものやそもそもただのGitHubリポジトリです。
そのため、GitHub Package RegistryにGitHub Actionsからpublishされたことでファイルが欠損したり、ビルド結果が変わってないかの互換性を確認する必要があります。

そのため、npmが内部的にパッケージを取得する際に利用するpacoteで変更前と変更後のパッケージをtarファイルとして取得し、実際に中身を比較するツールを書いてチェックしました。

依存するライブラリとtarファイルのdiffチェックにPkgDiffを使っています。

$ npm install execa pacote
$ brew install pkgdiff

check.js:

const fs = require('fs');
const path = require('path');
const pacote = require('pacote');
const execa = require('execa');
// output tar files
const tarDir = path.join(__dirname, 'tar')
const getName = (item) => {
  return typeof item === 'object' ? item.name : item
}
const getRegistry = (item) => {
  return typeof item === 'object' ? item.registry : undefined
}
const getToken = (item) => {
  return typeof item === 'object' ? item.token : undefined
}
/**
 * 比較対象
 * - name: 任意の名前
 * - before: 移行前のパッケージ
 * - after: 移行後のパッケージ
 **/
const list = [
  // git+https から GitHub Package Registryに移行したパッケージ
  {
    name: 'example-one',
    before: 'git+https://<GITHUb_TOKEN>:x-oauth-basic@github.com/scope/example-one.git#v1.0.0',
    after: {
      name: '@scope/example-one',
      registry: 'https://npm.pkg.github.com/scope',
      // package:read権限のGitHub Package Registry Token
      token: 'GITHUB_YYYYYYYYYYYYYYYYYYYYYYYYYYYY'
    }
  },
  // npm private registry から GitHub Package Registryに移行したパッケージ
  {
    name: 'example-two',
    before: {
      name: '@scope/example-two',
      registry: 'https://registry.npmjs.org',
      // npmのトークン
      token: 'NPM_TOKEN_XXXXXXXXXXXXXXXXXXXXXX'
    },
    after: {
      name: '@scope/example-two',
      registry: 'https://npm.pkg.github.com/scope',
      // package:read権限のGitHub Package Registry Token
      token: 'GITHUB_YYYYYYYYYYYYYYYYYYYYYYYYYYYY'
    }
  }
];

/**
 * list.jsに定義したパッケージの差分をチェックするスクリプト
 * git:// と GitHub Package npm の差分を見る
 */
async function main() {
  fs.mkdirSync(tarDir);

  for (const item of list) {
    const beforeFilePath = path.join(tarDir, `${item.name}-before.tar.gz`)
    const afterFilePath = path.join(tarDir, `${item.name}-after.tar.gz`)
    await pacote.tarball.file(getName(item.before), beforeFilePath, {
      registry: getRegistry(item.before),
      token: getToken(item.before)
    })
    console.info('before fetched', beforeFilePath)
    await pacote.tarball.file(getName(item.after), afterFilePath, {
      registry: getRegistry(item.after),
      token: getToken(item.after)
    })
    console.info('after fetched', afterFilePath)
    // require: https://github.com/lvc/pkgdiff
    try {
      const { stdout, stderr } = await execa('pkgdiff', [beforeFilePath, afterFilePath])
      if (stderr) {
        console.error(stderr)
      }
      console.log(stdout)
    } catch (error) {
      console.error(error.message)
    }
  }
}

main().catch(error => {
  console.error(error)
  process.exit(1)
});

このcheck.jsを実行すると移行前と移行後のパッケージをnpmと同じようにtarファイルとしてダウンロードし、
tarファイルの中身をPkgDiffで比較したレポートが取得できます。

PkgDiffのレポート例

PkgDiffのレポートでは、ファイルのDiffなども見られるので、結果を見て意図しない変更ないことを確認しました。

[ライブラリ利用側の移行の手順] GitHub Package Registryからインストールするためのnpmrc設定とコード修正

移行対象のライブラリがすべてGitHub Package Registryにpublishできたら、利用する側で.npmrcの設定をして新しいパッケージを使うようにするだけです。

パッケージの中身が同一のものとして確認が取れていれば、具体的に利用側で行う変更は次の2つです。

.npmrcの設定で @scope が GitHub Package Registry を参照するように変更する

こちらはとても単純にread:packagesの権限のみを付加した共有のGitHubトークンを発行し.npmrcに次のように記述するだけです。

.npmrc:

//npm.pkg.github.com/:_authToken=<GITHUB_TOKEN_read:packagesの権限のみ>
@scope:registry="https://npm.pkg.github.com"

この.npmrcを配置すると npm install @scope/foo は自動的にGitHub Package Registryを参照するようになります。

📝 ここでは移行をスムーズに行うために共有トークンを利用しましたが、最終的に各自のGitHubアカウントに紐づくトークンを利用できるようにするのが理想的です。(リポジトリにGitHubトークンをコミットしなくて済む状態になるのが理想的です)

📝 npmrc設定後の細かいハマりどころ

パッケージのバージョンが同じままnpm private registryからGitHub Package Registryに変更すると、
EINTEGRITYエラーでインストールが失敗します。

npm ERR! code EINTEGRITY
npm ERR! Verification failed while extracting @scope/name@1.0.0:
npm ERR! Verification failed while extracting @scope/name@1.0.0:
npm ERR! sha512-AaWB1bN1Gx+XXXXXXX== integrity checksum failed when using sha512: wanted sha512-AaWB1bN1GxYYYYYY== but got sha512-YYYYYYY==. (12345 bytes)

これはpackage-lock.jsonのINTEGRITYチェックが正しく働いていて、
パッケージ名@バージョンが同じなのに中身が異なるためチェックサムで失敗しています。

バージョンを更新するか同じバージョンとしてインストールし直すことでpackage-lock.jsonを更新できます。

次のコマンドでpackage.jsonに書かれたのと同じバージョンで@scope/nameをインストールし直すことができます。
インストールし直すとpackage-lock.jsonが更新されるため、この問題を回避できます。

npm i -S -E @scope/name

require("xxx")require("@scope/xxxx") に変更する

既にscoped moduleのパッケージ移行ならコードは変更する必要がありません。
ただし、元々のパッケージがscoped moduleじゃないもの(ここではgit+httpsで参照していたもの)は、コード側もrequireimport先を変更する必要がありました。

単純に置換すればいいのですが、Node.jsのコードではrequire(moduleName)のような変数を使ったrequireも可能です。
そのため、単純な静的置換だともれてしまう場合があります。

ここでは2つの工程で抜け漏れを防ぎました。

Dependency cruiserを使った静的なチェック

静的なチェックならDependency cruiserを使うのが確実です。

Dependency cruiserでは次のようなルールを書けばrequire("pkgname")しているコードを静的にチェックできます。

module.exports = {
  forbidden: [
    {
      "name": "not-to-use-pkgname",
      "comment": "don't allow to use pkgname",
      "severity": "error",
      "from": {},
      "to": { "path": "^pkgname" }
    }
  ]
}

静的なチェックだけだと漏れが存在する可能性もあるため、動的なチェックも含めることにしました。

Fakeパッケージを使った動的なチェック

動的なチェックとは require("pkgname") のように pkgname モジュールを読み込むと、
@scope/pkgname モジュールを返すFakeパッケージを作る方法があります。

この方法なら require(nameVariable) のような変数名での requireでも問題なく扱えます。
もし、require("pkgname")を読み込んでいるコードが残っているなら、読み込んだ際にエラーログを吐くことで検知できます。

具体的にこのFakeパッケージの作り方には次の方法があります。

ここでは require("pkgname") した時点でエラーログを出したかったので、
一番手軽であとから消せる"Gitリポジトリを使ってパッケージを参照する"を利用しました。

手順としては、pkgnameパッケージのリポジトリにOrphanブランチを作成して、
次のように@scope/pkgnameをexportするだけのパッケージブランチを作成するだけです。

console.error(new Error("Use `@scope/pkgname` instead of `pkgname`");
module.exports = require("@scope/pkgname")

この例では、fake-pkgnameというブランチにFakeパッケージを配置したので、
利用者側のpackage.jsonでGitHubリポジトリのを指定してインストールするだけです。

  "dependencies": {
     "pkgname": "git+https://<GITHUB_TOKEN>:x-oauth-basic@github.com/scope/pkgname.git#fake-pkgname"
  }

これで、仮にrequire("pkgname")を行うコードが残っていてもエラーログが出力されます。
(実際にはもう少し多機能なロガーライブラリも併用していました)

KARTEではDatadogを使ってエラーログに対してアラートを設定しているので、
アラートが飛んでこなければ移行成功です。

移行完了

利用側の変更はパッケージごとに少しずつ進めましたが、GitHub Packages Registryへの移行は特に大きな問題なく移行が完了しました。

現在では社内のパッケージはすべてGitHub Packages Registryにpublishされた状態で開発されています。

あとはGitHub Packages Registryの使い方やFAQを社内のドキュメントに書いて、最初に作成したMeta Issueを閉じて移行作業は完了としました。

社内ドキュメントの目次

GitHub Packages RegistryはGitHub Actionsとの相性がいいので、npm private registryよりも使い勝手が良いです。
一方でnpm CLIが考慮してない状態になると意味不明なエラーとなるケースもあります。

その場合は、GitHub Packages Registryのドキュメントをよく読むのとGitHub Packages Registryはリポジトリに紐付いた存在ということを意識すると解決できます。
たとえば、GitHub Packages Registryにあるパッケージが404となるパターンとして、そのトークンのユーザーがそのリポジトリへのアクセス権限を持っていないというパターンがあります。これはGitHub Packages Registryがリポジトリに紐付いたものであるからです。

この辺の問題はnpmがGitHubに買収されたことにより解決されていくことを期待しています。

最後にGitHub Packages Registryへの移行に協力してもらったチームの皆さんに感謝します。

Security Team
Author

Security Team

Comments