TL;DR 🧵 A Tauri app with two [[bin]] targets can't be bundled as a universal macOS app: only the main binary gets merged with lipo. And tauri-action will 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 of build() builds both architectures, then runs one lipo -create command, for the main binary only.
  • 📦 The bundler takes its list from get_binaries() in crates/tauri-cli/src/interface/rust.rs, which returns every [[bin]] whose required-features are 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

  1. 📖 When a tool fails strangely, read its source. The answer to surprise 1 was in about ten lines of tauri-cli.
  2. 🟢 A green check means "the steps ran", not "the job is done". Add a step that verifies what you actually care about.
  3. 📮 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 👇