How to cross-compile your Rust applications using cross-rs and GitHub Actions
and Docker
TL;DR: Le code
Introduction
In this blog post, we will have a look at how to cross-compile your Rust applications using cross-rs and GitHub Actions. We will also have a look at how to use the cross-rs Docker image to cross-compile your Rust applications locally.
But before we dig into the details, let's have a look at multi-platform support in Rust.
Motivation for multi-platform support
Nowadays, it is common to use different operating systems and architectures. We have IoT devices that run on ARM processors and servers which run on x86 processors. Or Apple computers that run on Apple Silicon processors. And then we have Windows, Linux and macOS. Enough reasons to support multiple platforms when developing software.
But now comes the downside: Most OS APIs are not compatible with each other. This difference in APIs is the reason why have to create platform-dependent code.
How Rust supports multi-platform
The good news is that Rust makes it easy to write multi-platform code. Rust has a built-in macro called cfg
which enables us the conditional compilation of code. It supports a lot of options so we can easily write platform-dependent parts of our code depending on the target platform.
https://doc.rust-lang.org/reference/conditional-compilation.html
Some examples of cfg
:
#[cfg(target_os = "linux")]
fn main() {
println!("This is Linux");
}
#[cfg(target_os = "macos")]
fn main() {
println!("This is macOS");
}
The cfg
macro also supports any
, all
and not
:
any
- If any of the given predicates is true, the code is included.all
- If all of the given predicates are true, the code is included.not
- If the given predicate is false, the code is included.
#[cfg(any(target_os = "linux", target_os = "macos"))]
fn main() {
println!("This is Linux or macOS");
}
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn main() {
println!("This is Linux on x86_64");
}
#[cfg(not(target_os = "windows"))]
fn main() {
println!("This is not Windows");
}
Rust also supports platform-dependent dependencies. We can use the target
attribute to specify dependencies for a specific platform. The cfg
syntax is also supported.
[dependencies]
# This dependency is only used on Linux
[target.'cfg(unix)'.dependencies]
openssl = "1.0.1"
Same for dev-dependencies
and build-dependencies
:
[dev-dependencies]
# This dev-dependency is only used on Linux
[target.'cfg(unix)'.dev-dependencies]
openssl = "1.0.1"
[build-dependencies]
# This build-dependency is only used on Linux
[target.'cfg(unix)'.build-dependencies]
openssl = "1.0.1"
Rust support tiers
Rust is organized in support tiers when it comes to multi-platform support. The Rust team provides different levels of support for different platforms. The support tiers are:
Tier 1 - Tier 1 platforms are "guaranteed to work", this is the highest level of support
Tier 2 - Tier 2 platforms are "guaranteed to build", but not necessarily to pass all tests
Tier 3 - Tier 3 platforms are those for which the Rust code has support, but which are not built or tested automatically. So there is no guarantee that they work.
According to the Rust team, the following platforms are Tier 1:
aarch64-unknown-linux-gnu
i686-pc-windows-gnu
i686-pc-windows-msvc
i686-unknown-linux-gnu
x86_64-apple-darwin
x86_64-pc-windows-gnu
x86_64-pc-windows-msvc
x86_64-unknown-linux-gnu
cross-rs
to the rescue
With cross-rs
we have an easy way to cross-compile our Rust applications. cross-rs
works by using pre-made Dockerfiles to build and run your application inside a Docker container. The list of supported platforms is quite long, have a look at the following link for more information:
Here is a short selection of supported platforms:
Dockerfile.x86_64-unknown-linux-gnu
Dockerfile.x86_64-pc-windows-gnu
Dockerfile.aarch64-unknown-linux-gnu
Dockerfile.i686-unknown-linux-gnu
Dockerfile.i686-pc-windows-gnu
Dockerfile.armv7-unknown-linux-gnueabihf
Dockerfile.riscv64gc-unknown-linux-gnu
Dockerfile.mips64-unknown-linux-gnuabi64
Dockerfile.powerpc64le-unknown-linux-gnu
Dockerfile.x86_64-unknown-linux-musl
Pretty impressive, right?
Let us create a simple demo project to see cross-rs
in action.
Prerequisites
An IDE or text editor of your choice
GitHub account
Initialize the demo project
The demo project is a simple Rust application that prints a FIGlet text to the console. We use clap to parse the command line arguments. That's it. Nothing fancy!
cargo init --bin figctl
Then we add the clap
dependency:
cargo add clap --features derive
And then we add a figlet-rs
dependency:
cargo add figlet-rs
Now we can add the following code to src/main.rs
:
use clap::{Parser, Args};
use figlet_rs::FIGfont;
#[derive(Parser, Debug)]
struct FigletCtl {
message: String,
}
fn main() {
let args = FigletCtl::parse();
let standard_font = FIGfont::standard().unwrap();
let figure = standard_font.convert(args.message.as_str());
println!("{}", figure.unwrap());
}
If you run the application now, you should see the following output:
➜ cargo run -q -- 'Hello World!'
_ _ _ _ __ __ _ _ _
| | | | ___ | | | | ___ \ \ / / ___ _ __ | | __| | | |
| |_| | / _ \ | | | | / _ \ \ \ /\ / / / _ \ | '__| | | / _` | | |
| _ | | __/ | | | | | (_) | \ V V / | (_) | | | | | | (_| | |_|
|_| |_| \___| |_| |_| \___/ \_/\_/ \___/ |_| |_| \__,_| (_)
Cross-compile the demo project
You can install cross-rs
with the following command:
cargo install cross --git https://github.com/cross-rs/cross
And let test it with a simple example.
cross build --target aarch64-unknown-linux-gnu
Now you should have a target/aarch64-unknown-linux-gnu/debug/figctl
binary. Let's run it and see what happens.
➜ ./target/aarch64-unknown-linux-gnu/debug/figctl "Hello World!"
zsh: exec format error: ./target/aarch64-unknown-linux-gnu/debug/figctl
The binary is not executable on our host system. But we can run it inside a Docker container.
docker run --rm -it -v $(pwd):/app --platform=linux/arm64 -w /app rust ./target/aarch64-unknown-linux-gnu/debug/figctl 'Hello World!'
_ _ _ _ __ __ _ _ _
| | | | ___ | | | | ___ \ \ / / ___ _ __ | | __| | | |
| |_| | / _ \ | | | | / _ \ \ \ /\ / / / _ \ | '__| | | / _` | | |
| _ | | __/ | | | | | (_) | \ V V / | (_) | | | | | | (_| | |_|
|_| |_| \___| |_| |_| \___/ \_/\_/ \___/ |_| |_| \__,_| (_)
It works! So let's see how we can use cross-rs
to build our application for multiple platforms with GitHub Actions.
GitHub Actions
We need to create a file called .github/workflows/ci.yml
and add the following content:
name: build and release
on:
workflow_dispatch:
release:
types: [ created ]
permissions:
contents: write
jobs:
build:
name: ${{ matrix.platform.os_name }} with rust ${{ matrix.toolchain }}
runs-on: ${{ matrix.platform.os }}
strategy:
fail-fast: false
matrix:
platform:
- os_name: Linux-aarch64
os: ubuntu-20.04
target: aarch64-unknown-linux-musl
bin: figctl-linux-arm64
- os_name: Linux-x86_64
os: ubuntu-20.04
target: x86_64-unknown-linux-gnu
bin: figctl-linux-amd64
- os_name: Windows-x86_64
os: windows-latest
target: x86_64-pc-windows-msvc
bin: figctl-amd64.exe
- os_name: macOS-x86_64
os: macOS-latest
target: x86_64-apple-darwin
bin: figctl-darwin-amd64
- os_name: macOS-aarch64
os: macOS-latest
target: aarch64-apple-darwin
bin: figctl-darwin-arm64
toolchain:
- stable
steps:
- uses: actions/checkout@v3
- name: Build binary
uses: houseabsolute/actions-rust-cross@v0
with:
command: "build"
target: ${{ matrix.platform.target }}
toolchain: ${{ matrix.toolchain }}
args: "--locked --release"
strip: true
- name: Rename binary (linux and macos)
run: mv target/${{ matrix.platform.target }}/release/figctl target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}
if: matrix.platform.os_name != 'Windows-x86_64'
- name: Rename binary (windows)
run: mv target/${{ matrix.platform.target }}/release/figctl.exe target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}
if: matrix.platform.os_name == 'Windows-x86_64'
- name: Generate SHA-256
run: shasum -a 256 target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }} | cut -d ' ' -f 1 > target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}.sha256
- name: Release binary and SHA-256 checksum to GitHub
uses: softprops/action-gh-release@v1
with:
files: |
target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}
target/${{ matrix.platform.target }}/release/${{ matrix.platform.bin }}.sha256
This workflow will build the application for the following platforms:
Linux-aarch64
Linux-x86_64
Windows-x86_64
macOS-x86_64
macOS-aarch64
It will also create a GitHub release for each platform.
Let's push the changes to GitHub and create a new release.
git add .
git commit -m "Add all the things"
git push
Now go to the GitHub repository and create a new release or use the GitHub CLI.
➜ gh release create v0.1.0
? Title (optional) My figctl cli
? Release notes Leave blank
? Is this a prerelease? No
? Submit? Publish release
https://github.com/dirien/rust-cross-compile/releases/tag/v0.1.0
This will trigger the GitHub Actions workflow and build the application for all the platforms.
Conclusion
Creating a cross-platform application with Rust is easy as Rust has a great toolchain and ecosystem. But it's not always easy to build the application for multiple platforms. With cross-rs
and GitHub Actions, we can build our application for multiple platforms and with GitHub Releases, we can distribute the application to our users.