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
Expo CI 33225d2b73 v20.1.0 2026-06-05 21:36:09 +00:00
Gabe Debes 7a97f297ee [eas-cli] Add eas update:embedded:delete command (#3809) 2026-06-05 14:27:30 -07:00
Gabe Debes 5f952fd33b [eas-cli] Add eas update:embedded:list command (#3811)
* [eas-cli] Add eas update:embedded:list command

* [eas-cli] Put "All channels" first in the channel-filter prompt

* [eas-cli] Refresh graphql schema snapshot
2026-06-05 14:09:31 -07:00
Gabe Debes 45354d12da [eas-cli] Add eas update:embedded:view command (#3810)
* [eas-cli] Add eas update:embedded:view command

* [eas-cli] Unit tests for EmbeddedUpdateQuery wrapper
2026-06-05 10:04:14 -07:00
Ash Wu 598f0050be [build-tools] Route Datadog metrics and logs by build vs job-run target (#3819)
Signed-off-by: Ash Wu <[email protected]>
2026-06-05 14:01:20 +08:00
52 changed files with 5598 additions and 411 deletions

No files matched your search

+11 -2
View File
@@ -8,12 +8,21 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [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
- [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))
## [20.0.0](https://github.com/expo/eas-cli/releases/tag/v20.0.0) - 2026-05-29
### 🛠 Breaking changes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "20.0.0",
"version": "20.1.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+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"
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "20.0.0",
"version": "20.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -38,14 +38,14 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "20.0.0",
"@expo/eas-build-job": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"@expo/env": "^0.4.0",
"@expo/logger": "20.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "20.0.0",
"@expo/steps": "20.1.0",
"@expo/template-file": "20.0.0",
"@expo/turtle-spawn": "20.0.0",
"@expo/xcpretty": "^4.3.1",
@@ -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",
@@ -22,13 +22,13 @@ describe('Datadog singleton', () => {
Datadog.setup(null);
});
it('POSTs distribution metrics to the turtle build metrics endpoint', () => {
it('POSTs distribution metrics to the turtle build metrics endpoint for build targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.distribution('eas.build.phase_duration', 1234, {
@@ -51,6 +51,7 @@ describe('Datadog singleton', () => {
build_phase: 'install_dependencies',
platform: 'ios',
result: 'success',
target: 'build',
},
},
],
@@ -61,13 +62,49 @@ describe('Datadog singleton', () => {
);
});
it('POSTs logs to the turtle build logs endpoint', () => {
it('POSTs distribution metrics to the turtle job run metrics endpoint for jobRun targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.maestro.install', 1, {
maestro_version: '2.1.0',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
'POST',
{
json: {
metrics: [
{
name: 'eas.maestro.install',
type: 'distribution',
value: 1,
tags: {
maestro_version: '2.1.0',
target: 'job_run',
},
},
],
},
headers: { Authorization: 'Bearer token-abc' },
retries: 2,
}
);
});
it('POSTs logs to the turtle build logs endpoint with buildId for build targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.log('artifact dry-run matched', {
@@ -85,6 +122,38 @@ describe('Datadog singleton', () => {
tags: {
event: 'find_artifacts_absolute_path_dry_run',
status: 'match',
target: 'build',
},
},
headers: { Authorization: 'Bearer token-abc' },
shouldThrowOnNotOk: false,
}
);
});
it('POSTs logs to the turtle job run logs endpoint with jobRunId for jobRun targets', () => {
turtleFetchMock.mockResolvedValueOnce({} as Response);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.log('Gradle cache restored (hit)', {
cache_type: 'gradle',
});
expect(turtleFetchMock).toHaveBeenCalledWith(
'https://api.expo.test/v2/turtle-job-runs/logs',
'POST',
{
json: {
jobRunId: 'job-run-id',
message: 'Gradle cache restored (hit)',
tags: {
cache_type: 'gradle',
target: 'job_run',
},
},
headers: { Authorization: 'Bearer token-abc' },
@@ -102,12 +171,12 @@ describe('Datadog singleton', () => {
expect(turtleFetchMock).not.toHaveBeenCalled();
});
it('swallows upload failures', async () => {
it('swallows metric upload failures for build targets', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.distribution('eas.build.phase_duration', 1);
@@ -119,18 +188,54 @@ 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' },
},
],
},
})
);
});
it('swallows log upload failures', async () => {
it('labels metric upload failures for jobRun targets with the jobRun kind', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.maestro.install', 1);
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle jobRun metric',
expect.any(Error),
expect.objectContaining({
extras: {
metrics: [
{
name: 'eas.maestro.install',
type: 'distribution',
value: 1,
tags: { target: 'job_run' },
},
],
},
})
);
});
it('swallows log upload failures for build targets', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
Datadog.log('artifact dry-run matched');
@@ -142,7 +247,37 @@ 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' },
},
},
})
);
});
it('labels log upload failures for jobRun targets with the jobRun kind', async () => {
turtleFetchMock.mockRejectedValueOnce(new Error('network down'));
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.log('Gradle cache restored (hit)');
await flushPromises();
expect(sentryCaptureMock).toHaveBeenCalledWith(
'Failed to report turtle jobRun log',
expect.any(Error),
expect.objectContaining({
extras: {
log: {
jobRunId: 'job-run-id',
message: 'Gradle cache restored (hit)',
tags: { target: 'job_run' },
},
},
})
);
@@ -153,13 +288,13 @@ describe('Datadog singleton', () => {
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'first-build-id',
robotAccessToken: 'first-token',
target: { kind: 'build', turtleBuildId: 'first-build-id' },
});
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'second-build-id',
robotAccessToken: 'second-token',
target: { kind: 'jobRun', turtleJobRunId: 'job-run-id' },
});
Datadog.distribution('eas.build.phase_duration', 1);
@@ -167,7 +302,7 @@ describe('Datadog singleton', () => {
expect(turtleFetchMock).toHaveBeenNthCalledWith(
1,
'https://api.expo.test/v2/turtle-builds/second-build-id/metrics',
'https://api.expo.test/v2/turtle-job-runs/job-run-id/metrics',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
@@ -175,7 +310,7 @@ describe('Datadog singleton', () => {
);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
2,
'https://api.expo.test/v2/turtle-builds/logs',
'https://api.expo.test/v2/turtle-job-runs/logs',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer second-token' },
@@ -192,8 +327,8 @@ describe('Datadog singleton', () => {
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
let flushed = false;
@@ -219,8 +354,8 @@ describe('Datadog singleton', () => {
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
let flushed = false;
@@ -236,6 +371,35 @@ describe('Datadog singleton', () => {
expect(flushed).toBe(true);
});
it('only awaits uploads enqueued since the previous flush', async () => {
let resolveSecondUpload!: (response: Response) => void;
turtleFetchMock.mockReturnValueOnce(new Promise<Response>(() => {})).mockReturnValueOnce(
new Promise<Response>(resolve => {
resolveSecondUpload = resolve;
})
);
Datadog.setup({
expoApiV2BaseUrl: 'https://api.expo.test/v2/',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
// The first upload never settles; the first flush rotates it out of the queue.
Datadog.distribution('eas.build.phase_duration', 1);
void Datadog.flushAsync();
Datadog.distribution('eas.build.phase_duration', 2);
let flushed = false;
const flushPromise = Datadog.flushAsync().then(() => {
flushed = true;
});
resolveSecondUpload({} as Response);
await flushPromise;
expect(flushed).toBe(true);
});
});
async function flushPromises(): Promise<void> {
@@ -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;
+47 -34
View File
@@ -1,15 +1,26 @@
import { Sentry } from './sentry';
import { turtleFetch } from './utils/turtleFetch';
type MetricsTarget =
| { kind: 'build'; turtleBuildId: string }
| { kind: 'jobRun'; turtleJobRunId: string };
type DatadogSetupOptions = {
expoApiV2BaseUrl: string;
turtleBuildId: string;
robotAccessToken: string;
target: MetricsTarget;
};
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;
@@ -19,28 +30,29 @@ export const Datadog = {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, turtleBuildId, robotAccessToken } = setupOptions;
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const metrics = [
{
name,
type: 'distribution' as const,
value,
tags,
tags: withTargetTag(tags, target),
},
];
const uploadPromise = turtleFetch(
new URL(`turtle-builds/${turtleBuildId}/metrics`, expoApiV2BaseUrl).toString(),
'POST',
{
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: 2,
}
).catch(err => {
Sentry.capture('Failed to report turtle build metric', err, {
const metricsPath =
target.kind === 'build'
? `turtle-builds/${target.turtleBuildId}/metrics`
: `turtle-job-runs/${target.turtleJobRunId}/metrics`;
const uploadPromise = turtleFetch(new URL(metricsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: { metrics },
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: 2,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} metric`, err, {
extras: { metrics },
});
});
@@ -52,25 +64,22 @@ export const Datadog = {
if (!setupOptions) {
return;
}
const { expoApiV2BaseUrl, turtleBuildId, robotAccessToken } = setupOptions;
const log = {
buildId: turtleBuildId,
message,
tags,
};
const { expoApiV2BaseUrl, robotAccessToken, target } = setupOptions;
const tagsWithTarget = withTargetTag(tags, target);
const log =
target.kind === 'build'
? { 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('turtle-builds/logs', expoApiV2BaseUrl).toString(),
'POST',
{
json: log,
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}
).catch(err => {
Sentry.capture('Failed to report turtle build log', err, {
const uploadPromise = turtleFetch(new URL(logsPath, expoApiV2BaseUrl).toString(), 'POST', {
json: log,
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
}).catch(err => {
Sentry.capture(`Failed to report turtle ${target.kind} log`, err, {
extras: { log },
});
});
@@ -79,6 +88,10 @@ export const Datadog = {
},
async flushAsync(): Promise<void> {
await Promise.allSettled(pendingUploads);
// Rotate so each flush only awaits its own batch; uploads enqueued during the
// await land in the fresh array for a later flush.
const uploads = pendingUploads;
pendingUploads = [];
await Promise.allSettled(uploads);
},
};
+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');
}
}
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "20.0.0",
"version": "20.1.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "20.0.0"
"@expo/steps": "20.1.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "20.0.0",
"version": "20.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
+176 -106
View File
@@ -149,7 +149,10 @@ eas --help COMMAND
* [`eas update:configure`](#eas-updateconfigure)
* [`eas update:delete GROUPID`](#eas-updatedelete-groupid)
* [`eas update:edit [GROUPID]`](#eas-updateedit-groupid)
* [`eas update:embedded:delete ID`](#eas-updateembeddeddelete-id)
* [`eas update:embedded:list`](#eas-updateembeddedlist)
* [`eas update:embedded:upload`](#eas-updateembeddedupload)
* [`eas update:embedded:view ID`](#eas-updateembeddedview-id)
* [`eas update:insights GROUPID`](#eas-updateinsights-groupid)
* [`eas update:list`](#eas-updatelist)
* [`eas update:republish`](#eas-updaterepublish)
@@ -196,7 +199,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/account/login.ts)_
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -213,7 +216,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/account/logout.ts)_
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -235,7 +238,7 @@ DESCRIPTION
view account usage and billing for the current cycle
```
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/account/usage.ts)_
_See code: [packages/eas-cli/src/commands/account/usage.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -252,7 +255,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/account/view.ts)_
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -266,7 +269,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -318,7 +321,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/branch/create.ts)_
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -339,7 +342,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/branch/delete.ts)_
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -359,7 +362,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/branch/list.ts)_
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -379,7 +382,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/branch/rename.ts)_
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -402,7 +405,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/branch/view.ts)_
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -443,7 +446,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/index.ts)_
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -463,7 +466,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/cancel.ts)_
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -481,7 +484,7 @@ DESCRIPTION
configure the project to support EAS Build
```
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/configure.ts)_
_See code: [packages/eas-cli/src/commands/build/configure.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -501,7 +504,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/delete.ts)_
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -526,7 +529,7 @@ DESCRIPTION
run dev client simulator/emulator build with matching fingerprint or create a new one
```
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/dev.ts)_
_See code: [packages/eas-cli/src/commands/build/dev.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -553,7 +556,7 @@ DESCRIPTION
download a simulator/emulator build by build ID or fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/download.ts)_
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -591,7 +594,7 @@ DESCRIPTION
inspect the state of the project at specific build stages, useful for troubleshooting
```
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/inspect.ts)_
_See code: [packages/eas-cli/src/commands/build/inspect.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -631,7 +634,7 @@ DESCRIPTION
list all builds for your project
```
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/list.ts)_
_See code: [packages/eas-cli/src/commands/build/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -661,7 +664,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/resign.ts)_
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -689,7 +692,7 @@ DESCRIPTION
run simulator/emulator builds from eas-cli
```
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/run.ts)_
_See code: [packages/eas-cli/src/commands/build/run.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -741,7 +744,7 @@ DESCRIPTION
get the latest version from EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/version/get.ts)_
_See code: [packages/eas-cli/src/commands/build/version/get.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -759,7 +762,7 @@ DESCRIPTION
update version of an app
```
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/version/set.ts)_
_See code: [packages/eas-cli/src/commands/build/version/set.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -777,7 +780,7 @@ DESCRIPTION
update a version in native code with a value stored on EAS servers
```
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/version/sync.ts)_
_See code: [packages/eas-cli/src/commands/build/version/sync.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -794,7 +797,7 @@ DESCRIPTION
view a build for your project
```
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/build/view.ts)_
_See code: [packages/eas-cli/src/commands/build/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -815,7 +818,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/create.ts)_
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -836,7 +839,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/delete.ts)_
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -858,7 +861,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/edit.ts)_
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:insights`
@@ -882,7 +885,7 @@ DESCRIPTION
display adoption, crash, and unique-user insights for a channel + runtime version
```
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/insights.ts)_
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/insights.ts)_
## `eas channel:list`
@@ -902,7 +905,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/list.ts)_
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -924,7 +927,7 @@ DESCRIPTION
pause a channel to stop it from sending updates
```
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/pause.ts)_
_See code: [packages/eas-cli/src/commands/channel/pause.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -946,7 +949,7 @@ DESCRIPTION
resume a channel to start sending updates
```
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/resume.ts)_
_See code: [packages/eas-cli/src/commands/channel/resume.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -981,7 +984,7 @@ DESCRIPTION
Roll a new branch out on a channel incrementally.
```
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/rollout.ts)_
_See code: [packages/eas-cli/src/commands/channel/rollout.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -1004,7 +1007,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/channel/view.ts)_
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -1025,7 +1028,7 @@ DESCRIPTION
display project configuration (app.json + eas.json)
```
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -1042,7 +1045,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/credentials/index.ts)_
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1060,7 +1063,7 @@ DESCRIPTION
Set up credentials for building your project.
```
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
_See code: [packages/eas-cli/src/commands/credentials/configure-build.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1089,7 +1092,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/deploy/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1114,7 +1117,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1135,7 +1138,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1156,7 +1159,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/deploy/delete.ts)_
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1193,7 +1196,7 @@ DESCRIPTION
register new Apple Devices to use for internal distribution
```
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/device/create.ts)_
_See code: [packages/eas-cli/src/commands/device/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1213,7 +1216,7 @@ DESCRIPTION
remove a registered device from your account
```
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/device/delete.ts)_
_See code: [packages/eas-cli/src/commands/device/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1234,7 +1237,7 @@ DESCRIPTION
list all registered devices for your account
```
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/device/list.ts)_
_See code: [packages/eas-cli/src/commands/device/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1255,7 +1258,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/device/rename.ts)_
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1269,7 +1272,7 @@ DESCRIPTION
view a device for your project
```
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/device/view.ts)_
_See code: [packages/eas-cli/src/commands/device/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1283,7 +1286,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1315,7 +1318,7 @@ DESCRIPTION
create an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/env/create.ts)_
_See code: [packages/eas-cli/src/commands/env/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1341,7 +1344,7 @@ DESCRIPTION
delete an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/env/delete.ts)_
_See code: [packages/eas-cli/src/commands/env/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1363,7 +1366,7 @@ DESCRIPTION
execute a command with environment variables from the selected environment
```
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/env/exec.ts)_
_See code: [packages/eas-cli/src/commands/env/exec.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1391,7 +1394,7 @@ DESCRIPTION
view an environment variable for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/env/get.ts)_
_See code: [packages/eas-cli/src/commands/env/get.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1419,7 +1422,7 @@ DESCRIPTION
list environment variables for the current project or account
```
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/env/list.ts)_
_See code: [packages/eas-cli/src/commands/env/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1442,7 +1445,7 @@ DESCRIPTION
pull environment variables for the selected environment to .env file
```
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/env/pull.ts)_
_See code: [packages/eas-cli/src/commands/env/pull.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1464,7 +1467,7 @@ DESCRIPTION
push environment variables from .env file to the selected environment
```
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/env/push.ts)_
_See code: [packages/eas-cli/src/commands/env/push.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1498,7 +1501,7 @@ DESCRIPTION
update an environment variable on the current project or account
```
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/env/update.ts)_
_See code: [packages/eas-cli/src/commands/env/update.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1542,7 +1545,7 @@ EXAMPLES
$ eas fingerprint:compare <FINGERPRINT-HASH> --update-id <UPDATE-ID> # Compare fingerprint from update against provided fingerprint
```
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/compare.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1573,7 +1576,7 @@ EXAMPLES
$ eas fingerprint:generate --json --non-interactive --platform android # Output fingerprint json to stdout
```
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
_See code: [packages/eas-cli/src/commands/fingerprint/generate.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1652,7 +1655,7 @@ DESCRIPTION
connect a project to an App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
## `eas integrations:asc:disconnect`
@@ -1671,7 +1674,7 @@ DESCRIPTION
disconnect the current project from its App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
## `eas integrations:asc:status`
@@ -1689,7 +1692,7 @@ DESCRIPTION
show the App Store Connect app link status for the current project
```
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
## `eas integrations:convex:connect`
@@ -1711,7 +1714,7 @@ DESCRIPTION
connect Convex to your Expo project
```
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
## `eas integrations:convex:dashboard`
@@ -1725,7 +1728,7 @@ DESCRIPTION
open the Convex dashboard for the linked Convex project
```
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
## `eas integrations:convex:project`
@@ -1739,7 +1742,7 @@ DESCRIPTION
display the Convex project linked to the current Expo app
```
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/convex/project.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/project.ts)_
## `eas integrations:convex:project:delete`
@@ -1757,7 +1760,7 @@ DESCRIPTION
remove the Convex project link for the current Expo app from EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
## `eas integrations:convex:team`
@@ -1771,7 +1774,7 @@ DESCRIPTION
display Convex teams linked to the current Expo app's owner account
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/convex/team.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/team.ts)_
## `eas integrations:convex:team:delete [CONVEX_TEAM]`
@@ -1792,7 +1795,7 @@ DESCRIPTION
remove a Convex team link from the current Expo app owner account's EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
## `eas integrations:convex:team:invite [CONVEX_TEAM]`
@@ -1812,7 +1815,7 @@ DESCRIPTION
send a Convex team invitation to your verified email address
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
## `eas login`
@@ -1864,7 +1867,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/metadata/lint.ts)_
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1882,7 +1885,7 @@ DESCRIPTION
generate the local store configuration from the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/metadata/pull.ts)_
_See code: [packages/eas-cli/src/commands/metadata/pull.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1900,7 +1903,7 @@ DESCRIPTION
sync the local store configuration to the app stores
```
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/metadata/push.ts)_
_See code: [packages/eas-cli/src/commands/metadata/push.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1959,7 +1962,7 @@ DESCRIPTION
arguments, a list of the available event names and associated event counts is returned.
```
_See code: [packages/eas-cli/src/commands/observe/events.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/observe/events.ts)_
_See code: [packages/eas-cli/src/commands/observe/events.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/observe/events.ts)_
## `eas observe:metrics [METRIC]`
@@ -1994,7 +1997,7 @@ DESCRIPTION
display individual performance metric samples ordered by value
```
_See code: [packages/eas-cli/src/commands/observe/metrics.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/observe/metrics.ts)_
_See code: [packages/eas-cli/src/commands/observe/metrics.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/observe/metrics.ts)_
## `eas observe:metrics-summary`
@@ -2025,7 +2028,7 @@ DESCRIPTION
display aggregated performance metric statistics grouped by app version
```
_See code: [packages/eas-cli/src/commands/observe/metrics-summary.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/observe/metrics-summary.ts)_
_See code: [packages/eas-cli/src/commands/observe/metrics-summary.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/observe/metrics-summary.ts)_
## `eas observe:routes`
@@ -2062,7 +2065,7 @@ DESCRIPTION
display app navigation route metrics (Cold TTR, Warm TTR, TTI) grouped by route name
```
_See code: [packages/eas-cli/src/commands/observe/routes.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/observe/routes.ts)_
_See code: [packages/eas-cli/src/commands/observe/routes.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/observe/routes.ts)_
## `eas observe:versions`
@@ -2087,7 +2090,7 @@ DESCRIPTION
display app versions with build and update details
```
_See code: [packages/eas-cli/src/commands/observe/versions.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/observe/versions.ts)_
_See code: [packages/eas-cli/src/commands/observe/versions.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/observe/versions.ts)_
## `eas onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -2117,7 +2120,7 @@ DESCRIPTION
open the project page in a web browser
```
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -2131,7 +2134,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/project/info.ts)_
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -2154,7 +2157,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/project/init.ts)_
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -2178,7 +2181,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/project/new.ts)_
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -2196,7 +2199,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/project/onboarding.ts)_
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -2229,7 +2232,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -2272,7 +2275,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/index.ts)_
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -2293,7 +2296,7 @@ DESCRIPTION
configure the project to support EAS Update
```
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/configure.ts)_
_See code: [packages/eas-cli/src/commands/update/configure.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -2314,7 +2317,7 @@ DESCRIPTION
delete all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/delete.ts)_
_See code: [packages/eas-cli/src/commands/update/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -2339,7 +2342,54 @@ DESCRIPTION
edit all the updates in an update group
```
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/edit.ts)_
_See code: [packages/eas-cli/src/commands/update/edit.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:embedded:delete ID`
delete an embedded update registered with EAS Update
```
USAGE
$ eas update:embedded:delete ID [--json] [--non-interactive]
ARGUMENTS
ID The ID of the embedded update (manifest UUID from app.manifest).
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--non-interactive Run the command in non-interactive mode.
DESCRIPTION
delete an embedded update registered with EAS Update
```
_See code: [packages/eas-cli/src/commands/update/embedded/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/embedded/delete.ts)_
## `eas update:embedded:list`
list embedded updates registered with EAS Update for this project
```
USAGE
$ eas update:embedded:list [-p ios|android] [--runtime-version <value>] [--channel <value>] [--limit <value>]
[--after-cursor <value>] [--json] [--non-interactive]
FLAGS
-p, --platform=<option> Filter by platform
<options: ios|android>
--after-cursor=<value> Return items after this cursor (for pagination)
--channel=<value> Filter by channel name (pass "all" to skip the channel prompt)
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies
--non-interactive.
--limit=<value> The number of items to fetch each query. Defaults to 25 and is capped at 50.
--non-interactive Run the command in non-interactive mode.
--runtime-version=<value> Filter by runtime version
DESCRIPTION
list embedded updates registered with EAS Update for this project
```
_See code: [packages/eas-cli/src/commands/update/embedded/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/embedded/list.ts)_
## `eas update:embedded:upload`
@@ -2369,7 +2419,27 @@ EXAMPLES
$ eas update:embedded:upload --platform android --bundle android/app/src/main/assets/index.android.bundle --manifest android/app/src/main/assets/app.manifest --channel production --build-id <BUILD-ID>
```
_See code: [packages/eas-cli/src/commands/update/embedded/upload.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/embedded/upload.ts)_
_See code: [packages/eas-cli/src/commands/update/embedded/upload.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/embedded/upload.ts)_
## `eas update:embedded:view ID`
view details of an embedded update registered with EAS Update
```
USAGE
$ eas update:embedded:view ID [--json]
ARGUMENTS
ID The ID of the embedded update (manifest UUID from app.manifest).
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr.
DESCRIPTION
view details of an embedded update registered with EAS Update
```
_See code: [packages/eas-cli/src/commands/update/embedded/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/embedded/view.ts)_
## `eas update:insights GROUPID`
@@ -2396,7 +2466,7 @@ DESCRIPTION
display launch, crash, unique-user, and size insights for an update group
```
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/insights.ts)_
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/insights.ts)_
## `eas update:list`
@@ -2423,7 +2493,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/list.ts)_
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -2461,7 +2531,7 @@ DESCRIPTION
roll back to an existing update
```
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/republish.ts)_
_See code: [packages/eas-cli/src/commands/update/republish.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2489,7 +2559,7 @@ DESCRIPTION
revert a rollout update for a project
```
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
_See code: [packages/eas-cli/src/commands/update/revert-update-rollout.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2519,7 +2589,7 @@ DESCRIPTION
roll back to the embedded update
```
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
_See code: [packages/eas-cli/src/commands/update/roll-back-to-embedded.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2540,7 +2610,7 @@ DESCRIPTION
instead execute "eas update:republish" or "eas update:roll-back-to-embedded".
```
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/rollback.ts)_
_See code: [packages/eas-cli/src/commands/update/rollback.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2564,7 +2634,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/update/view.ts)_
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2585,7 +2655,7 @@ DESCRIPTION
upload a local build and generate a sharable link
```
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2607,7 +2677,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/webhook/create.ts)_
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2627,7 +2697,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/webhook/delete.ts)_
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2646,7 +2716,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/webhook/list.ts)_
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2669,7 +2739,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/webhook/update.ts)_
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2686,7 +2756,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/webhook/view.ts)_
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2807,7 +2877,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2827,7 +2897,7 @@ DESCRIPTION
create a new workflow configuration YAML file
```
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/workflow/create.ts)_
_See code: [packages/eas-cli/src/commands/workflow/create.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2851,7 +2921,7 @@ DESCRIPTION
If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/workflow/logs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/logs.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -2891,7 +2961,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/workflow/run.ts)_
_See code: [packages/eas-cli/src/commands/workflow/run.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2913,7 +2983,7 @@ DESCRIPTION
list recent workflow runs for this project, with their IDs, statuses, and timestamps
```
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/workflow/runs.ts)_
_See code: [packages/eas-cli/src/commands/workflow/runs.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2941,7 +3011,7 @@ FLAG DESCRIPTIONS
Exit codes: 0 = success, 11 = failure, 12 = canceled, 13 = wait aborted.
```
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/workflow/status.ts)_
_See code: [packages/eas-cli/src/commands/workflow/status.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2961,7 +3031,7 @@ DESCRIPTION
validate a workflow configuration yaml file
```
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/workflow/validate.ts)_
_See code: [packages/eas-cli/src/commands/workflow/validate.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2983,5 +3053,5 @@ DESCRIPTION
workflow runs for the current project.
```
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v20.0.0/packages/eas-cli/src/commands/workflow/view.ts)_
_See code: [packages/eas-cli/src/commands/workflow/view.ts](https://github.com/expo/eas-cli/blob/v20.1.0/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+2186 -13
View File
File diff suppressed because it is too large. Load diff
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "20.0.0",
"version": "20.1.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -47,8 +47,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "20.0.0",
"@expo/eas-json": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"@expo/eas-json": "20.1.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "20.0.0",
@@ -63,7 +63,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "20.0.0",
"@expo/steps": "20.1.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -0,0 +1,130 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EmbeddedUpdateMutation } from '../../../../graphql/mutations/EmbeddedUpdateMutation';
import Log from '../../../../log';
import * as prompts from '../../../../prompts';
import * as json from '../../../../utils/json';
import UpdateEmbeddedDelete from '../delete';
jest.mock('../../../../graphql/mutations/EmbeddedUpdateMutation', () => ({
EmbeddedUpdateMutation: { deleteEmbeddedUpdateAsync: jest.fn() },
}));
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
jest.mock('../../../../prompts');
const mockDelete = jest.mocked(EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync);
const mockToggleConfirm = jest.mocked(prompts.toggleConfirmAsync);
const mockLogLog = jest.mocked(Log.log);
const mockLogWithTick = jest.mocked(Log.withTick);
const mockLogError = jest.mocked(Log.error);
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);
const VALID_UUID = 'a1b2c3d4-1234-4000-8000-000000000000';
const MOCK_CONTEXT = {
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
};
describe(UpdateEmbeddedDelete, () => {
const mockConfig = getMockOclifConfig();
let processExitSpy: jest.SpyInstance;
beforeEach(() => {
jest.clearAllMocks();
mockDelete.mockResolvedValue({ id: VALID_UUID });
mockToggleConfirm.mockResolvedValue(true);
processExitSpy = jest.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process.exit');
}) as never);
});
afterEach(() => {
processExitSpy.mockRestore();
});
function createCommand(argv: string[]): UpdateEmbeddedDelete {
const command = new UpdateEmbeddedDelete(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
return command;
}
it('deletes when the user confirms', async () => {
await createCommand([VALID_UUID]).run();
expect(mockToggleConfirm).toHaveBeenCalledTimes(1);
expect(mockDelete).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
id: VALID_UUID,
});
expect(mockLogWithTick).toHaveBeenCalledWith(`Deleted embedded update ${VALID_UUID}`);
});
it('skips confirmation in non-interactive mode', async () => {
await createCommand([VALID_UUID, '--non-interactive']).run();
expect(mockToggleConfirm).not.toHaveBeenCalled();
expect(mockDelete).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
id: VALID_UUID,
});
});
it('aborts (process.exit) without calling the mutation when the user declines', async () => {
mockToggleConfirm.mockResolvedValue(false);
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow('process.exit');
expect(mockDelete).not.toHaveBeenCalled();
expect(mockLogWithTick).not.toHaveBeenCalled();
});
it('--json prints { id } and skips the human-readable success line', async () => {
await createCommand([VALID_UUID, '--non-interactive', '--json']).run();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJson).toHaveBeenCalledWith({ id: VALID_UUID });
expect(mockLogWithTick).not.toHaveBeenCalled();
});
it('rethrows unexpected mutation errors', async () => {
const boom = new Error('boom');
mockDelete.mockRejectedValue(boom);
await expect(createCommand([VALID_UUID, '--non-interactive']).run()).rejects.toThrow('boom');
});
it('succeeds for an unknown id (server best-effort)', async () => {
// Server returns { id } even when nothing was deleted, so we should still tick.
await createCommand([VALID_UUID, '--non-interactive']).run();
expect(mockLogWithTick).toHaveBeenCalledWith(`Deleted embedded update ${VALID_UUID}`);
});
it('prints an explanatory message before prompting (interactive)', async () => {
await createCommand([VALID_UUID]).run();
const lines = mockLogLog.mock.calls.map(c => String(c[0])).join('\n');
expect(lines).toContain(`permanently delete embedded update: "${VALID_UUID}"`);
expect(lines).toContain('Diff patches already generated');
expect(lines).toContain('re-upload');
});
it('logs a cancel message when the user declines', async () => {
mockToggleConfirm.mockResolvedValue(false);
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow('process.exit');
expect(mockLogError).toHaveBeenCalledWith(
`Canceled deletion of embedded update: "${VALID_UUID}".`
);
});
it('passes the id through to deleteEmbeddedUpdateAsync', async () => {
await createCommand([VALID_UUID, '--non-interactive']).run();
expect(mockDelete).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
id: VALID_UUID,
});
});
it('--json + --non-interactive: skips prompt, skips tick, prints { id }', async () => {
await createCommand([VALID_UUID, '--non-interactive', '--json']).run();
expect(mockToggleConfirm).not.toHaveBeenCalled();
expect(mockLogWithTick).not.toHaveBeenCalled();
expect(mockPrintJson).toHaveBeenCalledWith({ id: VALID_UUID });
});
});
@@ -0,0 +1,280 @@
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../../../../graphql/generated';
import { ChannelQuery } from '../../../../graphql/queries/ChannelQuery';
import {
EmbeddedUpdateFragment,
EmbeddedUpdateQuery,
} from '../../../../graphql/queries/EmbeddedUpdateQuery';
import Log from '../../../../log';
import { selectAsync } from '../../../../prompts';
import * as json from '../../../../utils/json';
import UpdateEmbeddedList from '../list';
jest.mock('../../../../graphql/queries/EmbeddedUpdateQuery', () => ({
EmbeddedUpdateQuery: { viewPaginatedAsync: jest.fn() },
}));
jest.mock('../../../../graphql/queries/ChannelQuery', () => ({
ChannelQuery: { viewUpdateChannelsOnAppAsync: jest.fn() },
}));
jest.mock('../../../../prompts');
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
const mockPaginated = jest.mocked(EmbeddedUpdateQuery.viewPaginatedAsync);
const mockViewChannels = jest.mocked(ChannelQuery.viewUpdateChannelsOnAppAsync);
const mockSelectAsync = jest.mocked(selectAsync);
const mockLogLog = jest.mocked(Log.log);
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);
const MOCK_CONTEXT = {
projectId: 'project-123',
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
};
const ROW_A: EmbeddedUpdateFragment = {
id: 'aaaaaaaa-1111-4000-8000-000000000001',
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2026-05-29T00:00:00Z',
launchAsset: { id: 'asset-a', fileSize: 1024, finalFileSize: 768, fileSHA256: 'abc123' },
};
const ROW_B: EmbeddedUpdateFragment = {
id: 'bbbbbbbb-2222-4000-8000-000000000002',
platform: AppPlatform.Android,
runtimeVersion: '1.0.1',
channel: 'preview',
createdAt: '2026-05-30T00:00:00Z',
launchAsset: { id: 'asset-b', fileSize: 2048, finalFileSize: null, fileSHA256: 'def456' },
};
function emptyConnection(): {
edges: { cursor: string; node: EmbeddedUpdateFragment }[];
pageInfo: {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
};
} {
return {
edges: [],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: null, endCursor: null },
};
}
describe(UpdateEmbeddedList, () => {
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
// Default: no channels on the project (skips the prompt).
mockViewChannels.mockResolvedValue([]);
});
function createCommand(argv: string[]): UpdateEmbeddedList {
const command = new UpdateEmbeddedList(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
return command;
}
it('prints each row when results exist', async () => {
mockPaginated.mockResolvedValue({
edges: [
{ cursor: 'c1', node: ROW_A },
{ cursor: 'c2', node: ROW_B },
],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c2' },
});
await createCommand(['--non-interactive']).run();
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: undefined,
first: 25,
after: undefined,
});
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes(ROW_A.id))).toBe(true);
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes(ROW_B.id))).toBe(true);
});
it('prints empty message when no results', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand(['--non-interactive']).run();
expect(mockLogLog).toHaveBeenCalledWith('No embedded updates found.');
});
it('--json prints connection payload and skips formatted output', async () => {
mockPaginated.mockResolvedValue({
edges: [{ cursor: 'c1', node: ROW_A }],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
await createCommand(['--json', '--non-interactive']).run();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJson).toHaveBeenCalledWith({
embeddedUpdates: [ROW_A],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
expect(mockLogLog.mock.calls.every(c => !String(c[0]).includes(ROW_A.id))).toBe(true);
});
it('passes filter when flags supplied', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand([
'--non-interactive',
'--platform',
'ios',
'--runtime-version',
'1.2.0',
'--channel',
'preview',
]).run();
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: { platform: AppPlatform.Ios, runtimeVersion: '1.2.0', channel: 'preview' },
first: 25,
after: undefined,
});
});
it('shows next-page hint when hasNextPage', async () => {
mockPaginated.mockResolvedValue({
edges: [{ cursor: 'c1', node: ROW_A }],
pageInfo: { hasNextPage: true, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
await createCommand(['--non-interactive']).run();
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('--after-cursor c1'))).toBe(true);
});
it('prompts for channel in interactive mode and applies the selected channel', async () => {
mockViewChannels.mockResolvedValue([
{ id: 'ch1', name: 'production' } as any,
{ id: 'ch2', name: 'preview' } as any,
]);
mockSelectAsync.mockResolvedValue('preview');
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand([]).run();
expect(mockSelectAsync).toHaveBeenCalledTimes(1);
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: { platform: undefined, runtimeVersion: undefined, channel: 'preview' },
first: 25,
after: undefined,
});
});
it('skips channel filter when "All channels" is selected', async () => {
mockViewChannels.mockResolvedValue([{ id: 'ch1', name: 'production' } as any]);
// The sentinel value returned by selectAsync for the "All channels" option.
mockSelectAsync.mockResolvedValue('__embedded_update_list__all_channels__');
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand([]).run();
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: undefined,
first: 25,
after: undefined,
});
});
it('skips the channel prompt when --channel is supplied', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand(['--channel', 'preview']).run();
expect(mockSelectAsync).not.toHaveBeenCalled();
expect(mockViewChannels).not.toHaveBeenCalled();
});
it('treats --channel all as no channel filter', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand(['--channel', 'all']).run();
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: undefined,
first: 25,
after: undefined,
});
});
it('skips the channel prompt when the project has no channels', async () => {
mockViewChannels.mockResolvedValue([]);
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand([]).run();
expect(mockSelectAsync).not.toHaveBeenCalled();
});
it('passes --after-cursor through to viewPaginatedAsync', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand(['--non-interactive', '--after-cursor', 'cursor-123']).run();
expect(mockPaginated).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
appId: MOCK_CONTEXT.projectId,
filter: undefined,
first: 25,
after: 'cursor-123',
});
});
it('honors --limit', async () => {
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand(['--non-interactive', '--limit', '5']).run();
expect(mockPaginated).toHaveBeenCalledWith(
MOCK_CONTEXT.loggedIn.graphqlClient,
expect.objectContaining({ first: 5 })
);
});
it('prints the section header with row count', async () => {
mockPaginated.mockResolvedValue({
edges: [{ cursor: 'c1', node: ROW_A }],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
await createCommand(['--non-interactive']).run();
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('Embedded updates (1)'))).toBe(
true
);
});
it('suffixes the section header count with "+" when hasNextPage', async () => {
mockPaginated.mockResolvedValue({
edges: [{ cursor: 'c1', node: ROW_A }],
pageInfo: { hasNextPage: true, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
await createCommand(['--non-interactive']).run();
expect(mockLogLog.mock.calls.some(c => String(c[0]).includes('Embedded updates (1+)'))).toBe(
true
);
});
it('shows relative-time "ago" hints in formatted rows', async () => {
mockPaginated.mockResolvedValue({
edges: [{ cursor: 'c1', node: ROW_A }],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
});
await createCommand(['--non-interactive']).run();
expect(mockLogLog.mock.calls.some(c => /ago/.test(String(c[0])))).toBe(true);
});
it('puts "All channels" first in the prompt, then channels in order', async () => {
mockViewChannels.mockResolvedValue([
{ id: 'ch1', name: 'production' } as any,
{ id: 'ch2', name: 'preview' } as any,
]);
mockSelectAsync.mockResolvedValue('production');
mockPaginated.mockResolvedValue(emptyConnection());
await createCommand([]).run();
const [, choices] = mockSelectAsync.mock.calls[0];
const titles = (choices as any[]).map(c => c.title);
expect(titles).toEqual(['All channels', 'production', 'preview']);
});
});
@@ -0,0 +1,144 @@
import { CombinedError } from '@urql/core';
import { getMockOclifConfig } from '../../../../__tests__/commands/utils';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../../../../graphql/generated';
import {
EmbeddedUpdateFragment,
EmbeddedUpdateQuery,
isEmbeddedUpdateNotFoundError,
} from '../../../../graphql/queries/EmbeddedUpdateQuery';
import Log from '../../../../log';
import * as json from '../../../../utils/json';
import UpdateEmbeddedView from '../view';
jest.mock('../../../../graphql/queries/EmbeddedUpdateQuery', () => ({
EmbeddedUpdateQuery: { viewByIdAsync: jest.fn() },
isEmbeddedUpdateNotFoundError: jest.fn(),
}));
jest.mock('../../../../log');
jest.mock('../../../../utils/json');
const mockView = jest.mocked(EmbeddedUpdateQuery.viewByIdAsync);
const mockIsNotFound = jest.mocked(isEmbeddedUpdateNotFoundError);
const mockLogLog = jest.mocked(Log.log);
const mockEnableJsonOutput = jest.mocked(json.enableJsonOutput);
const mockPrintJson = jest.mocked(json.printJsonOnlyOutput);
const VALID_UUID = 'a1b2c3d4-1234-4000-8000-000000000000';
const MOCK_CONTEXT = {
projectId: 'project-123',
loggedIn: { graphqlClient: {} as ExpoGraphqlClient },
};
const MOCK_EMBEDDED_UPDATE: EmbeddedUpdateFragment = {
id: VALID_UUID,
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2026-05-29T00:00:00Z',
launchAsset: {
id: 'asset-1',
fileSize: 1024,
finalFileSize: 768,
fileSHA256: 'abc123',
},
};
describe(UpdateEmbeddedView, () => {
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
mockView.mockResolvedValue(MOCK_EMBEDDED_UPDATE);
mockIsNotFound.mockReturnValue(false);
});
function createCommand(argv: string[]): UpdateEmbeddedView {
const command = new UpdateEmbeddedView(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue(MOCK_CONTEXT);
return command;
}
it('prints formatted details on success', async () => {
await createCommand([VALID_UUID]).run();
expect(mockView).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
embeddedUpdateId: VALID_UUID,
appId: MOCK_CONTEXT.projectId,
});
// Header line + body line
expect(mockLogLog).toHaveBeenCalledTimes(2);
expect(mockLogLog.mock.calls[0][0]).toContain('Embedded update');
expect(mockLogLog.mock.calls[1][0]).toContain(VALID_UUID);
expect(mockLogLog.mock.calls[1][0]).toContain('Bundle size');
expect(mockLogLog.mock.calls[1][0]).toContain('Bundle SHA-256');
});
it('--json prints the raw embedded update and skips formatted output', async () => {
await createCommand([VALID_UUID, '--json']).run();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJson).toHaveBeenCalledWith(MOCK_EMBEDDED_UPDATE);
expect(mockLogLog).not.toHaveBeenCalled();
});
it('exits with a friendly message when the server returns NOT_FOUND', async () => {
const notFound = new CombinedError({ graphQLErrors: [] });
mockView.mockRejectedValue(notFound);
mockIsNotFound.mockReturnValue(true);
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow();
expect(mockIsNotFound).toHaveBeenCalledWith(notFound);
});
it('rethrows unexpected errors', async () => {
const boom = new Error('boom');
mockView.mockRejectedValue(boom);
mockIsNotFound.mockReturnValue(false);
await expect(createCommand([VALID_UUID]).run()).rejects.toThrow('boom');
});
it('renders bundle size from finalFileSize when present', async () => {
mockView.mockResolvedValue({
...MOCK_EMBEDDED_UPDATE,
launchAsset: { id: 'asset-x', fileSize: 2048, finalFileSize: 768, fileSHA256: 'abc' },
});
await createCommand([VALID_UUID]).run();
// 768 B; not 2.0 KB (which would be fileSize).
expect(mockLogLog.mock.calls[1][0]).toContain('768 B');
});
it('falls back to fileSize when finalFileSize is null', async () => {
mockView.mockResolvedValue({
...MOCK_EMBEDDED_UPDATE,
launchAsset: { id: 'asset-y', fileSize: 2048, finalFileSize: null, fileSHA256: 'abc' },
});
await createCommand([VALID_UUID]).run();
expect(mockLogLog.mock.calls[1][0]).toContain('2.0 KB');
});
it('shows runtime version, channel, and SHA-256 in the body', async () => {
await createCommand([VALID_UUID]).run();
const body = String(mockLogLog.mock.calls[1][0]);
expect(body).toContain('1.0.0');
expect(body).toContain('production');
expect(body).toContain('abc123');
});
it('shows a relative-time hint in the Created at row', async () => {
await createCommand([VALID_UUID]).run();
expect(mockLogLog.mock.calls[1][0]).toMatch(/\(.+ago\)/);
});
it('passes the project id and uuid through to viewByIdAsync', async () => {
await createCommand([VALID_UUID]).run();
expect(mockView).toHaveBeenCalledWith(MOCK_CONTEXT.loggedIn.graphqlClient, {
embeddedUpdateId: VALID_UUID,
appId: MOCK_CONTEXT.projectId,
});
});
});
@@ -0,0 +1,72 @@
import { Args } from '@oclif/core';
import EasCommand from '../../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { EmbeddedUpdateMutation } from '../../../graphql/mutations/EmbeddedUpdateMutation';
import Log from '../../../log';
import { toggleConfirmAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class UpdateEmbeddedDelete extends EasCommand {
static override description = 'delete an embedded update registered with EAS Update';
static override args = {
id: Args.string({
required: true,
description: 'The ID of the embedded update (manifest UUID from app.manifest).',
}),
};
static override flags = {
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const {
args: { id: embeddedUpdateId },
flags,
} = await this.parse(UpdateEmbeddedDelete);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
loggedIn: { graphqlClient },
} = await this.getContextAsync(UpdateEmbeddedDelete, { nonInteractive });
if (jsonFlag) {
enableJsonOutput();
}
if (!nonInteractive) {
Log.log(
`You are about to permanently delete embedded update: "${embeddedUpdateId}". ` +
`Diff patches already generated against this bundle keep serving, but new diffs ` +
`can't be generated until you re-upload it.`
);
Log.newLine();
const confirmed = await toggleConfirmAsync({ message: 'Are you sure you wish to proceed?' });
if (!confirmed) {
Log.error(`Canceled deletion of embedded update: "${embeddedUpdateId}".`);
process.exit(1);
}
}
// Best-effort delete on the server: deleting an unknown id succeeds (idempotent),
// so we don't need a not-found branch here.
await EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync(graphqlClient, {
id: embeddedUpdateId,
});
if (jsonFlag) {
printJsonOnlyOutput({ id: embeddedUpdateId });
return;
}
Log.withTick(`Deleted embedded update ${embeddedUpdateId}`);
}
}
@@ -0,0 +1,172 @@
import { Platform } from '@expo/eas-build-job';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../../commandUtils/pagination';
import { AppPlatform } from '../../../graphql/generated';
import { ChannelQuery } from '../../../graphql/queries/ChannelQuery';
import {
EmbeddedUpdateFragment,
EmbeddedUpdateQuery,
} from '../../../graphql/queries/EmbeddedUpdateQuery';
import { toAppPlatform } from '../../../graphql/types/AppPlatform';
import Log from '../../../log';
import { selectAsync } from '../../../prompts';
import { fromNow } from '../../../utils/date';
import formatFields from '../../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
const DEFAULT_LIMIT = 25;
const MAX_LIMIT = 50;
const CHANNELS_LIMIT = 50;
export default class UpdateEmbeddedList extends EasCommand {
static override description = 'list embedded updates registered with EAS Update for this project';
static override flags = {
platform: Flags.option({
char: 'p',
description: 'Filter by platform',
options: [Platform.IOS, Platform.ANDROID] as const,
})(),
'runtime-version': Flags.string({
description: 'Filter by runtime version',
}),
channel: Flags.string({
description: 'Filter by channel name (pass "all" to skip the channel prompt)',
}),
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_LIMIT, limit: MAX_LIMIT }),
'after-cursor': Flags.string({
description: 'Return items after this cursor (for pagination)',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(UpdateEmbeddedList);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(UpdateEmbeddedList, { nonInteractive });
if (jsonFlag) {
enableJsonOutput();
}
const platform: AppPlatform | undefined = flags.platform
? toAppPlatform(flags.platform)
: undefined;
// Resolve channel filter:
// - `--channel <name>`: use it
// - `--channel all` (or no flag in non-interactive / json): no channel filter
// - no flag in interactive: prompt with the project's channels + "All channels"
let channel: string | undefined;
if (flags.channel) {
channel = flags.channel.toLowerCase() === 'all' ? undefined : flags.channel;
} else if (!nonInteractive && !jsonFlag) {
channel = await promptForChannelAsync(graphqlClient, projectId);
}
const filter =
platform || flags['runtime-version'] || channel
? {
platform,
runtimeVersion: flags['runtime-version'],
channel,
}
: undefined;
const limit = flags.limit ?? DEFAULT_LIMIT;
const connection = await EmbeddedUpdateQuery.viewPaginatedAsync(graphqlClient, {
appId: projectId,
filter,
first: limit,
after: flags['after-cursor'],
});
const embeddedUpdates = connection.edges.map(e => e.node);
if (jsonFlag) {
printJsonOnlyOutput({
embeddedUpdates,
pageInfo: connection.pageInfo,
});
return;
}
if (embeddedUpdates.length === 0) {
Log.log('No embedded updates found.');
return;
}
Log.addNewLineIfNone();
Log.log(
chalk.bold(
`Embedded updates (${embeddedUpdates.length}${connection.pageInfo.hasNextPage ? '+' : ''}):`
)
);
Log.newLine();
Log.log(embeddedUpdates.map(formatEmbeddedUpdateRow).join(`\n\n${chalk.dim('———')}\n\n`));
if (connection.pageInfo.hasNextPage && connection.pageInfo.endCursor) {
Log.newLine();
Log.log(
chalk.dim(
`Showing ${embeddedUpdates.length}. For the next page, run with --after-cursor ${connection.pageInfo.endCursor}`
)
);
}
}
}
// Sentinel for the "All channels" option. We can't use `undefined` here because
// the underlying `prompts` library substitutes a choice's index when its value
// is undefined, which then leaks into the GraphQL filter.
const ALL_CHANNELS = '__embedded_update_list__all_channels__';
async function promptForChannelAsync(
graphqlClient: ExpoGraphqlClient,
projectId: string
): Promise<string | undefined> {
const channels = await ChannelQuery.viewUpdateChannelsOnAppAsync(graphqlClient, {
appId: projectId,
offset: 0,
limit: CHANNELS_LIMIT,
});
if (channels.length === 0) {
// Nothing to choose from — fall back to listing everything.
return undefined;
}
const selected = await selectAsync<string>('Filter embedded updates by which channel?', [
{ title: 'All channels', value: ALL_CHANNELS },
...channels.map(c => ({ title: c.name, value: c.name })),
]);
return selected === ALL_CHANNELS ? undefined : selected;
}
function formatEmbeddedUpdateRow(embeddedUpdate: EmbeddedUpdateFragment): string {
const createdAt = new Date(embeddedUpdate.createdAt);
return formatFields([
{ label: 'ID', value: embeddedUpdate.id },
{ label: 'Platform', value: embeddedUpdate.platform.toLowerCase() },
{ label: 'Runtime version', value: embeddedUpdate.runtimeVersion },
{ label: 'Channel', value: embeddedUpdate.channel },
{ label: 'Created', value: `${fromNow(createdAt)} ago` },
]);
}
@@ -0,0 +1,95 @@
import { Args, Errors } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../../commandUtils/flags';
import {
EmbeddedUpdateFragment,
EmbeddedUpdateQuery,
isEmbeddedUpdateNotFoundError,
} from '../../../graphql/queries/EmbeddedUpdateQuery';
import Log from '../../../log';
import { fromNow } from '../../../utils/date';
import { formatBytes } from '../../../utils/files';
import formatFields from '../../../utils/formatFields';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
export default class UpdateEmbeddedView extends EasCommand {
static override description = 'view details of an embedded update registered with EAS Update';
static override args = {
id: Args.string({
required: true,
description: 'The ID of the embedded update (manifest UUID from app.manifest).',
}),
};
static override flags = {
...EasJsonOnlyFlag,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const {
args: { id: embeddedUpdateId },
flags: { json: jsonFlag },
} = await this.parse(UpdateEmbeddedView);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(UpdateEmbeddedView, { nonInteractive: true });
if (jsonFlag) {
enableJsonOutput();
}
let embeddedUpdate;
try {
embeddedUpdate = await EmbeddedUpdateQuery.viewByIdAsync(graphqlClient, {
embeddedUpdateId,
appId: projectId,
});
} catch (e: unknown) {
if (isEmbeddedUpdateNotFoundError(e)) {
Errors.error(
`No embedded update found with id "${embeddedUpdateId}" for this project. ` +
`Run "eas update:embedded:list" to see the embedded updates registered for this app.`,
{ exit: 1 }
);
}
throw e;
}
if (jsonFlag) {
printJsonOnlyOutput(embeddedUpdate);
return;
}
Log.addNewLineIfNone();
Log.log(chalk.bold('Embedded update:'));
Log.log(formatEmbeddedUpdate(embeddedUpdate));
}
}
export function formatEmbeddedUpdate(embeddedUpdate: EmbeddedUpdateFragment): string {
const createdAt = new Date(embeddedUpdate.createdAt);
const bundleSize =
embeddedUpdate.launchAsset.finalFileSize ?? embeddedUpdate.launchAsset.fileSize;
return formatFields([
{ label: 'ID', value: embeddedUpdate.id },
{ label: 'Platform', value: embeddedUpdate.platform.toLowerCase() },
{ label: 'Runtime version', value: embeddedUpdate.runtimeVersion },
{ label: 'Channel', value: embeddedUpdate.channel },
{ label: 'Bundle size', value: formatBytes(bundleSize) },
{ label: 'Bundle SHA-256', value: embeddedUpdate.launchAsset.fileSHA256 },
{
label: 'Created at',
value: `${createdAt.toLocaleString()} (${fromNow(createdAt)} ago)`,
},
]);
}
+354 -4
View File
@@ -1459,6 +1459,7 @@ export type App = Project & {
description: Scalars['String']['output'];
devDomainName?: Maybe<AppDevDomainName>;
deviceRunSessionsPaginated: AppDeviceRunSessionsConnection;
embeddedUpdatesPaginated: AppEmbeddedUpdatesConnection;
/** Environment secrets for an app */
environmentSecrets: Array<EnvironmentSecret>;
environmentVariableEnvironments: Array<Scalars['EnvironmentVariableEnvironment']['output']>;
@@ -1607,6 +1608,8 @@ export type App = Project & {
workerDeploymentsCrashes?: Maybe<WorkerDeploymentCrashes>;
workerDeploymentsRequest: WorkerDeploymentRequestEdge;
workerDeploymentsRequests?: Maybe<WorkerDeploymentRequests>;
workflowDeviceTestCaseHistory: WorkflowDeviceTestCaseHistory;
workflowDeviceTestCaseInsights: WorkflowDeviceTestCaseInsights;
workflowRunGitBranchesPaginated: AppWorkflowRunGitBranchesConnection;
workflowRunsPaginated: AppWorkflowRunsConnection;
workflows: Array<Workflow>;
@@ -1697,6 +1700,16 @@ export type AppDeviceRunSessionsPaginatedArgs = {
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppEmbeddedUpdatesPaginatedArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<EmbeddedUpdateFilterInput>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppEnvironmentSecretsArgs = {
filterNames?: InputMaybe<Array<Scalars['String']['input']>>;
@@ -1921,6 +1934,21 @@ export type AppWorkerDeploymentsRequestsArgs = {
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppWorkflowDeviceTestCaseHistoryArgs = {
filters?: InputMaybe<WorkflowDeviceTestCaseHistoryFiltersInput>;
path: Scalars['String']['input'];
timespan: WorkflowDeviceTestCaseInsightsTimespanInput;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppWorkflowDeviceTestCaseInsightsArgs = {
filters?: InputMaybe<WorkflowDeviceTestCaseInsightsFiltersInput>;
timespan: WorkflowDeviceTestCaseInsightsTimespanInput;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppWorkflowRunGitBranchesPaginatedArgs = {
after?: InputMaybe<Scalars['String']['input']>;
@@ -2027,6 +2055,18 @@ export type AppDeviceRunSessionsConnection = {
pageInfo: PageInfo;
};
export type AppEmbeddedUpdateEdge = {
__typename?: 'AppEmbeddedUpdateEdge';
cursor: Scalars['String']['output'];
node: EmbeddedUpdate;
};
export type AppEmbeddedUpdatesConnection = {
__typename?: 'AppEmbeddedUpdatesConnection';
edges: Array<AppEmbeddedUpdateEdge>;
pageInfo: PageInfo;
};
export type AppFingerprintEdge = {
__typename?: 'AppFingerprintEdge';
cursor: Scalars['String']['output'];
@@ -2366,6 +2406,7 @@ export type AppObserveCustomEvent = {
eventName: Scalars['String']['output'];
expoSdkVersion?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
ingestedAt?: Maybe<Scalars['DateTime']['output']>;
properties: Array<AppObserveEventProperty>;
reactNativeVersion?: Maybe<Scalars['String']['output']>;
sessionId?: Maybe<Scalars['String']['output']>;
@@ -2463,6 +2504,7 @@ export type AppObserveCustomEventPropertyFilter = {
export type AppObserveEvent = {
__typename?: 'AppObserveEvent';
appBuildNumber: Scalars['String']['output'];
appEasBuildId?: Maybe<Scalars['String']['output']>;
appIdentifier: Scalars['String']['output'];
appName: Scalars['String']['output'];
appUpdateId?: Maybe<Scalars['String']['output']>;
@@ -5063,6 +5105,11 @@ export type DeleteDiscordUserResult = {
id: Scalars['ID']['output'];
};
export type DeleteEmbeddedUpdateResult = {
__typename?: 'DeleteEmbeddedUpdateResult';
id: Scalars['ID']['output'];
};
export type DeleteEnvironmentSecretResult = {
__typename?: 'DeleteEnvironmentSecretResult';
id: Scalars['ID']['output'];
@@ -6176,8 +6223,20 @@ export type EmbeddedUpdateAssetUploadSpec = {
storageKey: Scalars['String']['output'];
};
export type EmbeddedUpdateFilterInput = {
channel?: InputMaybe<Scalars['String']['input']>;
platform?: InputMaybe<AppPlatform>;
runtimeVersion?: InputMaybe<Scalars['String']['input']>;
};
export type EmbeddedUpdateMutation = {
__typename?: 'EmbeddedUpdateMutation';
/**
* Delete an embedded update by id. Best-effort: deleting an unknown id succeeds
* (mirrors background deletion jobs). The linked asset row and underlying GCS
* object are cleaned up via the entity's afterDelete trigger chain.
*/
deleteEmbeddedUpdate: DeleteEmbeddedUpdateResult;
/**
* Register an embedded bundle as the launch asset for a given app/platform/channel.
* Returns EMBEDDED_UPDATE_ASSET_NOT_AVAILABLE if the asset has not been finalized yet,
@@ -6187,10 +6246,31 @@ export type EmbeddedUpdateMutation = {
};
export type EmbeddedUpdateMutationDeleteEmbeddedUpdateArgs = {
id: Scalars['ID']['input'];
};
export type EmbeddedUpdateMutationUploadEmbeddedUpdateArgs = {
input: UploadEmbeddedUpdateInput;
};
export type EmbeddedUpdateQuery = {
__typename?: 'EmbeddedUpdateQuery';
/**
* Look up an embedded update by its id and the owning app's id.
* Throws EMBEDDED_UPDATE_NOT_FOUND when no embedded update with this id exists on
* the given app within the caller's account.
*/
byId: EmbeddedUpdate;
};
export type EmbeddedUpdateQueryByIdArgs = {
appId: Scalars['ID']['input'];
embeddedUpdateId: Scalars['ID']['input'];
};
export enum EntityTypeName {
AccountEntity = 'AccountEntity',
AccountSsoConfigurationEntity = 'AccountSSOConfigurationEntity',
@@ -7476,7 +7556,7 @@ export type JobRun = {
isWaived: Scalars['Boolean']['output'];
logFileUrls: Array<Scalars['String']['output']>;
/** Max run time in seconds for this job run. */
maxRunTimeSeconds?: Maybe<Scalars['Int']['output']>;
maxRunTimeSeconds: Scalars['Int']['output'];
name: Scalars['String']['output'];
priority: JobRunPriority;
/** String describing the worker profile used to run this job run. */
@@ -8520,6 +8600,7 @@ export type RootQuery = {
echoProject: EchoProjectQuery;
/** Top-level query object for querying Echo versions. */
echoVersion: EchoVersionQuery;
embeddedUpdates: EmbeddedUpdateQuery;
/** Top-level query object for querying Experimentation configuration. */
experimentation: ExperimentationQuery;
expoGoBuild: ExpoGoBuildQuery;
@@ -8776,7 +8857,7 @@ export type SsoUser = Actor & UserActor & {
preferences: UserPreferences;
/** Associated accounts */
primaryAccount: Account;
primaryAccountProfileImageUrl?: Maybe<Scalars['String']['output']>;
primaryAccountProfileImageUrl: Scalars['String']['output'];
/** @deprecated Use primaryAccountProfileImageUrl instead */
profilePhoto: Scalars['String']['output'];
/** Snacks associated with this account */
@@ -10062,7 +10143,7 @@ export type User = Actor & UserActor & {
preferences: UserPreferences;
/** Associated accounts */
primaryAccount: Account;
primaryAccountProfileImageUrl?: Maybe<Scalars['String']['output']>;
primaryAccountProfileImageUrl: Scalars['String']['output'];
/** @deprecated Use primaryAccountProfileImageUrl instead */
profilePhoto: Scalars['String']['output'];
/** Get all certified second factor authentication methods */
@@ -10166,7 +10247,7 @@ export type UserActor = {
preferences: UserPreferences;
/** Associated accounts */
primaryAccount: Account;
primaryAccountProfileImageUrl?: Maybe<Scalars['String']['output']>;
primaryAccountProfileImageUrl: Scalars['String']['output'];
/** @deprecated Use primaryAccountProfileImageUrl instead */
profilePhoto: Scalars['String']['output'];
/** Snacks associated with this user's personal account */
@@ -10221,6 +10302,8 @@ export type UserActorPublicData = {
firstName?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
lastName?: Maybe<Scalars['String']['output']>;
primaryAccountProfileImageUrl: Scalars['String']['output'];
/** @deprecated Use primaryAccountProfileImageUrl instead */
profilePhoto: Scalars['String']['output'];
/** Snacks associated with this user's personal account */
snacks: Array<Snack>;
@@ -11367,6 +11450,187 @@ export enum WorkflowArtifactStorageType {
R2 = 'R2'
}
/**
* Grouping key from the [shard N] prefix-stripped error_message. `count` is
* computed with uniqExact(test_case_result_id) so RMT pre-merge duplicates do
* not inflate it. Scope: scans ALL status='failed' rows (NOT just
* is_final_attempt=1) so failures from runs that ultimately passed on retry
* still surface.
*/
export type WorkflowDeviceTestCaseErrorPattern = {
__typename?: 'WorkflowDeviceTestCaseErrorPattern';
count: Scalars['Int']['output'];
lastSeenAt: Scalars['DateTime']['output'];
patternKey: Scalars['String']['output'];
sampleMessage: Scalars['String']['output'];
};
export type WorkflowDeviceTestCaseHistory = {
__typename?: 'WorkflowDeviceTestCaseHistory';
errorPatterns: Array<WorkflowDeviceTestCaseErrorPattern>;
recentRuns: WorkflowDeviceTestCaseRecentRunConnection;
timeSeries: Array<WorkflowDeviceTestCaseInsightsBucket>;
totals: WorkflowDeviceTestCaseInsightsTotals;
};
export type WorkflowDeviceTestCaseHistoryErrorPatternsArgs = {
first: Scalars['Int']['input'];
};
export type WorkflowDeviceTestCaseHistoryRecentRunsArgs = {
after?: InputMaybe<Scalars['String']['input']>;
first: Scalars['Int']['input'];
};
export type WorkflowDeviceTestCaseHistoryTimeSeriesArgs = {
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
};
export type WorkflowDeviceTestCaseHistoryFiltersInput = {
gitRefs?: InputMaybe<Array<Scalars['String']['input']>>;
workflowIds?: InputMaybe<Array<Scalars['ID']['input']>>;
};
export type WorkflowDeviceTestCaseInsights = {
__typename?: 'WorkflowDeviceTestCaseInsights';
facets: WorkflowDeviceTestCaseInsightsFacets;
tests: WorkflowDeviceTestCaseStatConnection;
timeSeries: Array<WorkflowDeviceTestCaseInsightsBucket>;
totals: WorkflowDeviceTestCaseInsightsTotals;
};
export type WorkflowDeviceTestCaseInsightsTestsArgs = {
after?: InputMaybe<Scalars['String']['input']>;
first: Scalars['Int']['input'];
search?: InputMaybe<Scalars['String']['input']>;
sortDirection?: InputMaybe<WorkflowDeviceTestCaseSortDirection>;
sortField?: InputMaybe<WorkflowDeviceTestCaseStatSortField>;
};
export type WorkflowDeviceTestCaseInsightsTimeSeriesArgs = {
granularity: WorkflowDeviceTestCaseInsightsTimeSeriesGranularity;
};
/**
* Mutually exclusive bucket counts (passedClean + flaky + failed = totalRuns for the bucket).
* Buckets are aligned to the UTC start of the requested granularity interval
* (minute / hour / day).
*/
export type WorkflowDeviceTestCaseInsightsBucket = {
__typename?: 'WorkflowDeviceTestCaseInsightsBucket';
bucketStartAt: Scalars['DateTime']['output'];
failed: Scalars['Int']['output'];
flaky: Scalars['Int']['output'];
passedClean: Scalars['Int']['output'];
};
export type WorkflowDeviceTestCaseInsightsFacets = {
__typename?: 'WorkflowDeviceTestCaseInsightsFacets';
gitRefs: Array<Scalars['String']['output']>;
tags: Array<Scalars['String']['output']>;
workflows: Array<WorkflowDeviceTestCaseWorkflowFacet>;
};
export type WorkflowDeviceTestCaseInsightsFiltersInput = {
gitRefs?: InputMaybe<Array<Scalars['String']['input']>>;
statuses?: InputMaybe<Array<WorkflowDeviceTestCaseStatusFilter>>;
tags?: InputMaybe<Array<Scalars['String']['input']>>;
workflowIds?: InputMaybe<Array<Scalars['ID']['input']>>;
};
/**
* A count metric returned for the current window AND the equivalent prior window
* (start − duration → start). The frontend uses (current, previous) to compute
* trend deltas. Trend ratios are NOT pre-computed server-side — see
* WorkflowsInsightsMetric for the equivalent convention.
*
* Float (not Int) to match WorkflowsInsightsMetric and avoid the GraphQL Int32
* ceiling: 90-day uniqExact counts on a high-volume project can plausibly
* exceed 2.1B. Values are integer-valued; the frontend reads them as JS numbers.
*/
export type WorkflowDeviceTestCaseInsightsMetric = {
__typename?: 'WorkflowDeviceTestCaseInsightsMetric';
currentValue: Scalars['Float']['output'];
previousValue: Scalars['Float']['output'];
};
/**
* Same shape as WorkflowDeviceTestCaseInsightsMetric but nullable on both sides —
* used for metrics like avgDurationMs / p90DurationMs where "no data in the
* window" is meaningful and must not collapse to 0.
*/
export type WorkflowDeviceTestCaseInsightsNullableMetric = {
__typename?: 'WorkflowDeviceTestCaseInsightsNullableMetric';
currentValue?: Maybe<Scalars['Float']['output']>;
previousValue?: Maybe<Scalars['Float']['output']>;
};
export enum WorkflowDeviceTestCaseInsightsTimeSeriesGranularity {
Day = 'DAY',
Hour = 'HOUR',
Minute = 'MINUTE'
}
export type WorkflowDeviceTestCaseInsightsTimespanInput = {
end: Scalars['DateTime']['input'];
start: Scalars['DateTime']['input'];
};
/**
* Raw counts only (paired with previous-window values). Pass rate, flake rate,
* and trends are computed in the frontend from the (currentValue, previousValue)
* pair on each metric.
*
* totalRuns = passedCleanCount + flakyCount + failedCount (over is_final_attempt=1 rows).
* distinctFlakyTestCount cannot be derived in the frontend from per-test rows
* because pagination means the frontend doesn't see all test paths.
*/
export type WorkflowDeviceTestCaseInsightsTotals = {
__typename?: 'WorkflowDeviceTestCaseInsightsTotals';
avgDurationMs: WorkflowDeviceTestCaseInsightsNullableMetric;
distinctFlakyTestCount: WorkflowDeviceTestCaseInsightsMetric;
failedCount: WorkflowDeviceTestCaseInsightsMetric;
flakyCount: WorkflowDeviceTestCaseInsightsMetric;
p90DurationMs: WorkflowDeviceTestCaseInsightsNullableMetric;
passedCleanCount: WorkflowDeviceTestCaseInsightsMetric;
totalRuns: WorkflowDeviceTestCaseInsightsMetric;
};
/**
* One row per execution (is_final_attempt=1). The is_flaky boolean drives the
* FLAKY pill — no per-attempt timeline is exposed in v1.
*/
export type WorkflowDeviceTestCaseRecentRun = {
__typename?: 'WorkflowDeviceTestCaseRecentRun';
commitSha?: Maybe<Scalars['String']['output']>;
createdAt: Scalars['DateTime']['output'];
durationMs?: Maybe<Scalars['Int']['output']>;
gitRef?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
isFlaky: Scalars['Boolean']['output'];
status: WorkflowDeviceTestCaseStatus;
workflowRunId: Scalars['ID']['output'];
workflowRunName: Scalars['String']['output'];
};
export type WorkflowDeviceTestCaseRecentRunConnection = {
__typename?: 'WorkflowDeviceTestCaseRecentRunConnection';
edges: Array<WorkflowDeviceTestCaseRecentRunEdge>;
pageInfo: PageInfo;
totalCount: Scalars['Int']['output'];
};
export type WorkflowDeviceTestCaseRecentRunEdge = {
__typename?: 'WorkflowDeviceTestCaseRecentRunEdge';
cursor: Scalars['String']['output'];
node: WorkflowDeviceTestCaseRecentRun;
};
/** A device test case result from a Maestro test execution. */
export type WorkflowDeviceTestCaseResult = {
__typename?: 'WorkflowDeviceTestCaseResult';
@@ -11420,12 +11684,73 @@ export type WorkflowDeviceTestCaseResultMutationCreateWorkflowDeviceTestCaseResu
input: CreateWorkflowDeviceTestCaseResultsInput;
};
export enum WorkflowDeviceTestCaseSortDirection {
Asc = 'ASC',
Desc = 'DESC'
}
export type WorkflowDeviceTestCaseStat = {
__typename?: 'WorkflowDeviceTestCaseStat';
avgDurationMs?: Maybe<Scalars['Int']['output']>;
failedCount: Scalars['Int']['output'];
flakyCount: Scalars['Int']['output'];
lastRunAt: Scalars['DateTime']['output'];
lastRunIsFlaky: Scalars['Boolean']['output'];
lastRunStatus: WorkflowDeviceTestCaseStatus;
name: Scalars['String']['output'];
p90DurationMs?: Maybe<Scalars['Int']['output']>;
passedCleanCount: Scalars['Int']['output'];
path: Scalars['String']['output'];
totalRuns: Scalars['Int']['output'];
};
export type WorkflowDeviceTestCaseStatConnection = {
__typename?: 'WorkflowDeviceTestCaseStatConnection';
edges: Array<WorkflowDeviceTestCaseStatEdge>;
pageInfo: PageInfo;
totalCount: Scalars['Int']['output'];
};
export type WorkflowDeviceTestCaseStatEdge = {
__typename?: 'WorkflowDeviceTestCaseStatEdge';
cursor: Scalars['String']['output'];
node: WorkflowDeviceTestCaseStat;
};
export enum WorkflowDeviceTestCaseStatSortField {
Fails = 'FAILS',
Flakes = 'FLAKES',
FlakeRate = 'FLAKE_RATE',
LastRun = 'LAST_RUN',
P90Duration = 'P90_DURATION',
PassRate = 'PASS_RATE',
Runs = 'RUNS'
}
/** Status of a device test case execution. */
export enum WorkflowDeviceTestCaseStatus {
Failed = 'FAILED',
Passed = 'PASSED'
}
/**
* Mutually exclusive — matches the chart bucket semantics (passedClean / flaky / failed).
* PASSED_CLEAN = is_final_attempt=1 AND status=passed AND is_flaky=0
* FLAKY = is_final_attempt=1 AND is_flaky=1 (still a pass, just with retries)
* FAILED = is_final_attempt=1 AND status=failed
*/
export enum WorkflowDeviceTestCaseStatusFilter {
Failed = 'FAILED',
Flaky = 'FLAKY',
PassedClean = 'PASSED_CLEAN'
}
export type WorkflowDeviceTestCaseWorkflowFacet = {
__typename?: 'WorkflowDeviceTestCaseWorkflowFacet';
id: Scalars['ID']['output'];
name: Scalars['String']['output'];
};
export type WorkflowJob = {
__typename?: 'WorkflowJob';
allDeviceTestCaseResults: Array<WorkflowDeviceTestCaseResult>;
@@ -12530,6 +12855,13 @@ export type UploadEmbeddedUpdateMutationVariables = Exact<{
export type UploadEmbeddedUpdateMutation = { __typename?: 'RootMutation', embeddedUpdate: { __typename?: 'EmbeddedUpdateMutation', uploadEmbeddedUpdate: { __typename?: 'EmbeddedUpdate', id: string, platform: AppPlatform, runtimeVersion: string, channel: string, createdAt: any } } };
export type DeleteEmbeddedUpdateMutationVariables = Exact<{
id: Scalars['ID']['input'];
}>;
export type DeleteEmbeddedUpdateMutation = { __typename?: 'RootMutation', embeddedUpdate: { __typename?: 'EmbeddedUpdateMutation', deleteEmbeddedUpdate: { __typename?: 'DeleteEmbeddedUpdateResult', id: string } } };
export type CreateEnvironmentSecretForAccountMutationVariables = Exact<{
input: CreateEnvironmentSecretInput;
accountId: Scalars['String']['input'];
@@ -13029,6 +13361,24 @@ export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
export type DeviceRunSessionsByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, deviceRunSessionsPaginated: { __typename?: 'AppDeviceRunSessionsConnection', edges: Array<{ __typename?: 'AppDeviceRunSessionEdge', cursor: string, node: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type ViewEmbeddedUpdateByIdQueryVariables = Exact<{
embeddedUpdateId: Scalars['ID']['input'];
appId: Scalars['ID']['input'];
}>;
export type ViewEmbeddedUpdateByIdQuery = { __typename?: 'RootQuery', embeddedUpdates: { __typename?: 'EmbeddedUpdateQuery', byId: { __typename?: 'EmbeddedUpdate', id: string, platform: AppPlatform, runtimeVersion: string, channel: string, createdAt: any, launchAsset: { __typename?: 'EmbeddedUpdateAsset', id: string, fileSize: number, finalFileSize?: number | null, fileSHA256: string } } } };
export type ViewEmbeddedUpdatesPaginatedQueryVariables = Exact<{
appId: Scalars['String']['input'];
first: Scalars['Int']['input'];
after?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<EmbeddedUpdateFilterInput>;
}>;
export type ViewEmbeddedUpdatesPaginatedQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, embeddedUpdatesPaginated: { __typename?: 'AppEmbeddedUpdatesConnection', edges: Array<{ __typename?: 'AppEmbeddedUpdateEdge', cursor: string, node: { __typename?: 'EmbeddedUpdate', id: string, platform: AppPlatform, runtimeVersion: string, channel: string, createdAt: any, launchAsset: { __typename?: 'EmbeddedUpdateAsset', id: string, fileSize: number, finalFileSize?: number | null, fileSHA256: string } } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type EnvironmentSecretsByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
}>;
@@ -4,6 +4,8 @@ import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
DeleteEmbeddedUpdateMutation,
DeleteEmbeddedUpdateMutationVariables,
UploadEmbeddedUpdateInput,
UploadEmbeddedUpdateMutation,
UploadEmbeddedUpdateMutationVariables,
@@ -61,4 +63,27 @@ export const EmbeddedUpdateMutation = {
);
return data.embeddedUpdate.uploadEmbeddedUpdate;
},
async deleteEmbeddedUpdateAsync(
graphqlClient: ExpoGraphqlClient,
{ id }: { id: string }
): Promise<{ id: string }> {
const data = await withErrorHandlingAsync(
graphqlClient
.mutation<DeleteEmbeddedUpdateMutation, DeleteEmbeddedUpdateMutationVariables>(
gql`
mutation DeleteEmbeddedUpdate($id: ID!) {
embeddedUpdate {
deleteEmbeddedUpdate(id: $id) {
id
}
}
}
`,
{ id }
)
.toPromise()
);
return data.embeddedUpdate.deleteEmbeddedUpdate;
},
};
@@ -97,3 +97,27 @@ describe('EmbeddedUpdateMutation.uploadEmbeddedUpdateAsync', () => {
expect(result).toEqual(expected);
});
});
describe('EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync', () => {
it('returns the deleted id from the GraphQL response', async () => {
const client = makeGraphqlClient({
embeddedUpdate: { deleteEmbeddedUpdate: { id: 'update-1' } },
});
const result = await EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync(client, {
id: 'update-1',
});
expect(result).toEqual({ id: 'update-1' });
});
it('passes the id through to the mutation variables', async () => {
const client = makeGraphqlClient({
embeddedUpdate: { deleteEmbeddedUpdate: { id: 'update-2' } },
});
await EmbeddedUpdateMutation.deleteEmbeddedUpdateAsync(client, { id: 'update-2' });
expect(client.mutation).toHaveBeenCalledWith(expect.anything(), { id: 'update-2' });
});
});
@@ -0,0 +1,118 @@
import { CombinedError } from '@urql/core';
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { Connection } from '../../utils/relay';
import {
EmbeddedUpdateFilterInput,
ViewEmbeddedUpdateByIdQuery,
ViewEmbeddedUpdateByIdQueryVariables,
ViewEmbeddedUpdatesPaginatedQuery,
ViewEmbeddedUpdatesPaginatedQueryVariables,
} from '../generated';
import { withErrorHandlingAsync } from '../client';
export function isEmbeddedUpdateNotFoundError(error: unknown): boolean {
return (
error instanceof CombinedError &&
error.graphQLErrors.some(e => e.extensions?.['errorCode'] === 'EMBEDDED_UPDATE_NOT_FOUND')
);
}
export type EmbeddedUpdateFragment = ViewEmbeddedUpdateByIdQuery['embeddedUpdates']['byId'];
export type EmbeddedUpdateFilter = EmbeddedUpdateFilterInput;
export const EmbeddedUpdateQuery = {
async viewByIdAsync(
graphqlClient: ExpoGraphqlClient,
{ embeddedUpdateId, appId }: { embeddedUpdateId: string; appId: string }
): Promise<EmbeddedUpdateFragment> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<ViewEmbeddedUpdateByIdQuery, ViewEmbeddedUpdateByIdQueryVariables>(
gql`
query ViewEmbeddedUpdateById($embeddedUpdateId: ID!, $appId: ID!) {
embeddedUpdates {
byId(embeddedUpdateId: $embeddedUpdateId, appId: $appId) {
id
platform
runtimeVersion
channel
createdAt
launchAsset {
id
fileSize
finalFileSize
fileSHA256
}
}
}
}
`,
{ embeddedUpdateId, appId },
{ additionalTypenames: ['EmbeddedUpdate'] }
)
.toPromise()
);
return data.embeddedUpdates.byId;
},
async viewPaginatedAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
filter,
first,
after,
}: { appId: string; filter?: EmbeddedUpdateFilter; first: number; after?: string }
): Promise<Connection<EmbeddedUpdateFragment>> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<ViewEmbeddedUpdatesPaginatedQuery, ViewEmbeddedUpdatesPaginatedQueryVariables>(
gql`
query ViewEmbeddedUpdatesPaginated(
$appId: String!
$first: Int!
$after: String
$filter: EmbeddedUpdateFilterInput
) {
app {
byId(appId: $appId) {
id
embeddedUpdatesPaginated(first: $first, after: $after, filter: $filter) {
edges {
cursor
node {
id
platform
runtimeVersion
channel
createdAt
launchAsset {
id
fileSize
finalFileSize
fileSHA256
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
}
`,
{ appId, first, after, filter },
{ additionalTypenames: ['EmbeddedUpdate'] }
)
.toPromise()
);
return data.app.byId.embeddedUpdatesPaginated;
},
};
@@ -0,0 +1,178 @@
import { CombinedError } from '@urql/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppPlatform } from '../../generated';
import { EmbeddedUpdateQuery, isEmbeddedUpdateNotFoundError } from '../EmbeddedUpdateQuery';
function makeGraphqlClient(data: unknown): ExpoGraphqlClient {
return {
query: jest.fn().mockReturnValue({
toPromise: jest.fn().mockResolvedValue({ data }),
}),
} as unknown as ExpoGraphqlClient;
}
function makeCombinedError(errorCode: string): CombinedError {
return new CombinedError({
graphQLErrors: [{ message: 'error', extensions: { errorCode } }],
});
}
describe('isEmbeddedUpdateNotFoundError', () => {
it('returns true for CombinedError with EMBEDDED_UPDATE_NOT_FOUND errorCode', () => {
expect(isEmbeddedUpdateNotFoundError(makeCombinedError('EMBEDDED_UPDATE_NOT_FOUND'))).toBe(
true
);
});
it('returns false for CombinedError with a different errorCode', () => {
expect(isEmbeddedUpdateNotFoundError(makeCombinedError('SOME_OTHER_ERROR'))).toBe(false);
});
it('returns false for CombinedError with no extensions', () => {
expect(
isEmbeddedUpdateNotFoundError(new CombinedError({ graphQLErrors: [{ message: 'oops' }] }))
).toBe(false);
});
it('returns false for a plain Error', () => {
expect(isEmbeddedUpdateNotFoundError(new Error('plain error'))).toBe(false);
});
it('returns false for null and non-error values', () => {
expect(isEmbeddedUpdateNotFoundError(null)).toBe(false);
expect(isEmbeddedUpdateNotFoundError('string')).toBe(false);
expect(isEmbeddedUpdateNotFoundError(42)).toBe(false);
});
});
describe('EmbeddedUpdateQuery.viewByIdAsync', () => {
it('returns the embedded update from the GraphQL response', async () => {
const expected = {
id: 'update-1',
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2024-01-01T00:00:00Z',
launchAsset: {
id: 'asset-1',
fileSize: 1024,
finalFileSize: 768,
fileSHA256: 'abc123',
},
};
const client = makeGraphqlClient({
embeddedUpdates: { byId: expected },
});
const result = await EmbeddedUpdateQuery.viewByIdAsync(client, {
embeddedUpdateId: 'update-1',
appId: 'app-1',
});
expect(result).toEqual(expected);
});
it('passes the embeddedUpdateId and appId to the query', async () => {
const client = makeGraphqlClient({
embeddedUpdates: {
byId: {
id: 'update-1',
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2024-01-01T00:00:00Z',
launchAsset: {
id: 'asset-1',
fileSize: 1024,
finalFileSize: null,
fileSHA256: 'abc',
},
},
},
});
await EmbeddedUpdateQuery.viewByIdAsync(client, {
embeddedUpdateId: 'update-1',
appId: 'app-1',
});
expect(client.query).toHaveBeenCalledWith(
expect.anything(),
{ embeddedUpdateId: 'update-1', appId: 'app-1' },
expect.objectContaining({ additionalTypenames: ['EmbeddedUpdate'] })
);
});
});
describe('EmbeddedUpdateQuery.viewPaginatedAsync', () => {
function makeConnection(): {
edges: { cursor: string; node: any }[];
pageInfo: {
hasNextPage: boolean;
hasPreviousPage: boolean;
startCursor: string | null;
endCursor: string | null;
};
} {
return {
edges: [
{
cursor: 'c1',
node: {
id: 'update-1',
platform: AppPlatform.Ios,
runtimeVersion: '1.0.0',
channel: 'production',
createdAt: '2024-01-01T00:00:00Z',
launchAsset: {
id: 'asset-1',
fileSize: 1024,
finalFileSize: null,
fileSHA256: 'abc',
},
},
},
],
pageInfo: { hasNextPage: false, hasPreviousPage: false, startCursor: 'c1', endCursor: 'c1' },
};
}
it('returns the connection from the GraphQL response', async () => {
const connection = makeConnection();
const client = makeGraphqlClient({
app: { byId: { id: 'app-1', embeddedUpdatesPaginated: connection } },
});
const result = await EmbeddedUpdateQuery.viewPaginatedAsync(client, {
appId: 'app-1',
first: 25,
});
expect(result).toEqual(connection);
});
it('forwards filter / first / after to the query variables', async () => {
const client = makeGraphqlClient({
app: { byId: { id: 'app-1', embeddedUpdatesPaginated: makeConnection() } },
});
await EmbeddedUpdateQuery.viewPaginatedAsync(client, {
appId: 'app-1',
first: 10,
after: 'cursor-99',
filter: { platform: AppPlatform.Ios, runtimeVersion: '1.0.0', channel: 'production' },
});
expect(client.query).toHaveBeenCalledWith(
expect.anything(),
{
appId: 'app-1',
first: 10,
after: 'cursor-99',
filter: { platform: AppPlatform.Ios, runtimeVersion: '1.0.0', channel: 'production' },
},
expect.objectContaining({ additionalTypenames: ['EmbeddedUpdate'] })
);
});
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "20.0.0",
"version": "20.1.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+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
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "20.0.0",
"version": "20.1.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,8 +26,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "20.0.0",
"@expo/eas-build-job": "20.0.0",
"@expo/build-tools": "20.1.0",
"@expo/eas-build-job": "20.1.0",
"@expo/logger": "20.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "20.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "20.0.0",
"version": "20.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,7 +27,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"@expo/logger": "20.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "20.0.0",
"version": "20.1.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,16 +19,16 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "20.0.0",
"@expo/build-tools": "20.1.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "20.0.0",
"@expo/eas-build-job": "20.0.0",
"@expo/eas-build-job": "20.1.0",
"@expo/logger": "20.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "20.0.0",
"@expo/steps": "20.1.0",
"@expo/template-file": "20.0.0",
"@expo/turtle-spawn": "20.0.0",
"@hapi/boom": "10.0.1",
@@ -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;
}
}
+3 -3
View File
@@ -136,14 +136,14 @@ describe('BuildService Datadog setup', () => {
expect(datadogSetupMock).toHaveBeenCalledWith({
expoApiV2BaseUrl: expect.any(String),
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'build', turtleBuildId: 'build-id' },
});
expect(createBuildContextMock).toHaveBeenCalled();
expect(datadogFlushAsyncMock).toHaveBeenCalled();
});
it('configures Datadog for non-platform jobs with the turtle build id', async () => {
it('configures Datadog for jobRun jobs with the turtle job run id', async () => {
const service = new BuildService();
service.checkForHangingWorker = jest.fn(async () => {});
@@ -158,8 +158,8 @@ describe('BuildService Datadog setup', () => {
expect(datadogSetupMock).toHaveBeenCalledWith({
expoApiV2BaseUrl: expect.any(String),
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
target: { kind: 'jobRun', turtleJobRunId: 'build-id' },
});
expect(createBuildContextMock).toHaveBeenCalled();
});
+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')
);
+5 -1
View File
@@ -283,8 +283,12 @@ export default class BuildService {
if (robotAccessToken) {
Datadog.setup({
expoApiV2BaseUrl: config.wwwApiV2BaseUrl,
turtleBuildId: this.buildId,
robotAccessToken,
// job.platform is set only for build jobs; for a jobRun, this.buildId is the
// turtle-job-run id (verified by the jobType === 'jobRun' guard in startBuild()).
target: job.platform
? { kind: 'build', turtleBuildId: this.buildId }
: { kind: 'jobRun', turtleJobRunId: this.buildId },
});
}
+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
+36 -65
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"
@@ -2856,22 +2856,22 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:20.0.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:20.1.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:20.0.0"
"@expo/eas-build-job": "npm:20.0.0"
"@expo/eas-build-job": "npm:20.1.0"
"@expo/env": "npm:^0.4.0"
"@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.0.0"
"@expo/steps": "npm:20.1.0"
"@expo/template-file": "npm:20.0.0"
"@expo/turtle-spawn": "npm:20.0.0"
"@expo/xcpretty": "npm:^4.3.1"
@@ -3113,7 +3113,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:20.0.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:20.1.0, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
@@ -3132,12 +3132,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:20.0.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:20.1.0, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:20.0.0"
"@expo/eas-build-job": "npm:20.1.0"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -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
@@ -3485,11 +3485,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:20.0.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:20.1.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:20.0.0"
"@expo/eas-build-job": "npm:20.1.0"
"@expo/logger": "npm:20.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
@@ -3558,16 +3558,16 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:20.0.0"
"@expo/build-tools": "npm:20.1.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:20.0.0"
"@expo/eas-build-job": "npm:20.0.0"
"@expo/eas-build-job": "npm:20.1.0"
"@expo/logger": "npm:20.0.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:20.0.0"
"@expo/steps": "npm:20.1.0"
"@expo/template-file": "npm:20.0.0"
"@expo/turtle-spawn": "npm:20.0.0"
"@hapi/boom": "npm:10.0.1"
@@ -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
@@ -10488,7 +10487,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:20.0.0"
"@expo/steps": "npm:20.1.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -11101,8 +11100,8 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:20.0.0"
"@expo/eas-build-job": "npm:20.0.0"
"@expo/build-tools": "npm:20.1.0"
"@expo/eas-build-job": "npm:20.1.0"
"@expo/logger": "npm:20.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:20.0.0"
@@ -11159,8 +11158,8 @@ __metadata:
"@expo/code-signing-certificates": "npm:0.0.5"
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/eas-build-job": "npm:20.0.0"
"@expo/eas-json": "npm:20.0.0"
"@expo/eas-build-job": "npm:20.1.0"
"@expo/eas-json": "npm:20.1.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:20.0.0"
@@ -11175,7 +11174,7 @@ __metadata:
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:20.0.0"
"@expo/steps": "npm:20.1.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:5.0.0"
"@graphql-codegen/introspection": "npm:4.0.0"
@@ -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"