Compare commits

...
Author SHA1 Message Date
Stanisław Chmiela 068a56b375 Refine submit config key resolution 2026-04-29 11:02:56 +02:00
Stanisław Chmiela ce4b4d58a6 Refactor resolve submit config 2026-04-28 15:46:52 +02:00
Stanisław Chmiela 639541c12b Add EAS submit config resolution function 2026-04-28 14:50:00 +02:00
Expo CI 900ad765bf update CHANGELOG.md 2026-04-22 17:53:23 +00:00
Expo CI 7c7fad4a13 v18.8.0 2026-04-22 17:50:27 +00:00
Douglas Lowder 7226c72122 [eas-cli] Add customParams support to observe:events (#3620)
# Why

Allow any custom params in Observe events to be retrieved as part of `observe:events` queries, and included in display and JSON output.

# How

Updated the event query and formatter.

# Test Plan

- Tested against projects in production
- Unit tests pass
2026-04-21 11:54:44 -07:00
Ash Wu ae6e0fd7f6 [build-tools] Reuse target_names to avoid duplicate iOS credentials preparation (#3611)
* [build-tools] Reuse target_names to avoid duplicate iOS credentials preparation

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

* clean up

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

* add test

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

* fix import order

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

* address comment

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-04-21 22:32:01 +08:00
Kadi Kraman d070f73bc5 [eas-cli] add update and channel insights (#3614)
* Add error handler to prompt for update required

* Add an update:insights command

* Add an --insights flag to update:view

* Refactor shared util

* Improve naming

* Add a create table helper

* Pull date formating to a util

* Improve readability

* Validate insights params

* Add a test to ensure the table renders

* Add channel insights

* Fix lint

* Add PR to changelog
2026-04-21 10:30:29 +01:00
Ash Wu 325bb29b57 [build-tools] Add EAS_FALLBACK_PACKAGE_MANAGER env var support to package manager resolver (#3608)
* [build-tools][eas-build-job] Add workflow fallback package manager support to build-tools resolver

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

* address comment

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-04-21 17:27:21 +08:00
Ash Wu 82a8e3c2d0 [build-tools] add parseXcactivitylogFunction (#3578)
* [build-tools] add parseXcactivitylogFunction

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

* address comments

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

* address comments

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

* clean up

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

* clean up

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

* clean up

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

* address comment

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-04-21 17:00:22 +08:00
Gabe DebesandStanisław Chmiela ec310dd115 [build-tools] Introduce eas/deploy and eas/export functions (#3598)
* [build-tools] Add eas/deploy function

* format

* Update CHANGELOG.md

Co-authored-by: Stanisław Chmiela <[email protected]>

* Remove no-source-maps, set if not undefined

* [build-tools] Add eas/export and refine eas/deploy for EAS Hosting workflows

- Add eas/export: expo export via runExpoCliCommand and getExportCommand, structured inputs, export_dir output.
- Refine eas/deploy: build argv with getDeployCommand, parse deploy JSON with parseDeploymentOutput, expanded tests.
- Register eas/export in getEasFunctions; update CHANGELOG for both steps.

* build-tools: prefer easd in dev with probe and logging

Probe easd --help when ENVIRONMENT is development; use easd if it
succeeds, otherwise log and fall back to npx eas-cli@staging. Log
when easd is used. Pass logger from easBuildInternal into resolver
so messages appear in build logs.

* build-tools: warn when using easd in dev; no fallback log

Probe easd --help in development; on success log a warn and use easd.
Fall back to npx eas-cli@staging without an info line. Pass logger from
easBuildInternal and runEasCliCommand so the warn appears in logs.

* Fix parsing

* Improved error message

* self review

* Increase test coverage

* Update packages/build-tools/src/steps/functions/export.ts

Co-authored-by: Stanisław Chmiela <[email protected]>

* Update packages/build-tools/src/steps/functions/deploy.ts

Co-authored-by: Stanisław Chmiela <[email protected]>

* Update packages/build-tools/src/steps/functions/deploy.ts

Co-authored-by: Stanisław Chmiela <[email protected]>

* build-tools: align eas/deploy and eas/export with workflow env and Expo CLI

- deploy: stop passing --environment from job metadata; rely on workflow step env
- deploy: parseDeploymentOutput returns {} on failure; always set output providers
- deploy tests: assert env forwarding; drop --environment expectations
- export: use minify, dump_assetmap, ssg inputs (defaults match expo export)
- export: drop local platform allowlist; invalid values fail in expo export

* test(build-tools): cover deploy_url alias fallback without production

Assert deploy_url uses aliases[0].url when production is absent but aliases exist, so the middle tier of the fallback chain is exercised alongside the url-only partial JSON case.

Use camelCase for the dump_assetmap local in the export step (input id unchanged).

* Update default values and checks

* Remove logger from resolveEasCommandPrefixAndEnvAsync

* Fix CI

* revert uneeded change

* test(build-tools): restore ENVIRONMENT after runEasCliCommand tests

Mirror the resolveEasCommandPrefixAndEnvAsync suite so staging does not leak to later tests.

---------

Co-authored-by: Stanisław Chmiela <[email protected]>
2026-04-20 11:47:15 -07:00
Jakub Grzywacz 84b09c4a28 [eas-cli] Fix adding existing capability identifiers (#3615)
# Why

When an iOS app extension target declares App Group entitlements (e.g. `'com.apple.security.application-groups': ['group.com.easwidgets']`) and the app group already exists in the Apple Developer team but is not linked to the target's bundle identifier, credential setup enabled the App Groups capability on the bundle but never attached the group resulting with capability on but 0 groups linked.
```
Current local entitlements:
{
  "com.apple.security.application-groups": [
    "group.com.easwidgets"
  ]
}
Will enable remote capability: com.apple.security.application-groups (App Groups).
Existing to disable:  [ 'FJXGJ28CQS_IN_APP_PURCHASE' ]
Patch Request: [ { capabilityType: 'APP_GROUPS', option: 'ON' } ]
✔ Synced capabilities: Enabled: App Groups
- Syncing capabilities identifiers
No capability identifiers need to be updated
✔ Synced capability identifiers: No updates
```
This is a regression introduced in https://github.com/expo/eas-cli/pull/3088/
Fixes https://github.com/expo/expo/issues/43677

# How

Fetch the bundle via `BundleId.infoAsync` to determine which IDs are already linked to this bundle.
- If it exists and ID is already linked -> skip.
- If it exists but is not linked -> just link.
- If it doesn't exist -> create it, then link (unchanged behavior).

# Test Plan

After this changes:
```
Current local entitlements:
{
  "com.apple.security.application-groups": [
    "group.com.easwidgets"
  ]
}
Will enable remote capability: com.apple.security.application-groups (App Groups).
Existing to disable:  [ '6C659CPDFA_IN_APP_PURCHASE' ]
Patch Request: [ { capabilityType: 'APP_GROUPS', option: 'ON' } ]
✔ Synced capabilities: Enabled: App Groups
- Syncing capabilities identifiers
Updating bundle identifier with capability identifiers: [
  {
    "capabilityType": "APP_GROUPS",
    "option": "ON",
    "relationships": {
      "appGroups": [
        "NW5UULASH8"
      ]
    }
  }
]
✔ Synced capability identifiers: Linked: group.com.easwidgets
```
2026-04-20 11:05:33 +02:00
Evan BaconandClaude Opus 4.6 d9338799a0 [eas-cli] Fix metadata:push preview video deletion and improve validation (#3603)
- Delete remote previews when their type is removed from store config
- Warn and skip unknown preview types with APP_ prefix suggestion
- Clear all existing previews before upload to avoid Apple limits
- Fix nock mock type error for axios adapter

Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
2026-04-17 15:15:02 -07:00
Mustafa Abban b22d89e41f [build-tools] remove experimental prefix from gradle cache env var (#3613) 2026-04-16 15:34:28 -04:00
Szymon Świerk 81fe0f237f extend the app_store_connect.build_upload interpolation context (#3599) 2026-04-16 15:50:39 +02:00
Douglas Lowder b9ddc04cf7 [eas-cli] Improve observe:metrics handling of app version update IDs (#3609)
<!-- 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

Currently the output of observe:metrics is unusable if an app version has many updates. Since the metrics are not broken out by update, it makes sense to make two changes:

- Remove update IDs from the display table
- Add the update IDs to the JSON output as an array, in case they are needed as input to other commands, or to AI

Additionally, made a change so that if a project ID is manually passed in, the observe commands do not unnecessarily try to create a new EAS project where none is needed.

# Test Plan

- Unit tests pass
- Tested manually against existing projects with metrics
2026-04-15 12:04:40 -07:00
Expo CI d6a8a4f694 update CHANGELOG.md 2026-04-14 18:51:07 +00:00
91 changed files with 6513 additions and 316 deletions

No files matched your search

+26 -3
View File
@@ -8,13 +8,36 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [build-tools] Add `eas/read_package_json` and `eas/read_app_config` functions ([#3585](https://github.com/expo/eas-cli/pull/3585) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `--skip-bundler` to `eas build:dev` to install and run a development build without starting the local Expo/Metro server. ([#3604](https://github.com/expo/eas-cli/pull/3604) by [@mmichels-brex](https://github.com/mmichels-brex))
### 🐛 Bug fixes
### 🧹 Chores
## [18.8.0](https://github.com/expo/eas-cli/releases/tag/v18.8.0) - 2026-04-22
### 🎉 New features
- [eas-cli] Support custom params in `observe:events` results. ([#3620](https://github.com/expo/eas-cli/pull/3620) by [@douglowder](https://github.com/douglowder))
- [build-tools] Add `eas/deploy` function for EAS Hosting web deployments. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
- [build-tools] Add `eas/export` function for Expo web exports. ([#3598](https://github.com/expo/eas-cli/pull/3598) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas update:insights <groupId>` command to display launch, crash, unique-user, and payload-size metrics for an update group. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `--insights` flag to `eas update:view <groupId>` to append the same metrics below the existing output. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
- [eas-cli] Add `eas channel:insights --channel <name> --runtime-version <version>` command to display adoption and crash metrics for a channel + runtime version. ([#3614](https://github.com/expo/eas-cli/pull/3614) by [@kadikraman](https://github.com/kadikraman))
### 🐛 Bug fixes
- [eas-cli] Fix `metadata:push` not deleting video previews from App Store Connect when removed from config. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Warn and skip unknown preview types in `metadata:push` with a helpful suggestion when the `APP_` screenshot prefix is mistakenly used. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Delete all existing previews in a preview set before uploading to avoid Apple's "Too many app previews" error. ([#3603](https://github.com/expo/eas-cli/pull/3603) by [@EvanBacon](https://github.com/EvanBacon))
- [eas-cli] Fixes for `observe` commands, including an issue for apps with many update IDs. ([#3609](https://github.com/expo/eas-cli/pull/3609) by [@douglowder](https://github.com/douglowder))
- [eas-cli] Add existing capability identifiers. ([#3615](https://github.com/expo/eas-cli/pull/3615) by [@jakex7](https://github.com/jakex7))
## [18.7.0](https://github.com/expo/eas-cli/releases/tag/v18.7.0) - 2026-04-14
### 🎉 New features
- [build-tools] Add `eas/read_package_json` and `eas/read_app_config` functions ([#3585](https://github.com/expo/eas-cli/pull/3585) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `--skip-bundler` to `eas build:dev` to install and run a development build without starting the local Expo/Metro server. ([#3604](https://github.com/expo/eas-cli/pull/3604) by [@mmichels-brex](https://github.com/mmichels-brex))
## [18.6.0](https://github.com/expo/eas-cli/releases/tag/v18.6.0) - 2026-04-10
### 🎉 New features
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "18.7.0",
"version": "18.8.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+4 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "18.7.0",
"version": "18.8.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -38,14 +38,15 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.6.0",
"@expo/eas-build-job": "18.8.0",
"@expo/eas-json": "18.8.0",
"@expo/env": "^0.4.0",
"@expo/logger": "18.5.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": "18.6.0",
"@expo/steps": "18.8.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@expo/xcpretty": "^4.3.1",
+16 -2
View File
@@ -16,6 +16,7 @@ import { runFastlaneGym, runFastlaneResign } from '../ios/fastlane';
import { installPods } from '../ios/pod';
import { downloadApplicationArchiveAsync } from '../ios/resign';
import { resolveArtifactPath, resolveBuildConfiguration, resolveScheme } from '../ios/resolve';
import { parseAndReportXcactivitylog } from '../steps/utils/ios/xcactivitylog';
import { cacheStatsAsync, restoreCcacheAsync } from '../steps/functions/restoreBuildCache';
import { saveCcacheAsync } from '../steps/functions/saveBuildCache';
import { uploadApplicationArchive } from '../utils/artifacts';
@@ -51,6 +52,7 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
const evictUsedBefore = new Date();
const credentialsManager = new CredentialsManager(ctx);
const workingDirectory = ctx.getReactNativeProjectDirectory();
let fastlaneResult: { derivedDataPath: string; workspacePath: string } | undefined;
try {
const credentials = await ctx.runBuildPhase(BuildPhase.PREPARE_CREDENTIALS, async () => {
return await credentialsManager.prepare();
@@ -146,10 +148,10 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
}
await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
const scheme = resolveScheme(ctx);
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
await runFastlaneGym(ctx, {
return await runFastlaneGym(ctx, {
credentials,
scheme,
buildConfiguration,
@@ -171,6 +173,18 @@ async function buildAsync(ctx: BuildContext<Ios.Job>): Promise<void> {
});
}
if (ctx.env.EXPERIMENTAL_EAS_XCACTIVITYLOG === '1') {
const { derivedDataPath, workspacePath } = nullthrows(fastlaneResult);
await ctx.runBuildPhase(BuildPhase.PARSE_XCACTIVITYLOG, async () => {
await parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
logger: ctx.logger,
proxyBaseUrl: ctx.env.EAS_BUILD_COCOAPODS_CACHE_URL,
});
});
}
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
});
@@ -0,0 +1,72 @@
import { BuildJob } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
import { resolveEasCommandPrefixAndEnvAsync } from '../../utils/easCli';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../../utils/easCli', () => ({
...jest.requireActual('../../utils/easCli'),
resolveEasCommandPrefixAndEnvAsync: jest.fn(),
}));
describe('easBuildInternal', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
cmd: 'npx',
args: ['-y', 'eas-cli@latest'],
extraEnv: {},
});
});
it('calls resolveEasCommandPrefixAndEnvAsync in runEasBuildInternalAsync', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const job = {
platform: 'android',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
await expect(
runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
})
).rejects.toThrow('build profile is missing in a build from git-based integration.');
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
it('calls resolveEasCommandPrefixAndEnvAsync in resolveEnvFromBuildProfileAsync', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const ctx = {
logger,
env: {},
job: { platform: 'ios' },
} as any;
await expect(resolveEnvFromBuildProfileAsync(ctx, { cwd: '/tmp/project' })).rejects.toThrow(
'build profile is missing in a build from git-based integration.'
);
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
});
@@ -1,11 +1,4 @@
import {
BuildJob,
EasCliNpmTags,
Env,
Metadata,
sanitizeBuildJob,
sanitizeMetadata,
} from '@expo/eas-build-job';
import { BuildJob, Env, Metadata, sanitizeBuildJob, sanitizeMetadata } from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
@@ -14,7 +7,7 @@ import Joi from 'joi';
import nullthrows from 'nullthrows';
import { BuildContext } from '../context';
import { isAtLeastNpm7Async } from '../utils/packageManager';
import { resolveEasCommandPrefixAndEnvAsync } from '../utils/easCli';
const EasBuildInternalResultSchema = Joi.object<{ job: object; metadata: object }>({
job: Joi.object().unknown(),
@@ -121,33 +114,6 @@ export async function resolveEnvFromBuildProfileAsync<TJob extends BuildJob>(
return env;
}
async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
cmd: string;
args: string[];
extraEnv: Env;
}> {
const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : [];
if (process.env.ENVIRONMENT === 'development') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
};
} else if (process.env.ENVIRONMENT === 'staging') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
};
} else {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
};
}
}
function validateEasBuildInternalResult<TJob extends BuildJob>({
oldJob,
result,
+3 -1
View File
@@ -173,7 +173,9 @@ export class BuildContext<TJob extends Job = Job> {
return path.join(this.workingdir, 'env');
}
public get packageManager(): PackageManager {
return resolvePackageManager(this.getReactNativeProjectDirectory());
return resolvePackageManager(this.getReactNativeProjectDirectory(), {
env: this.env,
});
}
public get appConfig(): Promise<ExpoConfig> {
if (!this._appConfig) {
@@ -22,6 +22,10 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /Users/expo/Library/Keychains
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -45,6 +49,10 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -70,6 +78,10 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -97,6 +109,10 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain /tmp/keychain\\""
disable_xcpretty(true)
buildlog_path("/tmp/logs")
derived_data_path("./build")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
output_directory("/tmp/output")
"
`;
@@ -117,6 +133,9 @@ destination("generic/platform=iOS Simulator")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -134,6 +153,9 @@ destination("platform=iOS Simulator,name=iPhone 15")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -153,5 +175,8 @@ destination("generic/platform=tvOS Simulator")
disable_xcpretty(true)
buildlog_path("/tmp/logs")
result_bundle(true)
result_bundle_path("./build/result-bundle.xcresult")
"
`;
@@ -0,0 +1,128 @@
import { Ios } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import path from 'path';
import { createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { Credentials } from '../credentials/manager';
import { DistributionType } from '../credentials/provisioningProfile';
import { runFastlaneGym } from '../fastlane';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../tvos', () => ({
isTVOS: jest.fn().mockResolvedValue(false),
}));
const WORKING_DIR = '/workingdir';
// BuildContext.getReactNativeProjectDirectory() nests the RN project under `${workingdir}/build`.
const IOS_DIR = path.join(WORKING_DIR, 'build', 'ios');
function makeIosBuildContext({ simulator }: { simulator: boolean }): BuildContext<Ios.Job> {
const job: Ios.Job = { ...createTestIosJob(), simulator };
return new BuildContext(job, {
workingdir: WORKING_DIR,
logger: createMockLogger(),
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
env: { __API_SERVER_URL: 'http://api.expo.test' },
uploadArtifact: jest.fn(),
});
}
const ARCHIVE_CREDENTIALS: Credentials = {
keychainPath: '/Users/expo/Library/Keychains/login.keychain',
distributionType: DistributionType.APP_STORE,
teamId: 'TEAM123',
applicationTargetProvisioningProfile: {} as any,
targetProvisioningProfiles: {
'com.example.app': {
bundleIdentifier: 'com.example.app',
uuid: 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee',
path: '/path/to/profile1.mobileprovision',
target: 'com.example.app',
teamId: 'TEAM123',
name: 'Main App Profile',
developerCertificate: Buffer.from('cert'),
certificateCommonName: 'iPhone Distribution',
distributionType: DistributionType.APP_STORE,
},
},
};
function readDerivedDataPathFromGymfile(gymfileContent: string, workspacePath: string): string {
const match = gymfileContent.match(/derived_data_path\("([^"]+)"\)/);
if (!match) {
throw new Error(
`Gymfile did not contain derived_data_path("..."). Content:\n${gymfileContent}`
);
}
return path.resolve(workspacePath, match[1]);
}
describe(runFastlaneGym, () => {
beforeEach(() => {
vol.reset();
vol.fromJSON({ [path.join(IOS_DIR, '.keep')]: '' });
(spawn as jest.Mock).mockClear();
});
describe.each([
{
name: 'archive',
simulator: false,
credentials: ARCHIVE_CREDENTIALS,
buildConfiguration: 'Release',
},
{
name: 'simulator',
simulator: true,
credentials: null,
buildConfiguration: 'Debug',
},
])('$name build', ({ simulator, credentials, buildConfiguration }) => {
it('returns paths that match the generated Gymfile template', async () => {
const ctx = makeIosBuildContext({ simulator });
const result = await runFastlaneGym(ctx, {
scheme: 'App',
buildConfiguration,
credentials,
entitlements: null,
});
expect(result).toEqual({
derivedDataPath: path.join(IOS_DIR, 'build'),
workspacePath: IOS_DIR,
});
const gymfileContent = vol.readFileSync(path.join(IOS_DIR, 'Gymfile'), 'utf8') as string;
expect(readDerivedDataPathFromGymfile(gymfileContent, result.workspacePath)).toBe(
result.derivedDataPath
);
});
});
it('still returns EAS-convention paths when a pre-existing ios/Gymfile is present', async () => {
vol.fromJSON({
[path.join(IOS_DIR, 'Gymfile')]: '# user-supplied Gymfile\n',
});
const ctx = makeIosBuildContext({ simulator: false });
const result = await runFastlaneGym(ctx, {
scheme: 'App',
credentials: ARCHIVE_CREDENTIALS,
entitlements: null,
});
expect(result).toEqual({
derivedDataPath: path.join(IOS_DIR, 'build'),
workspacePath: IOS_DIR,
});
});
});
@@ -58,6 +58,8 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Release',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
});
@@ -94,6 +96,8 @@ describe('gymfile', () => {
credentials: mockCredentials,
scheme: 'TestScheme',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: false,
logsDirectory: '/tmp/logs',
});
@@ -131,6 +135,8 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Release',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
entitlements: {
@@ -192,6 +198,8 @@ describe('gymfile', () => {
credentials: mockCredentials,
scheme: 'MyApp',
outputDirectory: '/tmp/output',
derivedDataPath: './build',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
});
@@ -210,6 +218,7 @@ describe('gymfile', () => {
scheme: 'MyApp',
buildConfiguration: 'Debug',
derivedDataPath: '/tmp/derived-data',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
simulatorDestination: 'generic/platform=iOS Simulator',
@@ -226,6 +235,7 @@ describe('gymfile', () => {
outputFile,
scheme: 'TestApp',
derivedDataPath: '/tmp/derived',
resultBundlePath: './build/result-bundle.xcresult',
clean: false,
logsDirectory: '/tmp/logs',
simulatorDestination: 'platform=iOS Simulator,name=iPhone 15',
@@ -243,6 +253,7 @@ describe('gymfile', () => {
scheme: 'MyTVApp',
buildConfiguration: 'Debug',
derivedDataPath: '/tmp/derived',
resultBundlePath: './build/result-bundle.xcresult',
clean: true,
logsDirectory: '/tmp/logs',
simulatorDestination: 'generic/platform=tvOS Simulator',
+16 -2
View File
@@ -3,6 +3,7 @@ import { bunyan } from '@expo/logger';
import spawn, { SpawnResult } from '@expo/turtle-spawn';
import fs from 'fs-extra';
import nullthrows from 'nullthrows';
import os from 'os';
import path from 'path';
import type { Credentials } from './credentials/manager';
@@ -28,7 +29,13 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
entitlements: object | null;
extraEnv?: Env;
}
): Promise<void> {
): Promise<{
derivedDataPath: string;
workspacePath: string;
}> {
const workspacePath = path.join(ctx.getReactNativeProjectDirectory(), 'ios');
const derivedDataPath = path.join(workspacePath, 'build');
await ensureGymfileExists(ctx, {
scheme,
buildConfiguration,
@@ -43,13 +50,15 @@ export async function runFastlaneGym<TJob extends Ios.Job>(
void buildLogger.watchLogFiles(ctx.buildLogsDirectory);
try {
await runFastlane(['gym'], {
cwd: path.join(ctx.getReactNativeProjectDirectory(), 'ios'),
cwd: workspacePath,
logger: ctx.logger,
env: { ...ctx.env, ...extraEnv },
});
} finally {
await buildLogger.flush();
}
return { derivedDataPath, workspacePath };
}
export async function runFastlaneResign<TJob extends Ios.Job>(
@@ -122,6 +131,8 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
}
ctx.logger.info('Creating Gymfile');
const resultBundlePath = path.join(os.tmpdir(), `result-bundle-${Date.now()}.xcresult`);
if (ctx.job.simulator) {
const isTV = await isTVOS(ctx);
const simulatorDestination = `generic/platform=${isTV ? 'tvOS' : 'iOS'} Simulator`;
@@ -131,6 +142,7 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
scheme,
buildConfiguration: buildConfiguration ?? 'release',
derivedDataPath: './build',
resultBundlePath,
clean: false,
logsDirectory,
simulatorDestination,
@@ -142,6 +154,8 @@ async function ensureGymfileExists<TJob extends Ios.Job>(
scheme,
buildConfiguration,
outputDirectory: './build',
derivedDataPath: './build',
resultBundlePath,
clean: false,
logsDirectory,
entitlements: entitlements ?? undefined,
+9
View File
@@ -11,6 +11,8 @@ interface ArchiveBuildOptions {
scheme: string;
buildConfiguration?: string;
outputDirectory: string;
derivedDataPath: string;
resultBundlePath: string;
clean: boolean;
logsDirectory: string;
entitlements?: object;
@@ -21,6 +23,7 @@ interface SimulatorBuildOptions {
scheme: string;
buildConfiguration?: string;
derivedDataPath: string;
resultBundlePath: string;
clean: boolean;
logsDirectory: string;
simulatorDestination: string;
@@ -34,6 +37,8 @@ export async function createGymfileForArchiveBuild({
buildConfiguration,
entitlements,
outputDirectory,
derivedDataPath,
resultBundlePath,
logsDirectory,
}: ArchiveBuildOptions): Promise<void> {
const PROFILES: { BUNDLE_ID: string; UUID: string }[] = [];
@@ -59,6 +64,8 @@ export async function createGymfileForArchiveBuild({
SCHEME: scheme,
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
OUTPUT_DIRECTORY: outputDirectory,
DERIVED_DATA_PATH: derivedDataPath,
RESULT_BUNDLE_PATH: resultBundlePath,
EXPORT_METHOD: credentials.distributionType,
CLEAN: String(clean),
LOGS_DIRECTORY: logsDirectory,
@@ -74,6 +81,7 @@ export async function createGymfileForSimulatorBuild({
scheme,
buildConfiguration,
derivedDataPath,
resultBundlePath,
logsDirectory,
simulatorDestination,
}: SimulatorBuildOptions): Promise<void> {
@@ -86,6 +94,7 @@ export async function createGymfileForSimulatorBuild({
SCHEME_BUILD_CONFIGURATION: buildConfiguration,
SCHEME_SIMULATOR_DESTINATION: simulatorDestination,
DERIVED_DATA_PATH: derivedDataPath,
RESULT_BUNDLE_PATH: resultBundlePath,
CLEAN: String(clean),
LOGS_DIRECTORY: logsDirectory,
},
@@ -0,0 +1,27 @@
import { getEasFunctionGroups } from '../easFunctionGroups';
describe(getEasFunctionGroups, () => {
it('includes eas/maestro_test for non-build jobs', () => {
const ctx = {
hasBuildJob: () => false,
job: {},
} as unknown as Parameters<typeof getEasFunctionGroups>[0];
const functionGroupIds = getEasFunctionGroups(ctx).map(functionGroup =>
functionGroup.getFullId()
);
expect(functionGroupIds).toEqual(['eas/maestro_test']);
});
it('includes eas/build for build jobs', () => {
const ctx = {
hasBuildJob: () => true,
job: {},
} as unknown as Parameters<typeof getEasFunctionGroups>[0];
const functionGroupIds = getEasFunctionGroups(ctx).map(functionGroup =>
functionGroup.getFullId()
);
expect(functionGroupIds).toEqual(expect.arrayContaining(['eas/build', 'eas/maestro_test']));
});
});
@@ -9,6 +9,8 @@ import { configureIosVersionFunction } from './functions/configureIosVersion';
import { createSubmissionEntityFunction } from './functions/createSubmissionEntity';
import { createDownloadArtifactFunction } from './functions/downloadArtifact';
import { createDownloadBuildFunction } from './functions/downloadBuild';
import { createEasDeployBuildFunction } from './functions/deploy';
import { createEasExportBuildFunction } from './functions/export';
import { eagerBundleBuildFunction } from './functions/eagerBundle';
import { createFindAndUploadBuildArtifactsBuildFunction } from './functions/findAndUploadBuildArtifacts';
import { generateGymfileFromTemplateFunction } from './functions/generateGymfileFromTemplate';
@@ -25,11 +27,13 @@ import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
import { createResolveSubmitConfigBuildFunction } from './functions/resolveSubmitConfig';
import {
createCacheStatsBuildFunction,
createRestoreBuildCacheFunction,
} from './functions/restoreBuildCache';
import { createRestoreCacheFunction } from './functions/restoreCache';
import { parseXcactivitylogFunction } from './functions/parseXcactivitylog';
import { runFastlaneFunction } from './functions/runFastlane';
import { runGradleFunction } from './functions/runGradle';
import { createSaveBuildCacheFunction } from './functions/saveBuildCache';
@@ -55,6 +59,8 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(),
createEasExportBuildFunction(),
createEasDeployBuildFunction(),
createRepackBuildFunction(),
createRestoreCacheFunction(),
createRestoreBuildCacheFunction(),
@@ -71,6 +77,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
configureIosVersionFunction(),
generateGymfileFromTemplateFunction(),
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
@@ -82,6 +89,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
calculateEASUpdateRuntimeVersionFunction(),
createSubmissionEntityFunction(),
createResolveSubmitConfigBuildFunction(ctx),
createUploadToAscBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
@@ -107,6 +107,29 @@ describe(createEasBuildBuildFunctionGroup, () => {
}
});
it('wires configure_ios_credentials target_names into configure_ios_version', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
buildCredentials: { test: {} },
});
const functionGroup = createEasBuildBuildFunctionGroup(buildToolsContext);
const globalCtx = createGlobalContextMock({ logger: createMockLogger() });
const steps = functionGroup.createBuildStepsFromFunctionGroupCall(globalCtx, {
callInputs: { working_directory: './my-app' },
});
const configureIosVersionStep = steps.find(s => s.displayName === 'Configure iOS version');
const targetNamesInput = configureIosVersionStep?.inputs?.find(
input => input.id === 'target_names'
);
expect(configureIosVersionStep).toBeDefined();
expect(targetNamesInput?.rawValue).toBe(
'${{ steps.configure_ios_credentials.outputs.target_names }}'
);
});
it('composes working directory with installPods step-level ./ios dir (iOS)', () => {
const buildToolsContext = createMockBuildToolsContext({
platform: Platform.IOS,
@@ -251,10 +251,14 @@ function createStepsForIosBuildWithCredentials({
installPods,
configureEASUpdate,
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'configure_ios_credentials',
workingDirectory,
}),
configureIosVersionFunction().createBuildStepFromFunctionCall(globalCtx, {
workingDirectory,
callInputs: {
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
},
}),
...(shouldUseEagerBundle(globalCtx.staticContext.metadata)
? [
@@ -0,0 +1,81 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestIosJob } from '../../../__tests__/utils/job';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { configureCredentialsAsync } from '../../utils/ios/configure';
import IosCredentialsManager, { type Credentials } from '../../utils/ios/credentials/manager';
import { DistributionType } from '../../utils/ios/credentials/provisioningProfile';
import { configureIosCredentialsFunction } from '../configureIosCredentials';
jest.mock('../../utils/ios/configure');
jest.mock('../../utils/ios/credentials/manager');
function createCredentials({
targetNames = ['app', 'share-extension'],
}: {
targetNames?: string[];
} = {}): Credentials {
return {
applicationTargetProvisioningProfile: {} as Credentials['applicationTargetProvisioningProfile'],
keychainPath: '/tmp/keychain',
targetProvisioningProfiles: Object.fromEntries(
targetNames.map(targetName => [
targetName,
{
path: `/tmp/${targetName}.mobileprovision`,
target: targetName,
bundleIdentifier: `com.example.${targetName}`,
teamId: 'TEAMID123',
uuid: `${targetName}-uuid`,
name: `${targetName} profile`,
distributionType: DistributionType.AD_HOC,
developerCertificate: Buffer.from('certificate'),
certificateCommonName: `${targetName} cert`,
},
])
),
distributionType: DistributionType.AD_HOC,
teamId: 'TEAMID123',
};
}
function createRawCredentials() {
return {
app: {
provisioningProfileBase64: 'cHJvZmlsZQ==',
distributionCertificate: {
dataBase64: 'Y2VydA==',
password: '',
},
},
};
}
describe(configureIosCredentialsFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(configureCredentialsAsync).mockResolvedValue(undefined);
});
it('exports target_names from prepared credentials', async () => {
jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const buildStep = configureIosCredentialsFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { job: createTestIosJob() },
}),
{
callInputs: {
credentials: createRawCredentials(),
},
}
);
await buildStep.executeAsync();
expect(configureCredentialsAsync).toHaveBeenCalled();
expect(buildStep.outputById.target_names.value).toBe(JSON.stringify(['app', 'widget']));
});
});
@@ -0,0 +1,286 @@
import { UserError } from '@expo/eas-build-job';
import { BuildWorkflow } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestIosJob } from '../../../__tests__/utils/job';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { updateVersionsAsync } from '../../utils/ios/configure';
import IosCredentialsManager, { type Credentials } from '../../utils/ios/credentials/manager';
import { DistributionType } from '../../utils/ios/credentials/provisioningProfile';
import { configureIosCredentialsFunction } from '../configureIosCredentials';
import { configureIosVersionFunction } from '../configureIosVersion';
jest.mock('../../utils/ios/configure');
jest.mock('../../utils/ios/credentials/manager');
function createCredentials({
targetNames = ['app', 'share-extension'],
}: {
targetNames?: string[];
} = {}): Credentials {
return {
applicationTargetProvisioningProfile: {} as Credentials['applicationTargetProvisioningProfile'],
keychainPath: '/tmp/keychain',
targetProvisioningProfiles: Object.fromEntries(
targetNames.map(targetName => [
targetName,
{
path: `/tmp/${targetName}.mobileprovision`,
target: targetName,
bundleIdentifier: `com.example.${targetName}`,
teamId: 'TEAMID123',
uuid: `${targetName}-uuid`,
name: `${targetName} profile`,
distributionType: DistributionType.AD_HOC,
developerCertificate: Buffer.from('certificate'),
certificateCommonName: `${targetName} cert`,
},
])
),
distributionType: DistributionType.AD_HOC,
teamId: 'TEAMID123',
};
}
function createRawCredentials() {
return {
app: {
provisioningProfileBase64: 'cHJvZmlsZQ==',
distributionCertificate: {
dataBase64: 'Y2VydA==',
password: '',
},
},
};
}
function createBuildStep({
callInputs = {},
job = createTestIosJob(),
}: {
callInputs?: Record<string, unknown>;
job?: ReturnType<typeof createTestIosJob>;
} = {}) {
return configureIosVersionFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: { job },
}),
{ callInputs }
);
}
describe(configureIosVersionFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(updateVersionsAsync).mockResolvedValue(undefined);
});
it('uses target_names without an explicit credentials input', async () => {
const buildStep = createBuildStep({
callInputs: {
target_names: ['app', 'widget'],
build_number: '42',
},
});
await buildStep.executeAsync();
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: '42',
appVersion: undefined,
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('uses credentials only to preserve the existing fallback path', async () => {
jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const buildStep = createBuildStep({
callInputs: {
credentials: createRawCredentials(),
app_version: '1.2.3',
},
});
await buildStep.executeAsync();
expect(IosCredentialsManager.prototype.prepare).toHaveBeenCalledTimes(1);
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('uses the implicit credentials default fallback from the job secrets', async () => {
const prepareSpy = jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const buildStep = createBuildStep({
job: createTestIosJob({ buildCredentials: createRawCredentials() }),
callInputs: {
app_version: '1.2.3',
},
});
await buildStep.executeAsync();
expect(prepareSpy).toHaveBeenCalledTimes(1);
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('prefers target_names when both target_names and credentials are provided', async () => {
const prepareSpy = jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['credentials-target'] }));
const buildStep = createBuildStep({
callInputs: {
target_names: ['input-target'],
credentials: createRawCredentials(),
app_version: '1.2.3',
},
});
await buildStep.executeAsync();
expect(prepareSpy).not.toHaveBeenCalled();
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['input-target'],
buildConfiguration: 'Release',
}
);
});
it('parses target_names exported by configure_ios_credentials through workflow interpolation', async () => {
const prepareSpy = jest
.spyOn(IosCredentialsManager.prototype, 'prepare')
.mockResolvedValue(createCredentials({ targetNames: ['app', 'widget'] }));
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
staticContextContent: {
job: createTestIosJob({ buildCredentials: createRawCredentials() }),
},
});
const configureIosCredentialsStep =
configureIosCredentialsFunction().createBuildStepFromFunctionCall(globalCtx, {
id: 'configure_ios_credentials',
});
const configureIosVersionStep = configureIosVersionFunction().createBuildStepFromFunctionCall(
globalCtx,
{
callInputs: {
target_names: '${{ steps.configure_ios_credentials.outputs.target_names }}',
app_version: '1.2.3',
},
}
);
await new BuildWorkflow(globalCtx, {
buildSteps: [configureIosCredentialsStep, configureIosVersionStep],
buildFunctions: {},
}).executeAsync();
expect(prepareSpy).toHaveBeenCalledTimes(1);
expect(configureIosCredentialsStep.outputById.target_names.value).toBe(
JSON.stringify(['app', 'widget'])
);
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
configureIosVersionStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: ['app', 'widget'],
buildConfiguration: 'Release',
}
);
});
it('accepts an empty target_names array without crashing', async () => {
const prepareSpy = jest.spyOn(IosCredentialsManager.prototype, 'prepare');
const buildStep = createBuildStep({
callInputs: {
target_names: [],
app_version: '1.2.3',
},
});
await expect(buildStep.executeAsync()).resolves.toBeUndefined();
expect(prepareSpy).not.toHaveBeenCalled();
expect(updateVersionsAsync).toHaveBeenCalledWith(
expect.anything(),
buildStep.ctx.workingDirectory,
{
buildNumber: undefined,
appVersion: '1.2.3',
},
{
targetNames: [],
buildConfiguration: 'Release',
}
);
});
it('rejects invalid target_names', async () => {
const buildStep = createBuildStep({
callInputs: {
target_names: ['app', 123],
app_version: '1.2.3',
},
});
const promise = buildStep.executeAsync();
await expect(promise).rejects.toBeInstanceOf(UserError);
await expect(promise).rejects.toThrow('"target_names" input must be an array of strings.');
});
it('uses the corrected App version error text', async () => {
const buildStep = createBuildStep({
callInputs: {
target_names: ['app'],
app_version: 'invalid-semver',
},
});
await expect(buildStep.executeAsync()).rejects.toThrow(
'App version provided by the "app_version" input is not a valid semver version: invalid-semver'
);
});
});
@@ -0,0 +1,290 @@
import { BuildRuntimePlatform } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { runEasCliCommand } from '../../../utils/easCli';
import { createEasDeployBuildFunction } from '../deploy';
jest.mock('../../../utils/easCli', () => ({
runEasCliCommand: jest.fn(),
}));
const mockDeployStdout = JSON.stringify({
url: 'https://example.dev',
production: { url: 'https://example.prod' },
aliases: [{ url: 'https://example.alias' }],
identifier: 'abc',
dashboardUrl: 'https://expo.dev/dashboard',
});
describe(createEasDeployBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('passes deploy flags from function inputs and sets deploy_json output', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
alias: 'preview-link',
prod: true,
source_maps: false,
},
env: { HOME: '/tmp/home' },
});
await buildStep.executeAsync();
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith(
'Running deploy command: eas deploy --non-interactive --json --export-dir dist --alias preview-link --prod'
);
expect(runEasCliCommand).toHaveBeenCalledWith({
args: [
'deploy',
'--non-interactive',
'--json',
'--export-dir',
'dist',
'--alias',
'preview-link',
'--prod',
],
options: expect.objectContaining({
cwd: buildStep.ctx.workingDirectory,
env: expect.objectContaining({ HOME: '/tmp/home' }),
logger: buildStep.ctx.logger,
}),
});
expect(buildStep.outputById.deploy_json.value).toBe(mockDeployStdout);
expect(buildStep.outputById.deploy_url.value).toBe('https://example.prod');
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev');
expect(buildStep.outputById.deploy_identifier.value).toBe('abc');
expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard');
expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias');
});
it('forwards workflow step env to the eas deploy process', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: { source_maps: true },
env: { HOME: '/tmp/home', EXAMPLE_WORKFLOW_ENV: 'from-workflow' },
});
await buildStep.executeAsync();
expect(runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--source-maps']),
})
);
expect(runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
options: expect.objectContaining({
env: expect.objectContaining({
HOME: '/tmp/home',
EXAMPLE_WORKFLOW_ENV: 'from-workflow',
}),
}),
})
);
});
it('throws an actionable error message when deploy command fails', async () => {
jest.mocked(runEasCliCommand).mockRejectedValue(new Error('deploy failed'));
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
let errorMessage = '';
try {
await buildStep.executeAsync();
} catch (error) {
errorMessage = error instanceof Error ? error.message : String(error);
}
expect(errorMessage).toContain('Deploy command failed.');
expect(errorMessage).toContain('This can happen when deploy inputs are invalid');
expect(errorMessage).toContain(
'Check the deploy step logs, verify your deploy configuration and exported files, then retry.'
);
expect(errorMessage).toContain('Original error: deploy failed');
});
it('passes custom export_dir to eas deploy', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: { export_dir: 'web-build' },
});
await buildStep.executeAsync();
expect(runEasCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: expect.arrayContaining(['--export-dir', 'web-build']),
})
);
});
it('does not pass --source-maps when source_maps is false', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(mockDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: { source_maps: false },
});
await buildStep.executeAsync();
const { args } = jest.mocked(runEasCliCommand).mock.calls[0][0];
expect(args).not.toContain('--source-maps');
});
it('sets deploy_json and skips optional outputs when stdout is not valid JSON', async () => {
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from('not-json'),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(buildStep.outputById.deploy_json.value).toBe('not-json');
expect(buildStep.ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.anything() }),
expect.stringContaining('Failed to parse')
);
expect(buildStep.outputById.deploy_url.value).toBeUndefined();
});
it('sets outputs from partial deploy JSON and leaves missing fields undefined', async () => {
const partialDeployStdout = JSON.stringify({
url: 'https://example.dev-only',
dashboardUrl: 'https://expo.dev/dashboard-only',
});
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(partialDeployStdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(buildStep.outputById.deploy_json.value).toBe(partialDeployStdout);
expect(buildStep.outputById.deploy_url.value).toBe('https://example.dev-only');
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.dev-only');
expect(buildStep.outputById.deploy_dashboard_url.value).toBe('https://expo.dev/dashboard-only');
expect(buildStep.outputById.deploy_identifier.value).toBeUndefined();
expect(buildStep.outputById.deploy_alias_url.value).toBeUndefined();
});
it('sets deploy_url from aliases[0].url when production is absent but aliases exist', async () => {
const stdout = JSON.stringify({
url: 'https://example.deployment',
aliases: [{ url: 'https://example.alias-first' }],
identifier: 'partial-alias',
});
jest.mocked(runEasCliCommand).mockResolvedValue({
stdout: Buffer.from(stdout),
stderr: Buffer.from(''),
pid: 1,
output: [],
status: 0,
signal: null,
error: null,
} as any);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasDeployBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(buildStep.outputById.deploy_url.value).toBe('https://example.alias-first');
expect(buildStep.outputById.deploy_deployment_url.value).toBe('https://example.deployment');
expect(buildStep.outputById.deploy_alias_url.value).toBe('https://example.alias-first');
expect(buildStep.outputById.deploy_identifier.value).toBe('partial-alias');
});
});
@@ -0,0 +1,122 @@
jest.mock('../../../utils/project', () => ({
runExpoCliCommand: jest.fn().mockResolvedValue({}),
}));
jest.mock('../../../utils/packageManager', () => ({
...jest.requireActual('../../../utils/packageManager'),
resolvePackageManager: jest.fn(),
}));
import { BuildRuntimePlatform } from '@expo/steps';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { PackageManager, resolvePackageManager } from '../../../utils/packageManager';
import { runExpoCliCommand } from '../../../utils/project';
import { createEasExportBuildFunction } from '../export';
describe(createEasExportBuildFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.NPM);
jest.mocked(runExpoCliCommand).mockResolvedValue({} as any);
});
it('runs expo export via runExpoCliCommand with default output_dir and platform web', async () => {
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
env: { HOME: '/tmp/home' },
});
await buildStep.executeAsync();
expect(buildStep.ctx.logger.info).toHaveBeenCalledWith(
'Running export command: expo export --output-dir dist --platform web'
);
expect(runExpoCliCommand).toHaveBeenCalledWith({
packageManager: PackageManager.NPM,
args: ['export', '--output-dir', 'dist', '--platform', 'web'],
options: {
cwd: buildStep.ctx.workingDirectory,
env: expect.objectContaining({ HOME: '/tmp/home' }),
logger: buildStep.ctx.logger,
stdio: 'pipe',
},
});
expect(buildStep.outputById.export_dir.value).toBe('dist');
});
it('passes optional flags matching expo export CLI', async () => {
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
output_dir: 'web-out',
dev: true,
minify: false,
dump_assetmap: true,
ssg: false,
api_only: true,
platform: 'web',
},
});
await buildStep.executeAsync();
expect(runExpoCliCommand).toHaveBeenCalledWith(
expect.objectContaining({
args: [
'export',
'--output-dir',
'web-out',
'--platform',
'web',
'--dev',
'--no-minify',
'--dump-assetmap',
'--no-ssg',
'--api-only',
],
})
);
expect(buildStep.outputById.export_dir.value).toBe('web-out');
});
it('uses resolved package manager (e.g. yarn)', async () => {
jest.mocked(resolvePackageManager).mockReturnValue(PackageManager.YARN);
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await buildStep.executeAsync();
expect(runExpoCliCommand).toHaveBeenCalledWith(
expect.objectContaining({ packageManager: PackageManager.YARN })
);
});
it('throws when export command fails', async () => {
jest.mocked(runExpoCliCommand).mockRejectedValue(new Error('export failed'));
const logger = createMockLogger();
const globalCtx = createGlobalContextMock({
logger,
runtimePlatform: BuildRuntimePlatform.LINUX,
});
const buildStep = createEasExportBuildFunction().createBuildStepFromFunctionCall(globalCtx, {});
await expect(buildStep.executeAsync()).rejects.toThrow('Export command failed: export failed');
});
});
@@ -0,0 +1,81 @@
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { parseAndReportXcactivitylog } from '../../utils/ios/xcactivitylog';
import { parseXcactivitylogFunction } from '../parseXcactivitylog';
jest.mock('../../utils/ios/xcactivitylog');
describe(parseXcactivitylogFunction, () => {
beforeEach(() => {
jest.resetAllMocks();
});
it('passes proxyBaseUrl from env into parseAndReportXcactivitylog', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
globalContext.updateEnv({ EAS_BUILD_COCOAPODS_CACHE_URL: 'https://cache.example.com' });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext);
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
proxyBaseUrl: 'https://cache.example.com',
})
);
});
it('passes xclogparserVersion as undefined when the input is not provided', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext);
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
xclogparserVersion: undefined,
})
);
});
it('resolves derived_data_path and workspace_path inputs against stepCtx.workingDirectory', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext, {
callInputs: {
derived_data_path: 'my-app/ios/Build',
workspace_path: 'my-app/ios',
},
});
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
derivedDataPath: expect.stringMatching(/\/my-app\/ios\/Build$/),
workspacePath: expect.stringMatching(/\/my-app\/ios$/),
})
);
});
it('accepts absolute paths for derived_data_path and workspace_path', async () => {
const globalContext = createGlobalContextMock({ logger: createMockLogger() });
const buildStep = parseXcactivitylogFunction().createBuildStepFromFunctionCall(globalContext, {
callInputs: {
derived_data_path: '/abs/derived',
workspace_path: '/abs/workspace',
},
});
await buildStep.executeAsync();
expect(parseAndReportXcactivitylog).toHaveBeenCalledWith(
expect.objectContaining({
derivedDataPath: '/abs/derived',
workspacePath: '/abs/workspace',
})
);
});
});
@@ -1,5 +1,10 @@
import { Ios } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import assert from 'assert';
import { configureCredentialsAsync } from '../utils/ios/configure';
@@ -26,9 +31,14 @@ export function configureIosCredentialsFunction(): BuildFunction {
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (stepCtx, { inputs }) => {
const rawCredentialsInput = inputs.credentials.value as Record<string, any>;
const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, {
outputProviders: [
BuildStepOutput.createProvider({
id: 'target_names',
required: true,
}),
],
fn: async (stepCtx, { inputs, outputs }) => {
const { value, error } = IosBuildCredentialsSchema.validate(inputs.credentials.value, {
stripUnknown: true,
convert: true,
abortEarly: false,
@@ -50,6 +60,7 @@ export function configureIosCredentialsFunction(): BuildFunction {
inputs.build_configuration.value as string | undefined
),
});
outputs.target_names.set(JSON.stringify(Object.keys(credentials.targetProvisioningProfiles)));
stepCtx.logger.info('Successfully configured iOS credentials');
},
@@ -1,7 +1,8 @@
import { Ios } from '@expo/eas-build-job';
import { Ios, UserError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import assert from 'assert';
import semver from 'semver';
import { z } from 'zod';
import { updateVersionsAsync } from '../utils/ios/configure';
import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials';
@@ -17,10 +18,15 @@ export function configureIosVersionFunction(): BuildFunction {
inputProviders: [
BuildStepInput.createProvider({
id: 'credentials',
required: true,
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
defaultValue: '${ eas.job.secrets.buildCredentials }',
}),
BuildStepInput.createProvider({
id: 'target_names',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.JSON,
}),
BuildStepInput.createProvider({
id: 'build_configuration',
required: false,
@@ -38,19 +44,6 @@ export function configureIosVersionFunction(): BuildFunction {
}),
],
fn: async (stepCtx, { inputs }) => {
const rawCredentialsInput = inputs.credentials.value as Record<string, any>;
const { value, error } = IosBuildCredentialsSchema.validate(rawCredentialsInput, {
stripUnknown: true,
convert: true,
abortEarly: false,
});
if (error) {
throw error;
}
const credentialsManager = new IosCredentialsManager(value);
const credentials = await credentialsManager.prepare(stepCtx.logger);
assert(stepCtx.global.staticContext.job, 'Job is not defined');
const job = stepCtx.global.staticContext.job as Ios.Job;
@@ -59,8 +52,9 @@ export function configureIosVersionFunction(): BuildFunction {
const appVersion =
(inputs.app_version.value as string | undefined) ?? job.version?.appVersion;
if (appVersion && !semver.valid(appVersion)) {
throw new Error(
`App verrsion provided by the "app_version" input is not a valid semver version: ${appVersion}`
throw new UserError(
'EAS_CONFIGURE_IOS_VERSION_INVALID_APP_VERSION',
`App version provided by the "app_version" input is not a valid semver version: ${appVersion}`
);
}
@@ -71,6 +65,33 @@ export function configureIosVersionFunction(): BuildFunction {
return;
}
const targetNamesInput = inputs.target_names.value;
let targetNames: string[];
if (targetNamesInput !== undefined) {
const parsed = z.array(z.string()).safeParse(targetNamesInput);
if (!parsed.success) {
throw new UserError(
'EAS_CONFIGURE_IOS_VERSION_INVALID_TARGET_NAMES',
'"target_names" input must be an array of strings.',
{ cause: parsed.error }
);
}
targetNames = parsed.data;
} else {
const { value, error } = IosBuildCredentialsSchema.validate(inputs.credentials.value, {
stripUnknown: true,
convert: true,
abortEarly: false,
});
if (error) {
throw error;
}
const credentialsManager = new IosCredentialsManager(value);
const credentials = await credentialsManager.prepare(stepCtx.logger);
targetNames = Object.keys(credentials.targetProvisioningProfiles);
}
stepCtx.logger.info('Setting iOS version...');
if (buildNumber) {
stepCtx.logger.info(`Build number: ${buildNumber}`);
@@ -87,7 +108,7 @@ export function configureIosVersionFunction(): BuildFunction {
appVersion,
},
{
targetNames: Object.keys(credentials.targetProvisioningProfiles),
targetNames,
buildConfiguration: resolveBuildConfiguration(
job,
inputs.build_configuration.value as string | undefined
@@ -0,0 +1,167 @@
import { UserError } from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { runEasCliCommand } from '../../utils/easCli';
export function createEasDeployBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'deploy',
name: 'Deploy',
__metricsId: 'eas/deploy',
inputProviders: [
BuildStepInput.createProvider({
id: 'alias',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
}),
BuildStepInput.createProvider({
id: 'prod',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'source_maps',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
// Match eas/export default `output_dir`
BuildStepInput.createProvider({
id: 'export_dir',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
defaultValue: 'dist',
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'deploy_json',
required: true,
}),
BuildStepOutput.createProvider({
id: 'deploy_url',
required: false,
}),
BuildStepOutput.createProvider({
id: 'deploy_deployment_url',
required: false,
}),
BuildStepOutput.createProvider({
id: 'deploy_identifier',
required: false,
}),
BuildStepOutput.createProvider({
id: 'deploy_dashboard_url',
required: false,
}),
BuildStepOutput.createProvider({
id: 'deploy_alias_url',
required: false,
}),
],
fn: async (stepsCtx, { inputs, outputs, env }) => {
const alias = inputs.alias.value as string | undefined;
const prod = inputs.prod.value as boolean | undefined;
const sourceMaps = inputs.source_maps.value as boolean | undefined;
const exportDir = inputs.export_dir.value as string;
const deployCommand = getDeployCommand({
exportDir,
alias,
prod,
sourceMaps,
});
stepsCtx.logger.info(`Running deploy command: eas ${deployCommand.join(' ')}`);
try {
const result = await runEasCliCommand({
args: deployCommand,
options: {
cwd: stepsCtx.workingDirectory,
env,
logger: stepsCtx.logger,
mode: PipeMode.STDERR_ONLY_AS_STDOUT,
},
});
const deployJson = result.stdout.toString();
outputs.deploy_json.set(deployJson);
const parsedDeploymentOutput = parseDeploymentOutput({
deployJson,
logger: stepsCtx.logger,
});
outputs.deploy_url.set(parsedDeploymentOutput.deploy_url);
outputs.deploy_deployment_url.set(parsedDeploymentOutput.deploy_deployment_url);
outputs.deploy_identifier.set(parsedDeploymentOutput.deploy_identifier);
outputs.deploy_dashboard_url.set(parsedDeploymentOutput.deploy_dashboard_url);
outputs.deploy_alias_url.set(parsedDeploymentOutput.deploy_alias_url);
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'unknown error';
throw new UserError(
'EAS_DEPLOY_FAILED',
'Deploy command failed.\n\n' +
`This can happen when deploy inputs are invalid, the export directory "${exportDir}" is missing, or EAS CLI authentication/network checks fail.\n` +
'Check the deploy step logs, verify your deploy configuration and exported files, then retry.\n' +
`Original error: ${errorMessage}`,
{ cause: error }
);
}
},
});
}
function getDeployCommand({
exportDir,
alias,
prod,
sourceMaps,
}: {
exportDir: string;
alias?: string;
prod?: boolean;
sourceMaps?: boolean;
}): string[] {
const deployCommand = ['deploy', '--non-interactive', '--json', '--export-dir', exportDir];
if (alias) {
deployCommand.push('--alias', alias);
}
if (prod) {
deployCommand.push('--prod');
}
if (sourceMaps) {
deployCommand.push('--source-maps');
}
return deployCommand;
}
function parseDeploymentOutput({ deployJson, logger }: { deployJson: string; logger: bunyan }): {
deploy_url?: string;
deploy_deployment_url?: string;
deploy_identifier?: string;
deploy_dashboard_url?: string;
deploy_alias_url?: string;
} {
try {
const deployObject = JSON.parse(deployJson);
return {
deploy_url:
deployObject.production?.url || deployObject.aliases?.[0]?.url || deployObject.url,
deploy_deployment_url: deployObject.url,
deploy_identifier: deployObject.identifier,
deploy_dashboard_url: deployObject.dashboardUrl,
deploy_alias_url: deployObject.aliases?.[0]?.url,
};
} catch (error: unknown) {
logger.warn(
{ err: error },
'Failed to parse "eas deploy" JSON output. Some outputs expected from this step will be undefined.'
);
return {};
}
}
@@ -29,7 +29,7 @@ export function eagerBundleBuildFunction(): BuildFunction {
throw new Error('Eager bundle is not supported for SDK version < 52');
}
const packageManager = resolvePackageManager(stepsCtx.workingDirectory);
const packageManager = resolvePackageManager(stepsCtx.workingDirectory, { env });
const resolvedEASUpdateRuntimeVersion = inputs.resolved_eas_update_runtime_version.value as
| string
| undefined;
@@ -0,0 +1,140 @@
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { resolvePackageManager } from '../../utils/packageManager';
import { runExpoCliCommand } from '../../utils/project';
export function createEasExportBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'export',
name: 'Export',
__metricsId: 'eas/export',
inputProviders: [
BuildStepInput.createProvider({
id: 'output_dir',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
defaultValue: 'dist',
}),
BuildStepInput.createProvider({
id: 'dev',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'minify',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'dump_assetmap',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'ssg',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'api_only',
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
required: false,
}),
BuildStepInput.createProvider({
id: 'platform',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
required: false,
defaultValue: 'web',
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'export_dir',
required: true,
}),
],
fn: async (stepsCtx, { inputs, outputs, env }) => {
const outputDir = inputs.output_dir.value as string;
const dev = inputs.dev.value as boolean | undefined;
const minify = inputs.minify.value as boolean | undefined;
const dumpAssetmap = inputs.dump_assetmap.value as boolean | undefined;
const ssg = inputs.ssg.value as boolean | undefined;
const apiOnly = inputs.api_only.value as boolean | undefined;
const platform = inputs.platform.value as string;
const packageManager = resolvePackageManager(stepsCtx.workingDirectory, { env });
const exportCommand = getExportCommand({
outputDir,
dev,
minify,
dumpAssetmap,
ssg,
apiOnly,
platform,
});
stepsCtx.logger.info(`Running export command: expo ${exportCommand.join(' ')}`);
try {
await runExpoCliCommand({
packageManager,
args: exportCommand,
options: {
cwd: stepsCtx.workingDirectory,
env,
logger: stepsCtx.logger,
stdio: 'pipe',
},
});
outputs.export_dir.set(outputDir);
} catch (error) {
throw new UserError(
'EXPO_EXPORT_FAILED',
`Export command failed: ${error instanceof Error ? error.message : 'unknown error'}`
);
}
},
});
}
function getExportCommand({
outputDir,
dev,
minify,
dumpAssetmap,
ssg,
apiOnly,
platform,
}: {
outputDir: string;
dev?: boolean;
minify?: boolean;
dumpAssetmap?: boolean;
ssg?: boolean;
apiOnly?: boolean;
platform: string;
}): string[] {
const exportCommand = ['export', '--output-dir', outputDir, '--platform', platform];
if (dev) {
exportCommand.push('--dev');
}
if (minify === false) {
exportCommand.push('--no-minify');
}
if (dumpAssetmap) {
exportCommand.push('--dump-assetmap');
}
if (ssg === false) {
exportCommand.push('--no-ssg');
}
if (apiOnly) {
exportCommand.push('--api-only');
}
return exportCommand;
}
@@ -6,6 +6,7 @@ import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@exp
import { templateString } from '@expo/template-file';
import assert from 'assert';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { IosBuildCredentialsSchema } from '../utils/ios/credentials/credentials';
@@ -36,6 +37,10 @@ const DEFAULT_CREDENTIALS_TEMPLATE = `
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
derived_data_path("<%- DERIVED_DATA_PATH %>")
result_bundle(true)
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
output_directory("<%- OUTPUT_DIRECTORY %>")
`;
@@ -55,6 +60,9 @@ const DEFAULT_SIMULATOR_TEMPLATE = `
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
result_bundle(true)
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
`;
export function generateGymfileFromTemplateFunction(): BuildFunction {
@@ -181,6 +189,7 @@ export function generateGymfileFromTemplateFunction(): BuildFunction {
ICLOUD_CONTAINER_ENVIRONMENT,
SCHEME_SIMULATOR_DESTINATION: simulatorDestination,
DERIVED_DATA_PATH: './build',
RESULT_BUNDLE_PATH: path.join(os.tmpdir(), `result-bundle-${Date.now()}.xcresult`),
...(PROFILES ? { PROFILES } : {}),
...(credentials
? {
@@ -27,7 +27,7 @@ export async function installNodeModules(
env: BuildStepEnv
): Promise<void> {
const { logger } = stepCtx;
const packageManager = resolvePackageManager(stepCtx.workingDirectory);
const packageManager = resolvePackageManager(stepCtx.workingDirectory, { env });
const packagerRunDir = findPackagerRootDir(stepCtx.workingDirectory);
if (packagerRunDir !== stepCtx.workingDirectory) {
@@ -0,0 +1,51 @@
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import path from 'path';
import { parseAndReportXcactivitylog } from '../utils/ios/xcactivitylog';
export function parseXcactivitylogFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'parse_xcactivitylog',
name: 'Analyze build performance',
__metricsId: 'eas/parse_xcactivitylog',
supportedRuntimePlatforms: [BuildRuntimePlatform.DARWIN],
inputProviders: [
BuildStepInput.createProvider({
id: 'derived_data_path',
required: false,
defaultValue: 'ios/build',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'workspace_path',
required: false,
defaultValue: 'ios',
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'xclogparser_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (stepCtx, { inputs, env }) => {
const derivedDataPath = inputs.derived_data_path.value as string;
const workspacePath = inputs.workspace_path.value as string;
const version = inputs.xclogparser_version.value as string | undefined;
await parseAndReportXcactivitylog({
derivedDataPath: path.resolve(stepCtx.workingDirectory, derivedDataPath),
workspacePath: path.resolve(stepCtx.workingDirectory, workspacePath),
xclogparserVersion: version,
logger: stepCtx.logger,
proxyBaseUrl: env.EAS_BUILD_COCOAPODS_CACHE_URL,
});
},
});
}
@@ -32,7 +32,7 @@ export function createPrebuildBuildFunction(): BuildFunction {
fn: async (stepCtx, { inputs, env }) => {
const { logger } = stepCtx;
const appleTeamId = inputs.apple_team_id.value as string | undefined;
const packageManager = resolvePackageManager(stepCtx.workingDirectory);
const packageManager = resolvePackageManager(stepCtx.workingDirectory, { env });
const defaultPlatform = process.platform === 'darwin' ? 'ios' : 'android';
const job = stepCtx.global.staticContext.job;
@@ -0,0 +1,182 @@
import { Platform, SubmissionConfig, SystemError, UserError } from '@expo/eas-build-job';
import { AndroidReleaseStatus, SubmitProfile } from '@expo/eas-json';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import { BuildStepEnv } from '@expo/steps';
import fs from 'fs-extra';
import { graphql } from 'gql.tada';
import path from 'node:path';
import { z } from 'zod';
import { BuildInfo, ResolvedSubmitConfig } from './common';
import { CustomBuildContext } from '../../../customBuildContext';
const ANDROID_APP_CREDENTIALS_QUERY = graphql(`
query ResolveSubmitConfigAndroidAppCredentials(
$appId: String!
$applicationIdentifier: String
) {
app {
byId(appId: $appId) {
id
androidAppCredentials(filter: { applicationIdentifier: $applicationIdentifier }) {
id
googleServiceAccountKeyForSubmissions {
id
keyJson
}
}
}
}
}
`);
const AndroidReleaseStatusToSubmissionReleaseStatus: Record<
AndroidReleaseStatus,
SubmissionConfig.Android.ReleaseStatus
> = {
[AndroidReleaseStatus.completed]: SubmissionConfig.Android.ReleaseStatus.COMPLETED,
[AndroidReleaseStatus.draft]: SubmissionConfig.Android.ReleaseStatus.DRAFT,
[AndroidReleaseStatus.halted]: SubmissionConfig.Android.ReleaseStatus.HALTED,
[AndroidReleaseStatus.inProgress]: SubmissionConfig.Android.ReleaseStatus.IN_PROGRESS,
};
export async function resolveAndroidSubmitConfigAsync({
artifactPath,
build,
ctx,
env,
logger,
profile,
workingDirectory,
}: {
artifactPath: string;
build: BuildInfo;
ctx: CustomBuildContext;
env: BuildStepEnv;
logger: bunyan;
profile: SubmitProfile<Platform.ANDROID>;
workingDirectory: string;
}): Promise<ResolvedSubmitConfig> {
const applicationId =
build.appIdentifier ??
profile.applicationId ??
(await readAndroidApplicationIdAsync(artifactPath, env, logger));
if (!applicationId) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_ANDROID_APPLICATION_ID_NOT_FOUND',
'Could not resolve Android applicationId from build metadata or artifact. Set applicationId in the Android submit profile, or pass an APK/AAB artifact with a readable package name.'
);
}
const googleServiceAccountKeyJson = profile.serviceAccountKeyPath
? await fs.readFile(path.resolve(workingDirectory, profile.serviceAccountKeyPath), 'utf8')
: await getGoogleServiceAccountKeyJsonAsync({
applicationId,
appId: build.appId,
ctx,
});
const releaseStatus = AndroidReleaseStatusToSubmissionReleaseStatus[profile.releaseStatus];
const baseConfigInput = {
changesNotSentForReview: profile.changesNotSentForReview,
googleServiceAccountKeyJson,
track: profile.track,
};
const configInput: z.input<typeof SubmissionConfig.Android.SchemaZ> =
releaseStatus === SubmissionConfig.Android.ReleaseStatus.IN_PROGRESS
? {
...baseConfigInput,
releaseStatus,
rollout: profile.rollout,
}
: {
...baseConfigInput,
releaseStatus,
};
return {
appIdentifier: applicationId,
config: SubmissionConfig.Android.SchemaZ.parse(configInput),
platform: Platform.ANDROID,
};
}
async function getGoogleServiceAccountKeyJsonAsync({
applicationId,
appId,
ctx,
}: {
applicationId: string;
appId: string;
ctx: CustomBuildContext;
}): Promise<string> {
const credentialsResult = await ctx.graphqlClient
.query(ANDROID_APP_CREDENTIALS_QUERY, {
appId,
applicationIdentifier: applicationId,
})
.toPromise();
if (credentialsResult.error) {
throw credentialsResult.error;
}
const key =
credentialsResult.data?.app.byId.androidAppCredentials[0]
?.googleServiceAccountKeyForSubmissions;
if (!key) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_ANDROID_SERVICE_ACCOUNT_KEY_NOT_CONFIGURED',
`Google Service Account Key for submissions is not configured for ${applicationId}. Configure a Google Service Account Key for submissions in EAS credentials, or set serviceAccountKeyPath in the Android submit profile.`
);
}
if (!key.keyJson) {
throw new SystemError(`Google Service Account Key ${key.id} could not be resolved.`);
}
return key.keyJson;
}
async function readAndroidApplicationIdAsync(
artifactPath: string,
env: BuildStepEnv,
logger: bunyan
): Promise<string | undefined> {
if (artifactPath.endsWith('.apk')) {
return await readAndroidApplicationIdFromApkAsync(artifactPath, env, logger);
} else if (artifactPath.endsWith('.aab')) {
return await readAndroidApplicationIdFromAabAsync(artifactPath, env, logger);
}
return undefined;
}
async function readAndroidApplicationIdFromApkAsync(
apkPath: string,
env: BuildStepEnv,
logger: bunyan
): Promise<string | undefined> {
const aapt2Result = await asyncResult(spawn('aapt2', ['dump', 'packagename', apkPath], { env }));
if (aapt2Result.ok) {
return aapt2Result.value.stdout.trim();
}
logger.warn('Failed to read APK package name with aapt2.');
return undefined;
}
async function readAndroidApplicationIdFromAabAsync(
aabPath: string,
env: BuildStepEnv,
logger: bunyan
): Promise<string | undefined> {
const result = await asyncResult(
spawn('bundletool', ['dump', 'manifest', `--bundle=${aabPath}`, '--xpath=/manifest/@package'], {
env,
})
);
if (!result.ok) {
logger.warn('Failed to read AAB package name with bundletool.');
return undefined;
}
return result.value.stdout.trim();
}
@@ -0,0 +1,84 @@
import { Platform, SubmissionConfig, UserError } from '@expo/eas-build-job';
import { EasJsonAccessor, EasJsonUtils, SubmitProfile } from '@expo/eas-json';
import { graphql } from 'gql.tada';
import { CustomBuildContext } from '../../../customBuildContext';
export type AppPlatform = 'ANDROID' | 'IOS';
export const AppPlatformToPlatform: Record<AppPlatform, Platform> = {
ANDROID: Platform.ANDROID,
IOS: Platform.IOS,
};
export type BuildInfo = {
appIdentifier?: string | null;
appId: string;
buildId: string;
projectOwnerAccountId: string;
platform: Platform;
};
export type ResolvedSubmitConfig = {
appIdentifier?: string;
config: SubmissionConfig.Android | SubmissionConfig.Ios;
platform: Platform;
};
const BUILD_BY_ID_QUERY = graphql(`
query ResolveSubmitConfigBuildById($buildId: ID!) {
builds {
byId(buildId: $buildId) {
id
platform
appIdentifier
app {
id
ownerAccount {
id
}
}
}
}
}
`);
export async function getBuildInfoAsync(
ctx: CustomBuildContext,
buildId: string
): Promise<BuildInfo> {
const result = await ctx.graphqlClient.query(BUILD_BY_ID_QUERY, { buildId }).toPromise();
if (result.error) {
throw result.error;
}
const build = result.data?.builds.byId;
if (!build) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_BUILD_NOT_FOUND',
`Could not find build ${buildId}.`
);
}
return {
appIdentifier: build.appIdentifier,
appId: build.app.id,
buildId: build.id,
projectOwnerAccountId: build.app.ownerAccount.id,
platform: AppPlatformToPlatform[build.platform],
};
}
export async function getSubmitProfileAsync<T extends Platform>({
platform,
profileName,
workingDirectory,
}: {
platform: T;
profileName: string;
workingDirectory: string;
}): Promise<SubmitProfile<T>> {
return await EasJsonUtils.getSubmitProfileAsync(
EasJsonAccessor.fromProjectPath(workingDirectory),
platform,
profileName
);
}
@@ -0,0 +1,139 @@
import { Platform, SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import path from 'node:path';
import { z } from 'zod';
import { resolveAndroidSubmitConfigAsync } from './android';
import { ResolvedSubmitConfig, getBuildInfoAsync, getSubmitProfileAsync } from './common';
import { resolveIosSubmitConfigAsync } from './ios';
import { CustomBuildContext } from '../../../customBuildContext';
export function createResolveSubmitConfigBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'resolve_submit_config',
name: 'Resolve submit config',
__metricsId: 'eas/resolve_submit_config',
inputProviders: [
BuildStepInput.createProvider({
id: 'build_id',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'profile',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'artifact_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'config_json',
required: true,
}),
BuildStepOutput.createProvider({
id: 'platform',
required: true,
}),
BuildStepOutput.createProvider({
id: 'app_identifier',
required: false,
}),
],
fn: async (stepsCtx, { env, inputs, outputs }) => {
const buildId = z.string().uuid().parse(inputs.build_id.value);
const profileName = z.string().parse(inputs.profile.value);
const artifactPath = path.resolve(
stepsCtx.workingDirectory,
z.string().parse(inputs.artifact_path.value)
);
const { config, platform, appIdentifier } = await resolveSubmitConfigAsync({
artifactPath,
buildId,
ctx,
env,
logger: stepsCtx.logger,
profileName,
workingDirectory: stepsCtx.workingDirectory,
});
outputs.config_json.set(JSON.stringify(config));
outputs.platform.set(platform);
if (appIdentifier) {
outputs.app_identifier.set(appIdentifier);
}
},
});
}
export async function resolveSubmitConfigAsync({
artifactPath,
buildId,
ctx,
env,
logger,
profileName,
workingDirectory,
}: {
artifactPath: string;
buildId: string;
ctx: CustomBuildContext;
env: BuildStepEnv;
logger: bunyan;
profileName: string;
workingDirectory: string;
}): Promise<ResolvedSubmitConfig> {
logger.info('Resolving submit config...');
const build = await getBuildInfoAsync(ctx, buildId);
switch (build.platform) {
case Platform.ANDROID: {
const profile = await getSubmitProfileAsync({
platform: build.platform,
profileName,
workingDirectory,
});
return await resolveAndroidSubmitConfigAsync({
artifactPath,
build,
ctx,
env,
logger,
profile,
workingDirectory,
});
}
case Platform.IOS: {
const profile = await getSubmitProfileAsync({
platform: build.platform,
profileName,
workingDirectory,
});
return await resolveIosSubmitConfigAsync({
artifactPath,
appId: build.appId,
buildAppIdentifier: build.appIdentifier,
ctx,
env,
profile,
projectOwnerAccountId: build.projectOwnerAccountId,
workingDirectory,
});
}
default:
throw new SystemError(`Unsupported platform: ${build.platform}`);
}
}
@@ -0,0 +1,214 @@
import { Platform, SubmissionConfig, UserError } from '@expo/eas-build-job';
import { SubmitProfile } from '@expo/eas-json';
import { BuildStepEnv } from '@expo/steps';
import fs from 'fs-extra';
import { graphql } from 'gql.tada';
import path from 'node:path';
import { z } from 'zod';
import { readIpaInfoAsync } from '../readIpaInfo';
import { ResolvedSubmitConfig } from './common';
import { CustomBuildContext } from '../../../customBuildContext';
const APPLE_APP_IDENTIFIER_QUERY = graphql(`
query ResolveSubmitConfigAppleAppIdentifier(
$accountId: String!
$bundleIdentifier: String!
) {
account {
byId(accountId: $accountId) {
id
appleAppIdentifiers(bundleIdentifier: $bundleIdentifier) {
id
bundleIdentifier
}
}
}
}
`);
const IOS_APP_CREDENTIALS_QUERY = graphql(`
query ResolveSubmitConfigIosAppCredentials(
$appId: String!
$appleAppIdentifierId: String!
) {
app {
byId(appId: $appId) {
id
iosAppCredentials(filter: { appleAppIdentifierId: $appleAppIdentifierId }) {
id
appStoreConnectApiKeyForSubmissions {
id
issuerIdentifier
keyIdentifier
keyP8
}
}
}
}
}
`);
export async function resolveIosSubmitConfigAsync({
appId,
artifactPath,
buildAppIdentifier,
ctx,
env,
profile,
projectOwnerAccountId,
workingDirectory,
}: {
appId: string;
artifactPath: string;
buildAppIdentifier?: string | null;
ctx: CustomBuildContext;
env: BuildStepEnv;
profile: SubmitProfile<Platform.IOS>;
projectOwnerAccountId: string;
workingDirectory: string;
}): Promise<ResolvedSubmitConfig> {
const bundleIdentifier =
buildAppIdentifier ??
profile.bundleIdentifier ??
(await readIpaInfoAsync(artifactPath)).bundleIdentifier;
if (!profile.ascAppId) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_IOS_ASC_APP_ID_NOT_CONFIGURED',
'Set ascAppId in the submit profile in eas.json.'
);
}
const appSpecificPassword = env.EXPO_APPLE_APP_SPECIFIC_PASSWORD;
const configInput: z.input<typeof SubmissionConfig.Ios.SchemaZ> = {
ascAppIdentifier: profile.ascAppId,
groups: profile.groups,
...(appSpecificPassword
? {
appleAppSpecificPassword: validateAppSpecificPassword(appSpecificPassword),
appleIdUsername: requireAppleIdUsername(profile.appleId ?? env.EXPO_APPLE_ID),
}
: {
ascApiJsonKey: await getAscApiJsonKeyAsync({
accountId: projectOwnerAccountId,
appId,
bundleIdentifier,
ctx,
profile,
workingDirectory,
}),
}),
};
return {
appIdentifier: bundleIdentifier,
config: SubmissionConfig.Ios.SchemaZ.parse(configInput),
platform: Platform.IOS,
};
}
async function getAscApiJsonKeyAsync({
accountId,
appId,
bundleIdentifier,
ctx,
profile,
workingDirectory,
}: {
accountId: string;
appId: string;
bundleIdentifier?: string;
ctx: CustomBuildContext;
profile: SubmitProfile<Platform.IOS>;
workingDirectory: string;
}): Promise<string> {
// Prefer explicit local ASC API key fields from eas.json when all are present.
if (profile.ascApiKeyPath && profile.ascApiKeyIssuerId && profile.ascApiKeyId) {
return JSON.stringify({
issuer_id: profile.ascApiKeyIssuerId,
key: await fs.readFile(path.resolve(workingDirectory, profile.ascApiKeyPath), 'utf8'),
key_id: profile.ascApiKeyId,
});
}
if (profile.ascApiKeyPath || profile.ascApiKeyIssuerId || profile.ascApiKeyId) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_IOS_ASC_API_KEY_INCOMPLETE',
'ascApiKeyPath, ascApiKeyIssuerId and ascApiKeyId must all be defined in eas.json.'
);
}
if (!bundleIdentifier) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_IOS_BUNDLE_IDENTIFIER_NOT_FOUND',
'Could not resolve iOS bundleIdentifier from build metadata or artifact. Set bundleIdentifier in the iOS submit profile, or pass an IPA artifact with a readable bundle identifier.'
);
}
// Server credentials are keyed by the Apple App Identifier, so first map the bundle id to it.
const appleAppIdentifierResult = await ctx.graphqlClient
.query(APPLE_APP_IDENTIFIER_QUERY, {
accountId,
bundleIdentifier,
})
.toPromise();
if (appleAppIdentifierResult.error) {
throw appleAppIdentifierResult.error;
}
const appleAppIdentifierId =
appleAppIdentifierResult.data?.account.byId.appleAppIdentifiers[0]?.id;
if (!appleAppIdentifierId) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_IOS_APP_IDENTIFIER_NOT_CONFIGURED',
`Apple App Identifier is not configured for ${bundleIdentifier}. Configure the bundle identifier in EAS credentials for this account.`
);
}
// The app credentials record points at the ASC API key selected for submissions.
const credentialsResult = await ctx.graphqlClient
.query(IOS_APP_CREDENTIALS_QUERY, {
appId,
appleAppIdentifierId,
})
.toPromise();
if (credentialsResult.error) {
throw credentialsResult.error;
}
const ascApiKey =
credentialsResult.data?.app.byId.iosAppCredentials[0]?.appStoreConnectApiKeyForSubmissions;
if (!ascApiKey) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_IOS_ASC_API_KEY_NOT_CONFIGURED',
`App Store Connect API Key for submissions is not configured for ${bundleIdentifier}. Configure an App Store Connect API Key for submissions in EAS credentials, or set ascApiKeyPath, ascApiKeyIssuerId, and ascApiKeyId in the iOS submit profile.`
);
}
return JSON.stringify({
issuer_id: ascApiKey.issuerIdentifier,
key: ascApiKey.keyP8,
key_id: ascApiKey.keyIdentifier,
});
}
function validateAppSpecificPassword(password: string): string {
if (!/^[a-z]{4}-[a-z]{4}-[a-z]{4}-[a-z]{4}$/.test(password)) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_IOS_APP_SPECIFIC_PASSWORD_INVALID',
'EXPO_APPLE_APP_SPECIFIC_PASSWORD must be in the format xxxx-xxxx-xxxx-xxxx, where x is a lowercase letter.'
);
}
return password;
}
function requireAppleIdUsername(appleIdUsername?: string): string {
if (!appleIdUsername) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_IOS_APPLE_ID_NOT_CONFIGURED',
'Set appleId in the submit profile or EXPO_APPLE_ID when using EXPO_APPLE_APP_SPECIFIC_PASSWORD.'
);
}
return appleIdUsername;
}
@@ -200,7 +200,7 @@ export async function restoreGradleCacheAsync({
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
if (env.EAS_GRADLE_CACHE !== '1') {
return;
}
@@ -161,7 +161,7 @@ export async function saveGradleCacheAsync({
env: Record<string, string | undefined>;
secrets?: { robotAccessToken?: string };
}): Promise<void> {
if (env.EXPERIMENTAL_EAS_GRADLE_CACHE !== '1') {
if (env.EAS_GRADLE_CACHE !== '1') {
return;
}
@@ -0,0 +1,504 @@
import downloadFile from '@expo/downloader';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import { createMockLogger } from '../../../../__tests__/utils/logger';
import {
buildTargetMetrics,
isCompileStep,
collectTopLevelCompileSteps,
formatReport,
parseAndReportXcactivitylog,
} from '../xcactivitylog';
jest.mock('@expo/downloader');
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
// Minimal fixture: two modules with compile steps
const FIXTURE_TWO_MODULES = {
schema: { name: 'TestProject' },
subSteps: [
{
title: 'Build target ModuleA',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 5.0,
startTimestamp: 100.0,
endTimestamp: 103.0,
signature: 'CompileC foo.c',
subSteps: [],
},
{
detailStepType: 'other',
signature: 'SwiftCompile normal bar.swift',
duration: 3.0,
startTimestamp: 101.0,
endTimestamp: 104.0,
subSteps: [],
},
],
},
{
title: 'Build target ModuleB',
subSteps: [
{
detailStepType: 'compileAssetsCatalog',
duration: 2.0,
startTimestamp: 200.0,
endTimestamp: 202.0,
signature: 'CompileAssetCatalog',
subSteps: [],
},
],
},
{
title: 'Build target TinyModule',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 0.1,
startTimestamp: 300.0,
endTimestamp: 300.1,
signature: 'CompileC tiny.c',
subSteps: [],
},
],
},
],
};
// Fixture: empty data
const FIXTURE_EMPTY = {
schema: { name: 'EmptyProject' },
subSteps: [],
};
// Fixture: no compile steps (only non-compile sub-steps)
const FIXTURE_NO_COMPILE = {
schema: { name: 'NoCompileProject' },
subSteps: [
{
title: 'Build target SomeTarget',
subSteps: [
{
detailStepType: 'linkCommand',
duration: 5.0,
startTimestamp: 100.0,
endTimestamp: 105.0,
signature: 'Ld something',
subSteps: [],
},
],
},
],
};
// Fixture: missing timestamps
const FIXTURE_MISSING_TIMESTAMPS = {
schema: { name: 'MissingTimestamps' },
subSteps: [
{
title: 'Build target PartialModule',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 3.0,
signature: 'CompileC partial.c',
subSteps: [],
// no startTimestamp/endTimestamp
},
],
},
],
};
// Fixture: nested compile steps (compile step inside non-compile step)
const FIXTURE_NESTED = {
schema: { name: 'NestedProject' },
subSteps: [
{
title: 'Build target NestedModule',
subSteps: [
{
detailStepType: 'scriptExecution',
duration: 10.0,
startTimestamp: 100.0,
endTimestamp: 110.0,
signature: 'PhaseScriptExecution',
subSteps: [
{
detailStepType: 'cCompilation',
duration: 2.0,
startTimestamp: 101.0,
endTimestamp: 103.0,
signature: 'CompileC nested.c',
subSteps: [],
},
],
},
],
},
],
};
const mockedDownloadFile = jest.mocked(downloadFile);
const mockedSpawn = jest.mocked(spawn);
function mockFilesystem({
tempDir = '/tmp/xclogparser-123',
jsonOutput = JSON.stringify(FIXTURE_TWO_MODULES),
}: {
tempDir?: string;
jsonOutput?: string;
} = {}) {
jest.spyOn(fs, 'mkdtemp').mockImplementation(async () => tempDir);
jest.spyOn(fs, 'chmod').mockImplementation(async () => undefined);
jest.spyOn(fs, 'readFile').mockImplementation(async () => jsonOutput);
jest.spyOn(fs, 'rm').mockImplementation(async () => undefined);
return {
reportPath: path.join(tempDir, 'xcactivitylog.json'),
tempDir,
};
}
describe('parseAndReportXcactivitylog', () => {
beforeEach(() => {
mockedDownloadFile.mockReset();
mockedSpawn.mockReset();
});
afterEach(() => {
jest.restoreAllMocks();
});
it('downloads xclogparser from GCS and logs only the final report on success', async () => {
const logger = createMockLogger();
const { tempDir, reportPath } = mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
});
expect(mockedDownloadFile).toHaveBeenCalledWith(
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.46.zip'),
{ retry: 3, timeout: 20_000 }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
1,
'unzip',
['-q', path.join(tempDir, 'XCLogParser-macOS-x86-64-arm64-v0.2.46.zip'), '-d', tempDir],
{ stdio: 'pipe' }
);
expect(mockedSpawn).toHaveBeenNthCalledWith(
2,
path.join(tempDir, 'xclogparser'),
[
'parse',
'--derived_data',
'/tmp/derived-data',
'--workspace',
'/tmp/workspace',
'--reporter',
'json',
'--output',
reportPath,
],
{ stdio: 'pipe' }
);
expect(fs.readFile).toHaveBeenCalledWith(reportPath, 'utf8');
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Xcode Build — Compile Metrics by Module')
);
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
it('tries the proxy URL first and falls back to the direct GCS URL', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile
.mockRejectedValueOnce(new Error('proxy failed'))
.mockResolvedValueOnce(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
1,
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
{ retry: 3, timeout: 20_000 }
);
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
{ retry: 3, timeout: 20_000 }
);
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('falls back to the direct GCS URL when the proxy download succeeds but unpacking fails', async () => {
const logger = createMockLogger();
mockFilesystem();
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockRejectedValueOnce(new Error('invalid zip'))
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any)
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any);
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
proxyBaseUrl: 'https://cache.example.com',
});
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
1,
'https://cache.example.com/storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
{ retry: 3, timeout: 20_000 }
);
expect(mockedDownloadFile).toHaveBeenNthCalledWith(
2,
'https://storage.googleapis.com/turtle-v2/xclogparser/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
'/tmp/xclogparser-123/XCLogParser-macOS-x86-64-arm64-v0.2.46.zip',
{ retry: 3, timeout: 20_000 }
);
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Xcode Build — Compile Metrics by Module')
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('skips reporting and logs debug when the JSON output is invalid', async () => {
const logger = createMockLogger();
mockFilesystem({ jsonOutput: '{not valid json' });
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
})
).resolves.toBeUndefined();
expect(logger.info).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to analyze build performance; continuing without a report'
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('skips reporting and logs debug when the JSON output does not match the expected schema', async () => {
const logger = createMockLogger();
mockFilesystem({
jsonOutput: JSON.stringify({
schema: { name: 'BadProject' },
subSteps: [{ title: 123, subSteps: [] }],
}),
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
})
).resolves.toBeUndefined();
expect(logger.info).not.toHaveBeenCalled();
expect(logger.debug).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to analyze build performance; continuing without a report'
);
expect(logger.warn).not.toHaveBeenCalled();
});
it('swallows cleanup failures without emitting additional logs', async () => {
const logger = createMockLogger();
mockFilesystem();
jest.spyOn(fs, 'rm').mockImplementation(async () => {
throw new Error('cleanup failed');
});
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn.mockResolvedValue({ stdout: '', stderr: '' } as any);
await expect(
parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
})
).resolves.toBeUndefined();
expect(logger.info).toHaveBeenCalledTimes(1);
expect(logger.debug).not.toHaveBeenCalled();
expect(logger.warn).not.toHaveBeenCalled();
});
});
describe('isCompileStep', () => {
it('identifies cCompilation', () => {
expect(isCompileStep({ detailStepType: 'cCompilation', signature: '' })).toBe(true);
});
it('identifies compileAssetsCatalog', () => {
expect(isCompileStep({ detailStepType: 'compileAssetsCatalog', signature: '' })).toBe(true);
});
it('identifies compileStoryboard', () => {
expect(isCompileStep({ detailStepType: 'compileStoryboard', signature: '' })).toBe(true);
});
it('identifies SwiftCompile by signature prefix', () => {
expect(
isCompileStep({ detailStepType: 'other', signature: 'SwiftCompile normal foo.swift' })
).toBe(true);
});
it('identifies SwiftGeneratePch by signature prefix', () => {
expect(isCompileStep({ detailStepType: 'other', signature: 'SwiftGeneratePch normal' })).toBe(
true
);
});
it('rejects non-compile steps', () => {
expect(isCompileStep({ detailStepType: 'linkCommand', signature: 'Ld something' })).toBe(false);
});
it('handles missing fields gracefully', () => {
expect(isCompileStep({})).toBe(false);
});
});
describe('collectTopLevelCompileSteps', () => {
it('collects direct compile children', () => {
const target = FIXTURE_TWO_MODULES.subSteps[0]; // ModuleA
const result = collectTopLevelCompileSteps(target);
expect(result).toHaveLength(2);
});
it('finds nested compile steps through non-compile parents', () => {
const target = FIXTURE_NESTED.subSteps[0]; // NestedModule
const result = collectTopLevelCompileSteps(target);
expect(result).toHaveLength(1);
expect(result[0].detailStepType).toBe('cCompilation');
});
it('returns empty array when no compile steps', () => {
const target = FIXTURE_NO_COMPILE.subSteps[0];
const result = collectTopLevelCompileSteps(target);
expect(result).toHaveLength(0);
});
});
describe('buildTargetMetrics', () => {
it('returns metrics sorted by taskSeconds descending', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
expect(results.length).toBeGreaterThan(0);
for (let i = 1; i < results.length; i++) {
expect(results[i - 1].taskSeconds).toBeGreaterThanOrEqual(results[i].taskSeconds);
}
});
it('computes correct taskSeconds for ModuleA', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
const moduleA = results.find(r => r.moduleName === 'ModuleA');
expect(moduleA).toBeDefined();
expect(moduleA!.taskSeconds).toBeCloseTo(8.0); // 5.0 + 3.0
});
it('computes correct wallSpan for ModuleA with overlapping intervals', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
const moduleA = results.find(r => r.moduleName === 'ModuleA');
expect(moduleA).toBeDefined();
// intervals: [100, 103] and [101, 104] → wall span = 104 - 100 = 4.0
expect(moduleA!.wallSpan).toBeCloseTo(4.0);
});
it('filters out modules below minTaskSeconds threshold', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES);
const tinyModule = results.find(r => r.moduleName === 'TinyModule');
expect(tinyModule).toBeUndefined(); // 0.1s < 0.5s default threshold
});
it('allows custom minTaskSeconds threshold', () => {
const { results } = buildTargetMetrics(FIXTURE_TWO_MODULES, { minTaskSeconds: 0 });
const tinyModule = results.find(r => r.moduleName === 'TinyModule');
expect(tinyModule).toBeDefined();
});
it('returns empty results for empty data', () => {
const { results, totalTaskSeconds } = buildTargetMetrics(FIXTURE_EMPTY);
expect(results).toHaveLength(0);
expect(totalTaskSeconds).toBe(0);
});
it('handles missing timestamps gracefully', () => {
const { results } = buildTargetMetrics(FIXTURE_MISSING_TIMESTAMPS);
const mod = results.find(r => r.moduleName === 'PartialModule');
expect(mod).toBeDefined();
expect(mod!.taskSeconds).toBeCloseTo(3.0);
expect(mod!.wallSpan).toBe(0); // no valid intervals
});
});
describe('formatReport', () => {
it('produces table with header and module rows', () => {
const report = formatReport(FIXTURE_TWO_MODULES);
expect(report).toContain('Xcode Build — Compile Metrics by Module');
expect(report).toContain('ModuleA');
expect(report).toContain('ModuleB');
expect(report).toContain('TOTAL');
expect(report).toContain('% Task');
});
it('does not include modules below threshold', () => {
const report = formatReport(FIXTURE_TWO_MODULES);
expect(report).not.toContain('TinyModule');
});
it('handles empty data without crashing', () => {
const report = formatReport(FIXTURE_EMPTY);
expect(report).toContain('Xcode Build — Compile Metrics by Module');
expect(report).toContain('TOTAL');
});
});
@@ -0,0 +1,418 @@
import downloadFile from '@expo/downloader';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import os from 'os';
import path from 'path';
import { z } from 'zod';
const DEFAULT_XCLOGPARSER_VERSION = 'v0.2.46';
const XCLOGPARSER_DOWNLOAD_URL = 'https://storage.googleapis.com/turtle-v2/xclogparser';
const XCLOGPARSER_DOWNLOAD_TIMEOUT_MS = 20_000;
const XCLOGPARSER_OUTPUT_FILENAME = 'xcactivitylog.json';
/**
* Download xclogparser, parse xcactivitylog from derived data, and log a
* compile metrics report. Never throws — all errors are logged at debug level.
*
* Can be called from both the step-based flow (BuildFunction) and the
* traditional builder flow (runBuildPhase).
*/
export async function parseAndReportXcactivitylog({
derivedDataPath,
workspacePath,
xclogparserVersion = DEFAULT_XCLOGPARSER_VERSION,
logger,
proxyBaseUrl,
}: {
derivedDataPath: string;
workspacePath: string;
xclogparserVersion?: string;
logger: bunyan;
proxyBaseUrl?: string;
}): Promise<void> {
let tempDir: string | undefined;
try {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'xclogparser-'));
const xclogparserPath = await downloadXclogparser(
tempDir,
xclogparserVersion,
logger,
proxyBaseUrl
);
const jsonOutputPath = await runXclogparser({
binaryPath: xclogparserPath,
derivedDataPath,
workspacePath,
outputDir: tempDir,
});
const data = XcactivitylogDataSchemaZ.parse(
JSON.parse(await fs.readFile(jsonOutputPath, 'utf8'))
);
logger.info(formatReport(data));
} catch (err: unknown) {
logger.debug({ err }, 'Failed to analyze build performance; continuing without a report');
} finally {
if (tempDir) {
await asyncResult(fs.rm(tempDir, { force: true, recursive: true }));
}
}
}
async function downloadXclogparser(
tempDir: string,
version: string,
logger: bunyan,
proxyBaseUrl?: string
): Promise<string> {
const zipName = getXclogparserZipName(version);
const zipPath = path.join(tempDir, zipName);
const directUrl = `${XCLOGPARSER_DOWNLOAD_URL}/${zipName}`;
const proxiedUrl = getProxiedDownloadUrl({ directUrl, proxyBaseUrl });
if (proxiedUrl) {
const proxiedDownloadResult = await asyncResult(
downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: proxiedUrl })
);
if (!proxiedDownloadResult.ok) {
logger.debug(
{ err: proxiedDownloadResult.reason },
'Failed to prepare xclogparser via the proxy path; falling back to the direct URL'
);
await cleanupXclogparserArtifacts({ tempDir, zipPath });
} else {
return proxiedDownloadResult.value;
}
}
return await downloadAndUnpackXclogparser({ tempDir, zipPath, sourceUrl: directUrl });
}
async function downloadAndUnpackXclogparser({
tempDir,
zipPath,
sourceUrl,
}: {
tempDir: string;
zipPath: string;
sourceUrl: string;
}): Promise<string> {
await downloadFile(sourceUrl, zipPath, { retry: 3, timeout: XCLOGPARSER_DOWNLOAD_TIMEOUT_MS });
return await unpackXclogparser({ tempDir, zipPath });
}
async function unpackXclogparser({
tempDir,
zipPath,
}: {
tempDir: string;
zipPath: string;
}): Promise<string> {
await spawn('unzip', ['-q', zipPath, '-d', tempDir], { stdio: 'pipe' });
const binaryPath = path.join(tempDir, 'xclogparser');
await fs.chmod(binaryPath, 0o755);
return binaryPath;
}
async function cleanupXclogparserArtifacts({
tempDir,
zipPath,
}: {
tempDir: string;
zipPath: string;
}): Promise<void> {
await asyncResult(fs.rm(zipPath, { force: true }));
await asyncResult(fs.rm(path.join(tempDir, 'xclogparser'), { force: true }));
}
async function runXclogparser({
binaryPath,
derivedDataPath,
workspacePath,
outputDir,
}: {
binaryPath: string;
derivedDataPath: string;
workspacePath: string;
outputDir: string;
}): Promise<string> {
const outputPath = path.join(outputDir, XCLOGPARSER_OUTPUT_FILENAME);
await spawn(
binaryPath,
[
'parse',
'--derived_data',
derivedDataPath,
'--workspace',
workspacePath,
'--reporter',
'json',
'--output',
outputPath,
],
{ stdio: 'pipe' }
);
return outputPath;
}
const XcactivitylogStepSchemaZ = z.object({
title: z.string().optional(),
detailStepType: z.string().optional(),
signature: z.string().optional(),
duration: z.number().optional(),
startTimestamp: z.number().optional(),
endTimestamp: z.number().optional(),
get subSteps() {
return z.array(XcactivitylogStepSchemaZ).optional();
},
});
const XcactivitylogDataSchemaZ = z.object({
schema: z.union([z.string(), z.object({ name: z.string().optional() })]).optional(),
subSteps: z.array(XcactivitylogStepSchemaZ).optional(),
});
type XcactivitylogStep = z.infer<typeof XcactivitylogStepSchemaZ>;
type XcactivitylogData = z.infer<typeof XcactivitylogDataSchemaZ>;
interface TargetMetric {
moduleName: string;
taskSeconds: number;
wallSpan: number;
activeWallTime: number;
}
interface Interval {
start: number;
end: number;
}
const COMPILE_DETAIL_TYPES = new Set(['cCompilation', 'compileAssetsCatalog', 'compileStoryboard']);
const COMPILE_SIGNATURE_PREFIXES = ['SwiftCompile ', 'SwiftGeneratePch '];
function getXclogparserZipName(version: string): string {
return `XCLogParser-macOS-x86-64-arm64-${version}.zip`;
}
function getProxiedDownloadUrl({
directUrl,
proxyBaseUrl,
}: {
directUrl: string;
proxyBaseUrl?: string;
}): string | null {
if (!proxyBaseUrl) {
return null;
}
const parsedUrl = new URL(directUrl);
return directUrl.replace(
`${parsedUrl.protocol}//${parsedUrl.host}`,
`${proxyBaseUrl}/${parsedUrl.host}`
);
}
export function isCompileStep(step: Partial<XcactivitylogStep>): boolean {
const detailStepType = step.detailStepType ?? '';
const signature = step.signature ?? '';
if (COMPILE_DETAIL_TYPES.has(detailStepType)) {
return true;
}
return COMPILE_SIGNATURE_PREFIXES.some(prefix => signature.startsWith(prefix));
}
export function collectTopLevelCompileSteps(
step: XcactivitylogStep,
results: XcactivitylogStep[] = []
): XcactivitylogStep[] {
for (const child of step.subSteps ?? []) {
if (isCompileStep(child)) {
results.push(child);
continue;
}
collectTopLevelCompileSteps(child, results);
}
return results;
}
function intervalFromStep(step: XcactivitylogStep): Interval | null {
if (typeof step.startTimestamp !== 'number' || typeof step.endTimestamp !== 'number') {
return null;
}
return { start: step.startTimestamp, end: step.endTimestamp };
}
function computeActiveWallTime(intervals: Interval[]): number {
if (intervals.length === 0) {
return 0;
}
const sorted = [...intervals].sort((a, b) => a.start - b.start);
let activeWallTime = 0;
let currentStart = sorted[0].start;
let currentEnd = sorted[0].end;
for (const interval of sorted.slice(1)) {
if (interval.start <= currentEnd) {
currentEnd = Math.max(currentEnd, interval.end);
continue;
}
activeWallTime += currentEnd - currentStart;
currentStart = interval.start;
currentEnd = interval.end;
}
activeWallTime += currentEnd - currentStart;
return activeWallTime;
}
export function buildTargetMetrics(
data: XcactivitylogData,
{ minTaskSeconds = 0.5 }: { minTaskSeconds?: number } = {}
): { results: TargetMetric[]; totalTaskSeconds: number } {
const results: TargetMetric[] = [];
for (const step of data.subSteps ?? []) {
const title = step.title ?? '';
if (!title.startsWith('Build target ')) {
continue;
}
const moduleName = title.replace('Build target ', '');
const compileSteps = collectTopLevelCompileSteps(step);
const taskSeconds = compileSteps.reduce((sum, s) => sum + (s.duration ?? 0), 0);
if (taskSeconds < minTaskSeconds) {
continue;
}
const intervals = compileSteps.map(intervalFromStep).filter((i): i is Interval => i !== null);
const minStart = intervals.length > 0 ? Math.min(...intervals.map(i => i.start)) : 0;
const maxEnd = intervals.length > 0 ? Math.max(...intervals.map(i => i.end)) : 0;
const wallSpan = intervals.length > 0 ? maxEnd - minStart : 0;
const activeWallTime = computeActiveWallTime(intervals);
results.push({ moduleName, taskSeconds, wallSpan, activeWallTime });
}
results.sort((a, b) => {
if (b.taskSeconds !== a.taskSeconds) {
return b.taskSeconds - a.taskSeconds;
}
if (b.wallSpan !== a.wallSpan) {
return b.wallSpan - a.wallSpan;
}
return a.moduleName.localeCompare(b.moduleName);
});
const totalTaskSeconds = results.reduce((sum, r) => sum + r.taskSeconds, 0);
return { results, totalTaskSeconds };
}
function formatSeconds(value: number): string {
return `${value.toFixed(1)}s`;
}
export function formatReport(data: XcactivitylogData): string {
const { results, totalTaskSeconds } = buildTargetMetrics(data);
const nameWidth = Math.max(6, ...results.map(r => r.moduleName.length)) + 2;
const taskWidth = 10;
const pctWidth = 8;
const wallWidth = 10;
const barMaxWidth = 20;
const maxTaskSeconds = results[0]?.taskSeconds ?? 1;
const header =
'┌─' +
'─'.repeat(nameWidth) +
'─┬────────────┬──────────┬────────────┬─' +
'─'.repeat(barMaxWidth) +
'─┐';
const divider =
'├─' +
'─'.repeat(nameWidth) +
'─┼────────────┼──────────┼────────────┼─' +
'─'.repeat(barMaxWidth) +
'─┤';
const footer =
'└─' +
'─'.repeat(nameWidth) +
'─┴────────────┴──────────┴────────────┴─' +
'─'.repeat(barMaxWidth) +
'─┘';
const lines: string[] = [];
lines.push('');
lines.push('Xcode Build — Compile Metrics by Module');
lines.push(
`Schema: ${typeof data.schema === 'string' ? data.schema : (data.schema?.name ?? 'unknown')}`
);
lines.push('% Task = share of total Compile Task Seconds');
lines.push('Wall = first compile start to last compile end');
lines.push('');
lines.push(header);
lines.push(
'│ ' +
'Module'.padEnd(nameWidth) +
' │ ' +
'Task'.padStart(taskWidth) +
' │ ' +
'% Task'.padStart(pctWidth) +
' │ ' +
'Wall'.padStart(wallWidth) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(divider);
for (const result of results) {
const pct = totalTaskSeconds === 0 ? 0 : (result.taskSeconds / totalTaskSeconds) * 100;
const barLength = Math.round((result.taskSeconds / maxTaskSeconds) * barMaxWidth);
const bar = '█'.repeat(barLength) + '░'.repeat(barMaxWidth - barLength);
lines.push(
'│ ' +
result.moduleName.padEnd(nameWidth) +
' │ ' +
formatSeconds(result.taskSeconds).padStart(taskWidth) +
' │ ' +
`${pct.toFixed(1)}%`.padStart(pctWidth) +
' │ ' +
formatSeconds(result.wallSpan).padStart(wallWidth) +
' │ ' +
bar +
' │'
);
}
lines.push(divider);
lines.push(
'│ ' +
'TOTAL'.padEnd(nameWidth) +
' │ ' +
formatSeconds(totalTaskSeconds).padStart(taskWidth) +
' │ ' +
'100.0%'.padStart(pctWidth) +
' │ ' +
'n/a'.padStart(wallWidth) +
' │ ' +
' '.repeat(barMaxWidth) +
' │'
);
lines.push(footer);
lines.push('');
return lines.join('\n');
}
@@ -20,5 +20,9 @@ export_xcargs "OTHER_CODE_SIGN_FLAGS=\\"--keychain <%- KEYCHAIN_PATH %>\\""
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
derived_data_path("<%- DERIVED_DATA_PATH %>")
result_bundle(true)
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
output_directory("<%- OUTPUT_DIRECTORY %>")
`;
@@ -13,4 +13,7 @@ destination("<%- SCHEME_SIMULATOR_DESTINATION %>")
disable_xcpretty(true)
buildlog_path("<%- LOGS_DIRECTORY %>")
result_bundle(true)
result_bundle_path("<%- RESULT_BUNDLE_PATH %>")
`;
@@ -0,0 +1,139 @@
import { EasCliNpmTags } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEasCommandPrefixAndEnvAsync, runEasCliCommand } from '../easCli';
import { isAtLeastNpm7Async } from '../packageManager';
jest.mock('@expo/turtle-spawn', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('../packageManager', () => ({
...jest.requireActual('../packageManager'),
isAtLeastNpm7Async: jest.fn(async () => true),
}));
describe(resolveEasCommandPrefixAndEnvAsync, () => {
const originalEnvironment = process.env.ENVIRONMENT;
beforeEach(() => {
jest.mocked(spawn).mockReset();
});
afterEach(() => {
process.env.ENVIRONMENT = originalEnvironment;
});
it('resolves easd in development when easd --help succeeds', async () => {
process.env.ENVIRONMENT = 'development';
jest.mocked(spawn).mockResolvedValueOnce({ status: 0 } as any);
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'easd',
args: [],
extraEnv: {},
});
expect(spawn).toHaveBeenCalledWith(
'easd',
['--help'],
expect.objectContaining({ stdio: ['ignore', 'pipe', 'pipe'] })
);
});
it('resolves staging tag in development when easd --help fails', async () => {
process.env.ENVIRONMENT = 'development';
jest.mocked(spawn).mockResolvedValueOnce({ status: 1 } as any);
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
});
});
it('resolves staging tag in development when easd probe throws', async () => {
process.env.ENVIRONMENT = 'development';
jest.mocked(spawn).mockRejectedValueOnce(new Error('easd not installed') as any);
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
});
});
it('resolves staging tag and EXPO_STAGING env in staging environment', async () => {
process.env.ENVIRONMENT = 'staging';
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
});
expect(spawn).not.toHaveBeenCalled();
});
it('resolves production tag by default', async () => {
process.env.ENVIRONMENT = 'production';
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: ['-y', `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
});
expect(spawn).not.toHaveBeenCalled();
});
it('omits -y when npm is older than v7', async () => {
jest.mocked(isAtLeastNpm7Async).mockResolvedValueOnce(false);
process.env.ENVIRONMENT = 'production';
const result = await resolveEasCommandPrefixAndEnvAsync();
expect(result).toEqual({
cmd: 'npx',
args: [`eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
});
});
});
describe(runEasCliCommand, () => {
const originalEnvironment = process.env.ENVIRONMENT;
beforeEach(() => {
jest.mocked(spawn).mockReset();
});
afterEach(() => {
process.env.ENVIRONMENT = originalEnvironment;
});
it('merges caller env with resolved extra env', async () => {
process.env.ENVIRONMENT = 'staging';
await runEasCliCommand({
args: ['deploy', '--json'],
options: {
cwd: '/tmp/project',
env: { FOO: 'bar' },
},
});
expect(spawn).toHaveBeenCalledWith(
'npx',
expect.arrayContaining([`eas-cli@${EasCliNpmTags.STAGING}`, 'deploy', '--json']),
expect.objectContaining({
cwd: '/tmp/project',
env: expect.objectContaining({
FOO: 'bar',
EXPO_STAGING: '1',
}),
})
);
});
});
@@ -1,3 +1,4 @@
import { errors } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import fs from 'fs-extra';
import { vol } from 'memfs';
@@ -5,6 +6,7 @@ import path from 'path';
import semver from 'semver';
import {
PackageManager,
findPackagerRootDir,
getPackageVersionFromPackageJson,
resolvePackageManager,
@@ -23,23 +25,23 @@ describe(resolvePackageManager, () => {
});
it('returns yarn when no lockfiles exist', async () => {
expect(resolvePackageManager(rootDir)).toBe('yarn');
expect(resolvePackageManager(rootDir, { env: {} })).toBe('yarn');
});
it('returns npm when only package-json.lock exist', async () => {
await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content');
expect(resolvePackageManager(rootDir)).toBe('npm');
expect(resolvePackageManager(rootDir, { env: {} })).toBe('npm');
});
it('returns yarn when only yarn.lock exists', async () => {
await fs.writeFile(path.join(rootDir, 'yarn.lock'), 'content');
expect(resolvePackageManager(rootDir)).toBe('yarn');
expect(resolvePackageManager(rootDir, { env: {} })).toBe('yarn');
});
it('returns yarn when both lockfiles exists', async () => {
await fs.writeFile(path.join(rootDir, 'yarn.lock'), 'content');
await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content');
expect(resolvePackageManager(rootDir)).toBe('yarn');
expect(resolvePackageManager(rootDir, { env: {} })).toBe('yarn');
});
it('returns npm within a monorepo', async () => {
@@ -55,7 +57,7 @@ describe(resolvePackageManager, () => {
name: '@monorepo/expo-app',
});
expect(resolvePackageManager(nestedDir)).toBe('npm');
expect(resolvePackageManager(nestedDir, { env: {} })).toBe('npm');
});
it('returns yarn with an invalid monorepo', async () => {
@@ -67,7 +69,51 @@ describe(resolvePackageManager, () => {
await fs.mkdirp(nestedDir);
await fs.writeFile(path.join(nestedDir, 'package.json'), 'content');
expect(resolvePackageManager(nestedDir)).toBe('yarn');
expect(resolvePackageManager(nestedDir, { env: {} })).toBe('yarn');
});
it('returns yarn when no lockfile and env var is an empty string', () => {
expect(
resolvePackageManager(rootDir, {
env: { EAS_FALLBACK_PACKAGE_MANAGER: '' },
})
).toBe(PackageManager.YARN);
});
it('returns bun from EAS_FALLBACK_PACKAGE_MANAGER when no lockfile', () => {
expect(resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bun' } })).toBe(
PackageManager.BUN
);
});
it('returns pnpm from EAS_FALLBACK_PACKAGE_MANAGER when no lockfile', () => {
expect(resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'pnpm' } })).toBe(
PackageManager.PNPM
);
});
it('ignores EAS_FALLBACK_PACKAGE_MANAGER when a lockfile exists', async () => {
await fs.writeFile(path.join(rootDir, 'package-lock.json'), 'content');
expect(
resolvePackageManager(rootDir, {
env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' }, // invalid, but should not even be read
})
).toBe(PackageManager.NPM);
});
it('throws a UserError on unsupported EAS_FALLBACK_PACKAGE_MANAGER value', () => {
expect(() =>
resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' } })
).toThrow(errors.UserError);
try {
resolvePackageManager(rootDir, { env: { EAS_FALLBACK_PACKAGE_MANAGER: 'bunn' } });
} catch (e) {
expect(e).toBeInstanceOf(errors.UserError);
const error = e as errors.UserError;
expect(error.errorCode).toBe('EAS_INVALID_FALLBACK_PACKAGE_MANAGER');
expect(error.message).toContain('bunn');
expect(error.message).toContain('yarn, npm, pnpm, bun');
}
});
});
+61
View File
@@ -0,0 +1,61 @@
import spawn, { SpawnOptions, SpawnResult } from '@expo/turtle-spawn';
import { EasCliNpmTags, Env } from '@expo/eas-build-job';
import { isAtLeastNpm7Async } from './packageManager';
async function probeEasdAsync(): Promise<boolean> {
try {
const result = await spawn('easd', ['--help'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
return result.status === 0;
} catch {
return false;
}
}
export async function resolveEasCommandPrefixAndEnvAsync(): Promise<{
cmd: string;
args: string[];
extraEnv: Env;
}> {
const npxArgsPrefix = (await isAtLeastNpm7Async()) ? ['-y'] : [];
if (process.env.ENVIRONMENT === 'development') {
if (await probeEasdAsync()) {
return { cmd: 'easd', args: [], extraEnv: {} };
}
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: {},
};
} else if (process.env.ENVIRONMENT === 'staging') {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.STAGING}`],
extraEnv: { EXPO_STAGING: '1' },
};
} else {
return {
cmd: 'npx',
args: [...npxArgsPrefix, `eas-cli@${EasCliNpmTags.PRODUCTION}`],
extraEnv: {},
};
}
}
export async function runEasCliCommand({
args,
options,
}: {
args: string[];
options: SpawnOptions;
}): Promise<SpawnResult> {
const { logger, ...spawnOptions } = options;
const { cmd, args: commandPrefixArgs, extraEnv } = await resolveEasCommandPrefixAndEnvAsync();
return await spawn(cmd, [...commandPrefixArgs, ...args], {
...spawnOptions,
logger,
env: { ...spawnOptions.env, ...extraEnv },
});
}
@@ -1,5 +1,7 @@
import { errors } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import * as PackageManagerUtils from '@expo/package-manager';
import { type BuildStepEnv } from '@expo/steps';
import spawnAsync from '@expo/turtle-spawn';
import semver from 'semver';
import { z } from 'zod';
@@ -11,7 +13,10 @@ export enum PackageManager {
BUN = 'bun',
}
export function resolvePackageManager(directory: string): PackageManager {
export function resolvePackageManager(
directory: string,
{ env }: { env: BuildStepEnv }
): PackageManager {
try {
const manager = PackageManagerUtils.resolvePackageManager(directory);
if (manager === 'npm') {
@@ -20,12 +25,24 @@ export function resolvePackageManager(directory: string): PackageManager {
return PackageManager.PNPM;
} else if (manager === 'bun') {
return PackageManager.BUN;
} else {
} else if (manager === 'yarn') {
return PackageManager.YARN;
}
} catch {
return PackageManager.YARN;
} catch {}
const fallback = env.EAS_FALLBACK_PACKAGE_MANAGER;
if (fallback) {
const parsed = z.enum(PackageManager).safeParse(fallback);
if (parsed.success) {
return parsed.data;
}
const allowed = Object.values(PackageManager).join(', ');
throw new errors.UserError(
'EAS_INVALID_FALLBACK_PACKAGE_MANAGER',
`Invalid EAS_FALLBACK_PACKAGE_MANAGER value "${fallback}" (expected one of: ${allowed}).`
);
}
return PackageManager.YARN;
}
/**
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "18.6.0",
"version": "18.8.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "18.6.0"
"@expo/steps": "18.8.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "18.6.0",
"version": "18.8.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -56,6 +56,110 @@ describe('StaticWorkflowInterpolationContextZ', () => {
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with cf_bundle_version', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'processing',
cf_bundle_version: '42',
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with build.id', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'complete',
build: {
id: 'build-abc123',
},
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect build_upload with both cf_bundle_version and build.id', () => {
const context = {
after: {},
needs: {},
workflow: {
id: 'workflow-id',
name: 'workflow-name',
filename: 'workflow.yml',
url: 'https://expo.dev/accounts/example/workflows/workflow-id',
},
app: {
id: 'app-id',
slug: 'app-slug',
},
account: {
id: 'account-id',
name: 'account-name',
},
app_store_connect: {
app: {
id: '1234567890',
},
build_upload: {
id: '123e4567-e89b-42d3-a456-426614174000',
state: 'complete',
cf_bundle_version: '42',
build: {
id: 'build-abc123',
},
},
},
};
expect(StaticWorkflowInterpolationContextZ.parse(context)).toEqual(context);
});
it('accepts app_store_connect context without build_upload', () => {
const context = {
after: {},
@@ -403,6 +403,7 @@ describe('Ios.JobSchema', () => {
);
expect(value).not.toMatchObject(managedJob);
});
test('validates channel', () => {
const managedJob = {
secrets: {
+6
View File
@@ -256,6 +256,12 @@ const AppStoreConnectContextZ = z.looseObject({
.looseObject({
id: z.string(),
state: z.enum(['awaiting_upload', 'processing', 'failed', 'complete']),
cf_bundle_version: z.string().optional(),
build: z
.looseObject({
id: z.string(),
})
.optional(),
})
.optional(),
external_beta: z
+3
View File
@@ -41,6 +41,7 @@ export enum BuildPhase {
INSTALL_PODS = 'INSTALL_PODS',
CONFIGURE_XCODE_PROJECT = 'CONFIGURE_XCODE_PROJECT',
RUN_FASTLANE = 'RUN_FASTLANE',
PARSE_XCACTIVITYLOG = 'PARSE_XCACTIVITYLOG',
// HOOKS
PRE_INSTALL_HOOK = 'PRE_INSTALL_HOOK',
@@ -108,6 +109,7 @@ export const buildPhaseDisplayName: Record<BuildPhase, string> = {
[BuildPhase.INSTALL_PODS]: 'Install pods',
[BuildPhase.CONFIGURE_XCODE_PROJECT]: 'Configure Xcode project',
[BuildPhase.RUN_FASTLANE]: 'Run fastlane',
[BuildPhase.PARSE_XCACTIVITYLOG]: 'Analyze build performance',
// HOOKS
[BuildPhase.PRE_INSTALL_HOOK]: 'Pre-install hook',
@@ -172,6 +174,7 @@ export const buildPhaseWebsiteId: Record<BuildPhase, string> = {
[BuildPhase.INSTALL_PODS]: 'install-pods',
[BuildPhase.CONFIGURE_XCODE_PROJECT]: 'configure-xcode-project',
[BuildPhase.RUN_FASTLANE]: 'run-fastlane',
[BuildPhase.PARSE_XCACTIVITYLOG]: 'parse-xcactivitylog',
// HOOKS
[BuildPhase.PRE_INSTALL_HOOK]: 'pre-install-hook',
+147 -90
View File
@@ -84,6 +84,7 @@ eas --help COMMAND
* [`eas channel:create [NAME]`](#eas-channelcreate-name)
* [`eas channel:delete [NAME]`](#eas-channeldelete-name)
* [`eas channel:edit [NAME]`](#eas-channeledit-name)
* [`eas channel:insights`](#eas-channelinsights)
* [`eas channel:list`](#eas-channellist)
* [`eas channel:pause [NAME]`](#eas-channelpause-name)
* [`eas channel:resume [NAME]`](#eas-channelresume-name)
@@ -133,6 +134,7 @@ eas --help COMMAND
* [`eas update:configure`](#eas-updateconfigure)
* [`eas update:delete GROUPID`](#eas-updatedelete-groupid)
* [`eas update:edit [GROUPID]`](#eas-updateedit-groupid)
* [`eas update:insights GROUPID`](#eas-updateinsights-groupid)
* [`eas update:list`](#eas-updatelist)
* [`eas update:republish`](#eas-updaterepublish)
* [`eas update:revert-update-rollout`](#eas-updaterevert-update-rollout)
@@ -178,7 +180,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -195,7 +197,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -217,7 +219,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -234,7 +236,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -248,7 +250,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -300,7 +302,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -321,7 +323,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -341,7 +343,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -361,7 +363,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -384,7 +386,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -422,7 +424,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -442,7 +444,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -460,7 +462,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -480,7 +482,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -502,7 +504,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -523,7 +525,7 @@ DESCRIPTION
download simulator/emulator builds for a given fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -561,7 +563,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -601,7 +603,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -631,7 +633,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -657,7 +659,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -709,7 +711,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -727,7 +729,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -745,7 +747,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -762,7 +764,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -783,7 +785,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -804,7 +806,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -826,7 +828,31 @@ 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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:insights`
display adoption, crash, and unique-user insights for a channel + runtime version
```
USAGE
$ eas channel:insights --channel <value> --runtime-version <value> [--days <value> | --start <value> | --end
<value>] [--json] [--non-interactive]
FLAGS
--channel=<value> (required) Name of the channel.
--days=<value> Show insights from the last N days (default 7, mutually exclusive with --start/--end).
--end=<value> End of insights time range (ISO date).
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--non-interactive Run the command in non-interactive mode.
--runtime-version=<value> (required) Runtime version to query insights for.
--start=<value> Start of insights time range (ISO date).
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/v18.8.0/packages/eas-cli/src/commands/channel/insights.ts)_
## `eas channel:list`
@@ -846,7 +872,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -868,7 +894,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -890,7 +916,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -925,7 +951,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -948,7 +974,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -969,7 +995,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/v18.7.0/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -986,7 +1012,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1004,7 +1030,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1033,7 +1059,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1058,7 +1084,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1079,7 +1105,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1100,7 +1126,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1137,7 +1163,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1157,7 +1183,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1178,7 +1204,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1199,7 +1225,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1213,7 +1239,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1227,7 +1253,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1259,7 +1285,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1285,7 +1311,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1307,7 +1333,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1335,7 +1361,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1363,7 +1389,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1386,7 +1412,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1408,7 +1434,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1442,7 +1468,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1486,7 +1512,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1517,7 +1543,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1626,7 +1652,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1644,7 +1670,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1662,7 +1688,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1714,7 +1740,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/v18.7.0/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1728,7 +1754,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1751,7 +1777,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -1775,7 +1801,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -1793,7 +1819,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -1826,7 +1852,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.7.0/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -1869,7 +1895,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -1890,7 +1916,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -1911,7 +1937,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -1936,7 +1962,34 @@ 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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:insights GROUPID`
display launch, crash, unique-user, and size insights for an update group
```
USAGE
$ eas update:insights GROUPID [--platform ios|android] [--days <value> | --start <value> | --end <value>] [--json]
[--non-interactive]
ARGUMENTS
GROUPID The ID of an update group.
FLAGS
--days=<value> Show insights from the last N days (default 7, mutually exclusive with --start/--end).
--end=<value> End of insights time range (ISO date).
--json Enable JSON output, non-JSON messages will be printed to stderr. Implies --non-interactive.
--non-interactive Run the command in non-interactive mode.
--platform=<option> Filter to a single platform.
<options: ios|android>
--start=<value> Start of insights time range (ISO date).
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/v18.8.0/packages/eas-cli/src/commands/update/insights.ts)_
## `eas update:list`
@@ -1963,7 +2016,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -2001,7 +2054,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2029,7 +2082,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2059,7 +2112,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2080,7 +2133,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2088,19 +2141,23 @@ update group details
```
USAGE
$ eas update:view GROUPID [--json]
$ eas update:view GROUPID [--insights] [--days <value> | --start <value> | --end <value>] [--json]
ARGUMENTS
GROUPID The ID of an update group.
FLAGS
--json Enable JSON output, non-JSON messages will be printed to stderr.
--days=<value> Show insights from the last N days (default 7). Only used with --insights.
--end=<value> End of insights time range (ISO date). Only used with --insights.
--insights Also show insights (launches, crash rate, unique users, payload size) for the update group.
--json Enable JSON output, non-JSON messages will be printed to stderr.
--start=<value> Start of insights time range (ISO date). Only used with --insights.
DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2121,7 +2178,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/v18.7.0/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v18.8.0/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2143,7 +2200,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2163,7 +2220,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2182,7 +2239,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2205,7 +2262,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2222,7 +2279,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2343,7 +2400,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v18.7.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/v18.8.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2363,7 +2420,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2387,7 +2444,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -2427,7 +2484,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2449,7 +2506,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2477,7 +2534,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2497,7 +2554,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2519,5 +2576,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/v18.7.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/v18.8.0/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+546 -6
View File
@@ -3719,6 +3719,55 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "changeAdditionalAgentCreditsCount",
"description": "Buys or revokes account's additional agent credits, charging the account the appropriate amount if needed.",
"args": [
{
"name": "accountID",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "newAdditionalAgentCreditsCount",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Account",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "changeAdditionalConcurrenciesCount",
"description": "Buys or revokes account's additional concurrencies, charging the account the appropriate amount if needed.",
@@ -29322,6 +29371,85 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "CreateEchoProjectIconInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "accentColor",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "model",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "prompt",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "source",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "EchoProjectIconSource",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "url",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "CreateEchoProjectInput",
@@ -35416,6 +35544,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "echoProjectIcon",
"description": null,
"args": [],
"type": {
"kind": "OBJECT",
"name": "EchoProjectIconGalleryItem",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "echoVersions",
"description": null,
@@ -35595,6 +35735,30 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "iconGallery",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "EchoProjectIconGalleryItem",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": null,
@@ -36072,6 +36236,117 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "EchoProjectIconGalleryItem",
"description": null,
"fields": [
{
"name": "accentColor",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createdAt",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "DateTime",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "model",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "prompt",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "source",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "EchoProjectIconSource",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "url",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "EchoProjectIconInput",
@@ -36111,6 +36386,29 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "EchoProjectIconSource",
"description": null,
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "AI_GENERATED",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "USER_UPLOADED",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "EchoProjectImage",
@@ -36208,6 +36506,71 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createEchoProjectIcons",
"description": "Add one or more icons to the project's icon gallery.\nUsed for both AI-generated batches and user uploads.",
"args": [
{
"name": "echoProjectId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "icons",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "CreateEchoProjectIconInput",
"ofType": null
}
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "EchoProjectIconGalleryItem",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deleteEchoProject",
"description": "Delete an Echo project by ID",
@@ -36241,6 +36604,104 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deleteEchoProjectIcon",
"description": "Delete an icon from the gallery. If it is the active icon, clears the project's icon.",
"args": [
{
"name": "echoProjectIconId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "echoProjectId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "EchoProjectIconGalleryItem",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "selectEchoProjectIcon",
"description": "Select an icon from the gallery as the project's active icon.",
"args": [
{
"name": "echoProjectIconId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "echoProjectId",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "EchoProject",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updateEchoProject",
"description": "Update an Echo project",
@@ -36865,6 +37326,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "status",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "url",
"description": null,
@@ -44711,6 +45188,51 @@
"name": "InvoiceQuery",
"description": null,
"fields": [
{
"name": "previewInvoiceForAdditionalAgentCreditsCountUpdate",
"description": "Previews the invoice for the specified number of additional agent credit units.",
"args": [
{
"name": "accountID",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "additionalAgentCreditsCount",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "OBJECT",
"name": "Invoice",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "previewInvoiceForAdditionalConcurrenciesCountUpdate",
"description": "Previews the invoice for the specified number of additional concurrencies.\nThis is the total number of concurrencies the customer wishes to purchase\non top of their base plan, not the relative change in concurrencies\nthe customer wishes to make. For example, specify \"3\" if the customer has\ntwo add-on concurrencies and wishes to purchase one more.",
@@ -49933,6 +50455,18 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "login",
"description": null,
"args": [],
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "provider",
"description": null,
@@ -49969,6 +50503,12 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "github",
"description": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "google",
"description": null,
@@ -56568,8 +57108,8 @@
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "Use primaryAccountProfileImageUrl instead"
},
{
"name": "snacks",
@@ -65546,8 +66086,8 @@
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "Use primaryAccountProfileImageUrl instead"
},
{
"name": "secondFactorDevices",
@@ -66305,8 +66845,8 @@
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
"isDeprecated": true,
"deprecationReason": "Use primaryAccountProfileImageUrl instead"
},
{
"name": "snacks",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "18.7.0",
"version": "18.8.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": "18.6.0",
"@expo/eas-json": "18.6.0",
"@expo/eas-build-job": "18.8.0",
"@expo/eas-json": "18.8.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "18.5.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": "18.6.0",
"@expo/steps": "18.8.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -0,0 +1,142 @@
import { ChannelRuntimeInsights } from '../../../graphql/queries/ChannelInsightsQuery';
import {
buildChannelInsightsJson,
buildChannelInsightsTable,
toChannelInsightsSummary,
} from '../formatInsights';
function makeInsights(
overrides: {
embeddedUpdateTotalUniqueUsers?: number;
mostPopularUpdates?: {
id: string;
group: string;
message: string | null;
runtimeVersion: string;
platform: string;
totalUniqueUsers: number;
}[];
} = {}
): ChannelRuntimeInsights {
return {
__typename: 'UpdateChannelRuntimeInsights',
id: 'rti-id',
embeddedUpdateTotalUniqueUsers: overrides.embeddedUpdateTotalUniqueUsers ?? 250,
mostPopularUpdates: (
overrides.mostPopularUpdates ?? [
{
id: 'u1',
group: 'group-a',
message: 'first',
runtimeVersion: '1.0.0',
platform: 'ios',
totalUniqueUsers: 800,
},
{
id: 'u2',
group: 'group-b',
message: 'second',
runtimeVersion: '1.0.0',
platform: 'android',
totalUniqueUsers: 200,
},
]
).map(u => ({
__typename: 'Update' as const,
id: u.id,
group: u.group,
message: u.message,
runtimeVersion: u.runtimeVersion,
platform: u.platform,
insights: {
__typename: 'UpdateInsights' as const,
id: `${u.id}-insights`,
totalUniqueUsers: u.totalUniqueUsers,
},
})),
uniqueUsersOverTime: {
__typename: 'UniqueUsersOverTimeData',
data: {
__typename: 'LineChartData',
labels: ['2026-04-09', '2026-04-10'],
datasets: [{ __typename: 'LineDataset', id: 'd1', label: 'iOS', data: [100, 200] }],
},
},
cumulativeMetricsOverTime: {
__typename: 'ChannelRuntimeCumulativeMetricsOverTimeData',
data: {
__typename: 'LineChartData',
labels: ['2026-04-09', '2026-04-10'],
datasets: [{ __typename: 'LineDataset', id: 'cd1', label: 'Launches', data: [10, 20] }],
},
metricsAtLastTimestamp: [
{ __typename: 'LineDatapoint', id: 'launches', label: 'Launches', data: 1234 },
],
},
};
}
const TIMESPAN = {
startTime: '2026-04-09T00:00:00.000Z',
endTime: '2026-04-16T00:00:00.000Z',
daysBack: 7,
};
describe(toChannelInsightsSummary, () => {
it('ranks most popular updates by their order in the response', () => {
const summary = toChannelInsightsSummary('production', '1.0.0', makeInsights(), TIMESPAN);
expect(summary.mostPopularUpdates.map(u => u.rank)).toEqual([1, 2]);
expect(summary.mostPopularUpdates[0].groupId).toBe('group-a');
});
it('sums OTA totalUniqueUsers across most popular updates', () => {
const summary = toChannelInsightsSummary('production', '1.0.0', makeInsights(), TIMESPAN);
expect(summary.otaTotalUniqueUsers).toBe(1000);
});
it('passes through embedded update unique users', () => {
const summary = toChannelInsightsSummary(
'production',
'1.0.0',
makeInsights({ embeddedUpdateTotalUniqueUsers: 42 }),
TIMESPAN
);
expect(summary.embeddedUpdateTotalUniqueUsers).toBe(42);
});
});
describe(buildChannelInsightsJson, () => {
it('returns a serializable object', () => {
const summary = toChannelInsightsSummary('production', '1.0.0', makeInsights(), TIMESPAN);
const json = buildChannelInsightsJson(summary) as any;
expect(json.channel).toBe('production');
expect(json.runtimeVersion).toBe('1.0.0');
expect(json.timespan.daysBack).toBe(7);
expect(json.mostPopularUpdates).toHaveLength(2);
expect(json.cumulativeMetricsAtLastTimestamp[0].label).toBe('Launches');
});
});
describe(buildChannelInsightsTable, () => {
it('renders channel, runtime, embedded users and most popular updates', () => {
const summary = toChannelInsightsSummary('production', '1.0.0', makeInsights(), TIMESPAN);
const table = buildChannelInsightsTable(summary);
expect(table).toMatch(/Channel/);
expect(table).toMatch(/production/);
expect(table).toMatch(/1\.0\.0/);
expect(table).toMatch(/Embedded update users/);
expect(table).toMatch(/group-a/);
expect(table).toMatch(/Most popular updates/);
});
it('shows a friendly message when there are no popular updates', () => {
const summary = toChannelInsightsSummary(
'production',
'1.0.0',
makeInsights({ mostPopularUpdates: [] }),
TIMESPAN
);
const table = buildChannelInsightsTable(summary);
expect(table).toMatch(/No update launches recorded/);
});
});
@@ -0,0 +1,164 @@
import chalk from 'chalk';
import { ChannelRuntimeInsights } from '../../graphql/queries/ChannelInsightsQuery';
import { formatTimespan } from '../../insights/formatTimespan';
import formatFields from '../../utils/formatFields';
import renderTextTable from '../../utils/renderTextTable';
export interface ChannelInsightsTimespan {
startTime: string;
endTime: string;
daysBack?: number;
}
export interface ChannelMostPopularUpdate {
rank: number;
groupId: string;
message: string | null;
platform: string;
totalUniqueUsers: number;
}
export interface ChannelInsightsSummary {
channelName: string;
runtimeVersion: string;
startTime: string;
endTime: string;
daysBack?: number;
embeddedUpdateTotalUniqueUsers: number;
otaTotalUniqueUsers: number;
mostPopularUpdates: ChannelMostPopularUpdate[];
cumulativeMetricsAtLastTimestamp: { id: string; label: string; data: number }[];
uniqueUsersOverTime: {
labels: string[];
datasets: { id: string; label: string; data: (number | null)[] }[];
};
cumulativeMetricsOverTime: {
labels: string[];
datasets: { id: string; label: string; data: (number | null)[] }[];
};
}
export function toChannelInsightsSummary(
channelName: string,
runtimeVersion: string,
insights: ChannelRuntimeInsights,
timespan: ChannelInsightsTimespan
): ChannelInsightsSummary {
const mostPopular: ChannelMostPopularUpdate[] = insights.mostPopularUpdates.map((u, i) => ({
rank: i + 1,
groupId: u.group,
message: u.message ?? null,
platform: u.platform,
totalUniqueUsers: u.insights.totalUniqueUsers,
}));
const otaTotalUniqueUsers = mostPopular.reduce((sum, u) => sum + u.totalUniqueUsers, 0);
return {
channelName,
runtimeVersion,
startTime: timespan.startTime,
endTime: timespan.endTime,
daysBack: timespan.daysBack,
embeddedUpdateTotalUniqueUsers: insights.embeddedUpdateTotalUniqueUsers,
otaTotalUniqueUsers,
mostPopularUpdates: mostPopular,
cumulativeMetricsAtLastTimestamp: insights.cumulativeMetricsOverTime.metricsAtLastTimestamp.map(
m => ({ id: m.id, label: m.label, data: m.data })
),
uniqueUsersOverTime: {
labels: insights.uniqueUsersOverTime.data.labels,
datasets: insights.uniqueUsersOverTime.data.datasets.map(d => ({
id: d.id,
label: d.label,
data: d.data,
})),
},
cumulativeMetricsOverTime: {
labels: insights.cumulativeMetricsOverTime.data.labels,
datasets: insights.cumulativeMetricsOverTime.data.datasets.map(d => ({
id: d.id,
label: d.label,
data: d.data,
})),
},
};
}
export function buildChannelInsightsJson(summary: ChannelInsightsSummary): object {
return {
channel: summary.channelName,
runtimeVersion: summary.runtimeVersion,
timespan: {
start: summary.startTime,
end: summary.endTime,
...(summary.daysBack !== undefined ? { daysBack: summary.daysBack } : {}),
},
embeddedUpdateTotalUniqueUsers: summary.embeddedUpdateTotalUniqueUsers,
otaTotalUniqueUsers: summary.otaTotalUniqueUsers,
mostPopularUpdates: summary.mostPopularUpdates,
cumulativeMetricsAtLastTimestamp: summary.cumulativeMetricsAtLastTimestamp,
uniqueUsersOverTime: summary.uniqueUsersOverTime,
cumulativeMetricsOverTime: summary.cumulativeMetricsOverTime,
};
}
export function buildChannelInsightsTable(summary: ChannelInsightsSummary): string {
const sections: string[] = [];
sections.push(chalk.bold('Channel insights:'));
sections.push(
formatFields([
{ label: 'Channel', value: summary.channelName },
{ label: 'Runtime version', value: summary.runtimeVersion },
{ label: 'Time range', value: formatTimespan(summary) },
{
label: 'Embedded update users',
value: summary.embeddedUpdateTotalUniqueUsers.toLocaleString(),
},
{ label: 'OTA update users', value: summary.otaTotalUniqueUsers.toLocaleString() },
])
);
if (summary.cumulativeMetricsAtLastTimestamp.length > 0) {
sections.push('');
sections.push(chalk.bold('Cumulative metrics at last timestamp:'));
sections.push(
formatFields(
summary.cumulativeMetricsAtLastTimestamp.map(m => ({
label: m.label,
value: m.data.toLocaleString(),
}))
)
);
}
if (summary.mostPopularUpdates.length > 0) {
sections.push('');
sections.push(chalk.bold(`Most popular updates${formatTrailingTimespan(summary)}:`));
sections.push(renderMostPopularTable(summary.mostPopularUpdates));
} else {
sections.push('');
sections.push(chalk.yellow('No update launches recorded for this channel and runtime.'));
}
return sections.join('\n');
}
function formatTrailingTimespan(summary: ChannelInsightsSummary): string {
return summary.daysBack ? ` (last ${summary.daysBack} days)` : '';
}
function renderMostPopularTable(rows: ChannelMostPopularUpdate[]): string {
return renderTextTable(
['#', 'Group ID', 'Platform', 'Unique users', 'Message'],
rows.map(r => [
String(r.rank),
r.groupId,
r.platform,
r.totalUniqueUsers.toLocaleString(),
r.message ?? '',
])
);
}
@@ -0,0 +1,118 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { ChannelInsightsQuery } from '../../../graphql/queries/ChannelInsightsQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import ChannelInsights from '../insights';
jest.mock('../../../graphql/queries/ChannelInsightsQuery', () => ({
ChannelInsightsQuery: { viewChannelRuntimeInsightsAsync: jest.fn() },
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockViewChannelRuntimeInsightsAsync = jest.mocked(
ChannelInsightsQuery.viewChannelRuntimeInsightsAsync
);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
const insightsResponse = {
__typename: 'UpdateChannelRuntimeInsights' as const,
id: 'rti',
embeddedUpdateTotalUniqueUsers: 50,
mostPopularUpdates: [
{
__typename: 'Update' as const,
id: 'u1',
group: 'group-a',
message: 'first',
runtimeVersion: '1.0.0',
platform: 'ios',
insights: { __typename: 'UpdateInsights' as const, id: 'u1-i', totalUniqueUsers: 200 },
},
],
uniqueUsersOverTime: {
__typename: 'UniqueUsersOverTimeData' as const,
data: {
__typename: 'LineChartData' as const,
labels: ['2026-04-09'],
datasets: [{ __typename: 'LineDataset' as const, id: 'd1', label: 'iOS', data: [50] }],
},
},
cumulativeMetricsOverTime: {
__typename: 'ChannelRuntimeCumulativeMetricsOverTimeData' as const,
data: {
__typename: 'LineChartData' as const,
labels: ['2026-04-09'],
datasets: [{ __typename: 'LineDataset' as const, id: 'cd1', label: 'Launches', data: [42] }],
},
metricsAtLastTimestamp: [
{ __typename: 'LineDatapoint' as const, id: 'launches', label: 'Launches', data: 42 },
],
},
};
describe(ChannelInsights, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
const projectId = 'project-1';
beforeEach(() => {
jest.clearAllMocks();
mockViewChannelRuntimeInsightsAsync.mockResolvedValue(insightsResponse);
});
function createCommand(argv: string[]): ChannelInsights {
const command = new ChannelInsights(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId,
loggedIn: { graphqlClient },
});
return command;
}
it('passes appId, channel, runtimeVersion and default 7-day timespan to the query', async () => {
const now = new Date('2026-04-16T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['--channel', 'production', '--runtime-version', '1.0.0']);
await command.runAsync();
expect(mockViewChannelRuntimeInsightsAsync).toHaveBeenCalledWith(graphqlClient, {
appId: projectId,
channelName: 'production',
runtimeVersion: '1.0.0',
startTime: '2026-04-09T12:00:00.000Z',
endTime: '2026-04-16T12:00:00.000Z',
});
jest.useRealTimers();
});
it('emits JSON when --json is passed', async () => {
const command = createCommand([
'--channel',
'production',
'--runtime-version',
'1.0.0',
'--json',
'--non-interactive',
]);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith(
expect.objectContaining({
channel: 'production',
runtimeVersion: '1.0.0',
embeddedUpdateTotalUniqueUsers: 50,
})
);
});
it('requires --channel and --runtime-version', async () => {
const command = createCommand([]);
await expect(command.runAsync()).rejects.toThrow();
});
});
@@ -0,0 +1,88 @@
import { Flags } from '@oclif/core';
import {
buildChannelInsightsJson,
buildChannelInsightsTable,
toChannelInsightsSummary,
} from '../../channel/insights/formatInsights';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { ChannelInsightsQuery } from '../../graphql/queries/ChannelInsightsQuery';
import { resolveInsightsTimeRange } from '../../insights/timeRange';
import Log from '../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ChannelInsights extends EasCommand {
static override description =
'display adoption, crash, and unique-user insights for a channel + runtime version';
static override flags = {
channel: Flags.string({
description: 'Name of the channel.',
required: true,
}),
'runtime-version': Flags.string({
description: 'Runtime version to query insights for.',
required: true,
}),
days: Flags.integer({
description:
'Show insights from the last N days (default 7, mutually exclusive with --start/--end).',
min: 1,
exclusive: ['start', 'end'],
}),
start: Flags.string({
description: 'Start of insights time range (ISO date).',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of insights time range (ISO date).',
exclusive: ['days'],
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ChannelInsights);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ChannelInsights, { nonInteractive });
if (json) {
enableJsonOutput();
}
const { daysBack, startTime, endTime } = resolveInsightsTimeRange(flags);
const insights = await ChannelInsightsQuery.viewChannelRuntimeInsightsAsync(graphqlClient, {
appId: projectId,
channelName: flags.channel,
runtimeVersion: flags['runtime-version'],
startTime,
endTime,
});
const summary = toChannelInsightsSummary(flags.channel, flags['runtime-version'], insights, {
startTime,
endTime,
daysBack,
});
if (json) {
printJsonOnlyOutput(buildChannelInsightsJson(summary));
} else {
Log.addNewLineIfNone();
Log.log(buildChannelInsightsTable(summary));
}
}
}
@@ -199,6 +199,8 @@ describe(ObserveMetrics, () => {
expect.any(Map),
expect.any(Array),
['min', 'average'],
expect.any(Map),
expect.any(Map),
expect.any(Map)
);
expect(mockPrintJsonOnlyOutput).toHaveBeenCalled();
@@ -81,16 +81,29 @@ export default class ObserveEvents extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags, args } = await this.parse(ObserveEvents);
const {
projectId: contextProjectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveEvents, {
nonInteractive: flags['non-interactive'],
});
const projectId = flags['project-id'] ?? contextProjectId;
let projectId: string;
let graphqlClient;
if (flags['project-id']) {
projectId = flags['project-id'];
const ctx = await this.getContextAsync(
{ contextDefinition: ObserveEvents.loggedInOnlyContextDefinition },
{ nonInteractive: flags['non-interactive'] }
);
graphqlClient = ctx.loggedIn.graphqlClient;
} else {
const ctx = await this.getContextAsync(ObserveEvents, {
nonInteractive: flags['non-interactive'],
});
projectId = ctx.projectId;
graphqlClient = ctx.loggedIn.graphqlClient;
}
if (flags.json) {
enableJsonOutput();
@@ -78,16 +78,29 @@ export default class ObserveMetrics extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveMetrics);
const {
projectId: contextProjectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveMetrics, {
nonInteractive: flags['non-interactive'],
});
const projectId = flags['project-id'] ?? contextProjectId;
let projectId: string;
let graphqlClient;
if (flags['project-id']) {
projectId = flags['project-id'];
const ctx = await this.getContextAsync(
{ contextDefinition: ObserveMetrics.loggedInOnlyContextDefinition },
{ nonInteractive: flags['non-interactive'] }
);
graphqlClient = ctx.loggedIn.graphqlClient;
} else {
const ctx = await this.getContextAsync(ObserveMetrics, {
nonInteractive: flags['non-interactive'],
});
projectId = ctx.projectId;
graphqlClient = ctx.loggedIn.graphqlClient;
}
if (flags.json) {
enableJsonOutput();
@@ -122,7 +135,14 @@ export default class ObserveMetrics extends EasCommand {
if (flags.json) {
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_JSON;
printJsonOnlyOutput(
buildObserveMetricsJson(metricsMap, metricNames, stats, totalEventCounts)
buildObserveMetricsJson(
metricsMap,
metricNames,
stats,
totalEventCounts,
buildNumbersMap,
updateIdsMap
)
);
} else {
const stats: StatisticKey[] = argumentsStat ?? DEFAULT_STATS_TABLE;
@@ -131,7 +151,6 @@ export default class ObserveMetrics extends EasCommand {
buildObserveMetricsTable(metricsMap, metricNames, stats, {
daysBack,
buildNumbersMap,
updateIdsMap,
totalEventCounts,
})
);
@@ -42,16 +42,29 @@ export default class ObserveVersions extends EasCommand {
...this.ContextOptions.LoggedIn,
};
private static loggedInOnlyContextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(ObserveVersions);
const {
projectId: contextProjectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(ObserveVersions, {
nonInteractive: flags['non-interactive'],
});
const projectId = flags['project-id'] ?? contextProjectId;
let projectId: string;
let graphqlClient;
if (flags['project-id']) {
projectId = flags['project-id'];
const ctx = await this.getContextAsync(
{ contextDefinition: ObserveVersions.loggedInOnlyContextDefinition },
{ nonInteractive: flags['non-interactive'] }
);
graphqlClient = ctx.loggedIn.graphqlClient;
} else {
const ctx = await this.getContextAsync(ObserveVersions, {
nonInteractive: flags['non-interactive'],
});
projectId = ctx.projectId;
graphqlClient = ctx.loggedIn.graphqlClient;
}
if (flags.json) {
enableJsonOutput();
@@ -0,0 +1,175 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { UpdateInsightsQuery } from '../../../graphql/queries/UpdateInsightsQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import UpdateInsights from '../insights';
jest.mock('../../../graphql/queries/UpdateInsightsQuery', () => ({
UpdateInsightsQuery: { viewUpdateGroupInsightsAsync: jest.fn() },
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockViewUpdateGroupInsightsAsync = jest.mocked(
UpdateInsightsQuery.viewUpdateGroupInsightsAsync
);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeUpdateResponse(platform: 'ios' | 'android', totalInstalls = 990): any {
return {
__typename: 'Update',
id: `${platform}-update`,
platform,
insights: {
__typename: 'UpdateInsights',
id: `${platform}-i`,
totalUniqueUsers: 100,
cumulativeAverageMetrics: {
__typename: 'CumulativeAverageMetrics',
launchAssetCount: 3,
averageUpdatePayloadBytes: 1024,
},
cumulativeMetrics: {
__typename: 'CumulativeMetrics',
metricsAtLastTimestamp: {
__typename: 'CumulativeMetricsTotals',
totalInstalls,
totalFailedInstalls: 10,
},
data: {
__typename: 'UpdatesMetricsData',
labels: ['2026-04-09'],
installsDataset: {
__typename: 'CumulativeUpdatesDataset',
id: `${platform}-i`,
label: 'Installs',
cumulative: [totalInstalls],
difference: [totalInstalls],
},
failedInstallsDataset: {
__typename: 'CumulativeUpdatesDataset',
id: `${platform}-f`,
label: 'Failed installs',
cumulative: [10],
difference: [10],
},
},
},
},
};
}
const updatesResponse = [makeUpdateResponse('android', 500), makeUpdateResponse('ios', 990)];
describe(UpdateInsights, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
mockViewUpdateGroupInsightsAsync.mockResolvedValue(updatesResponse);
});
function createCommand(argv: string[]): UpdateInsights {
const command = new UpdateInsights(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
});
return command;
}
it('uses default 7-day window when --days/--start/--end are not provided', async () => {
const now = new Date('2026-04-16T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['group-1']);
await command.runAsync();
expect(mockViewUpdateGroupInsightsAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
groupId: 'group-1',
startTime: '2026-04-09T12:00:00.000Z',
endTime: '2026-04-16T12:00:00.000Z',
})
);
jest.useRealTimers();
});
it('uses --days when explicitly provided', async () => {
const now = new Date('2026-04-16T12:00:00.000Z');
jest.useFakeTimers({ now });
const command = createCommand(['group-1', '--days', '14']);
await command.runAsync();
expect(mockViewUpdateGroupInsightsAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
startTime: '2026-04-02T12:00:00.000Z',
endTime: '2026-04-16T12:00:00.000Z',
})
);
jest.useRealTimers();
});
it('uses --start and --end when provided', async () => {
const command = createCommand([
'group-1',
'--start',
'2026-01-01T00:00:00.000Z',
'--end',
'2026-02-01T00:00:00.000Z',
]);
await command.runAsync();
expect(mockViewUpdateGroupInsightsAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({
startTime: '2026-01-01T00:00:00.000Z',
endTime: '2026-02-01T00:00:00.000Z',
})
);
});
it('rejects --days combined with --start', async () => {
const command = createCommand([
'group-1',
'--days',
'7',
'--start',
'2026-01-01T00:00:00.000Z',
]);
await expect(command.runAsync()).rejects.toThrow();
});
it('filters to a single platform when --platform is passed', async () => {
const command = createCommand(['group-1', '--platform', 'ios', '--json', '--non-interactive']);
await command.runAsync();
const json = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
expect(json.platforms.map((p: any) => p.platform)).toEqual(['ios']);
});
it('throws when --platform does not match any update in the group', async () => {
mockViewUpdateGroupInsightsAsync.mockResolvedValue([makeUpdateResponse('android', 500)]);
const command = createCommand(['group-1', '--platform', 'ios']);
await expect(command.runAsync()).rejects.toThrow(/no ios update/);
});
it('emits JSON when --json is passed', async () => {
const command = createCommand(['group-1', '--json', '--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
const json = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
expect(json.groupId).toBe('group-1');
expect(json.platforms.map((p: any) => p.platform)).toEqual(['android', 'ios']);
expect(json.platforms[0].totals.installs).toBe(500);
expect(json.platforms[1].totals.installs).toBe(990);
});
});
@@ -0,0 +1,158 @@
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { getMockOclifConfig } from '../../../__tests__/commands/utils';
import { UpdateInsightsQuery } from '../../../graphql/queries/UpdateInsightsQuery';
import { UpdateQuery } from '../../../graphql/queries/UpdateQuery';
import Log from '../../../log';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import UpdateView from '../view';
jest.mock('../../../graphql/queries/UpdateQuery', () => ({
UpdateQuery: { viewUpdateGroupAsync: jest.fn() },
}));
jest.mock('../../../graphql/queries/UpdateInsightsQuery', () => ({
UpdateInsightsQuery: { viewUpdateGroupInsightsAsync: jest.fn() },
}));
jest.mock('../../../log');
jest.mock('../../../utils/json');
const mockViewUpdateGroupAsync = jest.mocked(UpdateQuery.viewUpdateGroupAsync);
const mockViewUpdateGroupInsightsAsync = jest.mocked(
UpdateInsightsQuery.viewUpdateGroupInsightsAsync
);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
const mockLogLog = jest.mocked(Log.log);
const updateGroup = [
{
__typename: 'Update' as const,
id: 'u1',
group: 'group-1',
branch: { __typename: 'UpdateBranch' as const, id: 'b1', name: 'main' },
actor: null,
createdAt: '2026-04-09T00:00:00.000Z',
message: 'first',
runtimeVersion: '1.0.0',
platform: 'ios',
manifestPermalink: 'https://expo.dev/manifest/u1',
isRollBackToEmbedded: false,
rolloutPercentage: null,
codeSigningInfo: null,
gitCommitHash: null,
} as any,
];
function makeUpdateWithInsights(platform: 'ios' | 'android', totalInstalls = 990): any {
return {
__typename: 'Update',
id: `${platform}-update`,
platform,
insights: {
__typename: 'UpdateInsights',
id: `${platform}-i`,
totalUniqueUsers: 100,
cumulativeAverageMetrics: {
__typename: 'CumulativeAverageMetrics',
launchAssetCount: 3,
averageUpdatePayloadBytes: 1024,
},
cumulativeMetrics: {
__typename: 'CumulativeMetrics',
metricsAtLastTimestamp: {
__typename: 'CumulativeMetricsTotals',
totalInstalls,
totalFailedInstalls: 10,
},
data: {
__typename: 'UpdatesMetricsData',
labels: ['2026-04-09'],
installsDataset: {
__typename: 'CumulativeUpdatesDataset',
id: `${platform}-i`,
label: 'Installs',
cumulative: [totalInstalls],
difference: [totalInstalls],
},
failedInstallsDataset: {
__typename: 'CumulativeUpdatesDataset',
id: `${platform}-f`,
label: 'Failed installs',
cumulative: [10],
difference: [10],
},
},
},
},
};
}
const insightsResponse = [makeUpdateWithInsights('ios', 990)];
describe(UpdateView, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
mockViewUpdateGroupAsync.mockResolvedValue(updateGroup);
mockViewUpdateGroupInsightsAsync.mockResolvedValue(insightsResponse);
});
function createCommand(argv: string[]): UpdateView {
const command = new UpdateView(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
});
return command;
}
it('does not fetch insights when --insights is not set', async () => {
const command = createCommand(['group-1']);
await command.runAsync();
expect(mockViewUpdateGroupInsightsAsync).not.toHaveBeenCalled();
});
it('rejects --days without --insights', async () => {
const command = createCommand(['group-1', '--days', '7']);
await expect(command.runAsync()).rejects.toThrow(/--insights/);
});
it('rejects --start without --insights', async () => {
const command = createCommand(['group-1', '--start', '2026-04-01']);
await expect(command.runAsync()).rejects.toThrow(/--insights/);
});
it('fetches insights when --insights is set', async () => {
const command = createCommand(['group-1', '--insights']);
await command.runAsync();
expect(mockViewUpdateGroupInsightsAsync).toHaveBeenCalledWith(
graphqlClient,
expect.objectContaining({ groupId: 'group-1' })
);
});
it('outputs flat update array under --json without --insights', async () => {
const command = createCommand(['group-1', '--json']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
const arg = mockPrintJsonOnlyOutput.mock.calls[0][0];
expect(Array.isArray(arg)).toBe(true);
});
it('renders an insights section in the table output when --insights is set without --json', async () => {
const command = createCommand(['group-1', '--insights']);
await command.runAsync();
const logged = mockLogLog.mock.calls.map(call => String(call[0] ?? '')).join('\n');
expect(logged).toMatch(/Update group insights/);
});
it('wraps output as { updates, insights } under --json --insights', async () => {
const command = createCommand(['group-1', '--json', '--insights']);
await command.runAsync();
const arg = mockPrintJsonOnlyOutput.mock.calls[0][0] as any;
expect(arg.updates).toBeDefined();
expect(arg.insights).toBeDefined();
expect(arg.insights.platforms[0].totals.installs).toBe(990);
});
});
@@ -0,0 +1,97 @@
import { Args, Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { UpdateInsightsQuery } from '../../graphql/queries/UpdateInsightsQuery';
import { INSIGHTS_DEFAULT_DAYS_BACK, resolveInsightsTimeRange } from '../../insights/timeRange';
import Log from '../../log';
import {
buildUpdateInsightsJson,
buildUpdateInsightsTable,
toUpdateInsightsSummary,
} from '../../update/insights/formatInsights';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class UpdateInsights extends EasCommand {
static override description =
'display launch, crash, unique-user, and size insights for an update group';
static override args = {
groupId: Args.string({
required: true,
description: 'The ID of an update group.',
}),
};
static override flags = {
platform: Flags.option({
description: 'Filter to a single platform.',
options: ['ios', 'android'] as const,
})(),
days: Flags.integer({
description: `Show insights from the last N days (default ${INSIGHTS_DEFAULT_DAYS_BACK}, mutually exclusive with --start/--end).`,
min: 1,
exclusive: ['start', 'end'],
}),
start: Flags.string({
description: 'Start of insights time range (ISO date).',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of insights time range (ISO date).',
exclusive: ['days'],
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const {
args: { groupId },
flags,
} = await this.parse(UpdateInsights);
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
const {
loggedIn: { graphqlClient },
} = await this.getContextAsync(UpdateInsights, { nonInteractive });
if (json) {
enableJsonOutput();
}
const { daysBack, startTime, endTime } = resolveInsightsTimeRange(flags);
const allUpdates = await UpdateInsightsQuery.viewUpdateGroupInsightsAsync(graphqlClient, {
groupId,
startTime,
endTime,
});
const updates = flags.platform
? allUpdates.filter(u => u.platform === flags.platform)
: allUpdates;
if (updates.length === 0) {
throw new Error(
`Update group "${groupId}" has no ${flags.platform} update (available platforms: ${allUpdates
.map(u => u.platform)
.sort()
.join(', ')}).`
);
}
const summary = toUpdateInsightsSummary(groupId, updates, { startTime, endTime, daysBack });
if (json) {
printJsonOnlyOutput(buildUpdateInsightsJson(summary));
} else {
Log.addNewLineIfNone();
Log.log(buildUpdateInsightsTable(summary));
}
}
}
+59 -3
View File
@@ -1,10 +1,18 @@
import { Args } from '@oclif/core';
import { Args, Flags } from '@oclif/core';
import chalk from 'chalk';
import EasCommand from '../../commandUtils/EasCommand';
import { EasJsonOnlyFlag } from '../../commandUtils/flags';
import { UpdateInsightsQuery } from '../../graphql/queries/UpdateInsightsQuery';
import { UpdateQuery } from '../../graphql/queries/UpdateQuery';
import { resolveInsightsTimeRange } from '../../insights/timeRange';
import Log from '../../log';
import {
UpdateInsightsSummary,
buildUpdateInsightsJson,
buildUpdateInsightsTable,
toUpdateInsightsSummary,
} from '../../update/insights/formatInsights';
import {
formatUpdateGroup,
getUpdateGroupDescriptions,
@@ -23,6 +31,24 @@ export default class UpdateView extends EasCommand {
};
static override flags = {
insights: Flags.boolean({
description:
'Also show insights (launches, crash rate, unique users, payload size) for the update group.',
default: false,
}),
days: Flags.integer({
description: 'Show insights from the last N days (default 7). Only used with --insights.',
min: 1,
exclusive: ['start', 'end'],
}),
start: Flags.string({
description: 'Start of insights time range (ISO date). Only used with --insights.',
exclusive: ['days'],
}),
end: Flags.string({
description: 'End of insights time range (ISO date). Only used with --insights.',
exclusive: ['days'],
}),
...EasJsonOnlyFlag,
};
@@ -33,9 +59,13 @@ export default class UpdateView extends EasCommand {
async runAsync(): Promise<void> {
const {
args: { groupId },
flags: { json: jsonFlag },
flags: { json: jsonFlag, insights: insightsFlag, days, start, end },
} = await this.parse(UpdateView);
if (!insightsFlag && (days !== undefined || start !== undefined || end !== undefined)) {
throw new Error('--days, --start, and --end can only be used with --insights.');
}
const {
loggedIn: { graphqlClient },
} = await this.getContextAsync(UpdateView, { nonInteractive: true });
@@ -46,14 +76,40 @@ export default class UpdateView extends EasCommand {
const updatesByGroup = await UpdateQuery.viewUpdateGroupAsync(graphqlClient, { groupId });
let insightsSummary: UpdateInsightsSummary | null = null;
if (insightsFlag) {
const { daysBack, startTime, endTime } = resolveInsightsTimeRange({ days, start, end });
const updatesWithInsights = await UpdateInsightsQuery.viewUpdateGroupInsightsAsync(
graphqlClient,
{ groupId, startTime, endTime }
);
insightsSummary = toUpdateInsightsSummary(groupId, updatesWithInsights, {
startTime,
endTime,
daysBack,
});
}
if (jsonFlag) {
printJsonOnlyOutput(getUpdateJsonInfosForUpdates(updatesByGroup));
if (insightsSummary) {
printJsonOnlyOutput({
updates: getUpdateJsonInfosForUpdates(updatesByGroup),
insights: buildUpdateInsightsJson(insightsSummary),
});
} else {
printJsonOnlyOutput(getUpdateJsonInfosForUpdates(updatesByGroup));
}
} else {
const [updateGroupDescription] = getUpdateGroupDescriptions([updatesByGroup]);
Log.log(chalk.bold('Update group:'));
Log.log(formatUpdateGroup(updateGroupDescription));
if (insightsSummary) {
Log.addNewLineIfNone();
Log.log(buildUpdateInsightsTable(insightsSummary));
}
}
}
}
@@ -12,6 +12,29 @@ const mockItems = {
},
};
const buildBundleInfo = (linked: {
appGroups: Array<{ id: string }>;
merchantIds: Array<{ id: string }>;
cloudContainers: Array<{ id: string }>;
}): any => ({
attributes: {
bundleIdCapabilities: [
{
isType: (t: string) => t === 'APPLE_PAY',
attributes: { merchantIds: linked.merchantIds },
},
{
isType: (t: string) => t === 'APP_GROUPS',
attributes: { appGroups: linked.appGroups },
},
{
isType: (t: string) => t === 'ICLOUD',
attributes: { cloudContainers: linked.cloudContainers },
},
],
},
});
function mockCapabilities(Apple: any): void {
const mockGetAsync = (existingItems: any[]): jest.Mock =>
jest.fn(() => Promise.resolve(existingItems));
@@ -35,6 +58,16 @@ function mockCapabilities(Apple: any): void {
Apple.CloudContainer.getAsync = mockGetAsync([mockItems.cloudContainer.expo]);
Apple.CloudContainer.createAsync = mockCreateAsync('XXX-icloud-2');
Apple.BundleId.infoAsync = jest.fn(() =>
Promise.resolve(
buildBundleInfo({
merchantIds: [mockItems.merchant.expo],
appGroups: [mockItems.appGroup.expo],
cloudContainers: [mockItems.cloudContainer.expo],
})
)
);
}
function mockCapabilitiesAlreadyCreated(Apple: any): void {
@@ -52,6 +85,23 @@ function mockCapabilitiesAlreadyCreated(Apple: any): void {
mockItems.cloudContainer.expo,
{ id: 'XXX-icloud-2', attributes: { identifier: 'iCloud.bacon' } },
]);
Apple.BundleId.infoAsync.mockResolvedValue(
buildBundleInfo({
merchantIds: [
mockItems.merchant.expo,
{ id: 'XXX-merch-2', attributes: { identifier: 'merchant.bacon' } },
],
appGroups: [
mockItems.appGroup.expo,
{ id: 'XXX-group-2', attributes: { identifier: 'group.bacon' } },
],
cloudContainers: [
mockItems.cloudContainer.expo,
{ id: 'XXX-icloud-2', attributes: { identifier: 'iCloud.bacon' } },
],
})
);
}
describe(syncCapabilityIdentifiersForEntitlementsAsync, () => {
@@ -44,6 +44,20 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
const updateRequest: UpdateCapabilityRequest = [];
// Fetch the bundle with its linked capability identifiers to check for already linked identifiers.
const bundleWithRelationships = await BundleId.infoAsync(bundleId.context, {
id: bundleId.id,
query: {
includes: [
'bundleIdCapabilities',
'bundleIdCapabilities.appGroups',
'bundleIdCapabilities.merchantIds',
'bundleIdCapabilities.cloudContainers',
],
},
});
const linkedBundleCapabilities = bundleWithRelationships.attributes.bundleIdCapabilities ?? [];
// Iterate through the supported capabilities to build the request.
for (const classifier of CapabilityIdMapping) {
const CapabilityModel = classifier.capabilityIdModel;
@@ -73,15 +87,29 @@ export async function syncCapabilityIdentifiersForEntitlementsAsync(
// Get a list of all of the capability IDs that are already created on the server.
const existingIds = await CapabilityModel.getAsync(bundleId.context);
// A list of server IDs for linking.
const capabilityIdOpaqueIds = [];
const capabilitiesWithoutRemoteModels = capabilityIds.filter(
localId => existingIds.find(model => model.attributes.identifier === localId) === undefined
// Opaque ids of identifiers already linked to this capability.
const remoteLinkedIds = linkedBundleCapabilities.find(c => c.isType(classifier.capability))
?.attributes as Record<string, { id: string }[] | undefined> | undefined;
const alreadyLinkedOpaqueIds = new Set<string>(
(remoteLinkedIds?.[CapabilityModel.type] ?? []).map(model => model.id)
);
// A list of server IDs for linking.
const capabilityIdOpaqueIds: string[] = [];
// Iterate through all the local IDs and see if they exist on the server.
for (const localId of capabilitiesWithoutRemoteModels) {
let remoteIdModel = undefined;
for (const localId of capabilityIds) {
let remoteIdModel = existingIds.find(model => model.attributes.identifier === localId);
// Identifier already exists.
if (remoteIdModel) {
if (!alreadyLinkedOpaqueIds.has(remoteIdModel.id)) {
// Link the existing identifier to this bundle.
linkedIds.push(remoteIdModel.attributes.identifier);
capabilityIdOpaqueIds.push(remoteIdModel.id);
}
continue;
}
if (Log.isDebug) {
Log.log(`Creating capability ID: ${localId} (${CapabilityModel.type})`);
@@ -0,0 +1,49 @@
import { CombinedError } from '@urql/core';
import {
EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE,
withUpgradeRequiredErrorHandlingAsync,
} from '../client';
function makeError(message: string, extensions?: Record<string, unknown>): CombinedError {
return new CombinedError({
graphQLErrors: [{ message, extensions } as any],
});
}
describe(withUpgradeRequiredErrorHandlingAsync, () => {
it('returns data when the promise resolves successfully', async () => {
const result = await withUpgradeRequiredErrorHandlingAsync(
Promise.resolve({ data: { foo: 'bar' } } as any),
{ featureName: 'feature' }
);
expect(result).toEqual({ foo: 'bar' });
});
it('throws an upgrade-required error when the server returns the matching errorCode', async () => {
const error = makeError('Schema changed', { errorCode: EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE });
await expect(
withUpgradeRequiredErrorHandlingAsync(Promise.resolve({ error } as any), {
featureName: 'EAS Update insights',
})
).rejects.toThrow(/EAS Update insights is not supported by this version of eas-cli/);
});
it('throws an upgrade-required error when the server returns a "Cannot query field" validation error', async () => {
const error = makeError('Cannot query field "newField" on type "UpdateInsights".');
await expect(
withUpgradeRequiredErrorHandlingAsync(Promise.resolve({ error } as any), {
featureName: 'feature',
})
).rejects.toThrow(/not supported by this version of eas-cli/);
});
it('rethrows other GraphQL errors as-is', async () => {
const error = makeError('Random other error', { errorCode: 'SOMETHING_ELSE' });
await expect(
withUpgradeRequiredErrorHandlingAsync(Promise.resolve({ error } as any), {
featureName: 'feature',
})
).rejects.toBe(error);
});
});
+41
View File
@@ -1,7 +1,10 @@
import { CombinedError as GraphqlError, OperationResult } from '@urql/core';
import { EasCommandError } from '../commandUtils/errors';
import Log from '../log';
export const EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE = 'EAS_CLI_UPGRADE_REQUIRED';
export async function withErrorHandlingAsync<T>(promise: Promise<OperationResult<T>>): Promise<T> {
const { data, error } = await promise;
@@ -30,4 +33,42 @@ export async function withErrorHandlingAsync<T>(promise: Promise<OperationResult
return data;
}
/**
* Wraps `withErrorHandlingAsync` for queries that hit endpoints which may evolve in
* ways that require a newer eas-cli. The server signals this by returning a GraphQL
* error with `extensions.errorCode === EAS_CLI_UPGRADE_REQUIRED`. As a fallback we
* also detect schema validation errors of the form `Cannot query field "X" on type "Y"`,
* which surface when a field has been removed without a coded error.
*
* In either case we re-throw an `EasCommandError` instructing the user to upgrade.
*/
export async function withUpgradeRequiredErrorHandlingAsync<T>(
promise: Promise<OperationResult<T>>,
{ featureName }: { featureName: string }
): Promise<T> {
try {
return await withErrorHandlingAsync(promise);
} catch (error) {
if (isUpgradeRequiredError(error)) {
throw new EasCommandError(
`${featureName} is not supported by this version of eas-cli. ` +
`Upgrade to the latest version: \`npm install -g eas-cli@latest\`.`
);
}
throw error;
}
}
function isUpgradeRequiredError(error: unknown): boolean {
if (!(error instanceof GraphqlError)) {
return false;
}
return error.graphQLErrors.some(e => {
if (e?.extensions?.errorCode === EAS_CLI_UPGRADE_REQUIRED_ERROR_CODE) {
return true;
}
return /Cannot query field ".+" on type ".+"/.test(e?.message ?? '');
});
}
export { GraphqlError };
+108 -2
View File
@@ -646,6 +646,8 @@ export type AccountMutation = {
cancelAllSubscriptionsImmediately: Account;
/** Cancel scheduled subscription change */
cancelScheduledSubscriptionChange: Account;
/** Buys or revokes account's additional agent credits, charging the account the appropriate amount if needed. */
changeAdditionalAgentCreditsCount: Account;
/** Buys or revokes account's additional concurrencies, charging the account the appropriate amount if needed. */
changeAdditionalConcurrenciesCount: Account;
/** Upgrades or downgrades the active subscription to the newPlanIdentifier, which must be one of the EAS plans (i.e., Production or Enterprise). */
@@ -686,6 +688,12 @@ export type AccountMutationCancelScheduledSubscriptionChangeArgs = {
};
export type AccountMutationChangeAdditionalAgentCreditsCountArgs = {
accountID: Scalars['ID']['input'];
newAdditionalAgentCreditsCount: Scalars['Int']['input'];
};
export type AccountMutationChangeAdditionalConcurrenciesCountArgs = {
accountID: Scalars['ID']['input'];
newAdditionalConcurrenciesCount: Scalars['Int']['input'];
@@ -775,8 +783,11 @@ export type AccountNotificationPreferenceInput = {
export type AccountOnboardingStats = {
__typename?: 'AccountOnboardingStats';
firstBuildCompletedAt?: Maybe<Scalars['DateTime']['output']>;
firstProjectCreatedAt?: Maybe<Scalars['DateTime']['output']>;
firstSubmissionCompletedAt?: Maybe<Scalars['DateTime']['output']>;
firstUpdateCreatedAt?: Maybe<Scalars['DateTime']['output']>;
hasConfiguredUpdate: Scalars['Boolean']['output'];
hasConfiguredWorkflow: Scalars['Boolean']['output'];
hasTeamMembers: Scalars['Boolean']['output'];
};
@@ -4074,6 +4085,7 @@ export type ConvexTeamConnectionMutation = {
__typename?: 'ConvexTeamConnectionMutation';
createConvexTeamConnection: ConvexTeamConnection;
deleteConvexTeamConnection: ConvexTeamConnection;
sendConvexTeamInviteToVerifiedEmail: Scalars['Boolean']['output'];
};
@@ -4086,6 +4098,11 @@ export type ConvexTeamConnectionMutationDeleteConvexTeamConnectionArgs = {
convexTeamConnectionId: Scalars['ID']['input'];
};
export type ConvexTeamConnectionMutationSendConvexTeamInviteToVerifiedEmailArgs = {
input: SendConvexTeamInviteToVerifiedEmailInput;
};
export enum CrashSampleFor {
Newest = 'NEWEST',
Oldest = 'OLDEST'
@@ -4184,6 +4201,14 @@ export type CreateEchoMessagePartInput = {
type: EchoMessagePartType;
};
export type CreateEchoProjectIconInput = {
accentColor?: InputMaybe<Scalars['String']['input']>;
model?: InputMaybe<Scalars['String']['input']>;
prompt?: InputMaybe<Scalars['String']['input']>;
source: EchoProjectIconSource;
url: Scalars['String']['input'];
};
export type CreateEchoProjectInput = {
accountId: Scalars['ID']['input'];
currentPreviewEchoVersionId?: InputMaybe<Scalars['ID']['input']>;
@@ -5074,6 +5099,7 @@ export type EchoProject = {
description?: Maybe<Scalars['String']['output']>;
displayName?: Maybe<Scalars['String']['output']>;
echoChats: EchoChatConnection;
echoProjectIcon?: Maybe<EchoProjectIconGalleryItem>;
echoVersions: EchoVersionConnection;
/** Environment variables for this Echo project */
environmentVariables: Array<EnvironmentVariable>;
@@ -5084,6 +5110,7 @@ export type EchoProject = {
environmentVariablesIncludingSensitive: Array<EnvironmentVariableWithSecret>;
githubInfo?: Maybe<EchoProjectGithubInfo>;
icon?: Maybe<EchoProjectIcon>;
iconGallery: Array<EchoProjectIconGalleryItem>;
id: Scalars['ID']['output'];
images: Array<EchoProjectImage>;
initFromEchoProject?: Maybe<EchoProject>;
@@ -5156,11 +5183,27 @@ export type EchoProjectIcon = {
url: Scalars['String']['output'];
};
export type EchoProjectIconGalleryItem = {
__typename?: 'EchoProjectIconGalleryItem';
accentColor?: Maybe<Scalars['String']['output']>;
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
model?: Maybe<Scalars['String']['output']>;
prompt?: Maybe<Scalars['String']['output']>;
source: EchoProjectIconSource;
url: Scalars['String']['output'];
};
export type EchoProjectIconInput = {
accentColor?: InputMaybe<Scalars['String']['input']>;
url: Scalars['String']['input'];
};
export enum EchoProjectIconSource {
AiGenerated = 'AI_GENERATED',
UserUploaded = 'USER_UPLOADED'
}
export type EchoProjectImage = {
__typename?: 'EchoProjectImage';
createdAt: Scalars['DateTime']['output'];
@@ -5172,8 +5215,17 @@ export type EchoProjectMutation = {
__typename?: 'EchoProjectMutation';
/** Create a new Echo project */
createEchoProject: EchoProject;
/**
* Add one or more icons to the project's icon gallery.
* Used for both AI-generated batches and user uploads.
*/
createEchoProjectIcons: Array<EchoProjectIconGalleryItem>;
/** Delete an Echo project by ID */
deleteEchoProject: EchoProject;
/** Delete an icon from the gallery. If it is the active icon, clears the project's icon. */
deleteEchoProjectIcon: EchoProjectIconGalleryItem;
/** Select an icon from the gallery as the project's active icon. */
selectEchoProjectIcon: EchoProject;
/** Update an Echo project */
updateEchoProject: EchoProject;
};
@@ -5184,11 +5236,29 @@ export type EchoProjectMutationCreateEchoProjectArgs = {
};
export type EchoProjectMutationCreateEchoProjectIconsArgs = {
echoProjectId: Scalars['ID']['input'];
icons: Array<CreateEchoProjectIconInput>;
};
export type EchoProjectMutationDeleteEchoProjectArgs = {
id: Scalars['ID']['input'];
};
export type EchoProjectMutationDeleteEchoProjectIconArgs = {
echoProjectIconId: Scalars['ID']['input'];
echoProjectId: Scalars['ID']['input'];
};
export type EchoProjectMutationSelectEchoProjectIconArgs = {
echoProjectIconId: Scalars['ID']['input'];
echoProjectId: Scalars['ID']['input'];
};
export type EchoProjectMutationUpdateEchoProjectArgs = {
id: Scalars['ID']['input'];
input: UpdateEchoProjectInput;
@@ -5284,6 +5354,7 @@ export type EchoRepositoryResult = {
name: Scalars['String']['output'];
nodeIdentifier: Scalars['String']['output'];
owner: Scalars['String']['output'];
status: Scalars['String']['output'];
url: Scalars['String']['output'];
};
@@ -6354,6 +6425,8 @@ export type InvoicePeriod = {
export type InvoiceQuery = {
__typename?: 'InvoiceQuery';
/** Previews the invoice for the specified number of additional agent credit units. */
previewInvoiceForAdditionalAgentCreditsCountUpdate?: Maybe<Invoice>;
/**
* Previews the invoice for the specified number of additional concurrencies.
* This is the total number of concurrencies the customer wishes to purchase
@@ -6367,6 +6440,12 @@ export type InvoiceQuery = {
};
export type InvoiceQueryPreviewInvoiceForAdditionalAgentCreditsCountUpdateArgs = {
accountID: Scalars['ID']['input'];
additionalAgentCreditsCount: Scalars['Int']['input'];
};
export type InvoiceQueryPreviewInvoiceForAdditionalConcurrenciesCountUpdateArgs = {
accountID: Scalars['ID']['input'];
additionalConcurrenciesCount: Scalars['Int']['input'];
@@ -7099,11 +7178,13 @@ export type OAuthIdentity = {
__typename?: 'OAuthIdentity';
email: Scalars['String']['output'];
id: Scalars['ID']['output'];
login?: Maybe<Scalars['String']['output']>;
provider: OAuthProvider;
};
export enum OAuthProvider {
Apple = 'apple',
Github = 'github',
Google = 'google'
}
@@ -7953,6 +8034,7 @@ export type SsoUser = Actor & UserActor & {
/** Associated accounts */
primaryAccount: Account;
primaryAccountProfileImageUrl?: Maybe<Scalars['String']['output']>;
/** @deprecated Use primaryAccountProfileImageUrl instead */
profilePhoto: Scalars['String']['output'];
/** Snacks associated with this account */
snacks: Array<Snack>;
@@ -8042,6 +8124,10 @@ export type SecondFactorRegenerateBackupCodesResult = {
plaintextBackupCodes: Array<Scalars['String']['output']>;
};
export type SendConvexTeamInviteToVerifiedEmailInput = {
convexTeamConnectionId: Scalars['ID']['input'];
};
export type SentryInstallation = {
__typename?: 'SentryInstallation';
account: Account;
@@ -9202,6 +9288,7 @@ export type User = Actor & UserActor & {
/** Associated accounts */
primaryAccount: Account;
primaryAccountProfileImageUrl?: Maybe<Scalars['String']['output']>;
/** @deprecated Use primaryAccountProfileImageUrl instead */
profilePhoto: Scalars['String']['output'];
/** Get all certified second factor authentication methods */
secondFactorDevices: Array<UserSecondFactorDevice>;
@@ -9305,6 +9392,7 @@ export type UserActor = {
/** Associated accounts */
primaryAccount: Account;
primaryAccountProfileImageUrl?: Maybe<Scalars['String']['output']>;
/** @deprecated Use primaryAccountProfileImageUrl instead */
profilePhoto: Scalars['String']['output'];
/** Snacks associated with this user's personal account */
snacks: Array<Snack>;
@@ -11971,6 +12059,16 @@ export type ViewBuildsOnAppQueryVariables = Exact<{
export type ViewBuildsOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, builds: Array<{ __typename?: 'Build', id: string, status: BuildStatus, platform: AppPlatform, logFiles: Array<string>, channel?: string | null, distribution?: DistributionType | null, iosEnterpriseProvisioning?: BuildIosEnterpriseProvisioning | null, buildProfile?: string | null, sdkVersion?: string | null, appVersion?: string | null, appBuildVersion?: string | null, runtimeVersion?: string | null, gitCommitHash?: string | null, gitCommitMessage?: string | null, initialQueuePosition?: number | null, queuePosition?: number | null, estimatedWaitTimeLeftSeconds?: number | null, priority: BuildPriority, createdAt: any, updatedAt: any, message?: string | null, completedAt?: any | null, expirationDate?: any | null, isForIosSimulator: boolean, error?: { __typename?: 'BuildError', errorCode: string, message: string, docsUrl?: string | null } | null, artifacts?: { __typename?: 'BuildArtifacts', buildUrl?: string | null, xcodeBuildLogsUrl?: string | null, applicationArchiveUrl?: string | null, buildArtifactsUrl?: string | null } | null, fingerprint?: { __typename?: 'Fingerprint', id: string, hash: string } | null, initiatingActor?: { __typename: 'PartnerActor', id: string, displayName: string } | { __typename: 'Robot', id: string, displayName: string } | { __typename: 'SSOUser', id: string, displayName: string } | { __typename: 'User', id: string, displayName: string } | null, project: { __typename: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } } | { __typename: 'Snack', id: string, name: string, slug: string }, metrics?: { __typename?: 'BuildMetrics', buildWaitTime?: number | null, buildQueueTime?: number | null, buildDuration?: number | null } | null }> } } };
export type ViewChannelRuntimeInsightsOnAppQueryVariables = Exact<{
appId: Scalars['String']['input'];
channelName: Scalars['String']['input'];
runtimeVersion: Scalars['String']['input'];
timespan: InsightsTimespan;
}>;
export type ViewChannelRuntimeInsightsOnAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, updateChannelByName?: { __typename?: 'UpdateChannel', id: string, name: string, runtimeInsights: { __typename?: 'UpdateChannelRuntimeInsights', id: string, embeddedUpdateTotalUniqueUsers: number, mostPopularUpdates: Array<{ __typename?: 'Update', id: string, group: string, message?: string | null, runtimeVersion: string, platform: string, insights: { __typename?: 'UpdateInsights', id: string, totalUniqueUsers: number } }>, uniqueUsersOverTime: { __typename?: 'UniqueUsersOverTimeData', data: { __typename?: 'LineChartData', labels: Array<string>, datasets: Array<{ __typename?: 'LineDataset', id: string, label: string, data: Array<number | null> }> } }, cumulativeMetricsOverTime: { __typename?: 'ChannelRuntimeCumulativeMetricsOverTimeData', data: { __typename?: 'LineChartData', labels: Array<string>, datasets: Array<{ __typename?: 'LineDataset', id: string, label: string, data: Array<number | null> }> }, metricsAtLastTimestamp: Array<{ __typename?: 'LineDatapoint', id: string, label: string, data: number }> } } } | null } } };
export type ViewUpdateChannelBasicInfoOnAppQueryVariables = Exact<{
appId: Scalars['String']['input'];
channelName: Scalars['String']['input'];
@@ -12106,7 +12204,7 @@ export type AppObserveEventsQueryVariables = Exact<{
}>;
export type AppObserveEventsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', events: { __typename?: 'AppObserveEventsConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveEventEdge', cursor: string, node: { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string } }> } } } } };
export type AppObserveEventsQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, observe: { __typename?: 'AppObserve', events: { __typename?: 'AppObserveEventsConnection', pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, endCursor?: string | null }, edges: Array<{ __typename?: 'AppObserveEventEdge', cursor: string, node: { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string, customParams?: any | null } }> } } } } };
export type GetAssetMetadataQueryVariables = Exact<{
storageKeys: Array<Scalars['String']['input']> | Scalars['String']['input'];
@@ -12160,6 +12258,14 @@ export type GetAllSubmissionsForAppQueryVariables = Exact<{
export type GetAllSubmissionsForAppQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, submissions: Array<{ __typename?: 'Submission', id: string, status: SubmissionStatus, platform: AppPlatform, logFiles: Array<string>, app: { __typename?: 'App', id: string, name: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, androidConfig?: { __typename?: 'AndroidSubmissionConfig', applicationIdentifier?: string | null, track: string, releaseStatus?: SubmissionAndroidReleaseStatus | null, rollout?: number | null } | null, iosConfig?: { __typename?: 'IosSubmissionConfig', ascAppIdentifier: string, appleIdUsername?: string | null } | null, error?: { __typename?: 'SubmissionError', errorCode?: string | null, message?: string | null } | null }> } } };
export type ViewUpdateGroupInsightsQueryVariables = Exact<{
groupId: Scalars['ID']['input'];
timespan: InsightsTimespan;
}>;
export type ViewUpdateGroupInsightsQuery = { __typename?: 'RootQuery', updatesByGroup: Array<{ __typename?: 'Update', id: string, platform: string, insights: { __typename?: 'UpdateInsights', id: string, totalUniqueUsers: number, cumulativeAverageMetrics: { __typename?: 'CumulativeAverageMetrics', launchAssetCount: number, averageUpdatePayloadBytes: number }, cumulativeMetrics: { __typename?: 'CumulativeMetrics', metricsAtLastTimestamp: { __typename?: 'CumulativeMetricsTotals', totalInstalls: number, totalFailedInstalls: number }, data: { __typename?: 'UpdatesMetricsData', labels: Array<string>, installsDataset: { __typename?: 'CumulativeUpdatesDataset', id: string, label: string, cumulative: Array<number>, difference: Array<number> }, failedInstallsDataset: { __typename?: 'CumulativeUpdatesDataset', id: string, label: string, cumulative: Array<number>, difference: Array<number> } } } } }> };
export type ViewUpdatesByGroupQueryVariables = Exact<{
groupId: Scalars['ID']['input'];
}>;
@@ -12290,7 +12396,7 @@ export type FingerprintFragment = { __typename?: 'Fingerprint', id: string, hash
export type AppObserveTimeSeriesFragment = { __typename?: 'AppObserveTimeSeries', eventCount: number, appVersionMarkers: Array<{ __typename?: 'AppObserveAppVersion', appVersion: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, buildNumbers: Array<{ __typename?: 'AppObserveAppBuildNumber', appBuildNumber: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, updates: Array<{ __typename?: 'AppObserveAppUpdate', appUpdateId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, metrics: Array<{ __typename?: 'AppObserveAppVersionMetric', metricName: string, eventCount: number, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> }>, statistics: { __typename?: 'AppObserveTimeSeriesStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } };
export type AppObserveEventFragment = { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string };
export type AppObserveEventFragment = { __typename?: 'AppObserveEvent', id: string, metricName: string, metricValue: number, timestamp: any, appVersion: string, appBuildNumber: string, appUpdateId?: string | null, deviceModel: string, deviceOs: string, deviceOsVersion: string, countryCode?: string | null, sessionId?: string | null, easClientId: string, customParams?: any | null };
export type AppObserveAppVersionFragment = { __typename?: 'AppObserveAppVersion', appVersion: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, buildNumbers: Array<{ __typename?: 'AppObserveAppBuildNumber', appBuildNumber: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, updates: Array<{ __typename?: 'AppObserveAppUpdate', appUpdateId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number, easBuilds: Array<{ __typename?: 'AppObserveAppEasBuild', easBuildId: string, firstSeenAt: any, eventCount: number, uniqueUserCount: number }> }>, metrics: Array<{ __typename?: 'AppObserveAppVersionMetric', metricName: string, eventCount: number, statistics: { __typename?: 'AppObserveVersionMarkerStatistics', min?: number | null, max?: number | null, median?: number | null, average?: number | null, p80?: number | null, p90?: number | null, p99?: number | null } }> };
@@ -0,0 +1,112 @@
import gql from 'graphql-tag';
import { ChannelNotFoundError } from '../../channel/errors';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withUpgradeRequiredErrorHandlingAsync } from '../client';
import {
ViewChannelRuntimeInsightsOnAppQuery,
ViewChannelRuntimeInsightsOnAppQueryVariables,
} from '../generated';
export type ChannelRuntimeInsights = NonNullable<
ViewChannelRuntimeInsightsOnAppQuery['app']['byId']['updateChannelByName']
>['runtimeInsights'];
export const ChannelInsightsQuery = {
async viewChannelRuntimeInsightsAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
channelName,
runtimeVersion,
startTime,
endTime,
}: {
appId: string;
channelName: string;
runtimeVersion: string;
startTime: string;
endTime: string;
}
): Promise<ChannelRuntimeInsights> {
const data = await withUpgradeRequiredErrorHandlingAsync(
graphqlClient
.query<ViewChannelRuntimeInsightsOnAppQuery, ViewChannelRuntimeInsightsOnAppQueryVariables>(
gql`
query ViewChannelRuntimeInsightsOnApp(
$appId: String!
$channelName: String!
$runtimeVersion: String!
$timespan: InsightsTimespan!
) {
app {
byId(appId: $appId) {
id
updateChannelByName(name: $channelName) {
id
name
runtimeInsights {
id
embeddedUpdateTotalUniqueUsers(runtimeVersion: $runtimeVersion, timespan: $timespan)
mostPopularUpdates(runtimeVersion: $runtimeVersion, timespan: $timespan) {
id
group
message
runtimeVersion
platform
insights {
id
totalUniqueUsers(timespan: $timespan)
}
}
uniqueUsersOverTime(runtimeVersion: $runtimeVersion, timespan: $timespan) {
data {
labels
datasets {
id
label
data
}
}
}
cumulativeMetricsOverTime(runtimeVersion: $runtimeVersion, timespan: $timespan) {
data {
labels
datasets {
id
label
data
}
}
metricsAtLastTimestamp {
id
label
data
}
}
}
}
}
}
}
`,
{
appId,
channelName,
runtimeVersion,
timespan: { start: startTime, end: endTime },
},
{ additionalTypenames: ['UpdateChannel', 'Update', 'UpdateChannelRuntimeInsights'] }
)
.toPromise(),
{ featureName: 'EAS Update channel insights' }
);
const updateChannel = data.app.byId.updateChannelByName;
if (!updateChannel) {
throw new ChannelNotFoundError(`Could not find channel with the name ${channelName}`);
}
return updateChannel.runtimeInsights;
},
};
@@ -0,0 +1,67 @@
import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withUpgradeRequiredErrorHandlingAsync } from '../client';
import { ViewUpdateGroupInsightsQuery, ViewUpdateGroupInsightsQueryVariables } from '../generated';
export type UpdateWithInsightsObject = ViewUpdateGroupInsightsQuery['updatesByGroup'][number];
export const UpdateInsightsQuery = {
async viewUpdateGroupInsightsAsync(
graphqlClient: ExpoGraphqlClient,
{ groupId, startTime, endTime }: { groupId: string; startTime: string; endTime: string }
): Promise<UpdateWithInsightsObject[]> {
const data = await withUpgradeRequiredErrorHandlingAsync(
graphqlClient
.query<ViewUpdateGroupInsightsQuery, ViewUpdateGroupInsightsQueryVariables>(
gql`
query ViewUpdateGroupInsights($groupId: ID!, $timespan: InsightsTimespan!) {
updatesByGroup(group: $groupId) {
id
platform
insights {
id
totalUniqueUsers(timespan: $timespan)
cumulativeAverageMetrics {
launchAssetCount
averageUpdatePayloadBytes
}
cumulativeMetrics(timespan: $timespan) {
metricsAtLastTimestamp {
totalInstalls
totalFailedInstalls
}
data {
labels
installsDataset {
id
label
cumulative
difference
}
failedInstallsDataset {
id
label
cumulative
difference
}
}
}
}
}
}
`,
{ groupId, timespan: { start: startTime, end: endTime } },
{ additionalTypenames: ['Update', 'UpdateInsights'] }
)
.toPromise(),
{ featureName: 'EAS Update insights' }
);
if (data.updatesByGroup.length === 0) {
throw new Error(`Could not find any updates with group ID: "${groupId}"`);
}
return data.updatesByGroup;
},
};
@@ -33,6 +33,7 @@ export const AppObserveEventFragmentNode = gql`
countryCode
sessionId
easClientId
customParams
}
`;
@@ -0,0 +1,18 @@
import dateFormat from 'dateformat';
export interface InsightsTimespanFields {
startTime: string;
endTime: string;
daysBack?: number;
}
export function formatTimespan(timespan: InsightsTimespanFields): string {
if (timespan.daysBack) {
return `last ${timespan.daysBack} day${timespan.daysBack === 1 ? '' : 's'}`;
}
return `${toDateOnly(timespan.startTime)} to ${toDateOnly(timespan.endTime)}`;
}
export function toDateOnly(isoTimestamp: string): string {
return dateFormat(new Date(isoTimestamp), 'UTC:yyyy-mm-dd');
}
@@ -0,0 +1,12 @@
import { resolveTimeRange } from '../observe/startAndEndTime';
export const INSIGHTS_DEFAULT_DAYS_BACK = 7;
export function resolveInsightsTimeRange(flags: { days?: number; start?: string; end?: string }): {
daysBack?: number;
startTime: string;
endTime: string;
} {
const days = flags.days ?? (flags.start ? undefined : INSIGHTS_DEFAULT_DAYS_BACK);
return resolveTimeRange({ ...flags, days });
}
@@ -5,4 +5,4 @@ import { getAdapter } from 'axios';
// see: https://github.com/nock/nock#axios
export { default } from 'nock';
getRequestClient().defaults.adapter = getAdapter('http');
getRequestClient().defaults.adapter = getAdapter('http') as any;
@@ -11,9 +11,18 @@ import { AppleConfigReader } from '../../config/reader';
import { AppleConfigWriter } from '../../config/writer';
import { PartialAppleData } from '../../data';
import { PreviewsTask } from '../previews';
import Log from '../../../../log';
jest.mock('../../../../ora');
jest.mock('../../config/writer');
jest.mock('../../../../log', () => ({
__esModule: true,
default: {
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
},
}));
const mockFetch = jest.fn();
jest.mock('../../../../fetch', () => ({
@@ -377,6 +386,71 @@ describe(PreviewsTask, () => {
);
});
it('warns and skips when preview type uses screenshot display type (APP_ prefix)', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
previews: {
APP_IPHONE_67: './previews/demo.mp4',
} as any,
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const previewSets = new Map();
previewSets.set('en-US', new Map());
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('APP_IPHONE_67'));
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('IPHONE_67'));
});
it('warns and skips completely unknown preview types', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
previews: {
FAKE_DEVICE: './previews/demo.mp4',
} as any,
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const previewSets = new Map();
previewSets.set('en-US', new Map());
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(Log.warn).toHaveBeenCalledWith(expect.stringContaining('FAKE_DEVICE'));
// Should not suggest stripping APP_ prefix for non-APP_ types
expect(Log.warn).not.toHaveBeenCalledWith(expect.stringContaining('Did you mean'));
});
it('uploads new preview with object config including previewFrameTimeCode', async () => {
const config = new AppleConfigReader({
info: {
@@ -443,5 +517,54 @@ describe(PreviewsTask, () => {
})
);
});
it('deletes remote previews when preview type is removed from config', async () => {
const config = new AppleConfigReader({
info: {
'en-US': {
title: 'My App',
// No previews configured — the IPHONE_67 preview should be deleted
},
},
});
const locale = new AppStoreVersionLocalization(requestContext, 'LOC_1', {
locale: 'en-US',
} as any);
const existingPreview = new AppPreview(requestContext, 'PV_1', {
fileName: 'demo.mp4',
fileSize: 10240,
previewFrameTimeCode: null,
assetDeliveryState: { state: 'COMPLETE', errors: [], warnings: [] },
} as any);
const deleteScope = nock('https://api.appstoreconnect.apple.com')
.delete(`/v1/${AppPreview.type}/PV_1`)
.reply(204);
const previewTypeMap = new Map<PreviewType, AppPreviewSet>();
previewTypeMap.set(
PreviewType.IPHONE_67,
new AppPreviewSet(requestContext, 'PSET_1', {
previewType: PreviewType.IPHONE_67,
appPreviews: [existingPreview],
} as any)
);
const previewSets = new Map();
previewSets.set('en-US', previewTypeMap);
await new PreviewsTask().uploadAsync({
config,
context: {
previewSets,
versionLocales: [locale],
projectDir: '/test/project',
} as any,
});
expect(deleteScope.isDone()).toBeTruthy();
});
});
});
@@ -1,4 +1,5 @@
import {
ALL_PREVIEW_TYPES,
AppPreview,
AppPreviewSet,
AppStoreVersionLocalization,
@@ -136,9 +137,7 @@ export class PreviewsTask extends AppleTask {
for (const localeCode of locales) {
const previews = config.getPreviews(localeCode);
if (!previews || Object.keys(previews).length === 0) {
continue;
}
const existingSets = context.previewSets.get(localeCode);
const localization = context.versionLocales.find(l => l.attributes.locale === localeCode);
if (!localization) {
@@ -146,18 +145,51 @@ export class PreviewsTask extends AppleTask {
continue;
}
for (const [previewType, previewConfig] of Object.entries(previews)) {
if (!previewConfig) {
continue;
}
// Upload/sync configured previews
if (previews) {
for (const [previewType, previewConfig] of Object.entries(previews)) {
if (!previewConfig) {
continue;
}
await syncPreviewSetAsync(
context.projectDir,
localization,
previewType as PreviewType,
normalizePreviewConfig(previewConfig),
context.previewSets.get(localeCode)
);
if (!ALL_PREVIEW_TYPES.includes(previewType as PreviewType)) {
const strippedType = previewType.replace(/^APP_/, '');
const suggestion = ALL_PREVIEW_TYPES.includes(strippedType as PreviewType)
? chalk` Did you mean {bold ${strippedType}}? Preview types don't use the "APP_" prefix (that's only for screenshots).`
: '';
Log.warn(
chalk`{yellow Unknown preview type {bold ${previewType}} for ${localeCode}, skipping.${suggestion}}`
);
Log.warn(chalk`{yellow Valid preview types: ${ALL_PREVIEW_TYPES.join(', ')}}`);
continue;
}
await syncPreviewSetAsync(
context.projectDir,
localization,
previewType as PreviewType,
normalizePreviewConfig(previewConfig),
existingSets
);
}
}
// Delete remote previews for types no longer in config
if (existingSets) {
for (const [previewType, previewSet] of existingSets) {
if (previews?.[previewType]) {
continue;
}
const existingPreviews = previewSet.attributes.appPreviews || [];
for (const preview of existingPreviews) {
await logAsync(() => preview.deleteAsync(), {
pending: `Deleting video preview ${chalk.bold(preview.attributes.fileName)} (${localeCode})...`,
success: `Deleted video preview ${chalk.bold(preview.attributes.fileName)} (${localeCode})`,
failure: `Failed deleting video preview ${chalk.bold(preview.attributes.fileName)} (${localeCode})`,
});
}
}
}
}
}
@@ -231,15 +263,15 @@ async function syncPreviewSetAsync(
return;
}
// Delete existing previews that don't match
// Delete all existing previews before uploading the new one.
// Apple limits each set to 3 previews, and we manage one preview per set,
// so we need to clean up stale entries to avoid "Too many app previews" errors.
for (const preview of existingPreviews) {
if (preview.attributes.fileName !== fileName) {
await logAsync(() => preview.deleteAsync(), {
pending: `Deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})...`,
success: `Deleted old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
failure: `Failed deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
});
}
await logAsync(() => preview.deleteAsync(), {
pending: `Deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})...`,
success: `Deleted old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
failure: `Failed deleting old preview ${chalk.bold(preview.attributes.fileName)} (${locale})`,
});
}
// Upload new preview
@@ -158,6 +158,7 @@ describe(buildObserveEventsJson, () => {
sessionId: 'session-1',
easClientId: 'client-1',
timestamp: '2025-01-15T10:30:00.000Z',
customParams: null,
},
],
pageInfo: {
@@ -126,6 +126,8 @@ describe(buildObserveMetricsJson, () => {
expect(result.versions[0]).toEqual({
appVersion: '1.0.0',
platform: AppPlatform.Ios,
buildNumbers: [],
updateIds: [],
metrics: {
'expo.app_startup.tti': {
min: 0.1,
@@ -28,6 +28,7 @@ export interface ObserveEventJson {
sessionId: string | null;
easClientId: string;
timestamp: string;
customParams: { [key: string]: any } | null;
}
function formatDate(isoString: string): string {
@@ -39,6 +40,10 @@ function formatDate(isoString: string): string {
});
}
function resolveCustomParams(event: AppObserveEvent): { [key: string]: any } | null {
return event.customParams ?? null;
}
export interface BuildEventsTableOptions {
metricName: string;
daysBack?: number;
@@ -131,6 +136,7 @@ export function buildObserveEventsJson(
sessionId: event.sessionId ?? null,
easClientId: event.easClientId,
timestamp: event.timestamp,
customParams: resolveCustomParams(event),
})),
pageInfo: {
hasNextPage: pageInfo.hasNextPage,
+21 -33
View File
@@ -3,6 +3,7 @@ import chalk from 'chalk';
import { EasCommandError } from '../commandUtils/errors';
import { AppPlatform } from '../graphql/generated';
import { appPlatformDisplayNames } from '../platform';
import renderTextTable from '../utils/renderTextTable';
import { getMetricDisplayName } from './metricNames';
export type StatisticKey =
@@ -105,6 +106,8 @@ export type MetricValuesJson = Partial<Record<StatisticKey, number | null>>;
export interface ObserveMetricsVersionResult {
appVersion: string;
platform: AppPlatform;
buildNumbers: string[];
updateIds: string[];
metrics: Record<string, MetricValuesJson>;
}
@@ -117,7 +120,9 @@ export function buildObserveMetricsJson(
metricsMap: ObserveMetricsMap,
metricNames: string[],
stats: StatisticKey[],
totalEventCounts?: Map<string, number>
totalEventCounts?: Map<string, number>,
buildNumbersMap?: BuildNumbersMap,
updateIdsMap?: UpdateIdsMap
): ObserveMetricsJsonOutput {
const versions: ObserveMetricsVersionResult[] = [];
@@ -134,7 +139,13 @@ export function buildObserveMetricsJson(
metrics[metricName] = statValues;
}
versions.push({ appVersion, platform, metrics });
versions.push({
appVersion,
platform,
buildNumbers: buildNumbersMap?.get(key) ?? [],
updateIds: updateIdsMap?.get(key) ?? [],
metrics,
});
}
// Group total event counts by metric → platform
@@ -165,22 +176,6 @@ function buildTimeRangeDescription(daysBack?: number): string {
return '';
}
function renderTable(headers: string[], rows: string[][], footerRow?: string[]): string {
const allRows = footerRow ? [...rows, footerRow] : rows;
const colWidths = headers.map((h, i) =>
Math.max(h.length, ...allRows.map(r => (r[i] ?? '').length))
);
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
const lines = [chalk.bold(headerLine), separatorLine, ...dataLines];
if (footerRow) {
lines.push(separatorLine);
lines.push(footerRow.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
}
return lines.join('\n');
}
export function buildObserveMetricsTable(
metricsMap: ObserveMetricsMap,
metricNames: string[],
@@ -188,7 +183,6 @@ export function buildObserveMetricsTable(
options?: {
daysBack?: number;
buildNumbersMap?: BuildNumbersMap;
updateIdsMap?: UpdateIdsMap;
totalEventCounts?: Map<string, number>;
}
): string {
@@ -232,12 +226,7 @@ export function buildObserveMetricsTable(
}
}
}
// Check if any version has updates
const hasUpdates = options?.updateIdsMap
? Array.from(options.updateIdsMap.values()).some(ids => ids.length > 0)
: false;
const headers = ['App Version', ...(hasUpdates ? ['Updates'] : []), ...metricHeaders];
const headers = ['App Version', ...metricHeaders];
const sections: string[] = [chalk.bold(summaryLine)];
@@ -245,16 +234,14 @@ export function buildObserveMetricsTable(
sections.push('');
sections.push(chalk.bold(appPlatformDisplayNames[platform]));
const rows: string[][] = platformResults.map(result => {
const rows: string[][] = [];
for (const result of platformResults) {
const key = makeMetricsKey(result.appVersion, result.platform);
const buildNumbers = options?.buildNumbersMap?.get(key);
const versionLabel = buildNumbers?.length
? `${result.appVersion} (${buildNumbers.join(', ')})`
: result.appVersion;
const updateIds = options?.updateIdsMap?.get(key);
const updatesLabel = updateIds?.length ? updateIds.join(', ') : '';
const metricCells: string[] = [];
for (const m of metricNames) {
const values = result.metrics[m];
@@ -272,8 +259,9 @@ export function buildObserveMetricsTable(
}
}
}
return [versionLabel, ...(hasUpdates ? [updatesLabel] : []), ...metricCells];
});
rows.push([versionLabel, ...metricCells]);
}
let footerRow: string[] | undefined;
if (options?.totalEventCounts) {
@@ -283,11 +271,11 @@ export function buildObserveMetricsTable(
countCells.push(count != null ? count.toLocaleString() : '-');
}
if (countCells.some(c => c !== '-')) {
footerRow = ['Total events', ...(hasUpdates ? [''] : []), ...countCells];
footerRow = ['Total events', ...countCells];
}
}
sections.push(renderTable(headers, rows, footerRow));
sections.push(renderTextTable(headers, rows, footerRow));
}
return sections.join('\n');
@@ -6,6 +6,7 @@ import {
AppObserveAppUpdate,
} from '../graphql/generated';
import { appPlatformDisplayNames } from '../platform';
import renderTextTable from '../utils/renderTextTable';
import { AppVersionsResult } from './fetchVersions';
function formatDate(isoString: string): string {
@@ -97,14 +98,6 @@ export function buildObserveVersionsJson(results: AppVersionsResult[]): AppVersi
return output;
}
function renderTable(headers: string[], rows: string[][]): string {
const colWidths = headers.map((h, i) => Math.max(h.length, ...rows.map(r => r[i].length)));
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
const dataLines = rows.map(row => row.map((cell, i) => cell.padEnd(colWidths[i])).join(' '));
return [chalk.bold(headerLine), separatorLine, ...dataLines].join('\n');
}
export function buildObserveVersionsTable(results: AppVersionsResult[]): string {
const hasAnyVersions = results.some(r => r.appVersions.length > 0);
@@ -135,7 +128,7 @@ export function buildObserveVersionsTable(results: AppVersionsResult[]): string
String(version.updates.length),
]);
sections.push(renderTable(headers, rows));
sections.push(renderTextTable(headers, rows));
}
return sections.join('\n');
@@ -0,0 +1,206 @@
import { UpdateWithInsightsObject } from '../../../graphql/queries/UpdateInsightsQuery';
import {
buildUpdateInsightsJson,
buildUpdateInsightsTable,
formatBytes,
formatPercent,
toUpdateInsightsSummary,
} from '../formatInsights';
function makeUpdate(
platform: string,
overrides: {
updateId?: string;
totalInstalls?: number;
totalFailedInstalls?: number;
totalUniqueUsers?: number;
launchAssetCount?: number;
averageUpdatePayloadBytes?: number;
labels?: string[];
installsDifference?: number[];
failedInstallsDifference?: number[];
} = {}
): UpdateWithInsightsObject {
const labels = overrides.labels ?? ['2026-04-09', '2026-04-10'];
const installsDifference = overrides.installsDifference ?? [10, 20];
const failedInstallsDifference = overrides.failedInstallsDifference ?? [1, 2];
return {
__typename: 'Update',
id: overrides.updateId ?? `${platform}-update`,
platform,
insights: {
__typename: 'UpdateInsights',
id: `${platform}-insights`,
totalUniqueUsers: overrides.totalUniqueUsers ?? 100,
cumulativeAverageMetrics: {
__typename: 'CumulativeAverageMetrics',
launchAssetCount: overrides.launchAssetCount ?? 3,
averageUpdatePayloadBytes: overrides.averageUpdatePayloadBytes ?? 5_000_000,
},
cumulativeMetrics: {
__typename: 'CumulativeMetrics',
metricsAtLastTimestamp: {
__typename: 'CumulativeMetricsTotals',
totalInstalls: overrides.totalInstalls ?? 1000,
totalFailedInstalls: overrides.totalFailedInstalls ?? 10,
},
data: {
__typename: 'UpdatesMetricsData',
labels,
installsDataset: {
__typename: 'CumulativeUpdatesDataset',
id: `${platform}-installs`,
label: 'Installs',
cumulative: installsDifference.map((_, i) =>
installsDifference.slice(0, i + 1).reduce((a, b) => a + b, 0)
),
difference: installsDifference,
},
failedInstallsDataset: {
__typename: 'CumulativeUpdatesDataset',
id: `${platform}-failed`,
label: 'Failed installs',
cumulative: failedInstallsDifference.map((_, i) =>
failedInstallsDifference.slice(0, i + 1).reduce((a, b) => a + b, 0)
),
difference: failedInstallsDifference,
},
},
},
},
};
}
const TIMESPAN = {
startTime: '2026-04-09T00:00:00.000Z',
endTime: '2026-04-16T00:00:00.000Z',
daysBack: 7,
};
describe(toUpdateInsightsSummary, () => {
it('sorts platforms alphabetically', () => {
const summary = toUpdateInsightsSummary(
'group-1',
[makeUpdate('ios'), makeUpdate('android')],
TIMESPAN
);
expect(summary.platforms.map(p => p.platform)).toEqual(['android', 'ios']);
});
it('computes crash rate per platform', () => {
const summary = toUpdateInsightsSummary(
'group-1',
[
makeUpdate('android', { totalInstalls: 100, totalFailedInstalls: 5 }),
makeUpdate('ios', { totalInstalls: 990, totalFailedInstalls: 10 }),
],
TIMESPAN
);
expect(summary.platforms[0].crashRatePercent).toBeCloseTo((5 / 105) * 100, 5);
expect(summary.platforms[1].crashRatePercent).toBeCloseTo((10 / 1000) * 100, 5);
});
it('returns 0 crash rate when both install counts are zero', () => {
const summary = toUpdateInsightsSummary(
'group-1',
[
makeUpdate('android', {
totalInstalls: 0,
totalFailedInstalls: 0,
installsDifference: [],
failedInstallsDifference: [],
labels: [],
}),
],
TIMESPAN
);
expect(summary.platforms[0].crashRatePercent).toBe(0);
});
it('maps daily breakdown per platform from labels and difference datasets', () => {
const summary = toUpdateInsightsSummary(
'group-1',
[
makeUpdate('ios', {
labels: ['2026-04-09', '2026-04-10'],
installsDifference: [50, 75],
failedInstallsDifference: [1, 3],
}),
],
TIMESPAN
);
expect(summary.platforms[0].daily).toEqual([
{ date: '2026-04-09', installs: 50, failedInstalls: 1 },
{ date: '2026-04-10', installs: 75, failedInstalls: 3 },
]);
});
it('captures platform-specific updateId', () => {
const summary = toUpdateInsightsSummary(
'group-1',
[
makeUpdate('android', { updateId: 'android-id' }),
makeUpdate('ios', { updateId: 'ios-id' }),
],
TIMESPAN
);
expect(summary.platforms.find(p => p.platform === 'android')?.updateId).toBe('android-id');
expect(summary.platforms.find(p => p.platform === 'ios')?.updateId).toBe('ios-id');
});
});
describe(buildUpdateInsightsJson, () => {
it('returns a serializable object with per-platform totals, payload, and daily', () => {
const summary = toUpdateInsightsSummary(
'group-1',
[makeUpdate('android'), makeUpdate('ios', { totalInstalls: 500, totalFailedInstalls: 5 })],
TIMESPAN
);
const json = buildUpdateInsightsJson(summary) as any;
expect(json.groupId).toBe('group-1');
expect(json.timespan.daysBack).toBe(7);
expect(json.platforms).toHaveLength(2);
expect(json.platforms[0].platform).toBe('android');
expect(json.platforms[1].platform).toBe('ios');
expect(json.platforms[1].totals.installs).toBe(500);
expect(json.platforms[0].daily).toHaveLength(2);
});
});
describe(buildUpdateInsightsTable, () => {
it('renders per-platform sections with totals, crash rate and daily breakdown', () => {
const summary = toUpdateInsightsSummary(
'group-1',
[makeUpdate('android'), makeUpdate('ios')],
TIMESPAN
);
const table = buildUpdateInsightsTable(summary);
expect(table).toMatch(/group-1/);
expect(table).toMatch(/Platforms/);
expect(table).toMatch(/android, ios/);
expect(table).toMatch(/\bandroid\b/);
expect(table).toMatch(/\bios\b/);
expect(table).toMatch(/Launches/);
expect(table).toMatch(/Crash rate/);
expect(table).toMatch(/Daily breakdown/);
expect(table).toMatch(/2026-04-09/);
});
});
describe(formatBytes, () => {
it('formats bytes', () => {
expect(formatBytes(512)).toBe('512 B');
});
it('formats kilobytes', () => {
expect(formatBytes(2048)).toBe('2.0 KB');
});
it('formats megabytes', () => {
expect(formatBytes(5_242_880)).toBe('5.00 MB');
});
});
describe(formatPercent, () => {
it('formats with two decimal places', () => {
expect(formatPercent(12.3456)).toBe('12.35%');
});
});
@@ -0,0 +1,180 @@
import chalk from 'chalk';
import { UpdateWithInsightsObject } from '../../graphql/queries/UpdateInsightsQuery';
import { formatTimespan, toDateOnly } from '../../insights/formatTimespan';
import formatFields from '../../utils/formatFields';
import renderTextTable from '../../utils/renderTextTable';
export interface UpdateInsightsTimespan {
startTime: string;
endTime: string;
daysBack?: number;
}
export interface UpdateInsightsDailyEntry {
date: string;
installs: number;
failedInstalls: number;
}
export interface UpdateInsightsPlatformSummary {
platform: string;
updateId: string;
totalUniqueUsers: number;
totalInstalls: number;
totalFailedInstalls: number;
crashRatePercent: number;
launchAssetCount: number;
averageUpdatePayloadBytes: number;
daily: UpdateInsightsDailyEntry[];
}
export interface UpdateInsightsSummary {
groupId: string;
startTime: string;
endTime: string;
daysBack?: number;
platforms: UpdateInsightsPlatformSummary[];
}
export function toUpdateInsightsSummary(
groupId: string,
updates: UpdateWithInsightsObject[],
timespan: UpdateInsightsTimespan
): UpdateInsightsSummary {
const platforms = updates
.map(toPlatformSummary)
.sort((a, b) => a.platform.localeCompare(b.platform));
return {
groupId,
startTime: timespan.startTime,
endTime: timespan.endTime,
daysBack: timespan.daysBack,
platforms,
};
}
function toPlatformSummary(update: UpdateWithInsightsObject): UpdateInsightsPlatformSummary {
const { insights } = update;
const { totalInstalls, totalFailedInstalls } = insights.cumulativeMetrics.metricsAtLastTimestamp;
const denom = totalInstalls + totalFailedInstalls;
const crashRatePercent = denom === 0 ? 0 : (totalFailedInstalls / denom) * 100;
const { labels, installsDataset, failedInstallsDataset } = insights.cumulativeMetrics.data;
const daily: UpdateInsightsDailyEntry[] = labels.map((date, i) => ({
date,
installs: installsDataset.difference[i] ?? 0,
failedInstalls: failedInstallsDataset.difference[i] ?? 0,
}));
return {
platform: update.platform,
updateId: update.id,
totalUniqueUsers: insights.totalUniqueUsers,
totalInstalls,
totalFailedInstalls,
crashRatePercent,
launchAssetCount: insights.cumulativeAverageMetrics.launchAssetCount,
averageUpdatePayloadBytes: insights.cumulativeAverageMetrics.averageUpdatePayloadBytes,
daily,
};
}
export function buildUpdateInsightsJson(summary: UpdateInsightsSummary): object {
return {
groupId: summary.groupId,
timespan: {
start: summary.startTime,
end: summary.endTime,
...(summary.daysBack !== undefined ? { daysBack: summary.daysBack } : {}),
},
platforms: summary.platforms.map(p => ({
platform: p.platform,
updateId: p.updateId,
totals: {
uniqueUsers: p.totalUniqueUsers,
installs: p.totalInstalls,
failedInstalls: p.totalFailedInstalls,
crashRatePercent: p.crashRatePercent,
},
payload: {
launchAssetCount: p.launchAssetCount,
averageUpdatePayloadBytes: p.averageUpdatePayloadBytes,
},
daily: p.daily,
})),
};
}
export function buildUpdateInsightsTable(summary: UpdateInsightsSummary): string {
const sections: string[] = [];
sections.push(chalk.bold('Update group insights:'));
sections.push(
formatFields([
{ label: 'Group ID', value: summary.groupId },
{ label: 'Time range', value: formatTimespan(summary) },
{ label: 'Platforms', value: summary.platforms.map(p => p.platform).join(', ') || 'N/A' },
])
);
const dailyHeader = summary.daysBack ? ` (last ${summary.daysBack} days)` : '';
for (const platform of summary.platforms) {
sections.push('');
sections.push(chalk.bold(`${chalk.cyan(platform.platform)}:`));
sections.push(
formatFields([
{ label: 'Update ID', value: platform.updateId },
{ label: 'Launches', value: platform.totalInstalls.toLocaleString() },
{ label: 'Failed launches', value: platform.totalFailedInstalls.toLocaleString() },
{ label: 'Crash rate', value: formatPercent(platform.crashRatePercent) },
{ label: 'Unique users', value: platform.totalUniqueUsers.toLocaleString() },
{ label: 'Launch assets', value: platform.launchAssetCount.toLocaleString() },
{ label: 'Avg payload size', value: formatBytes(platform.averageUpdatePayloadBytes) },
])
);
if (platform.daily.length > 0) {
sections.push('');
sections.push(chalk.bold(` Daily breakdown${dailyHeader}:`));
sections.push('');
sections.push(indent(renderDailyTable(platform.daily), 2));
}
}
return sections.join('\n');
}
export function formatPercent(value: number): string {
return `${value.toFixed(2)}%`;
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) {
return `${bytes} B`;
}
if (bytes < 1024 * 1024) {
return `${(bytes / 1024).toFixed(1)} KB`;
}
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
function renderDailyTable(rows: UpdateInsightsDailyEntry[]): string {
return renderTextTable(
['Date', 'Launches', 'Crashes'],
rows.map(r => [
toDateOnly(r.date),
r.installs.toLocaleString(),
r.failedInstalls.toLocaleString(),
])
);
}
function indent(text: string, spaces: number): string {
const pad = ' '.repeat(spaces);
return text
.split('\n')
.map(line => (line.length > 0 ? pad + line : line))
.join('\n');
}
@@ -0,0 +1,28 @@
import chalk from 'chalk';
/**
* Render a simple column-aligned text table with a bold header row, a dashed
* separator, the data rows, and an optional footer row (preceded by its own
* separator). Columns are padded to the widest cell in that column.
*/
export default function renderTextTable(
headers: string[],
rows: string[][],
footerRow?: string[]
): string {
const allRows = footerRow ? [...rows, footerRow] : rows;
const colWidths = headers.map((h, i) =>
Math.max(h.length, ...allRows.map(r => (r[i] ?? '').length))
);
const headerLine = headers.map((h, i) => h.padEnd(colWidths[i])).join(' ');
const separatorLine = colWidths.map(w => '-'.repeat(w)).join(' ');
const dataLines = rows.map(row =>
row.map((cell, i) => (cell ?? '').padEnd(colWidths[i])).join(' ')
);
const lines = [chalk.bold(headerLine), separatorLine, ...dataLines];
if (footerRow) {
lines.push(separatorLine);
lines.push(footerRow.map((cell, i) => (cell ?? '').padEnd(colWidths[i])).join(' '));
}
return lines.join('\n');
}
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "18.6.0",
"version": "18.8.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": "18.6.0",
"@expo/eas-build-job": "18.8.0",
"chalk": "4.1.2",
"env-string": "1.0.1",
"fs-extra": "11.2.0",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli-local-build-plugin",
"version": "18.7.0",
"version": "18.8.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": "18.7.0",
"@expo/eas-build-job": "18.6.0",
"@expo/build-tools": "18.8.0",
"@expo/eas-build-job": "18.8.0",
"@expo/logger": "18.5.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "18.5.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "18.6.0",
"version": "18.8.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": "18.6.0",
"@expo/eas-build-job": "18.8.0",
"@expo/logger": "18.5.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "18.7.0",
"version": "18.8.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,16 +19,16 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "18.7.0",
"@expo/build-tools": "18.8.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.6.0",
"@expo/eas-build-job": "18.8.0",
"@expo/logger": "18.5.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "18.6.0",
"@expo/steps": "18.8.0",
"@expo/template-file": "18.5.0",
"@expo/turtle-spawn": "18.5.0",
"@hapi/boom": "10.0.1",
Generated
+18 -17
View File
@@ -2856,14 +2856,15 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:18.7.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:18.8.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:18.5.0"
"@expo/eas-build-job": "npm:18.6.0"
"@expo/eas-build-job": "npm:18.8.0"
"@expo/eas-json": "npm:18.8.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:18.5.0"
"@expo/package-manager": "npm:1.9.10"
@@ -2871,7 +2872,7 @@ __metadata:
"@expo/repack-app": "npm:~0.4.1"
"@expo/results": "npm:^1.0.0"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:18.6.0"
"@expo/steps": "npm:18.8.0"
"@expo/template-file": "npm:18.5.0"
"@expo/turtle-spawn": "npm:18.5.0"
"@expo/xcpretty": "npm:^4.3.1"
@@ -3111,7 +3112,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:18.6.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:18.8.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:
@@ -3128,12 +3129,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:18.6.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:18.8.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:18.6.0"
"@expo/eas-build-job": "npm:18.8.0"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -3481,11 +3482,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:18.6.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:18.8.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:18.6.0"
"@expo/eas-build-job": "npm:18.8.0"
"@expo/logger": "npm:18.5.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
@@ -3554,16 +3555,16 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:18.7.0"
"@expo/build-tools": "npm:18.8.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:18.5.0"
"@expo/eas-build-job": "npm:18.6.0"
"@expo/eas-build-job": "npm:18.8.0"
"@expo/logger": "npm:18.5.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:18.6.0"
"@expo/steps": "npm:18.8.0"
"@expo/template-file": "npm:18.5.0"
"@expo/turtle-spawn": "npm:18.5.0"
"@hapi/boom": "npm:10.0.1"
@@ -10331,7 +10332,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:18.6.0"
"@expo/steps": "npm:18.8.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10944,8 +10945,8 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:18.7.0"
"@expo/eas-build-job": "npm:18.6.0"
"@expo/build-tools": "npm:18.8.0"
"@expo/eas-build-job": "npm:18.8.0"
"@expo/logger": "npm:18.5.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:18.5.0"
@@ -11002,8 +11003,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:18.6.0"
"@expo/eas-json": "npm:18.6.0"
"@expo/eas-build-job": "npm:18.8.0"
"@expo/eas-json": "npm:18.8.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:18.5.0"
@@ -11018,7 +11019,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:18.6.0"
"@expo/steps": "npm:18.8.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:5.0.0"
"@graphql-codegen/introspection": "npm:4.0.0"