9 min read

Shipping a Rust native module to four targets from one CI

How cozy-steamworks turns one Rust crate into four prebuilt .node binaries, packs them into a tarball on GitHub Releases, and why a plain git dependency on the repo cannot work.

Cozy Coast talks to Steam through cozy-steamworks, my fork of steamworks.js. From the JavaScript side it behaves like any other package: you require it, call init, and ask for the player’s name. What sits underneath is a Rust crate compiled into a .node file, which is a shared library that Node loads directly, and the catch with a shared library is that it only runs on the operating system and CPU it was compiled for.

That means every release has to be built four times, once each for Windows x64, Linux x64, Intel Mac and Apple Silicon Mac. In this post I go through how a single GitHub Actions workflow produces all four binaries, and the packaging decisions around them, which honestly took me longer to get right than the Rust code did.

The Rust crate and its dependencies

In terms of configuration the Rust side is fairly small. Cargo.toml builds a cdylib, so the output is a dynamic library that Node can load, and it pulls in the three dependencies that matter:

[lib]
crate-type = ["cdylib"]

[dependencies]
napi = { version = "2.16.8", features = ["tokio_rt", "napi6", "serde-json"] }
napi-derive = "2.16.9"
tokio = { version = "1", features = ["sync", "time"] }
steamworks = { version = "0.13.1", features = ["serde", "raw-bindings"] }

steamworks 0.13.1 is the Rust binding over Steamworks SDK 1.64. Upstream had pinned a git revision of the 0.11.0 crate, and bumping it to the published 0.13.1 was the first commit I made on the fork. napi is napi-rs, the library that turns functions marked with #[napi] into exports Node can call. The tokio_rt feature is enabled because Steam answers most requests later, through a callback, and I needed a way to turn that into something JavaScript can await. The crate registers the callback with a oneshot channel, and the exported function then awaits the receiver under tokio::time::timeout. In practice this means a lobby list request becomes a JavaScript promise that either resolves or fails after 15 seconds. The timeout is important, because without it a request that Steam refuses outright would leave the promise pending forever. serde-json covers the opposite direction, when JavaScript registers a listener for a Steam callback: the callback data is serialized to serde_json::Value and handed to a ThreadsafeFunction, which is how it makes its way back to JS.

build.rs is only a few lines long. The first call is the standard napi setup, and the Linux line after it is the one doing real work:

fn main() {
    napi_build::setup();

    #[cfg(target_os = "linux")]
    println!("cargo:rustc-link-arg=-Wl,-rpath,$ORIGIN");
}

On Linux, that rpath tells the loader to look for libsteam_api.so in the same directory as the .node file, so the binary finds the Steam library it ships with instead of searching the system for one. Of course, this only helps if the .so file is actually sitting in that directory, and making sure of that is the job of the build script.

How build.js places Valve’s libraries next to the binary

Valve’s redistributable libraries are checked into the repo under sdk/redistributable_bin/, with one folder per platform: win64 has steam_api64.dll and steam_api64.lib, linux64 has libsteam_api.so, and osx has libsteam_api.dylib. When you run npm run build, it calls build.js, which maps the Rust target triple you are building for to one of those folders, copies the files into dist/<folder>, and then shells out to the napi CLI with these arguments:

const params = [
    'build',
    '--platform',
    '--no-dts-header',
    '--js', 'false',
    '--dts', '../../client.d.ts',
    relative,
    process.argv.slice(2).join(' ')
]

--platform adds the platform name as a suffix to the output file, which is what lets several binaries sit side by side, so the Windows build ends up as dist/win64/steamworksjs.win32-x64-msvc.node. --js false stops napi from generating its own loader, and I don’t want that one because the repo already has a hand-written index.js that picks the right binary based on process.platform and process.arch. --dts writes the TypeScript declarations for every #[napi] export into client.d.ts at the repo root. Since that file is generated (it was 689 lines at 0.8.0, I counted with wc -l), nobody should ever edit it by hand. Once napi exits cleanly, build.js runs npm run types, so that index.d.ts is regenerated against the declarations from the build that just ran.

The two Mac targets share the same osx folder. That works because the vendored libsteam_api.dylib is a universal binary: if you run file on it, it reports two architectures, x86_64 and arm64. So a single copy of the Steam library can serve both of the .node files that sit next to it.

Building four targets on two runners

The list of targets lives in package.json under napi.triples.additional: x86_64-pc-windows-msvc, x86_64-unknown-linux-gnu, x86_64-apple-darwin and aarch64-apple-darwin. You might expect one runner per target, but the workflow in .github/workflows/publish.yml gets away with only two.

The Linux job builds both the Linux and the Windows binaries, and it does that inside an old Ubuntu container:

build:
    runs-on: ubuntu-latest

    # Run in ubuntu:20.04 container to avoid the issue with glibc version
    container:
        image: ubuntu:20.04

I inherited that container from upstream, and it is there because of glibc. A .node file built against a newer glibc refuses to load on a machine with an older one, so the safe approach is to build on the oldest base you intend to support. The same job also installs cargo-xwin and adds the MSVC target to the Rust toolchain, which means the Windows binary is cross-compiled inside that Linux container and no Windows runner is involved at all:

- run: cargo install cargo-xwin

- name: Node install
  run: npm ci

- name: Build Linux
  run: npm run build -- --target x86_64-unknown-linux-gnu

- name: Build Windows
  run: npm run build -- --target x86_64-pc-windows-msvc

