Compare commits

..
Author SHA1 Message Date
Stanisław Chmiela e507735166 [worker] Add a packaged startup wrapper 2026-10-22 13:17:51 +02:00
197 changed files with 2711 additions and 9990 deletions

No files matched your search

@@ -0,0 +1,6 @@
---
'@expo/eas-build-job': minor
'@expo/build-tools': patch
---
Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions.
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': none
---
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
+6
View File
@@ -0,0 +1,6 @@
---
'eas-cli': none
'@expo/build-tools': none
---
Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases).
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Stop sandboxes after an optional maximum duration measured from readiness.
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs.
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status.
+5
View File
@@ -0,0 +1,5 @@
---
'@expo/worker': patch
---
Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages.
+1
View File
@@ -4,6 +4,7 @@ on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: '0 8 * * *' # 8:00 AM UTC time every day
-81
View File
@@ -2,87 +2,6 @@
This is the log of notable changes to EAS CLI and related packages.
## [27.1.0](https://github.com/expo/eas-cli/releases/tag/v27.1.0) - 2026-10-28
### 🎉 New features
- [eas-cli] Display queued workflow runs: `workflow:run --wait` and `workflow:status` show a run waiting for its concurrency group and the run blocking it, and `workflow:cancel` includes queued runs in its selection list. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Upload the workflow files that `uses` jobs reference, including nested ones, when running a local workflow file with `eas workflow:run`. ([#4769](https://github.com/expo/eas-cli/pull/4769) by [@sswrk](https://github.com/sswrk))
### 🐛 Bug fixes
- [build-tools] Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Fail the command when EAS CLI cannot pull environment variables from EAS servers, instead of continuing without them. ([#4774](https://github.com/expo/eas-cli/pull/4774) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Format the generated root changelog before committing a release. ([#4773](https://github.com/expo/eas-cli/pull/4773) by [@gwdp](https://github.com/gwdp))
- [build-tools][worker] Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
- [build-tools] Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Honor the `--non-interactive` flag in `workflow:cancel`; it was parsed but never applied, so automation could hang on the interactive picker. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Stop reporting a finish time for workflow runs that have not finished: `workflow:runs` reports `finishedAt` only for runs in a final status. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
## [27.0.0](https://github.com/expo/eas-cli/releases/tag/v27.0.0) - 2026-10-27
### 🛠 Breaking changes
- [eas-cli] Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. ([#4592](https://github.com/expo/eas-cli/pull/4592) by [@vonovak](https://github.com/vonovak))
### 🎉 New features
- [eas-cli] Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`. ([#4734](https://github.com/expo/eas-cli/pull/4734) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Add `simulator:logs` to read and follow logs from an iOS simulator session and download saved logs after it stops. ([#4545](https://github.com/expo/eas-cli/pull/4545) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
- [build-tools] Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
- [build-tools] Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error. ([#4760](https://github.com/expo/eas-cli/pull/4760) by [@wschurman](https://github.com/wschurman))
- [eas-cli] Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error. ([#4591](https://github.com/expo/eas-cli/pull/4591) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Show account roles and credential permission limits in `eas login` when you are already logged in. ([#4759](https://github.com/expo/eas-cli/pull/4759) by [@wschurman](https://github.com/wschurman))
- [build-tools] Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
- [build-tools] Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
- [build-tools] Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
- [build-tools] Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
### 🧹 Chores
- [eas-cli] Run the GraphQL schema and generated code check on pull requests to any base branch, including stacked pull requests. ([#4763](https://github.com/expo/eas-cli/pull/4763) by [@wschurman](https://github.com/wschurman))
## [26.0.0](https://github.com/expo/eas-cli/releases/tag/v26.0.0) - 2026-10-26
### 🛠 Breaking changes
- [eas-build-job][build-tools] Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### 🎉 New features
- [eas-build-job][build-tools] Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
- [eas-json] Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`. ([#4732](https://github.com/expo/eas-cli/pull/4732) by [@wschurman](https://github.com/wschurman))
- [build-tools] Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### 🐛 Bug fixes
- [eas-cli] Regenerate graphql code. ([#4692](https://github.com/expo/eas-cli/pull/4692) by [@wschurman](https://github.com/wschurman))
- [build-tools] Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
- [build-tools] Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
- [build-tools] Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
- [eas-cli] Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6. ([`78ba760`](https://github.com/expo/eas-cli/commit/78ba76001b67c9971236af1a1d02e46c2f82d24a) by [@vonovak](https://github.com/vonovak))
- [build-tools][steps] Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
- [worker] Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors. ([#3452](https://github.com/expo/eas-cli/pull/3452) by [@sjchmiela](https://github.com/sjchmiela))
- [eas-cli] Share one GraphQL client between the project ID check and the command, so commands send one fewer app query. ([#4646](https://github.com/expo/eas-cli/pull/4646) by [@vonovak](https://github.com/vonovak))
- [eas-cli] Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs. ([#4694](https://github.com/expo/eas-cli/pull/4694) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli] Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. ([#4693](https://github.com/expo/eas-cli/pull/4693) by [@brentvatne](https://github.com/brentvatne))
- [worker] Mask secrets in every field of uploaded job log records, such as logged errors, not only in log messages. ([#4698](https://github.com/expo/eas-cli/pull/4698) by [@brentvatne](https://github.com/brentvatne))
### 🧹 Chores
- [eas-cli] Make command descriptions in `eas --help` consistently lowercase without trailing periods. ([#4385](https://github.com/expo/eas-cli/pull/4385) by [@brentvatne](https://github.com/brentvatne))
- [eas-cli][build-tools] Bump Node.js to 22.23.4 to fix the CVEs in the [October 2026 Node.js security releases](https://nodejs.org/en/blog/vulnerability/october-2026-security-releases). ([#4714](https://github.com/expo/eas-cli/pull/4714) by [@ramonclaudio](https://github.com/ramonclaudio))
## [25.3.0](https://github.com/expo/eas-cli/releases/tag/v25.3.0) - 2026-10-20
### 🎉 New features
+1 -1
View File
@@ -215,7 +215,7 @@ if: ${{ steps.previous_step.outputs.success == 'true' && env.ENVIRONMENT == 'pro
Artifacts tracked as `ArtifactToUpload`:
- Managed artifacts (APK, IPA, AAB) with specific handling
- Workflow artifacts for any file
- Generic artifacts for any file
- Upload via `ctx.uploadArtifact()` or `upload-artifact` step
## Package Interdependencies
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "26.0.0",
"PRODUCTION": "26.0.0"
"STAGING": "25.3.0",
"PRODUCTION": "25.3.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "27.1.0",
"version": "25.3.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
-38
View File
@@ -1,43 +1,5 @@
# @expo/build-tools
## 27.1.0
### Patch Changes
- Explain App Store Connect error 90189 as a duplicate build number. ([#3461](https://github.com/expo/eas-cli/pull/3461) by [@sjchmiela](https://github.com/sjchmiela))
- Put the `NPM_TOKEN` registry credential in the builder's npm user config on EAS Build (`~/.npmrc`, or the file `NPM_CONFIG_USERCONFIG` names), where pnpm 10.34.2+ and 11.5.3+ still expand `${NPM_TOKEN}` (they ignore it in a project-level `.npmrc`). Explain what to change when the project's pnpm version needs a newer Node.js than the build runs, including when eas.json sets `node` or `pnpm`. ([#4729](https://github.com/expo/eas-cli/pull/4729) by [@hSATAC](https://github.com/hSATAC))
- Install `@expo/repack-app` for the `eas/repack` step with npm in a sandbox that sees only `PATH` and its own `HOME`, so the build machine's npm and Yarn configuration cannot break the install, fetch it through the EAS npm cache when the job uses one, and show the installer output in the build log. The step no longer looks the version up separately before installing. When the npm cache fails, the install retries against the public registry like the project install does; the `eas/run_agent` agent CLI install shares the same helper and gains the same retry. ([#4728](https://github.com/expo/eas-cli/pull/4728) by [@hSATAC](https://github.com/hSATAC))
## 27.0.0
### Patch Changes
- Keep the launched app open when an Appium iOS session starts. ([#4699](https://github.com/expo/eas-cli/pull/4699) by [@gwdp](https://github.com/gwdp))
- Run Appium through the prepared Full Disk Access helper when available on macOS. ([#4622](https://github.com/expo/eas-cli/pull/4622) by [@gwdp](https://github.com/gwdp))
- Identify the build or application archive when a download fails, and explain when the archive is no longer available. ([#4703](https://github.com/expo/eas-cli/pull/4703) by [@gwdp](https://github.com/gwdp))
- Run apsd setup in the background without delaying iOS app launch or preview readiness. ([#4706](https://github.com/expo/eas-cli/pull/4706) by [@gwdp](https://github.com/gwdp))
- Handle a device session stopped during startup as normal cleanup instead of reporting a startup error. ([#4716](https://github.com/expo/eas-cli/pull/4716) by [@gwdp](https://github.com/gwdp))
- Support idle timeouts for iOS web-preview-only sessions, resetting the timer when preview input arrives. ([#4731](https://github.com/expo/eas-cli/pull/4731) by [@gwdp](https://github.com/gwdp))
## 26.0.0
### Major Changes
- Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### Minor Changes
- Stop sandboxes after an optional maximum duration measured from readiness. ([#4702](https://github.com/expo/eas-cli/pull/4702) by [@sjchmiela](https://github.com/sjchmiela))
- Unify signed URL uploading to a single implementation and use it everywhere. ([#4712](https://github.com/expo/eas-cli/pull/4712) by [@AHGIJMKLKKZNPJKQR](https://github.com/AHGIJMKLKKZNPJKQR))
### Patch Changes
- Default Android emulators to 4 GiB of RAM to reduce ADB disconnects during large transfers. Retain the 2 GiB default for API 34 and below outside interactive device sessions, and preserve explicit emulator configuration overrides. ([#4727](https://github.com/expo/eas-cli/pull/4727) by [@sjchmiela](https://github.com/sjchmiela))
- Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
- Retry transient failures when reporting that a device run session has started. ([#4680](https://github.com/expo/eas-cli/pull/4680) by [@gwdp](https://github.com/gwdp))
- Retry the build lookup and the archive download of `eas/download_build` on transient failures, and name the failure cause in the build log. ([#4690](https://github.com/expo/eas-cli/pull/4690) by [@hSATAC](https://github.com/hSATAC))
- Stop printing workflow hook parsing messages in build logs: native builds showed them under an "Unknown build phase" group, and custom builds warned about unknown hook keys and hooks on steps that never run. ([#4717](https://github.com/expo/eas-cli/pull/4717) by [@hSATAC](https://github.com/hSATAC))
## 25.3.0
### Minor Changes
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "27.1.0",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -42,17 +42,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "27.1.0",
"@expo/eas-build-job": "27.1.0",
"@expo/downloader": "25.3.0",
"@expo/eas-build-job": "25.3.0",
"@expo/env": "^0.4.0",
"@expo/logger": "27.1.0",
"@expo/logger": "25.3.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "27.1.0",
"@expo/template-file": "27.1.0",
"@expo/turtle-spawn": "27.1.0",
"@expo/steps": "25.3.0",
"@expo/template-file": "25.3.0",
"@expo/turtle-spawn": "25.3.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -125,31 +125,6 @@ describe(resolveBuildPhaseErrorAsync, () => {
expect(err.trackingCode).toBeUndefined();
});
it('explains that the pnpm version needs a newer Node.js than the build runs', async () => {
const fakeError = new Error();
const err = await resolveBuildPhaseErrorAsync(
fakeError,
[
'Running "pnpm install --frozen-lockfile" in /home/expo/workingdir/build directory',
'warn: This version of pnpm requires at least Node.js v22.13',
'warn: The current version of Node.js is v20.19.4',
'warn: Visit https://r.pnpm.io/comp to see the list of past pnpm versions with respective Node.js version support.',
'Error [ERR_UNKNOWN_BUILTIN_MODULE]: No such built-in module: node:sqlite',
],
{
job: { platform: Platform.ANDROID } as Job,
phase: BuildPhase.INSTALL_DEPENDENCIES,
env: {},
},
'/fake/path'
);
expect(err.errorCode).toBe('EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR');
expect(err.message).toBe(
'This build uses a pnpm version that requires Node.js 22.13.0 or newer, but runs Node.js 20.19.4. You can fix it by either:\n- setting "node" to "22.13.0" or a newer version in the build profile in eas.json\n- using a pnpm version that supports Node.js 20.19.4: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json'
);
expect(err.docsUrl).toBe('https://docs.expo.dev/eas/json/');
});
it('detects xcode line error', async () => {
vol.fromJSON({
'/path/to/xcodelogs.log': originalFs.readFileSync(
@@ -1,33 +0,0 @@
import { errors } from '@expo/eas-build-job';
import nullthrows from 'nullthrows';
import semver from 'semver';
import { ErrorHandler } from './errors.types';
// example log: "warn: This version of pnpm requires at least Node.js v22.13" followed by "warn: The current version of Node.js is v20.19.4"
const PNPM_UNSUPPORTED_NODE_VERSION_REGEXP =
/This version of pnpm requires at least Node\.js v([\d.]+)\n.*The current version of Node\.js is v([\d.]+)/;
function createPnpmUnsupportedNodeVersionError(matchResult: RegExpMatchArray): errors.UserError {
const [, requiredNodeVersion, currentNodeVersion] = matchResult;
const minimumNodeVersion = nullthrows(semver.coerce(requiredNodeVersion)).version;
return new errors.UserError(
'EAS_BUILD_PNPM_UNSUPPORTED_NODE_VERSION_ERROR',
`This build uses a pnpm version that requires Node.js ${minimumNodeVersion} or newer, but runs Node.js ${currentNodeVersion}. You can fix it by either:
- setting "node" to "${minimumNodeVersion}" or a newer version in the build profile in eas.json
- using a pnpm version that supports Node.js ${currentNodeVersion}: change "packageManager" in package.json if your project sets it, and set "pnpm" in the build profile in eas.json`,
{ docsUrl: 'https://docs.expo.dev/eas/json/' }
);
}
export const pnpmUnsupportedNodeVersionErrorHandler: ErrorHandler<errors.UserError> = {
regexp: PNPM_UNSUPPORTED_NODE_VERSION_REGEXP,
createError: createPnpmUnsupportedNodeVersionError,
};
export function detectPnpmUnsupportedNodeVersionError(
output: string
): errors.UserError | undefined {
const match = output.match(PNPM_UNSUPPORTED_NODE_VERSION_REGEXP);
return match ? createPnpmUnsupportedNodeVersionError(match) : undefined;
}
@@ -1,7 +1,6 @@
import { BuildMode, BuildPhase, Platform, Workflow, errors } from '@expo/eas-build-job';
import { ErrorHandler, XCODE_BUILD_PHASE } from './errors.types';
import { pnpmUnsupportedNodeVersionErrorHandler } from './pnpmUnsupportedNodeVersionError';
import UserError = errors.UserError;
@@ -212,7 +211,6 @@ You are seeing this error because either:
return undefined;
},
},
pnpmUnsupportedNodeVersionErrorHandler,
{
platform: Platform.IOS,
phase: XCODE_BUILD_PHASE,
@@ -16,13 +16,11 @@ function createCtx(
{ workingdir = '/tmp/wd' }: { workingdir?: string } = {}
): {
ctx: BuildContext<Ios.Job>;
info: jest.Mock;
warn: jest.Mock;
} {
const info = jest.fn();
const warn = jest.fn();
const logger = {
info,
info: jest.fn(),
debug: jest.fn(),
error: jest.fn(),
warn,
@@ -38,7 +36,7 @@ function createCtx(
workingdir,
}
);
return { ctx, info, warn };
return { ctx, warn };
}
// Project checkout lives at <workingdir>/build (job projectRootDirectory is '.').
@@ -81,31 +79,22 @@ describe(parseJobHooksAsync, () => {
});
});
it('writes nothing to the build log while parsing', async () => {
const { ctx, info, warn } = createCtx({
before_install_node_modules: [{ run: 'echo hi' }],
before_submit: [{ run: 'echo hi' }],
before_bogus: [{ run: 'echo hi' }],
});
await parseJobHooksAsync(ctx, INSTALL);
expect(info).not.toHaveBeenCalled();
expect(warn).not.toHaveBeenCalled();
});
it('treats an unknown hook key as inert', async () => {
const { ctx } = createCtx({
it('treats an unknown hook key as inert and warns', async () => {
const { ctx, warn } = createCtx({
before_bogus: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey).toEqual({});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('unknown hook key "before_bogus"'));
});
it('shape-checks a registered-but-unwrapped key, then skips it', async () => {
const { ctx } = createCtx({
it('shape-checks a registered-but-unwrapped key, then warns and skips it', async () => {
const { ctx, warn } = createCtx({
before_submit: [{ run: 'echo hi' }],
});
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey.before_submit).toBeUndefined();
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
});
it('shape-validates a registered-but-unwrapped key even though it is skipped', async () => {
@@ -203,9 +192,10 @@ describe(parseJobHooksAsync, () => {
});
it('never loads a composite function referenced under a registered-but-unwrapped key', async () => {
const { ctx } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
const { ctx, warn } = createCtx({ before_submit: [{ uses: './.eas/functions/missing' }] });
const parsed = await parseJobHooksAsync(ctx, INSTALL);
expect(parsed!.hookEntriesByKey).toEqual({});
expect(warn).toHaveBeenCalledWith(expect.stringContaining('hooks.before_submit'));
});
it('wraps a cross-key duplicate step id in the aggregate error', async () => {
@@ -12,7 +12,6 @@ import spawn from '@expo/turtle-spawn';
import { randomBytes, randomUUID } from 'crypto';
import { vol } from 'memfs';
import fetch, { Response } from 'node-fetch';
import { Readable } from 'stream';
import { setTimeout } from 'timers/promises';
import { createMockLogger } from '../../__tests__/utils/logger';
@@ -391,13 +390,7 @@ describe('projectSources', () => {
ctx.graphqlClient.mutation = mockGraphqlMutation as any;
const fetchMock = jest.mocked(fetch);
let uploadedBody = '';
fetchMock.mockImplementation(async (_url, request) => {
for await (const chunk of request!.body as Readable) {
uploadedBody += chunk;
}
return { ok: true } as Response;
});
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
fetchMock.mockResolvedValueOnce({
ok: true,
json: async () => ({ data: ctx.job.projectArchive }),
@@ -421,14 +414,10 @@ describe('projectSources', () => {
'https://storage.example.com/upload',
expect.objectContaining({
method: 'PUT',
headers: {
'x-custom-header': 'value',
'content-length': '63',
'content-type': 'text/plain;charset=UTF-8',
},
headers: { 'x-custom-header': 'value' },
body: '{"archiveContent":["project/README.md","project/package.json"]}',
})
);
expect(uploadedBody).toBe('{"archiveContent":["project/README.md","project/package.json"]}');
// Verify build metadata was updated
expect(mockGraphqlMutation).toHaveBeenNthCalledWith(
@@ -44,8 +44,9 @@ export async function runHookableBuildPhaseAsync<TJob extends BuildJob, T>({
if (hooks) {
try {
await runHookEntriesAsync({ ctx, hooks, timing: 'after', anchor, anchorFailed: true });
} catch {
// The phase error wins.
} catch (hookError) {
// The phase error wins; still surface the after-hook failure in the log.
ctx.logger.error({ err: hookError }, `after_${anchor} hook failed`);
}
}
throw phaseError;
@@ -4,7 +4,6 @@ import path from 'path';
import { BuildContext } from '../context';
import { Sentry } from '../sentry';
import { NpmCacheRegistryInstallError, isNpmCacheInstallFailure } from '../utils/npmCacheRegistry';
import { PackageManager, findPackagerRootDir } from '../utils/packageManager';
import { isUsingModernYarnVersion } from '../utils/project';
@@ -197,6 +196,10 @@ export function resolvePackagerDir(ctx: BuildContext<Job>): string {
return packagerRunDir;
}
function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
return getErrorOutput(err).includes(npmCacheUrl);
}
function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean {
return (
line.includes(npmCacheUrl) &&
@@ -204,9 +207,24 @@ function isNpmCacheRegistryErrorLine(line: string, npmCacheUrl: string): boolean
);
}
function getErrorOutput(err: unknown): string {
if (!(err instanceof Error)) {
return '';
}
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
return [stdout, stderr].filter(Boolean).join('\n');
}
class NpmCacheRegistryNonFatalError extends SystemError {
override name = 'NpmCacheRegistryNonFatalError';
constructor() {
super('Non-fatal npm cache registry error during dependency install');
}
}
class NpmCacheRegistryInstallError extends SystemError {
override name = 'NpmCacheRegistryInstallError';
constructor(cause: unknown) {
super('Failed to install dependencies using npm cache registry', { cause });
}
}
+16 -5
View File
@@ -53,17 +53,19 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
const globalContext = new BuildStepGlobalContext(customBuildContext, false);
// Hook steps run in the checked-out project directory, not the pre-checkout
// target directory the global context defaults to.
globalContext.markAsCheckedOut();
globalContext.markAsCheckedOut(ctx.logger);
const externalFunctions = getEasFunctions(customBuildContext);
const externalFunctionGroups = getEasFunctionGroups(customBuildContext);
// Shape-validate every REGISTERED key, reachable or not (steps-world parity:
// a malformed `before_submit` fails a steps build, so it fails here too). An
// unknown key is inert — a key newer than this worker must be ignored, never
// fail the job.
// a malformed `before_submit` fails a steps build, so it fails here too), and
// warn on keys this build won't run. An unknown key is inert — a key newer
// than this worker must be ignored, never fail the job.
for (const [key, steps] of Object.entries(hooks)) {
if (parseHookKey(key) === null) {
const parsed = parseHookKey(key);
if (parsed === null) {
ctx.logger.warn(`Ignoring unknown hook key "${key}".`);
continue;
}
if (Array.isArray(steps) && steps.length === 0) {
@@ -79,6 +81,15 @@ export async function parseJobHooksAsync<TJob extends BuildJob>(
{ cause: err }
);
}
// Registered but not wrapped by this build: shape-checked above, then
// skipped. Function existence is deliberately NOT checked (that happens only
// during construction) — an unknown `uses:` under an unwrapped key warns
// rather than failing the build.
if (!wrappedAnchors.includes(parsed.anchorId)) {
ctx.logger.warn(
`Ignoring "hooks.${key}": this build does not run the "${parsed.anchorId}" step.`
);
}
}
// Construct entries for the wrapped anchors in EXECUTION order (per anchor,
@@ -5,13 +5,13 @@ import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import nullthrows from 'nullthrows';
import path from 'path';
import { z } from 'zod';
import { shallowCloneRepositoryAsync } from './git';
import { BuildContext } from '../context';
import { uploadContentWithSignedUrl } from '../storage/uploadWithSignedUrl';
import { turtleFetch } from '../utils/turtleFetch';
export async function prepareProjectSourcesAsync<TJob extends Job>(
@@ -208,9 +208,10 @@ async function uploadProjectMetadataAsync(
headers: Record<string, string>;
};
await uploadContentWithSignedUrl({
signedUrl: uploadSession,
content: JSON.stringify({ archiveContent: sortedFiles }),
await fetch(uploadSession.url, {
method: 'PUT',
body: JSON.stringify({ archiveContent: sortedFiles }),
headers: uploadSession.headers,
});
const updateMetadataResult = await ctx.graphqlClient
+1 -6
View File
@@ -71,12 +71,7 @@ export async function setupAsync<TJob extends BuildJob>(
}
);
await setUpNpmrcAsync({
projectTargetDirectory: ctx.buildDirectory,
workingDirectory: ctx.getReactNativeProjectDirectory(),
env: ctx.env,
logger: ctx.logger,
});
await setUpNpmrcAsync(ctx, ctx.logger);
if (ctx.job.platform === Platform.IOS && ctx.env.EAS_BUILD_RUNNER === 'eas-build') {
await deleteXcodeEnvLocalIfExistsAsync(ctx as BuildContext<Ios.Job>);
}
+4 -2
View File
@@ -4,11 +4,13 @@ import {
BuildPhaseResult,
Env,
EnvironmentSecretType,
GenericArtifactType,
Job,
LogMarker,
ManagedArtifactType,
Metadata,
errors,
isGenericArtifact,
} from '@expo/eas-build-job';
import { BuildTrigger } from '@expo/eas-build-job/dist/common';
import { bunyan } from '@expo/logger';
@@ -40,7 +42,7 @@ export type ArtifactToUpload =
paths: string[];
}
| {
type: null;
type: GenericArtifactType;
name: string;
paths: string[];
metadata?: Record<string, unknown>;
@@ -239,7 +241,7 @@ export class BuildContext<TJob extends Job = Job> {
logger: bunyan;
}): Promise<{ artifactId: string | null }> {
const result = await this._uploadArtifact({ artifact, logger });
if (result.filename && artifact.type !== null) {
if (result.filename && !isGenericArtifact(artifact)) {
this.artifacts[artifact.type] = result.filename;
}
return { artifactId: result.artifactId ?? null };
+2 -11
View File
@@ -3,16 +3,8 @@ import HttpLogStream from './logging/HttpLogStream';
import RemoteLoggerStream from './logging/RemoteLoggerStream';
export { Builders, HttpLogStream, RemoteLoggerStream };
export {
SignedUrlUploadApiError,
SignedUrlUploadNetworkError,
uploadWithSignedUrl,
} from './storage/uploadWithSignedUrl';
export type {
SignedUrl,
UploadWithSignedUrlOptions,
UploadWithSignedUrlParams,
} from './storage/uploadWithSignedUrl';
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
export { downloadCacheAsync } from './steps/functions/restoreCache';
export { uploadCacheAsync } from './steps/functions/saveCache';
@@ -34,7 +26,6 @@ export { formatSecondsForLog } from './utils/formatDuration';
export { findAndUploadXcodeBuildLogsAsync } from './ios/xcodeBuildLogs';
export { Hook, runHookIfPresent } from './utils/hooks';
export { detectPnpmUnsupportedNodeVersionError } from './buildErrors/pnpmUnsupportedNodeVersionError';
export { parseGradleProfile, formatGradleProfileReport } from './android/gradleProfile';
export type { GradleProfileTask } from './android/gradleProfile';
@@ -56,10 +56,10 @@ class RemoteLoggerStream extends Writable {
return null;
}
public async init(): Promise<void> {
public async init(): Promise<string> {
this.fileHandle = await fs.open(this.temporaryLogsPath, 'w+', 0o660);
this.flushInterval = setInterval(() => this.flush(), this.options.uploadIntervalMs);
await this.flush(true);
return (await this.flush(true)) as string;
}
public async cleanUp(): Promise<void> {
@@ -121,18 +121,17 @@ class RemoteLoggerStream extends Writable {
}
}
private async flush(force = false): Promise<void> {
private async flush(force = false): Promise<string | void> {
if (force || this.hasChangesToUpload) {
if (this.uploadingPromise) {
await this.uploadingPromise;
await this.flush(force);
return;
return await this.flush(force);
}
await this.flushInternal();
return await this.flushInternal();
}
}
private async flushInternal(): Promise<void> {
private async flushInternal(): Promise<string | void> {
await this.safeWriteToFile();
let resolveFn: PromiseResolveFn;
@@ -140,17 +139,21 @@ class RemoteLoggerStream extends Writable {
this.uploadingPromise = new Promise(res => {
resolveFn = res;
});
await this.upload()
return await this.upload()
.then(result => {
return result;
})
.catch(err => {
this.logger.error({ err }, 'Failed to upload logs file');
})
.then(() => {
.then(result => {
this.uploadingPromise = undefined;
resolveFn();
return result;
});
}
private async upload(): Promise<void> {
private async upload(): Promise<string | void> {
if (!this.uploadMethod) {
return;
}
@@ -162,7 +165,7 @@ class RemoteLoggerStream extends Writable {
);
};
await uploadWithSignedUrl({
return await uploadWithSignedUrl({
signedUrl: this.uploadMethod.signedUrl,
srcGeneratorAsync,
});
@@ -48,10 +48,7 @@ describe(createCheckoutBuildFunction, () => {
});
it('moves project sources to the target directory when no inputs are given', async () => {
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const { globalCtx, executeAsync } = createCheckoutStep({
logger,
projectArchive: GIT_PROJECT_ARCHIVE,
});
@@ -59,9 +56,6 @@ describe(createCheckoutBuildFunction, () => {
expect(await vol.promises.readFile(`${projectTargetDirectory}/app.json`, 'utf8')).toBe('{}');
expect(globalCtx.wasCheckedOut()).toBe(true);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Changing default working directory to')
);
expect(fetchAndCheckoutRefAsync).not.toHaveBeenCalled();
});
@@ -102,11 +96,14 @@ describe(createCheckoutBuildFunction, () => {
});
it('rejects the ref input when the project is already checked out', async () => {
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const { globalCtx, executeAsync } = createCheckoutStep({
logger,
projectArchive: GIT_PROJECT_ARCHIVE,
callInputs: { ref: 'feature/add-icon' },
});
globalCtx.markAsCheckedOut();
globalCtx.markAsCheckedOut(logger);
await expect(executeAsync()).rejects.toMatchObject({
errorCode: 'EAS_CHECKOUT_REF_AFTER_CHECKOUT',
@@ -98,7 +98,7 @@ beforeEach(() => {
const events = { stopAsync: stopEvents, getLastEventObservedAt: () => undefined };
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
});
@@ -162,59 +162,6 @@ afterEach(() => {
});
describe('downloadBuild', () => {
it.each([404, 410])('identifies the unavailable build on HTTP %s', async status => {
const buildId = randomUUID();
const { Response: ActualResponse } =
jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest
.mocked(fetch)
.mockResolvedValue(new ActualResponse('<Error>NoSuchKey</Error>', { status }));
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient: createMockGraphqlClient({ applicationArchiveUrl: APPLICATION_ARCHIVE_URL }),
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
message:
`Failed to download the application archive for build ${buildId} (HTTP ${status}). ` +
'The archive is unavailable. Check that the archive still exists and try again.',
});
expect(fetch).toHaveBeenCalledTimes(1);
});
it.each([404, 410, 500])(
'identifies the application URL without credentials on HTTP %s',
async status => {
const { Response: ActualResponse } =
jest.requireActual<typeof import('node-fetch')>('node-fetch');
jest.mocked(fetch).mockResolvedValue(new ActualResponse('download failed', { status }));
const download = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl:
'https://user:[email protected]/app.tar.gz?token=secret#fragment',
graphqlClient: createMockGraphqlClient({}),
robotAccessToken: null,
extensions: ['app'],
});
const prefix = `Failed to download the archive at https://example.test/app.tar.gz (HTTP ${status})`;
await expect(download).rejects.toThrow(
status === 500
? `${prefix}: download failed`
: `${prefix}. The archive is unavailable. ` +
'Check that the URL is correct and the archive still exists, then try again.'
);
await expect(download).rejects.not.toThrow(/password|secret|fragment/);
expect(fetch).toHaveBeenCalledTimes(1);
}
);
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
@@ -1,4 +1,4 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import spawn from '@expo/turtle-spawn';
import fs from 'fs/promises';
import os from 'os';
@@ -305,6 +305,7 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name:
format === 'html' ? 'Maestro Runner HTML Report' : 'Maestro Runner Allure Results',
paths: [
@@ -1090,6 +1091,7 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: 'Maestro HTML Report',
paths: ['/home/expo/.maestro/tests/android-maestro-html.html'],
}),
@@ -1418,6 +1420,7 @@ describe('createMaestroTestsBuildFunction', () => {
expect(mockUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({
type: GenericArtifactType.OTHER,
name: shot.displayName,
metadata: shot.metadata,
}),
@@ -165,7 +165,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
expect(lifetime.aborted).toBe(false);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
@@ -176,16 +176,6 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
jest.restoreAllMocks();
});
it('releases resources without monitoring when the session stopped during startup', async () => {
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
await run();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(monitorLocalEgressAsync).not.toHaveBeenCalled();
expect(stopPreview).toHaveBeenCalledTimes(1);
expect(stopEgress).toHaveBeenCalledTimes(1);
expect(lifetime.aborted).toBe(true);
});
it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
await run();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
@@ -1,10 +1,8 @@
import { type bunyan } from '@expo/logger';
import turtleSpawnAsync from '@expo/turtle-spawn';
import assert from 'node:assert';
import fg from 'fast-glob';
import { vol } from 'memfs';
import path from 'node:path';
import resolveFrom from 'resolve-from';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createTestAndroidJob, createTestIosJob } from '../../../__tests__/utils/job';
@@ -19,100 +17,17 @@ import {
} from '../repack';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('@expo/spawn-async');
jest.mock('@expo/repack-app');
jest.mock('resolve-from', () => jest.fn(() => '@expo/repack-app'));
jest.mock('resolve-from', () => jest.fn((_, moduleName) => moduleName));
jest.mock('../../utils/ios/credentials/manager');
describe(createRepackBuildFunction, () => {
beforeEach(() => {
jest.mocked(turtleSpawnAsync).mockImplementation((async (_command: string, args: string[]) => {
const sandbox = args[args.indexOf('--prefix') + 1];
const packageName = args[args.length - 1].replace(/@[^@/]*$/, '');
vol.fromJSON({
[path.join(sandbox, 'node_modules', packageName, 'package.json')]: JSON.stringify({
version: '2.0.0-next.1',
}),
});
}) as unknown as typeof turtleSpawnAsync);
});
afterEach(() => {
jest.restoreAllMocks();
vol.reset();
});
it('installs the repack package with npm in a sandbox whose env is only PATH and HOME, through the npm cache registry', async () => {
const globalCtx = createGlobalContextMock({
staticContextContent: {
job: {},
},
});
globalCtx.updateEnv({
PATH: '/usr/local/bin:/usr/bin',
EAS_BUILD_NPM_CACHE_URL: 'https://npm-cache.expo.test',
NPM_TOKEN: 'secret',
});
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
platform: 'ios',
source_app_path: '/path/to/source_app.ipa',
repack_package: '@scope/fork',
repack_version: 'next',
},
});
const logInfo = jest.spyOn(repackStep.ctx.logger, 'info');
await repackStep.executeAsync();
const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls;
const npmEnv = installCall[2]?.env;
assert(npmEnv);
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']);
expect(npmEnv.PATH).toMatch(/\/usr\/local\/bin:\/usr\/bin$/);
expect(installCall).toEqual([
'npm',
[
'install',
'--prefix',
npmEnv.HOME,
'--no-save',
'--package-lock=false',
'--no-audit',
'--no-fund',
'--no-update-notifier',
'--registry',
'https://npm-cache.expo.test',
'@scope/fork@next',
],
{ env: npmEnv, logger: repackStep.ctx.logger },
]);
expect(logInfo).toHaveBeenCalledWith('Using repack from: @scope/fork@next (2.0.0-next.1)');
expect(resolveFrom).toHaveBeenCalledWith(npmEnv.HOME, '@scope/fork');
});
it('installs the repack package from the default registry when the job has no npm cache', async () => {
const globalCtx = createGlobalContextMock({
staticContextContent: {
job: {},
},
});
globalCtx.updateEnv({ PATH: '/usr/local/bin:/usr/bin' });
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(globalCtx, {
callInputs: {
platform: 'ios',
source_app_path: '/path/to/source_app.ipa',
},
});
await repackStep.executeAsync();
const [installCall] = jest.mocked(turtleSpawnAsync).mock.calls;
expect(installCall[1]).not.toContain('--registry');
});
it('should set generated output path for successful repack', async () => {
const repack = createRepackBuildFunction();
const repackStep = repack.createBuildStepFromFunctionCall(
@@ -103,7 +103,7 @@ describe(createRunAgentBuildFunction, () => {
);
const npmEnv = installMock.mock.calls[0][2]!.env!;
const agentEnv = spawnMock.mock.calls[0][2].env!;
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'PATH']);
expect(Object.keys(npmEnv).sort()).toEqual(['HOME', 'LANG', 'PATH', 'TMPDIR']);
expect(Object.keys(agentEnv).sort()).toEqual([
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC',
'CLAUDE_CODE_OAUTH_TOKEN',
@@ -103,18 +103,6 @@ async function runAsync(
}
describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
it('cleans up a stopped startup without starting live-session polling', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValueOnce('stopped');
await runAsync(logger, BuildRuntimePlatform.DARWIN);
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(pollAgentDeviceArtifactsForUploadAsync).not.toHaveBeenCalled();
expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled();
expect(mockDaemonStopAsync).toHaveBeenCalled();
expect(mockTunnelStopAsync).toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalled();
});
beforeEach(async () => {
jest.clearAllMocks();
@@ -159,7 +147,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
}
return host;
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
await fs.promises.mkdir(path.dirname(DAEMON_ENTRY_PATH), { recursive: true });
@@ -1,7 +1,6 @@
import { BuildRuntimePlatform, type BuildStepContext } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import fs from 'node:fs';
import path from 'node:path';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { type CustomBuildContext } from '../../../customBuildContext';
@@ -203,78 +202,6 @@ describe(installAppiumAsync, () => {
});
describe('createStartAppiumRemoteSessionBuildFunction', () => {
it.each([
[BuildRuntimePlatform.DARWIN, true],
[BuildRuntimePlatform.DARWIN, false],
[BuildRuntimePlatform.LINUX, true],
[BuildRuntimePlatform.LINUX, false],
])('starts Appium on %s with helper present=%s', async (runtimePlatform, helperPresent) => {
const helper = '/usr/local/libexec/turtle-tcc-fda';
if (helperPresent) {
fs.mkdirSync(path.dirname(helper), { recursive: true });
fs.writeFileSync(helper, 'prepared helper');
}
jest.mocked(spawn).mockResolvedValue({ stdout: '{}' } as never);
jest
.mocked(IosSimulatorUtils.getAvailableDevicesAsync)
.mockResolvedValue([{ udid: 'simulator' }] as never);
jest
.mocked(AndroidEmulatorUtils.getAttachedDevicesAsync)
.mockResolvedValue([{ serialId: 'emulator-5554', state: 'device' }] as never);
// Stop at the launch boundary; no readiness requests or remote session are needed.
const launched = new Error('launch captured');
jest.mocked(spawnDetached).mockImplementation(() => {
throw launched;
});
const env = {
DEVICE_RUN_SESSION_ID: 'session',
EAS_SIMULATOR_NGROK_TUNNEL_DOMAIN: 'example.test',
PATH: '/configured/node/bin:/usr/bin',
DEVELOPER_DIR: '/Applications/Xcode.app/Contents/Developer',
};
const buildFunction = createStartAppiumRemoteSessionBuildFunction({} as CustomBuildContext);
await expect(
buildFunction.fn!(
{ logger, global: { runtimePlatform } } as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: { ...env, NGROK_AUTHTOKEN: 'token' },
} as never
)
).rejects.toBe(launched);
expect(spawnDetached).toHaveBeenCalledTimes(1);
const launch = jest.mocked(spawnDetached).mock.calls[0][0];
const appiumBinPath = path.join(launch.env.APPIUM_HOME!, 'node_modules', '.bin', 'appium');
const useHelper = runtimePlatform === BuildRuntimePlatform.DARWIN && helperPresent;
expect(launch).toEqual({
command: useHelper ? helper : appiumBinPath,
args: [
...(useHelper ? [appiumBinPath] : []),
'--address',
'127.0.0.1',
'--port',
'4723',
'--base-path',
'/',
'--log-level',
'error',
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
runtimePlatform === BuildRuntimePlatform.DARWIN
? '{"appium:eventTimings":true,"appium:isHeadless":true}'
: '{"appium:eventTimings":true}',
],
env: { ...env, APPIUM_HOME: launch.env.APPIUM_HOME },
logger,
});
});
it('declares the launch inputs so serve-sim can launch the app', () => {
const ctx = {} as unknown as CustomBuildContext;
const buildFunction = createStartAppiumRemoteSessionBuildFunction(ctx);
@@ -434,7 +361,6 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
expect(IosSimulatorUtils.getDeviceAsync).not.toHaveBeenCalled();
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
'appium:eventTimings': true,
'appium:isHeadless': true,
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/legacy-wda.app',
});
@@ -521,7 +447,6 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
);
expect(JSON.parse(jest.mocked(spawnDetached).mock.calls[0][0].args.at(-1)!)).toEqual({
'appium:eventTimings': true,
'appium:isHeadless': true,
'appium:usePreinstalledWDA': true,
'appium:prebuiltWDAPath': '/tmp/selected-wda.app',
});
@@ -128,7 +128,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
getLastPreviewActivityAt: jest.fn(),
finishAsync: mockPreviewStopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
// The (real) tool-server state wait reads this file from the redirected ~/.argent.
@@ -84,52 +84,11 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
getLastPreviewActivityAt: jest.fn(),
finishAsync: stopAsync,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue('started');
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
stopAsync.mockResolvedValue(undefined);
});
it('uses preview activity to reset the configured idle timeout', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } });
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
const observedAt = new Date();
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'leaves the idle timer disabled when omitted on %s',
async runtimePlatform => {
await runAsync(runtimePlatform);
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
);
}
);
it('leaves the idle timer disabled for zero', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, {
max_idle_time_minutes: { value: 0 },
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith(
expect.objectContaining({ idleTimeout: undefined, maxDurationSeconds: 120 })
);
});
it('rejects an Android idle timeout before starting the host', async () => {
await expect(
runAsync(BuildRuntimePlatform.LINUX, {
max_idle_time_minutes: { value: 5 },
})
).rejects.toThrow('Omit max_idle_time_minutes for Android sessions.');
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
});
it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
@@ -309,7 +268,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
'frame_ancestors',
'cors_origins',
'package_version',
'max_idle_time_minutes',
'max_duration_seconds',
]);
});
@@ -121,32 +121,26 @@ describe(createUploadArtifactBuildFunction, () => {
expect(typeInput.isRawValueOneOfAllowedValues()).toBe(false);
});
it.each(['other', 'android-apk', 'android-aab', 'ios-simulator-app', 'ios-ipa'])(
'uploads %s as a workflow artifact with metadata',
async type => {
contextUploadArtifact.mockClear();
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(
createGlobalContextMock({}),
{
callInputs: {
type,
key: 'Screen Recording',
path: '/',
metadata,
},
}
);
it('passes the metadata input through to runtimeApi.uploadArtifact', async () => {
contextUploadArtifact.mockClear();
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
// Generic ('other') artifact: only generic artifacts persist metadata in the worker
// (the managed-type upload paths drop it), so that's the type metadata is meant for.
type: 'other',
key: 'Screen Recording',
path: '/',
metadata,
},
});
await buildStep.executeAsync();
await buildStep.executeAsync();
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({ type: null, metadata }),
})
);
}
);
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({ artifact: expect.objectContaining({ metadata }) })
);
});
it('does not attach metadata to managed artifact types (the worker drops it)', async () => {
contextUploadArtifact.mockClear();
@@ -1,15 +1,7 @@
import { UserError } from '@expo/eas-build-job';
import fs from 'fs-extra';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { AscApiClient } from '../../utils/ios/AscApiClient';
import { AscApiUtils } from '../../utils/ios/AscApiUtils';
import {
createUploadToAscBuildFunction,
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
isRedundantBinaryUploadError,
isSdkVersionIssueError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
@@ -53,20 +45,6 @@ describe(isInvalidBundleIdentifierError, () => {
});
});
describe(isRedundantBinaryUploadError, () => {
it('returns true when all errors are redundant-binary-upload codes', () => {
expect(isRedundantBinaryUploadError([{ code: '90189' }, { code: '90189' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isRedundantBinaryUploadError([{ code: '90189' }, { code: '90062' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isRedundantBinaryUploadError([])).toBe(false);
});
});
describe(isMissingPurposeStringError, () => {
it('returns true when all errors are missing-purpose-string codes', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
@@ -132,62 +110,3 @@ describe(parseMissingUsageDescriptionKeys, () => {
).toEqual([]);
});
});
describe(createUploadToAscBuildFunction, () => {
it('keeps Apple duplicate-build details in the public error and the full state as its cause', async () => {
const state = {
state: 'FAILED',
errors: [
{
code: '90189',
description: 'Redundant Binary Upload. Build number 1 was already uploaded.',
},
],
warnings: [{ code: 'WARNING', description: 'Apple warning.' }],
infos: [{ code: 'INFO', description: 'Apple information.' }],
};
await fs.writeFile('/app.ipa', 'ipa');
await fs.writeFile('/key.json', '{}');
jest.spyOn(AscApiUtils, 'loadApiKeyAsync').mockResolvedValue({} as never);
jest.spyOn(AscApiUtils, 'getAppInfoAsync').mockResolvedValue({
data: { attributes: { name: 'App', bundleId: 'com.example.app' } },
} as never);
jest.spyOn(AscApiUtils, 'createBuildUploadAsync').mockResolvedValue({
data: { id: 'upload-id' },
} as never);
jest.spyOn(AscApiUtils, 'commitBuildUploadFileAsync').mockResolvedValue(undefined as never);
jest.spyOn(AscApiClient.prototype, 'postAsync').mockResolvedValue({
data: { id: 'file-id', attributes: { uploadOperations: [] } },
} as never);
jest
.spyOn(AscApiClient.prototype, 'getAsync')
.mockResolvedValueOnce({
data: { attributes: { assetDeliveryState: { state: 'COMPLETE' } } },
} as never)
.mockResolvedValueOnce({ data: { attributes: { state } } } as never);
try {
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
createGlobalContextMock({ staticContextContent: { job: {} } }),
{
callInputs: {
ipa_path: '/app.ipa',
asc_api_key_path: '/key.json',
apple_app_identifier: 'app-id',
bundle_version: '1',
bundle_short_version: '1.0',
wait_for_processing: true,
},
}
);
const error = await step.executeAsync().catch(error => error);
expect(error).toBeInstanceOf(UserError);
expect(error.errorCode).toBe('EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE');
expect(error.cause).toEqual(state);
expect(error.toExternalExpoError().message).toContain(
'Redundant Binary Upload. Build number 1 was already uploaded. (90189)'
);
} finally {
jest.restoreAllMocks();
}
});
});
@@ -1,39 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { NpmrcAuthTokenLine } from '../../../templates/npmrc';
import { createSetUpNpmrcBuildFunction } from '../useNpmToken';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
const projectTargetDirectory = '/workingdir/project';
const homeDirectory = '/home/expo';
describe(createSetUpNpmrcBuildFunction, () => {
beforeEach(async () => {
vol.reset();
await vol.promises.mkdir(projectTargetDirectory, { recursive: true });
await vol.promises.mkdir(homeDirectory, { recursive: true });
jest.mocked(spawn).mockResolvedValue({ stdout: `${homeDirectory}/.npmrc\n` } as never);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('adds the NPM_TOKEN placeholder from the step env to ~/.npmrc on EAS Build', async () => {
const globalCtx = createGlobalContextMock({ projectTargetDirectory });
globalCtx.updateEnv({ NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'eas-build' });
const step = createSetUpNpmrcBuildFunction().createBuildStepFromFunctionCall(globalCtx, {
callInputs: {},
});
await step.executeAsync();
expect(await vol.promises.readFile(`${homeDirectory}/.npmrc`, 'utf8')).toContain(
NpmrcAuthTokenLine
);
});
});
@@ -58,11 +58,7 @@ export function createCheckoutBuildFunction(): BuildFunction {
});
}
const previousWorkingDirectory = stepsCtx.global.defaultWorkingDirectory;
stepsCtx.global.markAsCheckedOut();
stepsCtx.logger.info(
`Changing default working directory to ${stepsCtx.global.defaultWorkingDirectory} (was ${previousWorkingDirectory})`
);
stepsCtx.global.markAsCheckedOut(stepsCtx.logger);
},
});
}
@@ -312,22 +312,8 @@ export async function downloadBuildAsync(
const response = await fetch(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
const archiveUrl = new URL(downloadUrl);
const source = params.buildId
? `application archive for build ${params.buildId}`
: `archive at ${archiveUrl.origin}${archiveUrl.pathname}`;
if (response.status === 404 || response.status === 410) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_ARCHIVE_UNAVAILABLE',
`Failed to download the ${source} (HTTP ${response.status}). ` +
'The archive is unavailable. ' +
(params.buildId
? 'Check that the archive still exists and try again.'
: 'Check that the URL is correct and the archive still exists, then try again.')
);
}
throw new Error(
`Failed to download the ${source} (HTTP ${response.status}): ${textResult.value}`
`Unexpected response from server (${response.status}): ${textResult.value}`
);
}
const archiveFilename = resolveArchiveFilename({ response, extensions });
@@ -1,4 +1,4 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { GenericArtifactType, SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
@@ -631,7 +631,7 @@ export function createMaestroTestsBuildFunction(ctx: CustomBuildContext): BuildF
try {
await ctx.runtimeApi.uploadArtifact({
artifact: {
type: null,
type: GenericArtifactType.OTHER,
name: selectedReport.name,
paths: [selectedReport.artifactPath],
},
@@ -738,7 +738,7 @@ async function uploadFailureScreenshotsAsync({
await fs.copyFile(shot.fileAbsPath, safePath);
await ctx.runtimeApi.uploadArtifact({
artifact: {
type: null,
type: GenericArtifactType.OTHER,
name: shot.displayName,
paths: [safePath],
metadata: shot.metadata,
@@ -23,7 +23,7 @@ import resolveFrom from 'resolve-from';
import { COMMON_FASTLANE_ENV } from '../../common/fastlane';
import IosCredentialsManager from '../utils/ios/credentials/manager';
import { installIsolatedNpmPackageAsync } from '../../utils/installIsolatedNpmPackageAsync';
import { resolvePackageVersionAsync } from '../../utils/packageManager';
export function createRepackBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -121,12 +121,18 @@ export function createRepackBuildFunction(): BuildFunction {
: undefined;
const jsBundleOnly = (inputs.js_bundle_only.value as boolean | undefined) ?? false;
const resolvedRepackVersion =
(await resolvePackageVersionAsync({
logger: stepsCtx.logger,
packageName: inputs.repack_package.value as string,
distTag: inputs.repack_version.value as string,
})) ?? (inputs.repack_version.value as string);
stepsCtx.logger.info(
`Using repack from: ${inputs.repack_package.value}@${inputs.repack_version.value} (${resolvedRepackVersion})`
);
const repackApp = await installAndImportRepackAsync({
packageName: inputs.repack_package.value as string,
versionSpec: inputs.repack_version.value as string,
pathEnv: env.PATH,
npmCacheUrl: env.EAS_BUILD_NPM_CACHE_URL,
logger: stepsCtx.logger,
version: resolvedRepackVersion,
});
const { repackAppIosAsync, repackAppAndroidAsync } = repackApp;
@@ -220,28 +226,16 @@ function createOutputPath({
*/
async function installAndImportRepackAsync({
packageName,
versionSpec,
pathEnv,
npmCacheUrl,
logger,
version,
}: {
packageName: string;
versionSpec: string;
pathEnv: string | undefined;
npmCacheUrl: string | undefined;
logger: bunyan;
version: string;
}): Promise<typeof import('@expo/repack-app')> {
const sandbox = await fs.promises.mkdtemp(path.join(os.tmpdir(), `repack-package-root-`));
await installIsolatedNpmPackageAsync({
packageSpec: `${packageName}@${versionSpec}`,
directory: sandbox,
npmCacheUrl,
pathEnv,
logger,
await spawnAsync('yarn', ['add', `${packageName}@${version}`], {
stdio: 'inherit',
cwd: sandbox,
});
const packageJsonPath = path.join(sandbox, 'node_modules', packageName, 'package.json');
const { version } = JSON.parse(await fs.promises.readFile(packageJsonPath, 'utf8'));
logger.info(`Using repack from: ${packageName}@${versionSpec} (${version})`);
return require(resolveFrom(sandbox, packageName));
}
@@ -11,7 +11,6 @@ import path from 'path';
import * as tar from 'tar';
import z from 'zod';
import { uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
import { formatBytes } from '../../utils/artifacts';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import { getCacheVersion } from '../utils/cache';
@@ -162,13 +161,16 @@ export async function uploadCacheAsync({
logger.info(`Uploading cache...`);
await uploadWithSignedUrl(
{
signedUrl: { url, headers },
srcGeneratorAsync: async () => fs.createReadStream(archivePath),
},
{ retries: 3, retryIntervalMs: 100 }
);
const uploadResponse = await retryOnDNSFailure(fetch)(new URL(url), {
method: 'PUT',
headers,
body: fs.createReadStream(archivePath),
});
if (!uploadResponse.ok) {
throw new Error(
`Unexpected response from cache server (${uploadResponse.status}): ${uploadResponse.statusText}`
);
}
logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`);
}
@@ -224,13 +226,16 @@ export async function uploadPublicCacheAsync({
logger.info(`Uploading public cache...`);
await uploadWithSignedUrl(
{
signedUrl: { url, headers },
srcGeneratorAsync: async () => fs.createReadStream(archivePath),
},
{ retries: 3, retryIntervalMs: 100 }
);
const uploadResponse = await retryOnDNSFailure(fetch)(new URL(url), {
method: 'PUT',
headers,
body: fs.createReadStream(archivePath),
});
if (!uploadResponse.ok) {
throw new Error(
`Unexpected response from cache server (${uploadResponse.status}): ${uploadResponse.statusText}`
);
}
logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`);
}
@@ -225,7 +225,7 @@ export async function runAgentDeviceRemoteSessionAsync(
tasks.signal.throwIfAborted();
logger.info(tasks.summary());
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -239,9 +239,6 @@ export async function runAgentDeviceRemoteSessionAsync(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
tasks.signal.throwIfAborted();
void pollAgentDeviceArtifactsForUploadAsync(ctx, {
deviceRunSessionId,
@@ -65,7 +65,6 @@ const APPIUM_HOST = '127.0.0.1';
const APPIUM_PORT = 4723;
const APPIUM_STARTUP_TIMEOUT_MS = 120_000;
const DEFAULT_APPIUM_VERSION = '^3';
const FDA_HELPER = '/usr/local/libexec/turtle-tcc-fda';
const AppiumInstalledDriversSchema = z.record(
z.string(),
@@ -226,12 +225,9 @@ export function createStartAppiumRemoteSessionBuildFunction(
});
setupSignal?.throwIfAborted();
const useFdaHelper =
runtimePlatform === BuildRuntimePlatform.DARWIN && fs.existsSync(FDA_HELPER);
appiumProcess = spawnDetached({
command: useFdaHelper ? FDA_HELPER : appiumBinPath,
command: appiumBinPath,
args: [
...(useFdaHelper ? [appiumBinPath] : []),
'--address',
APPIUM_HOST,
'--port',
@@ -246,13 +242,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
'--allow-insecure',
'*:session_discovery',
'--default-capabilities',
JSON.stringify({
'appium:eventTimings': true,
// Without it, XCUITest restarted the booted Simulator at session start, which
// closed the app serve-sim had launched. EAS VMs never show the Simulator window.
...(device.platformName === 'iOS' ? { 'appium:isHeadless': true } : {}),
...wdaCapabilities,
}),
JSON.stringify({ 'appium:eventTimings': true, ...wdaCapabilities }),
],
env: appiumEnv,
logger,
@@ -288,7 +278,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
authtoken: ngrokAuthtoken,
});
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -306,9 +296,6 @@ export function createStartAppiumRemoteSessionBuildFunction(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -360,7 +360,7 @@ export function createStartArgentRemoteSessionBuildFunction(
: 'Private preview URL unavailable for this session.'
);
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -374,9 +374,6 @@ export function createStartArgentRemoteSessionBuildFunction(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -1,4 +1,3 @@
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -58,11 +57,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_idle_time_minutes',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
@@ -74,20 +68,9 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
const ngrokTunnelDomain = getNgrokTunnelDomainOrThrow(stepEnv);
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(stepEnv);
const env = omitNgrokAuthtoken(stepEnv);
const maxIdleTimeMinutes = inputs.max_idle_time_minutes?.value as number | undefined;
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | undefined;
const { runtimePlatform } = global;
if (
maxIdleTimeMinutes !== undefined &&
maxIdleTimeMinutes > 0 &&
runtimePlatform !== BuildRuntimePlatform.DARWIN
) {
throw new UserError(
'UNSUPPORTED_WEB_PREVIEW_IDLE_TIMEOUT',
'Web preview idle timeouts require iOS preview activity reporting. Omit max_idle_time_minutes for Android sessions.'
);
}
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
@@ -146,7 +129,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
? `Preview URL: ${webPreview.sharePageUrl} (server: ${webPreview.apiUrl}).`
: 'Private preview URL unavailable for this session.'
);
const sessionStart = await uploadRemoteSessionConfigWithLocalEgressAsync({
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
ctx,
@@ -164,9 +147,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
},
logger,
});
if (sessionStart === 'stopped') {
return;
}
await waitForDeviceRunSessionStoppedAsync({
ctx,
@@ -174,14 +154,6 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
logger,
maxDurationSeconds,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: () => undefined,
getLastPreviewActivityAt: () => sessionHost.getLastPreviewActivityAt(),
}
: undefined,
});
} finally {
await sessionHost.finishAsync();
@@ -1,4 +1,9 @@
import { Job, ManagedArtifactType } from '@expo/eas-build-job';
import {
GenericArtifactType,
Job,
ManagedArtifactType,
isGenericArtifact,
} from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
@@ -28,12 +33,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
ManagedArtifactType.APPLICATION_ARCHIVE,
ManagedArtifactType.BUILD_ARTIFACTS,
...Object.keys(artifactTypeInputToManagedArtifactType),
// Keep accepting these legacy artifact types for compatibility.
'android-apk',
'android-aab',
'ios-simulator-app',
'ios-ipa',
'other',
...Object.values(GenericArtifactType),
],
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -126,13 +126,12 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
paths: artifactPaths,
name: (inputs.name.value || inputs.key.value) as string,
};
const artifact =
artifactSpec.type === null
? {
...artifactSpec,
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
}
: artifactSpec;
const artifact = isGenericArtifact(artifactSpec)
? {
...artifactSpec,
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
}
: artifactSpec;
try {
const { artifactId } = await ctx.runtimeApi.uploadArtifact({ artifact, logger });
@@ -142,7 +141,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
}
} catch (error) {
if (inputs.ignore_error.value) {
logger.error({ err: error }, 'Failed to upload artifact. Ignoring error.');
logger.error({ err: error }, `Failed to upload ${artifact.type}. Ignoring error.`);
// Ignoring error.
return;
}
@@ -159,7 +158,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
* values. This makes sure we support all:
* - kebab-case managed artifact types (the original)
* - snake-caps-case managed artifact types (the mistake)
* - workflow artifact types.
* - generic artifact types.
*/
function parseArtifactTypeInput({
inputValue,
@@ -167,15 +166,19 @@ function parseArtifactTypeInput({
}: {
inputValue: string;
platform: Job['platform'];
}): ManagedArtifactType | null {
}): GenericArtifactType | ManagedArtifactType {
if (!inputValue) {
// In build jobs the default artifact type is application-archive.
return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : null;
return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : GenericArtifactType.OTHER;
}
return (
artifactTypeInputToManagedArtifactType[inputValue] ??
Object.values(ManagedArtifactType).find(type => type === inputValue) ??
null
);
// Step's allowedValues ensures input is either
// a key of artifactTypeInputToManagedArtifactType
// or a value of an artifact type.
const translatedManagedArtifactType = artifactTypeInputToManagedArtifactType[inputValue];
if (translatedManagedArtifactType) {
return translatedManagedArtifactType;
}
return inputValue as GenericArtifactType | ManagedArtifactType;
}
@@ -262,19 +262,6 @@ export function createUploadToAscBuildFunction(): BuildFunction {
}
);
}
if (isRedundantBinaryUploadError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_VERSION_DUPLICATE',
`Increment Build Number: Build number ${bundleVersion} for app version ${bundleShortVersion} has already been used. ` +
'App Store Connect requires unique build numbers within each app version (version train). ' +
'Increment it by setting ios.buildNumber in app.json, or set "autoIncrement": true in eas.json (recommended). Then rebuild and resubmit.' +
`\n\nApp Store Connect errors:\n${itemizeMessages(errors)}`,
{
docsUrl: 'https://docs.expo.dev/build-reference/app-versions/',
cause: state,
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
@@ -357,10 +344,6 @@ export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90725');
}
export function isRedundantBinaryUploadError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90189');
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
@@ -1,6 +1,9 @@
import { BuildFunction } from '@expo/steps';
import fs from 'fs-extra';
import path from 'path';
import { setUpNpmrcAsync } from '../../utils/npmrc';
import { NpmrcTemplate } from '../../templates/npmrc';
import { findPackagerRootDir } from '../../utils/packageManager';
export function createSetUpNpmrcBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -9,12 +12,28 @@ export function createSetUpNpmrcBuildFunction(): BuildFunction {
name: 'Use NPM_TOKEN',
__metricsId: 'eas/use_npm_token',
fn: async (stepCtx, { env }) => {
await setUpNpmrcAsync({
projectTargetDirectory: stepCtx.global.projectTargetDirectory,
workingDirectory: stepCtx.workingDirectory,
env,
logger: stepCtx.logger,
});
const { logger } = stepCtx;
if (env.NPM_TOKEN) {
logger.info('We detected that you set the NPM_TOKEN environment variable');
const projectNpmrcPath = path.join(stepCtx.global.projectTargetDirectory, '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info('.npmrc already exists in your project directory, skipping generation');
} else {
logger.info('Creating .npmrc in your project directory with the following contents:');
logger.info(NpmrcTemplate);
await fs.writeFile(projectNpmrcPath, NpmrcTemplate);
}
} else {
const projectNpmrcPath = path.join(findPackagerRootDir(stepCtx.workingDirectory), '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info(
`.npmrc found at ${path.relative(
stepCtx.global.projectTargetDirectory,
projectNpmrcPath
)}`
);
}
}
},
});
}
@@ -186,7 +186,6 @@ describe(runAgentAsync, () => {
'--package-lock=false',
'--no-audit',
'--no-fund',
'--no-update-notifier',
'--registry',
'https://npm-cache.expo.test',
'@anthropic-ai/[email protected]',
@@ -18,14 +18,7 @@ import {
} from '../deviceRunSessionPreview';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
jest.mock('node-fetch', () => {
const actual = jest.requireActual('node-fetch');
return {
__esModule: true,
...actual,
default: jest.fn(),
};
});
jest.mock('node-fetch');
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
@@ -94,8 +87,8 @@ describe(startDeviceRunSessionPreview, () => {
'https://uploads.expo.test/preview',
expect.objectContaining({
method: 'PUT',
body: expect.any(Readable),
headers: { 'content-type': 'image/webp', 'content-length': String(image.length) },
body: image,
headers: { 'content-type': 'image/webp' },
})
);
await jest.advanceTimersByTimeAsync(59_999);
@@ -137,7 +130,9 @@ describe(startDeviceRunSessionPreview, () => {
await jest.advanceTimersByTimeAsync(0);
expect(logger.warn).toHaveBeenCalledTimes(1);
const [fields] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
expect(fields.err.message).toBe('Signed URL upload failed: network error.');
expect(fields.err.message).toBe(
'Session preview upload request failed (name: FetchError, type: request-timeout).'
);
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('uploads.expo.test');
});
@@ -1,5 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { FetchError } from 'node-fetch';
import { CustomBuildContext } from '../../../customBuildContext';
import { turtleFetch } from '../../../utils/turtleFetch';
@@ -12,7 +11,6 @@ import {
} from '../deviceRunSessionWorkerApi';
jest.mock('../../../utils/turtleFetch');
jest.unmock('node-fetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
type Response = InstanceType<typeof Response>;
@@ -264,12 +262,12 @@ describe(DeviceRunSessionWorkerApi, () => {
it('rejects an application response without the build field', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { platform: 'ios' } }));
await expect(createApi().getApplicationAsync({ retries: 3 })).rejects.toThrow(
await expect(createApi().getApplicationAsync()).rejects.toThrow(
'GET /v2/device-run-session-worker/application returned an unexpected response'
);
});
it('passes the signal and timeout without enabling nested retries', async () => {
it('passes the signal, timeout, and retries to the request', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
const controller = new AbortController();
@@ -282,7 +280,7 @@ describe(DeviceRunSessionWorkerApi, () => {
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
expect.any(String),
'POST',
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 0 })
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 1 })
);
});
@@ -321,7 +319,7 @@ describe(DeviceRunSessionWorkerApi, () => {
);
const error = await createApi()
.getSessionAsync({ retries: 0 })
.getSessionAsync()
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
@@ -336,7 +334,7 @@ describe(DeviceRunSessionWorkerApi, () => {
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
const error = await createApi()
.getSessionAsync({ retries: 0 })
.getSessionAsync()
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
@@ -350,7 +348,7 @@ describe(DeviceRunSessionWorkerApi, () => {
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'STOPPED' } }));
await expect(createApi().getSessionAsync({ retries: 3 })).rejects.toThrow(
await expect(createApi().getSessionAsync()).rejects.toThrow(
'GET /v2/device-run-session-worker/session returned an unexpected response'
);
});
@@ -358,183 +356,8 @@ describe(DeviceRunSessionWorkerApi, () => {
it('rejects a successful response that is not JSON', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('ok', { status: 200 }));
await expect(createApi().getSessionAsync({ retries: 3 })).rejects.toThrow(
await expect(createApi().getSessionAsync()).rejects.toThrow(
'GET /v2/device-run-session-worker/session returned a response that is not JSON.'
);
});
});
describe('device run session worker API retries', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.mocked(turtleFetch).mockReset();
});
afterEach(() => {
jest.useRealTimers();
});
it.each([429, 500, 502, 503, 504])(
'recovers from HTTP %s when starting a session',
async status => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(new Response('unavailable', { status }))
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
const result = createApi().startAsync(
{ remoteConfig: { url: 'https://preview.test' } },
{ retries: 3, timeoutMs: 30_000 }
);
const assertion = expect(result).resolves.toEqual({ id: 'drs-id', status: 'in-progress' });
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(2);
expect(jest.mocked(turtleFetch).mock.calls[1]).toEqual(
jest.mocked(turtleFetch).mock.calls[0]
);
expect(jest.mocked(turtleFetch).mock.calls[0][2]).toMatchObject({
retries: 0,
timeout: 30_000,
});
}
);
it('recovers from a network failure', async () => {
jest
.mocked(turtleFetch)
.mockRejectedValueOnce(new Error('socket hang up'))
.mockResolvedValueOnce(jsonResponse({ data: { build: null } }));
const assertion = expect(createApi().getApplicationAsync({ retries: 3 })).resolves.toEqual({
build: null,
});
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(2);
});
it('exhausts three retries with exponential backoff', async () => {
jest
.mocked(turtleFetch)
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
const assertion = expect(
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
).rejects.toMatchObject({
status: 503,
});
await jest.advanceTimersByTimeAsync(0);
expect(turtleFetch).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1000);
expect(turtleFetch).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(2000);
expect(turtleFetch).toHaveBeenCalledTimes(3);
await jest.advanceTimersByTimeAsync(4000);
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(4);
});
it.each([400, 401, 403, 404, 409])('does not retry HTTP %s', async status => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('refused', { status }));
await expect(
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
).rejects.toMatchObject({ status });
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it.each([
['status polling', (api: DeviceRunSessionWorkerApi) => api.getSessionAsync()],
['stop', (api: DeviceRunSessionWorkerApi) => api.stopAsync()],
[
'screenshot application metadata',
(api: DeviceRunSessionWorkerApi) => api.getApplicationAsync(),
],
['event log setup', (api: DeviceRunSessionWorkerApi) => api.createEventLogUploadSessionAsync()],
['preview setup', (api: DeviceRunSessionWorkerApi) => api.createPreviewUploadSessionAsync()],
[
'TURN without an explicit retry option',
(api: DeviceRunSessionWorkerApi) => api.fetchTurnIceServersAsync(),
],
])('does not retry %s by default', async (_name, call) => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
await expect(call(createApi())).rejects.toMatchObject({ status: 503 });
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it('honors an explicit retry count', async () => {
jest
.mocked(turtleFetch)
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
const assertion = expect(
createApi().fetchTurnIceServersAsync({ retries: 1 })
).rejects.toMatchObject({ status: 503 });
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(2);
});
it('does not send another request after cancellation during backoff', async () => {
const controller = new AbortController();
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
const assertion = expect(
createApi().getApplicationAsync({ signal: controller.signal, retries: 3 })
).rejects.toMatchObject({ name: 'AbortError' });
await jest.advanceTimersByTimeAsync(0);
controller.abort();
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
});
describe('device run session retry safety', () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
});
it('does not replay artifact creation after a transient failure', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
await expect(
createApi().createArtifactUploadSessionAsync({
name: 'report',
filename: 'report.json',
size: 1,
})
).rejects.toMatchObject({ status: 503 });
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it('does not send an already cancelled request', async () => {
const controller = new AbortController();
controller.abort();
await expect(
createApi().getApplicationAsync({ signal: controller.signal, retries: 3 })
).rejects.toMatchObject({
name: 'AbortError',
});
expect(turtleFetch).not.toHaveBeenCalled();
});
it.each(['system', 'body-timeout'])(
'retries a response body transport failure (%s)',
async type => {
jest.useFakeTimers();
try {
const response = jsonResponse({});
jest
.spyOn(response, 'json')
.mockRejectedValueOnce(new FetchError('connection interrupted', type));
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(response)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
const assertion = expect(
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
).resolves.toMatchObject({ status: 'in-progress' });
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
}
);
});
@@ -151,11 +151,10 @@ describe('with a device run session token', () => {
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('retries a transient failure when starting the session through the worker API', async () => {
it('starts the session through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'Service unavailable' }] }, 503))
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
await uploadRemoteSessionConfigAsync({
@@ -166,57 +165,9 @@ describe('with a device run session token', () => {
});
expectWorkerApiCall('start', 'POST', { json: { remoteConfig: { type: 'agent-device' } } });
expect(turtleFetch).toHaveBeenCalledTimes(2);
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('handles the structured stopped code without matching the message', async () => {
const ctx = createCtx({ withToken: true });
jest.mocked(turtleFetch).mockResolvedValueOnce(
jsonResponse(
{
errors: [
{ code: 'DEVICE_RUN_SESSION_ALREADY_STOPPED', message: 'A different explanation.' },
],
},
400
)
);
await expect(
uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: {},
logger,
})
).resolves.toBe('stopped');
expect(turtleFetch).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
'Device run session drs-id was stopped during startup.'
);
});
it.each([undefined, 'VALIDATION_ERROR'])('preserves an ordinary 400 with code %s', async code => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(
jsonResponse(
{ errors: [{ code, message: 'Device run session has already stopped.' }] },
400
)
);
await expect(
uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId: 'drs-id',
remoteConfig: {},
logger,
})
).rejects.toBeInstanceOf(SystemError);
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it('fails the start with a system error that does not contain the token', async () => {
const ctx = createCtx({ withToken: true });
jest
@@ -262,7 +213,7 @@ describe('with a device run session token', () => {
'--turn-credential',
'credential',
]);
expectWorkerApiCall('turn-ice-servers', 'POST', { timeout: 5000, retries: 0 });
expectWorkerApiCall('turn-ice-servers', 'POST', { timeout: 5000, retries: 1 });
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
});
@@ -411,9 +362,7 @@ describe('with a device run session token', () => {
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
})
).rejects.toThrow(
`Failed to download the application archive for build ${BUILD_ID} (HTTP 500): stop here`
);
).rejects.toThrow('Unexpected response from server (500): stop here');
expectWorkerApiCall('application', 'GET');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
@@ -638,9 +587,7 @@ describe('without a device run session token', () => {
robotAccessToken: ROBOT_ACCESS_TOKEN,
extensions: ['app'],
})
).rejects.toThrow(
`Failed to download the application archive for build ${BUILD_ID} (HTTP 500): stop here`
);
).rejects.toThrow('Unexpected response from server (500)');
expect(ctx.graphqlClient.query).toHaveBeenCalled();
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
@@ -1413,7 +1413,7 @@ it('does not read the iOS egress handoff for an Android host', async () => {
});
it.each([true, false])(
'runs setup in parallel with app launch and readiness, then joins it at teardown (app: %s)',
'waits for setup before launch or boot-only readiness (app: %s)',
async hasApp => {
const setup = deferred<void>();
const prepared = deferred<ServeSimApplicationOptions | void>();
@@ -1468,78 +1468,70 @@ it.each([true, false])(
? { installAppPath: '/tmp/App.app', launchAppIdentifier: 'dev.example.other' }
: undefined
);
if (hasApp) {
while (!jest.mocked(runServeSimActionAsync).mock.calls.length) {
await new Promise(resolve => setTimeout(resolve, 10));
if (!hasApp) {
let queried = false;
ready.resolve({
ok: true,
json: async () => {
queried = true;
return { status: 'ready', device: 'emulator-5554' };
},
} as never);
let reported = false;
void starting.then(() => {
reported = true;
});
while (!queried) {
await new Promise(resolve => setImmediate(resolve));
}
await new Promise(resolve => setImmediate(resolve));
expect(runServeSimActionAsync).toHaveBeenCalledTimes(2);
} else {
expect(reported).toBe(false);
expect(runServeSimActionAsync).not.toHaveBeenCalled();
setup.resolve();
const host = await starting;
expect(runServeSimActionAsync).not.toHaveBeenCalled();
await host.finishAsync();
return;
}
while (!jest.mocked(runServeSimActionAsync).mock.calls.length) {
await new Promise(resolve => setTimeout(resolve, 10));
}
await new Promise(resolve => setImmediate(resolve));
expect(runServeSimActionAsync).toHaveBeenCalledTimes(1);
setup.resolve();
while (jest.mocked(runServeSimActionAsync).mock.calls.length < 2) {
await new Promise(resolve => setTimeout(resolve, 10));
}
expect(
jest
.mocked(runServeSimActionAsync)
.mock.calls.map(([options]) => [options.action, options.params])
).toEqual(
hasApp
? [
['app.install', { udid: 'emulator-5554', path: `${directory}/App.app` }],
[
'app.launch',
{
udid: 'emulator-5554',
bundleId: 'dev.example.other',
launchArgs: [],
openUrl: undefined,
},
],
]
: []
).toEqual([
['app.install', { udid: 'emulator-5554', path: `${directory}/App.app` }],
[
'app.launch',
{
udid: 'emulator-5554',
bundleId: 'dev.example.other',
launchArgs: [],
openUrl: undefined,
},
],
]);
expect(runServeSimActionAsync).toHaveBeenCalledWith(
expect.objectContaining({ token: 'preview-token', timeoutMs: 120_000 })
);
expect(stageServeSimAppAsync).toHaveBeenCalledWith(
'/tmp/App.app',
env.TMPDIR ?? require('node:os').tmpdir()
);
if (hasApp) {
expect(runServeSimActionAsync).toHaveBeenCalledWith(
expect.objectContaining({ token: 'preview-token', timeoutMs: 120_000 })
);
expect(stageServeSimAppAsync).toHaveBeenCalledWith(
'/tmp/App.app',
env.TMPDIR ?? require('node:os').tmpdir()
);
}
let queried = false;
ready.resolve({
ok: true,
json: async () => {
queried = true;
return { status: 'ready', device: 'emulator-5554' };
},
json: async () => ({ status: 'ready', device: 'emulator-5554' }),
} as never);
let reported = false;
void starting.then(() => {
reported = true;
});
while (!queried) {
await new Promise(resolve => setImmediate(resolve));
}
await new Promise(resolve => setImmediate(resolve));
try {
expect(reported).toBe(true);
const host = await starting;
let finished = false;
const finishing = host.finishAsync().then(() => {
finished = true;
});
await new Promise(resolve => setImmediate(resolve));
expect(finished).toBe(false);
setup.resolve();
await finishing;
} finally {
setup.resolve();
await (await starting).finishAsync();
}
if (hasApp) {
await expect(access(directory)).rejects.toThrow();
}
const host = await starting;
await host.finishAsync();
await expect(access(directory)).rejects.toThrow();
}
);
@@ -1684,8 +1676,7 @@ it('warns and continues launching when disabling push fails', async () => {
await host.finishAsync();
});
it('cancels pending app preparation and observes late setup failure', async () => {
const application = deferred<ServeSimApplicationOptions>();
it('cancels pending setup without launching and observes its late failure', async () => {
const controller = new AbortController();
let rejectSetup!: (error: Error) => void;
jest.mocked(IosSimulatorUtils.disableApsdAsync).mockReturnValue(
@@ -1699,7 +1690,7 @@ it('cancels pending app preparation and observes late setup failure', async () =
logger,
timeoutMs: 2_000,
iosSimulatorUdid: 'emulator-5554',
application: application.promise,
launchAppIdentifier: 'dev.example.app',
signal: controller.signal,
});
const rejected = expect(starting).rejects.toThrow('session canceled');
@@ -2,14 +2,6 @@ jest.unmock('fs');
jest.unmock('fs/promises');
jest.unmock('node:fs');
jest.unmock('node:fs/promises');
jest.mock('node-fetch', () => {
const actual = jest.requireActual('node-fetch');
return {
__esModule: true,
...actual,
default: jest.fn(),
};
});
import { SandboxDaemonErrorCode, SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
@@ -318,7 +310,7 @@ describe('sandbox artifact uploads', () => {
expect(logger.error).toHaveBeenCalledTimes(1);
const logged = inspect([logger.info.mock.calls, logger.error.mock.calls], { depth: 10 });
expect(fetch).toHaveBeenCalledTimes(3);
expect(logged).toContain('Signed URL upload failed: ECONNRESET.');
expect(logged).toContain('Artifact PUT failed: ECONNRESET.');
expect(logged).not.toContain('secret-signature');
});
@@ -1,5 +1,6 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import turtleSpawn from '@expo/turtle-spawn';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import fs from 'node:fs/promises';
@@ -12,7 +13,6 @@ import { type AgentCli, type AgentInvocation, type AgentLogMessage } from './age
import { CLAUDE_CODE_CLI, prepareClaudeCodeAsync } from './agents/claude';
import { CODEX_CLI, prepareCodexAsync } from './agents/codex';
import { Sentry } from '../../sentry';
import { installIsolatedNpmPackageAsync } from '../../utils/installIsolatedNpmPackageAsync';
import { killProcessGroup } from '../../utils/processes';
export type AgentKind = 'claude-code' | 'codex';
@@ -76,13 +76,17 @@ export async function runAgentAsync({
const { packageSpec, bin, formatOutputLine } = AGENT_CLIS[agentKind];
logger.info(`Installing ${packageSpec}.`);
try {
await installIsolatedNpmPackageAsync({
packageSpec,
directory: cliDirectory,
npmCacheUrl,
pathEnv,
signal,
});
await turtleSpawn(
'npm',
[
'install',
...['--prefix', cliDirectory],
...['--no-save', '--package-lock=false', '--no-audit', '--no-fund'],
...(npmCacheUrl ? ['--registry', npmCacheUrl] : []),
packageSpec,
],
{ env: minimalEnv, signal }
);
} catch (error) {
signal.throwIfAborted();
throw new SystemError(
@@ -0,0 +1,120 @@
import { SystemError } from '@expo/eas-build-job';
import fetch, { Headers } from 'node-fetch';
import type { Readable } from 'node:stream';
import { finished } from 'node:stream/promises';
import { setTimeout as delay } from 'node:timers/promises';
import { AbortUtils } from '../../utils/AbortUtils';
export type ArtifactUploadSession = { url: string; headers: unknown };
export type ArtifactSource = { openStream: () => Readable };
export async function putArtifactWithRetriesAsync(
{
uploadSession,
source,
signal,
}: {
uploadSession: ArtifactUploadSession;
source: ArtifactSource;
signal?: AbortSignal;
},
{ attempts = 1 }: { attempts?: number } = {}
): Promise<void> {
for (let attempt = 0; attempt < attempts; attempt++) {
signal?.throwIfAborted();
const stream = source.openStream();
// Upload time scales with file size, so the deadline bounds stalls, not the whole request.
const timeout = AbortUtils.resettableTimeout(90_000);
try {
await putArtifactAsync({
uploadSession,
stream,
signal: signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal,
onProgress: timeout.resetDeadline,
});
return;
} catch (error) {
signal?.throwIfAborted();
const retryable =
error instanceof ArtifactPutError
? [408, 429, 500, 502, 503, 504].includes(error.status)
: timeout.signal.aborted || error instanceof ArtifactPutNetworkError;
if (!retryable || attempt + 1 === attempts) {
throw error;
}
await delay(500 * (attempt + 1), undefined, { signal });
} finally {
stream.destroy();
await finished(stream).catch(() => {});
}
}
}
async function putArtifactAsync({
uploadSession,
stream,
signal,
onProgress,
}: {
uploadSession: ArtifactUploadSession;
stream: Readable;
signal: AbortSignal;
onProgress: () => void;
}): Promise<void> {
const requestController = new AbortController();
try {
const responding = fetch(uploadSession.url, {
method: 'PUT',
headers: new Headers(uploadSession.headers as Record<string, string>),
body: stream,
signal: AbortSignal.any([signal, requestController.signal]),
});
// fetch has piped the body already, so this listener observes flow without starting it.
stream.on('data', onProgress);
let response;
try {
response = await responding;
} catch (error) {
signal.throwIfAborted();
throw error instanceof Error && error.name === 'FetchError'
? new ArtifactPutNetworkError(error)
: error;
}
signal.throwIfAborted();
if (!response.ok) {
throw new ArtifactPutError(response);
}
} finally {
// node-fetch destroys the request body on abort, and its response stream is a PassThrough
// whose destruction leaves the socket open, so the request itself is aborted here.
requestController.abort();
}
}
export class ArtifactPutError extends SystemError {
readonly status: number;
readonly reason: string;
constructor(response: { status: number; statusText: string }) {
const reason = `HTTP ${response.status} ${response.statusText}`;
super(`Artifact PUT failed: ${reason}.`);
this.status = response.status;
this.reason = reason;
}
}
// node-fetch names the request URL in its message, and here that URL is signed. The message keeps
// only the error code, and the original is not kept as the cause because loggers and Sentry print causes.
export class ArtifactPutNetworkError extends SystemError {
readonly code: string | undefined;
readonly reason: string;
constructor(error: Error & { code?: unknown }) {
const code = typeof error.code === 'string' ? error.code : undefined;
const reason = code ?? 'network error';
super(`Artifact PUT failed: ${reason}.`);
this.code = code;
this.reason = reason;
}
}
@@ -1,13 +1,13 @@
import { SystemError } from '@expo/eas-build-job';
import type { Readable } from 'node:stream';
import { CustomBuildContext } from '../../customBuildContext';
import {
type SignedUrl,
SignedUrlUploadApiError,
SignedUrlUploadNetworkError,
uploadWithSignedUrl,
} from '../../storage/uploadWithSignedUrl';
ArtifactPutError,
ArtifactPutNetworkError,
type ArtifactSource,
type ArtifactUploadSession,
putArtifactWithRetriesAsync,
} from './artifactPut';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
@@ -23,11 +23,7 @@ type ArtifactMetadata = {
export async function uploadDeviceRunSessionArtifactAsync(
ctx: CustomBuildContext,
options: ArtifactMetadata & {
openStream: () => Readable;
signal?: AbortSignal;
attempts?: number;
}
options: ArtifactMetadata & ArtifactSource & { signal?: AbortSignal; attempts?: number }
): Promise<void> {
try {
const uploadSession = await withDeviceRunSessionTimeoutAsync(
@@ -35,12 +31,12 @@ export async function uploadDeviceRunSessionArtifactAsync(
async signal => await createDeviceRunSessionArtifactUploadSessionAsync(ctx, options, signal)
);
options.signal?.throwIfAborted();
await uploadWithSignedUrl(
{ signedUrl: uploadSession, srcGeneratorAsync: async () => options.openStream() },
{ signal: options.signal, retries: (options.attempts ?? 1) - 1, retryIntervalMs: 500 }
await putArtifactWithRetriesAsync(
{ uploadSession, source: options, signal: options.signal },
{ attempts: options.attempts }
);
} catch (error) {
if (error instanceof SignedUrlUploadApiError || error instanceof SignedUrlUploadNetworkError) {
if (error instanceof ArtifactPutError || error instanceof ArtifactPutNetworkError) {
throw new DeviceRunSessionArtifactUploadError(options.artifactId, error.reason);
}
throw error;
@@ -57,9 +53,9 @@ async function createDeviceRunSessionArtifactUploadSessionAsync(
ctx: CustomBuildContext,
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
signal: AbortSignal
): Promise<SignedUrl> {
): Promise<ArtifactUploadSession> {
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
let uploadSession: SignedUrl;
let uploadSession: ArtifactUploadSession;
try {
uploadSession = await workerApi.createArtifactUploadSessionAsync(
{
@@ -2,6 +2,7 @@ import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { BuildRuntimePlatform, type BuildStepEnv } from '@expo/steps';
import spawn, { type SpawnOptions } from '@expo/turtle-spawn';
import fetch from 'node-fetch';
import { createWriteStream } from 'node:fs';
import { pipeline } from 'node:stream/promises';
import { mkdtemp, readFile, rm } from 'node:fs/promises';
@@ -9,7 +10,6 @@ import os from 'node:os';
import path from 'node:path';
import { type CustomBuildContext } from '../../customBuildContext';
import { uploadContentWithSignedUrl } from '../../storage/uploadWithSignedUrl';
import { killProcessGroup } from '../../utils/processes';
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
@@ -17,6 +17,31 @@ const PREVIEW_INTERVAL_MS = 60_000;
const PREVIEW_MAX_DIMENSION = 320;
const PREVIEW_QUALITY = 70;
const MAX_PREVIEW_SIZE_BYTES = 5 * 1024 * 1024;
/**
* node-fetch includes the request URL in its error messages, and this URL is a signed upload URL
* that is valid for the whole session. Replace such errors with one that names only the failure.
*/
async function putPreviewAsync(
uploadSession: { url: string; headers: Record<string, string> },
{ image, signal }: { image: Buffer; signal: AbortSignal }
): Promise<Awaited<ReturnType<typeof fetch>>> {
try {
return await fetch(uploadSession.url, {
method: 'PUT',
headers: uploadSession.headers,
body: image,
signal,
timeout: 30_000,
});
} catch (err) {
const { name, type, code } = err as { name?: unknown; type?: unknown; code?: unknown };
const details = Object.entries({ name, type, code })
.filter(([, value]) => typeof value === 'string' && value.length > 0)
.map(([key, value]) => `${key}: ${value}`)
.join(', ');
throw new Error(`Session preview upload request failed (${details || 'unknown error'}).`);
}
}
export function startDeviceRunSessionPreview({
ctx,
@@ -50,10 +75,10 @@ export function startDeviceRunSessionPreview({
if (controller.signal.aborted) {
return;
}
await uploadContentWithSignedUrl(
{ signedUrl: uploadSession, content: image },
{ signal, retries: 0, stallTimeoutMs: 30_000 }
);
const response = await putPreviewAsync(uploadSession, { image, signal });
if (!response.ok) {
throw new Error(`Session preview upload failed: ${response.status} ${response.statusText}`);
}
} catch (err) {
if (!controller.signal.aborted) {
logger.warn({ err }, 'Could not refresh the session preview; retrying in 60 seconds.');
@@ -1,11 +1,9 @@
import { type EnvironmentSecret, EnvironmentSecretType, SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import { FetchError } from 'node-fetch';
import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import { promiseRetryWithCondition } from '../../utils/promiseRetryWithCondition';
import { turtleFetch } from '../../utils/turtleFetch';
/**
@@ -77,7 +75,7 @@ type RequestOptions = {
signal?: AbortSignal;
/** node-fetch timeout of each attempt, in milliseconds. */
timeoutMs?: number;
/** Additional attempts for transient failures. Defaults to none; critical callers opt in. */
/** Additional attempts after a failed one. Defaults to none, so each call sends one request. */
retries?: number;
logger?: bunyan;
};
@@ -91,8 +89,7 @@ export class DeviceRunSessionWorkerApiError extends Error {
message: string,
/** The HTTP status of the response, or undefined when no response arrived. */
readonly status: number | undefined,
options?: ErrorOptions,
readonly code?: string
options?: ErrorOptions
) {
super(message, options);
this.name = 'DeviceRunSessionWorkerApiError';
@@ -192,44 +189,16 @@ export class DeviceRunSessionWorkerApi {
}
private async requestAsync<T>(
method: 'GET' | 'POST',
route: string,
dataSchema: z.ZodType<T>,
{ retries = 0, ...options }: RequestOptions & { json?: Record<string, unknown> }
): Promise<T> {
const request = () => {
options.signal?.throwIfAborted();
return this.requestOnceAsync(method, route, dataSchema, options);
};
if (retries === 0) {
return await request();
}
return await promiseRetryWithCondition(
request,
error =>
!options.signal?.aborted &&
error instanceof DeviceRunSessionWorkerApiError &&
(error.status === undefined || error.status === 429 || error.status >= 500),
{ retries, factor: 2, minTimeout: 1000 },
({ attemptNumber, maxAttemptsCount }) =>
options.logger?.warn(
`Retrying ${method} /v2/${DEVICE_RUN_SESSION_WORKER_PATH}${route} (attempt ${attemptNumber}/${maxAttemptsCount}).`
)
)();
}
private async requestOnceAsync<T>(
method: 'GET' | 'POST',
route: string,
dataSchema: z.ZodType<T>,
{
signal,
timeoutMs,
retries = 0,
logger,
json,
}: Omit<RequestOptions, 'retries'> & {
json?: Record<string, unknown>;
}
}: RequestOptions & { json?: Record<string, unknown> }
): Promise<T> {
const routeDescription = `${method} /v2/${DEVICE_RUN_SESSION_WORKER_PATH}${route}`;
const url = new URL(
@@ -243,7 +212,7 @@ export class DeviceRunSessionWorkerApi {
...(json ? { json } : {}),
...(signal ? { signal } : {}),
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
retries: 0,
retries,
shouldThrowOnNotOk: false,
logger,
})
@@ -268,21 +237,10 @@ export class DeviceRunSessionWorkerApi {
: response.statusText;
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} failed with status ${response.status}: ${apiErrorMessage}`,
response.status,
undefined,
apiError?.success ? apiError.data.errors[0]?.code : undefined
response.status
);
}
if (!bodyResult.ok) {
if (
bodyResult.reason instanceof FetchError &&
['system', 'body-timeout'].includes(bodyResult.reason.type)
) {
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} failed while reading the response body.`,
undefined
);
}
throw new DeviceRunSessionWorkerApiError(
`${routeDescription} returned a response that is not JSON.`,
response.status
@@ -577,7 +577,7 @@ async function startDeviceSessionHostInternalAsync(
}
let stagedAppDirectory: string | undefined;
let simulatorSetup: ReturnType<typeof asyncResult<void>> | undefined;
let beforeLaunch: ReturnType<typeof asyncResult<void>> | undefined;
const readinessController = new AbortController();
const readinessSignal = AbortSignal.any([startup.signal, readinessController.signal]);
let previewToken: string | undefined;
@@ -670,7 +670,7 @@ async function startDeviceSessionHostInternalAsync(
onStopped: successful => onFinished(successful && !previewFailed),
})
.finally(async () => {
await simulatorSetup;
await beforeLaunch;
if (stagedAppDirectory) {
await fs.promises.rm(stagedAppDirectory, { recursive: true, force: true });
}
@@ -711,8 +711,7 @@ async function startDeviceSessionHostInternalAsync(
);
}
signal?.throwIfAborted();
// APSD cleanup runs alongside app startup and readiness; teardown joins the task.
simulatorSetup = asyncResult(
beforeLaunch = asyncResult(
startup.untilAborted(
startup.run('Simulator setup', async taskLogger => {
try {
@@ -750,6 +749,7 @@ async function startDeviceSessionHostInternalAsync(
signal?.throwIfAborted();
await action('app.install', { udid: iosSimulatorUdid, path: staged.path });
}
(await beforeLaunch)?.enforceValue();
if (app.launchAppIdentifier) {
signal?.throwIfAborted();
await action('app.launch', {
@@ -31,17 +31,14 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
}: Parameters<typeof uploadRemoteSessionConfigAsync>[0] & {
env: BuildStepEnv;
signal?: AbortSignal;
}): Promise<'started' | 'stopped'> {
}): Promise<void> {
// Written by start_local_egress before simulator boot; absent for ordinary sessions.
const localEgress = await readLocalEgressHandoffAsync();
signal?.throwIfAborted();
const sessionStart = await uploadRemoteSessionConfigAsync({
await uploadRemoteSessionConfigAsync({
...options,
remoteConfig: { ...options.remoteConfig, ...buildEgressRemoteConfigFields(localEgress) },
});
if (sessionStart === 'stopped') {
return sessionStart;
}
if (localEgress && !signal?.aborted) {
// Guard refusals from here on show up under this step in the job log,
// alongside the monitor's reports, instead of under the boot step.
@@ -60,5 +57,4 @@ export async function uploadRemoteSessionConfigWithLocalEgressAsync({
...(localEgress.platform === 'android' ? { scanSimulatorConnections: false } : {}),
});
}
return sessionStart;
}
@@ -31,7 +31,6 @@ import { createProcessOutput } from './processOutput';
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
import {
type DeviceRunSessionStatus,
DeviceRunSessionWorkerApiError,
requireDeviceRunSessionWorkerApi,
} from './deviceRunSessionWorkerApi';
@@ -555,26 +554,14 @@ export async function uploadRemoteSessionConfigAsync({
deviceRunSessionId: string;
remoteConfig: Record<string, unknown>;
logger: bunyan;
}): Promise<'started' | 'stopped'> {
}): Promise<void> {
logger.info(
`Reporting remote config to the API server (device run session: ${deviceRunSessionId}).`
);
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
try {
await workerApi.startAsync(
{ remoteConfig },
{ retries: 3, timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS, logger }
);
return 'started';
await workerApi.startAsync({ remoteConfig });
} catch (err) {
if (
err instanceof DeviceRunSessionWorkerApiError &&
err.code === 'DEVICE_RUN_SESSION_ALREADY_STOPPED'
) {
logger.info(`Device run session ${deviceRunSessionId} was stopped during startup.`);
return 'stopped';
}
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${
err instanceof Error ? err.message : String(err)
@@ -14,8 +14,12 @@ import { performance } from 'node:perf_hooks';
import { Readable } from 'node:stream';
import { setTimeout as delay } from 'node:timers/promises';
import { SignedUrlUploadApiError, uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import {
ArtifactPutError,
type ArtifactUploadSession,
putArtifactWithRetriesAsync,
} from './artifactPut';
const CREATE_SANDBOX_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateSandboxArtifactUploadSession(
@@ -234,23 +238,18 @@ async function putSandboxArtifactAsync({
}: {
filePath: string;
size: number;
uploadSession: { url: string; headers: unknown };
uploadSession: ArtifactUploadSession;
signal: AbortSignal;
}): Promise<void> {
const openStream = (): Readable =>
size === 0 ? Readable.from([]) : createReadStream(filePath, { start: 0, end: size - 1 });
try {
await uploadWithSignedUrl(
{
signedUrl: {
url: uploadSession.url,
headers: uploadSession.headers as Record<string, string>,
},
srcGeneratorAsync: async () =>
size === 0 ? Readable.from([]) : createReadStream(filePath, { start: 0, end: size - 1 }),
},
{ signal, retries: 2, retryIntervalMs: 500 }
await putArtifactWithRetriesAsync(
{ uploadSession, source: { openStream }, signal },
{ attempts: 3 }
);
} catch (error) {
if (!(error instanceof SignedUrlUploadApiError && error.status === 412)) {
if (!(error instanceof ArtifactPutError && error.status === 412)) {
throw error;
}
}
@@ -1,254 +1,325 @@
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import { performance } from 'node:perf_hooks';
import { Readable } from 'node:stream';
import { randomBytes } from 'crypto';
import fetch, { RequestInit, Response } from 'node-fetch';
import { Readable } from 'stream';
import {
SignedUrlUploadApiError,
SignedUrlUploadNetworkError,
uploadContentWithSignedUrl,
uploadWithSignedUrl,
} from '../uploadWithSignedUrl';
import { type SignedUrl, uploadWithSignedUrl } from '../uploadWithSignedUrl';
jest.unmock('node-fetch');
jest.mock('node-fetch');
let handle: (request: IncomingMessage, response: ServerResponse) => void | Promise<void>;
let server: ReturnType<typeof createServer>;
let origin: string;
let signedUrl: { url: string; headers: Record<string, string> };
let requests: number;
class ErrorWithCode extends Error {
protected readonly _code: string | undefined;
async function readBodyAsync(request: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(chunk);
constructor(message: string, code?: string) {
super(message);
if (code) {
this._code = code;
}
}
public get code(): string | undefined {
return this._code;
}
return Buffer.concat(chunks).toString();
}
function respondWithStatuses(statuses: number[]): void {
handle = async (request, response) => {
await readBodyAsync(request);
response.statusCode = statuses[requests - 1];
response.end();
class DNSError extends ErrorWithCode {
protected readonly _code: 'ENOTFOUND' | 'EAI_AGAIN' = 'ENOTFOUND';
}
const TEST_BUCKET = 'turtle-v2-test';
function createSignedUrl(key: string, contentType: string): SignedUrl {
return {
url: `https://storage.googleapis.com/${TEST_BUCKET}/${key}?signature=test`,
headers: { 'content-type': contentType },
};
}
beforeEach(async () => {
requests = 0;
server = createServer((request, response) => {
requests++;
void handle(request, response);
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Missing test server address');
}
origin = `http://127.0.0.1:${address.port}`;
signedUrl = { url: `${origin}/bucket/object?signature=secret-signature`, headers: {} };
});
describe('signed URL uploader', () => {
describe('uploadWithSignedUrl', () => {
it('should throw an error if upload fails', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: false,
status: 500,
} as Response;
afterEach(async () => {
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
});
const key = 'cat.jpg';
describe(uploadWithSignedUrl, () => {
it('sends the full body on every attempt', async () => {
const bodies: string[] = [];
handle = async (request, response) => {
bodies.push(await readBodyAsync(request));
response.statusCode = bodies.length === 1 ? 503 : 200;
response.end();
};
const payload = '*'.repeat(128 * 1024);
const signedUrl = createSignedUrl(key, 'image/jpeg');
await uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync: async () => Readable.from([Buffer.from(payload)]) },
{ retryIntervalMs: 0 }
);
expect(bodies).toEqual([payload, payload]);
});
it('waits longer after each failed attempt', async () => {
respondWithStatuses([503, 503, 200]);
const startedAt = performance.now();
await uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync: async () => Readable.from(['body']) },
{ retryIntervalMs: 100 }
);
expect(requests).toBe(3);
expect(performance.now() - startedAt).toBeGreaterThanOrEqual(290);
});
it('does not retry a client error', async () => {
handle = async (request, response) => {
await readBodyAsync(request);
response.statusCode = 403;
response.end('<Error><Code>AccessDenied</Code></Error>');
};
const error = await uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync: async () => Readable.from(['body']) },
{ retryIntervalMs: 0 }
).catch(err => err);
expect(error).toBeInstanceOf(SignedUrlUploadApiError);
expect(error).toMatchObject({
status: 403,
reason: 'HTTP 403 Forbidden',
message: 'Signed URL upload failed: HTTP 403 Forbidden.',
});
expect(requests).toBe(1);
});
it('retries a dropped connection', async () => {
handle = async (request, response) => {
if (requests === 1) {
request.socket.destroy();
return;
}
await readBodyAsync(request);
response.end();
};
const srcGeneratorAsync = jest.fn(async () => Readable.from(['body']));
await uploadWithSignedUrl({ signedUrl, srcGeneratorAsync }, { retryIntervalMs: 0 });
expect(srcGeneratorAsync).toHaveBeenCalledTimes(2);
expect(requests).toBe(2);
});
it('retries a refused connection and keeps the signed URL out of the error', async () => {
await new Promise<void>(resolve => server.close(() => resolve()));
const srcGeneratorAsync = jest.fn(async () => Readable.from(['body']));
const error = await uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync },
{ retries: 1, retryIntervalMs: 0 }
).catch(err => err);
expect(error).toBeInstanceOf(SignedUrlUploadNetworkError);
expect(error).toMatchObject({ code: 'ECONNREFUSED' });
expect(error.message).not.toContain('secret-signature');
expect(srcGeneratorAsync).toHaveBeenCalledTimes(2);
});
it('does not retry a network error outside the retryable codes', async () => {
const srcGeneratorAsync = jest.fn(async () => Readable.from(['body']));
const error = await uploadWithSignedUrl(
{
signedUrl: { ...signedUrl, url: signedUrl.url.replace('http:', 'https:') },
srcGeneratorAsync,
},
{ retryIntervalMs: 0 }
).catch(err => err);
expect(error).toBeInstanceOf(SignedUrlUploadNetworkError);
expect(error).toMatchObject({ code: 'EPROTO' });
expect(srcGeneratorAsync).toHaveBeenCalledTimes(1);
});
it('propagates a source error without retrying, even with a retryable code', async () => {
const sourceError = Object.assign(new Error('read failed'), { code: 'ECONNRESET' });
const srcGeneratorAsync = jest.fn(async (): Promise<Readable> => {
throw sourceError;
fetchMock.mockImplementation(async () => res);
await expect(
uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
})
).rejects.toThrow();
});
await expect(
uploadWithSignedUrl({ signedUrl, srcGeneratorAsync }, { retryIntervalMs: 0 })
).rejects.toBe(sourceError);
expect(srcGeneratorAsync).toHaveBeenCalledTimes(1);
expect(requests).toBe(0);
});
it('should return stripped URL if successful', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
it('retries an attempt that stalls past the stall timeout', async () => {
handle = async (request, response) => {
await readBodyAsync(request);
if (requests > 1) {
response.end();
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
fetchMock.mockImplementation(async () => res);
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
});
it('should retry upload on DNS error up to 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
fetchMock
.mockImplementationOnce(async () => {
throw new DNSError('failed once');
})
.mockImplementationOnce(async () => {
throw new DNSError('failed twice', 'EAI_AGAIN');
})
.mockImplementation(async () => res);
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should retry upload on retriable status codes error up to 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
fetchMock
.mockImplementationOnce(async () => {
return {
ok: false,
status: 503,
} as Response;
})
.mockImplementationOnce(async () => {
return {
ok: false,
status: 408,
} as Response;
})
.mockImplementation(async () => res);
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should retry upload on retriable status codes and DNS errors error up to 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
fetchMock
.mockImplementationOnce(async () => {
return {
ok: false,
status: 503,
} as Response;
})
.mockImplementationOnce(async () => {
throw new DNSError('failed once');
})
.mockImplementation(async () => res);
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should not retry upload on DNS error more than 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
const lastDNSError = new DNSError('failed thrice');
fetchMock
.mockImplementationOnce(async () => {
throw new DNSError('failed once');
})
.mockImplementationOnce(async () => {
throw new DNSError('failed twice', 'EAI_AGAIN');
})
.mockImplementationOnce(async () => {
throw lastDNSError;
})
.mockImplementation(async () => res);
await expect(
uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
})
).rejects.toThrow(lastDNSError);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should not retry upload on retriable status code more than 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
fetchMock
.mockImplementationOnce(async () => {
return {
ok: false,
status: 503,
} as Response;
})
.mockImplementationOnce(async () => {
return {
ok: false,
status: 408,
} as Response;
})
.mockImplementationOnce(async () => {
return {
ok: false,
status: 504,
} as Response;
})
.mockImplementation(async () => res);
await expect(
uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
})
).rejects.toThrow();
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should not retry upload on other error codes', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
const nonDNSError = new ErrorWithCode('failed once', 'A_DIFFERENT_CODE');
fetchMock
.mockImplementationOnce(async () => {
throw nonDNSError;
})
.mockImplementation(async () => res);
await expect(
uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
})
).rejects.toThrow(nonDNSError);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('retries upload with full stream', async () => {
const fetchMock = jest.mocked(fetch);
const receivedBodies: Buffer[] = [];
const recordBody = async (request: RequestInit | undefined): Promise<void> => {
let body = Buffer.from([]);
for await (const chunk of request!.body as Readable) {
body = Buffer.concat([body, chunk]);
}
receivedBodies.push(body);
};
fetchMock
.mockImplementationOnce(async (_path, request) => {
await recordBody(request);
return {
ok: false,
status: 503,
} as Response;
})
.mockImplementationOnce(async (_path, request) => {
await recordBody(request);
throw new DNSError('failed once');
})
.mockImplementation(async (_path, request) => {
await recordBody(request);
return {
ok: true,
status: 201,
} as Response;
});
const signedUrl = createSignedUrl('text.txt', 'text/plain');
const bufferToUpload = randomBytes(16);
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from(bufferToUpload),
retryIntervalMs: 0,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/text.txt');
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(receivedBodies.length).toBe(3);
for (const body of receivedBodies) {
// Here we're testing that each body received in every retry is the same.
// If we passed the same body instance to each of the retries
// each retry would consume a chunk of the same stream,
// causing the uploaded file to be corrupted (missing first bytes).
expect(body).toStrictEqual(bufferToUpload);
}
};
const streams: Readable[] = [];
const srcGeneratorAsync = async (): Promise<Readable> => {
const stream = Readable.from(['body']);
streams.push(stream);
return stream;
};
await uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync },
{ retryIntervalMs: 0, stallTimeoutMs: 100 }
);
expect(requests).toBe(2);
expect(streams.every(stream => stream.destroyed)).toBe(true);
});
it('stops waiting for the next attempt when aborted', async () => {
const controller = new AbortController();
handle = async (request, response) => {
await readBodyAsync(request);
response.statusCode = 503;
response.end();
setTimeout(() => controller.abort(), 50);
};
await expect(
uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync: async () => Readable.from(['body']) },
{ signal: controller.signal, retryIntervalMs: 60_000 }
)
).rejects.toMatchObject({ name: 'AbortError' });
expect(requests).toBe(1);
});
});
describe(uploadContentWithSignedUrl, () => {
let received: IncomingMessage | undefined;
let body: string;
beforeEach(() => {
received = undefined;
handle = async (request, response) => {
received = request;
body = await readBodyAsync(request);
response.end();
};
});
it('counts the content length in bytes', async () => {
await uploadContentWithSignedUrl({ signedUrl, content: 'zażółć' });
expect(received?.headersDistinct['content-length']).toEqual(['10']);
expect(body).toBe('zażółć');
});
it('keeps a content length that the signed headers already set', async () => {
signedUrl.headers = { 'Content-Length': '5' };
await uploadContentWithSignedUrl({ signedUrl, content: 'hello' });
expect(received?.headersDistinct['content-length']).toEqual(['5']);
});
it('labels only string content as plain text', async () => {
await uploadContentWithSignedUrl({ signedUrl, content: 'hello' });
expect(received?.headersDistinct['content-type']).toEqual(['text/plain;charset=UTF-8']);
await uploadContentWithSignedUrl({ signedUrl, content: Buffer.from('hello') });
expect(received?.headersDistinct['content-type']).toBeUndefined();
});
it('keeps a content type that the signed headers already set', async () => {
signedUrl.headers = { 'Content-Type': 'application/json' };
await uploadContentWithSignedUrl({ signedUrl, content: '{}' });
expect(received?.headersDistinct['content-type']).toEqual(['application/json']);
});
});
});
+70
View File
@@ -0,0 +1,70 @@
import { Response } from 'node-fetch';
type RetryOptions = {
retries: number;
retryIntervalMs: number;
shouldRetryOnError: (error: unknown) => boolean;
shouldRetryOnResponse: (response: Response) => boolean;
};
// based on https://github.com/googleapis/nodejs-storage/blob/8ab50804fc7bae3bbd159bbb4adf65c02215b11b/src/storage.ts#L284-L320
export async function retryOnUploadFailure(
fn: (attemptCount: number) => Promise<Response>,
{
retries,
retryIntervalMs,
}: { retries: RetryOptions['retries']; retryIntervalMs: RetryOptions['retryIntervalMs'] }
): Promise<Response> {
return await retry(fn, {
retries,
retryIntervalMs,
shouldRetryOnError: err => {
return (
isErrorWithCode(err) &&
(err.code === 'ENOTFOUND' ||
err.code === 'EAI_AGAIN' ||
err.code === 'ECONNRESET' ||
err.code === 'ETIMEDOUT' ||
err.code === 'EPIPE')
);
},
shouldRetryOnResponse: resp => {
return [408, 429, 500, 502, 503, 504].includes(resp.status);
},
});
}
function isErrorWithCode(error: unknown): error is { code: string } {
return typeof error === 'object' && error !== null && 'code' in error;
}
/**
* Wrapper used to execute an inner function and possibly retry it if it throws an error
* @param fn Function to be executed and retried in case of error
* @param retries How many times at most should the function be retried
* @param retryIntervalMs Time interval between the retries
* @param shouldRetryOnError Function that determines if the function should be retried based on the error thrown
* @param shouldRetryOnResponse Function that determines if the function should be retried based on the response
*/
async function retry(
fn: (attemptCount: number) => Promise<Response>,
{ retries, retryIntervalMs, shouldRetryOnError, shouldRetryOnResponse }: RetryOptions
): Promise<Response> {
let attemptCount = -1;
for (;;) {
try {
attemptCount += 1;
const resp = await fn(attemptCount);
if (attemptCount < retries && shouldRetryOnResponse(resp)) {
await new Promise(res => setTimeout(res, retryIntervalMs));
} else {
return resp;
}
} catch (err: unknown) {
if (attemptCount === retries || !shouldRetryOnError(err)) {
throw err;
}
await new Promise(res => setTimeout(res, retryIntervalMs));
}
}
}
@@ -1,11 +1,8 @@
import { SystemError } from '@expo/eas-build-job';
import fetch, { FetchError } from 'node-fetch';
import { Readable } from 'node:stream';
import { finished } from 'node:stream/promises';
import { setTimeout as delay } from 'node:timers/promises';
import { Readable } from 'stream';
import { URL } from 'url';
import { AbortUtils } from '../utils/AbortUtils';
import { isConnectFailure, isConnectionInterruptedError } from '../utils/networkErrors';
import { retryOnUploadFailure } from './retry';
export type SignedUrl = {
url: string;
@@ -15,140 +12,46 @@ export type SignedUrl = {
export type UploadWithSignedUrlParams = {
signedUrl: SignedUrl;
srcGeneratorAsync: () => Promise<Readable>;
};
export type UploadWithSignedUrlOptions = {
signal?: AbortSignal;
retries?: number;
retryIntervalMs?: number;
stallTimeoutMs?: number;
retries?: number;
};
export async function uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync }: UploadWithSignedUrlParams,
{
signal,
retries = 2,
retryIntervalMs = 30_000,
stallTimeoutMs = 90_000,
}: UploadWithSignedUrlOptions = {}
): Promise<void> {
for (let attempt = 0; ; attempt++) {
signal?.throwIfAborted();
const stream = await srcGeneratorAsync();
// Upload time scales with file size, so the deadline bounds stalls, not the whole request.
const timeout = AbortUtils.resettableTimeout(stallTimeoutMs);
try {
await putAsync({
signedUrl,
stream,
signal: signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal,
onProgress: timeout.resetDeadline,
});
return;
} catch (error) {
signal?.throwIfAborted();
if (attempt >= retries || (!timeout.signal.aborted && !isRetryableError(error))) {
throw error;
}
await delay(retryIntervalMs * (attempt + 1), undefined, { signal });
} finally {
stream.destroy();
await finished(stream).catch(() => {});
}
}
}
export async function uploadContentWithSignedUrl(
{ signedUrl, content }: { signedUrl: SignedUrl; content: string | Buffer },
options?: UploadWithSignedUrlOptions
): Promise<void> {
const headers = { ...signedUrl.headers };
const headerNames = Object.keys(headers).map(name => name.toLowerCase());
if (!headerNames.includes('content-length')) {
headers['content-length'] = String(Buffer.byteLength(content));
}
if (typeof content === 'string' && !headerNames.includes('content-type')) {
headers['content-type'] = 'text/plain;charset=UTF-8';
}
await uploadWithSignedUrl(
{
signedUrl: { url: signedUrl.url, headers },
srcGeneratorAsync: async () => Readable.from([content]),
},
options
);
}
const RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504];
function isRetryableError(error: unknown): boolean {
if (error instanceof SignedUrlUploadApiError && RETRYABLE_STATUSES.includes(error.status)) {
return true;
}
return isConnectFailure(error) || isConnectionInterruptedError(error);
}
async function putAsync({
export async function uploadWithSignedUrl({
signedUrl,
stream,
signal,
onProgress,
}: {
signedUrl: SignedUrl;
stream: Readable;
signal: AbortSignal;
onProgress: () => void;
}): Promise<void> {
const requestController = new AbortController();
srcGeneratorAsync,
retries = 2,
retryIntervalMs = 30_000,
}: UploadWithSignedUrlParams): Promise<string> {
let resp;
try {
const responsePromise = fetch(signedUrl.url, {
method: 'PUT',
headers: signedUrl.headers,
body: stream,
signal: AbortSignal.any([signal, requestController.signal]),
});
// fetch has piped the body already, so this listener observes flow without starting it.
stream.on('data', onProgress);
const response = await responsePromise;
signal.throwIfAborted();
if (!response.ok) {
throw new SignedUrlUploadApiError(response);
resp = await retryOnUploadFailure(
async () => {
const src = await srcGeneratorAsync();
return await fetch(signedUrl.url, {
method: 'PUT',
headers: signedUrl.headers,
body: src,
});
},
{ retries, retryIntervalMs }
);
} catch (err: unknown) {
if (err instanceof FetchError) {
throw new Error(`Failed to upload the file, reason: ${err.code}`);
}
} catch (error) {
signal.throwIfAborted();
if (error instanceof FetchError) {
throw new SignedUrlUploadNetworkError(error);
}
throw error;
} finally {
// node-fetch destroys the request body on abort, and its response stream is a PassThrough
// whose destruction leaves the socket open, so the request itself is aborted here.
requestController.abort();
throw err;
}
}
export class SignedUrlUploadApiError extends SystemError {
readonly status: number;
readonly reason: string;
constructor(response: { status: number; statusText: string }) {
const reason = `HTTP ${response.status} ${response.statusText}`;
super(`Signed URL upload failed: ${reason}.`);
this.status = response.status;
this.reason = reason;
if (!resp.ok) {
let body: string | undefined;
try {
body = await resp.text();
} catch {}
throw new Error(
`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`
);
}
}
// node-fetch names the request URL in its message, which we want to avoid for signed urls.
export class SignedUrlUploadNetworkError extends SystemError {
readonly code: string | undefined;
readonly reason: string;
constructor(error: FetchError) {
const reason = error.code ?? 'network error';
super(`Signed URL upload failed: ${reason}.`);
this.code = error.code;
this.reason = reason;
}
const url = new URL(signedUrl.url);
return `${url.protocol}//${url.host}${url.pathname}`;
}
+1 -3
View File
@@ -1,5 +1,3 @@
export const NpmrcAuthTokenLine = '//registry.npmjs.org/:_authToken=${NPM_TOKEN}';
export const NpmrcTemplate = `${NpmrcAuthTokenLine}
export const NpmrcTemplate = `//registry.npmjs.org/:_authToken=\${NPM_TOKEN}
registry=https://registry.npmjs.org/
`;
@@ -154,9 +154,8 @@ export namespace AndroidEmulatorUtils {
try {
let configIniFileContent = await fs.promises.readFile(configIniFile, 'utf-8');
// Keep the lower RAM default for older images; newer guests need more headroom for ADB.
const apiLevel = systemImagePackage.match(/^system-images;android-(\d+)/)?.[1];
const ramSize = !env.DEVICE_RUN_SESSION_ID && apiLevel && Number(apiLevel) < 35 ? 2048 : 4096;
// Give interactive sessions more headroom for guest VSOCK packet allocations.
const ramSize = env.DEVICE_RUN_SESSION_ID ? 4096 : 2048;
logger.info(`Setting hw.ramSize to ${ramSize}.`);
configIniFileContent = `${configIniFileContent}\nhw.ramSize=${ramSize}\n`;
@@ -121,7 +121,7 @@ describe('AndroidEmulatorUtils', () => {
'hw.lcd.density=420',
'skin.path=pixel_9',
'showDeviceFrame=yes',
'hw.ramSize=4096',
'hw.ramSize=2048',
'',
'hw.lcd.density=220',
'',
@@ -1,89 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { createMockLogger } from '../../__tests__/utils/logger';
import { Sentry } from '../../sentry';
import { installIsolatedNpmPackageAsync } from '../installIsolatedNpmPackageAsync';
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
jest.mock('../../sentry', () => ({ Sentry: { capture: jest.fn() } }));
const spawnMock = jest.mocked(spawn);
const npmCacheUrl = 'https://npm-cache.expo.test';
describe(installIsolatedNpmPackageAsync, () => {
it('retries without the npm cache registry when the install fails against it', async () => {
const logger = createMockLogger();
const cacheFailure = Object.assign(new Error('npm install exited with non-zero code: 1'), {
stderr: `npm error request to ${npmCacheUrl}/@scope%2ffork failed, reason: ECONNREFUSED`,
});
spawnMock.mockRejectedValueOnce(cacheFailure).mockResolvedValueOnce({} as never);
await installIsolatedNpmPackageAsync({
packageSpec: '@scope/fork@next',
directory: '/tmp/sandbox',
npmCacheUrl,
pathEnv: '/usr/bin',
logger,
});
expect(spawnMock).toHaveBeenCalledTimes(2);
expect(spawnMock.mock.calls[0][1]).toEqual(expect.arrayContaining(['--registry', npmCacheUrl]));
expect(spawnMock.mock.calls[1][1]).not.toContain('--registry');
expect(spawnMock.mock.calls[1][2]).toEqual({
env: { PATH: '/usr/bin', HOME: '/tmp/sandbox' },
logger,
});
expect(logger.warn).toHaveBeenCalledWith(
`Failed to install @scope/fork@next using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
expect(Sentry.capture).toHaveBeenCalledWith(
expect.objectContaining({ name: 'NpmCacheRegistryInstallError', cause: cacheFailure }),
{ level: 'warning', extras: { packageSpec: '@scope/fork@next', npmCacheUrl } }
);
});
it('rethrows an install failure that does not mention the npm cache registry', async () => {
const error = Object.assign(new Error('npm install exited with non-zero code: 1'), {
stderr: 'npm error notarget No matching version found for @scope/fork@next.',
});
spawnMock.mockRejectedValueOnce(error);
await expect(
installIsolatedNpmPackageAsync({
packageSpec: '@scope/fork@next',
directory: '/tmp/sandbox',
npmCacheUrl,
pathEnv: '/usr/bin',
})
).rejects.toBe(error);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
it('rethrows the cancellation of an install without retrying', async () => {
const controller = new AbortController();
const cancellation = new Error('cancelled');
spawnMock.mockImplementationOnce((() => {
controller.abort(cancellation);
return Promise.reject(
Object.assign(new Error('npm install was killed with SIGTERM'), {
stderr: `npm error request to ${npmCacheUrl}/@scope%2ffork failed`,
})
);
}) as unknown as typeof spawn);
await expect(
installIsolatedNpmPackageAsync({
packageSpec: '@scope/fork@next',
directory: '/tmp/sandbox',
npmCacheUrl,
pathEnv: '/usr/bin',
signal: controller.signal,
})
).rejects.toBe(cancellation);
expect(spawnMock).toHaveBeenCalledTimes(1);
expect(Sentry.capture).not.toHaveBeenCalled();
});
});
@@ -20,7 +20,6 @@ it.each([
'ENETDOWN',
'ENETUNREACH',
'ENOTFOUND',
'ERR_SOCKET_CONNECTION_TIMEOUT',
])('recognizes wrapped %s as a connection establishment failure', code => {
const error = new Error('Request failed', {
cause: new TypeError('fetch failed', {
@@ -31,7 +30,7 @@ it.each([
expect(isConnectionInterruptedError(error)).toBe(false);
});
it.each(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'UND_ERR_SOCKET'])(
it.each(['ECONNRESET', 'UND_ERR_SOCKET'])(
'requires caller-specific recovery for wrapped %s',
code => {
const error = new Error('Request failed', {
@@ -42,7 +41,7 @@ it.each(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'UND_ERR_SOCKET'])(
}
);
it.each(['CERT_HAS_EXPIRED', undefined])('does not automatically retry %s', code => {
it.each(['ETIMEDOUT', 'CERT_HAS_EXPIRED', undefined])('does not automatically retry %s', code => {
const error = Object.assign(new Error('Request failed'), { code });
expect(isConnectFailure(error)).toBe(false);
expect(isConnectionInterruptedError(error)).toBe(false);
@@ -1,120 +0,0 @@
import spawn from '@expo/turtle-spawn';
import { vol } from 'memfs';
import { createMockLogger } from '../../__tests__/utils/logger';
import { NpmrcAuthTokenLine, NpmrcTemplate } from '../../templates/npmrc';
import { setUpNpmrcAsync } from '../npmrc';
jest.mock('fs');
jest.mock('@expo/turtle-spawn', () => ({ __esModule: true, default: jest.fn() }));
const projectTargetDirectory = '/workingdir/build';
const homeDirectory = '/home/expo';
describe(setUpNpmrcAsync, () => {
beforeEach(async () => {
vol.reset();
await vol.promises.mkdir(projectTargetDirectory, { recursive: true });
await vol.promises.mkdir(homeDirectory, { recursive: true });
jest.mocked(spawn).mockResolvedValue({ stdout: `${homeDirectory}/.npmrc\n` } as never);
});
afterEach(() => {
jest.restoreAllMocks();
});
it('adds the NPM_TOKEN placeholder to ~/.npmrc on EAS Build and leaves the project .npmrc alone', async () => {
const projectContents = '//npm.pkg.github.com/:_authToken=${GITHUB_TOKEN}\n';
await vol.promises.writeFile(`${projectTargetDirectory}/.npmrc`, projectContents);
await vol.promises.writeFile(`${homeDirectory}/.npmrc`, 'fund=false');
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'eas-build' },
logger: createMockLogger(),
});
expect(await vol.promises.readFile(`${homeDirectory}/.npmrc`, 'utf8')).toBe(
`fund=false\n${NpmrcAuthTokenLine}\n`
);
expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe(
projectContents
);
});
it('adds the NPM_TOKEN placeholder to the user config file npm resolves from the build env', async () => {
await vol.promises.mkdir(`${homeDirectory}/npm`, { recursive: true });
jest.mocked(spawn).mockResolvedValueOnce({ stdout: `${homeDirectory}/npm/npmrc\n` } as never);
const env = {
NPM_TOKEN: 'npm_secret_token',
EAS_BUILD_RUNNER: 'eas-build',
NPM_CONFIG_USERCONFIG: '~/npm/npmrc',
};
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env,
logger: createMockLogger(),
});
expect(spawn).toHaveBeenCalledWith(
'npm',
['config', 'get', 'userconfig'],
expect.objectContaining({ cwd: projectTargetDirectory, env })
);
expect(await vol.promises.readFile(`${homeDirectory}/npm/npmrc`, 'utf8')).toBe(
`\n${NpmrcAuthTokenLine}\n`
);
expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false);
});
it('creates the project .npmrc and leaves the user npmrc alone on local builds', async () => {
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'local-build-plugin' },
logger: createMockLogger(),
});
expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe(
NpmrcTemplate
);
expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false);
});
it('leaves an existing project .npmrc untouched on local builds', async () => {
const existingContents = 'registry=https://registry.example.com/\n';
await vol.promises.writeFile(`${projectTargetDirectory}/.npmrc`, existingContents);
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env: { NPM_TOKEN: 'npm_secret_token', EAS_BUILD_RUNNER: 'local-build-plugin' },
logger: createMockLogger(),
});
expect(await vol.promises.readFile(`${projectTargetDirectory}/.npmrc`, 'utf8')).toBe(
existingContents
);
});
it('reports an existing .npmrc when NPM_TOKEN is not set', async () => {
const logger = createMockLogger();
await vol.promises.writeFile(
`${projectTargetDirectory}/.npmrc`,
'registry=https://registry.example.com/\n'
);
await setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory: projectTargetDirectory,
env: { EAS_BUILD_RUNNER: 'eas-build' },
logger,
});
expect(logger.info).toHaveBeenCalledWith('.npmrc found at .npmrc');
expect(vol.existsSync(`${homeDirectory}/.npmrc`)).toBe(false);
});
});
@@ -1,7 +1,9 @@
import { errors } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import fs from 'fs-extra';
import { vol } from 'memfs';
import path from 'path';
import semver from 'semver';
import {
PackageManager,
@@ -14,6 +16,7 @@ import {
resolvePackageExec,
resolvePackageInstall,
resolvePackageManager,
resolvePackageVersionAsync,
shouldUseFrozenLockfile,
} from '../packageManager';
@@ -290,6 +293,33 @@ describe(resolvePackageInstall, () => {
});
});
describe(resolvePackageVersionAsync, () => {
const logger = {
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
} as unknown as bunyan;
it('should resolve version from package name and dist tag', async () => {
const version = await resolvePackageVersionAsync({
logger,
packageName: '@expo/repack-app',
distTag: 'latest',
});
expect(semver.valid(version)).toBeTruthy();
});
it('should return null if version cannot be resolved', async () => {
const version = await resolvePackageVersionAsync({
logger,
packageName: '@expo/repack-app',
distTag: 'nonexistent-dist-tag',
});
expect(version).toBeNull();
});
});
describe(findPackagerRootDir, () => {
beforeEach(() => {
vol.reset();
@@ -1,62 +0,0 @@
import { type bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import { NpmCacheRegistryInstallError, isNpmCacheInstallFailure } from './npmCacheRegistry';
import { Sentry } from '../sentry';
export async function installIsolatedNpmPackageAsync({
packageSpec,
directory,
npmCacheUrl,
pathEnv,
logger,
signal,
}: {
packageSpec: string;
directory: string;
npmCacheUrl: string | undefined;
pathEnv: string | undefined;
logger?: bunyan;
signal?: AbortSignal;
}): Promise<void> {
const installAsync = async (registryUrl: string | undefined): Promise<void> => {
await spawn(
'npm',
[
'install',
'--prefix',
directory,
'--no-save',
'--package-lock=false',
'--no-audit',
'--no-fund',
'--no-update-notifier',
...(registryUrl ? ['--registry', registryUrl] : []),
packageSpec,
],
// npm reads the user config in $HOME from any cwd, so the install directory doubles as HOME.
{ env: { PATH: pathEnv, HOME: directory }, logger, signal }
);
};
if (!npmCacheUrl) {
await installAsync(undefined);
return;
}
try {
await installAsync(npmCacheUrl);
} catch (error) {
signal?.throwIfAborted();
if (!isNpmCacheInstallFailure(error, npmCacheUrl)) {
throw error;
}
logger?.warn(
`Failed to install ${packageSpec} using the npm cache registry (${npmCacheUrl}). Retrying without the npm cache registry.`
);
Sentry.capture(new NpmCacheRegistryInstallError(error), {
level: 'warning',
extras: { packageSpec, npmCacheUrl },
});
await installAsync(undefined);
}
}
@@ -8,14 +8,6 @@ const CONNECT_FAILURE_CODES = new Set([
'ENETDOWN',
'ENETUNREACH',
'ENOTFOUND',
'ERR_SOCKET_CONNECTION_TIMEOUT',
]);
const CONNECTION_INTERRUPTED_CODES = new Set([
'ECONNRESET',
'EPIPE',
'ETIMEDOUT',
'UND_ERR_SOCKET',
]);
export function isConnectFailure(error: unknown): boolean {
@@ -34,11 +26,7 @@ export function isConnectFailure(error: unknown): boolean {
// The server may already have applied a request. The caller must decide whether repetition is safe.
export function isConnectionInterruptedError(error: unknown): boolean {
for (let current = error; current instanceof Error; current = current.cause) {
if (
'code' in current &&
typeof current.code === 'string' &&
CONNECTION_INTERRUPTED_CODES.has(current.code)
) {
if ('code' in current && (current.code === 'ECONNRESET' || current.code === 'UND_ERR_SOCKET')) {
return true;
}
}
@@ -1,20 +0,0 @@
import { SystemError } from '@expo/eas-build-job';
export function isNpmCacheInstallFailure(err: unknown, npmCacheUrl: string): boolean {
return getErrorOutput(err).includes(npmCacheUrl);
}
function getErrorOutput(err: unknown): string {
if (!(err instanceof Error)) {
return '';
}
const { stdout, stderr } = err as Error & { stdout?: string; stderr?: string };
return [stdout, stderr].filter(Boolean).join('\n');
}
export class NpmCacheRegistryInstallError extends SystemError {
override name = 'NpmCacheRegistryInstallError';
constructor(cause: unknown) {
super('Failed to install dependencies using npm cache registry', { cause });
}
}
+16 -50
View File
@@ -1,56 +1,23 @@
import { Job } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import spawn from '@expo/turtle-spawn';
import fs from 'fs-extra';
import path from 'path';
import { findPackagerRootDir } from './packageManager';
import { NpmrcAuthTokenLine, NpmrcTemplate } from '../templates/npmrc';
import { BuildContext } from '../context';
import { NpmrcTemplate } from '../templates/npmrc';
export async function setUpNpmrcAsync({
projectTargetDirectory,
workingDirectory,
env,
logger,
}: {
projectTargetDirectory: string;
workingDirectory: string;
env: Record<string, string | undefined>;
logger: bunyan;
}): Promise<void> {
if (!env.NPM_TOKEN) {
await logIfNpmrcExistsAsync(projectTargetDirectory, workingDirectory, logger);
return;
}
logger.info('We detected that you set the NPM_TOKEN environment variable');
// pnpm 10.34.2+ and 11.5.3+ ignore ${NPM_TOKEN} placeholders in a project-level .npmrc but expand them in ~/.npmrc
if (env.EAS_BUILD_RUNNER === 'eas-build') {
await addNpmTokenToUserNpmrcAsync(workingDirectory, env, logger);
export async function setUpNpmrcAsync(ctx: BuildContext<Job>, logger: bunyan): Promise<void> {
if (ctx.env.NPM_TOKEN) {
await createNpmrcIfNotExistsAsync(ctx, logger);
} else {
await createProjectNpmrcIfNotExistsAsync(projectTargetDirectory, logger);
await logIfNpmrcExistsAsync(ctx, logger);
}
}
async function addNpmTokenToUserNpmrcAsync(
workingDirectory: string,
env: Record<string, string | undefined>,
logger: bunyan
): Promise<void> {
const userNpmrcPath = (
await spawn('npm', ['config', 'get', 'userconfig'], {
cwd: findPackagerRootDir(workingDirectory),
env,
stdio: 'pipe',
})
).stdout.trim();
logger.info(`Adding "${NpmrcAuthTokenLine}" to ${userNpmrcPath}`);
await fs.appendFile(userNpmrcPath, `\n${NpmrcAuthTokenLine}\n`);
}
async function createProjectNpmrcIfNotExistsAsync(
projectTargetDirectory: string,
logger: bunyan
): Promise<void> {
const projectNpmrcPath = path.join(projectTargetDirectory, '.npmrc');
async function createNpmrcIfNotExistsAsync(ctx: BuildContext<Job>, logger: bunyan): Promise<void> {
logger.info('We detected that you set the NPM_TOKEN environment variable');
const projectNpmrcPath = path.join(ctx.buildDirectory, '.npmrc');
if (await fs.pathExists(projectNpmrcPath)) {
logger.info('.npmrc already exists in your project directory, skipping generation');
} else {
@@ -60,13 +27,12 @@ async function createProjectNpmrcIfNotExistsAsync(
}
}
async function logIfNpmrcExistsAsync(
projectTargetDirectory: string,
workingDirectory: string,
logger: bunyan
): Promise<void> {
const projectNpmrcPath = path.join(findPackagerRootDir(workingDirectory), '.npmrc');
async function logIfNpmrcExistsAsync(ctx: BuildContext<Job>, logger: bunyan): Promise<void> {
const projectNpmrcPath = path.join(
findPackagerRootDir(ctx.getReactNativeProjectDirectory()),
'.npmrc'
);
if (await fs.pathExists(projectNpmrcPath)) {
logger.info(`.npmrc found at ${path.relative(projectTargetDirectory, projectNpmrcPath)}`);
logger.info(`.npmrc found at ${path.relative(ctx.buildDirectory, projectNpmrcPath)}`);
}
}
@@ -1,4 +1,5 @@
import { errors } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import * as PackageManagerUtils from '@expo/package-manager';
import { type BuildStepEnv } from '@expo/steps';
import spawnAsync from '@expo/turtle-spawn';
@@ -125,6 +126,34 @@ export function resolvePackageInstall(
}
}
/**
* Get the version of a package from the dist-tags.
* Returns null if the version cannot be resolved.
*/
export async function resolvePackageVersionAsync({
logger,
packageName,
distTag,
}: {
logger: bunyan;
packageName: string;
distTag: string;
}): Promise<string | null> {
try {
const { stdout } = await spawnAsync('npm', ['view', packageName, 'dist-tags', '--json'], {
stdio: 'pipe',
});
const distTags = JSON.parse(stdout);
if (distTag in distTags) {
return distTags[distTag];
}
} catch (e: unknown) {
const message = e instanceof Error ? e.message : String(e);
logger.warn(`Unable to resolve version for ${packageName}@${distTag}: ${message}`);
}
return null;
}
export function findPackagerRootDir(currentDir: string): string {
return PackageManagerUtils.resolveWorkspaceRoot(currentDir) ?? currentDir;
}
@@ -1,17 +1,5 @@
# create-eas-build-function
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "27.1.0",
"version": "25.3.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "27.1.0"
"@expo/steps": "25.3.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
-12
View File
@@ -1,17 +1,5 @@
# @expo/downloader
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "27.1.0",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,17 +1,5 @@
# eas-build-cache-provider
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
## 26.0.0
No changes in this release.
## 25.3.0
No changes in this release.
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "27.1.0",
"version": "25.3.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
-18
View File
@@ -1,23 +1,5 @@
# @expo/eas-build-job
## 27.1.0
No changes in this release.
## 27.0.0
No changes in this release.
## 26.0.0
### Major Changes
- Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports. ([#4719](https://github.com/expo/eas-cli/pull/4719) by [@sjchmiela](https://github.com/sjchmiela))
### Minor Changes
- Export `validateAndroidLaunchOptions` and `quoteAndroidShellCommand` from `@expo/eas-build-job`, so Android launch arguments can be checked for NUL characters and length before a simulator session starts, and check them before the emulator boots in agent-device sessions. ([#4696](https://github.com/expo/eas-cli/pull/4696) by [@brentvatne](https://github.com/brentvatne))
## 25.3.0
No changes in this release.
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "27.1.0",
"version": "25.3.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "27.1.0",
"@expo/logger": "25.3.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "27.1.0",
"@expo/turtle-spawn": "25.3.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
+21
View File
@@ -7,3 +7,24 @@ export enum ManagedArtifactType {
*/
XCODE_BUILD_LOGS = 'XCODE_BUILD_LOGS',
}
export enum GenericArtifactType {
ANDROID_APK = 'android-apk',
ANDROID_AAB = 'android-aab',
IOS_SIMULATOR_APP = 'ios-simulator-app',
IOS_IPA = 'ios-ipa',
OTHER = 'other',
}
export const isGenericArtifact = <
TSpec extends { type: GenericArtifactType | ManagedArtifactType },
>(
artifactSpec: TSpec
): artifactSpec is TSpec & { type: GenericArtifactType } => {
if (Object.values(GenericArtifactType).includes(artifactSpec.type as GenericArtifactType)) {
return true;
}
return false;
};
-45
View File
@@ -1,50 +1,5 @@
# eas-cli
## 27.1.0
### Minor Changes
- Display queued workflow runs: `workflow:run --wait` and `workflow:status` show a run waiting for its concurrency group and the run blocking it, and `workflow:cancel` includes queued runs in its selection list. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- Upload the workflow files that `uses` jobs reference, including nested ones, when running a local workflow file with `eas workflow:run`. ([#4769](https://github.com/expo/eas-cli/pull/4769) by [@sswrk](https://github.com/sswrk))
### Patch Changes
- Fail the command when EAS CLI cannot pull environment variables from EAS servers, instead of continuing without them. ([#4774](https://github.com/expo/eas-cli/pull/4774) by [@wschurman](https://github.com/wschurman))
- Format the generated root changelog before committing a release. ([#4773](https://github.com/expo/eas-cli/pull/4773) by [@gwdp](https://github.com/gwdp))
- Honor the `--non-interactive` flag in `workflow:cancel`; it was parsed but never applied, so automation could hang on the interactive picker. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
- Stop reporting a finish time for workflow runs that have not finished: `workflow:runs` reports `finishedAt` only for runs in a final status. ([#4258](https://github.com/expo/eas-cli/pull/4258) by [@hSATAC](https://github.com/hSATAC))
## 27.0.0
### Major Changes
- Write the reason a command failed to stderr, next to the `command failed.` line, instead of to stdout. A program that reads the reason from stdout must read stderr instead. ([#4592](https://github.com/expo/eas-cli/pull/4592) by [@vonovak](https://github.com/vonovak))
### Minor Changes
- Send the `permissions.account.role` from the build profile to request a role for the build's `EXPO_TOKEN`. ([#4734](https://github.com/expo/eas-cli/pull/4734) by [@wschurman](https://github.com/wschurman))
- Add `simulator:logs` to read and follow logs from an iOS simulator session and download saved logs after it stops. ([#4545](https://github.com/expo/eas-cli/pull/4545) by [@gwdp](https://github.com/gwdp))
### Patch Changes
- Show permission guidance, including `permissions.account.role` in eas.json, when a build request fails with an authorization error. ([#4760](https://github.com/expo/eas-cli/pull/4760) by [@wschurman](https://github.com/wschurman))
- Print the notice about eas-cli in project dependencies only when stderr is a terminal, so programs that read stderr get the command's error. ([#4591](https://github.com/expo/eas-cli/pull/4591) by [@vonovak](https://github.com/vonovak))
- Show account roles and credential permission limits in `eas login` when you are already logged in. ([#4759](https://github.com/expo/eas-cli/pull/4759) by [@wschurman](https://github.com/wschurman))
- Fix simulator control action help routing and show help when a flag is incomplete. ([#4681](https://github.com/expo/eas-cli/pull/4681) by [@gwdp](https://github.com/gwdp))
- Require explicit `host:` simulator-host upload paths and report missing local files. ([#4683](https://github.com/expo/eas-cli/pull/4683) by [@gwdp](https://github.com/gwdp))
Missing local absolute paths now require the `host:` prefix. Actions that require upload IDs accept local files and reject host paths with an explanation.
## 26.0.0
### Patch Changes
- Regenerate graphql code. ([#4692](https://github.com/expo/eas-cli/pull/4692) by [@wschurman](https://github.com/wschurman))
- Read the app config fewer times in `eas build` and other project commands. An iOS build now runs `expo config` 2 times instead of 6. ([`78ba760`](https://github.com/expo/eas-cli/commit/78ba76001b67c9971236af1a1d02e46c2f82d24a) by [@vonovak](https://github.com/vonovak))
- Share one GraphQL client between the project ID check and the command, so commands send one fewer app query. ([#4646](https://github.com/expo/eas-cli/pull/4646) by [@vonovak](https://github.com/vonovak))
- Show why a simulator session failed in `eas simulator:get`, and add an `errors` array to its `--json` output. For an unknown error, `simulator:start` and `simulator:get` now show the failed step's error from the session logs. ([#4694](https://github.com/expo/eas-cli/pull/4694) by [@brentvatne](https://github.com/brentvatne))
- Show why a simulator session failed in `eas simulator:start`, such as an unknown `--device` name, instead of only the session status. ([#4693](https://github.com/expo/eas-cli/pull/4693) by [@brentvatne](https://github.com/brentvatne))
## 25.3.0
### Minor Changes
+198 -268
View File
File diff suppressed because it is too large. Load diff
+733 -2941
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "27.1.0",
"version": "25.3.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,12 +48,12 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "27.1.0",
"@expo/eas-json": "27.1.0",
"@expo/eas-build-job": "25.3.0",
"@expo/eas-json": "25.3.0",
"@expo/env": "^1.0.0",
"@expo/experimentation": "0.0.5",
"@expo/json-file": "8.3.3",
"@expo/logger": "27.1.0",
"@expo/logger": "25.3.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -65,7 +65,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "27.1.0",
"@expo/steps": "25.3.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -198,28 +198,6 @@ export function getMockEmptyWorkflowRunsFragment(): WorkflowRunFragment[] {
return getMockWorkflowRunsFragment();
}
export function getMockWorkflowRunFragment(
status: WorkflowRunStatus,
overrides?: Partial<WorkflowRunFragment>
): WorkflowRunFragment {
return {
id: `run-${status}`,
status,
triggerEventType: WorkflowRunTriggerEventType.Manual,
errors: [],
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
name: 'build',
id: 'build',
fileName: 'build.yml',
},
...overrides,
};
}
export function getMockWorkflowRunsFragment(
params?:
| undefined
@@ -262,7 +240,6 @@ export function getMockWorkflowRunsFragment(
status: WorkflowRunStatus.Failure,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
finalizedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
workflow: {
@@ -355,7 +332,6 @@ export function getMockWorkflowRunWithJobsFragment(
id: mockProjectId,
__typename: 'App',
name: 'App',
slug: 'app',
ownerAccount: {
id: 'account-id',
name: 'account-name',
@@ -388,7 +364,6 @@ export function getMockWorkflowRunWithBuildJobsFragment(
id: mockProjectId,
__typename: 'App',
name: 'App',
slug: 'app',
ownerAccount: {
id: 'account-id',
name: 'account-name',
@@ -1,88 +0,0 @@
import {
getMockWorkflowRunFragment,
mockCommandContext,
mockProjectId,
mockTestCommand,
} from './utils';
import WorkflowRunCancel from '../../commands/workflow/cancel';
import { WorkflowRunStatus } from '../../graphql/generated';
import { WorkflowRunMutation } from '../../graphql/mutations/WorkflowRunMutation';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { promptAsync } from '../../prompts';
jest.mock('../../graphql/queries/AppQuery');
jest.mock('../../graphql/mutations/WorkflowRunMutation');
jest.mock('../../log');
jest.mock('../../prompts');
describe(WorkflowRunCancel, () => {
afterEach(() => {
jest.clearAllMocks();
});
test('prompts with in-progress and queued runs and cancels the selected ones', async () => {
const ctx = mockCommandContext(WorkflowRunCancel, {
projectId: mockProjectId,
});
const inProgressRun = getMockWorkflowRunFragment(WorkflowRunStatus.InProgress);
const waitingRun = getMockWorkflowRunFragment(WorkflowRunStatus.Waiting);
jest
.mocked(AppQuery.byIdWorkflowRunsFilteredByStatusAsync)
.mockImplementation(async (_graphqlClient, _appId, status) =>
status === WorkflowRunStatus.InProgress ? [inProgressRun] : [waitingRun]
);
jest.mocked(promptAsync).mockResolvedValueOnce({ selectedRuns: [waitingRun.id] });
const cmd = mockTestCommand(WorkflowRunCancel, [], ctx);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
WorkflowRunStatus.InProgress,
50
);
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
mockProjectId,
WorkflowRunStatus.Waiting,
50
);
const promptCall = jest.mocked(promptAsync).mock.calls[0][0] as any;
expect(promptCall.choices.map((choice: any) => choice.value)).toEqual([
inProgressRun.id,
waitingRun.id,
]);
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledTimes(1);
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
{ workflowRunId: waitingRun.id }
);
});
test('throws in non-interactive mode when no run IDs are given', async () => {
const ctx = mockCommandContext(WorkflowRunCancel, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowRunCancel, ['--non-interactive'], ctx);
await expect(cmd.run()).rejects.toThrow(
'Must supply workflow run IDs as arguments when in non-interactive mode'
);
expect(promptAsync).not.toHaveBeenCalled();
});
test('cancels the given run IDs without prompting in non-interactive mode', async () => {
const ctx = mockCommandContext(WorkflowRunCancel, {
projectId: mockProjectId,
});
const cmd = mockTestCommand(WorkflowRunCancel, ['--non-interactive', 'run-id-1'], ctx);
await cmd.run();
expect(AppQuery.byIdWorkflowRunsFilteredByStatusAsync).not.toHaveBeenCalled();
expect(promptAsync).not.toHaveBeenCalled();
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledTimes(1);
expect(WorkflowRunMutation.cancelWorkflowRunAsync).toHaveBeenCalledWith(
ctx.loggedIn.graphqlClient,
{ workflowRunId: 'run-id-1' }
);
});
});
@@ -142,7 +142,6 @@ describe(WorkflowRunList, () => {
triggerEventType: WorkflowRunTriggerEventType.Manual,
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
finalizedAt: '2022-01-01T00:00:00.000Z',
gitCommitHash: '1234567890',
gitCommitMessage: 'commit message',
errors: [],
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more