Compare commits

..
Author SHA1 Message Date
AbbanMustafa fef31f74e5 fold into existing restore/save_build_cache 2026-03-18 01:16:41 -05:00
AbbanMustafa aa77ef6485 cleanup 2026-03-17 01:55:34 -05:00
AbbanMustafa c5a12d9c26 snapshots 2026-03-17 01:51:06 -05:00
AbbanMustafa 8a9bc57e28 cleanup 2026-03-17 01:39:01 -05:00
AbbanMustafa 94bf1ca46f cleanup 2026-03-17 01:31:28 -05:00
AbbanMustafa 8671d262f7 archive & simulator build support 2026-03-16 17:00:23 -05:00
AbbanMustafa 9aeaba46eb derived_data_path to the archive Gymfile template 2026-03-12 00:44:02 -05:00
AbbanMustafa d68a7fa34a proper path 2026-03-12 00:26:32 -05:00
AbbanMustafa b6450b28b6 proper path 2026-03-12 00:16:28 -05:00
AbbanMustafa 6090749291 run build phases 2026-03-11 19:19:49 -05:00
AbbanMustafa 10c148a359 derived data cache 2026-03-10 14:13:47 -05:00
23 changed files with 564 additions and 399 deletions

No files matched your search

-1
View File
@@ -1,7 +1,6 @@
name: CHANGELOG.md PR check
on:
workflow_dispatch:
pull_request:
types:
- opened
-3
View File
@@ -8,8 +8,6 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- Add `--skip-build-if-not-found` flag to `eas build:dev`. ([#3495](https://github.com/expo/eas-cli/pull/3495) by [@mmichels-brex](https://github.com/mmichels-brex))
### 🐛 Bug fixes
### 🧹 Chores
@@ -30,7 +28,6 @@ This is the log of notable changes to EAS CLI and related packages.
### 🐛 Bug fixes
- Check all certificates in provisioning profile during verification instead of only the first one. ([#3484](https://github.com/expo/eas-cli/pull/3484) by [@qwertey6](https://github.com/qwertey6))
- Provide an override for the new --environment flag requirement in the update command. ([#3442](https://github.com/expo/eas-cli/pull/3442) by [@douglowder](https://github.com/douglowder))
- Add missing `--include=dev` for `npm install` commands ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
- Add missing `--production false` for `yarn install` commands for Yarn Classic ([#3459](https://github.com/expo/eas-cli/pull/3459) by [@kitten](https://github.com/kitten))
+1 -1
View File
@@ -42,7 +42,7 @@ export async function runGradleCommand(
return line;
}
},
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx), LC_ALL: 'C.UTF-8' },
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx) },
});
if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
adjustOOMScore(spawnPromise, logger);
@@ -0,0 +1,57 @@
import { AndroidConfig } from '@expo/config-plugins';
import { Android } from '@expo/eas-build-job';
import fs from 'fs-extra';
import path from 'path';
import { BuildContext } from '../context';
import { EasBuildGradle } from '../templates/EasBuildGradle';
const APPLY_EAS_BUILD_GRADLE_LINE = 'apply from: "./eas-build.gradle"';
export async function configureBuildGradle(ctx: BuildContext<Android.Job>): Promise<void> {
ctx.logger.info('Injecting signing config into build.gradle');
if (await fs.pathExists(getEasBuildGradlePath(ctx.getReactNativeProjectDirectory()))) {
ctx.markBuildPhaseHasWarnings();
ctx.logger.warn('eas-build.gradle script is deprecated, please remove it from your project.');
}
await deleteEasBuildGradle(ctx.getReactNativeProjectDirectory());
await createEasBuildGradle(ctx.getReactNativeProjectDirectory());
await addApplyToBuildGradle(ctx.getReactNativeProjectDirectory());
}
async function deleteEasBuildGradle(projectRoot: string): Promise<void> {
const easBuildGradlePath = getEasBuildGradlePath(projectRoot);
await fs.remove(easBuildGradlePath);
}
function getEasBuildGradlePath(projectRoot: string): string {
return path.join(projectRoot, 'android/app/eas-build.gradle');
}
async function createEasBuildGradle(projectRoot: string): Promise<void> {
const easBuildGradlePath = getEasBuildGradlePath(projectRoot);
await fs.writeFile(easBuildGradlePath, EasBuildGradle);
}
async function addApplyToBuildGradle(projectRoot: string): Promise<void> {
const buildGradlePath = AndroidConfig.Paths.getAppBuildGradleFilePath(projectRoot);
const buildGradleContents = await fs.readFile(path.join(buildGradlePath), 'utf8');
if (hasLine(buildGradleContents, APPLY_EAS_BUILD_GRADLE_LINE)) {
return;
}
await fs.writeFile(
buildGradlePath,
`${buildGradleContents.trim()}\n${APPLY_EAS_BUILD_GRADLE_LINE}\n`
);
}
function hasLine(haystack: string, needle: string): boolean {
return (
haystack
.replace(/\r\n/g, '\n')
.split('\n')
// Check for both single and double quotes
.some(line => line === needle || line === needle.replace(/"/g, "'"))
);
}
@@ -1,266 +0,0 @@
import { Android, BuildPhase, BuildPhaseResult } from '@expo/eas-build-job';
import { vol } from 'memfs';
import { createTestAndroidJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { restoreCredentials } from '../../android/credentials';
import androidBuilder from '../android';
import { runBuilderWithHooksAsync } from '../common';
import {
injectConfigureVersionGradleConfig,
injectCredentialsGradleConfig,
} from '../../steps/utils/android/gradleConfig';
jest.mock('../common', () => ({
runBuilderWithHooksAsync: jest.fn(async (ctx, buildFn) => {
await buildFn(ctx);
return ctx.artifacts;
}),
}));
jest.mock('../custom', () => ({
runCustomBuildAsync: jest.fn(),
}));
jest.mock('../../android/credentials');
jest.mock('../../android/gradle', () => ({
ensureLFLineEndingsInGradlewScript: jest.fn(),
resolveGradleCommand: jest.fn(() => ':app:bundleRelease'),
runGradleCommand: jest.fn(),
}));
jest.mock('../../common/eagerBundle', () => ({
eagerBundleAsync: jest.fn(),
shouldUseEagerBundle: jest.fn(() => false),
}));
jest.mock('../../common/prebuild', () => ({
prebuildAsync: jest.fn(),
}));
jest.mock('../../common/setup', () => ({
setupAsync: jest.fn(),
}));
jest.mock('../../steps/functions/restoreBuildCache', () => ({
cacheStatsAsync: jest.fn(),
restoreCcacheAsync: jest.fn(),
}));
jest.mock('../../steps/functions/saveBuildCache', () => ({
saveCcacheAsync: jest.fn(),
}));
jest.mock('../../steps/utils/android/gradleConfig', () => ({
...jest.requireActual('../../steps/utils/android/gradleConfig'),
injectConfigureVersionGradleConfig: jest.fn(),
injectCredentialsGradleConfig: jest.fn(),
}));
jest.mock('../../utils/artifacts', () => ({
uploadApplicationArchive: jest.fn(),
}));
jest.mock('../../utils/expoUpdates', () => ({
configureExpoUpdatesIfInstalledAsync: jest.fn(),
resolveRuntimeVersionForExpoUpdatesIfConfiguredAsync: jest.fn(async () => null),
}));
jest.mock('../../utils/hooks', () => ({
Hook: {
POST_INSTALL: 'POST_INSTALL',
PRE_UPLOAD_ARTIFACTS: 'PRE_UPLOAD_ARTIFACTS',
},
runHookIfPresent: jest.fn(),
}));
jest.mock('../../utils/prepareBuildExecutable', () => ({
prepareExecutableAsync: jest.fn(),
}));
describe(androidBuilder, () => {
beforeEach(() => {
vol.fromJSON(
{
'/workingdir/env/.gitkeep': '',
'/workingdir/build/package.json': JSON.stringify({
name: 'test-app',
version: '1.0.0',
}),
},
'/'
);
});
it('injects Android version config without build credentials', async () => {
const job: Android.Job = {
...createTestAndroidJob(),
secrets: {
buildCredentials: undefined,
},
version: {
versionCode: '42',
versionName: '1.2.3',
},
};
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(injectConfigureVersionGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build',
{
versionCode: '42',
versionName: '1.2.3',
}
);
expect(injectCredentialsGradleConfig).not.toHaveBeenCalled();
expect(restoreCredentials).not.toHaveBeenCalled();
});
it('injects Android credentials config when build credentials are present', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(injectCredentialsGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build'
);
expect(restoreCredentials).toHaveBeenCalledWith(ctx);
expect(injectConfigureVersionGradleConfig).not.toHaveBeenCalled();
});
it('marks the configure Android version phase as warning for legacy eas-build.gradle', async () => {
const reportBuildPhaseStats = jest.fn();
const job: Android.Job = {
...createTestAndroidJob(),
secrets: {
buildCredentials: undefined,
},
version: {
versionCode: '42',
versionName: '1.2.3',
},
};
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
result: BuildPhaseResult.WARNING,
})
);
});
it('marks the prepare credentials phase as warning for legacy eas-build.gradle', async () => {
const reportBuildPhaseStats = jest.fn();
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
result: BuildPhaseResult.WARNING,
})
);
});
it('injects credentials and version config when both are configured', async () => {
const reportBuildPhaseStats = jest.fn();
const job: Android.Job = {
...createTestAndroidJob(),
version: {
versionCode: '42',
versionName: '1.2.3',
},
};
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(injectCredentialsGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build'
);
expect(restoreCredentials).toHaveBeenCalledWith(ctx);
expect(injectConfigureVersionGradleConfig).toHaveBeenCalledWith(
expect.anything(),
'/workingdir/build',
{
versionCode: '42',
versionName: '1.2.3',
}
);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
result: BuildPhaseResult.WARNING,
})
);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect.objectContaining({
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
result: BuildPhaseResult.WARNING,
})
);
});
it('runs the builder through hooks', async () => {
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
});
await androidBuilder(ctx);
expect(runBuilderWithHooksAsync).toHaveBeenCalledWith(ctx, expect.any(Function));
});
});
+2 -18
View File
@@ -10,17 +10,13 @@ import {
resolveGradleCommand,
runGradleCommand,
} from '../android/gradle';
import { configureBuildGradle } from '../android/gradleConfig';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { setupAsync } from '../common/setup';
import { Artifacts, BuildContext, SkipNativeBuildError } from '../context';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import {
injectConfigureVersionGradleConfig,
injectCredentialsGradleConfig,
warnIfLegacyEasBuildGradleExists,
} from '../steps/utils/android/gradleConfig';
import { uploadApplicationArchive } from '../utils/artifacts';
import {
configureExpoUpdatesIfInstalledAsync,
@@ -105,22 +101,10 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
nullthrows(ctx.job.secrets, 'Secrets must be defined for non-custom builds').buildCredentials
) {
await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
await warnIfLegacyEasBuildGradleExists(ctx);
await restoreCredentials(ctx);
await injectCredentialsGradleConfig(ctx.logger, ctx.getReactNativeProjectDirectory());
await configureBuildGradle(ctx);
});
}
if (ctx.job.version?.versionCode || ctx.job.version?.versionName) {
await ctx.runBuildPhase(BuildPhase.CONFIGURE_ANDROID_VERSION, async () => {
await warnIfLegacyEasBuildGradleExists(ctx);
await injectConfigureVersionGradleConfig(ctx.logger, ctx.getReactNativeProjectDirectory(), {
versionCode: ctx.job.version?.versionCode,
versionName: ctx.job.version?.versionName,
});
});
}
await ctx.runBuildPhase(BuildPhase.CONFIGURE_EXPO_UPDATES, async () => {
await configureExpoUpdatesIfInstalledAsync(ctx, {
resolvedRuntimeVersion: resolvedExpoUpdatesRuntimeVersion?.runtimeVersion ?? null,
+28 -2
View File
@@ -16,8 +16,13 @@ import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane';
import { installPods } from '../ios/pod';
import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import {
cacheStatsAsync,
patchPodsXcodeprojAsync,
restoreCcacheAsync,
restoreXcodeCacheAsync,
} from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync, saveXcodeCacheAsync } from '../steps/functions/saveBuildCache';
import { uploadApplicationArchive } from '../utils/artifacts';
import {
configureExpoUpdatesIfInstalledAsync,
@@ -51,6 +56,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
const evictUsedBefore = new Date();
const credentialsManager = new CredentialsManager(ctx);
const workingDirectory = ctx.getReactNativeProjectDirectory();
let xcodeCacheHit = false;
try {
const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
return await credentialsManager.prepare();
@@ -87,10 +93,23 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
env: ctx.env,
secrets: ctx.job.secrets,
});
xcodeCacheHit = await restoreXcodeCacheAsync({
logger: ctx.logger,
workingDirectory: ctx.buildDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
simulator: ctx.job.simulator,
});
});
await ctx.runBuildPhase(BuildPhase.INSTALL_PODS, async () => {
await runInstallPodsAsync(ctx);
await patchPodsXcodeprojAsync({
logger: ctx.logger,
workingDirectory: ctx.buildDirectory,
env: ctx.env,
cacheHit: xcodeCacheHit,
});
});
await ctx.runBuildPhase(BuildPhase.POST_INSTALL_HOOK, async () => {
@@ -188,6 +207,13 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
ctx.logger.info('Local builds do not support saving cache.');
return;
}
await saveXcodeCacheAsync({
logger: ctx.logger,
workingDirectory: ctx.buildDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
simulator: ctx.job.simulator,
});
await ctx.cacheManager?.saveCache(ctx);
await saveCcacheAsync({
logger: ctx.logger,
@@ -19,6 +19,8 @@ export_options({
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /Users/expo/Library/Keychains/login.keychain\\""
derived_data_path("./build")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
@@ -42,6 +44,8 @@ export_options({
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
derived_data_path("./build")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
@@ -67,6 +71,8 @@ export_options({
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
derived_data_path("./build")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
@@ -94,6 +100,8 @@ export_options({
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
derived_data_path("./build")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
@@ -57,6 +57,7 @@ describe('gymfile', () => {
credentials: mockCredentials,
scheme: 'MyApp',
buildConfiguration: 'Release',
derivedDataPath: './build',
outputDirectory: '/tmp/output',
clean: true,
logsDirectory: '/tmp/logs',
@@ -93,6 +94,7 @@ describe('gymfile', () => {
outputFile,
credentials: mockCredentials,
scheme: 'TestScheme',
derivedDataPath: './build',
outputDirectory: '/tmp/output',
clean: false,
logsDirectory: '/tmp/logs',
@@ -130,6 +132,7 @@ describe('gymfile', () => {
credentials: mockCredentials,
scheme: 'MyApp',
buildConfiguration: 'Release',
derivedDataPath: './build',
outputDirectory: '/tmp/output',
clean: true,
logsDirectory: '/tmp/logs',
@@ -191,6 +194,7 @@ describe('gymfile', () => {
outputFile,
credentials: mockCredentials,
scheme: 'MyApp',
derivedDataPath: './build',
outputDirectory: '/tmp/output',
clean: true,
logsDirectory: '/tmp/logs',
@@ -1,6 +1,5 @@
import { Ios } from '@expo/eas-build-job';
import { createLogger } from '@expo/logger';
import crypto from 'crypto';
import { provisioningProfile } from './fixtures';
import { BuildContext } from '../../../context';
@@ -74,45 +73,5 @@ describe('ProvisioningProfile class', () => {
await pp.destroy();
}
});
it('should match a certificate that is not at index 0 in DeveloperCertificates', async () => {
const pp = new ProvisioningProfile(
ctx,
Buffer.from(provisioningProfile.dataBase64, 'base64'),
keychain.data.path,
'testapp',
'Abc 123'
);
try {
await pp.init();
// Prepend an unrelated certificate so the real one moves to index 1
const dummyCert = Buffer.from('dummy-certificate-data');
(pp as any).developerCertificates = [dummyCert, ...(pp as any).developerCertificates];
// The original cert fingerprint should still match
expect(() => {
pp.verifyCertificate(provisioningProfile.certFingerprint);
}).not.toThrow();
// A non-matching fingerprint should still fail
expect(() => {
pp.verifyCertificate('2137');
}).toThrowError(/don't match/);
// The dummy cert's fingerprint should also match
const dummyFingerprint = crypto
.createHash('sha1')
.update(new Uint8Array(dummyCert))
.digest('hex')
.toUpperCase();
expect(() => {
pp.verifyCertificate(dummyFingerprint);
}).not.toThrow();
} finally {
await pp.destroy();
}
});
});
});
@@ -43,7 +43,6 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
private readonly profilePath: string;
private profileData?: ProvisioningProfileData;
private developerCertificates: Buffer[] = [];
constructor(
private readonly ctx: BuildContext<TJob>,
@@ -78,12 +77,11 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
}
public verifyCertificate(fingerprint: string): void {
const devCertFingerprints = this.getAllDerCertFingerprints();
if (!devCertFingerprints.includes(fingerprint)) {
const devCertFingerprint = this.genDerCertFingerprint();
if (devCertFingerprint !== fingerprint) {
throw new errors.CredentialsDistCertMismatchError(
`Provisioning profile and distribution certificate don't match.\n` +
`Profile's certificate fingerprints = [${devCertFingerprints.join(', ')}], ` +
`distribution certificate fingerprint = ${fingerprint}`
`Provisioning profile and distribution certificate don't match.
Profile's certificate fingerprint = ${devCertFingerprint}, distribution certificate fingerprint = ${fingerprint}`
);
}
}
@@ -116,10 +114,6 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
] as string;
const bundleIdentifier = applicationIdentifier.replace(/^.+?\./, '');
this.developerCertificates = (plistData.DeveloperCertificates as string[]).map((cert: string) =>
Buffer.from(cert, 'base64')
);
this.profileData = {
path: this.profilePath,
target: this.target,
@@ -127,7 +121,7 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
teamId: (plistData.TeamIdentifier as string[])[0],
uuid: plistData.UUID as string,
name: plistData.Name as string,
developerCertificate: this.developerCertificates[0],
developerCertificate: Buffer.from((plistData.DeveloperCertificates as string[])[0], 'base64'),
certificateCommonName: this.certificateCommonName,
distributionType: this.resolveDistributionType(plistData),
};
@@ -143,9 +137,11 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
}
}
private getAllDerCertFingerprints(): string[] {
return this.developerCertificates.map(cert =>
crypto.createHash('sha1').update(new Uint8Array(cert)).digest('hex').toUpperCase()
);
private genDerCertFingerprint(): string {
return crypto
.createHash('sha1')
.update(new Uint8Array(this.data.developerCertificate))
.digest('hex')
.toUpperCase();
}
}
+1
View File
@@ -141,6 +141,7 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
credentials: nullthrows(credentials, 'credentials must exist for non-simulator builds'),
scheme,
buildConfiguration,
derivedDataPath: './build',
outputDirectory: './build',
clean: false,
logsDirectory,
+3
View File
@@ -10,6 +10,7 @@ interface ArchiveBuildOptions {
credentials: Credentials;
scheme: string;
buildConfiguration?: string;
derivedDataPath: string;
outputDirectory: string;
clean: boolean;
logsDirectory: string;
@@ -32,6 +33,7 @@ export async function createGymfileForArchiveBuild({
credentials,
scheme,
buildConfiguration,
derivedDataPath,
entitlements,
outputDirectory,
logsDirectory,
@@ -58,6 +60,7 @@ export async function createGymfileForArchiveBuild({
KEYCHAIN_PATH: credentials.keychainPath,
SCHEME: scheme,
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
DERIVED_DATA_PATH: derivedDataPath,
OUTPUT_DIRECTORY: outputDirectory,
EXPORT_METHOD: credentials.distributionType,
CLEAN: String(clean),
@@ -74,6 +74,8 @@ function createStepsForIosSimulatorBuild({
globalCtx,
buildToolsContext,
}: HelperFunctionsInput): BuildStep[] {
const evictUsedBefore = new Date();
const calculateEASUpdateRuntimeVersion =
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'calculate_eas_update_runtime_version',
@@ -106,6 +108,11 @@ function createStepsForIosSimulatorBuild({
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
calculateEASUpdateRuntimeVersion,
installPods,
createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
platform: Platform.IOS,
},
}),
configureEASUpdate,
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
@@ -122,6 +129,11 @@ function createStepsForIosSimulatorBuild({
createFindAndUploadBuildArtifactsBuildFunction(
buildToolsContext
).createBuildStepFromFunctionCall(globalCtx),
createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
platform: Platform.IOS,
},
}),
];
}
@@ -195,9 +207,9 @@ function createStepsForIosBuildWithCredentials({
),
resolveAppleTeamIdFromCredentials,
prebuildStep,
restoreCache,
calculateEASUpdateRuntimeVersion,
installPods,
restoreCache,
configureEASUpdate,
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx),
@@ -1,4 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { Ios, Platform } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -7,7 +7,10 @@ import {
BuildStepInputValueTypeName,
spawnAsync,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'fs';
import nullthrows from 'nullthrows';
import path from 'path';
import { sendCcacheStatsAsync } from './ccacheStats';
import { decompressCacheAsync, downloadCacheAsync, downloadPublicCacheAsync } from './restoreCache';
@@ -17,6 +20,11 @@ import {
getCcachePath,
} from '../../utils/cacheKey';
import { TurtleFetchError } from '../../utils/turtleFetch';
import { XCODE_CACHE_KEY_PREFIX, generateXcodeCacheKeyAsync } from '../../utils/xcodeCacheKey';
// Stable path outside DerivedData where cached pod products are restored.
// xcodebuild archive wipes ArchiveIntermediates, so products must live elsewhere.
export const PODS_CACHE_DIR = '/tmp/pods-cache';
export function createRestoreBuildCacheFunction(): BuildFunction {
return new BuildFunction({
@@ -50,6 +58,23 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
if (platform === Platform.IOS) {
const simulator = (stepCtx.global.staticContext.job as Ios.Job).simulator;
const cacheHit = await restoreXcodeCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
simulator,
});
await patchPodsXcodeprojAsync({
logger,
workingDirectory,
env,
cacheHit,
});
}
},
});
}
@@ -176,6 +201,191 @@ export async function restoreCcacheAsync({
}
}
export async function restoreXcodeCacheAsync({
logger,
workingDirectory,
env,
secrets,
simulator,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
simulator?: boolean;
}): Promise<boolean> {
if (env.EAS_XCODE_CACHE !== '1') {
return false;
}
const robotAccessToken = nullthrows(
secrets?.robotAccessToken,
'Robot access token is required for cache operations'
);
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
try {
const cacheKey = await generateXcodeCacheKeyAsync(workingDirectory, simulator);
logger.info(`Restoring Xcode cache key: ${cacheKey}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const { archivePath, matchedKey } = await downloadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
paths: ['pods-products-v2'],
key: cacheKey,
keyPrefixes: [`${XCODE_CACHE_KEY_PREFIX}${simulator ? 'sim' : 'device'}-`],
platform: Platform.IOS,
});
await fs.promises.mkdir(PODS_CACHE_DIR, { recursive: true });
await spawn('tar', ['xzf', archivePath, '-C', PODS_CACHE_DIR]);
// Modulemap files contain hardcoded absolute paths from the original build.
// Rewrite them to point to the cache directory instead.
await rewriteModulemapPathsAsync(PODS_CACHE_DIR, logger);
logger.info(
`Xcode cache restored to ${PODS_CACHE_DIR} ${matchedKey === cacheKey ? '(direct hit)' : '(prefix match)'}`
);
return true;
} catch (err: unknown) {
if (err instanceof TurtleFetchError && err.response?.status === 404) {
logger.info('No Xcode cache found for this key');
} else {
logger.warn('Failed to restore Xcode cache: ', err);
}
return false;
}
}
async function rewriteModulemapPathsAsync(cacheDir: string, logger: bunyan): Promise<void> {
let modulemapFiles: string;
try {
const result = await spawn('find', [cacheDir, '-name', '*.modulemap'], { stdio: 'pipe' });
modulemapFiles = result.stdout.trim();
} catch {
return;
}
if (!modulemapFiles) {
return;
}
let patchCount = 0;
for (const filePath of modulemapFiles.split('\n')) {
if (!filePath) continue;
const content = await fs.promises.readFile(filePath, 'utf-8');
// Match absolute paths ending with /Release-<platform>/ (iphoneos, iphonesimulator, etc.)
const replaced = content.replace(/"[^"]*\/Release-[a-z]+\//g, `"${cacheDir}/`);
if (replaced !== content) {
await fs.promises.writeFile(filePath, replaced);
patchCount++;
}
}
logger.info(`Rewrote paths in ${patchCount} modulemap files`);
}
const PATCH_RUBY_SCRIPT = `
require 'xcodeproj'
project = Xcodeproj::Project.open('ios/Pods/Pods.xcodeproj')
umbrella_targets = project.native_targets.select { |t| t.name.start_with?('Pods-') }
abort "ERROR: No Pods-* umbrella targets found!" if umbrella_targets.empty?
umbrella_names = umbrella_targets.map(&:name)
puts "Keeping umbrella targets: #{umbrella_names.join(', ')}"
pod_targets = project.native_targets.reject { |t| t.name.start_with?('Pods-') }
puts "Found #{pod_targets.size} pod targets to remove"
umbrella_targets.each do |umbrella|
deps = umbrella.dependencies.to_a
deps.each { |dep| dep.remove_from_project }
puts "Cleared #{deps.size} dependencies from #{umbrella.name}"
end
pod_targets.each do |target|
target.remove_from_project
end
project.save
puts "Saved — #{project.native_targets.size} target(s) remaining"
`;
export async function patchPodsXcodeprojAsync({
logger,
workingDirectory,
env,
cacheHit,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
cacheHit: boolean;
}): Promise<void> {
if (env.EAS_XCODE_CACHE !== '1' || !cacheHit) {
return;
}
logger.info('Patching Pods.xcodeproj to remove pod targets...');
await spawnAsync('ruby', ['-e', PATCH_RUBY_SCRIPT], {
cwd: workingDirectory,
logger,
env,
stdio: 'pipe',
});
logger.info('Pods.xcodeproj patched successfully');
// Point PODS_CONFIGURATION_BUILD_DIR to the stable cache directory
// so Xcode finds pre-built products there instead of in DerivedData
await patchPodXcconfigAsync(workingDirectory, logger);
logger.info('Pods xcconfig patched successfully');
}
async function patchPodXcconfigAsync(workingDirectory: string, logger: bunyan): Promise<void> {
const targetSupportDir = path.join(workingDirectory, 'ios', 'Pods', 'Target Support Files');
let patchCount = 0;
const entries = await fs.promises.readdir(targetSupportDir);
for (const entry of entries) {
if (!entry.startsWith('Pods-')) {
continue;
}
const dir = path.join(targetSupportDir, entry);
const stat = await fs.promises.stat(dir);
if (!stat.isDirectory()) {
continue;
}
const files = await fs.promises.readdir(dir);
for (const file of files) {
if (!file.endsWith('.xcconfig')) {
continue;
}
const filePath = path.join(dir, file);
let content = await fs.promises.readFile(filePath, 'utf-8');
if (content.includes('PODS_CONFIGURATION_BUILD_DIR')) {
content = content.replace(
/PODS_CONFIGURATION_BUILD_DIR\s*=\s*.*/g,
`PODS_CONFIGURATION_BUILD_DIR = ${PODS_CACHE_DIR}`
);
await fs.promises.writeFile(filePath, content);
patchCount++;
logger.info(`Patched ${filePath}`);
}
}
}
logger.info(`Patched ${patchCount} xcconfig files`);
}
export async function cacheStatsAsync({
logger,
env,
@@ -1,4 +1,4 @@
import { Platform } from '@expo/eas-build-job';
import { Ios, Platform } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -9,9 +9,11 @@ import {
} from '@expo/steps';
import fs from 'fs';
import nullthrows from 'nullthrows';
import path from 'path';
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
import { generateXcodeCacheKeyAsync } from '../../utils/xcodeCacheKey';
export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFunction {
return new BuildFunction({
@@ -38,6 +40,17 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
);
}
if (platform === Platform.IOS) {
const simulator = (stepCtx.global.staticContext.job as Ios.Job).simulator;
await saveXcodeCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
simulator,
});
}
await saveCcacheAsync({
logger,
workingDirectory,
@@ -135,3 +148,121 @@ export async function saveCcacheAsync({
logger.error({ err }, 'Failed to save cache');
}
}
export async function saveXcodeCacheAsync({
logger,
workingDirectory,
env,
secrets,
simulator,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
simulator?: boolean;
}): Promise<void> {
if (env.EAS_XCODE_CACHE !== '1') {
return;
}
const productsPath = await findBuildProductsPathAsync(workingDirectory, logger);
if (!productsPath) {
logger.warn('No build products found, skipping Xcode cache save');
return;
}
logger.info(`Found build products at: ${productsPath}`);
const cachePaths = ['pods-products-v2'];
try {
const cacheKey = await generateXcodeCacheKeyAsync(workingDirectory, simulator);
logger.info(`Saving Xcode cache key: ${cacheKey}`);
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const robotAccessToken = nullthrows(
secrets?.robotAccessToken,
'Robot access token is required for cache operations'
);
const expoApiServerURL = nullthrows(env.__API_SERVER_URL, '__API_SERVER_URL is not set');
logger.info('Compressing Xcode build products...');
const { archivePath } = await compressCacheAsync({
paths: ['.'],
workingDirectory: productsPath,
verbose: env.EXPO_DEBUG === '1',
logger,
});
const { size } = await fs.promises.stat(archivePath);
await uploadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
archivePath,
key: cacheKey,
paths: cachePaths,
size,
platform: Platform.IOS,
});
logger.info('Xcode cache saved successfully');
} catch (err) {
logger.error({ err }, 'Failed to save Xcode cache');
}
}
async function findBuildProductsPathAsync(
workingDirectory: string,
logger: bunyan
): Promise<string | null> {
const iosBuildDir = path.join(workingDirectory, 'ios', 'build');
// Archive build: Build/Intermediates.noindex/ArchiveIntermediates/<AppName>/BuildProductsPath/Release-iphoneos/
const archiveIntermediatesDir = path.join(
iosBuildDir,
'Build',
'Intermediates.noindex',
'ArchiveIntermediates'
);
try {
const entries = await fs.promises.readdir(archiveIntermediatesDir);
for (const entry of entries) {
const buildProductsPath = path.join(archiveIntermediatesDir, entry, 'BuildProductsPath');
try {
const configs = await fs.promises.readdir(buildProductsPath);
const releaseDir = configs.find(c => c.startsWith('Release-'));
if (releaseDir) {
const fullPath = path.join(buildProductsPath, releaseDir);
logger.info(`Found archive build products: ${fullPath}`);
return fullPath;
}
} catch {
// No BuildProductsPath in this entry
}
}
} catch {
// No ArchiveIntermediates directory
}
// Simulator build: Build/Products/Release-iphonesimulator/
const productsDir = path.join(iosBuildDir, 'Build', 'Products');
try {
const configs = await fs.promises.readdir(productsDir);
const releaseDir = configs.find(c => c.startsWith('Release-'));
if (releaseDir) {
const fullPath = path.join(productsDir, releaseDir);
logger.info(`Found simulator build products: ${fullPath}`);
return fullPath;
}
} catch {
// No Products directory
}
logger.info('No build products found');
return null;
}
@@ -33,7 +33,7 @@ export async function runGradleCommand({
return line;
}
},
env: { ...env, ...extraEnv, LC_ALL: 'C.UTF-8' },
env: { ...env, ...extraEnv },
});
if (env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
adjustOOMScore(spawnPromise, logger);
@@ -1,5 +1,4 @@
import { AndroidConfig } from '@expo/config-plugins';
import { Android } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { templateString } from '@expo/template-file';
import fs from 'fs-extra';
@@ -7,7 +6,6 @@ import path from 'path';
import { EasBuildConfigureVersionGradleTemplate } from '../../../templates/EasBuildConfigureVersionGradle';
import { EasBuildInjectAndroidCredentialsGradle } from '../../../templates/EasBuildInjectAndroidCredentialsGradle';
import { BuildContext } from '../../../context';
const APPLY_EAS_BUILD_INJECT_CREDENTIALS_GRADLE_LINE =
'apply from: "./eas-build-inject-android-credentials.gradle"';
@@ -53,26 +51,6 @@ async function deleteEasBuildConfigureVersionGradle(workingDir: string): Promise
await fs.remove(targetPath);
}
let legacyEasBuildGradleWarningEmitted = false;
export async function warnIfLegacyEasBuildGradleExists(
ctx: BuildContext<Android.Job>
): Promise<void> {
const legacyGradlePath = getLegacyEasBuildGradlePath(ctx.getReactNativeProjectDirectory());
if (
(await fs.pathExists(legacyGradlePath)) &&
(process.env.NODE_ENV === 'test' || !legacyEasBuildGradleWarningEmitted)
) {
legacyEasBuildGradleWarningEmitted = true;
ctx.logger.warn('eas-build.gradle script is deprecated, please remove it from your project.');
ctx.markBuildPhaseHasWarnings();
}
}
function getLegacyEasBuildGradlePath(projectRoot: string): string {
return path.join(projectRoot, 'android/app/eas-build.gradle');
}
function getEasBuildInjectCredentialsGradlePath(workingDir: string): string {
return path.join(workingDir, 'android/app/eas-build-inject-android-credentials.gradle');
}
@@ -135,7 +113,7 @@ function hasLine(haystack: string, needle: string): boolean {
haystack
.replace(/\r\n/g, '\n')
.split('\n')
// Check for both single and double quotes.
// Check for both single and double quotes
.some(line => line === needle || line === needle.replace(/"/g, "'"))
);
}
@@ -42,7 +42,6 @@ export default class ProvisioningProfile {
private readonly profilePath: string;
private profileData?: ProvisioningProfileData;
private developerCertificates: Buffer[] = [];
constructor(
private readonly profile: Buffer,
@@ -74,12 +73,11 @@ export default class ProvisioningProfile {
}
public verifyCertificate(fingerprint: string): void {
const devCertFingerprints = this.getAllDerCertFingerprints();
if (!devCertFingerprints.includes(fingerprint)) {
const devCertFingerprint = this.genDerCertFingerprint();
if (devCertFingerprint !== fingerprint) {
throw new errors.CredentialsDistCertMismatchError(
`Provisioning profile and distribution certificate don't match.\n` +
`Profile's certificate fingerprints = [${devCertFingerprints.join(', ')}], ` +
`distribution certificate fingerprint = ${fingerprint}`
`Provisioning profile and distribution certificate don't match.
Profile's certificate fingerprint = ${devCertFingerprint}, distribution certificate fingerprint = ${fingerprint}`
);
}
}
@@ -112,10 +110,6 @@ export default class ProvisioningProfile {
] as string;
const bundleIdentifier = applicationIdentifier.replace(/^.+?\./, '');
this.developerCertificates = (plistData.DeveloperCertificates as string[]).map((cert: string) =>
Buffer.from(cert, 'base64')
);
this.profileData = {
path: this.profilePath,
target: this.target,
@@ -123,7 +117,7 @@ export default class ProvisioningProfile {
teamId: (plistData.TeamIdentifier as string[])[0],
uuid: plistData.UUID as string,
name: plistData.Name as string,
developerCertificate: this.developerCertificates[0],
developerCertificate: Buffer.from((plistData.DeveloperCertificates as string[])[0], 'base64'),
certificateCommonName: this.certificateCommonName,
distributionType: this.resolveDistributionType(plistData),
};
@@ -139,9 +133,11 @@ export default class ProvisioningProfile {
}
}
private getAllDerCertFingerprints(): string[] {
return this.developerCertificates.map(cert =>
crypto.createHash('sha1').update(new Uint8Array(cert)).digest('hex').toUpperCase()
);
private genDerCertFingerprint(): string {
return crypto
.createHash('sha1')
.update(new Uint8Array(this.data.developerCertificate))
.digest('hex')
.toUpperCase();
}
}
@@ -0,0 +1,54 @@
export const EasBuildGradle = `// Build integration with EAS
import java.nio.file.Paths
def versionCodeFromEnv = System.getenv("EAS_BUILD_ANDROID_VERSION_CODE")
def versionNameFromEnv = System.getenv("EAS_BUILD_ANDROID_VERSION_NAME")
android {
defaultConfig {
if (versionCodeFromEnv) {
versionCode = Integer.parseInt(versionCodeFromEnv)
}
if (versionNameFromEnv) {
versionName = versionNameFromEnv
}
}
applicationVariants.all { variant ->
variant.outputs.each { output ->
if (versionCodeFromEnv) {
output.versionCodeOverride = Integer.parseInt(versionCodeFromEnv)
}
if (versionNameFromEnv) {
output.versionNameOverride = versionNameFromEnv
}
}
}
signingConfigs {
release {
def credentialsJson = Paths.get(System.getenv("EAS_BUILD_WORKINGDIR")).resolve("credentials.json").toFile();
def credentials = new groovy.json.JsonSlurper().parse(credentialsJson)
def keystorePath = Paths.get(credentials.android.keystore.keystorePath);
storeFile keystorePath.toFile()
storePassword credentials.android.keystore.keystorePassword
keyAlias credentials.android.keystore.keyAlias
if (credentials.android.keystore.containsKey("keyPassword")) {
keyPassword credentials.android.keystore.keyPassword
} else {
// key password is required by Gradle, but PKCS keystores don't have one
// using the keystore password seems to satisfy the requirement
keyPassword credentials.android.keystore.keystorePassword
}
}
}
buildTypes {
release {
signingConfig android.signingConfigs.release
}
debug {
signingConfig android.signingConfigs.release
}
}
}`;
@@ -17,6 +17,8 @@ export_options({
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain <%- KEYCHAIN_PATH %>\\""
derived_data_path("<%- DERIVED_DATA_PATH %>")
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
@@ -0,0 +1,23 @@
import * as PackageManagerUtils from '@expo/package-manager';
import { hashFiles } from '@expo/steps';
import path from 'path';
import { findPackagerRootDir } from './packageManager';
export const XCODE_CACHE_KEY_PREFIX = 'ios-xcode-cache-';
export async function generateXcodeCacheKeyAsync(
workingDirectory: string,
simulator?: boolean
): Promise<string> {
const packagerRunDir = findPackagerRootDir(workingDirectory);
const manager = PackageManagerUtils.createForProject(packagerRunDir);
const lockPath = path.join(packagerRunDir, manager.lockFile);
const variant = simulator ? 'sim' : 'device';
try {
return `${XCODE_CACHE_KEY_PREFIX}${variant}-${hashFiles([lockPath])}`;
} catch (err: any) {
throw new Error(`Failed to read lockfile for Xcode cache key generation: ${err.message}`);
}
}
+1 -10
View File
@@ -43,10 +43,6 @@ export default class BuildDev extends EasCommand {
description: `Name of the build profile from eas.json. It must be a profile allowing to create emulator/simulator internal distribution dev client builds. The "${DEFAULT_EAS_BUILD_RUN_PROFILE_NAME}" build profile will be selected by default.`,
helpValue: 'PROFILE_NAME',
}),
'skip-build-if-not-found': Flags.boolean({
description: 'Skip build if no successful build with matching fingerprint is found.',
default: false,
}),
};
static override contextDefinition = {
@@ -132,15 +128,10 @@ export default class BuildDev extends EasCommand {
await this.startDevServerAsync({ projectDir, platform });
return;
} else {
Log.warn('Artifacts for this build expired.');
Log.warn('Artifacts for this build expired. New build will be started.');
}
}
if (flags['skip-build-if-not-found']) {
Log.log('No successful build with matching fingerprint found. Skipping build.');
return;
}
Log.log('🚀 No successful build with matching fingerprint found. Starting a new build...');
const previousBuildsForSelectedProfile = await this.getBuildsAsync({