GitHub ActionsでRustのプロジェクトをビルドしてReleasesにバイナリを置く

# はじめに

`v0.0.1`のようなタグを付けてGitHubにpushすると、以下のように、各OS・アーキテクチャ向けのビルド済みバイナリが、  
自動的にReleasesに配置されるRustプロジェクトのサンプルを作成しました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2542709/e758ec41-d5fe-660e-b45c-ea938c3cfb1e.png)

:::note warn
本記事で使用している[`actions-rs/toolchain`](https://github.com/actions-rs/toolchain)および[`actions-rs/cargo`](https://github.com/actions-rs/cargo)が、先月organizationごとarchiveされてしまったようです。  
直ちに使用不可となる可能性は低いかもしれませんが、今後は[`cross`](https://github.com/cross-rs/cross)を手動でインストールする等の代替手段を検討した方が良いかもしれません。 
:::

# ソースコード

リポジトリは以下で公開しています。

https://github.com/okmt765/rust-github-actions-example

今回メインとなるのは以下のファイルです。

https://github.com/okmt765/rust-github-actions-example/blob/master/.github/workflows/release.yml

このファイルを`.github/workflows/`ディレクトリに配置しておくだけで、`v0.0.1`のようなtagのpushをトリガーとして、  
自動的にワークフローが実行されます。

```yaml:release.yml
name: Release

# Releasesへのファイル追加のために書き込み権限が必要
permissions:
  contents: write

on:
  push:
    tags:
      - v*

jobs:
  build:
    runs-on: ${{ matrix.job.os }}
    strategy:
      fail-fast: false
      matrix:
        job:
          - { os: ubuntu-latest  , target: x86_64-unknown-linux-gnu       , use-cross: false , extension: ""   }
          - { os: ubuntu-latest  , target: x86_64-unknown-linux-musl      , use-cross: true  , extension: ""   }
          - { os: ubuntu-latest  , target: armv7-unknown-linux-gnueabihf  , use-cross: true  , extension: ""   }
          - { os: ubuntu-latest  , target: armv7-unknown-linux-musleabihf , use-cross: true  , extension: ""   }
          - { os: ubuntu-latest  , target: aarch64-unknown-linux-gnu      , use-cross: true  , extension: ""   }
          - { os: ubuntu-latest  , target: aarch64-unknown-linux-musl     , use-cross: true  , extension: ""   }
          - { os: macos-latest   , target: x86_64-apple-darwin            , use-cross: false , extension: ""   }
          - { os: macos-latest   , target: aarch64-apple-darwin           , use-cross: false , extension: ""   }
          - { os: windows-latest , target: x86_64-pc-windows-msvc         , use-cross: false , extension: .exe }
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      # Rustのpackage名を取得して環境変数に入れておく。(後のステップで使用)
      - name: Extract crate information
        shell: bash
        run: |
          echo "PROJECT_NAME=$(sed -n 's/^name = "\(.*\)"/\1/p' Cargo.toml | head -n1)" >> $GITHUB_ENV

      # rustcやcargoをインストール
      - name: Install Rust toolchain
        uses: actions-rs/toolchain@v1
        with:
          toolchain: stable
          target: ${{ matrix.job.target }}
          override: true
          profile: minimal

      # targetに応じてcargoもしくはcrossを使用してビルド
      - name: Build
        uses: actions-rs/cargo@v1
        with:
          use-cross: ${{ matrix.job.use-cross }}
          command: build
          args: --release --target ${{ matrix.job.target }}

      # ビルド済みバイナリをリネーム
      - name: Rename artifacts
        shell: bash
        run: |
          mv target/${{ matrix.job.target }}/release/${{ env.PROJECT_NAME }}{,-${{ github.ref_name }}-${{ matrix.job.target }}${{ matrix.job.extension }}}

      # ビルド済みバイナリをReleasesに配置
      - name: Release
        uses: softprops/action-gh-release@v1
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        with:
          files: |
            target/${{ matrix.job.target }}/release/${{ env.PROJECT_NAME }}-${{ github.ref_name }}-${{ matrix.job.target }}${{ matrix.job.extension }}
```

# 解説

## 書き込み権限の付与

```yaml
permissions:
  contents: write
```

Releasesへのファイル追加のため、書き込み権限が必要なので付与しています。  
リポジトリ設定の`Actions > General > Workflow permissions`で`Read and write permissions`を選択することでも付与可能ですが、  
その場合は全ワークフローに書き込み権限が付与されてしまいます。  
今回のようにワークフロー単位で最小限の権限のみ付与する方が安全かと思います。

## ビルド対象の指定

```yaml
matrix:
  job:
    - { os: ubuntu-latest  , target: x86_64-unknown-linux-gnu       , use-cross: false , extension: ""   }
    - { os: ubuntu-latest  , target: x86_64-unknown-linux-musl      , use-cross: true  , extension: ""   }
    - { os: ubuntu-latest  , target: armv7-unknown-linux-gnueabihf  , use-cross: true  , extension: ""   }
    - { os: ubuntu-latest  , target: armv7-unknown-linux-musleabihf , use-cross: true  , extension: ""   }
    - { os: ubuntu-latest  , target: aarch64-unknown-linux-gnu      , use-cross: true  , extension: ""   }
    - { os: ubuntu-latest  , target: aarch64-unknown-linux-musl     , use-cross: true  , extension: ""   }
    - { os: macos-latest   , target: x86_64-apple-darwin            , use-cross: false , extension: ""   }
    - { os: macos-latest   , target: aarch64-apple-darwin           , use-cross: false , extension: ""   }
    - { os: windows-latest , target: x86_64-pc-windows-msvc         , use-cross: false , extension: .exe }
```

ビルドを実行する[GitHub-hosted runner](https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners/about-github-hosted-runners)のOS、ビルド対象のtarget triple、[`cross`](https://github.com/cross-rs/cross)の使用有無などを指定しています。  
`cross`を使用することで、[`cross`がサポートしている](https://github.com/cross-rs/cross#supported-targets)ARMやMIPSなどのバイナリもビルド可能となります。  
今回は、通常のx86_64用、および個人的によく使うARM用のtargetを指定しています。

### 補足：muslについて

Linux向けのtargetでは、同じアーキテクチャでも`gnu`系と`musl`系という2つの選択肢があります。   
gnu系はglibcと動的リンクするため、ビルド時と異なる環境では実行できない場合があります。  
一方musl系はmusl libcと静的リンクし[^mips]、Golangのような依存無しのシングルバイナリが得られるため、どこでも実行出来て便利です。    

ただし、musl系はgnu系に比べて遅い[^musl1] [^musl2]という話もあるようです。  
また、gnu系とmusl系で一部動作が異なる場合があるため、注意が必要です。

例えば、現在[^now]musl系は、ファイル作成日時を取得する[`std::fs::Metadata`の`created()`メソッド](https://doc.rust-lang.org/std/fs/struct.Metadata.html#method.created)に対応していません。[^statx]  
以下のコードをそれぞれのtargetでビルド・実行すると、musl系の場合は取得に失敗します。

```rust
fn main() -> std::io::Result<()> {
    let file = std::fs::File::open("Cargo.toml")?;
    let metadata = file.metadata()?;
    let _ = dbg!(metadata.created());
    Ok(())
}
```

```shell-session
$ cargo run --target x86_64-unknown-linux-gnu
[src/main.rs:6] metadata.created() = Ok(
    SystemTime {
        tv_sec: 1698726456,
        tv_nsec: 826404242,
    },
)

$ cargo run --target x86_64-unknown-linux-musl
[src/main.rs:6] metadata.created() = Err(
    Error {
        kind: Unsupported,
        message: "creation time is not available on this platform currently",
    },
)
```

## プロジェクト名の取得

```yaml
- name: Extract crate information
  shell: bash
  run: |
    echo "PROJECT_NAME=$(sed -n 's/^name = "\(.*\)"/\1/p' Cargo.toml | head -n1)" >> $GITHUB_ENV
```

ビルド後にバイナリをリネームする際に使用するため、`Cargo.toml`からプロジェクト名を取得して環境変数に設定しています。

## Rust環境のインストール

```yaml
- name: Install Rust toolchain
  uses: actions-rs/toolchain@v1
  with:
    toolchain: stable
    target: ${{ matrix.job.target }}
    override: true
    profile: minimal
```

[`actions-rs/toolchain`](https://github.com/actions-rs/toolchain)を使用して、targetに応じた`rustc`や`cargo`等をインストールしています。

## ビルド

```yaml
- name: Build
  uses: actions-rs/cargo@v1
  with:
    use-cross: ${{ matrix.job.use-cross }}
    command: build
    args: --release --target ${{ matrix.job.target }}
```

[`actions-rs/cargo`](https://github.com/actions-rs/cargo)を使用してビルドを行ないます。  
`use-cross`がtrueの場合は`cross`、falseの場合は`cargo`が使用されます。

## ビルド済みバイナリのリネーム

```yaml
- name: Rename artifacts
  shell: bash
  run: |
    mv target/${{ matrix.job.target }}/release/${{ env.PROJECT_NAME }}{,-${{ github.ref_name }}-${{ matrix.job.target }}${{ matrix.job.extension }}}
```

ビルド済みバイナリのファイル名はtargetによらず同じになってしまうため、Releasesへ配置する前に、  
`hoge-v0.0.1-x86_64-unknown-linux-gnu`のような名前にリネームしています。

## バイナリをReleasesに配置

```yaml
- name: Release
  uses: softprops/action-gh-release@v1
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
  with:
    files: |
      target/${{ matrix.job.target }}/release/${{ env.PROJECT_NAME }}-${{ github.ref_name }}-${{ matrix.job.target }}${{ matrix.job.extension }}
```

[softprops/action-gh-release](https://github.com/softprops/action-gh-release)を使用してバイナリをReleasesに配置しています。

# 小ネタ

今回参考にした[pastelのworkflow](https://github.com/sharkdp/pastel/blob/v0.9.0/.github/workflows/CICD.yml#L132-L162)では、targetに応じた`strip`コマンドを使い分けて、ビルド済みバイナリのサイズを削減していました。  
昨年リリースされたCargo 1.59で[`strip`オプション](https://doc.rust-lang.org/nightly/cargo/reference/profiles.html#strip)が[安定化された](https://github.com/rust-lang/cargo/blob/0.73.1/CHANGELOG.md#added-13)ため、現在は`Cargo.toml`に以下の内容を記述しておくだけで同様の効果が得られます。
```toml:Cargo.toml
[profile.release]
strip = "symbols"
```

# おわりに

依存クレート次第では、`cross`を使用しても一部targetでのビルドが上手くいかない場合もあります。  
それでも上手くビルド出来た場合は、Raspberry PiやM5Stack UnitV2、スマホなどの上でそのまま動くARM用バイナリを簡単に得られるので、一度試してみる価値はあると思います。  
(非力なRaspberry Pi上でビルドしなくて済むのは嬉しい)

# 参考リンク

- GitHub Actions設定の参考にしたリポジトリ
  - https://github.com/sharkdp/pastel
  - https://github.com/Nukesor/pueue
- 使用したAction
  - https://github.com/actions/checkout
  - https://github.com/actions-rs/toolchain
  - https://github.com/actions-rs/cargo
  - https://github.com/softprops/action-gh-release


[^musl1]: https://www.reddit.com/r/rust/comments/gdycv8/why_does_musl_make_my_code_so_slow/
[^musl2]: https://github.com/rust-lang/rust/issues/70108
[^mips]: ただし、`mipsel-unknown-linux-musl`と`mips-unknown-linux-musl`はデフォルトでは動的リンクとなります。  
https://github.com/rust-lang/rust/issues/80693
[^now]: 本記事執筆時最新の安定版であるRust 1.73.0時点
[^statx]: 作成日時を取得出来る`statx`システムコールにmusl libcが対応していないようです。  
https://github.com/rust-lang/rust/blob/1.73.0/library/std/src/sys/unix/fs.rs#L95-L248