me

Mateus Costa

Rust releases for single and multiple targets with GitHub Actions

GitHub Actions has been growing at a fast pace and I’ve been using it in some pet projects. In a recent one, I tried the support for binary releases (CLI tools). Currently, the Infrastructure Rust team is also evaluating GitHub Actions so you expect support for different scenarios to be easy to setup.

I had trouble finding good and detailed material about how to setup releases for multiple targets with Rust for small projects, so I decided to document it myself hoping that it can help others.

GitHub Actions marketplace is full of different actions that combined can give you the same result. If you know another approach than the one here, please let me know.

1- Workflow for the releases

This workflow was set up for a small project, so we decided not having too many different steps when creating a new release:

Create new branch from master —> Open a PR —> Merge and release

Where Merge and release can be split into:

  • code checkout
  • creating the binary
  • incrementing version and pushing new tag/create a release
  • uploading generated binary/binaries to the new release

2- Releasing binaries for a single Linux target

Releasing binaries for a single target is simple. The folder structure for our project is the following (check it out here):

<project_folder>/.github/workflows
    ci.yml
    create_tag_and_release.yml

If you’re familiar with GitHub workflows, ci.yml is where we execute common Continuous Integration jobs like unit/integration tests. For most of my Rust projects, I usually run cargo check, cargo test, cargo fmt and cargo clippy. You can check what GitHub actions I use for running cargo commands and the entire file here.

Most of the times you want to release only when the other jobs are successful. In our case, it means that all jobs in ci.yml are passing. In our project we achieve this by using GitHub project’s settings:

  • go to your project’s main page
  • go to project Settings in the top menu
  • go to Branches in the left side menu
  • click on Add Rule
  • type a branch name pattern in Branch name pattern (in my case it was master)
  • tick the box Require status checks to pass before merging choosing the jobs that you want to make sure are green in order to make the branch “mergeable”
  • click on Create to confirm

And since the tutorial is about creating releases, I’m going to focus on explaining only the create_tag_and_release.yml file below:

name: Build, bump tag version and release

on:
  push:
    branches:
      - master

jobs:
  release:
    name: Build and Release
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
      - name: Build project
        run: cargo build --release --locked
      - name: Bump version and push tag/create release point
        uses: anothrNick/github-tag-action@1.17.2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          WITH_V: true
        id: bump_version
      - name: Upload binary to release
        uses: svenstaro/upload-release-action@v1-release
        with:
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          file: target/release/alemanes
          asset_name: alemanes-linux-amd64
          tag: ${{ steps.bump_version.outputs.new_tag }}
          overwrite: true

This file is going to be detailed in the next sections where I split the explanation for each block. I’m going to paste almost every block and explain it, but maybe you want to open the file in another tab in order to follow the explanation and compare to the entire file at the same time.

If I don’t mention something it’s because there’s nothing special about it for our context, but probably it’s mandatory in order to make everything work.

2.1 When to execute the release workflow

on:
  push:
    branches:
      - master

This workflow is going to be executed every time a commit is pushed on the master branch.

2.2 Release job and its steps

2.2.1 Job header

release:
    name: Build and Release
    runs-on: ubuntu-latest

Where name: Build and Release it’s the job’s name and runs-on: ubuntu-latest it’s the used virtual machine platform.

2.2.2 Checkout step

- name: Checkout code
  uses: actions/checkout@v2

Check out our project code using actions/checkout@v2 in order to execute the next steps on it.

2.2.3 Build and create a binary step

- name: Build project
  run: cargo build --release --locked

Execute cargo build command that generates the project’s binary in release mode (with optimizations for the target platform).

2.2.4 Bump version and push tag/create release

- name: Bump version and push tag/create release point
  uses: anothrNick/github-tag-action@1.17.2
  env:
    GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
    WITH_V: true
  id: bump_version

It bumps the version and pushes a tag, creating a release for it using anothrNick/github-tag-action@1.17.2 action.

id: bump_version is where we can send information from the current step to the next one. We need it in order to say for which tag/release we’re uploading the binary file.

