COLOPL Tech Blog

コロプラのエンジニアブログです

超簡単!社内ツール向け Homebrew + GitHub 活用術

こんにちは、エンジニアの工藤 @zeriyoshi です。

まえがき: 休職と PHP Conference Japan 2025 について

5月頃からフィジカル面で体調が悪く休職・入院しておりましたが、病気の特定と対処のための服薬治療によって症状が改善し、8月より復職させていただきました。

採択いただいていた PHP Conference Japan 2025 での 登壇 を辞退することになってしまい、大変ご迷惑をおかけいたしました。スピーカーとしてだけではなく、イベント参加者や運営スタッフなどとして引き続きカンファレンスなどのイベントに積極的に参加し協力していきたいと思っておりますので、今後とも何卒よろしくお願いいたします。

Homebrew

みなさん、 Homebrew は使っていますか?仕事で macOS を使っている人であればほとんどが使っているのではないかと思われます。

Homebrew はコマンドなど実行ファイルを配布するのにとても便利な手段で、例えば私は zsh よりも bash 派なのもあり Homebrew で bash をインストールしてデフォルトシェルに指定したり、 GitHub を操作する gh コマンドなどをインストールして使っています。更新も brew upgrade だけで完了するので、とても便利です。

しかし、社内でツールを配布するとなるとどうでしょうか。仕事のオペレーションに最適化された内製ツールを配布したいとなっても Homebrew ほどシンプルに行える方法はなかなか存在せず、ある人にとっては便利であっても別の人は不要なものもあるわけで、自分が必要なものだけを選んでいれることができ、アップデートも利用者が好きなタイミングで行え、かつ自動化できる Homebrew は開発者体験を効果的に向上してくれます。

Homebrew tap によるリポジトリ追加

Homebrew には tap と呼ばれる外部 git リポジトリ (Formula と呼ばれます) を追加する機能があります。例えば Terraform で有名な HashiCorp自前のリポジトリ を公開しており、 brew tap で追加することで最新の Terraform を導入・更新できるようになります。

Internal, Private リポジトリでの Homebrew tap

Homebrew は internal (組織所属者のみ) や private (権限付与者のみ) のリポジトリの追加にも対応しており、適切に SSH アクセスができれば利用できるようになっています。

しかし、自前で Homebrew の Formula リポジトリを維持管理するのは思っているよりも大変です。具体的には

  1. ツールごとの定義ファイルを Ruby DSL で記述する必要があること
  2. ツールのバージョンアップの度に Ruby DSL の定義も変更する必要があること
  3. Assets にあるバイナリが internal, private なリポジトリだとそのままではダウンロードできず、インストールできないこと

などが挙げられます。特にバイナリのダウンロードができないのが致命的です。

考えられる対応策として、以下のようなものが挙げられます。

Formula リポジトリ自体にツールのバイナリをそのまま配置する

Pros

  • 単一のリポジトリに全てが完結して揃った状態にできる

Cons

  • git リポジトリが肥大化し、管理が煩雑になる
  • ツール側から Formula リポジトリを操作しなければならず、権限管理が煩雑
    • 最もセキュアな形を取るのなら Formula リポジトリを手動で更新するしかない
      • 手間がかかるため使いづらい可能性

IP アドレス等で接続元制限をかけた Amazon S3 などにバイナリを配置しておく

Pros

  • 比較的シンプルな構造でわかりやすい

Cons

  • IP アドレスによる制限はモダンでなく管理が煩雑
    • リモートワーク時には VPN への接続が必須になり手間
  • GitHub Actions でビルドした結果を毎回 S3 にアップロードする必要が生じる
    • アップロード時の鍵などの共有に課題
    • アップロードしたあと Formula の url の向き先をアップロード後のものに更新しなければならない
      • 手動ですると手間がかかる

Homebrew Download Strategy

Homebrew は様々な機能拡張が行えるようになっており、例えばバイナリファイルをダウンロードしたり SHA-256 の検証をしたりするなどの部分をベースクラスを継承したクラスを作成することでカスタマイズできるようになっています。

