Cross Compiling Rust Projects in GitHub Actions

I was recently working on the CI setup for my ubi project with a couple goals. First, I wanted to stop using unmaintained actions from the actions-rs organization. Second, I wanted to add many more release targets for different platforms and architectures1.

Replacing some of what I used from actions-rs was pretty easy:

But what about actions-rs/cargo? You’d think that running cargo wouldn’t even need an action, and you’d be right. Except that this action doesn’t just run cargo. If you set its use-cross parameter to true it uses cross to do the build instead of cargo, making it trivial to cross-compile a Rust project.

I was already doing some cross-compilation for all my Rust projects, and I wanted to add more. So I needed to replace this action with something of my own. I couldn’t find any already written, probably because everyone who moved away from actions-rs kept saying things like “this is too trivial to need an action, it’s just running cargo build.”

So for my first pass, I simply embedded the build pieces directly in the GitHub workflow for UBI, like this:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
jobs:
  release:
    name: Release - ${{ matrix.platform.os_name }}
    if: startsWith( github.ref, 'refs/tags/v' ) || github.ref == 'refs/tags/test-release'
    strategy:
      matrix:
        platform:
          - os_name: FreeBSD-x86_64
            os: ubuntu-20.04
            target: x86_64-unknown-freebsd
            bin: ubi
            name: ubi-FreeBSD-x86_64.tar.gz
            cross: true
            cargo_command: ./cross

          - os_name: Linux-x86_64
            os: ubuntu-20.04
            target: x86_64-unknown-linux-musl
            bin: ubi
            name: ubi-Linux-x86_64-musl.tar.gz
            cross: false
            cargo_command: cargo

          - os_name: Windows-aarch64
            os: windows-latest
            target: aarch64-pc-windows-msvc
            bin: ubi.exe
            name: ubi-Windows-aarch64.zip
            cross: false
            cargo_command: cargo

          - os_name: macOS-x86_64
            os: macOS-latest
            target: x86_64-apple-darwin
            bin: ubi
            name: ubi-Darwin-x86_64.tar.gz
            cross: false
            cargo_command: cargo

    runs-on: ${{ matrix.platform.os }}
    steps:
      - name: Checkout
        uses: actions/checkout@v3
      - name: Install toolchain if not cross-compiling
        uses: dtolnay/rust-toolchain@stable
        with:
          targets: ${{ matrix.platform.target }}
        if: ${{ !matrix.platform.cross }}
      - name: Install musl-tools on Linux
        run: sudo apt-get update --yes && sudo apt-get install --yes musl-tools
        if: contains(matrix.platform.os, 'ubuntu') && !matrix.platform.cross
      - name: Install cross if cross-compiling (*nix)
        id: cross-nix
        shell: bash
        run: |
          set -e
          export TARGET="$HOME/bin"
          mkdir -p "$TARGET"
          ./bootstrap/bootstrap-ubi.sh
          "$HOME/bin/ubi" --project cross-rs/cross --matching musl --in .          
        if: matrix.platform.cross && !contains(matrix.platform.os, 'windows')
      - name: Install cross if cross-compiling (Windows)
        id: cross-windows
        shell: powershell
        run: |
          .\bootstrap\bootstrap-ubi.ps1
          .\ubi --project cross-rs/cross --in .          
        if: matrix.platform.cross && contains(matrix.platform.os, 'windows')
      - name: Build binary (*nix)
        shell: bash
        run: |
          ${{ matrix.platform.cargo_command }} build --locked --release --target ${{ matrix.platform.target }}          
        if: ${{ !contains(matrix.platform.os, 'windows') }}
      - name: Build binary (Windows)
        # We have to use the platform's native shell. If we use bash on
        # Windows then OpenSSL complains that the Perl it finds doesn't use
        # the platform's native paths and refuses to build.
        shell: powershell
        run: |
          & ${{ matrix.platform.cargo_command }} build --locked --release --target ${{ matrix.platform.target }}          
        if: contains(matrix.platform.os, 'windows')
      - name: Strip binary
        shell: bash
        run: |
          strip target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}          
        # strip doesn't work with cross-arch binaries on Linux or Windows.
        if: ${{ !(matrix.platform.cross || matrix.platform.target == 'aarch64-pc-windows-msvc') }}
      - name: Package as archive
        shell: bash
        run: |
          cd target/${{ matrix.platform.target }}/release
          if [[ "${{ matrix.platform.os }}" == "windows-latest" ]]; then
            7z a ../../../${{ matrix.platform.name }} ${{ matrix.platform.bin }}
          else
            tar czvf ../../../${{ matrix.platform.name }} ${{ matrix.platform.bin }}
          fi
          cd -          
      - name: Publish release artifacts
        uses: actions/upload-artifact@v3
        with:
          name: ubi-${{ matrix.platform.os_name }}
          path: "ubi*"
        if: github.ref == 'refs/tags/test-release'
      - name: Publish GitHub release
        uses: softprops/action-gh-release@v1
        with:
          draft: true
          files: "ubi*"
          body_path: Changes.md
        if: startsWith( github.ref, 'refs/tags/v' )

I’ve actually cut quite a bit of this out, notably the other 15 or so platforms in the matrix.

Here are the highlights:

  1. If it needs cross it will install that from its latest GitHub release using ubi itself. This is much faster than compiling cross by running cargo install. Otherwise it uses dtolnay/rust-toolchain to install the Rust toolchain.
  2. It will run the build command (cross or cargo) in the appropriate shell for the platform. Using the right shell matters for some corner cases. Notably, ubi now depends on the openssl crate with the vendored feature enabled. With that feature, the crate will actually compile OpenSSL and statically link it into your binary. But OpenSSL fails to compile in an msys shell on Windows!

And that’s really it. I’ve extracted the generic bits and turned it into a reusable action called Build Rust Projects with Cross.

You can see it in use in the release job for ubi. The YAML for precious is nearly identical (which suggests that maybe I need to write another action).


  1. For the 0.0.20 release, there are published binaries for 20 different OS/CPU targets, including many for Linux, some for Windows and macOS, and one each for FreeBSD and NetBSD. ↩︎