> A production-grade setup covering pre-commit checks, automated builds, and store deployment via GitHub Actions and Fastlane.
Pre-commit Hooks
yarn add -D husky lint-staged prettier
yarn husky init
Before every commit, lint-staged runs three quick jobs on the changed files:
-
removeConsoleLogs.jsremoves strayconsole.logcalls. - Prettier formats the code.
- ESLint fixes what it can automatically.
package.json
{
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"node scripts/removeConsoleLogs.js",
"prettier --write",
"eslint --fix"
]
}
}
scripts/removeConsoleLogs.js
const fs = require('fs');
const file = process.argv[2];
const original = fs.readFileSync(file, 'utf8');
const cleaned = original.replace(
/^\s*console\.(log|debug|info)\([^)]*\);?\s*$/gm,
''
);
if (cleaned !== original) {
fs.writeFileSync(file, cleaned);
}
.husky/pre-commit
npx lint-staged
Fastlane
Fastlane wraps your build commands into lanes, so local and CI runs stay identical.
Set it up per platform (android/fastlane and ios/fastlane):
echo "gem 'fastlane'" >> Gemfile
bundle install
cd android && bundle exec fastlane init && cd ..
cd ios && bundle exec fastlane init && cd ..
iOS Appfile
Appfile keeps your identifiers out of the Fastfile.
| Variable | Description |
|---|---|
DEVELOPER_APP_IDENTIFIER |
Your app's Bundle ID — in Xcode, select your project and open the General tab. |
FASTLANE_APPLE_ID |
The email used to access your Apple ID. |
APP_STORE_CONNECT_TEAM_ID |
Shown next to your name under Users and Access in App Store Connect. |
DEVELOPER_PORTAL_TEAM_ID |
Your Team ID from the Apple Developer portal under Membership. |
ios/fastlane/Appfile
app_identifier(ENV["DEVELOPER_APP_IDENTIFIER"])
apple_id(ENV["FASTLANE_APPLE_ID"])
itc_team_id(ENV["APP_STORE_CONNECT_TEAM_ID"])
team_id(ENV["DEVELOPER_PORTAL_TEAM_ID"])
Android Appfile
For Android, json_key_file is your Play Console service account key.
Play Console setup
- Go to Play Console → API access.
- Create a service account in Google Cloud Console.
- Download its JSON key.
- Grant it Release apps to testing tracks back in Play Console.
android/fastlane/Appfile
json_key_file("<path-to-play-console-service-account>.json")
package_name("com.yourcompany.app")
iOS Certificates with match
Certificates with match store your distribution certificate and provisioning profile encrypted in a private Git repository, so CI can pull them instead of relying on your local keychain.
| Variable | Description |
|---|---|
MATCH_PASSWORD |
Password used to retrieve certificates from GitHub. You choose this yourself; match init will prompt you for it along with your repository URL. |
GIT_AUTHORIZATION |
Also referred to as the Git authorization token. Create one using the appropriate guide, then format it as username:token. |
cd ios
bundle exec fastlane match init
# Creates ios/fastlane/Matchfile
bundle exec fastlane match appstore
# Syncs App Store certificate + profile
Check the official documentation for more details.
Android Fastfile
The following environment variables are used for Android signing:
| Variable | Description |
|---|---|
ANDROID_KEYSTORE_FILE |
Path to your existing release .jks/.keystore file. If you need a new one, run the command below. |
ANDROID_KEYSTORE_ALIAS |
The -alias used when generating the keystore. |
ANDROID_KEYSTORE_PASSWORD |
The password you set when generating the keystore. |
To generate a new keystore if needed:
keytool -genkeypair -v \
-keystore your_keystore.jks \
-keyalg RSA \
-keysize 2048 \
-validity 10000 \
-alias your_alias
android/fastlane/Fastfile
default_platform(:android)
platform :android do
desc 'Submit a new build to Play Store'
lane :deploy_to_play_store do
# Bump versionCode/versionName in build.gradle
# so every release is versioned automatically
increment_version_code(gradle_file_path: "app/build.gradle")
increment_version_name(
gradle_file_path: "app/build.gradle",
bump_type: "patch"
)
# Build a signed release .aab using the keystore secrets
gradle(
task: 'clean bundleRelease',
project_dir: './android',
properties: {
"android.injected.signing.store.file" => ENV['ANDROID_KEYSTORE_FILE'],
"android.injected.signing.store.password" => ENV['ANDROID_KEYSTORE_PASSWORD'],
"android.injected.signing.key.alias" => ENV['ANDROID_KEYSTORE_ALIAS'],
"android.injected.signing.key.password" => ENV['ANDROID_KEYSTORE_PASSWORD']
}
)
# Upload the .aab to Play Store as a draft on the production track
upload_to_play_store(
track: 'production',
release_status: "draft",
aab: File.absolute_path(
'../android/app/build/outputs/bundle/release/app-release.aab'
)
)
end
end
iOS Fastfile
The iOS Fastfile:
- Sets up a temporary CI keychain.
- Pulls certificates via
match. - Builds the app.
- Ships it to TestFlight.
Environment variables
| Variable | Description |
|---|---|
TEMP_KEYCHAIN_USER |
Any name you pick — only used to create a throwaway keychain in CI. |
TEMP_KEYCHAIN_PASSWORD |
Any password you pick — only used to create a throwaway keychain in CI. |
APPLE_KEY_ID |
From App Store Connect → Users and Access → Integrations → Keys. |
APPLE_ISSUER_ID |
The Issuer ID shown at the top of the App Store Connect API Keys page. |
APPLE_KEY_CONTENT |
The private key file (.p8) downloaded when creating the API key. Open it in a text editor and copy the full -----BEGIN/END PRIVATE KEY----- content. |
DEVELOPER_APP_ID |
Your app's numeric Apple ID — in App Store Connect under App Information. |
PROVISIONING_PROFILE_SPECIFIER |
Match AppStore <DEVELOPER_APP_IDENTIFIER> — the profile name match creates for you. |
ios/fastlane/Fastfile
default_platform(:ios)
def ensure_temp_keychain(name, password)
delete_keychain(name: name) if File.exist?(
File.expand_path("~/Library/Keychains/#{name}-db")
)
create_keychain(
name: name,
password: password,
unlock: false,
timeout: 0
)
end
platform :ios do
desc "Submit a new build to App Store"
lane :deploy_to_app_store do
# Bump the version number in Info.plist
# so every release is versioned automatically
increment_version_number(bump_type: "patch")
# Create a clean, temporary keychain for CI to store certs in
ensure_temp_keychain(
ENV["TEMP_KEYCHAIN_USER"],
ENV["TEMP_KEYCHAIN_PASSWORD"]
)
# Authenticate with App Store Connect using an API key
# (no Apple ID prompt in CI)
api_key = app_store_connect_api_key(
key_id: ENV["APPLE_KEY_ID"],
issuer_id: ENV["APPLE_ISSUER_ID"],
key_content: ENV["APPLE_KEY_CONTENT"],
duration: 1200,
in_house: false
)
# Pull the distribution cert + provisioning profile
# from the match git repo
match(
type: 'appstore',
app_identifier: ENV["DEVELOPER_APP_IDENTIFIER"],
git_basic_authorization: Base64.strict_encode64(
ENV["GIT_AUTHORIZATION"]
),
readonly: true,
keychain_name: ENV["TEMP_KEYCHAIN_USER"],
keychain_password: ENV["TEMP_KEYCHAIN_PASSWORD"],
api_key: api_key
)
# Build the signed .ipa
ipa = gym(
configuration: "Release",
export_xcargs: "-allowProvisioningUpdates",
workspace: 'YourApp.xcworkspace',
scheme: "YourApp Release",
export_method: "app-store",
export_options: {
provisioningProfiles: {
ENV["DEVELOPER_APP_ID"] => ENV["PROVISIONING_PROFILE_SPECIFIER"]
}
}
)
# Ship the build to TestFlight for internal testers
pilot(
apple_id: ENV["DEVELOPER_APP_ID"],
app_identifier: ENV["DEVELOPER_APP_IDENTIFIER"],
distribute_external: false,
notify_external_testers: true,
uses_non_exempt_encryption: false,
ipa: ipa,
skip_waiting_for_build_processing: true
)
end
end
Test Locally
Android
cd android && bundle exec fastlane deploy_to_play_store
iOS
cd ios && bundle exec fastlane deploy_to_app_store
GitHub Actions
One workflow, one file.
Lint and test first; deploy jobs only fire on master, once those checks pass.
Add all the environment variables from the Fastlane setup as secrets under:
Settings → Secrets and variables → Actions
.github/workflows/mobile-pipeline.yml
name: Mobile Build Pipeline
# Runs on every PR targeting master
on:
pull_request:
branches: [master]
jobs:
# Lints and tests the JS/TS code — gatekeeper for both deploy jobs below
lint-and-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: yarn lint
- run: yarn test --watchAll=false
# Builds and ships the Android app to Play Store
# — only runs if lint-and-test passes
deploy-android:
needs: lint-and-test
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'yarn'
- run: yarn install --frozen-lockfile
- uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '17' # needed to build with Gradle
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true # Fastlane runs on Ruby
- run: cd android && bundle exec fastlane deploy_to_play_store
# bumps version, builds, uploads
env:
ANDROID_KEYSTORE_FILE: ${{ secrets.ANDROID_KEYSTORE_FILE }}
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEYSTORE_ALIAS: ${{ secrets.ANDROID_KEYSTORE_ALIAS }}
# Builds and ships the iOS app to TestFlight
# — only runs if lint-and-test passes
deploy-ios:
needs: lint-and-test
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: 'yarn'
- run: yarn install --frozen-lockfile
- run: cd ios && pod install
# installs native iOS dependencies
- uses: ruby/setup-ruby@v1
with:
ruby-version: '3.3'
bundler-cache: true # Fastlane runs on Ruby
- run: cd ios && bundle exec fastlane deploy_to_app_store
# bumps version, signs, builds, ships
env:
APPLE_KEY_ID: ${{ secrets.APPLE_KEY_ID }}
APPLE_ISSUER_ID: ${{ secrets.APPLE_ISSUER_ID }}
APPLE_KEY_CONTENT: ${{ secrets.APPLE_KEY_CONTENT }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
GIT_AUTHORIZATION: ${{ secrets.GIT_AUTHORIZATION }}
TEMP_KEYCHAIN_USER: ${{ secrets.TEMP_KEYCHAIN_USER }}
TEMP_KEYCHAIN_PASSWORD: ${{ secrets.TEMP_KEYCHAIN_PASSWORD }}
Conclusion
With pre-commit hooks, Fastlane, and GitHub Actions wired together, every merge to master is linted, tested, versioned, and shipped — no manual steps, no missed builds.
Top comments (0)