ahmedjama.com
AJ

Building a cross platform Rust CI/CD pipeline with GitHub Actions

· 8 min read
Building a cross platform Rust CI/CD pipeline with GitHub Actions

Getting a Rust project to build reliably across multiple platforms can be challenging, particularly when you need to target ARM architectures like those found on Raspberry Pi devices. This guide walks through a GitHub Actions pipeline that handles cross-compilation, caching, and automated releases, making it straightforward to deliver binaries for various platforms.

TL;DR

This is the full pipeline we’ll break down in the following sections.

.github/workflows/release.yml

name: Release Build

on:
  push:
    tags:
      - 'v*'
  pull_request:
    branches:
      - main

env:
  CARGO_TERM_COLOR: always

jobs:
  build:
    name: Build - ${{ matrix.platform.target }}
    runs-on: ${{ matrix.platform.os }}
    
    strategy:
      fail-fast: false
      matrix:
        platform:
          # ARM Linux (Raspberry Pi) - Your priority platform
          - name: Linux ARM64
            os: ubuntu-latest
            target: aarch64-unknown-linux-gnu
            binary_ext: ""
            use_cross: true
          
          - name: Linux ARMv7
            os: ubuntu-latest
            target: armv7-unknown-linux-gnueabihf
            binary_ext: ""
            use_cross: true
          
          ## x86 Linux
          #- name: Linux x86_64
          #  os: ubuntu-latest
          #  target: x86_64-unknown-linux-gnu
          #  binary_ext: ""
          #  use_cross: false
          #
          ## Windows
          #- name: Windows x86_64
          #  os: windows-latest
          #  target: x86_64-pc-windows-msvc
          #  binary_ext: ".exe"
          #  use_cross: false
          #
          #- name: Windows ARM64
          #  os: windows-latest
          #  target: aarch64-pc-windows-msvc
          #  binary_ext: ".exe"
          #  use_cross: false
          #
          ## macOS
          #- name: macOS x86_64
          #  os: macos-latest
          #  target: x86_64-apple-darwin
          #  binary_ext: ""
          #  use_cross: false
          #
          #- name: macOS ARM64
          #  os: macos-latest
          #  target: aarch64-apple-darwin
          #  binary_ext: ""
          #  use_cross: false

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Rust toolchain
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.platform.target }}

      - name: Install cross-compilation tool
        if: matrix.platform.use_cross
        run: cargo install cross --git https://github.com/cross-rs/cross
      
      - name: Setup OpenSSL for native builds
        if: ${{ !matrix.platform.use_cross && matrix.platform.os == 'ubuntu-latest' }}
        run: |
          sudo apt-get update
          sudo apt-get install -y pkg-config libssl-dev

      - name: Cache cargo registry
        uses: actions/cache@v4
        with:
          path: ~/.cargo/registry
          key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}

      - name: Cache cargo index
        uses: actions/cache@v4
        with:
          path: ~/.cargo/git
          key: ${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}

      - name: Cache cargo build
        uses: actions/cache@v4
        with:
          path: target
          key: ${{ runner.os }}-cargo-build-target-${{ matrix.platform.target }}-${{ hashFiles('**/Cargo.lock') }}

      - name: Build with cross
        if: matrix.platform.use_cross
        run: cross build --release --target ${{ matrix.platform.target }}

      - name: Build natively
        if: ${{ !matrix.platform.use_cross }}
        run: cargo build --release --target ${{ matrix.platform.target }}

      - name: Get binary name
        id: binary_name
        shell: bash
        run: |
          BINARY_NAME=$(cargo metadata --no-deps --format-version 1 | jq -r '.packages[0].targets[] | select(.kind[] | contains("bin")) | .name' | head -n 1)
          echo "name=$BINARY_NAME" >> $GITHUB_OUTPUT

      - name: Package binary
        shell: bash
        run: |
          BINARY_NAME="${{ steps.binary_name.outputs.name }}"
          BINARY_PATH="target/${{ matrix.platform.target }}/release/${BINARY_NAME}${{ matrix.platform.binary_ext }}"
          ARCHIVE_NAME="${BINARY_NAME}-${{ matrix.platform.target }}"
          
          mkdir -p dist
          
          if [[ "${{ matrix.platform.os }}" == "windows-latest" ]]; then
            7z a "dist/${ARCHIVE_NAME}.zip" "${BINARY_PATH}"
          else
            tar -czf "dist/${ARCHIVE_NAME}.tar.gz" -C "target/${{ matrix.platform.target }}/release" "${BINARY_NAME}${{ matrix.platform.binary_ext }}"
          fi

      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: ${{ steps.binary_name.outputs.name }}-${{ matrix.platform.target }}
          path: dist/*
          if-no-files-found: error

  release:
    name: Create Release
    needs: build
    runs-on: ubuntu-latest
    if: startsWith(github.ref, 'refs/tags/v')
    
    steps:
      - name: Download all artifacts
        uses: actions/download-artifact@v4
        with:
          path: artifacts

      - name: Create release
        uses: softprops/action-gh-release@v1
        with:
          files: artifacts/**/*
          draft: false
          prerelease: false
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Pipeline overview

This workflow accomplishes two main tasks: building your Rust binary for multiple target platforms and creating GitHub releases with the compiled artifacts when you push a version tag. The pipeline triggers on two events: when you push a tag starting with v (such as v1.0.0), or when you open a pull request against the main branch. This means you get build verification on every PR and automatic releases when you tag a version.