note: anothrNick/github-tag-action@1.17.2 action only works in Linux VMs. If you try to generate a single release for macOs you get: ##[error]Container action is only supported on Linux. Fortunately, with extra steps, we can still make it work using the same idea for release for multiple targets that we’re going to set up later. I’ll add a proposed solution at the end of the tutorial.

2.2.5 Upload binary to release

- name: Upload binary to release
  uses: svenstaro/upload-release-action@v1-release
  with:
    repo_token: ${{ secrets.GITHUB_TOKEN }}
    file: target/release/alemanes
    asset_name: alemanes-linux-amd64
    tag: ${{ steps.bump_version.outputs.new_tag }}
    overwrite: true

Upload its binaries using the action svenstaro/upload-release-action@v1-release:

  • file: target/release/alemanes is the binary you want upload to the release (it can be any kind of file: e.g. tar.gz)
  • asset_name: alemanes-linux-amd64: filename that is going to appear for target/release/alemanes file in releases page.
  • tag: ${{ steps.bump_version.outputs.new_tag }}: to get the tag info from the previous step saying which tag/release we’re updating the file.

3- Releasing binaries for multiple targets

The main difference on releasing binaries for multiple targets is that we need two workflows: one to create a tag and dispatch a repository event. And another one to be triggered on every tag-created event dispatched by the first one.

This is a different project, so we have a different folder structure (check it out here):

<project_folder>/.github/workflows
    ci.yml
    create_new_tag.yml
    release.yml

ci.yml is the same as we saw in here.

The difference in the structure here is that releasing binaries for multiple targets requires a separated job to create a tag that is the same one used by Linux, Windows and macOS platforms.

Since we’re going to execute jobs in different VMs for each platform, is not possible right now to share information about the newly created tag only using tag: ${{ steps.bump_version.outputs.new_tag }} between jobs running in different platforms.

3.1 Create new tag and dispatch repository event

The previous problem is solved by a separated job (create_new_tag.yml) that creates a new tag and dispatch a repository event. This is the content of create_new_tag.yml:

name: Bump version, create new tag and release point
on:
  push:
    branches:
      - master

jobs:
  bump_version:
    name: Bump version, create tag/release point
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
      - name: Bump version and push tag/create release point
        id: bump_version
        uses: anothrNick/github-tag-action@1.17.2
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          WITH_V: true
      - name: Repository dispatch tag created event
        uses: peter-evans/repository-dispatch@v1
        with:
          token: ${{ secrets.REPO_ACCESS_TOKEN }}
          event-type: tag-created
          client-payload: '{"new_version": "${{ steps.bump_version.outputs.new_tag }}"}'

This job has 3 steps:

  • checkout code
  • bump the version and push tag/create a release
  • dispatch a tag-created event

The first two steps was described previously here, so let’s focus on the third one:

- name: Repository dispatch tag created event
        uses: peter-evans/repository-dispatch@v1
        with:
          token: ${{ secrets.REPO_ACCESS_TOKEN }}
          event-type: tag-created
          client-payload: '{"new_version": "${{ steps.bump_version.outputs.new_tag }}"}'

uses: peter-evans/repository-dispatch@v1 is the action that dispatchs the repository event and client-payload: '{"new_version": "${{steps.bump_version.outputs.new_tag }}"}' is a evaluated json payload used by the action. In the end the payload could look like:

{
   "new_version": "v0.10.0"
}

We dispatch this event(event-type: tag-created) from create_new_tag.yml including the newly created tag in its payload and other jobs can be executed upon this event.

3.2 Upload binaries to the newly created tag

The second job, release.yml, is executed when this event of type tag-created is dispatched and uses its received payload to identify the tag to upload binaries from differents platform to the same release.

name: Build and upload binaries to release

on:
  repository_dispatch:
    types: [tag-created]

