Rustで作ったCLIをgoreleaser + tagpr + brew tapで快適配布する

Rustで作ったCLIをgoreleaser + tagpr + brew tapで快適配布する

Clock Icon2025.02.03

はじめに

バイナリにコンパイルできる言語[1]でコマンドラインツールを開発し、公開するときには、次のような項目を整備したくなる場面があるかと思います。

要素 作業
ツール開発 複数アーキテクチャ向けのバイナリの作成
ツール開発 作成したバイナリの配布
リリース管理 CHANGELOGの作成
リリース管理 バージョンタグの付与
リリース管理 GitHub Releaseの作成

これらの作業は、ブログタイトルにあるようなOSSを組み合わせることで効率的に実施できます。とくに、上記表でいう「ツール開発」や「GitHub Releaseの作成」には、Go製のgoreleaserというツールが非常に便利です。

そして2025年1月にリリースされたgoreleaser(v2.5)から、Rustにも正式対応しました。ちょうどRustで作成したCLIを配布する機会があり、実際に活用してみて大変便利だったため、本記事を書くことにしました。

https://goreleaser.com/blog/rust-zig/

前提

Rustはクレートの構成によってはクロスコンパイルが難しい場合があります。環境によっては上手くいかないケースもあると思いますので、本記事で紹介する構成を前提の一例として参考にしていただければ幸いです。

  • リリース対象はコンパイル済みバイナリであり、crates.ioへのpublishは対象外
  • クロスコンパイルには、GitHub ActionsのStandard GitHub-hosted runnerであるmacos-14を使用(なぜubuntu-latestではないかは後述)
  • クレートの構成は以下の通り

https://github.com/shuntaka9576/cal2prompt/blob/c118f72fa2ed61695ee5222e79cdec7a9d15c904/Cargo.toml#L10-L29

  • 配布対象のアーキテクチャは以下の3種類
    • x86_64-apple-darwin
    • aarch64-apple-darwin
    • x86_64-unknown-linux-gnu
    • ※windows(x86_64-pc-windows-gnu)はツールの仕様上対応していませんが、後述するgoreleaserの設定に追加し、コンパイルできることは確認済です。

この記事で紹介している設定は、以下のリポジトリに実装例があります。詳細設定を確認したい場合はこちらをご参照ください。

https://github.com/shuntaka9576/cal2prompt/tree/main

構成

これらの作業はすべてGitHub Actions上で実行します。各作業項目と利用ツールの対応関係は以下のとおりです。

要素 作業 ツール
ツール開発 複数アーキテクチャ向けのバイナリの作成 goreleaser
ツール開発 作成したバイナリの配布 goreleaser, brew tap
リリース管理 CHANGELOGの作成 tagpr
リリース管理 バージョンタグの付与 tagpr
リリース管理 GitHub Releaseの作成 goreleaser

フローは次のようなイメージです。

cal2.drawio (1)

設定内容

goreleaserの設定

goreleaserは2.6系を使用しています。設定ファイルは次のとおりです。

.goreleaser.yml
version: 2

before:
  hooks:
    - rustup default stable
    - cargo install --locked cargo-zigbuild

builds:
  - id: "cal2prompt"
    builder: rust
    binary: cal2prompt
    targets:
      - x86_64-apple-darwin
      - aarch64-apple-darwin
      - x86_64-unknown-linux-gnu
    tool: "cargo"
    command: zigbuild
    flags:
      - --release
    skip: false

archives:
  - formats: ["tar.gz"]
    name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}"

checksum:
  name_template: "checksums.txt"

changelog:
  sort: asc
  filters:
    exclude:
      - "^docs:"
      - "^test:"

brews:
  - repository:
      owner: shuntaka9576
      name: homebrew-tap
      token: "{{ .Env.GH_PAT }}"
    commit_author:
      name: goreleaserbot
      email: bot@goreleaser.com
    directory: Formula
    license: MIT
    name: cal2prompt
    url_template: "https://github.com/shuntaka9576/cal2prompt/releases/download/{{ .Tag }}/{{ .ArtifactName }}"
    homepage: "https://shuntaka.dev/"
    description: "✨ Fetches your schedule (e.g., from Google Calendar) and converts it into a single LLM prompt. It can also run as an MCP (Model Context Protocol) server."
    test: |
      system "#{bin}/cal2prompt --help"
    install: |
      bin.install "cal2prompt"

