そもそもDependabotとは
GithubにDependabotという機能があるのはご存知でしょうか?
リポジトリ内で使用している依存関係(ライブラリ)で古いものがあれば更新をかけたPullRequestを自動発行してくれる機能です。
パブリックなリポジトリで以下のようなPRを見たことがある方も多いと思います。
もともとは独立したサービスでしたが、Githubに買収されてネイティブ機能として取り込まれたので導入がしやすくなりました。
.github/dependabot.yml
配下に設定ファイルを配置するだけでライブラリアップデートPR自動作成を有効に出来ます。
今回Dependabot自体の解説は省きますが私の携わるプロジェクトでは以下のような設定ファイルで月曜日の9時にチェックがかかるように運用を始めています。
version: 2
updates:
- package-ecosystem: "gradle"
directory: "/"
target-branch: "develop"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "Asia/Tokyo"
reviewers:
- "ignis-ltd/with-android"
まずはGithub Dependabotについて詳しく知りたいという方は以下の公式ドキュメントや記事が参考になると思います
※現状「Dependabot」でググると買収される前の独立したサービスだった時代のマーケットプレイス版の記事がたくさん出てきますが、そちらはいずれ廃止されてネイティブ機能に統合される可能性が高いのでご注意下さい
Dependabotの運用課題
Dependabotは1ライブラリのアップデートにつき1つのPRを発行されます。
一部のライブラリアップデートが問題を起こす可能性もあるので問題を切り分けるためにも個別になっている事自体は正しい形だと思っているのですが、いくつもアップデートがある場合やや課題が残ります。
サーバーサイドでも似たような課題は発生しうると思いますが、私の扱うプロジェクトではAndroidアプリなので上記のように5つのPRが一度に発行された場合5回ブランチをcheckoutしてビルドしてインストールして動作確認をする必要がありました。
もちろんCI上でテストを組んで自動化すればリスクを減らすことも出来ますが、やはりアプリの場合はUIの崩れ等が心配なのでマージ前に目視での動作確認は1度はしておきたいところです。
あるいはCIでブランチごとにデプロイまで行えばチェックアウトまではしなくても良いですが、それでも5回ダウンロードしてインストールして起動するだけでもちょっと大変ですよね。
解決策
そこで考えたアプローチとして、dependabotが発行したそれぞれのPRのブランチを一つのブランチへマージしてCIからビルドを行い、各PRにコメントとしてバイナリへのリンクを残すという手法を検討しました。
以下で実際のやり方をご紹介致します。
Dependabotが発行したブランチを統合する
まずアップデートがかかったGitブランチをすべて抽出して統合するRubyスクリプトを組んでみます。
dependabot/
の文字列から始まるブランチ一覧を取得する- 現在のブランチ(develop)からアップデートcommitのパッチファイルを作成する
- 2.で取得したパッチファイルをすべて適用する
(Ruby力は底辺なのでそのあたりは見逃してください )
source 'https://rubygems.org'
gem 'git'
require "git"
dependabotBranchs = []
git_client = Git.open(Dir.pwd)
git_client.fetch
git_client.branches.each do |branch|
if branch.name.start_with?('dependabot/')
dependabotBranchs.append(branch)
end
end
return if dependabotBranchs.size <= 0
dependabotBranchs.each do |dependabotBranch|
system("git format-patch -1 #{dependabotBranch.gcommit} --unified=0")
end
system("git apply 0001-*.patch --unidiff-zero")
ちょっとした解説
ここで肝となるのがパッチファイルの作成と適用方法です。
git format-patch
でパッチファイルを生成していますが、通常では前後3行の変更もDiffに含まれています。
つまりアップデートがかかったライブラリの行が隣り合っているときなど、前後3行以内に変更がすでにある場合コンフリクトとして判断されてしまい自動での統合がされません。
Dependabotによるバージョンアップの変更だけを抽出すればコンフリクトは起こり得ないはずなので、近い行で変更があってもコンフリクトとみなさず統合してほしいものです。
そこで--unified=0
パラメータを付与することで前後の行をDiffに含めないように調整しています。(参考)
また、git apply
でパッチファイルを適用する際にも小技を使っていて、通常では前後の行が含まれていない(--unified=0
を指定した)パッチファイルでは適用が失敗してしまうので--unidiff-zero
パラメータを付与して前後の行を無視して適用されるようにしています。(参考)
この辺の処理は当初使っていたruby-gitだと機能不足だったのでsystemコールに頼っています。
ここまででライブラリアップデートが統合されたブランチが出来上がっているはずなので、この状態でビルドすれば統合されたバイナリを生成できます。
Dependabotが発行したPRに統合したバイナリへのリンクを貼る
CI上でバイナリを生成してリンクURLも発行したと仮定して、もともとのDependabotが生成したPRにコメントを残すRubyスクリプトを組んでみます。
今度はGitではなくGithub APIを使って抽出します。
- 現在開かれているPullRequestで
dependabot/
の文字列から始まるブランチを抽出する - 抽出したPullRequestに対してバイナリへのリンクと統合したPullRequest一覧をコメントとして貼る
(Ruby力底辺コード)
require 'net/http'
require 'uri'
require 'json'
uri = URI.parse("https://api.github.com/repos/ignis-ltd/with_android/pulls")
request = Net::HTTP::Get.new(uri)
request["Accept"] = "application/vnd.github.v3+json"
request["Authorization"] = "token #{ENV['GITHUB_API_TOKEN']}"
req_options = {
use_ssl: uri.scheme == "https",
}
response = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http|
http.request(request)
end
dependabotPullRequests = []
responseBodyJson = JSON.parse(response.body, symbolize_names: true)
responseBodyJson.each do |pull|
if pull[:head][:ref].start_with?('dependabot/')
dependabotPullRequests.append(pull)
end
end
dependabotPullRequests.each do |pull|
uri = URI.parse("https://api.github.com/repos/ignis-ltd/with_android/issues/#{pull[:number]}/comments")
request = Net::HTTP::Post.new(uri)
request.body = JSON.dump({
"body" => "Deployed a binary that merged the following branches\n" + dependabotPullRequests.map { |pull| pull[:html_url] }.join(" ") + "\n\n#{ENV['INSTALL_PAGE_URL']}"
})
request["Accept"] = "application/vnd.github.v3+json"
request["Authorization"] = "token #{ENV['GITHUB_API_TOKEN']}"
req_options = {
use_ssl: uri.scheme == "https",
}
response = Net::HTTP.start(uri.hostname, uri.port, req_options) do |http|
http.request(request)
end
end
ちょっとした解説
基本的にはGithub APIを使って愚直に実装しているだけです。
上記のRubyスクリプトの実行には以下の環境変数とが必要となります。Github API用のURLも適宜変更して下さい
Githubのトークン情報は直接含めず、CIのシークレット環境変数などを使って下さい。
-
GITHUB_API_TOKEN ... GithubへのコメントするためのTokenを指定して下さい
- 個人アクセストークンを使用する - GitHub Docsを参照して取得してください
-
INSTALL_PAGE_URL ... 生成したバイナリへのリンクURLを指定して下さい
- 今回はCIでのビルドやデプロイまでは言及しないので省略しますが、アプリのCIは個人的にはBitrise推しです。
ここまで組むことができればDependabotが発行したPullRequestに以下のようなコメントを残すことが出来ます。
どのブランチを統合したのかと統合されたバイナリをどのPullRequestからも参照することが出来るようになります。
Dependabotの実行後にCIでスクリプトとビルドを定時実行させる
私のプロジェクトではDependabotの実行を月曜9:00に行っているため、このスクリプトを含んだCIの実行は月曜10:00に行うように調整しています。
以下はBitriseでの設定例です。このあたりはお好みで調整して下さい。
補足というか注意点
最初のDependabotのブランチを統合するスクリプトはGitのブランチ情報を基準にしてますが、後のコメントする際のスクリプトはPullRequest基準で検索をかけるので、(基本的にはないと思いますが)PullRequestが発行されていないDependabotブランチが存在する場合や、ビルド中にブランチやPRに変更を加えた場合はコメントの内容とバイナリの実態にズレが生じます。
また、dependabot/
という文字列でブランチ名を前方一致検索しているので、これに該当するブランチを手動で作った際に誤作動を起こすのでもう少し厳密な判定ロジックを設けるべきかもしれないです(横着感)
おわり
結構力技でしたが、ここまでやることで毎週の動作確認が1度で済ませられるのでコスト削減につながるかと思います。
小さなプログラムではそこまでライブラリが多くないと思うのでここまでする必要性も感じないかもしれませんが、10万行を超えてくる規模だと導入されているライブラリの数も膨大になり動作確認コストも馬鹿にできないので、このような対策を講じることでDependabotを最大限有効活用することが出来るようになりました。
また、今回DependabotをCI上で統合するお話ですが、スクリプトを書いて気合で統合してなんとかするという部分の話だけでCI側の話がほとんど出来ていないのでどこかでお話できたらと思います。