From 636ec7332958d1e66b1392b6ab633ec74e514d0f Mon Sep 17 00:00:00 2001 From: Kadi Kraman Date: Thu, 12 Mar 2026 15:02:53 +0000 Subject: [PATCH 1/8] Add function to report resolved version --- .../build-tools/src/steps/easFunctions.ts | 2 + .../src/steps/functionGroups/build.ts | 13 + .../src/steps/functions/readIpaInfo.ts | 2 +- .../steps/functions/reportResolvedVersion.ts | 241 ++++++++++++++++++ 4 files changed, 257 insertions(+), 1 deletion(-) create mode 100644 packages/build-tools/src/steps/functions/reportResolvedVersion.ts diff --git a/packages/build-tools/src/steps/easFunctions.ts b/packages/build-tools/src/steps/easFunctions.ts index 1265340a5a..ab4df5593a 100644 --- a/packages/build-tools/src/steps/easFunctions.ts +++ b/packages/build-tools/src/steps/easFunctions.ts @@ -22,6 +22,7 @@ import { createPrebuildBuildFunction } from './functions/prebuild'; import { createReadIpaInfoBuildFunction } from './functions/readIpaInfo'; import { createRepackBuildFunction } from './functions/repack'; import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults'; +import { createReportResolvedVersionBuildFunction } from './functions/reportResolvedVersion'; import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials'; import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig'; import { @@ -90,6 +91,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] { functions.push( ...[ createFindAndUploadBuildArtifactsBuildFunction(ctx), + createReportResolvedVersionBuildFunction(ctx), createResolveBuildConfigBuildFunction(ctx), createGetCredentialsForBuildTriggeredByGithubIntegration(ctx), ] diff --git a/packages/build-tools/src/steps/functionGroups/build.ts b/packages/build-tools/src/steps/functionGroups/build.ts index 01a18e62c3..3c53ceeba9 100644 --- a/packages/build-tools/src/steps/functionGroups/build.ts +++ b/packages/build-tools/src/steps/functionGroups/build.ts @@ -11,6 +11,7 @@ import { configureIosCredentialsFunction } from '../functions/configureIosCreden import { configureIosVersionFunction } from '../functions/configureIosVersion'; import { eagerBundleBuildFunction } from '../functions/eagerBundle'; import { createFindAndUploadBuildArtifactsBuildFunction } from '../functions/findAndUploadBuildArtifacts'; +import { createReportResolvedVersionBuildFunction } from '../functions/reportResolvedVersion'; import { generateGymfileFromTemplateFunction } from '../functions/generateGymfileFromTemplate'; import { injectAndroidCredentialsFunction } from '../functions/injectAndroidCredentials'; import { createInstallNodeModulesBuildFunction } from '../functions/installNodeModules'; @@ -122,6 +123,9 @@ function createStepsForIosSimulatorBuild({ createFindAndUploadBuildArtifactsBuildFunction( buildToolsContext ).createBuildStepFromFunctionCall(globalCtx), + createReportResolvedVersionBuildFunction( + buildToolsContext + ).createBuildStepFromFunctionCall(globalCtx), ]; } @@ -216,6 +220,9 @@ function createStepsForIosBuildWithCredentials({ createFindAndUploadBuildArtifactsBuildFunction( buildToolsContext ).createBuildStepFromFunctionCall(globalCtx), + createReportResolvedVersionBuildFunction( + buildToolsContext + ).createBuildStepFromFunctionCall(globalCtx), saveCache, createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx), ]; @@ -287,6 +294,9 @@ function createStepsForAndroidBuildWithoutCredentials({ createFindAndUploadBuildArtifactsBuildFunction( buildToolsContext ).createBuildStepFromFunctionCall(globalCtx), + createReportResolvedVersionBuildFunction( + buildToolsContext + ).createBuildStepFromFunctionCall(globalCtx), saveCache, createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx), ]; @@ -360,6 +370,9 @@ function createStepsForAndroidBuildWithCredentials({ createFindAndUploadBuildArtifactsBuildFunction( buildToolsContext ).createBuildStepFromFunctionCall(globalCtx), + createReportResolvedVersionBuildFunction( + buildToolsContext + ).createBuildStepFromFunctionCall(globalCtx), saveCache, createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx), ]; diff --git a/packages/build-tools/src/steps/functions/readIpaInfo.ts b/packages/build-tools/src/steps/functions/readIpaInfo.ts index e5e21ab17e..a697908de4 100644 --- a/packages/build-tools/src/steps/functions/readIpaInfo.ts +++ b/packages/build-tools/src/steps/functions/readIpaInfo.ts @@ -109,7 +109,7 @@ export async function readIpaInfoAsync(ipaPath: string): Promise { } } -function parseInfoPlistBuffer(data: Buffer): Record { +export function parseInfoPlistBuffer(data: Buffer): Record { const isBinaryPlist = data.subarray(0, 8).toString('ascii') === 'bplist00'; if (isBinaryPlist) { const parsedBinaryPlists = bplistParser.parseBuffer(data); diff --git a/packages/build-tools/src/steps/functions/reportResolvedVersion.ts b/packages/build-tools/src/steps/functions/reportResolvedVersion.ts new file mode 100644 index 0000000000..4f60372ef7 --- /dev/null +++ b/packages/build-tools/src/steps/functions/reportResolvedVersion.ts @@ -0,0 +1,241 @@ +import { Android, BuildJob, Ios, Platform } from '@expo/eas-build-job'; +import { BuildFunction, spawnAsync } from '@expo/steps'; +import fs from 'fs-extra'; +import { graphql } from 'gql.tada'; +import path from 'node:path'; + +import { parseInfoPlistBuffer, readIpaInfoAsync } from './readIpaInfo'; +import { CustomBuildContext } from '../../customBuildContext'; +import { findArtifacts } from '../../utils/artifacts'; + +export function createReportResolvedVersionBuildFunction( + ctx: CustomBuildContext +): BuildFunction { + return new BuildFunction({ + namespace: 'eas', + id: 'report_resolved_version', + name: 'Report resolved version', + __metricsId: 'eas/report_resolved_version', + fn: async (stepCtx) => { + try { + const { appVersion, appBuildVersion } = + ctx.job.platform === Platform.IOS + ? await extractIosVersionAsync(ctx, stepCtx.workingDirectory, stepCtx.logger) + : await extractAndroidVersionAsync(ctx, stepCtx.workingDirectory, stepCtx.logger); + + if (!appVersion && !appBuildVersion) { + stepCtx.logger.info('No resolved version found, skipping.'); + return; + } + + stepCtx.logger.info( + `Resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})` + ); + + const buildId = ctx.env.EAS_BUILD_ID; + if (!buildId) { + stepCtx.logger.warn('EAS_BUILD_ID not set, cannot report resolved version.'); + return; + } + + await reportResolvedVersionAsync(ctx, buildId, { + appVersion, + appBuildVersion, + }); + + stepCtx.logger.info('Reported resolved version to EAS.'); + } catch (err) { + stepCtx.logger.warn('Failed to report resolved version (non-fatal):', err); + } + }, + }); +} + +async function extractIosVersionAsync( + ctx: CustomBuildContext, + workingDirectory: string, + logger: any +): Promise<{ appVersion?: string; appBuildVersion?: string }> { + const iosJob = ctx.job as Ios.Job; + + if (iosJob.simulator) { + return await extractSimulatorAppVersionAsync(iosJob, workingDirectory, logger); + } + + const artifactPattern = iosJob.applicationArchivePath ?? 'ios/build/*.ipa'; + const artifacts = await findArtifacts({ + rootDir: workingDirectory, + patternOrPath: artifactPattern, + logger, + }); + + if (artifacts.length === 0) { + return {}; + } + + const ipaPath = artifacts[0]; + const ipaInfo = await readIpaInfoAsync(ipaPath); + + return { + appVersion: ipaInfo.bundleShortVersion, + appBuildVersion: ipaInfo.bundleVersion, + }; +} + +async function extractSimulatorAppVersionAsync( + iosJob: Ios.Job, + workingDirectory: string, + logger: any +): Promise<{ appVersion?: string; appBuildVersion?: string }> { + const artifactPattern = + iosJob.applicationArchivePath ?? 'ios/build/Build/Products/*simulator/*.app'; + const artifacts = await findArtifacts({ + rootDir: workingDirectory, + patternOrPath: artifactPattern, + logger, + }); + + if (artifacts.length === 0) { + return {}; + } + + const appPath = artifacts[0]; + const infoPlistPath = path.join(appPath, 'Info.plist'); + + if (!(await fs.pathExists(infoPlistPath))) { + return {}; + } + + const infoPlistBuffer = await fs.readFile(infoPlistPath); + const infoPlist = parseInfoPlistBuffer(infoPlistBuffer); + + return { + appVersion: typeof infoPlist.CFBundleShortVersionString === 'string' + ? infoPlist.CFBundleShortVersionString + : undefined, + appBuildVersion: typeof infoPlist.CFBundleVersion === 'string' + ? infoPlist.CFBundleVersion + : undefined, + }; +} + +async function extractAndroidVersionAsync( + ctx: CustomBuildContext, + workingDirectory: string, + logger: any +): Promise<{ appVersion?: string; appBuildVersion?: string }> { + const androidJob = ctx.job as Android.Job; + const artifactPattern = + androidJob.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}'; + const artifacts = await findArtifacts({ + rootDir: workingDirectory, + patternOrPath: artifactPattern, + logger, + }); + + if (artifacts.length === 0) { + return {}; + } + + const artifactPath = artifacts[0]; + const ext = path.extname(artifactPath).toLowerCase(); + + if (ext === '.apk') { + return await extractVersionFromApkAsync(artifactPath); + } else if (ext === '.aab') { + return await extractVersionFromAabAsync(artifactPath); + } + + return {}; +} + +async function extractVersionFromApkAsync( + apkPath: string +): Promise<{ appVersion?: string; appBuildVersion?: string }> { + const result = await spawnAsync('aapt2', ['dump', 'badging', apkPath], { + stdio: 'pipe', + }); + + return parseAaptOutput(result.stdout); +} + +async function extractVersionFromAabAsync( + aabPath: string +): Promise<{ appVersion?: string; appBuildVersion?: string }> { + const result = await spawnAsync( + 'bundletool', + ['dump', 'manifest', '--bundle', aabPath], + { stdio: 'pipe' } + ); + + return parseManifestXml(result.stdout); +} + +function parseAaptOutput( + output: string +): { appVersion?: string; appBuildVersion?: string } { + const versionNameMatch = output.match(/versionName='([^']+)'/); + const versionCodeMatch = output.match(/versionCode='([^']+)'/); + + return { + appVersion: versionNameMatch?.[1], + appBuildVersion: versionCodeMatch?.[1], + }; +} + +function parseManifestXml( + xml: string +): { appVersion?: string; appBuildVersion?: string } { + const versionNameMatch = xml.match(/android:versionName="([^"]+)"/); + const versionCodeMatch = xml.match(/android:versionCode="([^"]+)"/); + + return { + appVersion: versionNameMatch?.[1], + appBuildVersion: versionCodeMatch?.[1], + }; +} + +async function reportResolvedVersionAsync( + ctx: CustomBuildContext, + buildId: string, + { + appVersion, + appBuildVersion, + }: { + appVersion?: string; + appBuildVersion?: string; + } +): Promise { + const result = await ctx.graphqlClient + .mutation( + graphql(` + mutation ReportResolvedVersionMutation( + $buildId: ID! + $appVersion: String + $appBuildVersion: String + ) { + build { + updateBuildMetadata( + buildId: $buildId + metadata: { + appVersion: $appVersion + appBuildVersion: $appBuildVersion + } + ) { + id + } + } + } + `), + { + buildId, + appVersion: appVersion ?? null, + appBuildVersion: appBuildVersion ?? null, + } + ) + .toPromise(); + + if (result.error) { + throw result.error; + } +} From 132e8bfcf1b7ac8a63af4165f9e18e0ebc815e6f Mon Sep 17 00:00:00 2001 From: Kadi Kraman Date: Thu, 12 Mar 2026 16:08:59 +0000 Subject: [PATCH 2/8] Add tests for the parser functions --- .../__tests__/reportResolvedVersion.test.ts | 107 ++++++++++++++++++ .../steps/functions/reportResolvedVersion.ts | 4 +- 2 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 packages/build-tools/src/steps/functions/__tests__/reportResolvedVersion.test.ts diff --git a/packages/build-tools/src/steps/functions/__tests__/reportResolvedVersion.test.ts b/packages/build-tools/src/steps/functions/__tests__/reportResolvedVersion.test.ts new file mode 100644 index 0000000000..84fd1242a2 --- /dev/null +++ b/packages/build-tools/src/steps/functions/__tests__/reportResolvedVersion.test.ts @@ -0,0 +1,107 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +jest.unmock('fs'); +jest.unmock('node:fs'); +jest.unmock('fs/promises'); +jest.unmock('node:fs/promises'); + +import { parseInfoPlistBuffer } from '../readIpaInfo'; +import { parseAaptOutput, parseManifestXml } from '../reportResolvedVersion'; + +describe(parseAaptOutput, () => { + it('extracts versionName and versionCode from aapt2 output', () => { + const output = `package: name='com.example.app' versionCode='42' versionName='2.5.0' platformBuildVersionName='14' platformBuildVersionCode='34' compileSdkVersion='34' compileSdkVersionCodename='14' +sdkVersion:'21' +targetSdkVersion:'34' +uses-permission: name='android.permission.INTERNET'`; + + expect(parseAaptOutput(output)).toEqual({ + appVersion: '2.5.0', + appBuildVersion: '42', + }); + }); + + it('handles output with no version info', () => { + expect(parseAaptOutput('some unrelated output')).toEqual({ + appVersion: undefined, + appBuildVersion: undefined, + }); + }); + + it('handles versionName with prerelease suffix', () => { + const output = `package: name='com.example' versionCode='1' versionName='1.0.0-beta.1'`; + + expect(parseAaptOutput(output)).toEqual({ + appVersion: '1.0.0-beta.1', + appBuildVersion: '1', + }); + }); +}); + +describe(parseManifestXml, () => { + it('extracts versionName and versionCode from bundletool manifest XML', () => { + const xml = ` + + +`; + + expect(parseManifestXml(xml)).toEqual({ + appVersion: '3.1.0', + appBuildVersion: '10', + }); + }); + + it('handles XML with no version attributes', () => { + const xml = ` + +`; + + expect(parseManifestXml(xml)).toEqual({ + appVersion: undefined, + appBuildVersion: undefined, + }); + }); +}); + +describe('simulator .app Info.plist parsing', () => { + const FIXTURES_DIR = path.join(__dirname, 'fixtures'); + const SIMULATOR_APP_DIR = path.join(FIXTURES_DIR, 'TestSimulator.app'); + const INFO_PLIST_PATH = path.join(SIMULATOR_APP_DIR, 'Info.plist'); + + beforeAll(async () => { + // Create a minimal XML Info.plist fixture + await fs.promises.mkdir(SIMULATOR_APP_DIR, { recursive: true }); + await fs.promises.writeFile( + INFO_PLIST_PATH, + ` + + + + CFBundleIdentifier + com.example.test + CFBundleShortVersionString + 4.2.0 + CFBundleVersion + 99 + +` + ); + }); + + afterAll(async () => { + await fs.promises.rm(SIMULATOR_APP_DIR, { recursive: true, force: true }); + }); + + it('reads version from a .app Info.plist', async () => { + const buffer = await fs.promises.readFile(INFO_PLIST_PATH); + const infoPlist = parseInfoPlistBuffer(buffer); + + expect(infoPlist.CFBundleShortVersionString).toBe('4.2.0'); + expect(infoPlist.CFBundleVersion).toBe('99'); + }); +}); diff --git a/packages/build-tools/src/steps/functions/reportResolvedVersion.ts b/packages/build-tools/src/steps/functions/reportResolvedVersion.ts index 4f60372ef7..1fe8234338 100644 --- a/packages/build-tools/src/steps/functions/reportResolvedVersion.ts +++ b/packages/build-tools/src/steps/functions/reportResolvedVersion.ts @@ -171,7 +171,7 @@ async function extractVersionFromAabAsync( return parseManifestXml(result.stdout); } -function parseAaptOutput( +export function parseAaptOutput( output: string ): { appVersion?: string; appBuildVersion?: string } { const versionNameMatch = output.match(/versionName='([^']+)'/); @@ -183,7 +183,7 @@ function parseAaptOutput( }; } -function parseManifestXml( +export function parseManifestXml( xml: string ): { appVersion?: string; appBuildVersion?: string } { const versionNameMatch = xml.match(/android:versionName="([^"]+)"/); From db7f29f2983bcd797ccd151a876eaeb9068df1d4 Mon Sep 17 00:00:00 2001 From: Kadi Kraman Date: Thu, 12 Mar 2026 16:30:07 +0000 Subject: [PATCH 3/8] Format --- .../src/steps/functionGroups/build.ts | 24 +++++++-------- .../steps/functions/reportResolvedVersion.ts | 30 ++++++++----------- 2 files changed, 24 insertions(+), 30 deletions(-) diff --git a/packages/build-tools/src/steps/functionGroups/build.ts b/packages/build-tools/src/steps/functionGroups/build.ts index 3c53ceeba9..07ed53368e 100644 --- a/packages/build-tools/src/steps/functionGroups/build.ts +++ b/packages/build-tools/src/steps/functionGroups/build.ts @@ -123,9 +123,9 @@ function createStepsForIosSimulatorBuild({ createFindAndUploadBuildArtifactsBuildFunction( buildToolsContext ).createBuildStepFromFunctionCall(globalCtx), - createReportResolvedVersionBuildFunction( - buildToolsContext - ).createBuildStepFromFunctionCall(globalCtx), + createReportResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall( + globalCtx + ), ]; } @@ -220,9 +220,9 @@ function createStepsForIosBuildWithCredentials({ createFindAndUploadBuildArtifactsBuildFunction( buildToolsContext ).createBuildStepFromFunctionCall(globalCtx), - createReportResolvedVersionBuildFunction( - buildToolsContext - ).createBuildStepFromFunctionCall(globalCtx), + createReportResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall( + globalCtx + ), saveCache, createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx), ]; @@ -294,9 +294,9 @@ function createStepsForAndroidBuildWithoutCredentials({ createFindAndUploadBuildArtifactsBuildFunction( buildToolsContext ).createBuildStepFromFunctionCall(globalCtx), - createReportResolvedVersionBuildFunction( - buildToolsContext - ).createBuildStepFromFunctionCall(globalCtx), + createReportResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall( + globalCtx + ), saveCache, createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx), ]; @@ -370,9 +370,9 @@ function createStepsForAndroidBuildWithCredentials({ createFindAndUploadBuildArtifactsBuildFunction( buildToolsContext ).createBuildStepFromFunctionCall(globalCtx), - createReportResolvedVersionBuildFunction( - buildToolsContext - ).createBuildStepFromFunctionCall(globalCtx), + createReportResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall( + globalCtx + ), saveCache, createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx), ]; diff --git a/packages/build-tools/src/steps/functions/reportResolvedVersion.ts b/packages/build-tools/src/steps/functions/reportResolvedVersion.ts index 1fe8234338..7d97775eee 100644 --- a/packages/build-tools/src/steps/functions/reportResolvedVersion.ts +++ b/packages/build-tools/src/steps/functions/reportResolvedVersion.ts @@ -16,7 +16,7 @@ export function createReportResolvedVersionBuildFunction( id: 'report_resolved_version', name: 'Report resolved version', __metricsId: 'eas/report_resolved_version', - fn: async (stepCtx) => { + fn: async stepCtx => { try { const { appVersion, appBuildVersion } = ctx.job.platform === Platform.IOS @@ -110,12 +110,12 @@ async function extractSimulatorAppVersionAsync( const infoPlist = parseInfoPlistBuffer(infoPlistBuffer); return { - appVersion: typeof infoPlist.CFBundleShortVersionString === 'string' - ? infoPlist.CFBundleShortVersionString - : undefined, - appBuildVersion: typeof infoPlist.CFBundleVersion === 'string' - ? infoPlist.CFBundleVersion - : undefined, + appVersion: + typeof infoPlist.CFBundleShortVersionString === 'string' + ? infoPlist.CFBundleShortVersionString + : undefined, + appBuildVersion: + typeof infoPlist.CFBundleVersion === 'string' ? infoPlist.CFBundleVersion : undefined, }; } @@ -162,18 +162,14 @@ async function extractVersionFromApkAsync( async function extractVersionFromAabAsync( aabPath: string ): Promise<{ appVersion?: string; appBuildVersion?: string }> { - const result = await spawnAsync( - 'bundletool', - ['dump', 'manifest', '--bundle', aabPath], - { stdio: 'pipe' } - ); + const result = await spawnAsync('bundletool', ['dump', 'manifest', '--bundle', aabPath], { + stdio: 'pipe', + }); return parseManifestXml(result.stdout); } -export function parseAaptOutput( - output: string -): { appVersion?: string; appBuildVersion?: string } { +export function parseAaptOutput(output: string): { appVersion?: string; appBuildVersion?: string } { const versionNameMatch = output.match(/versionName='([^']+)'/); const versionCodeMatch = output.match(/versionCode='([^']+)'/); @@ -183,9 +179,7 @@ export function parseAaptOutput( }; } -export function parseManifestXml( - xml: string -): { appVersion?: string; appBuildVersion?: string } { +export function parseManifestXml(xml: string): { appVersion?: string; appBuildVersion?: string } { const versionNameMatch = xml.match(/android:versionName="([^"]+)"/); const versionCodeMatch = xml.match(/android:versionCode="([^"]+)"/); From 02b6ecab32b921f0f237e527d55ce5f2ab9e970a Mon Sep 17 00:00:00 2001 From: Kadi Kraman Date: Fri, 13 Mar 2026 10:08:52 +0000 Subject: [PATCH 4/8] Use fast-xml-parser --- .../steps/functions/reportResolvedVersion.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/packages/build-tools/src/steps/functions/reportResolvedVersion.ts b/packages/build-tools/src/steps/functions/reportResolvedVersion.ts index 7d97775eee..cd0d7ea10c 100644 --- a/packages/build-tools/src/steps/functions/reportResolvedVersion.ts +++ b/packages/build-tools/src/steps/functions/reportResolvedVersion.ts @@ -1,5 +1,6 @@ import { Android, BuildJob, Ios, Platform } from '@expo/eas-build-job'; import { BuildFunction, spawnAsync } from '@expo/steps'; +import { XMLParser } from 'fast-xml-parser'; import fs from 'fs-extra'; import { graphql } from 'gql.tada'; import path from 'node:path'; @@ -180,12 +181,20 @@ export function parseAaptOutput(output: string): { appVersion?: string; appBuild } export function parseManifestXml(xml: string): { appVersion?: string; appBuildVersion?: string } { - const versionNameMatch = xml.match(/android:versionName="([^"]+)"/); - const versionCodeMatch = xml.match(/android:versionCode="([^"]+)"/); + const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_' }); + const parsed = parser.parse(xml); + + const manifest = parsed?.manifest; + if (!manifest) { + return {}; + } + + const versionName = manifest['@_android:versionName']; + const versionCode = manifest['@_android:versionCode']; return { - appVersion: versionNameMatch?.[1], - appBuildVersion: versionCodeMatch?.[1], + appVersion: versionName != null ? String(versionName) : undefined, + appBuildVersion: versionCode != null ? String(versionCode) : undefined, }; } From ac9b5b5f8da26dbbd425db4ee206751841ad54b2 Mon Sep 17 00:00:00 2001 From: Kadi Kraman Date: Fri, 13 Mar 2026 10:31:52 +0000 Subject: [PATCH 5/8] Read archive path from previous step --- .../src/steps/functionGroups/build.ts | 32 ++++++- .../findAndUploadBuildArtifacts.test.ts | 18 ++++ .../functions/findAndUploadBuildArtifacts.ts | 19 +++- .../steps/functions/reportResolvedVersion.ts | 95 +++++++------------ 4 files changed, 94 insertions(+), 70 deletions(-) diff --git a/packages/build-tools/src/steps/functionGroups/build.ts b/packages/build-tools/src/steps/functionGroups/build.ts index 07ed53368e..a714cd5c30 100644 --- a/packages/build-tools/src/steps/functionGroups/build.ts +++ b/packages/build-tools/src/steps/functionGroups/build.ts @@ -124,7 +124,13 @@ function createStepsForIosSimulatorBuild({ buildToolsContext ).createBuildStepFromFunctionCall(globalCtx), createReportResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall( - globalCtx + globalCtx, + { + callInputs: { + application_archive_path: + '${ steps.find_and_upload_build_artifacts.application_archive_path }', + }, + } ), ]; } @@ -221,7 +227,13 @@ function createStepsForIosBuildWithCredentials({ buildToolsContext ).createBuildStepFromFunctionCall(globalCtx), createReportResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall( - globalCtx + globalCtx, + { + callInputs: { + application_archive_path: + '${ steps.find_and_upload_build_artifacts.application_archive_path }', + }, + } ), saveCache, createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx), @@ -295,7 +307,13 @@ function createStepsForAndroidBuildWithoutCredentials({ buildToolsContext ).createBuildStepFromFunctionCall(globalCtx), createReportResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall( - globalCtx + globalCtx, + { + callInputs: { + application_archive_path: + '${ steps.find_and_upload_build_artifacts.application_archive_path }', + }, + } ), saveCache, createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx), @@ -371,7 +389,13 @@ function createStepsForAndroidBuildWithCredentials({ buildToolsContext ).createBuildStepFromFunctionCall(globalCtx), createReportResolvedVersionBuildFunction(buildToolsContext).createBuildStepFromFunctionCall( - globalCtx + globalCtx, + { + callInputs: { + application_archive_path: + '${ steps.find_and_upload_build_artifacts.application_archive_path }', + }, + } ), saveCache, createCacheStatsBuildFunction().createBuildStepFromFunctionCall(globalCtx), diff --git a/packages/build-tools/src/steps/functions/__tests__/findAndUploadBuildArtifacts.test.ts b/packages/build-tools/src/steps/functions/__tests__/findAndUploadBuildArtifacts.test.ts index feb241019f..95a2457dd8 100644 --- a/packages/build-tools/src/steps/functions/__tests__/findAndUploadBuildArtifacts.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/findAndUploadBuildArtifacts.test.ts @@ -51,6 +51,24 @@ describe(createFindAndUploadBuildArtifactsBuildFunction, () => { await expect(buildStep.executeAsync()).resolves.not.toThrow(); }); + it('sets application_archive_path output to first archive', async () => { + const globalContext = createGlobalContextMock({}); + vol.fromJSON( + { + 'ios/build/test.ipa': '', + }, + globalContext.defaultWorkingDirectory + ); + const buildStep = findAndUploadBuildArtifacts.createBuildStepFromFunctionCall( + globalContext, + {} + ); + + await buildStep.executeAsync(); + + expect(buildStep.outputById.application_archive_path.value).toMatch(/ios\/build\/test\.ipa$/); + }); + it('throws build artifacts error', async () => { const globalContext = createGlobalContextMock({}); ctx.job.buildArtifactPaths = ['worker.log']; diff --git a/packages/build-tools/src/steps/functions/findAndUploadBuildArtifacts.ts b/packages/build-tools/src/steps/functions/findAndUploadBuildArtifacts.ts index 4803ff7fdb..94c7619ed2 100644 --- a/packages/build-tools/src/steps/functions/findAndUploadBuildArtifacts.ts +++ b/packages/build-tools/src/steps/functions/findAndUploadBuildArtifacts.ts @@ -1,5 +1,5 @@ import { BuildJob, Ios, ManagedArtifactType, Platform } from '@expo/eas-build-job'; -import { BuildFunction, BuildStepContext } from '@expo/steps'; +import { BuildFunction, BuildStepContext, BuildStepOutput } from '@expo/steps'; import { CustomBuildContext } from '../../customBuildContext'; import { findXcodeBuildLogsPathAsync } from '../../ios/xcodeBuildLogs'; @@ -13,7 +13,13 @@ export function createFindAndUploadBuildArtifactsBuildFunction( id: 'find_and_upload_build_artifacts', name: 'Find and upload build artifacts', __metricsId: 'eas/find_and_upload_build_artifacts', - fn: async stepCtx => { + outputProviders: [ + BuildStepOutput.createProvider({ + id: 'application_archive_path', + required: false, + }), + ], + fn: async (stepCtx, { outputs }) => { // We want each upload to print logs on its own // and we don't want to interleave logs from different uploads // so we execute uploads consecutively. @@ -24,7 +30,10 @@ export function createFindAndUploadBuildArtifactsBuildFunction( let firstError: any = null; try { - await uploadApplicationArchivesAsync({ ctx, stepCtx }); + const archivePath = await uploadApplicationArchivesAsync({ ctx, stepCtx }); + if (archivePath) { + outputs.application_archive_path.set(archivePath); + } } catch (err: unknown) { stepCtx.logger.error(`Failed to upload application archives.`, err); firstError ||= err; @@ -68,7 +77,7 @@ async function uploadApplicationArchivesAsync({ }: { ctx: CustomBuildContext; stepCtx: BuildStepContext; -}): Promise { +}): Promise { const applicationArchivePatternOrPath = ctx.job.platform === Platform.ANDROID ? (ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}') @@ -99,6 +108,8 @@ async function uploadApplicationArchivesAsync({ logger, }); logger.info('Done.'); + + return applicationArchives[0]; } async function uploadBuildArtifacts({ diff --git a/packages/build-tools/src/steps/functions/reportResolvedVersion.ts b/packages/build-tools/src/steps/functions/reportResolvedVersion.ts index cd0d7ea10c..bb2bd79490 100644 --- a/packages/build-tools/src/steps/functions/reportResolvedVersion.ts +++ b/packages/build-tools/src/steps/functions/reportResolvedVersion.ts @@ -1,5 +1,10 @@ -import { Android, BuildJob, Ios, Platform } from '@expo/eas-build-job'; -import { BuildFunction, spawnAsync } from '@expo/steps'; +import { BuildJob, Platform } from '@expo/eas-build-job'; +import { + BuildFunction, + BuildStepInput, + BuildStepInputValueTypeName, + spawnAsync, +} from '@expo/steps'; import { XMLParser } from 'fast-xml-parser'; import fs from 'fs-extra'; import { graphql } from 'gql.tada'; @@ -7,7 +12,6 @@ import path from 'node:path'; import { parseInfoPlistBuffer, readIpaInfoAsync } from './readIpaInfo'; import { CustomBuildContext } from '../../customBuildContext'; -import { findArtifacts } from '../../utils/artifacts'; export function createReportResolvedVersionBuildFunction( ctx: CustomBuildContext @@ -17,12 +21,25 @@ export function createReportResolvedVersionBuildFunction( id: 'report_resolved_version', name: 'Report resolved version', __metricsId: 'eas/report_resolved_version', - fn: async stepCtx => { + inputProviders: [ + BuildStepInput.createProvider({ + id: 'application_archive_path', + required: false, + allowedValueTypeName: BuildStepInputValueTypeName.STRING, + }), + ], + fn: async (stepCtx, { inputs }) => { try { + const archivePath = inputs.application_archive_path.value as string | undefined; + if (!archivePath) { + stepCtx.logger.info('No application archive path provided, skipping.'); + return; + } + const { appVersion, appBuildVersion } = ctx.job.platform === Platform.IOS - ? await extractIosVersionAsync(ctx, stepCtx.workingDirectory, stepCtx.logger) - : await extractAndroidVersionAsync(ctx, stepCtx.workingDirectory, stepCtx.logger); + ? await extractIosVersionAsync(archivePath) + : await extractAndroidVersionAsync(archivePath); if (!appVersion && !appBuildVersion) { stepCtx.logger.info('No resolved version found, skipping.'); @@ -53,30 +70,15 @@ export function createReportResolvedVersionBuildFunction( } async function extractIosVersionAsync( - ctx: CustomBuildContext, - workingDirectory: string, - logger: any + archivePath: string ): Promise<{ appVersion?: string; appBuildVersion?: string }> { - const iosJob = ctx.job as Ios.Job; + const ext = path.extname(archivePath).toLowerCase(); - if (iosJob.simulator) { - return await extractSimulatorAppVersionAsync(iosJob, workingDirectory, logger); + if (ext === '.app') { + return await extractSimulatorAppVersionAsync(archivePath); } - const artifactPattern = iosJob.applicationArchivePath ?? 'ios/build/*.ipa'; - const artifacts = await findArtifacts({ - rootDir: workingDirectory, - patternOrPath: artifactPattern, - logger, - }); - - if (artifacts.length === 0) { - return {}; - } - - const ipaPath = artifacts[0]; - const ipaInfo = await readIpaInfoAsync(ipaPath); - + const ipaInfo = await readIpaInfoAsync(archivePath); return { appVersion: ipaInfo.bundleShortVersion, appBuildVersion: ipaInfo.bundleVersion, @@ -84,23 +86,8 @@ async function extractIosVersionAsync( } async function extractSimulatorAppVersionAsync( - iosJob: Ios.Job, - workingDirectory: string, - logger: any + appPath: string ): Promise<{ appVersion?: string; appBuildVersion?: string }> { - const artifactPattern = - iosJob.applicationArchivePath ?? 'ios/build/Build/Products/*simulator/*.app'; - const artifacts = await findArtifacts({ - rootDir: workingDirectory, - patternOrPath: artifactPattern, - logger, - }); - - if (artifacts.length === 0) { - return {}; - } - - const appPath = artifacts[0]; const infoPlistPath = path.join(appPath, 'Info.plist'); if (!(await fs.pathExists(infoPlistPath))) { @@ -121,30 +108,14 @@ async function extractSimulatorAppVersionAsync( } async function extractAndroidVersionAsync( - ctx: CustomBuildContext, - workingDirectory: string, - logger: any + archivePath: string ): Promise<{ appVersion?: string; appBuildVersion?: string }> { - const androidJob = ctx.job as Android.Job; - const artifactPattern = - androidJob.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}'; - const artifacts = await findArtifacts({ - rootDir: workingDirectory, - patternOrPath: artifactPattern, - logger, - }); - - if (artifacts.length === 0) { - return {}; - } - - const artifactPath = artifacts[0]; - const ext = path.extname(artifactPath).toLowerCase(); + const ext = path.extname(archivePath).toLowerCase(); if (ext === '.apk') { - return await extractVersionFromApkAsync(artifactPath); + return await extractVersionFromApkAsync(archivePath); } else if (ext === '.aab') { - return await extractVersionFromAabAsync(artifactPath); + return await extractVersionFromAabAsync(archivePath); } return {}; From 63e1ce2f865d735fbce906534c6f18b76a66cb1c Mon Sep 17 00:00:00 2001 From: Kadi Kraman Date: Fri, 13 Mar 2026 10:55:58 +0000 Subject: [PATCH 6/8] Also report app versions in builder files --- packages/build-tools/src/builders/android.ts | 40 +++++++++++++++---- packages/build-tools/src/builders/ios.ts | 39 ++++++++++++++---- .../steps/functions/reportResolvedVersion.ts | 13 +++--- packages/build-tools/src/utils/artifacts.ts | 4 +- 4 files changed, 75 insertions(+), 21 deletions(-) diff --git a/packages/build-tools/src/builders/android.ts b/packages/build-tools/src/builders/android.ts index 15b77ecb8e..788a701e3a 100644 --- a/packages/build-tools/src/builders/android.ts +++ b/packages/build-tools/src/builders/android.ts @@ -10,6 +10,10 @@ import { resolveGradleCommand, runGradleCommand, } from '../android/gradle'; +import { + extractAndroidVersionAsync, + reportResolvedVersionAsync, +} from '../steps/functions/reportResolvedVersion'; import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle'; import { prebuildAsync } from '../common/prebuild'; import { setupAsync } from '../common/setup'; @@ -174,13 +178,35 @@ async function buildAsync(ctx: BuildContext): Promise { await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS); }); - await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => { - await uploadApplicationArchive(ctx, { - patternOrPath: ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}', - rootDir: ctx.getReactNativeProjectDirectory(), - logger: ctx.logger, - }); - }); + const archivePath = await ctx.runBuildPhase( + BuildPhase.UPLOAD_APPLICATION_ARCHIVE, + async () => { + return await uploadApplicationArchive(ctx, { + patternOrPath: + ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}', + rootDir: ctx.getReactNativeProjectDirectory(), + logger: ctx.logger, + }); + } + ); + + if (archivePath) { + try { + const { appVersion, appBuildVersion } = await extractAndroidVersionAsync(archivePath); + const buildId = ctx.env.EAS_BUILD_ID; + if (buildId && (appVersion || appBuildVersion)) { + await reportResolvedVersionAsync(ctx.graphqlClient, buildId, { + appVersion, + appBuildVersion, + }); + ctx.logger.info( + `Reported resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})` + ); + } + } catch (err) { + ctx.logger.warn({ err }, 'Failed to report resolved version (non-fatal)'); + } + } await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => { if (ctx.isLocal) { diff --git a/packages/build-tools/src/builders/ios.ts b/packages/build-tools/src/builders/ios.ts index 4ee9528627..9bccff0f25 100644 --- a/packages/build-tools/src/builders/ios.ts +++ b/packages/build-tools/src/builders/ios.ts @@ -16,6 +16,10 @@ import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane'; import { installPods } from '../ios/pod'; import { downloadApplicationArchiveAsync } from '../ios/resign'; import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve'; +import { + extractIosVersionAsync, + reportResolvedVersionAsync, +} from '../steps/functions/reportResolvedVersion'; import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache'; import { saveCcacheAsync } from '../steps/functions/saveBuildCache'; import { uploadApplicationArchive } from '../utils/artifacts'; @@ -175,13 +179,34 @@ async function buildAsync(ctx: BuildContext): Promise { await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS); }); - await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => { - await uploadApplicationArchive(ctx, { - patternOrPath: resolveArtifactPath(ctx), - rootDir: ctx.getReactNativeProjectDirectory(), - logger: ctx.logger, - }); - }); + const archivePath = await ctx.runBuildPhase( + BuildPhase.UPLOAD_APPLICATION_ARCHIVE, + async () => { + return await uploadApplicationArchive(ctx, { + patternOrPath: resolveArtifactPath(ctx), + rootDir: ctx.getReactNativeProjectDirectory(), + logger: ctx.logger, + }); + } + ); + + if (archivePath) { + try { + const { appVersion, appBuildVersion } = await extractIosVersionAsync(archivePath); + const buildId = ctx.env.EAS_BUILD_ID; + if (buildId && (appVersion || appBuildVersion)) { + await reportResolvedVersionAsync(ctx.graphqlClient, buildId, { + appVersion, + appBuildVersion, + }); + ctx.logger.info( + `Reported resolved version: ${appVersion ?? 'N/A'} (${appBuildVersion ?? 'N/A'})` + ); + } + } catch (err) { + ctx.logger.warn({ err }, 'Failed to report resolved version (non-fatal)'); + } + } await ctx.runBuildPhase(BuildPhase.SAVE_CACHE, async () => { if (ctx.isLocal) { diff --git a/packages/build-tools/src/steps/functions/reportResolvedVersion.ts b/packages/build-tools/src/steps/functions/reportResolvedVersion.ts index bb2bd79490..b919e99271 100644 --- a/packages/build-tools/src/steps/functions/reportResolvedVersion.ts +++ b/packages/build-tools/src/steps/functions/reportResolvedVersion.ts @@ -7,6 +7,7 @@ import { } from '@expo/steps'; import { XMLParser } from 'fast-xml-parser'; import fs from 'fs-extra'; +import { Client } from '@urql/core'; import { graphql } from 'gql.tada'; import path from 'node:path'; @@ -56,7 +57,7 @@ export function createReportResolvedVersionBuildFunction( return; } - await reportResolvedVersionAsync(ctx, buildId, { + await reportResolvedVersionAsync(ctx.graphqlClient, buildId, { appVersion, appBuildVersion, }); @@ -69,7 +70,7 @@ export function createReportResolvedVersionBuildFunction( }); } -async function extractIosVersionAsync( +export async function extractIosVersionAsync( archivePath: string ): Promise<{ appVersion?: string; appBuildVersion?: string }> { const ext = path.extname(archivePath).toLowerCase(); @@ -107,7 +108,7 @@ async function extractSimulatorAppVersionAsync( }; } -async function extractAndroidVersionAsync( +export async function extractAndroidVersionAsync( archivePath: string ): Promise<{ appVersion?: string; appBuildVersion?: string }> { const ext = path.extname(archivePath).toLowerCase(); @@ -169,8 +170,8 @@ export function parseManifestXml(xml: string): { appVersion?: string; appBuildVe }; } -async function reportResolvedVersionAsync( - ctx: CustomBuildContext, +export async function reportResolvedVersionAsync( + graphqlClient: Client, buildId: string, { appVersion, @@ -180,7 +181,7 @@ async function reportResolvedVersionAsync( appBuildVersion?: string; } ): Promise { - const result = await ctx.graphqlClient + const result = await graphqlClient .mutation( graphql(` mutation ReportResolvedVersionMutation( diff --git a/packages/build-tools/src/utils/artifacts.ts b/packages/build-tools/src/utils/artifacts.ts index cdf8a8a773..7fe3e9e3dd 100644 --- a/packages/build-tools/src/utils/artifacts.ts +++ b/packages/build-tools/src/utils/artifacts.ts @@ -120,7 +120,7 @@ export async function uploadApplicationArchive( patternOrPath: string; rootDir: string; } -): Promise { +): Promise { const applicationArchives = await findArtifacts({ rootDir, patternOrPath, logger }); const artifactsSizes = await getArtifactsSizes(applicationArchives); logger.info(`Application archives:`); @@ -136,6 +136,8 @@ export async function uploadApplicationArchive( }, logger, }); + + return applicationArchives[0]; } async function getArtifactsSizes(artifacts: string[]): Promise> { From 7944f61d59d6063274d84746d29ca071e7a90f33 Mon Sep 17 00:00:00 2001 From: Kadi Kraman Date: Fri, 13 Mar 2026 10:57:21 +0000 Subject: [PATCH 7/8] Format --- packages/build-tools/src/builders/android.ts | 18 +++++++----------- packages/build-tools/src/builders/ios.ts | 17 +++++++---------- 2 files changed, 14 insertions(+), 21 deletions(-) diff --git a/packages/build-tools/src/builders/android.ts b/packages/build-tools/src/builders/android.ts index 788a701e3a..88b3a045a0 100644 --- a/packages/build-tools/src/builders/android.ts +++ b/packages/build-tools/src/builders/android.ts @@ -178,17 +178,13 @@ async function buildAsync(ctx: BuildContext): Promise { await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS); }); - const archivePath = await ctx.runBuildPhase( - BuildPhase.UPLOAD_APPLICATION_ARCHIVE, - async () => { - return await uploadApplicationArchive(ctx, { - patternOrPath: - ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}', - rootDir: ctx.getReactNativeProjectDirectory(), - logger: ctx.logger, - }); - } - ); + const archivePath = await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => { + return await uploadApplicationArchive(ctx, { + patternOrPath: ctx.job.applicationArchivePath ?? 'android/app/build/outputs/**/*.{apk,aab}', + rootDir: ctx.getReactNativeProjectDirectory(), + logger: ctx.logger, + }); + }); if (archivePath) { try { diff --git a/packages/build-tools/src/builders/ios.ts b/packages/build-tools/src/builders/ios.ts index 9bccff0f25..32b85086dc 100644 --- a/packages/build-tools/src/builders/ios.ts +++ b/packages/build-tools/src/builders/ios.ts @@ -179,16 +179,13 @@ async function buildAsync(ctx: BuildContext): Promise { await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS); }); - const archivePath = await ctx.runBuildPhase( - BuildPhase.UPLOAD_APPLICATION_ARCHIVE, - async () => { - return await uploadApplicationArchive(ctx, { - patternOrPath: resolveArtifactPath(ctx), - rootDir: ctx.getReactNativeProjectDirectory(), - logger: ctx.logger, - }); - } - ); + const archivePath = await ctx.runBuildPhase(BuildPhase.UPLOAD_APPLICATION_ARCHIVE, async () => { + return await uploadApplicationArchive(ctx, { + patternOrPath: resolveArtifactPath(ctx), + rootDir: ctx.getReactNativeProjectDirectory(), + logger: ctx.logger, + }); + }); if (archivePath) { try { From 3e909154c1f50177d89afd332237eed00a287fa1 Mon Sep 17 00:00:00 2001 From: Kadi Kraman Date: Fri, 13 Mar 2026 11:07:28 +0000 Subject: [PATCH 8/8] Add tests for the extract functions --- .../__tests__/reportResolvedVersion.test.ts | 119 +++++++++++++++++- 1 file changed, 118 insertions(+), 1 deletion(-) diff --git a/packages/build-tools/src/steps/functions/__tests__/reportResolvedVersion.test.ts b/packages/build-tools/src/steps/functions/__tests__/reportResolvedVersion.test.ts index 84fd1242a2..c0a2236066 100644 --- a/packages/build-tools/src/steps/functions/__tests__/reportResolvedVersion.test.ts +++ b/packages/build-tools/src/steps/functions/__tests__/reportResolvedVersion.test.ts @@ -1,3 +1,4 @@ +import { spawnAsync } from '@expo/steps'; import fs from 'node:fs'; import path from 'node:path'; @@ -6,8 +7,20 @@ jest.unmock('node:fs'); jest.unmock('fs/promises'); jest.unmock('node:fs/promises'); +jest.mock('@expo/steps', () => ({ + ...jest.requireActual('@expo/steps'), + spawnAsync: jest.fn(), +})); + +const mockedSpawnAsync = jest.mocked(spawnAsync); + import { parseInfoPlistBuffer } from '../readIpaInfo'; -import { parseAaptOutput, parseManifestXml } from '../reportResolvedVersion'; +import { + extractAndroidVersionAsync, + extractIosVersionAsync, + parseAaptOutput, + parseManifestXml, +} from '../reportResolvedVersion'; describe(parseAaptOutput, () => { it('extracts versionName and versionCode from aapt2 output', () => { @@ -105,3 +118,107 @@ describe('simulator .app Info.plist parsing', () => { expect(infoPlist.CFBundleVersion).toBe('99'); }); }); + +describe(extractIosVersionAsync, () => { + const FIXTURES_DIR = path.join(__dirname, 'fixtures'); + const SIMULATOR_APP_DIR = path.join(FIXTURES_DIR, 'ExtractVersion.app'); + const INFO_PLIST_PATH = path.join(SIMULATOR_APP_DIR, 'Info.plist'); + const IPA_FIXTURE_PATH = path.join(FIXTURES_DIR, 'SmallestAppExample.ipa'); + + beforeAll(async () => { + await fs.promises.mkdir(SIMULATOR_APP_DIR, { recursive: true }); + await fs.promises.writeFile( + INFO_PLIST_PATH, + ` + + + + CFBundleShortVersionString + 2.0.1 + CFBundleVersion + 55 + +` + ); + }); + + afterAll(async () => { + await fs.promises.rm(SIMULATOR_APP_DIR, { recursive: true, force: true }); + }); + + it('extracts version from a .app directory', async () => { + const result = await extractIosVersionAsync(SIMULATOR_APP_DIR); + + expect(result).toEqual({ + appVersion: '2.0.1', + appBuildVersion: '55', + }); + }); + + it('extracts version from an .ipa file', async () => { + const result = await extractIosVersionAsync(IPA_FIXTURE_PATH); + + expect(result).toEqual({ + appVersion: '1.0', + appBuildVersion: '1', + }); + }); +}); + +describe(extractAndroidVersionAsync, () => { + afterEach(() => { + mockedSpawnAsync.mockReset(); + }); + + it('extracts version from an .apk via aapt2', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: `package: name='com.example.app' versionCode='42' versionName='2.5.0'`, + stderr: '', + } as any); + + const result = await extractAndroidVersionAsync('/path/to/app.apk'); + + expect(mockedSpawnAsync).toHaveBeenCalledWith( + 'aapt2', + ['dump', 'badging', '/path/to/app.apk'], + { + stdio: 'pipe', + } + ); + expect(result).toEqual({ + appVersion: '2.5.0', + appBuildVersion: '42', + }); + }); + + it('extracts version from an .aab via bundletool', async () => { + mockedSpawnAsync.mockResolvedValue({ + stdout: ` + +`, + stderr: '', + } as any); + + const result = await extractAndroidVersionAsync('/path/to/app.aab'); + + expect(mockedSpawnAsync).toHaveBeenCalledWith( + 'bundletool', + ['dump', 'manifest', '--bundle', '/path/to/app.aab'], + { stdio: 'pipe' } + ); + expect(result).toEqual({ + appVersion: '3.1.0', + appBuildVersion: '10', + }); + }); + + it('returns empty object for unknown extension', async () => { + const result = await extractAndroidVersionAsync('/path/to/archive.zip'); + + expect(mockedSpawnAsync).not.toHaveBeenCalled(); + expect(result).toEqual({}); + }); +});