
- 背景
- 1. GitHub Packagesの初期設定
- 2. 既存のnpmパッケージをGitHub Packagesにアップロード
- 3. GitHub Packagesからインストール
- 4. Capistrano deployを行えるようにする
背景
前回(2年以上前になりますが)このブログでVerdaccioについて紹介しましたが、
諸般の事情があり、プライベートNPMレジストリVerdaccioからGitHub Enterprise のPackagesへレジストリ変更することになりました。
(移行先のGitHubは、GitHub Enterprise Cloud(GHEC)です。)
過去にRubyGemsを同様の構成へ移行した際は大きな問題がなかったため、npmパッケージもレジストリ情報を変更してpublishすれば、スムーズに完了すると想定していました。
しかし、実際にはいくつもの壁に突き当たりました。
この記事では、その過程で発生した問題と解決策を備忘録として残します。
当初想定していた作業フロー
- GitHub Packagesの初期設定
- 既存のnpmパッケージをGitHub Packagesにアップロード
- GitHub Packagesからインストール
- Capistrano deployを行えるようにする
実際の作業フロー(と起こった問題)
- GitHub Packagesの初期設定
- 既存のnpmパッケージをGitHub Packagesにアップロード
- Verdaccio(S3)からnpmパッケージをローカルダウンロードしてGitHub Packagesへpublishするスクリプト作成
- ❌️ 既存のnpmパッケージのスコープの問題
- ❌️ npm scriptsでエラー
- package.jsonを修正してコミットしておく
- Verdaccio(S3)からnpmパッケージをローカルダウンロードしてGitHub Packagesへpublishするスクリプト作成
- GitHub Packagesからインストール
- ❌️ productionでインストール出来ない問題
- 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)
repo・write:packages・delete: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に prepublish や release があると、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')

これで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公式レジストリから削除されています。削除された経緯については以下のページに記載されています。
移行後、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へ移行する必要があります。
改めて、古い依存関係を持つアプリケーションを継続的にメンテナンスしていくことの重要性を痛感しました。