Open7

Rust 版 GoReleaser の cargo-dist を触ってみる

daido1976daido1976

https://opensource.axo.dev/cargo-dist/book/way-too-quickstart.html

クイックスタートの通り、

$ 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.

と書いてある通り、動かしてるマシンのプラットフォーム向けのバイナリができる。

daido1976daido1976

ここからが本番。

https://opensource.axo.dev/cargo-dist/book/installers/index.html

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 にセットする必要あり。

https://opensource.axo.dev/cargo-dist/book/installers/homebrew.html

ドキュメントのリンクが Classic 版 PAT の生成画面になってるが、Fine-grained 版で作成した方が良さそう。

daido1976daido1976

https://opensource.axo.dev/cargo-dist/book/ci/github.html

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-modeupload にすると以下のように PR ごとにアーティファクトが作られるので、実際に生成されるバイナリを確認することができる。

https://github.com/daido1976/cat-markdown-code-blocks/actions/runs/6912662427

クロスコンパイルじゃなくてプラットフォームごとにジョブが立ち上がるみたい。

daido1976daido1976

上記の差分を main にマージすればリリースの準備は OK。あとはよくあるバージョンの tag つけて push する方法でリリースする。
Cargo.toml の package セクションのバージョンと合わせないと CI でエラーになるので注意。

$ git tag v0.2.0
$ git push --tags

タグの push をトリガーに CI が動いて以下のようなリリースができ、
https://github.com/daido1976/cat-markdown-code-blocks/releases/tag/v0.2.0

Homebrew Taps 用リポジトリには以下のような Formula ができる。
goreleaser はデフォルトでルートに Formula のファイルを作ったはずだが、cargo-dist はデフォルトで Formula/ 以下に作る様子

https://github.com/daido1976/homebrew-tap/blob/f0871bbd629ef31dbb9cfc0d0d6cf7e206b0e9fc/Formula/cmcb-cli.rb

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
daido1976daido1976

雑なまとめ

  • cargo-dist で以下ができることを確認した
    • Mac/Linux/Windows 向けのビルド
    • GitHub Releases にアップロード
    • Homebrew tap に formula 追加して Homebrew でインストール
  • GoReleaser との違い
  • 普通に Rust で CLI ツール作って Homebrew とか bash でインストールできるようにしたい、って用途なら全然使えそう