Back to Journal
Mobile/Frontend

Complete Guide to Mobile CI/CD Pipelines with Typescript

A comprehensive guide to implementing Mobile CI/CD Pipelines using Typescript, covering architecture, code examples, and production-ready patterns.

Muneer Puthiya Purayil 14 min read

TypeScript powers the build tooling and automation layer of modern mobile CI/CD pipelines. Whether you're building scripts for Expo, configuring EAS Build, or writing custom Fastlane-adjacent tooling with Node.js, TypeScript provides type safety for the scripts that control your release process.

Build Configuration Management

typescript
1import { z } from "zod";
2import { readFile, writeFile } from "node:fs/promises";
3import { execSync } from "node:child_process";
4 
5const BuildConfigSchema = z.object({
6 ios: z.object({
7 bundleId: z.string(),
8 teamId: z.string(),
9 scheme: z.string(),
10 exportMethod: z.enum(["app-store", "ad-hoc", "enterprise", "development"]),
11 }),
12 android: z.object({
13 applicationId: z.string(),
14 versionCode: z.number().int().positive(),
15 minSdk: z.number().int(),
16 targetSdk: z.number().int(),
17 }),
18 environments: z.record(z.object({
19 apiUrl: z.string().url(),
20 analyticsKey: z.string().optional(),
21 featureFlags: z.record(z.boolean()).optional(),
22 })),
23});
24 
25type BuildConfig = z.infer<typeof BuildConfigSchema>;
26 
27async function loadBuildConfig(path: string): Promise<BuildConfig> {
28 const content = await readFile(path, "utf-8");
29 const raw = JSON.parse(content);
30 return BuildConfigSchema.parse(raw);
31}
32 
33async function setEnvironment(config: BuildConfig, env: string): Promise<void> {
34 const envConfig = config.environments[env];
35 if (!envConfig) {
36 throw new Error(`Environment '${env}' not found in build config`);
37 }
38 
39 const envFileContent = Object.entries(envConfig)
40 .filter(([, v]) => typeof v === "string")
41 .map(([k, v]) => `${camelToScreamingSnake(k)}=${v}`)
42 .join("\n");
43 
44 await writeFile(".env", envFileContent);
45 console.log(`Environment set to '${env}'`);
46}
47 
48function camelToScreamingSnake(str: string): string {
49 return str.replace(/[A-Z]/g, (letter) => `_${letter}`).toUpperCase();
50}
51 

Version Management

typescript
1import { readFile, writeFile } from "node:fs/promises";
2import { execSync } from "node:child_process";
3 
4interface VersionInfo {
5 version: string;
6 buildNumber: number;
7 gitSha: string;
8 branch: string;
9}
10 
11async function bumpVersion(
12 type: "major" | "minor" | "patch",
13): Promise<VersionInfo> {
14 const packageJson = JSON.parse(await readFile("package.json", "utf-8"));
15 const [major, minor, patch] = packageJson.version.split(".").map(Number);
16 
17 const newVersion = type === "major"
18 ? `${major + 1}.0.0`
19 : type === "minor"
20 ? `${major}.${minor + 1}.0`
21 : `${major}.${minor}.${patch + 1}`;
22 
23 packageJson.version = newVersion;
24 await writeFile("package.json", JSON.stringify(packageJson, null, 2) + "\n");
25 
26 const gitSha = execSync("git rev-parse --short HEAD").toString().trim();
27 const branch = execSync("git branch --show-current").toString().trim();
28 const buildNumber = parseInt(
29 execSync("git rev-list --count HEAD").toString().trim(),
30 );
31 
32 // Update iOS
33 await updateInfoPlist(newVersion, buildNumber);
34 
35 // Update Android
36 await updateBuildGradle(newVersion, buildNumber);
37 
38 return { version: newVersion, buildNumber, gitSha, branch };
39}
40 
41async function updateInfoPlist(version: string, build: number): Promise<void> {
42 execSync(
43 `/usr/libexec/PlistBuddy -c "Set :CFBundleShortVersionString ${version}" ios/App/Info.plist`,
44 );
45 execSync(
46 `/usr/libexec/PlistBuddy -c "Set :CFBundleVersion ${build}" ios/App/Info.plist`,
47 );
48}
49 
50async function updateBuildGradle(version: string, build: number): Promise<void> {
51 let gradle = await readFile("android/app/build.gradle", "utf-8");
52 gradle = gradle.replace(/versionCode \d+/, `versionCode ${build}`);
53 gradle = gradle.replace(/versionName "[^"]*"/, `versionName "${version}"`);
54 await writeFile("android/app/build.gradle", gradle);
55}
56 

