Split / Merge (Distributed Builds)
Fan out cross-platform builds across parallel CI jobs and merge them into a single release
Split/merge lets you build binaries for each platform on native CI runners (Linux, macOS, Windows) in parallel, then collect all the artifacts on one final job that creates the release.
This avoids slow cross-compilation and lets each job run on hardware that matches its target OS.
If your CI already runs the determinism harness, prefer
preserve-dist+release --publish-only— the harness's per-platform builds are already byte-stable, so a separate split/merge matrix repeats work.
How it works
- Matrix jobs — each job runs
anodizer release --spliton its native runner. This builds only the binaries for that job's platform and writes acontext.json(artifacts list + git state) to adist/<platform>/subdirectory. - Artifact handoff — each job uploads its
dist/<platform>/directory as a CI artifact. - Merge job — a final job downloads all platform artifacts, restores them into
dist/, then runsanodizer continue --merge(oranodizer release --merge). This loads the per-platform contexts, merges all artifacts, and runs all post-build stages: archives, checksums, signing, release upload, publishing, announcements.
Config
partial:
by: os # "os" (default) or "target"| Field | Type | Default | Description |
|---|---|---|---|
by | string | os | How to group targets into split jobs: os (one job per OS) or target (one job per full target triple). |
by: os (recommended)
All architecture variants for the same OS run in a single job. A project with targets x86_64-unknown-linux-gnu and aarch64-unknown-linux-gnu produces one Linux job.
by: target
Each unique target triple gets its own job. Use this when you need each architecture to run on different hardware (e.g., building native arm64 on a Graviton runner).
Target selection in split jobs
Each split job determines which targets to build using this priority chain:
TARGETenvironment variable — exact target triple (e.g.x86_64-unknown-linux-gnu).ANODIZER_OS+ optionalANODIZER_ARCHenvironment variables — filter by OS/arch.- Host auto-detection via
rustc -vV, interpreted according topartial.by.
CLI commands
anodizer release --split
Builds only the binaries for the current platform and writes output to dist/<platform>/context.json. No release is created; no signing or publishing happens.
anodizer release --splitanodizer release --merge
Loads all context.json files from dist/*/ subdirectories, merges the artifact lists, and runs the full post-build pipeline (archives, checksums, signing, release, blob storage, publish, announce).
anodizer release --mergeanodizer continue --merge
Equivalent to anodizer release --merge. Preferred for the merge job to make the intent explicit.
anodizer continue --mergeDry run
Both --split and --merge respect --dry-run:
anodizer release --split --dry-run
anodizer continue --merge --dry-runArtifact handoff
Each --split job writes its output to:
dist/
linux/ # or "darwin", "windows", or full triple if by: target
context.json # artifact list + git state for this platform
myapp # compiled binary
...
The context.json file contains the artifact metadata (paths, kinds, checksums) and git context (tag, commit, branch, template variables). The merge job uses this to reconstruct the artifact registry without rebuilding.
A dist/matrix.json file is also written (on the first --split run) listing the CI matrix entries with runner suggestions, though it is not required by the merge step.
GitHub Actions example
Uses tj-smith47/anodizer-action with built-in
upload-dist / download-dist to handle artifact handoff:
name: Release
on:
push:
tags:
- "v*"
jobs:
build:
name: Build (${{ matrix.target }})
strategy:
matrix:
include:
- target: linux
runner: ubuntu-latest
- target: darwin
runner: macos-latest
- target: windows
runner: windows-latest
runs-on: ${{ matrix.runner }}
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: tj-smith47/anodizer-action@v1
with:
install-rust: true
install: zig,cargo-zigbuild
upload-dist: true # uploads dist/ as dist-$RUNNER_OS
args: release --split --clean
env:
ANODIZER_OS: ${{ matrix.target }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
release:
name: Release (merge)
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- uses: tj-smith47/anodizer-action@v1
with:
auto-install: true
download-dist: true # downloads + merges dist-* artifacts
args: continue --merge
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
upload-dist: true uploads the split job's dist/ directory as an artifact
named dist-$RUNNER_OS (dist-Linux, dist-macOS, dist-Windows).
download-dist: true in the merge job downloads every dist-* artifact and
merges them into dist/ in the expected layout, fails the job if no split
context files are found.
If you need to manage artifacts manually (e.g., a non-GitHub runner), upload
each split job's dist/<platform>/ and download them into dist/ in the
merge job — the subdirectory names must match the dist/<platform>/ layout
written by --split, which depends on your partial.by setting.
Merge pipeline stages
When --merge runs, it executes all post-build stages in order:
- Archives
- nFPM (Linux packages)
- Snapcraft
- DMG
- MSI
- PKG
- Source archive
- Changelog
- Checksums
- Sign
- Release (GitHub/GitLab)
- Publish (Homebrew, Scoop, crates.io, etc.)
- Docker
- Blob storage
- Announce
Use --skip to skip individual stages during merge:
anodizer continue --merge --skip docker,announcepublish --merge / announce --merge
Beyond release --merge / continue --merge, anodizer also accepts
--merge on the publish-only and announce-only commands so the merge
step can be broken into smaller pieces (for example, one machine signs +
uploads to GitHub, a second machine fan-outs to package managers, a
third announces). Mirrors GoReleaser Pro's
goreleaser publish --merge / goreleaser announce --merge.
anodizer publish --merge # loads dist/<subdir>/context.json shards, runs publish pipeline
anodizer announce --merge # loads dist/<subdir>/context.json shards, runs announce pipeline
Both modes use the same shard-loader (and the same
worker-completeness + collision checks) as release --merge.
Divergence from GoReleaser
The split layout aligns with GoReleaser's dist/$GOOS shape only for
partial.by: os — dist/linux, dist/darwin, dist/windows are
written byte-for-byte the same in both tools. The other variants
diverge:
partial.by / variant | GoReleaser layout | Anodizer layout |
|---|---|---|
by: os (default) | dist/$GOOS | dist/<os> (same) |
by: target | dist/$GOOS_$GOARCH | dist/<rust-triple> (full triple) |
anodizer --targets=<csv> | not present | dist/targets-<first-triple> (harness only) |
Cross-tool migration is one-way: shards produced by one tool cannot be merged by the other. If you are migrating an existing GoReleaser project, regenerate shards with anodizer on the next release rather than mixing layouts.
Idempotency
Re-running anodizer release --split on the same shard against the
same source tree writes a byte-identical dist/<subdir>/context.json
(provided the inputs — git HEAD, env vars, timestamps via
SOURCE_DATE_EPOCH or metadata.mod_timestamp — are stable). The
merge step's loader uses sorted shard iteration so the merged artifact
order is also deterministic.
The before: hook list is skipped during --merge (mirrors
GoReleaser's
customization/general/partial/
contract: "this step will not run anything that the previous step
already did") so re-running merge after a transient publish failure
does not re-compile.