Compare commits

..
109 changed files with 6757 additions and 2899 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.
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
Add `eas/wait_for_asc_build_processing` to wait for an existing App Store Connect upload. Keep `eas/upload_to_asc`'s `wait_for_processing` input supported through the same processing logic.
-5
View File
@@ -1,5 +0,0 @@
---
'eas-cli': patch
---
Share one GraphQL client between the project ID check and the command, so commands send one fewer app query.
-5
View File
@@ -1,5 +0,0 @@
---
'@expo/build-tools': minor
---
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,
});
@@ -67,7 +67,6 @@ import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
import { createUploadEmbeddedBundleBuildFunction } from './functions/uploadEmbeddedBundle';
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
import { createWaitForAscBuildProcessingBuildFunction } from './functions/waitForAscBuildProcessing';
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
@@ -145,7 +144,6 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createSubmissionEntityFunction(),
createSubmitToGooglePlayBuildFunction(),
createUploadToAscBuildFunction(),
createWaitForAscBuildProcessingBuildFunction(),
createUpdateTestFlightMetadataBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
@@ -48,10 +48,7 @@ describe(createCheckoutBuildFunction, () => {
});
it('moves project sources to the target directory when no inputs are given', async () => {
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const { globalCtx, executeAsync } = createCheckoutStep({
logger,
projectArchive: GIT_PROJECT_ARCHIVE,
});
@@ -59,9 +56,6 @@ describe(createCheckoutBuildFunction, () => {
expect(await vol.promises.readFile(`${projectTargetDirectory}/app.json`, 'utf8')).toBe('{}');
expect(globalCtx.wasCheckedOut()).toBe(true);
expect(logger.info).toHaveBeenCalledWith(
expect.stringContaining('Changing default working directory to')
);
expect(fetchAndCheckoutRefAsync).not.toHaveBeenCalled();
});
@@ -102,11 +96,14 @@ describe(createCheckoutBuildFunction, () => {
});
it('rejects the ref input when the project is already checked out', async () => {
const logger = createMockLogger();
jest.mocked(logger.child).mockReturnValue(logger);
const { globalCtx, executeAsync } = createCheckoutStep({
logger,
projectArchive: GIT_PROJECT_ARCHIVE,
callInputs: { ref: 'feature/add-icon' },
});
globalCtx.markAsCheckedOut();
globalCtx.markAsCheckedOut(logger);
await expect(executeAsync()).rejects.toMatchObject({
errorCode: 'EAS_CHECKOUT_REF_AFTER_CHECKOUT',
@@ -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();
@@ -0,0 +1,112 @@
import {
isClosedVersionTrainError,
isInvalidBundleIdentifierError,
isMissingPurposeStringError,
isSdkVersionIssueError,
parseMissingUsageDescriptionKeys,
} from '../uploadToAsc';
describe(isClosedVersionTrainError, () => {
it('returns true when all errors are closed-version-train codes', () => {
expect(
isClosedVersionTrainError([
{ code: '90062' },
{ code: '90186' },
{ code: '90478' },
{ code: '90062' },
])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(
isClosedVersionTrainError([{ code: '90062' }, { code: '90186' }, { code: '12345' }])
).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isClosedVersionTrainError([])).toBe(false);
});
});
describe(isInvalidBundleIdentifierError, () => {
it('returns true when all errors are invalid-bundle-id codes', () => {
expect(
isInvalidBundleIdentifierError([{ code: '90054' }, { code: '90055' }, { code: '90055' }])
).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isInvalidBundleIdentifierError([{ code: '90055' }, { code: '90713' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isInvalidBundleIdentifierError([])).toBe(false);
});
});
describe(isMissingPurposeStringError, () => {
it('returns true when all errors are missing-purpose-string codes', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90683' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isMissingPurposeStringError([{ code: '90683' }, { code: '90054' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isMissingPurposeStringError([])).toBe(false);
});
});
describe(isSdkVersionIssueError, () => {
it('returns true when all errors are SDK-version-issue codes', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90725' }])).toBe(true);
});
it('returns false when any other error code is present', () => {
expect(isSdkVersionIssueError([{ code: '90725' }, { code: '90062' }])).toBe(false);
});
it('returns false when there are no errors', () => {
expect(isSdkVersionIssueError([])).toBe(false);
});
});
describe(parseMissingUsageDescriptionKeys, () => {
it('extracts missing UsageDescription keys from ASC messages', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSPhotoLibraryUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSPhotoLibraryUsageDescription', 'NSCameraUsageDescription']);
});
it('deduplicates repeated keys', () => {
expect(
parseMissingUsageDescriptionKeys([
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
{
description:
'The Info.plist file should contain a NSCameraUsageDescription key with a user-facing purpose string. (90683)',
},
])
).toEqual(['NSCameraUsageDescription']);
});
it('returns an empty array when no usage description key is present', () => {
expect(
parseMissingUsageDescriptionKeys([{ description: 'Some other upload validation error.' }])
).toEqual([]);
});
});
@@ -1,239 +0,0 @@
import fs from 'fs-extra';
import nock from 'nock';
import { generateKeyPairSync } from 'node:crypto';
import { setTimeout } from 'node:timers/promises';
import {
createCustomBuildContextMock,
createGlobalContextMock,
} from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import { getEasFunctions } from '../../easFunctions';
import { readIpaInfoAsync } from '../readIpaInfo';
import { createUploadToAscBuildFunction } from '../uploadToAsc';
jest.unmock('node-fetch');
jest.mock('node:timers/promises', () => ({ setTimeout: jest.fn().mockResolvedValue(undefined) }));
jest.mock('../readIpaInfo', () => ({
...jest.requireActual('../readIpaInfo'),
readIpaInfoAsync: jest.fn(),
}));
const { privateKey } = generateKeyPairSync('ec', { namedCurve: 'prime256v1' });
const api = () => nock('https://api.appstoreconnect.apple.com');
const logger = createMockLogger();
const globalContext = () => createGlobalContextMock({ projectTargetDirectory: '/project', logger });
async function waitAsync(diagnostics: Record<string, string> = {}): Promise<void> {
const fn = getEasFunctions(createCustomBuildContextMock()).find(
fn => fn.getFullId() === 'eas/wait_for_asc_build_processing'
)!;
await fn
.createBuildStepFromFunctionCall(globalContext(), {
callInputs: { asc_api_key_path: 'key.json', build_upload_id: 'upload', ...diagnostics },
})
.executeAsync();
}
async function uploadAsync(waitForProcessing?: boolean): Promise<void> {
await fs.outputFile('/project/app.ipa', 'ipa');
jest.mocked(readIpaInfoAsync).mockResolvedValue({
bundleIdentifier: 'com.test.ipa',
bundleVersion: '1',
bundleShortVersion: '1.0',
dtPlatformName: 'iphoneos',
});
mockUpload();
const step = createUploadToAscBuildFunction().createBuildStepFromFunctionCall(globalContext(), {
callInputs: {
ipa_path: 'app.ipa',
asc_api_key_path: 'key.json',
apple_app_identifier: 'app',
bundle_version: '1',
bundle_short_version: '1.0',
...(waitForProcessing === undefined ? {} : { wait_for_processing: waitForProcessing }),
},
});
await step.executeAsync();
}
function mockProcessing(state: string, errors: { code: string; description: string }[] = []): void {
api()
.get('/v1/buildUploads/upload')
.query(true)
.reply(200, {
data: { type: 'buildUploads', id: 'upload', attributes: { state: { state, errors } } },
});
}
function mockApp(): void {
api()
.get('/v1/apps/app')
.query(true)
.reply(200, {
data: { type: 'apps', id: 'app', attributes: { name: 'Test app', bundleId: 'com.test.app' } },
});
}
function mockUpload(): void {
mockApp();
api()
.post('/v1/buildUploads')
.reply(201, { data: { type: 'buildUploads', id: 'upload' } });
api()
.post('/v1/buildUploadFiles')
.reply(201, {
data: {
type: 'buildUploadFiles',
id: 'file',
attributes: {
uploadOperations: [
{
length: 3,
offset: 0,
partNumber: 1,
method: 'PUT',
requestHeaders: [],
url: 'https://upload.example.com/ipa',
},
],
},
},
});
nock('https://upload.example.com').put('/ipa', 'ipa').reply(200);
api()
.get('/v1/buildUploadFiles/file')
.query(true)
.twice()
.reply(200, {
data: {
type: 'buildUploadFiles',
id: 'file',
attributes: { assetDeliveryState: { state: 'COMPLETE' } },
},
});
}
beforeAll(() => nock.disableNetConnect());
afterAll(() => nock.enableNetConnect());
beforeEach(async () => {
await fs.outputJson('/project/key.json', {
key_id: 'TESTKEY',
key: privateKey.export({ type: 'pkcs8', format: 'pem' }),
});
jest.mocked(setTimeout).mockResolvedValue(undefined);
});
afterEach(() => {
try {
expect(nock.pendingMocks()).toEqual([]);
} finally {
nock.cleanAll();
jest.restoreAllMocks();
}
});
it('polls an existing upload through awaiting and processing states until complete', async () => {
mockProcessing('AWAITING_UPLOAD');
mockProcessing('PROCESSING');
mockProcessing('COMPLETE');
await expect(waitAsync()).resolves.toBeUndefined();
});
it('uses the supplied bundle identifier in mismatch errors without an IPA', async () => {
mockProcessing(
'FAILED',
['90054', '90055'].map(code => ({ code, description: 'Bundle mismatch' }))
);
mockApp();
api().get('/v1/apps').query(true).reply(200, { data: [] });
await expect(
waitAsync({ bundle_identifier: 'com.test.ipa', apple_app_identifier: 'app' })
).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
message: expect.stringContaining(
'IPA bundle identifier: com.test.ipa\nApp Store Connect app bundle identifier: com.test.app'
),
});
});
it.each<[string, string[], string]>([
['SDK version', ['90725'], 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE'],
['closed version train', ['90062', '90186', '90478'], 'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN'],
])('reports a %s processing error', async (_label, codes, errorCode) => {
mockProcessing(
'FAILED',
codes.map(code => ({ code, description: 'Rejected' }))
);
await expect(waitAsync()).rejects.toMatchObject({ errorCode });
});
it.each(
[
[],
['OTHER'],
['90725', 'OTHER'],
['90062', 'OTHER'],
['90054', 'OTHER'],
['90683', 'OTHER'],
].map(codes => ({ codes }))
)('keeps unclassified or mixed processing errors generic: $codes', async ({ codes }) => {
mockProcessing(
'FAILED',
codes.map(code => ({ code, description: 'Rejected' }))
);
await expect(waitAsync()).rejects.toThrow('Build upload (ID: upload) failed.');
});
it('reports missing privacy keys once each', async () => {
mockProcessing('FAILED', [
{
code: '90683',
description: 'Missing NSCameraUsageDescription and NSPhotoLibraryUsageDescription.',
},
{ code: '90683', description: 'Missing NSCameraUsageDescription.' },
]);
await expect(waitAsync()).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
message: expect.stringContaining(
'Missing keys reported by App Store Connect:\n- NSCameraUsageDescription\n- NSPhotoLibraryUsageDescription\n\n'
),
});
});
it('reports missing privacy purpose strings when Apple does not name a key', async () => {
mockProcessing('FAILED', [{ code: '90683', description: 'Missing purpose string.' }]);
await expect(waitAsync()).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
message: expect.not.stringContaining('Missing keys reported by App Store Connect:'),
});
});
it.each([undefined, false, true])(
'keeps upload wait_for_processing=%s compatible',
async waitForProcessing => {
if (waitForProcessing) {
mockProcessing('PROCESSING');
mockProcessing('FAILED', [{ code: '90725', description: 'SDK is too old' }]);
}
if (waitForProcessing) {
await expect(uploadAsync(waitForProcessing)).rejects.toMatchObject({
errorCode: 'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
});
} else {
await expect(uploadAsync(waitForProcessing)).resolves.toBeUndefined();
}
}
);
it.each(['upload', 'wait'])(
'preserves the successful timeout return for the %s step',
async step => {
mockProcessing('PROCESSING');
const start = Date.now();
jest.spyOn(Date, 'now').mockReturnValue(start);
jest.mocked(setTimeout).mockImplementationOnce(async () => {
jest.spyOn(Date, 'now').mockReturnValue(start + 30 * 60 * 1000);
});
await expect(step === 'upload' ? uploadAsync(true) : waitAsync()).resolves.toBeUndefined();
}
);
@@ -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)}).`);
}
@@ -93,7 +93,7 @@ export async function updateTestFlightMetadataAsync({
if (upload.attributes?.state?.state !== 'COMPLETE' || !buildId) {
throw new UserError(
'EAS_TESTFLIGHT_BUILD_NOT_READY',
'The uploaded build is not ready for TestFlight metadata. Run eas/wait_for_asc_build_processing first, or use eas/upload_to_asc with wait_for_processing: true.'
'The uploaded build is not ready for TestFlight metadata. Run eas/upload_to_asc with wait_for_processing: true first.'
);
}
logger.info(`Updating TestFlight metadata for Apple build ${buildId}...`);
@@ -1,4 +1,9 @@
import { Job, ManagedArtifactType } from '@expo/eas-build-job';
import {
GenericArtifactType,
Job,
ManagedArtifactType,
isGenericArtifact,
} from '@expo/eas-build-job';
import {
BuildFunction,
BuildStepInput,
@@ -28,12 +33,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
ManagedArtifactType.APPLICATION_ARCHIVE,
ManagedArtifactType.BUILD_ARTIFACTS,
...Object.keys(artifactTypeInputToManagedArtifactType),
// Keep accepting these legacy artifact types for compatibility.
'android-apk',
'android-aab',
'ios-simulator-app',
'ios-ipa',
'other',
...Object.values(GenericArtifactType),
],
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
@@ -126,13 +126,12 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
paths: artifactPaths,
name: (inputs.name.value || inputs.key.value) as string,
};
const artifact =
artifactSpec.type === null
? {
...artifactSpec,
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
}
: artifactSpec;
const artifact = isGenericArtifact(artifactSpec)
? {
...artifactSpec,
metadata: inputs.metadata.value as Record<string, unknown> | undefined,
}
: artifactSpec;
try {
const { artifactId } = await ctx.runtimeApi.uploadArtifact({ artifact, logger });
@@ -142,7 +141,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
}
} catch (error) {
if (inputs.ignore_error.value) {
logger.error({ err: error }, 'Failed to upload artifact. Ignoring error.');
logger.error({ err: error }, `Failed to upload ${artifact.type}. Ignoring error.`);
// Ignoring error.
return;
}
@@ -159,7 +158,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
* values. This makes sure we support all:
* - kebab-case managed artifact types (the original)
* - snake-caps-case managed artifact types (the mistake)
* - workflow artifact types.
* - generic artifact types.
*/
function parseArtifactTypeInput({
inputValue,
@@ -167,15 +166,19 @@ function parseArtifactTypeInput({
}: {
inputValue: string;
platform: Job['platform'];
}): ManagedArtifactType | null {
}): GenericArtifactType | ManagedArtifactType {
if (!inputValue) {
// In build jobs the default artifact type is application-archive.
return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : null;
return platform ? ManagedArtifactType.APPLICATION_ARCHIVE : GenericArtifactType.OTHER;
}
return (
artifactTypeInputToManagedArtifactType[inputValue] ??
Object.values(ManagedArtifactType).find(type => type === inputValue) ??
null
);
// Step's allowedValues ensures input is either
// a key of artifactTypeInputToManagedArtifactType
// or a value of an artifact type.
const translatedManagedArtifactType = artifactTypeInputToManagedArtifactType[inputValue];
if (translatedManagedArtifactType) {
return translatedManagedArtifactType;
}
return inputValue as GenericArtifactType | ManagedArtifactType;
}
@@ -1,3 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { asyncResult } from '@expo/results';
import {
BuildFunction,
@@ -14,7 +15,6 @@ import { z } from 'zod';
import { AscApiClient, AscApiClientPostApi } from '../utils/ios/AscApiClient';
import { AscApiUtils } from '../utils/ios/AscApiUtils';
import { readIpaInfoAsync } from './readIpaInfo';
import { waitForAscBuildProcessingAsync } from './waitForAscBuildProcessing';
export function createUploadToAscBuildFunction(): BuildFunction {
return new BuildFunction({
@@ -184,10 +184,10 @@ export function createUploadToAscBuildFunction(): BuildFunction {
const { errors = [], warnings = [] } = assetDeliveryState;
if (warnings.length > 0) {
stepsCtx.logger.warn(`Warnings:\n${AscApiUtils.itemizeMessages(warnings)}\n`);
stepsCtx.logger.warn(`Warnings:\n${itemizeMessages(warnings)}\n`);
}
if (errors.length > 0) {
stepsCtx.logger.error(`Errors:\n${AscApiUtils.itemizeMessages(errors)}\n`);
stepsCtx.logger.error(`Errors:\n${itemizeMessages(errors)}\n`);
}
if (assetDeliveryState.state === 'FAILED') {
@@ -208,19 +208,165 @@ export function createUploadToAscBuildFunction(): BuildFunction {
return;
}
await waitForAscBuildProcessingAsync({
client,
buildUploadId,
logger: stepsCtx.logger,
ipaBundleIdentifier: ipaInfoResult.ok ? ipaInfoResult.value.bundleIdentifier : undefined,
appleAppIdentifier: undefined,
ascAppBundleIdentifier,
bundleShortVersion,
});
stepsCtx.logger.info('Checking build upload status...');
const waitingForBuildStartedAt = Date.now();
const waitingLogIntervalMs = 10 * 1000;
let lastWaitLogTime = 0;
let lastWaitLogState: string | null = null;
while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) {
const {
data: {
attributes: { state },
},
} = await client.getAsync(
`/v1/buildUploads/:id`,
{ 'fields[buildUploads]': ['state', 'build'], include: ['build'] },
{ id: buildUploadId }
);
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
const now = Date.now();
if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) {
stepsCtx.logger.info(
`Waiting for build upload to complete... (status = ${state.state})`
);
lastWaitLogTime = now;
lastWaitLogState = state.state;
}
await setTimeout(2000);
continue;
}
stepsCtx.logger.info('\n');
const { errors = [], warnings = [], infos = [] } = state;
if (infos.length > 0) {
stepsCtx.logger.info(`Infos:\n${itemizeMessages(infos)}\n`);
}
if (warnings.length > 0) {
stepsCtx.logger.warn(`Warnings:\n${itemizeMessages(warnings)}\n`);
}
if (errors.length > 0) {
stepsCtx.logger.error(`Errors:\n${itemizeMessages(errors)}\n`);
}
if (state.state === 'FAILED') {
if (isSdkVersionIssueError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
{
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
const ipaInfoResult = await asyncResult(readIpaInfoAsync(ipaPath));
const ipaBundleIdentifier = ipaInfoResult.ok
? ipaInfoResult.value.bundleIdentifier
: null;
let visibleAppsSummary: string | null = null;
try {
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
} catch {
// Ok to fail, this is just trying to be helpful.
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier}\n\n` +
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
'\n\nOther App Store Connect apps visible to this API key:\n' +
(visibleAppsSummary ?? ' (unable to retrieve)'),
{
docsUrl: 'https://expo.fyi/asc-app-id',
}
);
}
if (isMissingPurposeStringError(errors)) {
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
throw new UserError(
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
`${
missingUsageDescriptionKeys.length > 0
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
'\n- '
)}\n\n`
: ''
}` +
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
'If you do not use CNG, update your app target Info.plist directly.',
{
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
}
);
}
if (isClosedVersionTrainError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
`Build upload was rejected by App Store Connect because the ${bundleShortVersion} app version is not accepted for new build submissions. ` +
'This usually means the version train is closed or lower than a previously approved version. ' +
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
);
}
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
} else if (state.state === 'COMPLETE') {
stepsCtx.logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
}
break;
}
},
});
}
function itemizeMessages(messages: { description: string; code: string }[]): string {
return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`;
}
export function isClosedVersionTrainError(messages: { code: string }[]): boolean {
return (
messages.length > 0 &&
messages.every(message => ['90062', '90186', '90478'].includes(message.code))
);
}
export function isSdkVersionIssueError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90725');
}
export function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
);
}
export function isMissingPurposeStringError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90683');
}
export function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] {
const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g;
const keys = new Set<string>();
for (const message of messages) {
const matches = message.description.matchAll(usageDescriptionKeyRegex);
for (const match of matches) {
keys.add(match[1]);
}
}
return [...keys];
}
async function uploadChunksAsync({
uploadOperations,
ipaPath,
@@ -1,245 +0,0 @@
import { UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { BuildFunction, BuildStepInput, BuildStepInputValueTypeName } from '@expo/steps';
import path from 'node:path';
import { setTimeout } from 'node:timers/promises';
import { z } from 'zod';
import { AscApiClient } from '../utils/ios/AscApiClient';
import { AscApiUtils } from '../utils/ios/AscApiUtils';
export function createWaitForAscBuildProcessingBuildFunction(): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'wait_for_asc_build_processing',
name: 'Wait for App Store Connect processing',
__metricsId: 'eas/wait_for_asc_build_processing',
inputProviders: [
BuildStepInput.createProvider({
id: 'asc_api_key_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'build_upload_id',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'bundle_identifier',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'apple_app_identifier',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'bundle_short_version',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
fn: async (ctx, { inputs }) => {
const parsedInputs = z
.object({
asc_api_key_path: z.string(),
build_upload_id: z.string(),
bundle_identifier: z.string().optional(),
apple_app_identifier: z.string().optional(),
bundle_short_version: z.string().optional(),
})
.parse({
asc_api_key_path: inputs.asc_api_key_path.value,
build_upload_id: inputs.build_upload_id.value,
bundle_identifier: inputs.bundle_identifier.value,
apple_app_identifier: inputs.apple_app_identifier.value,
bundle_short_version: inputs.bundle_short_version.value,
});
const keyPath = path.resolve(ctx.workingDirectory, parsedInputs.asc_api_key_path);
const key = await AscApiUtils.loadApiKeyAsync({ keyPath });
const client = new AscApiClient({ key, logger: ctx.logger });
await waitForAscBuildProcessingAsync({
client,
buildUploadId: parsedInputs.build_upload_id,
logger: ctx.logger,
ipaBundleIdentifier: parsedInputs.bundle_identifier,
appleAppIdentifier: parsedInputs.apple_app_identifier,
ascAppBundleIdentifier: undefined,
bundleShortVersion: parsedInputs.bundle_short_version,
});
},
});
}
export async function waitForAscBuildProcessingAsync({
client,
buildUploadId,
logger,
ipaBundleIdentifier,
appleAppIdentifier,
ascAppBundleIdentifier,
bundleShortVersion,
}: {
client: AscApiClient;
buildUploadId: string;
logger: bunyan;
ipaBundleIdentifier: string | undefined;
appleAppIdentifier: string | undefined;
ascAppBundleIdentifier: string | undefined;
bundleShortVersion: string | undefined;
}): Promise<void> {
logger.info('Checking build upload status...');
const waitingForBuildStartedAt = Date.now();
const waitingLogIntervalMs = 10 * 1000;
let lastWaitLogTime = 0;
let lastWaitLogState: string | null = null;
while (Date.now() - waitingForBuildStartedAt < 30 * 60 * 1000 /* 30 minutes */) {
const {
data: {
attributes: { state },
},
} = await client.getAsync(
`/v1/buildUploads/:id`,
{ 'fields[buildUploads]': ['state', 'build'], include: ['build'] },
{ id: buildUploadId }
);
if (state.state === 'AWAITING_UPLOAD' || state.state === 'PROCESSING') {
const now = Date.now();
if (lastWaitLogState !== state.state || now - lastWaitLogTime >= waitingLogIntervalMs) {
logger.info(`Waiting for build upload to complete... (status = ${state.state})`);
lastWaitLogTime = now;
lastWaitLogState = state.state;
}
await setTimeout(2000);
continue;
}
logger.info('\n');
const { errors = [], warnings = [], infos = [] } = state;
if (infos.length > 0) {
logger.info(`Infos:\n${AscApiUtils.itemizeMessages(infos)}\n`);
}
if (warnings.length > 0) {
logger.warn(`Warnings:\n${AscApiUtils.itemizeMessages(warnings)}\n`);
}
if (errors.length > 0) {
logger.error(`Errors:\n${AscApiUtils.itemizeMessages(errors)}\n`);
}
if (state.state === 'FAILED') {
if (isSdkVersionIssueError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_SDK_VERSION_ISSUE',
'Build upload was rejected by App Store Connect because the IPA was built with an iOS SDK that is too old for current App Store Connect requirements. ' +
'In an Expo project, this usually means the build used an outdated EAS iOS image or the project still depends on an Expo SDK / native setup that does not support the required Xcode toolchain. ' +
'Upgrade to a supported Expo SDK if needed and/or select a newer iOS image in `eas.json`, rebuild, and submit again.',
{
docsUrl: 'https://docs.expo.dev/workflow/upgrading-expo-sdk-walkthrough/',
}
);
}
if (isInvalidBundleIdentifierError(errors)) {
if (!ascAppBundleIdentifier && appleAppIdentifier) {
try {
const app = await AscApiUtils.getAppInfoAsync({ client, appleAppIdentifier });
ascAppBundleIdentifier = app.data.attributes.bundleId;
} catch {
// Keep the processing error if app details are unavailable.
}
}
let visibleAppsSummary: string | null = null;
try {
const apps = await AscApiUtils.getAppsAsync({ client, limit: 5 });
visibleAppsSummary = AscApiUtils.formatAppsList(apps);
} catch {
// Ok to fail, this is just trying to be helpful.
}
throw new UserError(
'EAS_UPLOAD_TO_ASC_INVALID_BUNDLE_ID',
`Build upload was rejected by App Store Connect because the app bundle identifier in the IPA does not match the selected App Store Connect app.\n\n` +
`IPA bundle identifier: ${ipaBundleIdentifier ?? '(unavailable)'}\n` +
`App Store Connect app bundle identifier: ${ascAppBundleIdentifier ?? '(unavailable)'}\n\n` +
'Bundle identifier cannot be changed for an existing App Store Connect app. ' +
'If you selected the wrong app, change the Apple app identifier in the submit profile. ' +
'If you selected the right app, you may want to select a different build to upload (or rebuild with a different profile).' +
'\n\nOther App Store Connect apps visible to this API key:\n' +
(visibleAppsSummary ?? ' (unable to retrieve)'),
{
docsUrl: 'https://expo.fyi/asc-app-id',
}
);
}
if (isMissingPurposeStringError(errors)) {
const missingUsageDescriptionKeys = parseMissingUsageDescriptionKeys(errors);
throw new UserError(
'EAS_UPLOAD_TO_ASC_MISSING_PURPOSE_STRING',
`Build upload was rejected by App Store Connect because Info.plist is missing one or more privacy purpose strings.\n\n` +
`${
missingUsageDescriptionKeys.length > 0
? `Missing keys reported by App Store Connect:\n- ${missingUsageDescriptionKeys.join(
'\n- '
)}\n\n`
: ''
}` +
'Add the missing keys with clear user-facing explanations, then rebuild and submit again.\n' +
'If you use Continuous Native Generation (CNG), update `ios.infoPlist` in app.json/app.config.js.\n' +
'If you do not use CNG, update your app target Info.plist directly.',
{
docsUrl: 'https://docs.expo.dev/guides/permissions/#ios',
}
);
}
if (isClosedVersionTrainError(errors)) {
throw new UserError(
'EAS_UPLOAD_TO_ASC_CLOSED_VERSION_TRAIN',
`Build upload was rejected by App Store Connect because the ${bundleShortVersion ?? 'selected'} app version is not accepted for new build submissions. ` +
'This usually means the version train is closed or lower than a previously approved version. ' +
'Bump the iOS app version (CFBundleShortVersionString, e.g. expo.version) to a higher version, then rebuild and submit again.'
);
}
throw new Error(`Build upload (ID: ${buildUploadId}) failed.`);
} else if (state.state === 'COMPLETE') {
logger.info(`Build upload (ID: ${buildUploadId}) complete!`);
}
break;
}
}
function isClosedVersionTrainError(messages: { code: string }[]): boolean {
return (
messages.length > 0 &&
messages.every(message => ['90062', '90186', '90478'].includes(message.code))
);
}
function isSdkVersionIssueError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90725');
}
function isInvalidBundleIdentifierError(messages: { code: string }[]): boolean {
return (
messages.length > 0 && messages.every(message => ['90054', '90055'].includes(message.code))
);
}
function isMissingPurposeStringError(messages: { code: string }[]): boolean {
return messages.length > 0 && messages.every(message => message.code === '90683');
}
function parseMissingUsageDescriptionKeys(messages: { description: string }[]): string[] {
const usageDescriptionKeyRegex = /\b(\w+UsageDescription)\b/g;
const keys = new Set<string>();
for (const message of messages) {
const matches = message.description.matchAll(usageDescriptionKeyRegex);
for (const match of matches) {
keys.add(match[1]);
}
}
return [...keys];
}
@@ -18,14 +18,7 @@ import {
} from '../deviceRunSessionPreview';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
jest.mock('node-fetch', () => {
const actual = jest.requireActual('node-fetch');
return {
__esModule: true,
...actual,
default: jest.fn(),
};
});
jest.mock('node-fetch');
jest.mock('@expo/turtle-spawn');
jest.mock('../../../utils/turtleFetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
@@ -94,8 +87,8 @@ describe(startDeviceRunSessionPreview, () => {
'https://uploads.expo.test/preview',
expect.objectContaining({
method: 'PUT',
body: expect.any(Readable),
headers: { 'content-type': 'image/webp', 'content-length': String(image.length) },
body: image,
headers: { 'content-type': 'image/webp' },
})
);
await jest.advanceTimersByTimeAsync(59_999);
@@ -137,7 +130,9 @@ describe(startDeviceRunSessionPreview, () => {
await jest.advanceTimersByTimeAsync(0);
expect(logger.warn).toHaveBeenCalledTimes(1);
const [fields] = jest.mocked(logger.warn).mock.calls[0] as unknown as [{ err: Error }];
expect(fields.err.message).toBe('Signed URL upload failed: network error.');
expect(fields.err.message).toBe(
'Session preview upload request failed (name: FetchError, type: request-timeout).'
);
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('uploads.expo.test');
});
@@ -1,5 +1,4 @@
import { SystemError } from '@expo/eas-build-job';
import { FetchError } from 'node-fetch';
import { CustomBuildContext } from '../../../customBuildContext';
import { turtleFetch } from '../../../utils/turtleFetch';
@@ -12,7 +11,6 @@ import {
} from '../deviceRunSessionWorkerApi';
jest.mock('../../../utils/turtleFetch');
jest.unmock('node-fetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
type Response = InstanceType<typeof Response>;
@@ -264,12 +262,12 @@ describe(DeviceRunSessionWorkerApi, () => {
it('rejects an application response without the build field', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { platform: 'ios' } }));
await expect(createApi().getApplicationAsync({ retries: 3 })).rejects.toThrow(
await expect(createApi().getApplicationAsync()).rejects.toThrow(
'GET /v2/device-run-session-worker/application returned an unexpected response'
);
});
it('passes the signal and timeout without enabling nested retries', async () => {
it('passes the signal, timeout, and retries to the request', async () => {
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
const controller = new AbortController();
@@ -282,7 +280,7 @@ describe(DeviceRunSessionWorkerApi, () => {
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
expect.any(String),
'POST',
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 0 })
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 1 })
);
});
@@ -321,7 +319,7 @@ describe(DeviceRunSessionWorkerApi, () => {
);
const error = await createApi()
.getSessionAsync({ retries: 0 })
.getSessionAsync()
.catch(err => err);
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
@@ -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
@@ -302,8 +302,4 @@ export namespace AscApiUtils {
.join('\n') || ' (none)'
);
}
export function itemizeMessages(messages: { description: string; code: string }[]): string {
return `- ${messages.map(m => `${m.description} (${m.code})`).join('\n- ')}`;
}
}
@@ -560,10 +560,7 @@ export async function uploadRemoteSessionConfigAsync({
);
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
try {
await workerApi.startAsync(
{ remoteConfig },
{ retries: 3, timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS, logger }
);
await workerApi.startAsync({ remoteConfig });
} catch (err) {
throw new SystemError(
`Failed to start device run session ${deviceRunSessionId}: ${
@@ -14,8 +14,12 @@ import { performance } from 'node:perf_hooks';
import { Readable } from 'node:stream';
import { setTimeout as delay } from 'node:timers/promises';
import { SignedUrlUploadApiError, uploadWithSignedUrl } from '../../storage/uploadWithSignedUrl';
import { graphqlAbortContext } from '../../utils/graphqlAbort';
import {
ArtifactPutError,
type ArtifactUploadSession,
putArtifactWithRetriesAsync,
} from './artifactPut';
const CREATE_SANDBOX_ARTIFACT_UPLOAD_SESSION_MUTATION = graphql(`
mutation CreateSandboxArtifactUploadSession(
@@ -234,23 +238,18 @@ async function putSandboxArtifactAsync({
}: {
filePath: string;
size: number;
uploadSession: { url: string; headers: unknown };
uploadSession: ArtifactUploadSession;
signal: AbortSignal;
}): Promise<void> {
const openStream = (): Readable =>
size === 0 ? Readable.from([]) : createReadStream(filePath, { start: 0, end: size - 1 });
try {
await uploadWithSignedUrl(
{
signedUrl: {
url: uploadSession.url,
headers: uploadSession.headers as Record<string, string>,
},
srcGeneratorAsync: async () =>
size === 0 ? Readable.from([]) : createReadStream(filePath, { start: 0, end: size - 1 }),
},
{ signal, retries: 2, retryIntervalMs: 500 }
await putArtifactWithRetriesAsync(
{ uploadSession, source: { openStream }, signal },
{ attempts: 3 }
);
} catch (error) {
if (!(error instanceof SignedUrlUploadApiError && error.status === 412)) {
if (!(error instanceof ArtifactPutError && error.status === 412)) {
throw error;
}
}
@@ -1,254 +1,325 @@
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
import { performance } from 'node:perf_hooks';
import { Readable } from 'node:stream';
import { randomBytes } from 'crypto';
import fetch, { RequestInit, Response } from 'node-fetch';
import { Readable } from 'stream';
import {
SignedUrlUploadApiError,
SignedUrlUploadNetworkError,
uploadContentWithSignedUrl,
uploadWithSignedUrl,
} from '../uploadWithSignedUrl';
import { type SignedUrl, uploadWithSignedUrl } from '../uploadWithSignedUrl';
jest.unmock('node-fetch');
jest.mock('node-fetch');
let handle: (request: IncomingMessage, response: ServerResponse) => void | Promise<void>;
let server: ReturnType<typeof createServer>;
let origin: string;
let signedUrl: { url: string; headers: Record<string, string> };
let requests: number;
class ErrorWithCode extends Error {
protected readonly _code: string | undefined;
async function readBodyAsync(request: IncomingMessage): Promise<string> {
const chunks: Buffer[] = [];
for await (const chunk of request) {
chunks.push(chunk);
constructor(message: string, code?: string) {
super(message);
if (code) {
this._code = code;
}
}
public get code(): string | undefined {
return this._code;
}
return Buffer.concat(chunks).toString();
}
function respondWithStatuses(statuses: number[]): void {
handle = async (request, response) => {
await readBodyAsync(request);
response.statusCode = statuses[requests - 1];
response.end();
class DNSError extends ErrorWithCode {
protected readonly _code: 'ENOTFOUND' | 'EAI_AGAIN' = 'ENOTFOUND';
}
const TEST_BUCKET = 'turtle-v2-test';
function createSignedUrl(key: string, contentType: string): SignedUrl {
return {
url: `https://storage.googleapis.com/${TEST_BUCKET}/${key}?signature=test`,
headers: { 'content-type': contentType },
};
}
beforeEach(async () => {
requests = 0;
server = createServer((request, response) => {
requests++;
void handle(request, response);
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address();
if (!address || typeof address === 'string') {
throw new Error('Missing test server address');
}
origin = `http://127.0.0.1:${address.port}`;
signedUrl = { url: `${origin}/bucket/object?signature=secret-signature`, headers: {} };
});
describe('signed URL uploader', () => {
describe('uploadWithSignedUrl', () => {
it('should throw an error if upload fails', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: false,
status: 500,
} as Response;
afterEach(async () => {
server.closeAllConnections();
await new Promise<void>(resolve => server.close(() => resolve()));
});
const key = 'cat.jpg';
describe(uploadWithSignedUrl, () => {
it('sends the full body on every attempt', async () => {
const bodies: string[] = [];
handle = async (request, response) => {
bodies.push(await readBodyAsync(request));
response.statusCode = bodies.length === 1 ? 503 : 200;
response.end();
};
const payload = '*'.repeat(128 * 1024);
const signedUrl = createSignedUrl(key, 'image/jpeg');
await uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync: async () => Readable.from([Buffer.from(payload)]) },
{ retryIntervalMs: 0 }
);
expect(bodies).toEqual([payload, payload]);
});
it('waits longer after each failed attempt', async () => {
respondWithStatuses([503, 503, 200]);
const startedAt = performance.now();
await uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync: async () => Readable.from(['body']) },
{ retryIntervalMs: 100 }
);
expect(requests).toBe(3);
expect(performance.now() - startedAt).toBeGreaterThanOrEqual(290);
});
it('does not retry a client error', async () => {
handle = async (request, response) => {
await readBodyAsync(request);
response.statusCode = 403;
response.end('<Error><Code>AccessDenied</Code></Error>');
};
const error = await uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync: async () => Readable.from(['body']) },
{ retryIntervalMs: 0 }
).catch(err => err);
expect(error).toBeInstanceOf(SignedUrlUploadApiError);
expect(error).toMatchObject({
status: 403,
reason: 'HTTP 403 Forbidden',
message: 'Signed URL upload failed: HTTP 403 Forbidden.',
});
expect(requests).toBe(1);
});
it('retries a dropped connection', async () => {
handle = async (request, response) => {
if (requests === 1) {
request.socket.destroy();
return;
}
await readBodyAsync(request);
response.end();
};
const srcGeneratorAsync = jest.fn(async () => Readable.from(['body']));
await uploadWithSignedUrl({ signedUrl, srcGeneratorAsync }, { retryIntervalMs: 0 });
expect(srcGeneratorAsync).toHaveBeenCalledTimes(2);
expect(requests).toBe(2);
});
it('retries a refused connection and keeps the signed URL out of the error', async () => {
await new Promise<void>(resolve => server.close(() => resolve()));
const srcGeneratorAsync = jest.fn(async () => Readable.from(['body']));
const error = await uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync },
{ retries: 1, retryIntervalMs: 0 }
).catch(err => err);
expect(error).toBeInstanceOf(SignedUrlUploadNetworkError);
expect(error).toMatchObject({ code: 'ECONNREFUSED' });
expect(error.message).not.toContain('secret-signature');
expect(srcGeneratorAsync).toHaveBeenCalledTimes(2);
});
it('does not retry a network error outside the retryable codes', async () => {
const srcGeneratorAsync = jest.fn(async () => Readable.from(['body']));
const error = await uploadWithSignedUrl(
{
signedUrl: { ...signedUrl, url: signedUrl.url.replace('http:', 'https:') },
srcGeneratorAsync,
},
{ retryIntervalMs: 0 }
).catch(err => err);
expect(error).toBeInstanceOf(SignedUrlUploadNetworkError);
expect(error).toMatchObject({ code: 'EPROTO' });
expect(srcGeneratorAsync).toHaveBeenCalledTimes(1);
});
it('propagates a source error without retrying, even with a retryable code', async () => {
const sourceError = Object.assign(new Error('read failed'), { code: 'ECONNRESET' });
const srcGeneratorAsync = jest.fn(async (): Promise<Readable> => {
throw sourceError;
fetchMock.mockImplementation(async () => res);
await expect(
uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
})
).rejects.toThrow();
});
await expect(
uploadWithSignedUrl({ signedUrl, srcGeneratorAsync }, { retryIntervalMs: 0 })
).rejects.toBe(sourceError);
expect(srcGeneratorAsync).toHaveBeenCalledTimes(1);
expect(requests).toBe(0);
});
it('should return stripped URL if successful', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
it('retries an attempt that stalls past the stall timeout', async () => {
handle = async (request, response) => {
await readBodyAsync(request);
if (requests > 1) {
response.end();
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
fetchMock.mockImplementation(async () => res);
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
});
it('should retry upload on DNS error up to 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
fetchMock
.mockImplementationOnce(async () => {
throw new DNSError('failed once');
})
.mockImplementationOnce(async () => {
throw new DNSError('failed twice', 'EAI_AGAIN');
})
.mockImplementation(async () => res);
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should retry upload on retriable status codes error up to 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
fetchMock
.mockImplementationOnce(async () => {
return {
ok: false,
status: 503,
} as Response;
})
.mockImplementationOnce(async () => {
return {
ok: false,
status: 408,
} as Response;
})
.mockImplementation(async () => res);
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should retry upload on retriable status codes and DNS errors error up to 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
fetchMock
.mockImplementationOnce(async () => {
return {
ok: false,
status: 503,
} as Response;
})
.mockImplementationOnce(async () => {
throw new DNSError('failed once');
})
.mockImplementation(async () => res);
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/cat.jpg');
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should not retry upload on DNS error more than 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
const lastDNSError = new DNSError('failed thrice');
fetchMock
.mockImplementationOnce(async () => {
throw new DNSError('failed once');
})
.mockImplementationOnce(async () => {
throw new DNSError('failed twice', 'EAI_AGAIN');
})
.mockImplementationOnce(async () => {
throw lastDNSError;
})
.mockImplementation(async () => res);
await expect(
uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
})
).rejects.toThrow(lastDNSError);
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should not retry upload on retriable status code more than 2 times', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
fetchMock
.mockImplementationOnce(async () => {
return {
ok: false,
status: 503,
} as Response;
})
.mockImplementationOnce(async () => {
return {
ok: false,
status: 408,
} as Response;
})
.mockImplementationOnce(async () => {
return {
ok: false,
status: 504,
} as Response;
})
.mockImplementation(async () => res);
await expect(
uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
})
).rejects.toThrow();
expect(fetchMock).toHaveBeenCalledTimes(3);
});
it('should not retry upload on other error codes', async () => {
const fetchMock = jest.mocked(fetch);
const res = {
ok: true,
status: 200,
} as Response;
const key = 'cat.jpg';
const signedUrl = createSignedUrl(key, 'image/jpeg');
const nonDNSError = new ErrorWithCode('failed once', 'A_DIFFERENT_CODE');
fetchMock
.mockImplementationOnce(async () => {
throw nonDNSError;
})
.mockImplementation(async () => res);
await expect(
uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from('image'),
retryIntervalMs: 0,
})
).rejects.toThrow(nonDNSError);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('retries upload with full stream', async () => {
const fetchMock = jest.mocked(fetch);
const receivedBodies: Buffer[] = [];
const recordBody = async (request: RequestInit | undefined): Promise<void> => {
let body = Buffer.from([]);
for await (const chunk of request!.body as Readable) {
body = Buffer.concat([body, chunk]);
}
receivedBodies.push(body);
};
fetchMock
.mockImplementationOnce(async (_path, request) => {
await recordBody(request);
return {
ok: false,
status: 503,
} as Response;
})
.mockImplementationOnce(async (_path, request) => {
await recordBody(request);
throw new DNSError('failed once');
})
.mockImplementation(async (_path, request) => {
await recordBody(request);
return {
ok: true,
status: 201,
} as Response;
});
const signedUrl = createSignedUrl('text.txt', 'text/plain');
const bufferToUpload = randomBytes(16);
const result = await uploadWithSignedUrl({
signedUrl,
srcGeneratorAsync: async () => Readable.from(bufferToUpload),
retryIntervalMs: 0,
});
expect(result).toEqual('https://storage.googleapis.com/turtle-v2-test/text.txt');
expect(fetchMock).toHaveBeenCalledTimes(3);
expect(receivedBodies.length).toBe(3);
for (const body of receivedBodies) {
// Here we're testing that each body received in every retry is the same.
// If we passed the same body instance to each of the retries
// each retry would consume a chunk of the same stream,
// causing the uploaded file to be corrupted (missing first bytes).
expect(body).toStrictEqual(bufferToUpload);
}
};
const streams: Readable[] = [];
const srcGeneratorAsync = async (): Promise<Readable> => {
const stream = Readable.from(['body']);
streams.push(stream);
return stream;
};
await uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync },
{ retryIntervalMs: 0, stallTimeoutMs: 100 }
);
expect(requests).toBe(2);
expect(streams.every(stream => stream.destroyed)).toBe(true);
});
it('stops waiting for the next attempt when aborted', async () => {
const controller = new AbortController();
handle = async (request, response) => {
await readBodyAsync(request);
response.statusCode = 503;
response.end();
setTimeout(() => controller.abort(), 50);
};
await expect(
uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync: async () => Readable.from(['body']) },
{ signal: controller.signal, retryIntervalMs: 60_000 }
)
).rejects.toMatchObject({ name: 'AbortError' });
expect(requests).toBe(1);
});
});
describe(uploadContentWithSignedUrl, () => {
let received: IncomingMessage | undefined;
let body: string;
beforeEach(() => {
received = undefined;
handle = async (request, response) => {
received = request;
body = await readBodyAsync(request);
response.end();
};
});
it('counts the content length in bytes', async () => {
await uploadContentWithSignedUrl({ signedUrl, content: 'zażółć' });
expect(received?.headersDistinct['content-length']).toEqual(['10']);
expect(body).toBe('zażółć');
});
it('keeps a content length that the signed headers already set', async () => {
signedUrl.headers = { 'Content-Length': '5' };
await uploadContentWithSignedUrl({ signedUrl, content: 'hello' });
expect(received?.headersDistinct['content-length']).toEqual(['5']);
});
it('labels only string content as plain text', async () => {
await uploadContentWithSignedUrl({ signedUrl, content: 'hello' });
expect(received?.headersDistinct['content-type']).toEqual(['text/plain;charset=UTF-8']);
await uploadContentWithSignedUrl({ signedUrl, content: Buffer.from('hello') });
expect(received?.headersDistinct['content-type']).toBeUndefined();
});
it('keeps a content type that the signed headers already set', async () => {
signedUrl.headers = { 'Content-Type': 'application/json' };
await uploadContentWithSignedUrl({ signedUrl, content: '{}' });
expect(received?.headersDistinct['content-type']).toEqual(['application/json']);
});
});
});
+70
View File
@@ -0,0 +1,70 @@
import { Response } from 'node-fetch';
type RetryOptions = {
retries: number;
retryIntervalMs: number;
shouldRetryOnError: (error: unknown) => boolean;
shouldRetryOnResponse: (response: Response) => boolean;
};
// based on https://github.com/googleapis/nodejs-storage/blob/8ab50804fc7bae3bbd159bbb4adf65c02215b11b/src/storage.ts#L284-L320
export async function retryOnUploadFailure(
fn: (attemptCount: number) => Promise<Response>,
{
retries,
retryIntervalMs,
}: { retries: RetryOptions['retries']; retryIntervalMs: RetryOptions['retryIntervalMs'] }
): Promise<Response> {
return await retry(fn, {
retries,
retryIntervalMs,
shouldRetryOnError: err => {
return (
isErrorWithCode(err) &&
(err.code === 'ENOTFOUND' ||
err.code === 'EAI_AGAIN' ||
err.code === 'ECONNRESET' ||
err.code === 'ETIMEDOUT' ||
err.code === 'EPIPE')
);
},
shouldRetryOnResponse: resp => {
return [408, 429, 500, 502, 503, 504].includes(resp.status);
},
});
}
function isErrorWithCode(error: unknown): error is { code: string } {
return typeof error === 'object' && error !== null && 'code' in error;
}
/**
* Wrapper used to execute an inner function and possibly retry it if it throws an error
* @param fn Function to be executed and retried in case of error
* @param retries How many times at most should the function be retried
* @param retryIntervalMs Time interval between the retries
* @param shouldRetryOnError Function that determines if the function should be retried based on the error thrown
* @param shouldRetryOnResponse Function that determines if the function should be retried based on the response
*/
async function retry(
fn: (attemptCount: number) => Promise<Response>,
{ retries, retryIntervalMs, shouldRetryOnError, shouldRetryOnResponse }: RetryOptions
): Promise<Response> {
let attemptCount = -1;
for (;;) {
try {
attemptCount += 1;
const resp = await fn(attemptCount);
if (attemptCount < retries && shouldRetryOnResponse(resp)) {
await new Promise(res => setTimeout(res, retryIntervalMs));
} else {
return resp;
}
} catch (err: unknown) {
if (attemptCount === retries || !shouldRetryOnError(err)) {
throw err;
}
await new Promise(res => setTimeout(res, retryIntervalMs));
}
}
}
@@ -1,11 +1,8 @@
import { SystemError } from '@expo/eas-build-job';
import fetch, { FetchError } from 'node-fetch';
import { Readable } from 'node:stream';
import { finished } from 'node:stream/promises';
import { setTimeout as delay } from 'node:timers/promises';
import { Readable } from 'stream';
import { URL } from 'url';
import { AbortUtils } from '../utils/AbortUtils';
import { isConnectFailure, isConnectionInterruptedError } from '../utils/networkErrors';
import { retryOnUploadFailure } from './retry';
export type SignedUrl = {
url: string;
@@ -15,140 +12,46 @@ export type SignedUrl = {
export type UploadWithSignedUrlParams = {
signedUrl: SignedUrl;
srcGeneratorAsync: () => Promise<Readable>;
};
export type UploadWithSignedUrlOptions = {
signal?: AbortSignal;
retries?: number;
retryIntervalMs?: number;
stallTimeoutMs?: number;
retries?: number;
};
export async function uploadWithSignedUrl(
{ signedUrl, srcGeneratorAsync }: UploadWithSignedUrlParams,
{
signal,
retries = 2,
retryIntervalMs = 30_000,
stallTimeoutMs = 90_000,
}: UploadWithSignedUrlOptions = {}
): Promise<void> {
for (let attempt = 0; ; attempt++) {
signal?.throwIfAborted();
const stream = await srcGeneratorAsync();
// Upload time scales with file size, so the deadline bounds stalls, not the whole request.
const timeout = AbortUtils.resettableTimeout(stallTimeoutMs);
try {
await putAsync({
signedUrl,
stream,
signal: signal ? AbortSignal.any([signal, timeout.signal]) : timeout.signal,
onProgress: timeout.resetDeadline,
});
return;
} catch (error) {
signal?.throwIfAborted();
if (attempt >= retries || (!timeout.signal.aborted && !isRetryableError(error))) {
throw error;
}
await delay(retryIntervalMs * (attempt + 1), undefined, { signal });
} finally {
stream.destroy();
await finished(stream).catch(() => {});
}
}
}
export async function uploadContentWithSignedUrl(
{ signedUrl, content }: { signedUrl: SignedUrl; content: string | Buffer },
options?: UploadWithSignedUrlOptions
): Promise<void> {
const headers = { ...signedUrl.headers };
const headerNames = Object.keys(headers).map(name => name.toLowerCase());
if (!headerNames.includes('content-length')) {
headers['content-length'] = String(Buffer.byteLength(content));
}
if (typeof content === 'string' && !headerNames.includes('content-type')) {
headers['content-type'] = 'text/plain;charset=UTF-8';
}
await uploadWithSignedUrl(
{
signedUrl: { url: signedUrl.url, headers },
srcGeneratorAsync: async () => Readable.from([content]),
},
options
);
}
const RETRYABLE_STATUSES = [408, 429, 500, 502, 503, 504];
function isRetryableError(error: unknown): boolean {
if (error instanceof SignedUrlUploadApiError && RETRYABLE_STATUSES.includes(error.status)) {
return true;
}
return isConnectFailure(error) || isConnectionInterruptedError(error);
}
async function putAsync({
export async function uploadWithSignedUrl({
signedUrl,
stream,
signal,
onProgress,
}: {
signedUrl: SignedUrl;
stream: Readable;
signal: AbortSignal;
onProgress: () => void;
}): Promise<void> {
const requestController = new AbortController();
srcGeneratorAsync,
retries = 2,
retryIntervalMs = 30_000,
}: UploadWithSignedUrlParams): Promise<string> {
let resp;
try {
const responsePromise = fetch(signedUrl.url, {
method: 'PUT',
headers: signedUrl.headers,
body: stream,
signal: AbortSignal.any([signal, requestController.signal]),
});
// fetch has piped the body already, so this listener observes flow without starting it.
stream.on('data', onProgress);
const response = await responsePromise;
signal.throwIfAborted();
if (!response.ok) {
throw new SignedUrlUploadApiError(response);
resp = await retryOnUploadFailure(
async () => {
const src = await srcGeneratorAsync();
return await fetch(signedUrl.url, {
method: 'PUT',
headers: signedUrl.headers,
body: src,
});
},
{ retries, retryIntervalMs }
);
} catch (err: unknown) {
if (err instanceof FetchError) {
throw new Error(`Failed to upload the file, reason: ${err.code}`);
}
} catch (error) {
signal.throwIfAborted();
if (error instanceof FetchError) {
throw new SignedUrlUploadNetworkError(error);
}
throw error;
} finally {
// node-fetch destroys the request body on abort, and its response stream is a PassThrough
// whose destruction leaves the socket open, so the request itself is aborted here.
requestController.abort();
throw err;
}
}
export class SignedUrlUploadApiError extends SystemError {
readonly status: number;
readonly reason: string;
constructor(response: { status: number; statusText: string }) {
const reason = `HTTP ${response.status} ${response.statusText}`;
super(`Signed URL upload failed: ${reason}.`);
this.status = response.status;
this.reason = reason;
if (!resp.ok) {
let body: string | undefined;
try {
body = await resp.text();
} catch {}
throw new Error(
`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`
);
}
}
// node-fetch names the request URL in its message, which we want to avoid for signed urls.
export class SignedUrlUploadNetworkError extends SystemError {
readonly code: string | undefined;
readonly reason: string;
constructor(error: FetchError) {
const reason = error.code ?? 'network error';
super(`Signed URL upload failed: ${reason}.`);
this.code = error.code;
this.reason = reason;
}
const url = new URL(signedUrl.url);
return `${url.protocol}//${url.host}${url.pathname}`;
}
@@ -154,9 +154,8 @@ export namespace AndroidEmulatorUtils {
try {
let configIniFileContent = await fs.promises.readFile(configIniFile, 'utf-8');
// Keep the lower RAM default for older images; newer guests need more headroom for ADB.
const apiLevel = systemImagePackage.match(/^system-images;android-(\d+)/)?.[1];
const ramSize = !env.DEVICE_RUN_SESSION_ID && apiLevel && Number(apiLevel) < 35 ? 2048 : 4096;
// Give interactive sessions more headroom for guest VSOCK packet allocations.
const ramSize = env.DEVICE_RUN_SESSION_ID ? 4096 : 2048;
logger.info(`Setting hw.ramSize to ${ramSize}.`);
configIniFileContent = `${configIniFileContent}\nhw.ramSize=${ramSize}\n`;
@@ -121,7 +121,7 @@ describe('AndroidEmulatorUtils', () => {
'hw.lcd.density=420',
'skin.path=pixel_9',
'showDeviceFrame=yes',
'hw.ramSize=4096',
'hw.ramSize=2048',
'',
'hw.lcd.density=220',
'',
@@ -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);
}
});
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
# Direct local development must not change the machine's Java installation.
case "${ENVIRONMENT:-}" in
development|test) exec "$@" ;;
staging|production) ;;
*) echo 'Worker startup requires a known ENVIRONMENT' >&2; exit 1 ;;
esac
here=$(cd "$(dirname "$0")" && pwd)
case "${NODE_OPTIONS:-}" in
*--use-bundled-ca*|*--use-system-ca*) echo 'Conflicting Node CA options' >&2; exit 1 ;;
esac
[ -z "${NODE_EXTRA_CA_CERTS:-}" ] || { echo 'Extra Node CA certificates require review' >&2; exit 1; }
export SSL_CERT_FILE="$here/tls/ca.pem"
export SSL_CERT_DIR="$here/tls/empty"
export CURL_CA_BUNDLE="$SSL_CERT_FILE"
export REQUESTS_CA_BUNDLE="$SSL_CERT_FILE"
export NODE_OPTIONS="${NODE_OPTIONS:+$NODE_OPTIONS }--use-openssl-ca"
[ -s "$SSL_CERT_FILE" ] && [ -d "$SSL_CERT_DIR" ]
case "$(uname -s)" in
Darwin) java_root=/Library/Java/JavaVirtualMachines ;;
Linux) java_root=/usr/lib/jvm ;;
*) echo 'Unsupported worker OS' >&2; exit 1 ;;
esac
# Only the Java blocklist update needs root. The worker keeps its original user.
if [ "$EUID" -eq 0 ]; then
/bin/bash "$here/tls/block-java-certificates.sh" "$java_root"
else
sudo -n /bin/bash "$here/tls/block-java-certificates.sh" "$java_root"
fi
exec "$@"
+57
View File
@@ -0,0 +1,57 @@
# Temporary eMudhra trust policy
The packaged startup shell applies this policy before the normal worker starts.
It uses the included CA bundle for Node, OpenSSL clients, curl, and Requests.
It adds the affected certificate fingerprints to Java blocklists. Only that
Java update needs root; the worker continues as its original user.
No startup download or image rebuild is required. The tarball must include
`bin/tls/empty/.gitkeep`: this directory replaces the system hashed CA directory.
Development and test environments skip the policy. Staging and production stop
startup if setup fails or inherited Node trust options conflict.
## Pinned data
`ca.pem` is the Mozilla CA bundle from https://curl.se/ca/cacert.pem, retrieved
on 2026-10-22. Its header identifies the source snapshot as 2026-10-20.
Source and bundled SHA-256:
`796f1389bfea13c0ed78f663b765baaff54a508e5483786bd6ece73c5e3e3cbe`.
The bundle already excludes all four affected root public keys. No certificates
were removed from the downloaded file. Original source comments are retained.
`blocked-certs.txt` contains SHA-256 fingerprints for the four roots and 16
intermediate certificates published at:
- https://emudhra.we-have-a-problem.com/roots.json
- https://emudhra.we-have-a-problem.com/impacted-intermediates.json
The test fixtures retain those source records and the 12 published TLS chains
from https://emudhra.we-have-a-problem.com/chains.zip. All were retrieved on
2026-10-22. No private keys are included. Changes to these files are security
policy changes and need review.
## Verification and limits
`yarn workspace @expo/worker test:startup` checks all 12 chains, a real local
Node HTTPS request through the packaged entry point, setup failure, and Java
blocklist preservation. Tests do not use real sudo or change the host JDKs.
`test:unit` also checks trust-variable inheritance into the job environment.
Before deployment, run the packaged worker on every supported Linux and macOS
image. Verify passwordless `sudo -n`, process shutdown, the worker connection,
all installed Java versions, Ruby, curl, Python, and valid HTTPS controls.
OpenSSL 3 is required for the offline chain tests, not for the worker startup.
This is a mitigation for the default worker and inherited job trust settings.
It does not cover:
- The earlier worker-tarball download or host-side network requests.
- Native macOS Keychain clients or a curl build that uses another trust backend.
- Jobs that replace trust settings, use their own CA file, or install another JDK.
- Separate container trust stores or JDKs outside the standard scanned directories.
The shared bundle can omit image-specific roots. Removing AAA and emSign roots
can break valid services. Review these compatibility effects before rollout.
Java file changes are tested; Java runtime rejection on each image is still
required. A partial setup failure must discard the VM. Replace existing workers
to apply or roll back the policy; do not remove it from a running worker.
@@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -euo pipefail
here=$(cd "$(dirname "$0")" && pwd)
java_root=$1
[ -s "$here/blocked-certs.txt" ]
for security in "$java_root"/*/{lib/security,jre/lib/security,Contents/Home/lib/security,Contents/Home/jre/lib/security}; do
[ -d "$security" ] || continue
blocklist="$security/blocked.certs"
case "$security" in */jre/lib/security) blocklist="$security/blacklisted.certs" ;; esac
[ ! -f "$security/blacklisted.certs" ] || blocklist="$security/blacklisted.certs"
if [ ! -f "$blocklist" ]; then printf 'Algorithm=SHA-256\n' > "$blocklist"; fi
algorithm=$(sed -n 's/^[[:space:]]*Algorithm[[:space:]]*=[[:space:]]*//p' "$blocklist" | tr -d '\r')
[ "$algorithm" = SHA-256 ] || { echo "Unsupported Java blocklist: $blocklist" >&2; exit 1; }
[ -f "$blocklist.before-eas-tls-policy" ] || cp -p "$blocklist" "$blocklist.before-eas-tls-policy"
[ -z "$(tail -c 1 "$blocklist")" ] || printf '\n' >> "$blocklist"
while IFS= read -r fingerprint; do
grep -Fxiq "$fingerprint" "$blocklist" || printf '%s\n' "$fingerprint" >> "$blocklist"
done < "$here/blocked-certs.txt"
echo "Updated Java blocklist: $blocklist"
done
+20
View File
@@ -0,0 +1,20 @@
0164D7CEAE5BB909C272AF151FF44B1177AE9B47D9424B94F1D9558C30934D19
42C4ACCD12C4F59597925E1A08A7A90102608543809308D50EC427E118BA1493
432EC25F0D28DE9006D97BF752F17BBB2CD41F669933FCB072D9A1148D0261F3
45A883F349AEDFD5F3A649B5498FFB121712BA8B448F3F2A91DBC5A766FCDDBE
4B2460A466F95FD40113BCDCCA2CEB29B3B093921DE45474DA7DF48874C630B1
579480AEA166290BD3C643589E795FC0D0E54E86505D180F1DFF99EFD3199512
5F1DED4DD590AF74631290CF073B122A2AAC6A1FA479778A0BD43579BD832699
62B7F9868263F72F6911FC78CEAF72C1979158B7368BF648683BB2AA39B02E72
7485B51C0ED192878FA1D2F948FB529AE1842CC5086C4B67F7BE5B2039DE9489
7DD78D5F4F13459A83DFF9ABBBA62EDBAF6F2D102BF257FD712F4D9F2746ED8D
84112972B8E59D14560AE7E7119654474AAD119990CF3722BFC1E3B91615E1FD
86A1ECBA089C4A8D3BBE2734C612BA341D813E043CF9E8A862CD5C57A36BBE6B
98B896625C0C230ADEEC3EB2CF6AAD5001ECF9156A68644E82374CE5833BC25B
9E39DE2881273ACC225A149C72BA3E0F51424963669AB633EE1DC7DF5DB067B4
A496E23AC0CAB8155FC9B117B97EAE368EFCBB9041AEC8804D5A308D2D01C2D8
CB6E15165AC802E9B3F993F16C25D41F4E81C21EBDE71AE1B012AC00BF9C7C3F
D7A7A0FB5D7E2731D771E9484EBCDEF71D5F0C3E0A2948782BC83EE0EA699EF4
E7568FE6E12331F4FD7DE61DA4C7559D0F8EB192A218D4741A3BA75184333838
F2EDAAFF3D9B701FB4D14C9F285F8C0BAD90FC3BCC1FA07D47F4718EDABA34A5
FD9D5C2DBBD4995AB69B8D50C439D9527B00529199C2FD01B2174EB58F07021F
File diff suppressed because it is too large. Load diff
+1
View File
@@ -0,0 +1 @@
No hashed CA files belong in this directory. It replaces the system CA lookup directory.
+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({});
});
});
+19 -3
View File
@@ -21,6 +21,10 @@ describe(getBuildEnv.name, () => {
EAS_BUILD_COCOAPODS_CACHE_URL: process.env.EAS_BUILD_COCOAPODS_CACHE_URL,
NPM_CONFIG_REGISTRY: process.env.NPM_CONFIG_REGISTRY,
NODE_OPTIONS: process.env.NODE_OPTIONS,
SSL_CERT_FILE: process.env.SSL_CERT_FILE,
SSL_CERT_DIR: process.env.SSL_CERT_DIR,
CURL_CA_BUNDLE: process.env.CURL_CA_BUNDLE,
REQUESTS_CA_BUNDLE: process.env.REQUESTS_CA_BUNDLE,
};
beforeEach(() => {
@@ -51,6 +55,10 @@ describe(getBuildEnv.name, () => {
restoreEnv('EAS_BUILD_COCOAPODS_CACHE_URL', originalCacheUrls.EAS_BUILD_COCOAPODS_CACHE_URL);
restoreEnv('NPM_CONFIG_REGISTRY', originalCacheUrls.NPM_CONFIG_REGISTRY);
restoreEnv('NODE_OPTIONS', originalCacheUrls.NODE_OPTIONS);
restoreEnv('SSL_CERT_FILE', originalCacheUrls.SSL_CERT_FILE);
restoreEnv('SSL_CERT_DIR', originalCacheUrls.SSL_CERT_DIR);
restoreEnv('CURL_CA_BUNDLE', originalCacheUrls.CURL_CA_BUNDLE);
restoreEnv('REQUESTS_CA_BUNDLE', originalCacheUrls.REQUESTS_CA_BUNDLE);
mockProcessPlatform(originalPlatform);
jest.restoreAllMocks();
});
@@ -331,8 +339,15 @@ describe(getBuildEnv.name, () => {
expect(getNodeMemoryOptions()).toBeUndefined();
});
it('keeps Node.js options inherited from the worker environment in override position', () => {
process.env.NODE_OPTIONS = '--max-old-space-size=2048 --enable-source-maps';
it('keeps worker trust settings and inherited Node.js options', () => {
process.env.NODE_OPTIONS = '--max-old-space-size=2048 --enable-source-maps --use-openssl-ca';
const trust = {
SSL_CERT_FILE: '/worker/bin/tls/ca.pem',
SSL_CERT_DIR: '/worker/bin/tls/empty',
CURL_CA_BUNDLE: '/worker/bin/tls/ca.pem',
REQUESTS_CA_BUNDLE: '/worker/bin/tls/ca.pem',
};
Object.assign(process.env, trust);
jest.spyOn(process, 'constrainedMemory').mockReturnValue(6 * 1024 ** 3);
jest.spyOn(v8, 'getHeapStatistics').mockReturnValue({
heap_size_limit: 2 * 1024 ** 3,
@@ -357,8 +372,9 @@ describe(getBuildEnv.name, () => {
});
expect(env.NODE_OPTIONS).toBe(
'--max-old-space-size=3072 --max-old-space-size=2048 --enable-source-maps'
'--max-old-space-size=3072 --max-old-space-size=2048 --enable-source-maps --use-openssl-ca'
);
expect(env).toEqual(expect.objectContaining(trust));
});
});
+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,
@@ -0,0 +1,38 @@
-----BEGIN CERTIFICATE-----
MIICQDCCAcagAwIBAgIUU3mgjls/8ZJ8upmukdlSQR1IF/UwCgYIKoZIzj0EAwIw
PzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN
IERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa
MDMxMTAvBgNVBAMMKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl
bS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATK3sfcOxxtM2Vdq9Qbw/GR
NsHlJcnqINEInXO3fkWhw/FYT80hkqGk0jSN/EKib3YZJsr5GyfkYcpEiA2rwMgm
o4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG
CCsGAQUFBwMBMDMGA1UdEQQsMCqCKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl
LWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFDggfJFz1eeToKmzFBSYkMCvpAu0MB8G
A1UdIwQYMBaAFOIeL+ccGTkOVUtiwSAkVzUkiiUUMAoGCCqGSM49BAMCA2gAMGUC
MDqDDOt7K313QIH0rhn/ZKnzFu8+/9W8OlTuFtDfVCTRDfRmqdyfsCfLsEk7xqHq
uQIxAJqyLERABmU98U46OCuT5bp5h0ytuSEIGM7orTpKci5S1u1B/AOmwv162U7R
TcVPHA==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw
ezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G
A1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV
BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y
ODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW
MRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA
IgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO
L4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2
PM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd
BgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG
A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC
MDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h
AQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB
QUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF
BQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB
AQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE
wqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM
QkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ
7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl
bN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V
GUyRCEIDhF5TBUuKF4E3kf1h
-----END CERTIFICATE-----
@@ -0,0 +1,56 @@
-----BEGIN CERTIFICATE-----
MIICOTCCAb6gAwIBAgIUY1R/PHrtXShV0uhLJ5EyjsHvMi0wCgYIKoZIzj0EAwIw
PzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN
IERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa
MC8xLTArBgNVBAMMJGR2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv
bTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFpE24ACBFHQLzpg+7D767thJ3+Q
NIoJJugUMT/OftEz6G6uDdxlS+9VfXYBDglHjJirjEPFNglOGJoQNQuiz7Gjgacw
gaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB
BQUHAwEwLwYDVR0RBCgwJoIkZHYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js
ZW0uY29tMB0GA1UdDgQWBBT4r5v4etYNuG21BqBqs273OkcHiDAfBgNVHSMEGDAW
gBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjOPQQDAgNpADBmAjEAlxNF+/8l
APpgibawACy15yn5bcvTksz2Zw0R9z9nUK9e3MSSk+hXagg7LVb7V4NtAjEA4qyR
laFPkaokrnTjqQieq8CgDQ6tIz3FjBvj3D7vkhKXHaml6zD2zT5ih2Y+lD2h
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDaDCCAu6gAwIBAgIQAKSu5njyM2K0wORgt3LyFDAKBggqhkjOPQQDAzBWMQsw
CQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl
ZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTU1
NDA4WhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11
ZGhyYSBCVjEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C
AQYFK4EEACIDYgAE557ZwEqXc8RzXlERHahawr1eZKs3S4DhLLwgOOweTw47gS/J
hZ4eZ6+wTi+LyX90hbplqpEe6KTkW2LVzAPVa+Z4s8OMtNyTfKJGDiOEU8iSk10p
1E+R4VYGtjzO2rRoo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L
WUIgkFQwHQYDVR0OBBYEFOIeL+ccGTkOVUtiwSAkVzUkiiUUMB0GA1UdJQQWMBQG
CCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow
KAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB
BAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY
aHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw
b3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQi5wN2MwNwYDVR0f
BDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j
cmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E
AwMDaAAwZQIwLmsGXi6oFMJ2klkcEMXMug6gvxQWZWkewiDWVzQyl2L6z6DuQfPO
Ibh1grvdULDCAjEAxZvLlxUavG69J6wnSjC8XBbqNIQsJ6CE2QdNOe84Ptxb6VG8
gt2TlfJbHii+KNvK
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDdDCCAvmgAwIBAgIQAJYaPvsTWVdu0EAFtGhKWzAKBggqhkjOPQQDAzBrMQsw
CQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhy
YSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290
IENBIC0gRzMwHhcNMjQwNzExMTU0MzM2WhcNMzkwNzA5MTgzMDAwWjBWMQswCQYD
VQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg
MB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUr
gQQAIgNiAARfD9w/q1cWxL6D9bMGH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8m
WO/RwXQy10DCSxT8vSIJClHKp+Ao4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFw
LKYf8EdDXAyjggF1MIIBcTAfBgNVHSMEGDAWgBR8XQKEE9TMipuBzhccLikenEhj
QjAdBgNVHQ4EFgQU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0lBBYwFAYIKwYB
BQUHAwEGCCsGAQUFBwMCMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB
FhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMHgGCCsGAQUFBwEBBGwwajAi
BggrBgEFBQcwAYYWaHR0cDovL29jc3AuZW1zaWduLmNvbTBEBggrBgEFBQcwAoY4
aHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9lbVNpZ25FQ0NSb290
Q0FHMy5jcnQwMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5lbXNpZ24uY29t
P1Jvb3RDQUczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIB
ATAKBggqhkjOPQQDAwNpADBmAjEAt9CIpRGp46QHQYDUBnLKw+vvK0/h9vmilEAg
TroUl/anH4x2rXHqiCtBTBfgKDegAjEA8WpG1LTZdAIL0FSYqxXeREaeTSxEofM0
1jJH3jGvCXC79LU9UPJ2o3TU+UIj2gMV
-----END CERTIFICATE-----
@@ -0,0 +1,38 @@
-----BEGIN CERTIFICATE-----
MIICQjCCAcegAwIBAgIUMH3z62Z6agEULBZqrfhpw9/ZpCAwCgYIKoZIzj0EAwIw
QDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF
TSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQxWhcNMjcwNTAzMDIyNzQx
WjAzMTEwLwYDVQQDDChkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js
ZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAMPpHsQqEqIerrj/durO
EN7gkUVJSVgIPN6+VJ2sEGXDyqXAN1fnNzGuASP/4WEr0RIhht3cYvuUYp2SYofr
w6OBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK
BggrBgEFBQcDATAzBgNVHREELDAqgihkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2
ZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBT8CNQz4OLVnIwrQYZBF+++9jTCvzAf
BgNVHSMEGDAWgBSufhBcPCmWwahHJwVZ8eA91OUK8zAKBggqhkjOPQQDAgNpADBm
AjEAp3ZLdfBvKcZxgR2p8LZaxWzP6uB7zU/EZ9BA49Ds2P5ByYkRodwK5qcwt4J/
VL/vAjEAnnxrcrDX7SzoJxuho604GBh6eyBLJLCdmcy+qb+LA3u5oxP/OjAzCA4x
v1kw6Tm4
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDzzCCAregAwIBAgIRAJHzR/Glt9OZYrleDuTimdEwDQYJKoZIhvcNAQELBQAw
ezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G
A1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV
BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y
ODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu
YzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE
ACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFypDPjYw8C
vRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpVbMyLpQVh
3xsqRB0Bo4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw
HQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMA4GA1UdDwEB/wQEAwIBBjAS
BgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD
AjAxBgNVHSAEKjAoMAgGBmeBDAECATANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO
IQECZDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B
QUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB
BQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC
AQEAr7b6Mx11TIJgVzHt6EH5qGNWeiiqoMrp8E5/k8s+jvupx1n083pZ5XOzUzND
wEUe0W4C4FIGUHNQ4XDwqOw0yK42xqm2dvCKgsko2UvDlT0haZVtTCeWMQOe5J/J
ziaMfZwsZNfZ3aaI6XPtWDZtwY8AaNfyaeCpbtm5GcGnyNWk4jSNpbtpo6NjfXuP
CFOm3rTjJ4zytP7Tso6iMm0L01cYFOlTmoPv4515R2r3SPnKDtijoB/D/vgxzojv
DafgyG9afX8+laO9NbMMz4sqC1vZx3984i/sBNFiugm3Vvdq5OdJRjuGC1QILIs6
dg/K3i8VQqRpbfaM9kIlMzZ3dQ==
-----END CERTIFICATE-----
@@ -0,0 +1,56 @@
-----BEGIN CERTIFICATE-----
MIICODCCAb+gAwIBAgIUbWvtwSNK/Qro223vAqyz323+VBAwCgYIKoZIzj0EAwIw
QDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF
TSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM2WhcNMjcwNTAzMDEwNDM2
WjAvMS0wKwYDVQQDDCRkdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j
b20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAScdBQpNUOOCcag6YEVFccNRSFe
UIl02iH53vDfoWPJFS005WzqF3CPDclB/kygP2gWF4pg/r5gPYdnI3/6Vdzro4Gn
MIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG
AQUFBwMBMC8GA1UdEQQoMCaCJGR2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i
bGVtLmNvbTAdBgNVHQ4EFgQUBj2yGO/TzDOE/u/6QeUvcW8y1iMwHwYDVR0jBBgw
FoAUrn4QXDwplsGoRycFWfHgPdTlCvMwCgYIKoZIzj0EAwIDZwAwZAIwBQOOQqG9
ccdzM/7XIlXLtota1esALaLmgEN/ZweosBIvk4xabHcERZInWVIwO45NAjBos8Ue
NwDe1X3ahdK2lkxqBEoBMtUHJN18TnJKs2nK1gmXpMbmY5oKnAQQgCEZPA4=
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDaDCCAu6gAwIBAgIPWIsuPCu4zaE+GhIJJUMhMAoGCCqGSM49BAMDMFYxCzAJ
BgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk
MSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM1
MDlaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk
aHJhIEluYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C
AQYFK4EEACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFy
pDPjYw8CvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpV
bMyLpQVh3xsqRB0Bo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L
WUIgkFQwHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMB0GA1UdJQQWMBQG
CCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow
KAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB
BAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY
aHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw
b3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQy5wN2MwNwYDVR0f
BDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j
cmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E
AwMDaAAwZQIxAMEN2HWMocbuWdy1d33pH6JpMvP/V/DIS5PtZbv1Q7TXEbxrcyxg
yuddOJscYDJJlAIwB4sH1BpbScDzkdD4tJBSwrG/1Mx82vb8YaySehNuu1+1oXNd
AEKwaJ0mVzScy2+6
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDdDCCAvmgAwIBAgIQAJYaPvsTWVdu0EAFtGhKWzAKBggqhkjOPQQDAzBrMQsw
CQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhy
YSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290
IENBIC0gRzMwHhcNMjQwNzExMTU0MzM2WhcNMzkwNzA5MTgzMDAwWjBWMQswCQYD
VQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg
MB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUr
gQQAIgNiAARfD9w/q1cWxL6D9bMGH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8m
WO/RwXQy10DCSxT8vSIJClHKp+Ao4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFw
LKYf8EdDXAyjggF1MIIBcTAfBgNVHSMEGDAWgBR8XQKEE9TMipuBzhccLikenEhj
QjAdBgNVHQ4EFgQU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0lBBYwFAYIKwYB
BQUHAwEGCCsGAQUFBwMCMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB
FhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMHgGCCsGAQUFBwEBBGwwajAi
BggrBgEFBQcwAYYWaHR0cDovL29jc3AuZW1zaWduLmNvbTBEBggrBgEFBQcwAoY4
aHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9lbVNpZ25FQ0NSb290
Q0FHMy5jcnQwMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5lbXNpZ24uY29t
P1Jvb3RDQUczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIB
ATAKBggqhkjOPQQDAwNpADBmAjEAt9CIpRGp46QHQYDUBnLKw+vvK0/h9vmilEAg
TroUl/anH4x2rXHqiCtBTBfgKDegAjEA8WpG1LTZdAIL0FSYqxXeREaeTSxEofM0
1jJH3jGvCXC79LU9UPJ2o3TU+UIj2gMV
-----END CERTIFICATE-----
@@ -0,0 +1,38 @@
-----BEGIN CERTIFICATE-----
MIICQDCCAcagAwIBAgIUUYu3O8bpN1GPvCIHqWpT/FYUNDowCgYIKoZIzj0EAwIw
PzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN
IEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa
MDMxMTAvBgNVBAMMKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl
bS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASjDLpnVdmvXM6R3UB2o42a
1GnrYfxRXo7UII36mBl9mIS7p9MkAryhPdxitFHkgTrrbypVtzpydNtJIGGGXUwE
o4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG
CCsGAQUFBwMBMDMGA1UdEQQsMCqCKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl
LWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFEVBY1ecTN9bLMDl4ifdnN2WZL+WMB8G
A1UdIwQYMBaAFD7cINmJRpiHe4c/2IFHslwV2EcGMAoGCCqGSM49BAMCA2gAMGUC
MQCyvr9gktgmtsuTuFleFV+YExNNKLf3GZieuUprQcERrzh798oYKavTqZeYf9Kz
RhECMCVs1B7KPh7/lgHRly14Z8RJA8ApR7e2cRREW5uPKFOSDlZ/RIezxsfEzsQ4
lAODeA==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDzTCCArWgAwIBAgIRANj4aF5PK9nYJAKoWhwetzEwDQYJKoZIhvcNAQELBQAw
ezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G
A1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV
BAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y
ODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW
MRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA
IgNiAAQl26haZNOX2gsQUunqVG5Ie+/qnJ2daKDF6tz+73Wmm7XW/AH0K/vKbrQL
zWTAdn42/9Bdl6gTxaoMbJUw55z18IZkaPE4BkIKpchRhAseJSVA55DktlTDhS7W
SvHoZX2jggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd
BgNVHQ4EFgQUPtwg2YlGmId7hz/YgUeyXBXYRwYwDgYDVR0PAQH/BAQDAgEGMBIG
A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC
MDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB
AngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB
Q2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF
BzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB
AJNFuAFW9Gnw/8+kCO4KIlZdnM1Sh2u+8cK0EYAHhXpb5FlGvYkcARigo5oS7VoM
7FOYlTueV6yICZKNAYawZPJPhNCGK+rp3cQBEz6d0n8OZEYZov9lb3ll8ORgSDuf
FVIW6Gagg/UnOamLBTOR8eD5V73SCme/B2cHUDw+m5E8cc+0Ujzz3Cn3ex5JrhXk
2T3s8PoVzEmhTgGboFfcWO1EYhPPrYwdxPYRQIXAXUXhLEOVNL9ASbZJLFI8E1IM
m7NwxVN2s38jWpaCUaaF0tg7hfiy7oe13rzJfHsdqldJVTTUTPWVx8TC4ju0VLet
bAlwhwFT6AP8uIVMGUHNw24=
-----END CERTIFICATE-----
@@ -0,0 +1,56 @@
-----BEGIN CERTIFICATE-----
MIICNzCCAb6gAwIBAgIUPfthO0PYGAYKs9lQ4gp1IOkOr2MwCgYIKoZIzj0EAwIw
PzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN
IEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa
MC8xLTArBgNVBAMMJGV2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv
bTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPZPwlA3io/AX/pMwu0dNl9WMxrr
C9j/xMLHgG72cWVzoX62REmvp9zyXQ04kHP9/sW6KnCcm5ZZ5i4YoJbDPASjgacw
gaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB
BQUHAwEwLwYDVR0RBCgwJoIkZXYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js
ZW0uY29tMB0GA1UdDgQWBBSvX4QR4ERW5uNUaMq0yYzXCZf0ZjAfBgNVHSMEGDAW
gBQ+3CDZiUaYh3uHP9iBR7JcFdhHBjAKBggqhkjOPQQDAgNnADBkAjACy/uJR01M
/O9IEZyRHX1LSU28bmg542K0IX9jDRTrZXUqkDWfiZIL7xfnrRrBpHoCMFbBIaOa
WwWtFtDg9ZqQvktsWS950u5qfW1sChgWNya/E3IMKFgtoHm2xrZsmfwLSQ==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDaDCCAu2gAwIBAgIQAMfEHv6S2CXwXq6EGkqCwTAKBggqhkjOPQQDAzBWMQsw
CQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl
ZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTYw
MzMxWhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11
ZGhyYSBCVjEbMBkGA1UEAwwSRU0gRVYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C
AQYFK4EEACIDYgAEJduoWmTTl9oLEFLp6lRuSHvv6pydnWigxerc/u91ppu11vwB
9Cv7ym60C81kwHZ+Nv/QXZeoE8WqDGyVMOec9fCGZGjxOAZCCqXIUYQLHiUlQOeQ
5LZUw4Uu1krx6GV9o4IBlTCCAZEwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L
WUIgkFQwHQYDVR0OBBYEFD7cINmJRpiHe4c/2IFHslwV2EcGMB0GA1UdJQQWMBQG
CCsGAQUFBwMBBggrBgEFBQcDAjBcBgNVHSAEVTBTMDkGCysGAQQBg44hAQABMCow
KAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB
BAGDjiEBAngwBwYFZ4EMAQEwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho
dHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv
c2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1FVlRMU0NBRzNCLnA3YzA3BgNVHR8E
MDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy
bDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD
AwNpADBmAjEA9Jq999oaOUKhFwopw33iCmt0OHIn3gjT43GfuOHR/YckXdwoNdDA
lIEhz0NgRO3/AjEAvY/a3OMvktIZLYjn+WLKmp0QhrIMV+e6EpS7j4GVeOKouNp6
MN/P+ByGeiRMghzB
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDdDCCAvmgAwIBAgIQAJYaPvsTWVdu0EAFtGhKWzAKBggqhkjOPQQDAzBrMQsw
CQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhy
YSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290
IENBIC0gRzMwHhcNMjQwNzExMTU0MzM2WhcNMzkwNzA5MTgzMDAwWjBWMQswCQYD
VQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg
MB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUr
gQQAIgNiAARfD9w/q1cWxL6D9bMGH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8m
WO/RwXQy10DCSxT8vSIJClHKp+Ao4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFw
LKYf8EdDXAyjggF1MIIBcTAfBgNVHSMEGDAWgBR8XQKEE9TMipuBzhccLikenEhj
QjAdBgNVHQ4EFgQU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0lBBYwFAYIKwYB
BQUHAwEGCCsGAQUFBwMCMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB
FhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMHgGCCsGAQUFBwEBBGwwajAi
BggrBgEFBQcwAYYWaHR0cDovL29jc3AuZW1zaWduLmNvbTBEBggrBgEFBQcwAoY4
aHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9lbVNpZ25FQ0NSb290
Q0FHMy5jcnQwMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5lbXNpZ24uY29t
P1Jvb3RDQUczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIB
ATAKBggqhkjOPQQDAwNpADBmAjEAt9CIpRGp46QHQYDUBnLKw+vvK0/h9vmilEAg
TroUl/anH4x2rXHqiCtBTBfgKDegAjEA8WpG1LTZdAIL0FSYqxXeREaeTSxEofM0
1jJH3jGvCXC79LU9UPJ2o3TU+UIj2gMV
-----END CERTIFICATE-----
@@ -0,0 +1,38 @@
-----BEGIN CERTIFICATE-----
MIICQDCCAcegAwIBAgIUTMUrGzSU+0BySFdrN3ALZq9Spv8wCgYIKoZIzj0EAwIw
QDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF
TSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy
WjAzMTEwLwYDVQQDDChldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js
ZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEglClDbotLFFeY9SpryT4
k958tvriY2DDnaPsmjQaecRhB6+L/8hLw6FMt+HT8SLvjIZnFY+AH92cBjedcNST
AKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK
BggrBgEFBQcDATAzBgNVHREELDAqgihldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2
ZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBSVtGbn6xUT1iosUeaGA15Nq640aTAf
BgNVHSMEGDAWgBSmJV6AqU2385Z8G8eR35K87kvrXzAKBggqhkjOPQQDAgNnADBk
AjAg7b5bIK9X50QEX0U6G8fQh12BnFQ079Iluzd0Y2TJWCjtVXZi3nOciM8RzNKM
tZECMCBf0JOyMt8RNS93Wj8kYbWkCtoKoUrqx7G3JTfGReKci+DdTvkF8/3F6doi
9KB22Q==
-----END CERTIFICATE-----
-----BEGIN CERTIFICATE-----
MIIDzTCCArWgAwIBAgIQBfB4dS3s56G7hZXTAxZxcTANBgkqhkiG9w0BAQsFADB7
MQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD
VQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE
AwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4
MTIzMTIzNTk1OVowQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5j
MRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA
IgNiAAQtVugppvuCxkpGl9lgr5UekGeukfkm00MG2kioqut4abamfm0nQm4EQqoD
s24ZiN/2NlFO4c8H9bpLP/KgZkWufTHVhmrTGqPSRusLMXfumct1ZsUWznSAimaP
kfDEf8qjggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd
BgNVHQ4EFgQUpiVegKlNt/OWfBvHkd+SvO5L618wDgYDVR0PAQH/BAQDAgEGMBIG
A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC
MDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB
AngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB
Q2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF
BzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB
AIFfHiaMSjnrdZygRYV4ekRBmG7ybcFk4hGQWMMyMFi0cAEk2+dGfz5bl8IaIbLq
z+uVJzhOgVEX7WqIfgbWfFsfPYHYjPHBmy7SlZ3BwA86IQ6qF/etpX2/qL9A5yRN
N3nVowUpaqV6H3aCpkAh3wR2ZNkvAbLDvPz3J0l+sEWjWv+ZISOV4R+hXDTdPXV0
1lKxnbwqEsdo/sE2QkCDKwFrZh/0uKjWspydJj+jSaDLTrGNx50Dgmn7VEcD1ZI5
FXjknVUiO25YlmS1dtVZSBbyUKSjlrP/yDwPcZunoAsmCAAtCKsFLviLaymzvsKI
eSSDGxpSySp2VCxompGDk8g=
-----END CERTIFICATE-----
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff. Show more