Compare commits

...
Author SHA1 Message Date
Stanisław Chmiela 8a04ae6342 [worker] Revert Xcode build tool prewarming 2026-08-14 20:50:13 +02:00
Stanisław Chmiela 8443581d13 [worker] Prewarm Xcode interface build tools 2026-08-14 20:33:26 +02:00
Stanisław Chmiela aa6e610000 [build-tools] Add local Xcode compilation cache 2026-08-14 18:14:57 +02:00
Tomek Czajecki 3cc58e500f [eas-cli] set up TestFlight group for existing ASC apps (#4136)
Set up the internal TestFlight group during interactive iOS submissions when ascAppId is configured, with an explicit opt-out flag. Skip setup in non-interactive mode, reuse the shared setup implementation, and fix the TestFlight group URL shown on partial tester failures.
2026-08-14 17:04:46 +03:00
expo-tuft[bot]andtchayen 2a7cb5e1fe [eas-cli] Set the project icon from the app config on init (#4177)
* [eas-cli] Set the project icon from the app config on init

Reuses the project:icon:set upload path, and only fills an icon when the
project does not have one yet.

Co-authored-by: tchayen <[email protected]>

* [eas-cli] Update CHANGELOG.md

Co-authored-by: tchayen <[email protected]>

---------

Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com>
Co-authored-by: tchayen <[email protected]>
2026-08-14 14:18:52 +03:00
Stanisław Chmiela 289d579869 [build-tools] Add maestro-runner installation backend (#4186)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

I want to try `maestro-runner` as Maestro test runner.

# How

Added support for installation of `maestro-runner` through `eas/install_maestro`.

I can see how icky this is, but I want to allow easy opt-in with `EAS_MAESTRO_BACKEND=maestro-runner` and we won't have easy access to all environment variables in `WorkflowMaestroTestJob.ts`.

# Test Plan

CI should pass. Verified `maestro-runner --version` output.
2026-08-14 09:28:05 +02:00
Mustafa Abban 067c0e578f [build-tools] upload source maps from builds for Observe (#4170)
* [build-tools] Upload Observe source maps from EAS builds

* [build-tools] Fix source map upload after storage refactor

* [build] configure source map uploads in build profiles

* fix CHANGELOG formatting

* use SystemError in source map upload, rename prepare to resolve
2026-08-12 14:36:55 -04:00
William Grosset 520c63fd3e [eas-cli] Improve non-interactive error for unconfigured projects (#4153)
* [eas-cli] Improve non-interactive error for unconfigured projects

* [eas-cli] Format non-interactive project error with indented commands

* [eas-cli] Skip redundant "not configured" warning in non-interactive mode

* [eas-cli] Show the non-interactive project error on all unconfigured paths
2026-08-11 10:55:31 -07:00
Expo CI 221e01568c update CHANGELOG.md and bump STAGING in cli-versions.json 2026-08-11 16:01:58 +00:00
53 changed files with 2836 additions and 251 deletions

No files matched your search

+14 -4
View File
@@ -8,15 +8,25 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [build-tools] Upload JavaScript source maps generated by builds. ([#4170](https://github.com/expo/eas-cli/pull/4170) by [@AbbanMustafa](https://github.com/AbbanMustafa))
- [eas-cli] Set the project icon on the EAS dashboard from the app config when running `eas init`, if the project does not have one yet. ([#4177](https://github.com/expo/eas-cli/pull/4177) by [@tchayen](https://github.com/tchayen))
### 🐛 Bug fixes
- [eas-cli] Make the non-interactive "EAS project not configured" error actionable on every path: list the exact `eas init --id` / `eas init --account` commands and the accounts you can create projects in, instead of suggesting bare `eas init`, which fails the same way. ([#4153](https://github.com/expo/eas-cli/pull/4153) by [@williamgrosset](https://github.com/williamgrosset))
- [eas-cli] Set up the internal TestFlight group and invite admin testers when submitting interactively with an existing `ascAppId`, with `--no-auto-testflight-setup` to disable it, and fix the TestFlight group URL printed when adding testers partially fails. ([#4136](https://github.com/expo/eas-cli/pull/4136) by [@tchayen](https://github.com/tchayen))
### 🧹 Chores
## [21.8.0](https://github.com/expo/eas-cli/releases/tag/v21.8.0) - 2026-08-11
### 🎉 New features
- [eas-cli] Add Supabase integration foundation: GraphQL client, shared integration helpers, and provisioning utilities. ([#4130](https://github.com/expo/eas-cli/pull/4130) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:connect` command. ([#4106](https://github.com/expo/eas-cli/pull/4106) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:dashboard` command. ([#4107](https://github.com/expo/eas-cli/pull/4107) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:disconnect` command. ([#4108](https://github.com/expo/eas-cli/pull/4108) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
### 🧹 Chores
## [21.7.1](https://github.com/expo/eas-cli/releases/tag/v21.7.1) - 2026-08-10
## [21.7.0](https://github.com/expo/eas-cli/releases/tag/v21.7.0) - 2026-08-07
+1 -1
View File
@@ -1,4 +1,4 @@
{
"STAGING": "21.7.1",
"STAGING": "21.8.0",
"PRODUCTION": "21.5.0"
}
@@ -0,0 +1,296 @@
#include <dlfcn.h>
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/*
* Xcode loads one plugin that implements the llcas_* API. This small shim loads
* Apple's local CAS plugin and forwards every API call to it. The only exception
* is the "remote-service-path" option: Xcode needs that option to schedule C and
* Objective-C cache tasks, but Apple's local plugin does not need a remote service.
* The shim accepts and discards that option. No cache data leaves the machine.
*/
typedef void *llcas_cas_options_t;
typedef void *llcas_cas_t;
typedef void *llcas_cancellable_t;
typedef uint32_t llcas_lookup_result_t;
typedef struct {
const uint8_t *data;
size_t size;
} llcas_digest_t;
typedef struct {
const void *data;
size_t size;
} llcas_data_t;
typedef struct {
uint64_t opaque;
} llcas_objectid_t;
typedef struct {
uint64_t opaque;
} llcas_loaded_object_t;
typedef struct {
uint64_t opaque_b;
uint64_t opaque_e;
} llcas_object_refs_t;
typedef void (*llcas_cas_load_object_cb)(void *, llcas_lookup_result_t,
llcas_loaded_object_t, char *);
typedef void (*llcas_actioncache_get_cb)(void *, llcas_lookup_result_t,
llcas_objectid_t, char *);
typedef void (*llcas_actioncache_put_cb)(void *, bool, char *);
static void *apple_plugin_handle;
static void *apple_plugin_symbol(const char *name) {
if (apple_plugin_handle == NULL) {
const char *path = getenv("EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN");
if (path == NULL || path[0] == '\0') {
path = "/Applications/Xcode.app/Contents/Developer/usr/lib/"
"libToolchainCASPlugin.dylib";
}
apple_plugin_handle = dlopen(path, RTLD_NOW | RTLD_LOCAL);
if (apple_plugin_handle == NULL) {
fprintf(stderr, "EAS local CAS plugin could not load %s: %s\n", path,
dlerror());
abort();
}
}
void *symbol = dlsym(apple_plugin_handle, name);
if (symbol == NULL) {
fprintf(stderr, "EAS local CAS plugin could not find %s: %s\n", name,
dlerror());
abort();
}
return symbol;
}
#define APPLE_PLUGIN(name, type) ((type)apple_plugin_symbol(#name))
void llcas_get_plugin_version(uint32_t *major, uint32_t *minor) {
typedef void (*fn_t)(uint32_t *, uint32_t *);
APPLE_PLUGIN(llcas_get_plugin_version, fn_t)(major, minor);
}
void llcas_string_dispose(char *value) {
typedef void (*fn_t)(char *);
APPLE_PLUGIN(llcas_string_dispose, fn_t)(value);
}
void llcas_cancellable_cancel(llcas_cancellable_t token) {
typedef void (*fn_t)(llcas_cancellable_t);
APPLE_PLUGIN(llcas_cancellable_cancel, fn_t)(token);
}
void llcas_cancellable_dispose(llcas_cancellable_t token) {
typedef void (*fn_t)(llcas_cancellable_t);
APPLE_PLUGIN(llcas_cancellable_dispose, fn_t)(token);
}
llcas_cas_options_t llcas_cas_options_create(void) {
typedef llcas_cas_options_t (*fn_t)(void);
return APPLE_PLUGIN(llcas_cas_options_create, fn_t)();
}
void llcas_cas_options_dispose(llcas_cas_options_t options) {
typedef void (*fn_t)(llcas_cas_options_t);
APPLE_PLUGIN(llcas_cas_options_dispose, fn_t)(options);
}
void llcas_cas_options_set_client_version(llcas_cas_options_t options,
uint32_t major, uint32_t minor) {
typedef void (*fn_t)(llcas_cas_options_t, uint32_t, uint32_t);
APPLE_PLUGIN(llcas_cas_options_set_client_version, fn_t)(options, major, minor);
}
void llcas_cas_options_set_ondisk_path(llcas_cas_options_t options,
const char *path) {
typedef void (*fn_t)(llcas_cas_options_t, const char *);
APPLE_PLUGIN(llcas_cas_options_set_ondisk_path, fn_t)(options, path);
}
bool llcas_cas_options_set_option(llcas_cas_options_t options, const char *name,
const char *value, char **error) {
if (name != NULL && strcmp(name, "remote-service-path") == 0) {
if (error != NULL) {
*error = NULL;
}
return false;
}
typedef bool (*fn_t)(llcas_cas_options_t, const char *, const char *,
char **);
return APPLE_PLUGIN(llcas_cas_options_set_option, fn_t)(options, name, value,
error);
}
llcas_cas_t llcas_cas_create(llcas_cas_options_t options, char **error) {
typedef llcas_cas_t (*fn_t)(llcas_cas_options_t, char **);
return APPLE_PLUGIN(llcas_cas_create, fn_t)(options, error);
}
void llcas_cas_dispose(llcas_cas_t cas) {
typedef void (*fn_t)(llcas_cas_t);
APPLE_PLUGIN(llcas_cas_dispose, fn_t)(cas);
}
int64_t llcas_cas_get_ondisk_size(llcas_cas_t cas, char **error) {
typedef int64_t (*fn_t)(llcas_cas_t, char **);
return APPLE_PLUGIN(llcas_cas_get_ondisk_size, fn_t)(cas, error);
}
bool llcas_cas_set_ondisk_size_limit(llcas_cas_t cas, int64_t size,
char **error) {
typedef bool (*fn_t)(llcas_cas_t, int64_t, char **);
return APPLE_PLUGIN(llcas_cas_set_ondisk_size_limit, fn_t)(cas, size, error);
}
bool llcas_cas_prune_ondisk_data(llcas_cas_t cas, char **error) {
typedef bool (*fn_t)(llcas_cas_t, char **);
return APPLE_PLUGIN(llcas_cas_prune_ondisk_data, fn_t)(cas, error);
}
char *llcas_cas_get_hash_schema_name(llcas_cas_t cas) {
typedef char *(*fn_t)(llcas_cas_t);
return APPLE_PLUGIN(llcas_cas_get_hash_schema_name, fn_t)(cas);
}
uint32_t llcas_digest_parse(llcas_cas_t cas, const char *printed,
uint8_t *bytes, size_t bytes_size, char **error) {
typedef uint32_t (*fn_t)(llcas_cas_t, const char *, uint8_t *, size_t,
char **);
return APPLE_PLUGIN(llcas_digest_parse, fn_t)(cas, printed, bytes,
bytes_size, error);
}
bool llcas_digest_print(llcas_cas_t cas, llcas_digest_t digest, char **printed,
char **error) {
typedef bool (*fn_t)(llcas_cas_t, llcas_digest_t, char **, char **);
return APPLE_PLUGIN(llcas_digest_print, fn_t)(cas, digest, printed, error);
}
bool llcas_cas_get_objectid(llcas_cas_t cas, llcas_digest_t digest,
llcas_objectid_t *object, char **error) {
typedef bool (*fn_t)(llcas_cas_t, llcas_digest_t, llcas_objectid_t *,
char **);
return APPLE_PLUGIN(llcas_cas_get_objectid, fn_t)(cas, digest, object, error);
}
llcas_digest_t llcas_objectid_get_digest(llcas_cas_t cas,
llcas_objectid_t object) {
typedef llcas_digest_t (*fn_t)(llcas_cas_t, llcas_objectid_t);
return APPLE_PLUGIN(llcas_objectid_get_digest, fn_t)(cas, object);
}
llcas_lookup_result_t llcas_cas_contains_object(llcas_cas_t cas,
llcas_objectid_t object,
bool globally, char **error) {
typedef llcas_lookup_result_t (*fn_t)(llcas_cas_t, llcas_objectid_t, bool,
char **);
return APPLE_PLUGIN(llcas_cas_contains_object, fn_t)(cas, object, globally,
error);
}
llcas_lookup_result_t llcas_cas_load_object(llcas_cas_t cas,
llcas_objectid_t object,
llcas_loaded_object_t *loaded,
char **error) {
typedef llcas_lookup_result_t (*fn_t)(llcas_cas_t, llcas_objectid_t,
llcas_loaded_object_t *, char **);
return APPLE_PLUGIN(llcas_cas_load_object, fn_t)(cas, object, loaded, error);
}
void llcas_cas_load_object_async(llcas_cas_t cas, llcas_objectid_t object,
void *context,
llcas_cas_load_object_cb callback,
llcas_cancellable_t *cancellable) {
typedef void (*fn_t)(llcas_cas_t, llcas_objectid_t, void *,
llcas_cas_load_object_cb, llcas_cancellable_t *);
APPLE_PLUGIN(llcas_cas_load_object_async,
fn_t)(cas, object, context, callback, cancellable);
}
bool llcas_cas_store_object(llcas_cas_t cas, llcas_data_t data,
const llcas_objectid_t *refs, size_t refs_count,
llcas_objectid_t *stored, char **error) {
typedef bool (*fn_t)(llcas_cas_t, llcas_data_t, const llcas_objectid_t *,
size_t, llcas_objectid_t *, char **);
return APPLE_PLUGIN(llcas_cas_store_object,
fn_t)(cas, data, refs, refs_count, stored, error);
}
llcas_data_t llcas_loaded_object_get_data(llcas_cas_t cas,
llcas_loaded_object_t object) {
typedef llcas_data_t (*fn_t)(llcas_cas_t, llcas_loaded_object_t);
return APPLE_PLUGIN(llcas_loaded_object_get_data, fn_t)(cas, object);
}
llcas_object_refs_t llcas_loaded_object_get_refs(llcas_cas_t cas,
llcas_loaded_object_t object) {
typedef llcas_object_refs_t (*fn_t)(llcas_cas_t, llcas_loaded_object_t);
return APPLE_PLUGIN(llcas_loaded_object_get_refs, fn_t)(cas, object);
}
size_t llcas_object_refs_get_count(llcas_cas_t cas, llcas_object_refs_t refs) {
typedef size_t (*fn_t)(llcas_cas_t, llcas_object_refs_t);
return APPLE_PLUGIN(llcas_object_refs_get_count, fn_t)(cas, refs);
}
llcas_objectid_t llcas_object_refs_get_id(llcas_cas_t cas,
llcas_object_refs_t refs,
size_t index) {
typedef llcas_objectid_t (*fn_t)(llcas_cas_t, llcas_object_refs_t, size_t);
return APPLE_PLUGIN(llcas_object_refs_get_id, fn_t)(cas, refs, index);
}
llcas_lookup_result_t llcas_actioncache_get_for_digest(llcas_cas_t cas,
llcas_digest_t digest,
llcas_objectid_t *result,
bool globally,
char **error) {
typedef llcas_lookup_result_t (*fn_t)(llcas_cas_t, llcas_digest_t,
llcas_objectid_t *, bool, char **);
return APPLE_PLUGIN(llcas_actioncache_get_for_digest,
fn_t)(cas, digest, result, globally, error);
}
void llcas_actioncache_get_for_digest_async(llcas_cas_t cas,
llcas_digest_t digest,
bool globally, void *context,
llcas_actioncache_get_cb callback,
llcas_cancellable_t *cancellable) {
typedef void (*fn_t)(llcas_cas_t, llcas_digest_t, bool, void *,
llcas_actioncache_get_cb, llcas_cancellable_t *);
APPLE_PLUGIN(llcas_actioncache_get_for_digest_async,
fn_t)(cas, digest, globally, context, callback, cancellable);
}
bool llcas_actioncache_put_for_digest(llcas_cas_t cas, llcas_digest_t digest,
llcas_objectid_t value, bool globally,
char **error) {
typedef bool (*fn_t)(llcas_cas_t, llcas_digest_t, llcas_objectid_t, bool,
char **);
return APPLE_PLUGIN(llcas_actioncache_put_for_digest,
fn_t)(cas, digest, value, globally, error);
}
void llcas_actioncache_put_for_digest_async(llcas_cas_t cas,
llcas_digest_t digest,
llcas_objectid_t value,
bool globally, void *context,
llcas_actioncache_put_cb callback,
llcas_cancellable_t *cancellable) {
typedef void (*fn_t)(llcas_cas_t, llcas_digest_t, llcas_objectid_t, bool,
void *, llcas_actioncache_put_cb, llcas_cancellable_t *);
APPLE_PLUGIN(llcas_actioncache_put_for_digest_async,
fn_t)(cas, digest, value, globally, context, callback,
cancellable);
}
@@ -4,6 +4,7 @@ import { Artifacts, BuildContext } from '../context';
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
import { maybeFindAndUploadBuildArtifacts } from '../utils/artifacts';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { maybeUploadSourceMapAsync } from '../utils/sourceMaps';
export async function runBuilderWithHooksAsync<T extends BuildJob>(
ctx: BuildContext<T>,
@@ -38,6 +39,9 @@ export async function runBuilderWithHooksAsync<T extends BuildJob>(
}
await ctx.runBuildPhase(BuildPhase.UPLOAD_BUILD_ARTIFACTS, async () => {
if (buildSuccess) {
await maybeUploadSourceMapAsync(ctx);
}
await maybeFindAndUploadBuildArtifacts(ctx, {
logger: ctx.logger,
});
+48 -23
View File
@@ -18,8 +18,12 @@ import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import { Sentry } from '../sentry';
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import {
cacheStatsAsync,
restoreCcacheAsync,
restoreXcodeCompilationCacheAsync,
} from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync, saveXcodeCompilationCacheAsync } from '../steps/functions/saveBuildCache';
import { uploadApplicationArchive } from '../utils/artifacts';
import {
configureExpoUpdatesIfInstalledAsync,
@@ -29,6 +33,7 @@ import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
import { isSourceMapUploadEnabled, resolveIosSourceMapPathAsync } from '../utils/sourceMaps';
const INSTALL_PODS_WARN_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
const INSTALL_PODS_KILL_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
@@ -99,13 +104,21 @@ async function buildInnerAsync(
return;
}
await ctx.cacheManager?.restoreCache(ctx);
await restoreCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
env: ctx.env,
secrets: ctx.job.secrets,
});
await Promise.all([
restoreCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
env: ctx.env,
secrets: ctx.job.secrets,
}),
restoreXcodeCompilationCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
}),
]);
});
await ctx.runBuildPhase(BuildPhase.INSTALL_PODS, async () => {
@@ -168,20 +181,24 @@ async function buildInnerAsync(
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
const scheme = resolveScheme(ctx);
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
const sourceMapPath = isSourceMapUploadEnabled(ctx)
? await resolveIosSourceMapPathAsync(ctx)
: null;
return await runFastlaneGym(ctx, {
credentials,
scheme,
buildConfiguration,
entitlements,
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
? {
extraEnv: {
extraEnv: {
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
? {
EXPO_UPDATES_FINGERPRINT_OVERRIDE:
resolvedExpoUpdatesRuntimeVersion?.runtimeVersion,
EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type,
},
}
: null),
}
: null),
...(sourceMapPath ? { SOURCEMAP_FILE: sourceMapPath } : null),
},
});
});
} finally {
@@ -237,14 +254,22 @@ async function buildInnerAsync(
return;
}
await ctx.cacheManager?.saveCache(ctx);
await saveCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
evictUsedBefore,
env: ctx.env,
secrets: ctx.job.secrets,
});
await Promise.all([
saveCcacheAsync({
logger: ctx.logger,
workingDirectory,
platform: ctx.job.platform,
evictUsedBefore,
env: ctx.env,
secrets: ctx.job.secrets,
}),
saveXcodeCompilationCacheAsync({
logger: ctx.logger,
workingDirectory,
env: ctx.env,
secrets: ctx.job.secrets,
}),
]);
});
await ctx.runBuildPhase(BuildPhase.CACHE_STATS, async () => {
@@ -0,0 +1,161 @@
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import os from 'os';
import path from 'path';
import { createMockLogger } from '../../__tests__/utils/logger';
import {
XCODE_COMPILATION_CACHE_ENV,
XCODE_COMPILATION_CACHE_RELATIVE_PATH,
compressXcodeCompilationCacheAsync,
decompressXcodeCompilationCacheAsync,
prepareXcodeCompilationCacheEnvAsync,
} from '../compilationCache';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
const DEVELOPER_DIRECTORY = '/Applications/Xcode.app/Contents/Developer';
const APPLE_PLUGIN_PATH = path.join(DEVELOPER_DIRECTORY, 'usr/lib/libToolchainCASPlugin.dylib');
const BUNDLED_SHIM_PATH = path.resolve(
__dirname,
'../../../bin/libeas_xcode_local_cas_plugin.dylib'
);
describe(prepareXcodeCompilationCacheEnvAsync, () => {
test('does nothing without the explicit opt-in', async () => {
const result = await prepareXcodeCompilationCacheEnvAsync({
derivedDataPath: '/project/ios/build',
env: {},
logger: createMockLogger(),
});
expect(result).toEqual({});
expect(spawn).not.toHaveBeenCalled();
});
test('enables compilation caching after validating the bundled arm64 shim', async () => {
vol.fromJSON({
[BUNDLED_SHIM_PATH]: 'bundled shim',
[APPLE_PLUGIN_PATH]: 'Apple plugin',
});
vol.mkdirSync(os.tmpdir(), { recursive: true });
jest.mocked(spawn).mockResolvedValue({} as any);
const result = await prepareXcodeCompilationCacheEnvAsync({
derivedDataPath: '/project/ios/build',
env: {
[XCODE_COMPILATION_CACHE_ENV]: '1',
DEVELOPER_DIR: DEVELOPER_DIRECTORY,
},
logger: createMockLogger(),
});
expect(spawn).toHaveBeenCalledWith(
'xcrun',
expect.arrayContaining([
'llvm-cas',
`--fcas-plugin-path=${BUNDLED_SHIM_PATH}`,
'--fcas-plugin-option=remote-service-path=/dev/null',
'--ingest',
BUNDLED_SHIM_PATH,
]),
expect.objectContaining({
env: expect.objectContaining({
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN: APPLE_PLUGIN_PATH,
}),
})
);
expect(result).toEqual({
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN: APPLE_PLUGIN_PATH,
GYM_XCARGS: `COMPILATION_CACHE_ENABLE_CACHING=YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS=YES COMPILATION_CACHE_ENABLE_PLUGIN=YES COMPILATION_CACHE_PLUGIN_PATH=${BUNDLED_SHIM_PATH} COMPILATION_CACHE_REMOTE_SERVICE_PATH=/dev/null`,
GYM_DERIVED_DATA_PATH: '/project/ios/build',
});
});
test('does not enable compilation caching when the bundled shim is missing', async () => {
const logger = createMockLogger();
vol.fromJSON({
[APPLE_PLUGIN_PATH]: 'Apple plugin',
});
const result = await prepareXcodeCompilationCacheEnvAsync({
derivedDataPath: '/project/ios/build',
env: {
[XCODE_COMPILATION_CACHE_ENV]: '1',
DEVELOPER_DIR: DEVELOPER_DIRECTORY,
},
logger,
});
expect(spawn).not.toHaveBeenCalled();
expect(result).toEqual({});
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
expect.stringContaining('compilation caching is disabled')
);
});
test('does not enable compilation caching when the bundled shim is incompatible', async () => {
const logger = createMockLogger();
vol.fromJSON({
[BUNDLED_SHIM_PATH]: 'bundled shim',
[APPLE_PLUGIN_PATH]: 'Apple plugin',
});
vol.mkdirSync(os.tmpdir(), { recursive: true });
jest.mocked(spawn).mockRejectedValue(new Error('incompatible plugin'));
const result = await prepareXcodeCompilationCacheEnvAsync({
derivedDataPath: '/project/ios/build',
env: {
[XCODE_COMPILATION_CACHE_ENV]: '1',
DEVELOPER_DIR: DEVELOPER_DIRECTORY,
},
logger,
});
expect(result).toEqual({});
expect(logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
expect.stringContaining('compilation caching is disabled')
);
});
});
describe('Xcode compilation cache archives', () => {
test('uses system tar to preserve sparse files', async () => {
const workingDirectory = '/working-directory';
vol.fromJSON({
[path.join(workingDirectory, XCODE_COMPILATION_CACHE_RELATIVE_PATH, 'cache.db')]: 'cache',
});
vol.mkdirSync(os.tmpdir(), { recursive: true });
jest.mocked(spawn).mockResolvedValue({} as any);
const { archivePath } = await compressXcodeCompilationCacheAsync({
workingDirectory,
env: {},
logger: createMockLogger(),
});
expect(spawn).toHaveBeenCalledWith(
'tar',
['-czf', archivePath, '-C', workingDirectory, XCODE_COMPILATION_CACHE_RELATIVE_PATH],
expect.any(Object)
);
await decompressXcodeCompilationCacheAsync({
archivePath,
workingDirectory,
env: {},
logger: createMockLogger(),
});
expect(spawn).toHaveBeenCalledWith(
'tar',
['-xzSf', archivePath, '-C', workingDirectory],
expect.any(Object)
);
});
});
@@ -6,6 +6,7 @@ import path from 'path';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import * as CompilationCache from '../compilationCache';
import { Credentials } from '../credentials/manager';
import { DistributionType } from '../credentials/provisioningProfile';
import { runFastlaneGym } from '../fastlane';
@@ -23,13 +24,19 @@ const WORKING_DIR = '/workingdir';
// BuildContext.getReactNativeProjectDirectory() nests the RN project under `${workingdir}/build`.
const IOS_DIR = path.join(WORKING_DIR, 'build', 'ios');
function makeIosBuildContext({ simulator }: { simulator: boolean }): BuildContext<Ios.Job> {
function makeIosBuildContext({
simulator,
env = {},
}: {
simulator: boolean;
env?: Record<string, string>;
}): BuildContext<Ios.Job> {
const job: Ios.Job = { ...createTestIosJob(), simulator };
return new BuildContext(job, {
workingdir: WORKING_DIR,
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: { __API_SERVER_URL: 'http://api.expo.test' },
env: { __API_SERVER_URL: 'http://api.expo.test', ...env },
uploadArtifact: jest.fn(),
});
}
@@ -71,6 +78,10 @@ describe(runFastlaneGym, () => {
(spawn as jest.Mock).mockClear();
});
afterEach(() => {
jest.restoreAllMocks();
});
describe.each([
{
name: 'archive',
@@ -125,4 +136,68 @@ describe(runFastlaneGym, () => {
workspacePath: IOS_DIR,
});
});
it('enables local Swift, C, and Objective-C compilation caching', async () => {
jest.spyOn(CompilationCache, 'prepareXcodeCompilationCacheEnvAsync').mockResolvedValue({
GYM_XCARGS:
'OTHER_SWIFT_FLAGS=-warnings-as-errors COMPILATION_CACHE_ENABLE_CACHING=YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS=YES COMPILATION_CACHE_ENABLE_PLUGIN=YES COMPILATION_CACHE_PLUGIN_PATH=/workingdir/bin/libeas_xcode_local_cas_plugin.dylib COMPILATION_CACHE_REMOTE_SERVICE_PATH=/dev/null',
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN:
'/Applications/Xcode.app/Contents/Developer/usr/lib/libToolchainCASPlugin.dylib',
GYM_DERIVED_DATA_PATH: path.join(IOS_DIR, 'build'),
});
const ctx = makeIosBuildContext({
simulator: false,
env: {
EAS_BUILD_XCODE_COMPILATION_CACHE: '1',
GYM_XCARGS: 'OTHER_SWIFT_FLAGS=-warnings-as-errors',
},
});
await runFastlaneGym(ctx, {
scheme: 'App',
credentials: ARCHIVE_CREDENTIALS,
entitlements: null,
});
expect(spawn).toHaveBeenCalledWith(
'fastlane',
['gym'],
expect.objectContaining({
env: expect.objectContaining({
GYM_XCARGS:
'OTHER_SWIFT_FLAGS=-warnings-as-errors COMPILATION_CACHE_ENABLE_CACHING=YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS=YES COMPILATION_CACHE_ENABLE_PLUGIN=YES COMPILATION_CACHE_PLUGIN_PATH=/workingdir/bin/libeas_xcode_local_cas_plugin.dylib COMPILATION_CACHE_REMOTE_SERVICE_PATH=/dev/null',
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN:
'/Applications/Xcode.app/Contents/Developer/usr/lib/libToolchainCASPlugin.dylib',
GYM_DERIVED_DATA_PATH: path.join(IOS_DIR, 'build'),
}),
})
);
});
it('does not enable compilation caching when the local plugin cannot be prepared', async () => {
const ctx = makeIosBuildContext({
simulator: false,
env: {
EAS_BUILD_XCODE_COMPILATION_CACHE: '1',
},
});
await runFastlaneGym(ctx, {
scheme: 'App',
credentials: ARCHIVE_CREDENTIALS,
entitlements: null,
});
expect(spawn).toHaveBeenCalledWith(
'fastlane',
['gym'],
expect.objectContaining({
env: expect.not.objectContaining({ GYM_XCARGS: expect.any(String) }),
})
);
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
expect.stringContaining('compilation caching is disabled')
);
});
});
@@ -0,0 +1,194 @@
import { Env, Platform } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { createHash } from 'crypto';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { CACHE_KEY_PREFIX_BY_PLATFORM, generateDefaultBuildCacheKeyAsync } from '../utils/cacheKey';
export const XCODE_COMPILATION_CACHE_ENV = 'EAS_BUILD_XCODE_COMPILATION_CACHE';
export const XCODE_COMPILATION_CACHE_RELATIVE_PATH = 'ios/build/CompilationCache.noindex';
export function getXcodeCompilationCachePath(derivedDataPath: string): string {
return path.join(derivedDataPath, 'CompilationCache.noindex');
}
async function prepareXcodeLocalCompilationCachePluginAsync({
env,
logger,
}: {
env: Env;
logger: bunyan;
}): Promise<{ pluginPath: string; applePluginPath: string }> {
const bundledPluginPath = path.resolve(
__dirname,
'../../bin/libeas_xcode_local_cas_plugin.dylib'
);
const developerDirectory =
env.DEVELOPER_DIR ??
(
await spawn('xcode-select', ['--print-path'], {
env,
logger,
})
).stdout.trim();
const applePluginPath = path.join(
developerDirectory,
'usr',
'lib',
'libToolchainCASPlugin.dylib'
);
if (!(await fs.pathExists(applePluginPath))) {
throw new Error(`The Xcode CAS plugin is missing at ${applePluginPath}.`);
}
if (!(await fs.pathExists(bundledPluginPath))) {
throw new Error(`The EAS local CAS plugin is missing at ${bundledPluginPath}.`);
}
const validationDirectory = await fs.mkdtemp(
path.join(os.tmpdir(), 'eas-xcode-local-cache-validation-')
);
try {
await spawn(
'xcrun',
[
'llvm-cas',
`--fcas-plugin-path=${bundledPluginPath}`,
'--fcas-plugin-option=remote-service-path=/dev/null',
`--cas=${path.join(validationDirectory, 'cas')}`,
'--ingest',
bundledPluginPath,
],
{
env: { ...env, EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN: applePluginPath },
logger,
}
);
} finally {
await fs.remove(validationDirectory);
}
return { pluginPath: bundledPluginPath, applePluginPath };
}
export async function compressXcodeCompilationCacheAsync({
workingDirectory,
env,
logger,
}: {
workingDirectory: string;
env: Env;
logger: bunyan;
}): Promise<{ archivePath: string }> {
const cachePath = path.join(workingDirectory, XCODE_COMPILATION_CACHE_RELATIVE_PATH);
if (!(await fs.pathExists(cachePath))) {
throw new Error(`The Xcode compilation cache does not exist at ${cachePath}.`);
}
const archiveDirectory = await fs.mkdtemp(path.join(os.tmpdir(), 'xcode-compilation-cache-'));
const archivePath = path.join(archiveDirectory, 'cache.tar.gz');
await spawn(
'tar',
['-czf', archivePath, '-C', workingDirectory, XCODE_COMPILATION_CACHE_RELATIVE_PATH],
{ env, logger }
);
return { archivePath };
}
export async function decompressXcodeCompilationCacheAsync({
archivePath,
workingDirectory,
env,
logger,
}: {
archivePath: string;
workingDirectory: string;
env: Env;
logger: bunyan;
}): Promise<void> {
await fs.ensureDir(workingDirectory);
await spawn('tar', ['-xzSf', archivePath, '-C', workingDirectory], { env, logger });
}
export async function generateXcodeCompilationCacheKeyAsync({
workingDirectory,
env,
logger,
}: {
workingDirectory: string;
env: Env;
logger: bunyan;
}): Promise<{ key: string; keyPrefix: string; xcodeVersion: string }> {
const xcodeVersion = (
await spawn('xcodebuild', ['-version'], {
env,
logger,
})
).stdout.trim();
const xcodeVersionHash = createHash('sha256').update(xcodeVersion).digest('hex').slice(0, 16);
const ccacheKey = await generateDefaultBuildCacheKeyAsync(workingDirectory, Platform.IOS);
const dependencyHash = ccacheKey.slice(CACHE_KEY_PREFIX_BY_PLATFORM[Platform.IOS].length);
const keyPrefix = `ios-xcode-compilation-cache-${xcodeVersionHash}-`;
return {
key: `${keyPrefix}${dependencyHash}`,
keyPrefix,
xcodeVersion: xcodeVersion.replace(/\s+/g, ' '),
};
}
export async function prepareXcodeCompilationCacheEnvAsync({
derivedDataPath,
env,
logger,
}: {
derivedDataPath: string;
env: Env;
logger: bunyan;
}): Promise<Env> {
if (env[XCODE_COMPILATION_CACHE_ENV] !== '1') {
return {};
}
let plugin: { pluginPath: string; applePluginPath: string };
try {
plugin = await prepareXcodeLocalCompilationCachePluginAsync({
env,
logger,
});
logger.info(`The EAS local Xcode CAS plugin is ready at ${plugin.pluginPath}.`);
} catch (error: any) {
logger.warn(
{ err: error },
'The EAS local Xcode CAS plugin is missing or incompatible. Xcode compilation caching is disabled.'
);
return {};
}
logger.info(
`Xcode compilation caching is enabled. The cache directory is ${getXcodeCompilationCachePath(derivedDataPath)}.`
);
const pluginXcargs = [
'COMPILATION_CACHE_ENABLE_PLUGIN=YES',
`COMPILATION_CACHE_PLUGIN_PATH=${plugin.pluginPath}`,
// A non-empty remote service path makes Xcode schedule C, Objective-C,
// precompiled-module, and precompiled-header cache tasks. The EAS plugin
// consumes this option and never opens the path or performs remote I/O.
'COMPILATION_CACHE_REMOTE_SERVICE_PATH=/dev/null',
].join(' ');
return {
GYM_XCARGS: [
env.GYM_XCARGS,
'COMPILATION_CACHE_ENABLE_CACHING=YES COMPILATION_CACHE_ENABLE_DIAGNOSTIC_REMARKS=YES',
pluginXcargs,
]
.filter(Boolean)
.join(' '),
GYM_DERIVED_DATA_PATH: derivedDataPath,
EAS_XCODE_LOCAL_CAS_APPLE_PLUGIN: plugin.applePluginPath,
};
}
+7 -1
View File
@@ -7,6 +7,7 @@ import os from 'os';
import path from 'path';
import type { Credentials } from './credentials/manager';
import * as CompilationCache from './compilationCache';
import { createFastfileForResigningBuild } from './fastfile';
import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile';
import { isTVOS } from './tvos';
@@ -46,13 +47,18 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
if (ctx.skipNativeBuild) {
throw new SkipNativeBuildError('Skipping fastlane build');
}
const compilationCacheEnv = await CompilationCache.prepareXcodeCompilationCacheEnvAsync({
derivedDataPath,
env: ctx.env,
logger: ctx.logger,
});
const buildLogger = new XcodeBuildLogger(ctx.logger, ctx.getReactNativeProjectDirectory());
void buildLogger.watchLogFiles(ctx.buildLogsDirectory);
try {
await runFastlane(['gym'], {
cwd: workspacePath,
logger: ctx.logger,
env: { ...ctx.env, ...extraEnv },
env: { ...ctx.env, ...extraEnv, ...compilationCacheEnv },
});
} finally {
await buildLogger.flush();
@@ -38,6 +38,7 @@ describe('createInstallMaestroBuildFunction', () => {
expect(step.getOutputValueByName('maestro_version')).toBe('1.41.0');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '1.41.0',
maestro_backend: 'maestro',
});
});
@@ -70,6 +71,7 @@ describe('createInstallMaestroBuildFunction', () => {
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '2.0.10',
maestro_backend: 'maestro',
});
});
@@ -87,4 +89,141 @@ describe('createInstallMaestroBuildFunction', () => {
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
});
it('checks maestro-runner when selected by input', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout:
command === 'maestro-runner'
? 'maestro-runner 1.2.3\n Commit: abc123\n Built: 2026-08-13\n Go: go1.26.5\n OS/Arch: darwin/arm64\n'
: '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(2);
expect(mockedSpawn.mock.calls.map(([command]) => command)).toEqual([
'maestro-runner',
'maestro-runner',
]);
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '1.2.3',
maestro_backend: 'maestro-runner',
});
});
it('selects maestro-runner from EAS_MAESTRO_BACKEND', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout: command === 'maestro-runner' ? 'maestro-runner 1.2.3\n' : '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_MAESTRO_BACKEND: 'maestro-runner',
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(mockedSpawn.mock.calls.map(([command]) => command)).toEqual([
'maestro-runner',
'maestro-runner',
]);
});
it('installs the latest maestro-runner version when it is missing', async () => {
let versionChecks = 0;
mockedSpawn.mockImplementation((async (command: string) => {
if (command === 'maestro-runner' && versionChecks++ === 0) {
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
}
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.2.3\n' : '' };
}) as any);
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
text: async () => '#!/bin/sh\n',
} as Response);
const originalPath = process.env.PATH;
try {
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
HOME: '/home/expo',
PATH: '/usr/bin',
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(fetchSpy).toHaveBeenCalledWith('https://open.devicelab.dev/install/maestro-runner');
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
[],
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
);
expect(mockedSpawn).toHaveBeenLastCalledWith(
'maestro-runner',
['--version'],
expect.objectContaining({
env: expect.objectContaining({
PATH: expect.stringMatching(/:\/home\/expo\/\.maestro-runner\/bin$/),
}),
})
);
} finally {
fetchSpy.mockRestore();
process.env.PATH = originalPath;
}
});
it('installs the requested maestro-runner version', async () => {
let versionChecks = 0;
mockedSpawn.mockImplementation((async (command: string) => {
if (command === 'maestro-runner' && versionChecks++ === 0) {
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
}
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.0.9\n' : '' };
}) as any);
jest.spyOn(global, 'fetch').mockResolvedValue({
text: async () => '#!/bin/sh\n',
} as Response);
const originalPath = process.env.PATH;
try {
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
HOME: '/home/expo',
PATH: '/usr/bin',
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner', maestro_version: '1.0.9' },
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
['--version', '1.0.9'],
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
);
} finally {
jest.restoreAllMocks();
process.env.PATH = originalPath;
}
});
});
@@ -0,0 +1,110 @@
import { Platform } from '@expo/eas-build-job';
import { vol } from 'memfs';
import { createMockLogger } from '../../../__tests__/utils/logger';
import {
XCODE_COMPILATION_CACHE_ENV,
XCODE_COMPILATION_CACHE_RELATIVE_PATH,
compressXcodeCompilationCacheAsync,
decompressXcodeCompilationCacheAsync,
generateXcodeCompilationCacheKeyAsync,
} from '../../../ios/compilationCache';
import { downloadCacheAsync } from '../restoreCache';
import { restoreXcodeCompilationCacheAsync } from '../restoreBuildCache';
import { uploadCacheAsync } from '../saveCache';
import { saveXcodeCompilationCacheAsync } from '../saveBuildCache';
jest.mock('../restoreCache', () => ({
downloadCacheAsync: jest.fn(),
}));
jest.mock('../saveCache', () => ({
uploadCacheAsync: jest.fn(),
}));
jest.mock('../../../ios/compilationCache', () => ({
XCODE_COMPILATION_CACHE_ENV: 'EAS_BUILD_XCODE_COMPILATION_CACHE',
XCODE_COMPILATION_CACHE_RELATIVE_PATH: 'ios/build/CompilationCache.noindex',
compressXcodeCompilationCacheAsync: jest.fn(),
decompressXcodeCompilationCacheAsync: jest.fn(),
generateXcodeCompilationCacheKeyAsync: jest.fn(),
}));
const WORKING_DIRECTORY = '/working-directory';
const ENV = {
[XCODE_COMPILATION_CACHE_ENV]: '1',
EAS_USE_CACHE: '1',
EAS_BUILD_ID: 'build-id',
__API_SERVER_URL: 'https://api.expo.test',
};
const SECRETS = { robotAccessToken: 'robot-access-token' };
describe('Xcode compilation build cache', () => {
beforeEach(() => {
jest.mocked(generateXcodeCompilationCacheKeyAsync).mockResolvedValue({
key: 'ios-xcode-compilation-cache-version-dependencies',
keyPrefix: 'ios-xcode-compilation-cache-version-',
xcodeVersion: 'Xcode 26.0 Build version 17A000',
});
});
test('restores through the active build cache service', async () => {
jest.mocked(downloadCacheAsync).mockResolvedValue({
archivePath: '/tmp/cache.tar.gz',
matchedKey: 'ios-xcode-compilation-cache-version-dependencies',
});
await restoreXcodeCompilationCacheAsync({
logger: createMockLogger(),
workingDirectory: WORKING_DIRECTORY,
env: ENV,
secrets: SECRETS,
});
expect(downloadCacheAsync).toHaveBeenCalledWith(
expect.objectContaining({
jobId: 'build-id',
key: 'ios-xcode-compilation-cache-version-dependencies',
keyPrefixes: ['ios-xcode-compilation-cache-version-'],
paths: [XCODE_COMPILATION_CACHE_RELATIVE_PATH],
platform: Platform.IOS,
})
);
expect(decompressXcodeCompilationCacheAsync).toHaveBeenCalledWith(
expect.objectContaining({
archivePath: '/tmp/cache.tar.gz',
workingDirectory: WORKING_DIRECTORY,
})
);
});
test('saves through the active build cache service', async () => {
vol.fromJSON({ '/tmp/xcode-cache.tar.gz': 'cache' });
jest.mocked(compressXcodeCompilationCacheAsync).mockResolvedValue({
archivePath: '/tmp/xcode-cache.tar.gz',
});
await saveXcodeCompilationCacheAsync({
logger: createMockLogger(),
workingDirectory: WORKING_DIRECTORY,
env: ENV,
secrets: SECRETS,
});
expect(compressXcodeCompilationCacheAsync).toHaveBeenCalledWith(
expect.objectContaining({
workingDirectory: WORKING_DIRECTORY,
})
);
expect(uploadCacheAsync).toHaveBeenCalledWith(
expect.objectContaining({
jobId: 'build-id',
key: 'ios-xcode-compilation-cache-version-dependencies',
paths: [XCODE_COMPILATION_CACHE_RELATIVE_PATH],
platform: Platform.IOS,
size: 5,
})
);
});
});
@@ -15,6 +15,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { MaestroBackend, resolveMaestroBackend } from './maestroBackend';
import { Datadog } from '../../datadog';
export function createInstallMaestroBuildFunction(): BuildFunction {
@@ -29,6 +30,11 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'backend',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
@@ -37,22 +43,28 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
}),
],
fn: async ({ logger, global }, { inputs, env, outputs }) => {
const requestedMaestroVersion = inputs.maestro_version.value as string | undefined;
const { value: currentMaestroVersion } = await asyncResult(getMaestroVersion({ env }));
const backend = resolveMaestroBackend({
input: inputs.backend.value,
env,
});
const requestedVersion = inputs.maestro_version.value as string | undefined;
const { value: currentMaestroVersion } = await asyncResult(
getMaestroVersion({ env, backend })
);
// When not running in EAS Build VM, do not modify local environment.
if (env.EAS_BUILD_RUNNER !== 'eas-build') {
const currentIsJavaInstalled = await isJavaInstalled({ env });
const currentIsIdbInstalled = await isIdbInstalled({ env });
const needsToInstallJava = backend === 'maestro' && !(await isJavaInstalled({ env }));
const needsToInstallIdb = backend === 'maestro' && !(await isIdbInstalled({ env }));
if (!currentIsJavaInstalled) {
if (needsToInstallJava) {
logger.warn(
'It seems Java is not installed. It is required to run Maestro. If the job fails, this may be the reason.'
);
logger.info('');
}
if (!currentIsIdbInstalled) {
if (needsToInstallIdb) {
logger.warn(
'It seems IDB is not installed. Maestro requires it to run flows on iOS Simulator. If the job fails, this may be the reason.'
);
@@ -61,27 +73,32 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
if (!currentMaestroVersion) {
logger.warn(
'It seems Maestro is not installed. Please install Maestro manually and rerun the job.'
`It seems ${backend} is not installed. Please install it manually and rerun the job.`
);
logger.info('');
}
// Guide is helpful in these two cases, it doesn't mention Java.
if (!currentIsIdbInstalled || !currentMaestroVersion) {
if (backend === 'maestro' && (needsToInstallIdb || !currentMaestroVersion)) {
logger.warn(
'For more info, check out Maestro installation guide: https://maestro.mobile.dev/getting-started/installing-maestro'
);
} else if (backend === 'maestro-runner' && !currentMaestroVersion) {
logger.warn(
'For more info, check out maestro-runner installation guide: https://github.com/devicelab-dev/maestro-runner#install'
);
}
if (currentMaestroVersion) {
outputs.maestro_version.set(currentMaestroVersion);
logger.info(`Maestro ${currentMaestroVersion} is ready.`);
logger.info(`${backend} ${currentMaestroVersion} is ready.`);
}
return;
}
if (!(await isJavaInstalled({ env }))) {
const needsToInstallJava = backend === 'maestro' && !(await isJavaInstalled({ env }));
if (needsToInstallJava) {
if (global.runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info('Installing Java');
await installJavaFromGcs({ logger, env });
@@ -94,10 +111,11 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
}
// IDB is only a requirement on macOS.
if (
const needsToInstallIdb =
backend === 'maestro' &&
global.runtimePlatform === BuildRuntimePlatform.DARWIN &&
!(await isIdbInstalled({ env }))
) {
!(await isIdbInstalled({ env }));
if (needsToInstallIdb) {
logger.info('Installing IDB');
await installIdbFromBrew({ logger, env });
}
@@ -106,43 +124,100 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
// and it is already installed which happens when developing on a local computer.
if (
!currentMaestroVersion ||
(requestedMaestroVersion && requestedMaestroVersion !== currentMaestroVersion)
(requestedVersion && requestedVersion !== currentMaestroVersion)
) {
await installMaestro({
version: requestedMaestroVersion,
global,
logger,
env,
});
switch (backend) {
case 'maestro':
await installMaestro({ version: requestedVersion, global, logger, env });
break;
case 'maestro-runner':
await installMaestroRunner({ version: requestedVersion, global, logger, env });
break;
}
}
const maestroVersionResult = await asyncResult(getMaestroVersion({ env }));
const maestroVersionResult = await asyncResult(getMaestroVersion({ env, backend }));
if (!maestroVersionResult.ok) {
logger.error(maestroVersionResult.reason, 'Failed to get Maestro version.');
throw new Error('Failed to ensure Maestro is installed.');
throw new Error(`Failed to ensure ${backend} is installed.`);
}
logger.info(`Maestro ${maestroVersionResult.value} is ready.`);
logger.info(`${backend} ${maestroVersionResult.value} is ready.`);
outputs.maestro_version.set(maestroVersionResult.value);
Datadog.distribution('eas.maestro.install', 1, {
maestro_version: maestroVersionResult.value,
maestro_backend: backend,
});
},
});
}
async function getMaestroVersion({ env }: { env: BuildStepEnv }): Promise<string> {
const { stdout } = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
// `maestro --version` can print an analytics notice to stdout before the version,
// e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
// Take the last version-looking token: the real version is printed after the notice, so
// this stays correct even if the notice itself contains a version-like string. Keeps the
// step output and the eas.maestro.install metric tag clean. Best-effort only: fall back to
// the raw output if none is found, so we never fail the build over a version string.
const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
return versions?.at(-1) ?? stdout.trim();
async function getMaestroVersion({
env,
backend,
}: {
env: BuildStepEnv;
backend: MaestroBackend;
}): Promise<string> {
switch (backend) {
case 'maestro': {
const { stdout } = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
// `maestro --version` can print an analytics notice to stdout before the version,
// e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
// Take the last version-looking token: the real version is printed after the notice.
const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
return versions?.at(-1) ?? stdout.trim();
}
case 'maestro-runner': {
const { stdout } = await spawn('maestro-runner', ['--version'], { stdio: 'pipe', env });
// maestro-runner prints build information after its version. The Go runtime version in
// that output is also semver-shaped, so read only the prefixed runner version.
return /^maestro-runner\s+(\S+)/m.exec(stdout)?.[1] ?? stdout.trim();
}
}
}
async function installMaestroRunner({
global,
version,
logger,
env,
}: {
version?: string;
logger: bunyan;
global: BuildStepGlobalContext;
env: BuildStepEnv;
}): Promise<void> {
logger.info('Fetching maestro-runner install script');
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install_maestro_runner'));
try {
const installMaestroRunnerScriptResponse = await fetch(
'https://open.devicelab.dev/install/maestro-runner'
);
const installMaestroRunnerScript = await installMaestroRunnerScriptResponse.text();
const scriptPath = path.join(tempDirectory, 'install_maestro_runner.sh');
await fs.promises.writeFile(scriptPath, installMaestroRunnerScript, { mode: 0o777 });
logger.info('Installing maestro-runner');
assert(
env.HOME,
'Failed to infer directory to install maestro-runner in: $HOME environment variable is empty.'
);
await spawn(scriptPath, version && version !== 'latest' ? ['--version', version] : [], {
logger,
env,
});
const binDir = path.join(env.HOME, '.maestro-runner', 'bin');
global.updateEnv({
...global.env,
PATH: `${global.env.PATH}:${binDir}`,
});
env.PATH = `${env.PATH}:${binDir}`;
process.env.PATH = `${process.env.PATH}:${binDir}`;
} finally {
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
}
}
async function installMaestro({
@@ -0,0 +1,25 @@
import { UserError } from '@expo/eas-build-job';
import { BuildStepEnv } from '@expo/steps';
import { z } from 'zod';
export const MaestroBackendSchema = z.enum(['maestro', 'maestro-runner']).default('maestro');
export type MaestroBackend = z.output<typeof MaestroBackendSchema>;
export function resolveMaestroBackend({
input,
env,
}: {
input: unknown;
env: BuildStepEnv;
}): MaestroBackend {
const result = MaestroBackendSchema.safeParse(input || env.EAS_MAESTRO_BACKEND || undefined);
if (!result.success) {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
'backend and EAS_MAESTRO_BACKEND must be either "maestro" or "maestro-runner".',
{ cause: result.error }
);
}
return result.data;
}
@@ -20,6 +20,12 @@ import {
getCcachePath,
} from '../../utils/cacheKey';
import { Datadog } from '../../datadog';
import {
XCODE_COMPILATION_CACHE_ENV,
XCODE_COMPILATION_CACHE_RELATIVE_PATH,
decompressXcodeCompilationCacheAsync,
generateXcodeCompilationCacheKeyAsync,
} from '../../ios/compilationCache';
import { GRADLE_CACHE_KEY_PREFIX, generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
import { TurtleFetchError, turtleFetch } from '../../utils/turtleFetch';
@@ -48,26 +54,91 @@ export function createRestoreBuildCacheFunction(): BuildFunction {
);
}
await restoreCcacheAsync({
logger,
workingDirectory,
platform,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
if (platform === Platform.ANDROID) {
await restoreGradleCacheAsync({
await Promise.all([
restoreCcacheAsync({
logger,
workingDirectory,
platform,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
}
}),
platform === Platform.IOS
? restoreXcodeCompilationCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
})
: restoreGradleCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
}),
]);
},
});
}
export async function restoreXcodeCompilationCacheAsync({
logger,
workingDirectory,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
const buildCacheEnabled =
env.EAS_RESTORE_CACHE === '1' || (env.EAS_USE_CACHE === '1' && env.EAS_RESTORE_CACHE !== '0');
if (!buildCacheEnabled || env[XCODE_COMPILATION_CACHE_ENV] !== '1') {
return;
}
try {
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');
const jobId = nullthrows(env.EAS_BUILD_ID, 'EAS_BUILD_ID is not set');
const { key, keyPrefix, xcodeVersion } = await generateXcodeCompilationCacheKeyAsync({
workingDirectory,
env,
logger,
});
logger.info(`Restoring Xcode compilation cache for ${xcodeVersion} with key: ${key}`);
const { archivePath, matchedKey } = await downloadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
paths: [XCODE_COMPILATION_CACHE_RELATIVE_PATH],
key,
keyPrefixes: [keyPrefix],
platform: Platform.IOS,
});
await decompressXcodeCompilationCacheAsync({
archivePath,
workingDirectory,
env,
logger,
});
logger.info(
`Xcode compilation cache restored ${matchedKey === key ? '(direct hit)' : '(prefix match)'}`
);
} catch (err: unknown) {
if (err instanceof TurtleFetchError && err.response?.status === 404) {
logger.info('No Xcode compilation cache found for this key');
} else {
logger.warn({ err }, 'Failed to restore Xcode compilation cache');
}
}
}
export function createCacheStatsBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -14,6 +14,12 @@ import os from 'os';
import path from 'path';
import { compressCacheAsync, uploadCacheAsync } from './saveCache';
import {
XCODE_COMPILATION_CACHE_ENV,
XCODE_COMPILATION_CACHE_RELATIVE_PATH,
compressXcodeCompilationCacheAsync,
generateXcodeCompilationCacheKeyAsync,
} from '../../ios/compilationCache';
import { formatBytes } from '../../utils/artifacts';
import { generateDefaultBuildCacheKeyAsync, getCcachePath } from '../../utils/cacheKey';
import { generateGradleCacheKeyAsync } from '../../utils/gradleCacheKey';
@@ -43,27 +49,87 @@ export function createSaveBuildCacheFunction(evictUsedBefore: Date): BuildFuncti
);
}
await saveCcacheAsync({
logger,
workingDirectory,
platform,
evictUsedBefore,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
if (platform === Platform.ANDROID) {
await saveGradleCacheAsync({
await Promise.all([
saveCcacheAsync({
logger,
workingDirectory,
platform,
evictUsedBefore,
env,
secrets: stepCtx.global.staticContext.job.secrets,
});
}
}),
platform === Platform.IOS
? saveXcodeCompilationCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
})
: saveGradleCacheAsync({
logger,
workingDirectory,
env,
secrets: stepCtx.global.staticContext.job.secrets,
}),
]);
},
});
}
export async function saveXcodeCompilationCacheAsync({
logger,
workingDirectory,
env,
secrets,
}: {
logger: bunyan;
workingDirectory: string;
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
const buildCacheEnabled =
env.EAS_SAVE_CACHE === '1' || (env.EAS_USE_CACHE === '1' && env.EAS_SAVE_CACHE !== '0');
if (!buildCacheEnabled || env[XCODE_COMPILATION_CACHE_ENV] !== '1') {
return;
}
try {
const { key, xcodeVersion } = await generateXcodeCompilationCacheKeyAsync({
workingDirectory,
env,
logger,
});
logger.info(`Saving Xcode compilation cache for ${xcodeVersion} with key: ${key}`);
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');
const { archivePath } = await compressXcodeCompilationCacheAsync({
workingDirectory,
env,
logger,
});
const { size } = await fs.promises.stat(archivePath);
logger.info(`Xcode compilation cache archive size: ${formatBytes(size)}`);
await uploadCacheAsync({
logger,
jobId,
expoApiServerURL,
robotAccessToken,
archivePath,
key,
paths: [XCODE_COMPILATION_CACHE_RELATIVE_PATH],
size,
platform: Platform.IOS,
});
} catch (err) {
logger.error({ err }, 'Failed to save Xcode compilation cache');
}
}
export async function saveCcacheAsync({
logger,
workingDirectory,
@@ -5,6 +5,7 @@ import path from 'path';
import { COMMON_FASTLANE_ENV } from '../../../common/fastlane';
import { XcodeBuildLogger } from '../../../common/xcpretty';
import * as CompilationCache from '../../../ios/compilationCache';
export async function runFastlaneGym({
workingDir,
@@ -19,14 +20,23 @@ export async function runFastlaneGym({
env: BuildStepEnv;
extraEnv?: BuildStepEnv;
}): Promise<void> {
const workspacePath = path.join(workingDir, 'ios');
const derivedDataPath = path.join(workspacePath, 'build');
// When caching is enabled, this also sets GYM_DERIVED_DATA_PATH so Fastlane
// and the cache restore/save operations use this exact directory.
const compilationCacheEnv = await CompilationCache.prepareXcodeCompilationCacheEnvAsync({
derivedDataPath,
env,
logger,
});
const buildLogger = new XcodeBuildLogger(logger, workingDir);
void buildLogger.watchLogFiles(buildLogsDirectory);
try {
await runFastlane(['gym'], {
cwd: path.join(workingDir, 'ios'),
cwd: workspacePath,
logger,
env,
extraEnv,
extraEnv: { ...extraEnv, ...compilationCacheEnv },
});
} finally {
await buildLogger.flush();
@@ -0,0 +1,138 @@
import { BuildJob, ManagedArtifactType } from '@expo/eas-build-job';
import fs from 'fs-extra';
import { vol } from 'memfs';
import { createTestAndroidJob, createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { maybeUploadSourceMapAsync, resolveIosSourceMapPathAsync } from '../sourceMaps';
function enableSourceMapUpload<TJob extends BuildJob>(job: TJob): TJob {
return {
...job,
experimental: {
...job.experimental,
uploadSourceMaps: true,
},
};
}
function createContext<TJob extends BuildJob>(
job: TJob,
env: Record<string, string> = {}
): { ctx: BuildContext<TJob>; uploadArtifact: jest.Mock } {
const uploadArtifact = jest.fn(async () => ({ filename: null }));
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
EAS_BUILD_RUNNER: 'eas-build',
...env,
},
uploadArtifact,
});
return { ctx, uploadArtifact };
}
describe(maybeUploadSourceMapAsync.name, () => {
it('uploads the final Android source map without sourcesContent', async () => {
const finalSourceMapPath =
'/workingdir/build/android/app/build/generated/sourcemaps/react/release/index.android.bundle.map';
vol.fromJSON({
[finalSourceMapPath]: JSON.stringify({
version: 3,
sources: ['App.tsx'],
sourcesContent: ['customer source'],
names: [],
mappings: '',
sections: [
{
map: {
version: 3,
sources: ['nested.ts'],
sourcesContent: ['nested customer source'],
names: [],
mappings: '',
},
},
],
}),
[`${finalSourceMapPath.slice(0, -4)}.packager.map`]: JSON.stringify({ version: 3 }),
[`${finalSourceMapPath.slice(0, -4)}.compiler.map`]: JSON.stringify({ version: 3 }),
});
const { ctx, uploadArtifact } = createContext(enableSourceMapUpload(createTestAndroidJob()));
await maybeUploadSourceMapAsync(ctx);
expect(uploadArtifact).toHaveBeenCalledWith({
artifact: {
type: ManagedArtifactType.SOURCE_MAP,
paths: ['/workingdir/observe-source-maps/android.map'],
},
logger: ctx.logger,
});
const uploadedSourceMap = await fs.readJson('/workingdir/observe-source-maps/android.map');
expect(uploadedSourceMap).not.toHaveProperty('sourcesContent');
expect(uploadedSourceMap.sections[0].map).not.toHaveProperty('sourcesContent');
});
it('does not guess when an Android build produces multiple final source maps', async () => {
vol.fromJSON({
'/workingdir/build/android/app/build/generated/sourcemaps/react/release/one.map':
JSON.stringify({ version: 3 }),
'/workingdir/build/android/other/build/generated/sourcemaps/react/release/two.map':
JSON.stringify({ version: 3 }),
});
const { ctx, uploadArtifact } = createContext(enableSourceMapUpload(createTestAndroidJob()));
const markBuildPhaseHasWarnings = jest.spyOn(ctx, 'markBuildPhaseHasWarnings');
await expect(maybeUploadSourceMapAsync(ctx)).resolves.toBeUndefined();
expect(uploadArtifact).not.toHaveBeenCalled();
expect(markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to upload source map.'
);
});
it('does nothing when source-map upload is disabled', async () => {
const { ctx, uploadArtifact } = createContext(createTestAndroidJob());
await maybeUploadSourceMapAsync(ctx);
expect(uploadArtifact).not.toHaveBeenCalled();
});
it('does not upload source maps from local builds', async () => {
const { ctx, uploadArtifact } = createContext(enableSourceMapUpload(createTestAndroidJob()), {
EAS_BUILD_RUNNER: 'local-build-plugin',
});
await maybeUploadSourceMapAsync(ctx);
expect(uploadArtifact).not.toHaveBeenCalled();
});
});
describe(resolveIosSourceMapPathAsync.name, () => {
it('uses an absolute worker-owned path by default', async () => {
const { ctx } = createContext(createTestIosJob());
await expect(resolveIosSourceMapPathAsync(ctx)).resolves.toBe(
'/workingdir/observe-source-maps/main.jsbundle.map'
);
});
it('preserves a configured path relative to the iOS project', async () => {
const { ctx } = createContext(createTestIosJob(), {
SOURCEMAP_FILE: 'build/custom/main.jsbundle.map',
});
await expect(resolveIosSourceMapPathAsync(ctx)).resolves.toBe(
'/workingdir/build/ios/build/custom/main.jsbundle.map'
);
});
});
@@ -0,0 +1,123 @@
import { BuildJob, ManagedArtifactType, Platform, SystemError } from '@expo/eas-build-job';
import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
import { BuildContext } from '../context';
const ANDROID_SOURCE_MAP_PATTERN = 'android/**/build/generated/sourcemaps/react/**/*.map';
const SOURCE_MAP_UPLOAD_DIRECTORY = 'observe-source-maps';
export function isSourceMapUploadEnabled(ctx: BuildContext<BuildJob>): boolean {
return !ctx.isLocal && ctx.job.experimental?.uploadSourceMaps === true;
}
export async function resolveIosSourceMapPathAsync(ctx: BuildContext<BuildJob>): Promise<string> {
const configuredPath = ctx.env.SOURCEMAP_FILE;
const sourceMapPath = configuredPath
? path.resolve(ctx.getReactNativeProjectDirectory(), 'ios', configuredPath)
: path.join(ctx.workingdir, SOURCE_MAP_UPLOAD_DIRECTORY, 'main.jsbundle.map');
await fs.ensureDir(path.dirname(sourceMapPath));
return sourceMapPath;
}
export async function maybeUploadSourceMapAsync(ctx: BuildContext<BuildJob>): Promise<void> {
if (!isSourceMapUploadEnabled(ctx)) {
return;
}
try {
const sourceMapPath = await resolveSourceMapPathAsync(ctx);
const strippedSourceMapPath = await stripSourcesContentAsync(ctx, sourceMapPath);
ctx.logger.info(`Uploading source map: ${sourceMapPath}`);
await ctx.uploadArtifact({
artifact: {
type: ManagedArtifactType.SOURCE_MAP,
paths: [strippedSourceMapPath],
},
logger: ctx.logger,
});
} catch (err: any) {
ctx.logger.warn({ err }, 'Failed to upload source map.');
ctx.markBuildPhaseHasWarnings();
}
}
async function resolveSourceMapPathAsync(ctx: BuildContext<BuildJob>): Promise<string> {
if (ctx.job.platform === Platform.IOS) {
const sourceMapPath = await resolveIosSourceMapPathAsync(ctx);
if (!(await fs.pathExists(sourceMapPath))) {
throw new SystemError(`The iOS source map was not generated at ${sourceMapPath}.`);
}
return sourceMapPath;
}
if (ctx.job.platform === Platform.ANDROID) {
const projectDir = ctx.getReactNativeProjectDirectory();
const sourceMapPaths = (
await fg(ANDROID_SOURCE_MAP_PATTERN, {
absolute: true,
cwd: projectDir,
onlyFiles: true,
})
).filter(sourceMapPath => !isIntermediateAndroidSourceMap(sourceMapPath));
if (sourceMapPaths.length === 0) {
throw new SystemError('The Android build did not generate a final composed source map.');
}
if (sourceMapPaths.length > 1) {
throw new SystemError(
`Found multiple final Android source maps: ${sourceMapPaths.join(', ')}. ` +
'Refusing to upload a source map that may not match the application archive.'
);
}
return sourceMapPaths[0];
}
throw new SystemError('Source-map upload is not supported for this build platform.');
}
function isIntermediateAndroidSourceMap(sourceMapPath: string): boolean {
return sourceMapPath.endsWith('.packager.map') || sourceMapPath.endsWith('.compiler.map');
}
async function stripSourcesContentAsync(
ctx: BuildContext<BuildJob>,
sourceMapPath: string
): Promise<string> {
const sourceMap = JSON.parse(await fs.readFile(sourceMapPath, 'utf8')) as unknown;
if (!isRecord(sourceMap) || sourceMap.version !== 3) {
throw new SystemError(`Invalid source map at ${sourceMapPath}.`);
}
removeSourcesContent(sourceMap);
const uploadDirectory = path.join(ctx.workingdir, SOURCE_MAP_UPLOAD_DIRECTORY);
const uploadPath = path.join(uploadDirectory, `${ctx.job.platform}.map`);
await fs.ensureDir(uploadDirectory);
await fs.writeFile(uploadPath, JSON.stringify(sourceMap), 'utf8');
return uploadPath;
}
function removeSourcesContent(value: unknown): void {
if (Array.isArray(value)) {
for (const child of value) {
removeSourcesContent(child);
}
return;
}
if (!isRecord(value)) {
return;
}
delete value.sourcesContent;
for (const child of Object.values(value)) {
removeSourcesContent(child);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -47,6 +47,9 @@ describe('Android.JobSchema', () => {
},
},
expoBuildUrl: 'https://expo.dev/fake/build/url',
experimental: {
uploadSourceMaps: true,
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
@@ -49,6 +49,9 @@ describe('Ios.JobSchema', () => {
},
},
expoBuildUrl: 'https://expo.dev/fake/build/url',
experimental: {
uploadSourceMaps: true,
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
+2
View File
@@ -119,6 +119,7 @@ export interface Job {
experimental?: {
prebuildCommand?: string;
uploadSourceMaps?: boolean;
};
expoBuildUrl?: string;
githubTriggerOptions?: {
@@ -186,6 +187,7 @@ export const JobSchema = Joi.object({
experimental: Joi.object({
prebuildCommand: Joi.string(),
uploadSourceMaps: Joi.boolean(),
}),
expoBuildUrl: Joi.string().uri().optional(),
githubTriggerOptions: Joi.object({
+1
View File
@@ -1,6 +1,7 @@
export enum ManagedArtifactType {
APPLICATION_ARCHIVE = 'APPLICATION_ARCHIVE',
BUILD_ARTIFACTS = 'BUILD_ARTIFACTS',
SOURCE_MAP = 'SOURCE_MAP',
/**
* @deprecated
*/
+2
View File
@@ -133,6 +133,7 @@ export interface Job {
experimental?: {
prebuildCommand?: string;
uploadSourceMaps?: boolean;
};
expoBuildUrl?: string;
githubTriggerOptions?: {
@@ -222,6 +223,7 @@ export const JobSchema = Joi.object({
experimental: Joi.object({
prebuildCommand: Joi.string(),
uploadSourceMaps: Joi.boolean(),
}),
expoBuildUrl: Joi.string().uri().optional(),
githubTriggerOptions: Joi.object({
@@ -104,6 +104,7 @@ export async function prepareJobAsync(
}),
experimental: {
prebuildCommand: buildProfile.prebuildCommand,
uploadSourceMaps: buildProfile.uploadSourceMaps,
},
mode: buildMode,
triggeredBy: BuildTrigger.EAS_CLI,
@@ -99,6 +99,7 @@ export async function prepareJobAsync(
}),
experimental: {
prebuildCommand: buildProfile.prebuildCommand,
uploadSourceMaps: buildProfile.uploadSourceMaps,
},
mode: buildMode,
triggeredBy: BuildTrigger.EAS_CLI,
@@ -4,7 +4,7 @@ import { anything, instance, mock, when } from 'ts-mockito';
import { Role } from '../../../../graphql/generated';
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { learnMore } from '../../../../log';
import Log, { learnMore } from '../../../../log';
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { isExpoInstalled } from '../../../../project/projectUtils';
import { promptAsync } from '../../../../prompts';
@@ -32,6 +32,8 @@ describe(getProjectIdAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest
.mocked(getConfigFilePaths)
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
@@ -379,6 +381,7 @@ describe(getProjectIdAsync, () => {
).resolves.toEqual('2345');
expect(promptAsync).toHaveBeenCalledTimes(1);
expect(Log.warn).toHaveBeenCalledWith('EAS project not configured.');
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).toHaveBeenCalledWith(
expect.anything(),
{ accountName: 'dominik', projectName: 'test' },
@@ -478,7 +481,14 @@ describe(getProjectIdAsync, () => {
{ nonInteractive: false }
)
).rejects.toThrow(
'Project is not configured. When using a robot access token, run "eas init --account <name> --non-interactive" or set the "owner" field in your app config before managing the project. Accounts this token can create projects in: notnotbrent'
`EAS project not configured. A robot access token cannot configure it interactively. ` +
`Run one of the following, then re-run this command:\n\n` +
`To link an existing project:\n\n` +
` eas init --id <project-id> --non-interactive\n\n` +
`To create a new project:\n\n` +
` eas init --account <account-name> --non-interactive\n\n` +
`Accounts you can create projects in: notnotbrent\n\n` +
`Alternatively, set the "owner" field in your app config.`
);
expect(promptAsync).not.toHaveBeenCalled();
@@ -493,12 +503,73 @@ describe(getProjectIdAsync, () => {
{ nonInteractive: true }
)
).rejects.toThrow(
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
`EAS project not configured. This command cannot configure it in non-interactive mode. ` +
`Run one of the following, then re-run this command:\n\n` +
`To link an existing project:\n\n` +
` eas init --id <project-id> --non-interactive\n\n` +
`To create a new project:\n\n` +
` eas init --account <account-name> --non-interactive\n\n` +
`Accounts you can create projects in: notnotbrent`
);
expect(promptAsync).not.toHaveBeenCalled();
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).not.toHaveBeenCalled();
});
it('defers to the project fetch in non-interactive mode when the account is already known', async () => {
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
__typename: 'User',
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
accounts: [
{
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
],
isExpoAdmin: false,
featureGates: {},
},
authenticationInfo: { accessToken: 'fake', sessionSecret: null },
} as any);
await expect(
getProjectIdAsync(
instance(sessionManagerMock),
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
{ nonInteractive: true }
)
).resolves.toEqual('2345');
expect(promptAsync).not.toHaveBeenCalled();
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).toHaveBeenCalledWith(
expect.anything(),
{ accountName: 'notnotbrent', projectName: 'test' },
{ nonInteractive: true },
expect.anything()
);
});
it('does not warn that the project is not configured in non-interactive mode', async () => {
await expect(
getProjectIdAsync(
sessionManager,
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
{ nonInteractive: true }
)
).rejects.toThrow();
expect(Log.warn).not.toHaveBeenCalledWith('EAS project not configured.');
});
});
it('throws if writing the ID back to the config fails', async () => {
@@ -17,9 +17,10 @@ import {
getPrivateExpoConfigAsync,
} from '../../../project/expoConfig';
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { getUnconfiguredProjectError } from '../../../project/projectNotConfiguredError';
import { promptAsync } from '../../../prompts';
import SessionManager from '../../../user/SessionManager';
import { Actor, getActorUsername, getCreatableAccountNames } from '../../../user/User';
import { Actor, getActorUsername } from '../../../user/User';
/**
* Save an EAS project ID to the appropriate field in the app config.
@@ -183,7 +184,9 @@ export async function validateOrSetProjectIdAsync({
throw new Error('This command must be run inside a project directory.');
}
Log.warn('EAS project not configured.');
if (!options.nonInteractive) {
Log.warn('EAS project not configured.');
}
const accountName = await chooseAccountNameForEASProjectAsync(exp, actor, options);
@@ -226,11 +229,11 @@ async function chooseAccountNameForEASProjectAsync(
}
if (actor.__typename === 'Robot') {
throw new Error(
`Project is not configured. When using a robot access token, run "eas init --account <name> --non-interactive" or set the "owner" field in your app config before managing the project. Accounts this token can create projects in: ${getCreatableAccountNames(
actor
).join(', ')}`
);
throw getUnconfiguredProjectError({
actor,
reason: 'A robot access token cannot configure it interactively.',
additionalFix: 'Alternatively, set the "owner" field in your app config.',
});
}
const allAccounts = actor.accounts;
@@ -239,9 +242,7 @@ async function chooseAccountNameForEASProjectAsync(
}
if (options.nonInteractive) {
throw new Error(
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
);
throw getUnconfiguredProjectError({ actor });
}
const choices = getAccountChoices(
+3 -56
View File
@@ -1,5 +1,5 @@
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { App, User, UserRole } from '@expo/apple-utils';
import { App } from '@expo/apple-utils';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import * as fs from 'fs-extra';
@@ -15,6 +15,7 @@ import { SetUpAscApiKey } from '../credentials/ios/actions/SetUpAscApiKey';
import { SetUpBuildCredentials } from '../credentials/ios/actions/SetUpBuildCredentials';
import { SetUpPushKey } from '../credentials/ios/actions/SetUpPushKey';
import { ensureAppExistsAsync } from '../credentials/ios/appstore/ensureAppExists';
import { ensureTestFlightGroupExistsAsync } from '../credentials/ios/appstore/ensureTestFlightGroup';
import { Target } from '../credentials/ios/types';
import {
WorkflowJobStatus,
@@ -60,62 +61,8 @@ export async function detectProjectSdkVersionAsync(
}
}
const TESTFLIGHT_GROUP_NAME = 'Team (Expo)';
async function setupTestFlightAsync(ascApp: App): Promise<void> {
let group;
for (let attempt = 0; attempt < 10; attempt++) {
try {
const groups = await ascApp.getBetaGroupsAsync({
query: { includes: ['betaTesters'] },
});
group = groups.find(
g => g.attributes.isInternalGroup && g.attributes.name === TESTFLIGHT_GROUP_NAME
);
if (!group) {
group = await ascApp.createBetaGroupAsync({
name: TESTFLIGHT_GROUP_NAME,
isInternalGroup: true,
hasAccessToAllBuilds: true,
});
}
break;
} catch (error: any) {
// Apple returns this error when the app isn't ready yet
if (error?.data?.errors?.some((e: any) => e.code === 'ENTITY_ERROR.RELATIONSHIP.INVALID')) {
if (attempt < 9) {
await sleepAsync(10_000);
continue;
}
}
throw error;
}
}
if (!group) {
throw new Error('Failed to create TestFlight group');
}
const users = await User.getAsync(ascApp.context);
const admins = users.filter(u => u.attributes.roles?.includes(UserRole.ADMIN));
const existingEmails = new Set(
group.attributes.betaTesters?.map((t: any) => t.attributes.email?.toLowerCase()) ?? []
);
const newTesters = admins
.filter(u => u.attributes.email && !existingEmails.has(u.attributes.email.toLowerCase()))
.map(u => ({
email: u.attributes.email!,
firstName: u.attributes.firstName ?? '',
lastName: u.attributes.lastName ?? '',
}));
if (newTesters.length > 0) {
await group.createBulkBetaTesterAssignmentsAsync(newTesters);
}
await ensureTestFlightGroupExistsAsync(ascApp);
}
/* eslint-disable no-console */
@@ -15,6 +15,7 @@ import { AppMutation } from '../../../graphql/mutations/AppMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { createOrModifyExpoConfigAsync } from '../../../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { maybeSetProjectIconFromAppConfigAsync } from '../../../project/projectIcon';
import { isExpoInstalled } from '../../../project/projectUtils';
import { confirmAsync, promptAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
@@ -36,6 +37,7 @@ jest.mock('../../../ora', () => ({
}),
}));
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
jest.mock('../../../project/projectIcon');
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
jest.mock('../../../project/projectUtils');
jest.mock('../../../utils/json');
@@ -887,4 +889,67 @@ describe(ProjectInit.name, () => {
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
});
describe('project icon', () => {
beforeEach(() => {
mockTestProject({ configuredProjectId: '1234', configuredOwner: jester.accounts[0].name });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
});
it('sets the icon from the app config', async () => {
await new ProjectInit([], commandOptions).run();
expect(jest.mocked(maybeSetProjectIconFromAppConfigAsync).mock.calls[0][1]).toEqual({
projectId: '1234',
projectDir: '/test-project',
});
});
it('does not set the icon with --no-icon', async () => {
await new ProjectInit(['--no-icon'], commandOptions).run();
expect(maybeSetProjectIconFromAppConfigAsync).not.toHaveBeenCalled();
});
it('reports the icon source in JSON output', async () => {
jest.mocked(maybeSetProjectIconFromAppConfigAsync).mockResolvedValue({
status: 'set',
icon: { field: 'icon', path: '/test-project/assets/icon.png' },
});
await new ProjectInit(['--json', '--non-interactive'], commandOptions).run();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
status: 'already-linked',
projectId: '1234',
owner: 'jester',
slug: 'testing-123',
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
icon: { source: 'icon' },
});
});
it('omits the icon from JSON output when it was not set', async () => {
jest.mocked(maybeSetProjectIconFromAppConfigAsync).mockResolvedValue({
status: 'skipped',
reason: 'no-icon-in-app-config',
});
await new ProjectInit(['--json', '--non-interactive'], commandOptions).run();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
status: 'already-linked',
projectId: '1234',
owner: 'jester',
slug: 'testing-123',
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
});
});
});
});
@@ -1,23 +1,17 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { getProjectDashboardUrl } from '../../../build/utils/url';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EASNonInteractiveFlag } from '../../../commandUtils/flags';
import { AppUploadSessionType } from '../../../graphql/generated';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import Log, { link } from '../../../log';
import { ora } from '../../../ora';
import { uploadAppScopedFileAtPathToGCSAsync } from '../../../uploads';
import { sleepAsync } from '../../../utils/promise';
const ALLOWED_EXTENSIONS = ['.png', '.jpg', '.jpeg'];
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; // matches the upload session's GCS limit
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 90_000;
import {
pollForProfileImageChangeAsync,
uploadProjectIconAsync,
validateIconAsync,
} from '../../../project/projectIcon';
export default class ProjectIconSet extends EasCommand {
static override description = 'set the project icon displayed on the EAS dashboard';
@@ -51,7 +45,7 @@ export default class ProjectIconSet extends EasCommand {
nonInteractive: flags['non-interactive'],
});
await validateImageAsync(imagePath);
await validateIconAsync(imagePath);
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
const projectDashboardUrl = getProjectDashboardUrl(app.ownerAccount.name, app.slug);
@@ -62,11 +56,7 @@ export default class ProjectIconSet extends EasCommand {
const spinner = ora('Uploading project icon').start();
try {
await uploadAppScopedFileAtPathToGCSAsync(graphqlClient, {
type: AppUploadSessionType.ProfileImageUpload,
appId: projectId,
path: imagePath,
});
await uploadProjectIconAsync(graphqlClient, { projectId, imagePath });
// The icon is processed asynchronously (resized and assigned to the
// project by the server), so poll until the icon URL changes.
@@ -84,48 +74,3 @@ export default class ProjectIconSet extends EasCommand {
}
}
}
async function validateImageAsync(imagePath: string): Promise<void> {
if (!(await fs.pathExists(imagePath))) {
throw new Error(`No file found at ${imagePath}`);
}
const extension = path.extname(imagePath).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(extension)) {
throw new Error(
`Unsupported image format "${extension}". The icon must be a PNG or JPEG file.`
);
}
const { size } = await fs.stat(imagePath);
if (size > MAX_IMAGE_SIZE_BYTES) {
throw new Error(
`The image is ${(size / 1024 / 1024).toFixed(1)} MB, but the maximum allowed size is 10 MB.`
);
}
}
async function pollForProfileImageChangeAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
previousProfileImageUrl,
projectDashboardUrl,
}: {
projectId: string;
previousProfileImageUrl: string | null;
projectDashboardUrl: string;
}
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
await sleepAsync(POLL_INTERVAL_MS);
const profileImageUrl = await AppQuery.byIdProfileImageUrlAsync(graphqlClient, projectId);
if (profileImageUrl && profileImageUrl !== previousProfileImageUrl) {
return;
}
}
throw new Error(
`Timed out waiting for the icon to be processed. It may still appear on the project page shortly: ${chalk.underline(
projectDashboardUrl
)}`
);
}
@@ -6,6 +6,7 @@ import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { maybeSetProjectIconFromAppConfigAsync } from '../../project/projectIcon';
import {
ProjectInitResult,
ensureOwnerSlugConsistencyAsync,
@@ -37,6 +38,12 @@ export default class ProjectInit extends EasCommand {
description:
'Whether to create a new project/link an existing project without additional prompts or overwrite any existing project ID when running with --id flag',
}),
icon: Flags.boolean({
description:
'Set the icon shown on the EAS dashboard from the app config, when the project does not have one yet',
default: true,
allowNo: true,
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -82,6 +89,13 @@ export default class ProjectInit extends EasCommand {
}
);
const iconResult = flags.icon
? await maybeSetProjectIconFromAppConfigAsync(graphqlClient, {
projectId: result.projectId,
projectDir,
})
: null;
if (jsonFlag) {
printJsonOnlyOutput({
status: result.status,
@@ -89,6 +103,7 @@ export default class ProjectInit extends EasCommand {
owner,
slug,
dashboardUrl: getProjectDashboardUrl(owner, slug),
...(iconResult?.status === 'set' ? { icon: { source: iconResult.icon.field } } : {}),
});
}
}
+10
View File
@@ -34,6 +34,7 @@ interface RawCommandFlags {
verbose: boolean;
wait: boolean;
'non-interactive': boolean;
'auto-testflight-setup': boolean;
'verbose-fastlane': boolean;
groups?: string[];
}
@@ -46,6 +47,7 @@ interface CommandFlags {
verbose: boolean;
wait: boolean;
nonInteractive: boolean;
autoTestFlightSetup: boolean;
isVerboseFastlaneEnabled: boolean;
groups?: string[];
}
@@ -106,6 +108,11 @@ export default class Submit extends EasCommand {
default: false,
description: 'Run command in non-interactive mode',
}),
'auto-testflight-setup': Flags.boolean({
default: true,
allowNo: true,
description: 'Set up an internal TestFlight group for the app (iOS only)',
}),
};
static override contextDefinition = {
@@ -152,6 +159,7 @@ export default class Submit extends EasCommand {
profile: submissionProfile.profile,
archiveFlags: flagsWithPlatform.archiveFlags,
nonInteractive: flagsWithPlatform.nonInteractive,
autoTestFlightSetup: flagsWithPlatform.autoTestFlightSetup,
isVerboseFastlaneEnabled: flagsWithPlatform.isVerboseFastlaneEnabled,
groups: flagsWithPlatform.groups,
actor,
@@ -198,6 +206,7 @@ export default class Submit extends EasCommand {
wait,
profile,
'non-interactive': nonInteractive,
'auto-testflight-setup': autoTestFlightSetup,
'verbose-fastlane': isVerboseFastlaneEnabled,
groups,
'what-to-test': whatToTest,
@@ -221,6 +230,7 @@ export default class Submit extends EasCommand {
wait,
profile,
nonInteractive,
autoTestFlightSetup,
whatToTest,
isVerboseFastlaneEnabled,
groups,
@@ -0,0 +1,105 @@
import { App, BetaGroup, User } from '@expo/apple-utils';
import { ensureTestFlightGroupExistsAsync } from '../ensureTestFlightGroup';
import { confirmAsync } from '../../../../prompts';
jest.mock('../../../../ora');
jest.mock('../../../../prompts', () => ({
confirmAsync: jest.fn(),
}));
jest.mock('@expo/apple-utils', () => ({
...jest.requireActual('@expo/apple-utils'),
User: { getAsync: jest.fn() },
BetaGroup: { deleteAsync: jest.fn() },
}));
function mockApp({
groups,
createdGroup,
}: {
groups: Partial<BetaGroup>[];
createdGroup?: Partial<BetaGroup>;
}): App {
return {
id: '1234567890',
context: {},
getBetaGroupsAsync: jest.fn().mockResolvedValue(groups),
createBetaGroupAsync: jest.fn().mockResolvedValue(createdGroup),
} as unknown as App;
}
function mockGroup({
hasAccessToAllBuilds,
}: {
hasAccessToAllBuilds: boolean;
}): Partial<BetaGroup> {
return {
id: 'group-id',
context: {} as BetaGroup['context'],
attributes: {
name: 'Team (Expo)',
isInternalGroup: true,
hasAccessToAllBuilds,
betaTesters: [],
} as unknown as BetaGroup['attributes'],
createBulkBetaTesterAssignmentsAsync: jest.fn(),
};
}
beforeEach(() => {
jest.mocked(confirmAsync).mockReset();
jest.mocked(User.getAsync).mockReset().mockResolvedValue([]);
jest.mocked(BetaGroup.deleteAsync).mockReset();
});
describe(ensureTestFlightGroupExistsAsync, () => {
it('skips setup when the app already has beta groups', async () => {
const app = mockApp({ groups: [mockGroup({ hasAccessToAllBuilds: true })] });
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: true });
expect(app.createBetaGroupAsync).not.toHaveBeenCalled();
expect(User.getAsync).not.toHaveBeenCalled();
});
it('creates a group and adds admins without prompting in non-interactive mode', async () => {
const app = mockApp({
groups: [],
createdGroup: mockGroup({ hasAccessToAllBuilds: true }),
});
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: true });
expect(app.createBetaGroupAsync).toHaveBeenCalledWith({
name: 'Team (Expo)',
isInternalGroup: true,
hasAccessToAllBuilds: true,
});
expect(confirmAsync).not.toHaveBeenCalled();
});
it('does not prompt or delete the group in non-interactive mode when it lacks access to all builds', async () => {
const app = mockApp({
groups: [],
createdGroup: mockGroup({ hasAccessToAllBuilds: false }),
});
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: true });
expect(confirmAsync).not.toHaveBeenCalled();
expect(BetaGroup.deleteAsync).not.toHaveBeenCalled();
});
it('prompts to regenerate the group in interactive mode when it lacks access to all builds', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
const app = mockApp({
groups: [],
createdGroup: mockGroup({ hasAccessToAllBuilds: false }),
});
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: false });
expect(confirmAsync).toHaveBeenCalled();
expect(BetaGroup.deleteAsync).not.toHaveBeenCalled();
});
});
@@ -12,12 +12,10 @@ const AUTO_GROUP_NAME = 'Team (Expo)';
* Ensure a TestFlight internal group with access to all builds exists for the app and has all admin users invited to it.
* This allows users to instantly access their builds from TestFlight after it finishes processing.
*/
export async function ensureTestFlightGroupExistsAsync(app: App): Promise<void> {
if (process.env.EAS_NO_AUTO_TESTFLIGHT_SETUP) {
Log.debug('EAS_NO_AUTO_TESTFLIGHT_SETUP is set, skipping TestFlight setup');
return;
}
export async function ensureTestFlightGroupExistsAsync(
app: App,
{ nonInteractive = false }: { nonInteractive?: boolean } = {}
): Promise<void> {
const groups = await app.getBetaGroupsAsync({
query: {
includes: ['betaTesters'],
@@ -33,19 +31,22 @@ export async function ensureTestFlightGroupExistsAsync(app: App): Promise<void>
const group = await ensureInternalGroupAsync({
app,
groups,
nonInteractive,
});
const users = await User.getAsync(app.context);
const admins = users.filter(user => user.attributes.roles?.includes(UserRole.ADMIN));
await addAllUsersToInternalGroupAsync(group, admins);
await addAllUsersToInternalGroupAsync(group, admins, app);
}
async function ensureInternalGroupAsync({
groups,
app,
nonInteractive,
}: {
groups: BetaGroup[];
app: App;
nonInteractive: boolean;
}): Promise<BetaGroup> {
let betaGroup = groups.find(group => group.attributes.name === AUTO_GROUP_NAME);
if (!betaGroup) {
@@ -88,6 +89,13 @@ async function ensureInternalGroupAsync({
// `hasAccessToAllBuilds` is a newer feature that allows the group to automatically have access to all builds. This cannot be patched so we need to recreate the group.
if (!betaGroup.attributes.hasAccessToAllBuilds) {
if (nonInteractive) {
// Deleting a group is destructive, so it needs explicit confirmation.
Log.warn(
`TestFlight group "${AUTO_GROUP_NAME}" does not have automatic access to new builds. Re-run in interactive mode to regenerate it, or recreate it in App Store Connect.`
);
return betaGroup;
}
if (
await confirmAsync({
message: 'Regenerate internal TestFlight group to allow automatic access to all builds?',
@@ -101,6 +109,7 @@ async function ensureInternalGroupAsync({
includes: ['betaTesters'],
},
}),
nonInteractive,
});
}
}
@@ -108,7 +117,11 @@ async function ensureInternalGroupAsync({
return betaGroup;
}
async function addAllUsersToInternalGroupAsync(group: BetaGroup, users: User[]): Promise<void> {
async function addAllUsersToInternalGroupAsync(
group: BetaGroup,
users: User[],
app: App
): Promise<void> {
let emails = users
.filter(user => user.attributes.email)
.map(user => ({
@@ -162,7 +175,7 @@ async function addAllUsersToInternalGroupAsync(group: BetaGroup, users: User[]):
});
if (!success) {
const groupUrl = await getTestFlightGroupUrlAsync(group);
const groupUrl = await getTestFlightGroupUrlAsync(group, app);
Log.error(
`Unable to add all admins to TestFlight group "${
@@ -181,12 +194,12 @@ async function addAllUsersToInternalGroupAsync(group: BetaGroup, users: User[]):
}
}
async function getTestFlightGroupUrlAsync(group: BetaGroup): Promise<string | null> {
async function getTestFlightGroupUrlAsync(group: BetaGroup, app: App): Promise<string | null> {
if (group.context.providerId) {
try {
const session = await Session.getSessionForProviderIdAsync(group.context.providerId);
return `https://appstoreconnect.apple.com/teams/${session.provider.publicProviderId}/apps/6741088859/testflight/groups/${group.id}`;
return `https://appstoreconnect.apple.com/teams/${session.provider.publicProviderId}/apps/${app.id}/testflight/groups/${group.id}`;
} catch (error) {
// Avoid crashing if we can't get the session.
Log.debug('Failed to get session for provider ID', error);
@@ -15,7 +15,13 @@ describe(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync, () => {
jester
)
).rejects.toThrow(
`EAS project not configured. To configure it non-interactively, choose the account that should own the project and run:\n\n eas init --account <name> --non-interactive\n\nAccounts you have permissions to create projects in: jester\n\nAlternatively, set the "owner" field in your app config, or run "eas init" to configure it interactively.`
`EAS project not configured. This command cannot configure it in non-interactive mode. ` +
`Run one of the following, then re-run this command:\n\n` +
`To link an existing project:\n\n` +
` eas init --id <project-id> --non-interactive\n\n` +
`To create a new project:\n\n` +
` eas init --account fake --non-interactive\n\n` +
`Accounts you can create projects in: jester`
);
});
});
@@ -0,0 +1,171 @@
import { vol } from 'memfs';
import { getPrivateExpoConfigAsync } from '../expoConfig';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppUploadSessionType } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { uploadAppScopedFileAtPathToGCSAsync } from '../../uploads';
import { maybeSetProjectIconFromAppConfigAsync, resolveAppConfigIconAsync } from '../projectIcon';
jest.mock('fs');
jest.mock('../expoConfig');
jest.mock('../../graphql/queries/AppQuery');
jest.mock('../../log');
jest.mock('../../uploads');
const mockGetPrivateExpoConfigAsync = jest.mocked(getPrivateExpoConfigAsync);
const mockByIdProfileImageUrlAsync = jest.mocked(AppQuery.byIdProfileImageUrlAsync);
const mockUploadAsync = jest.mocked(uploadAppScopedFileAtPathToGCSAsync);
const graphqlClient = {} as ExpoGraphqlClient;
const projectDir = '/app';
const projectId = 'project-123';
function mockAppConfig(exp: Record<string, any>): void {
mockGetPrivateExpoConfigAsync.mockResolvedValue({
name: 'testing 123',
slug: 'testing-123',
...exp,
} as any);
}
beforeEach(() => {
vol.reset();
jest.clearAllMocks();
});
describe(resolveAppConfigIconAsync, () => {
it('resolves the icon field relative to the project directory', async () => {
vol.fromJSON({ '/app/assets/icon.png': 'fake-png-bytes' });
mockAppConfig({ icon: './assets/icon.png' });
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toEqual({
field: 'icon',
path: '/app/assets/icon.png',
});
});
it('falls back to ios.icon when the top-level icon file is missing', async () => {
vol.fromJSON({ '/app/assets/ios-icon.png': 'fake-png-bytes' });
mockAppConfig({ icon: './assets/missing.png', ios: { icon: './assets/ios-icon.png' } });
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toEqual({
field: 'ios.icon',
path: '/app/assets/ios-icon.png',
});
});
it('uses the light variant when ios.icon is a per-appearance map', async () => {
vol.fromJSON({ '/app/assets/light.png': 'fake-png-bytes' });
mockAppConfig({
ios: { icon: { light: './assets/light.png', dark: './assets/dark.png' } },
});
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toEqual({
field: 'ios.icon',
path: '/app/assets/light.png',
});
});
it('falls back to the Android adaptive icon foreground', async () => {
vol.fromJSON({ '/app/assets/foreground.png': 'fake-png-bytes' });
mockAppConfig({
android: { adaptiveIcon: { foregroundImage: './assets/foreground.png' } },
});
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toEqual({
field: 'android.adaptiveIcon.foregroundImage',
path: '/app/assets/foreground.png',
});
});
it('ignores a remote icon URL', async () => {
mockAppConfig({ icon: 'https://example.com/icon.png' });
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toBeNull();
});
it('returns null when the app config has no icon', async () => {
mockAppConfig({});
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toBeNull();
});
it('returns null when the app config cannot be read', async () => {
mockGetPrivateExpoConfigAsync.mockRejectedValue(new Error('Invalid app config.'));
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toBeNull();
});
});
describe(maybeSetProjectIconFromAppConfigAsync, () => {
it('uploads the app config icon when the project has none', async () => {
vol.fromJSON({ '/app/assets/icon.png': 'fake-png-bytes' });
mockAppConfig({ icon: './assets/icon.png' });
mockByIdProfileImageUrlAsync.mockResolvedValue(null);
await expect(
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
).resolves.toEqual({
status: 'set',
icon: { field: 'icon', path: '/app/assets/icon.png' },
});
expect(mockUploadAsync).toHaveBeenCalledWith(graphqlClient, {
type: AppUploadSessionType.ProfileImageUpload,
appId: projectId,
path: '/app/assets/icon.png',
});
});
it('does not overwrite an icon the project already has', async () => {
vol.fromJSON({ '/app/assets/icon.png': 'fake-png-bytes' });
mockAppConfig({ icon: './assets/icon.png' });
mockByIdProfileImageUrlAsync.mockResolvedValue('https://example.com/existing.png');
await expect(
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
).resolves.toEqual({ status: 'skipped', reason: 'icon-already-set' });
expect(mockUploadAsync).not.toHaveBeenCalled();
});
it('skips without a server round-trip when the app config has no icon', async () => {
mockAppConfig({});
await expect(
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
).resolves.toEqual({ status: 'skipped', reason: 'no-icon-in-app-config' });
expect(mockByIdProfileImageUrlAsync).not.toHaveBeenCalled();
expect(mockUploadAsync).not.toHaveBeenCalled();
});
it('skips an unsupported image format instead of throwing', async () => {
vol.fromJSON({ '/app/assets/icon.gif': 'fake-gif-bytes' });
mockAppConfig({ icon: './assets/icon.gif' });
await expect(
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
).resolves.toEqual({ status: 'skipped', reason: 'invalid-icon' });
expect(mockUploadAsync).not.toHaveBeenCalled();
});
it('skips an image over the size limit instead of throwing', async () => {
vol.fromJSON({ '/app/assets/icon.png': 'x'.repeat(10 * 1024 * 1024 + 1) });
mockAppConfig({ icon: './assets/icon.png' });
await expect(
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
).resolves.toEqual({ status: 'skipped', reason: 'invalid-icon' });
expect(mockUploadAsync).not.toHaveBeenCalled();
});
it('does not propagate an upload failure', async () => {
vol.fromJSON({ '/app/assets/icon.png': 'fake-png-bytes' });
mockAppConfig({ icon: './assets/icon.png' });
mockByIdProfileImageUrlAsync.mockResolvedValue(null);
mockUploadAsync.mockRejectedValue(new Error('Upload session expired'));
await expect(
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
).resolves.toEqual({ status: 'skipped', reason: 'upload-failed' });
});
});
@@ -1,5 +1,6 @@
import chalk from 'chalk';
import { getUnconfiguredProjectError } from './projectNotConfiguredError';
import { getProjectDashboardUrl } from '../build/utils/url';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { Role } from '../graphql/generated';
@@ -8,7 +9,7 @@ import { AppQuery } from '../graphql/queries/AppQuery';
import { link } from '../log';
import { ora } from '../ora';
import { confirmAsync } from '../prompts';
import { Actor, getCreatableAccountNames } from '../user/User';
import { Actor } from '../user/User';
/**
* 1. Looks for an existing project on EAS servers. If found, ask the user whether this is the
@@ -38,13 +39,7 @@ export async function fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsyn
);
if (options.nonInteractive) {
throw new Error(
`EAS project not configured. To configure it non-interactively, choose the account that should own the project and run:\n\n eas init --account <name> --non-interactive\n\nAccounts you have permissions to create projects in: ${getCreatableAccountNames(
actor
).join(
', '
)}\n\nAlternatively, set the "owner" field in your app config, or run "eas init" to configure it interactively.`
);
throw getUnconfiguredProjectError({ actor, accountName });
}
const account = allAccounts.find(a => a.name === accountName);
if (!account) {
+176
View File
@@ -0,0 +1,176 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { getPrivateExpoConfigAsync } from './expoConfig';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { AppUploadSessionType } from '../graphql/generated';
import { AppQuery } from '../graphql/queries/AppQuery';
import Log from '../log';
import { uploadAppScopedFileAtPathToGCSAsync } from '../uploads';
import { sleepAsync } from '../utils/promise';
const ALLOWED_EXTENSIONS = ['.png', '.jpg', '.jpeg'];
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; // matches the upload session's GCS limit
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 90_000;
/** An icon found in the app config, with the field it came from for display purposes. */
export interface AppConfigIcon {
field: string;
path: string;
}
export type SetProjectIconFromAppConfigResult =
| { status: 'set'; icon: AppConfigIcon }
| {
status: 'skipped';
reason: 'no-icon-in-app-config' | 'icon-already-set' | 'invalid-icon' | 'upload-failed';
};
/**
* Set the project icon from the app config, but only when the project does not have one yet.
* Overwriting an icon picked on the dashboard would be surprising, and the app config is not the
* source of truth for it.
*
* Never throws. The project is already created or linked by the time this runs, so a failed icon
* upload must not fail the command that called it.
*/
export async function maybeSetProjectIconFromAppConfigAsync(
graphqlClient: ExpoGraphqlClient,
{ projectId, projectDir }: { projectId: string; projectDir: string }
): Promise<SetProjectIconFromAppConfigResult> {
const icon = await resolveAppConfigIconAsync(projectDir);
if (!icon) {
return { status: 'skipped', reason: 'no-icon-in-app-config' };
}
try {
await validateIconAsync(icon.path);
} catch (error: any) {
Log.warn(`Did not set the project icon from "${icon.field}": ${error.message}`);
return { status: 'skipped', reason: 'invalid-icon' };
}
const existingIconUrl = await AppQuery.byIdProfileImageUrlAsync(graphqlClient, projectId);
if (existingIconUrl) {
Log.debug('The project already has an icon, skipping the icon from the app config.');
return { status: 'skipped', reason: 'icon-already-set' };
}
try {
await uploadProjectIconAsync(graphqlClient, { projectId, imagePath: icon.path });
} catch (error: any) {
Log.warn(`Failed to set the project icon from "${icon.field}": ${error.message}`);
return { status: 'skipped', reason: 'upload-failed' };
}
// The server resizes the image and assigns it to the project asynchronously. Callers that need
// the icon to be visible before they return should poll with pollForProfileImageChangeAsync.
Log.withTick(`Set the project icon from ${chalk.bold(icon.field)} in your app config`);
return { status: 'set', icon };
}
/**
* Find the app icon on disk. Falls through to the platform-specific icons so that a project that
* only sets `ios.icon` or an Android adaptive icon still gets one.
*/
export async function resolveAppConfigIconAsync(projectDir: string): Promise<AppConfigIcon | null> {
let exp;
try {
exp = await getPrivateExpoConfigAsync(projectDir);
} catch (error: any) {
Log.debug(`Could not read the app config to resolve the project icon: ${error.message}`);
return null;
}
const candidates: [string, unknown][] = [
['icon', exp.icon],
['ios.icon', exp.ios?.icon],
['android.adaptiveIcon.foregroundImage', exp.android?.adaptiveIcon?.foregroundImage],
];
for (const [field, value] of candidates) {
const iconPath = resolveLocalIconPath(projectDir, value);
if (iconPath && (await fs.pathExists(iconPath))) {
return { field, path: iconPath };
}
}
return null;
}
function resolveLocalIconPath(projectDir: string, value: unknown): string | null {
// SDK 52 and above allow `ios.icon` to be a per-appearance map. The light icon is the one shown
// in most places, so it is the closest match for a single project icon.
let rawPath: string | null = null;
if (typeof value === 'string') {
rawPath = value;
} else if (value && typeof value === 'object' && typeof (value as any).light === 'string') {
rawPath = (value as any).light;
}
// Remote icons (http://, https://, data:) are not files we can upload. The scheme must be at
// least two characters so that a Windows drive letter is still treated as a path.
if (!rawPath || /^[a-z][a-z0-9+.-]+:/i.test(rawPath)) {
return null;
}
return path.resolve(projectDir, rawPath);
}
export async function validateIconAsync(imagePath: string): Promise<void> {
if (!(await fs.pathExists(imagePath))) {
throw new Error(`No file found at ${imagePath}`);
}
const extension = path.extname(imagePath).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(extension)) {
throw new Error(
`Unsupported image format "${extension}". The icon must be a PNG or JPEG file.`
);
}
const { size } = await fs.stat(imagePath);
if (size > MAX_IMAGE_SIZE_BYTES) {
throw new Error(
`The image is ${(size / 1024 / 1024).toFixed(1)} MB, but the maximum allowed size is 10 MB.`
);
}
}
export async function uploadProjectIconAsync(
graphqlClient: ExpoGraphqlClient,
{ projectId, imagePath }: { projectId: string; imagePath: string }
): Promise<void> {
await uploadAppScopedFileAtPathToGCSAsync(graphqlClient, {
type: AppUploadSessionType.ProfileImageUpload,
appId: projectId,
path: imagePath,
});
}
export async function pollForProfileImageChangeAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
previousProfileImageUrl,
projectDashboardUrl,
}: {
projectId: string;
previousProfileImageUrl: string | null;
projectDashboardUrl: string;
}
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
await sleepAsync(POLL_INTERVAL_MS);
const profileImageUrl = await AppQuery.byIdProfileImageUrlAsync(graphqlClient, projectId);
if (profileImageUrl && profileImageUrl !== previousProfileImageUrl) {
return;
}
}
throw new Error(
`Timed out waiting for the icon to be processed. It may still appear on the project page shortly: ${chalk.underline(
projectDashboardUrl
)}`
);
}
@@ -0,0 +1,34 @@
import { Actor, getCreatableAccountNames } from '../user/User';
/**
* Build the error shown when an EAS project is not configured and the CLI cannot configure it
* without more input. Lists the exact commands to run, because bare "eas init" also needs
* interaction and fails in the same situation.
*
* @param actor the logged-in actor
* @param accountName the account that should own the project, when it is already known
* @param reason why the CLI cannot configure the project here
* @param additionalFix an extra way to resolve the error, appended at the end
*/
export function getUnconfiguredProjectError({
actor,
accountName,
reason = 'This command cannot configure it in non-interactive mode.',
additionalFix,
}: {
actor: Actor;
accountName?: string;
reason?: string;
additionalFix?: string;
}): Error {
return new Error(
`EAS project not configured. ${reason} ` +
`Run one of the following, then re-run this command:\n\n` +
`To link an existing project:\n\n` +
` eas init --id <project-id> --non-interactive\n\n` +
`To create a new project:\n\n` +
` eas init --account ${accountName ?? '<account-name>'} --non-interactive\n\n` +
`Accounts you can create projects in: ${getCreatableAccountNames(actor).join(', ')}` +
(additionalFix ? `\n\n${additionalFix}` : '')
);
}
+3
View File
@@ -21,6 +21,7 @@ export interface SubmissionContext<T extends Platform> {
analyticsEventProperties: AnalyticsEventProperties;
exp: ExpoConfig;
nonInteractive: boolean;
autoTestFlightSetup: boolean;
isVerboseFastlaneEnabled: boolean;
groups: T extends Platform.IOS ? string[] : undefined;
platform: T;
@@ -49,6 +50,7 @@ export async function createSubmissionContextAsync<T extends Platform>(params: {
credentialsCtx?: CredentialsContext;
env?: Env;
nonInteractive: boolean;
autoTestFlightSetup?: boolean;
isVerboseFastlaneEnabled: boolean;
groups: string[] | undefined;
platform: T;
@@ -116,6 +118,7 @@ export async function createSubmissionContextAsync<T extends Platform>(params: {
return {
...rest,
autoTestFlightSetup: params.autoTestFlightSetup ?? true,
accountName: account.name,
credentialsCtx,
groups,
@@ -94,7 +94,7 @@ async function createAppStoreConnectAppAsync(
});
try {
await ensureTestFlightGroupExistsAsync(app);
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: ctx.nonInteractive });
} catch (error: any) {
// This process is not critical to the app submission so we shouldn't let it fail the entire process.
Log.error(
@@ -11,6 +11,7 @@ import {
} from './AppSpecificPasswordSource';
import { AscApiKeySource, AscApiKeySourceType } from './AscApiKeySource';
import IosSubmitter, { IosSubmissionOptions } from './IosSubmitter';
import { ensureTestFlightSetupForExistingAppAsync } from './ensureTestFlightSetup';
import { MissingCredentialsError } from '../../credentials/errors';
import Log, { learnMore } from '../../log';
import { ArchiveSource, ArchiveSourceType, getArchiveAsync } from '../ArchiveSource';
@@ -171,6 +172,7 @@ export default class IosSubmitCommand {
private async resolveAscAppIdentifierAsync(): Promise<Result<string>> {
const { ascAppId } = this.ctx.profile;
if (ascAppId) {
await ensureTestFlightSetupForExistingAppAsync(this.ctx, ascAppId);
return result(ascAppId);
} else if (this.ctx.nonInteractive) {
return result(
@@ -23,6 +23,7 @@ import {
import { refreshContextSubmitProfileAsync } from '../../commons';
import { SubmissionContext, createSubmissionContextAsync } from '../../context';
import IosSubmitCommand from '../IosSubmitCommand';
import { ensureTestFlightSetupForExistingAppAsync } from '../ensureTestFlightSetup';
jest.mock('fs');
jest.mock('../../../ora');
@@ -52,6 +53,9 @@ jest.mock('../../commons', () => {
refreshContextSubmitProfileAsync: jest.fn(),
};
});
jest.mock('../ensureTestFlightSetup', () => ({
ensureTestFlightSetupForExistingAppAsync: jest.fn(),
}));
const vcsClient = resolveVcsClient();
@@ -203,6 +207,11 @@ describe(IosSubmitCommand, () => {
submittedBuildId: undefined,
});
expect(ensureTestFlightSetupForExistingAppAsync).toHaveBeenCalledWith(
expect.anything(),
'12345678'
);
delete process.env.EXPO_APPLE_APP_SPECIFIC_PASSWORD;
});
describe('build selected from EAS', () => {
@@ -0,0 +1,153 @@
import { App } from '@expo/apple-utils';
import { Platform } from '@expo/eas-build-job';
import { resolveAscApiKeyForAppCredentialsAsync } from '../../../credentials/ios/actions/AscApiKeyUtils';
import { AuthenticationMode } from '../../../credentials/ios/appstore/authenticateTypes';
import { ensureTestFlightGroupExistsAsync } from '../../../credentials/ios/appstore/ensureTestFlightGroup';
import { hasAscEnvVars } from '../../../credentials/ios/appstore/resolveCredentials';
import { SubmissionContext } from '../../context';
import { ensureTestFlightSetupForExistingAppAsync } from '../ensureTestFlightSetup';
jest.mock('@expo/apple-utils', () => ({
...jest.requireActual('@expo/apple-utils'),
App: { infoAsync: jest.fn() },
}));
jest.mock('../../../credentials/ios/actions/AscApiKeyUtils', () => ({
resolveAscApiKeyForAppCredentialsAsync: jest.fn(),
}));
jest.mock('../../../credentials/ios/appstore/ensureTestFlightGroup', () => ({
ensureTestFlightGroupExistsAsync: jest.fn(),
}));
jest.mock('../../../credentials/ios/appstore/resolveCredentials', () => ({
...jest.requireActual('../../../credentials/ios/appstore/resolveCredentials'),
hasAscEnvVars: jest.fn(),
}));
function createContext({
nonInteractive = false,
autoTestFlightSetup = true,
}: {
nonInteractive?: boolean;
autoTestFlightSetup?: boolean;
}): {
ctx: SubmissionContext<Platform.IOS>;
ensureAuthenticatedAsync: jest.Mock;
} {
const authCtx = {
team: { id: 'team-id' },
ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' },
authState: { context: {} },
};
const appStore: any = { authCtx: undefined };
const ensureAuthenticatedAsync = jest.fn(async () => {
appStore.authCtx = authCtx;
return authCtx;
});
appStore.ensureAuthenticatedAsync = ensureAuthenticatedAsync;
return {
ctx: {
nonInteractive,
autoTestFlightSetup,
applicationIdentifierOverride: 'com.example.app',
profile: {},
user: { accounts: [{ name: 'account' }] },
accountName: 'account',
projectName: 'project',
credentialsCtx: { appStore },
graphqlClient: {},
} as SubmissionContext<Platform.IOS>,
ensureAuthenticatedAsync,
};
}
describe(ensureTestFlightSetupForExistingAppAsync, () => {
beforeEach(() => {
delete process.env.EXPO_ASC_API_KEY_PATH;
delete process.env.EXPO_ASC_KEY_ID;
delete process.env.EXPO_ASC_ISSUER_ID;
delete process.env.EXPO_APPLE_TEAM_ID;
jest.mocked(hasAscEnvVars).mockReset().mockReturnValue(false);
jest.mocked(resolveAscApiKeyForAppCredentialsAsync).mockReset().mockResolvedValue(null);
jest
.mocked(App.infoAsync)
.mockReset()
.mockResolvedValue({ id: '12345678' } as App);
jest.mocked(ensureTestFlightGroupExistsAsync).mockReset();
});
it('sets up TestFlight in non-interactive mode when stored credentials are complete', async () => {
jest.mocked(resolveAscApiKeyForAppCredentialsAsync).mockResolvedValue({
ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' },
teamId: 'team-id',
teamName: 'Team',
});
const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true });
await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678');
expect(ensureAuthenticatedAsync).toHaveBeenCalledWith({
mode: AuthenticationMode.API_KEY,
ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' },
teamId: 'team-id',
teamName: 'Team',
teamType: expect.any(String),
});
expect(ensureTestFlightGroupExistsAsync).toHaveBeenCalledWith(expect.anything(), {
nonInteractive: true,
});
});
it('sets up TestFlight in non-interactive mode when environment credentials are complete', async () => {
jest.mocked(hasAscEnvVars).mockReturnValue(true);
process.env.EXPO_ASC_API_KEY_PATH = '/path/to/key.p8';
process.env.EXPO_ASC_KEY_ID = 'key-id';
process.env.EXPO_ASC_ISSUER_ID = 'issuer-id';
process.env.EXPO_APPLE_TEAM_ID = 'team-id';
const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true });
await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678');
expect(ensureAuthenticatedAsync).toHaveBeenCalledWith({
mode: AuthenticationMode.API_KEY,
teamId: 'team-id',
teamType: expect.any(String),
});
expect(ensureTestFlightGroupExistsAsync).toHaveBeenCalledWith(expect.anything(), {
nonInteractive: true,
});
});
it('skips setup when environment credentials are incomplete', async () => {
jest.mocked(hasAscEnvVars).mockReturnValue(true);
process.env.EXPO_ASC_KEY_ID = 'key-id';
const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true });
await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678');
expect(ensureAuthenticatedAsync).not.toHaveBeenCalled();
expect(resolveAscApiKeyForAppCredentialsAsync).not.toHaveBeenCalled();
expect(ensureTestFlightGroupExistsAsync).not.toHaveBeenCalled();
});
it('skips setup when stored credentials have no Apple Team ID', async () => {
jest.mocked(resolveAscApiKeyForAppCredentialsAsync).mockResolvedValue({
ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' },
});
const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true });
await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678');
expect(ensureAuthenticatedAsync).not.toHaveBeenCalled();
expect(ensureTestFlightGroupExistsAsync).not.toHaveBeenCalled();
});
it('skips setup when automatic setup is disabled', async () => {
const { ctx, ensureAuthenticatedAsync } = createContext({ autoTestFlightSetup: false });
await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678');
expect(ensureAuthenticatedAsync).not.toHaveBeenCalled();
expect(resolveAscApiKeyForAppCredentialsAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,100 @@
import { App } from '@expo/apple-utils';
import { Platform } from '@expo/eas-build-job';
import nullthrows from 'nullthrows';
import {
AppleTeamType,
AuthenticationMode,
} from '../../credentials/ios/appstore/authenticateTypes';
import { getRequestContext } from '../../credentials/ios/appstore/authenticate';
import { ensureTestFlightGroupExistsAsync } from '../../credentials/ios/appstore/ensureTestFlightGroup';
import {
hasAscEnvVars,
resolveAppleTeamTypeFromEnvironment,
} from '../../credentials/ios/appstore/resolveCredentials';
import { resolveAscApiKeyForAppCredentialsAsync } from '../../credentials/ios/actions/AscApiKeyUtils';
import Log from '../../log';
import { getBundleIdentifierAsync } from '../../project/ios/bundleIdentifier';
import { SubmissionContext } from '../context';
/**
* Best-effort TestFlight internal group setup for an App Store Connect app
* that already exists with `ascAppId` provided.
*/
export async function ensureTestFlightSetupForExistingAppAsync(
ctx: SubmissionContext<Platform.IOS>,
ascAppIdentifier: string
): Promise<void> {
if (!ctx.autoTestFlightSetup) {
return;
}
try {
const bundleIdentifier =
ctx.applicationIdentifierOverride ??
ctx.profile.bundleIdentifier ??
(await getBundleIdentifierAsync(ctx.projectDir, ctx.exp, ctx.vcsClient));
const appLookupParams = {
account: nullthrows(
ctx.user.accounts.find(a => a.name === ctx.accountName),
`You do not have access to account: ${ctx.accountName}`
),
projectName: ctx.projectName,
bundleIdentifier,
};
if (!ctx.credentialsCtx.appStore.authCtx) {
const teamType =
resolveAppleTeamTypeFromEnvironment() ?? AppleTeamType.COMPANY_OR_ORGANIZATION;
if (hasAscEnvVars()) {
const teamId = process.env.EXPO_APPLE_TEAM_ID;
if (
!process.env.EXPO_ASC_API_KEY_PATH ||
!process.env.EXPO_ASC_KEY_ID ||
!process.env.EXPO_ASC_ISSUER_ID ||
!teamId
) {
Log.log('App Store Connect credentials are incomplete, skipping TestFlight setup');
return;
}
await ctx.credentialsCtx.appStore.ensureAuthenticatedAsync({
mode: AuthenticationMode.API_KEY,
teamId,
teamType,
});
} else {
const resolvedKey = await resolveAscApiKeyForAppCredentialsAsync({
graphqlClient: ctx.graphqlClient,
app: appLookupParams,
});
const teamId = resolvedKey?.teamId ?? process.env.EXPO_APPLE_TEAM_ID;
if (!resolvedKey || !teamId) {
Log.log('No complete App Store Connect credentials, skipping TestFlight setup');
return;
}
Log.log('Using App Store Connect API Key from EAS credentials service.');
await ctx.credentialsCtx.appStore.ensureAuthenticatedAsync({
mode: AuthenticationMode.API_KEY,
ascApiKey: resolvedKey.ascApiKey,
teamId,
teamName: resolvedKey.teamName,
teamType,
});
}
}
const authCtx = ctx.credentialsCtx.appStore.authCtx;
if (!authCtx) {
Log.debug('No App Store Connect API key available, skipping TestFlight setup');
return;
}
const app = await App.infoAsync(getRequestContext(authCtx), { id: ascAppIdentifier });
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: ctx.nonInteractive });
} catch (error: any) {
// Group setup is a convenience on top of the submission and must never
// block it.
Log.warn('Skipping TestFlight group setup:', error);
}
}
@@ -40,6 +40,27 @@ test('minimal valid eas.json for both platforms', async () => {
});
});
test('source map uploads can be enabled for both platforms', async () => {
await fs.writeJson('/project/eas.json', {
build: {
production: {
uploadSourceMaps: true,
},
},
});
const accessor = EasJsonAccessor.fromProjectPath('/project');
const iosProfile = await EasJsonUtils.getBuildProfileAsync(accessor, Platform.IOS, 'production');
const androidProfile = await EasJsonUtils.getBuildProfileAsync(
accessor,
Platform.ANDROID,
'production'
);
expect(androidProfile.uploadSourceMaps).toBe(true);
expect(iosProfile.uploadSourceMaps).toBe(true);
});
test('minimal valid eas.json for both platforms when reading eas.json from string', async () => {
const accessor = EasJsonAccessor.fromRawString(
JSON.stringify({
+1
View File
@@ -61,6 +61,7 @@ const CommonBuildProfileSchema = Joi.object({
// artifacts
buildArtifactPaths: Joi.array().items(Joi.string()),
uploadSourceMaps: Joi.boolean(),
// cache
cache: CacheSchema,
+1
View File
@@ -61,6 +61,7 @@ export interface CommonBuildProfile {
// artifacts
buildArtifactPaths?: string[];
uploadSourceMaps?: boolean;
// cache
cache?: Omit<Cache, 'clear'>;
+14 -4
View File
@@ -103,8 +103,8 @@ fi
if [[ "$PLATFORM" == "ios" ]]; then
record_sim_package_dir="$ROOT_DIR/packages/build-tools/resources/record-sim"
record_sim_bin_dir="$target_root_dir/packages/build-tools/bin"
mkdir -p "$record_sim_bin_dir"
build_tools_bin_dir="$target_root_dir/packages/build-tools/bin"
mkdir -p "$build_tools_bin_dir"
record_sim_bin_path=$(swift build \
-c release \
--package-path "$record_sim_package_dir" \
@@ -114,8 +114,18 @@ if [[ "$PLATFORM" == "ios" ]]; then
-c release \
--package-path "$record_sim_package_dir" \
--build-path "$record_sim_build_dir"
cp "$record_sim_bin_path/record-sim" "$record_sim_bin_dir/record-sim"
chmod +x "$record_sim_bin_dir/record-sim"
cp "$record_sim_bin_path/record-sim" "$build_tools_bin_dir/record-sim"
chmod +x "$build_tools_bin_dir/record-sim"
xcrun clang \
-arch arm64 \
-dynamiclib \
-O2 \
-Wall \
-Wextra \
-Werror \
-o "$build_tools_bin_dir/libeas_xcode_local_cas_plugin.dylib" \
"$ROOT_DIR/packages/build-tools/resources/xcode-local-cache-plugin/eas_local_cas_plugin.c"
# build plugin
pushd "$ROOT_DIR/packages/expo-cocoapods-proxy" >/dev/null 2>&1
+32 -1
View File
@@ -1,10 +1,11 @@
import { RuntimeSettings } from '@expo/build-tools';
import { Platform, Workflow } from '@expo/eas-build-job';
import { ManagedArtifactType, Platform, Workflow } from '@expo/eas-build-job';
import config from '../config';
import { createBuildContext } from '../context';
import { getBuildEnv } from '../env';
import { prepareRuntimeEnvironmentConfigFiles } from '../runtimeEnvironment';
import { uploadSourceMapAsync } from '../upload';
jest.mock('../env', () => ({
getBuildEnv: jest.fn(() => ({
@@ -15,6 +16,11 @@ jest.mock('../env', () => ({
jest.mock('../runtimeEnvironment', () => ({
prepareRuntimeEnvironmentConfigFiles: jest.fn(async () => {}),
}));
jest.mock('../upload', () => ({
...jest.requireActual('../upload'),
uploadSourceMapAsync: jest.fn(async () => ({ filename: null })),
uploadWithAnalyticsAsync: jest.fn(async fn => await fn()),
}));
describe(createBuildContext.name, () => {
const childLogger = {
@@ -84,4 +90,29 @@ describe(createBuildContext.name, () => {
})
);
});
it('routes managed source maps to the source-map uploader', async () => {
const ctx = await createBuildContext({
...baseOptions,
job: {
platform: Platform.ANDROID,
type: Workflow.GENERIC,
secrets: { environmentSecrets: [] },
} as any,
});
await ctx.uploadArtifact({
artifact: {
type: ManagedArtifactType.SOURCE_MAP,
paths: ['/workingdir/observe-source-maps/android.map'],
},
logger: childLogger,
});
expect(uploadSourceMapAsync).toHaveBeenCalledWith(ctx, {
sourceMapPath: '/workingdir/observe-source-maps/android.map',
buildId: baseOptions.buildId,
logger: childLogger,
});
});
});
@@ -8,6 +8,7 @@ import { Response } from 'node-fetch';
import {
uploadApplicationArchiveAsync,
uploadBuildArtifactsAsync,
uploadSourceMapAsync,
uploadWorkflowArtifactAsync,
} from '../upload';
import { turtleFetch } from '../utils/turtleFetch';
@@ -465,6 +466,90 @@ describe(uploadBuildArtifactsAsync.name, () => {
});
});
describe(uploadSourceMapAsync.name, () => {
it('uploads and registers a source map with its dedicated artifact type', async () => {
vol.fromJSON({
'./source-map.map': JSON.stringify({ version: 3, sources: [], names: [], mappings: '' }),
});
const buildId = randomUUID();
// @ts-expect-error
const ctx: BuildContext<Job> = {
env: {
EAS_BUILD_ID: buildId,
},
job: {
platform: 'android',
secrets: {
robotAccessToken: 'fake-token',
},
} as Job,
logger: mockLogger,
};
const bucketKey = `source-maps/${buildId}/source-map.map`;
const uploadUrl = `https://upload.url/${randomUUID()}`;
const authorization = randomUUID();
turtleFetchMock.mockImplementation(async url => {
if (url === `https://api.expo.test/v2/turtle-builds/${buildId}/artifacts/`) {
return {
ok: true,
status: 200,
} as Response;
} else if (url === `https://api.expo.test/v2/turtle-builds/${buildId}/upload-sessions/`) {
return {
ok: true,
status: 200,
json: async () => ({
data: {
bucketKey,
url: uploadUrl,
headers: { authorization },
storageType: 'GCS',
},
}),
} as Response;
}
return { ok: false, status: 404 } as Response;
});
await uploadSourceMapAsync(ctx, {
sourceMapPath: './source-map.map',
buildId,
logger: ctx.logger,
});
expect(uploadWithSignedUrl).toHaveBeenCalledWith(
expect.objectContaining({
signedUrl: {
url: uploadUrl,
headers: { authorization },
},
})
);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
1,
`https://api.expo.test/v2/turtle-builds/${buildId}/upload-sessions/`,
'POST',
expect.objectContaining({
json: {
filename: `source-map-${buildId}.map`,
name: 'Source Map',
size: expect.any(Number),
type: 'sourceMap',
},
})
);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
2,
`https://api.expo.test/v2/turtle-builds/${buildId}/artifacts/`,
'POST',
expect.objectContaining({
json: { source: { bucketKey, type: 'GCS' }, type: 'sourceMap' },
})
);
});
});
describe('with signed upload url provided via www', () => {
it('should use www signed upload url', async () => {
vol.fromJSON({
+8
View File
@@ -13,6 +13,7 @@ import sentry from './sentry';
import {
uploadApplicationArchiveAsync,
uploadBuildArtifactsAsync,
uploadSourceMapAsync,
uploadWithAnalyticsAsync,
uploadWorkflowArtifactAsync,
} from './upload';
@@ -67,6 +68,13 @@ export async function createBuildContext<TJob extends Job>({
analytics
);
}
case ManagedArtifactType.SOURCE_MAP: {
assert(job.platform, 'Uploading source maps outside of builds is not supported.');
return await uploadWithAnalyticsAsync(
() => uploadSourceMapAsync(ctx, { sourceMapPath: paths[0], buildId, logger }),
analytics
);
}
default: {
return await uploadWithAnalyticsAsync(
() =>
+69 -3
View File
@@ -137,6 +137,59 @@ export async function uploadBuildArtifactsAsync(
}
}
export async function uploadSourceMapAsync(
ctx: BuildContext,
{
sourceMapPath,
buildId,
logger,
}: {
sourceMapPath: string;
buildId: string;
logger: bunyan;
}
): Promise<{ filename: string | null }> {
const { localPath, size } = await prepareArtifactsForUploadAsync(logger, [sourceMapPath]);
const filename = `source-map-${buildId}.map`;
let uploadSession: SignedUrl | null = null;
try {
const { signedUrl, bucketKey, storageType } = await createUploadSessionAsync(ctx, {
filename,
name: 'Source Map',
size,
artifactType: 'sourceMap',
});
uploadSession = signedUrl;
await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => {
return fs.createReadStream(localPath);
},
});
await saveArtifactAsync(ctx, { bucketKey, type: 'sourceMap', storageType });
return { filename: null };
} catch (err: any) {
logger.error({ err, filename, size }, 'Source map upload failed');
throw new errors.SystemError('Failed to upload source map.', {
trackingCode: 'EAS_BUILD_UPLOAD_SOURCE_MAP_FAILED',
metadata: {
filename,
size,
...uploadSession,
...(err instanceof ErrorWithMetadata ? err.metadata : {}),
},
cause: err,
});
}
}
export async function uploadWorkflowArtifactAsync(
ctx: BuildContext,
{
@@ -246,7 +299,14 @@ async function createUploadSessionAsync(
name,
size,
metadata,
}: { filename: string; name: string; size: number; metadata?: Record<string, unknown> }
artifactType,
}: {
filename: string;
name: string;
size: number;
metadata?: Record<string, unknown>;
artifactType?: 'sourceMap';
}
): Promise<{
bucketKey: string;
signedUrl: SignedUrl;
@@ -273,7 +333,13 @@ async function createUploadSessionAsync(
'POST',
// 'name' is ignored by Turtle Build router, but provide it for potential use for telemetry, etc.
{
json: { filename, name, size, metadata },
json: {
filename,
name,
size,
metadata,
...(artifactType ? { type: artifactType } : {}),
},
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
@@ -377,7 +443,7 @@ async function saveArtifactAsync(
storageType,
}: {
bucketKey: string;
type: 'applicationArchive' | 'buildArtifacts';
type: 'applicationArchive' | 'buildArtifacts' | 'sourceMap';
storageType: ArchiveSourceType | null;
}
): Promise<void> {