The Mac job runs on macos-latest and builds x86_64-apple-darwin and then aarch64-apple-darwin one after the other, with MACOSX_DEPLOYMENT_TARGET: '10.13' set at the workflow level so it applies to both Mac builds. Each build job uploads its dist folder as an artifact (binaries-windows-linux and binaries-macos) with if-no-files-found: error, so a build that produced nothing fails right there instead of later during the release. There is also a third job, check, which runs cargo fmt --all --check and cargo clippy to catch formatting and lint problems.

Which events build and which ones release

The workflow runs on pushes to main, on v* tags, on every pull request and on manual dispatch, and every one of those builds all four targets. That way every change shows whether it still compiles for all platforms, but I only want a release when I explicitly ask for one, so only two of those triggers go on to the release job:

release:
    # Release only from this repository, and only for a version tag or a
    # manual dispatch. An ordinary push to main builds but does not release.
    if: ${{ github.repository == 'crimson-med/cozy-steamworks' && (startsWith(github.ref, 'refs/tags/v') || github.event_name == 'workflow_dispatch') }}

The repository check is there so that forks of the fork don’t try to cut releases with their own tokens. Once that condition passes, the release job downloads both artifacts into a single dist/ folder with merge-multiple: true, and before doing anything else it checks that each of the four binaries is present, stopping the job if one is missing:

for f in \
    dist/win64/steamworksjs.win32-x64-msvc.node \
    dist/linux64/steamworksjs.linux-x64-gnu.node \
    dist/osx/steamworksjs.darwin-x64.node \
    dist/osx/steamworksjs.darwin-arm64.node; do
    test -f "$f" || { echo "missing $f"; exit 1; }
done

After that, it reads the version out of package.json. On a tag push, the tag has to be exactly v followed by that version, and if it isn’t, the job fails with tag $TAG does not match package.json version $VERSION. On a manual dispatch, the job derives the tag from the version instead. With both paths working this way, the release name and the package version can never disagree.

Packing the tarball and publishing it on GitHub Releases

The packing step itself is a call to npm pack, followed by a check on what ended up in the archive:

- name: Pack tarball
  id: pack
  run: |
      FILE=$(npm pack --silent)
      echo "file=$FILE" >> "$GITHUB_OUTPUT"
      tar -tzf "$FILE" | grep -E 'dist/.*\.node$'

npm pack respects the files field in package.json, which lists dist/*, index.js, *.d.ts and README.md, so only those end up in the tarball. The grep at the end works as a safety check: if no .node file made it into the archive, grep finds nothing and the step fails. The last step then uses gh release create to attach the tarball to a GitHub Release, or gh release upload --clobber to replace it if that release already exists, and the release notes include the install line.

The workflow used to publish to npm, using JS-DevTools/npm-publish@v1 and an NPM_TOKEN secret. In August I replaced that with GitHub Releases, and the commit message explains why: consumers install by tarball URL, so no npm account or token is needed. In a consumer’s package.json, the dependency looks like this:

"@cozycoast/steamworks.js": "https://github.com/crimson-med/cozy-steamworks/releases/download/v0.7.0/cozycoast-steamworks.js-0.7.0.tgz"

The shortcut most people would reach for is pointing at the repo directly, with "github:crimson-med/cozy-steamworks" as a git dependency, and unfortunately that does not work here. The problem is that .gitignore excludes /dist, so the binaries are never committed to the repository. There is also no install or postinstall script that would compile them on your machine, and even if there were one, it would mean having Rust and Clang on every developer’s computer. What happens in practice is that the git install succeeds, and then index.js calls require('./dist/win64/steamworksjs.win32-x64-msvc.node') and throws because the file isn’t there. The only place where all four binaries exist is the tarball that CI packed, which is why the install goes through the tarball URL.

Using the module from Electron

Cozy Coast is an Electron app, and that adds three rules on the consuming side.

The first rule is that every Steam call stays in the main process. By default the native module cannot be loaded in a renderer, and the workaround upstream uses is to set nodeIntegration: true with contextIsolation: false, which the repo’s own test/electron/main.js still does. That is acceptable for a test harness, but I don’t want it in a game I ship, so in Cozy Coast the renderer asks for what it needs over IPC and the main process makes the Steam call and sends back the answer.

The second rule is that the package has to stay outside the asar archive. The .node file is loaded by the operating system’s library loader, and that loader needs to find libsteam_api next to it on a real filesystem path. Because it has no way to look inside an archive, the package has to live on disk as ordinary files.

The third rule is that the redistributables for each platform get copied into the root of the build, from sdk/redistributable_bin/<platform>. On top of that, you call electronEnableSteamOverlay() at the end of main.js. What it does is append the in-process-gpu and disable-direct-composition switches and invalidate every window at 60 Hz whenever it is not painting, which keeps the Steam overlay drawing even when the page itself has nothing new to paint. I wrote up the rest of how Cozy Coast’s Electron shell is put together in the desktop widget post.

Cutting a release

The README describes the release procedure in one line: bump version in package.json and package-lock.json, merge to main, and push a vX.Y.Z tag. The workflow checks the tag against package.json, but it doesn’t check package-lock.json, and you can see the effect of that in the git log. Version 0.8.0 went in with the screenshots commit, and the lockfile only caught up afterwards in a separate commit, “Bump package-lock.json to 0.8.0”. Consumers never see that mismatch, because package-lock.json isn’t in the files list and npm leaves lockfiles out of packed tarballs anyway. If I wanted the workflow to catch it, it would only take one more node -p line in the meta step.

The code is at github.com/crimson-med/cozy-steamworks. The networking half of the fork is what Cozy Coast’s hub runs on, and I cover it in multiplayer with zero servers.

Let's connect.

Always happy to talk shop, compare notes, or just say hi. Email or LinkedIn is the fastest way to reach me.

Get in touch