Releasing Rust Binaries with JReleaser

You've spent some time working on that Rust project of yours and are now ready to post a release. It's quite straight forward to build and upload binaries as assets to a Git release but let's face it, we can't expect consumers to find the release page, select the appropriate binary for their platform, download the file, and configure their environment settings to point to the new binary. Well yes, some people like to do that but most prefer to use a tool instead, something that automates the steps just described, something like a package manager such as Homebrew, Snapcraft, Chocolatey, and others.

These package managers have a set of requirements such as binaries (or zipballs/tarballs) available at a public URL (the Git release page being a perfect candidate) but might as well be an S3 bucket or a JFrog Artifactory URL. They also require checksums (SHA-256 being the most popular option), as well as additional metadata such as license, project website, icons, etc. Supporting each one of these packaging options for your project may become a tedious task. This is where JReleaser comes in to save the day.

JReleaser

JReleaser and Rust

Even though JReleaser is a tool born in the Java world it may be used with non Java projects, such as Rust based projects. There are a few considerations to be followed in order to use them together.

To begin with JReleaser follows a set of platform definitions such as "osx-x86_64" and "linux-aarch64" whereas Rust prefers a different set such as "x86_64-apple-darwin" and "aarch64-unknown-linux-gnu". This is not a problem at all as we can use JReleaser's platform replacements feature to map from one set to the other.

Configuring Platforms

Say we'd like to build binaries for the 3 major platforms supported by GitHub runners that is, linux, windows, and macOS. Using the default configuration file jreleaser.yml would lead to these mappings:

platform:
  replacements:
    'osx-x86_64': 'x86_64-apple-darwin'
    'linux-x86_64': 'x86_64-unknown-linux-gnu'
    'windows-x86_64': 'x86_64-pc-windows-msvc'

If YAML is not to your liking then you may switch to TOML and write down the configuration in jreleaser.toml.

Next, JReleaser does not build the binaries, it's only concerned with releasing them thus you continue to use the same Cargo instructions as you've had so far. If you want you may cross compile binaries or run the build in specific nodes. What's important is that at the end of the build a set of binaries should be ready for JReleaser to continue with its responsibilities. For this reason we only have to list the binaries we'd like to see released.

Assembling Distributions

Binaries must be part of what's know as a distribution, the one we need for Rust binaries is of type BINARY. Distributions of this type follow a particular file structure which is usually packaged as a archives (zipballs or tarballs):

.
├── LICENSE
├── README
└── bin
    └── executable

You may use Cargo to create such structure and archive or let JReleaser perform that task for you. If you choose the latter then you gain the option to create such archives in a platform independent manner. Say we have a "helloworld" application that produces a "helloworld[.exe]" executable (depending on the platform of course) that we'd like to package alongside the project's LICENSE file. This is what would be needed in the jreleaser.yml config file to acchieve this goal:

assemble:
  archive:
    helloworld:
      active: ALWAYS
      formats: [ ZIP ]
      attachPlatform: true
      fileSets:
        - input: 'target/release'
          output: 'bin'
          includes: [ 'helloworld{.exe,}' ]
        - input: '.'
          includes: [ 'LICENSE' ]

This configuration, along side the platform replacements defined earlier should produce a set of binaries like these ones when running on GitHub Actions:

  • helloworld-<version>-x86_64-apple-darwin.zip
  • helloworld-<version>-x86_64-pc-windows-msvc.zip
  • helloworld-<version>-x86_64-unknown-linux-gnu.zip

The value of <version> will be set as part of the GitHub workflow execution as we'll see in a moment. Listing the binaries inside a distribution can be done in this way:

distributions:
  helloworld:
    type: BINARY
    executable:
      windowsExtension: exe
    artifacts:
      - path: '{{artifactsDir}}/{{distributionName}}-{{projectVersion}}-x86_64-apple-darwin.zip'
        platform: 'osx-x86_64'
      - path: '{{artifactsDir}}/{{distributionName}}-{{projectVersion}}-x86_64-unknown-linux-gnu.zip'
        platform: 'linux-x86_64'
      - path: '{{artifactsDir}}/{{distributionName}}-{{projectVersion}}-x86_64-pc-windows-msvc.zip'
        platform: 'windows-x86_64'

Mind that even though the archives follow the Rust platform conventions we still have to use the Java conventions as values for the platform: properties, just so that JReleaser may perform proper validation and configure selected package managers as needed.

Finishing the Configuration

There are 2 additional blocks that need to be configured: project metadata such as name, license, authors, etc; release and changelog configuration. The whole file could end up looking like this:

jreleaser.yml

environment:
  properties:
    artifactsDir: out/jreleaser/assemble/helloworld/archive

project:
  name: helloworld
  description: HelloWorld in Rust
  longDescription: HelloWorld in Rust
  website: https://github.com/aalmiray/helloworld-rust
  authors:
    - Andres Almiray
  license: MIT
  extraProperties:
    inceptionYear: 2021

platform:
  replacements:
    'osx-x86_64': 'x86_64-apple-darwin'
    'linux-x86_64': 'x86_64-unknown-linux-gnu'
    'windows-x86_64': 'x86_64-pc-windows-msvc'

release:
  github:
    name: helloworld-rust
    overwrite: true
    changelog:
      formatted: ALWAYS
      format: '- {{commitShortHash}} {{commitTitle}}'
      preset: conventional-commits
      contributors:
        format: '- {{contributorName}}{{#contributorUsernameAsLink}} ({{.}}){{/contributorUsernameAsLink}}'

