Compare commits

..
Author SHA1 Message Date
Brent Vatne 72b8a78145 [worker] Apply log masking to uploaded Xcode build logs
Pass the job's log masks to the Xcode build log upload and mask the file
line by line while streaming it, including values written in the escaped
form Xcode uses for build settings.
2026-10-20 16:31:19 -07:00
126 changed files with 2403 additions and 3923 deletions

No files matched your search

-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Regenerate graphql code
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
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.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Add a configurable timeout to `eas/wait_for_asc_build_processing` and fail when Apple does not finish processing before the deadline.
@@ -1,5 +0,0 @@
---
'@expo/eas-json': minor
---
Add `permissions.account.role` to build profiles to request a role for the build's `EXPO_TOKEN`.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Retry transient failures when reporting that a device run session has started.
@@ -1,5 +0,0 @@
---
'@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
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
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.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': none
---
Make command descriptions in `eas --help` consistently lowercase without trailing periods.
-6
View File
@@ -1,6 +0,0 @@
---
'@expo/build-tools': patch
'@expo/steps': patch
---
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.
-6
View File
@@ -1,6 +0,0 @@
---
'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).
@@ -1,5 +0,0 @@
---
'@expo/worker': patch
---
Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Stop sandboxes after an optional maximum duration measured from readiness.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Add `eas/wait_for_asc_build_processing` to wait for an existing App Store Connect upload. Keep `eas/upload_to_asc`'s `wait_for_processing` input supported through the same processing logic.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Share one GraphQL client between the project ID check and the command, so commands send one fewer app query.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Add eas/submit_testflight_beta_review to submit a processed build for beta review separately from metadata updates.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Unify signed URL uploading to a single implementation and use it everywhere
@@ -0,0 +1,5 @@
---
'@expo/worker': patch
---
Apply the job log's secret masking to uploaded Xcode build logs.
-6
View File
@@ -1,6 +0,0 @@
---
'@expo/eas-build-job': major
'@expo/build-tools': major
---
Remove legacy artifact type exports and route workflow uploads through workflow storage without file-type categories. Preserve workflow uploads and Maestro reports.
+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
+1 -1
View File
@@ -6,4 +6,4 @@ MISE_NODE_COREPACK = "true"
actionlint = "1.7.12"
gcloud = "570.0.0"
jq = "1.8.1"
node = "22.23.4"
node = "22.23.3"
+1 -1
View File
@@ -26,7 +26,7 @@
"build:egress-guard": "resources/egress-guard/build.sh",
"test:egress-guard": "resources/egress-guard/tests/run-policy-tests.sh && resources/egress-guard/tests/run-guard-tests.sh",
"typecheck": "tsc",
"generate-appium-commands": "mise exec [email protected]3.4 -- node scripts/generate-appium-commands.js",
"generate-appium-commands": "mise exec [email protected]2.0 -- node scripts/generate-appium-commands.js",
"prepack": "rimraf dist \"*.tsbuildinfo\" && yarn gql && tsc -p tsconfig.build.json",
"jest-unit": "jest --config jest/unit-config.ts",
"jest-integration": "jest --config jest/integration-config.ts",
@@ -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;
+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
+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 -10
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';
@@ -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,
});
@@ -67,9 +67,7 @@ import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
import { createUploadEmbeddedBundleBuildFunction } from './functions/uploadEmbeddedBundle';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createWaitForAscBuildProcessingBuildFunction } from './functions/waitForAscBuildProcessing';
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
import { createSubmitTestFlightBetaReviewBuildFunction } from './functions/submitTestFlightBetaReview';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
import { createUploadPosthogSourcemapsFunction } from './functions/uploadPosthogSourcemaps';
@@ -146,9 +144,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createSubmissionEntityFunction(),
createSubmitToGooglePlayBuildFunction(),
createUploadToAscBuildFunction(),
createWaitForAscBuildProcessingBuildFunction(),
createUpdateTestFlightMetadataBuildFunction(),
createSubmitTestFlightBetaReviewBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
createMaestroTestsBuildFunction(ctx),
@@ -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',
@@ -11,7 +11,6 @@ import * as tar from 'tar';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { Datadog } from '../../../datadog';
import * as files from '../../../utils/files';
import { createDownloadBuildFunction, downloadBuildAsync } from '../downloadBuild';
@@ -31,17 +30,11 @@ const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.
function createMockGraphqlClient({
applicationArchiveUrl,
error,
transientError,
}: {
applicationArchiveUrl?: string | null;
error?: Error;
transientError?: Error;
}): Client {
const toPromise = jest.fn();
if (transientError) {
toPromise.mockResolvedValueOnce({ error: transientError, data: undefined });
}
toPromise.mockResolvedValue(
const toPromise = jest.fn().mockResolvedValue(
error
? { error, data: undefined }
: {
@@ -69,7 +62,7 @@ function createSuccessfulResponse({
url,
headers,
}: {
body: Buffer | Readable;
body: Buffer;
url: string;
headers?: Record<string, string>;
}): Response {
@@ -79,7 +72,7 @@ function createSuccessfulResponse({
return {
ok: true,
body: body instanceof Readable ? body : Readable.from(body),
body: Readable.from(body),
url,
headers: {
get: (name: string): string | null => normalizedHeaders[name.toLowerCase()] ?? null,
@@ -87,47 +80,6 @@ function createSuccessfulResponse({
} as unknown as Response;
}
function createUndiciNetworkError(code: string): CombinedError {
return new CombinedError({
networkError: Object.assign(new TypeError('fetch failed'), {
cause: Object.assign(new Error(`read ${code}`), { code }),
}),
});
}
const { FetchError: ActualFetchError } = jest.requireActual(
'node-fetch'
) as typeof import('node-fetch');
function createSocketFetchError(url: string, code: string): Error {
return new ActualFetchError(`request to ${url} failed, reason: connect ${code}`, 'system', {
code,
} as any);
}
function useFakeBackoffTimers(): void {
// Streams and fs callbacks need the real nextTick and setImmediate; only the backoff timers are faked.
jest.useFakeTimers({ doNotFake: ['nextTick', 'setImmediate', 'queueMicrotask'] });
}
const MAX_IO_TURNS_BEFORE_BACKOFF = 1000;
async function waitForBackoffTimerAsync(): Promise<void> {
for (let turns = 0; jest.getTimerCount() === 0; turns++) {
if (turns === MAX_IO_TURNS_BEFORE_BACKOFF) {
throw new Error('No backoff timer was scheduled.');
}
await new Promise(resolve => setImmediate(resolve));
}
}
async function fireBackoffTimersAsync(count: number): Promise<void> {
for (let i = 0; i < count; i++) {
await waitForBackoffTimerAsync();
await jest.advanceTimersToNextTimerAsync();
}
}
async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
const sourceDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'flat-app-source-'));
const archivePath = path.join(os.tmpdir(), `${randomUUID()}.tar.gz`);
@@ -156,11 +108,6 @@ async function createFlatAppTarGzBufferAsync(): Promise<Buffer> {
}
}
afterEach(() => {
jest.useRealTimers();
jest.restoreAllMocks();
});
describe('downloadBuild', () => {
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
@@ -518,13 +465,13 @@ describe('downloadBuild', () => {
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError with the cause code after retrying a failing build lookup', async () => {
useFakeBackoffTimers();
it('throws SystemError when GraphQL request fails with a network error', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: createUndiciNetworkError('ECONNRESET'),
error: new CombinedError({
networkError: new Error('Network request failed'),
}),
});
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
@@ -533,81 +480,19 @@ describe('downloadBuild', () => {
robotAccessToken: null,
extensions: ['app'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: [Network] fetch failed (ECONNRESET)`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup failed on attempt 6', {
event: 'network_retry',
operation: 'download_build.build_lookup',
outcome: 'failed',
attempts: '6',
code: 'ECONNRESET',
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it.each([
['a network error', createUndiciNetworkError('ECONNRESET'), 'ECONNRESET'],
[
'a transient GraphQL error',
new CombinedError({
graphQLErrors: [
{
message: 'Too many requests',
extensions: { isTransient: true, errorCode: 'TOO_MANY_REQUESTS' },
},
],
response: { status: 200 },
}),
'TOO_MANY_REQUESTS',
],
])('downloads after a build lookup that failed once with %s', async (_, transientError, code) => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
transientError,
});
jest
.mocked(fetch)
.mockResolvedValue(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(path.basename(artifactPath)).toBe('app.apk');
expect(graphqlClient.query).toHaveBeenCalledTimes(2);
expect(datadogLog).toHaveBeenCalledWith('download_build.build_lookup recovered on attempt 2', {
event: 'network_retry',
operation: 'download_build.build_lookup',
outcome: 'recovered',
attempts: '2',
code,
});
});
it('throws SystemError when GraphQL request returns 5xx', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
graphQLErrors: [{ message: 'Internal Server Error' }],
error: Object.assign(new Error('Internal Server Error'), {
response: { status: 500 },
}),
});
@@ -619,14 +504,12 @@ describe('downloadBuild', () => {
robotAccessToken: null,
extensions: ['app'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: [GraphQL] Internal Server Error`,
message: `Could not fetch build ${buildId}: Internal Server Error`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(6);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
@@ -651,193 +534,10 @@ describe('downloadBuild', () => {
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
});
expect(graphqlClient.query).toHaveBeenCalledTimes(1);
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('downloads after an archive request that failed once with a socket error', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'))
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(path.basename(artifactPath)).toBe('app.apk');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
});
it('throws SystemError with the cause code after retrying a failing build archive request', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest
.mocked(fetch)
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ETIMEDOUT'));
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toMatchObject({
message: `Could not download the application archive: request to ${APPLICATION_ARCHIVE_URL} failed, reason: connect ETIMEDOUT`,
});
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith('download_build.archive_download failed on attempt 6', {
event: 'network_retry',
operation: 'download_build.archive_download',
outcome: 'failed',
attempts: '6',
code: 'ETIMEDOUT',
});
});
function createBrokenBody(code: string): Readable {
return Readable.from(
(async function* () {
yield Buffer.from('he');
throw Object.assign(new Error(`body failed with ${code}`), { code });
})()
);
}
it.each(['ERR_STREAM_PREMATURE_CLOSE', 'ECONNRESET'])(
'downloads again when the archive body breaks off with %s after the headers arrived',
async code => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
const brokenBody = createBrokenBody(code);
jest
.mocked(fetch)
.mockResolvedValueOnce(
createSuccessfulResponse({ body: brokenBody, url: 'https://cdn.test/app.apk' })
)
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
await fireBackoffTimersAsync(1);
const { artifactPath } = await promise;
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(2);
expect(datadogLog).toHaveBeenCalledWith(
'download_build.archive_download recovered on attempt 2',
expect.objectContaining({ outcome: 'recovered', code })
);
}
);
it('throws SystemError when the build archive body keeps breaking off', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockImplementation(async () =>
createSuccessfulResponse({
body: createBrokenBody('ECONNRESET'),
url: 'https://cdn.test/app.apk',
})
);
const datadogLog = jest.spyOn(Datadog, 'log');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(SystemError);
await fireBackoffTimersAsync(5);
await rejection;
await expect(promise).rejects.toMatchObject({
message: 'Could not download the application archive: body failed with ECONNRESET',
});
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
expect(datadogLog).toHaveBeenCalledWith(
'download_build.archive_download failed on attempt 6',
expect.objectContaining({ outcome: 'failed', code: 'ECONNRESET' })
);
});
it('does not start another archive attempt after an abort during the backoff', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValueOnce(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ECONNRESET'));
const controller = new AbortController();
const reason = new Error('step timed out');
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
signal: controller.signal,
});
const rejection = expect(promise).rejects.toBe(reason);
await waitForBackoffTimerAsync();
controller.abort(reason);
await jest.advanceTimersToNextTimerAsync();
await rejection;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(1);
});
it('rethrows the raw error of a failing user-supplied archive URL after the retries', async () => {
useFakeBackoffTimers();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockRejectedValue(createSocketFetchError(APPLICATION_ARCHIVE_URL, 'ENOTFOUND'));
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
});
const rejection = expect(promise).rejects.toBeInstanceOf(ActualFetchError);
await fireBackoffTimersAsync(5);
await rejection;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(6);
});
it('should throw an error if no matching files are found', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
@@ -905,21 +605,6 @@ describe('createDownloadBuildFunction', () => {
};
}
function createApplicationResponse(buildId: string): Response {
return new ActualResponse(
JSON.stringify({
data: {
build: {
id: buildId,
gitCommitHash: null,
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
}),
{ status: 200 }
);
}
function createStep(ctx: any, callInputs: Record<string, unknown>) {
return createDownloadBuildFunction(ctx).createBuildStepFromFunctionCall(
createGlobalContextMock({
@@ -935,7 +620,18 @@ describe('createDownloadBuildFunction', () => {
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockImplementation(async url => {
if (url === workerApiUrl) {
return createApplicationResponse(buildId);
return new ActualResponse(
JSON.stringify({
data: {
build: {
id: buildId,
gitCommitHash: null,
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
},
},
}),
{ status: 200 }
);
}
return {
ok: false,
@@ -965,33 +661,6 @@ describe('createDownloadBuildFunction', () => {
expect(graphqlClient.query).not.toHaveBeenCalled();
});
it('retries the worker API lookup after a 5xx response', async () => {
useFakeBackoffTimers();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({});
jest
.mocked(fetch)
.mockResolvedValueOnce(new ActualResponse('Service Unavailable', { status: 503 }))
.mockResolvedValueOnce(createApplicationResponse(buildId))
.mockResolvedValueOnce(
createSuccessfulResponse({ body: Buffer.from('hello'), url: 'https://cdn.test/app.apk' })
);
const execution = createStep(createSessionCtx(graphqlClient), {
build_id: buildId,
extensions: ['apk'],
}).executeAsync();
await fireBackoffTimersAsync(1);
await execution;
expect(jest.mocked(fetch)).toHaveBeenCalledTimes(3);
expect(jest.mocked(fetch).mock.calls.map(([url]) => url)).toEqual([
workerApiUrl,
workerApiUrl,
APPLICATION_ARCHIVE_URL,
]);
});
it('does not call the worker API for an application_archive_url input', async () => {
const graphqlClient = createMockGraphqlClient({});
jest.mocked(fetch).mockResolvedValue({
@@ -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,
}),
@@ -1,4 +1,4 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { SystemError } from '@expo/eas-build-job';
import { Client, fetchExchange } from '@urql/core';
import * as sandboxDaemon from '../../utils/sandboxDaemon';
@@ -6,90 +6,6 @@ import * as sandboxDaemon from '../../utils/sandboxDaemon';
import { createStartSandboxBuildFunction, markSandboxReadyAsync } from '../startSandbox';
describe('sandbox build functions', () => {
it.each([
{ completion: 'duration', duration: 0.02 },
{ completion: 'cancellation', duration: 60 },
{ completion: 'duration', duration: 0.0001 },
])(
'stops after $completion with $duration seconds only after readiness',
async ({ completion, duration }) => {
const controller = new AbortController();
let ready!: () => void;
const stopAsync = jest.fn(async () => {});
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync').mockResolvedValue({
ready: new Promise<void>(resolve => {
ready = resolve;
}),
stopAsync,
});
const mutation = jest.fn(() => ({ toPromise: async () => ({ data: {} }) }));
const logger = { info: jest.fn(), child: jest.fn() };
logger.child.mockReturnValue(logger);
const fn = createStartSandboxBuildFunction({
mcpServerUrl: 'ws://localhost:8787',
graphqlClient: { mutation },
} as any);
try {
const pending = fn.fn!({ logger } as any, {
inputs: {
sandbox_id: { value: 'sandbox-id' },
max_duration_seconds: { value: duration },
},
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
signal: controller.signal,
});
await new Promise(resolve => setTimeout(resolve, 40));
expect(mutation).not.toHaveBeenCalled();
expect(stopAsync).not.toHaveBeenCalled();
ready();
if (completion === 'cancellation') {
await new Promise(resolve => setImmediate(resolve));
expect(stopAsync).not.toHaveBeenCalled();
controller.abort();
}
await pending;
expect(mutation).toHaveBeenCalledTimes(1);
expect(stopAsync).toHaveBeenCalledTimes(1);
const timeoutMessage = `Sandbox reached its maximum runtime of ${duration} seconds. Stopping.`;
if (completion === 'duration') {
expect(logger.info).toHaveBeenCalledWith(timeoutMessage);
} else {
expect(logger.info).not.toHaveBeenCalledWith(timeoutMessage);
}
} finally {
start.mockRestore();
}
}
);
it.each([0, -1, Infinity, NaN])(
'rejects invalid maximum duration %s before starting the daemon',
async duration => {
const start = jest.spyOn(sandboxDaemon, 'startSandboxDaemonAsync');
const fn = createStartSandboxBuildFunction({
mcpServerUrl: 'ws://localhost:8787',
} as any);
try {
const result = fn.fn!({} as any, {
inputs: {
sandbox_id: { value: 'sandbox-id' },
max_duration_seconds: { value: duration },
},
outputs: {},
env: { __EAS_SANDBOX_MCP_TOKEN: 'token' },
});
await expect(result).rejects.toBeInstanceOf(UserError);
await expect(result).rejects.toThrow(
`Invalid sandbox maximum duration: ${duration}. Provide a finite number greater than zero, or omit max_duration_seconds.`
);
expect(start).not.toHaveBeenCalled();
} finally {
start.mockRestore();
}
}
);
it('aborts an active ready request and keeps its authentication headers', async () => {
const controller = new AbortController();
let requestStarted!: () => void;
@@ -1,104 +0,0 @@
import fs from 'fs-extra';
import nock from 'nock';
import { generateKeyPairSync } from 'node:crypto';
import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../../__tests__/utils/context';
import { getEasFunctions } from '../../easFunctions';
jest.unmock('node-fetch');
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const api = () => nock('https://api.appstoreconnect.apple.com');
beforeAll(() => nock.disableNetConnect());
afterAll(() => nock.enableNetConnect());
beforeEach(async () => {
await fs.outputJson('/project/key.json', {
key_id: 'TESTKEY',
key: privateKey.export({ type: 'pkcs8', format: 'pem' }),
});
});
afterEach(() => {
try {
expect(nock.pendingMocks()).toEqual([]);
} finally {
nock.cleanAll();
}
});
async function submitAsync(): Promise<void> {
const fn = getEasFunctions(createCustomBuildContextMock()).find(
fn => fn.getFullId() === 'eas/submit_testflight_beta_review'
)!;
await fn
.createBuildStepFromFunctionCall(
createGlobalContextMock({ projectTargetDirectory: '/project' }),
{
callInputs: { asc_api_key_path: 'key.json', build_upload_id: 'upload' },
}
)
.executeAsync();
}
function mockBuild(state = 'COMPLETE'): void {
api()
.get('/v1/buildUploads/upload')
.query(true)
.reply(200, {
data: {
type: 'buildUploads',
id: 'upload',
attributes: { state: { state } },
relationships: { build: { data: { type: 'builds', id: 'build' } } },
},
});
}
function mockReviewState(externalBuildState: string): void {
api()
.get('/v1/builds/build/buildBetaDetail')
.query(true)
.reply(200, {
data: {
id: 'detail',
attributes: { internalBuildState: 'READY_FOR_BETA_TESTING', externalBuildState },
},
});
}
it.each(['READY_FOR_BETA_SUBMISSION', 'IN_BETA_REVIEW', 'READY_FOR_BETA_TESTING'])(
'submits for review only when needed (%s)',
async state => {
mockBuild();
mockReviewState(state);
if (state === 'READY_FOR_BETA_SUBMISSION') {
api()
.post('/v1/betaAppReviewSubmissions', {
data: {
type: 'betaAppReviewSubmissions',
relationships: { build: { data: { type: 'builds', id: 'build' } } },
},
})
.reply(201, { data: { id: 'review', type: 'betaAppReviewSubmissions' } });
}
await expect(submitAsync()).resolves.toBeUndefined();
}
);
it('does not submit an unprocessed build', async () => {
mockBuild('PROCESSING');
await expect(submitAsync()).rejects.toThrow('not ready for beta review');
});
it('does not treat an arbitrary conflict as success', async () => {
mockBuild();
mockReviewState('READY_FOR_BETA_SUBMISSION');
api()
.post('/v1/betaAppReviewSubmissions')
.reply(409, {
errors: [{ status: '409', code: 'STATE_ERROR', detail: 'Missing beta review information' }],
});
await expect(submitAsync()).rejects.toThrow('Missing beta review information');
});
@@ -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();
@@ -0,0 +1,112 @@
import {
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
isSdkVersionIssueError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
describe(isClosedVersionTrainError, () => {
it('returns true when all errors are closed-version-train codes', () => {
expect(
isClosedVersionTrainError([
{ code: '90062' },
{ code: '90186' },
{ code: '90478' },
{ code: '90062' },
])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(
isClosedVersionTrainError([{ code: '90062' }, { code: '90186' }, { code: '12345' }])
).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isClosedVersionTrainError([])).toBe(false);
});
});
describe(isInvalidBundleIdentifierError, () => {
it('returns true when all errors are invalid-bundle-id codes', () => {
expect(
isInvalidBundleIdentifierError([{ code: '90054' }, { code: '90055' }, { code: '90055' }])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isInvalidBundleIdentifierError([{ code: '90055' }, { code: '90713' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isInvalidBundleIdentifierError([])).toBe(false);
});
});
describe(isMissingPurposeStringError, () => {
it('returns true when all errors are missing-purpose-string codes', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90054' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isMissingPurposeStringError([])).toBe(false);
});
});
describe(isSdkVersionIssueError, () => {
it('returns true when all errors are SDK-version-issue codes', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90725' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90062' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isSdkVersionIssueError([])).toBe(false);
});
});
describe(parseMissingUsageDescriptionKeys, () => {
it('extracts missing UsageDescription keys from ASC messages', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSPhotoLibraryUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSPhotoLibraryUsageDescription', 'NSCameraUsageDescription']);
});
it('deduplicates repeated keys', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSCameraUsageDescription']);
});
it('returns an empty array when no usage description key is present', () => {
expect(
parseMissingUsageDescriptionKeys([{ description: 'Some other upload validation error.' }])
).toEqual([]);
});
});
@@ -1,253 +0,0 @@
import fs from 'fs-extra';
import nock from 'nock';
import { generateKeyPairSync } from 'node:crypto';
import { setTimeout } from 'node:timers/promises';
import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { getEasFunctions } from '../../easFunctions';
import { readIpaInfoAsync } from '../readIpaInfo';
import { createUploadToAscBuildFunction } from '../uploadToAsc';
jest.unmock('node-fetch');
jest.mock('node:timers/promises', () => ({ setTimeout: jest.fn().mockResolvedValue(undefined) }));
jest.mock('../readIpaInfo', () => ({
...jest.requireActual('../readIpaInfo'),
readIpaInfoAsync: jest.fn(),
}));
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const api = () => nock('https://api.appstoreconnect.apple.com');
const logger = createMockLogger();
const globalContext = () => createGlobalContextMock({ projectTargetDirectory: '/project', logger });
async function waitAsync(diagnostics: Record<string, string | number> = {}): Promise<void> {
const fn = getEasFunctions(createCustomBuildContextMock()).find(
fn => fn.getFullId() === 'eas/wait_for_asc_build_processing'
)!;
await fn
.createBuildStepFromFunctionCall(globalContext(), {
callInputs: { asc_api_key_path: 'key.json', build_upload_id: 'upload', ...diagnostics },
})
.executeAsync();
}
async function uploadAsync(waitForProcessing?: boolean): Promise<void> {
await fs.outputFile('/project/app.ipa', 'ipa');
jest.mocked(readIpaInfoAsync).mockResolvedValue({
bundleIdentifier: 'com.test.ipa',
bundleVersion: '1',
bundleShortVersion: '1.0',
dtPlatformName: 'iphoneos',
});
mockUpload();
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(globalContext(), {
callInputs: {
ipa_path: 'app.ipa',
asc_api_key_path: 'key.json',
apple_app_identifier: 'app',
bundle_version: '1',
bundle_short_version: '1.0',
...(waitForProcessing === undefined ? {} : { wait_for_processing: waitForProcessing }),
},
});
await step.executeAsync();
}
function mockProcessing(state: string, errors: { code: string; description: string }[] = []): void {
api()
.get('/v1/buildUploads/upload')
.query(true)
.reply(200, {
data: { type: 'buildUploads', id: 'upload', attributes: { state: { state, errors } } },
});
}
function mockApp(): void {
api()
.get('/v1/apps/app')
.query(true)
.reply(200, {
data: { type: 'apps', id: 'app', attributes: { name: 'Test app', bundleId: 'com.test.app' } },
});
}
function mockUpload(): void {
mockApp();
api()
.post('/v1/buildUploads')
.reply(201, { data: { type: 'buildUploads', id: 'upload' } });
api()
.post('/v1/buildUploadFiles')
.reply(201, {
data: {
type: 'buildUploadFiles',
id: 'file',
attributes: {
uploadOperations: [
{
length: 3,
offset: 0,
partNumber: 1,
method: 'PUT',
requestHeaders: [],
url: 'https://upload.example.com/ipa',
},
],
},
},
});
nock('https://upload.example.com').put('/ipa', 'ipa').reply(200);
api()
.get('/v1/buildUploadFiles/file')
.query(true)
.twice()
.reply(200, {
data: {
type: 'buildUploadFiles',
id: 'file',
attributes: { assetDeliveryState: { state: 'COMPLETE' } },
},
});
}
beforeAll(() => nock.disableNetConnect());
afterAll(() => nock.enableNetConnect());
beforeEach(async () => {
await fs.outputJson('/project/key.json', {
key_id: 'TESTKEY',
key: privateKey.export({ type: 'pkcs8', format: 'pem' }),
});
jest.mocked(setTimeout).mockResolvedValue(undefined);
});
afterEach(() => {
try {
expect(nock.pendingMocks()).toEqual([]);
} finally {
nock.cleanAll();
jest.restoreAllMocks();
}
});
it('polls an existing upload through awaiting and processing states until complete', async () => {
mockProcessing('AWAITING_UPLOAD');
mockProcessing('PROCESSING');
mockProcessing('COMPLETE');
await expect(waitAsync()).resolves.toBeUndefined();
});
it('reports a processing timeout instead of success when Apple is still processing', async () => {
let now = 0;
jest.spyOn(Date, 'now').mockImplementation(() => now);
jest.mocked(setTimeout).mockImplementation(async () => {
now += 2000;
});
mockProcessing('PROCESSING');
await expect(waitAsync({ wait_processing_timeout_seconds: 1 })).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_PROCESSING_TIMEOUT',
});
});
it('uses the supplied bundle identifier in mismatch errors without an IPA', async () => {
mockProcessing(
'FAILED',
['90054', '90055'].map(code => ({ code, description: 'Bundle mismatch' }))
);
mockApp();
api().get('/v1/apps').query(true).reply(200, { data: [] });
await expect(
waitAsync({ bundle_identifier: 'com.test.ipa', apple_app_identifier: 'app' })
).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
message: expect.stringContaining(
'IPA bundle identifier: com.test.ipa\nApp Store Connect app bundle identifier: com.test.app'
),
});
});
it.each<[string, string[], string]>([
['SDK version', ['90725'], 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE'],
['closed version train', ['90062', '90186', '90478'], 'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN'],
])('reports a %s processing error', async (_label, codes, errorCode) => {
mockProcessing(
'FAILED',
codes.map(code => ({ code, description: 'Rejected' }))
);
await expect(waitAsync()).rejects.toMatchObject({ errorCode });
});
it.each(
[
[],
['OTHER'],
['90725', 'OTHER'],
['90062', 'OTHER'],
['90054', 'OTHER'],
['90683', 'OTHER'],
].map(codes => ({ codes }))
)('keeps unclassified or mixed processing errors generic: $codes', async ({ codes }) => {
mockProcessing(
'FAILED',
codes.map(code => ({ code, description: 'Rejected' }))
);
await expect(waitAsync()).rejects.toThrow('Build upload (ID: upload) failed.');
});
it('reports missing privacy keys once each', async () => {
mockProcessing('FAILED', [
{
code: '90683',
description: 'Missing NSCameraUsageDescription and NSPhotoLibraryUsageDescription.',
},
{ code: '90683', description: 'Missing NSCameraUsageDescription.' },
]);
await expect(waitAsync()).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
message: expect.stringContaining(
'Missing keys reported by App Store Connect:\n- NSCameraUsageDescription\n- NSPhotoLibraryUsageDescription\n\n'
),
});
});
it('reports missing privacy purpose strings when Apple does not name a key', async () => {
mockProcessing('FAILED', [{ code: '90683', description: 'Missing purpose string.' }]);
await expect(waitAsync()).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
message: expect.not.stringContaining('Missing keys reported by App Store Connect:'),
});
});
it.each([undefined, false, true])(
'keeps upload wait_for_processing=%s compatible',
async waitForProcessing => {
if (waitForProcessing) {
mockProcessing('PROCESSING');
mockProcessing('FAILED', [{ code: '90725', description: 'SDK is too old' }]);
}
if (waitForProcessing) {
await expect(uploadAsync(waitForProcessing)).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
});
} else {
await expect(uploadAsync(waitForProcessing)).resolves.toBeUndefined();
}
}
);
it.each(['upload', 'wait'])(
'reports the default processing timeout for the %s step',
async step => {
mockProcessing('PROCESSING');
const start = Date.now();
jest.spyOn(Date, 'now').mockReturnValue(start);
jest.mocked(setTimeout).mockImplementationOnce(async () => {
jest.spyOn(Date, 'now').mockReturnValue(start + 30 * 60 * 1000);
});
await expect(step === 'upload' ? uploadAsync(true) : waitAsync()).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_PROCESSING_TIMEOUT',
});
}
);
@@ -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);
},
});
}
@@ -7,7 +7,7 @@ import {
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { Client, CombinedError } from '@urql/core';
import { Client } from '@urql/core';
import contentDisposition from 'content-disposition';
import { glob } from 'fast-glob';
import { graphql } from 'gql.tada';
@@ -26,16 +26,10 @@ import { CustomBuildContext } from '../../customBuildContext';
import { formatBytes } from '../../utils/artifacts';
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import {
describeError,
isTransferFailure,
isTransientGraphqlError,
} from '../../utils/networkErrors';
import { retryOnNetworkFailureAsync } from '../../utils/retryOnNetworkFailure';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
import { pluralize } from '../../utils/strings';
import {
type DeviceRunSessionWorkerApi,
DeviceRunSessionWorkerApiError,
getDeviceRunSessionWorkerApi,
} from '../utils/deviceRunSessionWorkerApi';
@@ -158,34 +152,21 @@ async function fetchApplicationArchiveUrlAsync({
signal,
});
}
let data;
try {
data = await retryOnNetworkFailureAsync(
async () => {
const queryResult = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
if (queryResult.error) {
throw queryResult.error;
}
return queryResult.data;
},
{
operation: 'download_build.build_lookup',
signal,
isRetryable: error => error instanceof CombinedError && isTransientGraphqlError(error),
}
);
} catch (error) {
signal?.throwIfAborted();
const message = `Could not fetch build ${buildId}: ${describeError(error)}`;
throw error instanceof CombinedError && !isTransientGraphqlError(error)
? new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error })
: new SystemError(message, { cause: error });
}
const result = await graphqlClient
.query(BUILD_BY_ID_QUERY, { buildId }, graphqlAbortContext(signal))
.toPromise();
signal?.throwIfAborted();
const applicationArchiveUrl = data?.builds.byId?.artifacts?.applicationArchiveUrl;
if (result.error) {
const { error } = result;
const message = `Could not fetch build ${buildId}: ${error.message}`;
throw error.networkError || result.error.response?.status >= 500
? new SystemError(message, { cause: error })
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
}
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
if (!applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
@@ -214,18 +195,13 @@ async function fetchDeviceRunSessionApplicationArchiveUrlAsync({
}): Promise<string> {
let application;
try {
application = await retryOnNetworkFailureAsync(
() => deviceRunSessionWorkerApi.getApplicationAsync({ signal }),
{
operation: 'download_build.session_build_lookup',
signal,
isRetryable: error => error instanceof DeviceRunSessionWorkerApiError && error.isTransient,
}
);
application = await deviceRunSessionWorkerApi.getApplicationAsync({ signal });
} catch (err) {
signal?.throwIfAborted();
throw new SystemError(
`Could not fetch build ${buildId} for this device run session: ${describeError(err)}`,
`Could not fetch build ${buildId} for this device run session: ${
err instanceof Error ? err.message : String(err)
}`,
{ cause: err }
);
}
@@ -305,34 +281,18 @@ export async function downloadBuildAsync(
);
signal?.throwIfAborted();
let archivePath: string;
try {
archivePath = await retryOnNetworkFailureAsync(
async () => {
const response = await fetch(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
throw new Error(
`Unexpected response from server (${response.status}): ${textResult.value}`
);
}
const archiveFilename = resolveArchiveFilename({ response, extensions });
const attemptArchivePath = path.join(downloadDestinationDirectory, archiveFilename);
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
await streamPipeline(response.body, fs.createWriteStream(attemptArchivePath));
return attemptArchivePath;
},
{ operation: 'download_build.archive_download', signal, isRetryable: isTransferFailure }
);
} catch (error) {
signal?.throwIfAborted();
if (params.buildId && isTransferFailure(error)) {
throw new SystemError(`Could not download the application archive: ${describeError(error)}`, {
cause: error,
});
}
throw error;
const response = await retryOnDNSFailure(fetch)(downloadUrl, { headers, signal });
if (!response.ok) {
const textResult = await asyncResult(response.text());
throw new Error(`Unexpected response from server (${response.status}): ${textResult.value}`);
}
const archiveFilename = resolveArchiveFilename({ response, extensions });
const archivePath = path.join(downloadDestinationDirectory, archiveFilename);
// On abort, node-fetch emits an error on the body stream, which fails the pipeline.
await streamPipeline(response.body, fs.createWriteStream(archivePath));
signal?.throwIfAborted();
const { size } = await fs.promises.stat(archivePath);
@@ -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,
@@ -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)}).`);
}
@@ -1,4 +1,4 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { SystemError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import { graphql } from 'gql.tada';
@@ -32,11 +32,6 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_duration_seconds',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async (stepCtx, { inputs, signal, env }) => {
// The daemon needs this credential, but shell commands do not.
@@ -49,18 +44,6 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
throw new SystemError('MCP server URL is required to start the sandbox daemon.');
}
const sandboxId = String(inputs.sandbox_id.value);
const maxDurationSeconds = inputs.max_duration_seconds?.value;
if (
maxDurationSeconds !== undefined &&
(typeof maxDurationSeconds !== 'number' ||
!Number.isFinite(maxDurationSeconds) ||
maxDurationSeconds <= 0)
) {
throw new UserError(
'EAS_SANDBOX_INVALID_DURATION',
`Invalid sandbox maximum duration: ${String(maxDurationSeconds)}. Provide a finite number greater than zero, or omit max_duration_seconds.`
);
}
const daemon = await startSandboxDaemonAsync({
credential: sandboxToken,
serverUrl: mcpServerUrl,
@@ -80,21 +63,7 @@ export function createStartSandboxBuildFunction(ctx: CustomBuildContext): BuildF
stepCtx.logger.info('Sandbox daemon started.');
await withLogPhaseAsync(stepCtx.logger, AWAIT_SANDBOX_STEP_NAME, async () => {
const timeoutSignal =
maxDurationSeconds === undefined
? undefined
: AbortSignal.timeout(Math.ceil(maxDurationSeconds * 1000));
await waitForCompletionAsync(
AbortSignal.any([
...(signal ? [signal] : []),
...(timeoutSignal ? [timeoutSignal] : []),
])
);
if (timeoutSignal?.aborted) {
stepCtx.logger.info(
`Sandbox reached its maximum runtime of ${maxDurationSeconds} seconds. Stopping.`
);
}
await waitForCancellationAsync(signal);
});
} finally {
await daemon.stopAsync();
@@ -120,11 +89,11 @@ export async function markSandboxReadyAsync(
}
}
async function waitForCompletionAsync(signal: AbortSignal): Promise<void> {
if (signal.aborted) {
async function waitForCancellationAsync(signal?: AbortSignal): Promise<void> {
if (signal?.aborted) {
return;
}
await new Promise<void>(resolve => {
signal.addEventListener('abort', () => resolve(), { once: true });
signal?.addEventListener('abort', () => resolve(), { once: true });
});
}
@@ -1,72 +0,0 @@
import { UserError } from '@expo/eas-build-job';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import path from 'node:path';
import { z } from 'zod';
import { AscApiClient } from '../utils/ios/AscApiClient';
import { AscApiUtils } from '../utils/ios/AscApiUtils';
export function createSubmitTestFlightBetaReviewBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'submit_testflight_beta_review',
name: 'Submit for TestFlight beta review',
__metricsId: 'eas/submit_testflight_beta_review',
inputProviders: [
BuildStepInput.createProvider({
id: 'asc_api_key_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'build_upload_id',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (ctx, { inputs }) => {
const { ascApiKeyPath, buildUploadId } = z
.object({ ascApiKeyPath: z.string(), buildUploadId: z.string() })
.parse({
ascApiKeyPath: inputs.asc_api_key_path.value,
buildUploadId: inputs.build_upload_id.value,
});
const keyPath = path.resolve(ctx.workingDirectory, ascApiKeyPath);
const key = await AscApiUtils.loadApiKeyAsync({ keyPath });
const client = new AscApiClient({ key, logger: ctx.logger });
const { data: upload } = await client.getAsync(
'/v1/buildUploads/:id',
{
'fields[buildUploads]': ['state', 'build'],
include: ['build'],
},
{ id: buildUploadId }
);
const buildId = upload.relationships?.build?.data?.id;
if (upload.attributes.state.state !== 'COMPLETE' || !buildId) {
throw new UserError(
'EAS_TESTFLIGHT_BUILD_NOT_READY',
'The uploaded build is not ready for beta review. Run eas/wait_for_asc_build_processing before this step.'
);
}
const { data } = await client.getAsync('/v1/builds/:id/buildBetaDetail', {}, { id: buildId });
const reviewedStates = [
'WAITING_FOR_BETA_REVIEW',
'IN_BETA_REVIEW',
'READY_FOR_BETA_TESTING',
'IN_BETA_TESTING',
];
if (reviewedStates.includes(data.attributes.externalBuildState ?? '')) {
ctx.logger.info('This build is already in beta review or available for external testing.');
return;
}
await client.postAsync('/v1/betaAppReviewSubmissions', {
data: {
type: 'betaAppReviewSubmissions',
relationships: { build: { data: { type: 'builds', id: buildId } } },
},
});
ctx.logger.info('Build submitted for beta review.');
},
});
}
@@ -93,7 +93,7 @@ export async function updateTestFlightMetadataAsync({
if (upload.attributes?.state?.state !== 'COMPLETE' || !buildId) {
throw new UserError(
'EAS_TESTFLIGHT_BUILD_NOT_READY',
'The uploaded build is not ready for TestFlight metadata. Run eas/wait_for_asc_build_processing first, or use eas/upload_to_asc with wait_for_processing: true.'
'The uploaded build is not ready for TestFlight metadata. Run eas/upload_to_asc with wait_for_processing: true first.'
);
}
logger.info(`Updating TestFlight metadata for Apple build ${buildId}...`);
@@ -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;
}
@@ -1,3 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
@@ -14,7 +15,6 @@ import { z } from 'zod';
import { AscApiClient, AscApiClientPostApi } from '../utils/ios/AscApiClient';
import { AscApiUtils } from '../utils/ios/AscApiUtils';
import { readIpaInfoAsync } from './readIpaInfo';
import { waitForAscBuildProcessingAsync } from './waitForAscBuildProcessing';
export function createUploadToAscBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -184,10 +184,10 @@ export function createUploadToAscBuildFunction(): BuildFunction {
const { errors = [], warnings = [] } = assetDeliveryState;
if (warnings.length > 0) {
stepsCtx.logger.warn(`Warnings:\n${AscApiUtils.itemizeMessages(warnings)}\n`);
stepsCtx.logger.warn(`Warnings:\n${itemizeMessages(warnings)}\n`);
}
if (errors.length > 0) {
stepsCtx.logger.error(`Errors:\n${AscApiUtils.itemizeMessages(errors)}\n`);
stepsCtx.logger.error(`Errors:\n${itemizeMessages(errors)}\n`);
}
if (assetDeliveryState.state === 'FAILED') {
@@ -208,19 +208,165 @@ export function createUploadToAscBuildFunction(): BuildFunction {
return;
}
await waitForAscBuildProcessingAsync({
client,
buildUploadId,
logger: stepsCtx.logger,
ipaBundleIdentifier: ipaInfoResult.ok ? ipaInfoResult.value.bundleIdentifier : undefined,
appleAppIdentifier: undefined,
ascAppBundleIdentifier,
bundleShortVersion,
});
stepsCtx.logger.info('Checking build upload status...');
const waitingForBuildStartedAt = Date.now();
const waitingLogIntervalMs = 10 * 1000;
let lastWaitLogTime = 0;
let lastWaitLogState: string | null = null;
while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) {
const {
data: {
attributes: { state },
},
} = await client.getAsync(
`/v1/buildUploads/:id`,
{ 'fields[buildUploads]': ['state', 'build'], include: ['build'] },
{ id: buildUploadId }
);
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
const now = Date.now();
if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) {
stepsCtx.logger.info(
`Waiting for build upload to complete... (status = ${state.state})`
);
lastWaitLogTime = now;
lastWaitLogState = state.state;
}
await setTimeout(2000);
continue;
}
stepsCtx.logger.info('\n');
const { errors = [], warnings = [], infos = [] } = state;
if (infos.length > 0) {
stepsCtx.logger.info(`Infos:\n${itemizeMessages(infos)}\n`);
}
if (warnings.length > 0) {
stepsCtx.logger.warn(`Warnings:\n${itemizeMessages(warnings)}\n`);
}
if (errors.length > 0) {
stepsCtx.logger.error(`Errors:\n${itemizeMessages(errors)}\n`);
}
if (state.state === 'FAILED') {
if (isSdkVersionIssueError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
{
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
? ipaInfoResult.value.bundleIdentifier
: null;
let visibleAppsSummary: string | null = null;
try {
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
} catch {
// Ok to fail, this is just trying to be helpful.
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` +
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
'\n\nOther App Store Connect apps visible to this API key:\n' +
(visibleAppsSummary ?? ' (unable to retrieve)'),
{
docsUrl: 'https://expo.fyi/asc-app-id',
}
);
}
if (isMissingPurposeStringError(errors)) {
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
throw new UserError(
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
`${
missingUsageDescriptionKeys.length > 0
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
'\n- '
)}\n\n`
: ''
}` +
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
'If you do not use CNG, update your app target Info.plist directly.',
{
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
}
);
}
if (isClosedVersionTrainError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
`Build upload was rejected by App Store Connect because the ${bundleShortVersion} app version is not accepted for new build submissions. ` +
'This usually means the version train is closed or lower than a previously approved version. ' +
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
);
}
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
} else if (state.state === 'COMPLETE') {
stepsCtx.logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
}
break;
}
},
});
}
function itemizeMessages(messages: { description: string; code: string }[]): string {
return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`;
}
export function isClosedVersionTrainError(messages: { code: string }[]): boolean {
return (
messages.length > 0 &&
messages.every(message => ['90062', '90186', '90478'].includes(message.code))
);
}
export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90725');
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
);
}
export function isMissingPurposeStringError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90683');
}
export function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] {
const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g;
const keys = new Set<string>();
for (const message of messages) {
const matches = message.description.matchAll(usageDescriptionKeyRegex);
for (const match of matches) {
keys.add(match[1]);
}
}
return [...keys];
}
async function uploadChunksAsync({
uploadOperations,
ipaPath,
@@ -1,261 +0,0 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { AscApiClient } from '../utils/ios/AscApiClient';
import { AscApiUtils } from '../utils/ios/AscApiUtils';
export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'wait_for_asc_build_processing',
name: 'Wait for App Store Connect processing',
__metricsId: 'eas/wait_for_asc_build_processing',
inputProviders: [
BuildStepInput.createProvider({
id: 'wait_processing_timeout_seconds',
required: false,
defaultValue: 30 * 60,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
BuildStepInput.createProvider({
id: 'asc_api_key_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'build_upload_id',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'bundle_identifier',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'apple_app_identifier',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'bundle_short_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (ctx, { inputs }) => {
const parsedInputs = z
.object({
asc_api_key_path: z.string(),
build_upload_id: z.string(),
wait_processing_timeout_seconds: z.number().finite().positive(),
bundle_identifier: z.string().optional(),
apple_app_identifier: z.string().optional(),
bundle_short_version: z.string().optional(),
})
.parse({
asc_api_key_path: inputs.asc_api_key_path.value,
build_upload_id: inputs.build_upload_id.value,
wait_processing_timeout_seconds: inputs.wait_processing_timeout_seconds.value,
bundle_identifier: inputs.bundle_identifier.value,
apple_app_identifier: inputs.apple_app_identifier.value,
bundle_short_version: inputs.bundle_short_version.value,
});
const keyPath = path.resolve(ctx.workingDirectory, parsedInputs.asc_api_key_path);
const key = await AscApiUtils.loadApiKeyAsync({ keyPath });
const client = new AscApiClient({ key, logger: ctx.logger });
await waitForAscBuildProcessingAsync({
client,
buildUploadId: parsedInputs.build_upload_id,
processingTimeoutSeconds: parsedInputs.wait_processing_timeout_seconds,
logger: ctx.logger,
ipaBundleIdentifier: parsedInputs.bundle_identifier,
appleAppIdentifier: parsedInputs.apple_app_identifier,
ascAppBundleIdentifier: undefined,
bundleShortVersion: parsedInputs.bundle_short_version,
});
},
});
}
export async function waitForAscBuildProcessingAsync({
client,
buildUploadId,
logger,
ipaBundleIdentifier,
appleAppIdentifier,
ascAppBundleIdentifier,
bundleShortVersion,
processingTimeoutSeconds = 30 * 60,
}: {
client: AscApiClient;
buildUploadId: string;
logger: bunyan;
ipaBundleIdentifier: string | undefined;
appleAppIdentifier: string | undefined;
ascAppBundleIdentifier: string | undefined;
bundleShortVersion: string | undefined;
processingTimeoutSeconds?: number;
}): Promise<void> {
logger.info('Checking build upload status...');
const waitingForBuildStartedAt = Date.now();
const waitingLogIntervalMs = 10 * 1000;
let lastWaitLogTime = 0;
let lastWaitLogState: string | null = null;
while (Date.now() - waitingForBuildStartedAt < processingTimeoutSeconds * 1000) {
const {
data: {
attributes: { state },
},
} = await client.getAsync(
`/v1/buildUploads/:id`,
{ 'fields[buildUploads]': ['state', 'build'], include: ['build'] },
{ id: buildUploadId }
);
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
const now = Date.now();
if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) {
logger.info(`Waiting for build upload to complete... (status = ${state.state})`);
lastWaitLogTime = now;
lastWaitLogState = state.state;
}
await setTimeout(2000);
continue;
}
logger.info('\n');
const { errors = [], warnings = [], infos = [] } = state;
if (infos.length > 0) {
logger.info(`Infos:\n${AscApiUtils.itemizeMessages(infos)}\n`);
}
if (warnings.length > 0) {
logger.warn(`Warnings:\n${AscApiUtils.itemizeMessages(warnings)}\n`);
}
if (errors.length > 0) {
logger.error(`Errors:\n${AscApiUtils.itemizeMessages(errors)}\n`);
}
if (state.state === 'FAILED') {
if (isSdkVersionIssueError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
{
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
if (!ascAppBundleIdentifier && appleAppIdentifier) {
try {
const app = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
ascAppBundleIdentifier = app.data.attributes.bundleId;
} catch {
// Keep the processing error if app details are unavailable.
}
}
let visibleAppsSummary: string | null = null;
try {
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
} catch {
// Ok to fail, this is just trying to be helpful.
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier ?? '(unavailable)'}\n\n` +
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
'\n\nOther App Store Connect apps visible to this API key:\n' +
(visibleAppsSummary ?? ' (unable to retrieve)'),
{
docsUrl: 'https://expo.fyi/asc-app-id',
}
);
}
if (isMissingPurposeStringError(errors)) {
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
throw new UserError(
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
`${
missingUsageDescriptionKeys.length > 0
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
'\n- '
)}\n\n`
: ''
}` +
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
'If you do not use CNG, update your app target Info.plist directly.',
{
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
}
);
}
if (isClosedVersionTrainError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
`Build upload was rejected by App Store Connect because the ${bundleShortVersion ?? 'selected'} app version is not accepted for new build submissions. ` +
'This usually means the version train is closed or lower than a previously approved version. ' +
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
);
}
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
} else if (state.state === 'COMPLETE') {
logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
return;
}
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_PROCESSING_TIMEOUT',
`Apple did not finish processing build upload ${buildUploadId} within ${processingTimeoutSeconds} seconds. ` +
'The binary was uploaded. Check its status in App Store Connect before uploading it again, or increase wait_processing_timeout_seconds.'
);
}
function isClosedVersionTrainError(messages: { code: string }[]): boolean {
return (
messages.length > 0 &&
messages.every(message => ['90062', '90186', '90478'].includes(message.code))
);
}
function isSdkVersionIssueError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90725');
}
function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
);
}
function isMissingPurposeStringError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90683');
}
function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] {
const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g;
const keys = new Set<string>();
for (const message of messages) {
const matches = message.description.matchAll(usageDescriptionKeyRegex);
for (const match of matches) {
keys.add(match[1]);
}
}
return [...keys];
}
@@ -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);
@@ -332,16 +330,14 @@ describe(DeviceRunSessionWorkerApi, () => {
});
it('reports a request that failed before www responded', async () => {
const reason = new Error('socket hang up');
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
jest.mocked(turtleFetch).mockRejectedValueOnce(new Error('socket hang up'));
const error = await createApi()
.getSessionAsync({ retries: 0 })
.getSessionAsync()
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
expect(error.status).toBeUndefined();
expect(error.cause).toBe(reason);
expect(error.message).toContain('failed before www responded: socket hang up');
});
@@ -350,7 +346,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 +354,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,7 +165,6 @@ 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();
});
@@ -215,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);
});
@@ -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');
});
@@ -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;
};
@@ -90,16 +88,11 @@ export class DeviceRunSessionWorkerApiError extends Error {
constructor(
message: string,
/** The HTTP status of the response, or undefined when no response arrived. */
readonly status: number | undefined,
options?: ErrorOptions
readonly status: number | undefined
) {
super(message, options);
super(message);
this.name = 'DeviceRunSessionWorkerApiError';
}
get isTransient(): boolean {
return this.status === undefined || this.status >= 500;
}
}
/**
@@ -191,44 +184,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(
@@ -242,7 +207,7 @@ export class DeviceRunSessionWorkerApi {
...(json ? { json } : {}),
...(signal ? { signal } : {}),
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
retries: 0,
retries,
shouldThrowOnNotOk: false,
logger,
})
@@ -253,8 +218,7 @@ export class DeviceRunSessionWorkerApi {
`${routeDescription} failed before www responded: ${
reason instanceof Error ? reason.message : String(reason)
}. Check the network connection of the worker.`,
undefined,
{ cause: reason }
undefined
);
}
const response = responseResult.value;
@@ -271,15 +235,6 @@ export class DeviceRunSessionWorkerApi {
);
}
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
@@ -43,7 +43,6 @@ const GetApi = {
data: z.object({
id: z.string(),
attributes: z.object({
externalBuildState: z.string().optional(),
internalBuildState: z.enum([
'PROCESSING',
'PROCESSING_EXCEPTION',
@@ -218,19 +217,6 @@ const GetApi = {
} satisfies ApiSchema;
const PostApi = {
'/v1/betaAppReviewSubmissions': {
request: z.object({
data: z.object({
type: z.literal('betaAppReviewSubmissions'),
relationships: z.object({
build: z.object({ data: z.object({ type: z.literal('builds'), id: z.string() }) }),
}),
}),
}),
response: z.object({
data: z.object({ id: z.string(), type: z.literal('betaAppReviewSubmissions') }),
}),
},
// https://developer.apple.com/documentation/appstoreconnectapi/post-v1-builds-_id_-relationships-betagroups
'/v1/builds/:id/relationships/betaGroups': {
path: z.object({ id: z.string() }),
@@ -302,8 +302,4 @@ export namespace AscApiUtils {
.join('\n') || ' (none)'
);
}
export function itemizeMessages(messages: { description: string; code: string }[]): string {
return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`;
}
}
@@ -560,10 +560,7 @@ export async function uploadRemoteSessionConfigAsync({
);
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
try {
await workerApi.startAsync(
{ remoteConfig },
{ retries: 3, timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS, logger }
);
await workerApi.startAsync({ remoteConfig });
} catch (err) {
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${
@@ -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}`;
}
@@ -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,16 +1,4 @@
import { CombinedError } from '@urql/core';
import {
describeError,
findErrorCode,
findGraphqlErrorCode,
isConnectFailure,
isConnectionInterruptedError,
isTransferFailure,
isTransientGraphqlError,
} from '../networkErrors';
const { FetchError } = jest.requireActual('node-fetch') as typeof import('node-fetch');
import { isConnectFailure, isConnectionInterruptedError } from '../networkErrors';
it.each([
'EAI_AGAIN',
@@ -20,7 +8,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 +18,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,82 +29,8 @@ 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);
});
it('finds the errno code on the cause of an undici fetch failure', () => {
const error = new CombinedError({
networkError: new TypeError('fetch failed', {
cause: Object.assign(new Error('read ECONNRESET'), { code: 'ECONNRESET' }),
}),
});
expect(findErrorCode(error)).toBe('ECONNRESET');
expect(findErrorCode(new Error('Request failed'))).toBeUndefined();
expect(findErrorCode('Request failed')).toBeUndefined();
});
it('finds the www error code on a GraphQL error', () => {
const error = new CombinedError({
graphQLErrors: [
{ message: 'Too many requests', extensions: { errorCode: 'TOO_MANY_REQUESTS' } },
],
});
expect(findGraphqlErrorCode(error)).toBe('TOO_MANY_REQUESTS');
expect(
findGraphqlErrorCode(new CombinedError({ graphQLErrors: ['Build not found'] }))
).toBeUndefined();
expect(findGraphqlErrorCode(new Error('Request failed'))).toBeUndefined();
});
it.each([
['Request failed', 'ECONNRESET', 'Request failed (ECONNRESET)'],
['read ECONNRESET', 'ECONNRESET', 'read ECONNRESET'],
['Request failed', undefined, 'Request failed'],
])('describes %p with code %p as %p', (message, code, expected) => {
expect(describeError(Object.assign(new Error(message), { code }))).toBe(expected);
});
it('treats a missing response, a 5xx response or an isTransient GraphQL error as transient', () => {
expect(
isTransientGraphqlError(new CombinedError({ networkError: new Error('fetch failed') }))
).toBe(true);
expect(
isTransientGraphqlError(
new CombinedError({ graphQLErrors: ['Internal Server Error'], response: { status: 503 } })
)
).toBe(true);
expect(
isTransientGraphqlError(
new CombinedError({
graphQLErrors: [{ message: 'Database unavailable', extensions: { isTransient: true } }],
response: { status: 200 },
})
)
).toBe(true);
expect(
isTransientGraphqlError(
new CombinedError({ graphQLErrors: ['Build not found'], response: { status: 200 } })
)
).toBe(false);
});
it.each([
[
'a node-fetch socket failure',
new FetchError('request failed', 'system', { code: 'ECONNRESET' } as any),
true,
],
['a node-fetch body size failure', new FetchError('too large', 'max-size'), false],
['a body reset', Object.assign(new Error('aborted'), { code: 'ECONNRESET' }), true],
[
'a body closed early',
Object.assign(new Error('closed'), { code: 'ERR_STREAM_PREMATURE_CLOSE' }),
true,
],
['an HTTP error response', new Error('Unexpected response from server (500)'), false],
])('classifies %s as a transfer failure or not', (_label, error, expected) => {
expect(isTransferFailure(error)).toBe(expected);
});
@@ -1,5 +1,3 @@
import { CombinedError } from '@urql/core';
const CONNECT_FAILURE_CODES = new Set([
'EAI_AGAIN',
'ECONNREFUSED',
@@ -8,14 +6,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,60 +24,9 @@ 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;
}
}
return false;
}
/** Errno code of the error or its causes; undici keeps it on cause, urql on networkError. */
export function findErrorCode(error: unknown): string | undefined {
let current: unknown = error;
while (current instanceof Error) {
if ('code' in current && typeof current.code === 'string') {
return current.code;
}
current = current instanceof CombinedError ? current.networkError : current.cause;
}
return undefined;
}
export function findGraphqlErrorCode(error: unknown): string | undefined {
if (!(error instanceof CombinedError)) {
return undefined;
}
const code = error.graphQLErrors.at(0)?.extensions.errorCode;
return typeof code === 'string' ? code : undefined;
}
export function describeError(error: unknown): string {
const message = error instanceof Error ? error.message : String(error);
const code = findErrorCode(error);
return code && !message.includes(code) ? `${message} (${code})` : message;
}
export function isTransientGraphqlError(error: CombinedError): boolean {
return (
Boolean(error.networkError) ||
error.response?.status >= 500 ||
error.graphQLErrors.some(graphqlError => graphqlError.extensions.isTransient === true)
);
}
// node-fetch throws type 'system' for a socket failure before the headers; after them it forwards
// ECONNRESET or ERR_STREAM_PREMATURE_CLOSE to the body. HTTP error responses never throw.
export function isTransferFailure(error: unknown): boolean {
return (
(error instanceof Error &&
error.name === 'FetchError' &&
'type' in error &&
error.type === 'system') ||
isConnectionInterruptedError(error) ||
findErrorCode(error) === 'ERR_STREAM_PREMATURE_CLOSE'
);
}
@@ -1,53 +0,0 @@
import { Datadog } from '../datadog';
import { findErrorCode, findGraphqlErrorCode } from './networkErrors';
import { promiseRetryWithCondition } from './promiseRetryWithCondition';
export async function retryOnNetworkFailureAsync<T>(
attemptAsync: () => Promise<T>,
{
operation,
signal,
isRetryable,
}: {
operation: string;
signal?: AbortSignal;
isRetryable: (error: unknown) => boolean;
}
): Promise<T> {
const shouldRetry = (error: unknown): boolean => !signal?.aborted && isRetryable(error);
let attempts = 1;
let lastError: unknown;
const report = (outcome: 'recovered' | 'failed', error: unknown): void => {
Datadog.log(`${operation} ${outcome} on attempt ${attempts}`, {
event: 'network_retry',
operation,
outcome,
attempts: String(attempts),
code: findErrorCode(error) ?? findGraphqlErrorCode(error) ?? 'unknown',
});
};
try {
const result = await promiseRetryWithCondition(
async () => {
signal?.throwIfAborted();
return await attemptAsync();
},
shouldRetry,
{ retries: 5, minTimeout: 1_000, factor: 2 },
({ attemptNumber, error }) => {
attempts = attemptNumber;
lastError = error;
}
)();
if (attempts > 1) {
report('recovered', lastError);
}
return result;
} catch (error) {
if (attempts > 1 && !signal?.aborted) {
report('failed', error);
}
throw error;
}
}
+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;
};
+731 -725
View File
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -252,7 +252,7 @@
},
"update": {
"node": {
"version": "22.23.4"
"version": "22.23.3"
},
"s3": {
"templates": {
+6 -5
View File
@@ -1,4 +1,3 @@
import { ExpoConfig } from '@expo/config';
import { Env, Platform } from '@expo/eas-build-job';
import { BuildProfile, EasJson, ResourceClass } from '@expo/eas-json';
import JsonFile from '@expo/json-file';
@@ -14,6 +13,7 @@ import { createIosContextAsync } from './ios/build';
import { LocalBuildMode, LocalBuildOptions } from './local';
import { resolveBuildResourceClass } from './utils/resourceClass';
import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/AnalyticsManager';
import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CredentialsContext } from '../credentials/context';
import { LazyExperimentationClient } from '../experimentation/LazyExperimentationClient';
@@ -40,8 +40,7 @@ export async function createBuildContextAsync<T extends Platform>({
graphqlClient,
analytics,
vcsClient,
exp,
projectId,
getDynamicPrivateProjectConfigAsync,
customBuildConfigMetadata,
buildLoggerLevel,
freezeCredentials,
@@ -67,8 +66,7 @@ export async function createBuildContextAsync<T extends Platform>({
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
vcsClient: Client;
exp: ExpoConfig;
projectId: string;
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
customBuildConfigMetadata?: CustomBuildConfigMetadata;
buildLoggerLevel?: LoggerLevel;
freezeCredentials: boolean;
@@ -79,6 +77,9 @@ export async function createBuildContextAsync<T extends Platform>({
experimentation?: LazyExperimentationClient;
authenticationInfo?: LoggedInAuthenticationInfo;
}): Promise<BuildContext<T>> {
const { exp, projectId } = await getDynamicPrivateProjectConfigAsync({
env,
});
const projectName = exp.slug;
const [account, workflow] = await Promise.all([
+15 -19
View File
@@ -119,7 +119,7 @@ export async function runBuildAndSubmitAsync({
actor,
getDynamicPrivateProjectConfigAsync,
downloadSimBuildAutoConfirm,
evaluatedConfig,
envOverride,
experimentation,
authenticationInfo,
}: {
@@ -131,7 +131,7 @@ export async function runBuildAndSubmitAsync({
actor: Actor;
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
downloadSimBuildAutoConfirm?: boolean;
evaluatedConfig?: { env: Env; exp: ExpoConfig; projectId: string };
envOverride?: Env;
/** Enables the Free plan limit experiments on the build request error path. */
experimentation?: LazyExperimentationClient;
/** Lets the Free plan limit checkout experiment create a checkout session. */
@@ -224,15 +224,15 @@ export async function runBuildAndSubmitAsync({
for (const buildProfile of buildProfiles) {
const platform = toAppPlatform(buildProfile.platform);
const { env, exp, projectId } =
evaluatedConfig ??
(await evaluateConfigWithEnvVarsAsync({
buildProfile: buildProfile.profile,
buildProfileName: buildProfile.profileName,
graphqlClient,
getProjectConfig: getDynamicPrivateProjectConfigAsync,
opts: { env: buildProfile.profile.env },
}));
const { env } = !envOverride
? await evaluateConfigWithEnvVarsAsync({
buildProfile: buildProfile.profile,
buildProfileName: buildProfile.profileName,
graphqlClient,
getProjectConfig: getDynamicPrivateProjectConfigAsync,
opts: { env: buildProfile.profile.env },
})
: { env: envOverride };
const { build: maybeBuild, buildCtx } = await prepareAndStartBuildAsync({
projectDir,
@@ -244,8 +244,7 @@ export async function runBuildAndSubmitAsync({
graphqlClient,
analytics,
vcsClient,
exp,
projectId,
getDynamicPrivateProjectConfigAsync,
customBuildConfigMetadata: customBuildConfigMetadataByPlatform[platform],
env,
easJsonAccessor,
@@ -385,8 +384,7 @@ async function prepareAndStartBuildAsync({
graphqlClient,
analytics,
vcsClient,
exp,
projectId,
getDynamicPrivateProjectConfigAsync,
customBuildConfigMetadata,
env,
easJsonAccessor,
@@ -402,8 +400,7 @@ async function prepareAndStartBuildAsync({
graphqlClient: ExpoGraphqlClient;
analytics: Analytics;
vcsClient: Client;
exp: ExpoConfig;
projectId: string;
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
customBuildConfigMetadata?: CustomBuildConfigMetadata;
easJsonAccessor: EasJsonAccessor;
env: Env;
@@ -426,8 +423,7 @@ async function prepareAndStartBuildAsync({
graphqlClient,
analytics,
vcsClient,
exp,
projectId,
getDynamicPrivateProjectConfigAsync,
customBuildConfigMetadata,
buildLoggerLevel: flags.buildLoggerLevel ?? (Log.isDebug ? LoggerLevel.DEBUG : undefined),
freezeCredentials: flags.freezeCredentials,
@@ -3,7 +3,7 @@ import { ExpoConfig } from '@expo/config';
import ContextField, { ContextOptions } from './ContextField';
import { createGraphqlClient } from './contextUtils/createGraphqlClient';
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync, isProjectIdInAppConfig } from './contextUtils/getProjectIdAsync';
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
import {
ExpoConfigOptions,
@@ -18,57 +18,83 @@ export type DynamicConfigContextFn = (options?: ExpoConfigOptions) => Promise<{
}>;
export class DynamicPublicProjectConfigContextField extends ContextField<DynamicConfigContextFn> {
async getValueAsync(options: ContextOptions): Promise<DynamicConfigContextFn> {
return await createDynamicConfigFnAsync(getPublicExpoConfigAsync, options);
async getValueAsync({
nonInteractive,
sessionManager,
withServerSideEnvironment,
}: ContextOptions): Promise<DynamicConfigContextFn> {
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
return async (options?: ExpoConfigOptions) => {
const expBefore = await getPublicExpoConfigAsync(projectDir, options);
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
nonInteractive,
env: options?.env,
});
if (withServerSideEnvironment) {
const { authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive,
});
const graphqlClient = createGraphqlClient(authenticationInfo);
const serverSideEnvironmentVariables = await loadServerSideEnvironmentVariablesAsync({
environment: withServerSideEnvironment,
projectId,
graphqlClient,
});
options = {
...options,
env: {
...options?.env,
...serverSideEnvironmentVariables,
},
};
}
const exp = await getPublicExpoConfigAsync(projectDir, options);
return {
exp,
projectDir,
projectId,
};
};
}
}
export class DynamicPrivateProjectConfigContextField extends ContextField<DynamicConfigContextFn> {
async getValueAsync(options: ContextOptions): Promise<DynamicConfigContextFn> {
return await createDynamicConfigFnAsync(getPrivateExpoConfigAsync, options);
}
}
async function createDynamicConfigFnAsync(
getExpoConfigAsync: (projectDir: string, options?: ExpoConfigOptions) => Promise<ExpoConfig>,
{ nonInteractive, sessionManager, withServerSideEnvironment }: ContextOptions
): Promise<DynamicConfigContextFn> {
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
return async (options?: ExpoConfigOptions) => {
const expBefore = await getExpoConfigAsync(projectDir, options);
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
nonInteractive,
env: options?.env,
});
if (withServerSideEnvironment) {
const { authenticationInfo } = await sessionManager.ensureLoggedInAsync({
async getValueAsync({
nonInteractive,
sessionManager,
withServerSideEnvironment,
}: ContextOptions): Promise<DynamicConfigContextFn> {
const projectDir = await findProjectDirAndVerifyProjectSetupAsync();
return async (options?: ExpoConfigOptions) => {
const expBefore = await getPrivateExpoConfigAsync(projectDir, options);
const projectId = await getProjectIdAsync(sessionManager, expBefore, {
nonInteractive,
env: options?.env,
});
const graphqlClient = createGraphqlClient(authenticationInfo);
const serverSideEnvironmentVariables = await loadServerSideEnvironmentVariablesAsync({
environment: withServerSideEnvironment,
projectId,
graphqlClient,
});
options = {
...options,
env: {
...options?.env,
...serverSideEnvironmentVariables,
},
};
} else if (isProjectIdInAppConfig(expBefore, projectId)) {
if (withServerSideEnvironment) {
const { authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive,
});
const graphqlClient = createGraphqlClient(authenticationInfo);
const serverSideEnvironmentVariables = await loadServerSideEnvironmentVariablesAsync({
environment: withServerSideEnvironment,
projectId,
graphqlClient,
});
options = {
...options,
env: {
...options?.env,
...serverSideEnvironmentVariables,
},
};
}
const exp = await getPrivateExpoConfigAsync(projectDir, options);
return {
exp: expBefore,
exp,
projectDir,
projectId,
};
}
const exp = await getExpoConfigAsync(projectDir, options);
return {
exp,
projectDir,
projectId,
};
};
}
}
@@ -1,5 +1,5 @@
import ContextField, { ContextOptions } from './ContextField';
import { ExpoGraphqlClient } from './contextUtils/createGraphqlClient';
import { ExpoGraphqlClient, createGraphqlClient } from './contextUtils/createGraphqlClient';
import { LoggedInAuthenticationInfo } from '../../user/SessionManager';
import { Actor } from '../../user/User';
import FeatureGateEnvOverrides from '../gating/FeatureGateEnvOverrides';
@@ -22,7 +22,7 @@ export default class LoggedInContextField extends ContextField<LoggedInContextTy
});
const featureGateServerValues: { [key: string]: boolean } = actor?.featureGates ?? {};
const graphqlClient = sessionManager.getGraphqlClient(authenticationInfo);
const graphqlClient = createGraphqlClient(authenticationInfo);
return {
actor,
@@ -1,5 +1,5 @@
import ContextField, { ContextOptions } from './ContextField';
import { ExpoGraphqlClient } from './contextUtils/createGraphqlClient';
import { ExpoGraphqlClient, createGraphqlClient } from './contextUtils/createGraphqlClient';
import { Actor } from '../../user/User';
import FeatureGateEnvOverrides from '../gating/FeatureGateEnvOverrides';
import FeatureGating from '../gating/FeatureGating';
@@ -21,7 +21,7 @@ export default class MaybeLoggedInContextField extends ContextField<MaybeLoggedI
sessionSecret: sessionManager.getSessionSecret(),
};
const graphqlClient = sessionManager.getGraphqlClient(authenticationInfo);
const graphqlClient = createGraphqlClient(authenticationInfo);
const actor = await sessionManager.getUserAsync();
const featureGateServerValues: { [key: string]: boolean } = actor?.featureGates ?? {};
@@ -4,7 +4,7 @@ import { InvalidEasJsonError } from '@expo/eas-json/build/errors';
import ContextField, { ContextOptions } from './ContextField';
import { createGraphqlClient } from './contextUtils/createGraphqlClient';
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync, isProjectIdInAppConfig } from './contextUtils/getProjectIdAsync';
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
@@ -58,10 +58,7 @@ export class OptionalPrivateProjectConfigContextField extends ContextField<
});
serverSideEnvVars = serverSideEnvironmentVariables;
}
const exp =
withServerSideEnvironment || !isProjectIdInAppConfig(expBefore, projectId)
? await getPrivateExpoConfigAsync(projectDir, { env: serverSideEnvVars })
: expBefore;
const exp = await getPrivateExpoConfigAsync(projectDir, { env: serverSideEnvVars });
return {
exp,
projectDir,
@@ -3,7 +3,7 @@ import { ExpoConfig } from '@expo/config';
import ContextField, { ContextOptions } from './ContextField';
import { createGraphqlClient } from './contextUtils/createGraphqlClient';
import { findProjectDirAndVerifyProjectSetupAsync } from './contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync, isProjectIdInAppConfig } from './contextUtils/getProjectIdAsync';
import { getProjectIdAsync } from './contextUtils/getProjectIdAsync';
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
@@ -39,10 +39,7 @@ export class PrivateProjectConfigContextField extends ContextField<{
});
serverSideEnvVars = serverSideEnvironmentVariables;
}
const exp =
withServerSideEnvironment || !isProjectIdInAppConfig(expBefore, projectId)
? await getPrivateExpoConfigAsync(projectDir, { env: serverSideEnvVars })
: expBefore;
const exp = await getPrivateExpoConfigAsync(projectDir, { env: serverSideEnvVars });
return {
projectId,
@@ -1,63 +0,0 @@
import nock from 'nock';
import { AnalyticsWithOrchestration } from '../../../analytics/AnalyticsManager';
import { getExpoApiBaseUrl } from '../../../api';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import SessionManager from '../../../user/SessionManager';
import { ContextOptions } from '../ContextField';
import LoggedInContextField from '../LoggedInContextField';
import { getProjectIdAsync } from '../contextUtils/getProjectIdAsync';
const app = {
__typename: 'App',
id: 'PROJECT_ID',
name: 'app',
fullName: '@owner/app',
slug: 'app',
ownerAccount: { __typename: 'Account', id: 'ACCOUNT_ID', name: 'owner' },
githubRepository: null,
};
beforeEach(() => {
nock.disableNetConnect();
});
afterEach(() => {
nock.cleanAll();
nock.enableNetConnect();
});
describe(LoggedInContextField, () => {
it('shares the GraphQL cache with the project ID check', async () => {
const analytics: AnalyticsWithOrchestration = {
logEvent: jest.fn(),
setActor: jest.fn(),
flushAsync: jest.fn(),
getDeviceId: jest.fn(() => null),
};
const sessionManager = new SessionManager(analytics);
jest.spyOn(sessionManager, 'ensureLoggedInAsync').mockResolvedValue({
actor: { __typename: 'User', id: 'USER_ID', username: 'owner' } as any,
authenticationInfo: { accessToken: 'token', sessionSecret: null },
});
const scope = nock(getExpoApiBaseUrl())
.post('/graphql')
.once()
.reply(200, { data: { app: { __typename: 'AppQuery', byId: app } } });
const projectId = await getProjectIdAsync(
sessionManager,
{ name: 'app', slug: 'app', extra: { eas: { projectId: 'PROJECT_ID' } } },
{ nonInteractive: true }
);
const { graphqlClient } = await new LoggedInContextField().getValueAsync({
sessionManager,
nonInteractive: true,
} as ContextOptions);
expect(await AppQuery.byIdAsync(graphqlClient, projectId)).toMatchObject({
fullName: '@owner/app',
});
expect(scope.isDone()).toBe(true);
});
});
@@ -1,144 +0,0 @@
import { ExpoConfig } from '@expo/config';
import { anything, instance, mock, when } from 'ts-mockito';
import { getPrivateExpoConfigAsync, getPublicExpoConfigAsync } from '../../../project/expoConfig';
import SessionManager from '../../../user/SessionManager';
import { ContextOptions } from '../ContextField';
import {
DynamicPrivateProjectConfigContextField,
DynamicPublicProjectConfigContextField,
} from '../DynamicProjectConfigContextField';
import { OptionalPrivateProjectConfigContextField } from '../OptionalPrivateProjectConfigContextField';
import { PrivateProjectConfigContextField } from '../PrivateProjectConfigContextField';
import { findProjectDirAndVerifyProjectSetupAsync } from '../contextUtils/findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from '../contextUtils/getProjectIdAsync';
import { loadServerSideEnvironmentVariablesAsync } from '../contextUtils/loadServerSideEnvironmentVariablesAsync';
jest.mock('../../../project/expoConfig');
jest.mock('../contextUtils/findProjectDirAndVerifyProjectSetupAsync');
jest.mock('../contextUtils/getProjectIdAsync', () => ({
...jest.requireActual('../contextUtils/getProjectIdAsync'),
getProjectIdAsync: jest.fn(),
}));
jest.mock('../contextUtils/loadServerSideEnvironmentVariablesAsync');
jest.mock('../contextUtils/createGraphqlClient');
const projectId = 'test-project-id';
const linkedExp = { name: 'a', slug: 'a', extra: { eas: { projectId } } } as ExpoConfig;
const unlinkedExp = { name: 'a', slug: 'a' } as ExpoConfig;
type LoadConfigAsync = (
options: ContextOptions
) => Promise<{ exp: ExpoConfig; projectId: string } | undefined>;
const fields: [string, LoadConfigAsync, typeof getPrivateExpoConfigAsync][] = [
[
DynamicPublicProjectConfigContextField.name,
async options =>
await (
await new DynamicPublicProjectConfigContextField().getValueAsync(options)
)(),
getPublicExpoConfigAsync,
],
[
DynamicPrivateProjectConfigContextField.name,
async options =>
await (
await new DynamicPrivateProjectConfigContextField().getValueAsync(options)
)(),
getPrivateExpoConfigAsync,
],
[
PrivateProjectConfigContextField.name,
async options => await new PrivateProjectConfigContextField().getValueAsync(options),
getPrivateExpoConfigAsync,
],
[
OptionalPrivateProjectConfigContextField.name,
async options => await new OptionalPrivateProjectConfigContextField().getValueAsync(options),
getPrivateExpoConfigAsync,
],
];
function createContextOptions(withServerSideEnvironment: string | null): ContextOptions {
const sessionManager = mock<SessionManager>();
when(sessionManager.ensureLoggedInAsync(anything())).thenResolve({
authenticationInfo: { accessToken: null, sessionSecret: null },
} as any);
return {
nonInteractive: true,
sessionManager: instance(sessionManager),
withServerSideEnvironment,
} as ContextOptions;
}
describe.each(fields)('%s', (_name, loadConfigAsync, getExpoConfigAsync) => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(findProjectDirAndVerifyProjectSetupAsync).mockResolvedValue('/app');
jest.mocked(getProjectIdAsync).mockResolvedValue(projectId);
});
it('reads the app config once when the project ID is in it', async () => {
jest.mocked(getExpoConfigAsync).mockResolvedValue(linkedExp);
const result = await loadConfigAsync(createContextOptions(null));
expect(result).toMatchObject({ exp: linkedExp, projectId });
expect(getExpoConfigAsync).toHaveBeenCalledTimes(1);
});
it('reads the app config again after the project ID is written to it', async () => {
jest
.mocked(getExpoConfigAsync)
.mockResolvedValueOnce(unlinkedExp)
.mockResolvedValueOnce(linkedExp);
const result = await loadConfigAsync(createContextOptions(null));
expect(result?.exp).toBe(linkedExp);
expect(getExpoConfigAsync).toHaveBeenCalledTimes(2);
});
it('reads the app config again with server-side environment variables', async () => {
jest.mocked(getExpoConfigAsync).mockResolvedValue(linkedExp);
jest.mocked(loadServerSideEnvironmentVariablesAsync).mockResolvedValue({ B: '2' });
await loadConfigAsync(createContextOptions('production'));
expect(getExpoConfigAsync).toHaveBeenCalledTimes(2);
expect(getExpoConfigAsync).toHaveBeenLastCalledWith('/app', { env: { B: '2' } });
});
it('reads the app config again when the server-side environment is empty', async () => {
jest.mocked(getExpoConfigAsync).mockResolvedValue(linkedExp);
jest.mocked(loadServerSideEnvironmentVariablesAsync).mockResolvedValue({});
await loadConfigAsync(createContextOptions('production'));
expect(getExpoConfigAsync).toHaveBeenCalledTimes(2);
});
});
describe(DynamicPrivateProjectConfigContextField, () => {
beforeEach(() => {
jest.resetAllMocks();
jest.mocked(findProjectDirAndVerifyProjectSetupAsync).mockResolvedValue('/app');
jest.mocked(getProjectIdAsync).mockResolvedValue(projectId);
});
it('merges server-side environment variables into the env passed in', async () => {
jest.mocked(getPrivateExpoConfigAsync).mockResolvedValue(linkedExp);
jest.mocked(loadServerSideEnvironmentVariablesAsync).mockResolvedValue({ B: '2' });
const getConfigAsync = await new DynamicPrivateProjectConfigContextField().getValueAsync(
createContextOptions('production')
);
await getConfigAsync({ env: { A: '1' } });
expect(getPrivateExpoConfigAsync).toHaveBeenNthCalledWith(1, '/app', { env: { A: '1' } });
expect(getPrivateExpoConfigAsync).toHaveBeenNthCalledWith(2, '/app', {
env: { A: '1', B: '2' },
});
});
});
@@ -10,7 +10,6 @@ import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../
import { isExpoInstalled } from '../../../../project/projectUtils';
import { promptAsync } from '../../../../prompts';
import SessionManager from '../../../../user/SessionManager';
import { ExpoGraphqlClient } from '../createGraphqlClient';
import { findProjectRootAsync } from '../findProjectDirAndVerifyProjectSetupAsync';
import { getProjectIdAsync } from '../getProjectIdAsync';
@@ -41,8 +40,6 @@ describe(getProjectIdAsync, () => {
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
__typename: 'User',
@@ -189,7 +186,6 @@ describe(getProjectIdAsync, () => {
it('throws when the owner is not specified and is different than logged in user which is a robot', async () => {
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
__typename: 'Robot',
@@ -414,7 +410,6 @@ describe(getProjectIdAsync, () => {
it('uses the only account without prompting when the user has a single account', async () => {
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
__typename: 'User',
@@ -453,7 +448,6 @@ describe(getProjectIdAsync, () => {
it('throws for a robot user when no owner is set', async () => {
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
__typename: 'Robot',
@@ -515,7 +509,6 @@ describe(getProjectIdAsync, () => {
it('defers to the project fetch in non-interactive mode when the account is already known', async () => {
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.getGraphqlClient(anything())).thenReturn({} as ExpoGraphqlClient);
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
__typename: 'User',
@@ -3,7 +3,7 @@ import { Env } from '@expo/eas-build-job';
import chalk from 'chalk';
import semver from 'semver';
import { ExpoGraphqlClient } from './createGraphqlClient';
import { ExpoGraphqlClient, createGraphqlClient } from './createGraphqlClient';
import { findProjectRootAsync } from './findProjectDirAndVerifyProjectSetupAsync';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import Log, { learnMore } from '../../../log';
@@ -92,16 +92,12 @@ export async function getProjectIdAsync(
const { actor, authenticationInfo } = await sessionManager.ensureLoggedInAsync({
nonInteractive: options.nonInteractive,
});
const graphqlClient = sessionManager.getGraphqlClient(authenticationInfo);
const graphqlClient = createGraphqlClient(authenticationInfo);
const projectId = await validateOrSetProjectIdAsync({ exp, graphqlClient, actor, options });
return projectId;
}
export function isProjectIdInAppConfig(exp: Pick<ExpoConfig, 'extra'>, projectId: string): boolean {
return exp.extra?.eas?.projectId === projectId;
}
export async function validateOrSetProjectIdAsync({
exp,
graphqlClient,
+1 -1
View File
@@ -33,7 +33,7 @@ const PROJECT_PAGES: Record<string, string> = {
export default class Browse extends EasCommand {
static override description =
'transition from the terminal to the web browser to view and interact with your project on https://expo.dev';
'Transition from the terminal to the web browser to view and interact with your project on https://expo.dev';
static override args = {
page: Args.string({
+3 -3
View File
@@ -109,7 +109,7 @@ export default class BuildDev extends EasCommand {
const workflow = await resolveWorkflowAsync(projectDir, platform, vcsClient);
const evaluatedConfig = await evaluateConfigWithEnvVarsAsync({
const { env } = await evaluateConfigWithEnvVarsAsync({
buildProfile: buildProfile.profile,
buildProfileName: buildProfile.profileName,
graphqlClient,
@@ -118,7 +118,7 @@ export default class BuildDev extends EasCommand {
});
const fingerprint = await createFingerprintAsync(projectDir, {
env: evaluatedConfig.env,
env,
workflow,
platforms: [platform],
});
@@ -197,7 +197,7 @@ export default class BuildDev extends EasCommand {
actor,
getDynamicPrivateProjectConfigAsync,
downloadSimBuildAutoConfirm: true,
evaluatedConfig,
envOverride: env,
});
if (!flags['skip-bundler']) {
await this.startDevServerAsync({ projectDir, platform });
@@ -13,7 +13,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
import { pollForBackgroundJobReceiptAsync } from '../../utils/pollForBackgroundJobReceiptAsync';
export default class ChannelDelete extends EasCommand {
static override description = 'delete a channel';
static override description = 'Delete a channel';
static override args = {
name: Args.string({
@@ -50,7 +50,7 @@ type ChannelRolloutArgsAndFlags = {
Partial<CreateRolloutNonInteractiveOptions>;
export default class ChannelRollout extends EasCommand {
static override description = 'roll a new branch out on a channel incrementally';
static override description = 'Roll a new branch out on a channel incrementally.';
static override args = {
channel: Args.string({
@@ -7,7 +7,7 @@ import { SetUpBuildCredentialsCommandAction } from '../../credentials/manager/Se
import { selectPlatformAsync } from '../../platform';
export default class InitializeBuildCredentials extends EasCommand {
static override description = 'set up credentials for building your project';
static override description = 'Set up credentials for building your project.';
static override flags = {
platform: Flags.option({
@@ -22,7 +22,7 @@ interface DeployAliasDeleteFlags {
}
export default class WorkerAliasDelete extends EasCommand {
static override description = 'delete deployment aliases';
static override description = 'Delete deployment aliases.';
static override aliases = ['worker:alias:delete'];
static override state = 'preview';
@@ -39,7 +39,7 @@ interface RawDeployAliasFlags {
}
export default class WorkerAlias extends EasCommand {
static override description = 'assign deployment aliases';
static override description = 'Assign deployment aliases.';
static override aliases = ['worker:alias', 'deploy:promote'];
static override state = 'preview';
@@ -23,7 +23,7 @@ interface RawDeployDeleteFlags {
}
export default class WorkerDelete extends EasCommand {
static override description = 'delete a deployment';
static override description = 'Delete a deployment.';
static override aliases = ['worker:delete'];
static override state = 'preview';
+1 -1
View File
@@ -100,7 +100,7 @@ async function withSuppressedOutputAsync<T>(fn: () => Promise<T>): Promise<T> {
/* eslint-enable no-console */
export default class Go extends EasCommand {
static override description = 'create a custom Expo Go and submit to TestFlight';
static override description = 'Create a custom Expo Go and submit to TestFlight';
static override hidden = true;
static override flags = {
@@ -22,7 +22,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class ObserveEvent extends EasCommand {
static override description =
'display a single Observe event (metric, log, or error) by its ID. IDs are included in event data when the `--json` flag is passed to `eas observe:session`, `eas observe:metrics`, or `eas observe:events`';
'display a single Observe event (metric, log, or error) by its ID. IDs are included in event data when the `--json` flag is passed to `eas observe:session`, `eas observe:metrics`, or `eas observe:events`.';
static override args = {
id: Args.string({
@@ -36,7 +36,7 @@ const DEFAULT_EVENTS_LIMIT = 10;
export default class ObserveEvents extends EasCommand {
static override description =
'display individual events emitted by the app via `logEvent`, filtered by the event name in the argument. With no arguments, a list of the available event names and associated event counts is returned';
'display individual events emitted by the app via `logEvent`, filtered by the event name in the argument. With no arguments, a list of the available event names and associated event counts is returned.';
static override args = {
eventName: Args.string({
+1 -1
View File
@@ -134,7 +134,7 @@ export default class New extends EasCommand {
static override aliases = ['new'];
static override description =
'create a new project configured with Expo Application Services (EAS)';
'Create a new project configured with Expo Application Services (EAS)';
static override args = {
path: Args.string({
@@ -35,7 +35,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class SimulatorControl extends EasCommand {
static override aliases = ['sim:control'];
static override description =
'[EXPERIMENTAL] run a serve-sim action on a remote iOS simulator session on EAS. Run without an action to list the actions the session supports. To save a screenshot locally, run `eas simulator:control screenshot.capture --output ./screenshot.png`';
'[EXPERIMENTAL] run a serve-sim action on a remote iOS simulator session on EAS. Run without an action to list the actions the session supports. To save a screenshot locally, run `eas simulator:control screenshot.capture --output ./screenshot.png`.';
static override strict = false;
static override examples = [
@@ -39,7 +39,7 @@ type DeviceRunSessionArtifact = DeviceRunSessionById['artifacts'][number];
export default class SimulatorGet extends EasCommand {
static override aliases = ['sim:get'];
static override description =
"[EXPERIMENTAL] get info about a remote simulator session on EAS by its simulator session ID. Preview links follow the session's saved sharing preference";
"[EXPERIMENTAL] get info about a remote simulator session on EAS by its simulator session ID. Preview links follow the session's saved sharing preference.";
static override flags = {
id: Flags.string({
@@ -83,7 +83,7 @@ export default class Simulator extends EasCommand {
static override aliases = ['simulator:start', 'sim:start'];
static override hiddenAliases = ['sim'];
static override description =
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it. The default browser preview link requires sign-in';
'[EXPERIMENTAL] start a remote simulator session on EAS and get instructions to connect to it. The default browser preview link requires sign-in.';
static override flags = {
platform: Flags.option({
@@ -9,7 +9,7 @@ import { promptAsync } from '../../prompts';
export default class WorkflowRunCancel extends EasCommand {
static override description =
'cancel one or more workflow runs. If no workflow run IDs are provided, you will be prompted to select IN_PROGRESS runs to cancel';
'Cancel one or more workflow runs. If no workflow run IDs are provided, you will be prompted to select IN_PROGRESS runs to cancel.';
static override strict = false;
@@ -9,7 +9,7 @@ import { enableJsonOutput, printJsonOnlyOutput } from '../../utils/json';
export default class WorkflowList extends EasCommand {
static override hidden = true;
static override description = 'list workflows for the current project';
static override description = 'List workflows for the current project';
static override flags = {
...EasJsonOnlyFlag,
@@ -26,7 +26,7 @@ function printLogsForAllSteps(logs: JobLogs): void {
export default class WorkflowLogView extends EasCommand {
static override description =
'view logs for a workflow run, selecting a job and step to view. You can pass in either a workflow run ID or a job ID. If no ID is passed in, you will be prompted to select from recent workflow runs for the current project';
'view logs for a workflow run, selecting a job and step to view. You can pass in either a workflow run ID or a job ID. If no ID is passed in, you will be prompted to select from recent workflow runs for the current project.';
static override flags = {
...EasJsonOnlyFlag,
Loaded 100 of 126 files, more files were not shown because too many files have changed in this diff. Show more