Heroku上のアプリに自身のGemfileを書き換えさせる

Heroku上で動作させているアプリケーションに、自身のレポジトリ上のGemfileを更新させる方法について書きます。この記事では、HerokuのGitHub Sync、Bundler::Dsl、GitHub API v3のContents APIなどを利用します。

どういう場合に必要になるか

自分は、r7kamura/ruboty-bundlerというRubotyのプラグインをつくる上でこの処理が必要になりました。普段Slackでいろいろな仕事をしてくれるBotをHerokuで動かしているのですが、RubotyのプラグインはGemfileで管理する形式になっているため、プラグインを追加するたびにGemfileを書き換えなければいけません。この作業が面倒だと思い、Botに命じれば勝手にプラグインを入れてくれるようにしようと考えました。

GitHub Contents API

GitHub API v3には、2013年頃にContents APIが追加されました。これは、レポジトリ上のファイルを取得したり、更新したりするためのAPIです。Contents APIの登場以前にファイルを更新するには、Blobs APITrees APIなどを組み合わせなければなりませんでした。Contents APIのおかげで、より簡単にファイル操作が行えるようになりました。

今回はContents APIを利用して、masterブランチのGemfileおよびGemfile.lockというファイルを取得し、追加したいGemをGemfileに追記し、bundle install でGemfile.lockを更新したあと、再びContents APIを利用してレポジトリ上のファイルを更新します。Gemfileを取得する例を以下に示します。contentというプロパティに、Base64形式でエンコードされたデータが格納されています。また、shaプロパティにSHA1の値が入っていますが、ファイルを更新する場合はファイルの内容と一緒にこのSHA1の値を送る必要があるので大事な値です。

$ curl https://api.github.com/repos/increments/qiitan-rb/contents/Gemfile
{
  "name": "Gemfile",
  "path": "Gemfile",
  "sha": "88bf9ae8f760bfdb8e1f37ffdb70a9475cb76857",
  "size": 389,
  "url": "https://api.github.com/repos/increments/qiitan-rb/contents/Gemfile?ref=master",
  "html_url": "https://github.com/increments/qiitan-rb/blob/master/Gemfile",
  "git_url": "https://api.github.com/repos/increments/qiitan-rb/git/blobs/88bf9ae8f760bfdb8e1f37ffdb70a9475cb76857",
  "download_url": "https://raw.githubusercontent.com/increments/qiitan-rb/master/Gemfile",
  "type": "file",
  "content": "c291cmNlICJodHRwczovL3J1YnlnZW1zLm9yZyIKCmdlbSAicmFrZSIKZ2Vt\nICJydWJvdHktcmVwbGFjZSIKZ2VtICJydWJvdHktYWxpYXMiCmdlbSAicnVi\nb3R5LWJ1bmRsZXIiCmdlbSAicnVib3R5LWNyb24iCmdlbSAicnVib3R5LWVj\naG8iCmdlbSAicnVib3R5LWdpdGh1YiIKZ2VtICJydWJvdHktZ29vZ2xlX2Nh\nbGVuZGFyIgpnZW0gInJ1Ym90eS1nb29nbGVfaW1hZ2UiCmdlbSAicnVib3R5\nLWxndG0iCmdlbSAicnVib3R5LXFpaXRhX3BvbGljZSIKZ2VtICJydWJvdHkt\ncmVkaXMiCmdlbSAicnVib3R5LXJ1YnlfZXZhbCIKZ2VtICJydWJvdHktc2Nv\ncmVrZWVwZXIiCmdlbSAicnVib3R5LXNsYWNrIgpnZW0gInJ1Ym90eS10YWxr\nIgpnZW0gInJ1Ym90eS10d2l0dGVyX3NlYXJjaCI=\n",
  "encoding": "base64",
  "_links": {
    "self": "https://api.github.com/repos/increments/qiitan-rb/contents/Gemfile?ref=master",
    "git": "https://api.github.com/repos/increments/qiitan-rb/git/blobs/88bf9ae8f760bfdb8e1f37ffdb70a9475cb76857",
    "html": "https://github.com/increments/qiitan-rb/blob/master/Gemfile"
  }
}

bundle install on Heroku

Herokuのプロセス上でもbundle installが実行できます。但しRubyで動作しているプロセス上から Kernel.#system などを利用して実行する場合、自身のプロセスにBundlerが参照する環境変数が幾つか与えられているため、これを外した状態で実行する必要があります。Bundler.with_clean_env というメソッドに与えたブロックの中で処理を実行すれば、そのように振る舞うことができます。

また、bundle install を実行するとき、.bundle/config というファイルが存在すれば、そこに記述されている設定を読み込んだ上で処理が実行されます。このファイルは最初に bundle install が実行されたときに生成されます。この設定ファイルの存在により、初めて実行したときの設定が記憶され、二度目の実行時にも一度目の実行時と同じ設定で bundle install が実行されるという訳です。今回のケースではこの設定ファイルの存在が新規Gemの追加を妨げてしまうため、何らかの対策が必要です。単純に削除するのも良いでしょう。

File.delete(".bundle/config")
Bundler.with_clean_env { `bundle install` }

Gemfileをパースする

今回の目的の中では、Gemfileに任意のGemの定義を追加する必要があります。そのためには、Gemfileの内容を一旦解析し、データを追加し、有効なGemfileを再生成しなければなりません。このとき、bundle install が内部で利用している Bundler::Dsl.evaluate が利用できます。このメソッドにGemfileとGemfile.lockのファイルパスを与えると、それらの解析結果を表す Bundler::Definition のインスタンスが得られます。このインスタンスから、Bundler::Definition#dependencies を利用して Bundler::Dependency のインスタンスの配列を取り出せます。Bundler::Dependency は、それぞれが gem "ruboty", ">= 1.0.0" のような1つのGemに対する定義を表現するオブジェクトです。

以下のコードは、カレントディレクトリのGemfileとGemfile.lockを解析し、ruboty-kokodeikkuというGemを追加し、Gemfileの内容を再生成する簡単な例です。実際には gem メソッドにはバージョン情報やオプションなどを与えられたりするので、その辺りの面倒も見たほうが良いでしょう。

dependencies = Bundler::Dsl.evaluate("Gemfile", "Gemfile.lock", {}).dependencies
dependencies << Bundler::Dependency.new("ruboty-kokodeikku")
gemfile = %<source "https://rubygems.org"\n\n>
gemfile << dependencies.map { |dependency| "gem #{dependency.to_s.inspect}\n" }.join

Heroku GitHub Sync

image

Herokuの管理画面で設定を行うと、GitHubのmasterブランチにコードがPushされたときに、自動でHerokuに反映してくれるようになります。この設定が有効化されていると、Contents APIでGemfileとGemfile.lockを更新したときに自動で反映されてくれるということになります。Travis CIなどでもデプロイの自動化はできますが、特長を挙げるとすれば、こちらの方が反映完了までの時間がより短く済みます。

おしまい

以上の処理を組み合わせると、Heroku上のアプリに自身のGemfileを書き換えさせる処理が可能になります。特に注意すべき点についてのみ触れましたが、ruboty-bundler というGemでは同様の処理を実現しているので、参考になるかもしれません。