クロスコンパイルにはcargo-zigbuildを利用しています。macos-14(ARM)上でx86_64-apple-darwinx86_64-unknown-linux-gnuをビルドしています。cargo以外ではcrossにも対応しています。詳しくはドキュメントやサンプルリポジトリが参考になります。

https://goreleaser.com/customization/builds/rust/
https://github.com/goreleaser/example-rust

tagprの設定

.tagpr
[tagpr]
	vPrefix = true
	releaseBranch = main
	versionFile = Cargo.toml
	release = false

tagprはGitHub Releaseの作成機能を備えていますが、今回はgoreleaser側でリリースを行うためrelease=falseとしています。

versionFileとしてCargo.tomlを指定しています。CLI本体では、コンパイル時にCargo.tomlのバージョンを埋め込む仕様にしています。以下のコードでは、ついでにGitのハッシュとCLI名も埋め込んでいます。

https://github.com/shuntaka9576/cal2prompt/blob/df26235b495ef6989d5d61c9b4ffb1e1fde6171e/src/main.rs#L10-L17

mainブランチにマージされた際に実行されるGitHub Actions定義

.github/workflows/tagpr.yml
name: tagpr

on:
  push:
    branches:
    - "main"
  workflow_dispatch:

jobs:
  tagpr:
    runs-on: macos-latest
    permissions:
      contents: write
      pull-requests: write
    steps:
    - name: checkout
      uses: actions/checkout@v4
    - name: tagpr
      id: tagpr
      uses: Songmu/tagpr@v1
      env:
        GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    - uses: ./.github/actions/release
      if: "steps.tagpr.outputs.tag != ''"
      with:
        goreleaser_token: ${{ secrets.GORELEASER_TOKEN }}
        goreleaser_args: "release --clean"

macos-latestを選択した理由は、リンクオプションでmacOS SDKのCoreFoundationを要求してしまい、ubuntu-latestだとコンパイルエラーとなったためです。

上記ワークフローでは、tagprが作成した「バージョンを付与するためのPR」がmainブランチにマージされるとsteps.tagpr.outputs.tagにバージョン名が入り、そこからリリース用のcomposite actionが実行される仕組みになっています。以下がそのcomposite actionです。

.github/actions/release/action.yml
name: release

on:
  push:
    tags:
    - "v[0-9]+.[0-9]+.[0-9]+"

inputs:
  goreleaser_token:
    description: goreleaser token
    required: true
  goreleaser_args:
    description: "goreleaser args"
    required: true

runs:
  using: composite
  steps:
  - name: "Setup zig"
    uses: mlugg/setup-zig@v1
  # - uses: rui314/setup-mold@v1
  - name: "Release"
    uses: goreleaser/goreleaser-action@v6
    with:
      distribution: goreleaser
      version: "~> v2"
      args: ${{ inputs.goreleaser_args }}
    env:
      GH_PAT: ${{ inputs.goreleaser_token }}
      GITHUB_TOKEN: ${{ github.token }}

このcomposite actionでは、goreleaserを使って次の作業を実施します。

  • クロスコンパイル
  • GitHub Releaseの作成
  • brew tapへの設定反映

GitHub側の設定

PR作成権限の付与

GitHub ActionsからPRを作成できるようにするために、リポジトリの設定から「GitHub ActionsにPull requestを作成する権限」を付与します。これでtagprがPRを作成できるようになります。

CleanShot 2025-02-03 at 11.41.49@2x

brew tap連携のためのPAT設定

別リポジトリ(homebrew-tap)へ書き込み権限を与える必要があるため、Personal Access Token(PAT)を発行し、Secretsとして登録します。

  1. PATを発行する
    CleanShot 2025-02-03 at 11.45.44@2x
    CleanShot 2025-02-03 at 11.46.54@2x
    CleanShot 2025-02-03 at 11.48.16@2x

  2. CLI側のリポジトリでAction Secretsとして登録
    CleanShot 2025-02-03 at 11.49.37@2x

