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

この例を見てもらうのが手っ取り早いと思います。
# recipe.rb 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
# Update local app files using recipe.rb $ sashimi_tanpopo local --target-dir=/path/to/app --params=ruby_version:3.4.5 /path/to/recipe.rb # Update local app files using recipe.rb and create Pull Request $ 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 というのを作ってました。
自分だけが使うなら別にいいんですが、OSSではあるものの特にライブラリ化とかはしていなかったため同じような問題に困ってる人がいる場合に勧めづらいのが難点でした。
そのため、gem化して他の人が使いやすくしました。
作る時に考えたこと
採用言語について
採用言語についてはいくつか考えました
- Go
- Ruby
- mruby
色々考えた結果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も同様ですね)
# node.yml ruby_version: "3.4"
実行時にファイル内にパラメータが静的に存在する必要があるので、実行時に動的にパラメータを渡したい場合にはちょっとした工夫が必要でした。
僕はmyapp_version_upgraderだと下記のように動的にnode.ymlを作っていました。
- https://github.com/sue445/myapp_version_upgrader/blob/main/lib/base_runner.rb#L44-L56
- https://github.com/sue445/myapp_version_upgrader/blob/main/lib/ruby_update_runner.rb#L19-L28
今回のユースケースだと実行時にパラメータを引数できるようにしたかったのでツール側でサポートするようにしました。
ちなみに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 # Fetch RUBY_PATCHLEVEL from https://github.com/ruby/ruby/blob/master/version.h 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でメンテできるようになったので詳しくはこれを見てください。
- https://github.com/sue445/sashimi_tanpopo-personal
- https://gitlab.com/sue445/sashimi_tanpopo-personal-gitlab
昨今ではOSSでもGitHub ActionsやGitLab CIで使いやすくするような再利用可能なコンポーネントを提供することが多いため、それぞれのCIサービス用にコンポーネントを作りました。
- https://github.com/marketplace/actions/sashimi_tanpopo_action
- https://gitlab.com/explore/catalog/sue445/sashimi_tanpopo-components
余談
名前について
いい感じの名前を思いつくまでが一番大変でしたw
いくつかあった候補の中で一番しっくりきたのがsashimi_tanpopoだったのでこの名前にしました
余談ですが他の命名候補は下記でした。
リポジトリをたくさん作った
今回はgem本体以外にも色々リポジトリを作ったので色々大変でした...(GitHubとGitLabで計6つ)

GitLab

*1:mruby版のgem
*2:https://github.com/sue445/sashimi_tanpopo/blob/v0.2.0/lib/sashimi_tanpopo/dsl.rb
*3:https://github.com/itamae-kitchen/itamae/pull/241#issuecomment-313833899
*4:https://github.com/rails/thor/wiki/Method-Options#types-for-method_options
*5:https://github.com/sue445/sashimi_tanpopo-personal/blob/7aa1e99353fd3d83811e9ded8e9854a3b2596535/recipes/upgrade_ruby.rb#L1-L18
*6:sashimi_tanpopo-personal-privateはprivateリポジトリにある自作ツールやTerraformリポジトリの管理用