Rust 版 GoReleaser の cargo-dist を触ってみる
ずっと GoReleaser の Rust 版が欲しいと思っていたら、先日こんなツイートを見かけた。
cargo-dist is the goreleaser for Rust
ほんまか?ということで触ってみる
クイックスタートの通り、
$ cargo dist init --yes
$ cargo dist build
実行ログ
$ cargo dist build
building artifacts:
cmcb-cli-aarch64-apple-darwin.tar.xz
cmcb-cli-aarch64-apple-darwin.tar.xz.sha256
building cargo target (aarch64-apple-darwin/dist --workspace)
Finished dist [optimized] target(s) in 0.17s
announcing v0.1.0
cmcb-cli 0.1.0
.../cat-markdown-code-blocks/target/distrib/cmcb-cli-aarch64-apple-darwin.tar.xz
[bin] cmcb
[misc] LICENSE, README.md
[checksum] .../cat-markdown-code-blocks/target/distrib/cmcb-cli-aarch64-apple-darwin.tar.xz.sha256
でサクッとローカルでバイナリができた。
The build command will by default try to build things for the computer you're running it on.
と書いてある通り、動かしてるマシンのプラットフォーム向けのバイナリができる。
ここからが本番。
cargo-dist は基本的に実行可能なバイナリを含むアーカイブ(zip/tarball)の生成がメインで、バイナリのインストール周りを支援するものをざっくりインストーラと呼んでる。
インストーラとしては shell、homebrew、npm などがサポートされている。
今回は homebrew でインストールできるところまでやってみる。
cargo dist init する時にインストーラ指定できるので、homebrew
を指定する。
途中で Homebrew Taps 用のリポジトリ名を聞かれるので事前に準備しておく必要あり。
$ cargo dist init --installer homebrew
実行ログ
$ cargo dist init --installer homebrew
let's setup your cargo-dist config...
✔ what platforms do you want to build for?
(select with arrow keys and space, submit with enter) · Linux x64 (x86_64-unknown-linux-gnu), macOS Apple Silicon (aarch64-apple-darwin), macOS Intel (x86_64-apple-darwin), Windows x64 (x
86_64-pc-windows-msvc)
✔ enable Github CI and Releases? · yes
✔ check your release process in pull requests? · plan - run 'cargo dist plan' on PRs (recommended)
✔ what installers do you want to build?
(select with arrow keys and space, submit with enter) · homebrew
✔ you've enabled Homebrew support; if you want cargo-dist
to automatically push package updates to a tap (repository) for you,
please enter the tap name (in GitHub owner/name format) · daido1976/homebrew-tap
✔ Homebrew package will be published to daido1976/homebrew-tap
✔ You must provision a GitHub token and expose it as a secret named
HOMEBREW_TAP_TOKEN in GitHub Actions. For more information,
see the documentation:
https://opensource.axo.dev/cargo-dist/book/installers/homebrew.html
✔ added [profile.dist] to your root Cargo.toml
✔ added [workspace.metadata.dist] to your root Cargo.toml
✔ cargo-dist is setup!
running 'cargo dist generate' to apply any changes
generated Github CI to ./.github/workflows/release.yml
実行すると以下のように新規に .github/workflows/release.yml
が作られ、Cargo.toml
にも差分ができる。
新規作成される.github/workflows/release.yml
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..9df997d
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,237 @@
+# Copyright 2022-2023, axodotdev
+# SPDX-License-Identifier: MIT or Apache-2.0
+#
+# CI that:
+#
+# * checks for a Git Tag that looks like a release
+# * builds artifacts with cargo-dist (archives, installers, hashes)
+# * uploads those artifacts to temporary workflow zip
+# * on success, uploads the artifacts to a Github Release™
+#
+# Note that the Github Release™ will be created with a generated
+# title/body based on your changelogs.
+name: Release
+
+permissions:
+ contents: write
+
+# This task will run whenever you push a git tag that looks like a version
+# like "1.0.0", "v0.1.0-prerelease.1", "my-app/0.1.0", "releases/v1.0.0", etc.
+# Various formats will be parsed into a VERSION and an optional PACKAGE_NAME, where
+# PACKAGE_NAME must be the name of a Cargo package in your workspace, and VERSION
+# must be a Cargo-style SemVer Version (must have at least major.minor.patch).
+#
+# If PACKAGE_NAME is specified, then the release will be for that
+# package (erroring out if it doesn't have the given version or isn't cargo-dist-able).
+#
+# If PACKAGE_NAME isn't specified, then the release will be for all
+# (cargo-dist-able) packages in the workspace with that version (this mode is
+# intended for workspaces with only one dist-able package, or with all dist-able
+# packages versioned/released in lockstep).
+#
+# If you push multiple tags at once, separate instances of this workflow will
+# spin up, creating an independent Github Release™ for each one. However Github
+# will hard limit this to 3 tags per commit, as it will assume more tags is a
+# mistake.
+#
+# If there's a prerelease-style suffix to the version, then the Github Release™
+# will be marked as a prerelease.
+on:
+ push:
+ tags:
+ - '**[0-9]+.[0-9]+.[0-9]+*'
+ pull_request:
+
+jobs:
+ # Run 'cargo dist plan' to determine what tasks we need to do
+ plan:
+ runs-on: ubuntu-latest
+ outputs:
+ val: ${{ steps.plan.outputs.manifest }}
+ tag: ${{ !github.event.pull_request && github.ref_name || '' }}
+ tag-flag: ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }}
+ publishing: ${{ !github.event.pull_request }}
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ - name: Install cargo-dist
+ run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.4.3/cargo-dist-installer.sh | sh"
+ - id: plan
+ run: |
+ cargo dist plan ${{ !github.event.pull_request && format('--tag={0}', github.ref_name) || '' }} --output-format=json > dist-manifest.json
+ echo "cargo dist plan ran successfully"
+ cat dist-manifest.json
+ echo "manifest=$(jq -c "." dist-manifest.json)" >> "$GITHUB_OUTPUT"
+ - name: "Upload dist-manifest.json"
+ uses: actions/upload-artifact@v3
+ with:
+ name: artifacts
+ path: dist-manifest.json
+
+ # Build and packages all the platform-specific things
+ upload-local-artifacts:
+ # Let the initial task tell us to not run (currently very blunt)
+ needs: plan
+ if: ${{ fromJson(needs.plan.outputs.val).releases != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }}
+ strategy:
+ fail-fast: false
+ # Target platforms/runners are computed by cargo-dist in create-release.
+ # Each member of the matrix has the following arguments:
+ #
+ # - runner: the github runner
+ # - dist-args: cli flags to pass to cargo dist
+ # - install-dist: expression to run to install cargo-dist on the runner
+ #
+ # Typically there will be:
+ # - 1 "global" task that builds universal installers
+ # - N "local" tasks that build each platform's binaries and platform-specific installers
+ matrix: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix }}
+ runs-on: ${{ matrix.runner }}
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ BUILD_MANIFEST_NAME: target/distrib/${{ join(matrix.targets, '-') }}-dist-manifest.json
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ - uses: swatinem/rust-cache@v2
+ - name: Install cargo-dist
+ run: ${{ matrix.install_dist }}
+ - name: Install dependencies
+ run: |
+ ${{ matrix.packages_install }}
+ - name: Build artifacts
+ run: |
+ # Actually do builds and make zips and whatnot
+ cargo dist build ${{ needs.plan.outputs.tag-flag }} --print=linkage --output-format=json ${{ matrix.dist_args }} > dist-manifest.json
+ echo "cargo dist ran successfully"
+ - id: cargo-dist
+ name: Post-build
+ # We force bash here just because github makes it really hard to get values up
+ # to "real" actions without writing to env-vars, and writing to env-vars has
+ # inconsistent syntax between shell and powershell.
+ shell: bash
+ run: |
+ # Parse out what we just built and upload it to the Github Release™
+ echo "paths<<EOF" >> "$GITHUB_OUTPUT"
+ jq --raw-output ".artifacts[]?.path | select( . != null )" dist-manifest.json >> "$GITHUB_OUTPUT"
+ echo "EOF" >> "$GITHUB_OUTPUT"
+
+ cp dist-manifest.json "$BUILD_MANIFEST_NAME"
+ - name: "Upload artifacts"
+ uses: actions/upload-artifact@v3
+ with:
+ name: artifacts
+ path: |
+ ${{ steps.cargo-dist.outputs.paths }}
+ ${{ env.BUILD_MANIFEST_NAME }}
+
+ # Build and package all the platform-agnostic(ish) things
+ upload-global-artifacts:
+ needs: [plan, upload-local-artifacts]
+ runs-on: "ubuntu-20.04"
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ - name: Install cargo-dist
+ run: "curl --proto '=https' --tlsv1.2 -LsSf https://github.com/axodotdev/cargo-dist/releases/download/v0.4.3/cargo-dist-installer.sh | sh"
+ # Get all the local artifacts for the global tasks to use (for e.g. checksums)
+ - name: Fetch local artifacts
+ uses: actions/download-artifact@v3
+ with:
+ name: artifacts
+ path: target/distrib/
+ - id: cargo-dist
+ shell: bash
+ run: |
+ cargo dist build ${{ needs.plan.outputs.tag-flag }} --output-format=json "--artifacts=global" > dist-manifest.json
+ echo "cargo dist ran successfully"
+
+ # Parse out what we just built and upload it to the Github Release™
+ echo "paths<<EOF" >> "$GITHUB_OUTPUT"
+ jq --raw-output ".artifacts[]?.path | select( . != null )" dist-manifest.json >> "$GITHUB_OUTPUT"
+ echo "EOF" >> "$GITHUB_OUTPUT"
+ - name: "Upload artifacts"
+ uses: actions/upload-artifact@v3
+ with:
+ name: artifacts
+ path: ${{ steps.cargo-dist.outputs.paths }}
+
+ should-publish:
+ needs:
+ - plan
+ - upload-local-artifacts
+ - upload-global-artifacts
+ if: ${{ needs.plan.outputs.publishing == 'true' }}
+ runs-on: ubuntu-latest
+ steps:
+ - name: print tag
+ run: echo "ok we're publishing!"
+
+ publish-homebrew-formula:
+ needs: [plan, should-publish]
+ runs-on: "ubuntu-20.04"
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ PLAN: ${{ needs.plan.outputs.val }}
+ GITHUB_USER: "axo bot"
+ GITHUB_EMAIL: "admin+bot@axo.dev"
+ if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ repository: "daido1976/homebrew-tap"
+ token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
+ # So we have access to the formula
+ - name: Fetch local artifacts
+ uses: actions/download-artifact@v3
+ with:
+ name: artifacts
+ path: Formula/
+ - name: Commit formula files
+ run: |
+ git config --global user.name "${GITHUB_USER}"
+ git config --global user.email "${GITHUB_EMAIL}"
+
+ for release in $(echo "$PLAN" | jq --compact-output '.releases[]'); do
+ name=$(echo "$release" | jq .app_name --raw-output)
+ version=$(echo "$release" | jq .app_version --raw-output)
+
+ git add Formula/${name}.rb
+ git commit -m "${name} ${version}"
+ done
+ git push
+
+ # Create a Github Release with all the results once everything is done
+ publish-release:
+ needs: [plan, should-publish]
+ runs-on: ubuntu-latest
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ steps:
+ - uses: actions/checkout@v4
+ with:
+ submodules: recursive
+ - name: "Download artifacts"
+ uses: actions/download-artifact@v3
+ with:
+ name: artifacts
+ path: artifacts
+ - name: Cleanup
+ run: |
+ # Remove the granular manifests
+ rm artifacts/*-dist-manifest.json
+ - name: Create Release
+ uses: ncipollo/release-action@v1
+ with:
+ tag: ${{ needs.plan.outputs.tag }}
+ name: ${{ fromJson(needs.plan.outputs.val).announcement_title }}
+ body: ${{ fromJson(needs.plan.outputs.val).announcement_github_body }}
+ prerelease: ${{ fromJson(needs.plan.outputs.val).announcement_is_prerelease }}
+ artifacts: "artifacts/*"
Cargo.tomlの差分
diff --git a/Cargo.toml b/Cargo.toml
index 45516a2..b302606 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -5,3 +5,25 @@ members = [
"wasm",
]
resolver = "2"
+
+# Config for 'cargo dist'
+[workspace.metadata.dist]
+# The preferred cargo-dist version to use in CI (Cargo.toml SemVer syntax)
+cargo-dist-version = "0.4.3"
+# CI backends to support
+ci = ["github"]
+# The installers to generate for each app
+installers = ["homebrew"]
+# A GitHub repo to push Homebrew formulas to
+tap = "daido1976/homebrew-tap"
+# Target platforms to build apps for (Rust target-triple syntax)
+targets = ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin", "x86_64-apple-darwin", "x86_64-pc-windows-msvc"]
+# Publish jobs to run in CI
+publish-jobs = ["homebrew"]
+# Publish jobs to run in CI
+pr-run-mode = "plan"
+
+# The profile that 'cargo dist' will build with
+[profile.dist]
+inherits = "release"
+lto = "thin"
goreleaser ではワークフローの実装が goreleaser-action に切り出されていて release.yml
からはそれを呼ぶだけになっていたが、cargo-dist では現状 release.yml
にベタっと出力される。
また、以下の通り Homebrew Taps 用リポジトリの Contents : Read and write
の権限を持った PAT を作成し、HOMEBREW_TAP_TOKEN
という名前で GitHub Actions の Secret にセットする必要あり。
ドキュメントのリンクが Classic 版 PAT の生成画面になってるが、Fine-grained 版で作成した方が良さそう。
CI として現在は GitHub Actions のみサポートしている。
ワークフローファイル(.github/workflows/release.yml
)は前述の通り cargo dist init した時に生成される。
cargo dist init の時に Cargo.toml の package セクションに repository
が入ってないと怒られるので入れておく必要あり。(cargo workspaces の場合は対象のバイナリクレートに入れる)
pr-run-mode
はデフォルトで plan
になっており、terraform plan
のように実際には実行されず、実行計画だけが出力される。
pr-run-mode
を upload
にすると以下のように PR ごとにアーティファクトが作られるので、実際に生成されるバイナリを確認することができる。
クロスコンパイルじゃなくてプラットフォームごとにジョブが立ち上がるみたい。
上記の差分を main にマージすればリリースの準備は OK。あとはよくあるバージョンの tag つけて push する方法でリリースする。
Cargo.toml の package セクションのバージョンと合わせないと CI でエラーになるので注意。
$ git tag v0.2.0
$ git push --tags
タグの push をトリガーに CI が動いて以下のようなリリースができ、
Homebrew Taps 用リポジトリには以下のような Formula ができる。
goreleaser はデフォルトでルートに Formula のファイルを作ったはずだが、cargo-dist はデフォルトで Formula/ 以下に作る様子。
GitHub Release のリリースノートに記載の通り、以下を実行すると無事 Homebrew 経由でインストールできる。
$ brew install daido1976/homebrew-tap/cmcb-cli
# 以下のように homebrew- は省略しても可能
# See. https://docs.brew.sh/Taps#repository-naming-conventions-and-assumptions
$ brew install daido1976/tap/cmcb-cli
雑なまとめ
- cargo-dist で以下ができることを確認した
- Mac/Linux/Windows 向けのビルド
- GitHub Releases にアップロード
- Homebrew tap に formula 追加して Homebrew でインストール
- GoReleaser との違い
- 足りてない機能はいっぱいある
- 現状だとクロスコンパイルはサポートしてない様子(単純に CI 上で複数マシン立ち上げてるだけ)
- 普通に Rust で CLI ツール作って Homebrew とか bash でインストールできるようにしたい、って用途なら全然使えそう