ersionManager {
private configPath: string;
private config: VersionConfig;
constructor(projectRoot: string) {
this.configPath = join(projectRoot, '.distribution-version.json');
this.config = this.loadOrCreate();
}
private loadOrCreate(): VersionConfig {
if (existsSync(this.configPath)) {
return JSON.parse(readFileSync(this.configPath, 'utf-8'));
}
const initial: VersionConfig = { major: 1, minor: 0, patch: 0, build: 1 };
writeFileSync(this.configPath, JSON.stringify(initial, null, 2));
return initial;
}
bump(type: 'major' | 'minor' | 'patch'): void {
this.config[type]++;
if (type !== 'patch') this.config.patch = 0;
if (type === 'major') this.config.minor = 0;
this.config.build++;
writeFileSync(this.configPath, JSON.stringify(this.config, null, 2));
}
get versionString(): string {
return ${this.config.major}.${this.config.minor}.${this.config.patch};
}
get buildNumber(): number {
return this.config.build;
}
}
### Step 2: Automate Code Signing
Manual certificate management is the single largest source of distribution failures. Use platform APIs to fetch and rotate provisioning profiles automatically.
For iOS, leverage the App Store Connect API with JWT authentication. For Android, use the Google Play Developer API with service account keys. Store secrets in a vault (GitHub Secrets, AWS Secrets Manager, or 1Password Service Accounts).
```typescript
// src/distribution/signing-manager.ts
import { execSync } from 'child_process';
import * as fs from 'fs';
export class SigningManager {
static async downloadIosProfile(teamId: string, bundleId: string, profileType: string): Promise<void> {
const cmd = `fastlane match development --readonly --team_id ${teamId} --app_identifier ${bundleId} --type ${profileType}`;
execSync(cmd, { stdio: 'inherit' });
}
static async validateAndroidKeystore(keystorePath: string, alias: string): Promise<boolean> {
try {
execSync(`keytool -list -keystore ${keystorePath} -alias ${alias} -storepass $ANDROID_KEYSTORE_PASSWORD`, { stdio: 'pipe' });
return true;
} catch {
return false;
}
}
}
Step 3: Orchestrate the Pipeline
GitHub Actions provides deterministic execution environments. The pipeline should separate build, sign, distribute, and validate phases.
# .github/workflows/distribute.yml
name: Mobile Distribution Pipeline
on:
push:
tags: ['v*']
workflow_dispatch:
inputs:
environment:
type: choice
options: [internal, beta, production]
jobs:
build-and-sign:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with: { node-version: 20 }
- name: Install dependencies
run: npm ci
- name: Bump version & generate metadata
run: npx ts-node src/distribution/version-manager.ts --bump ${{ github.event.inputs.environment == 'production' ? 'minor' : 'patch' }}
- name: Download signing assets
env:
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
FASTLANE_USER: ${{ secrets.APPLE_DEV_PORTAL_USER }}
run: fastlane match download_all
- name: Build iOS
run: fastlane ios build
- name: Upload to TestFlight
run: fastlane ios beta
- name: Build Android
run: fastlane android build
- name: Upload to Play Internal
run: fastlane android internal
validate-distribution:
needs: build-and-sign
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run bundle analysis
run: npx ts-node src/distribution/bundle-validator.ts
- name: Publish OTA update metadata
run: npx ts-node src/distribution/ota-publisher.ts --env ${{ github.event.inputs.environment }}
Step 4: Integrate OTA Fallback
Store review cycles block critical fixes. An OTA framework (CodePush, Expo Updates, or Capacitor Live Updates) enables runtime patching without store submission. Architecture decision: restrict OTA to JavaScript/TypeScript assets and configuration files. Never distribute native binaries or security-critical logic via OTA.
// src/distribution/ota-publisher.ts
import { execSync } from 'child_process';
import { VersionManager } from './version-manager';
interface OTAPayload {
label: string;
rollout: number;
mandatory: boolean;
}
export async function publishOTAUpdate(env: string, payload: OTAPayload): Promise<void> {
const version = new VersionManager(process.cwd()).versionString;
const cmd = `npx expo publish --release-channel ${env} --message "v${version} distribution patch" --rollout-percentage ${payload.rollout} --private`;
execSync(cmd, { stdio: 'inherit' });
console.log(`[OTA] Published ${payload.label} to ${env} (${payload.rollout}% rollout)`);
}
Architecture Rationale
- Decoupled phases: Build, sign, and distribute run as isolated jobs. Failure in one doesn't corrupt artifacts from another.
- Secret isolation: Signing credentials never touch the repository. They're injected at runtime via vault-integrated CI variables.
- Reversible distribution: OTA fallback + feature flags enable percentage-based rollout and instant rollback without app store intervention.
- Auditability: Version manager writes deterministic state to
.distribution-version.json. Every release produces a traceable artifact manifest.
Pitfall Guide
1. Hardcoding Certificates in CI/CD
Mistake: Storing .p12 or .jks files in version control or passing passwords as plaintext environment variables.
Impact: Credential leakage, unauthorized builds, immediate store rejection if keys are rotated externally.
Best Practice: Use a secret manager with short-lived tokens. Rotate certificates quarterly. Validate checksums of downloaded profiles before build execution.
Mistake: Distributing native code changes, permission updates, or entitlement modifications via OTA.
Impact: App Store/Play Store policy violations, app crashes on mismatched native/JS bridges, user trust erosion.
Best Practice: Restrict OTA to framework-level assets, API endpoints, and feature flags. Maintain a native binary release cadence of 2β4 weeks regardless of OTA capability.
Mistake: Using different versioning schemes for iOS and Android, causing confusion in analytics, crash reporting, and user support.
Impact: Duplicate bug reports, impossible rollback tracing, failed A/B test segmentation.
Best Practice: Enforce a single source of truth for version metadata. Sync CFBundleShortVersionString, versionName, and internal build IDs through a shared TypeScript configuration.
4. Skipping Bundle Size Validation Before Distribution
Mistake: Uploading bloated binaries without analyzing asset duplication, unoptimized images, or unnecessary native libraries.
Impact: Store rejection for exceeding size limits, poor download conversion, increased CDN costs for OTA payloads.
Best Practice: Integrate bundle analysis into the pipeline. Fail builds if iOS exceeds 200MB (Wi-Fi only) or Android APK exceeds 150MB. Use bundletool and xcrun altool for pre-flight validation.
5. Assuming Rollback Means Re-uploading the Same Version
Mistake: Trying to push a previously rejected or broken build without incrementing the build number.
Impact: Store API rejection, metadata conflicts, extended downtime.
Best Practice: Treat every distribution attempt as immutable. Increment build numbers automatically. Use OTA for runtime fixes; use store uploads only for native changes.
Mistake: Pushing localized metadata changes directly to production without staging review.
Impact: Incorrect store listings, compliance violations, user confusion.
Best Practice: Route metadata changes through a draft channel. Require manual approval for production pushes. Validate localization strings against store guidelines before sync.
7. Missing Gradual Rollout Configuration
Mistake: Releasing to 100% of users immediately after approval.
Impact: Amplified crash reports, support ticket spikes, impossible isolation of regression sources.
Best Practice: Default to 10% rollout for production. Monitor crash-free sessions, ANR rates, and network latency. Auto-promote to 50% after 24 hours of stability.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Startup MVP | Automated CI/CD + TestFlight/Play Internal | Fast iteration, low infrastructure overhead, direct user feedback loop | Low setup cost, minimal cloud spend |
| Enterprise Internal | MDM + Enterprise Certificates + Private Repo | Bypasses store review, enables device management, complies with internal security | Medium cost (MDM licenses, certificate management) |
| Consumer App | Automated CI/CD + OTA Fallback + Gradual Rollout | Balances store compliance with rapid patching, minimizes user-facing downtime | Medium setup, low ongoing operational cost |
| High-Security/Finance | Air-gapped build environment + Hardware-backed signing + Manual release gates | Meets regulatory compliance, prevents supply chain attacks, ensures auditability | High cost (HSM, dedicated CI runners, compliance overhead) |
Configuration Template
# .github/workflows/mobile-distribution.yml
name: Mobile Distribution Pipeline
on:
push:
branches: [main]
paths: ['packages/mobile/**']
workflow_dispatch:
env:
NODE_VERSION: 20
JAVA_VERSION: 17
jobs:
setup:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: ${{ env.NODE_VERSION }}, cache: 'npm' }
- uses: actions/setup-java@v4
with: { distribution: 'temurin', java-version: ${{ env.JAVA_VERSION }}, cache: 'gradle' }
- run: npm ci
- name: Generate distribution manifest
run: npx ts-node scripts/distribution/manifest.ts
ios-distribution:
needs: setup
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- name: Sync provisioning
env:
MATCH_GIT_URL: ${{ secrets.MATCH_REPO }}
MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }}
run: fastlane match development --readonly
- name: Build & Upload
run: fastlane ios distribute --env beta
- name: Validate Bundle
run: xcrun altool --validate-app -f app.ipa -t ios -u ${{ secrets.APPLE_ID }} -p ${{ secrets.APPLE_APP_SPECIFIC_PASSWORD }}
android-distribution:
needs: setup
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Decode Keystore
run: echo "${{ secrets.ANDROID_KEYSTORE_B64 }}" | base64 -d > keystore.jks
- name: Build & Upload
env:
ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }}
ANDROID_KEY_ALIAS: ${{ secrets.ANDROID_KEY_ALIAS }}
PLAY_SERVICE_ACCOUNT_JSON: ${{ secrets.PLAY_SERVICE_ACCOUNT }}
run: fastlane android distribute --env internal
- name: Analyze APK
run: bundletool analyze-apks --apks app.apks
ota-publish:
needs: [ios-distribution, android-distribution]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Publish Runtime Update
run: npx expo publish --release-channel ${{ github.ref_name }} --rollout-percentage 10
Quick Start Guide
- Initialize version control: Run
npx ts-node scripts/distribution/version-manager.ts --init in your project root. This creates .distribution-version.json and sets baseline marketing/build numbers.
- Configure CI secrets: Add
MATCH_GIT_URL, MATCH_PASSWORD, APPLE_ID, APPLE_APP_SPECIFIC_PASSWORD, ANDROID_KEYSTORE_B64, ANDROID_KEYSTORE_PASSWORD, and PLAY_SERVICE_ACCOUNT to your repository's encrypted secrets.
- Generate fastlane configuration: Execute
fastlane init in both ios/ and android/ directories. Point lane files to the shared TypeScript distribution scripts.
- Trigger first pipeline: Push a commit or dispatch the workflow manually. The pipeline will validate certificates, build binaries, upload to beta channels, and publish a 10% OTA rollout. Monitor the dashboard for crash-free session metrics before promoting to production.