Compare commits

..
Author SHA1 Message Date
Expo CI 27a2e2abc7 v19.1.0 2026-05-25 13:43:21 +00:00
Szymon Świerk 149f5d86a4 [build-tools] Pass refresh ad-hoc provisioning profile through build:internal (#3770)
<!-- 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

Git-based integration builds call `eas build:internal` via `runEasBuildInternalAsync`. The refresh flag must reach the CLI and remain on the job object returned to the rest of the build.

# How

Forward `--refresh-ad-hoc-provisioning-profile` when set on the iOS job; retain `refresh_ad_hoc_provisioning_profile` in `validateEasBuildInternalResult`.

# Test Plan

`cd packages/build-tools && yarn test src/common/__tests__/easBuildInternal.test.ts`
2026-05-25 12:18:13 +02:00
Szymon Świerk 18c2e82385 [eas-cli] Wire refresh ad-hoc profile through build:internal and prepareJob (#3769)
<!-- 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

Workflow builds run `eas build:internal` on EAS workers to resolve credentials and emit the job JSON. The base branch adds `--refresh-ad-hoc-provisioning-profile` to eas build, but that flag was not available on `build:internal`, and the iOS job did not persist `refresh_ad_hoc_provisioning_profile` when the flag was set.

Without this wiring, git-based/workflow builds could not trigger ad-hoc profile refresh or pass the intent through to the job payload.

# How

- Add `--refresh-ad-hoc-provisioning-profile` to `eas build:internal` and pass it into `runBuildAndSubmitAsync` (same behavior as eas build).
- In `prepareJob.ts`, set `refresh_ad_hoc_provisioning_profile: true` on the iOS job when the credentials context has the flag enabled.

# Test Plan

`yarn typecheck`
2026-05-25 11:49:51 +02:00
Szymon Świerk 43ffe46428 [eas-build-job] Add refreshAdHocProvisioningProfile to iOS job (#3717)
<!-- 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

ENG-21094 needs a job field to carry ad-hoc provisioning profile refresh intent for git-based/workflow builds; #3716 added CLI behavior on eas build; this PR is schema-only.

# How

Updated `packages/eas-build-job/src/ios.ts` interface + Joi schema.

# Test Plan

`cd packages/eas-build-job && yarn test src/__tests__/ios.test.ts`
`yarn typecheck`
2026-05-25 11:43:14 +02:00
Ash Wu fb9b49cd5b [build-tools] keep exit code & stderr when xclogparser failed (#3772)
* [build-tools] keep exit code & stderr when xclogparser failed

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

* address comments

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

---------

Signed-off-by: Ash Wu <[email protected]>
2026-05-25 15:15:21 +08:00
Stanisław Chmiela 17eb5f0687 [worker] Remove build phase stats websocket reporting (#3787)
We now send build phases through `Datadog`!

---

## Summary
- remove the stale BuildPhaseStats type and BuildContext reporting callback
- drop the worker BUILD_PHASE_STATS websocket message and service forwarding path
- update tests to assert the remaining Datadog phase duration metrics path

## Why
Build phase metrics now flow from worker to www to Datadog, so the old worker to launcher to Datadog reporting path was dead code.

## Validation
- yarn run -T oxfmt --check packages/eas-build-job/src/common.ts packages/eas-build-job/src/index.ts packages/build-tools/src/context.ts packages/build-tools/src/builders/__tests__/android.test.ts packages/worker/src/context.ts packages/worker/src/service.ts packages/worker/src/external/turtle.ts packages/worker/src/__integration__/utils.ts packages/worker/src/__unit__/service.test.ts
- yarn jest --config jest/unit-config.ts src/builders/__tests__/android.test.ts src/__tests__/context.test.ts --runInBand --no-cache (from packages/build-tools)
- yarn jest --config jest.config.unit.ts src/__unit__/service.test.ts --runInBand (from packages/worker)
- yarn typecheck
2026-05-25 08:36:40 +02:00
Stanisław Chmiela 6297ac19d9 [build-tools] Dry-run add support for absolute artifact glob paths (#3738)
## Summary

Updates artifact path resolution so absolute paths containing glob metacharacters are treated as glob patterns instead of literal paths.

This lets `eas/upload_artifact` handle paths like `/var/folders/.../T/maestro_xctestrunner_xcodebuild_output*`, while preserving the existing literal-path behavior for non-dynamic absolute paths.

## Why

`upload_artifact.path` already accepts paths or patterns, but absolute patterns were previously short-circuited through `fs.pathExists()` and never reached `fast-glob`.

## Test Plan

- `yarn workspace @expo/build-tools jest-unit packages/build-tools/src/utils/__tests__/artifacts.test.ts`

Note: the focused test was run in the existing local checkout with the same two-file patch. The clean PR worktree did not have its own install state.
2026-05-22 18:13:54 +02:00
Stanisław Chmiela 6d86f91dab [worker] Derive worker runtime info from os (#3783)
## Summary
- Remove worker-side `resourceClass` parsing and types from runtime config.
- Remove `ResourceClassToPlatform`, since worker env setup now uses the job platform directly.
- Replace resource-class VM spec logging with OS-derived CPU model, vCPU count, and total RAM.

## Notes
- CPU model is normalized to `Apple M4 Pro` (from "Apple M4 Pro (Virtual)") for Apple chips and `Intel`/`AMD` for cloud CPU models.

## Validation
- `yarn oxfmt --check packages/worker/src/config.ts packages/worker/src/displayRuntimeInfo.ts packages/worker/src/external/turtle.ts packages/worker/src/env.ts packages/worker/src/__unit__/env.test.ts`
- `yarn workspace @expo/worker test:unit -- env.test.ts`
- `yarn workspace @expo/worker typecheck`
- verified `os.cpus().length` is 4 for 4 CPU tart VM and `os.totalmem() / 1024 / 1024 / 1024` is `8` for 8 GB RAM tart VM
2026-05-22 16:35:44 +02:00
Stanisław Chmiela cedfabe18a [worker] Size Gradle heap from runner memory (#3782)
## Summary
- Replace resource-class-based Gradle heap sizing with a live calculation from runner total memory.
- Preserve existing Gradle settings for metaspace, parallel execution, daemon disablement, and old-Java MaxPermSize handling.
- Add worker unit coverage for the effective heap sizes.

## Effective heap sizing
- 15-16 GB runners resolve to `-Xmx4g`.
- 31-32 GB runners resolve to `-Xmx8g`.
- Lower-memory runners are clamped to at least `-Xmx1g`.

## Validation
- `yarn oxfmt --check packages/worker/src/env.ts packages/worker/src/__unit__/env.test.ts`
- `yarn workspace @expo/worker test:unit -- env.test.ts`
- `yarn workspace @expo/worker typecheck`
2026-05-22 16:18:36 +02:00
Stanisław Chmiela 20a21908d0 [worker] Stop logging dependency metrics failures to build output (#3784)
## Summary

- Stop reporting project dependency metrics failures to the build logger.
- Keep the existing Sentry warning capture for this failure path.

## Context

This addresses user-visible logging seen in build https://expo.dev/accounts/ammar_tripiq/projects/tripiq-client/builds/8f01c353-187b-4368-b569-94484d04220b where missing package.json caused:

```
Failed to report project dependencies metrics
ENOENT: no such file or directory, open '/Users/expo/workingdir/build/package.json'
```

## Validation

- yarn oxfmt packages/worker/src/external/analytics.ts
2026-05-22 16:09:55 +02:00
Stanisław Chmiela 18b1dd8d9d [build-tools] Preserve app entitlements when resigning iOS builds (#3785)
Not sure if this is the right fix, but sounds right?

## Summary

Updates the iOS build resign Fastfile to pass `use_app_entitlements: true` to Fastlane's `resign` action.

This makes EAS preserve and merge the app's existing code-signing entitlements when re-signing an IPA with a new provisioning profile, instead of signing directly with the broader provisioning-profile entitlements. This matters for capabilities like associated domains, where the profile may contain `*` but the app signature needs concrete values such as `applinks:...` for universal links to keep working.

Context: https://exponent-internal.slack.com/archives/C09H6RFAX27/p1779446992844929

## Validation

- `yarn install`
- `yarn workspace @expo/template-file build`
- `yarn workspace @expo/build-tools jest-unit fastfile.test.ts`
- `yarn oxfmt packages/build-tools/src/templates/FastfileResign.ts packages/build-tools/src/ios/__tests__/__snapshots__/fastfile.test.ts.snap`
2026-05-22 14:23:58 +02:00
Szymon Świerk fce2a77909 [eas-cli] Add --refresh-ad-hoc-provisioning-profile for non-interactive builds (#3716)
<!-- 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

When a new device is added to an account, and a non-interactive development build is run (e.g. invoked by CI or by a workflow job), even without the `freeze-credentials` flag, the provisioning profile is not refreshed, and the internal build is not available for the newly registered device. See the Linear issue: https://linear.app/expo/issue/ENG-7041/allow-including-all-eas-registered-devices-automatically-in-non

# How

Added an opt-in ad-hoc provisioning profile refresh to non-interactive builds with flag `--refresh-ad-hoc-provisioning-profile`.

When the flag is present, we hit `www` GQL API to get the ASC API key (the submission key), and we invoke the ad-hoc profile sync with all devices selected. `packages/eas-cli/src/credentials/ios/actions/SetUpAdhocProvisioningProfile.ts` is where this logic happens.

The flag mustn't be present when `--freeze-credentials` is present, these are conflicting flags.

I'm leaning towards an opt-in flag instead of extending the default behavior because of the extra credentials configuration requirements and side effects of the provisioning profile refresh logic.

# Test Plan

Added/amended tests.

Manual verification:
1. Did `easd build --platform ios --profile development --non-interactive --refresh-ad-hoc-credential`, verified the provisioning profile matches the set of Apple devices on my account.
2. Changed the set of Apple devices on my account.
3. `easd build --platform ios --profile development --non-interactive --refresh-ad-hoc-credential` again, verified the re-generated provisioning profile matches the Apple devices on my account.
2026-05-22 10:41:42 +02:00
Expo CI c562f08da9 update CHANGELOG.md 2026-05-21 23:19:58 +00:00
Expo CI 5dee409567 v19.0.8 2026-05-21 23:17:53 +00:00
Gabe Debes ea2cd4f318 [eas-cli] eas go: use SDK version from current project when available (#3776)
* [eas-cli] eas go: use SDK version from current project when available

* fix fmt
2026-05-21 15:36:59 -07:00
Expo CI 639a7cce61 update CHANGELOG.md 2026-05-21 21:02:47 +00:00
Expo CI b0db6d1dfa v19.0.7 2026-05-21 21:00:27 +00:00
Szymon Dziedzic 783033acb9 [eas-cli] Add simulator:list command (#3775) 2026-05-21 22:58:13 +02:00
Expo CI 4366f517c1 update CHANGELOG.md 2026-05-21 17:08:37 +00:00
Expo CI 1e9a0981a5 v19.0.6 2026-05-21 17:05:52 +00:00
Szymon Dziedzic 43953d6f16 [eas-cli] Add --json flag to simulator:stop command (#3773)
# Why

`eas simulator:stop` only emitted human-readable spinner output, so
wrapper scripts and automation can't consume the resulting session id
and status. Sibling commands (e.g. `simulator:start`, `branch:rename`)
already expose `--json` for machine-readable output.

# How

- Swap `EASNonInteractiveFlag` for `EasNonInteractiveAndJsonFlags`
  (adds `--json`, preserves `--non-interactive`).
- Use `resolveNonInteractiveAndJsonFlags` so `--json` implies
  `--non-interactive`, matching `simulator:start`.
- Call `enableJsonOutput()` before any logging so spinner output is
  redirected to stderr when `--json` is set.
- On successful stop, emit `{ id, status }` via `printJsonOnlyOutput`.

# Test Plan

- `yarn typecheck`, `yarn lint`, and `yarn fmt:check` pass for the
  modified file.
- Manual: `eas simulator:stop --id <id> --json` writes only the JSON
  payload to stdout; spinner/log messages are redirected to stderr.
2026-05-21 18:03:46 +02:00
Krystof Woldrich 7bb8c0e749 feat(simulator): add get --json output (#3765) 2026-05-21 17:51:45 +02:00
Christian Falch 150176debc fix(precompile) changed min sdk-version for precompile to 5.0.26 (#3771)
* fix(precompile) changed min sdk-version for precompile to 5.0.26

After publishing new fixes also on SDK-55 for the precompile, we'd like to lock in on a higher version.

This commit fixes this by updating the tests and implementation to require 55.0.26 as the minimum sdk version.

* fixed changelog
2026-05-21 15:38:22 +02:00
Szymon Dziedzic fa6c877a81 [build-tools] Add start_argent_remote_session build function (#3757) 2026-05-20 20:16:26 +02:00
Expo CI 9ee8758aac update CHANGELOG.md 2026-05-20 18:14:13 +00:00
Expo CI d084e9ab9d v19.0.5 2026-05-20 18:11:50 +00:00
Stanisław Chmiela ec0ac26fc3 [eas-cli] Improve iOS config fallback Sentry diagnostics (#3763)
## Summary
- capture an explicit `iOS entitlements config fallback` Sentry message so this fallback is easier to find than the generic spawn error
- tag the event with app/build/config-resolution metadata
- include spawn failure details in a JSON extra, preserving empty stdout/stderr as `""`
- add a changelog entry

## Test plan
- `yarn workspace eas-cli typecheck`
- `git diff --check`
2026-05-20 19:37:12 +02:00
Expo CI 51fb25c689 update CHANGELOG.md 2026-05-20 15:31:46 +00:00
Expo CI 81cfeede9a v19.0.4 2026-05-20 15:28:33 +00:00
Stanisław Chmiela 4bff24b71c [eas-cli] Update eas-cli observe GraphQL types (#3762)
## Summary
- update eas-cli GraphQL schema/types for the new observe `uniqueActiveUserCount` field
- add the new `lastSeenUserCount` generated field from the PR diff

## Validation
- `yarn run -T oxfmt packages/eas-cli/src/graphql/types/Observe.ts packages/eas-cli/src/commands/observe/__tests__/versions.test.ts packages/eas-cli/src/observe/__tests__/fetchMetrics.test.ts packages/eas-cli/src/graphql/generated.ts packages/eas-cli/graphql.schema.json`
- `yarn workspace eas-cli typecheck`
2026-05-20 17:24:20 +02:00
Expo CI 503b62b73d v19.0.3 2026-05-20 15:02:48 +00:00
Mustafa Abban aaab3c98a1 [build-tools] place gradle profile step after run gradle (#3761)
* [build-tools] place gradle profile step after run gradle

* [build-tools] place gradle profile step after run gradle
2026-05-20 11:00:29 -04:00
Expo CI b64f25b36d v19.0.2 2026-05-20 14:39:34 +00:00
Ash Wu 1e209b00a8 [build-tools] Reduce xcactivitylog parser output size (#3760)
Signed-off-by: Ash Wu <[email protected]>
2026-05-20 22:29:59 +08:00
74 changed files with 2897 additions and 563 deletions

No files matched your search

+26
View File
@@ -8,10 +8,36 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Add `--refresh-ad-hoc-provisioning-profile` flag to refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials in non-interactive mode. ([#3716](https://github.com/expo/eas-cli/pull/3716) by [@sswrk](https://github.com/sswrk))
- [eas-build-job] Add optional `refreshAdHocProvisioningProfile` field to iOS build jobs. ([#3717](https://github.com/expo/eas-cli/pull/3717) by [@sswrk](https://github.com/sswrk))
- [build-tools] Pass `refreshAdHocProvisioningProfile` through `eas build:internal` for git-based integration builds. ([#3770](https://github.com/expo/eas-cli/pull/3770) by [@sswrk](https://github.com/sswrk))
### 🐛 Bug fixes
### 🧹 Chores
## [19.0.8](https://github.com/expo/eas-cli/releases/tag/v19.0.8) - 2026-05-21
### 🐛 Bug fixes
- [eas-cli] `eas go` now pre-selects the SDK version from the current project's `app.json` or `app.config.js` when available. ([#3776](https://github.com/expo/eas-cli/pull/3776) by [@gwdp](https://github.com/gwdp))
## [19.0.7](https://github.com/expo/eas-cli/releases/tag/v19.0.7) - 2026-05-21
## [19.0.6](https://github.com/expo/eas-cli/releases/tag/v19.0.6) - 2026-05-21
### 🐛 Bug fixes
- [build-tools] Update the minimum Expo version required for iOS precompiled modules to 55.0.26. ([#3771](https://github.com/expo/eas-cli/pull/3771) by [@chrfalch](https://github.com/chrfalch))
## [19.0.5](https://github.com/expo/eas-cli/releases/tag/v19.0.5) - 2026-05-20
### 🐛 Bug fixes
- [eas-cli] Improve Sentry diagnostics for managed iOS entitlements config fallback. ([#3763](https://github.com/expo/eas-cli/pull/3763) by [@sjchmiela](https://github.com/sjchmiela))
## [19.0.4](https://github.com/expo/eas-cli/releases/tag/v19.0.4) - 2026-05-20
## [19.0.1](https://github.com/expo/eas-cli/releases/tag/v19.0.1) - 2026-05-19
### 🐛 Bug fixes
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "19.0.1",
"version": "19.1.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "19.0.0",
"version": "19.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -38,14 +38,14 @@
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "19.0.0",
"@expo/eas-build-job": "19.0.0",
"@expo/eas-build-job": "19.1.0",
"@expo/env": "^0.4.0",
"@expo/logger": "19.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.2.0",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "19.0.0",
"@expo/steps": "19.1.0",
"@expo/template-file": "19.0.0",
"@expo/turtle-spawn": "19.0.0",
"@expo/xcpretty": "^4.3.1",
@@ -4,6 +4,7 @@ import { vol } from 'memfs';
import { createTestAndroidJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { Datadog } from '../../datadog';
import { restoreCredentials } from '../../android/credentials';
import androidBuilder from '../android';
import { runBuilderWithHooksAsync } from '../common';
@@ -67,8 +68,11 @@ jest.mock('../../utils/prepareBuildExecutable', () => ({
prepareExecutableAsync: jest.fn(),
}));
const datadogDistributionMock = jest.spyOn(Datadog, 'distribution');
describe(androidBuilder, () => {
beforeEach(() => {
jest.clearAllMocks();
vol.fromJSON(
{
'/workingdir/env/.gitkeep': '',
@@ -138,7 +142,6 @@ describe(androidBuilder, () => {
});
it('marks the configure Android version phase as warning for legacy eas-build.gradle', async () => {
const reportBuildPhaseStats = jest.fn();
const job: Android.Job = {
...createTestAndroidJob(),
secrets: {
@@ -157,23 +160,23 @@ describe(androidBuilder, () => {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
});
it('marks the prepare credentials phase as warning for legacy eas-build.gradle', async () => {
const reportBuildPhaseStats = jest.fn();
const ctx = new BuildContext(createTestAndroidJob(), {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
@@ -182,23 +185,23 @@ describe(androidBuilder, () => {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
await androidBuilder(ctx);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
});
it('injects credentials and version config when both are configured', async () => {
const reportBuildPhaseStats = jest.fn();
const job: Android.Job = {
...createTestAndroidJob(),
version: {
@@ -214,7 +217,6 @@ describe(androidBuilder, () => {
__API_SERVER_URL: 'http://api.expo.test',
},
uploadArtifact: jest.fn(),
reportBuildPhaseStats,
});
vol.mkdirSync('/workingdir/build/android/app', { recursive: true });
vol.writeFileSync('/workingdir/build/android/app/eas-build.gradle', '// Legacy content');
@@ -234,15 +236,19 @@ describe(androidBuilder, () => {
versionName: '1.2.3',
}
);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
buildPhase: BuildPhase.PREPARE_CREDENTIALS,
build_phase: BuildPhase.PREPARE_CREDENTIALS.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
expect(reportBuildPhaseStats).toHaveBeenCalledWith(
expect(datadogDistributionMock).toHaveBeenCalledWith(
'eas.build.phase_duration',
expect.any(Number),
expect.objectContaining({
buildPhase: BuildPhase.CONFIGURE_ANDROID_VERSION,
build_phase: BuildPhase.CONFIGURE_ANDROID_VERSION.toLowerCase(),
result: BuildPhaseResult.WARNING,
})
);
@@ -10,6 +10,8 @@ import {
resolveGradleCommand,
runGradleCommand,
} from '../android/gradle';
import { parseGradleProfile, formatGradleProfileReport } from '../android/gradleProfile';
import { Sentry } from '../sentry';
import { eagerBundleAsync, shouldUseEagerBundle } from '../common/eagerBundle';
import { prebuildAsync } from '../common/prebuild';
import { setupAsync } from '../common/setup';
@@ -180,6 +182,20 @@ async function buildAsync(ctx: BuildContext<Android.Job>): Promise<void> {
});
});
await ctx.runBuildPhase(BuildPhase.GRADLE_BUILD_PROFILE, async () => {
try {
const androidDir = path.join(ctx.getReactNativeProjectDirectory(), 'android');
const profileTasks = await parseGradleProfile(androidDir);
if (profileTasks.length > 0) {
const report = formatGradleProfileReport(profileTasks);
ctx.logger.info(report);
}
} catch (err: any) {
Sentry.capture('Failed to parse Gradle build profile', err);
ctx.markBuildPhaseSkipped();
}
});
await ctx.runBuildPhase(BuildPhase.PRE_UPLOAD_ARTIFACTS_HOOK, async () => {
await runHookIfPresent(ctx, Hook.PRE_UPLOAD_ARTIFACTS);
});
@@ -1,4 +1,4 @@
import { BuildJob } from '@expo/eas-build-job';
import { ArchiveSourceType, BuildJob, Platform, Workflow } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { resolveEnvFromBuildProfileAsync, runEasBuildInternalAsync } from '../easBuildInternal';
@@ -15,7 +15,7 @@ jest.mock('../../utils/easCli', () => ({
describe('easBuildInternal', () => {
beforeEach(() => {
jest.resetAllMocks();
jest.clearAllMocks();
jest.mocked(resolveEasCommandPrefixAndEnvAsync).mockResolvedValue({
cmd: 'npx',
args: ['-y', 'eas-cli@latest'],
@@ -69,4 +69,131 @@ describe('easBuildInternal', () => {
expect(resolveEasCommandPrefixAndEnvAsync).toHaveBeenCalledWith();
expect(spawn).not.toHaveBeenCalled();
});
it('passes --refresh-ad-hoc-provisioning-profile to build:internal for iOS jobs with refreshAdHocProvisioningProfile', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const internalJob = {
platform: Platform.IOS,
type: Workflow.GENERIC,
triggeredBy: 'EAS_CLI',
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
projectRootDirectory: '.',
secrets: {
buildCredentials: {
testapp: {
distributionCertificate: {
dataBase64: 'YmluYXJ5Y29udGVudDE=',
password: 'distCertPassword',
},
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
},
},
},
initiatingUserId: 'user-id',
appId: 'app-id',
};
jest.mocked(spawn).mockResolvedValue({
stdout: Buffer.from(
JSON.stringify({
job: internalJob,
metadata: {},
})
),
stderr: Buffer.from(''),
} as any);
const job = {
platform: Platform.IOS,
buildProfile: 'preview',
refreshAdHocProvisioningProfile: true,
appId: 'app-id',
initiatingUserId: 'user-id',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
await runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
});
expect(spawn).toHaveBeenCalledWith(
'npx',
expect.arrayContaining([
'build:internal',
'--platform',
Platform.IOS,
'--profile',
'preview',
'--refresh-ad-hoc-provisioning-profile',
]),
expect.any(Object)
);
});
it('preserves refreshAdHocProvisioningProfile from the incoming job', async () => {
const logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
child: jest.fn(),
} as any;
const internalJob = {
platform: Platform.IOS,
type: Workflow.GENERIC,
triggeredBy: 'EAS_CLI',
projectArchive: { type: ArchiveSourceType.URL, url: 'https://example.com' },
projectRootDirectory: '.',
secrets: {
buildCredentials: {
testapp: {
distributionCertificate: {
dataBase64: 'YmluYXJ5Y29udGVudDE=',
password: 'distCertPassword',
},
provisioningProfileBase64: 'MnRuZXRub2N5cmFuaWI=',
},
},
},
initiatingUserId: 'user-id',
appId: 'app-id',
};
jest.mocked(spawn).mockResolvedValue({
stdout: Buffer.from(
JSON.stringify({
job: internalJob,
metadata: {},
})
),
stderr: Buffer.from(''),
} as any);
const job = {
platform: Platform.IOS,
buildProfile: 'preview',
refreshAdHocProvisioningProfile: true,
appId: 'app-id',
initiatingUserId: 'user-id',
secrets: { robotAccessToken: 'token' },
} as unknown as BuildJob;
const { newJob } = await runEasBuildInternalAsync({
job,
logger,
env: {},
cwd: '/tmp/project',
});
expect((newJob as any).refreshAdHocProvisioningProfile).toBe(true);
});
});
@@ -1,4 +1,12 @@
import { BuildJob, Env, Metadata, sanitizeBuildJob, sanitizeMetadata } from '@expo/eas-build-job';
import {
BuildJob,
Env,
Ios,
Metadata,
Platform,
sanitizeBuildJob,
sanitizeMetadata,
} from '@expo/eas-build-job';
import { PipeMode, bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
@@ -42,6 +50,11 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
autoSubmitArgs.push('--auto-submit');
}
const refreshAdHocProvisioningProfileArgs = [];
if (job.platform === Platform.IOS && job.refreshAdHocProvisioningProfile === true) {
refreshAdHocProvisioningProfileArgs.push('--refresh-ad-hoc-provisioning-profile');
}
try {
const result = await spawn(
cmd,
@@ -53,6 +66,7 @@ export async function runEasBuildInternalAsync<TJob extends BuildJob>({
'--profile',
buildProfile,
...autoSubmitArgs,
...refreshAdHocProvisioningProfileArgs,
],
{
cwd,
@@ -138,6 +152,9 @@ function validateEasBuildInternalResult<TJob extends BuildJob>({
// We want to retain values that we have set on the job.
appId: oldJob.appId,
initiatingUserId: oldJob.initiatingUserId,
...(oldJob.platform === Platform.IOS && oldJob.refreshAdHocProvisioningProfile === true
? { refreshAdHocProvisioningProfile: true }
: null),
}) as TJob;
assert(newJob.platform === oldJob.platform, 'eas-cli returned a job for a wrong platform');
const newMetadata = sanitizeMetadata(value.metadata);
-5
View File
@@ -2,7 +2,6 @@ import { ExpoConfig } from '@expo/config';
import {
BuildPhase,
BuildPhaseResult,
BuildPhaseStats,
Env,
EnvironmentSecretType,
GenericArtifactType,
@@ -65,7 +64,6 @@ export interface BuildContextOptions {
err?: Error,
options?: { tags?: Record<string, string>; extras?: Record<string, string> }
) => void;
reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
skipNativeBuild?: boolean;
metadata?: Metadata;
expoApiV2BaseUrl?: string;
@@ -98,7 +96,6 @@ export class BuildContext<TJob extends Job = Job> {
private buildPhaseSkipped = false;
private buildPhaseHasWarnings = false;
private _appConfig?: Promise<ExpoConfig>;
private readonly reportBuildPhaseStats?: (stats: BuildPhaseStats) => void;
public readonly graphqlClient: Client;
constructor(job: TJob, options: BuildContextOptions) {
@@ -114,7 +111,6 @@ export class BuildContext<TJob extends Job = Job> {
this._metadata = options.metadata;
this.skipNativeBuild = options.skipNativeBuild;
this.expoApiV2BaseUrl = options.expoApiV2BaseUrl;
this.reportBuildPhaseStats = options.reportBuildPhaseStats;
const environmentSecrets = this.getEnvironmentSecrets(job);
this._env = {
@@ -355,7 +351,6 @@ export class BuildContext<TJob extends Job = Job> {
}
await this.collectAndUpdateEnvVariablesAsync();
this.reportBuildPhaseStats?.({ buildPhase: this.buildPhase, result, durationMs });
if (this.job.platform) {
Datadog.distribution('eas.build.phase_duration', durationMs, {
build_phase: this.buildPhase.toLowerCase(),
@@ -9,6 +9,7 @@ exports[`fastfile createFastfileForResigningBuild should create Fastfile with al
"com.example.app" => "/path/to/profiles/main.mobileprovision",
"com.example.app.widget" => "/path/to/profiles/widget.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/Users/expo/Library/Keychains/login.keychain"
)
end
@@ -22,6 +23,7 @@ exports[`fastfile createFastfileForResigningBuild should handle empty provisioni
signing_identity: "iPhone Distribution",
provisioning_profile: {
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -39,6 +41,7 @@ exports[`fastfile createFastfileForResigningBuild should handle multiple provisi
"com.example.app.extension" => "/profiles/extension.mobileprovision",
"com.example.app.intents" => "/profiles/intents.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -53,6 +56,7 @@ exports[`fastfile createFastfileForResigningBuild should handle paths with speci
provisioning_profile: {
"com.example.app" => "/path/with spaces/profile (1).mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/Users/expo/Library/Keychains/login keychain.keychain"
)
end
@@ -67,6 +71,7 @@ exports[`fastfile createFastfileForResigningBuild should handle single provision
provisioning_profile: {
"com.example.app" => "/tmp/profile.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -81,6 +86,7 @@ exports[`fastfile createFastfileForResigningBuild should produce valid Ruby synt
provisioning_profile: {
"com.example.app" => "/path/to/profile.mobileprovision",
},
use_app_entitlements: true,
keychain_path: "/tmp/keychain"
)
end
@@ -126,14 +126,14 @@ describe(installPods, () => {
});
it('does not add precompiled modules env vars below the minimum Expo version', async () => {
const ctx = makeIosBuildContext({ expoVersion: '55.0.17' });
const ctx = makeIosBuildContext({ expoVersion: '55.0.25' });
await installPods(ctx, {});
expect((spawn as jest.Mock).mock.calls[0][2].env.EXPO_USE_PRECOMPILED_MODULES).toBeUndefined();
expect(ctx.logger.info).toHaveBeenCalledWith(
expect.stringMatching(
/^Detected expo=55\.0\.17; not enabling precompiled modules use because precompiled modules require expo>=/
/^Detected expo=55\.0\.25; not enabling precompiled modules use because precompiled modules require expo>=55\.0\.26\./
)
);
});
+1 -1
View File
@@ -5,7 +5,7 @@ import semver from 'semver';
import { BuildContext } from '../context';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.21';
const MIN_PRECOMPILED_MODULES_EXPO_VERSION = '55.0.26';
const PRECOMPILED_MODULES_BASE_URL =
'https://storage.googleapis.com/eas-build-precompiled-modules/';
@@ -41,6 +41,7 @@ import { createSaveCacheFunction } from './functions/saveCache';
import { createSendSlackMessageFunction } from './functions/sendSlackMessage';
import { createStartAgentDeviceRemoteSessionBuildFunction } from './functions/startAgentDeviceRemoteSession';
import { createStartAndroidEmulatorBuildFunction } from './functions/startAndroidEmulator';
import { createStartArgentRemoteSessionBuildFunction } from './functions/startArgentRemoteSession';
import { createStartCuttlefishDeviceBuildFunction } from './functions/startCuttlefishDevice';
import { createStartIosSimulatorBuildFunction } from './functions/startIosSimulator';
import { createStartServeSimRemoteSessionBuildFunction } from './functions/startServeSimRemoteSession';
@@ -81,6 +82,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
runFastlaneFunction(),
parseXcactivitylogFunction(),
createStartAgentDeviceRemoteSessionBuildFunction(ctx),
createStartArgentRemoteSessionBuildFunction(ctx),
createStartAndroidEmulatorBuildFunction(),
createStartCuttlefishDeviceBuildFunction(),
createStartIosSimulatorBuildFunction(),
@@ -8,26 +8,24 @@ import {
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { CustomBuildContext } from '../../customBuildContext';
import { sleepAsync } from '../../utils/retry';
import {
DetachedProcessHandle,
ensureBrewPackageInstalledAsync,
ensureCloudflaredInstalledAsync,
getDeviceRunSessionIdOrThrow,
spawnDetached,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForFileAsync,
waitForMatchInOutputAsync,
} from '../utils/remoteDeviceRunSession';
const AGENT_DEVICE_REPO_URL = 'https://github.com/callstackincubator/agent-device.git';
const SRC_DIR = '/tmp/agent-device-src';
const DAEMON_JSON_PATH = path.join(os.homedir(), '.agent-device', 'daemon.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const CLOUDFLARED_LINUX_INSTALL_PATH = '/usr/local/bin/cloudflared';
const STARTUP_TIMEOUT_MS = 60_000;
export function createStartAgentDeviceRemoteSessionBuildFunction(
@@ -92,12 +90,12 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
});
logger.info(`Waiting for daemon credentials at ${DAEMON_JSON_PATH}.`);
await waitForFileAsync({
const { port: daemonPort, token: daemonToken } = await waitForFileAsync({
filePath: DAEMON_JSON_PATH,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'agent-device daemon',
description: 'agent-device daemon credentials',
parse: parseDaemonInfo,
});
const { port: daemonPort, token: daemonToken } = readDaemonInfo(DAEMON_JSON_PATH);
logger.info(`Daemon is listening on port ${daemonPort}; loaded auth token.`);
logger.info(`Starting cloudflared tunnel to http://localhost:${daemonPort}.`);
@@ -148,62 +146,6 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
});
}
async function ensureCloudflaredInstalledAsync({
runtimePlatform,
env,
logger,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<string> {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
return 'cloudflared';
}
if (await isCommandAvailableAsync({ command: 'cloudflared', env })) {
return 'cloudflared';
}
const cloudflaredArch = cloudflaredLinuxArchForNodeArch(os.arch());
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflaredArch}`;
logger.info(`Downloading cloudflared from ${downloadUrl} to ${CLOUDFLARED_LINUX_INSTALL_PATH}.`);
await spawn('sudo', ['curl', '-fsSL', '-o', CLOUDFLARED_LINUX_INSTALL_PATH, downloadUrl], {
env,
logger,
});
await spawn('sudo', ['chmod', '+x', CLOUDFLARED_LINUX_INSTALL_PATH], { env, logger });
// Return the absolute install path so the tunnel command works even when
// /usr/local/bin is not on the step's PATH.
return CLOUDFLARED_LINUX_INSTALL_PATH;
}
function cloudflaredLinuxArchForNodeArch(arch: string): 'amd64' | 'arm64' {
if (arch === 'x64') {
return 'amd64';
}
if (arch === 'arm64') {
return 'arm64';
}
throw new SystemError(
`Unsupported architecture for cloudflared on Linux: "${arch}". Expected "x64" or "arm64".`
);
}
async function isCommandAvailableAsync({
command,
env,
}: {
command: string;
env: BuildStepEnv;
}): Promise<boolean> {
try {
await spawn('bash', ['-c', `command -v ${command}`], { env, ignoreStdio: true });
return true;
} catch {
return false;
}
}
async function cloneAgentDeviceAsync({
packageVersion,
env,
@@ -220,54 +162,7 @@ async function cloneAgentDeviceAsync({
});
}
async function waitForMatchInOutputAsync({
process,
pattern,
timeoutMs,
description,
}: {
process: DetachedProcessHandle;
pattern: RegExp;
timeoutMs: number;
description: string;
}): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const match = pattern.exec(process.getOutput());
if (match) {
return match[1] ?? match[0];
}
await sleepAsync(1_000);
}
throw new SystemError(
`Timed out waiting for ${description} to start. Last output:\n${process.getOutput() || '<empty>'}`
);
}
async function waitForFileAsync({
filePath,
timeoutMs,
description,
}: {
filePath: string;
timeoutMs: number;
description: string;
}): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
await fs.promises.access(filePath);
return;
} catch {
// not yet; keep polling
}
await sleepAsync(1_000);
}
throw new SystemError(`Timed out waiting for ${description} to write ${filePath}.`);
}
function readDaemonInfo(filePath: string): { port: number; token: string } {
const raw = fs.readFileSync(filePath, 'utf8');
function parseDaemonInfo(raw: string): { port: number; token: string } {
const parsed = JSON.parse(raw) as unknown;
if (
!parsed ||
@@ -276,7 +171,7 @@ function readDaemonInfo(filePath: string): { port: number; token: string } {
typeof (parsed as { token: unknown }).token !== 'string'
) {
throw new SystemError(
`Expected ${filePath} to contain { "httpPort": <number>, "token": "..." }.`
'Expected daemon credentials to contain { "httpPort": <number>, "token": "..." }.'
);
}
const { httpPort, token } = parsed as { httpPort: number; token: string };
@@ -0,0 +1,149 @@
import { SystemError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
BuildStepInput,
BuildStepInputValueTypeName,
} from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import {
ensureCloudflaredInstalledAsync,
getDeviceRunSessionIdOrThrow,
spawnDetached,
startServeSimWithTunnelAsync,
uploadRemoteSessionConfigAsync,
waitForFileAsync,
waitForMatchInOutputAsync,
} from '../utils/remoteDeviceRunSession';
const ARGENT_PACKAGE_NAME = '@swmansion/argent';
const ARGENT_STATE_FILE = path.join(os.homedir(), '.argent', 'tool-server.json');
const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
const STARTUP_TIMEOUT_MS = 60_000;
const ArgentToolServerStateSchema = z.object({ port: z.number() });
export function createStartArgentRemoteSessionBuildFunction(
ctx: CustomBuildContext
): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'start_argent_remote_session',
name: 'Start argent remote session',
__metricsId: 'eas/start_argent_remote_session',
inputProviders: [
BuildStepInput.createProvider({
id: 'package_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async ({ logger, global }, { inputs, env }) => {
// Fail fast before any expensive setup if the orchestrator-injected
// DEVICE_RUN_SESSION_ID env var is missing — without it we cannot
// report the remote config back to the API server.
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const packageVersion = inputs.package_version.value as string | undefined;
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
logger.info(
`Starting argent remote session (version: ${versionSpec}, runtime: ${runtimePlatform}).`
);
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
}
logger.info('Ensuring cloudflared is installed.');
const cloudflaredCommand = await ensureCloudflaredInstalledAsync({
runtimePlatform,
env,
logger,
});
// Stale state from a previous run would mask the new server's port.
await fs.promises.rm(ARGENT_STATE_FILE, { force: true });
logger.info(`Launching ${ARGENT_PACKAGE_NAME}@${versionSpec} via bunx.`);
// `argent mcp` is the public entry that triggers @argent/tools-client
// to spawn the tool-server detached + unref'd, so the tool-server
// outlives this MCP process. ARGENT_IDLE_TIMEOUT_MINUTES=0 disables the
// 30-min idle shutdown that would otherwise tear the tunnel down.
spawnDetached({
command: 'bunx',
args: [`${ARGENT_PACKAGE_NAME}@${versionSpec}`, 'mcp'],
env: { ...env, ARGENT_IDLE_TIMEOUT_MINUTES: '0' },
});
logger.info(`Waiting for argent tool-server state at ${ARGENT_STATE_FILE}.`);
const { port: toolServerPort } = await waitForFileAsync({
filePath: ARGENT_STATE_FILE,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'argent tool-server state',
parse: parseArgentToolServerState,
});
logger.info(`Argent tool-server is listening on port ${toolServerPort}.`);
logger.info(`Starting cloudflared tunnel to http://localhost:${toolServerPort}.`);
const cloudflared = spawnDetached({
command: cloudflaredCommand,
args: ['tunnel', '--url', `http://localhost:${toolServerPort}`],
env,
});
logger.info('Waiting for a public tunnel URL.');
const toolsUrl = await waitForMatchInOutputAsync({
process: cloudflared,
pattern: /https:\/\/[a-z0-9-]+\.trycloudflare\.com/,
timeoutMs: STARTUP_TIMEOUT_MS,
description: 'cloudflared tunnel',
});
logger.info(`Tunnel is ready at ${toolsUrl}.`);
// serve-sim is iOS-only — Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const serveSim = await startServeSimWithTunnelAsync({
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
webPreviewUrl = serveSim.previewUrl;
logger.info(`Web preview URL: ${webPreviewUrl}`);
}
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: {
toolsUrl,
...(webPreviewUrl ? { webPreviewUrl } : {}),
},
logger,
});
logger.info('Remote session is live. Keeping the job alive until the session is stopped.');
// Keep the turtle job alive so the tool-server and tunnel stay reachable
// until stopDeviceRunSession cancels the run.
await new Promise<never>(() => {});
},
});
}
function parseArgentToolServerState(raw: string): { port: number } {
const result = ArgentToolServerStateSchema.safeParse(JSON.parse(raw));
if (!result.success) {
throw new SystemError(
`Expected tool-server state to contain { "port": <number>, ... }: ${result.error.message}`
);
}
return result.data;
}
@@ -7,9 +7,9 @@ import { createMockLogger } from '../../../../__tests__/utils/logger';
import { Sentry } from '../../../../sentry';
import {
buildTargetMetrics,
isCompileStep,
collectTopLevelCompileSteps,
formatReport,
isCompileStep,
parseAndReportXcactivitylog,
} from '../xcactivitylog';
@@ -267,6 +267,9 @@ describe('parseAndReportXcactivitylog', () => {
'/tmp/workspace',
'--reporter',
'json',
'--omit_warnings',
'--omit_notes',
'--trunc_large_issues',
'--output',
reportPath,
],
@@ -382,7 +385,7 @@ describe('parseAndReportXcactivitylog', () => {
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "parsing_xclogparser_output"',
expect.any(Error),
{ tags: { phase: 'parsing_xclogparser_output' } }
expect.objectContaining({ tags: { phase: 'parsing_xclogparser_output' } })
);
});
@@ -413,7 +416,7 @@ describe('parseAndReportXcactivitylog', () => {
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "parsing_xclogparser_output"',
expect.any(Error),
{ tags: { phase: 'parsing_xclogparser_output' } }
expect.objectContaining({ tags: { phase: 'parsing_xclogparser_output' } })
);
});
@@ -441,7 +444,45 @@ describe('parseAndReportXcactivitylog', () => {
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "running_xclogparser"',
expect.objectContaining({ message: 'spawn xclogparser ENOENT' }),
{ tags: { phase: 'running_xclogparser' } }
expect.objectContaining({ tags: { phase: 'running_xclogparser' } })
);
});
it('surfaces spawn error stderr/stdout/status/signal as Sentry extras', async () => {
const logger = createMockLogger();
mockFilesystem();
const spawnError: any = new Error('xclogparser exited with non-zero code: 1');
spawnError.stderr = 'XCLogParser fatal: missing xcactivitylog\n';
spawnError.stdout = 'some stdout';
spawnError.status = 1;
spawnError.signal = null;
mockedDownloadFile.mockResolvedValue(undefined);
mockedSpawn
.mockRejectedValueOnce(new Error('xclogparser not found')) // version detect
.mockResolvedValueOnce({ stdout: '', stderr: '' } as any) // unzip
.mockRejectedValueOnce(spawnError); // xclogparser run
await parseAndReportXcactivitylog({
derivedDataPath: '/tmp/derived-data',
workspacePath: '/tmp/workspace',
logger,
env: TEST_ENV,
});
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "running_xclogparser"',
expect.any(Error),
{
tags: { phase: 'running_xclogparser' },
extras: {
exitStatus: 1,
signal: null,
stderr: 'XCLogParser fatal: missing xcactivitylog\n',
stdout: 'some stdout',
},
}
);
});
@@ -467,7 +508,7 @@ describe('parseAndReportXcactivitylog', () => {
expect(Sentry.capture).toHaveBeenCalledWith(
'Build performance analysis failed during "downloading_xclogparser"',
expect.objectContaining({ message: 'network unreachable' }),
{ tags: { phase: 'downloading_xclogparser' } }
expect.objectContaining({ tags: { phase: 'downloading_xclogparser' } })
);
});
@@ -524,6 +565,9 @@ describe('parseAndReportXcactivitylog', () => {
'/tmp/workspace',
'--reporter',
'json',
'--omit_warnings',
'--omit_notes',
'--trunc_large_issues',
'--output',
reportPath,
],
@@ -82,7 +82,15 @@ export async function parseAndReportXcactivitylog({
} catch (err: any) {
logger.info('Build performance analysis skipped.');
const msg = `Build performance analysis failed during "${phase}"`;
Sentry.capture(msg, err, { tags: { phase } });
Sentry.capture(msg, err, {
tags: { phase },
extras: {
exitStatus: err?.status,
signal: err?.signal,
stderr: err?.stderr?.slice(-4000),
stdout: err?.stdout?.slice(-4000),
},
});
} finally {
if (tempDir) {
await asyncResult(fs.rm(tempDir, { force: true, recursive: true }));
@@ -203,6 +211,9 @@ async function runXclogparser({
workspacePath,
'--reporter',
'json',
'--omit_warnings',
'--omit_notes',
'--trunc_large_issues',
'--output',
outputPath,
],
@@ -1,13 +1,16 @@
import { SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import { BuildRuntimePlatform, BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import { graphql } from 'gql.tada';
import fs from 'node:fs';
import os from 'node:os';
import { CustomBuildContext } from '../../customBuildContext';
import { sleepAsync } from '../../utils/retry';
const TRYCLOUDFLARE_URL_PATTERN = /https:\/\/[a-z0-9-]+\.trycloudflare\.com/;
const CLOUDFLARED_LINUX_INSTALL_PATH = '/usr/local/bin/cloudflared';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
@@ -157,3 +160,112 @@ function matchLabeledUrl(content: string, label: string): string | null {
const match = labelPattern.exec(content);
return match ? match[1] : null;
}
export async function ensureCloudflaredInstalledAsync({
runtimePlatform,
env,
logger,
}: {
runtimePlatform: BuildRuntimePlatform;
env: BuildStepEnv;
logger: bunyan;
}): Promise<string> {
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await ensureBrewPackageInstalledAsync({ name: 'cloudflared', env, logger });
return 'cloudflared';
}
if (await isCommandAvailableAsync({ command: 'cloudflared', env })) {
return 'cloudflared';
}
const cloudflaredArch = cloudflaredLinuxArchForNodeArch(os.arch());
const downloadUrl = `https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-${cloudflaredArch}`;
logger.info(`Downloading cloudflared from ${downloadUrl} to ${CLOUDFLARED_LINUX_INSTALL_PATH}.`);
await spawn('sudo', ['curl', '-fsSL', '-o', CLOUDFLARED_LINUX_INSTALL_PATH, downloadUrl], {
env,
logger,
});
await spawn('sudo', ['chmod', '+x', CLOUDFLARED_LINUX_INSTALL_PATH], { env, logger });
// Return the absolute install path so the tunnel command works even when
// /usr/local/bin is not on the step's PATH.
return CLOUDFLARED_LINUX_INSTALL_PATH;
}
function cloudflaredLinuxArchForNodeArch(arch: string): 'amd64' | 'arm64' {
if (arch === 'x64') {
return 'amd64';
}
if (arch === 'arm64') {
return 'arm64';
}
throw new SystemError(
`Unsupported architecture for cloudflared on Linux: "${arch}". Expected "x64" or "arm64".`
);
}
async function isCommandAvailableAsync({
command,
env,
}: {
command: string;
env: BuildStepEnv;
}): Promise<boolean> {
try {
await spawn('bash', ['-c', `command -v ${command}`], { env, ignoreStdio: true });
return true;
} catch {
return false;
}
}
export async function waitForMatchInOutputAsync({
process,
pattern,
timeoutMs,
description,
}: {
process: DetachedProcessHandle;
pattern: RegExp;
timeoutMs: number;
description: string;
}): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const match = pattern.exec(process.getOutput());
if (match) {
return match[1] ?? match[0];
}
await sleepAsync(1_000);
}
throw new SystemError(
`Timed out waiting for ${description} to start. Last output:\n${process.getOutput() || '<empty>'}`
);
}
export async function waitForFileAsync<T>({
filePath,
timeoutMs,
description,
parse,
}: {
filePath: string;
timeoutMs: number;
description: string;
parse: (raw: string) => T;
}): Promise<T> {
const deadline = Date.now() + timeoutMs;
let lastError: unknown;
while (Date.now() < deadline) {
try {
const raw = await fs.promises.readFile(filePath, 'utf8');
return parse(raw);
} catch (err) {
lastError = err;
}
await sleepAsync(1_000);
}
throw new SystemError(
`Timed out waiting for ${description} to be ready at ${filePath}${
lastError instanceof Error ? `: ${lastError.message}` : ''
}.`
);
}
@@ -5,6 +5,7 @@ export const FastfileResignTemplate = `lane :do_resign do
provisioning_profile: {<% _.forEach(PROFILES, function(profile) { %>
"<%- profile.BUNDLE_ID %>" => "<%- profile.PATH %>",<% }); %>
},
use_app_entitlements: true,
keychain_path: "<%- KEYCHAIN_PATH %>"
)
end
@@ -1,13 +1,20 @@
import fs from 'fs-extra';
import { vol } from 'memfs';
import { Sentry } from '../../sentry';
import { findArtifacts } from '../artifacts';
jest.mock('fs');
jest.mock('../../sentry', () => ({
Sentry: {
capture: jest.fn(),
},
}));
describe(findArtifacts, () => {
beforeEach(async () => {
vol.reset();
jest.clearAllMocks();
});
test('with correct path', async () => {
@@ -69,6 +76,44 @@ describe(findArtifacts, () => {
});
expect(loggerMock.error).toHaveBeenCalledTimes(0);
expect(paths.length).toBe(2);
expect(Sentry.capture).not.toHaveBeenCalled();
});
test('with absolute glob pattern', async () => {
await fs.mkdirp('/tmp');
await fs.mkdirp('/tmp/maestro_xctestrunner_xcodebuild_output123');
await fs.mkdirp('/tmp/maestro_xctestrunner_xcodebuild_output456');
const loggerMock = {
info: jest.fn(),
error: jest.fn(),
};
await expect(
findArtifacts({
rootDir: '/Users/expo/build',
patternOrPath: '/tmp/maestro_xctestrunner_xcodebuild_output*',
logger: loggerMock as any,
})
).rejects.toThrow(
'There are no files matching pattern "/tmp/maestro_xctestrunner_xcodebuild_output*"'
);
expect(loggerMock.error).toHaveBeenCalledTimes(0);
expect(Sentry.capture).toHaveBeenCalledWith(expect.any(Error), {
tags: {
source: 'find-artifacts',
reason: 'absolute_path',
},
extras: {
rootDir: '/Users/expo/build',
patternOrPath: '/tmp/maestro_xctestrunner_xcodebuild_output*',
currentCount: 0,
dryRunCount: 2,
currentSample: [],
dryRunSample: [
'/tmp/maestro_xctestrunner_xcodebuild_output123',
'/tmp/maestro_xctestrunner_xcodebuild_output456',
],
},
});
});
test('with missing file in empty directory', async () => {
@@ -6,6 +6,7 @@ import path from 'path';
import promiseLimit from 'promise-limit';
import { BuildContext } from '../context';
import { Sentry } from '../sentry';
export class FindArtifactsError extends Error {}
@@ -24,6 +25,11 @@ export async function findArtifacts({
? [patternOrPath]
: []
: await fg(patternOrPath, { cwd: rootDir, onlyFiles: false });
await maybeReportAbsoluteGlobDryRunMismatchAsync({
rootDir,
patternOrPath,
files,
});
if (files.length === 0) {
if (fg.isDynamicPattern(patternOrPath)) {
throw new FindArtifactsError(`There are no files matching pattern "${patternOrPath}"`);
@@ -48,6 +54,78 @@ export async function findArtifacts({
});
}
async function findArtifactsWithAbsoluteGlobSupportDryRunAsync(
rootDir: string,
patternOrPath: string
): Promise<string[]> {
return path.isAbsolute(patternOrPath) && !fg.isDynamicPattern(patternOrPath)
? (await fs.pathExists(patternOrPath))
? [patternOrPath]
: []
: await fg(patternOrPath, { cwd: rootDir, onlyFiles: false });
}
async function maybeReportAbsoluteGlobDryRunMismatchAsync({
rootDir,
patternOrPath,
files,
}: {
rootDir: string;
patternOrPath: string;
files: string[];
}): Promise<void> {
if (!path.isAbsolute(patternOrPath)) {
return;
}
try {
const filesWithAbsoluteGlobSupport = await findArtifactsWithAbsoluteGlobSupportDryRunAsync(
rootDir,
patternOrPath
);
if (areArtifactListsEqual(files, filesWithAbsoluteGlobSupport)) {
return;
}
Sentry.capture(new Error('findArtifacts output changed for an absolute path'), {
tags: {
source: 'find-artifacts',
reason: 'absolute_path',
},
extras: {
rootDir,
patternOrPath,
currentCount: files.length,
dryRunCount: filesWithAbsoluteGlobSupport.length,
currentSample: files.slice(0, 20),
dryRunSample: filesWithAbsoluteGlobSupport.slice(0, 20),
},
});
} catch (err: any) {
Sentry.capture(err, {
tags: {
source: 'find-artifacts',
reason: 'absolute_path_dry_run_failed',
},
extras: {
rootDir,
patternOrPath,
currentCount: files.length,
currentSample: files.slice(0, 20),
},
});
}
}
function areArtifactListsEqual(first: string[], second: string[]): boolean {
if (first.length !== second.length) {
return false;
}
const sortedFirst = [...first].sort();
const sortedSecond = [...second].sort();
return sortedFirst.every((artifactPath, index) => artifactPath === sortedSecond[index]);
}
async function logMissingFileError(artifactPath: string, buildLogger: bunyan): Promise<void> {
let currentPath = artifactPath;
while (!(await fs.pathExists(currentPath))) {
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "19.0.0",
"version": "19.1.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "19.0.0"
"@expo/steps": "19.1.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "19.0.0",
"version": "19.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -534,4 +534,47 @@ describe('Ios.JobSchema', () => {
expect(value).toMatchObject(job);
expect(error).toBeFalsy();
});
test('accepts optional refreshAdHocProvisioningProfile', () => {
const job = {
secrets: {
buildCredentials,
},
type: Workflow.GENERIC,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'http://localhost:3000',
},
projectRootDirectory: '.',
initiatingUserId: randomUUID(),
appId: randomUUID(),
refreshAdHocProvisioningProfile: true,
};
const { value, error } = Ios.JobSchema.validate(job, joiOptions);
expect(value.refreshAdHocProvisioningProfile).toBe(true);
expect(error).toBeFalsy();
});
test('does not require refreshAdHocProvisioningProfile', () => {
const job = {
secrets: {
buildCredentials,
},
type: Workflow.GENERIC,
platform: Platform.IOS,
projectArchive: {
type: ArchiveSourceType.URL,
url: 'http://localhost:3000',
},
projectRootDirectory: '.',
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
const { value, error } = Ios.JobSchema.validate(job, joiOptions);
expect(value.refreshAdHocProvisioningProfile).toBeUndefined();
expect(error).toBeFalsy();
});
});
-6
View File
@@ -181,12 +181,6 @@ export const CacheSchema = Joi.object({
paths: Joi.array().items(Joi.string()).default([]),
});
export interface BuildPhaseStats {
buildPhase: BuildPhase;
result: BuildPhaseResult;
durationMs: number;
}
const GitHubContextZ = z.object({
triggering_actor: z.string().optional(),
event_name: z.enum(['push', 'pull_request', 'workflow_dispatch', 'schedule']),
-1
View File
@@ -5,7 +5,6 @@ export {
ArchiveSource,
ArchiveSourceSchemaZ,
BuildMode,
BuildPhaseStats,
BuildTrigger,
EasCliNpmTags,
Env,
+4
View File
@@ -144,6 +144,8 @@ export interface Job {
appId: string;
environment?: string;
refreshAdHocProvisioningProfile?: boolean;
}
const SecretsSchema = Joi.object({
@@ -229,6 +231,8 @@ export const JobSchema = Joi.object({
environment: Joi.string(),
refreshAdHocProvisioningProfile: Joi.boolean(),
workflowInterpolationContext: Joi.object().custom(workflowInterpolationContext =>
StaticWorkflowInterpolationContextZ.optional().parse(workflowInterpolationContext)
),
+104 -101
View File
@@ -190,7 +190,7 @@ ALIASES
$ eas login
```
_See code: [packages/eas-cli/src/commands/account/login.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/account/login.ts)_
## `eas account:logout`
@@ -207,7 +207,7 @@ ALIASES
$ eas logout
```
_See code: [packages/eas-cli/src/commands/account/logout.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/account/logout.ts)_
## `eas account:usage [ACCOUNT_NAME]`
@@ -229,7 +229,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/account/usage.ts)_
## `eas account:view`
@@ -246,7 +246,7 @@ ALIASES
$ eas whoami
```
_See code: [packages/eas-cli/src/commands/account/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/account/view.ts)_
## `eas analytics [STATUS]`
@@ -260,7 +260,7 @@ DESCRIPTION
display or change analytics settings
```
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/analytics.ts)_
_See code: [packages/eas-cli/src/commands/analytics.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/analytics.ts)_
## `eas autocomplete [SHELL]`
@@ -312,7 +312,7 @@ DESCRIPTION
create a branch
```
_See code: [packages/eas-cli/src/commands/branch/create.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/branch/create.ts)_
## `eas branch:delete [NAME]`
@@ -333,7 +333,7 @@ DESCRIPTION
delete a branch
```
_See code: [packages/eas-cli/src/commands/branch/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/branch/delete.ts)_
## `eas branch:list`
@@ -353,7 +353,7 @@ DESCRIPTION
list all branches
```
_See code: [packages/eas-cli/src/commands/branch/list.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/branch/list.ts)_
## `eas branch:rename`
@@ -373,7 +373,7 @@ DESCRIPTION
rename a branch
```
_See code: [packages/eas-cli/src/commands/branch/rename.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/branch/rename.ts)_
## `eas branch:view [NAME]`
@@ -396,7 +396,7 @@ DESCRIPTION
view a branch
```
_See code: [packages/eas-cli/src/commands/branch/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/branch/view.ts)_
## `eas build`
@@ -406,7 +406,8 @@ start a build
USAGE
$ eas build [-p android|ios|all] [-e PROFILE_NAME] [--local] [--output <value>] [--wait] [--clear-cache]
[-s | --auto-submit-with-profile PROFILE_NAME] [--what-to-test <value>] [-m <value>] [--build-logger-level
trace|debug|info|warn|error|fatal] [--freeze-credentials] [--verbose-logs] [--json] [--non-interactive]
trace|debug|info|warn|error|fatal] [--freeze-credentials] [--refresh-ad-hoc-provisioning-profile] [--verbose-logs]
[--json] [--non-interactive]
FLAGS
-e, --profile=PROFILE_NAME Name of the build profile from eas.json. Defaults to "production" if
@@ -425,6 +426,8 @@ FLAGS
--local Run build locally [experimental]
--non-interactive Run the command in non-interactive mode.
--output=<value> Output path for local build
--refresh-ad-hoc-provisioning-profile Refresh managed ad-hoc provisioning profiles from App Store Connect
before gathering build credentials
--verbose-logs Use verbose logs for the build process
--[no-]wait Wait for build(s) to complete
--what-to-test=<value> Specify the "What to Test" information for the build in TestFlight
@@ -434,7 +437,7 @@ DESCRIPTION
start a build
```
_See code: [packages/eas-cli/src/commands/build/index.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/build/index.ts)_
## `eas build:cancel [BUILD_ID]`
@@ -454,7 +457,7 @@ DESCRIPTION
cancel a build
```
_See code: [packages/eas-cli/src/commands/build/cancel.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/build/cancel.ts)_
## `eas build:configure`
@@ -472,7 +475,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/build/configure.ts)_
## `eas build:delete [BUILD_ID]`
@@ -492,7 +495,7 @@ DESCRIPTION
delete a build
```
_See code: [packages/eas-cli/src/commands/build/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/build/delete.ts)_
## `eas build:dev`
@@ -517,7 +520,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/build/dev.ts)_
## `eas build:download`
@@ -544,7 +547,7 @@ DESCRIPTION
download a simulator/emulator build by build ID or fingerprint hash
```
_See code: [packages/eas-cli/src/commands/build/download.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/build/download.ts)_
## `eas build:inspect`
@@ -582,7 +585,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/build/inspect.ts)_
## `eas build:list`
@@ -622,7 +625,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/build/list.ts)_
## `eas build:resign`
@@ -652,7 +655,7 @@ DESCRIPTION
re-sign a build archive
```
_See code: [packages/eas-cli/src/commands/build/resign.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/build/resign.ts)_
## `eas build:run`
@@ -680,7 +683,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/build/run.ts)_
## `eas build:submit`
@@ -732,7 +735,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/build/version/get.ts)_
## `eas build:version:set`
@@ -750,7 +753,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/build/version/set.ts)_
## `eas build:version:sync`
@@ -768,7 +771,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/build/version/sync.ts)_
## `eas build:view [BUILD_ID]`
@@ -785,7 +788,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/build/view.ts)_
## `eas channel:create [NAME]`
@@ -806,7 +809,7 @@ DESCRIPTION
create a channel
```
_See code: [packages/eas-cli/src/commands/channel/create.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/channel/create.ts)_
## `eas channel:delete [NAME]`
@@ -827,7 +830,7 @@ DESCRIPTION
Delete a channel
```
_See code: [packages/eas-cli/src/commands/channel/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/channel/delete.ts)_
## `eas channel:edit [NAME]`
@@ -849,7 +852,7 @@ DESCRIPTION
point a channel at a new branch
```
_See code: [packages/eas-cli/src/commands/channel/edit.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/channel/edit.ts)_
## `eas channel:insights`
@@ -873,7 +876,7 @@ DESCRIPTION
display adoption, crash, and unique-user insights for a channel + runtime version
```
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/channel/insights.ts)_
_See code: [packages/eas-cli/src/commands/channel/insights.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/channel/insights.ts)_
## `eas channel:list`
@@ -893,7 +896,7 @@ DESCRIPTION
list all channels
```
_See code: [packages/eas-cli/src/commands/channel/list.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/channel/list.ts)_
## `eas channel:pause [NAME]`
@@ -915,7 +918,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/channel/pause.ts)_
## `eas channel:resume [NAME]`
@@ -937,7 +940,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/channel/resume.ts)_
## `eas channel:rollout [CHANNEL]`
@@ -972,7 +975,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/channel/rollout.ts)_
## `eas channel:view [NAME]`
@@ -995,7 +998,7 @@ DESCRIPTION
view a channel
```
_See code: [packages/eas-cli/src/commands/channel/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/channel/view.ts)_
## `eas config`
@@ -1016,7 +1019,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/v19.0.1/packages/eas-cli/src/commands/config.ts)_
_See code: [packages/eas-cli/src/commands/config.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/config.ts)_
## `eas credentials`
@@ -1033,7 +1036,7 @@ DESCRIPTION
manage credentials
```
_See code: [packages/eas-cli/src/commands/credentials/index.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/credentials/index.ts)_
## `eas credentials:configure-build`
@@ -1051,7 +1054,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/credentials/configure-build.ts)_
## `eas deploy [options]`
@@ -1080,7 +1083,7 @@ ALIASES
$ eas worker:deploy
```
_See code: [packages/eas-cli/src/commands/deploy/index.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/deploy/index.ts)_
## `eas deploy:alias`
@@ -1105,7 +1108,7 @@ ALIASES
$ eas deploy:promote
```
_See code: [packages/eas-cli/src/commands/deploy/alias/index.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/deploy/alias/index.ts)_
## `eas deploy:alias:delete [ALIAS_NAME]`
@@ -1126,7 +1129,7 @@ ALIASES
$ eas worker:alias:delete
```
_See code: [packages/eas-cli/src/commands/deploy/alias/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/deploy/alias/delete.ts)_
## `eas deploy:delete [DEPLOYMENT_ID]`
@@ -1147,7 +1150,7 @@ ALIASES
$ eas worker:delete
```
_See code: [packages/eas-cli/src/commands/deploy/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/deploy/delete.ts)_
## `eas deploy:promote`
@@ -1184,7 +1187,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/device/create.ts)_
## `eas device:delete`
@@ -1204,7 +1207,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/device/delete.ts)_
## `eas device:list`
@@ -1225,7 +1228,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/device/list.ts)_
## `eas device:rename`
@@ -1246,7 +1249,7 @@ DESCRIPTION
rename a registered device
```
_See code: [packages/eas-cli/src/commands/device/rename.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/device/rename.ts)_
## `eas device:view [UDID]`
@@ -1260,7 +1263,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/device/view.ts)_
## `eas diagnostics`
@@ -1274,7 +1277,7 @@ DESCRIPTION
display environment info
```
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/diagnostics.ts)_
_See code: [packages/eas-cli/src/commands/diagnostics.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/diagnostics.ts)_
## `eas env:create [ENVIRONMENT]`
@@ -1306,7 +1309,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/env/create.ts)_
## `eas env:delete [ENVIRONMENT]`
@@ -1332,7 +1335,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/env/delete.ts)_
## `eas env:exec ENVIRONMENT BASH_COMMAND`
@@ -1354,7 +1357,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/env/exec.ts)_
## `eas env:get [ENVIRONMENT]`
@@ -1382,7 +1385,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/env/get.ts)_
## `eas env:list [ENVIRONMENT]`
@@ -1410,7 +1413,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/env/list.ts)_
## `eas env:pull [ENVIRONMENT]`
@@ -1433,7 +1436,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/env/pull.ts)_
## `eas env:push [ENVIRONMENT]`
@@ -1455,7 +1458,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/env/push.ts)_
## `eas env:update [ENVIRONMENT]`
@@ -1489,7 +1492,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/env/update.ts)_
## `eas fingerprint:compare [HASH1] [HASH2]`
@@ -1533,7 +1536,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/fingerprint/compare.ts)_
## `eas fingerprint:generate`
@@ -1564,7 +1567,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/fingerprint/generate.ts)_
## `eas help [COMMAND]`
@@ -1643,7 +1646,7 @@ DESCRIPTION
connect a project to an App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/connect.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/integrations/asc/connect.ts)_
## `eas integrations:asc:disconnect`
@@ -1662,7 +1665,7 @@ DESCRIPTION
disconnect the current project from its App Store Connect app
```
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/disconnect.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/integrations/asc/disconnect.ts)_
## `eas integrations:asc:status`
@@ -1680,7 +1683,7 @@ DESCRIPTION
show the App Store Connect app link status for the current project
```
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/asc/status.ts)_
_See code: [packages/eas-cli/src/commands/integrations/asc/status.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/integrations/asc/status.ts)_
## `eas integrations:convex:connect`
@@ -1702,7 +1705,7 @@ DESCRIPTION
connect Convex to your Expo project
```
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/connect.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/integrations/convex/connect.ts)_
## `eas integrations:convex:dashboard`
@@ -1716,7 +1719,7 @@ DESCRIPTION
open the Convex dashboard for the linked Convex project
```
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/dashboard.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/integrations/convex/dashboard.ts)_
## `eas integrations:convex:project`
@@ -1730,7 +1733,7 @@ DESCRIPTION
display the Convex project linked to the current Expo app
```
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/project.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/project.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/integrations/convex/project.ts)_
## `eas integrations:convex:project:delete`
@@ -1748,7 +1751,7 @@ DESCRIPTION
remove the Convex project link for the current Expo app from EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/project/delete.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/integrations/convex/project/delete.ts)_
## `eas integrations:convex:team`
@@ -1762,7 +1765,7 @@ DESCRIPTION
display Convex teams linked to the current Expo app's owner account
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/team.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/integrations/convex/team.ts)_
## `eas integrations:convex:team:delete [CONVEX_TEAM]`
@@ -1783,7 +1786,7 @@ DESCRIPTION
remove a Convex team link from the current Expo app owner account's EAS servers
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team/delete.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/integrations/convex/team/delete.ts)_
## `eas integrations:convex:team:invite [CONVEX_TEAM]`
@@ -1803,7 +1806,7 @@ DESCRIPTION
send a Convex team invitation to your verified email address
```
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
_See code: [packages/eas-cli/src/commands/integrations/convex/team/invite.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/integrations/convex/team/invite.ts)_
## `eas login`
@@ -1855,7 +1858,7 @@ DESCRIPTION
validate the local store configuration
```
_See code: [packages/eas-cli/src/commands/metadata/lint.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/metadata/lint.ts)_
## `eas metadata:pull`
@@ -1873,7 +1876,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/metadata/pull.ts)_
## `eas metadata:push`
@@ -1891,7 +1894,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/metadata/push.ts)_
## `eas new [PATH]`
@@ -1943,7 +1946,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/v19.0.1/packages/eas-cli/src/commands/open.ts)_
_See code: [packages/eas-cli/src/commands/open.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/open.ts)_
## `eas project:info`
@@ -1957,7 +1960,7 @@ DESCRIPTION
information about the current project
```
_See code: [packages/eas-cli/src/commands/project/info.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/project/info.ts)_
## `eas project:init`
@@ -1980,7 +1983,7 @@ ALIASES
$ eas init
```
_See code: [packages/eas-cli/src/commands/project/init.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/project/init.ts)_
## `eas project:new [PATH]`
@@ -2004,7 +2007,7 @@ ALIASES
$ eas new
```
_See code: [packages/eas-cli/src/commands/project/new.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/project/new.ts)_
## `eas project:onboarding [TARGET_PROJECT_DIRECTORY]`
@@ -2022,7 +2025,7 @@ ALIASES
$ eas onboarding
```
_See code: [packages/eas-cli/src/commands/project/onboarding.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/project/onboarding.ts)_
## `eas submit`
@@ -2055,7 +2058,7 @@ ALIASES
$ eas build:submit
```
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/submit.ts)_
_See code: [packages/eas-cli/src/commands/submit.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/submit.ts)_
## `eas update`
@@ -2098,7 +2101,7 @@ DESCRIPTION
publish an update group
```
_See code: [packages/eas-cli/src/commands/update/index.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/update/index.ts)_
## `eas update:configure`
@@ -2119,7 +2122,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/update/configure.ts)_
## `eas update:delete GROUPID`
@@ -2140,7 +2143,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/update/delete.ts)_
## `eas update:edit [GROUPID]`
@@ -2165,7 +2168,7 @@ 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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/update/edit.ts)_
## `eas update:insights GROUPID`
@@ -2192,7 +2195,7 @@ DESCRIPTION
display launch, crash, unique-user, and size insights for an update group
```
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v19.0.1/packages/eas-cli/src/commands/update/insights.ts)_
_See code: [packages/eas-cli/src/commands/update/insights.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/update/insights.ts)_
## `eas update:list`
@@ -2219,7 +2222,7 @@ DESCRIPTION
view the recent updates
```
_See code: [packages/eas-cli/src/commands/update/list.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/update/list.ts)_
## `eas update:republish`
@@ -2257,7 +2260,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/update/republish.ts)_
## `eas update:revert-update-rollout`
@@ -2285,7 +2288,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/update/revert-update-rollout.ts)_
## `eas update:roll-back-to-embedded`
@@ -2315,7 +2318,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/update/roll-back-to-embedded.ts)_
## `eas update:rollback`
@@ -2336,7 +2339,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/update/rollback.ts)_
## `eas update:view GROUPID`
@@ -2360,7 +2363,7 @@ DESCRIPTION
update group details
```
_See code: [packages/eas-cli/src/commands/update/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/update/view.ts)_
## `eas upload`
@@ -2381,7 +2384,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/v19.0.1/packages/eas-cli/src/commands/upload.ts)_
_See code: [packages/eas-cli/src/commands/upload.ts](https://github.com/expo/eas-cli/blob/v19.1.0/packages/eas-cli/src/commands/upload.ts)_
## `eas webhook:create`
@@ -2403,7 +2406,7 @@ DESCRIPTION
create a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/create.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/webhook/create.ts)_
## `eas webhook:delete [ID]`
@@ -2423,7 +2426,7 @@ DESCRIPTION
delete a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/delete.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/webhook/delete.ts)_
## `eas webhook:list`
@@ -2442,7 +2445,7 @@ DESCRIPTION
list webhooks
```
_See code: [packages/eas-cli/src/commands/webhook/list.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/webhook/list.ts)_
## `eas webhook:update`
@@ -2465,7 +2468,7 @@ DESCRIPTION
update a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/update.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/webhook/update.ts)_
## `eas webhook:view ID`
@@ -2482,7 +2485,7 @@ DESCRIPTION
view a webhook
```
_See code: [packages/eas-cli/src/commands/webhook/view.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/webhook/view.ts)_
## `eas whoami`
@@ -2603,7 +2606,7 @@ DESCRIPTION
to cancel.
```
_See code: [packages/eas-cli/src/commands/workflow/cancel.ts](https://github.com/expo/eas-cli/blob/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/workflow/cancel.ts)_
## `eas workflow:create [NAME]`
@@ -2623,7 +2626,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/workflow/create.ts)_
## `eas workflow:logs [ID]`
@@ -2647,7 +2650,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/workflow/logs.ts)_
## `eas workflow:run FILE`
@@ -2687,7 +2690,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/workflow/run.ts)_
## `eas workflow:runs`
@@ -2709,7 +2712,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/workflow/runs.ts)_
## `eas workflow:status [WORKFLOW_RUN_ID]`
@@ -2737,7 +2740,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/workflow/status.ts)_
## `eas workflow:validate PATH`
@@ -2757,7 +2760,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/workflow/validate.ts)_
## `eas workflow:view [ID]`
@@ -2779,5 +2782,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/v19.0.1/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/v19.1.0/packages/eas-cli/src/commands/workflow/view.ts)_
<!-- commandsstop -->
+363
View File
@@ -9913,6 +9913,83 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "deviceRunSessionsPaginated",
"description": null,
"args": [
{
"name": "after",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "before",
"description": null,
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "filter",
"description": null,
"type": {
"kind": "INPUT_OBJECT",
"name": "DeviceRunSessionFilterInput",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "first",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "last",
"description": null,
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AppDeviceRunSessionsConnection",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "environmentSecrets",
"description": "Environment secrets for an app",
@@ -12955,6 +13032,100 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AppDeviceRunSessionEdge",
"description": null,
"fields": [
{
"name": "cursor",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "DeviceRunSession",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AppDeviceRunSessionsConnection",
"description": null,
"fields": [
{
"name": "edges",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AppDeviceRunSessionEdge",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": null,
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "AppFingerprintEdge",
@@ -14382,6 +14553,39 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "uniqueActiveUserCount",
"description": "Approximate count of unique users (`eas_client_id`) with at least one\nsupported-metric event in `[startTime, endTime)`. Uses ClickHouse's\nHyperLogLog `uniq()`, so the value can drift by a small percent on apps\nwith very large user bases. `metricNames` on the input is currently\nignored: the count is always over all supported metrics, matching the\nuniverse used by `appVersions` headline counts.",
"args": [
{
"name": "input",
"description": null,
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "INPUT_OBJECT",
"name": "AppObserveReleasesInput",
"ofType": null
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updates",
"description": null,
@@ -14458,6 +14662,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "lastSeenUserCount",
"description": "Unique users whose most recent supported-metric event in the queried\nrange ran this build's embedded bundle.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "uniqueUserCount",
"description": null,
@@ -14569,6 +14789,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "lastSeenUserCount",
"description": "Unique users whose most recent supported-metric event in the queried\nrange was on this (appVersion, appBuildNumber) tuple.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "uniqueUserCount",
"description": null,
@@ -14584,6 +14820,30 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updates",
"description": "Updates seen on this build number in the queried time range. Each\nentry's counts and EAS builds are scoped to this (appVersion,\nappBuildNumber, appUpdateId) tuple.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "AppObserveAppUpdate",
"ofType": null
}
}
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
@@ -14755,6 +15015,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "lastSeenUserCount",
"description": "Unique users whose most recent supported-metric event in the queried\nrange matched this update at its current nesting level.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "uniqueUserCount",
"description": null,
@@ -14854,6 +15130,22 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "lastSeenUserCount",
"description": "Unique users whose most recent supported-metric event in\n`[startTime, endTime)` was on this version. Each active user\ncontributes to exactly one version, so summing this field across the\nreturned versions yields the total active users in the window (equal\nto `AppObserve.uniqueActiveUserCount` up to HyperLogLog noise).\nA version may be returned with `lastSeenUserCount = 0` when\nevery user who touched it later moved to a newer version. Users with\nno events in the window are not counted on any version.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "metrics",
"description": null,
@@ -37371,6 +37663,77 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "DeviceRunSessionFilterInput",
"description": null,
"fields": null,
"inputFields": [
{
"name": "platforms",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "AppPlatform",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "statuses",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "DeviceRunSessionStatus",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "types",
"description": null,
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "DeviceRunSessionType",
"ofType": null
}
}
},
"defaultValue": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "DeviceRunSessionMutation",
+4 -7
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "19.0.1",
"version": "19.1.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -47,8 +47,8 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "19.0.0",
"@expo/eas-json": "19.0.0",
"@expo/eas-build-job": "19.1.0",
"@expo/eas-json": "19.1.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "19.0.0",
@@ -63,7 +63,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "19.0.0",
"@expo/steps": "19.1.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -221,9 +221,6 @@
"secret": {
"description": "manage project and account secrets"
},
"simulator": {
"description": "manage remote simulator sessions"
},
"update": {
"description": "manage individual updates"
},
@@ -0,0 +1,149 @@
import { getConfigFilePaths } from '@expo/config';
import { getWorkflowRunUrl } from '../../build/utils/url';
import Go from '../../commands/go';
import { WorkflowRunStatus } from '../../graphql/generated';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { WorkflowRunQuery } from '../../graphql/queries/WorkflowRunQuery';
import Log from '../../log';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
import { uploadAccountScopedFileAsync } from '../../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../../project/uploadAccountScopedProjectSourceAsync';
import { ensureActorHasPrimaryAccount } from '../../user/actions';
import { detectProjectSdkVersionAsync } from '../../commands/go';
import { mockTestCommand } from './utils';
jest.mock('@expo/config', () => ({
...jest.requireActual('@expo/config'),
getConfigFilePaths: jest.fn(),
}));
jest.mock('../../project/expoConfig');
jest.mock('../../log', () => ({
__esModule: true,
default: {
log: jest.fn(),
withTick: jest.fn(),
newLine: jest.fn(),
succeed: jest.fn(),
debug: jest.fn(),
markFreshLine: jest.fn(),
error: jest.fn(),
},
learnMore: jest.fn().mockReturnValue(''),
}));
jest.mock('../../ora', () => ({
ora: jest.fn().mockReturnValue({
start: jest.fn().mockReturnThis(),
stop: jest.fn().mockReturnThis(),
fail: jest.fn().mockReturnThis(),
succeed: jest.fn().mockReturnThis(),
}),
}));
jest.mock('fs-extra', () => ({
ensureDir: jest.fn().mockResolvedValue(undefined),
writeFile: jest.fn().mockResolvedValue(undefined),
remove: jest.fn().mockResolvedValue(undefined),
}));
jest.mock('../../user/actions');
jest.mock('../../graphql/queries/WorkflowRunQuery');
jest.mock('../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../project/uploadAccountScopedFileAsync');
jest.mock('../../project/uploadAccountScopedProjectSourceAsync');
jest.mock('../../build/utils/url');
const mockGetConfigFilePaths = jest.mocked(getConfigFilePaths);
const mockGetPrivateExpoConfigAsync = jest.mocked(getPrivateExpoConfigAsync);
describe('detectProjectSdkVersionAsync', () => {
it('returns undefined when no config file exists', async () => {
mockGetConfigFilePaths.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBeUndefined();
});
it('returns the sdkVersion from the project config', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/project/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBe('55.0.0');
});
it('returns undefined when reading the config throws', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/project/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockRejectedValue(new Error('config error'));
await expect(detectProjectSdkVersionAsync('/project')).resolves.toBeUndefined();
});
});
const mockAccount = { id: 'account-id', name: 'testuser' };
const mockActor = {
__typename: 'User' as const,
id: 'user-id',
username: 'testuser',
primaryAccount: mockAccount,
};
describe('Go command', () => {
beforeEach(() => {
jest.mocked(ensureActorHasPrimaryAccount).mockReturnValue(mockAccount as any);
jest.mocked(WorkflowRunQuery.expoGoRepackConfigurationAsync).mockResolvedValue({
files: [],
sdkVersion: '55.0.0',
} as any);
jest.mocked(WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync).mockResolvedValue({
id: 'run-id',
} as any);
jest.mocked(uploadAccountScopedProjectSourceAsync).mockResolvedValue({
projectArchiveBucketKey: 'archive-key',
});
jest
.mocked(uploadAccountScopedFileAsync)
.mockResolvedValue({ fileBucketKey: 'file-key' } as any);
jest.mocked(getWorkflowRunUrl).mockReturnValue('https://expo.dev/run/123');
});
afterEach(() => {
jest.clearAllMocks();
});
function makeCmd(argv: string[] = []) {
const ctx = {
loggedIn: { actor: mockActor as any, graphqlClient: {} as any },
analytics: {} as any,
};
const cmd = mockTestCommand(Go, ['--bundle-id', 'com.test.go', ...argv], ctx);
jest.spyOn(cmd as any, 'ensureEasProjectAsync').mockResolvedValue('project-id');
jest.spyOn(cmd as any, 'setupCredentialsAsync').mockResolvedValue({ id: 'asc-app-id' });
jest.spyOn(cmd as any, 'monitorWorkflowJobsAsync').mockResolvedValue(WorkflowRunStatus.Success);
return cmd;
}
it('logs auto-selected SDK message and reports resolved version after dispatch', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);
await makeCmd().run();
expect(Log.log).toHaveBeenCalledWith(expect.stringContaining('SDK 55'));
expect(Log.withTick).toHaveBeenCalledWith(expect.stringContaining('Using Expo Go SDK'));
});
it('skips auto-select log when --sdk-version flag is provided', async () => {
mockGetConfigFilePaths.mockReturnValue({
staticConfigPath: '/app.json',
dynamicConfigPath: null,
});
mockGetPrivateExpoConfigAsync.mockResolvedValue({ sdkVersion: '55.0.0' } as any);
await makeCmd(['--sdk-version', '55.0.0']).run();
expect(Log.log).not.toHaveBeenCalledWith(expect.stringContaining('Auto-selected'));
});
});
@@ -42,6 +42,7 @@ export async function createBuildContextAsync<T extends Platform>({
customBuildConfigMetadata,
buildLoggerLevel,
freezeCredentials,
refreshAdHocProvisioningProfile: refreshAdHocProvisioningProfileFlag,
isVerboseLoggingEnabled,
whatToTest,
env,
@@ -65,6 +66,7 @@ export async function createBuildContextAsync<T extends Platform>({
customBuildConfigMetadata?: CustomBuildConfigMetadata;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
refreshAdHocProvisioningProfile?: boolean;
isVerboseLoggingEnabled: boolean;
whatToTest?: string;
env: Record<string, string>;
@@ -90,6 +92,8 @@ export async function createBuildContextAsync<T extends Platform>({
const requiredPackageManager = resolvePackageManager(projectDir);
const refreshAdHocProvisioningProfile = refreshAdHocProvisioningProfileFlag ?? false;
const credentialsCtx = new CredentialsContext({
projectInfo: { exp, projectId },
nonInteractive,
@@ -101,6 +105,7 @@ export async function createBuildContextAsync<T extends Platform>({
easJsonCliConfig,
vcsClient,
freezeCredentials,
refreshAdHocProvisioningProfile,
});
const devClientProperties = getDevClientEventProperties({
@@ -0,0 +1,40 @@
import { Platform } from '@expo/eas-build-job';
import { CredentialsSource } from '@expo/eas-json';
import { BuildContext } from '../../context';
import { ensureIosCredentialsAsync } from '../credentials';
describe(ensureIosCredentialsAsync, () => {
it('errors when refresh is enabled with local credentials source', async () => {
const buildCtx = {
buildProfile: {
credentialsSource: CredentialsSource.LOCAL,
simulator: false,
withoutCredentials: false,
},
credentialsCtx: {
refreshAdHocProvisioningProfile: true,
},
} as BuildContext<Platform.IOS>;
await expect(ensureIosCredentialsAsync(buildCtx, [])).rejects.toThrow(
'--refresh-ad-hoc-provisioning-profile cannot be used with credentialsSource "local". Use remote credentials or omit the flag.'
);
});
it('does not reject refresh when credentials source is remote', async () => {
const buildCtx = {
buildProfile: {
credentialsSource: CredentialsSource.REMOTE,
simulator: true,
withoutCredentials: false,
distribution: 'internal',
},
credentialsCtx: {
refreshAdHocProvisioningProfile: true,
},
} as BuildContext<Platform.IOS>;
await expect(ensureIosCredentialsAsync(buildCtx, [])).resolves.toBeUndefined();
});
});
+10 -2
View File
@@ -17,6 +17,16 @@ export async function ensureIosCredentialsAsync(
return;
}
const { credentialsSource } = buildCtx.buildProfile;
if (
buildCtx.credentialsCtx.refreshAdHocProvisioningProfile &&
credentialsSource === 'local'
) {
throw new Error(
'--refresh-ad-hoc-provisioning-profile cannot be used with credentialsSource "local". Use remote credentials or omit the flag.'
);
}
const provider = new IosCredentialsProvider(buildCtx.credentialsCtx, {
app: await getAppFromContextAsync(buildCtx.credentialsCtx),
targets,
@@ -24,8 +34,6 @@ export async function ensureIosCredentialsAsync(
enterpriseProvisioning: buildCtx.buildProfile.enterpriseProvisioning,
});
const { credentialsSource } = buildCtx.buildProfile;
logCredentialsSource(credentialsSource, Platform.IOS);
return {
credentials: await provider.getCredentialsAsync(credentialsSource),
@@ -112,6 +112,9 @@ export async function prepareJobAsync(
// See: https://github.com/expo/eas-build/pull/454
appId: ctx.projectId,
initiatingUserId: ctx.user.id,
...(ctx.credentialsCtx.refreshAdHocProvisioningProfile && {
refreshAdHocProvisioningProfile: true,
}),
};
return sanitizeBuildJob(job) as Ios.Job;
}
@@ -103,6 +103,7 @@ export interface BuildFlags {
message?: string;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
refreshAdHocProvisioningProfile?: boolean;
isVerboseLoggingEnabled?: boolean;
whatToTest?: string;
simulator?: SimulatorRunTarget;
@@ -413,6 +414,7 @@ async function prepareAndStartBuildAsync({
customBuildConfigMetadata,
buildLoggerLevel: flags.buildLoggerLevel ?? (Log.isDebug ? LoggerLevel.DEBUG : undefined),
freezeCredentials: flags.freezeCredentials,
refreshAdHocProvisioningProfile: flags.refreshAdHocProvisioningProfile,
isVerboseLoggingEnabled: flags.isVerboseLoggingEnabled ?? false,
whatToTest: flags.whatToTest,
env,
+1
View File
@@ -35,4 +35,5 @@ export interface BuildFlags {
message?: string;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
refreshAdHocProvisioningProfile?: boolean;
}
@@ -40,6 +40,7 @@ interface RawBuildFlags {
message?: string;
'build-logger-level'?: LoggerLevel;
'freeze-credentials': boolean;
'refresh-ad-hoc-provisioning-profile': boolean;
'verbose-logs'?: boolean;
'what-to-test'?: string;
}
@@ -121,6 +122,11 @@ export default class Build extends EasCommand {
default: false,
description: 'Prevent the build from updating credentials in non-interactive mode',
}),
'refresh-ad-hoc-provisioning-profile': Flags.boolean({
default: false,
description:
'Refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials',
}),
'verbose-logs': Flags.boolean({
default: false,
description: 'Use verbose logs for the build process',
@@ -188,6 +194,20 @@ export default class Build extends EasCommand {
flags: RawBuildFlags
): Omit<BuildFlags, 'requestedPlatform'> & { requestedPlatform?: RequestedPlatform } {
const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (flags['refresh-ad-hoc-provisioning-profile']) {
if (!nonInteractive) {
Errors.error(
'--refresh-ad-hoc-provisioning-profile can only be used in non-interactive mode.',
{ exit: 1 }
);
}
if (flags['freeze-credentials']) {
Errors.error(
'Cannot use --refresh-ad-hoc-provisioning-profile with --freeze-credentials.',
{ exit: 1 }
);
}
}
if (!flags.local && flags.output) {
Errors.error('--output is allowed only for local builds', { exit: 1 });
}
@@ -249,6 +269,7 @@ export default class Build extends EasCommand {
message,
buildLoggerLevel: flags['build-logger-level'],
freezeCredentials: flags['freeze-credentials'],
refreshAdHocProvisioningProfile: flags['refresh-ad-hoc-provisioning-profile'],
isVerboseLoggingEnabled: flags['verbose-logs'],
whatToTest: flags['what-to-test'],
};
@@ -40,6 +40,11 @@ export default class BuildInternal extends EasCommand {
helpValue: 'PROFILE_NAME',
exclusive: ['auto-submit'],
}),
'refresh-ad-hoc-provisioning-profile': Flags.boolean({
default: false,
description:
'Refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials',
}),
};
static override contextDefinition = {
@@ -85,6 +90,7 @@ export default class BuildInternal extends EasCommand {
profile: flags.profile,
nonInteractive: true,
freezeCredentials: false,
refreshAdHocProvisioningProfile: flags['refresh-ad-hoc-provisioning-profile'],
wait: false,
clearCache: false,
json: true,
+31 -5
View File
@@ -1,4 +1,4 @@
import { ExpoConfig } from '@expo/config';
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { App, User, UserRole } from '@expo/apple-utils';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
@@ -28,6 +28,7 @@ import { WorkflowRunQuery } from '../graphql/queries/WorkflowRunQuery';
import Log, { learnMore } from '../log';
import { confirmAsync } from '../prompts';
import { ora } from '../ora';
import { getPrivateExpoConfigAsync } from '../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync';
import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync';
@@ -45,6 +46,20 @@ function deriveBundleIdSlug(bundleId: string): string {
return bundleId.split('.').filter(Boolean).pop()!;
}
export async function detectProjectSdkVersionAsync(
projectDir: string
): Promise<string | undefined> {
const paths = getConfigFilePaths(projectDir);
if (!paths.staticConfigPath && !paths.dynamicConfigPath) {
return;
}
try {
return (await getPrivateExpoConfigAsync(projectDir)).sdkVersion;
} catch {
return;
}
}
const TESTFLIGHT_GROUP_NAME = 'Team (Expo)';
async function setupTestFlightAsync(ascApp: App): Promise<void> {
@@ -189,7 +204,13 @@ export default class Go extends EasCommand {
});
Log.withTick(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`);
const sdkVersion = flags['sdk-version'];
const detectedSdkVersion = await detectProjectSdkVersionAsync(process.cwd());
if (detectedSdkVersion && !flags['sdk-version']) {
Log.log(
`Current project using SDK ${detectedSdkVersion.split('.')[0]}. Auto-selected same version. To use a different version, pass --sdk-version.`
);
}
const sdkVersion = flags['sdk-version'] ?? detectedSdkVersion;
const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor);
if (!isBundleIdentifierValid(bundleId)) {
throw new Error(
@@ -231,7 +252,11 @@ export default class Go extends EasCommand {
}
);
const { workflowUrl, workflowRunId } = await this.dispatchWorkflowAsync(
const {
workflowUrl,
workflowRunId,
sdkVersion: resolvedSdkVersion,
} = await this.dispatchWorkflowAsync(
graphqlClient,
projectId,
actor,
@@ -242,6 +267,7 @@ export default class Go extends EasCommand {
tmpDir,
vcsClient
);
Log.withTick(`Using Expo Go SDK ${chalk.cyan(resolvedSdkVersion.split('.')[0])}`);
Log.withTick(`Build started: ${chalk.cyan(workflowUrl)}`);
const status = await this.monitorWorkflowJobsAsync(graphqlClient, workflowRunId);
@@ -401,7 +427,7 @@ export default class Go extends EasCommand {
sdkVersion: string | undefined,
tmpDir: string,
vcsClient: Client
): Promise<{ workflowUrl: string; workflowRunId: string }> {
): Promise<{ workflowUrl: string; workflowRunId: string; sdkVersion: string }> {
const account = ensureActorHasPrimaryAccount(actor);
const repackConfig = await WorkflowRunQuery.expoGoRepackConfigurationAsync(graphqlClient, {
@@ -451,7 +477,7 @@ export default class Go extends EasCommand {
const workflowUrl = getWorkflowRunUrl(account.name, deriveBundleIdSlug(bundleId), result.id);
return { workflowUrl, workflowRunId: result.id };
return { workflowUrl, workflowRunId: result.id, sdkVersion: repackConfig.sdkVersion };
}
private async monitorWorkflowJobsAsync(
@@ -171,6 +171,7 @@ describe(ObserveVersions, () => {
firstSeenAt: '2025-06-01T00:00:00.000Z',
eventCount: 100,
uniqueUserCount: 50,
lastSeenUserCount: 12,
buildNumbers: [],
updates: [],
metrics: [],
@@ -0,0 +1,111 @@
import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
DeviceRunSessionByIdQuery,
DeviceRunSessionStatus,
DeviceRunSessionType,
JobRunStatus,
} from '../../../graphql/generated';
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import SimulatorGet from '../get';
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../log');
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
const spinner = {
fail: jest.fn(),
start: jest.fn(),
succeed: jest.fn(),
};
spinner.start.mockReturnValue(spinner);
return spinner;
}),
}));
jest.mock('../../../utils/json');
type DeviceRunSessionById = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
const mockByIdAsync = jest.mocked(DeviceRunSessionQuery.byIdAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeDeviceRunSession(overrides: Partial<DeviceRunSessionById> = {}): DeviceRunSessionById {
return {
id: 'session-123',
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.AgentDevice,
app: {
id: 'app-123',
slug: 'testapp',
ownerAccount: {
id: 'account-123',
name: 'testuser',
},
},
remoteConfig: {
__typename: 'AgentDeviceRunSessionRemoteConfig',
agentDeviceRemoteSessionUrl: 'https://agent.example.com',
agentDeviceRemoteSessionToken: 'token-123',
webPreviewUrl: 'https://preview.example.com',
},
turtleJobRun: {
id: 'job-123',
status: JobRunStatus.InProgress,
},
...overrides,
};
}
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
describe(SimulatorGet, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
});
function createCommand(argv: string[]): {
command: SimulatorGet;
getContextAsync: jest.SpyInstance;
} {
const command = new SimulatorGet(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
loggedIn: { graphqlClient },
});
return { command, getContextAsync };
}
it('emits JSON when --json is passed', async () => {
const session = makeDeviceRunSession();
mockByIdAsync.mockResolvedValue(session);
const { command, getContextAsync } = createCommand(['--id', 'session-123', '--json']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(getContextAsync).toHaveBeenCalledWith(SimulatorGet, {
nonInteractive: true,
});
expect(mockByIdAsync).toHaveBeenCalledWith(graphqlClient, 'session-123');
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
id: 'session-123',
type: 'agent-device',
status: DeviceRunSessionStatus.InProgress,
jobRunUrl: 'https://expo.dev/accounts/testuser/projects/testapp/job-runs/job-123',
remoteConfig: session.remoteConfig,
});
});
});
@@ -0,0 +1,213 @@
import { Config } from '@oclif/core';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import {
AppPlatform,
DeviceRunSessionStatus,
DeviceRunSessionType,
DeviceRunSessionsByAppIdQuery,
JobRunStatus,
} from '../../../graphql/generated';
import { DeviceRunSessionQuery } from '../../../graphql/queries/DeviceRunSessionQuery';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
import SimulatorList from '../list';
jest.mock('../../../graphql/queries/DeviceRunSessionQuery');
jest.mock('../../../log');
jest.mock('../../../ora', () => ({
ora: jest.fn(() => {
const spinner = {
fail: jest.fn(),
start: jest.fn(),
succeed: jest.fn(),
};
spinner.start.mockReturnValue(spinner);
return spinner;
}),
}));
jest.mock('../../../utils/json');
type DeviceRunSessionEdge =
DeviceRunSessionsByAppIdQuery['app']['byId']['deviceRunSessionsPaginated']['edges'][number];
type DeviceRunSessionNode = DeviceRunSessionEdge['node'];
type DeviceRunSessionsPaginated =
DeviceRunSessionsByAppIdQuery['app']['byId']['deviceRunSessionsPaginated'];
const mockListByAppIdAsync = jest.mocked(DeviceRunSessionQuery.listByAppIdAsync);
const mockEnableJsonOutput = jest.mocked(enableJsonOutput);
const mockPrintJsonOnlyOutput = jest.mocked(printJsonOnlyOutput);
function makeSession(overrides: Partial<DeviceRunSessionNode> = {}): DeviceRunSessionNode {
return {
id: 'session-123',
status: DeviceRunSessionStatus.InProgress,
type: DeviceRunSessionType.AgentDevice,
platform: AppPlatform.Ios,
createdAt: '2025-01-01T00:00:00.000Z',
startedAt: '2025-01-01T00:00:05.000Z',
finishedAt: null,
app: {
id: 'app-123',
slug: 'testapp',
ownerAccount: {
id: 'account-123',
name: 'testuser',
},
},
turtleJobRun: {
id: 'job-123',
status: JobRunStatus.InProgress,
},
...overrides,
};
}
function makeConnection(
nodes: DeviceRunSessionNode[],
pageInfo: Partial<DeviceRunSessionsPaginated['pageInfo']> = {}
): DeviceRunSessionsPaginated {
return {
edges: nodes.map((node, index) => ({ cursor: `cursor-${index}`, node })),
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: nodes.length > 0 ? 'cursor-0' : null,
endCursor: nodes.length > 0 ? `cursor-${nodes.length - 1}` : null,
...pageInfo,
},
};
}
function getMockOclifConfig(): Config {
const config = new Config({ root: __dirname });
config.runHook = async () => ({
failures: [],
successes: [],
});
return config;
}
describe(SimulatorList, () => {
const graphqlClient = {} as ExpoGraphqlClient;
const mockConfig = getMockOclifConfig();
beforeEach(() => {
jest.clearAllMocks();
});
function createCommand(argv: string[]): {
command: SimulatorList;
getContextAsync: jest.SpyInstance;
} {
const command = new SimulatorList(argv, mockConfig);
// @ts-expect-error getContextAsync is protected
const getContextAsync = jest.spyOn(command, 'getContextAsync').mockResolvedValue({
projectId: 'app-123',
loggedIn: { graphqlClient },
});
return { command, getContextAsync };
}
it('emits JSON when --json is passed', async () => {
const session = makeSession();
mockListByAppIdAsync.mockResolvedValue(makeConnection([session]));
const { command, getContextAsync } = createCommand(['--json']);
await command.runAsync();
expect(mockEnableJsonOutput).toHaveBeenCalled();
expect(getContextAsync).toHaveBeenCalledWith(SimulatorList, {
nonInteractive: true,
});
expect(mockListByAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'app-123',
first: 10,
after: undefined,
filter: undefined,
});
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
sessions: [
{
id: 'session-123',
type: 'agent-device',
status: DeviceRunSessionStatus.InProgress,
platform: AppPlatform.Ios,
createdAt: '2025-01-01T00:00:00.000Z',
startedAt: '2025-01-01T00:00:05.000Z',
finishedAt: undefined,
jobRunUrl: 'https://expo.dev/accounts/testuser/projects/testapp/job-runs/job-123',
},
],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: 'cursor-0',
endCursor: 'cursor-0',
},
});
});
it('passes filters and cursor to the query', async () => {
mockListByAppIdAsync.mockResolvedValue(makeConnection([]));
const { command } = createCommand([
'--json',
'--status',
'in-progress',
'--status',
'new',
'--type',
'argent',
'--platform',
'ios',
'--limit',
'25',
'--after',
'page-cursor',
]);
await command.runAsync();
expect(mockListByAppIdAsync).toHaveBeenCalledWith(graphqlClient, {
appId: 'app-123',
first: 25,
after: 'page-cursor',
filter: {
statuses: [DeviceRunSessionStatus.InProgress, DeviceRunSessionStatus.New],
types: [DeviceRunSessionType.Argent],
platforms: [AppPlatform.Ios],
},
});
});
it('runs non-interactively without --json', async () => {
const session = makeSession({ id: 'session-1', turtleJobRun: null });
mockListByAppIdAsync.mockResolvedValue(makeConnection([session]));
const { command, getContextAsync } = createCommand(['--non-interactive']);
await command.runAsync();
expect(mockEnableJsonOutput).not.toHaveBeenCalled();
expect(mockPrintJsonOnlyOutput).not.toHaveBeenCalled();
expect(getContextAsync).toHaveBeenCalledWith(SimulatorList, {
nonInteractive: true,
});
expect(mockListByAppIdAsync).toHaveBeenCalled();
});
it('handles empty results', async () => {
mockListByAppIdAsync.mockResolvedValue(makeConnection([]));
const { command } = createCommand(['--json']);
await command.runAsync();
expect(mockPrintJsonOnlyOutput).toHaveBeenCalledWith({
sessions: [],
pageInfo: {
hasNextPage: false,
hasPreviousPage: false,
startCursor: null,
endCursor: null,
},
});
});
});
+28 -4
View File
@@ -2,14 +2,22 @@ import { Flags } from '@oclif/core';
import { getBareJobRunUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { DeviceRunSessionStatus } from '../../graphql/generated';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import { formatRemoteSessionInstructions } from '../../simulator/utils';
import {
deviceRunSessionTypeToFlagValue,
formatRemoteSessionInstructions,
} from '../../simulator/utils';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class SimulatorGet extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] get info about a remote simulator session on EAS by its device run session ID';
@@ -18,7 +26,7 @@ export default class SimulatorGet extends EasCommand {
description: 'Device run session ID',
required: true,
}),
...EASNonInteractiveFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
@@ -27,11 +35,16 @@ export default class SimulatorGet extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(SimulatorGet);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorGet, {
nonInteractive: flags['non-interactive'],
nonInteractive,
});
const fetchSpinner = ora(`Fetching device run session ${flags.id}`).start();
@@ -48,6 +61,17 @@ export default class SimulatorGet extends EasCommand {
? getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, session.turtleJobRun.id)
: '';
if (jsonFlag) {
printJsonOnlyOutput({
id: session.id,
type: deviceRunSessionTypeToFlagValue(session.type),
status: session.status,
jobRunUrl: jobRunUrl || undefined,
remoteConfig: session.remoteConfig,
});
return;
}
Log.newLine();
Log.log(`ID: ${session.id}`);
Log.log(`Type: ${session.type}`);
@@ -0,0 +1,185 @@
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import { getBareJobRunUrl } from '../../build/utils/url';
import EasCommand from '../../commandUtils/EasCommand';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { getLimitFlagWithCustomValues } from '../../commandUtils/pagination';
import {
AppPlatform,
DeviceRunSessionFilterInput,
DeviceRunSessionStatus,
} from '../../graphql/generated';
import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQuery';
import Log, { link } from '../../log';
import { ora } from '../../ora';
import {
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
deviceRunSessionTypeToFlagValue,
} from '../../simulator/utils';
import { fromNow } from '../../utils/date';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
const DEFAULT_LIMIT = 10;
const MAX_LIMIT = 100;
const STATUS_FLAG_VALUES: Record<DeviceRunSessionStatus, string> = {
[DeviceRunSessionStatus.New]: 'new',
[DeviceRunSessionStatus.InProgress]: 'in-progress',
[DeviceRunSessionStatus.Stopped]: 'stopped',
[DeviceRunSessionStatus.Errored]: 'errored',
};
const STATUS_BY_FLAG_VALUE = Object.fromEntries(
Object.entries(STATUS_FLAG_VALUES).map(([status, value]) => [
value,
status as DeviceRunSessionStatus,
])
);
const PLATFORM_FLAG_VALUES: Record<AppPlatform, string> = {
[AppPlatform.Android]: 'android',
[AppPlatform.Ios]: 'ios',
};
const PLATFORM_BY_FLAG_VALUE = Object.fromEntries(
Object.entries(PLATFORM_FLAG_VALUES).map(([platform, value]) => [value, platform as AppPlatform])
);
export default class SimulatorList extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] list remote simulator sessions for the current project';
static override flags = {
status: Flags.option({
description: 'Filter by session status (repeatable)',
options: Object.values(STATUS_FLAG_VALUES),
multiple: true,
})(),
type: Flags.option({
description: 'Filter by session type (repeatable)',
options: Object.values(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES),
multiple: true,
})(),
platform: Flags.option({
description: 'Filter by device platform (repeatable)',
options: Object.values(PLATFORM_FLAG_VALUES),
multiple: true,
})(),
limit: getLimitFlagWithCustomValues({ defaultTo: DEFAULT_LIMIT, limit: MAX_LIMIT }),
after: Flags.string({
description:
'Cursor for pagination. Use the endCursor from a previous query to fetch the next page.',
}),
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
...this.ContextOptions.ProjectId,
...this.ContextOptions.LoggedIn,
};
async runAsync(): Promise<void> {
const { flags } = await this.parse(SimulatorList);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
projectId,
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorList, {
nonInteractive,
});
const filter: DeviceRunSessionFilterInput = {};
if (flags.status && flags.status.length > 0) {
filter.statuses = flags.status.map(value => STATUS_BY_FLAG_VALUE[value]);
}
if (flags.type && flags.type.length > 0) {
filter.types = flags.type.map(value => DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[value]);
}
if (flags.platform && flags.platform.length > 0) {
filter.platforms = flags.platform.map(value => PLATFORM_BY_FLAG_VALUE[value]);
}
const limit = flags.limit ?? DEFAULT_LIMIT;
const fetchSpinner = jsonFlag ? null : ora('Fetching device run sessions').start();
let connection;
try {
connection = await DeviceRunSessionQuery.listByAppIdAsync(graphqlClient, {
appId: projectId,
first: limit,
after: flags.after,
filter: Object.keys(filter).length > 0 ? filter : undefined,
});
fetchSpinner?.succeed(`Fetched ${connection.edges.length} device run session(s)`);
} catch (err) {
fetchSpinner?.fail('Failed to fetch device run sessions');
throw err;
}
const sessions = connection.edges.map(edge => edge.node);
if (jsonFlag) {
printJsonOnlyOutput({
sessions: sessions.map(session => ({
id: session.id,
type: deviceRunSessionTypeToFlagValue(session.type),
status: session.status,
platform: session.platform,
createdAt: session.createdAt,
startedAt: session.startedAt ?? undefined,
finishedAt: session.finishedAt ?? undefined,
jobRunUrl: session.turtleJobRun
? getBareJobRunUrl(
session.app.ownerAccount.name,
session.app.slug,
session.turtleJobRun.id
)
: undefined,
})),
pageInfo: connection.pageInfo,
});
return;
}
if (sessions.length === 0) {
Log.newLine();
Log.log('No device run sessions found.');
return;
}
Log.newLine();
const formattedEntries = sessions.map(session => {
const jobRunUrl = session.turtleJobRun
? getBareJobRunUrl(session.app.ownerAccount.name, session.app.slug, session.turtleJobRun.id)
: null;
const lines = [
`ID: ${session.id}`,
`Type: ${session.type}`,
`Status: ${session.status}`,
`Platform: ${session.platform}`,
`Created: ${fromNow(new Date(session.createdAt))} ago`,
];
if (jobRunUrl) {
lines.push(`URL: ${link(jobRunUrl)}`);
}
return lines.join('\n');
});
Log.log(formattedEntries.join(`\n\n${chalk.dim('———')}\n\n`));
if (connection.pageInfo.hasNextPage && connection.pageInfo.endCursor) {
Log.newLine();
Log.log(
`More results available. Re-run with --after ${connection.pageInfo.endCursor} to fetch the next page.`
);
}
}
}
@@ -18,6 +18,8 @@ import { DeviceRunSessionQuery } from '../../graphql/queries/DeviceRunSessionQue
import Log, { link } from '../../log';
import { ora } from '../../ora';
import {
DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE,
DEVICE_RUN_SESSION_TYPE_FLAG_VALUES,
DeviceRunSessionRemoteConfig,
formatRemoteSessionInstructions,
} from '../../simulator/utils';
@@ -28,21 +30,8 @@ import nullthrows from 'nullthrows';
const POLL_INTERVAL_MS = 5_000; // 5 seconds
const POLL_TIMEOUT_MS = 15 * 60 * 1_000; // 15 minutes
// Mapping enum → CLI flag value. Declared as Record<DeviceRunSessionType, string>
// so adding a new enum value in codegen fails the build until it is wired up here.
const DEVICE_RUN_SESSION_TYPE_FLAG_VALUES: Record<DeviceRunSessionType, string> = {
[DeviceRunSessionType.AgentDevice]: 'agent-device',
[DeviceRunSessionType.Argent]: 'argent',
[DeviceRunSessionType.ServeSim]: 'serve-sim',
};
const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries(
(Object.entries(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES) as [DeviceRunSessionType, string][]).map(
([type, value]) => [value, type]
)
) as Record<string, DeviceRunSessionType>;
export default class SimulatorStart extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it';
@@ -1,11 +1,16 @@
import { Flags } from '@oclif/core';
import EasCommand from '../../commandUtils/EasCommand';
import { EASNonInteractiveFlag } from '../../commandUtils/flags';
import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { DeviceRunSessionMutation } from '../../graphql/mutations/DeviceRunSessionMutation';
import { ora } from '../../ora';
import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class SimulatorStop extends EasCommand {
static override hidden = true;
static override description =
'[EXPERIMENTAL] stop a remote simulator session on EAS by its device run session ID';
@@ -14,7 +19,7 @@ export default class SimulatorStop extends EasCommand {
description: 'Device run session ID',
required: true,
}),
...EASNonInteractiveFlag,
...EasNonInteractiveAndJsonFlags,
};
static override contextDefinition = {
@@ -23,11 +28,16 @@ export default class SimulatorStop extends EasCommand {
async runAsync(): Promise<void> {
const { flags } = await this.parse(SimulatorStop);
const { json: jsonFlag, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags);
if (jsonFlag) {
enableJsonOutput();
}
const {
loggedIn: { graphqlClient },
} = await this.getContextAsync(SimulatorStop, {
nonInteractive: flags['non-interactive'],
nonInteractive,
});
const stopSpinner = ora(`🛑 Stopping device run session ${flags.id}`).start();
@@ -37,6 +47,10 @@ export default class SimulatorStop extends EasCommand {
flags.id
);
stopSpinner.succeed(`🎉 Device run session ${session.id} is ${session.status.toLowerCase()}`);
if (jsonFlag) {
printJsonOnlyOutput({ id: session.id, status: session.status });
}
} catch (err) {
stopSpinner.fail(`Failed to stop device run session ${flags.id}`);
throw err;
@@ -27,6 +27,7 @@ export class CredentialsContext {
public readonly nonInteractive: boolean;
public readonly autoAcceptCredentialReuse: boolean;
public readonly freezeCredentials: boolean = false;
public readonly refreshAdHocProvisioningProfile: boolean = false;
public readonly projectDir: string;
public readonly user: Actor;
public readonly graphqlClient: ExpoGraphqlClient;
@@ -52,6 +53,7 @@ export class CredentialsContext {
vcsClient: Client;
freezeCredentials?: boolean;
autoAcceptCredentialReuse?: boolean;
refreshAdHocProvisioningProfile?: boolean;
env?: Env;
}
) {
@@ -65,6 +67,7 @@ export class CredentialsContext {
this.autoAcceptCredentialReuse = options.autoAcceptCredentialReuse ?? false;
this.projectInfo = options.projectInfo;
this.freezeCredentials = options.freezeCredentials ?? false;
this.refreshAdHocProvisioningProfile = options.refreshAdHocProvisioningProfile ?? false;
this.usesBroadcastPushNotifications =
options.projectInfo?.exp.ios?.usesBroadcastPushNotifications ?? false;
}
@@ -1,6 +1,37 @@
import { promptAsync } from '../.././../prompts';
import { AppleDevice, AppleDeviceFragment } from '../../../graphql/generated';
import { AppleDevice, AppleDeviceClass, AppleDeviceFragment } from '../../../graphql/generated';
import { APPLE_DEVICE_CLASS_LABELS } from '../../../graphql/types/credentials/AppleDevice';
import { ApplePlatform } from '../appstore/constants';
export function filterDevicesForApplePlatform(
devices: AppleDeviceFragment[],
applePlatform: ApplePlatform
): AppleDeviceFragment[] {
if (applePlatform === ApplePlatform.TV_OS) {
throw new Error('Filtering for tvOS is not supported yet');
}
if (applePlatform === ApplePlatform.VISION_OS) {
throw new Error('Filtering for visionOS is not supported yet');
}
return devices.filter(device => isDeviceCompatibleWithApplePlatform(device, applePlatform));
}
function isDeviceCompatibleWithApplePlatform(
device: AppleDeviceFragment,
applePlatform: Exclude<ApplePlatform, ApplePlatform.TV_OS | ApplePlatform.VISION_OS>
): boolean {
switch (applePlatform) {
case ApplePlatform.IOS:
return (
device.deviceClass === AppleDeviceClass.Iphone ||
device.deviceClass === AppleDeviceClass.Ipad
);
case ApplePlatform.MAC_OS:
return device.deviceClass === AppleDeviceClass.Mac;
}
}
export async function chooseDevicesAsync(
allDevices: AppleDeviceFragment[],
@@ -6,7 +6,11 @@ import nullthrows from 'nullthrows';
import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils';
import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from './BuildCredentialsUtils';
import { chooseDevicesAsync, formatDeviceLabel } from './DeviceUtils';
import {
chooseDevicesAsync,
filterDevicesForApplePlatform,
formatDeviceLabel,
} from './DeviceUtils';
import { SetUpDistributionCertificate } from './SetUpDistributionCertificate';
import DeviceCreateAction, { RegistrationMethod } from '../../../devices/actions/create/action';
import {
@@ -18,6 +22,8 @@ import {
IosAppBuildCredentialsFragment,
IosDistributionType,
} from '../../../graphql/generated';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
import Log from '../../../log';
import { getApplePlatformFromTarget } from '../../../project/ios/target';
import {
@@ -29,9 +35,13 @@ import {
import differenceBy from '../../../utils/expodash/differenceBy';
import { CredentialsContext } from '../../context';
import { MissingCredentialsNonInteractiveError } from '../../errors';
import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes';
import { ProvisioningProfile } from '../appstore/Credentials.types';
import { ApplePlatform } from '../appstore/constants';
import { hasAscEnvVars } from '../appstore/resolveCredentials';
import { MinimalAscApiKey } from '../credentials';
import { Target } from '../types';
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';
@@ -51,11 +61,23 @@ export class SetUpAdhocProvisioningProfile {
async runAsync(ctx: CredentialsContext): Promise<IosAppBuildCredentialsFragment> {
const { app } = this.options;
if (ctx.refreshAdHocProvisioningProfile && ctx.freezeCredentials) {
throw new Error(
'Cannot refresh ad-hoc provisioning profile when credentials are frozen. Remove --freeze-credentials or --refresh-ad-hoc-provisioning-profile.'
);
}
const distCert = await new SetUpDistributionCertificate(
app,
IosDistributionType.AdHoc
).runAsync(ctx);
if (ctx.nonInteractive && ctx.refreshAdHocProvisioningProfile) {
await this.ensureAppStoreAuthenticatedForAdhocRefreshAsync(ctx, app);
return await this.runWithDistributionCertificateAsync(ctx, distCert);
}
const areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
if (ctx.nonInteractive) {
@@ -111,6 +133,11 @@ export class SetUpAdhocProvisioningProfile {
appleTeam
);
if (registeredAppleDevices.length === 0) {
if (ctx.nonInteractive) {
throw new Error(
'No devices are registered for this Apple team. Register devices with eas device:create first.'
);
}
const shouldRegisterDevices = await confirmAsync({
message: `You don't have any registered devices yet. Would you like to register them now?`,
initial: true,
@@ -127,13 +154,13 @@ export class SetUpAdhocProvisioningProfile {
const provisionedDeviceIdentifiers = (
currentBuildCredentials?.provisioningProfile?.appleDevices ?? []
).map(i => i.identifier);
const chosenDevices = await chooseDevicesAsync(
registeredAppleDevices,
provisionedDeviceIdentifiers
);
const applePlatform = getApplePlatformFromTarget(target);
const chosenDevices =
ctx.nonInteractive && ctx.refreshAdHocProvisioningProfile
? filterDevicesForApplePlatform(registeredAppleDevices, applePlatform)
: await chooseDevicesAsync(registeredAppleDevices, provisionedDeviceIdentifiers);
// 4. Reuse or create the profile on Apple Developer Portal
const applePlatform = getApplePlatformFromTarget(target);
const profileType =
applePlatform === ApplePlatform.TV_OS
? ProfileType.TVOS_APP_ADHOC
@@ -187,21 +214,25 @@ export class SetUpAdhocProvisioningProfile {
Log.log(
'Most commonly devices fail to to be provisioned while they are still being processed by Apple, which can take up to 24-72 hours. Check your Apple Developer Portal page at https://developer.apple.com/account/resources/devices/list, the devices in "Processing" status cannot be provisioned yet'
);
const shouldContinue = await selectAsync(
'Do you want to continue without provisioning these devices?',
[
{
title: 'Yes',
value: true,
},
{
title: 'No (EAS CLI will exit)',
value: false,
},
]
);
if (!shouldContinue) {
Errors.exit(1);
if (ctx.nonInteractive && ctx.refreshAdHocProvisioningProfile) {
// Continue without prompting in non-interactive refresh mode.
} else {
const shouldContinue = await selectAsync(
'Do you want to continue without provisioning these devices?',
[
{
title: 'Yes',
value: true,
},
{
title: 'No (EAS CLI will exit)',
value: false,
},
]
);
if (!shouldContinue) {
Errors.exit(1);
}
}
}
@@ -371,6 +402,67 @@ export class SetUpAdhocProvisioningProfile {
}
}
}
private async ensureAppStoreAuthenticatedForAdhocRefreshAsync(
ctx: CredentialsContext,
app: AppLookupParams
): Promise<void> {
if (hasAscEnvVars()) {
await ctx.appStore.ensureAuthenticatedAsync({ mode: AuthenticationMode.API_KEY });
return;
}
const resolvedKey = await resolveAscApiKeyForAppCredentialsAsync({
graphqlClient: ctx.graphqlClient,
app,
});
if (!resolvedKey) {
throw new Error(
'No App Store Connect API Key found for ad-hoc provisioning profile refresh. In non-interactive mode, provide one via:\n' +
' - Environment variables: EXPO_ASC_API_KEY_PATH, EXPO_ASC_KEY_ID, EXPO_ASC_ISSUER_ID\n' +
' - EAS credentials service: configure an App Store Connect API Key for submissions on this app'
);
}
await ctx.appStore.ensureAuthenticatedAsync({
mode: AuthenticationMode.API_KEY,
ascApiKey: resolvedKey.ascApiKey,
teamId: resolvedKey.teamId,
teamName: resolvedKey.teamName,
// Provide a non-enterprise team type to avoid interactive team-type resolution.
// Ad-hoc profile handling below uses explicit ProfileType and does not branch on team.inHouse.
teamType: AppleTeamType.COMPANY_OR_ORGANIZATION,
});
}
}
async function resolveAscApiKeyForAppCredentialsAsync({
graphqlClient,
app,
}: {
graphqlClient: ExpoGraphqlClient;
app: AppLookupParams;
}): Promise<{
ascApiKey: MinimalAscApiKey;
teamId?: string;
teamName?: string;
} | null> {
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, app);
if (!ascKeyFragment) {
return null;
}
Log.log('Using App Store Connect API Key from EAS credentials service.');
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, ascKeyFragment.id);
return {
ascApiKey: {
keyP8: fullKey.keyP8,
keyId: fullKey.keyIdentifier,
issuerId: fullKey.issuerIdentifier,
},
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
};
}
export function doUDIDsMatch(udidsA: string[], udidsB: string[]): boolean {
@@ -54,6 +54,18 @@ export class SetUpTargetBuildCredentials {
ctx: CredentialsContext
): Promise<IosAppBuildCredentialsFragment> {
const { app, distribution, enterpriseProvisioning, target } = this.options;
if (ctx.refreshAdHocProvisioningProfile) {
if (distribution !== 'internal') {
throw new Error(
'--refresh-ad-hoc-provisioning-profile is only supported for internal distribution builds.'
);
}
if (enterpriseProvisioning === 'universal') {
throw new Error(
'--refresh-ad-hoc-provisioning-profile is only supported for ad-hoc internal builds.'
);
}
}
if (distribution === 'internal') {
if (enterpriseProvisioning === 'adhoc') {
return await new SetUpAdhocProvisioningProfile({ app, target }).runAsync(ctx);
@@ -1,7 +1,65 @@
import { v4 as uuidv4 } from 'uuid';
import { AppleDeviceClass, AppleDeviceFragment } from '../../../../graphql/generated';
import { formatDeviceLabel } from '../DeviceUtils';
import { ApplePlatform } from '../../appstore/constants';
import { filterDevicesForApplePlatform, formatDeviceLabel } from '../DeviceUtils';
function createDevice(
overrides: Partial<AppleDeviceFragment> & Pick<AppleDeviceFragment, 'identifier'>
): AppleDeviceFragment {
return {
__typename: 'AppleDevice',
id: uuidv4(),
name: 'device',
model: null,
deviceClass: null,
createdAt: new Date().toISOString(),
...overrides,
} as AppleDeviceFragment;
}
describe(filterDevicesForApplePlatform, () => {
const iphone = createDevice({
identifier: 'iphone',
deviceClass: AppleDeviceClass.Iphone,
});
const ipad = createDevice({
identifier: 'ipad',
deviceClass: AppleDeviceClass.Ipad,
});
const mac = createDevice({
identifier: 'mac',
deviceClass: AppleDeviceClass.Mac,
});
const appleTv = createDevice({
identifier: 'appletv',
model: 'AppleTV6,3',
deviceClass: AppleDeviceClass.Unknown,
});
const unknown = createDevice({
identifier: 'unknown',
deviceClass: AppleDeviceClass.Unknown,
});
const noClass = createDevice({
identifier: 'noclass',
deviceClass: null,
});
it('keeps only iPhone and iPad devices for iOS targets', () => {
expect(
filterDevicesForApplePlatform(
[iphone, ipad, mac, appleTv, unknown, noClass],
ApplePlatform.IOS
)
).toEqual([iphone, ipad]);
});
it('throws for tvOS targets', () => {
expect(() =>
filterDevicesForApplePlatform([iphone, ipad, mac, appleTv], ApplePlatform.TV_OS)
).toThrow('Filtering for tvOS is not supported yet');
});
});
describe(formatDeviceLabel, () => {
it('returns createdAt clause', async () => {
@@ -23,17 +23,37 @@ import { ApplePlatform } from '../../appstore/constants';
import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from '../BuildCredentialsUtils';
import { chooseDevicesAsync } from '../DeviceUtils';
import { SetUpAdhocProvisioningProfile, doUDIDsMatch } from '../SetUpAdhocProvisioningProfile';
import { getAscApiKeyForAppSubmissionsAsync } from '../../api/GraphqlClient';
import { AuthenticationMode, AppleTeamType } from '../../appstore/authenticateTypes';
import { hasAscEnvVars } from '../../appstore/resolveCredentials';
import { AppStoreConnectApiKeyQuery } from '../../../../graphql/queries/AppStoreConnectApiKeyQuery';
jest.mock('../BuildCredentialsUtils');
jest.mock('../../../context');
jest.mock('../../../ios/api/GraphqlClient');
jest.mock('../../../ios/api/GraphqlClient', () => ({
...jest.requireActual('../../../ios/api/GraphqlClient'),
getAscApiKeyForAppSubmissionsAsync: jest.fn(),
}));
jest.mock('../../appstore/resolveCredentials', () => ({
hasAscEnvVars: jest.fn(),
}));
jest.mock('../../../../graphql/queries/AppStoreConnectApiKeyQuery', () => ({
AppStoreConnectApiKeyQuery: {
getByIdAsync: jest.fn(),
},
}));
jest.mock('../DeviceUtils', () => {
return {
__esModule: true,
...jest.requireActual('../DeviceUtils'),
chooseDevicesAsync: jest.fn(),
formatDeviceLabel: jest.requireActual('../DeviceUtils').formatDeviceLabel,
};
});
jest.mock('../SetUpDistributionCertificate', () => ({
SetUpDistributionCertificate: jest.fn(),
}));
import { SetUpDistributionCertificate } from '../SetUpDistributionCertificate';
jest.mock('../../../../project/ios/target');
jest.mock('../../../../prompts');
@@ -148,6 +168,231 @@ describe('runWithDistributionCertificateAsync', () => {
});
});
describe('runAsync', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
jest.mocked(SetUpDistributionCertificate).mockImplementation(
() =>
({
runAsync: jest.fn().mockResolvedValue({} as AppleDistributionCertificateFragment),
}) as any
);
});
const setUpAdhocProvisioningProfile = new SetUpAdhocProvisioningProfile({
app: { account: {} as Account, projectName: 'projName', bundleIdentifier: 'bundleId' },
target: { targetName: 'targetName', bundleIdentifier: 'bundleId', entitlements: {} },
});
it('skips non-interactive reuse when refresh is enabled', async () => {
const { ctx } = setUpTest();
Object.defineProperty(ctx, 'nonInteractive', { value: true });
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
const areBuildCredentialsSetupAsyncSpy = jest
.spyOn(SetUpAdhocProvisioningProfile.prototype as any, 'areBuildCredentialsSetupAsync')
.mockResolvedValue(true);
const ensureAppStoreAuthenticatedForAdhocRefreshAsyncSpy = jest
.spyOn(
SetUpAdhocProvisioningProfile.prototype as any,
'ensureAppStoreAuthenticatedForAdhocRefreshAsync'
)
.mockResolvedValue(undefined);
jest
.spyOn(SetUpAdhocProvisioningProfile.prototype, 'runWithDistributionCertificateAsync')
.mockResolvedValue({} as IosAppBuildCredentialsFragment);
await setUpAdhocProvisioningProfile.runAsync(ctx);
expect(areBuildCredentialsSetupAsyncSpy).not.toHaveBeenCalled();
expect(ensureAppStoreAuthenticatedForAdhocRefreshAsyncSpy).toHaveBeenCalledWith(ctx, {
account: {} as Account,
projectName: 'projName',
bundleIdentifier: 'bundleId',
});
expect(
SetUpAdhocProvisioningProfile.prototype.runWithDistributionCertificateAsync
).toHaveBeenCalled();
expect(getBuildCredentialsAsync).not.toHaveBeenCalled();
});
it('fails when refresh is enabled and credentials are frozen', async () => {
const { ctx } = setUpTest();
Object.defineProperty(ctx, 'nonInteractive', { value: true });
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
Object.defineProperty(ctx, 'freezeCredentials', { value: true });
await expect(setUpAdhocProvisioningProfile.runAsync(ctx)).rejects.toThrow(
'Cannot refresh ad-hoc provisioning profile when credentials are frozen'
);
});
});
describe('refresh ad-hoc provisioning profile', () => {
beforeEach(() => {
jest.clearAllMocks();
jest.restoreAllMocks();
jest.mocked(getApplePlatformFromTarget).mockReturnValue(ApplePlatform.IOS);
jest.mocked(SetUpDistributionCertificate).mockImplementation(
() =>
({
runAsync: jest.fn().mockResolvedValue({} as AppleDistributionCertificateFragment),
}) as any
);
jest.mocked(hasAscEnvVars).mockReturnValue(false);
jest.mocked(getAscApiKeyForAppSubmissionsAsync).mockResolvedValue(null);
});
const setUpAdhocProvisioningProfile = new SetUpAdhocProvisioningProfile({
app: { account: {} as Account, projectName: 'projName', bundleIdentifier: 'bundleId' },
target: { targetName: 'targetName', bundleIdentifier: 'bundleId', entitlements: {} },
});
it('provisions all registered devices without prompting in refresh mode', async () => {
const { ctx, distCert } = setUpRefreshTest();
Object.defineProperty(ctx, 'nonInteractive', { value: true });
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
await setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(ctx, distCert);
expect(chooseDevicesAsync).not.toHaveBeenCalled();
expect(ctx.appStore.createOrReuseAdhocProvisioningProfileAsync).toHaveBeenCalledWith(
['id1', 'id2', 'id3'],
'bundleId',
distCert.serialNumber,
expect.anything()
);
});
it('filters devices by target platform only in refresh mode', async () => {
const { ctx, distCert } = setUpRefreshTest();
Object.defineProperty(ctx, 'nonInteractive', { value: true });
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
ctx.ios.getDevicesForAppleTeamAsync = jest.fn().mockResolvedValue([
{ identifier: 'id1', model: 'iPhone17,1', deviceClass: AppleDeviceClass.Iphone },
{ identifier: 'id2', model: 'AppleTV6,3', deviceClass: AppleDeviceClass.Unknown },
{ identifier: 'id3', deviceClass: AppleDeviceClass.Mac },
]);
await setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(ctx, distCert);
expect(ctx.appStore.createOrReuseAdhocProvisioningProfileAsync).toHaveBeenCalledWith(
['id1'],
'bundleId',
distCert.serialNumber,
expect.anything()
);
expect(chooseDevicesAsync).not.toHaveBeenCalled();
});
it('continues without prompting when some devices are not provisioned in refresh mode', async () => {
const { ctx, distCert } = setUpRefreshTest();
Object.defineProperty(ctx, 'nonInteractive', { value: true });
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
jest.mocked(getBuildCredentialsAsync).mockResolvedValue({
provisioningProfile: {
appleTeam: { appleTeamIdentifier: 'team' },
appleDevices: [{ identifier: 'id1' }],
developerPortalIdentifier: 'provisioningProfileId',
},
} as IosAppBuildCredentialsFragment);
ctx.ios.updateProvisioningProfileAsync = jest.fn().mockResolvedValue({
appleTeam: { appleTeamIdentifier: 'team' },
appleDevices: [{ identifier: 'id1' }],
developerPortalIdentifier: 'provisioningProfileId',
});
await setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(ctx, distCert);
expect(selectAsync).not.toHaveBeenCalled();
expect(assignBuildCredentialsAsync).toHaveBeenCalled();
});
it('errors when no devices are registered in non-interactive mode', async () => {
const { ctx, distCert } = setUpRefreshTest();
Object.defineProperty(ctx, 'nonInteractive', { value: true });
ctx.ios.getDevicesForAppleTeamAsync = jest.fn().mockResolvedValue([]);
await expect(
setUpAdhocProvisioningProfile.runWithDistributionCertificateAsync(ctx, distCert)
).rejects.toThrow(
'No devices are registered for this Apple team. Register devices with eas device:create first.'
);
});
describe('ensureAppStoreAuthenticatedForAdhocRefreshAsync', () => {
it('authenticates with ASC environment variables when present', async () => {
const { ctx } = setUpRefreshTest();
jest.mocked(hasAscEnvVars).mockReturnValue(true);
jest
.spyOn(SetUpAdhocProvisioningProfile.prototype, 'runWithDistributionCertificateAsync')
.mockResolvedValue({} as IosAppBuildCredentialsFragment);
await setUpAdhocProvisioningProfile.runAsync(ctx);
expect(ctx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith({
mode: AuthenticationMode.API_KEY,
});
expect(getAscApiKeyForAppSubmissionsAsync).not.toHaveBeenCalled();
});
it('authenticates with the stored submissions ASC API key when env vars are absent', async () => {
const { ctx } = setUpRefreshTest();
jest.mocked(hasAscEnvVars).mockReturnValue(false);
jest.mocked(getAscApiKeyForAppSubmissionsAsync).mockResolvedValue({
id: 'asc-key-id',
appleTeam: {
appleTeamIdentifier: 'TEAM123',
appleTeamName: 'Team Name',
},
} as any);
jest.mocked(AppStoreConnectApiKeyQuery.getByIdAsync).mockResolvedValue({
keyP8: 'key-p8',
keyIdentifier: 'key-id',
issuerIdentifier: 'issuer-id',
} as any);
jest
.spyOn(SetUpAdhocProvisioningProfile.prototype, 'runWithDistributionCertificateAsync')
.mockResolvedValue({} as IosAppBuildCredentialsFragment);
await setUpAdhocProvisioningProfile.runAsync(ctx);
expect(ctx.appStore.ensureAuthenticatedAsync).toHaveBeenCalledWith({
mode: AuthenticationMode.API_KEY,
ascApiKey: {
keyP8: 'key-p8',
keyId: 'key-id',
issuerId: 'issuer-id',
},
teamId: 'TEAM123',
teamName: 'Team Name',
teamType: AppleTeamType.COMPANY_OR_ORGANIZATION,
});
});
});
});
function setUpRefreshTest(): {
ctx: CredentialsContext;
distCert: AppleDistributionCertificateFragment;
} {
const { ctx, distCert } = setUpTest();
Object.defineProperty(ctx, 'nonInteractive', { value: true });
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', { value: true });
distCert.appleTeam = {
appleTeamIdentifier: 'team',
} as AppleDistributionCertificateFragment['appleTeam'];
distCert.serialNumber = 'serial-number';
ctx.appStore.ensureAuthenticatedAsync = jest.fn().mockResolvedValue({});
jest.mocked(getBuildCredentialsAsync).mockResolvedValue(null);
ctx.ios.getDevicesForAppleTeamAsync = jest.fn().mockResolvedValue([
{ identifier: 'id1', deviceClass: AppleDeviceClass.Iphone },
{ identifier: 'id2', deviceClass: AppleDeviceClass.Ipad },
{ identifier: 'id3', deviceClass: AppleDeviceClass.Iphone },
] as AppleDeviceFragment[]);
return { ctx, distCert };
}
function setUpTest(): { ctx: CredentialsContext; distCert: AppleDistributionCertificateFragment } {
const ctx = jest.mocked(
new CredentialsContext(
@@ -177,8 +422,7 @@ function setUpTest(): { ctx: CredentialsContext; distCert: AppleDistributionCert
{ identifier: 'id2', name: 'Device 2', deviceClass: AppleDeviceClass.Iphone },
{ identifier: 'id3', name: 'Device 3', deviceClass: AppleDeviceClass.Mac },
] as AppleDevice[]);
// @ts-expect-error
jest.mocked(getApplePlatformFromTarget).mockResolvedValue(ApplePlatform.IOS);
jest.mocked(getApplePlatformFromTarget).mockReturnValue(ApplePlatform.IOS);
Object.defineProperty(ctx, 'appStore', { value: jest.mock('../../appstore/AppStoreApi') });
ctx.appStore.createOrReuseAdhocProvisioningProfileAsync = jest.fn().mockResolvedValue({
provisioningProfileId: 'provisioningProfileId',
@@ -186,6 +430,11 @@ function setUpTest(): { ctx: CredentialsContext; distCert: AppleDistributionCert
ctx.ios.createOrGetExistingAppleAppIdentifierAsync = jest
.fn()
.mockResolvedValue({} as AppleAppIdentifierFragment);
ctx.ios.createProvisioningProfileAsync = jest.fn().mockResolvedValue({
appleTeam: { appleTeamIdentifier: 'team' },
appleDevices: [{ identifier: 'id1' }, { identifier: 'id2' }, { identifier: 'id3' }],
developerPortalIdentifier: 'provisioningProfileId',
});
jest.mocked(assignBuildCredentialsAsync).mockResolvedValue({} as IosAppBuildCredentialsFragment);
const distCert = {} as AppleDistributionCertificateFragment;
return { ctx, distCert };
@@ -0,0 +1,110 @@
import { Env } from '@expo/eas-build-job';
import { EasJson } from '@expo/eas-json';
import { Analytics } from '../../../../analytics/AnalyticsManager';
import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils/createGraphqlClient';
import { Account, IosAppBuildCredentialsFragment } from '../../../../graphql/generated';
import { Actor } from '../../../../user/User';
import { Client } from '../../../../vcs/vcs';
import { CredentialsContext, CredentialsContextProjectInfo } from '../../../context';
import { SetUpAdhocProvisioningProfile } from '../SetUpAdhocProvisioningProfile';
import { SetUpInternalProvisioningProfile } from '../SetUpInternalProvisioningProfile';
import { SetUpTargetBuildCredentials } from '../SetUpTargetBuildCredentials';
jest.mock('../SetUpAdhocProvisioningProfile', () => ({
SetUpAdhocProvisioningProfile: jest.fn(),
}));
jest.mock('../SetUpInternalProvisioningProfile', () => ({
SetUpInternalProvisioningProfile: jest.fn(),
}));
jest.mock('../../../context');
describe(SetUpTargetBuildCredentials, () => {
const app = {
account: { name: 'account' } as Account,
projectName: 'projName',
bundleIdentifier: 'bundleId',
};
const target = { targetName: 'targetName', bundleIdentifier: 'bundleId', entitlements: {} };
beforeEach(() => {
jest.clearAllMocks();
jest.mocked(SetUpAdhocProvisioningProfile).mockImplementation(
() =>
({
runAsync: jest.fn().mockResolvedValue({} as IosAppBuildCredentialsFragment),
}) as any
);
jest.mocked(SetUpInternalProvisioningProfile).mockImplementation(
() =>
({
runAsync: jest.fn().mockResolvedValue({} as IosAppBuildCredentialsFragment),
}) as any
);
});
function createCtx(overrides: Partial<CredentialsContext> = {}): CredentialsContext {
const ctx = jest.mocked(
new CredentialsContext(
{} as {
projectInfo: CredentialsContextProjectInfo | null;
easJsonCliConfig?: EasJson['cli'];
nonInteractive: boolean;
projectDir: string;
user: Actor;
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
env?: Env;
vcsClient: Client;
}
)
);
Object.defineProperty(ctx, 'refreshAdHocProvisioningProfile', {
value: true,
writable: true,
});
return Object.assign(ctx, overrides);
}
it('errors when refresh is enabled for store distribution builds', async () => {
const action = new SetUpTargetBuildCredentials({
app,
distribution: 'store',
entitlements: {},
target,
});
await expect(action.setupBuildCredentialsAsync(createCtx())).rejects.toThrow(
'--refresh-ad-hoc-provisioning-profile is only supported for internal distribution builds.'
);
});
it('errors when refresh is enabled for universal enterprise internal builds', async () => {
const action = new SetUpTargetBuildCredentials({
app,
distribution: 'internal',
enterpriseProvisioning: 'universal',
entitlements: {},
target,
});
await expect(action.setupBuildCredentialsAsync(createCtx())).rejects.toThrow(
'--refresh-ad-hoc-provisioning-profile is only supported for ad-hoc internal builds.'
);
});
it('delegates to internal provisioning setup when refresh is enabled for internal ad-hoc builds', async () => {
const action = new SetUpTargetBuildCredentials({
app,
distribution: 'internal',
entitlements: {},
target,
});
const ctx = createCtx();
await action.setupBuildCredentialsAsync(ctx);
expect(SetUpInternalProvisioningProfile).toHaveBeenCalledWith({ app, target });
expect(SetUpAdhocProvisioningProfile).not.toHaveBeenCalled();
});
});
+85
View File
@@ -1458,6 +1458,7 @@ export type App = Project & {
/** @deprecated Classic updates have been deprecated. */
description: Scalars['String']['output'];
devDomainName?: Maybe<AppDevDomainName>;
deviceRunSessionsPaginated: AppDeviceRunSessionsConnection;
/** Environment secrets for an app */
environmentSecrets: Array<EnvironmentSecret>;
environmentVariableEnvironments: Array<Scalars['EnvironmentVariableEnvironment']['output']>;
@@ -1686,6 +1687,16 @@ export type AppDeploymentsArgs = {
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppDeviceRunSessionsPaginatedArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<DeviceRunSessionFilterInput>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type AppEnvironmentSecretsArgs = {
filterNames?: InputMaybe<Array<Scalars['String']['input']>>;
@@ -2004,6 +2015,18 @@ export type AppDevDomainNameMutationChangeDevDomainNameArgs = {
name: Scalars['DevDomainName']['input'];
};
export type AppDeviceRunSessionEdge = {
__typename?: 'AppDeviceRunSessionEdge';
cursor: Scalars['String']['output'];
node: DeviceRunSession;
};
export type AppDeviceRunSessionsConnection = {
__typename?: 'AppDeviceRunSessionsConnection';
edges: Array<AppDeviceRunSessionEdge>;
pageInfo: PageInfo;
};
export type AppFingerprintEdge = {
__typename?: 'AppFingerprintEdge';
cursor: Scalars['String']['output'];
@@ -2147,6 +2170,15 @@ export type AppObserve = {
navigationRoutes: AppObserveNavigationRoutesConnection;
timeSeries: AppObserveTimeSeries;
totalEventCount: Scalars['Int']['output'];
/**
* Approximate count of unique users (`eas_client_id`) with at least one
* supported-metric event in `[startTime, endTime)`. Uses ClickHouse's
* HyperLogLog `uniq()`, so the value can drift by a small percent on apps
* with very large user bases. `metricNames` on the input is currently
* ignored: the count is always over all supported metrics, matching the
* universe used by `appVersions` headline counts.
*/
uniqueActiveUserCount: Scalars['Int']['output'];
updates: AppObserveUpdatesConnection;
};
@@ -2216,6 +2248,11 @@ export type AppObserveTimeSeriesArgs = {
};
export type AppObserveUniqueActiveUserCountArgs = {
input: AppObserveReleasesInput;
};
export type AppObserveUpdatesArgs = {
input: AppObserveUpdatesInput;
};
@@ -2224,6 +2261,11 @@ export type AppObserveAppBuildEmbeddedSummary = {
__typename?: 'AppObserveAppBuildEmbeddedSummary';
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
/**
* Unique users whose most recent supported-metric event in the queried
* range ran this build's embedded bundle.
*/
lastSeenUserCount: Scalars['Int']['output'];
uniqueUserCount: Scalars['Int']['output'];
};
@@ -2239,7 +2281,18 @@ export type AppObserveAppBuildNumber = {
embedded?: Maybe<AppObserveAppBuildEmbeddedSummary>;
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
/**
* Unique users whose most recent supported-metric event in the queried
* range was on this (appVersion, appBuildNumber) tuple.
*/
lastSeenUserCount: Scalars['Int']['output'];
uniqueUserCount: Scalars['Int']['output'];
/**
* Updates seen on this build number in the queried time range. Each
* entry's counts and EAS builds are scoped to this (appVersion,
* appBuildNumber, appUpdateId) tuple.
*/
updates: Array<AppObserveAppUpdate>;
};
export type AppObserveAppEasBuild = {
@@ -2257,6 +2310,11 @@ export type AppObserveAppUpdate = {
easBuilds: Array<AppObserveAppEasBuild>;
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
/**
* Unique users whose most recent supported-metric event in the queried
* range matched this update at its current nesting level.
*/
lastSeenUserCount: Scalars['Int']['output'];
uniqueUserCount: Scalars['Int']['output'];
};
@@ -2266,6 +2324,17 @@ export type AppObserveAppVersion = {
buildNumbers: Array<AppObserveAppBuildNumber>;
eventCount: Scalars['Int']['output'];
firstSeenAt: Scalars['DateTime']['output'];
/**
* Unique users whose most recent supported-metric event in
* `[startTime, endTime)` was on this version. Each active user
* contributes to exactly one version, so summing this field across the
* returned versions yields the total active users in the window (equal
* to `AppObserve.uniqueActiveUserCount` up to HyperLogLog noise).
* A version may be returned with `lastSeenUserCount = 0` when
* every user who touched it later moved to a newer version. Users with
* no events in the window are not counted on any version.
*/
lastSeenUserCount: Scalars['Int']['output'];
metrics: Array<AppObserveAppVersionMetric>;
uniqueUserCount: Scalars['Int']['output'];
updates: Array<AppObserveAppUpdate>;
@@ -5237,6 +5306,12 @@ export type DeviceRunSession = {
updatedAt: Scalars['DateTime']['output'];
};
export type DeviceRunSessionFilterInput = {
platforms?: InputMaybe<Array<AppPlatform>>;
statuses?: InputMaybe<Array<DeviceRunSessionStatus>>;
types?: InputMaybe<Array<DeviceRunSessionType>>;
};
export type DeviceRunSessionMutation = {
__typename?: 'DeviceRunSessionMutation';
/** Create a device run session */
@@ -12908,6 +12983,16 @@ export type DeviceRunSessionByIdQueryVariables = Exact<{
export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', byId: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, remoteConfig?: { __typename: 'AgentDeviceRunSessionRemoteConfig', agentDeviceRemoteSessionUrl: string, agentDeviceRemoteSessionToken: string, webPreviewUrl?: string | null } | { __typename: 'ArgentRunSessionRemoteConfig', toolsUrl: string, webPreviewUrl?: string | null } | { __typename: 'ServeSimRunSessionRemoteConfig', previewUrl: string, streamUrl: string } | null, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } } };
export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
first?: InputMaybe<Scalars['Int']['input']>;
after?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<DeviceRunSessionFilterInput>;
}>;
export type DeviceRunSessionsByAppIdQuery = { __typename?: 'RootQuery', app: { __typename?: 'AppQuery', byId: { __typename?: 'App', id: string, deviceRunSessionsPaginated: { __typename?: 'AppDeviceRunSessionsConnection', edges: Array<{ __typename?: 'AppDeviceRunSessionEdge', cursor: string, node: { __typename?: 'DeviceRunSession', id: string, status: DeviceRunSessionStatus, type: DeviceRunSessionType, platform: AppPlatform, createdAt: any, startedAt?: any | null, finishedAt?: any | null, app: { __typename?: 'App', id: string, slug: string, ownerAccount: { __typename?: 'Account', id: string, name: string } }, turtleJobRun?: { __typename?: 'JobRun', id: string, status: JobRunStatus } | null } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage: boolean, hasPreviousPage: boolean, startCursor?: string | null, endCursor?: string | null } } } } };
export type EnvironmentSecretsByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
}>;
@@ -2,7 +2,13 @@ import gql from 'graphql-tag';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { withErrorHandlingAsync } from '../client';
import { DeviceRunSessionByIdQuery, DeviceRunSessionByIdQueryVariables } from '../generated';
import {
DeviceRunSessionByIdQuery,
DeviceRunSessionByIdQueryVariables,
DeviceRunSessionFilterInput,
DeviceRunSessionsByAppIdQuery,
DeviceRunSessionsByAppIdQueryVariables,
} from '../generated';
export const DeviceRunSessionQuery = {
async byIdAsync(
@@ -58,4 +64,74 @@ export const DeviceRunSessionQuery = {
);
return data.deviceRunSessions.byId;
},
async listByAppIdAsync(
graphqlClient: ExpoGraphqlClient,
{
appId,
first,
after,
filter,
}: {
appId: string;
first?: number;
after?: string;
filter?: DeviceRunSessionFilterInput;
}
): Promise<DeviceRunSessionsByAppIdQuery['app']['byId']['deviceRunSessionsPaginated']> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<DeviceRunSessionsByAppIdQuery, DeviceRunSessionsByAppIdQueryVariables>(
gql`
query DeviceRunSessionsByAppId(
$appId: String!
$first: Int
$after: String
$filter: DeviceRunSessionFilterInput
) {
app {
byId(appId: $appId) {
id
deviceRunSessionsPaginated(first: $first, after: $after, filter: $filter) {
edges {
cursor
node {
id
status
type
platform
createdAt
startedAt
finishedAt
app {
id
slug
ownerAccount {
id
name
}
}
turtleJobRun {
id
status
}
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
}
`,
{ appId, first, after, filter },
{ requestPolicy: 'network-only' }
)
.toPromise()
);
return data.app.byId.deviceRunSessionsPaginated;
},
};
@@ -28,6 +28,7 @@ function makeAppVersion(
eventCount: metrics.reduce((sum, m) => sum + m.eventCount, 0),
uniqueUserCount: 50,
firstSeenAt: '2025-01-01T00:00:00.000Z',
lastSeenUserCount: 12,
buildNumbers: [],
updates: [],
metrics: metrics.map(m => ({
@@ -20,7 +20,7 @@ export async function getManagedApplicationTargetEntitlementsAsync(
env: Record<string, string>,
vcsClient: Client
): Promise<JSONObject> {
let expoConfigError: Error | undefined;
let expoConfigError: any;
if (isExpoInstalled(projectDir)) {
try {
const { stdout } = await spawnExpoCommand(
@@ -45,15 +45,22 @@ export async function getManagedApplicationTargetEntitlementsAsync(
scope.setTag('build_id', process.env.EAS_BUILD_ID);
}
scope.setTag('config_resolution', 'ios_entitlements_introspection');
scope.setExtra('message', 'iOS entitlements config fallback');
scope.setExtra('stdout', error.stdout);
scope.setExtra('stderr', error.stderr);
Sentry.captureException(error);
scope.setExtra(
'expo_config_command_error',
JSON.stringify({
message: error.message,
output: error.output,
signal: error.signal,
status: error.status,
stderr: error.stderr,
stdout: error.stdout,
})
);
Sentry.captureMessage('iOS entitlements config fallback', 'error');
});
} catch {
// do nothing
}
expoConfigError = error;
Log.warn(
`Failed to read the app config from the project using the local Expo CLI: ${formatError(error)}`
@@ -110,7 +117,7 @@ async function resolveManagedApplicationTargetEntitlementsWithBundledConfigAsync
}
}
function formatError(error: Error & { stderr?: string; stdout?: string }): string {
function formatError(error: any): string {
return error.stderr?.trim() || error.stdout?.trim() || error.message;
}
+19 -1
View File
@@ -1,8 +1,26 @@
import { DeviceRunSessionByIdQuery } from '../graphql/generated';
import { DeviceRunSessionByIdQuery, DeviceRunSessionType } from '../graphql/generated';
type DeviceRunSessionByIdResult = DeviceRunSessionByIdQuery['deviceRunSessions']['byId'];
export type DeviceRunSessionRemoteConfig = NonNullable<DeviceRunSessionByIdResult['remoteConfig']>;
// Mapping enum -> CLI flag value. Declared as Record<DeviceRunSessionType, string>
// so adding a new enum value in codegen fails the build until it is wired up here.
export const DEVICE_RUN_SESSION_TYPE_FLAG_VALUES: Record<DeviceRunSessionType, string> = {
[DeviceRunSessionType.AgentDevice]: 'agent-device',
[DeviceRunSessionType.Argent]: 'argent',
[DeviceRunSessionType.ServeSim]: 'serve-sim',
};
export const DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE = Object.fromEntries(
(Object.entries(DEVICE_RUN_SESSION_TYPE_FLAG_VALUES) as [DeviceRunSessionType, string][]).map(
([type, value]) => [value, type]
)
) as Record<string, DeviceRunSessionType>;
export function deviceRunSessionTypeToFlagValue(type: DeviceRunSessionType): string {
return DEVICE_RUN_SESSION_TYPE_FLAG_VALUES[type];
}
export function formatRemoteSessionInstructions(
remoteConfig: DeviceRunSessionRemoteConfig
): string {
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-json",
"version": "19.0.0",
"version": "19.1.0",
"description": "A library for interacting with eas.json",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
@@ -32,7 +32,7 @@
},
"dependencies": {
"@babel/code-frame": "7.23.5",
"@expo/eas-build-job": "19.0.0",
"@expo/eas-build-job": "19.1.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": "19.0.0",
"version": "19.1.0",
"description": "Tool for running EAS compatible builds on a local machine.",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
@@ -26,8 +26,8 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/build-tools": "19.0.0",
"@expo/eas-build-job": "19.0.0",
"@expo/build-tools": "19.1.0",
"@expo/eas-build-job": "19.1.0",
"@expo/logger": "19.0.0",
"@expo/spawn-async": "^1.7.2",
"@expo/turtle-spawn": "19.0.0",
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/steps",
"version": "19.0.0",
"version": "19.1.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -27,7 +27,7 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/eas-build-job": "19.0.0",
"@expo/eas-build-job": "19.1.0",
"@expo/logger": "19.0.0",
"@expo/spawn-async": "^1.7.2",
"arg": "^5.0.2",
+4 -4
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/worker",
"version": "19.0.0",
"version": "19.1.0",
"private": true,
"scripts": {
"start": "yarn watch",
@@ -19,16 +19,16 @@
},
"dependencies": {
"@babel/core": "7.24.7",
"@expo/build-tools": "19.0.0",
"@expo/build-tools": "19.1.0",
"@expo/config-plugins": "55.0.7",
"@expo/config-types": "55.0.5",
"@expo/downloader": "19.0.0",
"@expo/eas-build-job": "19.0.0",
"@expo/eas-build-job": "19.1.0",
"@expo/logger": "19.0.0",
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "19.0.0",
"@expo/steps": "19.1.0",
"@expo/template-file": "19.0.0",
"@expo/turtle-spawn": "19.0.0",
"@hapi/boom": "10.0.1",
+1 -4
View File
@@ -49,10 +49,7 @@ export class WsHelper {
public onMessage(cb: (message: any) => void): void {
this.onMessageCb = jest.fn(raw => {
logger.debug({ raw }, 'ws message');
const parsed = JSON.parse(raw);
if (parsed.type !== 'build-phase-stats') {
cb(parsed);
}
cb(JSON.parse(raw));
});
this.ws.on('message', this.onMessageCb);
}
+25 -1
View File
@@ -1,13 +1,15 @@
import { Platform, Workflow } from '@expo/eas-build-job';
import os from 'os';
import config from '../config';
import { getBuildEnv } from '../env';
import { getBuildEnv, getGradleMemoryOptions } from '../env';
describe(getBuildEnv.name, () => {
const originalSentryDsn = config.sentry.dsn;
afterEach(() => {
config.sentry.dsn = originalSentryDsn;
jest.restoreAllMocks();
});
it('passes the CLI sentry DSN to worker child processes', () => {
@@ -59,4 +61,26 @@ describe(getBuildEnv.name, () => {
expect(env.EXPO_PRECOMPILED_MODULES_BASE_URL).toBeUndefined();
expect(env.EXPO_PRECOMPILED_MODULES_PATH).toBeUndefined();
});
it('sizes Gradle memory options from total memory', () => {
const totalMemory = jest.spyOn(os, 'totalmem');
totalMemory.mockReturnValue(16 * 1024 ** 3);
expect(getGradleMemoryOptions()).toEqual({
maxHeapSize: '4g',
});
totalMemory.mockReturnValue(32 * 1024 ** 3);
expect(getGradleMemoryOptions()).toEqual({
maxHeapSize: '8g',
});
});
it('keeps Gradle memory options conservative on low-memory runners', () => {
jest.spyOn(os, 'totalmem').mockReturnValue(4 * 1024 ** 3);
expect(getGradleMemoryOptions()).toEqual({
maxHeapSize: '1g',
});
});
});
+3 -47
View File
@@ -1,5 +1,5 @@
import { Datadog } from '@expo/build-tools';
import { BuildPhase, BuildPhaseResult, Job, errors } from '@expo/eas-build-job';
import { Job, errors } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import path from 'path';
@@ -105,42 +105,6 @@ describe(getExpoPackageVersionAsync, () => {
});
});
describe('BuildService.reportBuildPhaseStats', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('sends phase stats to the launcher websocket', () => {
const send = jest.fn();
const service = new BuildService();
service.setWS({ send } as any);
const stats = {
buildPhase: BuildPhase.RUN_FASTLANE,
result: BuildPhaseResult.SUCCESS,
durationMs: 1234,
};
service.reportBuildPhaseStats(stats);
expect(send).toHaveBeenCalledWith({
type: 'build-phase-stats',
...stats,
});
});
it('does not send phase stats when there is no launcher websocket', () => {
const service = new BuildService();
service.reportBuildPhaseStats({
buildPhase: BuildPhase.PREPARE_PROJECT,
result: BuildPhaseResult.SUCCESS,
durationMs: 1,
});
expect(datadogSetupMock).not.toHaveBeenCalled();
});
});
describe('BuildService Datadog setup', () => {
const buildLogger = { child: jest.fn(() => buildLogger) } as any;
@@ -175,11 +139,7 @@ describe('BuildService Datadog setup', () => {
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
});
expect(createBuildContextMock).toHaveBeenCalledWith(
expect.objectContaining({
reportBuildPhaseStatsFn: expect.any(Function),
})
);
expect(createBuildContextMock).toHaveBeenCalled();
expect(datadogFlushAsyncMock).toHaveBeenCalled();
});
@@ -201,11 +161,7 @@ describe('BuildService Datadog setup', () => {
turtleBuildId: 'build-id',
robotAccessToken: 'token-abc',
});
expect(createBuildContextMock).toHaveBeenCalledWith(
expect.objectContaining({
reportBuildPhaseStatsFn: expect.any(Function),
})
);
expect(createBuildContextMock).toHaveBeenCalled();
});
it('leaves Datadog unconfigured when the robot access token is unavailable', async () => {
+1 -26
View File
@@ -1,12 +1,4 @@
import {
Artifacts,
BuildContext,
Builders,
Sentry,
parseGradleProfile,
formatGradleProfileReport,
runGenericJobAsync,
} from '@expo/build-tools';
import { Artifacts, BuildContext, Builders, runGenericJobAsync } from '@expo/build-tools';
import {
Android,
BuildJob,
@@ -21,8 +13,6 @@ import {
} from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import omit from 'lodash/omit';
import path from 'path';
import config from './config';
import { displayWorkerRuntimeInfo } from './displayRuntimeInfo';
import { Analytics, Event, logProjectDependenciesAsync } from './external/analytics';
@@ -92,21 +82,6 @@ export async function build({
analytics.logEvent(Event.WORKER_BUILD_SUCCESS, {});
if (job.platform === Platform.ANDROID) {
try {
await ctx.runBuildPhase(BuildPhase.GRADLE_BUILD_PROFILE, async () => {
const androidDir = path.join(ctx.getReactNativeProjectDirectory(), 'android');
const profileTasks = await parseGradleProfile(androidDir);
if (profileTasks.length > 0) {
const report = formatGradleProfileReport(profileTasks);
ctx.logger.info(report);
}
});
} catch (err: any) {
Sentry.capture('Failed to parse Gradle build profile', err);
}
}
return artifacts;
} catch (err: any) {
if ('mode' in job && ![BuildMode.CUSTOM, BuildMode.REPACK].includes(job.mode)) {
+1 -5
View File
@@ -2,7 +2,7 @@ import { GCS, GCSLoggerStream } from '@expo/build-tools';
import path from 'path';
import { Environment } from './constants';
import { ResourceClass, Worker } from './external/turtle';
import { Worker } from './external/turtle';
import env from './utils/env';
type ReplaceUndefinedWithNull<T> = undefined extends T ? Exclude<T, undefined> | null : T;
@@ -102,10 +102,6 @@ export default {
transform: createBase64EnvTransformer('runMetricsServer'),
defaultValue: null,
}),
resourceClass: env<ResourceClass | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('resourceClass'),
defaultValue: null,
}),
buildId: env<string>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('buildId'),
}),
+1 -4
View File
@@ -1,5 +1,5 @@
import { BuildContext, BuildContextOptions, LogBuffer } from '@expo/build-tools';
import { BuildPhaseStats, Job, ManagedArtifactType, Metadata, Platform } from '@expo/eas-build-job';
import { Job, ManagedArtifactType, Metadata, Platform } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import assert from 'assert';
@@ -24,7 +24,6 @@ export function createBuildContext<TJob extends Job>({
projectId,
buildId,
buildLogger,
reportBuildPhaseStatsFn,
}: {
job: TJob;
logBuffer: LogBuffer;
@@ -33,7 +32,6 @@ export function createBuildContext<TJob extends Job>({
projectId: string;
buildId: string;
buildLogger: bunyan;
reportBuildPhaseStatsFn: (stats: BuildPhaseStats) => void;
}): BuildContext<TJob> {
const env = getBuildEnv({ job, projectId, metadata, buildId });
const childLogger = buildLogger.child({ buildId });
@@ -89,7 +87,6 @@ export function createBuildContext<TJob extends Job>({
},
cacheManager: new GCSCacheManager(),
metadata,
reportBuildPhaseStats: reportBuildPhaseStatsFn,
expoApiV2BaseUrl: config.wwwApiV2BaseUrl,
});
return ctx;
+22 -29
View File
@@ -1,25 +1,11 @@
import { BuildContext } from '@expo/build-tools';
import { EnvironmentSecretType, Job } from '@expo/eas-build-job';
import formatBytes from 'filesize';
import fs from 'fs-extra';
import os from 'os';
import config from './config';
import { Environment } from './constants';
import { ResourceClass } from './external/turtle';
const RESOURCE_CLASS_DESCRIPTION: Record<ResourceClass, string> = {
[ResourceClass.ANDROID_N2_1_3_12]: 'Intel, 4 vCPUs, 16 GB RAM',
[ResourceClass.ANDROID_N2_2_6_24]: 'Intel, 8 vCPUs, 32 GB RAM',
[ResourceClass.IOS_M1_4_16]: 'M1, 2 cores, 8 GB RAM',
[ResourceClass.IOS_M2_2_8]: 'M2, 2 cores, 8 GB RAM',
[ResourceClass.IOS_M2_PRO_4_12]: 'M2 Pro, 4 cores, 12 GB RAM',
[ResourceClass.IOS_M4_PRO_5_20]: 'M4 Pro, 5 cores, 20 GB RAM',
[ResourceClass.IOS_M4_PRO_10_40]: 'M4 Pro, 10 cores, 40 GB RAM',
[ResourceClass.IOS_M2_4_22]: 'M2, 4 cores, 22 GB RAM',
[ResourceClass.LINUX_C3D_STANDARD_4]: 'AMD, 4 vCPUs, 16 GB RAM',
[ResourceClass.LINUX_C3D_STANDARD_8]: 'AMD, 8 vCPUs, 32 GB RAM',
[ResourceClass.LINUX_C4D_STANDARD_4]: 'AMD, 4 vCPUs, 15 GB RAM',
[ResourceClass.LINUX_C4D_STANDARD_8]: 'AMD, 8 vCPUs, 31 GB RAM',
};
export function displayWorkerRuntimeInfo(ctx: BuildContext<Job>): void {
printVMSpecs(ctx);
@@ -29,22 +15,29 @@ export function displayWorkerRuntimeInfo(ctx: BuildContext<Job>): void {
}
function printVMSpecs(ctx: BuildContext<Job>): void {
const { resourceClass } = config;
const specs = [
getCpuModelDescription(),
`${os.cpus().length} vCPUs`,
`${formatBytes(os.totalmem())} RAM`,
].flatMap(spec => spec || []);
if (resourceClass) {
const resourceClassDescription = RESOURCE_CLASS_DESCRIPTION[resourceClass];
ctx.logger.info(specs.join(', '));
ctx.logger.info('');
}
if (resourceClassDescription) {
ctx.logger.info(resourceClassDescription);
} else {
ctx.logger.debug(
`Resource class is unsupported: ${resourceClass}, valid values: ${Object.keys(
RESOURCE_CLASS_DESCRIPTION
)}}`
);
}
ctx.logger.info('');
function getCpuModelDescription(): string | null {
const cpuModel = os.cpus()[0]?.model.trim().replaceAll(/\s+/g, ' ').replace(' (Virtual)', '');
if (!cpuModel) {
return null;
}
if (cpuModel.toLowerCase().includes('intel')) {
return 'Intel';
} else if (cpuModel.toLowerCase().includes('amd')) {
return 'AMD';
}
return cpuModel;
}
function printImageMessage(ctx: BuildContext<Job>): void {
+10 -25
View File
@@ -1,15 +1,12 @@
import { Env, Job, Metadata, Platform, Workflow } from '@expo/eas-build-job';
import { spawnSync } from 'child_process';
import micromatch from 'micromatch';
import os from 'os';
import path from 'path';
import config from './config';
import { Environment } from './constants';
import {
ResourceClass,
ResourceClassToPlatform,
androidImagesWithJavaVersionLowerThen11,
} from './external/turtle';
import { androidImagesWithJavaVersionLowerThen11 } from './external/turtle';
import { getAccessedEnvs } from './utils/env';
// keep in sync with local-build-plugin env vars
@@ -48,8 +45,7 @@ export function getBuildEnv({
setEnv(env, 'EAS_BUILD_WORKINGDIR', path.join(config.workingdir, 'build'));
setEnv(env, 'EAS_BUILD_PROJECT_ID', projectId);
const runnerPlatform =
job.platform ?? (config.resourceClass && ResourceClassToPlatform[config.resourceClass]);
const runnerPlatform = job.platform;
if (runnerPlatform === Platform.IOS) {
setEnv(env, 'EAS_BUILD_COCOAPODS_CACHE_URL', config.cocoapodsCacheUrl);
setEnv(env, 'COMPILER_INDEX_STORE_ENABLE', 'NO');
@@ -77,9 +73,7 @@ export function getBuildEnv({
}
if (config.env !== Environment.TEST) {
const maxHeapSize = config.resourceClass
? (ResourceClassToMaxHeapSize[config.resourceClass] ?? '4g')
: '4g';
const { maxHeapSize } = getGradleMemoryOptions();
setEnv(
env,
@@ -163,19 +157,10 @@ function setEnv(env: Env, key: string, value: string | null | undefined): void {
}
}
const ResourceClassToMaxHeapSize: Record<ResourceClass, string | null> = {
[ResourceClass.LINUX_C3D_STANDARD_4]: '4g', // 4 out of 16 GB, 12 GB left
[ResourceClass.LINUX_C3D_STANDARD_8]: '8g', // 8 out of 32 GB, 24 GB left
[ResourceClass.LINUX_C4D_STANDARD_4]: '4g', // 4 out of 15 GB, 11 GB left
[ResourceClass.LINUX_C4D_STANDARD_8]: '8g', // 8 out of 31 GB, 23 GB left
[ResourceClass.ANDROID_N2_1_3_12]: '4g', // 4 out of 12 GB, 8 GB left
[ResourceClass.ANDROID_N2_2_6_24]: '8g', // 8 out of 24 GB, 16 GB left
export function getGradleMemoryOptions() {
const totalMemoryGb = os.totalmem() / 1024 / 1024 / 1024;
// We don't care about Gradle heap size for macOS resource classes
[ResourceClass.IOS_M1_4_16]: null,
[ResourceClass.IOS_M2_2_8]: null,
[ResourceClass.IOS_M2_PRO_4_12]: null,
[ResourceClass.IOS_M4_PRO_5_20]: null,
[ResourceClass.IOS_M4_PRO_10_40]: null,
[ResourceClass.IOS_M2_4_22]: null,
};
return {
maxHeapSize: `${Math.max(1, Math.round(totalMemoryGb / 4))}g`,
};
}
-1
View File
@@ -82,7 +82,6 @@ export async function logProjectDependenciesAsync(
});
} catch (error: any) {
const msg = 'Failed to report project dependencies metrics';
ctx.logger.error({ err: error }, msg);
sentry.capture(msg, error, {
level: 'warning',
tags: {
+2 -49
View File
@@ -1,43 +1,5 @@
import { GCS } from '@expo/build-tools';
import {
BuildJob,
BuildPhase,
BuildPhaseResult,
Generic,
Metadata,
Platform,
errors,
} from '@expo/eas-build-job';
export enum ResourceClass {
ANDROID_N2_1_3_12 = 'android-n2-1.3-12',
ANDROID_N2_2_6_24 = 'android-n2-2.6-24',
IOS_M1_4_16 = 'ios-m1-4-16',
IOS_M2_2_8 = 'ios-m2-2-8',
IOS_M2_PRO_4_12 = 'ios-m2-pro-4-12',
IOS_M2_4_22 = 'ios-m2-4-22',
IOS_M4_PRO_5_20 = 'ios-m4-pro-5-20',
IOS_M4_PRO_10_40 = 'ios-m4-pro-10-40',
LINUX_C3D_STANDARD_4 = 'linux-c3d-standard-4',
LINUX_C3D_STANDARD_8 = 'linux-c3d-standard-8',
LINUX_C4D_STANDARD_4 = 'linux-c4d-standard-4',
LINUX_C4D_STANDARD_8 = 'linux-c4d-standard-8',
}
export const ResourceClassToPlatform: Record<ResourceClass, Platform> = {
'android-n2-1.3-12': Platform.ANDROID,
'android-n2-2.6-24': Platform.ANDROID,
'ios-m1-4-16': Platform.IOS,
'ios-m2-2-8': Platform.IOS,
'ios-m2-4-22': Platform.IOS,
'ios-m2-pro-4-12': Platform.IOS,
'ios-m4-pro-5-20': Platform.IOS,
'ios-m4-pro-10-40': Platform.IOS,
[ResourceClass.LINUX_C3D_STANDARD_4]: Platform.ANDROID,
[ResourceClass.LINUX_C3D_STANDARD_8]: Platform.ANDROID,
[ResourceClass.LINUX_C4D_STANDARD_4]: Platform.ANDROID,
[ResourceClass.LINUX_C4D_STANDARD_8]: Platform.ANDROID,
};
import { BuildJob, Generic, Metadata, errors } from '@expo/eas-build-job';
export const androidImagesWithJavaVersionLowerThen11 = [
'ubuntu-20.04-jdk-8-ndk-r19c',
@@ -67,7 +29,6 @@ export namespace Worker {
mavenCacheUrl: string | undefined;
cocoapodsCacheUrl: string | undefined;
runMetricsServer: boolean;
resourceClass: ResourceClass;
type: 'jobRun';
buildId: string;
@@ -86,7 +47,6 @@ export namespace Worker {
mavenCacheUrl: string | undefined;
cocoapodsCacheUrl: string | undefined;
runMetricsServer: boolean;
resourceClass: ResourceClass;
type?: never;
buildId: string;
@@ -100,7 +60,6 @@ export namespace WorkerMessage {
STATE_RESPONSE = 'state-response',
SUCCESS = 'success',
ERROR = 'error',
BUILD_PHASE_STATS = 'build-phase-stats',
ABORTED = 'aborted',
}
@@ -109,7 +68,7 @@ export namespace WorkerMessage {
TIMEOUT = 'timeout',
}
export type Message = StateResponse | BuildSuccess | BuildError | BuildPhaseStats | BuildAborted;
export type Message = StateResponse | BuildSuccess | BuildError | BuildAborted;
export interface StateResponse {
type: MessageType.STATE_RESPONSE;
status: Worker.Status;
@@ -131,12 +90,6 @@ export namespace WorkerMessage {
applicationArchiveName: string | null;
buildArtifactsName: string | null;
}
export interface BuildPhaseStats {
type: MessageType.BUILD_PHASE_STATS;
buildPhase: BuildPhase;
result: BuildPhaseResult;
durationMs: number;
}
export interface BuildAborted {
type: MessageType.ABORTED;
reason?: AbortReason;
-14
View File
@@ -10,7 +10,6 @@ import {
BuildJob,
BuildMode,
BuildPhase,
BuildPhaseStats,
Ios,
Job,
Metadata,
@@ -241,16 +240,6 @@ export default class BuildService {
});
}
public reportBuildPhaseStats(stats: BuildPhaseStats): void {
if (this.ws) {
logger.info(stats, 'Sending build phase stats');
this.ws.send({
type: WorkerMessage.MessageType.BUILD_PHASE_STATS,
...stats,
});
}
}
public syncLauncherState({ buildId }: LauncherMessage.StateQuery): void {
if (this.buildId !== buildId) {
// should not happen (malicious or invalid connection)
@@ -307,9 +296,6 @@ export default class BuildService {
projectId,
buildId: this.buildId,
buildLogger,
reportBuildPhaseStatsFn: stats => {
this.reportBuildPhaseStats(stats);
},
});
this.buildContext = ctx;
Generated
+17 -17
View File
@@ -2856,14 +2856,14 @@ __metadata:
languageName: node
linkType: hard
"@expo/build-tools@npm:19.0.0, @expo/build-tools@workspace:packages/build-tools":
"@expo/build-tools@npm:19.1.0, @expo/build-tools@workspace:packages/build-tools":
version: 0.0.0-use.local
resolution: "@expo/build-tools@workspace:packages/build-tools"
dependencies:
"@expo/config": "npm:55.0.10"
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:19.0.0"
"@expo/eas-build-job": "npm:19.0.0"
"@expo/eas-build-job": "npm:19.1.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:19.0.0"
"@expo/package-manager": "npm:1.9.10"
@@ -2871,7 +2871,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:19.0.0"
"@expo/steps": "npm:19.1.0"
"@expo/template-file": "npm:19.0.0"
"@expo/turtle-spawn": "npm:19.0.0"
"@expo/xcpretty": "npm:^4.3.1"
@@ -3112,7 +3112,7 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-build-job@npm:19.0.0, @expo/eas-build-job@workspace:packages/eas-build-job":
"@expo/eas-build-job@npm:19.1.0, @expo/eas-build-job@workspace:packages/eas-build-job":
version: 0.0.0-use.local
resolution: "@expo/eas-build-job@workspace:packages/eas-build-job"
dependencies:
@@ -3131,12 +3131,12 @@ __metadata:
languageName: unknown
linkType: soft
"@expo/eas-json@npm:19.0.0, @expo/eas-json@workspace:packages/eas-json":
"@expo/eas-json@npm:19.1.0, @expo/eas-json@workspace:packages/eas-json":
version: 0.0.0-use.local
resolution: "@expo/eas-json@workspace:packages/eas-json"
dependencies:
"@babel/code-frame": "npm:7.23.5"
"@expo/eas-build-job": "npm:19.0.0"
"@expo/eas-build-job": "npm:19.1.0"
"@tsconfig/node20": "npm:20.1.8"
"@types/babel__code-frame": "npm:7.0.3"
"@types/fs-extra": "npm:11.0.4"
@@ -3484,11 +3484,11 @@ __metadata:
languageName: node
linkType: hard
"@expo/steps@npm:19.0.0, @expo/steps@workspace:packages/steps":
"@expo/steps@npm:19.1.0, @expo/steps@workspace:packages/steps":
version: 0.0.0-use.local
resolution: "@expo/steps@workspace:packages/steps"
dependencies:
"@expo/eas-build-job": "npm:19.0.0"
"@expo/eas-build-job": "npm:19.1.0"
"@expo/logger": "npm:19.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@jest/globals": "npm:^29.7.0"
@@ -3557,16 +3557,16 @@ __metadata:
resolution: "@expo/worker@workspace:packages/worker"
dependencies:
"@babel/core": "npm:7.24.7"
"@expo/build-tools": "npm:19.0.0"
"@expo/build-tools": "npm:19.1.0"
"@expo/config-plugins": "npm:55.0.7"
"@expo/config-types": "npm:55.0.5"
"@expo/downloader": "npm:19.0.0"
"@expo/eas-build-job": "npm:19.0.0"
"@expo/eas-build-job": "npm:19.1.0"
"@expo/logger": "npm:19.0.0"
"@expo/results": "npm:1.0.0"
"@expo/rudder-sdk-node": "npm:1.1.1"
"@expo/spawn-async": "npm:1.7.2"
"@expo/steps": "npm:19.0.0"
"@expo/steps": "npm:19.1.0"
"@expo/template-file": "npm:19.0.0"
"@expo/turtle-spawn": "npm:19.0.0"
"@hapi/boom": "npm:10.0.1"
@@ -10348,7 +10348,7 @@ __metadata:
resolution: "create-eas-build-function@workspace:packages/create-eas-build-function"
dependencies:
"@expo/package-manager": "npm:1.9.10"
"@expo/steps": "npm:19.0.0"
"@expo/steps": "npm:19.1.0"
"@jest/globals": "npm:^29.7.0"
"@types/jest": "npm:^29.5.12"
"@types/node": "npm:20.14.2"
@@ -10961,8 +10961,8 @@ __metadata:
version: 0.0.0-use.local
resolution: "eas-cli-local-build-plugin@workspace:packages/local-build-plugin"
dependencies:
"@expo/build-tools": "npm:19.0.0"
"@expo/eas-build-job": "npm:19.0.0"
"@expo/build-tools": "npm:19.1.0"
"@expo/eas-build-job": "npm:19.1.0"
"@expo/logger": "npm:19.0.0"
"@expo/spawn-async": "npm:^1.7.2"
"@expo/turtle-spawn": "npm:19.0.0"
@@ -11019,8 +11019,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:19.0.0"
"@expo/eas-json": "npm:19.0.0"
"@expo/eas-build-job": "npm:19.1.0"
"@expo/eas-json": "npm:19.1.0"
"@expo/env": "npm:^1.0.0"
"@expo/json-file": "npm:8.3.3"
"@expo/logger": "npm:19.0.0"
@@ -11035,7 +11035,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:19.0.0"
"@expo/steps": "npm:19.1.0"
"@expo/timeago.js": "npm:1.0.0"
"@graphql-codegen/cli": "npm:5.0.0"
"@graphql-codegen/introspection": "npm:4.0.0"