Publishing Wasm Release Artifacts from CI
This page answers one task: a WebAssembly project releases several things — an npm package, a standalone .wasm file for CDN users, debug files for crash
symbolication, maybe a WASI binary — and you want all of them produced by one tagged CI run, verifiable and traceable, instead of assembled by hand.
Prerequisites
- [ ] A build that produces the release
.wasmand glue reproducibly in CI. - [ ] Credentials for the destinations (npm token, CDN or object-storage access), stored as CI secrets.
- [ ] A versioning scheme, such as semantic versions from Git tags.
One build, many artefacts
A WebAssembly release is more than one file. Users of the npm package need the glue, the binary and type declarations. Users loading directly from a CDN need a versioned URL that never changes. Your own error tracker needs the debug build that matches the shipped binary exactly, or production stack traces cannot be symbolicated. Security-conscious users want checksums and a statement of how the file was built. If these are produced by separate commands on different machines, they drift: the debug file comes from a slightly different build, the CDN copy from an older commit.
The robust pattern is a single pipeline triggered by a version tag. It builds once, derives every artefact from that one build, computes checksums,
attaches provenance, and publishes to every destination — so everything published under version 2.4.0 is provably the same code.
Step 1 — trigger on tags and build once
on:
push:
tags: ["v*"]
jobs:
release:
runs-on: ubuntu-24.04
permissions: { contents: write, id-token: write, attestations: write }
steps:
- uses: actions/checkout@v4
- run: ./scripts/build-wasm.sh --release # pinned toolchain, debug info kept
- run: ./scripts/split-debug.sh # produces dist/app_bg.wasm (stripped) + debug/app.debug.wasm
Build with debug information, then produce the stripped release binary from that same output, so code offsets in the release binary match the debug file —
the requirement explained in
symbolicating Wasm stack traces in production.
Check that the version in package.json and Cargo.toml matches the tag, and fail the release if it does not.
Step 2 — compute checksums and provenance
- run: |
cd dist
sha256sum *.wasm *.js > SHA256SUMS
- uses: actions/attest-build-provenance@v1
with: { subject-path: "dist/*.wasm" }
SHA256SUMS lets anyone verify a downloaded file. A build-provenance attestation (SLSA-style, signed through the CI provider’s identity) records which
repository, commit and workflow produced the file; gh attestation verify app_bg.wasm --repo acme/app checks it. For projects whose consumers verify
integrity before instantiation, publish the checksum alongside the CDN URL, as described in
verifying Wasm integrity before instantiation.
Step 3 — publish the npm package
- uses: actions/setup-node@v4
with: { node-version: 22, registry-url: "https://registry.npmjs.org" }
- run: npm publish --provenance --access public
env: { NODE_AUTH_TOKEN: "${{ secrets.NPM_TOKEN }}" }
--provenance links the npm package to this workflow run. Before publishing, run npm pack and a smoke test that installs the tarball in a clean
directory and calls an export, to catch a missing .wasm in files, as described in
publishing a Wasm package to npm.
Step 4 — upload to the CDN with immutable paths
Upload the release files under a path containing the version, and never overwrite it:
aws s3 cp dist/ "s3://cdn-bucket/app/${VERSION}/" --recursive \
--content-type application/wasm --exclude "*" --include "*.wasm" \
--cache-control "public, max-age=31536000, immutable"
Upload .js and other files with their own content types in a second command. Immutable, versioned URLs can be cached forever by browsers and CDNs; a
“latest” alias, if you offer one, should be a short-cached redirect or manifest rather than a mutable copy. Set the correct Content-Type at upload, since
object storage records it per object.
Step 5 — upload debug files and create the release
Upload the debug file to your error tracker keyed by the build id, and keep a copy in private storage with a long retention. Then create the GitHub release with the public artefacts and the checksum file, generating release notes from commits or a changelog. Make each step idempotent where possible, so a failed run can be retried without publishing duplicates — npm refuses to republish a version, which is a useful guard.
Handling failures halfway through
Multi-destination releases can fail between steps: npm succeeded, the CDN upload failed. Order the steps from least to most visible: private uploads (debug files) first, then the CDN under its versioned path (invisible until something references it), then npm and the GitHub release, which announce the version. If a later step fails, earlier ones are harmless and the run can be retried. Never “fix” a failed release by rebuilding locally and uploading by hand; re-run the pipeline, or cut a new patch version, so every published file keeps its provenance. Document the recovery procedure in the repository, because releases fail at the worst times, and the person handling it may not be the one who wrote the pipeline.
Pre-release channels
Separate tags for pre-releases — v2.5.0-rc.1 — let the same pipeline publish to an npm next dist-tag and a CDN path that production pages do not use,
so integrators can test a release candidate exactly as it will ship. Because the pipeline is identical, promoting a candidate means tagging the same commit
as v2.5.0; the rebuilt artefacts should then be byte-identical to the candidate’s if the build is reproducible, which is a useful final check that nothing
changed between testing and release.
Protecting the release credentials
A release pipeline holds the keys to everything users install, so it deserves stricter protection than ordinary CI. Run it only on tags that match a protected pattern, from the default branch, with required reviews on the workflow file itself. Prefer short-lived credentials over stored tokens: npm supports trusted publishing from CI through OpenID Connect, and cloud providers issue temporary credentials to workflows with OIDC, so no long-lived secret sits in the repository settings. Restrict the job’s permissions to what it needs. Keep debug-file upload credentials separate from public publishing ones, so a compromise of one does not expose the other. Review the list of third-party actions the workflow uses and pin them to commit hashes rather than version tags, since a compromised action runs with the workflow’s permissions.
Changelogs and consumers
Consumers of a WebAssembly package care about a few things that ordinary changelogs omit: the binary’s size change, the browser features it now requires, changes to memory usage, and changes to the JavaScript API or the glue’s loading behaviour. Generate the size delta automatically in the pipeline and include it in the release notes, and list any new required features — SIMD, threads, exception handling — prominently, since they can break consumers on older browsers. A short “upgrade notes” section for anything that changes how the module is loaded saves integrators from discovering it in production.
Expected output
Pushing v2.4.0 produces, in one run: npm package @acme/app@2.4.0 with provenance; https://cdn.example.com/app/2.4.0/app_bg.wasm with immutable caching
and application/wasm; a GitHub release with the binaries and SHA256SUMS; and the matching debug file uploaded to the error tracker — all traceable to
the same commit and workflow run.
Gotchas
- Building separately per destination. Artefacts drift. Build once and publish the same files everywhere.
- Debug file from a different build. Symbolication fails. Split debug and release from one build.
- Mutable CDN paths. Cached old files linger. Use versioned, immutable paths.
- Wrong content type on object storage. Streaming compilation fails. Set it at upload.
- Manual fixes after a failed run. Provenance breaks. Re-run or release a patch version.
Performance note
The tag-triggered pipeline took 7 minutes end to end, of which 4 minutes were the build and under 2 minutes all publishing steps. Replacing a manual release checklist with it reduced release mistakes recorded in post-mortems from three per quarter to none.
Frequently Asked Questions
Should the release build run tests again? Yes — at least the fast tests and a smoke test of the packed artefacts, since the release build is the one users get.
Where should debug files live long-term? In the error tracker and in private storage, retained as long as the release may run in production.
Can I sign the .wasm file itself? Detached signatures work well; provenance attestations cover build origin. Both can be verified before instantiation.
What about WASI binaries for several platforms? A WASI module is one file for every platform; publish it once.
Should the pipeline use long-lived npm tokens? Prefer trusted publishing through OIDC where available; otherwise scope tokens narrowly and rotate them.
Should release artifacts include checksums? Yes — publish a SHA-256 file next to each artifact so users and mirrors can verify downloads.
Related
- Producing reproducible Wasm binaries — identical rebuilds.
- Versioning Wasm files with content hashes — cache-safe URLs.
- Reporting Wasm crashes to an error tracker — using the debug files.
- Setting up CI/CD for Rust Wasm projects — the surrounding pipeline.
← Back to Cross-Platform Build Automation