Mobile releases with fastlane
This is opt-in. kinhin's runners, configuration and commands work exactly as before unless you run kinhin mobile init in a project. Running it only adds files to that project: fastlane lanes, a Gemfile, and a CI workflow. kinhin's own config is not read or changed by it.
Scaffold fastlane into an iOS, Android, Flutter, React Native, Expo, Capacitor, Kotlin Multiplatform or .NET MAUI project, and run store uploads from a kinhin runner. kinhin mobile init writes the lanes, a pinned Gemfile and a manual release workflow. Nothing is uploaded until you trigger the workflow.
Quick start
Install kinhin, then run this from anywhere, with the project folder as the argument. The tutorial walks through a first build end to end, the credentials page covers each secret, and store screenshots covers the screenshots lane:
kinhin mobile init /path/to/your/appIt detects the framework, then writes only the files that are missing:
| Project | Detected by | Writes |
|---|---|---|
| Flutter | pubspec.yaml | Gemfile, ios/fastlane/, android/fastlane/, .github/workflows/mobile-release.yml |
| React Native (bare) | "react-native" in package.json | same as Flutter |
| Capacitor / Ionic | "@capacitor/core" in package.json | same as Flutter. Each platform's CI job runs npm run build and npx cap sync. |
| Kotlin Multiplatform | iosApp/, Gradle settings, and the multiplatform plugin in a build file | iosApp/fastlane/, androidApp/fastlane/, workflow. The Xcode build phase builds the shared framework. |
| .NET MAUI | a .csproj with <UseMaui>true</UseMaui> (root or one folder down) | ios/fastlane/, android/fastlane/, workflow. dotnet publish builds and signs the artifacts. |
| Expo | "expo" in package.json | eas.json only. Expo uses EAS by default (see Expo). |
| Native iOS | ios/ or *.xcodeproj / *.xcworkspace | Gemfile, fastlane/ (or ios/fastlane/), workflow |
| Native Android | android/ or settings.gradle(.kts) | Gemfile, fastlane/ (or android/fastlane/), workflow |
Flags:
--framework ios|android|flutter|react-native|expo|capacitor|kmp|maui|nativeoverrides detection.--ci github|gitea|forgejo|gitlab|bitbucket|azure|buildkitepicks the CI system the release pipeline is written for. By default it is detected: a.buildkite/folder or Azure Pipelines file, then the host of theoriginremote, then the repository's other CI files, else GitHub. See Other CI systems.--forceoverwrites files that already exist.--dry-runprints what would be written.
Then, in the project:
bundle install
# Run from the folder that holds fastlane/: ios/ or android/ for Flutter and React Native,
# the project root for a native project with its project file at the root.
cd ios && bundle exec fastlane lanes
cd ../android && bundle exec fastlane lanesLanes
| Platform | Lane | What it does |
|---|---|---|
| iOS | beta | match (readonly) → build_app (App Store export; IPA_PATH or dotnet publish for MAUI replaces it) → upload_to_testflight |
| iOS | release | upload_to_app_store with metadata and screenshots skipped; not submitted for review |
| iOS | screenshots | snapshot (UI tests on a simulator) → optional frameit (FRAME_SCREENSHOTS=1) → optional upload (UPLOAD_SCREENSHOTS=1). See store screenshots. |
| Android | screenshots | screengrab (instrumented tests on an emulator) → optional upload (UPLOAD_SCREENSHOTS=1). See store screenshots. |
| Android | beta | gradle builds a release AAB (or uses AAB_PATH; dotnet publish for MAUI) → upload_to_play_store to the internal track |
The templates use the documented fastlane interfaces: app_store_connect_api_key with match, pilot and deliver with API key auth, and supply with a service account JSON. See the sources at the end.
Secrets
Nothing secret is committed. Create these as repository secrets, and the workflow maps them into the lanes:
| Secret | Used for |
|---|---|
ASC_KEY_ID, ASC_ISSUER_ID, ASC_KEY_P8 | App Store Connect API key. The .p8 file is written to a temp path in the job, then deleted. |
MATCH_GIT_URL, MATCH_PASSWORD | Encrypted certificates repo for match (readonly on CI) |
APPLE_TEAM_ID, APP_IDENTIFIER, IOS_SCHEME | iOS app and build settings |
PLAY_SERVICE_ACCOUNT_JSON | Google Play Developer API (supply) |
ANDROID_KEYSTORE_BASE64, ANDROID_KEYSTORE_PASSWORD, ANDROID_KEY_ALIAS, ANDROID_KEY_PASSWORD | Upload keystore, passed to Gradle as android.injected.signing.* |
ANDROID_PACKAGE_NAME | Play package name |
MAUI_CODESIGN_KEY, MAUI_PROVISIONING_PROFILE (variables, not secrets) | .NET MAUI iOS signing: the identity and profile name that match installed. See .NET MAUI. |
Lane environment variables
The lanes read these from the environment. The CI jobs set them; set them yourself for a local run.
| Variable | Lane | Meaning |
|---|---|---|
IOS_PROJECT, IOS_WORKSPACE | iOS | Xcode project or workspace, when it is not at the default place. Passed to build_app and snapshot. |
IPA_PATH | iOS | An IPA that another step already built and signed. The lane skips build_app and uploads it. |
IPA_MAX_AGE_HOURS | iOS | How old IPA_PATH may be (default 24). An older IPA is refused. |
MAUI_PROJECT | iOS, Android | The .NET MAUI .csproj, relative to the app folder. The lane runs dotnet publish instead of Gradle or build_app. |
ANDROID_GRADLE_DIR | Android | The Gradle root, relative to the Android folder (Kotlin Multiplatform: ..). |
ANDROID_GRADLE_MODULE | Android | The module that holds the app (default app). Kotlin Multiplatform: androidApp. |
AAB_PATH | Android | A bundle that another step already built. The lane skips Gradle. |
Create the App Store Connect API key once: the Account Holder requests API access under Integrations, then an Account Holder or Admin creates a team key. You can download the .p8 once only. Team keys cover all apps and cannot be limited per app.
Runner
The workflow asks for runs-on: [self-hosted, kinhin-xcode], the Xcode image label. If your pool uses another label, change it in .github/workflows/mobile-release.yml. kinhin does not store any of these secrets.
Versions as variables. The toolchain versions are set in the workflow, but you can override them without editing it: DOTNET_VERSION (MAUI's .NET SDK, default 10.0.x) and JAVA_VERSION (the Java for the Android job; defaults to 21 for Capacitor and 17 otherwise). Set them as repository variables to test a newer SDK or JDK. The Actions stay pinned to commit SHAs, so a newer action needs its SHA updated in the templates.
Ruby. fastlane 2.241, which the ~> 2.240 pin resolves to, needs Ruby 3.2 or newer. The macOS system Ruby is 2.6, so the workflow installs Homebrew's Ruby on the runner before bundle install. Homebrew's Ruby is newer than 3.2 and ships bundler. Locally, use Ruby 3.2 or newer (for example rbenv or Homebrew's ruby@3.3).
Other CI systems
GitHub gets the workflow described above. Every other system gets two files: .kinhin/mobile-release.sh in the app folder, which holds the release steps as shell (the framework's setup, the signing files from secrets, bundle install, the lane), and a small pipeline file that starts it by hand, targets the Xcode runner, passes the secrets as environment variables and keeps the screenshots. The script takes the platform and lane: bash .kinhin/mobile-release.sh ios beta.
--ci | Pipeline file | Runner | Start it from |
|---|---|---|---|
gitea, forgejo | .gitea/workflows/mobile-release.yml, .forgejo/workflows/mobile-release.yml | runs-on: [kinhin-xcode] | Actions > mobile-release > Run workflow |
gitlab | .gitlab/mobile-release.yml, included from .gitlab-ci.yml | tags: [kinhin-xcode] | Build > Pipelines > Run pipeline (set MOBILE_LANE) |
bitbucket | bitbucket-pipelines.yml (a custom pipeline) | runs-on: [self.hosted, macos, kinhin-xcode] | Pipelines > Run pipeline > custom: mobile-release |
azure | .azure-pipelines/mobile-release.yml | the kinhin pool, demands: [kinhin_xcode] | a pipeline made from that file |
buildkite | .buildkite/mobile-release.yml | agents: { queue: kinhin } | a pipeline whose steps upload that file |
kinhin never edits a pipeline file the repository already has. With an existing .gitlab-ci.yml it prints the include: line to add; with an existing bitbucket-pipelines.yml it writes bitbucket-pipelines.mobile-release.yml beside it, for you to copy the mobile-release entry from.
There are no setup actions outside GitHub, so the runner image supplies the tools: Ruby 3.2 or newer (the script installs ruby@3.3 with mise when the image has mise and an older Ruby), and Node, Flutter, Java or .NET as the app needs. Declare them in the repository's .kinhin/config.yml (toolchains; kinhin toolchains detect --write proposes the list). Gitea and Forgejo run actions/checkout and actions/upload-artifact (v3, the artifact protocol those servers implement) by full GitHub URL, so the image also needs Node.js for those two actions.
Framework notes
The generated CI jobs run the framework's own setup before the lanes, so a clean checkout builds without manual steps. A monorepo app works too; see Monorepos.
Flutter. The jobs install the Flutter SDK (subosito/flutter-action) and run flutter pub get, which also writes android/local.properties. On iOS they run flutter build ios --config-only --release before CocoaPods, so the generated Flutter config exists when the Xcode project is built. The Android lane then builds the signed bundle with Gradle, using the same signing properties as the native projects, and finds it at build/app/outputs/bundle/release/. Set AAB_PATH only when another step has already built a bundle; then Gradle is skipped. An IPA build needs a distribution certificate, not a development one. The iOS deployment target must be 15.0 or higher for the Xcode 26 toolchain: flutter create still writes 13.0, which fails with "deployment target ... is set to 13.0". The flutter_app example sets 15.0 in ios/Runner.xcodeproj/project.pbxproj.
React Native (bare). The jobs set up Node 22 and install dependencies before CocoaPods and Gradle run: npm ci when there is a package-lock.json, yarn install --frozen-lockfile when there is a yarn.lock, pnpm install --frozen-lockfile when there is a pnpm-lock.yaml, and npm install otherwise. No official fastlane guide exists for React Native. The layout follows fastlane's standard setup. The e2e checks the same steps (npm, pod install, a simulator build and the signed Android bundle), but a real store upload is still unverified. The Android build needs ANDROID_HOME on the runner, or an android/local.properties with sdk.dir.
Native. Works for projects with ios/ and android/ folders, or with the project files at the root.
Capacitor and Ionic
Capacitor keeps its iOS project in ios/App/App.xcodeproj, one level below the fastlane folder. The CI job exports IOS_PROJECT=App/App.xcodeproj for that, so the iOS lanes find the project. For a local run, set it yourself:
cd ios
IOS_PROJECT=App/App.xcodeproj bundle exec fastlane betaCapacitor 8's Android library needs Java 21, so the Android job uses Java 21 for Capacitor projects (the other frameworks use Java 17). Capacitor 8 resolves its iOS packages with Swift Package Manager, so there is no Podfile.
Capacitor generates ios/App/App.xcodeproj, and the kinhin repository ignores *.xcodeproj for its XcodeGen examples, so a Capacitor project that lives in a kinhin repository needs an exception in its .gitignore. examples/capacitor_app shows it.
Kotlin Multiplatform
Detected by an iosApp/ folder, Gradle settings, and the multiplatform plugin in a build file (the root or shared/). The Android module is androidApp under a Gradle root one folder above androidApp/. The CI jobs set ANDROID_GRADLE_DIR=.. and ANDROID_GRADLE_MODULE=androidApp, so the lanes build :androidApp:bundle. For a local run, set them yourself. The iOS build runs the Xcode build phase, which calls ./gradlew :shared:embedAndSignAppleFrameworkForXcode. Run Gradle with JDK 17 (JDK 26 fails with Kotlin), and export JAVA_HOME before a plain ./gradlew run.
.NET MAUI
Detected by a .csproj with <UseMaui>true</UseMaui> at the root or one folder down. The iOS build needs macOS and the .NET 10 SDK with the MAUI workload (dotnet workload install maui). The CI job installs the SDK with actions/setup-dotnet, but the Linux catalog entry still pins the apt dotnet-sdk-8.0, which cannot build MAUI for .NET 10. MAUI's iOS build runs on macOS only.
fastlane signs nothing for MAUI. The lanes call dotnet publish with MAUI_PROJECT set to the .csproj (the CI jobs set it). The iOS lane runs after match, so it can sign with the certificate match installed. Set MAUI_CODESIGN_KEY (the signing identity) and MAUI_PROVISIONING_PROFILE (the profile name) as repository variables. The Android lane signs the AAB with the upload keystore. Screenshots are not scaffolded for MAUI, because MAUI does not generate UI test projects.
Expo
Expo's recommended path is EAS Build and EAS Submit. Expo local builds are supported, but Expo says the toolchain is yours to provide (fastlane included), only one platform builds per run, and eas.json's fastlane and ndk keys are ignored in local builds. kinhin mobile init therefore writes only eas.json for Expo.
To use fastlane with Expo, create the native folders, then scaffold them as a React Native project:
npm install
npx expo prebuild --no-install # creates ios/ and android/ (both platforms when no --platform is given)
kinhin mobile init --framework react-native .The e2e runs this route: it builds the Android bundle through the lane with a signing keystore, and runs pod install and a Release device build for iOS. Expo's own --platform flag takes one value, so leave it out to get both platforms.
Caveats to check before relying on this:
- EAS Submit uploads to TestFlight. The iOS
releaselane uploads a build to App Store Connect without submitting it for review. - The docs do not say whether
eas-build-pre-installhooks run in local builds. Test it before depending on it.
Monorepos
kinhin mobile init writes the workflow to the git repository's own .github/workflows/ (the only place GitHub reads workflows from), with paths relative to that repository. For an app at apps/mobile in a monorepo, the jobs run working-directory: apps/mobile/ios and the setup steps run in apps/mobile. Run it from the app folder, or pass the path: kinhin mobile init apps/mobile. A project with no git repository above it gets paths from its own root.
Errors you may see
The full list, with the cause and the fix for each, is in the troubleshooting page. The most common ones:
| Message | Where | What to do |
|---|---|---|
no iOS or Android project in ...; pass --framework | kinhin mobile init | The folder has no detectable project. Pass --framework. |
no ios/ or android/ folder in ... for a flutter project | kinhin mobile init | Run flutter create (or react-native init) first. |
Missing environment variables: ASC_KEY_ID, ... | fastlane | Set the listed secrets. The lane stops before any store call. |
ASC_KEY_FILEPATH does not exist | fastlane | The .p8 was not written. Check the "Write App Store Connect API key" step. |
Keystore not found at ... | fastlane | Check ANDROID_KEYSTORE_BASE64 and the "Write upload keystore" step. |
No release AAB at ... | fastlane | Gradle did not produce the bundle. Check AAB_PATH and the Gradle output. |
No IPA at ..., IPA at ... is older than N hours | fastlane | IPA_PATH points at a missing or stale IPA. Rebuild it, or raise IPA_MAX_AGE_HOURS. |
dotnet publish wrote no IPA / no AAB | fastlane | The MAUI build produced no artifact. Read the dotnet publish output above it. |
Cannot locate tasks that match ':androidApp:bundleRelease' | fastlane | Kotlin Multiplatform: the Gradle task name is set by the lane. Check ANDROID_GRADLE_MODULE. |
The MobileScaffoldTests Swift tests cover the scaffolding logic. They run in swift test, which CI and scripts/check.sh both run.
Verification
Two checks cover the integration:
- The
MobileScaffoldTestsSwift tests cover the scaffolding: detection, keep and force, dry run, monorepo paths and the error paths. They run inswift test, whichscripts/check.shand CI both run. scripts/mobile-e2e.shruns against a clean copy of the committed examples. It checks the templates still generate the committed examples, runsbundle installand lists the lanes, checks the secret errors in an empty environment, lints the generated workflows with actionlint, and confirms every fastlane option the templates use exists.--with-buildsadds the native, Flutter and React Native builds and the signed-bundle check. It needs Ruby 3.2+ (setRUBY_BINif it is not on the usual path). Building the binary needs Swift; the builds need JDK 17, an Android SDK, Xcode, Flutter, XcodeGen, andjarsigner(part of the JDK). The React Native steps need npm and CocoaPods.
The e2e script does not upload anything and does not read credentials. The store and runner steps still need a real project. The checklist in docs/plans/mobile-fastlane-test-checklist.md lists what is covered and what is not.
Run this checklist on a fixture project before you trust a release:
kinhin mobile init --dry-run /path/to/applists what would be written.bundle installsucceeds with Ruby 3.2 or newer.bundle exec fastlane laneslists the lanes, run from each platform's folder (see Quick start).- Trigger
mobile-releasewithlane: betaagainst a test app and confirm the TestFlight or internal track build. - Re-run
kinhin mobile initand confirm existing files are kept.
Sources
Checked 2026-10-11. The versions and dates below are what those pages showed.
- fastlane setup: iOS, Android. fastlane 2.240.1 was the latest release seen on GitHub (2026-09-15). The Gemfile pin
~> 2.240should be rechecked on each update. - Actions:
match,pilot,deliver,gym,supply,gradle,app_store_connect_api_key - CI: fastlane with GitHub Actions
- Flutter: Continuous Delivery
- Expo: local builds, lifecycle hooks, EAS Submit for iOS
- Google Play: Play Developer API getting started
- Apple: App Store Connect API
Unverified: the React Native layout, Play App Signing details, and the Xcode version needed for uploads. Apple's help page and a third-party source disagree on that version, so check Apple's developer news.