assemble:
  archive:
    helloworld:
      active: ALWAYS
      formats: [ ZIP ]
      attachPlatform: true
      fileSets:
        - input: 'target/release'
          output: 'bin'
          includes: [ 'helloworld{.exe,}' ]
        - input: '.'
          includes: [ 'LICENSE' ]

distributions:
  helloworld:
    type: BINARY
    executable:
      windowsExtension: exe
    artifacts:
      - path: '{{artifactsDir}}/{{distributionName}}-{{projectVersion}}-x86_64-apple-darwin.zip'
        platform: 'osx-x86_64'
      - path: '{{artifactsDir}}/{{distributionName}}-{{projectVersion}}-x86_64-unknown-linux-gnu.zip'
        platform: 'linux-x86_64'
      - path: '{{artifactsDir}}/{{distributionName}}-{{projectVersion}}-x86_64-pc-windows-msvc.zip'
        platform: 'windows-x86_64'

Full sources for this particular project can be found at https://github.com/aalmiray/helloworld-rust. You may have noticed the environment: block and the use of a Mustache template named artifactsDir in the artifact paths. We need these two items as the artifacts will be assembled by separate runners on GitHub Actions, uploaded to a common directory to be collected, then downloaded by another GitHub workflow job and be released. The collecting path is different as the assembly path. If you were to run cross compilation on a single node and skip artifact collection then these 2 settings wouldn't be needed.

Adding a GitHub Workflow

Speaking of the GitHub workflow, you may have JReleaser trigger on any particular condition such as pushing a tag, or a workflow dispatch, or any other kind of event. For demo purposes the helloworld application uses a push on the main branch:

.github/workflows/early-access.yml:

name: EarlyAccess

# Build on every push to main
on:
  push:
    branches: [ main ]

jobs:
  build:
    strategy:
      fail-fast: true
      matrix:
        os: [ ubuntu-latest, macOS-latest, windows-latest ]
    runs-on: ${{ matrix.os }}

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

      # Configure the Rust toolchain
      - uses: actions-rs/toolchain@v1
        with:
          toolchain: stable

      - uses: actions-rs/cargo@v1
        with:
          command: build
          args: --release --all-features

      # Read project version from file
      - name: Version
        id: version
        uses: juliangruber/read-file-action@v1
        with:
          path: VERSION
          trim: true

      # Assemble the zipball
      - name: Assemble
        uses: jreleaser/release-action@v2
        with:
          version: early-access
          arguments: assemble
        env:
          JRELEASER_PROJECT_VERSION: ${{ steps.version.outputs.content }}
          JRELEASER_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      # Upload archive
      - name: Upload artifacts
        uses: actions/upload-artifact@v2
        with:
          name: artifacts
          path: |
            out/jreleaser/assemble/helloworld/archive/*.zip

      - name: JReleaser output
        if: always()
        uses: actions/upload-artifact@v2
        with:
          name: jreleaser-${{ matrix.os }}
          path: |
            out/jreleaser/trace.log
            out/jreleaser/output.properties

  # Release all archives
  release:
    needs: [ build ]
    runs-on: ubuntu-latest

    steps:
      # Checkout with full history
      - name: Checkout
        uses: actions/checkout@v2
        with:
          fetch-depth: 0

      # Download all collected archives
      - name: Download artifacts
        uses: actions/download-artifact@v2

      # Read project version from file
      - name: Version
        id: version
        uses: juliangruber/read-file-action@v1
        with:
          path: VERSION
          trim: true

      # Release it!
      - name: Release
        uses: jreleaser/release-action@v2
        with:
          version: early-access
          arguments: release -PartifactsDir=artifacts -PskipArchiveResolver
        env:
          JRELEASER_PROJECT_VERSION: ${{ steps.version.outputs.content }}
          JRELEASER_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

      - name: JReleaser output
        if: always()
        uses: actions/upload-artifact@v2
        with:
          name: jreleaser-release
          path: |
            out/jreleaser/trace.log
            out/jreleaser/output.properties

Now all that's left is to push commits to the main branch and have a release pop up after a few minutes. The release may look like this https://github.com/aalmiray/helloworld-rust/releases/tag/early-access

Adding Package Managers

But what about those package managers we mentioned earlier? Well those may be added to the distribution. If your application follows established convention then there's little that needs to be configured. Here's for example how to enable a multi-platform Homebrew formula that is, a formula that can install binaries on macOS and Linux:

distributions:
  helloworld:
    type: BINARY
    executable:
      windowsExtension: exe
    artifacts:
      - path: '{{artifactsDir}}/{{distributionName}}-{{projectVersion}}-x86_64-apple-darwin.zip'
        platform: 'osx-x86_64'
      - path: '{{artifactsDir}}/{{distributionName}}-{{projectVersion}}-x86_64-unknown-linux-gnu.zip'
        platform: 'linux-x86_64'
      - path: '{{artifactsDir}}/{{distributionName}}-{{projectVersion}}-x86_64-pc-windows-msvc.zip'
        platform: 'windows-x86_64'
    # Just add this block!!
    brew:
      active: ALWAYS
      multiPlatform: true

Then switch from invoking the release command to the full-release command and you should be in business, it's just that easy. There are of course more options that you may set depending on your needs. Head to the JReleaser Guide to discover more about them.

Keep on coding!

Image by Ulrike Leone from Pixabay

Liked it? Take a second to support aalmiray on Patreon!

Leave a Reply

Your email address will not be published.

This site uses Akismet to reduce spam. Learn how your comment data is processed.

ˆ Back To Top