sashimi_tanpopoについて
ファイルを特定のルールで編集して差分があった時にPull RequestやMerge Requestを作るためのgemです。
github.com

この例を見てもらうのが手っ取り早いと思います。
update_file ".ruby-version" do |content|
content.gsub!(/^[\d.]+$/, params[:ruby_version])
end
update_file "Dockerfile" do |content|
content.gsub!(/^FROM ruby:([\d.]+)$/, %Q{FROM ruby:#{params[:ruby_version]}})
end
@ruby_minor_version = params[:ruby_version].to_f
update_file ".rubocop.yml" do |content|
content.gsub!(/TargetRubyVersion: ([\d.]+)/, "TargetRubyVersion: #{@ruby_minor_version}")
end
update_file ".github/workflows/*.yml" do |content|
content.gsub!(/ruby-version: "(.+)"/, %Q{ruby-version: "#{params[:ruby_version]}"})
end
$ sashimi_tanpopo local --target-dir=/path/to/app --params=ruby_version:3.4.5 /path/to/recipe.rb
$ sashimi_tanpopo github --target-dir=/path/to/app --params=ruby_version:3.4.5 \
--message="Upgrade to Ruby 3.4.5" --github-repository=yourname/yourrepo --pr-title="Upgrade to Ruby 3.4.5" \
--pr-source-branch=ruby_3.4.5 --pr-target-branch=main --pr-draft /path/to/recipe.rb
10月頭の3連休辺りから作り始めたので開発期間は3週間くらいだと思います。(gem以外のも部分も含む)
なぜ作ったか
1人で複数のOSSをメンテしてる場合、複数のリポジトリで一斉に同じ変更をしたいことが多々あります。
僕はこういう作業を「OSS開発の刺し身タンポポ作業」と呼んでます。
そのために https://github.com/sue445/myapp_version_upgrader というのを作ってました。
sue445.hatenablog.com
自分だけが使うなら別にいいんですが、OSSではあるものの特にライブラリ化とかはしていなかったため同じような問題に困ってる人がいる場合に勧めづらいのが難点でした。
そのため、gem化して他の人が使いやすくしました。
作る時に考えたこと
採用言語について
採用言語についてはいくつか考えました
- Go
- メリット:ビルド済みのシングルバイナリを配布できるので利用者側の実行環境を問わない、GitHubやGitLabの利用するためのAPIクライアントがそれぞれある
- デメリット:Goの中で柔軟なDSLを作るのが大変
- Ruby
- メリット:柔軟な言語仕様で内部DSLを作りやすい、GitHubやGitLabの利用するためのAPIクライアントがそれぞれある
- デメリット:利用者の実行環境に別途ランタイムとしてRubyが必要
- mruby
- メリット:ビルド済のシングルバイナリを配布できる、柔軟な言語仕様で内部DSLを作りやすい
- デメリット:GitHubやGitLabの利用するためのAPIクライアントがmgem*1で見つからなかったので自作する必要がある
色々考えた結果Rubyで作ることにしました。
利用者側の実行環境に別途ランタイムとしてRubyが必要な問題に関してはgemがインストール済のDockerイメージを配布することにしました。
https://github.com/sue445/sashimi_tanpopo/pkgs/container/sashimi_tanpopo
Itamaeを使うかどうか
sashimi_tanpopoの前身となったmyapp_version_upgraderではDSLでファイルを編集する部分に https://github.com/itamae-kitchen/itamae を利用していました。ちなみに僕はItamaeのメンテナでもあります。
Itamaeは非常に便利なツールなのですが、今回のユースケースだと下記の問題がありました。
1. オーバースペック
Itamaeはインフラ構成を管理するためのツールでsshした先でpackageのインストールやserviceの有効化なども行ってくれます。
しかしsashimi_tanpopoではローカルのファイルを編集することができればいいので、Itamaeに含まれているインフラ操作に関する大多数の依存が不要でした。
そのため、Itamaeで実装されていたようなDSLを自分で実装しました。*2
DSLを自分で実装したことによるメリットもあったのでそれは後で書きます。
2. Itamaeは動的にパラメータを設定できない
Itamaeでパラメータを渡す場合には下記のようなnodeファイルを利用することになります。(この辺の仕様はChefやAnsibleも同様ですね)
ruby_version: "3.4"
実行時にファイル内にパラメータが静的に存在する必要があるので、実行時に動的にパラメータを渡したい場合にはちょっとした工夫が必要でした。
僕はmyapp_version_upgraderだと下記のように動的にnode.ymlを作っていました。
今回のユースケースだと実行時にパラメータを引数できるようにしたかったのでツール側でサポートするようにしました。
ちなみにnode.ymlでerb対応するようなパッチもあったのですが、Itamaeの方針にあわないという理由でリジェクトされたことがあります *3
Itamaeとの差分
glob対応
Itamaeで特定のディレクトリ配下の複数のファイルに対してレシピを適用したい場合、下記のような工夫が必要でした。
Dir.glob(".github/workflows/*.yml").each do |workflow_file|
file workflow_file do
action :edit
block do |content|
content.gsub!(/ruby-version: "(.+)"/, %Q{ruby-version: "#{node[:ruby_version]}"})
end
only_if "ls #{workflow_file}"
end
end
しかし毎回 Dir.glob を手書きするのも大変なのでsashimi_tanpopoでは下記のようにglob記法に対応することで複数ファイルに対応しました。
update_file ".github/workflows/*.yml" do |content|
content.gsub!(/ruby-version: "(.+)"/, %Q{ruby-version: "#{params[:ruby_version]}"})
end
実行時引数で渡せるようにした
動的にパラメータを渡したかったのでsashimi_tanpopoでは下記のように実行時のパラメータとして自然に渡せるようにしました。
sashimi_tanpopo github --params ruby_version:3.4
余談: key:value って記法は個人的に若干違和感あるんですが、https://github.com/rails/thor がそういう仕様なので踏襲した感じです。 *4
その他の工夫ポイント
レシピファイル内で普通にRubyが書ける
レシピファイル(ファイルを編集するルールを定義したファイル)は普通のRubyファイルなのでRubyの文法でコードが書けます。
下記のようにレシピファイル内でローカル変数やインスタンスやメソッドを定義することでいい感じにすることができます。 *5
def ruby_version_with_patch_level(ruby_version)
v = ruby_version.split(".")
return nil unless v.size == 3
git_tag = "v" + ruby_version.gsub(".", "_")
version_h = URI.open("https://raw.githubusercontent.com/ruby/ruby/#{git_tag}/version.h").read
ruby_patchlevel = /^#define\s+RUBY_PATCHLEVEL\s+(\d+)/.match(version_h).to_a[1]
"#{ruby_version}p#{ruby_patchlevel}"
end
v = params[:ruby_version].split(".")
@is_full_version = v.count == 3
@ruby_minor_version = "#{v[0]}.#{v[1]}"
@gcp_runtime_version = "ruby#{v[0]}#{v[1]}"
@ruby_version_with_patch_level = ruby_version_with_patch_level(params[:ruby_version])
update_file ".rubocop.yml" do |content|
content.gsub!(/TargetRubyVersion: ([\d.]+)/, "TargetRubyVersion: #{@ruby_minor_version}")
end
CIで使いやすくした
冒頭の例を見ただけだと大したことないかと思うかもしれないですが、sashimi_tanpopoはCIで動かすことで真価を発揮します。
こういうファイル を編集するだけで複数のリポジトリに対してPull RequestやMerge Requestをいい感じに作ることができます。
自分がメンテしてるOSSに関してはある程度sashimi_tanpopoでメンテできるようになったので詳しくはこれを見てください。
昨今ではOSSでもGitHub ActionsやGitLab CIで使いやすくするような再利用可能なコンポーネントを提供することが多いため、それぞれのCIサービス用にコンポーネントを作りました。
余談
名前について
いい感じの名前を思いつくまでが一番大変でしたw
いくつかあった候補の中で一番しっくりきたのがsashimi_tanpopoだったのでこの名前にしました
余談ですが他の命名候補は下記でした。
- sashitan(刺し身タンポポの略)
- kobitosan(小人さんが寝てる間に仕事をしてくれるイメージ)
- senju_kannon(千手観音のようにファイルを編集するイメージ)
今回はgem本体以外にも色々リポジトリを作ったので色々大変でした...(GitHubとGitLabで計6つ)
GitHub *6

GitLab