EAS Build Configuration

typescript
1// eas.config.ts - Type-safe EAS Build configuration
2import type { ExpoConfig } from "@expo/config";
3 
4interface EASBuildProfile {
5 distribution: "store" | "internal";
6 channel: string;
7 ios: {
8 simulator?: boolean;
9 buildConfiguration: string;
10 resourceClass: "m-medium" | "m-large" | "m1-medium";
11 };
12 android: {
13 buildType: "apk" | "app-bundle";
14 gradleCommand: string;
15 };
16 env: Record<string, string>;
17}
18 
19const profiles: Record<string, EASBuildProfile> = {
20 development: {
21 distribution: "internal",
22 channel: "development",
23 ios: {
24 simulator: true,
25 buildConfiguration: "Debug",
26 resourceClass: "m-medium",
27 },
28 android: {
29 buildType: "apk",
30 gradleCommand: ":app:assembleDebug",
31 },
32 env: {
33 APP_ENV: "development",
34 API_URL: "https://api-dev.example.com",
35 },
36 },
37 preview: {
38 distribution: "internal",
39 channel: "preview",
40 ios: {
41 buildConfiguration: "Release",
42 resourceClass: "m1-medium",
43 },
44 android: {
45 buildType: "apk",
46 gradleCommand: ":app:assembleRelease",
47 },
48 env: {
49 APP_ENV: "staging",
50 API_URL: "https://api-staging.example.com",
51 },
52 },
53 production: {
54 distribution: "store",
55 channel: "production",
56 ios: {
57 buildConfiguration: "Release",
58 resourceClass: "m1-medium",
59 },
60 android: {
61 buildType: "app-bundle",
62 gradleCommand: ":app:bundleRelease",
63 },
64 env: {
65 APP_ENV: "production",
66 API_URL: "https://api.example.com",
67 },
68 },
69};
70 

Need a second opinion on your mobile/frontend architecture?

I run free 30-minute strategy calls for engineering teams tackling this exact problem.

Book a Free Call

Automated Testing Pipeline

typescript
1import { execSync, ExecSyncOptionsWithStringEncoding } from "node:child_process";
2 
3interface TestResult {
4 suite: string;
5 passed: number;
6 failed: number;
7 skipped: number;
8 duration: number;
9}
10 
11const execOptions: ExecSyncOptionsWithStringEncoding = {
12 encoding: "utf-8",
13 stdio: ["pipe", "pipe", "pipe"],
14};
15 
16async function runTestSuite(): Promise<TestResult[]> {
17 const results: TestResult[] = [];
18 
19 // Unit tests
20 console.log("Running unit tests...");
21 const unitStart = Date.now();
22 try {
23 execSync("npx jest --ci --json --outputFile=test-results/unit.json", execOptions);
24 const unitResults = JSON.parse(
25 await readFile("test-results/unit.json", "utf-8"),
26 );
27 results.push({
28 suite: "unit",
29 passed: unitResults.numPassedTests,
30 failed: unitResults.numFailedTests,
31 skipped: unitResults.numPendingTests,
32 duration: Date.now() - unitStart,
33 });
34 } catch (error) {
35 results.push({ suite: "unit", passed: 0, failed: 1, skipped: 0, duration: Date.now() - unitStart });
36 }
37 
38 // Integration tests
39 console.log("Running integration tests...");
40 const integStart = Date.now();
41 try {
42 execSync("npx jest --ci --config jest.integration.config.js --json --outputFile=test-results/integration.json", execOptions);
43 const integResults = JSON.parse(
44 await readFile("test-results/integration.json", "utf-8"),
45 );
46 results.push({
47 suite: "integration",
48 passed: integResults.numPassedTests,
49 failed: integResults.numFailedTests,
50 skipped: integResults.numPendingTests,
51 duration: Date.now() - integStart,
52 });
53 } catch (error) {
54 results.push({ suite: "integration", passed: 0, failed: 1, skipped: 0, duration: Date.now() - integStart });
55 }
56 
57 // TypeScript type checking
58 console.log("Running type check...");
59 const typeStart = Date.now();
60 try {
61 execSync("npx tsc --noEmit", execOptions);
62 results.push({ suite: "typecheck", passed: 1, failed: 0, skipped: 0, duration: Date.now() - typeStart });
63 } catch {
64 results.push({ suite: "typecheck", passed: 0, failed: 1, skipped: 0, duration: Date.now() - typeStart });
65 }
66 
67 return results;
68}
69 
70function generateReport(results: TestResult[]): string {
71 const totalPassed = results.reduce((sum, r) => sum + r.passed, 0);
72 const totalFailed = results.reduce((sum, r) => sum + r.failed, 0);
73 const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
74 
75 let report = `## Test Results\n\n`;
76 report += `| Suite | Passed | Failed | Duration |\n`;
77 report += `|-------|--------|--------|----------|\n`;
78 
79 for (const r of results) {
80 const status = r.failed > 0 ? "❌" : "✅";
81 report += `| ${status} ${r.suite} | ${r.passed} | ${r.failed} | ${(r.duration / 1000).toFixed(1)}s |\n`;
82 }
83 
84 report += `\n**Total: ${totalPassed} passed, ${totalFailed} failed in ${(totalDuration / 1000).toFixed(1)}s**\n`;
85 
86 return report;
87}
88 