実際の流れ

実際に流れを確認してみましょう。下記のスクリーンショットは作業後のものなので、すでにマージ済みですがご容赦ください。

  1. バグ修正などを行い、mainブランチへマージすると…
    CleanShot 2025-02-03 at 10.53.34@2x

  2. tagprによって、次のようなバージョンタグ付与用PRが作成されます。
    CleanShot 2025-02-03 at 10.55.58@2x

  3. PRの内容は以下のように、.tagprで指定したversionFileであるCargo.tomlが更新されます。
    CleanShot 2025-02-03 at 10.56.58@2x

    Cargo.lockについては現状、別の依存関係に影響を与えないよう手動で更新していますが、将来的には改善したいです。良い方法があれば...

  4. バージョンがv0.1.2としてリリース可能なタイミングでこのPRをマージすると、自動的にGitHub Releaseが作成されます。
    CleanShot 2025-02-03 at 11.02.25@2x

  5. brew tap側もgoreleaser経由で更新されていることが確認できます。
    CleanShot 2025-02-03 at 11.03.53@2x

  6. 実際にインストールすると、v0.1.2が取得できていることを確認できます。

$ brew install shuntaka9576/tap/cal2prompt
==> Auto-updating Homebrew...
...
cal2prompt 0.1.1 is already installed but outdated (so it will be upgraded).
==> Upgrading shuntaka9576/tap/cal2prompt
  0.1.1 -> 0.1.2
...
$ cal2prompt --version
cal2prompt version 0.1.2 (rev:df26235)

その他で詰まったところ

reqwestのコンパイル周り

macos-latestx86_64-unknown-linux-gnu向けにコンパイルしようとすると、opensslが見つからないエラーが出ました。これはreqwestを使用している影響が大きそうでした。

Cargo.toml
reqwest = { version = "0.12.12", features = ["json"] }
エラー内容
warning: openssl-sys@0.9.104: Could not find directory of OpenSSL installation, ...
error: failed to run custom build command for `openssl-sys v0.9.104`
...

解決のため、システムにインストールされたOpenSSLを使うのではなく、内部に同梱(vendored)されたOpenSSLをビルドして利用するよう変更しました。

Cargo.toml
openssl = { version = "0.10", features = ["vendored"] }

しかし、これによりコンパイル時間が大幅に伸びてしまったため、最終的にはTLSライブラリとしてrustls-tlsを使用し、opensslクレートを削除してビルド時間を短縮しました。

Cargo.toml
reqwest = { version = "0.12", default-features = false, features = [
  "json",
  "rustls-tls",
] }

この変更により、ビルド時間を約6分短縮できました。詳細は以下のPRをご覧ください。
https://github.com/shuntaka9576/cal2prompt/pull/2

goreleaserでcargo workspaceのバイナリビルドはまだ不安定

cargo workspace全体を対象に今回のようなバイナリビルドを行う構成は、現時点ではまだ難しい部分があるようです(publishのフロー自体は対応しているようです)。興味がある方は公式ドキュメントやGitHub Issuesなども併せて参照してみてください。

さいごに

goreleaserをすでにGoで使っていた方はヌルッと使えると思います!

現状手元で確認した限りmacos-latestとcargo-zigbuild構成だとmoldが使われていないようで、この分のオーバヘッドがありそうです。

クロスコンパイルにこだわるのもROI的に微妙ですね。長期を見据えたら大人しく環境ごとにコンパイルするのが色んな地雷踏まずに済むので良い気がします。今回は手早くリリースしたかったので無理やりクロスコンパイルが通る構成でゴリ押ししました。何かの参考になれば幸いです!

他にももっと良いやり方やTipsがあれば共有して頂けるとありがたいです!

脚注
  1. 例を挙げるとC, C++, Go, Rustなどが該当します。DenoやNode.jsのシングルバイナリ作成機能も含みます。 ↩︎

Share this article

facebook logohatena logotwitter logo

© Classmethod, Inc. All rights reserved.