mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
150b8c5cdf | ||
|
|
f256e1f044 | ||
|
|
9f5e8d5547 | ||
|
|
88506d01a6 | ||
|
|
77e36e0743 | ||
|
|
ce8f438610 |
No files matched your search
@@ -1,6 +1,7 @@
|
||||
name: CHANGELOG.md PR check
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
|
||||
@@ -8,6 +8,8 @@ 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
|
||||
@@ -28,6 +30,7 @@ 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))
|
||||
|
||||
@@ -42,7 +42,7 @@ export async function runGradleCommand(
|
||||
return line;
|
||||
}
|
||||
},
|
||||
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx) },
|
||||
env: { ...ctx.env, ...extraEnv, ...resolveVersionOverridesEnvs(ctx), LC_ALL: 'C.UTF-8' },
|
||||
});
|
||||
if (ctx.env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
|
||||
adjustOOMScore(spawnPromise, logger);
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
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, "'"))
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
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));
|
||||
});
|
||||
});
|
||||
@@ -10,13 +10,17 @@ 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,
|
||||
@@ -101,10 +105,22 @@ 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 configureBuildGradle(ctx);
|
||||
await injectCredentialsGradleConfig(ctx.logger, ctx.getReactNativeProjectDirectory());
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -16,13 +16,8 @@ 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,
|
||||
patchPodsXcodeprojAsync,
|
||||
restoreCcacheAsync,
|
||||
restoreXcodeCacheAsync,
|
||||
} from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync, saveXcodeCacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
|
||||
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
|
||||
import { uploadApplicationArchive } from '../utils/artifacts';
|
||||
import {
|
||||
configureExpoUpdatesIfInstalledAsync,
|
||||
@@ -56,7 +51,6 @@ 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();
|
||||
@@ -93,23 +87,10 @@ 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 () => {
|
||||
@@ -207,13 +188,6 @@ 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,8 +19,6 @@ 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")
|
||||
|
||||
@@ -44,8 +42,6 @@ export_options({
|
||||
|
||||
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
|
||||
|
||||
derived_data_path("./build")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
@@ -71,8 +67,6 @@ export_options({
|
||||
|
||||
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
|
||||
|
||||
derived_data_path("./build")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
@@ -100,8 +94,6 @@ export_options({
|
||||
|
||||
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
|
||||
|
||||
derived_data_path("./build")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("/tmp/logs")
|
||||
|
||||
|
||||
@@ -57,7 +57,6 @@ describe('gymfile', () => {
|
||||
credentials: mockCredentials,
|
||||
scheme: 'MyApp',
|
||||
buildConfiguration: 'Release',
|
||||
derivedDataPath: './build',
|
||||
outputDirectory: '/tmp/output',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
@@ -94,7 +93,6 @@ describe('gymfile', () => {
|
||||
outputFile,
|
||||
credentials: mockCredentials,
|
||||
scheme: 'TestScheme',
|
||||
derivedDataPath: './build',
|
||||
outputDirectory: '/tmp/output',
|
||||
clean: false,
|
||||
logsDirectory: '/tmp/logs',
|
||||
@@ -132,7 +130,6 @@ describe('gymfile', () => {
|
||||
credentials: mockCredentials,
|
||||
scheme: 'MyApp',
|
||||
buildConfiguration: 'Release',
|
||||
derivedDataPath: './build',
|
||||
outputDirectory: '/tmp/output',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
@@ -194,7 +191,6 @@ describe('gymfile', () => {
|
||||
outputFile,
|
||||
credentials: mockCredentials,
|
||||
scheme: 'MyApp',
|
||||
derivedDataPath: './build',
|
||||
outputDirectory: '/tmp/output',
|
||||
clean: true,
|
||||
logsDirectory: '/tmp/logs',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Ios } from '@expo/eas-build-job';
|
||||
import { createLogger } from '@expo/logger';
|
||||
import crypto from 'crypto';
|
||||
|
||||
import { provisioningProfile } from './fixtures';
|
||||
import { BuildContext } from '../../../context';
|
||||
@@ -73,5 +74,45 @@ 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,6 +43,7 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
|
||||
|
||||
private readonly profilePath: string;
|
||||
private profileData?: ProvisioningProfileData;
|
||||
private developerCertificates: Buffer[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly ctx: BuildContext<TJob>,
|
||||
@@ -77,11 +78,12 @@ export default class ProvisioningProfile<TJob extends Ios.Job> {
|
||||
}
|
||||
|
||||
public verifyCertificate(fingerprint: string): void {
|
||||
const devCertFingerprint = this.genDerCertFingerprint();
|
||||
if (devCertFingerprint !== fingerprint) {
|
||||
const devCertFingerprints = this.getAllDerCertFingerprints();
|
||||
if (!devCertFingerprints.includes(fingerprint)) {
|
||||
throw new errors.CredentialsDistCertMismatchError(
|
||||
`Provisioning profile and distribution certificate don't match.
|
||||
Profile's certificate fingerprint = ${devCertFingerprint}, distribution certificate fingerprint = ${fingerprint}`
|
||||
`Provisioning profile and distribution certificate don't match.\n` +
|
||||
`Profile's certificate fingerprints = [${devCertFingerprints.join(', ')}], ` +
|
||||
`distribution certificate fingerprint = ${fingerprint}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -114,6 +116,10 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
|
||||
] 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,
|
||||
@@ -121,7 +127,7 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
|
||||
teamId: (plistData.TeamIdentifier as string[])[0],
|
||||
uuid: plistData.UUID as string,
|
||||
name: plistData.Name as string,
|
||||
developerCertificate: Buffer.from((plistData.DeveloperCertificates as string[])[0], 'base64'),
|
||||
developerCertificate: this.developerCertificates[0],
|
||||
certificateCommonName: this.certificateCommonName,
|
||||
distributionType: this.resolveDistributionType(plistData),
|
||||
};
|
||||
@@ -137,11 +143,9 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
|
||||
}
|
||||
}
|
||||
|
||||
private genDerCertFingerprint(): string {
|
||||
return crypto
|
||||
.createHash('sha1')
|
||||
.update(new Uint8Array(this.data.developerCertificate))
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
private getAllDerCertFingerprints(): string[] {
|
||||
return this.developerCertificates.map(cert =>
|
||||
crypto.createHash('sha1').update(new Uint8Array(cert)).digest('hex').toUpperCase()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -141,7 +141,6 @@ 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,
|
||||
|
||||
@@ -10,7 +10,6 @@ interface ArchiveBuildOptions {
|
||||
credentials: Credentials;
|
||||
scheme: string;
|
||||
buildConfiguration?: string;
|
||||
derivedDataPath: string;
|
||||
outputDirectory: string;
|
||||
clean: boolean;
|
||||
logsDirectory: string;
|
||||
@@ -33,7 +32,6 @@ export async function createGymfileForArchiveBuild({
|
||||
credentials,
|
||||
scheme,
|
||||
buildConfiguration,
|
||||
derivedDataPath,
|
||||
entitlements,
|
||||
outputDirectory,
|
||||
logsDirectory,
|
||||
@@ -60,7 +58,6 @@ 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,8 +74,6 @@ function createStepsForIosSimulatorBuild({
|
||||
globalCtx,
|
||||
buildToolsContext,
|
||||
}: HelperFunctionsInput): BuildStep[] {
|
||||
const evictUsedBefore = new Date();
|
||||
|
||||
const calculateEASUpdateRuntimeVersion =
|
||||
calculateEASUpdateRuntimeVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
id: 'calculate_eas_update_runtime_version',
|
||||
@@ -108,11 +106,6 @@ function createStepsForIosSimulatorBuild({
|
||||
createPrebuildBuildFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
createRestoreBuildCacheFunction().createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
}),
|
||||
configureEASUpdate,
|
||||
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
|
||||
? [
|
||||
@@ -129,11 +122,6 @@ function createStepsForIosSimulatorBuild({
|
||||
createFindAndUploadBuildArtifactsBuildFunction(
|
||||
buildToolsContext
|
||||
).createBuildStepFromFunctionCall(globalCtx),
|
||||
createSaveBuildCacheFunction(evictUsedBefore).createBuildStepFromFunctionCall(globalCtx, {
|
||||
callInputs: {
|
||||
platform: Platform.IOS,
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -207,9 +195,9 @@ function createStepsForIosBuildWithCredentials({
|
||||
),
|
||||
resolveAppleTeamIdFromCredentials,
|
||||
prebuildStep,
|
||||
restoreCache,
|
||||
calculateEASUpdateRuntimeVersion,
|
||||
installPods,
|
||||
restoreCache,
|
||||
configureEASUpdate,
|
||||
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Ios, Platform } from '@expo/eas-build-job';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -7,10 +7,7 @@ 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';
|
||||
@@ -20,11 +17,6 @@ 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({
|
||||
@@ -58,23 +50,6 @@ 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,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -201,191 +176,6 @@ 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 { Ios, Platform } from '@expo/eas-build-job';
|
||||
import { Platform } from '@expo/eas-build-job';
|
||||
import { bunyan } from '@expo/logger';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
@@ -9,11 +9,9 @@ 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({
|
||||
@@ -40,17 +38,6 @@ 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,
|
||||
@@ -148,121 +135,3 @@ 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 },
|
||||
env: { ...env, ...extraEnv, LC_ALL: 'C.UTF-8' },
|
||||
});
|
||||
if (env.EAS_BUILD_RUNNER === 'eas-build' && process.platform === 'linux') {
|
||||
adjustOOMScore(spawnPromise, logger);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -6,6 +7,7 @@ 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"';
|
||||
@@ -51,6 +53,26 @@ 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');
|
||||
}
|
||||
@@ -113,7 +135,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,6 +42,7 @@ export default class ProvisioningProfile {
|
||||
|
||||
private readonly profilePath: string;
|
||||
private profileData?: ProvisioningProfileData;
|
||||
private developerCertificates: Buffer[] = [];
|
||||
|
||||
constructor(
|
||||
private readonly profile: Buffer,
|
||||
@@ -73,11 +74,12 @@ export default class ProvisioningProfile {
|
||||
}
|
||||
|
||||
public verifyCertificate(fingerprint: string): void {
|
||||
const devCertFingerprint = this.genDerCertFingerprint();
|
||||
if (devCertFingerprint !== fingerprint) {
|
||||
const devCertFingerprints = this.getAllDerCertFingerprints();
|
||||
if (!devCertFingerprints.includes(fingerprint)) {
|
||||
throw new errors.CredentialsDistCertMismatchError(
|
||||
`Provisioning profile and distribution certificate don't match.
|
||||
Profile's certificate fingerprint = ${devCertFingerprint}, distribution certificate fingerprint = ${fingerprint}`
|
||||
`Provisioning profile and distribution certificate don't match.\n` +
|
||||
`Profile's certificate fingerprints = [${devCertFingerprints.join(', ')}], ` +
|
||||
`distribution certificate fingerprint = ${fingerprint}`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -110,6 +112,10 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
|
||||
] 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,
|
||||
@@ -117,7 +123,7 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
|
||||
teamId: (plistData.TeamIdentifier as string[])[0],
|
||||
uuid: plistData.UUID as string,
|
||||
name: plistData.Name as string,
|
||||
developerCertificate: Buffer.from((plistData.DeveloperCertificates as string[])[0], 'base64'),
|
||||
developerCertificate: this.developerCertificates[0],
|
||||
certificateCommonName: this.certificateCommonName,
|
||||
distributionType: this.resolveDistributionType(plistData),
|
||||
};
|
||||
@@ -133,11 +139,9 @@ Profile's certificate fingerprint = ${devCertFingerprint}, distribution certific
|
||||
}
|
||||
}
|
||||
|
||||
private genDerCertFingerprint(): string {
|
||||
return crypto
|
||||
.createHash('sha1')
|
||||
.update(new Uint8Array(this.data.developerCertificate))
|
||||
.digest('hex')
|
||||
.toUpperCase();
|
||||
private getAllDerCertFingerprints(): string[] {
|
||||
return this.developerCertificates.map(cert =>
|
||||
crypto.createHash('sha1').update(new Uint8Array(cert)).digest('hex').toUpperCase()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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,8 +17,6 @@ export_options({
|
||||
|
||||
export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain <%- KEYCHAIN_PATH %>\\""
|
||||
|
||||
derived_data_path("<%- DERIVED_DATA_PATH %>")
|
||||
|
||||
disable_xcpretty(true)
|
||||
buildlog_path("<%- LOGS_DIRECTORY %>")
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
@@ -43,6 +43,10 @@ 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 = {
|
||||
@@ -128,10 +132,15 @@ export default class BuildDev extends EasCommand {
|
||||
await this.startDevServerAsync({ projectDir, platform });
|
||||
return;
|
||||
} else {
|
||||
Log.warn('Artifacts for this build expired. New build will be started.');
|
||||
Log.warn('Artifacts for this build expired.');
|
||||
}
|
||||
}
|
||||
|
||||
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({
|
||||
|
||||
Reference in new issue
Block a user