こんにちは、エンジニアの工藤 @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 リポジトリを維持管理するのは思っているよりも大変です。具体的には
- ツールごとの定義ファイルを Ruby DSL で記述する必要があること
- ツールのバージョンアップの度に Ruby DSL の定義も変更する必要があること
- Assets にあるバイナリが internal, private なリポジトリだとそのままではダウンロードできず、インストールできないこと
などが挙げられます。特にバイナリのダウンロードができないのが致命的です。
考えられる対応策として、以下のようなものが挙げられます。
Formula リポジトリ自体にツールのバイナリをそのまま配置する
Pros
- 単一のリポジトリに全てが完結して揃った状態にできる
Cons
- git リポジトリが肥大化し、管理が煩雑になる
- ツール側から Formula リポジトリを操作しなければならず、権限管理が煩雑
- 最もセキュアな形を取るのなら 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 について
コロプラオフィスにはトイレの空き状況チェックができる環境が用意されており、 toilet は API 経由でそれを参照してチェックしています。
もともと社内向けポータルに表示機能があったのですが、エンジニアとしてはコマンドラインの方が使いやすいなと思って移植しました。
コロプラオフィスは現状 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 で情報発信していますので、是非メンバー登録とフォローをよろしくお願いいたします。
また、コロプラではゲームや基盤開発のバックエンド・インフラエンジニアを積極採用中です!
興味を持っていただいた方はぜひお気軽にご連絡ください。