Three systems and continuous integration
Files: .github/workflows/ci.yml,
crates/lineup-app/src/main.rs,
crates/lineup-app/build.rs
and Cargo.toml
Ferrous Lineup runs on Linux, macOS and Windows from one code base. Almost none of its code knows which system it is on, and three workflows on GitHub check every change on all three. This chapter is about how a Rust program stays portable, and how the checks run.
Who talks to the operating system
Ferrous Lineup never calls the operating system for sound, windows or dialogs itself. Four crates do:
| Job | Crate | Linux | macOS | Windows |
|---|---|---|---|---|
| Sound | cpal | ALSA (also through PipeWire or PulseAudio) | Core Audio | WASAPI |
| Window, keys and mouse | winit, inside eframe | X11 or Wayland | AppKit | Win32 |
| Drawing | wgpu, inside eframe | Vulkan or OpenGL | Metal | DirectX 12 or Vulkan |
| File dialogs | rfd | the desktop portal | the Finder’s open and save panels | the Explorer dialog |
Each of these crates holds the code for every system and keeps only the
right one while compiling. The app calls cpal::default_host(), draws
with egui and opens an rfd::FileDialog; none of that names a system.
Each library was chosen for that: anything that worked on only one
system would have to be replaced to reach the others.
The workspace’s own code names a system in four places:
main.rshides the console window on Windows (below);build.rsputs the icon and the version into the Windows.exe;- the lead sheet chooses
cmd,openorxdg-opento show a PDF in the system’s viewer; - the sounds window shows a folder inside the home folder as
~/...only on Linux and macOS, where people write paths that way.
How a crate keeps one system’s code: cfg
Inside cpal, the default host is chosen like this (shortened):
#[cfg(any(target_os = "linux", target_os = "freebsd"))]
mod platform_impl {
pub fn default_host() -> Host {
AlsaHost::new().expect("the default host should always be available").into()
}
}
#[cfg(target_vendor = "apple")]
mod platform_impl {
pub fn default_host() -> Host {
CoreAudioHost::new().expect("the default host should always be available").into()
}
}
Two modules with the same name would normally be an error. But
#[cfg(...)] is a conditional compilation attribute: when its
condition is false, the item under it is removed before the compiler
even looks at it. On Linux only the first module exists; on a Mac only
the second. Conditions can ask about:
- the system you build for:
target_os = "windows",target_os = "macos",target_family = "unix"(or justunix),target_vendor = "apple"; - the kind of build:
debug_assertionsis on in debug builds,testwhile runningcargo test(every#[cfg(test)] mod testsuses it); - a crate’s features:
feature = "wayland"; - and combinations, with
any(..),all(..)andnot(..).
Cargo can do the same for dependencies. cpal’s own Cargo.toml asks
for the ALSA library only on systems that have it:
[target.'cfg(any(target_os = "linux", target_os = "freebsd"))'.dependencies]
alsa = "0.11"
There is also a cfg!(...) macro, which gives true or false as an
ordinary value. The lead sheet uses it:
#![allow(unused)]
fn main() {
let mut command = if cfg!(target_os = "windows") {
}
The difference matters. With cfg!, every branch is still compiled, so
every branch must be valid on every system; with #[cfg], code for other
systems is never compiled at all, which is why it can call functions
that only exist there. That is also its danger: a typo in a
#[cfg(windows)] function goes unnoticed on Linux, because the compiler
never reads it. Code you cannot compile is code you cannot trust, which
is why it is best kept inside a few well-tested crates, and why the
continuous integration below builds on every system.
Features: choosing the parts of a crate
The rfd line in the workspace’s Cargo.toml turns part of the crate
off:
rfd = { version = "0.17", default-features = false, features = ["xdg-portal"] }
A feature is an optional part of a crate, named in its Cargo.toml.
Some are on unless you say otherwise: the default features. rfd
normally turns on two: xdg-portal, which asks the Linux desktop to
show its own file dialog (over D-Bus, the desktop’s message system, so
there is no GTK to install), and wayland, which tells the portal which
window the dialog belongs to. Ferrous Lineup doesn’t tell it, so
default-features = false drops wayland, and features = ["xdg-portal"] keeps the one that is needed. Fewer features means fewer
crates to compile. On macOS and Windows the Linux feature changes
nothing: the #[cfg]s inside rfd leave its code out.
Features can only add. If two crates in a program use rfd, one with
wayland and one without, Cargo builds it once, with wayland. So a
feature must never take something away: a crate with it must still do
everything it did without it.
No console window on Windows: cfg_attr
On Windows, every program is either a console program, which opens a
terminal window when started, or a windows program, which does not.
Rust makes console programs unless told otherwise, so double-clicking
ferrous-lineup.exe would open an empty black window next to the app.
One line at the top of main.rs says otherwise:
#![allow(unused)]
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
fn main() {
}
windows_subsystem = "windows"asks for the second kind. On other systems the attribute does nothing.#![...], with!, applies to the thing it is inside, here the whole crate.#[...]without!applies to the next item.cfg_attr(condition, attribute)applies the attribute only when the condition is true.not(debug_assertions)is true in release builds.
So cargo run, a debug build, keeps the console, where println! and
panic messages show while you work, and cargo build --release makes
the quiet program you give to other people. Those messages then go
nowhere on Windows, which is one more reason the app shows audio errors
and the “Could not open” and “Could not save” messages inside its own
window.
A build script for the .exe
crates/lineup-app/build.rs is a build script: Cargo compiles and
runs it before compiling the crate. On Windows it puts the icon, the
version and the copyright into the .exe, so Explorer and the taskbar
show the app’s icon and the file’s Properties page its name:
#![allow(unused)]
fn main() {
#[cfg(windows)]
{
let version = env!("CARGO_PKG_VERSION");
}
Here #[cfg(windows)] describes the computer doing the build, because
a build script runs there: the resource compiler it calls is
Microsoft’s, which only Windows has. The release workflow builds the
Windows program on Windows, so the icon is always there. macOS and Linux
take their icons from the bundles and desktop files in packaging/ (see
Release packages).
Small differences the code already handles
- Shortcuts:
Modifiers::COMMANDis Ctrl on Linux and Windows and ⌘ on macOS. - Paths:
PathBufandPathhandle/and\, drive letters and file names that are not valid text.std::env::args_ospasses the file to open exactly as the system gave it. - Replacing a file:
std::fs::renamereplaces an existing file on every system, which safe saving depends on. - Sound: cpal hands each system’s audio callback the same
&mut [f32], so the audio thread never knows where it runs.
Three workflows
A workflow is a file in .github/workflows/ that tells GitHub what
to run, on which machines, and when. Ferrous Lineup has three:
| Workflow | Runs on | Does |
|---|---|---|
CI (ci.yml) | every pull request and every push to main | formats, lints, tests and builds on Linux, macOS and Windows |
Release (release.yml) | every pull request, push to main and v tag, and by hand | builds the downloads for every system, and publishes them for a tag, pushed or written in a run by hand (see Release packages) |
Book (book.yml) | changes to the books and manuals | builds the websites and PDFs, and publishes them from main (see How the books are built) |
A pull request shows a check for each job, green when it passed and red when it failed, with a link to its log.
One job, three machines: the matrix
The CI workflow has one job, check, run three times:
check:
name: Build and test (${{ matrix.name }})
strategy:
fail-fast: false
matrix:
include:
- name: Linux
os: ubuntu-latest
artifact: ferrous-lineup-linux
binary: target/release/ferrous-lineup
- name: macOS
os: macos-latest
artifact: ferrous-lineup-macos
binary: target/release/ferrous-lineup
- name: Windows
os: windows-latest
artifact: ferrous-lineup-windows
binary: target/release/ferrous-lineup.exe
runs-on: ${{ matrix.os }}
- A matrix turns one job into several. Each entry under
includeis one copy of the job, with its own values forname,os,artifactandbinary. ${{ matrix.os }}is replaced by the entry’s value, soruns-onpicks a Linux, Mac or Windows machine, and the job’s name says which (“Build and test (macOS)”).fail-fast: falselets the other copies finish when one fails. Without it, a Windows failure would cancel the Mac job, and a second problem would show only after the first was fixed.
Each copy then runs the same steps:
- Install system libraries, on Linux only: the development files for ALSA, xkbcommon and Wayland. macOS and Windows come with theirs.
- Rust, the newest stable, with
rustfmtandclippy. - A cache of Cargo’s downloads and compiled dependencies
(
Swatinem/rust-cache), so a run compiles only what changed. - Check formatting, on Linux only: formatting is the same everywhere.
- Lint:
cargo clippy --workspace --all-targets -- -D warnings. - Test:
cargo test --workspace, in a debug build (see Running them). - Build release, and upload the program as the run’s artifact.
The steps that only make sense on one system say so:
- name: Install system libraries
if: runner.os == 'Linux'
Clippy and the tests run on all three: clippy because each system
compiles slightly different code, and the tests because a test that
passes on one system can fail on another (a path written with /, a
file that cannot be renamed while it is open).
On the page of a run, under Artifacts, are the three programs.
GitHub zips each, and the zip forgets that the program was executable,
so on Linux and macOS it needs chmod +x ferrous-lineup after
unzipping. The proper downloads, with installers, come from the Release
workflow.
Checking Windows code from Linux
Waiting for CI to find a Windows mistake is slow. Rust can check code for another system without leaving Linux:
rustup target add x86_64-pc-windows-gnu
cargo clippy --workspace --all-targets --target x86_64-pc-windows-gnu -- -D warnings
A target is the system you build for, as opposed to the one you
build on. rustup target add downloads Rust’s standard library
compiled for Windows. cargo clippy and cargo check only run the
compiler’s checks and never link a program, so they need neither
Windows’ linker nor its system libraries. That catches most mistakes, a
Windows-only #[cfg] that does not compile or a crate that does not
support Windows at all, though not what only happens when the program
runs. The same works for Macs with aarch64-apple-darwin (Apple’s
processors) or x86_64-apple-darwin (Intel Macs), as long as no
dependency needs Apple’s own tools to build.
What the checks cannot prove
The CI machines have no sound card and no screen. The jobs prove that the program builds and that the music logic is right on each system, not that you hear anything: the window tests draw into memory (see Window tests), and the audio tests render into a buffer. A run on a real Mac or Windows computer is still the real test of sound and drawing.