DEV Community

UBro-dev
UBro-dev

Posted on

I found a bug in Tauri's universal macOS builds (and my CI lied to me)

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"
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

๐Ÿ” 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 = []
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

โš ๏ธ 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...
Enter fullscreen mode Exit fullscreen mode

๐Ÿ› ๏ธ 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
Enter fullscreen mode Exit fullscreen mode

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; }
Enter fullscreen mode Exit fullscreen mode

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 ๐Ÿ‘‡

Top comments (2)

Collapse
 
gautamtalksdev profile image
Gautam K •

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.

Collapse
 
ubrodev profile image
UBro-dev •

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.