jobs:
  release:
    name: Build and Release
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        include:
          - os: ubuntu-latest
            artifact_name: leis-municipais
            asset_name: leis-municipais-linux-amd64
          - os: macos-latest
            artifact_name: leis-municipais
            asset_name: leis-municipais-macos-amd64
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
      - name: Build project
        run: cargo build --release --locked
      - name: Upload binary to release
        uses: svenstaro/upload-release-action@v1-release
        with:
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          file: target/release/${{ matrix.artifact_name }}
          asset_name: ${{ matrix.asset_name }}
          tag: ${{ github.event.client_payload.new_version }}

3.3 When to execute the release workflow

on:
  repository_dispatch:
    types: [tag-created]

This workflow is going to be executed every time there is a repository_dispatch of type tag-created(in our case dispatched by create_new_tag.yml)

3.4 Different environments to run the Release job

name: Build and Release
    runs-on: ${{ matrix.os }}
    strategy:
      matrix:
        include:
          - os: ubuntu-latest
            artifact_name: leis-municipais
            asset_name: leis-municipais-linux-amd64
          - os: macos-latest
            artifact_name: leis-municipais
            asset_name: leis-municipais-macos-amd64

strategy and matrix means that you have more than one variation of environment to run your job in. Is like an array where each block inside include specifies one configuration. In our example is for ubuntu-latest and macos-latest. arfifact_name and asset_name are also necessary for the next steps(artifact_name maybe could be omitted since they have the same value).

3.5 Release job and its steps

3.5.1 Checkout and build

- name: Checkout code
  uses: actions/checkout@v2
- name: Build project
  run: cargo build --release --locked

Here is the same as we saw in here. The difference is that all steps run for each different platform.

3.5.2 Upload binary to release

- name: Upload binary to release
  uses: svenstaro/upload-release-action@v1-release
  with:
    repo_token: ${{ secrets.GITHUB_TOKEN }}
    file: target/release/${{ matrix.artifact_name }}
    asset_name: ${{ matrix.asset_name }}
    tag: ${{ github.event.client_payload.new_version }}

Here we also use svenstaro/upload-release-action@v1-release action to upload the generated binaries to the created tag/release. The difference is that we use matrix context properties to differentiate between each platform:

  • file: target/release/${{ matrix.artifact_name }} is the generated binary that is going to be uploaded to the release
  • asset_name: ${{ matrix.asset_name }}: is the binary’s name that appears in the release page. Since we have two different platforms, we want to differentiate them for the users.
  • tag: ${{ github.event.client_payload.new_version }}: is how we access the information received from the dispatched repository event. What matters here is the new_version property that contains the desired tag version to upload our binary files.

In the end, you should have something as we have in our releases page. After downloading one of the binaries, you probably need to run chmod +x binary_file in order to execute it.

4- Conclusion

The final set up of the workflow seems simple now, but it took some time to check how tags, versioning and releases work in GitHub. After that finding the actions that you need and at the same time associating the events that you need to run each workflow. Also stumbling in some workflow “limitations” like how to pass data between different jobs and understanding how you can achieve the same result using a different approach.

5- Extras

5.1 Pull request events

During the setup I also had tried with pull_request event:

pull_request:
    branches:
      - master

And I learnt that means that this is going to make the workflow to be executed NOT just when the PR it’s merged into master but even when a pull request is created. I didn’t want that, so I removed.

5.2 Solving the releasing binaries for a single target for Windows/macOS platforms

As mentioned in one of the previous sections anothrNick/github-tag-action@1.17.2 action only works in Linux VMs. Using the same approach that we used for multiple target releases, we could solve the problem for a single Windows release, per example, with something like (beware I didn’t test this workflow):

name: Build, bump tag version and release

on:
  repository_dispatch:
    types: [tag-created]

jobs:
  release:
    name: Build and Release
    runs-on: windows-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2
      - name: Build project
        run: cargo build --release --locked
      - name: Upload binary to release
        uses: svenstaro/upload-release-action@v1-release
        with:
          repo_token: ${{ secrets.GITHUB_TOKEN }}
          file: target/release/alemanes
          asset_name: alemanes-linux-amd64
          tag: ${{ github.event.client_payload.new_version }}
          overwrite: true