11 min read

Shipping Rust chrome to four decks from one CI rig

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

Cozy Coast talks to Steam through cozy-steamworks, my fork of steamworks.js. From the JavaScript side it behaves like any other street-legal package, choom: you require it, call init, and ask for the player’s name. What sits under the synthskin is Rust chrome, a crate compiled into a .node file, which is a shared library that Node jacks in directly, and the catch with a shared library is that it only runs on the operating system and CPU it was forged for, the same way ripperdoc chrome only takes in the body it was fitted to.

That means every release has to be built four times, once each for Windows x64, Linux x64, Intel Mac and Apple Silicon Mac, four decks with four different sockets. In this post I go through how a single GitHub Actions workflow cranks out all four binaries in one run, and the packaging calls around them, the fixer work of getting that chrome into a buyer’s hands, which honestly burned more of my nights than the Rust code did.

The Rust crate and the chrome it pulls in

In terms of configuration the Rust side travels light, no heavy chrome bolted on. Cargo.toml builds a cdylib, so the output is a dynamic library that Node can jack into, 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, stale chrome by any street measure, and bumping it to the published 0.13.1 was the first commit I made on the fork. napi is napi-rs, the ripperdoc of this rig, the library that grafts functions marked with #[napi] onto Node as exports it can call. The tokio_rt feature is enabled because Steam deals like a slow fixer: it answers most requests later, through a callback, and I needed a way to turn that callback 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 flatlines after 15 seconds. The timeout earns its eddies, because without it a request that Steam bounces off its ICE outright would leave the promise pending forever, a merc waiting by the holo for a fixer who already skipped town. serde-json covers the run in 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 the intel makes its way back to JS.

build.rs is only a few lines long, lean chrome with no fat on it. The first call is the standard napi setup, the kind of boilerplate every netrunner types from memory, and the Linux line after it is the one doing the real gig:

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 scavving the system for some stranger’s copy. It is a preem trick, but it only helps if the .so file is actually sitting in that directory, and making sure of that is the build script’s gig.

How build.js stashes Valve’s chrome next to the binary

Valve’s redistributable libraries are checked into the repo under sdk/redistributable_bin/, with one stash 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 works like a fixer matching gigs to crews: it 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, the netrunner who drives the actual compile, 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 share a safehouse without stepping on each other, 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 riding along because the repo already has a hand-written index.js, a street-smart fixer 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), no netrunner should ever jack in and edit it by hand, because it belongs to the build and not to you. Once napi exits clean and the gig is done, 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 bunk in the same osx folder. That works because the vendored libsteam_api.dylib is a universal binary, chrome with two sockets built in: if you run file on it, it reports two architectures, x86_64 and arm64. So a single copy of Valve’s chrome can serve both of the .node files crashing next to it, and nobody spends eddies on a second one.

Four targets, two runners, one crew

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, a merc for every gig, but the workflow in .github/workflows/publish.yml gets the whole run done with only two on the payroll.

The Linux job is the heavy merc of the crew: it builds both the Linux and the Windows binaries, and it does that inside an old Ubuntu container, a beat-up safehouse picked on purpose:

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, like fresh chrome rejected by old wetware, so the safe play 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 gets hired for the gig 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, a rented rig on GitHub’s dime, 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 crew that comes back from the run empty-handed gets flatlined right there instead of later during the release. There is also a third job, check, which runs cargo fmt --all --check and cargo clippy and works as the bouncer at the Afterlife door, turning away formatting and lint problems before they get a drink.

Which triggers build and which ones cash out a release

The workflow wakes up on pushes to main, on v* tags, on every pull request and on manual dispatch, and every one of those runs builds all four targets. That way every change proves it still compiles for every deck in the city, but I only want a release when I explicitly put out the call, so only two of those triggers get past the door 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 ICE for forks of the fork, so some scav’s clone doesn’t try to cut releases with its own tokens and run a gig it was never hired for. Once that condition passes, the release job downloads both artifacts, the haul from both runners, into a single dist/ folder with merge-multiple: true, and before doing anything else it counts heads, checking that each of the four binaries is present and stopping the job if one of the crew never showed up:

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 flatlines with tag $TAG does not match package.json version $VERSION. On a manual dispatch, the job derives the tag from the version instead, so no netrunner is typing it in by hand. With both paths wired this way, the release name and the package version can never sell two different stories to the street.

Packing the tarball and dropping the chrome on GitHub Releases

The packing step itself is a call to npm pack, followed by a pat-down on what actually ended up in the archive before the haul leaves the rig:

- 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 make it into the tarball and everything else stays off the truck. The grep at the end works as a safety check, the last guard at the loading dock: if no .node file made it into the archive, grep finds nothing, the step flatlines, and no hollow tarball ever hits the street. The last step then uses gh release create to attach the tarball to a GitHub Release, or gh release upload --clobber to swap it in if that release already exists, and the release notes carry the install line so any choom can jack it in.

The workflow used to publish to npm, using JS-DevTools/npm-publish@v1 and an NPM_TOKEN secret. In August I cut that wire to the corpo registry and replaced it with GitHub Releases, and the commit message explains why: consumers install by tarball URL, so no npm account or token is needed, which means one less corpo account to feed and one less secret sitting around for a scav to lift. 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 mercs would reach for is jacking straight into the repo, with "github:crimson-med/cozy-steamworks" as a git dependency, and unfortunately that gonk move does not work here. The problem is that .gitignore excludes /dist, so the binaries are never committed to the repository and a clone arrives with an empty chrome socket. There is also no install or postinstall script that would compile them on your machine, and even if there were one, it would mean hauling Rust and Clang onto every developer’s deck. What happens in practice is that the git install goes through clean, and then index.js calls require('./dist/win64/steamworksjs.win32-x64-msvc.node') and throws because the file isn’t there, a samurai reaching for a katana in an empty sheath. The only place where all four binaries exist is the tarball that CI packed, which is why the install goes through the tarball URL.

Jacking in from Electron

Cozy Coast is an Electron app, and that stacks three house rules on the consuming side of the deal, the kind a fixer lays down before any gig starts.

The first rule is that every Steam call stays in the main process. By default the native module cannot be loaded in a renderer, whose wetware simply won’t take that chrome, 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 in a game I ship it would mean dropping my own ICE and leaving the back door open, so in Cozy Coast the renderer asks for what it needs over IPC, and the main process makes the Steam call like a fixer working the line 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, an old-school ripperdoc who needs to find libsteam_api lying next to it on a real filesystem path. Because it has no way to see through the walls of an archive, however good its optics, the package has to live on disk as ordinary files out on the street, with none of its chrome sealed inside the asar.

The third rule is that the redistributables for each platform get copied into the root of the build, from sdk/redistributable_bin/<platform>, so Valve’s chrome rides along with every deck the game ships to. 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, like a neon sign that stays lit over an empty street. I wrote up the rest of how Cozy Coast’s Electron shell is wired together in the desktop widget post.

Calling the release run

The README lays out 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 crack that leaves in the ICE right there in the git log. Version 0.8.0 went in with the screenshots commit, and the lockfile only limped in afterwards on 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, so no choom downstream ever eats the glitch. If I wanted the rig to catch it, patching that hole 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, the chrome that lets chooms meet up with no server in the middle, and I cover it in multiplayer with zero servers.

Let's link up, choom.

Always down to trade notes, talk shop, or just ping. The net is the fastest way to reach me.

Ping me