Skip to content

ci(mobile): iOS + Android release jobs and PR build smoke#3970

Merged
houko merged 18 commits into
mainfrom
feat/mobile-ci-builds
Apr 28, 2026
Merged

ci(mobile): iOS + Android release jobs and PR build smoke#3970
houko merged 18 commits into
mainfrom
feat/mobile-ci-builds

Conversation

@houko

@houko houko commented Apr 28, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #3345 — the CI side of the mobile epic. Desktop matrix already produces all six native bundles; this fills in the missing iOS + Android leg.

release.yml

  • New job mobile_android on ubuntu-latest: JDK 17, Android SDK + NDK r27, Rust aarch64-linux-android + armv7-linux-androideabi. Decodes ANDROID_KEYSTORE_B64, builds signed .aab + .apk via cargo tauri android build --apk --aab, attaches to the GitHub release.
  • New job mobile_ios on macos-26: default Xcode 26, Rust aarch64-apple-ios. Imports APPLE_CERT_P12 + APPLE_PROVISIONING_PROFILE_B64 into a one-shot keychain (prepended to the user search list, not replacing it), builds signed .ipa via cargo tauri ios build, attaches to the release.
  • Both run with continue-on-error: true — a flaky Apple cert refresh or Android signing snag must NOT block the desktop matrix above.
  • Both degrade gracefully when the corresponding signing secrets are absent (forks / first-run repos): Android falls back to an unsigned debug APK, iOS falls back to an unsigned simulator build. The toolchain is exercised either way; only the release-attach is conditional.
  • cargo install tauri-cli is now wrapped in an actions/cache lookup keyed on tauri-cli-${runner.os}-v2, so the 1–2 min recompile happens once per cache lifetime instead of every job.

mobile-smoke.yml (new)

