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 👇
