ilo is a single Rust binary. But the people I want using it install software in different ways. Rust developers use cargo install. Node developers use npx. Claude Code users install a skill from the marketplace. Everyone else downloads a binary from GitHub.
Maintaining four separate release processes would mean four chances to forget a step. So the whole thing runs from one trigger: pushing a version tag.
git tag v0.9.1
git push origin main --tags
That kicks off a GitHub Actions workflow that builds, publishes, and distributes to all four channels.
Five targets, three runners
The workflow uses a build matrix with five targets:
strategy:
matrix:
include:
- target: x86_64-unknown-linux-gnu
os: ubuntu-latest
- target: aarch64-unknown-linux-gnu
os: ubuntu-latest
cross: true
- target: x86_64-apple-darwin
os: macos-latest
- target: aarch64-apple-darwin
os: macos-latest
- target: x86_64-pc-windows-msvc
os: windows-latest
Five targets, three OS runners. The aarch64-unknown-linux-gnu target uses cross because GitHub doesn’t offer ARM Linux runners. cross handles the cross-compilation toolchain in Docker. Everything else compiles natively on the runner’s architecture.
Each build uses sparse-checkout to pull only src/, Cargo.toml, Cargo.lock, build.rs, and SPEC.md. No point cloning the full repo with examples, docs, and npm scaffolding when you just need the Rust source.
Compiling to WASM
A separate job compiles to wasm32-wasip1:
cargo build --release --target wasm32-wasip1 --no-default-features
--no-default-features is important. The default features include Cranelift (for the JIT compiler) and HTTP support. Neither works in WASM. The interpreter and VM still run fine without them.
The output is a single ilo.wasm file, roughly 2MB.
GitHub Releases
A release job waits for both the native builds and the WASM build to finish, then downloads all artifacts, generates SHA256 checksums, and creates a release using softprops/action-gh-release:
- name: Generate checksums
run: sha256sum ilo-* > checksums-sha256.txt
- name: Create release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: |
ilo-*
checksums-sha256.txt
generate_release_notes: true pulls commit messages since the last tag. No manual changelog writing.
crates.io
- name: Publish to crates.io
run: |
output=$(cargo publish 2>&1) && echo "$output" || {
echo "$output"
echo "$output" | grep -q "already exists" || exit 1
}
env:
CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }}
The error handling is worth noting. If the version already exists on crates.io (from a re-run or a race condition), the job swallows the error. Any other failure still breaks the build. Without this, a re-triggered workflow would fail on a step that already succeeded.
npm via WASM
This is the most involved channel. The npm package doesn’t contain any Rust. It contains the compiled WASM binary and a 37-line Node.js shim that runs it through WASI:
const wasi = new WASI({
version: "preview1",
args: ["ilo", ...argv.slice(2)],
env: process.env,
preopens: { "/": "/" },
});
const wasmBuffer = await readFile(wasmPath);
const { instance } = await WebAssembly.instantiate(
wasmBuffer,
wasi.getImportObject()
);
wasi.start(instance);
Node 20+ ships WASI support built in. No native addons, no compilation step, no platform-specific binaries. A user runs npx ilo-lang 'fn x:n>n;*x 2' 5 and it works on any OS with Node installed.
The version number comes from the git tag, not from package.json:
- name: Set npm version from tag
run: npm version ${GITHUB_REF_NAME#v} --no-git-tag-version
This means package.json in the repo always has a placeholder version. The CI pipeline stamps the real version at publish time. One source of truth for the version number: the git tag.
Claude Code marketplace
The Claude Code skill is different from the other three channels. It doesn’t get published by CI. It lives in the repo at skills/ilo/SKILL.md and gets installed by users via claude plugin add or by copying the skill directory.
The skill doesn’t bundle the ilo binary either. It includes an ensure-ilo.sh script that runs at the start of every task:
if command -v ilo >/dev/null 2>&1; then
CURRENT_VER=$(ilo --version 2>/dev/null | sed 's/ilo //' | sed 's/v//')
LATEST=$(curl -fsSL ".../releases/latest" | ...)
if [ "$CURRENT_VER" = "$LATEST" ]; then
echo "ilo is up to date (${CURRENT_VER})"
exit 0
fi
fi
It checks the GitHub API for the latest release, compares with the installed version, and downloads the right native binary for the platform. Falls back to npx ilo-lang if the binary install fails (Windows, unusual architectures, corporate firewalls).
This means the skill is always current without any manual update step. When the CI pipeline publishes a new release, every Claude Code user gets it on their next skill invocation.
One set of artifacts
The key design choice: all four channels consume the same artifacts.
- GitHub Releases gets the raw binaries
- crates.io gets the Rust source (compiles on the user’s machine)
- npm gets the WASM binary
- Claude Code’s skill downloads the native binary from GitHub Releases
There’s no separate build for npm or the skill. The WASM binary that npm ships is the same one uploaded to GitHub Releases. The binary that ensure-ilo.sh downloads is the same one from the release step.
The version is the git tag. The Cargo.toml version has to match for crates.io, package.json is stamped at publish time, and the skill manifest reads it from the binary, but all of them derive from the tag.
What broke along the way
The WASM build originally included the Cranelift JIT. It compiled fine to WASM but panicked at runtime because Cranelift needs to mmap executable memory, which WASI doesn’t provide. --no-default-features fixed it but also removed HTTP support, so get/post builtins don’t work in the npm version. That’s an acceptable tradeoff for a version that runs everywhere without compilation.
The npm shim originally used process.argv directly. WASI expects args[0] to be the program name, so argv.slice(2) (skipping node and the script path) wasn’t enough. It needed ["ilo", ...argv.slice(2)] to match what a native binary would see.
The ensure-ilo.sh script went through a few iterations on Windows. uname -s returns MINGW64_NT-10.0-19045 in Git Bash. The case statement needs to match MINGW*|MSYS*|CYGWIN*|Windows_NT to catch all the variants.
How a release works now
Bump Cargo.toml, commit, tag, push:
# edit Cargo.toml version
git add Cargo.toml
git commit -m "release: v0.9.1"
git tag v0.9.1
git push origin main --tags
A few minutes later: GitHub Release with five binaries and checksums, crates.io updated, npm package published with the WASM build, and every Claude Code user picks up the new version on their next invocation.