Compare commits

..
Author SHA1 Message Date
AbbanMustafa 8c45fdfd7e [build-tools] - bubble validation error for when caches exceed max size 2026-06-11 10:34:23 -04:00
AbbanMustafa b024609a5e [build-tools] - bubble validation error for when caches exceed max size 2026-06-10 17:03:32 -04:00
AbbanMustafa 8afc461064 [build-tools] - bubble validation error for when caches exceed max size 2026-06-10 16:48:58 -04:00
Szymon Dziedzic d2ed1feb67 Speed up agent-device session startup (#3841) 2026-06-10 14:29:55 +02:00
Stanisław Chmiela cc49343b39 [worker] Use runtime settings from storage (#3801)
<!-- If this PR requires a changelog entry, add it by commenting the PR with the command `/changelog-entry [breaking-change|new-feature|bug-fix|chore] [message]`. -->
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

https://github.com/expo/workflow-orchestration/pull/851

# How

Added `RuntimeSettings` namespace in `build-tools`. It is expected to be called in the beginning with `loadAsync` to fetch data from the bucket.

# Test Plan

Once approved deploy to staging, run a few builds with different settings.
2026-06-10 12:21:53 +02:00
Ash Wu 10f9edc910 [build-tools] fix getMaestroVersion parsing error (#3834)
Signed-off-by: Ash Wu <[email protected]>
2026-06-10 16:21:51 +08:00
Stanisław Chmiela 9d1a6b7796 [scripts] Add runtime settings editor (#3812)
I want us to move off of killswitches in Redis coming from `workflow-orchestration` towards fetching killswitches from a simple JSON file.

I picked GCS over R2 because it lets us make objects fully public (no `Authorization` required) and I (Stanley) found no way to achieve the same thing with R2.

This pull request adds a script `scripts/edit-runtime-settings staging | production` that is going to let us edit the file.
2026-06-09 14:14:50 +02:00
Gabe Debes d8cca2c8e8 [expo-cocoapods-proxy] Bump bundler to fix compatibility with RubyGems 4 (#3824)
* [expo-cocoapods-proxy] Bump bundler to fix compatibility with RubyGems 4

* update CHANGELOG.md

* Add manual worker build workflow

* Add manual worker build workflow

* tmp: trigger worker build on branch push

* Trigger worker build on PRs touching worker or cocoapods-proxy

* Print job statuses on EAS workflow success

* Remove manual worker build workflow
2026-06-08 08:59:27 -07:00
Kudo Chien fb818fce54 [repack] add ios signing entitlements params (#3803) 2026-06-08 22:52:07 +08:00
Ash Wu 11263c23a0 [build-tools] fix worker crash when xcpretty formatter throws on xcodebuild output (#3818)
* [build-tools] fix worker crash when xcpretty formatter throws on xcodebuild output

Signed-off-by: Ash Wu <[email protected]>

* address comments

Signed-off-by: Ash Wu <[email protected]>

---------

Signed-off-by: Ash Wu <[email protected]>
2026-06-08 21:04:27 +08:00
Ash Wu 80baa13ffa [build-tools] track installed maestro cli version (#3822)
Signed-off-by: Ash Wu <[email protected]>
2026-06-08 15:05:54 +08:00
Expo CI 58982ee212 update CHANGELOG.md 2026-06-05 21:39:01 +00:00
29 changed files with 1411 additions and 207 deletions

No files matched your search

+10 -4
View File
@@ -8,15 +8,21 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
### 🐛 Bug fixes
- [expo-cocoapods-proxy] Fix iOS worker tarball build failing on macOS Tahoe due to bundler incompatibility with RubyGems 4. ([#3824](https://github.com/expo/eas-cli/pull/3824) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
## [20.1.0](https://github.com/expo/eas-cli/releases/tag/v20.1.0) - 2026-06-05
### 🎉 New features
- [eas-cli] Add `eas update:embedded:view` command. ([#3810](https://github.com/expo/eas-cli/pull/3810) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:list` command. ([#3811](https://github.com/expo/eas-cli/pull/3811) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:embedded:delete` command. ([#3809](https://github.com/expo/eas-cli/pull/3809) by [@gwdp](https://github.com/gwdp))
- [build-tools] Auto-upload embedded bundle after build when `EAS_UPDATE_EXPERIMENTAL_UPLOAD_EMBEDDED_BUNDLE` is set. ([#3767](https://github.com/expo/eas-cli/pull/3767) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
### 🧹 Chores
## [20.0.0](https://github.com/expo/eas-cli/releases/tag/v20.0.0) - 2026-05-29
### 🛠 Breaking changes
+2
View File
@@ -3,4 +3,6 @@ _.path = "./tools/bin"
MISE_NODE_COREPACK = "true"
[tools]
gcloud = "570.0.0"
jq = "1.8.1"
node = "20.19.4"
+1 -1
View File
@@ -77,7 +77,7 @@
"zod": "^4.3.5"
},
"devDependencies": {
"@expo/repack-app": "~0.4.1",
"@expo/repack-app": "~0.6.1",
"@types/fs-extra": "^11.0.4",
"@types/jest": "^29.5.12",
"@types/lodash": "^4.17.4",
@@ -51,6 +51,7 @@ describe('Datadog singleton', () => {
build_phase: 'install_dependencies',
platform: 'ios',
result: 'success',
target: 'build',
},
},
],
@@ -70,7 +71,7 @@ describe('Datadog singleton', () => {
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.workflow.maestro_cli_version', 1, {
Datadog.distribution('eas.maestro.install', 1, {
maestro_version: '2.1.0',
});
@@ -81,11 +82,12 @@ describe('Datadog singleton', () => {
json: {
metrics: [
{
name: 'eas.workflow.maestro_cli_version',
name: 'eas.maestro.install',
type: 'distribution',
value: 1,
tags: {
maestro_version: '2.1.0',
target: 'job_run',
},
},
],
@@ -120,6 +122,7 @@ describe('Datadog singleton', () => {
tags: {
event: 'find_artifacts_absolute_path_dry_run',
status: 'match',
target: 'build',
},
},
headers: { Authorization: 'Bearer token-abc' },
@@ -150,6 +153,7 @@ describe('Datadog singleton', () => {
message: 'Gradle cache restored (hit)',
tags: {
cache_type: 'gradle',
target: 'job_run',
},
},
headers: { Authorization: 'Bearer token-abc' },
@@ -184,7 +188,14 @@ describe('Datadog singleton', () => {
expect.any(Error),
expect.objectContaining({
extras: {
metrics: [{ name: 'eas.build.phase_duration', type: 'distribution', value: 1, tags: {} }],
metrics: [
{
name: 'eas.build.phase_duration',
type: 'distribution',
value: 1,
tags: { target: 'build' },
},
],
},
})
);
@@ -198,7 +209,7 @@ describe('Datadog singleton', () => {
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.workflow.maestro_cli_version', 1);
Datadog.distribution('eas.maestro.install', 1);
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
@@ -207,7 +218,12 @@ describe('Datadog singleton', () => {
expect.objectContaining({
extras: {
metrics: [
{ name: 'eas.workflow.maestro_cli_version', type: 'distribution', value: 1, tags: {} },
{
name: 'eas.maestro.install',
type: 'distribution',
value: 1,
tags: { target: 'job_run' },
},
],
},
})
@@ -231,7 +247,11 @@ describe('Datadog singleton', () => {
expect.any(Error),
expect.objectContaining({
extras: {
log: { buildId: 'build-id', message: 'artifact dry-run matched', tags: {} },
log: {
buildId: 'build-id',
message: 'artifact dry-run matched',
tags: { target: 'build' },
},
},
})
);
@@ -253,7 +273,11 @@ describe('Datadog singleton', () => {
expect.any(Error),
expect.objectContaining({
extras: {
log: { jobRunId: 'job-run-id', message: 'Gradle cache restored (hit)', tags: {} },
log: {
jobRunId: 'job-run-id',
message: 'Gradle cache restored (hit)',
tags: { target: 'job_run' },
},
},
})
);
@@ -0,0 +1,317 @@
import fetch from 'node-fetch';
import { RuntimeSettings } from '../runtimeSettings';
import { Sentry } from '../sentry';
jest.mock('node-fetch', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
describe('runtimeSettings', () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
};
const originalCacheUrls = {
EAS_NPM_CACHE_URL: process.env.EAS_NPM_CACHE_URL,
EAS_NODEJS_CACHE_URL: process.env.EAS_NODEJS_CACHE_URL,
EAS_MAVEN_CACHE_URL: process.env.EAS_MAVEN_CACHE_URL,
EAS_COCOAPODS_CACHE_URL: process.env.EAS_COCOAPODS_CACHE_URL,
};
const originalPlatform = process.platform;
afterEach(() => {
jest.mocked(fetch).mockReset();
jest.restoreAllMocks();
jest.mocked(Sentry.capture).mockReset();
logger.info.mockReset();
logger.warn.mockReset();
restoreEnv('EAS_NPM_CACHE_URL', originalCacheUrls.EAS_NPM_CACHE_URL);
restoreEnv('EAS_NODEJS_CACHE_URL', originalCacheUrls.EAS_NODEJS_CACHE_URL);
restoreEnv('EAS_MAVEN_CACHE_URL', originalCacheUrls.EAS_MAVEN_CACHE_URL);
restoreEnv('EAS_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_COCOAPODS_CACHE_URL);
mockProcessPlatform(originalPlatform);
});
beforeEach(async () => {
jest.mocked(fetch).mockResolvedValue(response({}));
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
jest.mocked(fetch).mockReset();
logger.info.mockReset();
logger.warn.mockReset();
});
it('fetches runtime settings from the staging and production buckets', async () => {
jest.mocked(fetch).mockResolvedValue(response({}));
await RuntimeSettings.loadAsync({
environment: 'staging',
logger: logger as any,
cacheUrlFallbacks: {
npm: 'https://fallback-npm.example',
},
});
await RuntimeSettings.loadAsync({ environment: 'production', logger: logger as any });
await RuntimeSettings.loadAsync({ environment: 'test', logger: logger as any });
expect(fetch).toHaveBeenNthCalledWith(
1,
'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
{
signal: expect.anything(),
}
);
expect(fetch).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/eas-workflows-production/runtime-settings.json',
{
signal: expect.anything(),
}
);
expect(fetch).toHaveBeenCalledTimes(2);
});
it('does not use caches by default when remote settings are unavailable', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(response({}, 404));
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
expect(logger.warn).toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith('Failed to fetch worker runtime settings', {
extras: {
status: 404,
url: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
},
level: 'warning',
});
});
it('does not override existing settings when remote settings are invalid', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest
.mocked(fetch)
.mockResolvedValueOnce(response({ caches: { linux: { npm: true } } }))
.mockResolvedValueOnce(response({ iosPrecompiledModules: 'enabled' }));
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
expect(logger.warn).toHaveBeenCalled();
expect(Sentry.capture).toHaveBeenCalledWith(
'Failed to load worker runtime settings',
expect.any(Error),
{
extras: {
url: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
},
level: 'warning',
}
);
});
it('accepts partial runtime settings and ignores unknown fields', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { npm: false, nodejs: true, maven: true },
darwin: { npm: true, maven: true, cocoapods: false },
},
iosPrecompiledModules: true,
unknownFutureSetting: true,
})
);
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.getNodeJsCacheUrl()).toBe('https://node.example');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
mockProcessPlatform('darwin');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBeNull();
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(true);
});
it('uses Maven and CocoaPods caches when enabled for the current platform and their environment variables exist', async () => {
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { maven: true },
darwin: { maven: true, cocoapods: true },
},
})
);
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
mockProcessPlatform('linux');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
mockProcessPlatform('darwin');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://maven.example');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://pods.example');
});
it('uses cache URL fallbacks when enabled cache environment variables are not set', async () => {
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { npm: true, nodejs: true, maven: true },
darwin: { cocoapods: true },
},
})
);
await RuntimeSettings.loadAsync({
environment: 'staging',
logger: logger as any,
cacheUrlFallbacks: {
npm: 'https://fallback-npm.example',
nodejs: 'https://fallback-node.example',
maven: 'https://fallback-maven.example',
cocoapods: 'https://fallback-pods.example',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://fallback-npm.example');
expect(RuntimeSettings.getNodeJsCacheUrl()).toBe('https://fallback-node.example');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://fallback-maven.example');
mockProcessPlatform('darwin');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://fallback-pods.example');
});
it('prefers cache URL environment variables over fallbacks', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { npm: true },
},
})
);
await RuntimeSettings.loadAsync({
environment: 'staging',
logger: logger as any,
cacheUrlFallbacks: {
npm: 'https://fallback-npm.example',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
});
it('does not use cache URL fallbacks when runtime settings disable caches', async () => {
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { npm: false },
},
})
);
await RuntimeSettings.loadAsync({
environment: 'staging',
logger: logger as any,
cacheUrlFallbacks: {
npm: 'https://fallback-npm.example',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('clears cache URL fallbacks when loading without them', async () => {
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
logger: logger as any,
cacheUrlFallbacks: {
npm: 'https://fallback-npm.example',
},
});
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not use caches when runtime settings are not loaded', () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.isolateModules(() => {
const { RuntimeSettings } = require('../runtimeSettings');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(RuntimeSettings.isUsingIosPrecompiledModulesEnabled()).toBe(false);
});
});
it('reports when loaded runtime settings omit the current platform cache settings', async () => {
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
mockProcessPlatform('darwin');
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({
message: 'Runtime settings are missing cache settings for platform "darwin"',
}),
{
extras: {
configuredPlatforms: ['linux'],
platform: 'darwin',
},
level: 'error',
}
);
});
});
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
function mockProcessPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform,
});
}
function response(body: unknown, status = 200): any {
return {
ok: status >= 200 && status < 300,
status,
json: async () => body,
};
}
@@ -0,0 +1,94 @@
import { EventEmitter } from 'events';
import spawnAsync from '@expo/spawn-async';
import { ExpoRunFormatter } from '@expo/xcpretty';
import fg from 'fast-glob';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { XcodeBuildLogger } from '../xcpretty';
jest.mock('@expo/spawn-async');
jest.mock('@expo/xcpretty');
jest.mock('fast-glob');
jest.mock('../../sentry', () => ({
Sentry: {
setup: jest.fn(),
capture: jest.fn(),
flush: jest.fn(),
},
}));
describe(XcodeBuildLogger, () => {
let stdout: EventEmitter;
let formatter: { pipe: jest.Mock; getBuildSummary: jest.Mock };
beforeEach(() => {
jest.clearAllMocks();
stdout = new EventEmitter();
const spawnPromise = Object.assign(new Promise(() => {}), {
child: { stdout, kill: jest.fn() },
});
jest.mocked(spawnAsync).mockReturnValue(spawnPromise as any);
formatter = {
pipe: jest.fn(),
getBuildSummary: jest.fn().mockReturnValue('summary'),
};
jest.mocked(ExpoRunFormatter.create).mockReturnValue(formatter as any);
jest.mocked(fg).mockResolvedValue(['build.log'] as any);
});
async function startWatchingAsync(logger: ReturnType<typeof createMockLogger>): Promise<void> {
const buildLogger = new XcodeBuildLogger(logger, '/project');
await buildLogger.watchLogFiles('/logs');
}
it('logs formatted lines', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
formatter.pipe.mockReturnValue(['line1', 'line2']);
stdout.emit('data', 'raw xcodebuild output');
expect(formatter.pipe).toHaveBeenCalledWith('raw xcodebuild output');
expect(logger.info).toHaveBeenCalledWith('line1');
expect(logger.info).toHaveBeenCalledWith('line2');
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('falls back to raw logs when the formatter throws', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
formatter.pipe.mockImplementation(() => {
throw new RangeError('Invalid array length');
});
stdout.emit('data', 'offending output');
expect(logger.info).toHaveBeenCalledWith('offending output');
});
it('reports the formatter error to Sentry only once', async () => {
const logger = createMockLogger();
await startWatchingAsync(logger);
const error = new RangeError('Invalid array length');
formatter.pipe.mockImplementation(() => {
throw error;
});
stdout.emit('data', 'offending output');
stdout.emit('data', 'more offending output');
expect(logger.info).toHaveBeenCalledWith('offending output');
expect(logger.info).toHaveBeenCalledWith('more offending output');
expect(Sentry.capture).toHaveBeenCalledTimes(1);
expect(Sentry.capture).toHaveBeenCalledWith(
'xcpretty formatter failed to parse xcodebuild logs',
error,
{ extras: { data: 'offending output' } }
);
});
});
@@ -6,11 +6,14 @@ import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
import { Sentry } from '../sentry';
const CHECK_FILE_INTERVAL_MS = 1000;
export class XcodeBuildLogger {
private loggerError?: Error;
private flushing: boolean = false;
private reportedFormatterError: boolean = false;
private logReaderPromise?: SpawnPromise<SpawnResult>;
private logsPath?: string;
@@ -60,9 +63,21 @@ export class XcodeBuildLogger {
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
this.logReaderPromise.child.stdout.on('data', (data: string) => {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
try {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
}
} catch (err: any) {
// The formatter can throw on unexpected xcodebuild output. Fall back to
// raw logs instead of crashing the process with an uncaught exception.
if (!this.reportedFormatterError) {
this.reportedFormatterError = true;
Sentry.capture('xcpretty formatter failed to parse xcodebuild logs', err, {
extras: { data: data.toString().slice(-2000) },
});
}
this.logger.info(data.toString());
}
});
await this.logReaderPromise;
+11 -3
View File
@@ -14,6 +14,13 @@ type DatadogSetupOptions = {
let setupOptions: DatadogSetupOptions | null = null;
let pendingUploads: Promise<unknown>[] = [];
function withTargetTag(
tags: Record<string, string>,
target: MetricsTarget
): Record<string, string> {
return { ...tags, target: target.kind === 'build' ? 'build' : 'job_run' };
}
export const Datadog = {
setup(opts: DatadogSetupOptions | null): void {
setupOptions = opts;
@@ -29,7 +36,7 @@ export const Datadog = {
name,
type: 'distribution' as const,
value,
tags,
tags: withTargetTag(tags, target),
},
];
@@ -58,10 +65,11 @@ export const Datadog = {
return;
}
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const tagsWithTarget = withTargetTag(tags, target);
const log =
target.kind === 'build'
? { buildId: target.turtleBuildId, message, tags }
: { jobRunId: target.turtleJobRunId, message, tags };
? { buildId: target.turtleBuildId, message, tags: tagsWithTarget }
: { jobRunId: target.turtleJobRunId, message, tags: tagsWithTarget };
const logsPath = target.kind === 'build' ? 'turtle-builds/logs' : 'turtle-job-runs/logs';
const uploadPromise = turtleFetch(new URL(logsPath, expoApiV2BaseUrl).toString(), 'POST', {
+1
View File
@@ -27,3 +27,4 @@ export * from './generic';
export { Datadog } from './datadog';
export { Sentry } from './sentry';
export * from './runtimeSettings';
+1 -1
View File
@@ -10,8 +10,8 @@ import type { Credentials } from './credentials/manager';
import { createFastfileForResigningBuild } from './fastfile';
import { createGymfileForArchiveBuild, createGymfileForSimulatorBuild } from './gymfile';
import { isTVOS } from './tvos';
import { XcodeBuildLogger } from './xcpretty';
import { COMMON_FASTLANE_ENV } from '../common/fastlane';
import { XcodeBuildLogger } from '../common/xcpretty';
import { BuildContext, SkipNativeBuildError } from '../context';
export async function runFastlaneGym<TJob extends Ios.Job>(
+1 -1
View File
@@ -51,7 +51,7 @@ async function resolvePrecompiledModulesPodInstallEnvAsync<TJob extends Ios.Job>
return {};
}
if (ctx.job.builderEnvironment?.env?.EAS_USE_PRECOMPILED_MODULES !== '1') {
if (ctx.env.EAS_USE_PRECOMPILED_MODULES !== '1') {
return {};
}
+128
View File
@@ -0,0 +1,128 @@
import { type bunyan } from '@expo/logger';
import fetch from 'node-fetch';
import { z } from 'zod';
import { Sentry } from './sentry';
const ENVIRONMENT_TO_RUNTIME_SETTINGS_URL: Record<string, string | undefined> = {
staging: 'https://storage.googleapis.com/eas-workflows-staging/runtime-settings.json',
production: 'https://storage.googleapis.com/eas-workflows-production/runtime-settings.json',
};
const RuntimeSettingsSchema = z
.object({
caches: z.record(
z.string(),
z
.object({
npm: z.boolean(),
nodejs: z.boolean(),
maven: z.boolean(),
cocoapods: z.boolean(),
})
.partial()
),
iosPrecompiledModules: z.boolean(),
})
.partial();
type CacheUrlFallbacks = {
npm?: string | null;
nodejs?: string | null;
maven?: string | null;
cocoapods?: string | null;
};
let runtimeSettings: z.infer<typeof RuntimeSettingsSchema> = {};
let cacheUrlFallbacks: CacheUrlFallbacks = {};
export namespace RuntimeSettings {
export async function loadAsync({
environment,
logger,
cacheUrlFallbacks: nextCacheUrlFallbacks = {},
}: {
environment: string;
logger: bunyan;
cacheUrlFallbacks?: CacheUrlFallbacks;
}): Promise<void> {
cacheUrlFallbacks = nextCacheUrlFallbacks;
const url = ENVIRONMENT_TO_RUNTIME_SETTINGS_URL[environment];
if (!url) {
return;
}
try {
const response = await fetch(url, {
signal: AbortSignal.timeout(1000),
});
if (!response.ok) {
logger.warn(
{ url, status: response.status },
'Failed to fetch worker runtime settings, using defaults'
);
Sentry.capture('Failed to fetch worker runtime settings', {
extras: { url, status: response.status },
level: 'warning',
});
return;
}
runtimeSettings = RuntimeSettingsSchema.parse(await response.json());
if (runtimeSettings.caches && !runtimeSettings.caches[process.platform]) {
Sentry.capture(
new Error(
`Runtime settings are missing cache settings for platform "${process.platform}"`
),
{
extras: {
configuredPlatforms: Object.keys(runtimeSettings.caches),
platform: process.platform,
},
level: 'error',
}
);
}
logger.info({ url, settings: runtimeSettings }, 'Loaded worker runtime settings');
} catch (err) {
logger.warn({ err, url }, 'Failed to load worker runtime settings, using defaults');
Sentry.capture(
'Failed to load worker runtime settings',
err instanceof Error ? err : new Error(String(err)),
{
extras: { url },
level: 'warning',
}
);
}
}
export function isUsingIosPrecompiledModulesEnabled(): boolean {
return runtimeSettings.iosPrecompiledModules ?? false;
}
export function getNpmCacheUrl(): string | null {
return runtimeSettings.caches?.[process.platform]?.npm
? process.env.EAS_NPM_CACHE_URL || cacheUrlFallbacks.npm || null
: null;
}
export function getNodeJsCacheUrl(): string | null {
return runtimeSettings.caches?.[process.platform]?.nodejs
? process.env.EAS_NODEJS_CACHE_URL || cacheUrlFallbacks.nodejs || null
: null;
}
export function getMavenCacheUrl(): string | null {
return runtimeSettings.caches?.[process.platform]?.maven
? process.env.EAS_MAVEN_CACHE_URL || cacheUrlFallbacks.maven || null
: null;
}
export function getCocoapodsCacheUrl(): string | null {
return runtimeSettings.caches?.[process.platform]?.cocoapods
? process.env.EAS_COCOAPODS_CACHE_URL || cacheUrlFallbacks.cocoapods || null
: null;
}
}
@@ -0,0 +1,90 @@
import spawn from '@expo/turtle-spawn';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { Datadog } from '../../../datadog';
import { createInstallMaestroBuildFunction } from '../installMaestro';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../../datadog', () => ({
Datadog: {
distribution: jest.fn(),
},
}));
const mockedSpawn = jest.mocked(spawn);
describe('createInstallMaestroBuildFunction', () => {
beforeEach(() => {
jest.clearAllMocks();
// `maestro --version` reports an installed version; `java -version` succeeds.
// With no requested version and Maestro present, the step skips installation.
mockedSpawn.mockImplementation((async (command: string) => ({
stdout: command === 'maestro' ? '1.41.0\n' : '',
})) as any);
});
it('reports the detected Maestro version to Datadog on EAS Build VMs', async () => {
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('1.41.0');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '1.41.0',
});
});
it('does not report to Datadog outside EAS Build VMs', async () => {
const installMaestro = createInstallMaestroBuildFunction();
const step = installMaestro.createBuildStepFromFunctionCall(createGlobalContextMock(), {
callInputs: {},
});
await step.executeAsync();
expect(Datadog.distribution).not.toHaveBeenCalled();
});
it('extracts the version when `maestro --version` prints an analytics notice', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout:
command === 'maestro'
? 'Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS environment variable to any value before running Maestro.\n2.0.10\n'
: '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '2.0.10',
});
});
it('uses the trailing version when the notice itself contains an earlier version-like string', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout: command === 'maestro' ? 'Analytics schema v2.0.0 enabled.\n2.0.10\n' : '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
});
});
@@ -15,6 +15,8 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { Datadog } from '../../datadog';
export function createInstallMaestroBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
@@ -123,13 +125,24 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
logger.info(`Maestro ${maestroVersionResult.value} is ready.`);
outputs.maestro_version.set(maestroVersionResult.value);
Datadog.distribution('eas.maestro.install', 1, {
maestro_version: maestroVersionResult.value,
});
},
});
}
async function getMaestroVersion({ env }: { env: BuildStepEnv }): Promise<string> {
const maestroVersion = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
return maestroVersion.stdout.trim();
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 installMaestro({
@@ -57,6 +57,16 @@ export function createRepackBuildFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'ios_signing_use_source_app_entitlements',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'ios_signing_app_entitlements_path',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'repack_version',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -138,6 +148,12 @@ export function createRepackBuildFunction(): BuildFunction {
iosSigningOptions: await resolveIosSigningOptionsAsync({
job: stepsCtx.global.staticContext.job,
logger: stepsCtx.logger,
useAppEntitlements: inputs.ios_signing_use_source_app_entitlements.value as
| boolean
| undefined,
entitlementsPath: inputs.ios_signing_app_entitlements_path.value as
| string
| undefined,
}),
logger: stepsCtx.logger,
spawnAsync: repackSpawnAsync,
@@ -275,9 +291,13 @@ export async function resolveAndroidSigningOptionsAsync({
export async function resolveIosSigningOptionsAsync({
job,
logger,
useAppEntitlements,
entitlementsPath,
}: {
job: Job;
logger: bunyan;
useAppEntitlements?: boolean;
entitlementsPath?: string;
}): Promise<IosSigningOptions | undefined> {
const iosJob = job as Ios.Job;
const buildCredentials = iosJob.secrets?.buildCredentials;
@@ -295,5 +315,7 @@ export async function resolveIosSigningOptionsAsync({
provisioningProfile,
keychainPath: credentials.keychainPath,
signingIdentity: credentials.applicationTargetProvisioningProfile.data.certificateCommonName,
useAppEntitlements,
entitlementsPath,
};
}
@@ -141,6 +141,10 @@ export async function uploadCacheAsync({
return;
}
const textResult = await asyncResult(response.text());
const errorMessage = extractValidationErrorMessage(textResult.value);
if (errorMessage) {
throw new Error(errorMessage);
}
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
}
@@ -231,6 +235,19 @@ export async function uploadPublicCacheAsync({
logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`);
}
function extractValidationErrorMessage(body: string | undefined): string | undefined {
if (!body) {
return undefined;
}
try {
const { errors } = JSON.parse(body);
if (errors?.[0]?.code === 'VALIDATION_ERROR') {
return errors[0].message;
}
} catch {}
return undefined;
}
export async function compressCacheAsync({
paths,
workingDirectory,
@@ -1,18 +1,21 @@
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { type bunyan } from '@expo/logger';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepEnv,
type BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { CustomBuildContext } from '../../customBuildContext';
import { type CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import {
type DetachedProcessHandle,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
@@ -23,6 +26,7 @@ import {
waitForFileAsync,
} from '../utils/remoteDeviceRunSession';
const AGENT_DEVICE_PACKAGE_NAME = 'agent-device';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
const SRC_DIR = '/tmp/agent-device-src';
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
@@ -64,34 +68,12 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
}
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
cwd: SRC_DIR,
env,
logger,
});
logger.info('Launching agent-device daemon.');
spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
});
const daemonProcess = await startAgentDeviceDaemonAsync({ packageVersion, env, logger });
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
const { port: daemonPort, token: daemonToken } = await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon credentials',
parse: parseDaemonInfo,
const { port: daemonPort, token: daemonToken } = await waitForDaemonInfoAsync({
daemonProcess,
});
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
@@ -137,6 +119,93 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
});
}
async function startAgentDeviceDaemonAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<DetachedProcessHandle> {
const packageSpec = createAgentDevicePackageSpec(packageVersion);
try {
logger.info(`Installing ${packageSpec} globally with Bun.`);
await spawn('bun', ['add', '--global', packageSpec], {
env,
logger,
});
const daemonPath = getGlobalAgentDeviceDaemonPath(env);
if (!fs.existsSync(daemonPath)) {
throw new SystemError(`Expected agent-device daemon entry at ${daemonPath}.`);
}
logger.info(`Launching daemon from ${daemonPath}.`);
return spawnDetached({
command: 'node',
args: [daemonPath],
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
});
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
const bunVersion = await getBunVersionForDiagnosticsAsync(env);
Sentry.capture(
'Failed to start agent-device daemon from global Bun package; falling back to git clone',
error,
{
level: 'warning',
tags: {
phase: 'agent-device-daemon-start',
fallback: 'git-clone',
},
extras: {
packageSpec,
packageVersion: packageVersion ?? 'latest',
bunVersion,
bunInstallConfigured: Boolean(env.BUN_INSTALL?.trim()),
},
}
);
logger.warn(
`Failed to start daemon from global ${packageSpec}; falling back to git clone: ${error.message}`
);
return await startAgentDeviceDaemonFromGitAsync({ packageVersion, env, logger });
}
}
async function startAgentDeviceDaemonFromGitAsync({
packageVersion,
env,
logger,
}: {
packageVersion: string | undefined;
env: BuildStepEnv;
logger: bunyan;
}): Promise<DetachedProcessHandle> {
logger.info(
packageVersion
? `Cloning agent-device @ v${packageVersion} into ${SRC_DIR}.`
: `Cloning agent-device (latest) into ${SRC_DIR}.`
);
await cloneAgentDeviceAsync({ packageVersion, env, logger });
logger.info('Installing agent-device dependencies.');
await spawn('bun', ['install', '--production'], {
cwd: SRC_DIR,
env,
logger,
});
logger.info('Launching daemon from cloned agent-device source.');
return spawnDetached({
command: 'bun',
args: ['run', 'src/daemon.ts'],
cwd: SRC_DIR,
env: { ...env, AGENT_DEVICE_DAEMON_SERVER_MODE: 'http' },
});
}
async function cloneAgentDeviceAsync({
packageVersion,
env,
@@ -153,6 +222,63 @@ async function cloneAgentDeviceAsync({
});
}
async function waitForDaemonInfoAsync({
daemonProcess,
}: {
daemonProcess: DetachedProcessHandle;
}): Promise<{ port: number; token: string }> {
try {
return await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon credentials',
parse: parseDaemonInfo,
});
} catch (err) {
const output = daemonProcess.getOutput();
throw new SystemError(
`${
err instanceof Error
? err.message
: `Timed out waiting for agent-device daemon credentials.`
}${output ? `\nagent-device daemon output:\n${output}` : ''}`
);
}
}
async function getBunVersionForDiagnosticsAsync(env: BuildStepEnv): Promise<string> {
try {
const result = await spawn('bun', ['--version'], { stdio: 'pipe', env, cwd: os.tmpdir() });
return result.stdout.trim() || 'unknown';
} catch {
return 'unknown';
}
}
function createAgentDevicePackageSpec(packageVersion: string | undefined): string {
const versionSpec = packageVersion ? packageVersion.replace(/^v(?=\d)/, '') : 'latest';
return `${AGENT_DEVICE_PACKAGE_NAME}@${versionSpec}`;
}
function getGlobalAgentDeviceDaemonPath(env: BuildStepEnv): string {
return path.join(
getBunInstallDirectory(env),
'install',
'global',
'node_modules',
AGENT_DEVICE_PACKAGE_NAME,
'dist',
'src',
'internal',
'daemon.js'
);
}
function getBunInstallDirectory(env: BuildStepEnv): string {
const bunInstall = env.BUN_INSTALL?.trim();
return bunInstall ? bunInstall : path.join(os.homedir(), '.bun');
}
function parseDaemonInfo(raw: string): { port: number; token: string } {
const parsed = JSON.parse(raw) as unknown;
if (
@@ -3,8 +3,8 @@ import { BuildStepEnv } from '@expo/steps';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import path from 'path';
import { XcodeBuildLogger } from './xcpretty';
import { COMMON_FASTLANE_ENV } from '../../../common/fastlane';
import { XcodeBuildLogger } from '../../../common/xcpretty';
export async function runFastlaneGym({
workingDir,
@@ -1,91 +0,0 @@
import { bunyan } from '@expo/logger';
import spawnAsync, { SpawnPromise, SpawnResult } from '@expo/spawn-async';
import { ExpoRunFormatter } from '@expo/xcpretty';
import assert from 'assert';
import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
const CHECK_FILE_INTERVAL_MS = 1000;
export class XcodeBuildLogger {
private loggerError?: Error;
private flushing: boolean = false;
private logReaderPromise?: SpawnPromise<SpawnResult>;
private logsPath?: string;
constructor(
private readonly logger: bunyan,
private readonly projectRoot: string
) {}
public async watchLogFiles(logsDirectory: string): Promise<void> {
while (!this.flushing) {
const logsFilename = await this.getBuildLogFilename(logsDirectory);
if (logsFilename) {
this.logsPath = path.join(logsDirectory, logsFilename);
void this.startBuildLogger(this.logsPath);
return;
}
await new Promise(res => setTimeout(res, CHECK_FILE_INTERVAL_MS));
}
}
public async flush(): Promise<void> {
this.flushing = true;
if (this.loggerError) {
throw this.loggerError;
}
if (this.logReaderPromise) {
this.logReaderPromise.child.kill('SIGINT');
try {
await this.logReaderPromise;
} catch {}
}
if (this.logsPath) {
await this.findBundlerErrors(this.logsPath);
}
}
private async getBuildLogFilename(logsDirectory: string): Promise<string | undefined> {
const paths = await fg('*.log', { cwd: logsDirectory });
return paths.length >= 1 ? paths[0] : undefined;
}
private async startBuildLogger(logsPath: string): Promise<void> {
try {
const formatter = ExpoRunFormatter.create(this.projectRoot, {
// TODO: Can provide xcode project name for better parsing
isDebug: false,
});
this.logReaderPromise = spawnAsync('tail', ['-n', '+0', '-f', logsPath], { stdio: 'pipe' });
assert(this.logReaderPromise.child.stdout, 'stdout is not available');
this.logReaderPromise.child.stdout.on('data', (data: string) => {
const lines = formatter.pipe(data.toString());
for (const line of lines) {
this.logger.info(line);
}
});
await this.logReaderPromise;
this.logger.info(formatter.getBuildSummary());
} catch (err: any) {
if (!this.flushing) {
this.loggerError = err;
}
}
}
private async findBundlerErrors(logsPath: string): Promise<void> {
try {
const logFile = await fs.readFile(logsPath, 'utf-8');
const match = logFile.match(
/Welcome to Metro!\s* Fast - Scalable - Integrated\s*([\s\S]*)Run CLI with --verbose flag for more details.\nCommand PhaseScriptExecution failed with a nonzero exit code/
);
if (match) {
this.logger.info(match[1]);
}
} catch (err) {
this.logger.error({ err }, 'Failed to read Xcode logs');
}
}
}
+2 -2
View File
@@ -131,7 +131,7 @@ PLATFORMS
ruby
DEPENDENCIES
bundler (~> 1.17)
bundler (>= 2.4)
cocoapods (~> 1.12)
expo-cocoapods-proxy!
rake (~> 10.0)
@@ -139,4 +139,4 @@ DEPENDENCIES
rubocop (~> 1.48)
BUNDLED WITH
2.1.4
4.0.13
@@ -32,7 +32,7 @@ Gem::Specification.new do |spec|
spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
spec.require_paths = ['lib']
spec.add_development_dependency 'bundler', '~> 1.17'
spec.add_development_dependency 'bundler', '>= 2.4'
spec.add_development_dependency 'rake', '~> 10.0'
spec.add_development_dependency 'rspec', '~> 3.0'
end
@@ -0,0 +1,40 @@
describe('config', () => {
const originalEnvironment = process.env.ENVIRONMENT;
const originalWorkerRuntimeConfigBase64 = process.env.WORKER_RUNTIME_CONFIG_BASE64;
afterEach(() => {
restoreEnv('ENVIRONMENT', originalEnvironment);
restoreEnv('WORKER_RUNTIME_CONFIG_BASE64', originalWorkerRuntimeConfigBase64);
jest.resetModules();
});
it('parses cache URL fallbacks from worker runtime config', () => {
process.env.ENVIRONMENT = 'staging';
process.env.WORKER_RUNTIME_CONFIG_BASE64 = Buffer.from(
JSON.stringify({
buildId: 'build-id',
npmCacheUrl: 'https://npm-cache.example',
nodeJsCacheUrl: 'https://node-cache.example',
mavenCacheUrl: 'https://maven-cache.example',
cocoapodsCacheUrl: 'https://cocoapods-cache.example',
})
).toString('base64');
jest.isolateModules(() => {
const config = require('../config').default;
expect(config.npmCacheUrl).toBe('https://npm-cache.example');
expect(config.nodeJsCacheUrl).toBe('https://node-cache.example');
expect(config.mavenCacheUrl).toBe('https://maven-cache.example');
expect(config.cocoapodsCacheUrl).toBe('https://cocoapods-cache.example');
});
});
});
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
+225
View File
@@ -1,3 +1,4 @@
import { RuntimeSettings } from '@expo/build-tools';
import { Platform, Workflow } from '@expo/eas-build-job';
import os from 'os';
@@ -6,9 +7,44 @@ import { getBuildEnv, getGradleMemoryOptions } from '../env';
describe(getBuildEnv.name, () => {
const originalSentryDsn = config.sentry.dsn;
const originalPlatform = process.platform;
const originalCacheUrls = {
EAS_NPM_CACHE_URL: process.env.EAS_NPM_CACHE_URL,
EAS_NODEJS_CACHE_URL: process.env.EAS_NODEJS_CACHE_URL,
EAS_MAVEN_CACHE_URL: process.env.EAS_MAVEN_CACHE_URL,
EAS_COCOAPODS_CACHE_URL: process.env.EAS_COCOAPODS_CACHE_URL,
EAS_BUILD_NPM_CACHE_URL: process.env.EAS_BUILD_NPM_CACHE_URL,
NPM_CACHE_URL: process.env.NPM_CACHE_URL,
NVM_NODEJS_ORG_MIRROR: process.env.NVM_NODEJS_ORG_MIRROR,
EAS_BUILD_MAVEN_CACHE_URL: process.env.EAS_BUILD_MAVEN_CACHE_URL,
EAS_BUILD_COCOAPODS_CACHE_URL: process.env.EAS_BUILD_COCOAPODS_CACHE_URL,
};
beforeEach(() => {
delete process.env.EAS_BUILD_NPM_CACHE_URL;
delete process.env.NPM_CACHE_URL;
delete process.env.NVM_NODEJS_ORG_MIRROR;
delete process.env.EAS_BUILD_MAVEN_CACHE_URL;
delete process.env.EAS_BUILD_COCOAPODS_CACHE_URL;
jest.spyOn(RuntimeSettings, 'getNpmCacheUrl').mockReturnValue(null);
jest.spyOn(RuntimeSettings, 'getNodeJsCacheUrl').mockReturnValue(null);
jest.spyOn(RuntimeSettings, 'getMavenCacheUrl').mockReturnValue(null);
jest.spyOn(RuntimeSettings, 'getCocoapodsCacheUrl').mockReturnValue(null);
jest.spyOn(RuntimeSettings, 'isUsingIosPrecompiledModulesEnabled').mockReturnValue(false);
});
afterEach(() => {
config.sentry.dsn = originalSentryDsn;
restoreEnv('EAS_NPM_CACHE_URL', originalCacheUrls.EAS_NPM_CACHE_URL);
restoreEnv('EAS_NODEJS_CACHE_URL', originalCacheUrls.EAS_NODEJS_CACHE_URL);
restoreEnv('EAS_MAVEN_CACHE_URL', originalCacheUrls.EAS_MAVEN_CACHE_URL);
restoreEnv('EAS_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_COCOAPODS_CACHE_URL);
restoreEnv('EAS_BUILD_NPM_CACHE_URL', originalCacheUrls.EAS_BUILD_NPM_CACHE_URL);
restoreEnv('NPM_CACHE_URL', originalCacheUrls.NPM_CACHE_URL);
restoreEnv('NVM_NODEJS_ORG_MIRROR', originalCacheUrls.NVM_NODEJS_ORG_MIRROR);
restoreEnv('EAS_BUILD_MAVEN_CACHE_URL', originalCacheUrls.EAS_BUILD_MAVEN_CACHE_URL);
restoreEnv('EAS_BUILD_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_BUILD_COCOAPODS_CACHE_URL);
mockProcessPlatform(originalPlatform);
jest.restoreAllMocks();
});
@@ -62,6 +98,180 @@ describe(getBuildEnv.name, () => {
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBeUndefined();
});
it('adds precompiled modules env vars for iOS jobs when enabled', () => {
jest.mocked(RuntimeSettings.isUsingIosPrecompiledModulesEnabled).mockReturnValue(true);
const env = getBuildEnv({
job: {
platform: Platform.IOS,
type: Workflow.GENERIC,
builderEnvironment: {
env: {},
},
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.EAS_USE_PRECOMPILED_MODULES).toBe('1');
});
it('does not add precompiled modules env vars for Android jobs when enabled', () => {
jest.mocked(RuntimeSettings.isUsingIosPrecompiledModulesEnabled).mockReturnValue(true);
const env = getBuildEnv({
job: {
platform: Platform.ANDROID,
type: Workflow.MANAGED,
builderEnvironment: {
env: {},
},
username: 'expo-user',
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.EAS_USE_PRECOMPILED_MODULES).toBeUndefined();
});
it('leaves job-provided precompiled modules env vars in override position', () => {
jest.mocked(RuntimeSettings.isUsingIosPrecompiledModulesEnabled).mockReturnValue(true);
const job = {
platform: Platform.IOS,
type: Workflow.GENERIC,
builderEnvironment: {
env: {
EAS_USE_PRECOMPILED_MODULES: '0',
},
},
} as any;
const baseEnv = getBuildEnv({
job,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect({ ...baseEnv, ...job.builderEnvironment.env }.EAS_USE_PRECOMPILED_MODULES).toBe('0');
});
it('does not expose disabled Linux cache URLs in build env', () => {
mockProcessPlatform('linux');
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
const env = getBuildEnv({
job: {
platform: Platform.ANDROID,
type: Workflow.MANAGED,
builderEnvironment: {
env: {},
},
username: 'expo-user',
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.NPM_CACHE_URL).toBeUndefined();
expect(env.NVM_NODEJS_ORG_MIRROR).toBeUndefined();
expect(env.EAS_BUILD_NPM_CACHE_URL).toBeUndefined();
expect(env.EAS_BUILD_MAVEN_CACHE_URL).toBeUndefined();
expect(env.EAS_NPM_CACHE_URL).toBeUndefined();
expect(env.EAS_NODEJS_CACHE_URL).toBeUndefined();
expect(env.EAS_MAVEN_CACHE_URL).toBeUndefined();
});
it('does not expose disabled Darwin cache URLs in build env', () => {
mockProcessPlatform('darwin');
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
process.env.EAS_COCOAPODS_CACHE_URL = 'https://pods.example';
const env = getBuildEnv({
job: {
platform: Platform.IOS,
type: Workflow.GENERIC,
builderEnvironment: {
env: {},
},
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.NPM_CACHE_URL).toBeUndefined();
expect(env.NVM_NODEJS_ORG_MIRROR).toBeUndefined();
expect(env.EAS_BUILD_NPM_CACHE_URL).toBeUndefined();
expect(env.EAS_BUILD_COCOAPODS_CACHE_URL).toBeUndefined();
expect(env.EAS_NPM_CACHE_URL).toBeUndefined();
expect(env.EAS_NODEJS_CACHE_URL).toBeUndefined();
expect(env.EAS_COCOAPODS_CACHE_URL).toBeUndefined();
});
it('exposes enabled cache URLs inferred from private worker environment variables', () => {
mockProcessPlatform('linux');
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
process.env.EAS_NODEJS_CACHE_URL = 'https://node.example';
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
jest.mocked(RuntimeSettings.getNpmCacheUrl).mockReturnValue('https://npm.example');
jest.mocked(RuntimeSettings.getNodeJsCacheUrl).mockReturnValue('https://node.example');
jest.mocked(RuntimeSettings.getMavenCacheUrl).mockReturnValue('https://maven.example');
const env = getBuildEnv({
job: {
platform: Platform.ANDROID,
type: Workflow.MANAGED,
builderEnvironment: {
env: {},
},
username: 'expo-user',
} as any,
projectId: 'project-id',
metadata: {
buildProfile: 'production',
gitCommitHash: 'abc123',
username: 'expo-user',
} as any,
buildId: 'build-id',
});
expect(env.NPM_CACHE_URL).toBe('https://npm.example');
expect(env.EAS_BUILD_NPM_CACHE_URL).toBe('https://npm.example');
expect(env.NVM_NODEJS_ORG_MIRROR).toBe('https://node.example');
expect(env.EAS_BUILD_MAVEN_CACHE_URL).toBe('https://maven.example');
expect(env.EAS_NPM_CACHE_URL).toBeUndefined();
expect(env.EAS_NODEJS_CACHE_URL).toBeUndefined();
expect(env.EAS_MAVEN_CACHE_URL).toBeUndefined();
});
it('sizes Gradle memory options from total memory', () => {
const totalMemory = jest.spyOn(os, 'totalmem');
@@ -84,3 +294,18 @@ describe(getBuildEnv.name, () => {
});
});
});
function mockProcessPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform,
});
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
@@ -1,10 +1,18 @@
// @ts-nocheck
import { RuntimeSettings } from '@expo/build-tools';
import { Android, Ios, Job } from '@expo/eas-build-job';
import templateFile from '@expo/template-file';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import { pathExists } from 'fs-extra';
import { prepareRuntimeEnvironment } from '../runtimeEnvironment';
import { mkdirp, pathExists } from 'fs-extra';
import config from '../config';
import {
prepareRuntimeEnvironment,
prepareRuntimeEnvironmentConfigFiles,
} from '../runtimeEnvironment';
jest.mock('fs-extra');
jest.mock('@expo/template-file');
jest.mock('@expo/turtle-spawn');
const spawnResult: SpawnResult = {
@@ -31,10 +39,75 @@ const ctx = {
const builderConfig: Ios.BuilderEnvironment | Android.BuilderEnvironment = {};
describe('prepareRuntimeEnvironment', () => {
const originalEnvironment = config.env;
const originalPlatform = process.platform;
const originalCacheUrls = {
EAS_NPM_CACHE_URL: process.env.EAS_NPM_CACHE_URL,
EAS_MAVEN_CACHE_URL: process.env.EAS_MAVEN_CACHE_URL,
};
beforeEach(() => {
jest.mocked(spawn).mockReset();
});
afterEach(() => {
config.env = originalEnvironment;
restoreEnv('EAS_NPM_CACHE_URL', originalCacheUrls.EAS_NPM_CACHE_URL);
restoreEnv('EAS_MAVEN_CACHE_URL', originalCacheUrls.EAS_MAVEN_CACHE_URL);
mockProcessPlatform(originalPlatform);
jest.restoreAllMocks();
});
describe(prepareRuntimeEnvironmentConfigFiles.name, () => {
beforeEach(() => {
config.env = 'production';
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
process.env.EAS_MAVEN_CACHE_URL = 'https://maven.example';
});
it('does not prepare disabled Linux cache config files', async () => {
mockProcessPlatform('linux');
jest.spyOn(RuntimeSettings, 'getNpmCacheUrl').mockReturnValue(null);
jest.spyOn(RuntimeSettings, 'getMavenCacheUrl').mockReturnValue(null);
await prepareRuntimeEnvironmentConfigFiles();
expect(spawn).not.toHaveBeenCalledWith('npm', [
'config',
'set',
'registry',
'https://npm.example',
]);
expect(templateFile).not.toHaveBeenCalled();
expect(mkdirp).not.toHaveBeenCalled();
});
it('prepares enabled Linux cache config files', async () => {
mockProcessPlatform('linux');
jest.spyOn(RuntimeSettings, 'getNpmCacheUrl').mockReturnValue('https://npm.example');
jest.spyOn(RuntimeSettings, 'getMavenCacheUrl').mockReturnValue('https://maven.example');
await prepareRuntimeEnvironmentConfigFiles();
expect(spawn).toHaveBeenCalledWith('npm', [
'config',
'set',
'registry',
'https://npm.example',
]);
expect(templateFile).toHaveBeenCalledWith(
expect.stringContaining('yarnrc.yml'),
{ URL: 'https://npm.example' },
expect.stringContaining('.yarnrc.yml')
);
expect(templateFile).toHaveBeenCalledWith(
expect.stringContaining('init.gradle'),
{ URL: 'https://maven.example' },
expect.stringContaining('init.gradle')
);
});
});
describe('installNode', () => {
describe('prepareRuntimeEnvironment', () => {
beforeEach(() => {
@@ -188,3 +261,18 @@ describe('prepareRuntimeEnvironment', () => {
});
});
});
function mockProcessPlatform(platform: NodeJS.Platform): void {
Object.defineProperty(process, 'platform', {
configurable: true,
value: platform,
});
}
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
+22 -6
View File
@@ -1,3 +1,4 @@
import { RuntimeSettings } from '@expo/build-tools';
import { Env, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import { spawnSync } from 'child_process';
import micromatch from 'micromatch';
@@ -35,9 +36,14 @@ export function getBuildEnv({
setEnv(env, 'EAS_BUILD_PLATFORM', job.platform);
setEnv(env, 'EAS_CLI_SENTRY_DSN', config.sentry.dsn);
// NPM_CACHE_URL is deprecated
setEnv(env, 'NPM_CACHE_URL', config.npmCacheUrl);
setEnv(env, 'NVM_NODEJS_ORG_MIRROR', config.nodeJsCacheUrl);
setEnv(env, 'EAS_BUILD_NPM_CACHE_URL', config.npmCacheUrl);
const npmCacheUrl = RuntimeSettings.getNpmCacheUrl();
const nodeJsCacheUrl = RuntimeSettings.getNodeJsCacheUrl();
const mavenCacheUrl = RuntimeSettings.getMavenCacheUrl();
const cocoapodsCacheUrl = RuntimeSettings.getCocoapodsCacheUrl();
setEnv(env, 'NPM_CACHE_URL', npmCacheUrl);
setEnv(env, 'NVM_NODEJS_ORG_MIRROR', nodeJsCacheUrl);
setEnv(env, 'EAS_BUILD_NPM_CACHE_URL', npmCacheUrl);
setEnv(env, 'EAS_BUILD_PROFILE', metadata.buildProfile);
setEnv(env, 'EAS_BUILD_GIT_COMMIT_HASH', metadata.gitCommitHash);
setEnv(env, 'EAS_BUILD_ID', buildId);
@@ -47,8 +53,11 @@ export function getBuildEnv({
const runnerPlatform = job.platform;
if (runnerPlatform === Platform.IOS) {
setEnv(env, 'EAS_BUILD_COCOAPODS_CACHE_URL', config.cocoapodsCacheUrl);
setEnv(env, 'EAS_BUILD_COCOAPODS_CACHE_URL', cocoapodsCacheUrl);
setEnv(env, 'COMPILER_INDEX_STORE_ENABLE', 'NO');
if (RuntimeSettings.isUsingIosPrecompiledModulesEnabled()) {
setEnv(env, 'EAS_USE_PRECOMPILED_MODULES', '1');
}
if (job.builderEnvironment?.env?.EAS_USE_CACHE === '1') {
setEnv(env, 'USE_CCACHE', '1');
@@ -69,7 +78,7 @@ export function getBuildEnv({
setEnv(env, 'ANDROID_CCACHE', binPath);
}
}
setEnv(env, 'EAS_BUILD_MAVEN_CACHE_URL', config.mavenCacheUrl);
setEnv(env, 'EAS_BUILD_MAVEN_CACHE_URL', mavenCacheUrl);
}
if (config.env !== Environment.TEST) {
@@ -136,7 +145,14 @@ export function getBuildEnv({
}
function getFilteredEnv(): Env {
const envToFilter = [...getAccessedEnvs(), 'KUBERNETES_*'];
const envToFilter = [
...getAccessedEnvs(),
'EAS_NPM_CACHE_URL',
'EAS_NODEJS_CACHE_URL',
'EAS_MAVEN_CACHE_URL',
'EAS_COCOAPODS_CACHE_URL',
'KUBERNETES_*',
];
const envToReturn = micromatch(
Object.keys(process.env),
envToFilter.map(env => `!${env}`)
+12
View File
@@ -1,3 +1,5 @@
import { RuntimeSettings } from '@expo/build-tools';
import config from './config';
import logger from './logger';
import { startServer } from './metricsServer';
@@ -7,6 +9,16 @@ import { prepareWorkingdir } from './workingdir';
import startWsServer from './ws';
async function main(): Promise<void> {
await RuntimeSettings.loadAsync({
environment: config.env,
logger,
cacheUrlFallbacks: {
npm: config.npmCacheUrl,
nodejs: config.nodeJsCacheUrl,
maven: config.mavenCacheUrl,
cocoapods: config.cocoapodsCacheUrl,
},
});
await prepareRuntimeEnvironmentConfigFiles();
await prepareWorkingdir();
startWsServer();
+9 -6
View File
@@ -1,4 +1,4 @@
import { BuildContext } from '@expo/build-tools';
import { BuildContext, RuntimeSettings } from '@expo/build-tools';
import { Job, errors } from '@expo/eas-build-job';
import templateFile from '@expo/template-file';
import spawn from '@expo/turtle-spawn';
@@ -23,25 +23,28 @@ export async function prepareRuntimeEnvironmentConfigFiles(): Promise<void> {
return;
}
if (config.npmCacheUrl) {
const npmCacheUrl = RuntimeSettings.getNpmCacheUrl();
const mavenCacheUrl = RuntimeSettings.getMavenCacheUrl();
if (npmCacheUrl) {
// create ~/.npmrc
await spawn('npm', ['config', 'set', 'registry', config.npmCacheUrl]);
await spawn('npm', ['config', 'set', 'registry', npmCacheUrl]);
// create ~/.yarnrc.yml
await templateFile(
path.join(__dirname, '../src/templates/yarnrc.yml'),
{
URL: config.npmCacheUrl,
URL: npmCacheUrl,
},
path.join(os.homedir(), '.yarnrc.yml')
);
}
if (config.mavenCacheUrl) {
if (mavenCacheUrl) {
await fs.mkdirp(path.join(os.homedir(), '.gradle'));
await templateFile(
path.join(__dirname, '../src/templates/init.gradle'),
{
URL: config.mavenCacheUrl,
URL: mavenCacheUrl,
},
path.join(os.homedir(), '.gradle/init.gradle')
);
+77
View File
@@ -0,0 +1,77 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ $# -ne 1 || ("$1" != "staging" && "$1" != "production") ]]; then
echo "Usage: scripts/edit-runtime-settings staging|production" >&2
exit 1
fi
if ! command -v curl >/dev/null 2>&1; then
echo "curl is required." >&2
exit 1
fi
for tool in gcloud jq; do
if ! command -v "$tool" >/dev/null 2>&1; then
echo "$tool is required. Run \`mise install\` from the repository root and try again." >&2
exit 1
fi
done
env="$1"
object_uri="gs://eas-workflows-${env}/runtime-settings.json"
object_url="https://storage.googleapis.com/eas-workflows-${env}/runtime-settings.json"
cache_control="no-cache, no-store, must-revalidate"
editor="${EDITOR:-vi}"
tmpfile="$(mktemp)"
minified_tmpfile="$(mktemp)"
downloaded_tmpfile="$(mktemp)"
trap 'rm -f "$tmpfile" "$minified_tmpfile" "$downloaded_tmpfile"' EXIT
echo "Remote URL: $object_url"
curl -fsSL "$object_url" | jq . >"$tmpfile"
echo "Edit and verify the pretty-formatted JSON file before saving."
while true; do
"$editor" "$tmpfile"
if jq empty "$tmpfile" >/dev/null; then
break
fi
read -r -n 1 -s -p "JSON is invalid. Press any key to reopen editor." _
echo >&2
done
jq -c . "$tmpfile" >"$minified_tmpfile"
echo "Contents to upload:"
cat "$minified_tmpfile"
echo
if [[ "$env" == "production" ]]; then
confirmation=""
read -r -p 'Type "production" to upload production runtime settings: ' confirmation
if [[ "$confirmation" != "production" ]]; then
echo "Production upload canceled." >&2
exit 1
fi
fi
gcloud storage cp \
--cache-control="$cache_control" \
--content-type=application/json \
--predefined-acl=publicRead \
"$minified_tmpfile" \
"$object_uri"
curl -fsSL "$object_url" >"$downloaded_tmpfile"
if ! cmp -s "$minified_tmpfile" "$downloaded_tmpfile"; then
echo "Read-after-write check failed: uploaded file differs from remote object." >&2
exit 1
fi
echo "Confirmed remote contents:"
cat "$downloaded_tmpfile"
echo
Generated
+19 -48
View File
@@ -789,15 +789,6 @@ __metadata:
languageName: node
linkType: hard
"@babel/code-frame@npm:7.10.4, @babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:~7.10.4":
version: 7.10.4
resolution: "@babel/code-frame@npm:7.10.4"
dependencies:
"@babel/highlight": "npm:^7.10.4"
checksum: 10c0/69e0f52986a1f40231d891224f420436629b6678711b68c088e97b7bdba1607aeb5eb9cfb070275c433f0bf43c37c134845db80d1cdbf5ac88a69b0bdcce9402
languageName: node
linkType: hard
"@babel/code-frame@npm:7.23.5, @babel/code-frame@npm:^7.23.5":
version: 7.23.5
resolution: "@babel/code-frame@npm:7.23.5"
@@ -808,6 +799,15 @@ __metadata:
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.0.0, @babel/code-frame@npm:^7.10.4, @babel/code-frame@npm:~7.10.4":
version: 7.10.4
resolution: "@babel/code-frame@npm:7.10.4"
dependencies:
"@babel/highlight": "npm:^7.10.4"
checksum: 10c0/69e0f52986a1f40231d891224f420436629b6678711b68c088e97b7bdba1607aeb5eb9cfb070275c433f0bf43c37c134845db80d1cdbf5ac88a69b0bdcce9402
languageName: node
linkType: hard
"@babel/code-frame@npm:^7.12.13":
version: 7.12.13
resolution: "@babel/code-frame@npm:7.12.13"
@@ -2868,7 +2868,7 @@ __metadata:
"@expo/logger": "npm:20.0.0"
"@expo/package-manager": "npm:1.9.10"
"@expo/plist": "npm:^0.2.0"
"@expo/repack-app": "npm:~0.4.1"
"@expo/repack-app": "npm:~0.6.1"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:20.1.0"
@@ -3419,15 +3419,15 @@ __metadata:
languageName: node
linkType: hard
"@expo/repack-app@npm:~0.4.1":
version: 0.4.1
resolution: "@expo/repack-app@npm:0.4.1"
"@expo/repack-app@npm:~0.6.1":
version: 0.6.1
resolution: "@expo/repack-app@npm:0.6.1"
dependencies:
commander: "npm:^14.0.2"
picocolors: "npm:^1.1.1"
bin:
repack-app: bin/cli.js
checksum: 10c0/4bc30a7ca95221f079026633802c32894ef25d55677212e86859194bf1a042598eaa5977510a277c3ceee6af6bd0d0a3aff3e21c4510c37b7dea584dd927ed0e
checksum: 10c0/fdf3088b4f327393056631270bf90a67ba949e7994892a62abc985bd9478119ab2f1d5be35fa1efb6bd1d2780c49a3dbb87cb669d9c65b44e3d9a0b41fbf091f
languageName: node
linkType: hard
@@ -3614,16 +3614,15 @@ __metadata:
linkType: soft
"@expo/xcpretty@npm:^4.3.1":
version: 4.3.2
resolution: "@expo/xcpretty@npm:4.3.2"
version: 4.4.4
resolution: "@expo/xcpretty@npm:4.4.4"
dependencies:
"@babel/code-frame": "npm:7.10.4"
"@babel/code-frame": "npm:^7.20.0"
chalk: "npm:^4.1.0"
find-up: "npm:^5.0.0"
js-yaml: "npm:^4.1.0"
bin:
excpretty: build/cli.js
checksum: 10c0/e524817b2e42fb8c8914fca7e8f7c2f723f4f6d338a57b7ae97cd3e76da8108af63a22d4c7dc2e96a192a248a242f6e0f8056f0ca53bc4fb5cd2e5ae428e0891
checksum: 10c0/cd555ad49438dee2cc3f2950ecbef3048f7169bbdadc8db169cfcddaad13668fee6377c010624ed4079dc439c46d6023d2551da403a2070deec000bc864e8dd8
languageName: node
linkType: hard
@@ -12119,16 +12118,6 @@ __metadata:
languageName: node
linkType: hard
"find-up@npm:^5.0.0":
version: 5.0.0
resolution: "find-up@npm:5.0.0"
dependencies:
locate-path: "npm:^6.0.0"
path-exists: "npm:^4.0.0"
checksum: 10c0/062c5a83a9c02f53cdd6d175a37ecf8f87ea5bbff1fdfb828f04bfa021441bc7583e8ebc0872a4c1baab96221fb8a8a275a19809fb93fbc40bd69ec35634069a
languageName: node
linkType: hard
"find-yarn-workspace-root@npm:^2.0.0":
version: 2.0.0
resolution: "find-yarn-workspace-root@npm:2.0.0"
@@ -15375,15 +15364,6 @@ __metadata:
languageName: node
linkType: hard
"locate-path@npm:^6.0.0":
version: 6.0.0
resolution: "locate-path@npm:6.0.0"
dependencies:
p-locate: "npm:^5.0.0"
checksum: 10c0/d3972ab70dfe58ce620e64265f90162d247e87159b6126b01314dd67be43d50e96a50b517bce2d9452a79409c7614054c277b5232377de50416564a77ac7aad3
languageName: node
linkType: hard
"lodash.clonedeep@npm:^4.5.0":
version: 4.5.0
resolution: "lodash.clonedeep@npm:4.5.0"
@@ -17349,7 +17329,7 @@ __metadata:
languageName: node
linkType: hard
"p-limit@npm:3.1.0, p-limit@npm:^3.0.1, p-limit@npm:^3.0.2, p-limit@npm:^3.1.0":
"p-limit@npm:3.1.0, p-limit@npm:^3.0.1, p-limit@npm:^3.1.0":
version: 3.1.0
resolution: "p-limit@npm:3.1.0"
dependencies:
@@ -17394,15 +17374,6 @@ __metadata:
languageName: node
linkType: hard
"p-locate@npm:^5.0.0":
version: 5.0.0
resolution: "p-locate@npm:5.0.0"
dependencies:
p-limit: "npm:^3.0.2"
checksum: 10c0/2290d627ab7903b8b70d11d384fee714b797f6040d9278932754a6860845c4d3190603a0772a663c8cb5a7b21d1b16acb3a6487ebcafa9773094edc3dfe6009a
languageName: node
linkType: hard
"p-map-series@npm:2.1.0":
version: 2.1.0
resolution: "p-map-series@npm:2.1.0"