🟪

Herokuでプライベートリポジトリのgemをビルドする

2023/08/10に公開

こんにちは。株式会社ペライチ CTO の瀬川です。

会社のサービスが増えてくると共通処理をパッケージにまとめたくなってきますよね。
ペライチでは最近 Rails のサービスが10個を超えてきており、共通処理をまとめた gem をプライベートリポジトリに作ることにしました。
プライベートな gem の CI/CD、特に Heroku での取り扱いに少し苦労したのでまとめておこうと思います。

GitHub リポジトリを gem として指定する

Gemfileに github: オプションをつけることで github リポジトリを gem として直接指定することができます。

# Gemfile
gem "the_private_gem", github: "hogeorg/the_private_gem", tag: "v1.0.0"

gem にプライベートリポジトリを指定した場合、ローカルでは以下のように bundle install 時に GitHub へのログインを求められます。

$ bundle install
Bundler 2.4.18 is running, but your lockfile was generated with 2.3.26. Installing Bundler 2.3.26 and restarting using that version.
Fetching gem metadata from https://rubygems.org/.
Fetching bundler 2.3.26
Installing bundler 2.3.26
Fetching gem metadata from https://rubygems.org/.........
Fetching https://github.com/PagerTree/attr_encrypted.git
NOTE: Gem::Specification#has_rdoc= is deprecated with no replacement. It will be removed in Rubygems 4
Gem::Specification#has_rdoc= called from /Users/segawa/.rbenv/versions/3.1.1/lib/ruby/gems/3.1.0/bundler/gems/attr_encrypted-eafd77a11f69/attr_encrypted.gemspec:22.
Fetching https://github.com/hogeorg/the_private_gem.git
Username for 'https://github.com': tektoh
Password for 'https://tektoh@github.com':

ここでパスワードには Personal Access Token (PAT) を指定する必要があります。 PAT は Developer Settings から作成します。

CI 上でのビルド

Personal access tokens are intended to access GitHub resources on behalf of yourself. To access resources on behalf of an organization, or for long-lived integrations, you should use a GitHub App.

PAT のドキュメントでは上記のように、組織としてリソースにアクセスする場合は GitHub App を使うことが推奨されています。
オーガニゼーションで GitHub App を作成しておくと離職者があってもアクセストークンが無効にならないのでトークンが管理しやすくなります。

オーガニゼーションの GitHub App は、オーガニゼーションの Developer settings > GitHub Apps から作成できます。GitHub Appsの細かい設定は今回は割愛しますが、パーミッションは Contents のリード権限が必要です。

GitHub App のアクセストークンを発行する

GitHub App のアクセストークンは以下の API で発行できます。

POST /app/installations/{installation_id}/access_tokens

https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#create-an-installation-access-token-for-an-app

つまり installation_id が必要です。 
installation_id 取得は以下の API です。

GET /repos/{owner}/{repo}/installation

https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-a-repository-installation-for-the-authenticated-app

上記2つの API は JWT で認証をします。
https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app

JWT は iss に GitHub App の App ID を設定し、 RS256 アルゴリズムで GitHub App の Private key を使って署名する必要があります。

まとめると、以下の手順でようやくアクセストークンが手に入ります。

  1. GitHub App を作成
  2. GitHub App の Private key を発行
  3. GitHub App の App ID と Private key を使って JWT を作成
  4. API でリポジトリを指定して installation_id を取得
  5. API でアクセストークンを発行

CI の bundle install にアクセストークンを渡す

ローカル環境で bundle install した時は PAT をプロンプトで入力しましたが、CIでは以下のように環境変数でアクセストークンを渡す必要があります。

BUNDLE_GITHUB__COM=x-access-token:${GITHUB_ACCESS_TOKEN}

Heroku でのビルド

Heroku では buildpack-ruby を使うことでビルドフェーズで bundle install が実行されます。
プライベートリポジトリの gem をインストールする場合、 buildpack-ruby より先に GitHub App のアクセストークンを取得して環境変数に設定するビルドパックが必要になってきます。

ビルドパックを作る

