TL;DR ๐งต A Tauri app with two
[[bin]]targets can't be bundled as a universal macOS app: only the main binary gets merged withlipo. Andtauri-actionwill happily finish green after uploading nothing. Below: why it happens, workarounds for both, and the issue I filed.
I maintain StackerPulse, a small open-source desktop app built with Rust and Tauri v2. It ships for Windows, Linux and macOS from a single GitHub Actions workflow.
Getting the macOS release out took two surprises:
| Symptom | Cause | Fix | |
|---|---|---|---|
| ๐ #1 | Failed to copy binary โฆ does not exist |
universal build merges only the main binary | make extra bins opt-in with required-features
|
| โ #2 | CI is green, release has no installers |
tauri-action silently skips uploads without a release id |
pass tagName, verify the assets |
๐ Surprise 1: "file does not exist" in a universal build
The app crate has two binaries: the app itself and a small command-line scanner.
[[bin]]
name = "stacker-pulse"
path = "src/main.rs"
[[bin]]
name = "seedscan-cli"
path = "src/seedscan_cli.rs"
Windows and Linux bundled fine. The universal macOS build (--target universal-apple-darwin) compiled for both architectures and then died at bundling:
Built application at: .../target/universal-apple-darwin/release/stacker-pulse
failed to bundle project: Failed to copy binary from
".../target/universal-apple-darwin/release/seedscan-cli": does not exist
๐ What's going on
I read the tauri-cli source (2.12.4). Two things don't line up:
- ๐งฉ In
crates/tauri-cli/src/interface/rust/desktop.rs, the universal branch ofbuild()builds both architectures, then runs onelipo -createcommand, for the main binary only. - ๐ฆ The bundler takes its list from
get_binaries()incrates/tauri-cli/src/interface/rust.rs, which returns every[[bin]]whoserequired-featuresare enabled.
The extra binary is compiled for each architecture but never merged into
target/universal-apple-darwin/release/, so the bundler goes looking for a file that was never created.
๐ ๏ธ Workaround
get_binaries() skips binaries whose required-features aren't enabled. Make the extra binary opt-in:
[[bin]]
name = "seedscan-cli"
path = "src/seedscan_cli.rs"
required-features = ["cli"]
[features]
cli = []
tauri build enables no features, so the app bundles cleanly on every OS. The CLI is built separately, once per architecture on macOS, and merged by hand:
cargo build --release --features cli --bin seedscan-cli --target aarch64-apple-darwin
cargo build --release --features cli --bin seedscan-cli --target x86_64-apple-darwin
lipo -create -output seedscan-cli \
target/aarch64-apple-darwin/release/seedscan-cli \
target/x86_64-apple-darwin/release/seedscan-cli
โ ๏ธ Pass --features cli to cargo test and cargo clippy too, or the CLI's tests silently stop running. This workaround relies on tauri-cli respecting required-features, which is reliable since 2.11.3.
๐ฎ Reported upstream
There was no existing report for this case: older issues are about externalBin sidecars, a different mechanism. So I filed tauri-apps/tauri#16460 with a minimal reproduction and a suggested fix: run lipo for every binary returned by get_binaries(), not just the main one.
At the time of writing, the issue is open and waiting for triage.
โ Surprise 2: green CI, empty release
With bundling fixed, the release workflow finished green. The draft release had the CLI archives and checksums, and no installers at all. ๐
The workflow creates a draft release first and passes its id to tauri-apps/tauri-action. My step that looked up the id returned an empty string. Given no release id and no tag name, tauri-action builds everything, logs this, and exits successfully:
No releaseId or tagName provided, skipping all uploads...
๐ ๏ธ Two fixes
1. Give tauri-action the tag as a fallback, so it can find the draft on its own, and make the id lookup fail loudly:
- uses: tauri-apps/tauri-action@<pinned-sha>
with:
releaseId: ${{ needs.create-release.outputs.release_id }}
tagName: ${{ github.ref_name }}
releaseDraft: true
2. Check the result, not the exit code:
- name: The installers really are on the draft
shell: bash
env:
GH_TOKEN: ${{ github.token }}
run: |
case "$RUNNER_OS" in
Windows) want='\.msi$' ;;
macOS) want='\.dmg$' ;;
*) want='\.AppImage$' ;;
esac
gh release view "$TAG" --json assets --jq '.assets[].name' > assets.txt
grep -Eq "$want" assets.txt || { echo "::error::No installer was attached"; exit 1; }
The next run attached every installer, and a missing one now turns the build red instead of quietly shipping an empty release. ๐
๐ก Takeaways
- ๐ When a tool fails strangely, read its source. The answer to surprise 1 was in about ten lines of tauri-cli.
- ๐ข A green check means "the steps ran", not "the job is done". Add a step that verifies what you actually care about.
- ๐ฎ Report it upstream, even with a workaround in hand. A minimal reproduction and a pointer to the code takes an hour and saves the next person a day.
The project, the workflow and the full changelog are on GitHub. If you've hit something similar with Tauri or tauri-action, I'd love to hear about it in the comments ๐
Top comments (2)
Surprise 2 is the one everyone should steal a fix from: a release job that ends green with zero assets. A final step that lists the release's assets with gh release view and fails if the expected count is missing costs five lines and turns "CI passed" back into "the installers exist". Nice work tracing the lipo call in the source too.
Thanks, that's exactly what I ended up adding. The release job now runs gh release view on the draft and fails if any installer is missing. Since 3.2.0 there's a second check too: latest.json for the auto-updater has to list all four platforms with links pinned to the tag. Five lines each, and I sleep better.