Why CI/CD matters for a hybrid mobile codebase
When a startup ships a mobile product, speed and reliability are non‑negotiable. A monorepo that houses both an Ionic web view (usually Angular or React) and a native React Native layer introduces two distinct build pipelines, two sets of dependencies, and two deployment targets (iOS and Android). Manual builds quickly become a bottleneck: a missed version bump, a flaky test, or a forgotten signing key can delay a release by days.
A well‑engineered CI/CD pipeline solves three problems at once:
- Consistency – Every commit goes through the same steps, guaranteeing that the app you test locally is the same you ship.
- Fast feedback – Unit, component, and end‑to‑end tests run in parallel, catching regressions before they reach QA.
- Automation of repetitive tasks – Code signing, version bumping, and store uploads happen without human intervention, reducing human error.
Below is a pragmatic blueprint that works with a typical Yarn‑based monorepo, GitHub Actions, and Fastlane. The concepts apply equally to GitLab CI, Azure Pipelines, or CircleCI.
Organising the monorepo for CI clarity
A clear folder layout makes the pipeline easier to understand and to cache.
my-app/
├─ packages/
│ ├─ ionic/ # Ionic project (React or Angular)
│ └─ rn/ # React Native project
├─ scripts/ # Helper scripts (version bump, lint, etc.)
├─ .github/workflows/ # CI definitions
└─ package.json # Root Yarn workspace definition
- Root
package.jsondeclares the workspaces so a singleyarn installpulls all dependencies. scripts/contains tiny Node scripts that can be called from the CI without pulling the whole repo into a Docker image.- Separate
ionicandrnpackages keep platform‑specific config files (capacitor.config.json,android/app,ios/) isolated, which simplifies caching.
The GitHub Actions workflow
The following workflow runs on every push to main and on pull‑request updates. It builds, tests, and optionally releases when a tag is pushed.
name: CI/CD for Ionic + RN
on:
push:
branches: [main]
tags: [v*]
pull_request:
branches: [main]
jobs:
setup:
runs-on: ubuntu-latest
outputs:
cache-key: ${{ steps.cache.outputs.cache-key }}
steps:
- uses: actions/checkout@v4
- name: Cache node_modules
id: cache
uses: actions/cache@v3
with:
path: |
**/node_modules
~/.cache/Yarn
key: ${{ runner.os }}-yarn-${{ hashFiles('**/yarn.lock') }}
- name: Install dependencies
run: yarn install --frozen-lockfile
lint:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Restore cache
uses: actions/cache@v3
with:
path: |
**/node_modules
~/.cache/Yarn
key: ${{ needs.setup.outputs.cache-key }}
- name: Lint
run: yarn lint
test:
needs: lint
runs-on: ubuntu-latest
strategy:
matrix:
platform: [ionic, rn]
steps:
- uses: actions/checkout@v4
- name: Restore cache
uses: actions/cache@v3
with:
path: |
**/node_modules
~/.cache/Yarn
key: ${{ needs.setup.outputs.cache-key }}
- name: Run unit tests
run: |
if [ "${{ matrix.platform }}" = "ionic" ]; then
yarn workspace ionic test
else
yarn workspace rn test
fi
build:
needs: test
runs-on: macos-latest
strategy:
matrix:
platform: [ionic, rn]
steps:
- uses: actions/checkout@v4
- name: Restore cache
uses: actions/cache@v3
with:
path: |
**/node_modules
~/.cache/Yarn
key: ${{ needs.setup.outputs.cache-key }}
- name: Build ${{ matrix.platform }}
env:
NODE_OPTIONS: --max_old_space_size=4096
run: |
if [ "${{ matrix.platform }}" = "ionic" ]; then
cd packages/ionic && yarn build && npx cap copy ios && npx cap copy android
else
cd packages/rn && npx react-native bundle --platform ios --dev false --entry-file index.js --bundle-output ios/main.jsbundle && cd ios && pod install && xcodebuild -workspace MyApp.xcworkspace -scheme MyApp -configuration Release -derivedDataPath build
fi
- name: Upload artifact
uses: actions/upload-artifact@v3
with:
name: ${{ matrix.platform }}-build
path: |
packages/ionic/www/**
packages/rn/ios/build/**
packages/rn/android/app/build/outputs/apk/release/**
release:
if: startsWith(github.ref, 'refs/tags/v')
needs: build
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- name: Download artifacts
uses: actions/download-artifact@v3
with:
path: artifacts
- name: Set up Fastlane
run: |
gem install fastlane -NV
- name: Deploy iOS
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
APPLE_ID: ${{ secrets.APPLE_ID }}
FASTLANE_USER: ${{ secrets.APPLE_ID }}
FASTLANE_PASSWORD: ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
run: |
cd packages/rn/ios
fastlane ios release
- name: Deploy Android
env:
GOOGLE_PLAY_JSON_KEY: ${{ secrets.GOOGLE_PLAY_JSON_KEY }}
run: |
cd packages/rn/android
fastlane android release
Key points in the workflow
- Separate jobs for lint, test, and build – failures surface early, and each job can be retried independently.
- MacOS runners for iOS builds – required for Xcode and Fastlane.
- Caching node_modules dramatically reduces install time; the cache key includes the lockfile hash, so any dependency change invalidates it.
- Matrix strategy – one job runs the Ionic pipeline, another runs the React Native pipeline, keeping logs tidy.
Testing strategy that scales with the monorepo
A hybrid project has three testing layers:
- Unit tests – Run with Jest (Ionic) and Jest/React‑Native‑Testing‑Library (RN). Keep them fast (<1 s per suite) so the CI matrix can execute them in parallel.
- Component / UI tests – For Ionic, use Cypress with the
@cypress/reactplugin; for RN, use Detox. Both can run in headless mode on CI. - End‑to‑end device tests – Services like Firebase Test Lab or AWS Device Farm let you run the full app on a matrix of real devices. Trigger them only on
mainor when a release tag is created to keep PR feedback quick.
Example of a shared script (scripts/run-tests.js) that decides which suite to execute based on an environment variable:
// scripts/run-tests.js
const { execSync } = require('child_process');
const platform = process.env.PLATFORM;
if (platform === 'ionic') {
execSync('yarn workspace ionic test:unit', { stdio: 'inherit' });
execSync('yarn workspace ionic test:e2e', { stdio: 'inherit' });
} else if (platform === 'rn') {
execSync('yarn workspace rn test:unit', { stdio: 'inherit' });
execSync('yarn workspace rn test:detox', { stdio: 'inherit' });
} else {
console.error('Unknown PLATFORM');
process.exit(1);
}
In the CI file, you can call node scripts/run-tests.js after setting PLATFORM.
Automating versioning and code signing
Manual version bumps are a common source of human error. The standard-version package can generate a semver tag from conventional commit messages.
# Bump version and push tag (run on release branch only)
npm install -g standard-version
standard-version --release-as patch
git push --follow-tags origin main
For iOS, use Fastlane match to store certificates in a private Git repo. For Android, keep the google-play-service.json key in GitHub Secrets and let Fastlane decrypt it during the release job.
Deploying with Fastlane – a single source of truth
Fastlane lanes encapsulate the entire release flow. A minimal Fastfile for both platforms looks like this:
# fastlane/Fastfile
default_platform(:ios)
platform :ios do
desc "Push a new beta build to TestFlight"
lane :beta do
capture_screenshots
build_app(scheme: "MyApp", export_method: "app-store")
upload_to_testflight
end
desc "Release a new version to the App Store"
lane :release do
capture_screenshots
build_app(scheme: "MyApp", export_method: "app-store")
upload_to_app_store(skip_metadata: true, skip_screenshots: true)
end
end
platform :android do
desc "Deploy a new version to Google Play"
lane :release do
gradle(task: "assembleRelease")
upload_to_play_store(track: "production")
end
end
Running fastlane ios release or fastlane android release from the CI job completes the end‑to‑end flow: compile, sign, upload, and optionally notify Slack via a webhook.
Checklist for a production‑ready pipeline
- [ ] Monorepo caching – node_modules, CocoaPods, Gradle caches.
- [ ] Static analysis – ESLint for both packages, TypeScript strict mode.
- [ ] Dependency audit –
yarn auditas a separate job. - [ ] Secret management – All signing keys and API tokens stored in GitHub Secrets or a vault.
- [ ] Rollback plan – Keep the previous build artifact for quick hot‑fixes; Fastlane can also promote a prior build.
- [ ] Monitoring – Enable crash reporting (Sentry, Firebase Crashlytics) in the released binaries.
By wiring these pieces together, a startup can ship a new mobile version to both stores in under an hour, with confidence that the same code that passed CI is what users download.
Takeaway
Investing a few days to set up a robust CI/CD pipeline for a shared‑code Ionic + React Native monorepo pays off quickly. You gain repeatable builds, fast feedback on every pull request, and a zero‑touch release process that scales as your team grows. The template above is deliberately minimal—adapt it to your own tooling, but keep the core ideas of caching, parallel testing, automated versioning, and Fastlane‑driven deployment.
- Ionic
- React Native
- Mobile
- Devops
- Ci