Release Automation

typescript
1import { Octokit } from "@octokit/rest";
2 
3interface ReleaseConfig {
4 owner: string;
5 repo: string;
6 version: string;
7 buildNumber: number;
8 changelog: string;
9 assets: Array<{ name: string; path: string }>;
10}
11 
12class ReleaseManager {
13 private octokit: Octokit;
14 
15 constructor(token: string) {
16 this.octokit = new Octokit({ auth: token });
17 }
18 
19 async createRelease(config: ReleaseConfig): Promise<string> {
20 const tagName = `v${config.version}-${config.buildNumber}`;
21 
22 const release = await this.octokit.repos.createRelease({
23 owner: config.owner,
24 repo: config.repo,
25 tag_name: tagName,
26 name: `${config.version} (${config.buildNumber})`,
27 body: config.changelog,
28 draft: true,
29 });
30 
31 for (const asset of config.assets) {
32 const content = await readFile(asset.path);
33 await this.octokit.repos.uploadReleaseAsset({
34 owner: config.owner,
35 repo: config.repo,
36 release_id: release.data.id,
37 name: asset.name,
38 data: content as unknown as string,
39 });
40 }
41 
42 return release.data.html_url;
43 }
44 
45 async generateChangelog(
46 owner: string,
47 repo: string,
48 since: string,
49 ): Promise<string> {
50 const commits = await this.octokit.repos.listCommits({
51 owner,
52 repo,
53 since,
54 per_page: 100,
55 });
56 
57 const categories: Record<string, string[]> = {
58 features: [],
59 fixes: [],
60 other: [],
61 };
62 
63 for (const commit of commits.data) {
64 const message = commit.commit.message.split("\n")[0];
65 if (message.startsWith("feat")) {
66 categories.features.push(message);
67 } else if (message.startsWith("fix")) {
68 categories.fixes.push(message);
69 } else {
70 categories.other.push(message);
71 }
72 }
73 
74 let changelog = "";
75 if (categories.features.length) {
76 changelog += "### Features\n" + categories.features.map((m) => `- ${m}`).join("\n") + "\n\n";
77 }
78 if (categories.fixes.length) {
79 changelog += "### Bug Fixes\n" + categories.fixes.map((m) => `- ${m}`).join("\n") + "\n\n";
80 }
81 
82 return changelog;
83 }
84}
85 

Conclusion

TypeScript brings type safety and tooling familiarity to mobile CI/CD automation. Build configuration schemas validated with Zod, type-safe EAS profiles, and automated version management eliminate the class of bugs where a misconfigured build silently produces the wrong output. For teams already writing their mobile app in TypeScript (React Native, Expo), extending that to the build pipeline creates a unified developer experience.

The practical impact is fewer "the build broke and nobody knows why" incidents. When your build configuration is validated by the type system and your version management is automated, the surface area for human error shrinks to near zero.

FAQ

Need expert help?

Building with mobile/frontend?

I help teams ship production-grade systems. From architecture review to hands-on builds.

Muneer Puthiya Purayil

SaaS Architect & AI Systems Engineer. 10+ years shipping production infrastructure across fintech, automotive, e-commerce, and healthcare.

Engage

Start a
Conversation.

For teams building at scale: SaaS platforms, agentic AI systems, and enterprise mobile infrastructure. Scope and fit are evaluated before any engagement begins.

Limited availability · Q3 / Q4 2026