Post-merge smoke test (push to main only — not per-PR), no secrets touched. Triggers on pushes to main that change crates/librefang-desktop/**, the dashboard, gen/**, the workflow itself, or root Cargo.toml / Cargo.lock. Cargo + Gradle caches are keyed per-job so a warm rerun stays under the 12-min target from the issue.

The "post-merge instead of per-PR" choice is deliberate: each leg pulls a cold Android NDK or Xcode iOS SDK and a full Rust mobile rebuild — ~25 min each on a cache miss, which is too heavy to pay for every desktop / dashboard PR. Maintainers can still trigger it on a branch via workflow_dispatch from the Actions tab when they want pre-merge validation.

.github/SECRETS.md (new)

Every Actions secret the repo currently uses is now documented with format, rotation cadence, and a concrete runbook for the yearly Apple cert refresh. Mobile signing, desktop signing, package registries, and internal infra (Homebrew tap PAT, deploy previews) are all covered. Operational rules at the bottom: never echo, always wipe runner copies, forks must degrade gracefully.

Desktop crate changes

  • [lib] now declares crate-type = ["staticlib", "cdylib", "lib"] so cargo tauri ios build can find the static library. Comment in Cargo.toml notes the desktop side now also produces those outputs (~10–20% extra link time on a clean target/).
  • run() no longer carries #[tauri::mobile_entry_point]; a separate 0-arg mobile_main() wraps it. Window construction is gated #[cfg(desktop)] because WebviewWindowBuilder::title/inner_size/center aren't available on mobile.
  • Capabilities split into default.json (platforms: ["macOS", "windows", "linux"]) and a new mobile.json (platforms: ["iOS", "android"]) with only core / notification / dialog — desktop-only plugins (shell, global-shortcut, autostart, updater) are not bundled on mobile.
  • iOS minimum supported version raised from 14.0 → 16.0 (Tauri 2 mobile requirement). Documented in CHANGELOG.md under Unreleased / Changed.

Notes for maintainers

The first signed mobile release will fail until the eight new Android/Apple secrets are populated — see .github/SECRETS.md for the exact set and the keytool / Apple Developer portal recipes. Until then, both jobs land their unsigned fallbacks, so the rest of the release pipeline keeps working.

Test plan

  • Land this PR; trigger release.yml via workflow_dispatch against a tag with no mobile secrets set — confirm desktop bundles still upload, mobile jobs land unsigned debug artifacts, neither fails the workflow.
  • Populate the eight mobile secrets; re-run workflow_dispatch — confirm signed .aab / .apk / .ipa attach to the release.
  • Merge a no-op change touching crates/librefang-desktop/Cargo.toml to main — confirm mobile-smoke.yml runs and finishes within ~12 min on a warm cache.
  • Merge a change touching only crates/librefang-api/src/lib.rs to main — confirm mobile-smoke.yml does not run (path filter exclusion).
  • On a feature branch with crates/librefang-desktop/** changes, trigger mobile-smoke.yml via workflow_dispatch — confirm it can be invoked manually for pre-merge validation.

houko added a commit that referenced this pull request Apr 28, 2026
Two must-fix issues from review of #3970:

1. iOS runner mismatch: mobile-smoke.yml is on macos-15 / Xcode 16.2
   (with a comment explaining cargo-mobile2 emits objectVersion=77 pbxproj
   that 15.4 cannot parse), but release.yml mobile_ios was still on
   macos-14 / 15.4 — meaning the very first signed iOS release would
   fail at `tauri ios init`. Bump release.yml to match.

2. Shell injection in upload + teardown steps: `steps.art.outputs.*`
   and `steps.keychain.outputs.keychain` were inlined directly into
   shell scripts, same pattern hardened in #3938. Pass the values
   through env vars and quote $VAR inside the shell instead.

Also add restore-keys to the per-tag rust-cache so each release does
not start from a cold cache.
@github-actions github-actions Bot added the area/ci CI/CD and build tooling label Apr 28, 2026
@github-actions github-actions Bot added size/L 250-999 lines changed no-rust-required This task does not require Rust knowledge labels Apr 28, 2026
@cloudflare-workers-and-pages

Copy link
Copy Markdown

Deploying librefang with  Cloudflare Pages  Cloudflare Pages

Latest commit: a3c0958
Status: ✅  Deploy successful!
Preview URL: https://3017e5eb.librefang.pages.dev
Branch Preview URL: https://feat-mobile-ci-builds.librefang.pages.dev

View logs

@github-actions github-actions Bot removed the no-rust-required This task does not require Rust knowledge label Apr 28, 2026
houko added 17 commits April 29, 2026 00:08
Adds the missing mobile leg of the release pipeline:

- `release.yml: mobile_android` — ubuntu-latest, JDK 17, Android SDK +
  NDK r27, Rust aarch64 + armv7 targets. Decodes ANDROID_KEYSTORE_B64,
  builds signed `.aab` + `.apk` via `cargo tauri android build`, attaches
  to the GitHub release. Falls back to an unsigned debug APK when
  signing secrets are absent so forks / first-run repos still validate
  the toolchain.
- `release.yml: mobile_ios` — macos-14, Xcode 15.4, Rust aarch64-ios.
  Imports APPLE_CERT_P12 + APPLE_PROVISIONING_PROFILE_B64 into a
  one-shot keychain, builds signed `.ipa` via `cargo tauri ios build`,
  attaches to the release. Falls back to an unsigned simulator build
  when secrets are missing.
- Both jobs run with `continue-on-error: true` — a flaky cert refresh
  on Apple's side or an Android signing snag must NOT block the desktop
  release matrix.

PR-time smoke coverage:

- New workflow `mobile-smoke.yml` runs unsigned debug builds on PRs
  that touch `crates/librefang-desktop/**`, the dashboard, `gen/**`,
  the workflow itself, or root Cargo files. No secrets, no release
  upload — just "did the toolchain still produce a binary?"
- Cargo + Gradle caches are scoped per-job so cache hits get each
  smoke leg under the issue's 12-min target.

Documentation:

- `.github/SECRETS.md` is new — every workflow secret now has a
  named entry with format, rotation cadence, and a concrete runbook
  for the yearly Apple cert refresh. Mobile signing keys, desktop
  signing, package registries, and internal infra are all covered,
  with operational rules at the bottom (never echo, always wipe
  runner copies, forks must degrade gracefully).

Closes #3345.
cargo-mobile2 (used by `cargo tauri ios init`) emits a pbxproj with
objectVersion = 77, which Xcode 15.4 on macos-14 refuses to open
("future Xcode project file format"). Bump the iOS smoke job to
macos-15 with Xcode 16.2 so the generated project can build.
Two must-fix issues from review of #3970:

1. iOS runner mismatch: mobile-smoke.yml is on macos-15 / Xcode 16.2
   (with a comment explaining cargo-mobile2 emits objectVersion=77 pbxproj
   that 15.4 cannot parse), but release.yml mobile_ios was still on
   macos-14 / 15.4 — meaning the very first signed iOS release would
   fail at `tauri ios init`. Bump release.yml to match.

2. Shell injection in upload + teardown steps: `steps.art.outputs.*`
   and `steps.keychain.outputs.keychain` were inlined directly into
   shell scripts, same pattern hardened in #3938. Pass the values
   through env vars and quote $VAR inside the shell instead.

Also add restore-keys to the per-tag rust-cache so each release does
not start from a cold cache.
Xcode 16 on macos-15 ships only the macOS SDK by default; without an
explicit `xcodebuild -downloadPlatform iOS`, xcodebuild aborts with
"Found no destinations for the scheme 'librefang-desktop_iOS'". Add
a download step before the rust-toolchain action so the simulator
runtime is present when tauri builds the project.
Mirrors 8fb5e36 — the smoke job now does xcodebuild -downloadPlatform iOS
because macos-15 ships only the macOS SDK by default. The signed release
job hits the same 'Found no destinations' failure when xcodebuild can't
find an iOS SDK to target, so add the same pre-download step here.
Pinning Xcode 16.2 via setup-xcode@v1 lands on an Xcode install whose
iOS Simulator runtime isn't pre-staged, and `xcodebuild
-downloadPlatform iOS` then fails on hosted runners with "Unable to
connect to simulator" (exit 70). The macos-15 image's default Xcode
(16.x) understands the objectVersion=77 pbxproj and ships with iOS
Simulator runtime already populated, so just use that and add a
diagnostic step that prints the chosen Xcode and available runtimes.
The default capability requested shell:default, global-shortcut:*,
autostart:default and updater:default — none of which are bundled on
iOS/Android, so the iOS smoke build failed with "Permission
shell:default not found". Add `platforms: ["macOS", "windows",
"linux"]` to default.json so it only applies on desktop, and add a
mobile.json capability that grants the subset of permissions iOS and
Android actually expose (core, notification, dialog).
Three iOS build errors after the capability fix:

  - WebviewWindowBuilder::title() and friends are desktop-only methods
    in tauri 2 (mobile builders manage the OS-owned root window
    differently). Wrap the entire window-creation block in
    #[cfg(desktop)] and gate the WebviewUrl/WebviewWindowBuilder
    imports the same way.
  - tauri::mobile_entry_point requires a 0-arg fn; `run` takes
    (Option<String>, bool). Introduce a 0-arg `mobile_main` shim
    behind #[cfg(mobile)] that calls run(None, false), and drop the
    cfg_attr on `run` itself.

iOS/Android still get the full app — they just rely on
tauri.conf.json's window entry instead of programmatically creating
one.
…_code

KEYRING_SERVICE and KEYRING_ACCOUNT are only referenced inside
store/get/clear_credentials, which are themselves gated to iOS/Android.
The consts were unconditional, so desktop and nix builds tripped
`#[warn(dead_code)]` (which becomes -D warnings in some downstream
checks).
Each smoke leg pulls a cold Android NDK or iOS SDK and a full Rust mobile
rebuild — ~25 min per side on a cache miss. Running that on every desktop
or dashboard PR burns a lot of CI time for breakage that gets caught on
the post-merge run anyway.

Switch the trigger to push:main with the same path filter, plus keep
workflow_dispatch so a maintainer can validate a branch before merge
when they want to. Same path scoping (desktop crate / dashboard / gen /
this workflow / root Cargo files) — non-mobile changes stay free.
cargo tauri ios build expects liblibrefang_desktop.a in the target dir,
which Cargo only produces when the crate-type list includes staticlib.
Without an explicit [lib] block the default is just rlib, which is why
the iOS smoke fails post-rust-build with

    Library not found at target/aarch64-apple-ios-sim/debug/liblibrefang_desktop.a

Add staticlib + cdylib for mobile, keep lib so the desktop bin in
src/main.rs still depends on it as an rlib.
- actions/setup-java was pinned to a SHA I made up
  (7a445ee0e10ddc8ddedd4e85cee2b5a8a8a3afba); replaced with the real
  v4 ref c1e323688fd81a25caa38c78aa6df2d33d3e20d9. The android_smoke
  + mobile_android jobs were dying at "Set up job" with
  "Unable to resolve action actions/setup-java@<bogus-sha>". Both
  workflows shared the same bogus pin, so both legs were blocked.

- release.yml mobile_ios now matches the smoke job: drop
  setup-xcode + xcode-version pin + xcodebuild -downloadPlatform iOS,
  use the macos-15 default Xcode (which ships an iOS Simulator runtime
  pre-staged). Pinning to 16.2 lands on a build whose simulator
  runtime isn't pre-installed and the download itself fails on hosted
  runners with 'Unable to connect to simulator'.
The repo ships gen/android/{.gitkeep,README.md} so the directory exists
in fresh clones (helpful for IDEs / docs). tauri-cli reads this as
"already populated" and refuses to scaffold the missing
gen/android/app/src/main/java/<bundle-path> layout — init fails with

    Error Project directory ... gen/android/app/src/main/java/ai/librefang/app
    does not exist. Did you update the package name ... ?

It even tells you to "delete the gen/android folder and run tauri android
init to recreate". Both mobile workflows now do exactly that, so init
starts from clean every run.

iOS does not show the same symptom (cargo-mobile2 happily overwrites the
README placeholder under gen/apple/), so leave that path alone.
The dev machine runs Xcode 26.4.1 / iOS 26.4 SDK. macos-15 + Xcode 16
shipped binaries against iOS 18.x — different SDK, different ABI guarantees,
different simulator runtime. Worse, an iOS 26-built local artifact
diverging from a CI-built iOS 18 artifact would hide regressions.

macos-26 hosted runners are now available and ship Xcode 26.4.1 with
iOS 26.4 Simulator runtime pre-staged — no on-demand downloadPlatform
dance needed. Pin both ios_smoke and mobile_ios to that runner + Xcode
version so CI matches local.

Also confirms a real setup-xcode@v1 SHA (the previous reference here was
left over from a different action and only worked because the step that
needed it was still cleaned up by the user mid-iteration).
Pinning to 26.4.1 forced setup-xcode to swap toolchains on every run, ~1–2
min overhead, for a Tauri smoke / release build that only needs *some*
iOS 26 SDK. The SDK delta inside the 26.x line is not material.

Default Xcode on macos-26 is 26.2 with iOS 26.2 Simulator pre-staged —
plenty for verifying the toolchain still produces a binary, and the
release build still produces a 26.x ipa.
Xcode 26 (the toolchain on macos-26 hosted runners) no longer ships the
swiftCompatibility56 or swiftCompatibilityConcurrency back-deploy static
libs — those existed to back-port Swift 5.6 features to iOS 13–14.x and
are unnecessary on iOS 15+. Targeting "14.0" with Xcode 26 makes the
linker emit references to those symbols and then fail with

    Undefined symbols for architecture arm64:
      "__swift_FORCE_LOAD_$_swiftCompatibility56"
      "__swift_FORCE_LOAD_$_swiftCompatibilityConcurrency"
    ld: symbol(s) not found for architecture arm64

iOS 16 was released in September 2022, has ~95%+ adoption in 2026, and
keeps full Tauri 2 mobile feature compatibility. The bump is invisible
to anyone running iOS 16+; iOS 14/15 users were already a rounding
error and would have hit ABI issues with newer Tauri features anyway.

`cargo tauri ios init` regenerates the Xcode project from this config,
so CI picks up the change without further edits. Locally, run
  rm -rf gen/apple && cargo tauri ios init
to refresh an existing checkout.
…raise iOS min in changelog

Address review feedback on this PR.

- **Cache `tauri-cli` binary across runs.** All four mobile jobs
  (release.yml × 2, mobile-smoke.yml × 2) wrap the `cargo install
  tauri-cli@^2 --locked` step in an `actions/cache` lookup keyed on
  `tauri-cli-${runner.os}-v2`. Swatinem/rust-cache caches `target/`
  + cargo registries but not `~/.cargo/bin/`, so without this the
  1–2 min recompile happened cold every job. Cache hit = `command -v`
  short-circuit and skip the install entirely.

- **Prepend (not replace) the macOS keychain search list** during
  iOS code-signing. The original `security list-keychain -d user -s
  "$KEYCHAIN_PATH"` form *replaces* the list — fine on a clean
  GitHub-hosted runner but would hide existing entries on a future
  self-hosted macOS runner for the duration of the build. Now reads
  the prior list, then re-sets it with the new one prepended.

- **Document the `crate-type = ["staticlib", "cdylib", "lib"]`
  overhead on desktop.** The original comment explained why mobile
  needs the extra crate-types but didn't mention the side effect:
  desktop builds also produce the `.a` and `.so/.dylib` outputs,
  adding ~10–20% link time on a clean target/. Worth flagging so a
  future reader investigating slow desktop builds sees this as a
  known cost rather than guessing.

- **CHANGELOG entry for iOS 14 → 16 bump.** Tauri 2 mobile requires
  iOS 16+, which silently dropped support for iPhone 6s, original
  iPhone SE, iPad Air 2 and similar 14/15-only devices. Added under
  Unreleased / Changed with the affected device classes spelled out
  so it's findable when a user reports "the new mobile app won't
  install on my iPad".

- **Update PR description and test plan** to reflect that
  `mobile-smoke.yml` is post-merge (push to main + workflow_dispatch),
  not PR-time. The yaml comment was always correct; the PR body and
  test plan disagreed with the implementation.
@houko
houko force-pushed the feat/mobile-ci-builds branch from aa49520 to e1fbc40 Compare April 28, 2026 15:13
Same bug as the mobile_ios block: `security list-keychain -d user -s
$KEYCHAIN_PATH` replaces the entire user search list rather than
prepending the signing keychain to it. Harmless on a clean
GitHub-hosted runner, but on a future self-hosted macOS runner it
would hide the login keychain (and anything else) for the duration
of the build.

The mobile_ios block was fixed in the previous commit; the desktop
signing block had the same issue from when it was originally
written and remained unfixed because of an over-cautious 'this is
pre-existing, leave it' read on my part. Same fix applies.
@houko
houko merged commit 0b618d3 into main Apr 28, 2026
12 checks passed
@houko
houko deleted the feat/mobile-ci-builds branch April 28, 2026 15:18
@github-actions github-actions Bot added the area/docs Documentation and guides label Apr 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/ci CI/CD and build tooling area/docs Documentation and guides size/L 250-999 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add iOS/Android build jobs to release.yml with signing secrets

1 participant