PLAY DEVELOPERS BLOG

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

HuluやTVerなどの日本最大級の動画配信を支える株式会社PLAYが運営するテックブログです。

VerdaccioからGitHub Packagesへのnpmパッケージ移行

背景

前回(2年以上前になりますが)このブログでVerdaccioについて紹介しましたが、
諸般の事情があり、プライベートNPMレジストリVerdaccioからGitHub Enterprise のPackagesへレジストリ変更することになりました。
(移行先のGitHubは、GitHub Enterprise Cloud(GHEC)です。)

過去にRubyGemsを同様の構成へ移行した際は大きな問題がなかったため、npmパッケージもレジストリ情報を変更してpublishすれば、スムーズに完了すると想定していました。

しかし、実際にはいくつもの壁に突き当たりました。
この記事では、その過程で発生した問題と解決策を備忘録として残します。

当初想定していた作業フロー

  1. GitHub Packagesの初期設定
  2. 既存のnpmパッケージをGitHub Packagesにアップロード
  3. GitHub Packagesからインストール
  4. Capistrano deployを行えるようにする

実際の作業フロー(と起こった問題)

  1. GitHub Packagesの初期設定
  2. 既存のnpmパッケージをGitHub Packagesにアップロード
    • Verdaccio(S3)からnpmパッケージをローカルダウンロードしてGitHub Packagesへpublishするスクリプト作成
      • ❌️ 既存のnpmパッケージのスコープの問題
      • ❌️ npm scriptsでエラー
    • package.jsonを修正してコミットしておく
  3. GitHub Packagesからインストール
    • ❌️ productionでインストール出来ない問題
  4. Capistrano deployを行えるようにする
    • ❌️ デプロイ専用ユーザーを作成できず、複数人でのデプロイが必要な場合の問題

1. GitHub Packagesの初期設定

まずは初期設定を行います。

Personal Access Token(PAT)の設定

GitHub Packages では、personal access token (classic)を使用した認証のみがサポートされています。

GitHub > 右上ユーザーアイコン > Settings > Developer Settings > :key: Personal access tokens > Tokens (classic)
repowrite:packagesdelete:packages にチェックを入れてトークン作成します。

[参考] personal access token (classic) の作成 docs.github.com

ローカルマシンの初期設定

~/.npmrcの修正

~/.npmrc

# プライベートNPMレジストリURLは削除またはnpm公式URLに変更します
# registry=https://npmregistry.xxxxxxxx.xxx/
registry=https://registry.npmjs.org/

# 先程作成したPATを追記します
//npm.pkg.github.com/:_authToken=ghp_XXXXXXXXXX

# scope毎のregistryをorganization毎に設定します。(例: organizationがplayjp-hogehogeとplayjp-fugafugaの場合)
@playjp-hogehoge:registry="https://npm.pkg.github.com"
@playjp-fugafuga:registry="https://npm.pkg.github.com"

~/.yarnrcの修正

.yarnrcを使っている場合は、以下のように修正します。

~/.yarnrc

# scope毎のregistryをorganization毎に設定します。(例: organizationがplayjp-hogehogeとplayjp-fugafugaの場合)
"@playjp-hogehoge:registry" "https://npm.pkg.github.com/playjp-hogehoge"
"@playjp-fugafuga:registry" "https://npm.pkg.github.com/playjp-fugafuga"

設定を入れたら、npmログインを行います。
USERNAMEは、USERNAME: https://github.com/settings/profile でユーザ名横の括弧内に記載の名称になります。

$ npm login --scope=@playjp-hogehoge --auth-type=legacy --registry=https://npm.pkg.github.com

> Username: USERNAME
# PATを使う
> Password: ghp_XXXXXXXXXX
# GitHubログイン時に利用しているメールアドレス
> Email: (this IS public)

これでローカルマシンの初期設定は完了です。

2. 既存のnpmパッケージをGitHub Packagesにアップロード

既存のVerdaccio上に存在するプライベートなnpmパッケージを、GitHub Packagesへ移行します。
既存npmパッケージをそのまま移行できるわけではなく、いくつか設定の書き換えが必要だったため
ローカルマシンにダウンロードして一度ファイルを展開し、package.jsonの書き換えを行った後、
再度tgzファイル化してGitHub Packagesへpublishします。

registry設定の追加

package.jsonに、GitHub Packages用に下記設定を追加します。

package.json

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

repository設定が省略されていたら追加します。

package.json

"repository": {
  "type": "git",
  "url": "ssh://git@github.com/playjp-hogehoge/repository-name.git"
},

privateなパッケージであることを明示します。

package.json

"license": "UNLICENSED",

既存のnpmパッケージのスコープの問題

