mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-13 20:28:08 -10:00
Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a04ae6342 | ||
|
|
8443581d13 | ||
|
|
aa6e610000 | ||
|
|
3cc58e500f | ||
|
|
2a7cb5e1fe | ||
|
|
289d579869 | ||
|
|
067c0e578f | ||
|
|
520c63fd3e | ||
|
|
221e01568c |
No files matched your search
+14
-4
@@ -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
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,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(),
|
||||
};
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
export enum ManagedArtifactType {
|
||||
APPLICATION_ARCHIVE = 'APPLICATION_ARCHIVE',
|
||||
BUILD_ARTIFACTS = 'BUILD_ARTIFACTS',
|
||||
SOURCE_MAP = 'SOURCE_MAP',
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
|
||||
@@ -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,
|
||||
|
||||
+74
-3
@@ -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(
|
||||
|
||||
@@ -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 } } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
+105
@@ -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);
|
||||
|
||||
+7
-1
@@ -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' });
|
||||
});
|
||||
});
|
||||
+3
-8
@@ -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) {
|
||||
|
||||
@@ -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}` : '')
|
||||
);
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -61,6 +61,7 @@ const CommonBuildProfileSchema = Joi.object({
|
||||
|
||||
// artifacts
|
||||
buildArtifactPaths: Joi.array().items(Joi.string()),
|
||||
uploadSourceMaps: Joi.boolean(),
|
||||
|
||||
// cache
|
||||
cache: CacheSchema,
|
||||
|
||||
@@ -61,6 +61,7 @@ export interface CommonBuildProfile {
|
||||
|
||||
// artifacts
|
||||
buildArtifactPaths?: string[];
|
||||
uploadSourceMaps?: boolean;
|
||||
|
||||
// cache
|
||||
cache?: Omit<Cache, 'clear'>;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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(
|
||||
() =>
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in new issue
Block a user