The build matrix

The heart of this pipeline is the build matrix, which defines each target platform. Each entry specifies the operating system to run on, the Rust target triple, whether the binary needs a file extension, and whether cross-compilation tools are required.

platform:
  - name: Linux ARM64
    os: ubuntu-latest
    target: aarch64-unknown-linux-gnu
    binary_ext: ""
    use_cross: true
  
  - name: Linux ARMv7
    os: ubuntu-latest
    target: armv7-unknown-linux-gnueabihf
    binary_ext: ""
    use_cross: true

The configuration shown focuses on ARM Linux targets, which is particularly useful if you’re building for Raspberry Pi or similar devices. The commented-out sections demonstrate how you can easily extend this to include x86 Linux, Windows, and macOS builds. Simply uncomment the platforms you need.

The use_cross flag is crucial. When set to true, the pipeline uses the cross tool for compilation rather than native cargo builds. This is necessary for ARM targets when building on x86 runners, as GitHub Actions doesn’t provide native ARM runners for public repositories.

Setting up the build environment

After checking out your code, the pipeline installs the Rust toolchain with support for your target platform. The dtolnay/rust-toolchain action makes this straightforward, automatically configuring the correct target.

For cross-compilation scenarios, the workflow installs the cross tool, which uses Docker containers with the necessary toolchains and system libraries for each target. For native builds on Ubuntu, it ensures OpenSSL development libraries are available, as many Rust projects depend on these.

Caching strategy

Build times matter, particularly when you’re compiling for multiple targets. This pipeline implements three levels of caching to speed up subsequent builds.

The cargo registry cache stores downloaded crate metadata, whilst the cargo index cache holds the git repository information for dependencies. Most importantly, the build cache stores compiled dependencies in the target directory. This last cache is keyed by both the platform and your Cargo.lock file, ensuring that cached dependencies remain valid across builds whilst allowing different platforms to maintain separate caches.

- name: Cache cargo build
  uses: actions/cache@v4
  with:
    path: target
    key: ${{ runner.os }}-cargo-build-target-${{ matrix.platform.target }}-${{ hashFiles('**/Cargo.lock') }}

When dependencies haven’t changed, these caches can reduce build times from minutes to seconds.

Building and packaging

The build step itself is conditional: it uses cross for ARM targets and native cargo for others. This keeps the workflow flexible whilst handling the complexity of cross-compilation where needed.

- name: Build with cross
  if: matrix.platform.use_cross
  run: cross build --release --target ${{ matrix.platform.target }}

After building, the pipeline automatically detects your binary name from the Cargo metadata. This means you don’t need to hardcode the binary name in your workflow, making it reusable across different projects.

The packaging step creates compressed archives appropriate for each platform. Linux and macOS binaries are packaged as .tar.gz files, whilst Windows binaries go into .zip archives. Each archive is named with both the binary name and target triple, making it clear which file is for which platform.

Creating releases

The second job runs only when you’ve pushed a tag. It downloads all the build artifacts from the matrix job and creates a GitHub release using the softprops/action-gh-release action. The release includes all the packaged binaries, making it easy for users to download the version they need.

release:
  name: Create Release
  needs: build
  runs-on: ubuntu-latest
  if: startsWith(github.ref, 'refs/tags/v')

The needs: build dependency ensures all builds complete successfully before attempting to create a release. If any build fails, the release job won’t run.

Adapting this pipeline

To use this pipeline in your own project, copy the workflow file to .github/workflows/release.yml in your repository. Then make these adjustments:

First, uncomment the platform targets you need. If you’re only building for x86 Linux, you can remove the ARM targets and disable cross-compilation. If you need Windows support, uncomment those entries.

Second, adjust the trigger conditions if needed. You might want to build on every push to main, or only on tags, depending on your workflow.

Third, consider whether you need the OpenSSL setup step. If your project doesn’t use OpenSSL (directly or through dependencies), you can remove it. Conversely, if you need other system libraries, add them in a similar step.

Finally, review the caching strategy. The current setup works well for most projects, but if you have particularly large dependencies or multiple workspace members, you might want to tune the cache keys.

Configuring repository permissions

Before this pipeline can create releases, you need to grant it the necessary permissions. GitHub Actions uses the GITHUB_TOKEN to authenticate with the GitHub API, but by default, this token has restricted permissions in modern repositories.

Navigate to your repository settings, then go to Actions > General. Scroll down to the “Workflow permissions” section. You’ll see two options: “Read repository contents and packages permissions” and “Read and write permissions”. Select “Read and write permissions” to allow the workflow to create releases and upload artefacts.

Alternatively, if you prefer to keep the default read-only permissions for most workflows, you can grant permissions explicitly in this workflow file by adding a permissions block at the top level:

permissions:
  contents: write

This approach is more secure as it limits write access to only this workflow rather than all workflows in your repository. However, for many projects, particularly those where you control all the workflows, the repository-level setting is simpler to manage.

Testing before release

Because this pipeline runs on pull requests, you can verify that your code builds successfully for all targets before merging. The artifacts are uploaded but not released, giving you a chance to download and test them if needed. Only when you push a version tag does the pipeline create a public release.

This approach provides confidence that your releases will work across platforms whilst keeping the feedback loop tight during development. Your users get reliable, cross-platform binaries, and you get automated builds that just work.

Share: