Cross-Platform Build Automation
A .wasm artifact compiled on an Apple Silicon laptop must execute byte-for-byte identically on an
x86_64 Linux CI node and an ARM64 Windows workstation. WebAssembly makes that promise easy to keep at
runtime — the binary format is host-independent — but the build that produces it is not. Floating
toolchain versions, unpinned crates, cache keys that drift, and a wasm-opt that differs by one
Binaryen release are enough to ship two different binaries from the same commit. This guide builds a
continuous-integration pipeline that produces reproducible WebAssembly across heterogeneous runners,
caches the expensive parts without poisoning correctness, and gates every merge on a validated,
size-budgeted artifact.
The stakes are concrete. When two engineers on different operating systems produce different binaries
from the same commit, every downstream signal becomes unreliable: a size regression check fires on noise,
a content hash used for cache-busting changes for no reason, and a bug that reproduces in one developer’s
artifact vanishes in another’s. The cure is not heroics in code review — it is a build that is
mechanically deterministic, so the only thing that can change the output is a change to the source. The
rest of this guide is the mechanics of making that true on real CI, where the variables you must pin are
the compiler channel, the dependency lockfile, the wasm-bindgen and Binaryen versions, and the cache
keys that decide what gets reused between runs.
Prerequisites
Before wiring up the pipeline, confirm each of these is in place:
- [ ] A CI runner with a clean checkout step (GitHub Actions
ubuntu-latest,macos-latest, or a self-hosted equivalent) - [ ]
rustupwith thewasm32-unknown-unknowntarget added, plus a pinnedrust-toolchain.toml - [ ]
emsdkchecked out and pinned to a known release if you also build C/C++ (./emsdk install 3.1.61) - [ ]
wasm-packandwasm-bindgen-cliinstalled at versions that match yourCargo.lock - [ ]
binaryen(wasm-opt) andwasm-tools(wasm-validate) available onPATHin CI - [ ] Deterministic cache keys derived from
Cargo.lock, the toolchain version, and the OS - [ ] A committed size budget (a
size-limitconfig or a baseline byte count to diff against)
Pipeline architecture
Every reproducible WebAssembly build is the same linear pipeline: restore a pinned toolchain, restore
the dependency and build caches, compile to the chosen target triple, post-process with wasm-bindgen,
shrink with wasm-opt, validate the binary structurally, run the test suite, then publish the artifact.
The two cacheable boundaries — the toolchain and the target/ directory — are where most of the wall
time lives, and where most of the correctness bugs hide.
The compiler must also know its target. wasm32-unknown-unknown strips all standard-library syscalls
and is the right choice for a browser host that supplies I/O through JavaScript glue; wasm32-wasi
embeds POSIX-like syscall abstractions and targets standalone runtimes such as wasmtime or wasmer.
Pass --target explicitly in every job — never rely on an implicit default, because the default differs
between a workstation and a fresh runner and that difference is exactly the kind of drift this pipeline
exists to eliminate.
Two of these nodes deserve naming up front because they dominate both the wall time and the failure
surface. The toolchain restore is cheap to get right and expensive to get wrong: a runner that resolves
stable will silently track a new compiler the day it ships, so a job that was green yesterday can fail
today with no source change. The target/ restore is the inverse — it is the largest time saving
available and the most dangerous cache to mis-key, because an incremental build that reuses object files
from a different compiler can link successfully and still emit a binary no clean build would produce. Get
those two boundaries right and the rest of the pipeline is plumbing.
Build workflow
The workflow below is the canonical shape. Each step maps to one node in the diagram; the YAML is real and runs as written on GitHub Actions.
1. Pin the toolchain
Commit a rust-toolchain.toml so every runner resolves the identical compiler. This file is the single
source of truth — the CI action reads it instead of installing stable (a moving target).
[toolchain]
channel = "1.83.0"
targets = ["wasm32-unknown-unknown"]
components = ["rustfmt", "clippy"]
2. Check out and install the toolchain
dtolnay/rust-toolchain honors the rust-toolchain.toml automatically, so no version is hard-coded in
the workflow itself.
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-unknown-unknown
- name: Install wasm-pack
run: cargo install wasm-pack --version 0.13.1 --locked
3. Restore the dependency and build caches
Cache ~/.cargo/registry, ~/.cargo/git, and target/ keyed on the OS, the target triple, and a hash
of Cargo.lock. The key must change whenever any input that affects the output changes — that is the
whole discipline of a correct cache.
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target
key: wasm-${{ runner.os }}-${{ matrix.target }}-${{ hashFiles('rust-toolchain.toml', '**/Cargo.lock') }}
restore-keys: |
wasm-${{ runner.os }}-${{ matrix.target }}-
4. Compile to the target triple
Build with --locked so a stale lockfile fails the job instead of silently resolving new dependency
versions. The --locked flag is the compile-time half of reproducibility; the cache key is the other
half.
rustup target add wasm32-unknown-unknown
cargo build --target wasm32-unknown-unknown --release --locked
5. Generate bindings with wasm-bindgen
wasm-pack build runs wasm-bindgen as a post-processing step over the raw .wasm, emitting the ES
module wrapper and the .d.ts declarations your application imports. The CLI version must match the
wasm-bindgen crate version in Cargo.lock exactly, or the generated glue references symbols the binary
does not export.
wasm-pack build --target web --release --out-dir pkg
6. Shrink with wasm-opt
Chain a wasm-opt pass to strip debug sections and aggressively shrink the module. Pin Binaryen so the
optimization output is stable across runs — the full set of size passes is covered in the companion
Wasm optimization flags & size reduction
guide.
wasm-opt pkg/app_bg.wasm -Oz \
--enable-bulk-memory \
--strip-debug \
-o pkg/app_bg.wasm
7. Validate the binary
Run wasm-validate so a structurally malformed module fails the pipeline before it ever reaches a
browser. This catches a truncated artifact, a bad wasm-opt transform, or a corrupted cache entry.
wasm-validate pkg/app_bg.wasm && echo "module is structurally valid"
8. Test against real engines
wasm-pack test --headless instantiates the module in Chromium and Gecko, asserting that the ES module
resolves and the exports behave. Append --no-sandbox for Chrome inside a container.
wasm-pack test --headless --chrome --firefox
9. Publish the artifact
Upload pkg/ so downstream deploy jobs consume a single, validated, reproducible build rather than
recompiling.
- uses: actions/upload-artifact@v4
with:
name: wasm-pkg-${{ matrix.target }}
path: pkg/
A build script as the single entry point
CI and local development should run the same commands, or local green will not predict CI green. Wrap
the whole flow behind one script so the workflow YAML calls one line and a developer calls the same line.
A just recipe reads cleanly and keeps the optimization flags in one place:
# justfile — invoked identically in CI and on a workstation
set shell := ["bash", "-uc"]
build:
cargo build --target wasm32-unknown-unknown --release --locked
wasm-pack build --target web --release --out-dir pkg
wasm-opt pkg/app_bg.wasm -Oz --enable-bulk-memory --strip-debug -o pkg/app_bg.wasm
verify:
wasm-validate pkg/app_bg.wasm
wasm-pack test --headless --chrome --firefox
ci: build verify
If your team standardizes on Node tooling instead, the equivalent npm script keeps the contract:
{
"scripts": {
"build:wasm": "wasm-pack build --target web --release --out-dir pkg && wasm-opt pkg/app_bg.wasm -Oz --strip-debug -o pkg/app_bg.wasm",
"verify:wasm": "wasm-validate pkg/app_bg.wasm && wasm-pack test --headless --chrome"
}
}
The C/C++ path follows the same shape with emcc standing in for cargo; tune it for size and emit an
ES module so the bundler can treat it like any other dependency.
emcc src/main.cpp -o dist/app.js \
-Oz \
-s WASM=1 \
-s MODULARIZE=1 \
-s EXPORT_ES6=1 \
-s ALLOW_MEMORY_GROWTH=1 \
-s ENVIRONMENT=web
Optimization & tradeoffs
The two big caches pay off differently. Caching ~/.cargo/registry and ~/.cargo/git saves the
download and the crates.io index resolution — pure latency, no correctness risk, restore it greedily.
Caching target/ saves recompilation, which is far larger, but it is also where staleness creeps in:
an incremental build that reuses object files compiled against a different toolchain can produce a binary
that no clean build would. The rule that keeps both is bind the cache key to every input that changes
the output — the OS, the target triple, the lockfile hash, and the toolchain version. When all four are
in the key, a target/ hit is provably safe; when any is missing, the cache can lie.
Matrix builds are how you prove portability without paying for it serially. Fan out across
{ubuntu-latest, macos-latest, windows-latest} and {wasm32-unknown-unknown, wasm32-wasi} and the jobs
run in parallel; a green matrix is direct evidence the binary is host-independent.
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
target: [wasm32-unknown-unknown, wasm32-wasi]
runs-on: ${{ matrix.os }}
Reproducibility has a few concrete levers beyond pinning. Set --locked everywhere so a drifted lockfile
is a hard failure. Strip debug sections in release so the artifact does not embed absolute build paths.
Where you need bit-identical output across machines, pass RUSTFLAGS="--remap-path-prefix $PWD=." so the
embedded paths are relative rather than machine-specific. Self-hosted runners cut wall time 40–60% by
keeping caches warm, at the cost of maintenance and weaker isolation — use ephemeral containers if you go
that route so one build cannot leak state into the next.
There is a real tension between speed and trust that you have to resolve deliberately rather than by
default. The fastest possible pipeline restores everything and recompiles nothing; the most trustworthy
one runs cargo clean and rebuilds from source on every release. Neither extreme is correct in
production. The pragmatic split is to cache aggressively on pull-request builds — where fast feedback
matters more than bit-exact output — and to run a clean, fully pinned build on the main deploy path so
the artifact you actually ship was produced from a known-empty state. That way developers get the
sub-15-second iteration loop and the published binary still carries the reproducibility guarantee.
Optimization level interacts with this too: a -Oz pass is deterministic for a fixed Binaryen version
but slow, so running it only on the deploy path keeps PR builds fast without weakening what ships.
Gotchas & failure modes
Cache-key drift. The most common silent failure is a cache key that does not capture the toolchain.
If your key is wasm-${{ runner.os }}-${{ hashFiles('Cargo.lock') }} and you bump the compiler in
rust-toolchain.toml, the key is unchanged, so CI restores a target/ compiled by the old toolchain and
incrementally links it against the new one. The build may even succeed — and ship a binary no clean build
would produce. Always include the toolchain file in hashFiles(...).
wasm-bindgen CLI versus crate mismatch. A browser console error reading
RuntimeError: invalid magic number or a missing __wbindgen_start export almost always means the
installed wasm-bindgen-cli differs from the wasm-bindgen crate version in Cargo.lock. Pin the CLI to
the exact crate version and reinstall with --force when the lockfile moves:
cargo install wasm-bindgen-cli --version 0.2.100 --force.
wasm-opt out-of-memory on hosted runners. GitHub-hosted runners cap memory around 7 GB, and an
aggressive -Oz pass on a large module can abort. Isolate the failure with wasm-pack build --dev (the
dev profile skips wasm-opt); if the compile succeeds, the optimizer is the culprit. Either pin a known
Binaryen version, move to a larger runner tier, or split the optimization into a separate job.
Unpinned Binaryen changing the output. Two Binaryen releases can produce different — both valid —
binaries from the same input, breaking byte-for-byte reproducibility and any size baseline. Pin the
wasm-opt version in CI and cross-reference it against the Binaryen bundled with your wasm-pack
release.
Verification
Treat the artifact as untrusted until it passes three checks. First, structural validity:
wasm-validate parses the binary against the spec and exits non-zero on any malformation, so it belongs
in the pipeline immediately after wasm-opt. Second, content inspection: wasm-objdump -x pkg/app_bg.wasm
prints the section headers, import/export tables, and memory definitions, letting a PR check assert the
export surface has not changed unexpectedly. Third, a size gate: capture the post-compression byte count
and fail the merge if it regresses.
wasm-validate pkg/app_bg.wasm
wasm-objdump -x pkg/app_bg.wasm | grep -E '^Export|^Memory'
printf 'brotli size: %s bytes\n' "$(brotli -c pkg/app_bg.wasm | wc -c)"
Wire those into a CI step that compares against the main-branch baseline and rejects a payload that
grows beyond budget without justification. A pipeline that validates structure, inspects the export
surface, and enforces a byte budget on every PR is what turns “it built” into “it built correctly and is
the same binary everyone else gets.”
It is worth distinguishing the three checks, because they fail for different reasons and catch different
classes of bug. wasm-validate is a correctness check on the binary’s structure — it would catch a
truncated upload, a corrupted cache entry, or a wasm-opt pass that produced something the spec rejects,
none of which a passing compile guarantees. wasm-objdump is a contract check: by asserting the set of
exported functions and the memory definition has not changed, it turns an accidental ABI break into a red
PR instead of a runtime error in a consumer. The size gate is an economics check, guarding the property
your users feel most directly — download and instantiation time. Running all three on every change is
inexpensive (each is sub-second on a typical module) and together they close the gap between a build that
merely completed and one you can deploy with confidence.
In this guide
- Setting up CI/CD for Rust Wasm projects —
a complete GitHub Actions workflow with caching,
wasm-pack,wasm-opt, headless tests, and a deploy gate.
Frequently Asked Questions
Why does my cache hit but the binary still differs between runners?
Because the cache key is missing an input that affects the output — almost always the toolchain version.
A target/ restore is only safe when the key binds the OS, the target triple, the Cargo.lock hash, and
the toolchain file together. Add rust-toolchain.toml to hashFiles(...) and the drift disappears.
Should I cache target/ at all given the staleness risk?
Yes, as long as the key is correct. The recompilation it saves dwarfs the registry download, and a
properly scoped key makes a hit provably safe. The danger is only an under-specified key, not caching
itself.
Do I need a matrix across three operating systems if Wasm is host-independent? The runtime artifact is host-independent, but the build is not — toolchain bugs, path handling, and line-ending differences surface only on the OS where they occur. A green matrix is your evidence that the build, not just the binary, is portable.
How do I keep local builds and CI from diverging?
Put the whole flow behind one script (a justfile or an npm script) and call that single entry point
from both the workflow YAML and the developer’s shell. When the commands are identical, local green
predicts CI green.
Related
- Wasm optimization flags & size reduction — the full
wasm-optpass catalogue the pipeline calls. - Rust to Wasm compilation guide — the
wasm-packworkflow that feeds these builds. - C/C++ to Wasm with Emscripten — the
emccequivalent of the build script. - ESM bindings & module generation — packaging the validated artifact for your app.
← Back to Compilation Pipelines & Toolchain Setup