GitHub Packagesを利用するには、npmパッケージにスコープ設定は必須です。
既存のプライベートnpmパッケージにはスコープが未設定のものがいくつか存在していました。
また、設定するスコープは、GitHubの所属するorganization名と一致させる必要があります。
スコープが設定されているプライベートnpmパッケージでも、GitHub organization名と不一致のパッケージがありました。
これらのパッケージにもスコープ名を設定しました。

package.json

# ❌️ スコープ未設定
"name": "hogehoge-js"

# ❌️ スコープとGitHub organization名が不一致
"name": "@playjp-pikopiko/pikopiko"

# ⭕️ GitHub organization名のスコープが設定されている
"name": "@playjp-hogehoge/hogehoge-js"

jsファイルでimportなどで、パスやnpmパッケージ名等が記載されている場合は、そちらも書き換える必要があります。

import {HogeHoge1} from '@playjp-hogehoge/hogehoge-js';

npm scriptsでエラー

GitHub Packagesへのnpm publish時に以下のようなエラーが発生しました。

npm ERR! missing script: release
npm ERR! rimraf ./dist/package.json

scriptsに prepublishrelease があると、publish 時に不要な処理が動いてしまうようです。

build のみを残し、他の script を削除します。

package.json

# ❌️ prepublishが含まれている
"scripts": {
  "clean": "rimraf ./dist",
  "build": "npm run clean && ./node_modules/.bin/babel ./src --out-dir ./dist",
  "watch": "./node_modules/.bin/babel ./src --watch --out-dir ./dist",
  "prepublish": "npm run build"
},

# ⭕️ prepublishを削除する
"scripts": {
  "clean": "rimraf ./dist",
  "build": "npm run clean && npx babel ./src --out-dir ./dist",
  "watch": "npx babel ./src --watch --out-dir ./dist",
  "prepare": "npm run build"
},

修正済のpackage.json

package.jsonを修正してコミットします。

package.json

{
  "name": "@playjp-hogehoge/hogehoge-js",
  "version": "0.0.0",
  "description": "hogehoge description",
  "main": "dist/index.js",
  "author": "PLAY, inc.",
  "license": "UNLICENSED",
  "scripts": {
    "clean": "rimraf ./dist",
    "build": "npm run clean && npx babel ./src --out-dir ./dist",
    "watch": "npx babel ./src --watch --out-dir ./dist",
    "prepare": "npm run build"
  },
  "publishConfig": {
    "access": "restricted",
    "registry": "https://npm.pkg.github.com/"
  },
  "files": [
    "dist/"
  ],
  "repository": {
    "type": "git",
    "url": "ssh://git@github.com/playjp-hogehoge/repository-name.git"
  },
  "dependencies": { /*** ***/ },
  "peerDependencies": { /*** ***/ },
  "devDependencies": { /*** ***/ }
}

移行用スクリプト(Ruby)

リポジトリが30以上に加え、それぞれの既存パッケージが複数バージョン存在しているため

スクリプトで移行処理を行います。

scripts/migrate_npm_package.rb

require 'fileutils'
require 'json'
require 'open3'
require 'active_support/core_ext/object/blank'
require 'tmpdir'
require 'time'

