Compare commits

..
Author SHA1 Message Date
Stanisław Chmiela e507735166 [worker] Add a packaged startup wrapper 2026-10-22 13:17:51 +02:00
83 changed files with 1977 additions and 2489 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.
@@ -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.
-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.
-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.
@@ -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 @@
---
'eas-cli': patch
---
Share one GraphQL client between the project ID check and the command, so commands send one fewer app query.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': patch
---
Allow an App Store Connect processing timeout to be configured and report an error when processing does not finish before the deadline.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Unify signed URL uploading to a single implementation and use it everywhere
-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
@@ -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,
});
@@ -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',
@@ -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,
}),
@@ -121,32 +121,26 @@ describe(createUploadArtifactBuildFunction, () => {
expect(typeInput.isRawValueOneOfAllowedValues()).toBe(false);
});
it.each(['other', 'android-apk', 'android-aab', 'ios-simulator-app', 'ios-ipa'])(
'uploads %s as a workflow artifact with metadata',
async type => {
contextUploadArtifact.mockClear();
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(
createGlobalContextMock({}),
{
callInputs: {
type,
key: 'Screen Recording',
path: '/',
metadata,
},
}
);
it('passes the metadata input through to runtimeApi.uploadArtifact', async () => {
contextUploadArtifact.mockClear();
const metadata = { kind: 'screen-recording', startedAtMs: '123', device: 'emulator-5554' };
const buildStep = uploadArtifact.createBuildStepFromFunctionCall(createGlobalContextMock({}), {
callInputs: {
// Generic ('other') artifact: only generic artifacts persist metadata in the worker
// (the managed-type upload paths drop it), so that's the type metadata is meant for.
type: 'other',
key: 'Screen Recording',
path: '/',
metadata,
},
});
await buildStep.executeAsync();
await buildStep.executeAsync();
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({
artifact: expect.objectContaining({ type: null, metadata }),
})
);
}
);
expect(contextUploadArtifact).toHaveBeenCalledWith(
expect.objectContaining({ artifact: expect.objectContaining({ metadata }) })
);
});
it('does not attach metadata to managed artifact types (the worker drops it)', async () => {
contextUploadArtifact.mockClear();
@@ -1,78 +0,0 @@
import fs from 'fs-extra';
import nock from 'nock';
import { generateKeyPairSync } from 'node:crypto';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createUploadToAscBuildFunction } from '../uploadToAsc';
jest.unmock('node-fetch');
jest.mock('../readIpaInfo', () => ({
readIpaInfoAsync: async () => ({ dtPlatformName: 'iphoneos' }),
}));
beforeAll(() => nock.disableNetConnect());
afterAll(() => nock.enableNetConnect());
afterEach(() => {
try {
expect(nock.pendingMocks()).toEqual([]);
} finally {
nock.cleanAll();
}
});
it.each(['COMPLETE', 'PROCESSING'] as const)(
'only reports success when Apple processing is COMPLETE (received %s)',
async state => {
await fs.ensureDir('/tmp');
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
await fs.writeJson('/tmp/key.json', {
key_id: 'TESTKEY',
key: privateKey.export({ type: 'pkcs8', format: 'pem' }),
});
await fs.writeFile('/tmp/app.ipa', 'test');
const api = nock('https://api.appstoreconnect.apple.com');
api
.get('/v1/apps/app')
.query(true)
.reply(200, {
data: { id: 'app', type: 'apps', attributes: { name: 'App', bundleId: 'dev.test' } },
});
api.post('/v1/buildUploads').reply(201, { data: { id: 'upload', type: 'buildUploads' } });
api.post('/v1/buildUploadFiles').reply(201, {
data: { id: 'file', type: 'buildUploadFiles', attributes: { uploadOperations: [] } },
});
const file = {
data: {
id: 'file',
type: 'buildUploadFiles',
attributes: { assetDeliveryState: { state: 'COMPLETE' } },
},
};
api.get('/v1/buildUploadFiles/file').query(true).twice().reply(200, file);
api
.get('/v1/buildUploads/upload')
.query(true)
.reply(200, {
data: { id: 'upload', type: 'buildUploads', attributes: { state: { state } } },
});
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(
createGlobalContextMock(),
{
callInputs: {
ipa_path: '/tmp/app.ipa',
asc_api_key_path: '/tmp/key.json',
apple_app_identifier: 'app',
bundle_version: '1',
bundle_short_version: '1.0',
wait_for_processing: true,
wait_processing_timeout_seconds: 1,
},
}
);
if (state === 'COMPLETE') {
await expect(step.executeAsync()).resolves.toBeUndefined();
} else {
await expect(step.executeAsync()).rejects.toThrow('Apple did not finish processing');
}
}
);
@@ -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);
},
});
}
@@ -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,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;
}
@@ -55,12 +55,6 @@ export function createUploadToAscBuildFunction(): BuildFunction {
defaultValue: false,
allowedValueTypeName: BuildStepInputValueTypeName.BOOLEAN,
}),
BuildStepInput.createProvider({
id: 'wait_processing_timeout_seconds',
required: false,
defaultValue: 30 * 60,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
@@ -79,11 +73,6 @@ export function createUploadToAscBuildFunction(): BuildFunction {
const bundleVersion = z.string().parse(inputs.bundle_version.value);
const bundleShortVersion = z.string().parse(inputs.bundle_short_version.value);
const waitForProcessing = Boolean(inputs.wait_for_processing.value);
const processingTimeoutSeconds = z
.number()
.int()
.positive()
.parse(inputs.wait_processing_timeout_seconds.value);
const ipaPath = path.resolve(stepsCtx.workingDirectory, ipaPathInput);
if (!(await fs.pathExists(ipaPath))) {
@@ -224,7 +213,7 @@ export function createUploadToAscBuildFunction(): BuildFunction {
const waitingLogIntervalMs = 10 * 1000;
let lastWaitLogTime = 0;
let lastWaitLogState: string | null = null;
while (Date.now() - waitingForBuildStartedAt < processingTimeoutSeconds * 1000) {
while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) {
const {
data: {
attributes: { state },
@@ -333,14 +322,9 @@ export function createUploadToAscBuildFunction(): BuildFunction {
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
} else if (state.state === 'COMPLETE') {
stepsCtx.logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
return;
}
break;
}
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.'
);
},
});
}
@@ -18,14 +18,7 @@ import {
} from '../deviceRunSessionPreview';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
jest.mock('node-fetch', () => {
const actual = jest.requireActual('node-fetch');
return {
__esModule: true,
...actual,
default: jest.fn(),
};
});
jest.mock('node-fetch');
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
@@ -94,8 +87,8 @@ describe(startDeviceRunSessionPreview, () => {
'https://uploads.expo.test/preview',
expect.objectContaining({
method: 'PUT',
body: expect.any(Readable),
headers: { 'content-type': 'image/webp', 'content-length': String(image.length) },
body: image,
headers: { 'content-type': 'image/webp' },
})
);
await jest.advanceTimersByTimeAsync(59_999);
@@ -137,7 +130,9 @@ describe(startDeviceRunSessionPreview, () => {
await jest.advanceTimersByTimeAsync(0);
expect(logger.warn).toHaveBeenCalledTimes(1);
const [fields] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
expect(fields.err.message).toBe('Signed URL upload failed: network error.');
expect(fields.err.message).toBe(
'Session preview upload request failed (name: FetchError, type: request-timeout).'
);
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('uploads.expo.test');
});
@@ -1,5 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { FetchError } from 'node-fetch';
import { CustomBuildContext } from '../../../customBuildContext';
import { turtleFetch } from '../../../utils/turtleFetch';
@@ -12,7 +11,6 @@ import {
} from '../deviceRunSessionWorkerApi';
jest.mock('../../../utils/turtleFetch');
jest.unmock('node-fetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
type Response = InstanceType<typeof Response>;
@@ -264,12 +262,12 @@ describe(DeviceRunSessionWorkerApi, () => {
it('rejects an application response without the build field', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { platform: 'ios' } }));
await expect(createApi().getApplicationAsync({ retries: 3 })).rejects.toThrow(
await expect(createApi().getApplicationAsync()).rejects.toThrow(
'GET /v2/device-run-session-worker/application returned an unexpected response'
);
});
it('passes the signal and timeout without enabling nested retries', async () => {
it('passes the signal, timeout, and retries to the request', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
const controller = new AbortController();
@@ -282,7 +280,7 @@ describe(DeviceRunSessionWorkerApi, () => {
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
expect.any(String),
'POST',
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 0 })
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 1 })
);
});
@@ -321,7 +319,7 @@ describe(DeviceRunSessionWorkerApi, () => {
);
const error = await createApi()
.getSessionAsync({ retries: 0 })
.getSessionAsync()
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
@@ -336,7 +334,7 @@ describe(DeviceRunSessionWorkerApi, () => {
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
const error = await createApi()
.getSessionAsync({ retries: 0 })
.getSessionAsync()
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
@@ -350,7 +348,7 @@ describe(DeviceRunSessionWorkerApi, () => {
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'STOPPED' } }));
await expect(createApi().getSessionAsync({ retries: 3 })).rejects.toThrow(
await expect(createApi().getSessionAsync()).rejects.toThrow(
'GET /v2/device-run-session-worker/session returned an unexpected response'
);
});
@@ -358,183 +356,8 @@ describe(DeviceRunSessionWorkerApi, () => {
it('rejects a successful response that is not JSON', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('ok', { status: 200 }));
await expect(createApi().getSessionAsync({ retries: 3 })).rejects.toThrow(
await expect(createApi().getSessionAsync()).rejects.toThrow(
'GET /v2/device-run-session-worker/session returned a response that is not JSON.'
);
});
});
describe('device run session worker API retries', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.mocked(turtleFetch).mockReset();
});
afterEach(() => {
jest.useRealTimers();
});
it.each([429, 500, 502, 503, 504])(
'recovers from HTTP %s when starting a session',
async status => {
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(new Response('unavailable', { status }))
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
const result = createApi().startAsync(
{ remoteConfig: { url: 'https://preview.test' } },
{ retries: 3, timeoutMs: 30_000 }
);
const assertion = expect(result).resolves.toEqual({ id: 'drs-id', status: 'in-progress' });
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(2);
expect(jest.mocked(turtleFetch).mock.calls[1]).toEqual(
jest.mocked(turtleFetch).mock.calls[0]
);
expect(jest.mocked(turtleFetch).mock.calls[0][2]).toMatchObject({
retries: 0,
timeout: 30_000,
});
}
);
it('recovers from a network failure', async () => {
jest
.mocked(turtleFetch)
.mockRejectedValueOnce(new Error('socket hang up'))
.mockResolvedValueOnce(jsonResponse({ data: { build: null } }));
const assertion = expect(createApi().getApplicationAsync({ retries: 3 })).resolves.toEqual({
build: null,
});
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(2);
});
it('exhausts three retries with exponential backoff', async () => {
jest
.mocked(turtleFetch)
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
const assertion = expect(
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
).rejects.toMatchObject({
status: 503,
});
await jest.advanceTimersByTimeAsync(0);
expect(turtleFetch).toHaveBeenCalledTimes(1);
await jest.advanceTimersByTimeAsync(1000);
expect(turtleFetch).toHaveBeenCalledTimes(2);
await jest.advanceTimersByTimeAsync(2000);
expect(turtleFetch).toHaveBeenCalledTimes(3);
await jest.advanceTimersByTimeAsync(4000);
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(4);
});
it.each([400, 401, 403, 404, 409])('does not retry HTTP %s', async status => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('refused', { status }));
await expect(
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
).rejects.toMatchObject({ status });
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it.each([
['status polling', (api: DeviceRunSessionWorkerApi) => api.getSessionAsync()],
['stop', (api: DeviceRunSessionWorkerApi) => api.stopAsync()],
[
'screenshot application metadata',
(api: DeviceRunSessionWorkerApi) => api.getApplicationAsync(),
],
['event log setup', (api: DeviceRunSessionWorkerApi) => api.createEventLogUploadSessionAsync()],
['preview setup', (api: DeviceRunSessionWorkerApi) => api.createPreviewUploadSessionAsync()],
[
'TURN without an explicit retry option',
(api: DeviceRunSessionWorkerApi) => api.fetchTurnIceServersAsync(),
],
])('does not retry %s by default', async (_name, call) => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
await expect(call(createApi())).rejects.toMatchObject({ status: 503 });
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it('honors an explicit retry count', async () => {
jest
.mocked(turtleFetch)
.mockImplementation(async () => new Response('unavailable', { status: 503 }));
const assertion = expect(
createApi().fetchTurnIceServersAsync({ retries: 1 })
).rejects.toMatchObject({ status: 503 });
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(2);
});
it('does not send another request after cancellation during backoff', async () => {
const controller = new AbortController();
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
const assertion = expect(
createApi().getApplicationAsync({ signal: controller.signal, retries: 3 })
).rejects.toMatchObject({ name: 'AbortError' });
await jest.advanceTimersByTimeAsync(0);
controller.abort();
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
});
describe('device run session retry safety', () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
});
it('does not replay artifact creation after a transient failure', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(new Response('unavailable', { status: 503 }));
await expect(
createApi().createArtifactUploadSessionAsync({
name: 'report',
filename: 'report.json',
size: 1,
})
).rejects.toMatchObject({ status: 503 });
expect(turtleFetch).toHaveBeenCalledTimes(1);
});
it('does not send an already cancelled request', async () => {
const controller = new AbortController();
controller.abort();
await expect(
createApi().getApplicationAsync({ signal: controller.signal, retries: 3 })
).rejects.toMatchObject({
name: 'AbortError',
});
expect(turtleFetch).not.toHaveBeenCalled();
});
it.each(['system', 'body-timeout'])(
'retries a response body transport failure (%s)',
async type => {
jest.useFakeTimers();
try {
const response = jsonResponse({});
jest
.spyOn(response, 'json')
.mockRejectedValueOnce(new FetchError('connection interrupted', type));
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(response)
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
const assertion = expect(
createApi().startAsync({ remoteConfig: {} }, { retries: 3 })
).resolves.toMatchObject({ status: 'in-progress' });
await jest.runAllTimersAsync();
await assertion;
expect(turtleFetch).toHaveBeenCalledTimes(2);
} finally {
jest.useRealTimers();
}
}
);
});
@@ -151,11 +151,10 @@ describe('with a device run session token', () => {
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
});
it('retries a transient failure when starting the session through the worker API', async () => {
it('starts the session through the worker API', async () => {
const ctx = createCtx({ withToken: true });
jest
.mocked(turtleFetch)
.mockResolvedValueOnce(jsonResponse({ errors: [{ message: 'Service unavailable' }] }, 503))
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'in-progress' } }));
await uploadRemoteSessionConfigAsync({
@@ -166,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;
};
@@ -191,44 +189,16 @@ export class DeviceRunSessionWorkerApi {
}
private async requestAsync<T>(
method: 'GET' | 'POST',
route: string,
dataSchema: z.ZodType<T>,
{ retries = 0, ...options }: RequestOptions & { json?: Record<string, unknown> }
): Promise<T> {
const request = () => {
options.signal?.throwIfAborted();
return this.requestOnceAsync(method, route, dataSchema, options);
};
if (retries === 0) {
return await request();
}
return await promiseRetryWithCondition(
request,
error =>
!options.signal?.aborted &&
error instanceof DeviceRunSessionWorkerApiError &&
(error.status === undefined || error.status === 429 || error.status >= 500),
{ retries, factor: 2, minTimeout: 1000 },
({ attemptNumber, maxAttemptsCount }) =>
options.logger?.warn(
`Retrying ${method} /v2/${DEVICE_RUN_SESSION_WORKER_PATH}${route} (attempt ${attemptNumber}/${maxAttemptsCount}).`
)
)();
}
private async requestOnceAsync<T>(
method: 'GET' | 'POST',
route: string,
dataSchema: z.ZodType<T>,
{
signal,
timeoutMs,
retries = 0,
logger,
json,
}: Omit<RequestOptions, 'retries'> & {
json?: Record<string, unknown>;
}
}: RequestOptions & { json?: Record<string, unknown> }
): Promise<T> {
const routeDescription = `${method} /v2/${DEVICE_RUN_SESSION_WORKER_PATH}${route}`;
const url = new URL(
@@ -242,7 +212,7 @@ export class DeviceRunSessionWorkerApi {
...(json ? { json } : {}),
...(signal ? { signal } : {}),
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
retries: 0,
retries,
shouldThrowOnNotOk: false,
logger,
})
@@ -271,15 +241,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
@@ -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',
'',
@@ -20,7 +20,6 @@ it.each([
'ENETDOWN',
'ENETUNREACH',
'ENOTFOUND',
'ERR_SOCKET_CONNECTION_TIMEOUT',
])('recognizes wrapped %s as a connection establishment failure', code => {
const error = new Error('Request failed', {
cause: new TypeError('fetch failed', {
@@ -31,7 +30,7 @@ it.each([
expect(isConnectionInterruptedError(error)).toBe(false);
});
it.each(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'UND_ERR_SOCKET'])(
it.each(['ECONNRESET', 'UND_ERR_SOCKET'])(
'requires caller-specific recovery for wrapped %s',
code => {
const error = new Error('Request failed', {
@@ -42,7 +41,7 @@ it.each(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'UND_ERR_SOCKET'])(
}
);
it.each(['CERT_HAS_EXPIRED', undefined])('does not automatically retry %s', code => {
it.each(['ETIMEDOUT', 'CERT_HAS_EXPIRED', undefined])('does not automatically retry %s', code => {
const error = Object.assign(new Error('Request failed'), { code });
expect(isConnectFailure(error)).toBe(false);
expect(isConnectionInterruptedError(error)).toBe(false);
@@ -8,14 +8,6 @@ const CONNECT_FAILURE_CODES = new Set([
'ENETDOWN',
'ENETUNREACH',
'ENOTFOUND',
'ERR_SOCKET_CONNECTION_TIMEOUT',
]);
const CONNECTION_INTERRUPTED_CODES = new Set([
'ECONNRESET',
'EPIPE',
'ETIMEDOUT',
'UND_ERR_SOCKET',
]);
export function isConnectFailure(error: unknown): boolean {
@@ -34,11 +26,7 @@ export function isConnectFailure(error: unknown): boolean {
// The server may already have applied a request. The caller must decide whether repetition is safe.
export function isConnectionInterruptedError(error: unknown): boolean {
for (let current = error; current instanceof Error; current = current.cause) {
if (
'code' in current &&
typeof current.code === 'string' &&
CONNECTION_INTERRUPTED_CODES.has(current.code)
) {
if ('code' in current && (current.code === 'ECONNRESET' || current.code === 'UND_ERR_SOCKET')) {
return true;
}
}
+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
+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,
+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 });
+134 -137
View File
@@ -183,26 +183,12 @@ export type Account = {
/** Convex team connections for this account */
convexTeamConnections: Array<ConvexTeamConnection>;
createdAt: Scalars['DateTime']['output'];
/**
* Whether the account turned on credentials protection. When protection is on, reading,
* creating, changing, or deleting build, submission, and push credentials needs the
* PUBLISH_PROTECTED permission, in every project of the account.
*/
credentialsProtectionEnabled: Scalars['Boolean']['output'];
/** Simulator sessions for all apps associated with this account, sorted newest first. */
deviceRunSessionsPaginated: AccountDeviceRunSessionsConnection;
displayName?: Maybe<Scalars['String']['output']>;
/** Environment secrets for an account */
environmentSecrets: Array<EnvironmentSecret>;
environmentVariableEnvironments: Array<Scalars['EnvironmentVariableEnvironment']['output']>;
/**
* Whether the account turned on environment variable protection. When protection is on,
* reading, using, creating, changing, or deleting an environment variable whose environments are
* exclusively production needs the PUBLISH_PROTECTED permission, whatever its visibility, in
* every project of the account. Expo can also turn protection on for an account; this field
* does not show that.
*/
environmentVariableProtectionEnabled: Scalars['Boolean']['output'];
/** Environment variables for an account */
environmentVariables: Array<EnvironmentVariable>;
/** Environment variables for an account with decrypted secret values */
@@ -782,23 +768,8 @@ export type AccountMutation = {
requestRefund?: Maybe<Scalars['Boolean']['output']>;
/** Revoke specified Permissions for Actor. Actor must already have at least one permission on the account. */
revokeActorPermissions: Account;
/**
* Set whether credentials protection is on for this account. When it is on, reading, creating,
* changing, or deleting build, submission, and push credentials needs the PUBLISH_PROTECTED
* permission on the account, in every project of the account. Requires the ADMIN permission on
* the account.
*/
setCredentialsProtectionEnabled: Account;
/** Set the display name for the account. Pass null to clear it and fall back to the account identifier. */
setDisplayName: Account;
/**
* Set whether environment variable protection is on for this account. When it is on, reading,
* using, creating, changing, or deleting an environment variable whose environments are
* exclusively production needs the PUBLISH_PROTECTED permission on the account, whatever its
* visibility, in every project of the account. Requires the ADMIN permission on the account.
* Turning protection off also requires a sudo session.
*/
setEnvironmentVariableProtectionEnabled: Account;
/** Set whether EAS Observe ingestion is paused for this account. */
setObserveIngestionPaused: Account;
/** Require authorization to send push notifications for experiences owned by this account */
@@ -871,24 +842,12 @@ export type AccountMutation_RevokeActorPermissionsArgs = {
};
export type AccountMutation_SetCredentialsProtectionEnabledArgs = {
accountID: Scalars['ID']['input'];
credentialsProtectionEnabled: Scalars['Boolean']['input'];
};
export type AccountMutation_SetDisplayNameArgs = {
accountID: Scalars['ID']['input'];
displayName?: InputMaybe<Scalars['String']['input']>;
};
export type AccountMutation_SetEnvironmentVariableProtectionEnabledArgs = {
accountID: Scalars['ID']['input'];
environmentVariableProtectionEnabled: Scalars['Boolean']['input'];
};
export type AccountMutation_SetObserveIngestionPausedArgs = {
accountID: Scalars['ID']['input'];
paused: Scalars['Boolean']['input'];
@@ -1203,11 +1162,6 @@ export type AgentDeviceRunSessionRemoteConfig = {
webPreviewUrl?: Maybe<Scalars['String']['output']>;
};
export enum AgentKind {
ClaudeCode = 'CLAUDE_CODE',
Codex = 'CODEX'
}
export enum AgentProvider {
Anthropic = 'ANTHROPIC',
Openai = 'OPENAI'
@@ -1282,44 +1236,6 @@ export enum AgentProviderConnectionStatus {
Valid = 'VALID'
}
export type AgentRun = {
__typename?: 'AgentRun';
agentKind: AgentKind;
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
maxDurationSeconds: Scalars['Int']['output'];
prompt?: Maybe<Scalars['String']['output']>;
turtleJobRun: JobRun;
updatedAt: Scalars['DateTime']['output'];
};
export type AgentRunConnection = {
__typename?: 'AgentRunConnection';
edges: Array<AgentRunEdge>;
pageInfo: PageInfo;
};
export type AgentRunEdge = {
__typename?: 'AgentRunEdge';
cursor: Scalars['String']['output'];
node: AgentRun;
};
export type AgentRunFilterInput = {
agentKinds?: InputMaybe<Array<AgentKind>>;
statuses?: InputMaybe<Array<JobRunStatus>>;
};
export type AgentRunQuery = {
__typename?: 'AgentRunQuery';
byId: AgentRun;
};
export type AgentRunQuery_ByIdArgs = {
agentRunId: Scalars['ID']['input'];
};
export type AndroidAppBuildCredentials = {
__typename?: 'AndroidAppBuildCredentials';
androidKeystore?: Maybe<AndroidKeystore>;
@@ -1680,8 +1596,6 @@ export type App = Project & {
__typename?: 'App';
/** Coalesced project activity for an app */
activityTimelineProjectActivities: Array<ActivityTimelineProjectActivity>;
/** Agent runs for this project, newest first. Empty filters include all runs. */
agentRunsPaginated: AgentRunConnection;
/** Android app credentials for the project */
androidAppCredentials: Array<AndroidAppCredentials>;
appStoreConnectApp?: Maybe<AppStoreConnectApp>;
@@ -1845,16 +1759,6 @@ export type App_ActivityTimelineProjectActivitiesArgs = {
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type App_AgentRunsPaginatedArgs = {
after?: InputMaybe<Scalars['String']['input']>;
before?: InputMaybe<Scalars['String']['input']>;
filter?: InputMaybe<AgentRunFilterInput>;
first?: InputMaybe<Scalars['Int']['input']>;
last?: InputMaybe<Scalars['Int']['input']>;
};
/** Represents an Exponent App (or Experience in legacy terms) */
export type App_AndroidAppCredentialsArgs = {
filter?: InputMaybe<AndroidAppCredentialsFilter>;
@@ -2487,6 +2391,11 @@ export type AppObserve = {
* @deprecated Use overview.stability instead.
*/
overviewStability: AppObserveOverviewStability;
/**
* Per-version, per-platform metric summaries for the Overview version-comparison matrix.
* @deprecated Use overview.versionComparison instead.
*/
overviewVersionComparison: AppObserveOverviewVersionComparison;
/** One session's metrics and logs for the timeline (ENG-26160). Unknown session ids yield empty lists. */
session: AppObserveSession;
/** @deprecated Use metrics.timeSeries instead. */
@@ -2501,6 +2410,11 @@ export type AppObserve = {
* universe used by `appVersions` headline counts.
*/
uniqueActiveUserCount: Scalars['Int']['output'];
/**
* Update download activity (the Recent updates list). Not available on the free tier or legacy plans.
* @deprecated Use easUpdates.list instead.
*/
updates: AppObserveUpdatesConnection;
/** Namespaced successor API (ENG-26160). Prefer over the flat `customEventNames`/`customEventList` fields. */
userEvents: AppObserveUserEvents;
};
@@ -2589,6 +2503,11 @@ export type AppObserve_OverviewStabilityArgs = {
};
export type AppObserve_OverviewVersionComparisonArgs = {
input: AppObserveOverviewVersionComparisonInput;
};
export type AppObserve_SessionArgs = {
id: Scalars['ID']['input'];
};
@@ -2603,6 +2522,11 @@ export type AppObserve_UniqueActiveUserCountArgs = {
input: AppObserveReleasesInput;
};
export type AppObserve_UpdatesArgs = {
input: AppObserveUpdatesInput;
};
export type AppObserveAppBuildEmbeddedSummary = {
__typename?: 'AppObserveAppBuildEmbeddedSummary';
eventCount: Scalars['Int']['output'];
@@ -4043,6 +3967,31 @@ export type AppObserveUpdatesConnection = {
totalDownloads: Scalars['Int']['output'];
};
export type AppObserveUpdatesInput = {
after?: InputMaybe<Scalars['String']['input']>;
appBuildNumber?: InputMaybe<Scalars['String']['input']>;
appUpdateId?: InputMaybe<Scalars['String']['input']>;
appVersion?: InputMaybe<Scalars['String']['input']>;
endTime: Scalars['DateTime']['input'];
environment?: InputMaybe<Scalars['String']['input']>;
first?: InputMaybe<Scalars['Int']['input']>;
orderBy?: InputMaybe<AppObserveUpdatesOrderBy>;
platform?: InputMaybe<AppObservePlatform>;
/** Filter to these platforms. Cannot be set together with platform. */
platforms?: InputMaybe<Array<AppObservePlatform>>;
startTime: Scalars['DateTime']['input'];
};
export type AppObserveUpdatesOrderBy = {
direction: AppObserveUpdatesOrderByDirection;
field: AppObserveUpdatesOrderByField;
};
export enum AppObserveUpdatesOrderByDirection {
Asc = 'ASC',
Desc = 'DESC'
}
export enum AppObserveUpdatesOrderByField {
DownloadCount = 'DOWNLOAD_COUNT',
FirstSeenAt = 'FIRST_SEEN_AT',
@@ -5426,7 +5375,6 @@ export enum AuthProviderIdentifier {
MsEntraId = 'MS_ENTRA_ID',
Okta = 'OKTA',
OneLogin = 'ONE_LOGIN',
Rippling = 'RIPPLING',
StubIdp = 'STUB_IDP'
}
@@ -5575,7 +5523,6 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
embeddedUpdate?: Maybe<EmbeddedUpdate>;
enqueuedAt?: Maybe<Scalars['DateTime']['output']>;
error?: Maybe<BuildError>;
/** @deprecated Build wait estimates are no longer available. This field returns null. */
estimatedWaitTimeLeftSeconds?: Maybe<Scalars['Int']['output']>;
expirationDate?: Maybe<Scalars['DateTime']['output']>;
fingerprint?: Maybe<Fingerprint>;
@@ -5583,7 +5530,7 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
gitCommitMessage?: Maybe<Scalars['String']['output']>;
gitRef?: Maybe<Scalars['String']['output']>;
id: Scalars['ID']['output'];
/** @deprecated Build queue position is no longer available. This field returns null. */
/** Queue position is 1-indexed */
initialQueuePosition?: Maybe<Scalars['Int']['output']>;
initiatingActor?: Maybe<Actor>;
iosEnterpriseProvisioning?: Maybe<BuildIosEnterpriseProvisioning>;
@@ -5606,7 +5553,7 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
projectMetadataFileUrl?: Maybe<Scalars['String']['output']>;
projectRootDirectory?: Maybe<Scalars['String']['output']>;
provisioningStartedAt?: Maybe<Scalars['DateTime']['output']>;
/** @deprecated Build queue position is no longer available. This field returns null. */
/** Queue position is 1-indexed */
queuePosition?: Maybe<Scalars['Int']['output']>;
reactNativeVersion?: Maybe<Scalars['String']['output']>;
releaseChannel?: Maybe<Scalars['String']['output']>;
@@ -5941,11 +5888,6 @@ export type BuildOrBuildJob = {
};
export type BuildParamsInput = {
/**
* The role of the EXPO_TOKEN that the build gets. The caller must have at least this role on the
* account, or the mutation fails. Without it, the token has the default permissions.
*/
expoTokenRole?: InputMaybe<JobExpoTokenRole>;
reactNativeVersion?: InputMaybe<Scalars['String']['input']>;
resourceClass: BuildResourceClass;
sdkVersion?: InputMaybe<Scalars['String']['input']>;
@@ -6415,6 +6357,24 @@ export type CreateConvexTeamConnectionInput = {
deploymentRegion: Scalars['String']['input'];
};
export type CreateDeviceRunSessionArtifactUploadSessionInput = {
filename: Scalars['String']['input'];
kind?: InputMaybe<Scalars['String']['input']>;
metadata?: InputMaybe<Scalars['JSONObject']['input']>;
name: Scalars['String']['input'];
size: Scalars['ByteSize']['input'];
};
export type CreateDeviceRunSessionArtifactUploadSessionResult = {
__typename?: 'CreateDeviceRunSessionArtifactUploadSessionResult';
uploadSession: DeviceRunSessionArtifactUploadSession;
};
export type CreateDeviceRunSessionEventLogUploadSessionResult = {
__typename?: 'CreateDeviceRunSessionEventLogUploadSessionResult';
uploadSession: DeviceRunSessionEventLogUploadSession;
};
export type CreateDeviceRunSessionInput = {
/**
* The coding agent sending this request, recorded for analytics only and never used for
@@ -6447,14 +6407,6 @@ export type CreateDeviceRunSessionInput = {
* Mutually exclusive with buildFingerprint, applicationArchiveUrl, and expoGo.
*/
buildId?: InputMaybe<Scalars['ID']['input']>;
/**
* Origins of pages that may call the session's preview host API cross-origin, for example to
* render the device with @expo/hub-client. Framing the preview does not need this. Same value
* rules as frameAncestors. At most 10. The page still needs the preview token from remoteConfig.
* If omitted, only expo.dev may call the preview API cross-origin. Available on Production and
* Enterprise plans.
*/
corsOrigins?: InputMaybe<Array<Scalars['String']['input']>>;
/**
* Where the virtual device's proxied network traffic exits to the internet. If
* omitted, all traffic exits from EAS infrastructure.
@@ -6472,7 +6424,6 @@ export type CreateDeviceRunSessionInput = {
* as https://*.example.com; plain http is accepted for localhost only. At most 10. The
* embedding page still needs the preview token from remoteConfig, and whoever embeds the
* preview can also control the device. If omitted, only expo.dev may embed the preview.
* Available on Production and Enterprise plans.
*/
frameAncestors?: InputMaybe<Array<Scalars['String']['input']>>;
/** iOS Simulator options. Only supported when platform is IOS. */
@@ -6484,7 +6435,7 @@ export type CreateDeviceRunSessionInput = {
launchArgs?: InputMaybe<Array<Scalars['String']['input']>>;
/**
* Stop the session automatically after this many minutes without observed
* session activity. Must be positive and no greater than the session's maximum
* session activity. Must be positive and smaller than the session's maximum
* duration (maxRunTimeMinutes when set). Only supported for
* agent-device, argent, and Appium sessions. If omitted, the session has no idle
* timeout.
@@ -6613,6 +6564,13 @@ export type CreateGitHubRepositorySettingsInput = {
};
export type CreateIosDeviceRunSessionInput = {
/**
* Origins of pages that may call the session's preview host API cross-origin, for example to
* render the device with @expo/hub-client. Framing the preview does not need this. Same value
* rules as frameAncestors. At most 10. The page still needs the preview token from remoteConfig.
* If omitted, only expo.dev may call the preview API cross-origin.
*/
corsOrigins?: InputMaybe<Array<Scalars['String']['input']>>;
/** Simulator device name or UDID to use for the session (e.g. "iPhone 16 Pro"). */
deviceIdentifier?: InputMaybe<Scalars['String']['input']>;
};
@@ -6653,7 +6611,6 @@ export type CreateSandboxInput = {
agentRunId?: InputMaybe<Scalars['ID']['input']>;
appId: Scalars['ID']['input'];
image?: InputMaybe<Scalars['String']['input']>;
maxDurationSeconds?: InputMaybe<Scalars['Int']['input']>;
operatingSystem: SandboxOperatingSystem;
projectArchive?: InputMaybe<SandboxProjectArchiveInput>;
resourceClass: SandboxResourceClass;
@@ -7177,6 +7134,12 @@ export type DeviceRunSessionArtifactQuery_ByIdArgs = {
deviceRunSessionArtifactId: Scalars['ID']['input'];
};
export type DeviceRunSessionArtifactUploadSession = {
__typename?: 'DeviceRunSessionArtifactUploadSession';
headers: Scalars['JSONObject']['output'];
url: Scalars['String']['output'];
};
/**
* Where the virtual device's proxied network traffic exits to the internet. When
* unset, all traffic exits from EAS infrastructure.
@@ -7193,6 +7156,12 @@ export enum DeviceRunSessionEgress {
Local = 'LOCAL'
}
export type DeviceRunSessionEventLogUploadSession = {
__typename?: 'DeviceRunSessionEventLogUploadSession';
headers: Scalars['JSONObject']['output'];
url: Scalars['String']['output'];
};
export type DeviceRunSessionFilterInput = {
/** Case-insensitive match anywhere in the session name. */
name?: InputMaybe<Scalars['String']['input']>;
@@ -7205,8 +7174,20 @@ export type DeviceRunSessionFilterInput = {
export type DeviceRunSessionMutation = {
__typename?: 'DeviceRunSessionMutation';
/** Create an upload session for an artifact while the backing job run is not in a final state */
createArtifactUploadSession: CreateDeviceRunSessionArtifactUploadSessionResult;
/** Create a device run session */
createDeviceRunSession: DeviceRunSession;
/**
* Create or reuse a standard artifact and a two-hour upload URL for repeatedly
* overwriting its structured event log while the backing job run is running.
*/
createEventLogUploadSession: CreateDeviceRunSessionEventLogUploadSessionResult;
/**
* Create or renew a two-hour upload URL for overwriting the session preview.
* Reuses one WebP artifact while the backing job run is active.
*/
createPreviewUploadSession: CreateDeviceRunSessionArtifactUploadSessionResult;
/**
* Permanently delete a finished session and its artifacts. The backing job,
* if present, must also have finished. Returns the deleted session ID.
@@ -7218,6 +7199,8 @@ export type DeviceRunSessionMutation = {
* ERRORED).
*/
ensureDeviceRunSessionStopped: DeviceRunSession;
/** Mark a device run session as started and persist remote connection details */
startDeviceRunSession: DeviceRunSession;
/**
* Update a device run session name and tags, including on sessions
* that have already finished.
@@ -7226,11 +7209,27 @@ export type DeviceRunSessionMutation = {
};
export type DeviceRunSessionMutation_CreateArtifactUploadSessionArgs = {
deviceRunSessionId: Scalars['ID']['input'];
input: CreateDeviceRunSessionArtifactUploadSessionInput;
};
export type DeviceRunSessionMutation_CreateDeviceRunSessionArgs = {
deviceRunSessionInput: CreateDeviceRunSessionInput;
};
export type DeviceRunSessionMutation_CreateEventLogUploadSessionArgs = {
deviceRunSessionId: Scalars['ID']['input'];
};
export type DeviceRunSessionMutation_CreatePreviewUploadSessionArgs = {
deviceRunSessionId: Scalars['ID']['input'];
};
export type DeviceRunSessionMutation_DeleteDeviceRunSessionArgs = {
deviceRunSessionId: Scalars['ID']['input'];
};
@@ -7241,6 +7240,12 @@ export type DeviceRunSessionMutation_EnsureDeviceRunSessionStoppedArgs = {
};
export type DeviceRunSessionMutation_StartDeviceRunSessionArgs = {
deviceRunSessionId: Scalars['ID']['input'];
remoteConfig: Scalars['JSONObject']['input'];
};
export type DeviceRunSessionMutation_UpdateDeviceRunSessionArgs = {
deviceRunSessionId: Scalars['ID']['input'];
input: UpdateDeviceRunSessionInput;
@@ -7523,9 +7528,7 @@ export enum EntityTypeName {
ConvexProjectEntity = 'ConvexProjectEntity',
ConvexTeamConnectionEntity = 'ConvexTeamConnectionEntity',
CustomerEntity = 'CustomerEntity',
/** @deprecated This entity type was deleted. Existing audit logs can still reference it. */
EchoProjectEntity = 'EchoProjectEntity',
/** @deprecated This entity type was deleted. Existing audit logs can still reference it. */
EchoVersionEntity = 'EchoVersionEntity',
EnvironmentVariableEntity = 'EnvironmentVariableEntity',
GoogleServiceAccountKeyEntity = 'GoogleServiceAccountKeyEntity',
@@ -8520,6 +8523,11 @@ export type GitHubUserMutation = {
__typename?: 'GitHubUserMutation';
/** Delete a GitHub User by ID */
deleteGitHubUser: DeleteGitHubUserResult;
/**
* Generate a GitHub User Access Token
* @deprecated This mutation is scheduled for removal.
*/
generateGitHubUserAccessToken?: Maybe<Scalars['String']['output']>;
};
@@ -8527,6 +8535,11 @@ export type GitHubUserMutation_DeleteGitHubUserArgs = {
id: Scalars['ID']['input'];
};
export type GitHubUserMutation_GenerateGitHubUserAccessTokenArgs = {
githubAppRegistrationId?: InputMaybe<Scalars['ID']['input']>;
};
export type GoogleServiceAccountKey = {
__typename?: 'GoogleServiceAccountKey';
account: Account;
@@ -8955,17 +8968,9 @@ export type IosSubmissionConfigInput = {
isVerboseFastlaneEnabled?: InputMaybe<Scalars['Boolean']['input']>;
};
/** A role that a build can request for its EXPO_TOKEN. */
export enum JobExpoTokenRole {
Developer = 'DEVELOPER',
ReleaseManager = 'RELEASE_MANAGER',
Runner = 'RUNNER'
}
/** Represents a Turtle Job Run */
export type JobRun = {
__typename?: 'JobRun';
agentRun?: Maybe<AgentRun>;
app: App;
artifacts: Array<WorkflowArtifact>;
createdAt: Scalars['DateTime']['output'];
@@ -9997,7 +10002,7 @@ export type RootMutation = {
deployments: DeploymentsMutation;
/** Mutations that assign or modify DevDomainNames for apps */
devDomainName: AppDevDomainNameMutation;
/** Mutations that create, stop, update, and delete device run sessions */
/** Mutations that create, start, stop, and update device run sessions */
deviceRunSession: DeviceRunSessionMutation;
/** Mutations for Discord users */
discordUser: DiscordUserMutation;
@@ -10117,7 +10122,6 @@ export type RootQuery = {
account: AccountQuery;
/** Top-level query object for querying AccountSSOConfigurationPublicData */
accountSSOConfigurationPublicData: AccountSsoConfigurationPublicDataQuery;
agentRuns: AgentRunQuery;
app: AppQuery;
/**
* Look up app by app id
@@ -10441,7 +10445,6 @@ export type Sandbox = {
finishedAt?: Maybe<Scalars['DateTime']['output']>;
id: Scalars['ID']['output'];
lastUsedAt: Scalars['DateTime']['output'];
maxDurationSeconds?: Maybe<Scalars['Int']['output']>;
startedAt?: Maybe<Scalars['DateTime']['output']>;
status: SandboxStatus;
turtleJobRun: JobRun;
@@ -10721,6 +10724,8 @@ export type ServeSimRunSessionRemoteConfig = {
/** Session token gating the preview. Null when the preview runs ungated. */
previewToken?: Maybe<Scalars['String']['output']>;
previewUrl: Scalars['String']['output'];
/** @deprecated Use previewUrl instead. */
streamUrl?: Maybe<Scalars['String']['output']>;
};
export type SetSupabaseConnectionOrganizationInput = {
@@ -12089,15 +12094,11 @@ export type UsageBudget = {
export type UsageBudgetLimits = {
__typename?: 'UsageBudgetLimits';
builds?: Maybe<Scalars['Int']['output']>;
concurrentSandboxes?: Maybe<Scalars['Int']['output']>;
sandboxMinutes?: Maybe<Scalars['Int']['output']>;
simulatorMinutes?: Maybe<Scalars['Int']['output']>;
};
export type UsageBudgetLimitsInput = {
builds?: InputMaybe<Scalars['Int']['input']>;
concurrentSandboxes?: InputMaybe<Scalars['Int']['input']>;
sandboxMinutes?: InputMaybe<Scalars['Int']['input']>;
simulatorMinutes?: InputMaybe<Scalars['Int']['input']>;
};
@@ -12125,10 +12126,6 @@ export type UsageBudgetRemaining = {
__typename?: 'UsageBudgetRemaining';
/** Available builds, or null when there is no build limit. */
builds?: Maybe<Scalars['Int']['output']>;
/** Available sandbox slots, including starting and running sandboxes, or null when unlimited. */
concurrentSandboxes?: Maybe<Scalars['Int']['output']>;
/** Available minutes after completed usage and active reservations, or null when unlimited. */
sandboxMinutes?: Maybe<Scalars['Float']['output']>;
/** Available minutes after completed usage and active reservations, or null when unlimited. */
simulatorMinutes?: Maybe<Scalars['Float']['output']>;
};
+1 -20
View File
@@ -9,10 +9,7 @@ import { fetchUserAsync } from './fetchUser';
import { ApiV2Error } from '../ApiV2Error';
import { AnalyticsWithOrchestration } from '../analytics/AnalyticsManager';
import { ApiV2Client } from '../api';
import {
ExpoGraphqlClient,
createGraphqlClient,
} from '../commandUtils/context/contextUtils/createGraphqlClient';
import { createGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { CurrentUserQuery } from '../graphql/generated';
import { UserQuery } from '../graphql/queries/UserQuery';
import Log, { learnMore } from '../log';
@@ -46,7 +43,6 @@ type Actor = NonNullable<CurrentUserQuery['meActor']>;
export default class SessionManager {
private currentActor: Actor | undefined;
private graphqlClient: { credential: string | null; client: ExpoGraphqlClient } | undefined;
constructor(private readonly analytics: AnalyticsWithOrchestration) {}
@@ -91,21 +87,6 @@ export default class SessionManager {
await this.setSessionAsync(undefined);
}
/**
* Returns one GraphQL client per credential, so context fields of one command share
* the urql cache. For example, the project ID check and the command both query the app.
*/
public getGraphqlClient(authenticationInfo: {
accessToken: string | null;
sessionSecret: string | null;
}): ExpoGraphqlClient {
const credential = authenticationInfo.accessToken ?? authenticationInfo.sessionSecret;
if (this.graphqlClient?.credential !== credential) {
this.graphqlClient = { credential, client: createGraphqlClient(authenticationInfo) };
}
return this.graphqlClient.client;
}
/**
* @param requestTimeoutMs caps the actor lookup when no actor is cached yet. Callers that must
* not delay a command past a deadline pass it; the default has no timeout. A capped lookup
@@ -5,10 +5,7 @@ import path from 'path';
import { ApiV2Error } from '../../ApiV2Error';
import { AnalyticsWithOrchestration } from '../../analytics/AnalyticsManager';
import { ApiV2Client } from '../../api';
import {
ExpoGraphqlClient,
createGraphqlClient,
} from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { createGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { UserQuery } from '../../graphql/queries/UserQuery';
import Log from '../../log';
import { promptAsync } from '../../prompts';
@@ -116,28 +113,6 @@ describe(SessionManager, () => {
});
});
describe('getGraphqlClient', () => {
beforeEach(() => {
jest.mocked(createGraphqlClient).mockImplementation(() => ({}) as ExpoGraphqlClient);
});
it('returns the same client for the same credential', () => {
const sessionManager = new SessionManager(analytics);
const client = sessionManager.getGraphqlClient({ accessToken: 'token', sessionSecret: null });
expect(
sessionManager.getGraphqlClient({ accessToken: 'token', sessionSecret: 'secret' })
).toBe(client);
});
it('returns a new client when the credential changes', () => {
const sessionManager = new SessionManager(analytics);
const client = sessionManager.getGraphqlClient({ accessToken: null, sessionSecret: 'a' });
expect(sessionManager.getGraphqlClient({ accessToken: null, sessionSecret: 'b' })).not.toBe(
client
);
});
});
describe('getUserAsync', () => {
it('skips fetching user without access token or session secret', async () => {
const sessionManager = new SessionManager(analytics);
-21
View File
@@ -207,27 +207,6 @@
}
]
},
"permissions": {
"type": "object",
"description": "Permissions of the EXPO_TOKEN that the build steps use. The user or robot that starts the build must have at least the requested role, or the build fails.",
"markdownDescription": "Permissions of the `EXPO_TOKEN` that the build steps use. The user or robot that starts the build must have at least the requested role, or the build fails.",
"additionalProperties": false,
"required": ["account"],
"properties": {
"account": {
"type": "object",
"additionalProperties": false,
"required": ["role"],
"properties": {
"role": {
"description": "The account role of the EXPO_TOKEN.",
"markdownDescription": "The account role of the `EXPO_TOKEN`.",
"enum": ["runner", "developer", "release_manager"]
}
}
}
}
},
"config": {
"description": "Custom workflow file name that will be used to run this build. You can also specify this property on platform level for platform-specific workflows. Learn more: https://docs.expo.dev/custom-builds/get-started/",
"type": "string"
@@ -800,85 +800,6 @@ test('Android-specific resourceClass', async () => {
).resolves.not.toThrow();
});
test('valid eas.json with permissions.account.role', async () => {
await fs.writeJson('/project/eas.json', {
build: {
production: {
permissions: { account: { role: 'release_manager' } },
},
},
});
const accessor = EasJsonAccessor.fromProjectPath('/project');
const iosProfile = await EasJsonUtils.getBuildProfileAsync(accessor, Platform.IOS, 'production');
const androidProfile = await EasJsonUtils.getBuildProfileAsync(
accessor,
Platform.ANDROID,
'production'
);
expect(iosProfile.permissions).toEqual({ account: { role: 'release_manager' } });
expect(androidProfile.permissions).toEqual({ account: { role: 'release_manager' } });
});
test('permissions.account.role is inherited from the extended profile', async () => {
await fs.writeJson('/project/eas.json', {
build: {
base: {
permissions: { account: { role: 'runner' } },
},
production: {
extends: 'base',
},
preview: {
extends: 'base',
permissions: { account: { role: 'developer' } },
},
},
});
const accessor = EasJsonAccessor.fromProjectPath('/project');
const productionProfile = await EasJsonUtils.getBuildProfileAsync(
accessor,
Platform.IOS,
'production'
);
const previewProfile = await EasJsonUtils.getBuildProfileAsync(accessor, Platform.IOS, 'preview');
expect(productionProfile.permissions).toEqual({ account: { role: 'runner' } });
expect(previewProfile.permissions).toEqual({ account: { role: 'developer' } });
});
test('invalid permissions.account.role', async () => {
await fs.writeJson('/project/eas.json', {
build: {
production: {
permissions: { account: { role: 'admin' } },
},
},
});
const accessor = EasJsonAccessor.fromProjectPath('/project');
await expect(
EasJsonUtils.getBuildProfileAsync(accessor, Platform.IOS, 'production')
).rejects.toThrowError(/build.production.permissions.account.role.*must be one of/);
});
test('permissions without account.role', async () => {
await fs.writeJson('/project/eas.json', {
build: {
production: {
permissions: { account: {} },
},
},
});
const accessor = EasJsonAccessor.fromProjectPath('/project');
await expect(
EasJsonUtils.getBuildProfileAsync(accessor, Platform.IOS, 'production')
).rejects.toThrowError(/build.production.permissions.account.role.*is required/);
});
test('build profile with platform-specific custom build config', async () => {
await fs.writeJson('/project/eas.json', {
build: {
+1 -12
View File
@@ -1,7 +1,7 @@
import Joi from 'joi';
import semver from 'semver';
import { JobExpoTokenRole, ResourceClass } from './types';
import { ResourceClass } from './types';
const AllowedCommonResourceClasses: ResourceClass[] = [
ResourceClass.DEFAULT,
@@ -31,21 +31,10 @@ const CacheSchema = Joi.object({
'Cannot provide both "cache.customPaths" and "cache.paths" - use "cache.paths"',
});
const PermissionsSchema = Joi.object({
account: Joi.object({
role: Joi.string()
.valid(...Object.values(JobExpoTokenRole))
.required(),
}).required(),
});
const CommonBuildProfileSchema = Joi.object({
// builder
resourceClass: Joi.string().valid(...AllowedCommonResourceClasses),
// permissions of the EXPO_TOKEN used by the build
permissions: PermissionsSchema,
// build environment
env: Joi.object().pattern(Joi.string(), Joi.string().empty(null)),
node: Joi.string().empty(null).custom(semverCheck),
-15
View File
@@ -20,18 +20,6 @@ export enum ResourceClass {
M_LARGE = 'm-large',
}
export enum JobExpoTokenRole {
RUNNER = 'runner',
DEVELOPER = 'developer',
RELEASE_MANAGER = 'release_manager',
}
export interface BuildProfilePermissions {
account: {
role: JobExpoTokenRole;
};
}
export type DistributionType = 'store' | 'internal';
export type IosEnterpriseProvisioning = 'adhoc' | 'universal';
@@ -44,9 +32,6 @@ export interface CommonBuildProfile {
// builder
resourceClass?: ResourceClass;
// permissions of the EXPO_TOKEN used by the build
permissions?: BuildProfilePermissions;
// build environment
env?: Record<string, string>;
node?: string;
-2
View File
@@ -5,12 +5,10 @@ export { Platform } from '@expo/eas-build-job';
export {
AndroidVersionAutoIncrement,
BuildProfile,
BuildProfilePermissions,
CredentialsSource,
DistributionType,
IosEnterpriseProvisioning,
IosVersionAutoIncrement,
JobExpoTokenRole,
ResourceClass,
} from './build/types';
export { EasJsonAccessor } from './accessor';
+4 -1
View File
@@ -202,8 +202,11 @@ export class BuildStepGlobalContext {
return new BuildStepContext(this, options);
}
public markAsCheckedOut(): void {
public markAsCheckedOut(logger: bunyan): void {
this.didCheckOut = true;
logger.info(
`Changing default working directory to ${this.defaultWorkingDirectory} (was ${this.projectTargetDirectory})`
);
}
public get hasAnyPreviousStepFailed(): boolean {
+19 -4
View File
@@ -38,7 +38,7 @@ import {
parseLocalCompositeFunctionPath,
} from './utils/localCompositeFunctions';
type ValidatedHooks = ReadonlyMap<HookKey, Step[]>;
type ValidatedHooks = ReadonlyMap<HookKey, { anchorId: HookAnchorId; steps: Step[] }>;
export class StepsConfigParser extends AbstractConfigParser {
private readonly steps: Step[];
@@ -114,6 +114,7 @@ export class StepsConfigParser extends AbstractConfigParser {
// step ids identical across the splicing→engine rollout.
const buildSteps: BuildStep[] = [];
const hooksByAnchorStep = new Map<BuildStep, AnchorHooks>();
const seenAnchorIds = new Set<HookAnchorId>();
for (const stepConfig of validatedSteps) {
const maybeFunctionGroup =
@@ -134,6 +135,7 @@ export class StepsConfigParser extends AbstractConfigParser {
if (anchorId === undefined) {
continue;
}
seenAnchorIds.add(anchorId);
const anchorHooks = await this.constructAnchorHooksAsync(
anchorId,
validatedHooks,
@@ -160,6 +162,7 @@ export class StepsConfigParser extends AbstractConfigParser {
'Hook anchors are not supported on local composite function steps.'
);
}
seenAnchorIds.add(anchorId);
const before = await this.constructHookSideEntriesAsync(
anchorId,
'before',
@@ -187,6 +190,14 @@ export class StepsConfigParser extends AbstractConfigParser {
}
}
for (const [hookKey, { anchorId }] of validatedHooks) {
if (!seenAnchorIds.has(anchorId)) {
this.ctx.baseLogger.warn(
`Ignoring "hooks.${hookKey}": this build does not run the "${anchorId}" step.`
);
}
}
return {
buildSteps,
buildFunctionById,
@@ -195,13 +206,14 @@ export class StepsConfigParser extends AbstractConfigParser {
}
private validateHooks(): ValidatedHooks {
const validatedHooks = new Map<HookKey, Step[]>();
const validatedHooks = new Map<HookKey, { anchorId: HookAnchorId; steps: Step[] }>();
for (const [hookKey, hookSteps] of Object.entries(this.hooks)) {
// A worker must not fail on a hook key newer than itself, so unregistered
// keys skip validation entirely (their steps may reference functions this
// worker lacks).
const parsed = parseHookKey(hookKey);
if (parsed === null) {
this.ctx.baseLogger.warn(`Ignoring unknown hook key "${hookKey}".`);
continue;
}
// An empty array is a deliberate no-op (e.g. opting out of a default);
@@ -211,7 +223,10 @@ export class StepsConfigParser extends AbstractConfigParser {
continue;
}
try {
validatedHooks.set(`${parsed.side}_${parsed.anchorId}`, validateSteps(hookSteps));
validatedHooks.set(`${parsed.side}_${parsed.anchorId}`, {
anchorId: parsed.anchorId,
steps: validateSteps(hookSteps),
});
} catch (err) {
throw new BuildConfigError(
`Invalid steps in "hooks.${hookKey}": ${err instanceof Error ? err.message : String(err)}`
@@ -271,7 +286,7 @@ export class StepsConfigParser extends AbstractConfigParser {
validatedHooks: ValidatedHooks,
compositeFunctionExpander: CompositeFunctionExpander
): Promise<HookEntry[]> {
const hookSteps = validatedHooks.get(`${side}_${anchorId}`);
const hookSteps = validatedHooks.get(`${side}_${anchorId}`)?.steps;
if (hookSteps === undefined) {
return [];
}
@@ -130,7 +130,7 @@ describe(BuildStep, () => {
projectTargetDirectory: '/a/b',
relativeWorkingDirectory: 'c',
});
ctx.markAsCheckedOut();
ctx.markAsCheckedOut(ctx.baseLogger);
const id = 'test1';
const command = 'ls -la';
@@ -150,7 +150,7 @@ describe(BuildStep, () => {
projectTargetDirectory: '/a/b',
relativeWorkingDirectory: 'c',
});
ctx.markAsCheckedOut();
ctx.markAsCheckedOut(ctx.baseLogger);
const id = 'test1';
const command = 'ls -la';
@@ -65,7 +65,7 @@ describe(BuildStepGlobalContext, () => {
),
false
);
ctx.markAsCheckedOut();
ctx.markAsCheckedOut(ctx.baseLogger);
expect(ctx.defaultWorkingDirectory).toBe(workingDirectory);
});
});
@@ -136,7 +136,7 @@ describe(BuildStepGlobalContext, () => {
},
createMockLogger()
);
ctx.markAsCheckedOut();
ctx.markAsCheckedOut(ctx.baseLogger);
expect(ctx.stepsInternalBuildDirectory).toBe('/m/n/o');
expect(ctx.defaultWorkingDirectory).toBe('/g/h/i');
expect(ctx.runtimePlatform).toBe(BuildRuntimePlatform.DARWIN);
@@ -199,7 +199,7 @@ describe(BuildStepGlobalContext, () => {
const ctx = createGlobalContextMock({
relativeWorkingDirectory: 'apps/mobile',
});
ctx.markAsCheckedOut();
ctx.markAsCheckedOut(ctx.baseLogger);
const relativeChildCtx = ctx.stepCtx({
relativeWorkingDirectory: 'scripts',
@@ -230,7 +230,7 @@ describe(BuildStepGlobalContext, () => {
projectTargetDirectory: tempDir,
relativeWorkingDirectory: '',
});
ctx.markAsCheckedOut();
ctx.markAsCheckedOut(ctx.baseLogger);
});
afterEach(() => {
@@ -283,6 +283,9 @@ describe('StepsConfigParser hook construction', () => {
});
expect(orderedDisplayNames(workflow)).toEqual(['Checkout']);
expect(workflow.hooksByAnchorStep.size).toBe(0);
expect(ctx.baseLogger.warn).toHaveBeenCalledWith(
'Ignoring "hooks.before_install_node_modules": this build does not run the "install_node_modules" step.'
);
});
it('rejects a hook step using a composite function missing from the catalog with BuildConfigError', async () => {
@@ -855,17 +858,26 @@ describe('StepsConfigParser lazy hook composite loading', () => {
expect(orderedDisplayNames(workflow)).toEqual(['Checkout']);
});
it('ignores unknown and unrun hook keys without warning', async () => {
const workflow = await parseWorkflowAsync({
it('warns for a registered hook key whose anchor never appeared', async () => {
await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/checkout' }],
hooks: {
before_install_node_modules: [{ run: 'echo never' }],
before_install_node_module: [{ run: 'echo typo' }],
},
hooks: { before_install_node_modules: [{ run: 'echo never' }] },
});
expect(workflow.hooksByAnchorStep.size).toBe(0);
expect(ctx.baseLogger.warn).not.toHaveBeenCalled();
expect(ctx.baseLogger.warn).toHaveBeenCalledWith(
'Ignoring "hooks.before_install_node_modules": this build does not run the "install_node_modules" step.'
);
});
it('warns for an unknown hook key', async () => {
await parseWorkflowAsync({
ctx,
steps: [{ uses: 'eas/checkout' }],
hooks: { before_install_node_module: [{ run: 'echo typo' }] },
});
expect(ctx.baseLogger.warn).toHaveBeenCalledWith(
'Ignoring unknown hook key "before_install_node_module".'
);
});
it('wraps a loader failure in a BuildConfigError naming the before-side hook key', async () => {
+15
View File
@@ -42,3 +42,18 @@ Github action builds a docker image for every PR with any change in `infra/eas-b
### VM template
Github action builds a new vm template for every PR with any change in `infra/eas-build-worker-images/ios/{{ templateName }}`.
### Packaged startup
Existing services run `src/services/worker/dist/main.js`. In the worker archive,
that path points to `bin/bootstrap.cjs`. The bootstrap loads only Node built-ins
and starts `bin/start-worker.sh`. The shell then uses `exec` to start the normal
worker with the original Node binary, options, and arguments.
Put setup that must precede worker imports or Node startup options in the shell.
The bootstrap forwards SIGINT, SIGTERM, IPC messages, and the worker exit status.
It remains the supervised parent process; test service shutdown on both platforms
before deployment. A failure in the startup shell prevents the worker from
loading. Direct execution of `packages/worker/dist/main.js` bypasses this hook.
Run `yarn workspace @expo/worker test:startup` for the process-level checks.
+53
View File
@@ -0,0 +1,53 @@
// This entry point must only load Node built-ins before the startup shell runs.
const { spawn } = require('node:child_process');
const path = require('node:path');
const worker = spawn(
'/bin/bash',
[
path.join(__dirname, 'start-worker.sh'),
process.execPath,
...process.execArgv,
path.join(__dirname, '../dist/main.js'),
...process.argv.slice(2),
],
{ stdio: process.send ? ['inherit', 'inherit', 'inherit', 'ipc'] : 'inherit' }
);
const signals = ['SIGINT', 'SIGTERM'];
for (const signal of signals) {
process.on(signal, () => worker.kill(signal));
}
// Retain the readiness channel used by process managers in development.
if (process.send) {
worker.on('message', message => {
if (process.connected) {
process.send(message, () => {});
}
});
process.on('message', message => {
if (worker.connected) {
worker.send(message, () => {});
}
});
process.on('disconnect', () => {
if (worker.connected) {
worker.disconnect();
}
});
}
worker.on('error', error => {
process.stderr.write(`Worker startup failed: ${error.stack ?? error}\n`, () => process.exit(1));
});
worker.on('exit', (code, signal) => {
if (signal) {
for (const forwarded of signals) {
process.removeAllListeners(forwarded);
}
process.kill(process.pid, signal);
} else {
process.exit(code ?? 1);
}
});
+5
View File
@@ -0,0 +1,5 @@
#!/usr/bin/env bash
set -euo pipefail
# Startup setup belongs here, before the normal worker's Node process starts.
exec "$@"
+3 -2
View File
@@ -11,11 +11,12 @@
"compile": "tsc -p tsconfig.build.json",
"prepublishOnly": "npm run build",
"test": "yarn test:unit",
"test:unit": "./resources/jest --config jest.config.unit.ts",
"test:unit": "yarn test:startup && ./resources/jest --config jest.config.unit.ts",
"test:unit:watch": "./resources/jest --config jest.config.unit.ts --watch",
"test:integration": "./resources/jest --runInBand --config jest.config.integration.ts",
"test:integration:watch": "./resources/jest --runInBand --config jest.config.integration.ts --watchAll",
"typecheck": "tsc"
"typecheck": "tsc",
"test:startup": "node --test tests/*.test.cjs"
},
"dependencies": {
"@babel/core": "7.24.7",
+2 -2
View File
@@ -120,10 +120,10 @@ fi
# Create backward-compatible symlink for orchestrator
# The orchestrator expects ./src/services/worker/dist/main.js
# but the new structure has ./packages/worker/dist/main.js
# Route it through the packaged startup wrapper before loading worker modules.
# We're only symlinking the specific file to discover any other legacy path usage
mkdir -p "$target_root_dir/src/services/worker/dist"
ln -s ../../../../packages/worker/dist/main.js "$target_root_dir/src/services/worker/dist/main.js"
ln -s ../../../../packages/worker/bin/bootstrap.cjs "$target_root_dir/src/services/worker/dist/main.js"
tar zcf $OUTPUT_FILE -C $target_root_dir .
+1 -26
View File
@@ -5,7 +5,7 @@ import config from '../config';
import { createBuildContext } from '../context';
import { getBuildEnv } from '../env';
import { prepareRuntimeEnvironmentConfigFiles } from '../runtimeEnvironment';
import { uploadSourceMapAsync, uploadWorkflowArtifactAsync } from '../upload';
import { uploadSourceMapAsync } from '../upload';
jest.mock('../env', () => ({
getBuildEnv: jest.fn(() => ({
@@ -18,7 +18,6 @@ jest.mock('../runtimeEnvironment', () => ({
}));
jest.mock('../upload', () => ({
...jest.requireActual('../upload'),
uploadWorkflowArtifactAsync: jest.fn(async () => ({ artifactId: 'artifact-id' })),
uploadSourceMapAsync: jest.fn(async () => ({ filename: null })),
uploadWithAnalyticsAsync: jest.fn(async fn => await fn()),
}));
@@ -130,28 +129,4 @@ describe(createBuildContext.name, () => {
logger: childLogger,
});
});
it('uploads named artifacts to workflow storage and returns their ID', async () => {
const ctx = await createBuildContext({
...baseOptions,
job: {
type: Workflow.GENERIC,
secrets: { environmentSecrets: [] },
} as any,
});
const metadata = { kind: 'maestro-report' };
const result = await ctx.uploadArtifact({
artifact: { type: null, name: 'Report', paths: ['/workingdir/report'], metadata },
logger: childLogger,
});
expect(uploadWorkflowArtifactAsync).toHaveBeenCalledWith(ctx, {
artifactPaths: ['/workingdir/report'],
name: 'Report',
metadata,
logger: childLogger,
});
expect(result).toEqual({ artifactId: 'artifact-id' });
expect(ctx.artifacts).toEqual({});
});
});
+3 -1
View File
@@ -18,7 +18,9 @@ jest.mock('@expo/build-tools', () => {
MockRemoteLoggerStream.instances.push(this);
}
public async init(): Promise<void> {}
public async init(): Promise<string> {
return 'https://gcs.expo.test/logs';
}
public async cleanUp(): Promise<void> {}
@@ -111,7 +111,6 @@ describe('prepareRuntimeEnvironment', () => {
describe('installNode', () => {
describe('prepareRuntimeEnvironment', () => {
beforeEach(() => {
jest.spyOn(RuntimeSettings, 'getNodeJsCacheUrl').mockReturnValue('https://node.example');
jest.mocked(pathExists).mockResolvedValue(true);
jest.mocked(spawn).mockImplementation((cmd, _args, opts) => {
@@ -149,104 +148,6 @@ describe('prepareRuntimeEnvironment', () => {
);
});
it.each([
{
mirror: 'https://node.example',
failures: 0,
attempts: ['https://node.example'],
succeeds: true,
},
{
mirror: 'https://node.example',
failures: 1,
attempts: ['https://node.example', undefined],
succeeds: true,
},
{
mirror: 'https://node.example',
failures: 2,
attempts: ['https://node.example', undefined],
succeeds: false,
},
{
mirror: 'https://private.example',
failures: 1,
attempts: ['https://private.example'],
succeeds: false,
},
{
mirror: 'https://node.example',
cacheUrl: null,
failures: 1,
attempts: ['https://node.example'],
succeeds: false,
},
{ mirror: undefined, failures: 1, attempts: [undefined], succeeds: false },
{ mirror: '', failures: 1, attempts: [''], succeeds: false },
])(
'handles Node.js install with mirror=$mirror and failures=$failures',
async ({ mirror, failures, attempts, succeeds, cacheUrl = 'https://node.example' }) => {
jest.mocked(RuntimeSettings.getNodeJsCacheUrl).mockReturnValue(cacheUrl);
const buildContext = {
...ctx,
env: { ...ctx.env, PATH: '/usr/bin', NVM_NODEJS_ORG_MIRROR: mirror },
};
const installMirrors: (string | undefined)[] = [];
jest.mocked(spawn).mockImplementation(async (cmd, args, opts) => {
if (cmd === 'bash' && args[1].includes('nvm install')) {
installMirrors.push(opts?.env?.NVM_NODEJS_ORG_MIRROR);
if (installMirrors.length <= failures) {
throw new Error('Node.js download failed');
}
return { ...spawnResult, stdout: 'Now using node v16.20.1 (npm v8.19.4)' };
}
return spawnResult;
});
const preparation = prepareRuntimeEnvironment(buildContext, { node: 'v16' }, false);
if (succeeds) {
await preparation;
expect(buildContext.env.PATH).toMatch(
/\/\.nvm\/versions\/node\/v16\.20\.1\/bin:\/usr\/bin$/
);
expect(spawn).toHaveBeenCalledWith(
'bash',
['-c', 'source ~/.nvm/nvm.sh && nvm alias default 16.20.1'],
expect.anything()
);
expect(ctx.logger.error).not.toHaveBeenCalled();
} else {
await expect(preparation).rejects.toThrow('Failed to install Node.js.');
expect(buildContext.env.PATH).toBe('/usr/bin');
}
expect(installMirrors).toStrictEqual(attempts);
expect(buildContext.env.NVM_NODEJS_ORG_MIRROR).toBe(attempts[attempts.length - 1]);
}
);
it('does not retry Node.js installation when setting the default alias fails', async () => {
const buildContext = {
...ctx,
env: { ...ctx.env, NVM_NODEJS_ORG_MIRROR: 'https://node.example' },
};
let installCount = 0;
jest.mocked(spawn).mockImplementation(async (cmd, args) => {
if (cmd === 'bash' && args[1].includes('nvm install')) {
installCount++;
}
if (cmd === 'bash' && args[1].includes('nvm alias')) {
throw new Error('Cannot write default alias');
}
return spawnResult;
});
await expect(
prepareRuntimeEnvironment(buildContext, { node: '16.20.1' }, false)
).rejects.toThrow('Failed to install Node.js.');
expect(installCount).toBe(1);
expect(buildContext.env.NVM_NODEJS_ORG_MIRROR).toBe('https://node.example');
});
it('installs Bun when a specified version is different from installed version', async () => {
let isFirstTimeCheckingBunVersion = true;
jest.mocked(spawn).mockImplementation((cmd, _args, _opts) => {
+5 -22
View File
@@ -136,32 +136,15 @@ async function installNode(ctx: PreDownloadBuildContext, version: string): Promi
let sanitizedVersion = version.startsWith('v') ? version.slice(1) : version;
try {
ctx.logger.info(`Installing node v${sanitizedVersion}`);
const installAsync = async () => {
return await spawn('bash', ['-c', `source ~/.nvm/nvm.sh && nvm install ${version}`], {
const { stdout } = await spawn(
'bash',
['-c', `source ~/.nvm/nvm.sh && nvm install ${version}`],
{
logger: ctx.logger,
env: ctx.env,
});
};
let result;
try {
result = await installAsync();
} catch (err) {
const mirror = ctx.env.NVM_NODEJS_ORG_MIRROR;
// Only bypass the internal mirror enabled by the runtime settings.
if (!mirror || mirror !== RuntimeSettings.getNodeJsCacheUrl()) {
throw err;
}
);
ctx.logger.warn(
{ err },
'Failed to install Node.js using the worker mirror. Removing NVM_NODEJS_ORG_MIRROR from the build environment for the rest of this build and retrying with the default download source.'
);
// Keep the failed mirror disabled for the rest of this build.
delete ctx.env.NVM_NODEJS_ORG_MIRROR;
result = await installAsync();
}
const { stdout } = result;
sanitizedVersion = stdout.match(/Now using node v(\d+\.\d+\.\d+)/)?.[1] ?? sanitizedVersion;
await spawn('bash', ['-c', `source ~/.nvm/nvm.sh && nvm alias default ${sanitizedVersion}`], {
logger: ctx.logger,
+100
View File
@@ -0,0 +1,100 @@
const assert = require('node:assert/strict');
const { spawn } = require('node:child_process');
const { once } = require('node:events');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { test } = require('node:test');
function archive(t, main, shell) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'worker-startup-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const worker = path.join(root, 'packages/worker');
fs.mkdirSync(path.join(worker, 'dist'), { recursive: true });
fs.cpSync(path.join(__dirname, '../bin'), path.join(worker, 'bin'), { recursive: true });
fs.writeFileSync(path.join(worker, 'dist/main.js'), main);
if (shell) {
fs.writeFileSync(path.join(worker, 'bin/start-worker.sh'), shell);
}
// Exercise the path that existing images run, including Node's symlink resolution.
const legacy = path.join(root, 'src/services/worker/dist/main.js');
fs.mkdirSync(path.dirname(legacy), { recursive: true });
fs.symlinkSync('../../../../packages/worker/bin/bootstrap.cjs', legacy);
return legacy;
}
function launch(t, entry, args = []) {
const child = spawn(process.execPath, [entry, ...args], {
stdio: ['ignore', 'pipe', 'pipe', 'ipc'],
});
t.after(() => {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGKILL');
}
});
return child;
}
test(
'startup settings reach the worker before its first module runs',
{ timeout: 10000 },
async t => {
const entry = archive(
t,
'process.send({ ready: true, args: process.argv.slice(2), setting: process.env.STARTUP_TEST }); process.on("message", () => process.exit(7));',
'#!/bin/bash\nset -euo pipefail\nexport STARTUP_TEST=installed\nexec "$@"\n'
);
const child = launch(t, entry, ['argument with spaces']);
const exited = once(child, 'exit');
assert.deepEqual((await once(child, 'message'))[0], {
ready: true,
args: ['argument with spaces'],
setting: 'installed',
});
child.send('stop');
assert.deepEqual(await exited, [7, null]);
}
);
for (const signal of ['SIGINT', 'SIGTERM']) {
test(`forwards ${signal} and preserves termination status`, { timeout: 10000 }, async t => {
const entry = archive(t, 'process.send("ready"); setInterval(() => {}, 1000);');
const child = launch(t, entry);
const exited = once(child, 'exit');
await once(child, 'message');
child.kill(signal);
assert.deepEqual(await exited, [null, signal]);
});
}
test('startup failure prevents loading the worker', { timeout: 10000 }, async t => {
const entry = archive(
t,
'process.send("must not start");',
'#!/bin/bash\nset -euo pipefail\nexit 42\nexec "$@"\n'
);
const child = launch(t, entry);
const messages = [];
child.on('message', message => messages.push(message));
assert.deepEqual(await once(child, 'exit'), [42, null]);
assert.deepEqual(messages, []);
});
test('runs without IPC and retains stdout, stderr, and exit code', { timeout: 10000 }, async t => {
const entry = archive(
t,
'console.log("worker output"); console.error("worker error"); process.exit(9);'
);
const child = spawn(process.execPath, [entry], { stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '';
let stderr = '';
child.stdout.on('data', data => {
stdout += data;
});
child.stderr.on('data', data => {
stderr += data;
});
assert.deepEqual(await once(child, 'close'), [9, null]);
assert.equal(stdout, 'worker output\n');
assert.equal(stderr, 'worker error\n');
});