通常、バイナリのダウンロードは CurlDownloadStrategy を使って行われます。そこで今回はこのクラスを継承し、以下の要件を満たすクラスを独自で実装しました。

  • org/repository を指定することで必須のフィールドを自動で設定してくれる
  • 対象の org/repository の Release 一覧を取得し、 gh コマンドを用いて draft ではない最新のリリース (SemVer 準拠) を取得する
  • Release の Assets にある実行バイナリを gh コマンドを用いてダウンロードする

Homebrew はドキュメンテーションがしっかりしており、これらを自身でも読み解きつつ、 Claude Code のアシストも得ながら GitHubGHInternalReleaseDownloadStrategy を作成しました。

ソース: lib/github_gh_internal_release_download_strategy.rb

require 'json'
require 'digest'
require 'tmpdir'
require 'open3'

class GitHubGHInternalReleaseDownloadStrategy < CurlDownloadStrategy
  URL_PATTERN = %r{https://github.com/([^/]+)/([^/]+)/releases/download/([^/]+)/(\S+)}.freeze
  GH_EXECUTABLE_PATHS = [
    "/opt/homebrew/bin/gh",
    "/usr/local/bin/gh",
    "/home/linuxbrew/.linuxbrew/bin/gh"
  ].freeze
  CHECKSUM_FILE_PATTERNS = %w[checksums.txt SHA256SUMS sha256sums.txt checksums.sha256].freeze
  RELEASE_LIMIT = 50
  LATEST_TAG = "latest"
  VERSION_PREFIX = "v"
  SHA256_PATTERN = /^SHA256\s*\((.+?)\)\s*=\s*([a-f0-9]{64})$/i.freeze
  SHA256_ALTERNATE_PATTERN = /^([a-f0-9]{64})\s+\*?(.+)$/i.freeze

  attr_reader :latest_release_info

  def initialize(url, name, version, **meta)
    @meta = meta
    @auto_verify_sha256 = meta.fetch(:auto_verify_sha256, true)
    @original_url = url
    
    parse_github_url(url)
    setup_github_cli
    
    if @tag == LATEST_TAG
      @latest_release_info = fetch_latest_stable_release
      @tag = @latest_release_info[:tag]
      url = build_download_url(@tag)
    end
    
    super(url, name, version, **meta)
    parse_url_components
    
    if @latest_release_info && (version.nil? || version.to_s.empty?)
      @version = Version.new(@latest_release_info[:version])
    end
  end

  class << self
    def get_latest_release_info(repo_url, asset_pattern = nil)
      strategy = new(repo_url, nil, nil, use_latest_release: true, skip_validation: true)
      strategy.get_latest_release_info(asset_pattern)
    end

    def get_latest_version_for_formula(repo)
      gh_command = find_gh_executable
      return nil unless gh_command
      
      releases = fetch_releases(gh_command, repo)
      return nil unless releases
      
      extract_latest_version(releases)
    end

    def get_sha256_for_binary_set(repo, binary_names = {})
      gh_command = find_gh_executable
      return {} unless gh_command
      
      releases = fetch_releases(gh_command, repo)
      return {} unless releases
      
      tag = extract_latest_tag(releases)
      return {} unless tag
      
      checksums = collect_checksums(repo, tag, binary_names, gh_command)
      checksums
    end

    private

    def find_gh_executable
      GH_EXECUTABLE_PATHS.each do |path|
        return path if executable_exists?(path)
      end
      
      system("gh version > /dev/null 2>&1") ? "gh" : nil
    end

    def executable_exists?(path)
      File.exist?(path) && File.executable?(path)
    end

    def fetch_releases(gh_command, repo)
      command = build_release_list_command(gh_command, repo)
      stdout, stderr, status = Open3.capture3(*command)
      return nil unless status.success?
      
      JSON.parse(stdout)
    end

    def build_release_list_command(gh_command, repo)
      [gh_command, "release", "list", "--repo", repo, "--limit", RELEASE_LIMIT.to_s, 
       "--json", "tagName,publishedAt,isPrerelease"]
    end

    def extract_latest_version(releases)
      release = find_latest_stable_release(releases)
      return nil unless release
      
      release["tagName"].gsub(/^#{VERSION_PREFIX}/, "")
    end

    def extract_latest_tag(releases)
      release = find_latest_stable_release(releases)
      release&.dig("tagName")
    end

    def find_latest_stable_release(releases)
      stable_releases = releases.reject { |r| r["isPrerelease"] }
      stable_releases.max_by { |r| parse_version(r["tagName"]) }
    end

    def parse_version(tag)
      cleaned_tag = tag.gsub(/^#{VERSION_PREFIX}/, "")
      Gem::Version.new(cleaned_tag)
    rescue ArgumentError
      Gem::Version.new("0.0.0")
    end

    def collect_checksums(repo, tag, binary_names, gh_command)
      checksums = fetch_checksums_from_file(repo, tag, binary_names)
      
      if checksums.empty?
        checksums = calculate_checksums_directly(repo, tag, binary_names, gh_command)
      end
      
      checksums
    end

    def fetch_checksums_from_file(repo, tag, binary_names)
      checksums = {}
      binary_names.each do |platform, filename|
        sha256 = get_sha256_for_asset(repo, tag, filename)
        checksums[platform] = sha256 if sha256
      end
      checksums
    end

    def calculate_checksums_directly(repo, tag, binary_names, gh_command)
      checksums = {}
      binary_names.each do |platform, filename|
        sha256 = calculate_sha256_by_downloading(repo, tag, filename, gh_command)
        checksums[platform] = sha256 if sha256
      end
      checksums
    end

    def calculate_sha256_by_downloading(repo, tag, filename, gh_command)
      Dir.mktmpdir do |temp_dir|
        command = build_download_command(gh_command, repo, tag, filename, temp_dir)
        stdout, stderr, status = Open3.capture3(*command)
        return nil unless status.success?
        
        downloaded_file = File.join(temp_dir, filename)
        return nil unless File.exist?(downloaded_file)
        
        Digest::SHA256.file(downloaded_file).hexdigest
      end
    end

    def build_download_command(gh_command, repo, tag, filename, dir)
      [gh_command, "release", "download", tag,
       "--repo", repo,
       "--pattern", filename,
       "--dir", dir,
       "--clobber"]
    end

    def get_sha256_for_asset(repo, tag, filename)
      gh_command = find_gh_executable
      return nil unless gh_command
      
      Dir.mktmpdir do |temp_dir|
        checksums_content = download_checksums_file(gh_command, repo, tag, temp_dir)
        return nil unless checksums_content
        
        parse_checksum_for_file(checksums_content, filename)
      end
    end

    def download_checksums_file(gh_command, repo, tag, temp_dir)
      CHECKSUM_FILE_PATTERNS.each do |pattern|
        command = build_download_command(gh_command, repo, tag, pattern, temp_dir)
        stdout, stderr, status = Open3.capture3(*command)
        
        if status.success?
          checksums_file = File.join(temp_dir, pattern)
          return File.read(checksums_file) if File.exist?(checksums_file)
        end
      end
      nil
    end

    def parse_checksum_for_file(content, filename)
      content.each_line do |line|
        line = line.strip
        next if line.empty? || line.start_with?("#")
        
        if line =~ SHA256_PATTERN
          return $2 if $1 == filename
        elsif line =~ SHA256_ALTERNATE_PATTERN
          return $1 if $2 == filename
        end
      end
      nil
    end
  end

  def get_latest_release_info(asset_pattern = nil)
    return @latest_release_info if @latest_release_info
    
    setup_github_cli
    @latest_release_info = fetch_latest_stable_release
    
    if asset_pattern
      @latest_release_info[:assets] = fetch_assets_info(@latest_release_info[:tag], asset_pattern)
    end
    
    @latest_release_info
  end

  private

  def _fetch(url:, resolved_url:, timeout:)
    Dir.mktmpdir do |temp_dir|
      checksums_sha256 = fetch_checksums(temp_dir) if @auto_verify_sha256
      
      download_release_asset(temp_dir)
      
      downloaded_file = File.join(temp_dir, @filename)
      verify_and_move_file(downloaded_file, checksums_sha256)
    end
  end

  def fetch_checksums(temp_dir)
    checksums_content = download_checksums_file(temp_dir)
    return nil unless checksums_content
    
    parse_checksums(checksums_content)
  end

  def download_checksums_file(temp_dir)
    CHECKSUM_FILE_PATTERNS.each do |pattern|
      content = attempt_checksum_download(pattern, temp_dir)
      if content
        ohai "Found checksums file: #{pattern}"
        return content
      end
    end
    nil
  end

  def attempt_checksum_download(pattern, temp_dir)
    command = build_release_download_command(pattern, temp_dir)
    stdout, stderr, status = Open3.capture3(*command)
    
    return nil unless status.success?
    
    checksums_file = File.join(temp_dir, pattern)
    File.exist?(checksums_file) ? File.read(checksums_file) : nil
  end

  def parse_checksums(content)
    checksums = {}
    content.each_line do |line|
      line = line.strip
      next if line.empty? || line.start_with?("#")
      
      filename, sha256 = parse_checksum_line(line)
      checksums[filename] = sha256 if filename && sha256
    end
    checksums
  end

  def parse_checksum_line(line)
    if line =~ SHA256_PATTERN
      [$1, $2]
    elsif line =~ SHA256_ALTERNATE_PATTERN
      [$2, $1]
    else
      [nil, nil]
    end
  end

  def download_release_asset(temp_dir)
    command = build_release_download_command(@filename, temp_dir)
    
    ohai "Downloading #{@filename} from #{@owner}/#{@repo} (tag: #{@tag})..."
    opoo "Using gh at: #{@gh_command}" if Homebrew.verbose?
    
    stdout, stderr, status = Open3.capture3(*command)
    
    unless status.success?
      raise CurlDownloadStrategyError, "Failed to download: #{stderr}"
    end
  end

  def build_release_download_command(pattern, dir)
    [@gh_command, "release", "download", @tag,
     "--repo", "#{@owner}/#{@repo}",
     "--pattern", pattern,
     "--dir", dir,
     "--clobber"]
  end

  def verify_and_move_file(downloaded_file, checksums_sha256)
    unless File.exist?(downloaded_file)
      raise CurlDownloadStrategyError, "Downloaded file not found at expected location"
    end
    
    calculated_sha256 = Digest::SHA256.file(downloaded_file).hexdigest
    
    verify_sha256(calculated_sha256, checksums_sha256) if @auto_verify_sha256
    
    FileUtils.mv(downloaded_file, temporary_path)
    
    @latest_release_info[:sha256] = calculated_sha256 if @latest_release_info
  end

  def verify_sha256(calculated_sha256, checksums_sha256)
    if checksums_sha256
      expected_sha256 = checksums_sha256[@filename]
      if expected_sha256
        verify_checksum_match(calculated_sha256, expected_sha256)
      else
        opoo "No SHA256 found in checksums file for #{@filename}"
      end
    else
      opoo "SHA256 calculated but not verified (no checksums file): #{calculated_sha256}"
    end
  end

  def verify_checksum_match(calculated, expected)
    if calculated.downcase != expected.downcase
      raise CurlDownloadStrategyError, 
            "SHA256 mismatch for #{@filename}\nExpected: #{expected}\nActual: #{calculated}"
    end
    ohai "SHA256 verified: #{calculated}"
  end

  def parse_github_url(url)
    unless url =~ URL_PATTERN
      raise CurlDownloadStrategyError, 'Invalid url pattern for GitHub Release.'
    end
    
    _, @owner, @repo, @tag, @filename = *url.match(URL_PATTERN)
  end

  def parse_url_components
    parse_github_url(@url)
  end

  def build_download_url(tag)
    @original_url.gsub(/\/releases\/download\/#{LATEST_TAG}\//, "/releases/download/#{tag}/")
  end

  def setup_github_cli
    @gh_command = find_gh_command
    validate_gh_authentication
  end

  def find_gh_command
    candidates = build_gh_candidates
    
    candidates.uniq.each do |path|
      if File.exist?(path) && File.executable?(path)
        opoo "Found gh at: #{path}" if Homebrew.verbose?
        return path
      end
    end
    
    return "gh" if system("gh version > /dev/null 2>&1")
    
    raise_gh_not_found_error(candidates)
  end

  def build_gh_candidates
    candidates = []
    
    candidates << "#{HOMEBREW_PREFIX}/bin/gh" if defined?(HOMEBREW_PREFIX)
    candidates.concat(GH_EXECUTABLE_PATHS)
    candidates << "#{ENV["HOMEBREW_PREFIX"]}/bin/gh" if ENV["HOMEBREW_PREFIX"]
    
    which_result = which("gh")
    candidates << which_result if which_result
    
    ENV["PATH"].split(":").each do |path|
      gh_path = File.join(path, "gh")
      candidates << gh_path if File.exist?(gh_path)
    end
    
    candidates
  end

  def raise_gh_not_found_error(candidates)
    raise CurlDownloadStrategyError, <<~EOS
      GitHub CLI (gh) is not installed or not found in expected locations.
      
      Searched locations:
      #{candidates.join("\n  ")}
      
      Please install GitHub CLI with:
        brew install gh
      
      Then authenticate with:
        gh auth login
    EOS
  end

  def validate_gh_authentication
    validate_gh_version
    validate_gh_auth_status
  end

  def validate_gh_version
    stdout, stderr, status = Open3.capture3(@gh_command, "version")
    
    unless status.success?
      raise CurlDownloadStrategyError, <<~EOS
        GitHub CLI (gh) command failed to execute.
        Command: #{@gh_command} version
        Error: #{stderr}
        
        Please ensure GitHub CLI is properly installed:
          brew install gh
      EOS
    end
  end

  def validate_gh_auth_status
    stdout, stderr, status = Open3.capture3(@gh_command, "auth", "status")
    
    unless status.success?
      raise CurlDownloadStrategyError, <<~EOS
        GitHub CLI is not authenticated.
        
        Please authenticate with:
          gh auth login
        
        Error: #{stderr}
      EOS
    end
  end

  def fetch_latest_stable_release
    releases = fetch_all_releases
    latest = find_latest_stable(releases)
    
    unless latest
      raise CurlDownloadStrategyError, "No stable releases found"
    end
    
    build_release_info(latest)
  end

  def fetch_all_releases
    command = [@gh_command, "release", "list", 
               "--repo", "#{@owner}/#{@repo}", 
               "--limit", RELEASE_LIMIT.to_s,
               "--json", "tagName,publishedAt,isPrerelease"]
    
    stdout, stderr, status = Open3.capture3(*command)
    
    unless status.success?
      raise CurlDownloadStrategyError, "Failed to fetch release list: #{stderr}"
    end
    
    JSON.parse(stdout)
  end

  def find_latest_stable(releases)
    stable_releases = releases.reject { |r| r["isPrerelease"] }
    
    stable_releases.max_by do |release|
      tag = release["tagName"].gsub(/^#{VERSION_PREFIX}/, "")
      parse_version_safe(tag)
    end
  end

  def parse_version_safe(tag)
    Gem::Version.new(tag)
  rescue ArgumentError
    Gem::Version.new("0.0.0")
  end

  def build_release_info(release)
    tag = release["tagName"]
    {
      tag: tag,
      version: tag.gsub(/^#{VERSION_PREFIX}/, ""),
      published_at: release["publishedAt"]
    }
  end

  def fetch_assets_info(tag, pattern)
    assets = fetch_release_assets(tag)
    
    case pattern
    when Hash
      match_multiple_assets(assets, pattern)
    when String
      match_single_asset(assets, pattern)
    else
      format_all_assets(assets)
    end
  end

  def fetch_release_assets(tag)
    command = [@gh_command, "release", "view", tag, 
               "--repo", "#{@owner}/#{@repo}", 
               "--json", "assets"]
    
    stdout, stderr, status = Open3.capture3(*command)
    
    unless status.success?
      raise CurlDownloadStrategyError, "Failed to fetch release assets: #{stderr}"
    end
    
    JSON.parse(stdout)["assets"]
  end

  def match_multiple_assets(assets, patterns)
    matching_assets = {}
    
    patterns.each do |key, asset_pattern|
      asset = find_matching_asset(assets, asset_pattern)
      matching_assets[key] = format_asset_info(asset) if asset
    end
    
    matching_assets
  end

  def match_single_asset(assets, pattern)
    asset = find_matching_asset(assets, pattern)
    asset ? format_asset_info(asset) : {}
  end

  def find_matching_asset(assets, pattern)
    assets.find { |asset| File.fnmatch(pattern, asset["name"]) }
  end

  def format_all_assets(assets)
    assets.map { |asset| format_asset_info(asset) }
  end

  def format_asset_info(asset)
    {
      name: asset["name"],
      download_url: asset["browserDownloadUrl"],
      size: asset["size"]
    }
  end
end

また、より簡単に記述できるようにするために Formula も簡素化した継承クラスを作成しました。

ソース: lib/github_gh_internal_formula.rb

require_relative './github_gh_internal_release_download_strategy'

class GitHubGHInternalFormula < Formula
  def self.repo(name)
    @repo_name = name
  end

  def self.binary(basename)
    @binary_basename = basename
  end

  def self.repo_name
    @repo_name || raise(NotImplementedError, "Subclass must define repo_name using 'repo' method")
  end

  def self.binary_basename
    @binary_basename || raise(NotImplementedError, "Subclass must define binary_basename using 'binary' method")
  end

  def self.test_command_args
    ["-v"]
  end

  def self.binary_names
    @binary_names ||= if respond_to?(:custom_binary_names)
      custom_binary_names
    else
      {
        darwin_amd64: "#{binary_basename}-darwin-amd64",
        darwin_arm64: "#{binary_basename}-darwin-arm64",
        linux_amd64: "#{binary_basename}-linux-amd64",
        linux_arm64: "#{binary_basename}-linux-arm64"
      }
    end.freeze
  end

  def self.checksums
    @checksums ||= GitHubGHInternalReleaseDownloadStrategy.get_sha256_for_binary_set(repo_name, binary_names)
  end

  def self.platform_key
    if OS.mac?
      Hardware::CPU.intel? ? :darwin_amd64 : :darwin_arm64
    else
      Hardware::CPU.intel? ? :linux_amd64 : :linux_arm64
    end
  end

  def self.setup_platform_binary
    key = platform_key
    binary_name = binary_names[key]
    url "https://github.com/#{repo_name}/releases/download/latest/#{binary_name}",
        using: GitHubGHInternalReleaseDownloadStrategy
    sha256 checksums[key] if checksums[key]
  end

  def self.inherited(subclass)
    super
    TracePoint.new(:end) do |tp|
      if tp.self == subclass
        tp.disable
        subclass.class_eval do
          homepage "https://github.com/#{subclass.repo_name}"
          latest_version = GitHubGHInternalReleaseDownloadStrategy.get_latest_version_for_formula(subclass.repo_name)
          version latest_version if latest_version
          if subclass.platform_key
            subclass.setup_platform_binary
          end
        end
      end
    end.enable
  end

  def install
    bin.install self.class.binary_names[self.class.platform_key] => self.class.binary_basename
  end

  def test
    system "#{bin}/#{self.class.binary_basename}", *self.class.test_command_args
  end
end

このクラスを用いて以下のように Formula を作成しました。 toiletコロプラオフィス社内のトイレの空き状況をチェックする自作の くだらない ツールです。

toilet.rb

require_relative './lib/github_gh_internal_formula'

class Toilet < GitHubGHInternalFormula
  desc "COLOPL toilet vacancy checker"
  repo "colopl-indie/toilet"
  binary "toilet"

  def self.test_command_args
    ["--help"]
  end
end

これらを格納した Homebrew tap 用のリポジトリである colopl-indie/homebrew-formulas を作成し、以下のようなディレクトリ構成としました。

.
├── lib
│   ├── github_gh_internal_formula.rb
│   └── github_gh_internal_release_download_strategy.rb
├── README.md
└── toilet.rb

実際の使用方法

この方法では常に gh コマンドを必要とするので、事前に Homebrew で gh コマンドをインストールし、権限のある Organization アカウントでログインしておく必要があります。

セットアップが終わったら後は普通に Homebrew でインストールするだけです。 toilet ならこんな感じです (Homebrew 公式に同名のものがあるとそちらが優先されてしまうので、フルネームでインストールするのを推奨します)

$ brew install colopl-indie/formulas/toilet
~~~ 省略 ~~~
$ toilet --help
     _
    | |
 ___| | COLOPL
(    .'     Toilet
 )  (
COLOPL Toilet Utility v2.0.3

Usage:
  toilet [command]

Available Commands:
  check       Check available restrooms
  completion  Generate the autocompletion script for the specified shell
  help        Help about any command
  watch       Watch availability

Flags:
  -h, --help   help for toilet

Use "toilet [command] --help" for more information about a command.

更新の自動化

ちなみにこの方法では、常に対象となるリポジトリの最新のリリースを取得し利用するため Release さえ適切に行えていればツールのバージョンアップの度に Homebrew リポジトリ側を更新する必要はありません。 逆に言えば古いバージョンを入れられないということにはなりますが、まあ社内ツールなのでその時はリリースを削除するだけで大丈夫です。

おまけ: GitHub Actions を用いた Go 製ツールの自動リリース

先ほどの toilet では、 GitHub Actions にて vX.Y.Z でタグが切られており、正式に SemVer の要件を満たした場合に自動的にリリースを作成しバイナリをアップロードするようにしています。 SemVer の要件を満たさない (例: vX.Y.Z-pre 等) 場合は draft としてリリースします。

以下は toilet で自動リリースするための GitHub Actions の定義の例です。

name: Build and Release

on:
  push:
    tags:
      - 'v*'

concurrency:
  group: release-${{ github.ref }}
  cancel-in-progress: false

permissions:
  contents: read

jobs:
  build:
    name: Build binaries
    runs-on: ubuntu-24.04
    strategy:
      matrix:
        goos: [linux, darwin, windows]
        goarch: [amd64, arm64]
    steps:
    - name: Checkout code
      uses: actions/checkout@v5
      with:
        fetch-depth: 0
    - name: Set up Go
      uses: actions/setup-go@v6
      with:
        go-version: stable
        cache: true
    - name: Verify dependencies
      run: |
        go mod verify
        go mod download
    - name: Replace placeholders
      run: |
        sed -i 's#%COLOPL_TOILET_API%#${{ secrets.COLOPL_TOILET_API }}#g' "toilet.go"
        sed -i 's#%COLOPL_TOILET_VERSION%#${{ github.ref_name }}#g' "toilet.go"
    - name: Build binary
      env:
        GOOS: ${{ matrix.goos }}
        GOARCH: ${{ matrix.goarch }}
        CGO_ENABLED: 0
      run: |
        BINARY_NAME="toilet-${{ matrix.goos }}-${{ matrix.goarch }}"
        if test "${{ matrix.goos }}" = "windows"; then
          BINARY_NAME="${BINARY_NAME}.exe"
        fi
        go build -v -ldflags="-s -w" -o "${BINARY_NAME}" "toilet.go"
    - name: Upload artifacts
      uses: actions/upload-artifact@v4
      with:
        name: toilet-${{ matrix.goos }}-${{ matrix.goarch }}
        path: toilet-*
        retention-days: 1
        if-no-files-found: error
  release:
    name: Create Release and Upload Assets
    needs: build
    runs-on: ubuntu-24.04
    permissions:
      contents: write
    steps:
    - name: Checkout code
      uses: actions/checkout@v5
      with:
        fetch-depth: 0
    - name: Download all artifacts
      uses: actions/download-artifact@v5
      with:
        path: artifacts/
    - name: Check if tag follows semantic versioning pattern
      id: check_tag
      run: |
        TAG_NAME="${{ github.ref_name }}"
        if echo "${TAG_NAME}" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
          echo "draft=false" >> $GITHUB_OUTPUT
        else
          echo "draft=true" >> $GITHUB_OUTPUT
        fi
    - name: Create Release
      id: create_release
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        gh release create "${{ github.ref_name }}" \
          --title "Release ${{ github.ref_name }}" \
          --generate-notes \
          --draft="${{ steps.check_tag.outputs.draft }}"
        sleep 3
        gh release view "${{ github.ref_name }}"
    - name: Upload Release Assets
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        for FILE in artifacts/*/toilet-*; do
          if test -f "${FILE}"; then
            gh release upload "${{ github.ref_name }}" "${FILE}" --clobber
          fi
        done
    - name: Generate and upload checksums
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
      run: |
        cd "artifacts"
        find . -type f -name 'toilet-*' -exec sh -c 'echo "$(sha256sum "$1" | cut -d" " -f1)  $(basename "$1")"' _ {} \; > "checksums.txt"
        gh release upload "${{ github.ref_name }}" "checksums.txt" --clobber

おわりに

この方法により、以下の課題を解決できました。

  • Internal, Private なツールのリポジトリにおけるバイナリリリースフローの統一
    • GitHub の Release 機能を用いて自動的に Homebrew にもそれを反映できる環境に
  • SHA-256 チェックサムを利用したセキュアなバイナリ配布
  • GitHub 公式 CLI である gh コマンドのフル活用
    • モダンな認証方式でより安全かつ使いやすい仕組みを実現
  • シンプルな構成による保守コストの低減

最後になりますが、体調不良により各所にご迷惑をおかけし大変申し訳ございませんでした。挽回できるようバリバリ活動していきますので、引き続きよろしくお願いいたします!

おまけのおまけ: toilet について

コロプラオフィスにはトイレの空き状況チェックができる環境が用意されており、 toiletAPI 経由でそれを参照してチェックしています。 もともと社内向けポータルに表示機能があったのですが、エンジニアとしてはコマンドラインの方が使いやすいなと思って移植しました。

コロプラオフィスは現状 5F と 6F のフロアがあるのですが、例えば以下のようにすることで 6F のトイレが空いたら通知してくれます。 macOS なら通知センターにも飛びます。

$ toilet watch --targets=6F
     _
    | |
 ___| | COLOPL
(    .'     Toilet
 )  (   
TOILET AVAILABLE!!!: 6F

あとは単純に状況のチェックもできます

$ toilet check
     _
    | |
 ___| | COLOPL
(    .'     Toilet
 )  (   
Area    Current Status  Empty   Max
6F      Available       3       6
5F      Available       2       8



ColoplTechについて

コロプラでは、勉強会やブログを通じてエンジニアの方々に役立つ技術や取り組みを幅広く発信していきます。
connpass および X で情報発信していますので、是非メンバー登録とフォローをよろしくお願いいたします。

また、コロプラではゲームや基盤開発のバックエンド・インフラエンジニアを積極採用中です!
興味を持っていただいた方はぜひお気軽にご連絡ください。