def migrate_npm_package(scope:, package_name:, repository_url: nil, base_dir: "#{ENV['HOME']}/projects/github/verdaccio", only_latest: false)
  github_scheme = 'https'
  github_url = 'github.com'
  package_dir = File.join(base_dir, "@#{scope}", package_name)
  user_npmrc = File.expand_path("~/.npmrc")

  unless Dir.exist?(package_dir)
    puts "----- Not found: #{package_dir}"
    return false
  end

  ok_packages = []
  ng_packages = []
  tgz_files = Dir.glob(File.join(package_dir, "*.tgz"))

  puts "----- Migrating #{package_name} in scope @#{scope}"

  # GitHub PackagesでLatest version扱いとなるのは最後にpublishされたものなので、古い順にpublishしていく
  tgz_files.sort_by! do |path|
    filename = File.basename(path)
    version_str = filename.match(/-(\d+\.\d+\.\d+(?:-[\w\.]+)?)/)&.captures&.first || "0.0.0"
    Gem::Version.new(version_str)
  end

  if only_latest
    tgz_files = [tgz_files.last]
  end
  return false if tgz_files.blank?

  tgz_files.each do |tgz_path|
    puts "----- Processing #{File.basename(tgz_path)}"
    tmpdir = Dir.mktmpdir
    unpacked_dir = File.join(tmpdir, "package")
    pkg_json_path = File.join(unpacked_dir, "package.json")

    begin
      # 展開
      system("tar", "-xzf", tgz_path, "-C", tmpdir)
      json = JSON.parse(File.read(pkg_json_path))

      # 引数にGitHubリポジトリURLが指定されている場合は差し替える
      if repository_url.present?
        json["repository"] = {}
        json["repository"]["type"] = "git"
        json["repository"]["url"] = repository_url
      else
        repository_url = json["repository"]
      end
      if repository_url.blank?
        puts "----- GitHub repository url is not found. #{repository_url}"
        ng_packages << File.basename(tgz_path)
        next
      end

      # GitHubリポジトリURLからownerを取得
      repository_url = repository_url.is_a?(Hash) ? repository_url["url"] : repository_url
      matches = repository_url.match(%r{#{Regexp.escape(github_url)}(/|:)(?<owner>.+?)/(?<name>.+?)(\.git)?$})
      owner = matches[:owner] rescue nil
      if owner.blank?
        puts "----- GitHub organization is not found. #{repository_url}"
        ng_packages << File.basename(tgz_path)
        next
      end

      if scope != owner
        # scopeとGitHubリポジトリのownerが異なっていたらownerに書き換える
        json["name"] = json["name"].gsub(/\@#{scope}/, "@#{owner}")
      elsif !json["name"].match(/^@/)
        # scope未設定の場合はownerをscopeに追加する
        json["name"] = "@#{owner}/#{json["name"]}"
      end
      if json["name"] != "@#{owner}/#{package_name}"
        json["name"] = "@#{owner}/#{package_name}"
      end

      # publishConfigを追加
      json["publishConfig"] = {
        "registry" => "#{github_scheme}://npm.pkg.#{github_url}/#{owner}"
      }

      # prepublishやreleaseなどを除外するために、もとのscripts.buildだけ残すようにする
      scripts = json["scripts"] || {}
      json["scripts"] = {
        "build" => scripts["build"] || "echo no build"
      }

      # privateなパッケージはUNLICENSED
      json["license"] = "UNLICENSED"

      # Dependencies内の書き換え
      # @hogehogeスコープを@playjp-hogehogeに書き換える場合の例です
      if json["dependencies"].present?
        _dependencies = json["dependencies"]
        new_dependencies = {}
        _dependencies.each do |_package_name, _version|
          if _package_name.match(/\@hogehoge/)
            _package_name = _package_name.gsub(/\@hogehoge/, "@playjp-hogehoge")
          end
          new_dependencies[_package_name] = _version
        end
        json["dependencies"] = new_dependencies
      end

      File.write(pkg_json_path, JSON.pretty_generate(json))

      # distディレクトリ内のスコープ書き換え
      dist_dir = File.join(unpacked_dir, "dist")
      Dir.glob(File.join(dist_dir, "**", "*.{js,mjs,cjs,ts,jsx,tsx}")).each do |file|
        next unless File.file?(file)

        content = File.read(file)

        # import/requireのscope置換
        updated = content.gsub(/(['"])@hogehoge\/(.*?)\1/, "\\1@playjp-hogehoge/\\2\\1")

        if content != updated
          puts "  rename scope: #{file}"
          File.write(file, updated)
        end
      end

      Dir.chdir(tmpdir) do
        # npm pack で再構築
        repacked_name = `npm pack #{unpacked_dir}`.strip
        repacked_path = File.join(tmpdir, repacked_name)

        puts "----- Dry run: #{repacked_name}"
        dryrun_cmd = ["npm", "publish", repacked_path, "--dry-run", "--userconfig", user_npmrc]
        dryrun_out, dryrun_err, status = Open3.capture3(*dryrun_cmd)
        puts "----- Dry run complete: output: #{dryrun_out} status: #{status}"

        if dryrun_out.include?("#{owner}/#{package_name}")
          puts "----- Publishing #{repacked_name}..."
          publish_cmd = ["npm", "publish", repacked_path, "--userconfig", user_npmrc]
          _p_out, _p_err, p_status = Open3.capture3(*publish_cmd)
          if p_status.success?
            puts "----- Publish Success!"
            ok_packages << File.basename(tgz_path)
          else
            puts "----- Publish Failed!"
            puts _p_err
            ng_packages << File.basename(tgz_path)
          end
        else
          puts "----- Skipping publish. Registry mismatch or error."
          puts dryrun_out
          puts dryrun_err
          ng_packages << File.basename(tgz_path)
        end
      end
    ensure
      FileUtils.remove_entry(tmpdir) if tmpdir && Dir.exist?(tmpdir)
    end
  end

  puts "----- Migration complete for #{package_name}"
  puts "published success packages: #{ok_packages.blank? ? "(none)" : ok_packages.join(', ')}"
  puts "published failed packages: #{ng_packages.blank? ? "(none)" : ng_packages.join(', ')}"

  # ログ出力
  write_npm_result_log(ok_packages, ng_packages)

  return true
end

def write_npm_result_log(success_packages, failure_packages)
  timestamp = Time.now.strftime("%Y%m%d-%H%M%S")
  log_filename = "./log/npm_migration_log_#{timestamp}.txt"
  
  File.open(log_filename, 'w') do |f|
    f.puts "--- NPMパッケージ移行結果 (#{Time.now.to_s}) ---"
    f.puts
    f.puts "✅ 成功: #{success_packages.count}"
    f.puts "❌ 失敗: #{failure_packages.count}"
    f.puts
    
    unless success_packages.empty?
      f.puts "--- 成功したファイル ---"
      success_packages.each { |pkg_name| f.puts(pkg_name) }
      f.puts
    end

    unless failure_packages.empty?
      f.puts "--- 失敗したファイル ---"
      failure_packages.each { |pkg_name| f.puts(pkg_name) }
      f.puts
    end
  end

  puts "\n----------------------------------------"
  puts "✅ 実行結果を #{log_filename} に書き出しました。"
  puts "----------------------------------------"
end

RubyスクリプトなのでRailsコンソールから実行します。

$ RAILS_ENV=development ./bin/rails console

pry(main)> require './scripts/migrate_npm_package'

migrate_npm_package(scope: 'playjp-hogehoge', package_name: 'hogehoge-js', repository_url: 'https://github.com/playjp-hogehoge/repository-name')

GitHub Packages

これでnpmパッケージが移行できました。

3. GitHub Packagesからインストール

GitHub Packagesへpublishできたnpmパッケージをnpm install/yarn installします。
npm installとyarn addどちらでもインストール可能な状態であることを確認します。

productionでインストール出来ない問題

この問題が、移行における最大の障害となりました。
まず、production環境でのインストール時に下記のようなエラーが出ていました。

Invariant Violation: You should not use <Route> or withRouter() outside a <Router>

VerdaccioはプライベートNPMレジストリですが、プロキシとして機能し、一度ダウンロードしたパブリックなnpmパッケージをキャッシュします。
問題となったのは、このキャッシュ上に存在していたreact-router@4.4.0です。
このバージョンは現在npm公式レジストリから削除されています。削除された経緯については以下のページに記載されています。

React Router v5

移行後、GitHub Packagesを参照する構成ではこの古いバージョンが見つからず、代わりに4.4.0-beta.8が参照されてしまいました。
この意図しないバージョンアップが原因で、production環境ではUIが描画されなくなるという致命的な不具合が発生しました。

react-routerおよびreact-router-domを安定版の5.3.4にバージョンアップ、
関連パッケージのpackage.jsonをすべて修正、buildし直して再publishしてUIが描画されるようになり、無事動作が確認できました。

4. Capistrano deployを行えるようにする

アプリケーションはRuby on Rails(Webpacker + React)構成で、デプロイにはCapistrano を利用しています。

デプロイ専用ユーザーを作成できず、複数人でのデプロイが必要な場合の問題

あまりないと思いますが、デプロイ用ユーザを作成できない状態だが、複数名がdeploy可能な状況にするために、
Capistrano deploy実行時にUSER/PATを引数で渡してnpm tokenを行えるようにします。

.npmrc の自動生成と自動削除

.bashrc.zshrcにUSER/PATを設定します。

~/.bashrc

# GitHub Enterprise Cloud Personal Access Token
export GHEC_USER="xxxxxxxxxx"
export GHEC_PAT="ghp_xxxxxxxxxx"

以下のようなCapistranoタスクをdeploy前後に追加して、GitHub Packagesからnpm installを行えるようにします。
.npmrcはdeploy中だけ一時的に生成し、終了後に削除することでセキュアにtokenを扱えます。

config/deploy.rb

# 一時的に.npmrcを配置してGitHub Packagesからnpm installを行えるようにする
desc 'Generate .npmrc with token from ENV'
task :generate_npmrc do
  on roles(:web) do
    within release_path do
      npmrc_content = <<~NPMRC
        @playjp-hogehoge:registry=https://npm.pkg.github.com/
        @playjp-fugafuga:registry=https://npm.pkg.github.com/
        //npm.pkg.github.com/:_authToken=#{ENV['GHEC_PAT']}
      NPMRC
      upload! StringIO.new(npmrc_content), "#{release_path}/.npmrc"
    end
  end
end

# npm installが完了したら.npmrcを削除する
task :cleanup_npmrc do
  on roles(:web) do
    within release_path do
      execute :rm, "-f .npmrc"
    end
  end
end
before 'deploy:updated', 'deploy:generate_npmrc'
after 'deploy:updated', 'deploy:cleanup_npmrc'

ここまでで、アップロードからダウンロードまで、一通りGitHub Packagesで行えるようになりました。

所感

react-routerで発覚したように、アプリケーションが依存する古いパッケージの中には、同様に現在公式から入手できなくなっているものが存在する可能性があります。
他のパッケージについても依存関係の整合性を確認の上、GitHub Packagesへ移行する必要があります。
改めて、古い依存関係を持つアプリケーションを継続的にメンテナンスしていくことの重要性を痛感しました。