ビルドバックは以下の3つのスクリプトで構成されます。

  • bin/detect: この buildpack がアプリに適用されるかどうかを判定します。
  • bin/compile: アプリで変換手順を実行するために使用されます。
  • bin/release: メタデータをランタイムに戻します。

bin/detect はとりあえず exit 0 を実行すれば良く、 bin/release は作成しなくても問題ありません。
bin/compile にアクセストークンを発行するスクリプトを書いていきます。

#!/usr/bin/env bash
# bin/compile
set -e

# Utility functions
indent()     { sed 's/^/       /'; }
arrow()      { sed 's/^/-----> /'; }
indent-err() { sed "s/^/$(printf '\033')[31m       /;s/$/$(printf '\033')[0m/"; }
arrow-err()  { sed "s/^/$(printf '\033')[31m-----> /;s/$/$(printf '\033')[0m/"; }

# Base64 URL エンコードする関数
function base64url_encode {
  declare input=$(cat -)
  echo -n "${input}" | base64 | url_safe
}

# Base64 エンコードをURLセーフにする関数
function url_safe {
  declare input=$(cat -)
  echo -n "${input}" |  tr -d '=\r\n' | tr '+' '-' | tr '/' '_'
}

BUILD_DIR=${1:-}
CACHE_DIR=${2:-}
ENV_DIR=${3:-}
BIN_DIR=$(cd $(dirname $0); pwd)
BP_DIR=$(cd $BIN_DIR; cd ..; pwd)

cd "$BUILD_DIR"

# ビルドフェーズでは Config Vars は環境変数には展開されておらず、 ENV_DIR 内にファイルで配置されている。
GITHUB_USER=$(cat $ENV_DIR/GITHUB_USER)
GITHUB_ORG=$(cat $ENV_DIR/GITHUB_ORG)
GITHUB_REPO=$(cat $ENV_DIR/GITHUB_REPO)
GITHUB_APP_ID=$(cat $ENV_DIR/GITHUB_APP_ID)
# Private key は改行を含んでいるため、 一度 Base64 エンコードして Config Vars に入れ、デコードして利用する
GITHUB_APP_PRIVATE_KEY_BASE64=$(cat $ENV_DIR/GITHUB_APP_PRIVATE_KEY_BASE64)
GITHUB_APP_PRIVATE_KEY=$(echo $GITHUB_APP_PRIVATE_KEY_BASE64 | base64 -d)

# GitHub App の App ID と Private key を使って JWT を作成
JWT_IAT=$(date +%s)
JWT_EXP=$(($JWT_IAT + 60))
JWT_ISS=$GITHUB_APP_ID
JWT_HEADER=$(echo -n '{"alg":"RS256","type":"JWT"}' | base64url_encode)
JWT_PAYLOAD=$(echo -n "{\"iat\":$JWT_IAT,\"exp\":$JWT_EXP,\"iss\":\"$JWT_ISS\"}" | base64url_encode)
# openssl dgst のバイナリをそのままパイプで渡そうとすると null 文字が削られてしまうため、テンポラリファイルに書き出してからファイルに Base64 エンコードをかけている
echo -n "$JWT_HEADER.$JWT_PAYLOAD" | openssl dgst -sha256 -sign <(echo "$GITHUB_APP_PRIVATE_KEY") -binary > /tmp/jwt_signature
JWT_SIGNATURE=$(base64 -i /tmp/jwt_signature | url_safe)
JWT=$JWT_HEADER.$JWT_PAYLOAD.$JWT_SIGNATURE

