mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1572a6420a | ||
|
|
524d0222ae | ||
|
|
c0151e4ce0 | ||
|
|
44f5ec55c9 | ||
|
|
bae232ac30 | ||
|
|
b5442a5bb6 | ||
|
|
547496c588 | ||
|
|
9f3ecf60c3 | ||
|
|
6275c1e54c | ||
|
|
d02de4da64 | ||
|
|
04c214be93 | ||
|
|
1d9607ca00 | ||
|
|
78ba76001b | ||
|
|
3c8b62fb39 | ||
|
|
c3a26fef34 |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'eas-cli': patch
|
||||
---
|
||||
|
||||
Regenerate graphql code
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@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
|
||||
---
|
||||
|
||||
Explain Google Play failures caused by missing app declarations, a disabled API, or an upload certificate that is not yet active.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': patch
|
||||
---
|
||||
|
||||
Retry transient failures when reporting that a device run session has started.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/worker': patch
|
||||
---
|
||||
|
||||
Retry Node.js installation without the internal Node.js cache mirror when the first attempt fails, while preserving other configured mirrors.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@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.
|
||||
@@ -0,0 +1,5 @@
|
||||
---
|
||||
'@expo/build-tools': minor
|
||||
---
|
||||
|
||||
Unify signed URL uploading to a single implementation and use it everywhere
|
||||
@@ -12,6 +12,7 @@ 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';
|
||||
@@ -390,7 +391,13 @@ describe('projectSources', () => {
|
||||
ctx.graphqlClient.mutation = mockGraphqlMutation as any;
|
||||
|
||||
const fetchMock = jest.mocked(fetch);
|
||||
fetchMock.mockImplementation(async () => ({ ok: true }) as Response);
|
||||
let uploadedBody = '';
|
||||
fetchMock.mockImplementation(async (_url, request) => {
|
||||
for await (const chunk of request!.body as Readable) {
|
||||
uploadedBody += chunk;
|
||||
}
|
||||
return { ok: true } as Response;
|
||||
});
|
||||
fetchMock.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
json: async () => ({ data: ctx.job.projectArchive }),
|
||||
@@ -414,10 +421,14 @@ describe('projectSources', () => {
|
||||
'https://storage.example.com/upload',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
headers: { 'x-custom-header': 'value' },
|
||||
body: '{"archiveContent":["project/README.md","project/package.json"]}',
|
||||
headers: {
|
||||
'x-custom-header': 'value',
|
||||
'content-length': '63',
|
||||
'content-type': 'text/plain;charset=UTF-8',
|
||||
},
|
||||
})
|
||||
);
|
||||
expect(uploadedBody).toBe('{"archiveContent":["project/README.md","project/package.json"]}');
|
||||
|
||||
// Verify build metadata was updated
|
||||
expect(mockGraphqlMutation).toHaveBeenNthCalledWith(
|
||||
|
||||
@@ -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,10 +208,9 @@ async function uploadProjectMetadataAsync(
|
||||
headers: Record<string, string>;
|
||||
};
|
||||
|
||||
await fetch(uploadSession.url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ archiveContent: sortedFiles }),
|
||||
headers: uploadSession.headers,
|
||||
await uploadContentWithSignedUrl({
|
||||
signedUrl: uploadSession,
|
||||
content: JSON.stringify({ archiveContent: sortedFiles }),
|
||||
});
|
||||
|
||||
const updateMetadataResult = await ctx.graphqlClient
|
||||
|
||||
@@ -3,8 +3,16 @@ import HttpLogStream from './logging/HttpLogStream';
|
||||
import RemoteLoggerStream from './logging/RemoteLoggerStream';
|
||||
|
||||
export { Builders, HttpLogStream, RemoteLoggerStream };
|
||||
export { uploadWithSignedUrl } from './storage/uploadWithSignedUrl';
|
||||
export type { SignedUrl, UploadWithSignedUrlParams } from './storage/uploadWithSignedUrl';
|
||||
export {
|
||||
SignedUrlUploadApiError,
|
||||
SignedUrlUploadNetworkError,
|
||||
uploadWithSignedUrl,
|
||||
} from './storage/uploadWithSignedUrl';
|
||||
export type {
|
||||
SignedUrl,
|
||||
UploadWithSignedUrlOptions,
|
||||
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<string> {
|
||||
public async init(): Promise<void> {
|
||||
this.fileHandle = await fs.open(this.temporaryLogsPath, 'w+', 0o660);
|
||||
this.flushInterval = setInterval(() => this.flush(), this.options.uploadIntervalMs);
|
||||
return (await this.flush(true)) as string;
|
||||
await this.flush(true);
|
||||
}
|
||||
|
||||
public async cleanUp(): Promise<void> {
|
||||
@@ -121,17 +121,18 @@ class RemoteLoggerStream extends Writable {
|
||||
}
|
||||
}
|
||||
|
||||
private async flush(force = false): Promise<string | void> {
|
||||
private async flush(force = false): Promise<void> {
|
||||
if (force || this.hasChangesToUpload) {
|
||||
if (this.uploadingPromise) {
|
||||
await this.uploadingPromise;
|
||||
return await this.flush(force);
|
||||
await this.flush(force);
|
||||
return;
|
||||
}
|
||||
return await this.flushInternal();
|
||||
await this.flushInternal();
|
||||
}
|
||||
}
|
||||
|
||||
private async flushInternal(): Promise<string | void> {
|
||||
private async flushInternal(): Promise<void> {
|
||||
await this.safeWriteToFile();
|
||||
|
||||
let resolveFn: PromiseResolveFn;
|
||||
@@ -139,21 +140,17 @@ class RemoteLoggerStream extends Writable {
|
||||
this.uploadingPromise = new Promise(res => {
|
||||
resolveFn = res;
|
||||
});
|
||||
return await this.upload()
|
||||
.then(result => {
|
||||
return result;
|
||||
})
|
||||
await this.upload()
|
||||
.catch(err => {
|
||||
this.logger.error({ err }, 'Failed to upload logs file');
|
||||
})
|
||||
.then(result => {
|
||||
.then(() => {
|
||||
this.uploadingPromise = undefined;
|
||||
resolveFn();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
private async upload(): Promise<string | void> {
|
||||
private async upload(): Promise<void> {
|
||||
if (!this.uploadMethod) {
|
||||
return;
|
||||
}
|
||||
@@ -165,7 +162,7 @@ class RemoteLoggerStream extends Writable {
|
||||
);
|
||||
};
|
||||
|
||||
return await uploadWithSignedUrl({
|
||||
await uploadWithSignedUrl({
|
||||
signedUrl: this.uploadMethod.signedUrl,
|
||||
srcGeneratorAsync,
|
||||
});
|
||||
|
||||
@@ -67,6 +67,7 @@ import { createUploadArtifactBuildFunction } from './functions/uploadArtifact';
|
||||
import { createUploadDeviceRunSessionScreenRecordingsBuildFunction } from './functions/uploadDeviceRunSessionScreenRecordings';
|
||||
import { createUploadEmbeddedBundleBuildFunction } from './functions/uploadEmbeddedBundle';
|
||||
import { createUploadToAscBuildFunction } from './functions/uploadToAsc';
|
||||
import { createWaitForAscBuildProcessingBuildFunction } from './functions/waitForAscBuildProcessing';
|
||||
import { createUpdateTestFlightMetadataBuildFunction } from './functions/updateTestFlightMetadata';
|
||||
import { createSetUpNpmrcBuildFunction } from './functions/useNpmToken';
|
||||
import { createWaitForPosthogMetricFunction } from './functions/waitForPosthogMetric';
|
||||
@@ -144,6 +145,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
|
||||
createSubmissionEntityFunction(),
|
||||
createSubmitToGooglePlayBuildFunction(),
|
||||
createUploadToAscBuildFunction(),
|
||||
createWaitForAscBuildProcessingBuildFunction(),
|
||||
createUpdateTestFlightMetadataBuildFunction(),
|
||||
|
||||
createReportMaestroTestResultsFunction(ctx),
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,239 @@
|
||||
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();
|
||||
}
|
||||
);
|
||||
@@ -11,6 +11,7 @@ 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';
|
||||
@@ -161,16 +162,13 @@ export async function uploadCacheAsync({
|
||||
|
||||
logger.info(`Uploading cache...`);
|
||||
|
||||
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}`
|
||||
);
|
||||
}
|
||||
await uploadWithSignedUrl(
|
||||
{
|
||||
signedUrl: { url, headers },
|
||||
srcGeneratorAsync: async () => fs.createReadStream(archivePath),
|
||||
},
|
||||
{ retries: 3, retryIntervalMs: 100 }
|
||||
);
|
||||
logger.info(`Uploaded cache archive to ${archivePath} (${formatBytes(size)}).`);
|
||||
}
|
||||
|
||||
@@ -226,16 +224,13 @@ export async function uploadPublicCacheAsync({
|
||||
|
||||
logger.info(`Uploading public cache...`);
|
||||
|
||||
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}`
|
||||
);
|
||||
}
|
||||
await uploadWithSignedUrl(
|
||||
{
|
||||
signedUrl: { url, headers },
|
||||
srcGeneratorAsync: async () => fs.createReadStream(archivePath),
|
||||
},
|
||||
{ retries: 3, retryIntervalMs: 100 }
|
||||
);
|
||||
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/upload_to_asc with wait_for_processing: true first.'
|
||||
'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.'
|
||||
);
|
||||
}
|
||||
logger.info(`Updating TestFlight metadata for Apple build ${buildId}...`);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { UserError } from '@expo/eas-build-job';
|
||||
import { asyncResult } from '@expo/results';
|
||||
import {
|
||||
BuildFunction,
|
||||
@@ -15,6 +14,7 @@ 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${itemizeMessages(warnings)}\n`);
|
||||
stepsCtx.logger.warn(`Warnings:\n${AscApiUtils.itemizeMessages(warnings)}\n`);
|
||||
}
|
||||
if (errors.length > 0) {
|
||||
stepsCtx.logger.error(`Errors:\n${itemizeMessages(errors)}\n`);
|
||||
stepsCtx.logger.error(`Errors:\n${AscApiUtils.itemizeMessages(errors)}\n`);
|
||||
}
|
||||
|
||||
if (assetDeliveryState.state === 'FAILED') {
|
||||
@@ -208,165 +208,19 @@ export function createUploadToAscBuildFunction(): BuildFunction {
|
||||
return;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
await waitForAscBuildProcessingAsync({
|
||||
client,
|
||||
buildUploadId,
|
||||
logger: stepsCtx.logger,
|
||||
ipaBundleIdentifier: ipaInfoResult.ok ? ipaInfoResult.value.bundleIdentifier : undefined,
|
||||
appleAppIdentifier: undefined,
|
||||
ascAppBundleIdentifier,
|
||||
bundleShortVersion,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
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,7 +18,14 @@ import {
|
||||
} from '../deviceRunSessionPreview';
|
||||
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.mock('node-fetch', () => {
|
||||
const actual = jest.requireActual('node-fetch');
|
||||
return {
|
||||
__esModule: true,
|
||||
...actual,
|
||||
default: jest.fn(),
|
||||
};
|
||||
});
|
||||
jest.mock('@expo/turtle-spawn');
|
||||
jest.mock('../../../utils/turtleFetch');
|
||||
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
|
||||
@@ -87,8 +94,8 @@ describe(startDeviceRunSessionPreview, () => {
|
||||
'https://uploads.expo.test/preview',
|
||||
expect.objectContaining({
|
||||
method: 'PUT',
|
||||
body: image,
|
||||
headers: { 'content-type': 'image/webp' },
|
||||
body: expect.any(Readable),
|
||||
headers: { 'content-type': 'image/webp', 'content-length': String(image.length) },
|
||||
})
|
||||
);
|
||||
await jest.advanceTimersByTimeAsync(59_999);
|
||||
@@ -130,9 +137,7 @@ 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(
|
||||
'Session preview upload request failed (name: FetchError, type: request-timeout).'
|
||||
);
|
||||
expect(fields.err.message).toBe('Signed URL upload failed: network error.');
|
||||
expect(JSON.stringify(jest.mocked(logger.warn).mock.calls)).not.toContain('uploads.expo.test');
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import { FetchError } from 'node-fetch';
|
||||
|
||||
import { CustomBuildContext } from '../../../customBuildContext';
|
||||
import { turtleFetch } from '../../../utils/turtleFetch';
|
||||
@@ -11,6 +12,7 @@ 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>;
|
||||
@@ -262,12 +264,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()).rejects.toThrow(
|
||||
await expect(createApi().getApplicationAsync({ retries: 3 })).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/application returned an unexpected response'
|
||||
);
|
||||
});
|
||||
|
||||
it('passes the signal, timeout, and retries to the request', async () => {
|
||||
it('passes the signal and timeout without enabling nested retries', async () => {
|
||||
jest.mocked(turtleFetch).mockResolvedValueOnce(jsonResponse({ data: { iceServers: [] } }));
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -280,7 +282,7 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
|
||||
expect.any(String),
|
||||
'POST',
|
||||
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 1 })
|
||||
expect.objectContaining({ signal: controller.signal, timeout: 5000, retries: 0 })
|
||||
);
|
||||
});
|
||||
|
||||
@@ -319,7 +321,7 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
);
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync()
|
||||
.getSessionAsync({ retries: 0 })
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
@@ -334,7 +336,7 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
jest.mocked(turtleFetch).mockRejectedValueOnce(reason);
|
||||
|
||||
const error = await createApi()
|
||||
.getSessionAsync()
|
||||
.getSessionAsync({ retries: 0 })
|
||||
.catch(err => err);
|
||||
|
||||
expect(error).toBeInstanceOf(DeviceRunSessionWorkerApiError);
|
||||
@@ -348,7 +350,7 @@ describe(DeviceRunSessionWorkerApi, () => {
|
||||
.mocked(turtleFetch)
|
||||
.mockResolvedValueOnce(jsonResponse({ data: { id: 'drs-id', status: 'STOPPED' } }));
|
||||
|
||||
await expect(createApi().getSessionAsync()).rejects.toThrow(
|
||||
await expect(createApi().getSessionAsync({ retries: 3 })).rejects.toThrow(
|
||||
'GET /v2/device-run-session-worker/session returned an unexpected response'
|
||||
);
|
||||
});
|
||||
@@ -356,8 +358,183 @@ 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()).rejects.toThrow(
|
||||
await expect(createApi().getSessionAsync({ retries: 3 })).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();
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
+4
-2
@@ -151,10 +151,11 @@ describe('with a device run session token', () => {
|
||||
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the session through the worker API', async () => {
|
||||
it('retries a transient failure when starting 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({
|
||||
@@ -165,6 +166,7 @@ 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();
|
||||
});
|
||||
|
||||
@@ -213,7 +215,7 @@ describe('with a device run session token', () => {
|
||||
'--turn-credential',
|
||||
'credential',
|
||||
]);
|
||||
expectWorkerApiCall('turn-ice-servers', 'POST', { timeout: 5000, retries: 1 });
|
||||
expectWorkerApiCall('turn-ice-servers', 'POST', { timeout: 5000, retries: 0 });
|
||||
expect(jest.mocked(turtleFetch)).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -2,6 +2,14 @@ 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';
|
||||
@@ -310,7 +318,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('Artifact PUT failed: ECONNRESET.');
|
||||
expect(logged).toContain('Signed URL upload failed: ECONNRESET.');
|
||||
expect(logged).not.toContain('secret-signature');
|
||||
});
|
||||
|
||||
|
||||
@@ -302,28 +302,6 @@ export namespace GooglePlayUtils {
|
||||
code = 'EAS_GOOGLE_PLAY_DRAFT_APP';
|
||||
detail =
|
||||
'This app is still a draft in Play Console. Submit a draft release without a rollout, or complete the app setup in Play Console before submitting a release to testers. Use releaseStatus: "draft" in eas.json, or release_status: "draft" in a workflow.';
|
||||
} else if (/must declare the use of advertising ID/i.test(message)) {
|
||||
code = 'EAS_GOOGLE_PLAY_ADVERTISING_ID_DECLARATION_REQUIRED';
|
||||
detail =
|
||||
'Google Play requires an advertising ID declaration for this app. Complete the declaration in Play Console so it matches the app and its SDKs, then submit again.';
|
||||
} else if (
|
||||
/must let us know whether your app uses any Foreground Service permissions/i.test(message)
|
||||
) {
|
||||
code = 'EAS_GOOGLE_PLAY_FOREGROUND_SERVICE_DECLARATION_REQUIRED';
|
||||
detail =
|
||||
'Google Play requires a foreground service declaration for this app. Complete the declaration in Play Console for the permissions used by the app, then submit again.';
|
||||
} else if (
|
||||
/Google Play Android Developer API has not been used in project .* before or it is disabled/i.test(
|
||||
message
|
||||
)
|
||||
) {
|
||||
code = 'EAS_GOOGLE_PLAY_API_DISABLED';
|
||||
detail =
|
||||
'The Google Play Android Developer API is not enabled for the Google Cloud project used by this service account. Enable the API in that project, wait for the change to take effect, then submit again.';
|
||||
} else if (/upload certificate.*not yet valid.*recently reset/i.test(message)) {
|
||||
code = 'EAS_GOOGLE_PLAY_UPLOAD_CERTIFICATE_NOT_YET_VALID';
|
||||
detail =
|
||||
'Google Play has not activated the upload certificate after its reset. Check the activation time in Play Console or the reset confirmation, wait until the certificate is active, then submit again. Do not reset the key again to resolve this wait.';
|
||||
} else if (error.status === 409 || /edit.*(?:expired|invalid|not found)/i.test(message)) {
|
||||
code = 'EAS_GOOGLE_PLAY_EDIT_INVALID';
|
||||
detail =
|
||||
|
||||
@@ -194,48 +194,6 @@ it.each([
|
||||
expect((mapped as Error).message).toContain('releaseStatus: "draft" in eas.json');
|
||||
}
|
||||
});
|
||||
it.each([
|
||||
[
|
||||
400,
|
||||
'Your app targets Android 13 (API 33) or above. You must declare the use of advertising ID in Play Console.',
|
||||
'EAS_GOOGLE_PLAY_ADVERTISING_ID_DECLARATION_REQUIRED',
|
||||
'advertising ID declaration',
|
||||
],
|
||||
[
|
||||
403,
|
||||
'You must let us know whether your app uses any Foreground Service permissions.',
|
||||
'EAS_GOOGLE_PLAY_FOREGROUND_SERVICE_DECLARATION_REQUIRED',
|
||||
'foreground service declaration',
|
||||
],
|
||||
[
|
||||
403,
|
||||
'Google Play Android Developer API has not been used in project 123 before or it is disabled. Enable it by visiting https://console.developers.google.com/',
|
||||
'EAS_GOOGLE_PLAY_API_DISABLED',
|
||||
'Google Cloud project',
|
||||
],
|
||||
[
|
||||
403,
|
||||
'The upload certificate this APK is signed with is not yet valid because it has been recently reset.',
|
||||
'EAS_GOOGLE_PLAY_UPLOAD_CERTIFICATE_NOT_YET_VALID',
|
||||
'activation time',
|
||||
],
|
||||
] as const)('gives a specific app-setup remedy for %s %s', (status, message, code, remedy) => {
|
||||
const original = new GooglePlayApiError(status, message, [], {
|
||||
requestPath: '/example',
|
||||
responseBody: 'diagnostic',
|
||||
});
|
||||
const mapped = GooglePlayUtils.mapGooglePlayError(original, packageName);
|
||||
expect(mapped).toMatchObject({
|
||||
errorCode: code,
|
||||
cause: original,
|
||||
metadata: { googlePlayRequestPath: '/example', googlePlayErrorResponse: 'diagnostic' },
|
||||
});
|
||||
expect((mapped as Error).message).toContain(remedy);
|
||||
expect((mapped as Error).message).toContain(`App: ${packageName}`);
|
||||
expect((mapped as Error).message).toContain('https://play.google.com/console/');
|
||||
expect((mapped as Error).message).not.toContain('Check the track');
|
||||
});
|
||||
|
||||
it('maps a rejected OAuth key to a credential error', () => {
|
||||
const original = new GooglePlayAuthRequestError(400, 'invalid_grant');
|
||||
expect(GooglePlayUtils.mapGooglePlayError(original, packageName)).toMatchObject({
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
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 {
|
||||
ArtifactPutError,
|
||||
ArtifactPutNetworkError,
|
||||
type ArtifactSource,
|
||||
type ArtifactUploadSession,
|
||||
putArtifactWithRetriesAsync,
|
||||
} from './artifactPut';
|
||||
type SignedUrl,
|
||||
SignedUrlUploadApiError,
|
||||
SignedUrlUploadNetworkError,
|
||||
uploadWithSignedUrl,
|
||||
} from '../../storage/uploadWithSignedUrl';
|
||||
import { withDeviceRunSessionTimeoutAsync } from './deviceRunSessionTimeout';
|
||||
import { requireDeviceRunSessionWorkerApi } from './deviceRunSessionWorkerApi';
|
||||
|
||||
@@ -23,7 +23,11 @@ type ArtifactMetadata = {
|
||||
|
||||
export async function uploadDeviceRunSessionArtifactAsync(
|
||||
ctx: CustomBuildContext,
|
||||
options: ArtifactMetadata & ArtifactSource & { signal?: AbortSignal; attempts?: number }
|
||||
options: ArtifactMetadata & {
|
||||
openStream: () => Readable;
|
||||
signal?: AbortSignal;
|
||||
attempts?: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
try {
|
||||
const uploadSession = await withDeviceRunSessionTimeoutAsync(
|
||||
@@ -31,12 +35,12 @@ export async function uploadDeviceRunSessionArtifactAsync(
|
||||
async signal => await createDeviceRunSessionArtifactUploadSessionAsync(ctx, options, signal)
|
||||
);
|
||||
options.signal?.throwIfAborted();
|
||||
await putArtifactWithRetriesAsync(
|
||||
{ uploadSession, source: options, signal: options.signal },
|
||||
{ attempts: options.attempts }
|
||||
await uploadWithSignedUrl(
|
||||
{ signedUrl: uploadSession, srcGeneratorAsync: async () => options.openStream() },
|
||||
{ signal: options.signal, retries: (options.attempts ?? 1) - 1, retryIntervalMs: 500 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (error instanceof ArtifactPutError || error instanceof ArtifactPutNetworkError) {
|
||||
if (error instanceof SignedUrlUploadApiError || error instanceof SignedUrlUploadNetworkError) {
|
||||
throw new DeviceRunSessionArtifactUploadError(options.artifactId, error.reason);
|
||||
}
|
||||
throw error;
|
||||
@@ -53,9 +57,9 @@ async function createDeviceRunSessionArtifactUploadSessionAsync(
|
||||
ctx: CustomBuildContext,
|
||||
{ deviceRunSessionId, artifactId, name, filename, kind, metadata, size }: ArtifactMetadata,
|
||||
signal: AbortSignal
|
||||
): Promise<ArtifactUploadSession> {
|
||||
): Promise<SignedUrl> {
|
||||
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
|
||||
let uploadSession: ArtifactUploadSession;
|
||||
let uploadSession: SignedUrl;
|
||||
try {
|
||||
uploadSession = await workerApi.createArtifactUploadSessionAsync(
|
||||
{
|
||||
|
||||
@@ -2,7 +2,6 @@ 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';
|
||||
@@ -10,6 +9,7 @@ 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,31 +17,6 @@ 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,
|
||||
@@ -75,10 +50,10 @@ export function startDeviceRunSessionPreview({
|
||||
if (controller.signal.aborted) {
|
||||
return;
|
||||
}
|
||||
const response = await putPreviewAsync(uploadSession, { image, signal });
|
||||
if (!response.ok) {
|
||||
throw new Error(`Session preview upload failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
await uploadContentWithSignedUrl(
|
||||
{ signedUrl: uploadSession, content: image },
|
||||
{ signal, retries: 0, stallTimeoutMs: 30_000 }
|
||||
);
|
||||
} catch (err) {
|
||||
if (!controller.signal.aborted) {
|
||||
logger.warn({ err }, 'Could not refresh the session preview; retrying in 60 seconds.');
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -75,7 +77,7 @@ type RequestOptions = {
|
||||
signal?: AbortSignal;
|
||||
/** node-fetch timeout of each attempt, in milliseconds. */
|
||||
timeoutMs?: number;
|
||||
/** Additional attempts after a failed one. Defaults to none, so each call sends one request. */
|
||||
/** Additional attempts for transient failures. Defaults to none; critical callers opt in. */
|
||||
retries?: number;
|
||||
logger?: bunyan;
|
||||
};
|
||||
@@ -189,16 +191,44 @@ 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,
|
||||
}: RequestOptions & { json?: Record<string, unknown> }
|
||||
}: Omit<RequestOptions, 'retries'> & {
|
||||
json?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<T> {
|
||||
const routeDescription = `${method} /v2/${DEVICE_RUN_SESSION_WORKER_PATH}${route}`;
|
||||
const url = new URL(
|
||||
@@ -212,7 +242,7 @@ export class DeviceRunSessionWorkerApi {
|
||||
...(json ? { json } : {}),
|
||||
...(signal ? { signal } : {}),
|
||||
...(timeoutMs !== undefined ? { timeout: timeoutMs } : {}),
|
||||
retries,
|
||||
retries: 0,
|
||||
shouldThrowOnNotOk: false,
|
||||
logger,
|
||||
})
|
||||
@@ -241,6 +271,15 @@ 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,4 +302,8 @@ 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,7 +560,10 @@ export async function uploadRemoteSessionConfigAsync({
|
||||
);
|
||||
const workerApi = requireDeviceRunSessionWorkerApi(ctx);
|
||||
try {
|
||||
await workerApi.startAsync({ remoteConfig });
|
||||
await workerApi.startAsync(
|
||||
{ remoteConfig },
|
||||
{ retries: 3, timeoutMs: DEVICE_RUN_SESSION_WORKER_REQUEST_TIMEOUT_MS, logger }
|
||||
);
|
||||
} catch (err) {
|
||||
throw new SystemError(
|
||||
`Failed to start device run session ${deviceRunSessionId}: ${
|
||||
|
||||
@@ -14,12 +14,8 @@ 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(
|
||||
@@ -238,18 +234,23 @@ async function putSandboxArtifactAsync({
|
||||
}: {
|
||||
filePath: string;
|
||||
size: number;
|
||||
uploadSession: ArtifactUploadSession;
|
||||
uploadSession: { url: string; headers: unknown };
|
||||
signal: AbortSignal;
|
||||
}): Promise<void> {
|
||||
const openStream = (): Readable =>
|
||||
size === 0 ? Readable.from([]) : createReadStream(filePath, { start: 0, end: size - 1 });
|
||||
try {
|
||||
await putArtifactWithRetriesAsync(
|
||||
{ uploadSession, source: { openStream }, signal },
|
||||
{ attempts: 3 }
|
||||
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 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof ArtifactPutError && error.status === 412)) {
|
||||
if (!(error instanceof SignedUrlUploadApiError && error.status === 412)) {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,325 +1,254 @@
|
||||
import { randomBytes } from 'crypto';
|
||||
import fetch, { RequestInit, Response } from 'node-fetch';
|
||||
import { Readable } from 'stream';
|
||||
import { type IncomingMessage, type ServerResponse, createServer } from 'node:http';
|
||||
import { performance } from 'node:perf_hooks';
|
||||
import { Readable } from 'node:stream';
|
||||
|
||||
import { type SignedUrl, uploadWithSignedUrl } from '../uploadWithSignedUrl';
|
||||
import {
|
||||
SignedUrlUploadApiError,
|
||||
SignedUrlUploadNetworkError,
|
||||
uploadContentWithSignedUrl,
|
||||
uploadWithSignedUrl,
|
||||
} from '../uploadWithSignedUrl';
|
||||
|
||||
jest.mock('node-fetch');
|
||||
jest.unmock('node-fetch');
|
||||
|
||||
class ErrorWithCode extends Error {
|
||||
protected readonly _code: string | undefined;
|
||||
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;
|
||||
|
||||
constructor(message: string, code?: string) {
|
||||
super(message);
|
||||
if (code) {
|
||||
this._code = code;
|
||||
}
|
||||
}
|
||||
|
||||
public get code(): string | undefined {
|
||||
return this._code;
|
||||
async function readBodyAsync(request: IncomingMessage): Promise<string> {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) {
|
||||
chunks.push(chunk);
|
||||
}
|
||||
return Buffer.concat(chunks).toString();
|
||||
}
|
||||
|
||||
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 },
|
||||
function respondWithStatuses(statuses: number[]): void {
|
||||
handle = async (request, response) => {
|
||||
await readBodyAsync(request);
|
||||
response.statusCode = statuses[requests - 1];
|
||||
response.end();
|
||||
};
|
||||
}
|
||||
|
||||
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;
|
||||
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: {} };
|
||||
});
|
||||
|
||||
const key = 'cat.jpg';
|
||||
afterEach(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>(resolve => server.close(() => resolve()));
|
||||
});
|
||||
|
||||
const signedUrl = createSignedUrl(key, 'image/jpeg');
|
||||
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);
|
||||
|
||||
fetchMock.mockImplementation(async () => res);
|
||||
await expect(
|
||||
uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync: async () => Readable.from('image'),
|
||||
retryIntervalMs: 0,
|
||||
})
|
||||
).rejects.toThrow();
|
||||
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('should return stripped URL if successful', 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.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);
|
||||
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;
|
||||
});
|
||||
|
||||
await expect(
|
||||
uploadWithSignedUrl({ signedUrl, srcGeneratorAsync }, { retryIntervalMs: 0 })
|
||||
).rejects.toBe(sourceError);
|
||||
expect(srcGeneratorAsync).toHaveBeenCalledTimes(1);
|
||||
expect(requests).toBe(0);
|
||||
});
|
||||
|
||||
it('retries an attempt that stalls past the stall timeout', async () => {
|
||||
handle = async (request, response) => {
|
||||
await readBodyAsync(request);
|
||||
if (requests > 1) {
|
||||
response.end();
|
||||
}
|
||||
};
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
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,8 +1,11 @@
|
||||
import { SystemError } from '@expo/eas-build-job';
|
||||
import fetch, { FetchError } from 'node-fetch';
|
||||
import { Readable } from 'stream';
|
||||
import { URL } from 'url';
|
||||
import { Readable } from 'node:stream';
|
||||
import { finished } from 'node:stream/promises';
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
|
||||
import { retryOnUploadFailure } from './retry';
|
||||
import { AbortUtils } from '../utils/AbortUtils';
|
||||
import { isConnectFailure, isConnectionInterruptedError } from '../utils/networkErrors';
|
||||
|
||||
export type SignedUrl = {
|
||||
url: string;
|
||||
@@ -12,46 +15,140 @@ export type SignedUrl = {
|
||||
export type UploadWithSignedUrlParams = {
|
||||
signedUrl: SignedUrl;
|
||||
srcGeneratorAsync: () => Promise<Readable>;
|
||||
retryIntervalMs?: number;
|
||||
retries?: number;
|
||||
};
|
||||
|
||||
export async function uploadWithSignedUrl({
|
||||
signedUrl,
|
||||
srcGeneratorAsync,
|
||||
retries = 2,
|
||||
retryIntervalMs = 30_000,
|
||||
}: UploadWithSignedUrlParams): Promise<string> {
|
||||
let resp;
|
||||
try {
|
||||
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}`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
export type UploadWithSignedUrlOptions = {
|
||||
signal?: AbortSignal;
|
||||
retries?: number;
|
||||
retryIntervalMs?: number;
|
||||
stallTimeoutMs?: number;
|
||||
};
|
||||
|
||||
if (!resp.ok) {
|
||||
let body: string | undefined;
|
||||
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 {
|
||||
body = await resp.text();
|
||||
} catch {}
|
||||
throw new Error(
|
||||
`Failed to upload file: status: ${resp.status} status text: ${resp.statusText}, body: ${body}`
|
||||
);
|
||||
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({
|
||||
signedUrl,
|
||||
stream,
|
||||
signal,
|
||||
onProgress,
|
||||
}: {
|
||||
signedUrl: SignedUrl;
|
||||
stream: Readable;
|
||||
signal: AbortSignal;
|
||||
onProgress: () => void;
|
||||
}): Promise<void> {
|
||||
const requestController = new AbortController();
|
||||
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);
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// 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}`;
|
||||
}
|
||||
@@ -20,6 +20,7 @@ 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', {
|
||||
@@ -30,7 +31,7 @@ it.each([
|
||||
expect(isConnectionInterruptedError(error)).toBe(false);
|
||||
});
|
||||
|
||||
it.each(['ECONNRESET', 'UND_ERR_SOCKET'])(
|
||||
it.each(['ECONNRESET', 'EPIPE', 'ETIMEDOUT', 'UND_ERR_SOCKET'])(
|
||||
'requires caller-specific recovery for wrapped %s',
|
||||
code => {
|
||||
const error = new Error('Request failed', {
|
||||
@@ -41,7 +42,7 @@ it.each(['ECONNRESET', 'UND_ERR_SOCKET'])(
|
||||
}
|
||||
);
|
||||
|
||||
it.each(['ETIMEDOUT', 'CERT_HAS_EXPIRED', undefined])('does not automatically retry %s', code => {
|
||||
it.each(['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,6 +8,14 @@ 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 {
|
||||
@@ -26,7 +34,11 @@ 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 && (current.code === 'ECONNRESET' || current.code === 'UND_ERR_SOCKET')) {
|
||||
if (
|
||||
'code' in current &&
|
||||
typeof current.code === 'string' &&
|
||||
CONNECTION_INTERRUPTED_CODES.has(current.code)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+725
-731
File diff suppressed because it is too large.
Load diff
@@ -1,3 +1,4 @@
|
||||
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';
|
||||
@@ -13,7 +14,6 @@ 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,7 +40,8 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
exp,
|
||||
projectId,
|
||||
customBuildConfigMetadata,
|
||||
buildLoggerLevel,
|
||||
freezeCredentials,
|
||||
@@ -66,7 +67,8 @@ export async function createBuildContextAsync<T extends Platform>({
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
vcsClient: Client;
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
|
||||
exp: ExpoConfig;
|
||||
projectId: string;
|
||||
customBuildConfigMetadata?: CustomBuildConfigMetadata;
|
||||
buildLoggerLevel?: LoggerLevel;
|
||||
freezeCredentials: boolean;
|
||||
@@ -77,9 +79,6 @@ 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([
|
||||
|
||||
@@ -119,7 +119,7 @@ export async function runBuildAndSubmitAsync({
|
||||
actor,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
downloadSimBuildAutoConfirm,
|
||||
envOverride,
|
||||
evaluatedConfig,
|
||||
experimentation,
|
||||
authenticationInfo,
|
||||
}: {
|
||||
@@ -131,7 +131,7 @@ export async function runBuildAndSubmitAsync({
|
||||
actor: Actor;
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
|
||||
downloadSimBuildAutoConfirm?: boolean;
|
||||
envOverride?: Env;
|
||||
evaluatedConfig?: { env: Env; exp: ExpoConfig; projectId: string };
|
||||
/** 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 } = !envOverride
|
||||
? await evaluateConfigWithEnvVarsAsync({
|
||||
buildProfile: buildProfile.profile,
|
||||
buildProfileName: buildProfile.profileName,
|
||||
graphqlClient,
|
||||
getProjectConfig: getDynamicPrivateProjectConfigAsync,
|
||||
opts: { env: buildProfile.profile.env },
|
||||
})
|
||||
: { env: envOverride };
|
||||
const { env, exp, projectId } =
|
||||
evaluatedConfig ??
|
||||
(await evaluateConfigWithEnvVarsAsync({
|
||||
buildProfile: buildProfile.profile,
|
||||
buildProfileName: buildProfile.profileName,
|
||||
graphqlClient,
|
||||
getProjectConfig: getDynamicPrivateProjectConfigAsync,
|
||||
opts: { env: buildProfile.profile.env },
|
||||
}));
|
||||
|
||||
const { build: maybeBuild, buildCtx } = await prepareAndStartBuildAsync({
|
||||
projectDir,
|
||||
@@ -244,7 +244,8 @@ export async function runBuildAndSubmitAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
exp,
|
||||
projectId,
|
||||
customBuildConfigMetadata: customBuildConfigMetadataByPlatform[platform],
|
||||
env,
|
||||
easJsonAccessor,
|
||||
@@ -384,7 +385,8 @@ async function prepareAndStartBuildAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
exp,
|
||||
projectId,
|
||||
customBuildConfigMetadata,
|
||||
env,
|
||||
easJsonAccessor,
|
||||
@@ -400,7 +402,8 @@ async function prepareAndStartBuildAsync({
|
||||
graphqlClient: ExpoGraphqlClient;
|
||||
analytics: Analytics;
|
||||
vcsClient: Client;
|
||||
getDynamicPrivateProjectConfigAsync: DynamicConfigContextFn;
|
||||
exp: ExpoConfig;
|
||||
projectId: string;
|
||||
customBuildConfigMetadata?: CustomBuildConfigMetadata;
|
||||
easJsonAccessor: EasJsonAccessor;
|
||||
env: Env;
|
||||
@@ -423,7 +426,8 @@ async function prepareAndStartBuildAsync({
|
||||
graphqlClient,
|
||||
analytics,
|
||||
vcsClient,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
exp,
|
||||
projectId,
|
||||
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 } from './contextUtils/getProjectIdAsync';
|
||||
import { getProjectIdAsync, isProjectIdInAppConfig } from './contextUtils/getProjectIdAsync';
|
||||
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
|
||||
import {
|
||||
ExpoConfigOptions,
|
||||
@@ -18,83 +18,57 @@ export type DynamicConfigContextFn = (options?: ExpoConfigOptions) => Promise<{
|
||||
}>;
|
||||
|
||||
export class DynamicPublicProjectConfigContextField extends ContextField<DynamicConfigContextFn> {
|
||||
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,
|
||||
};
|
||||
};
|
||||
async getValueAsync(options: ContextOptions): Promise<DynamicConfigContextFn> {
|
||||
return await createDynamicConfigFnAsync(getPublicExpoConfigAsync, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class DynamicPrivateProjectConfigContextField extends ContextField<DynamicConfigContextFn> {
|
||||
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, {
|
||||
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({
|
||||
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 getPrivateExpoConfigAsync(projectDir, options);
|
||||
const graphqlClient = createGraphqlClient(authenticationInfo);
|
||||
const serverSideEnvironmentVariables = await loadServerSideEnvironmentVariablesAsync({
|
||||
environment: withServerSideEnvironment,
|
||||
projectId,
|
||||
graphqlClient,
|
||||
});
|
||||
options = {
|
||||
...options,
|
||||
env: {
|
||||
...options?.env,
|
||||
...serverSideEnvironmentVariables,
|
||||
},
|
||||
};
|
||||
} else if (isProjectIdInAppConfig(expBefore, projectId)) {
|
||||
return {
|
||||
exp,
|
||||
exp: expBefore,
|
||||
projectDir,
|
||||
projectId,
|
||||
};
|
||||
}
|
||||
const exp = await getExpoConfigAsync(projectDir, options);
|
||||
return {
|
||||
exp,
|
||||
projectDir,
|
||||
projectId,
|
||||
};
|
||||
}
|
||||
};
|
||||
}
|
||||
+5
-2
@@ -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 } from './contextUtils/getProjectIdAsync';
|
||||
import { getProjectIdAsync, isProjectIdInAppConfig } from './contextUtils/getProjectIdAsync';
|
||||
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
|
||||
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
|
||||
@@ -58,7 +58,10 @@ export class OptionalPrivateProjectConfigContextField extends ContextField<
|
||||
});
|
||||
serverSideEnvVars = serverSideEnvironmentVariables;
|
||||
}
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir, { env: serverSideEnvVars });
|
||||
const exp =
|
||||
withServerSideEnvironment || !isProjectIdInAppConfig(expBefore, projectId)
|
||||
? await getPrivateExpoConfigAsync(projectDir, { env: serverSideEnvVars })
|
||||
: expBefore;
|
||||
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 } from './contextUtils/getProjectIdAsync';
|
||||
import { getProjectIdAsync, isProjectIdInAppConfig } from './contextUtils/getProjectIdAsync';
|
||||
import { loadServerSideEnvironmentVariablesAsync } from './contextUtils/loadServerSideEnvironmentVariablesAsync';
|
||||
import { getPrivateExpoConfigAsync } from '../../project/expoConfig';
|
||||
|
||||
@@ -39,7 +39,10 @@ export class PrivateProjectConfigContextField extends ContextField<{
|
||||
});
|
||||
serverSideEnvVars = serverSideEnvironmentVariables;
|
||||
}
|
||||
const exp = await getPrivateExpoConfigAsync(projectDir, { env: serverSideEnvVars });
|
||||
const exp =
|
||||
withServerSideEnvironment || !isProjectIdInAppConfig(expBefore, projectId)
|
||||
? await getPrivateExpoConfigAsync(projectDir, { env: serverSideEnvVars })
|
||||
: expBefore;
|
||||
|
||||
return {
|
||||
projectId,
|
||||
|
||||
+144
@@ -0,0 +1,144 @@
|
||||
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' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -98,6 +98,10 @@ export async function getProjectIdAsync(
|
||||
return projectId;
|
||||
}
|
||||
|
||||
export function isProjectIdInAppConfig(exp: Pick<ExpoConfig, 'extra'>, projectId: string): boolean {
|
||||
return exp.extra?.eas?.projectId === projectId;
|
||||
}
|
||||
|
||||
export async function validateOrSetProjectIdAsync({
|
||||
exp,
|
||||
graphqlClient,
|
||||
|
||||
@@ -109,7 +109,7 @@ export default class BuildDev extends EasCommand {
|
||||
|
||||
const workflow = await resolveWorkflowAsync(projectDir, platform, vcsClient);
|
||||
|
||||
const { env } = await evaluateConfigWithEnvVarsAsync({
|
||||
const evaluatedConfig = 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,
|
||||
env: evaluatedConfig.env,
|
||||
workflow,
|
||||
platforms: [platform],
|
||||
});
|
||||
@@ -197,7 +197,7 @@ export default class BuildDev extends EasCommand {
|
||||
actor,
|
||||
getDynamicPrivateProjectConfigAsync,
|
||||
downloadSimBuildAutoConfirm: true,
|
||||
envOverride: env,
|
||||
evaluatedConfig,
|
||||
});
|
||||
if (!flags['skip-bundler']) {
|
||||
await this.startDevServerAsync({ projectDir, platform });
|
||||
|
||||
+137
-134
@@ -183,12 +183,26 @@ 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 */
|
||||
@@ -768,8 +782,23 @@ 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 */
|
||||
@@ -842,12 +871,24 @@ 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'];
|
||||
@@ -1162,6 +1203,11 @@ export type AgentDeviceRunSessionRemoteConfig = {
|
||||
webPreviewUrl?: Maybe<Scalars['String']['output']>;
|
||||
};
|
||||
|
||||
export enum AgentKind {
|
||||
ClaudeCode = 'CLAUDE_CODE',
|
||||
Codex = 'CODEX'
|
||||
}
|
||||
|
||||
export enum AgentProvider {
|
||||
Anthropic = 'ANTHROPIC',
|
||||
Openai = 'OPENAI'
|
||||
@@ -1236,6 +1282,44 @@ 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>;
|
||||
@@ -1596,6 +1680,8 @@ 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>;
|
||||
@@ -1759,6 +1845,16 @@ 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>;
|
||||
@@ -2391,11 +2487,6 @@ 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. */
|
||||
@@ -2410,11 +2501,6 @@ 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;
|
||||
};
|
||||
@@ -2503,11 +2589,6 @@ export type AppObserve_OverviewStabilityArgs = {
|
||||
};
|
||||
|
||||
|
||||
export type AppObserve_OverviewVersionComparisonArgs = {
|
||||
input: AppObserveOverviewVersionComparisonInput;
|
||||
};
|
||||
|
||||
|
||||
export type AppObserve_SessionArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
@@ -2522,11 +2603,6 @@ export type AppObserve_UniqueActiveUserCountArgs = {
|
||||
input: AppObserveReleasesInput;
|
||||
};
|
||||
|
||||
|
||||
export type AppObserve_UpdatesArgs = {
|
||||
input: AppObserveUpdatesInput;
|
||||
};
|
||||
|
||||
export type AppObserveAppBuildEmbeddedSummary = {
|
||||
__typename?: 'AppObserveAppBuildEmbeddedSummary';
|
||||
eventCount: Scalars['Int']['output'];
|
||||
@@ -3967,31 +4043,6 @@ 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',
|
||||
@@ -5375,6 +5426,7 @@ export enum AuthProviderIdentifier {
|
||||
MsEntraId = 'MS_ENTRA_ID',
|
||||
Okta = 'OKTA',
|
||||
OneLogin = 'ONE_LOGIN',
|
||||
Rippling = 'RIPPLING',
|
||||
StubIdp = 'STUB_IDP'
|
||||
}
|
||||
|
||||
@@ -5523,6 +5575,7 @@ 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>;
|
||||
@@ -5530,7 +5583,7 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
|
||||
gitCommitMessage?: Maybe<Scalars['String']['output']>;
|
||||
gitRef?: Maybe<Scalars['String']['output']>;
|
||||
id: Scalars['ID']['output'];
|
||||
/** Queue position is 1-indexed */
|
||||
/** @deprecated Build queue position is no longer available. This field returns null. */
|
||||
initialQueuePosition?: Maybe<Scalars['Int']['output']>;
|
||||
initiatingActor?: Maybe<Actor>;
|
||||
iosEnterpriseProvisioning?: Maybe<BuildIosEnterpriseProvisioning>;
|
||||
@@ -5553,7 +5606,7 @@ export type Build = ActivityTimelineProjectActivity & BuildOrBuildJob & {
|
||||
projectMetadataFileUrl?: Maybe<Scalars['String']['output']>;
|
||||
projectRootDirectory?: Maybe<Scalars['String']['output']>;
|
||||
provisioningStartedAt?: Maybe<Scalars['DateTime']['output']>;
|
||||
/** Queue position is 1-indexed */
|
||||
/** @deprecated Build queue position is no longer available. This field returns null. */
|
||||
queuePosition?: Maybe<Scalars['Int']['output']>;
|
||||
reactNativeVersion?: Maybe<Scalars['String']['output']>;
|
||||
releaseChannel?: Maybe<Scalars['String']['output']>;
|
||||
@@ -5888,6 +5941,11 @@ 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']>;
|
||||
@@ -6357,24 +6415,6 @@ 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
|
||||
@@ -6407,6 +6447,14 @@ 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.
|
||||
@@ -6424,6 +6472,7 @@ 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. */
|
||||
@@ -6435,7 +6484,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 smaller than the session's maximum
|
||||
* session activity. Must be positive and no greater 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.
|
||||
@@ -6564,13 +6613,6 @@ 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']>;
|
||||
};
|
||||
@@ -6611,6 +6653,7 @@ 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;
|
||||
@@ -7134,12 +7177,6 @@ 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.
|
||||
@@ -7156,12 +7193,6 @@ 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']>;
|
||||
@@ -7174,20 +7205,8 @@ 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.
|
||||
@@ -7199,8 +7218,6 @@ 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.
|
||||
@@ -7209,27 +7226,11 @@ 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'];
|
||||
};
|
||||
@@ -7240,12 +7241,6 @@ 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;
|
||||
@@ -7528,7 +7523,9 @@ 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',
|
||||
@@ -8523,11 +8520,6 @@ 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']>;
|
||||
};
|
||||
|
||||
|
||||
@@ -8535,11 +8527,6 @@ export type GitHubUserMutation_DeleteGitHubUserArgs = {
|
||||
id: Scalars['ID']['input'];
|
||||
};
|
||||
|
||||
|
||||
export type GitHubUserMutation_GenerateGitHubUserAccessTokenArgs = {
|
||||
githubAppRegistrationId?: InputMaybe<Scalars['ID']['input']>;
|
||||
};
|
||||
|
||||
export type GoogleServiceAccountKey = {
|
||||
__typename?: 'GoogleServiceAccountKey';
|
||||
account: Account;
|
||||
@@ -8968,9 +8955,17 @@ 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'];
|
||||
@@ -10002,7 +9997,7 @@ export type RootMutation = {
|
||||
deployments: DeploymentsMutation;
|
||||
/** Mutations that assign or modify DevDomainNames for apps */
|
||||
devDomainName: AppDevDomainNameMutation;
|
||||
/** Mutations that create, start, stop, and update device run sessions */
|
||||
/** Mutations that create, stop, update, and delete device run sessions */
|
||||
deviceRunSession: DeviceRunSessionMutation;
|
||||
/** Mutations for Discord users */
|
||||
discordUser: DiscordUserMutation;
|
||||
@@ -10122,6 +10117,7 @@ export type RootQuery = {
|
||||
account: AccountQuery;
|
||||
/** Top-level query object for querying AccountSSOConfigurationPublicData */
|
||||
accountSSOConfigurationPublicData: AccountSsoConfigurationPublicDataQuery;
|
||||
agentRuns: AgentRunQuery;
|
||||
app: AppQuery;
|
||||
/**
|
||||
* Look up app by app id
|
||||
@@ -10445,6 +10441,7 @@ 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;
|
||||
@@ -10724,8 +10721,6 @@ 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 = {
|
||||
@@ -12094,11 +12089,15 @@ 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']>;
|
||||
};
|
||||
|
||||
@@ -12126,6 +12125,10 @@ 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']>;
|
||||
};
|
||||
|
||||
@@ -207,6 +207,27 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"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,6 +800,85 @@ 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,7 +1,7 @@
|
||||
import Joi from 'joi';
|
||||
import semver from 'semver';
|
||||
|
||||
import { ResourceClass } from './types';
|
||||
import { JobExpoTokenRole, ResourceClass } from './types';
|
||||
|
||||
const AllowedCommonResourceClasses: ResourceClass[] = [
|
||||
ResourceClass.DEFAULT,
|
||||
@@ -31,10 +31,21 @@ 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),
|
||||
|
||||
@@ -20,6 +20,18 @@ 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';
|
||||
@@ -32,6 +44,9 @@ 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;
|
||||
|
||||
@@ -5,10 +5,12 @@ export { Platform } from '@expo/eas-build-job';
|
||||
export {
|
||||
AndroidVersionAutoIncrement,
|
||||
BuildProfile,
|
||||
BuildProfilePermissions,
|
||||
CredentialsSource,
|
||||
DistributionType,
|
||||
IosEnterpriseProvisioning,
|
||||
IosVersionAutoIncrement,
|
||||
JobExpoTokenRole,
|
||||
ResourceClass,
|
||||
} from './build/types';
|
||||
export { EasJsonAccessor } from './accessor';
|
||||
|
||||
@@ -18,9 +18,7 @@ jest.mock('@expo/build-tools', () => {
|
||||
MockRemoteLoggerStream.instances.push(this);
|
||||
}
|
||||
|
||||
public async init(): Promise<string> {
|
||||
return 'https://gcs.expo.test/logs';
|
||||
}
|
||||
public async init(): Promise<void> {}
|
||||
|
||||
public async cleanUp(): Promise<void> {}
|
||||
|
||||
|
||||
@@ -111,6 +111,7 @@ 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) => {
|
||||
@@ -148,6 +149,104 @@ 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) => {
|
||||
|
||||
@@ -136,15 +136,32 @@ 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 { stdout } = await spawn(
|
||||
'bash',
|
||||
['-c', `source ~/.nvm/nvm.sh && nvm install ${version}`],
|
||||
{
|
||||
const installAsync = async () => {
|
||||
return 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,
|
||||
|
||||
Reference in new issue
Block a user