以下の内容はhttps://sue445.hatenablog.com/entry/2025/10/26/121451より取得しました。


OSS開発の刺し身タンポポ作業を解消するためにsashimi_tanpopoを作った

sashimi_tanpopoについて

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

github.com

この例を見てもらうのが手っ取り早いと思います。

# 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 というのを作ってました。

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も同様ですね)

# node.yml
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

  # 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でメンテできるようになったので詳しくはこれを見てください。

昨今ではOSSでもGitHub ActionsやGitLab CIで使いやすくするような再利用可能なコンポーネントを提供することが多いため、それぞれのCIサービス用にコンポーネントを作りました。

余談

名前について

いい感じの名前を思いつくまでが一番大変でしたw

いくつかあった候補の中で一番しっくりきたのがsashimi_tanpopoだったのでこの名前にしました

余談ですが他の命名候補は下記でした。

  • sashitan(刺し身タンポポの略)
  • kobitosan(小人さんが寝てる間に仕事をしてくれるイメージ)
  • senju_kannon(千手観音のようにファイルを編集するイメージ)

リポジトリをたくさん作った

今回はgem本体以外にも色々リポジトリを作ったので色々大変でした...(GitHubとGitLabで計6つ)

GitHub *6

GitLab




以上の内容はhttps://sue445.hatenablog.com/entry/2025/10/26/121451より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14