# リポジトリを指定して installation_id を取得
echo "Getting installation id for $GITHUB_ORG/$GITHUB_REPO" | arrow
INSTALLATION=$(curl -sS -L \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer $JWT" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  "https://api.github.com/repos/$GITHUB_ORG/$GITHUB_REPO/installation")
INSTALLATION_ID=$(echo $INSTALLATION | jq -r '.id')

if [ $INSTALLATION_ID = "null" ]; then
  echo "Could not find installation id for $GITHUB_ORG/$GITHUB_REPO" | arrow-err
  exit 1
fi

# アクセストークンを発行
echo "Getting access token for $GITHUB_ORG/$GITHUB_REPO" | arrow
ACCESS_TOKEN=$(curl -sS -L \
  -X POST \
  -H "Accept: application/vnd.github+json" \
  -H "Authorization: Bearer $JWT" \
  -H "X-GitHub-Api-Version: 2022-11-28" \
  https://api.github.com/app/installations/$INSTALLATION_ID/access_tokens \
  -d '{}')
TOKEN=$(echo $ACCESS_TOKEN | jq -r '.token')

if [ $TOKEN = "null" ]; then
  echo "Could not create access token for $GITHUB_ORG/$GITHUB_REPO" | arrow-err
  exit 1
fi

# BP_DIR/export に環境変数を書き込んでおくと、次に実行されるビルドパックに環境変数を渡すことができる
echo "export GITHUB_ACCESS_TOKEN=$TOKEN" > $BP_DIR/export
echo "export BUNDLE_GITHUB__COM=x-access-token:${TOKEN}" >> $BP_DIR/export

ビルドパックを設定する

ビルドパックのスクリプトをパブリックリポジトリに push し、 heroku/ruby より先に実行されるようにセットします。

$ heroku buildpacks -a hogeapp
=== hogeapp Buildpack URLs

1. https://github.com/hogeorg/buildpack-gh-access-token
2. heroku/ruby

Config Vars を設定する

上記のビルドパックで利用する環境変数を Config Vars に設定します。

  • GITHUB_ORG: ビルドするリポジトリのオーガニゼーション名
  • GITHUB_REPO: ビルドするリポジトリのリポジトリ名
  • GITHUB_APP_ID: GitHub App の App ID
  • GITHUB_APP_PRIVATE_KEY_BASE64: GitHub App の Private key を Base64 エンコードしたもの。

Private key は base64 コマンドでエンコードします。

$ base64 -i hogeapp.2023-08-01.private-key.pem

デプロイ

以上の設定でデプロイすると、プライベートリポジトリの gem を Heroku でビルドできるようになりました。

Build log

-----> Building on the Heroku-20 stack
-----> Deleting 4 files matching .slugignore patterns.
-----> Using buildpacks:
       1. https://github.com/hogeorg/buildpack-gh-access-token
       2. heroku/ruby
-----> GitHub Access Token app detected
-----> Getting installation id for hogeorg/the_private_gem
-----> Getting access token for hogeorg/the_private_gem
-----> Ruby app detected
-----> Installing bundler 2.3.25
-----> Removing BUNDLED WITH version in the Gemfile.lock
-----> Compiling Ruby/Rails
-----> Using Ruby version: ruby-3.1.1
-----> Installing dependencies using bundler 2.3.25
       Running: BUNDLE_WITHOUT='development:test' BUNDLE_PATH=vendor/bundle BUNDLE_BIN=vendor/bundle/bin BUNDLE_DEPLOYMENT=1 bundle install -j4
       Fetching gem metadata from https://rubygems.org/.........
       Fetching https://github.com/hogeorg/the_private_gem.git
       :
       Using the_private_gem 1.0.0 from https://github.com/hogeorg/the_private_gem.git (at v1.0.0@1111111)
       :
       Bundle complete! 41 Gemfile dependencies, 99 gems now installed.
       Gems in the groups 'development' and 'test' were not installed.
       Bundled gems are installed into `./vendor/bundle`
       Bundle completed (2.85s)

Well done!

採用情報

現在エンジニア募集しています!

▼ 採用ページ
https://recruit.peraichi.co.jp/

▼ 選考をご希望の方はこちら(募集職種一覧)
https://hrmos.co/pages/peraichi/jobs?category=1629135637016141824&utm_source=techblog&utm_medium=referral&utm_campaign=article-01h7ak6e1sycp7j732httb7hwb

▼ まずはカジュアル面談をご希望の方はこちら
https://hrmos.co/pages/peraichi/jobs/0000029?utm_source=techblog&utm_medium=referral&utm_campaign=article-01h7ak6e1sycp7j732httb7hwb

募集中の職種についてご興味がある方は、お気軽にお申し込みください(CTO がお会いします)

ペライチ

Discussion