Compare commits

...
Author SHA1 Message Date
Stanisław Chmiela 45f2df0130 Regenerate GraphQL types 2026-06-15 17:46:40 +02:00
Stanisław Chmiela f8c2b2daf4 [worker] Remove legacy upload code, add retries, auto-waive builds (#3848)
## What changed

- Removed `gcsSignedUploadUrlForApplicationArchive` and `gcsSignedUploadUrlForBuildArtifacts` from worker runtime config and runtime config typing.
- Removed launcher signed-upload fallback behavior for managed application archive and build artifacts uploads.
- Classified managed artifact upload failures as `SystemError`s with internal tracking codes while keeping the public error code as `SERVER_ERROR`.
- Added retry options to worker upload API `turtleFetch` calls for upload-session creation and artifact metadata saving.

## Why

These signed upload URL fields are no longer expected, so keeping fallback paths made the worker handle an obsolete runtime contract. Upload failures in these managed paths are infrastructure/API/storage failures rather than user-actionable project issues.

## Validation

- `yarn oxfmt packages/worker/src/config.ts packages/worker/src/upload.ts packages/worker/src/external/turtle.ts packages/worker/src/__unit__/upload.test.ts`
- `yarn workspace @expo/worker test:unit src/__unit__/upload.test.ts --runInBand`
- `yarn workspace @expo/worker typecheck`
2026-06-15 17:38:41 +02:00
Mustafa Abban 22fc4da732 [build-tools] Enable Gradle cache via machine-level gradle.properties to avoid fingerprint mutation (#3852) 2026-06-12 09:34:58 -04:00
Stanisław Chmiela 1c7c79d445 [build-tools] Drop legacy cache URL fallbacks (#3856)
## Summary

Drops the temporary legacy cache URL fallback path now that the worker env-var PR is deployed.

This removes the fallback URL state from runtime settings, removes the old cache URL fields from worker runtime config parsing/types, and removes the now-obsolete config fallback test.

## Impact

Cache URL sourcing now relies on the runtime-settings cache gates plus the new worker cache URL environment variables only. Public build env names remain unchanged.

## Validation

CI should pass. Once deployed to staging I'll confirm the env vars and cache config is there.
2026-06-12 10:06:16 +02:00
Szymon Świerk b30dd9054d [eas-cli] Validate/regenerate/create provisioning profile in non-interactive iOS builds using submission ASC API key when present (#3805)
# Why

Many projects already configure an App Store Connect API key in EAS credentials for submissions, but non-interactive iOS App Store and Enterprise builds (`eas build --non-interactive`) could not use that key when validating or repairing provisioning profiles on Apple servers. Instead, CI had to provide `EXPO_ASC_*` environment variables separately, even when a usable key was already stored on EAS.

This makes non-interactive credential setup more convenient by reusing the EAS-stored submission ASC API key for App Store and Enterprise profile validation and repair.

# How

- Added `tryAuthenticateAppStoreWithEasAscApiKeyAsync` in `AscApiKeyUtils.ts` — a best-effort, non-throwing helper that authenticates `ctx.appStore` in non-interactive mode using:
  1. `EXPO_ASC_API_KEY_PATH` / `EXPO_ASC_KEY_ID` / `EXPO_ASC_ISSUER_ID` environment variables, or
  2. The App Store Connect API key configured for submissions on the app in EAS credentials.
- In `SetUpProvisioningProfile.runAsync`, call this helper before Apple-side validation when running non-interactively without an existing auth context.
- Use actual authentication state (`ctx.appStore.authCtx`) instead of startup `defaultAuthenticationMode` when deciding whether Apple authentication is available in `SetUpProvisioningProfile`, `CreateProvisioningProfile`, and `ConfigureProvisioningProfile`.
- In non-interactive mode, treat unexpected Apple validation errors as best-effort: log a warning and continue rather than failing the build.
- Extracted shared `resolveAscApiKeyForAppCredentialsAsync` (previously duplicated in `SetUpAdhocProvisioningProfile`) for reuse across credential flows.
- Derive the `AppleTeamType` for profile operations from the distribution type (`IN_HOUSE` for enterprise, `COMPANY_OR_ORGANIZATION` otherwise), with `EXPO_APPLE_TEAM_TYPE` override.
- Updated error messages to mention both `EXPO_ASC_*` environment variables and EAS-stored ASC API keys.

Out of scope: ad-hoc provisioning profile refresh (opt-in, handled separately via `--refresh-ad-hoc-provisioning-profile`).

# Test Plan

- Added unit tests for auto-authentication, team type resolution, best-effort validation skip, and missing-auth paths in `SetUpProvisioningProfile-test.ts`.
- Tested manually:
  - Run an interactive iOS production build and create a provisioning profile
  - Confirm non-interactive build works with that profile
  - Revoke the provisioning profile in the Apple Developer portal
  - Confirm non-interactive build succeeds and the profile is repaired when the submission ASC API key is configured in app credentials (`easd build --platform ios --profile production --non-interactive`)
2026-06-11 19:12:58 +02:00
Szymon DziedzicandClaude Opus 4.7 7af74c605e [eas-cli] Add --max-duration-minutes flag to eas simulator:start (#3806)
* [eas-cli] Regenerate GraphQL schema

* [eas-cli] Add --max-duration-minutes flag to eas simulator:start

Co-Authored-By: Claude Opus 4.7 <[email protected]>

* [eas-cli] Only reject negative --max-duration-minutes in simulator:start

Leave the upper-bound check to the server instead of capping it client-side.

Co-Authored-By: Claude Opus 4.7 <[email protected]>

---------

Co-authored-by: Claude Opus 4.7 <[email protected]>
2026-06-11 16:58:14 +02:00
Szymon DziedzicandClaude Opus 4.8 a5fccedf4e build-tools: fetch Cloudflare TURN ICE servers from www and pass to serve-sim (#3828)
When launching serve-sim, the worker requests short-lived TURN ICE servers from www's turtle-job-runs route (same robot-token pattern as the project clone URL) and forwards them as --stun-url/--turn-url/--turn-username/--turn-credential. Covers all three serve-sim launchers (serve_sim, agent_device, argent).

Best-effort: on any failure serve-sim falls back to its built-in P2P/STUN behavior, so a TURN outage never blocks a session.

Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-11 16:43:24 +02:00
Ash Wu f1aa26d9f3 [build-tools] read JUnit new file attribute for Maestro 2.6+ (#3839)
Signed-off-by: Ash Wu <[email protected]>
2026-06-11 20:59:27 +08:00
Szymon Świerk 68a0847058 [build-tools] Query GQL for build download URL in the downloadBuild function, instead of constructing it manually (#3842)
* [build-tools] Query GQL for build download URL in the downloadBuild function, instead of constructing it manually

* more precise error handling (make use of UserError/SystemError)
2026-06-11 14:15:13 +02:00
Stanisław Chmiela 1431a20b86 Log user-provided custom functions during parsing (#3847)
I want to learn how much are custom functions being used.
2026-06-11 10:40:49 +02:00
39 changed files with 3481 additions and 555 deletions

No files matched your search

+2
View File
@@ -8,6 +8,8 @@ This is the log of notable changes to EAS CLI and related packages.
### 🎉 New features
- [eas-cli] Non-interactive iOS App Store and Enterprise builds can now use the App Store Connect API key stored in EAS credentials as a submission key to validate and repair provisioning profiles on Apple servers, without requiring `EXPO_ASC_*` environment variables or an interactive Apple login. ([#3805](https://github.com/expo/eas-cli/pull/3805) by [@sswrk](https://github.com/sswrk))
### 🐛 Bug fixes
- [expo-cocoapods-proxy] Fix iOS worker tarball build failing on macOS Tahoe due to bundler incompatibility with RubyGems 4. ([#3824](https://github.com/expo/eas-cli/pull/3824) by [@gwdp](https://github.com/gwdp))
@@ -50,13 +50,7 @@ describe('runtimeSettings', () => {
it('fetches runtime settings from the staging and production buckets', async () => {
jest.mocked(fetch).mockResolvedValue(response({}));
await RuntimeSettings.loadAsync({
environment: 'staging',
logger: logger as any,
cacheUrlFallbacks: {
npm: 'https://fallback-npm.example',
},
});
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
await RuntimeSettings.loadAsync({ environment: 'production', logger: logger as any });
await RuntimeSettings.loadAsync({ environment: 'test', logger: logger as any });
@@ -172,94 +166,6 @@ describe('runtimeSettings', () => {
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://pods.example');
});
it('uses cache URL fallbacks when enabled cache environment variables are not set', async () => {
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { npm: true, nodejs: true, maven: true },
darwin: { cocoapods: true },
},
})
);
await RuntimeSettings.loadAsync({
environment: 'staging',
logger: logger as any,
cacheUrlFallbacks: {
npm: 'https://fallback-npm.example',
nodejs: 'https://fallback-node.example',
maven: 'https://fallback-maven.example',
cocoapods: 'https://fallback-pods.example',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://fallback-npm.example');
expect(RuntimeSettings.getNodeJsCacheUrl()).toBe('https://fallback-node.example');
expect(RuntimeSettings.getMavenCacheUrl()).toBe('https://fallback-maven.example');
mockProcessPlatform('darwin');
expect(RuntimeSettings.getCocoapodsCacheUrl()).toBe('https://fallback-pods.example');
});
it('prefers cache URL environment variables over fallbacks', async () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { npm: true },
},
})
);
await RuntimeSettings.loadAsync({
environment: 'staging',
logger: logger as any,
cacheUrlFallbacks: {
npm: 'https://fallback-npm.example',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBe('https://npm.example');
});
it('does not use cache URL fallbacks when runtime settings disable caches', async () => {
jest.mocked(fetch).mockResolvedValue(
response({
caches: {
linux: { npm: false },
},
})
);
await RuntimeSettings.loadAsync({
environment: 'staging',
logger: logger as any,
cacheUrlFallbacks: {
npm: 'https://fallback-npm.example',
},
});
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('clears cache URL fallbacks when loading without them', async () => {
jest.mocked(fetch).mockResolvedValue(response({ caches: { linux: { npm: true } } }));
await RuntimeSettings.loadAsync({
environment: 'staging',
logger: logger as any,
cacheUrlFallbacks: {
npm: 'https://fallback-npm.example',
},
});
await RuntimeSettings.loadAsync({ environment: 'staging', logger: logger as any });
mockProcessPlatform('linux');
expect(RuntimeSettings.getNpmCacheUrl()).toBeNull();
});
it('does not use caches when runtime settings are not loaded', () => {
process.env.EAS_NPM_CACHE_URL = 'https://npm.example';
@@ -7,6 +7,7 @@ import { createMockLogger } from '../../__tests__/utils/logger';
import { prepareProjectSourcesAsync } from '../../common/projectSources';
import { BuildContext } from '../../context';
import { CustomBuildContext } from '../../customBuildContext';
import { Datadog } from '../../datadog';
import { findAndUploadXcodeBuildLogsAsync } from '../../ios/xcodeBuildLogs';
import { runCustomBuildAsync } from '../custom';
@@ -17,6 +18,7 @@ const findAndUploadXcodeBuildLogsAsyncMock = jest.mocked(findAndUploadXcodeBuild
describe(runCustomBuildAsync, () => {
let ctx: BuildContext<BuildJob>;
const datadogLogSpy = jest.spyOn(Datadog, 'log');
beforeEach(() => {
const job = createTestIosJob();
@@ -27,10 +29,24 @@ describe(runCustomBuildAsync, () => {
vol.fromJSON(
{
'test.yaml': `
functions:
test_function:
name: Test function
path: ./custom-function
shell: /bin/zsh
supported_platforms:
- darwin
- linux
inputs:
- name
- profile
outputs:
- artifact_path
build:
steps:
- eas/checkout
`,
'custom-function/package.json': '{}',
},
'/workingdir/temporary-custom-build'
);
@@ -77,6 +93,21 @@ describe(runCustomBuildAsync, () => {
expect(prepareProjectSourcesAsync).toHaveBeenCalledTimes(2);
});
it('reports user-provided custom functions when parsing the custom build config', async () => {
await runCustomBuildAsync(ctx);
expect(datadogLogSpy).toHaveBeenCalledWith('Custom build saw user-provided function', {
event: 'custom_build_user_provided_function',
custom_function_id: 'test_function',
custom_function_input_count: '2',
custom_function_module_path: '/workingdir/temporary-custom-build/custom-function',
custom_function_name: 'Test function',
custom_function_output_count: '1',
custom_function_shell: '/bin/zsh',
custom_function_supported_runtime_platforms: 'darwin,linux',
});
});
it('awaits drainPendingMetricUploads after workflow execution', async () => {
let resolveDrain!: () => void;
const drainSpy = jest
+32 -1
View File
@@ -1,5 +1,11 @@
import { BuildJob, BuildPhase, BuildTrigger, Ios, Platform } from '@expo/eas-build-job';
import { BuildConfigParser, BuildStepGlobalContext, StepsConfigParser, errors } from '@expo/steps';
import {
BuildConfigParser,
BuildStepGlobalContext,
BuildWorkflow,
StepsConfigParser,
errors,
} from '@expo/steps';
import assert from 'assert';
import fs from 'fs/promises';
import nullthrows from 'nullthrows';
@@ -9,6 +15,7 @@ import { resolveEnvFromBuildProfileAsync } from '../common/easBuildInternal';
import { prepareProjectSourcesAsync } from '../common/projectSources';
import { Artifacts, BuildContext } from '../context';
import { CustomBuildContext } from '../customBuildContext';
import { Datadog } from '../datadog';
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
import { getEasFunctionGroups } from '../steps/easFunctionGroups';
import { getEasFunctions } from '../steps/easFunctions';
@@ -81,6 +88,7 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
throw parseError;
}
});
logUserProvidedCustomFunctions(workflow);
try {
try {
await workflow.executeAsync();
@@ -103,3 +111,26 @@ export async function runCustomBuildAsync(ctx: BuildContext<BuildJob>): Promise<
return ctx.artifacts;
}
function logUserProvidedCustomFunctions(workflow: BuildWorkflow): void {
for (const buildFunction of Object.values(workflow.buildFunctions)) {
if (!buildFunction.customFunctionModulePath) {
continue;
}
Datadog.log('Custom build saw user-provided function', {
event: 'custom_build_user_provided_function',
custom_function_id: buildFunction.getFullId(),
custom_function_module_path: buildFunction.customFunctionModulePath,
custom_function_input_count: String(buildFunction.inputProviders?.length ?? 0),
custom_function_output_count: String(buildFunction.outputProviders?.length ?? 0),
...(buildFunction.name ? { custom_function_name: buildFunction.name } : {}),
...(buildFunction.shell ? { custom_function_shell: buildFunction.shell } : {}),
...(buildFunction.supportedRuntimePlatforms
? {
custom_function_supported_runtime_platforms:
buildFunction.supportedRuntimePlatforms.join(','),
}
: {}),
});
}
}
+4 -15
View File
@@ -26,27 +26,16 @@ const RuntimeSettingsSchema = z
})
.partial();
type CacheUrlFallbacks = {
npm?: string | null;
nodejs?: string | null;
maven?: string | null;
cocoapods?: string | null;
};
let runtimeSettings: z.infer<typeof RuntimeSettingsSchema> = {};
let cacheUrlFallbacks: CacheUrlFallbacks = {};
export namespace RuntimeSettings {
export async function loadAsync({
environment,
logger,
cacheUrlFallbacks: nextCacheUrlFallbacks = {},
}: {
environment: string;
logger: bunyan;
cacheUrlFallbacks?: CacheUrlFallbacks;
}): Promise<void> {
cacheUrlFallbacks = nextCacheUrlFallbacks;
const url = ENVIRONMENT_TO_RUNTIME_SETTINGS_URL[environment];
if (!url) {
return;
@@ -104,25 +93,25 @@ export namespace RuntimeSettings {
export function getNpmCacheUrl(): string | null {
return runtimeSettings.caches?.[process.platform]?.npm
? process.env.EAS_NPM_CACHE_URL || cacheUrlFallbacks.npm || null
? process.env.EAS_NPM_CACHE_URL || null
: null;
}
export function getNodeJsCacheUrl(): string | null {
return runtimeSettings.caches?.[process.platform]?.nodejs
? process.env.EAS_NODEJS_CACHE_URL || cacheUrlFallbacks.nodejs || null
? process.env.EAS_NODEJS_CACHE_URL || null
: null;
}
export function getMavenCacheUrl(): string | null {
return runtimeSettings.caches?.[process.platform]?.maven
? process.env.EAS_MAVEN_CACHE_URL || cacheUrlFallbacks.maven || null
? process.env.EAS_MAVEN_CACHE_URL || null
: null;
}
export function getCocoapodsCacheUrl(): string | null {
return runtimeSettings.caches?.[process.platform]?.cocoapods
? process.env.EAS_COCOAPODS_CACHE_URL || cacheUrlFallbacks.cocoapods || null
? process.env.EAS_COCOAPODS_CACHE_URL || null
: null;
}
}
@@ -61,7 +61,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
createInstallNodeModulesBuildFunction(),
createPrebuildBuildFunction(),
createReadIpaInfoBuildFunction(),
createDownloadBuildFunction(),
createDownloadBuildFunction(ctx),
createEasExportBuildFunction(),
createEasDeployBuildFunction(),
createRepackBuildFunction(),
@@ -1,4 +1,6 @@
import { SystemError, UserError } from '@expo/eas-build-job';
import { createLogger } from '@expo/logger';
import { Client, CombinedError } from '@urql/core';
import fetch, { Response } from 'node-fetch';
import { randomUUID } from 'node:crypto';
import fs from 'node:fs';
@@ -17,22 +19,63 @@ const APP_TAR_GZ_BUFFER = Buffer.from(
'base64'
);
const APPLICATION_ARCHIVE_URL = `https://expo.dev/artifacts/eas/${randomUUID()}.tar.gz`;
function createMockGraphqlClient({
applicationArchiveUrl,
error,
}: {
applicationArchiveUrl?: string | null;
error?: Error;
}): Client {
const toPromise = jest.fn().mockResolvedValue(
error
? { error, data: undefined }
: {
data: {
builds: {
byId: {
id: randomUUID(),
platform: 'IOS',
artifacts: {
applicationArchiveUrl: applicationArchiveUrl ?? null,
},
},
},
},
}
);
return {
query: jest.fn().mockReturnValue({ toPromise }),
} as unknown as Client;
}
describe('downloadBuild', () => {
it('should handle an archive', async () => {
it('downloads from applicationArchiveUrl returned by GraphQL', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(APP_TAR_GZ_BUFFER),
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
url: APPLICATION_ARCHIVE_URL,
} as unknown as Response);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
expoApiServerURL: 'http://api.expo.test',
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
expect(artifactPath).toBeDefined();
expect(await fs.promises.readFile(path.join(artifactPath, 'TestApp'), 'utf8')).toBe(
'i am executable\n'
@@ -40,16 +83,21 @@ describe('downloadBuild', () => {
});
it('should handle a straight-up file', async () => {
const applicationArchiveUrl = `https://expo.dev/artifacts/eas/${randomUUID()}.apk`;
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(Buffer.from('hello')),
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.txt?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
url: applicationArchiveUrl,
} as unknown as Response);
const { artifactPath } = await downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
expoApiServerURL: 'http://api.expo.test',
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
@@ -58,18 +106,114 @@ describe('downloadBuild', () => {
expect(await fs.promises.readFile(artifactPath, 'utf-8')).toBe('hello');
});
it('throws UserError when the build has no application archive url', async () => {
const graphqlClient = createMockGraphqlClient({ applicationArchiveUrl: null });
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
})
).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
message: 'Build does not have an application archive url',
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request fails with a network error', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
networkError: new Error('Network request failed'),
}),
});
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: [Network] Network request failed`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws SystemError when GraphQL request returns 5xx', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: Object.assign(new Error('Internal Server Error'), {
response: { status: 500 },
}),
});
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
await expect(promise).rejects.toBeInstanceOf(SystemError);
await expect(promise).rejects.toMatchObject({
message: `Could not fetch build ${buildId}: Internal Server Error`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('throws UserError when GraphQL returns a client error', async () => {
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
error: new CombinedError({
graphQLErrors: [{ message: 'Build not found' }],
}),
});
const promise = downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId,
graphqlClient,
robotAccessToken: null,
extensions: ['app'],
});
await expect(promise).rejects.toBeInstanceOf(UserError);
await expect(promise).rejects.toMatchObject({
errorCode: 'EAS_DOWNLOAD_BUILD_FETCH_FAILED',
message: `Could not fetch build ${buildId}: [GraphQL] Build not found`,
});
expect(jest.mocked(fetch)).not.toHaveBeenCalled();
});
it('should throw an error if no matching files are found', async () => {
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
jest.mocked(fetch).mockResolvedValue({
ok: true,
body: Readable.from(APP_TAR_GZ_BUFFER),
url: `https://storage.googleapis.com/eas-workflows-production/artifacts/${randomUUID()}/${randomUUID()}/application-${randomUUID()}.tar.gz?X-Goog-Algorithm=GOOG4-RSA-SHA256`,
url: APPLICATION_ARCHIVE_URL,
} as unknown as Response);
await expect(
downloadBuildAsync({
logger: createLogger({ name: 'test' }),
buildId: randomUUID(),
expoApiServerURL: 'http://api.expo.test',
graphqlClient,
robotAccessToken: null,
extensions: ['apk'],
})
@@ -79,7 +223,11 @@ describe('downloadBuild', () => {
describe('createDownloadBuildFunction', () => {
it('should download a build', async () => {
const downloadBuild = createDownloadBuildFunction();
const buildId = randomUUID();
const graphqlClient = createMockGraphqlClient({
applicationArchiveUrl: APPLICATION_ARCHIVE_URL,
});
const downloadBuild = createDownloadBuildFunction({ graphqlClient } as any);
const logger = createMockLogger();
const buildStep = downloadBuild.createBuildStepFromFunctionCall(
@@ -92,7 +240,7 @@ describe('createDownloadBuildFunction', () => {
}),
{
callInputs: {
build_id: randomUUID(),
build_id: buildId,
extensions: ['app'],
},
}
@@ -105,5 +253,9 @@ describe('createDownloadBuildFunction', () => {
} as unknown as Response);
await expect(buildStep.executeAsync()).rejects.toThrow('Internal Server Error');
expect(jest.mocked(fetch)).toHaveBeenCalledWith(
APPLICATION_ARCHIVE_URL,
expect.objectContaining({ headers: undefined })
);
});
});
@@ -4,10 +4,14 @@ import { vol } from 'memfs';
import {
copyLatestAttemptXml,
isFileAttrRun,
junitFileHasFileAttrs,
mergeJUnitReports,
parseFailedFlowsFromFileAttrs,
parseFailedFlowsFromJUnit,
parseJUnitTestCases,
parseMaestroResults,
parseMaestroResultsFromFileAttrs,
} from '../maestroResultParser';
describe(parseMaestroResults, () => {
@@ -298,7 +302,169 @@ describe(parseMaestroResults, () => {
});
});
describe(parseMaestroResultsFromFileAttrs, () => {
it('uses file= as the path', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file=".maestro/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseMaestroResultsFromFileAttrs('/junit');
expect(results[0]).toEqual(
expect.objectContaining({ name: 'login', path: '.maestro/login.yaml' })
);
});
it('keeps two same-named flows separate by their file= path', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="login" file="b/login.yaml" status="ERROR" time="1.0"><failure>x</failure></testcase>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseMaestroResultsFromFileAttrs('/junit');
expect(results.map(r => r.path).sort()).toEqual(['a/login.yaml', 'b/login.yaml']);
});
it('returns per-attempt results keyed by file= across attempt files', async () => {
vol.fromJSON({
'/junit/android-maestro-junit-attempt-0.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" time="1.0"><failure>x</failure></testcase>',
'</testsuite></testsuites>',
].join('\n'),
'/junit/android-maestro-junit-attempt-1.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseMaestroResultsFromFileAttrs('/junit');
expect(results).toEqual([
expect.objectContaining({ path: 'flows/login.yaml', status: 'failed', retryCount: 0 }),
expect.objectContaining({ path: 'flows/login.yaml', status: 'passed', retryCount: 1 }),
]);
});
it('returns empty array when the directory does not exist', async () => {
const results = await parseMaestroResultsFromFileAttrs('/nope');
expect(results).toEqual([]);
});
it('skips testcases without file= instead of emitting an undefined path (contract guard)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="b" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseMaestroResultsFromFileAttrs('/junit');
expect(results).toEqual([expect.objectContaining({ name: 'a', path: 'a.yaml' })]);
});
});
describe('junitFileHasFileAttrs', () => {
it('returns true when every testcase has a non-empty file= attribute', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="b" file="b.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(true);
});
it('returns false when any testcase lacks file= (mixed report)', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" file="a.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="b" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
});
it('returns false when file= is an empty string', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" file="" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
});
it('returns false for a legacy report with no file= attributes', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
expect(await junitFileHasFileAttrs('/junit/report.xml')).toBe(false);
});
it('returns false when the file cannot be read', async () => {
expect(await junitFileHasFileAttrs('/missing.xml')).toBe(false);
});
});
describe('isFileAttrRun', () => {
it('returns false for an empty list (an empty report is not a file-attr run)', () => {
expect(isFileAttrRun([])).toBe(false);
});
});
describe(parseJUnitTestCases, () => {
it('extracts the file= attribute when present', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file=".maestro/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results[0].file).toBe('.maestro/login.yaml');
});
it('treats a missing or empty file= attribute as undefined', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="a" status="SUCCESS" time="1.0"/>',
' <testcase name="b" file="" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const results = await parseJUnitTestCases('/junit');
expect(results.find(r => r.name === 'a')!.file).toBeUndefined();
expect(results.find(r => r.name === 'b')!.file).toBeUndefined();
});
it('parses a single testcase with SUCCESS status', async () => {
vol.fromJSON({
'/junit/report.xml': [
@@ -582,6 +748,100 @@ describe(parseJUnitTestCases, () => {
});
});
describe(parseFailedFlowsFromFileAttrs, () => {
it('returns the failing flows by file= (duplicate names retry correctly)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>
<testcase name="login" file="b/login.yaml" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
'/proj/b/login.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toEqual(['b/login.yaml']);
});
it('returns null when a file= path does not exist on disk (dumb retry, never legacy)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="gone" file="missing/flow.yaml" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toBeNull();
});
it('returns null when the report lacks file= attributes (callers must pre-check)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" file="a.yaml" time="1.0"><failure>x</failure></testcase>
<testcase name="B" time="1.0"><failure>y</failure></testcase>
</testsuite></testsuites>`,
'/proj/a.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toBeNull();
});
it('dedupes repeated failing file= paths (same flow failed twice in one attempt)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="login" file="flows/login.yaml" time="1.0"><failure>x</failure></testcase>
<testcase name="login" file="flows/login.yaml" time="1.0"><failure>y</failure></testcase>
</testsuite></testsuites>`,
'/proj/flows/login.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toEqual(['flows/login.yaml']);
});
it('resolves and existence-checks an absolute file= path', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="ext" file="/outside/flow.yaml" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
'/outside/flow.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toEqual(['/outside/flow.yaml']);
});
it('returns null when junit XML is truncated mid-tag (partial parse risk)', async () => {
vol.fromJSON({
'/tmp/jr/attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="A" file="a.yaml" time="1.0"><failure>x</failure></testcase>
<testcase name="B" file="b.yaml"`, // intentionally truncated mid-tag
'/proj/a.yaml': '',
});
const result = await parseFailedFlowsFromFileAttrs({
junitFile: '/tmp/jr/attempt-0.xml',
workingDirectory: '/proj',
});
expect(result).toBeNull();
});
});
describe('parseFailedFlowsFromJUnit', () => {
it('returns the subset of input flow paths whose testcases failed', async () => {
vol.fromJSON({
@@ -765,6 +1025,28 @@ describe('mergeJUnitReports', () => {
expect(b.failure).toBeUndefined();
});
it('keeps a same-named flow from an earlier attempt when keyed by file=', async () => {
vol.fromJSON({
'/tmp/r/android-maestro-junit-attempt-0.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>
<testcase name="login" file="b/login.yaml" time="1.0"><failure>x</failure></testcase>
</testsuite></testsuites>`,
'/tmp/r/android-maestro-junit-attempt-1.xml': `<?xml version="1.0"?>
<testsuites><testsuite>
<testcase name="login" file="b/login.yaml" status="SUCCESS" time="1.0"/>
</testsuite></testsuites>`,
});
await mergeJUnitReports({ sourceDir: '/tmp/r', outputPath: '/tmp/final.xml' });
const out = await fs.readFile('/tmp/final.xml', 'utf-8');
const parsed = new XMLParser({ ignoreAttributes: false }).parse(out);
const testcases = parsed.testsuites.testsuite.testcase;
const files = testcases.map((t: any) => t['@_file']).sort();
expect(files).toEqual(['a/login.yaml', 'b/login.yaml']);
});
it('throws when source directory contains no *.xml files', async () => {
// No per-attempt JUnit files were ever written (maestro crashed before
// producing output). Phase 3 in runMaestroTests treats this as a SystemError
@@ -273,6 +273,33 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it('uses the file= path on reports that carry it, without scanning flow files', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'junitFileHasFileAttrs').mockResolvedValue(true);
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
const legacyParseSpy = jest.spyOn(parser, 'parseFailedFlowsFromJUnit');
const newParseSpy = jest
.spyOn(parser, 'parseFailedFlowsFromFileAttrs')
.mockResolvedValue(['flows/b.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml', 'flows/b.yaml'],
retries: 1,
output_format: 'junit',
retry_failed_only: true,
platform: 'android',
});
await step.executeAsync();
expect(newParseSpy).toHaveBeenCalledTimes(1);
// The new branch needs no name→path map: no scan, no legacy parser.
expect(discoverySpy).not.toHaveBeenCalled();
expect(legacyParseSpy).not.toHaveBeenCalled();
const a1 = mockedSpawn.mock.calls[1][1]!;
expect(a1).toContain('flows/b.yaml');
expect(a1).not.toContain('flows/a.yaml');
});
it('falls back to all-flows retry when nameToPath is null (duplicate flow names)', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(discovery, 'buildFlowNameToPathMap').mockResolvedValue(null);
@@ -293,6 +320,44 @@ describe('createMaestroTestsBuildFunction', () => {
);
});
it('never scans flow files when all attempts succeed', async () => {
mockedSpawn.mockResolvedValue(SPAWN_SUCCESS);
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
const step = createStep({ flow_path: ['flows/a.yaml'], platform: 'android' });
await step.executeAsync();
expect(discoverySpy).not.toHaveBeenCalled();
});
it('memoizes the flow scan across retries (legacy reports)', async () => {
// Routing note: the step lands in the legacy arm because the real
// junitFileHasFileAttrs returns false — the attempt XML does not exist on
// the mock fs. Creating that file with file= attrs would flip the branch.
mockedSpawn
.mockRejectedValueOnce(rejectExit1())
.mockRejectedValueOnce(rejectExit1())
.mockResolvedValueOnce(SPAWN_SUCCESS);
const discoverySpy = jest
.spyOn(discovery, 'buildFlowNameToPathMap')
.mockResolvedValue(new Map([['a', 'flows/a.yaml']]));
const parseSpy = jest
.spyOn(parser, 'parseFailedFlowsFromJUnit')
.mockResolvedValue(['flows/a.yaml']);
const step = createStep({
flow_path: ['flows/a.yaml'],
retries: 2,
output_format: 'junit',
platform: 'android',
});
await step.executeAsync();
// Two failed attempts hit the legacy branch twice; the scan runs once.
expect(parseSpy).toHaveBeenCalledTimes(2);
expect(discoverySpy).toHaveBeenCalledTimes(1);
});
it('defaults retry_failed_only to true when omitted from inputs', async () => {
mockedSpawn.mockRejectedValueOnce(rejectExit1()).mockResolvedValueOnce(SPAWN_SUCCESS);
jest.spyOn(parser, 'parseFailedFlowsFromJUnit').mockResolvedValue(['flows/b.yaml']);
@@ -3,6 +3,7 @@ import { vol } from 'memfs';
import { createGlobalContextMock } from '../../../__tests__/utils/context';
import { createMockLogger } from '../../../__tests__/utils/logger';
import * as discovery from '../maestroFlowDiscovery';
import { createReportMaestroTestResultsFunction } from '../reportMaestroTestResults';
const JUNIT_PASS = [
@@ -43,15 +44,24 @@ describe(createReportMaestroTestResultsFunction, () => {
} as unknown as Client;
});
// Returns a mock logger whose child() is itself, so step-level log calls can
// be asserted on the returned object.
function createSelfChildedLogger(): ReturnType<typeof createMockLogger> {
const logger = createMockLogger();
(logger.child as jest.Mock).mockReturnValue(logger);
return logger;
}
function createStep(overrides?: {
callInputs?: Record<string, unknown>;
staticContextContent?: Record<string, unknown>;
env?: Record<string, string>;
logger?: ReturnType<typeof createMockLogger>;
}) {
const ctx = { graphqlClient: mockGraphqlClient };
const fn = createReportMaestroTestResultsFunction(ctx as any);
const globalCtx = createGlobalContextMock({
logger: createMockLogger(),
logger: overrides?.logger ?? createMockLogger(),
projectTargetDirectory: '/root/project',
staticContextContent: {
expoApiServerURL: 'https://api.expo.test',
@@ -105,6 +115,54 @@ describe(createReportMaestroTestResultsFunction, () => {
]);
});
it('reports both flows when two share a name but have distinct file= paths', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="a/login.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="login" file="b/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: {
createWorkflowDeviceTestCaseResults: [{ id: '1' }, { id: '2' }],
},
},
});
await createStep().executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
const [, variables] = (mockGraphqlClient.mutation as jest.Mock).mock.calls[0];
expect(variables.input.testCaseResults.map((r: any) => r.path).sort()).toEqual([
'a/login.yaml',
'b/login.yaml',
]);
});
it('never scans flow files for a file= report, even when flow_path is provided', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const discoverySpy = jest.spyOn(discovery, 'buildFlowNameToPathMap');
mockMutationFn.mockResolvedValue({
data: {
workflowDeviceTestCaseResult: { createWorkflowDeviceTestCaseResults: [{ id: '1' }] },
},
});
await createStep({ callInputs: { flow_path: ['flows/login.yaml'] } }).executeAsync();
expect(mockGraphqlClient.mutation).toHaveBeenCalledTimes(1);
expect(discoverySpy).not.toHaveBeenCalled();
});
it('sends tags and properties from JUnit XML', async () => {
vol.fromJSON({
'/junit/report.xml': [
@@ -221,7 +279,7 @@ describe(createReportMaestroTestResultsFunction, () => {
await createStep().executeAsync();
});
it('skips report when duplicate testcase names found', async () => {
it('skips report when duplicate testcase names found (legacy), advising unique names', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
@@ -238,8 +296,27 @@ describe(createReportMaestroTestResultsFunction, () => {
}),
});
await createStep().executeAsync();
const logger = createSelfChildedLogger();
await createStep({ logger }).executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('unique name'));
});
it('skips report when the same flow file appears twice in one attempt, advising flow_path check', async () => {
vol.fromJSON({
'/junit/report.xml': [
'<?xml version="1.0"?>',
'<testsuites><testsuite>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="1.0"/>',
' <testcase name="login" file="flows/login.yaml" status="SUCCESS" time="2.0"/>',
'</testsuite></testsuites>',
].join('\n'),
});
const logger = createSelfChildedLogger();
await createStep({ logger }).executeAsync();
expect(mockGraphqlClient.mutation).not.toHaveBeenCalled();
expect(logger.error).toHaveBeenCalledWith(expect.stringContaining('flow_path entries'));
});
it('sends per-attempt results with same name but different retryCount', async () => {
@@ -1,4 +1,4 @@
import { UserError } from '@expo/eas-build-job';
import { SystemError, UserError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import {
@@ -7,7 +7,9 @@ import {
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import { Client } from '@urql/core';
import { glob } from 'fast-glob';
import { graphql } from 'gql.tada';
import fetch from 'node-fetch';
import fs from 'node:fs';
import os from 'node:os';
@@ -16,6 +18,7 @@ import stream from 'stream';
import { promisify } from 'util';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
import { formatBytes } from '../../utils/artifacts';
import { decompressTarAsync, isFileTarGzAsync } from '../../utils/files';
import { retryOnDNSFailure } from '../../utils/retryOnDNSFailure';
@@ -23,7 +26,21 @@ import { pluralize } from '../../utils/strings';
const streamPipeline = promisify(stream.pipeline);
export function createDownloadBuildFunction(): BuildFunction {
const BUILD_BY_ID_QUERY = graphql(`
query DownloadBuildByIdQuery($buildId: ID!) {
builds {
byId(buildId: $buildId) {
id
platform
artifacts {
applicationArchiveUrl
}
}
}
}
`);
export function createDownloadBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'download_build',
@@ -59,7 +76,7 @@ export function createDownloadBuildFunction(): BuildFunction {
const { artifactPath } = await downloadBuildAsync({
logger,
buildId,
expoApiServerURL: stepsCtx.global.staticContext.expoApiServerURL,
graphqlClient: ctx.graphqlClient,
robotAccessToken: stepsCtx.global.staticContext.job.secrets?.robotAccessToken ?? null,
extensions,
});
@@ -69,16 +86,45 @@ export function createDownloadBuildFunction(): BuildFunction {
});
}
async function fetchApplicationArchiveUrlAsync({
buildId,
graphqlClient,
}: {
buildId: string;
graphqlClient: Client;
}): Promise<string> {
const result = await graphqlClient.query(BUILD_BY_ID_QUERY, { buildId }).toPromise();
if (result.error) {
const { error } = result;
const message = `Could not fetch build ${buildId}: ${error.message}`;
throw error.networkError || result.error.response?.status >= 500
? new SystemError(message, { cause: error })
: new UserError('EAS_DOWNLOAD_BUILD_FETCH_FAILED', message, { cause: error });
}
const applicationArchiveUrl = result.data?.builds.byId?.artifacts?.applicationArchiveUrl;
if (!applicationArchiveUrl) {
throw new UserError(
'EAS_DOWNLOAD_BUILD_NO_APPLICATION_ARCHIVE',
'Build does not have an application archive url'
);
}
return applicationArchiveUrl;
}
export async function downloadBuildAsync({
logger,
buildId,
expoApiServerURL,
graphqlClient,
robotAccessToken,
extensions,
}: {
logger: bunyan;
buildId: string;
expoApiServerURL: string;
graphqlClient: Client;
robotAccessToken: string | null;
extensions: string[];
}): Promise<{ artifactPath: string }> {
@@ -86,12 +132,11 @@ export async function downloadBuildAsync({
path.join(os.tmpdir(), 'download_build-downloaded-')
);
const response = await retryOnDNSFailure(fetch)(
new URL(`/v2/artifacts/eas/${buildId}`, expoApiServerURL),
{
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
}
);
const downloadUrl = await fetchApplicationArchiveUrlAsync({ buildId, graphqlClient });
const response = await retryOnDNSFailure(fetch)(downloadUrl, {
headers: robotAccessToken ? { Authorization: `Bearer ${robotAccessToken}` } : undefined,
});
if (!response.ok) {
const textResult = await asyncResult(response.text());
@@ -26,14 +26,14 @@ export function createFindAndUploadBuildArtifactsBuildFunction(
try {
await uploadApplicationArchivesAsync({ ctx, stepCtx });
} catch (err: unknown) {
stepCtx.logger.error(`Failed to upload application archives.`, err);
stepCtx.logger.error({ err }, `Failed to upload application archives.`);
firstError ||= err;
}
try {
await uploadBuildArtifacts({ ctx, stepCtx });
} catch (err: unknown) {
stepCtx.logger.error(`Failed to upload build artifacts.`, err);
stepCtx.logger.error({ err }, `Failed to upload build artifacts.`);
firstError ||= err;
}
@@ -1,3 +1,4 @@
import { asyncResult } from '@expo/results';
import { XMLBuilder, XMLParser, XMLValidator } from 'fast-xml-parser';
import fs from 'fs/promises';
import path from 'path';
@@ -18,6 +19,7 @@ const ATTEMPT_PATTERN = /attempt-(\d+)/;
export interface JUnitTestCaseResult {
name: string;
file: string | undefined;
status: 'passed' | 'failed';
duration: number; // milliseconds
errorMessage: string | null;
@@ -32,6 +34,12 @@ const xmlParser = new XMLParser({
isArray: name => ['testsuite', 'testcase', 'property'].includes(name),
});
// A `file=` attribute counts as present only when it is a non-empty string.
function fileAttrOf(tc: any): string | undefined {
const f = tc?.['@_file'];
return typeof f === 'string' && f.length > 0 ? f : undefined;
}
function parseJUnitContent(content: string): JUnitTestCaseResult[] {
const results: JUnitTestCaseResult[] = [];
try {
@@ -54,6 +62,8 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
continue;
}
const file = fileAttrOf(tc);
const timeStr = tc['@_time'];
const timeSeconds = timeStr ? parseFloat(timeStr) : 0;
const duration = Number.isFinite(timeSeconds) ? Math.round(timeSeconds * 1000) : 0;
@@ -94,7 +104,7 @@ function parseJUnitContent(content: string): JUnitTestCaseResult[] {
: [];
delete properties['tags'];
results.push({ name, status, duration, errorMessage, tags, properties });
results.push({ name, file, status, duration, errorMessage, tags, properties });
}
}
} catch {
@@ -131,6 +141,142 @@ export async function parseJUnitTestCases(junitDirectory: string): Promise<JUnit
return perFile.flat();
}
// ---------------------------------------------------------------------------
// Maestro >= 2.6.0 code path: every <testcase> carries a `file=` attribute with
// its flow's own path (all-or-none per run). Callers check the report first
// (isFileAttrRun / junitFileHasFileAttrs) and route here; the legacy name→path
// scan is then never consulted.
// ---------------------------------------------------------------------------
export function isFileAttrRun(
testcases: JUnitTestCaseResult[]
): testcases is (JUnitTestCaseResult & { file: string })[] {
return testcases.length > 0 && testcases.every(tc => tc.file !== undefined);
}
// Unreadable or attribute-less reports ⇒ false: the caller then takes the
// legacy path, whose own guards degrade safely.
export async function junitFileHasFileAttrs(junitFile: string): Promise<boolean> {
return isFileAttrRun(await parseJUnitFile(junitFile));
}
async function fileExists(absPath: string): Promise<boolean> {
return (await asyncResult(fs.stat(absPath))).ok;
}
// Group by `file=` so two same-named flows in different files stay separate.
export async function parseMaestroResultsFromFileAttrs(
junitDirectory: string
): Promise<MaestroFlowResult[]> {
interface JUnitEntry {
result: JUnitTestCaseResult;
sourceFile: string;
}
let junitEntries: string[];
try {
junitEntries = await fs.readdir(junitDirectory);
} catch {
return [];
}
const xmlFiles = junitEntries.filter(f => f.endsWith('.xml')).sort();
const entries: JUnitEntry[] = [];
for (const xmlFile of xmlFiles) {
const fileResults = await parseJUnitFile(path.join(junitDirectory, xmlFile));
for (const result of fileResults) {
entries.push({ result, sourceFile: xmlFile });
}
}
const byPath = new Map<string, JUnitEntry[]>();
for (const entry of entries) {
// Callers verify the report with isFileAttrRun/junitFileHasFileAttrs first;
// skip (rather than emit an undefined path) if that contract is violated.
const flowPath = entry.result.file;
if (flowPath === undefined) {
continue;
}
const group = byPath.get(flowPath) ?? [];
group.push(entry);
byPath.set(flowPath, group);
}
const results: MaestroFlowResult[] = [];
for (const [flowPath, group] of byPath) {
// retryCount is derived from each entry's source filename (`attempt-N`);
// a single entry from `report.xml` (no marker) collapses to attempt 0.
const sorted = group
.map(entry => {
const match = entry.sourceFile.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
return { ...entry, attemptIndex };
})
.sort((a, b) => a.attemptIndex - b.attemptIndex);
for (const { result, attemptIndex } of sorted) {
results.push({
name: result.name,
path: flowPath,
status: result.status,
errorMessage: result.errorMessage,
duration: result.duration,
retryCount: attemptIndex,
tags: result.tags,
properties: result.properties,
});
}
}
return results;
}
/**
* Returns the `file=` paths of the failing testcases in the given attempt's
* JUnit file, or `null` when the result cannot be trusted (caller then falls
* back to dumb retry — re-run everything; never the legacy scan).
*
* Callers verify the report carries `file=` attributes first
* (junitFileHasFileAttrs).
*/
export async function parseFailedFlowsFromFileAttrs(args: {
junitFile: string;
workingDirectory: string;
}): Promise<string[] | null> {
// Same hardening as the legacy parser: fast-xml-parser is lenient — a
// truncated XML could drop testcases, and trusting that partial failing set
// would skip retries for cut-off flows.
let content: string;
try {
content = await fs.readFile(args.junitFile, 'utf-8');
} catch {
return null;
}
if (XMLValidator.validate(content) !== true) {
return null;
}
const testcases = parseJUnitContent(content);
if (!isFileAttrRun(testcases)) {
return null;
}
const failing = testcases.filter(tc => tc.status === 'failed');
if (failing.length === 0) {
return [];
}
// Each failing testcase is self-identifying via `file=`, so duplicate flow
// names retry correctly. path.resolve (not join): `file=` may be absolute
// when the flow lives outside the working directory.
const paths = [...new Set(failing.map(tc => tc.file))];
for (const p of paths) {
if (!(await fileExists(path.resolve(args.workingDirectory, p)))) {
return null;
}
}
return paths;
}
export async function parseMaestroResults(
junitDirectory: string,
nameToPath: Map<string, string> | null
@@ -313,6 +459,13 @@ export async function mergeJUnitReports(args: {
}
const match = filename.match(ATTEMPT_PATTERN);
const attemptIndex = match ? parseInt(match[1], 10) : 0;
// Maestro >= 2.6.0 reports: key by `file=` so two same-named flows in
// different files merge separately. Legacy reports keep name keying.
const allCases = testsuites.flatMap((suite: any) =>
Array.isArray(suite?.testcase) ? suite.testcase : []
);
const useFileKeys =
allCases.length > 0 && allCases.every((tc: any) => fileAttrOf(tc) !== undefined);
const testcasesByName = new Map<string, unknown[]>();
for (const suite of testsuites) {
const cases = suite?.testcase;
@@ -324,9 +477,10 @@ export async function mergeJUnitReports(args: {
if (typeof name !== 'string') {
continue;
}
const group = testcasesByName.get(name) ?? [];
const key = useFileKeys ? fileAttrOf(tc)! : name;
const group = testcasesByName.get(key) ?? [];
group.push(tc);
testcasesByName.set(name, group);
testcasesByName.set(key, group);
}
}
if (testcasesByName.size === 0) {
@@ -14,7 +14,9 @@ import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import {
copyLatestAttemptXml,
junitFileHasFileAttrs,
mergeJUnitReports,
parseFailedFlowsFromFileAttrs,
parseFailedFlowsFromJUnit,
} from './maestroResultParser';
import { sleepAsync } from '../../utils/retry';
@@ -200,13 +202,10 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
throw new SystemError('Failed to create JUnit report directory', { cause: err });
}
// null → duplicate flow names; retry-failed-only disabled, fall through to
// dumb retry (re-run everything) on failure.
const nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: flowPaths,
projectRoot: stepCtx.workingDirectory,
logger,
});
// Legacy-only (Maestro < 2.6.0 reports carry no `file=` attribute): the
// flow scan is built lazily in the retry branch below and memoized so
// retries share one scan. Never runs when the report has `file=`.
let nameToPathPromise: Promise<Map<string, string> | null> | undefined;
// Retry loop. spawn-async error shapes:
// ENOENT/EACCES → infra (binary missing/not executable) → SystemError.
@@ -214,7 +213,7 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
// else (signal-only, OOM kill, unknown) → infra → SystemError, never
// downgraded to "tests failed".
// Retry-failed-only (junit mode): after a failed attempt, subset to the failing
// flows. parseFailedFlowsFromJUnit returns null when the JUnit cannot be
// flows. The failed-flow parsers return null when the JUnit cannot be
// trusted; we then fall through to dumb retry (re-run everything).
let flowsToRun: string[] = flowPaths;
let lastAttemptExitCode: number | null = null;
@@ -263,11 +262,26 @@ export function createMaestroTestsBuildFunction(): BuildFunction {
break;
}
if (retryFailedOnly && outputFormat === 'junit' && outputPath && nameToPath) {
const failed = await parseFailedFlowsFromJUnit({
junitFile: outputPath,
nameToPath,
});
if (retryFailedOnly && outputFormat === 'junit' && outputPath) {
let failed: string[] | null;
if (await junitFileHasFileAttrs(outputPath)) {
failed = await parseFailedFlowsFromFileAttrs({
junitFile: outputPath,
workingDirectory: stepCtx.workingDirectory,
});
} else {
// Legacy (Maestro < 2.6.0): map failed testcase names back to flow
// paths via the flow-file scan. DELETE this arm once the fleet is
// on >= 2.6.0.
const nameToPath = await (nameToPathPromise ??= buildFlowNameToPathMap({
inputFlowPaths: flowPaths,
projectRoot: stepCtx.workingDirectory,
logger,
}));
failed = nameToPath
? await parseFailedFlowsFromJUnit({ junitFile: outputPath, nameToPath })
: null;
}
if (failed !== null && failed.length > 0) {
flowsToRun = failed;
logger.info(
@@ -3,7 +3,13 @@ import { graphql } from 'gql.tada';
import { z } from 'zod';
import { buildFlowNameToPathMap } from './maestroFlowDiscovery';
import { MaestroFlowResult, parseMaestroResults } from './maestroResultParser';
import {
MaestroFlowResult,
isFileAttrRun,
parseJUnitTestCases,
parseMaestroResults,
parseMaestroResultsFromFileAttrs,
} from './maestroResultParser';
import { CustomBuildContext } from '../../customBuildContext';
const FlowPathSchema = z.array(z.string().min(1)).min(1);
@@ -61,47 +67,64 @@ export function createReportMaestroTestResultsFunction(ctx: CustomBuildContext):
return;
}
const flowPathRaw = inputs.flow_path.value;
let nameToPath: Map<string, string> | null = null;
if (flowPathRaw !== undefined) {
const parsed = FlowPathSchema.safeParse(flowPathRaw);
if (parsed.success) {
nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: parsed.data,
projectRoot: stepsCtx.workingDirectory,
logger,
});
} else {
logger.warn(
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
);
}
}
try {
const flowResults = await parseMaestroResults(junitDirectory, nameToPath);
// Maestro >= 2.6.0 stamps every <testcase> with its flow's path in a
// `file=` attribute; older reports need the legacy flow-file scan to
// map testcase names back to paths.
const usedFileAttrs = isFileAttrRun(await parseJUnitTestCases(junitDirectory));
let flowResults: MaestroFlowResult[];
if (usedFileAttrs) {
flowResults = await parseMaestroResultsFromFileAttrs(junitDirectory);
} else {
// Legacy (Maestro < 2.6.0) — DELETE this arm once the fleet is on >= 2.6.0.
const flowPathRaw = inputs.flow_path.value;
let nameToPath: Map<string, string> | null = null;
if (flowPathRaw !== undefined) {
const parsed = FlowPathSchema.safeParse(flowPathRaw);
if (parsed.success) {
nameToPath = await buildFlowNameToPathMap({
inputFlowPaths: parsed.data,
projectRoot: stepsCtx.workingDirectory,
logger,
});
} else {
logger.warn(
'Ignoring malformed flow_path input (expected a non-empty array of non-empty strings).'
);
}
}
flowResults = await parseMaestroResults(junitDirectory, nameToPath);
}
if (flowResults.length === 0) {
logger.info('No maestro test results found, skipping report');
return;
}
// Detect truly conflicting results: same (name, retryCount) pair means different flow files
// share the same name (Maestro config override), which we can't disambiguate.
// Same name with different retryCount is expected (per-attempt results from retries).
// Detect truly conflicting results: the same (path, retryCount) twice means two
// flow files resolved to the same path, which we can't disambiguate and the API
// rejects as a duplicate. Same path with different retryCount is expected
// (per-attempt results from retries).
const seen = new Set<string>();
const conflicting = new Set<string>();
for (const r of flowResults) {
const key = `${r.name}:${r.retryCount}`;
const key = `${r.path}:${r.retryCount}`;
if (seen.has(key)) {
conflicting.add(r.name);
conflicting.add(r.path);
}
seen.add(key);
}
if (conflicting.size > 0) {
const conflictList = [...conflicting].join(', ');
logger.error(
`Duplicate test case names found in JUnit output: ${[...conflicting].join(
', '
)}. Skipping report. Ensure each Maestro flow has a unique name.`
usedFileAttrs
? `The same flow file was reported more than once in a single attempt: ` +
`${conflictList}. Skipping report. Check for duplicate flow_path entries ` +
`or leftover XML files in the JUnit report directory.`
: `Duplicate Maestro flow names found: ${conflictList}. Skipping report. ` +
`Give each flow a unique name, or upgrade Maestro to >= 2.6.0 (flows are ` +
`then identified by file path, so duplicate names are fine).`
);
return;
}
@@ -206,16 +206,16 @@ export async function restoreGradleCacheAsync({
}
try {
const gradlePropertiesPath = path.join(workingDirectory, 'android', 'gradle.properties');
const gradlePropertiesContent = await fs.promises.readFile(gradlePropertiesPath, 'utf-8');
await fs.promises.writeFile(
gradlePropertiesPath,
`${gradlePropertiesContent}\n\norg.gradle.caching=true\n`
);
// Enable build cache via user-level gradle.properties.
// This avoids mutating android/gradle.properties which affects fingerprinting.
const gradleUserHome = path.join(os.homedir(), '.gradle');
await fs.promises.mkdir(gradleUserHome, { recursive: true });
const userGradlePropertiesPath = path.join(gradleUserHome, 'gradle.properties');
await fs.promises.appendFile(userGradlePropertiesPath, '\norg.gradle.caching=true\n');
// Configure cache cleanup via init script (works with both Gradle 8 and 9,
// org.gradle.cache.cleanup property was removed in Gradle 9)
const initScriptDir = path.join(os.homedir(), '.gradle', 'init.d');
// Configure cache cleanup via init script.
// Works with both Gradle 8 and 9 (org.gradle.cache.cleanup property was removed in Gradle 9).
const initScriptDir = path.join(gradleUserHome, 'init.d');
await fs.promises.mkdir(initScriptDir, { recursive: true });
await fs.promises.writeFile(
path.join(initScriptDir, 'eas-cache-cleanup.gradle'),
@@ -90,7 +90,7 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
// on Darwin. Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const { previewUrl } = await startServeSimWithTunnelAsync({
const { previewUrl } = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
@@ -101,7 +101,7 @@ export function createStartArgentRemoteSessionBuildFunction(
// serve-sim is iOS-only — Android sessions go without a preview URL.
let webPreviewUrl: string | undefined;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
const serveSim = await startServeSimWithTunnelAsync({
const serveSim = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
@@ -30,7 +30,7 @@ export function createStartServeSimRemoteSessionBuildFunction(
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync({
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
@@ -128,7 +128,7 @@ export function createUploadArtifactBuildFunction(ctx: CustomBuildContext): Buil
}
} catch (error) {
if (inputs.ignore_error.value) {
logger.error(`Failed to upload ${artifact.type}. Ignoring error.`, error);
logger.error({ err: error }, `Failed to upload ${artifact.type}. Ignoring error.`);
// Ignoring error.
return;
}
@@ -0,0 +1,154 @@
import { bunyan } from '@expo/logger';
import { BuildStepEnv } from '@expo/steps';
import { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
fetchServeSimTurnArgsAsync,
turnIceServersToServeSimArgs,
} from '../remoteDeviceRunSession';
jest.mock('../../../utils/turtleFetch');
jest.mock('../../../sentry');
function createLoggerMock(): bunyan {
return {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
} as unknown as bunyan;
}
function createCtxMock(): CustomBuildContext {
return {
env: {
__API_SERVER_URL: 'https://api.expo.test',
},
job: {
secrets: { robotAccessToken: 'robot-token' },
},
} as unknown as CustomBuildContext;
}
function createEnvMock(): BuildStepEnv {
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
}
describe(turnIceServersToServeSimArgs, () => {
it('returns no args for an empty ICE server list', () => {
expect(turnIceServersToServeSimArgs([])).toEqual([]);
});
it('builds --stun-url and --turn-url flags from Cloudflare ICE servers', () => {
expect(
turnIceServersToServeSimArgs([
{ urls: ['stun:stun.cloudflare.com:3478', 'stun:stun.cloudflare.com:53'] },
{
urls: [
'turn:turn.cloudflare.com:3478?transport=udp',
'turns:turn.cloudflare.com:443?transport=tcp',
],
username: 'user-123',
credential: 'cred-456',
},
])
).toEqual([
'--stun-url',
'stun:stun.cloudflare.com:3478,stun:stun.cloudflare.com:53',
'--turn-url',
'turn:turn.cloudflare.com:3478?transport=udp,turns:turn.cloudflare.com:443?transport=tcp',
'--turn-username',
'user-123',
'--turn-credential',
'cred-456',
]);
});
it('emits only --turn-url flags when no credential-less (STUN) entry is present', () => {
expect(
turnIceServersToServeSimArgs([
{
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
username: 'u',
credential: 'c',
},
])
).toEqual([
'--turn-url',
'turns:turn.cloudflare.com:443?transport=tcp',
'--turn-username',
'u',
'--turn-credential',
'c',
]);
});
it('emits only --stun-url when there is no credentialed TURN entry', () => {
expect(turnIceServersToServeSimArgs([{ urls: ['stun:stun.cloudflare.com:3478'] }])).toEqual([
'--stun-url',
'stun:stun.cloudflare.com:3478',
]);
});
});
describe(fetchServeSimTurnArgsAsync, () => {
beforeEach(() => {
jest.mocked(turtleFetch).mockReset();
});
it('requests TURN ICE servers from the device run session endpoint and returns serve-sim args', async () => {
jest.mocked(turtleFetch).mockResolvedValue({
json: async () => ({
data: {
iceServers: [
{ urls: ['stun:stun.cloudflare.com:3478'] },
{
urls: ['turns:turn.cloudflare.com:443?transport=tcp'],
username: 'u',
credential: 'c',
},
],
},
}),
} as unknown as Awaited<ReturnType<typeof turtleFetch>>);
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger: createLoggerMock(),
});
expect(args).toEqual([
'--stun-url',
'stun:stun.cloudflare.com:3478',
'--turn-url',
'turns:turn.cloudflare.com:443?transport=tcp',
'--turn-username',
'u',
'--turn-credential',
'c',
]);
expect(jest.mocked(turtleFetch)).toHaveBeenCalledWith(
'https://api.expo.test/v2/device-run-sessions/drs-id/turn-ice-servers',
'POST',
expect.objectContaining({
headers: { Authorization: 'Bearer robot-token' },
})
);
});
it('returns [] and warns when the request fails so serve-sim falls back to P2P/STUN', async () => {
jest.mocked(turtleFetch).mockRejectedValue(new Error('boom'));
const logger = createLoggerMock();
const args = await fetchServeSimTurnArgsAsync(createCtxMock(), {
env: createEnvMock(),
logger,
});
expect(args).toEqual([]);
expect(logger.warn).toHaveBeenCalled();
expect(jest.mocked(Sentry).capture).toHaveBeenCalled();
});
});
@@ -4,11 +4,15 @@ import { BuildStepEnv } from '@expo/steps';
import spawn from '@expo/turtle-spawn';
import * as ngrok from '@ngrok/ngrok';
import { graphql } from 'gql.tada';
import nullthrows from 'nullthrows';
import { z } from 'zod';
import { randomBytes } from 'node:crypto';
import fs from 'node:fs';
import { CustomBuildContext } from '../../customBuildContext';
import { Sentry } from '../../sentry';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
@@ -60,6 +64,105 @@ export function getNgrokAuthtokenOrThrow(env: BuildStepEnv): string {
return authtoken;
}
const TurnIceServersSchema = z.array(
z.object({
urls: z.array(z.string()),
username: z.string().optional(),
credential: z.string().optional(),
})
);
export type TurnIceServers = z.infer<typeof TurnIceServersSchema>;
const TurnIceServersResponseSchema = z.object({
data: z.object({
iceServers: TurnIceServersSchema,
}),
});
/**
* Translate Cloudflare ICE servers into serve-sim CLI flags: `--stun-url` (the
* credential-less entries) and `--turn-url`/`--turn-username`/`--turn-credential`
* (the entry carrying the short-lived credentials).
*/
export function turnIceServersToServeSimArgs(iceServers: TurnIceServers): string[] {
const stunUrls = iceServers
.filter(server => !server.username && !server.credential)
.flatMap(server => server.urls);
const turnServer = iceServers.find(server => server.username && server.credential);
const args: string[] = [];
if (stunUrls.length > 0) {
args.push('--stun-url', stunUrls.join(','));
}
if (turnServer?.username && turnServer.credential && turnServer.urls.length > 0) {
args.push(
'--turn-url',
turnServer.urls.join(','),
'--turn-username',
turnServer.username,
'--turn-credential',
turnServer.credential
);
}
return args;
}
/**
* Fetch short-lived Cloudflare TURN ICE servers for this job run from www
* (minted on demand, mirroring how the worker fetches project clone URLs) and
* translate them into serve-sim CLI flags.
*
* Best-effort: on any failure we log and return [] so serve-sim falls back to
* its built-in P2P/STUN behavior. The credential is passed to serve-sim as a
* process arg and deliberately not logged (turtle-spawn never logs argv and the
* worker is single-tenant).
*/
export async function fetchServeSimTurnArgsAsync(
ctx: CustomBuildContext,
{ env, logger }: { env: BuildStepEnv; logger: bunyan }
): Promise<string[]> {
try {
const deviceRunSessionId = getDeviceRunSessionIdOrThrow(env);
const expoApiServerUrl = nullthrows(ctx.env.__API_SERVER_URL, '__API_SERVER_URL is not set');
const robotAccessToken = nullthrows(
ctx.job.secrets?.robotAccessToken,
'robot access token is not set'
);
const response = await turtleFetch(
new URL(
`/v2/device-run-sessions/${deviceRunSessionId}/turn-ice-servers`,
expoApiServerUrl
).toString(),
'POST',
{
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
timeout: 5000,
retries: 1,
logger,
}
);
const { data } = TurnIceServersResponseSchema.parse(await response.json());
const args = turnIceServersToServeSimArgs(data.iceServers);
if (args.length > 0) {
logger.info('Configured serve-sim with Cloudflare TURN ICE servers.');
}
return args;
} catch (err) {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not fetch Cloudflare TURN ICE servers', error, { level: 'warning' });
logger.warn(
{ err: error },
'Could not fetch Cloudflare TURN ICE servers; serve-sim will fall back to P2P/STUN.'
);
return [];
}
}
export async function uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
@@ -120,18 +223,22 @@ export function spawnDetached({
return { getOutput: () => output };
}
export async function startServeSimWithTunnelAsync({
baseDomain,
env,
logger,
timeoutMs,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
}): Promise<{ previewUrl: string; streamUrl: string }> {
export async function startServeSimWithTunnelAsync(
ctx: CustomBuildContext,
{
baseDomain,
env,
logger,
timeoutMs,
}: {
baseDomain: string;
env: BuildStepEnv;
logger: bunyan;
timeoutMs: number;
}
): Promise<{ previewUrl: string; streamUrl: string }> {
logger.info('Launching serve-sim with tunnel.');
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
const serveSim = spawnDetached({
command: 'npx',
args: [
@@ -147,6 +254,7 @@ export async function startServeSimWithTunnelAsync({
'0.55',
'--codec',
'webrtc',
...turnArgs,
],
env,
});
+1394 -16
View File
File diff suppressed because it is too large. Load diff
@@ -62,6 +62,11 @@ export default class SimulatorStart extends EasCommand {
description:
'Version of the package backing the device run session (e.g. "0.1.3-alpha.3"). Defaults to "latest" when omitted.',
}),
'max-duration-minutes': Flags.integer({
description:
'Maximum duration of the device run session in minutes before it is automatically stopped. Only customizable on paid plans. Defaults to a value derived from the job run priority when omitted.',
min: 0,
}),
force: Flags.boolean({
description:
'[default: true] Create a new device session even when an existing simulator session is present in the environment.',
@@ -125,6 +130,7 @@ export default class SimulatorStart extends EasCommand {
platform,
type: DEVICE_RUN_SESSION_TYPE_BY_FLAG_VALUE[flags.type],
packageVersion: flags['package-version'],
maxRunTimeMinutes: flags['max-duration-minutes'],
});
deviceRunSessionId = session.id;
const jobRunId = nullthrows(session.turtleJobRun?.id, 'Expected device run session to start');
@@ -5,7 +5,9 @@ import path from 'path';
import { UserRole } from '@expo/apple-utils';
import { formatAppleTeam } from './AppleTeamFormatting';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AccountFragment, AppStoreConnectApiKeyFragment } from '../../../graphql/generated';
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
import Log, { learnMore } from '../../../log';
import { confirmAsync, promptAsync, selectAsync } from '../../../prompts';
import { fromNow } from '../../../utils/date';
@@ -14,7 +16,11 @@ import {
getCredentialsFromUserAsync,
shouldAutoGenerateCredentialsAsync,
} from '../../utils/promptForCredentials';
import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { AscApiKey } from '../appstore/Credentials.types';
import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes';
import { hasAscEnvVars } from '../appstore/resolveCredentials';
import {
AscApiKeyPath,
MinimalAscApiKey,
@@ -277,3 +283,78 @@ export function formatAscApiKey(ascApiKey: AppStoreConnectApiKeyFragment): strin
line += chalk.gray(`\n Updated: ${fromNow(new Date(updatedAt))} ago`);
return line;
}
export async function resolveAscApiKeyForAppCredentialsAsync({
graphqlClient,
app,
}: {
graphqlClient: ExpoGraphqlClient;
app: AppLookupParams;
}): Promise<{
ascApiKey: MinimalAscApiKey;
teamId?: string;
teamName?: string;
} | null> {
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, app);
if (!ascKeyFragment) {
return null;
}
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, ascKeyFragment.id);
return {
ascApiKey: {
keyP8: fullKey.keyP8,
keyId: fullKey.keyIdentifier,
issuerId: fullKey.issuerIdentifier,
},
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
};
}
/**
* Best-effort helper that populates `ctx.appStore.authCtx` in non-interactive mode
* by loading an App Store Connect API key (from env vars, or by fetching the app's
* stored key from the www GraphQL API).
*
* Returns true if `ctx.appStore.authCtx` is set after the call, false otherwise.
* Never throws.
*/
export async function tryAuthenticateAppStoreWithEasAscApiKeyAsync(
ctx: CredentialsContext,
app: AppLookupParams,
teamType: AppleTeamType
): Promise<boolean> {
if (ctx.appStore.authCtx) {
return true;
}
try {
if (hasAscEnvVars()) {
await ctx.appStore.ensureAuthenticatedAsync({
mode: AuthenticationMode.API_KEY,
teamType,
});
return !!ctx.appStore.authCtx;
}
const resolvedKey = await resolveAscApiKeyForAppCredentialsAsync({
graphqlClient: ctx.graphqlClient,
app,
});
if (!resolvedKey) {
return false;
}
Log.log('Using App Store Connect API Key from EAS credentials service.');
await ctx.appStore.ensureAuthenticatedAsync({
mode: AuthenticationMode.API_KEY,
ascApiKey: resolvedKey.ascApiKey,
teamId: resolvedKey.teamId,
teamName: resolvedKey.teamName,
teamType,
});
return !!ctx.appStore.authCtx;
} catch (err: any) {
Log.warn(
`Failed to authenticate with the App Store Connect API key from EAS credentials service: ${err.message ?? err}`
);
return false;
}
}
@@ -16,7 +16,7 @@ import {
import { AppleProvisioningProfileMutationResult } from '../api/graphql/mutations/AppleProvisioningProfileMutation';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { ProvisioningProfileStoreInfo } from '../appstore/Credentials.types';
import { AuthCtx, AuthenticationMode } from '../appstore/authenticateTypes';
import { AuthCtx } from '../appstore/authenticateTypes';
import { Target } from '../types';
export class ConfigureProvisioningProfile {
@@ -34,12 +34,9 @@ export class ConfigureProvisioningProfile {
throw new ForbidCredentialModificationError(
'Remove the --freeze-credentials flag to configure a Provisioning Profile.'
);
} else if (
ctx.nonInteractive &&
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
) {
} else if (ctx.nonInteractive && !ctx.appStore.authCtx) {
throw new InsufficientAuthenticationNonInteractiveError(
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for submissions for bundle identifier ${this.app.bundleIdentifier} on EAS. ${learnMore(
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
)}`
);
@@ -14,7 +14,7 @@ import { askForUserProvidedAsync } from '../../utils/promptForCredentials';
import { AppleProvisioningProfileMutationResult } from '../api/graphql/mutations/AppleProvisioningProfileMutation';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { ProvisioningProfile } from '../appstore/Credentials.types';
import { AuthCtx, AuthenticationMode } from '../appstore/authenticateTypes';
import { AuthCtx } from '../appstore/authenticateTypes';
import { provisioningProfileSchema } from '../credentials';
import { Target } from '../types';
@@ -30,12 +30,9 @@ export class CreateProvisioningProfile {
throw new ForbidCredentialModificationError(
'Run this command again without the --freeze-credentials flag in order to generate a new Provisioning Profile.'
);
} else if (
ctx.nonInteractive &&
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
) {
} else if (ctx.nonInteractive && !ctx.appStore.authCtx) {
throw new InsufficientAuthenticationNonInteractiveError(
`In order to generate a new Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
`In order to generate a new Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for submissions for bundle identifier ${this.app.bundleIdentifier} on EAS. ${learnMore(
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
)}`
);
@@ -11,6 +11,7 @@ import {
filterDevicesForApplePlatform,
formatDeviceLabel,
} from './DeviceUtils';
import { resolveAscApiKeyForAppCredentialsAsync } from './AscApiKeyUtils';
import { SetUpDistributionCertificate } from './SetUpDistributionCertificate';
import DeviceCreateAction, { RegistrationMethod } from '../../../devices/actions/create/action';
import {
@@ -22,8 +23,6 @@ import {
IosAppBuildCredentialsFragment,
IosDistributionType,
} from '../../../graphql/generated';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppStoreConnectApiKeyQuery } from '../../../graphql/queries/AppStoreConnectApiKeyQuery';
import Log from '../../../log';
import { getApplePlatformFromTarget } from '../../../project/ios/target';
import {
@@ -35,13 +34,11 @@ import {
import differenceBy from '../../../utils/expodash/differenceBy';
import { CredentialsContext } from '../../context';
import { MissingCredentialsNonInteractiveError } from '../../errors';
import { getAscApiKeyForAppSubmissionsAsync } from '../api/GraphqlClient';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { AppleTeamType, AuthenticationMode } from '../appstore/authenticateTypes';
import { ProvisioningProfile } from '../appstore/Credentials.types';
import { ApplePlatform } from '../appstore/constants';
import { hasAscEnvVars } from '../appstore/resolveCredentials';
import { MinimalAscApiKey } from '../credentials';
import { Target } from '../types';
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';
@@ -424,6 +421,7 @@ export class SetUpAdhocProvisioningProfile {
);
}
Log.log('Using App Store Connect API Key from EAS credentials service.');
await ctx.appStore.ensureAuthenticatedAsync({
mode: AuthenticationMode.API_KEY,
ascApiKey: resolvedKey.ascApiKey,
@@ -436,35 +434,6 @@ export class SetUpAdhocProvisioningProfile {
}
}
async function resolveAscApiKeyForAppCredentialsAsync({
graphqlClient,
app,
}: {
graphqlClient: ExpoGraphqlClient;
app: AppLookupParams;
}): Promise<{
ascApiKey: MinimalAscApiKey;
teamId?: string;
teamName?: string;
} | null> {
const ascKeyFragment = await getAscApiKeyForAppSubmissionsAsync(graphqlClient, app);
if (!ascKeyFragment) {
return null;
}
Log.log('Using App Store Connect API Key from EAS credentials service.');
const fullKey = await AppStoreConnectApiKeyQuery.getByIdAsync(graphqlClient, ascKeyFragment.id);
return {
ascApiKey: {
keyP8: fullKey.keyP8,
keyId: fullKey.keyIdentifier,
issuerId: fullKey.issuerIdentifier,
},
teamId: ascKeyFragment.appleTeam?.appleTeamIdentifier,
teamName: ascKeyFragment.appleTeam?.appleTeamName ?? undefined,
};
}
export function doUDIDsMatch(udidsA: string[], udidsB: string[]): boolean {
const setA = new Set(udidsA);
const setB = new Set(udidsB);
@@ -1,5 +1,6 @@
import nullthrows from 'nullthrows';
import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } from './AscApiKeyUtils';
import {
assignBuildCredentialsAsync,
getBuildCredentialsAsync,
@@ -15,7 +16,7 @@ import {
IosAppBuildCredentialsFragment,
IosDistributionType,
} from '../../../graphql/generated';
import { learnMore } from '../../../log';
import Log, { learnMore } from '../../../log';
import { getApplePlatformFromTarget } from '../../../project/ios/target';
import { confirmAsync } from '../../../prompts';
import { CredentialsContext } from '../../context';
@@ -25,7 +26,8 @@ import {
} from '../../errors';
import { AppLookupParams } from '../api/graphql/types/AppLookupParams';
import { ProvisioningProfileStoreInfo } from '../appstore/Credentials.types';
import { AuthenticationMode } from '../appstore/authenticateTypes';
import { resolveAppleTeamTypeFromEnvironment } from '../appstore/resolveCredentials';
import { AppleTeamType } from '../appstore/authenticateTypes';
import { Target } from '../types';
import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile';
@@ -102,8 +104,28 @@ export class SetUpProvisioningProfile {
this.app,
this.distributionType
).runAsync(ctx);
if (ctx.nonInteractive && !ctx.appStore.authCtx) {
await tryAuthenticateAppStoreWithEasAscApiKeyAsync(
ctx,
this.app,
this.resolveTeamTypeForAuthentication()
);
}
const areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
let areBuildCredentialsSetup: boolean;
try {
areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx);
} catch (error: any) {
if (ctx.nonInteractive) {
Log.warn(
'Skipping Provisioning Profile validation on Apple servers due to an unexpected validation error. Continuing with local validation result.'
);
Log.debug('Provisioning profile validation on Apple servers failed:', error);
areBuildCredentialsSetup = true;
} else {
throw error;
}
}
if (areBuildCredentialsSetup) {
return nullthrows(await getBuildCredentialsAsync(ctx, this.app, this.distributionType));
}
@@ -111,12 +133,11 @@ export class SetUpProvisioningProfile {
throw new ForbidCredentialModificationError(
'Provisioning profile is not configured correctly. Remove the --freeze-credentials flag to configure it.'
);
} else if (
ctx.nonInteractive &&
ctx.appStore.defaultAuthenticationMode !== AuthenticationMode.API_KEY
) {
}
if (ctx.nonInteractive && !ctx.appStore.authCtx) {
throw new InsufficientAuthenticationNonInteractiveError(
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. ${learnMore(
`In order to configure your Provisioning Profile, authentication with an ASC API key is required in non-interactive mode. Either set the EXPO_ASC_API_KEY_PATH/EXPO_ASC_KEY_ID/EXPO_ASC_ISSUER_ID environment variables, or configure an App Store Connect API Key for submissions for bundle identifier ${this.app.bundleIdentifier} on EAS. ${learnMore(
'https://docs.expo.dev/build/building-on-ci/#optional-provide-an-asc-api-token-for-your-apple-team'
)}`
);
@@ -167,6 +188,24 @@ export class SetUpProvisioningProfile {
return updatedProfile;
}
/**
* The team type determines `team.inHouse`, which in turn selects the Apple profile
* type used for every subsequent profile lookup and creation (IOS_APP_INHOUSE for
* enterprise vs IOS_APP_STORE otherwise). We derive it from the distribution
* type, which is exactly what the requested operation needs: enterprise
* builds require an in-house team, other distribution types don't.
* A genuine team/distribution mismatch is rejected by Apple regardless of this value.
*/
private getDerivedTeamTypeForAuthentication(): AppleTeamType {
return this.distributionType === IosDistributionType.Enterprise
? AppleTeamType.IN_HOUSE
: AppleTeamType.COMPANY_OR_ORGANIZATION;
}
private resolveTeamTypeForAuthentication(): AppleTeamType {
return resolveAppleTeamTypeFromEnvironment() ?? this.getDerivedTeamTypeForAuthentication();
}
private getCurrentProfileStoreInfo(
profiles: ProvisioningProfileStoreInfo[],
currentProfile: AppleProvisioningProfileFragment
@@ -19,8 +19,9 @@ import {
ForbidCredentialModificationError,
InsufficientAuthenticationNonInteractiveError,
} from '../../../errors';
import { AuthenticationMode } from '../../appstore/authenticateTypes';
import { AppleTeamType, AuthenticationMode } from '../../appstore/authenticateTypes';
import { validateProvisioningProfileAsync } from '../../validators/validateProvisioningProfile';
import { tryAuthenticateAppStoreWithEasAscApiKeyAsync } from '../AscApiKeyUtils';
import { getAppLookupParamsFromContextAsync } from '../BuildCredentialsUtils';
import { SetUpProvisioningProfile } from '../SetUpProvisioningProfile';
@@ -30,11 +31,15 @@ jest.mock('../SetUpDistributionCertificate');
jest.mock('../ConfigureProvisioningProfile');
jest.mock('../CreateProvisioningProfile');
jest.mock('../../validators/validateProvisioningProfile');
jest.mock('../AscApiKeyUtils');
jest.mock('../../../../graphql/queries/AppQuery');
describe('SetUpProvisioningProfile', () => {
beforeEach(() => {
delete process.env.EXPO_APPLE_TEAM_TYPE;
jest.mocked(AppQuery.byIdAsync).mockResolvedValue(testAppQueryByIdResponse);
jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mockReset();
jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mockResolvedValue(false);
});
const testCases = ['NON_INTERACTIVE', 'INTERACTIVE'];
@@ -262,4 +267,270 @@ describe('SetUpProvisioningProfile', () => {
// expect provisioning profile not to be deleted on expo servers
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
});
it('auto-authenticates before validation in nonInteractive mode when credentials are already valid', async () => {
let authAttemptedBeforeValidation = false;
jest
.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync)
.mockImplementation(async authedCtx => {
authAttemptedBeforeValidation = true;
(authedCtx.appStore as any).authCtx = testAuthCtx;
return true;
});
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => {
expect(authAttemptedBeforeValidation).toBe(true);
return true;
});
const ctx = createCtxMock({
nonInteractive: true,
appStore: {
...getAppstoreMock(),
authCtx: undefined,
},
ios: {
...getNewIosApiMock(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
},
});
const appLookupParams = await getAppLookupParamsFromContextAsync(
ctx,
findApplicationTarget(testTargets)
);
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
appLookupParams,
testTarget,
IosDistributionType.AppStore
);
await setupProvisioningProfileAction.runAsync(ctx);
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([
[ctx, appLookupParams, AppleTeamType.COMPANY_OR_ORGANIZATION],
]);
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
0
);
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
});
it('uses best-effort validation when Apple validation fails in nonInteractive mode', async () => {
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => {
throw new Error('Apple API request failed');
});
const ctx = createCtxMock({
nonInteractive: true,
appStore: {
...getAppstoreMock(),
authCtx: testAuthCtx,
},
ios: {
...getNewIosApiMock(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
},
});
const appLookupParams = await getAppLookupParamsFromContextAsync(
ctx,
findApplicationTarget(testTargets)
);
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
appLookupParams,
testTarget,
IosDistributionType.AppStore
);
await expect(setupProvisioningProfileAction.runAsync(ctx)).resolves.toEqual(
testCommonIosAppCredentialsFragment.iosAppBuildCredentialsList[0]
);
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls.length).toBe(0);
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
0
);
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
});
it('rethrows Apple validation failures in interactive mode', async () => {
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => {
throw new Error('Apple API request failed');
});
const ctx = createCtxMock({
nonInteractive: false,
appStore: {
...getAppstoreMock(),
authCtx: testAuthCtx,
},
ios: {
...getNewIosApiMock(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
},
});
const appLookupParams = await getAppLookupParamsFromContextAsync(
ctx,
findApplicationTarget(testTargets)
);
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
appLookupParams,
testTarget,
IosDistributionType.AppStore
);
await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrow(
'Apple API request failed'
);
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
0
);
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
});
it('repairs the Provisioning Profile in nonInteractive mode after auto-authenticating with the EAS-stored ASC API key', async () => {
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false);
const appstoreMock = getAppstoreMock();
const ctx = createCtxMock({
nonInteractive: true,
appStore: {
...appstoreMock,
defaultAuthenticationMode: AuthenticationMode.USER,
authCtx: undefined,
ensureAuthenticatedAsync: jest.fn(() => testAuthCtx),
listProvisioningProfilesAsync: jest.fn(() => [
{
provisioningProfileId: nullthrows(
testCommonIosAppCredentialsFragment.iosAppBuildCredentialsList[0].provisioningProfile
).developerPortalIdentifier,
},
]),
},
ios: {
...getNewIosApiMock(),
getIosAppCredentialsWithBuildCredentialsAsync: jest.fn(
() => testCommonIosAppCredentialsFragment
),
createOrGetExistingAppleAppIdentifierAsync: jest.fn(() => testAppleAppIdentifierFragment),
createOrUpdateIosAppBuildCredentialsAsync: jest.fn(
() => testIosAppBuildCredentialsFragment
),
},
});
jest
.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync)
.mockImplementation(async authedCtx => {
(authedCtx.appStore as any).authCtx = testAuthCtx;
return true;
});
const appLookupParams = await getAppLookupParamsFromContextAsync(
ctx,
findApplicationTarget(testTargets)
);
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
appLookupParams,
testTarget,
IosDistributionType.AppStore
);
await setupProvisioningProfileAction.runAsync(ctx);
// expect we attempted to auto-authenticate with the EAS-stored ASC API key
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([
[ctx, appLookupParams, AppleTeamType.COMPANY_OR_ORGANIZATION],
]);
// expect build credentials to be created or updated on expo servers
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
1
);
// expect provisioning profile not to be deleted on expo servers (it still exists on Apple)
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
});
it('passes IN_HOUSE team type for enterprise profiles when auto-authenticating in nonInteractive mode', async () => {
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false);
const appstoreMock = getAppstoreMock();
const ctx = createCtxMock({
nonInteractive: true,
appStore: {
...appstoreMock,
defaultAuthenticationMode: AuthenticationMode.USER,
authCtx: undefined,
},
});
const appLookupParams = await getAppLookupParamsFromContextAsync(
ctx,
findApplicationTarget(testTargets)
);
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
appLookupParams,
testTarget,
IosDistributionType.Enterprise
);
await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrowError(
InsufficientAuthenticationNonInteractiveError
);
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([
[ctx, appLookupParams, AppleTeamType.IN_HOUSE],
]);
});
it('uses EXPO_APPLE_TEAM_TYPE over derived team type when auto-authenticating in nonInteractive mode', async () => {
process.env.EXPO_APPLE_TEAM_TYPE = AppleTeamType.IN_HOUSE;
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false);
const appstoreMock = getAppstoreMock();
const ctx = createCtxMock({
nonInteractive: true,
appStore: {
...appstoreMock,
defaultAuthenticationMode: AuthenticationMode.USER,
authCtx: undefined,
},
});
const appLookupParams = await getAppLookupParamsFromContextAsync(
ctx,
findApplicationTarget(testTargets)
);
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
appLookupParams,
testTarget,
IosDistributionType.AppStore
);
await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrowError(
InsufficientAuthenticationNonInteractiveError
);
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls).toEqual([
[ctx, appLookupParams, AppleTeamType.IN_HOUSE],
]);
});
it('errors in nonInteractive mode when the profile needs regeneration but no Apple authentication can be established', async () => {
jest.mocked(validateProvisioningProfileAsync).mockImplementation(async () => false);
// tryAuthenticate fails to establish auth (default mock returns false and leaves authCtx unset).
const ctx = createCtxMock({
nonInteractive: true,
});
const appLookupParams = await getAppLookupParamsFromContextAsync(
ctx,
findApplicationTarget(testTargets)
);
const setupProvisioningProfileAction = new SetUpProvisioningProfile(
appLookupParams,
testTarget,
IosDistributionType.AppStore
);
await expect(setupProvisioningProfileAction.runAsync(ctx)).rejects.toThrowError(
InsufficientAuthenticationNonInteractiveError
);
expect(jest.mocked(tryAuthenticateAppStoreWithEasAscApiKeyAsync).mock.calls.length).toBe(1);
expect(jest.mocked(ctx.ios.createOrUpdateIosAppBuildCredentialsAsync).mock.calls.length).toBe(
0
);
expect(jest.mocked(ctx.ios.deleteProvisioningProfilesAsync).mock.calls.length).toBe(0);
});
});
@@ -6,6 +6,7 @@ import { promptAsync } from '../../../../prompts';
import { AppleTeamType } from '../authenticateTypes';
import * as Keychain from '../keychain';
import {
resolveAppleTeamTypeFromEnvironment,
resolveAppleTeamAsync,
resolveAscApiKeyAsync,
resolveUserCredentialsAsync,
@@ -73,6 +74,17 @@ const testTeam = {
teamName: 'test-name',
teamType: AppleTeamType.IN_HOUSE,
};
describe(resolveAppleTeamTypeFromEnvironment, () => {
it('returns undefined when EXPO_APPLE_TEAM_TYPE is not set', () => {
expect(resolveAppleTeamTypeFromEnvironment()).toBeUndefined();
});
it('returns parsed team type when EXPO_APPLE_TEAM_TYPE is set', () => {
process.env.EXPO_APPLE_TEAM_TYPE = AppleTeamType.COMPANY_OR_ORGANIZATION;
expect(resolveAppleTeamTypeFromEnvironment()).toBe(AppleTeamType.COMPANY_OR_ORGANIZATION);
});
});
describe(resolveAppleTeamAsync, () => {
it(`uses option overrides over environment variables`, async () => {
process.env.EXPO_APPLE_TEAM_ID = 'not supposed to be here';
@@ -118,7 +118,7 @@ function assertAppleTeamType(maybeTeamType: any): AppleTeamType {
return maybeTeamType;
}
function resolveAppleTeamTypeFromEnvironment(): AppleTeamType | undefined {
export function resolveAppleTeamTypeFromEnvironment(): AppleTeamType | undefined {
if (!process.env.EXPO_APPLE_TEAM_TYPE) {
return undefined;
}
+231 -2
View File
@@ -168,6 +168,8 @@ export type Account = {
/** Owning UserActor of this account if personal account */
ownerUserActor?: Maybe<UserActor>;
pendingSentryInstallation?: Maybe<PendingSentryInstallation>;
/** PostHog organization connection for this account */
posthogOrganizationConnection?: Maybe<PostHogOrganizationConnection>;
profileImageUrl: Scalars['String']['output'];
pushSecurityEnabled: Scalars['Boolean']['output'];
requireTwoFactor: Scalars['Boolean']['output'];
@@ -1530,6 +1532,7 @@ export type App = Project & {
* @deprecated Classic updates have been deprecated.
*/
playStoreUrl?: Maybe<Scalars['String']['output']>;
posthogProject?: Maybe<PostHogProject>;
/** @deprecated No longer supported */
privacy: Scalars['String']['output'];
/** @deprecated No longer supported */
@@ -4893,6 +4896,11 @@ export type CreateIosSubmissionInput = {
submittedBuildId?: InputMaybe<Scalars['ID']['input']>;
};
export type CreatePostHogAccountRequestInput = {
accountId: Scalars['ID']['input'];
region: PostHogRegion;
};
export type CreateSentryProjectInput = {
appId: Scalars['ID']['input'];
sentryProjectId: Scalars['String']['input'];
@@ -6295,6 +6303,8 @@ export enum EntityTypeName {
IosAppCredentialsEntity = 'IosAppCredentialsEntity',
LogRocketOrganizationEntity = 'LogRocketOrganizationEntity',
LogRocketProjectEntity = 'LogRocketProjectEntity',
PostHogOrganizationConnectionEntity = 'PostHogOrganizationConnectionEntity',
PostHogProjectEntity = 'PostHogProjectEntity',
UserInvitationEntity = 'UserInvitationEntity',
UserPermissionEntity = 'UserPermissionEntity',
VexoAccountConnectionEntity = 'VexoAccountConnectionEntity',
@@ -6535,6 +6545,121 @@ export type EstimatedUsagePlatformDetail = {
value: Scalars['Float']['output'];
};
export type ExitInterviewBeginChatTurnInput = {
accountId: Scalars['ID']['input'];
};
export type ExitInterviewBeginChatTurnResult = {
__typename?: 'ExitInterviewBeginChatTurnResult';
ok: Scalars['Boolean']['output'];
};
export type ExitInterviewCompleteInput = {
accountId: Scalars['ID']['input'];
conversationId: Scalars['ID']['input'];
messages: Array<ExitInterviewMessageInput>;
outcome: ExitInterviewOutcome;
/**
* Optional classification tags. Present when the website's tagging call
* succeeded; omitted when it failed or when the user dismissed without
* engaging. When present, drives the conversation-tagged event and the
* tagged Slack notification.
*/
tags?: InputMaybe<ExitInterviewTagsInput>;
};
export type ExitInterviewCompleteResult = {
__typename?: 'ExitInterviewCompleteResult';
success: Scalars['Boolean']['output'];
};
export type ExitInterviewFeedbackInput = {
accountId: Scalars['ID']['input'];
conversationId: Scalars['ID']['input'];
feedback: Scalars['String']['input'];
outcome: ExitInterviewOutcome;
};
export type ExitInterviewFeedbackResult = {
__typename?: 'ExitInterviewFeedbackResult';
success: Scalars['Boolean']['output'];
};
export type ExitInterviewMessageInput = {
content: Scalars['String']['input'];
role: ExitInterviewMessageRole;
};
export enum ExitInterviewMessageRole {
Assistant = 'ASSISTANT',
User = 'USER'
}
export type ExitInterviewMutation = {
__typename?: 'ExitInterviewMutation';
/**
* Gate a chat turn before the website route calls OpenAI. Enforces
* authorization (account admin), eligibility (active paid subscription,
* not cancelling), and a per-user rate limit (30 turns/hour). The
* website's /api/exit-interview/chat route calls this server-to-server
* on every turn; failures map to HTTP 4xx and abort the stream before
* any OpenAI tokens are spent.
*/
beginChatTurn: ExitInterviewBeginChatTurnResult;
/**
* Mark a treatment-variant exit-interview chat conversation complete. Emits
* the conversation-completed event to RudderStack with the full transcript.
* If the transcript carries at least one server-signed assistant turn (i.e.
* the user actually engaged the bot), fans out the raw transcript to Slack.
* If the client also provides tags, emits a separate conversation-tagged
* event and a tagged Slack notification. Control-variant one-shot feedback
* goes through submitFeedback.
*/
complete: ExitInterviewCompleteResult;
/**
* Record a control-variant one-shot feedback submission. Emits the
* feedback-submitted event to RudderStack. No Slack notification, no
* tagging — those pipelines are scoped to the treatment chat flow via
* complete.
*/
submitFeedback: ExitInterviewFeedbackResult;
};
export type ExitInterviewMutationBeginChatTurnArgs = {
input: ExitInterviewBeginChatTurnInput;
};
export type ExitInterviewMutationCompleteArgs = {
input: ExitInterviewCompleteInput;
};
export type ExitInterviewMutationSubmitFeedbackArgs = {
input: ExitInterviewFeedbackInput;
};
export enum ExitInterviewOutcome {
CancelledImmediately = 'CANCELLED_IMMEDIATELY',
ContinuedToStripe = 'CONTINUED_TO_STRIPE',
Dismissed = 'DISMISSED',
KeptPlan = 'KEPT_PLAN'
}
/**
* Tags produced by the website's churn-classification step. The website runs
* this synchronously before calling complete, then forwards the structured
* result. Allowed category / sentiment values are enforced by the resolver.
*/
export type ExitInterviewTagsInput = {
actionableInsight: Scalars['Boolean']['input'];
category: Scalars['String']['input'];
competitorMention?: InputMaybe<Scalars['String']['input']>;
productArea?: InputMaybe<Scalars['String']['input']>;
sentiment: Scalars['String']['input'];
};
export enum Experiment {
Orbit = 'ORBIT'
}
@@ -8127,6 +8252,58 @@ export type PinnedDashboardView = {
export type PlanEnablement = Concurrencies | EasTotalPlanEnablement;
export type PostHogIntegrationQuery = {
__typename?: 'PostHogIntegrationQuery';
clientIdentifier: Scalars['String']['output'];
};
export type PostHogOrganizationConnection = {
__typename?: 'PostHogOrganizationConnection';
account: Account;
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
posthogOrganizationIdentifier: Scalars['String']['output'];
posthogOrganizationName: Scalars['String']['output'];
posthogProjects: Array<PostHogProject>;
posthogRegion: PostHogRegion;
updatedAt: Scalars['DateTime']['output'];
};
export type PostHogOrganizationConnectionMutation = {
__typename?: 'PostHogOrganizationConnectionMutation';
createPostHogAccountRequest: PostHogOrganizationConnection;
/** Removes the Expo-side connection only; the PostHog organization is preserved. */
deletePostHogOrganizationConnection: Scalars['ID']['output'];
};
export type PostHogOrganizationConnectionMutationCreatePostHogAccountRequestArgs = {
input: CreatePostHogAccountRequestInput;
};
export type PostHogOrganizationConnectionMutationDeletePostHogOrganizationConnectionArgs = {
id: Scalars['ID']['input'];
};
export type PostHogProject = {
__typename?: 'PostHogProject';
app: App;
createdAt: Scalars['DateTime']['output'];
id: Scalars['ID']['output'];
posthogHost: Scalars['String']['output'];
posthogOrganizationConnection: PostHogOrganizationConnection;
posthogProjectIdentifier: Scalars['String']['output'];
posthogProjectName: Scalars['String']['output'];
posthogProjectToken: Scalars['String']['output'];
updatedAt: Scalars['DateTime']['output'];
};
export enum PostHogRegion {
Eu = 'EU',
Us = 'US'
}
export type Project = {
description: Scalars['String']['output'];
fullName: Scalars['String']['output'];
@@ -8448,6 +8625,8 @@ export type RootMutation = {
environmentSecret: EnvironmentSecretMutation;
/** Mutations that create and delete EnvironmentVariables */
environmentVariable: EnvironmentVariableMutation;
/** Mutations for the exit-interview chat shown when a user cancels their plan. */
exitInterview: ExitInterviewMutation;
/** Mutations that modify App fingerprints */
fingerprint: FingerprintMutation;
/** Mutations that utilize services facilitated by the GitHub App */
@@ -8480,6 +8659,7 @@ export type RootMutation = {
me: MeMutation;
/** Notification preference management */
notificationPreference: NotificationPreferenceMutation;
posthogOrganizationConnection: PostHogOrganizationConnectionMutation;
/** Mutations that create, update, and delete Robots */
robot: RobotMutation;
/** Mutations for Sentry installations */
@@ -8488,6 +8668,7 @@ export type RootMutation = {
sentryProject: SentryProjectMutation;
/** Mutations that modify an EAS Submit submission */
submission: SubmissionMutation;
tunnels: TunnelsMutation;
turtleBrownfieldArtifacts: TurtleBrownfieldArtifactMutation;
update: UpdateMutation;
updateBranch: UpdateBranchMutation;
@@ -8626,6 +8807,8 @@ export type RootQuery = {
* this is the appropriate top-level query object
*/
meUserActor?: Maybe<UserActor>;
/** Top-level query object for querying PostHog Integration information. */
posthogIntegration: PostHogIntegrationQuery;
/** @deprecated Snacks and apps should be queried separately */
project: ProjectQuery;
/** Top-level query object for querying Runtimes. */
@@ -8920,7 +9103,6 @@ export type SecondFactorDeviceConfiguration = {
isPrimary: Scalars['Boolean']['input'];
method: SecondFactorMethod;
name: Scalars['String']['input'];
smsPhoneNumber?: InputMaybe<Scalars['String']['input']>;
};
export type SecondFactorDeviceConfigurationResult = {
@@ -8939,7 +9121,10 @@ export type SecondFactorInitiationResult = {
export enum SecondFactorMethod {
/** Google Authenticator (TOTP) */
Authenticator = 'AUTHENTICATOR',
/** SMS */
/**
* SMS
* @deprecated No longer supported
*/
Sms = 'SMS'
}
@@ -9410,6 +9595,23 @@ export type TimelineActivityFilterInput = {
types?: InputMaybe<Array<ActivityTimelineProjectActivityType>>;
};
export type TunnelSignedUrlResult = {
__typename?: 'TunnelSignedUrlResult';
label: Scalars['ID']['output'];
url: Scalars['String']['output'];
};
export type TunnelsMutation = {
__typename?: 'TunnelsMutation';
/** Create a signed tunnel URL for an account */
createSignedTunnelUrl: TunnelSignedUrlResult;
};
export type TunnelsMutationCreateSignedTunnelUrlArgs = {
accountId: Scalars['ID']['input'];
};
export type TurtleBrownfieldArtifactMutation = {
__typename?: 'TurtleBrownfieldArtifactMutation';
createTurtleBrownfieldArtifact: CreateBrownfieldArtifactResult;
@@ -10136,6 +10338,8 @@ export type User = Actor & UserActor & {
location?: Maybe<Scalars['String']['output']>;
newEmailPendingVerification?: Maybe<Scalars['String']['output']>;
oAuthIdentities: Array<OAuthIdentity>;
/** Registered passkey credentials */
passkeyCredentials: Array<UserPasskeyCredential>;
/** Pending UserInvitations for this user. Only resolves for the viewer. */
pendingUserInvitations: Array<UserInvitation>;
pinnedApps: Array<App>;
@@ -10516,6 +10720,7 @@ export enum UserEntityTypeName {
PasswordEntity = 'PasswordEntity',
SsoUserEntity = 'SSOUserEntity',
UserEntity = 'UserEntity',
UserPasskeyCredentialEntity = 'UserPasskeyCredentialEntity',
UserPermissionEntity = 'UserPermissionEntity',
UserSecondFactorBackupCodesEntity = 'UserSecondFactorBackupCodesEntity',
UserSecondFactorDeviceEntity = 'UserSecondFactorDeviceEntity'
@@ -10645,6 +10850,21 @@ export type UserLogNameTypeMapping = {
typeName: UserEntityTypeName;
};
/** A passkey credential belonging to a User */
export type UserPasskeyCredential = {
__typename?: 'UserPasskeyCredential';
/**
* Authenticator Attestation Globally Unique Identifier — identifies the authenticator model
* (e.g., 1Password, iCloud Keychain). See https://passkeydeveloper.github.io/passkey-authenticator-aaguids/explorer/
*/
aaguid?: Maybe<Scalars['ID']['output']>;
createdAt: Scalars['DateTime']['output'];
credentialDeviceType: Scalars['String']['output'];
id: Scalars['ID']['output'];
lastUsedAt?: Maybe<Scalars['DateTime']['output']>;
name: Scalars['String']['output'];
};
export type UserPermission = {
__typename?: 'UserPermission';
actor: Actor;
@@ -11757,6 +11977,14 @@ export type WorkflowJob = {
approvals: Array<WorkflowJobApproval>;
createdAt: Scalars['DateTime']['output'];
credentialsAppleDeviceRegistrationRequest?: Maybe<AppleDeviceRegistrationRequest>;
/**
* All test case attempt rows produced by this job's own execution (turtle job
* run), ordered by path then retryCount. Unlike deviceTestCaseResults and
* allDeviceTestCaseResults, this never includes rows from other jobs in the
* workflow retry chain.
*/
deviceTestCaseResultAttempts: Array<WorkflowDeviceTestCaseResult>;
/** @deprecated Use deviceTestCaseResultAttempts instead */
deviceTestCaseResults: Array<WorkflowDeviceTestCaseResult>;
environment?: Maybe<Scalars['String']['output']>;
errors: Array<WorkflowJobError>;
@@ -12095,6 +12323,7 @@ export enum WorkflowRunTriggerEventType {
GithubPullRequestReopened = 'GITHUB_PULL_REQUEST_REOPENED',
GithubPullRequestSynchronize = 'GITHUB_PULL_REQUEST_SYNCHRONIZE',
GithubPush = 'GITHUB_PUSH',
GithubRefDelete = 'GITHUB_REF_DELETE',
Manual = 'MANUAL',
RepackExpoGo = 'REPACK_EXPO_GO',
Schedule = 'SCHEDULE'
@@ -1,40 +0,0 @@
describe('config', () => {
const originalEnvironment = process.env.ENVIRONMENT;
const originalWorkerRuntimeConfigBase64 = process.env.WORKER_RUNTIME_CONFIG_BASE64;
afterEach(() => {
restoreEnv('ENVIRONMENT', originalEnvironment);
restoreEnv('WORKER_RUNTIME_CONFIG_BASE64', originalWorkerRuntimeConfigBase64);
jest.resetModules();
});
it('parses cache URL fallbacks from worker runtime config', () => {
process.env.ENVIRONMENT = 'staging';
process.env.WORKER_RUNTIME_CONFIG_BASE64 = Buffer.from(
JSON.stringify({
buildId: 'build-id',
npmCacheUrl: 'https://npm-cache.example',
nodeJsCacheUrl: 'https://node-cache.example',
mavenCacheUrl: 'https://maven-cache.example',
cocoapodsCacheUrl: 'https://cocoapods-cache.example',
})
).toString('base64');
jest.isolateModules(() => {
const config = require('../config').default;
expect(config.npmCacheUrl).toBe('https://npm-cache.example');
expect(config.nodeJsCacheUrl).toBe('https://node-cache.example');
expect(config.mavenCacheUrl).toBe('https://maven-cache.example');
expect(config.cocoapodsCacheUrl).toBe('https://cocoapods-cache.example');
});
});
});
function restoreEnv(key: string, value: string | undefined): void {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
+165 -139
View File
@@ -1,11 +1,10 @@
import { BuildContext, GCS } from '@expo/build-tools';
import { Job } from '@expo/eas-build-job';
import { Job, errors } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { randomBytes, randomUUID } from 'crypto';
import { vol } from 'memfs';
import { Response } from 'node-fetch';
import config from '../config';
import {
uploadApplicationArchiveAsync,
uploadBuildArtifactsAsync,
@@ -59,7 +58,14 @@ describe(uploadApplicationArchiveAsync.name, () => {
buildId: 'buildId',
logger: ctx.logger,
})
).rejects.toThrow('env variables are not set');
).rejects.toMatchObject({
errorCode: errors.ErrorCode.SERVER_ERROR,
trackingCode: 'EAS_BUILD_UPLOAD_APPLICATION_ARCHIVE_FAILED',
message: 'Failed to upload application archive.',
cause: expect.objectContaining({
message: expect.stringContaining('env variables are not set'),
}),
});
ctx.env.__WORKFLOW_JOB_ID = randomUUID();
@@ -69,7 +75,14 @@ describe(uploadApplicationArchiveAsync.name, () => {
buildId: 'buildId',
logger: ctx.logger,
})
).rejects.toThrow('robot access token is not set');
).rejects.toMatchObject({
errorCode: errors.ErrorCode.SERVER_ERROR,
trackingCode: 'EAS_BUILD_UPLOAD_APPLICATION_ARCHIVE_FAILED',
message: 'Failed to upload application archive.',
cause: expect.objectContaining({
message: expect.stringContaining('robot access token is not set'),
}),
});
});
it('should upload the application archive', async () => {
@@ -151,6 +164,9 @@ describe(uploadApplicationArchiveAsync.name, () => {
headers: {
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
},
retries: 2,
retryIntervalMs: 1000,
logger: ctx.logger,
})
);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
@@ -162,84 +178,81 @@ describe(uploadApplicationArchiveAsync.name, () => {
headers: {
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
},
retries: 2,
retryIntervalMs: 1000,
logger: ctx.logger,
})
);
});
describe('with signed upload url provided via config', () => {
beforeAll(() => {
config.gcsSignedUploadUrlForApplicationArchive = {
url: 'https://upload.url/from-config',
headers: {
authorization: 'test-signed-upload-authorization',
},
};
});
afterAll(() => {
config.gcsSignedUploadUrlForApplicationArchive = null;
it('should throw a system error if the application archive upload fails', async () => {
vol.fromJSON({
'./artifact.ipa': JSON.stringify(randomBytes(20)),
});
const workflowJobId = randomUUID();
it('should fall back to signed upload URL if upload session fails', async () => {
vol.fromJSON({
'./artifact.ipa': JSON.stringify(randomBytes(20)),
});
const workflowJobId = randomUUID();
// @ts-expect-error
const ctx: BuildContext<Job> = {
env: {
__WORKFLOW_JOB_ID: workflowJobId,
// @ts-expect-error
const ctx: BuildContext<Job> = {
env: {
__WORKFLOW_JOB_ID: workflowJobId,
},
job: {
secrets: {
robotAccessToken: 'fake-token',
},
job: {
secrets: {
robotAccessToken: 'fake-token',
},
} as Job,
logger: mockLogger,
};
} as Job,
logger: mockLogger,
};
turtleFetchMock.mockImplementation(async () => {
return {
json: async () => ({
data: {
bucketKey: 'test/bucketKey',
url: 'https://upload.url/from-www',
headers: {
authorization: 'test-signed-upload-authorization',
},
storageType: 'GCS',
const bucketKey = `test/${randomUUID()}/artifact.ipa`;
const uploadUrl = `https://upload.url/${randomUUID()}`;
const testSignedUploadAuthorization = randomUUID();
const uploadError = new Error('upload failed');
turtleFetchMock.mockImplementation(async () => {
return {
ok: true,
status: 200,
json: async () => ({
data: {
bucketKey,
url: uploadUrl,
headers: {
authorization: testSignedUploadAuthorization,
},
}),
ok: true,
} as unknown as Response;
});
// GCS fails to upload to upload session from www.
jest.mocked(GCS.uploadWithSignedUrl).mockImplementation(async ({ signedUrl }) => {
if (signedUrl.url === 'https://upload.url/from-www') {
throw new Error('upload failed');
}
return signedUrl.url;
});
storageType: 'GCS',
},
}),
} as Response;
});
jest.mocked(GCS.uploadWithSignedUrl).mockRejectedValueOnce(uploadError);
let thrownError: unknown;
try {
await uploadApplicationArchiveAsync(ctx, {
artifactPaths: ['./artifact.ipa'],
buildId: randomUUID(),
logger: ctx.logger,
});
} catch (err) {
thrownError = err;
}
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
expect.objectContaining({
signedUrl: {
url: 'https://upload.url/from-config',
headers: {
authorization: 'test-signed-upload-authorization',
},
},
})
);
expect(turtleFetchMock).toHaveBeenCalledTimes(1);
expect(thrownError).toBeInstanceOf(errors.SystemError);
expect(thrownError).toMatchObject({
errorCode: errors.ErrorCode.SERVER_ERROR,
trackingCode: 'EAS_BUILD_UPLOAD_APPLICATION_ARCHIVE_FAILED',
message: 'Failed to upload application archive.',
buildPhase: undefined,
metadata: expect.objectContaining({
filename: expect.stringMatching(/^application-.*\.ipa$/),
size: expect.any(Number),
url: uploadUrl,
headers: {
authorization: testSignedUploadAuthorization,
},
}),
cause: uploadError,
});
});
});
@@ -263,7 +276,14 @@ describe(uploadBuildArtifactsAsync.name, () => {
buildId: 'buildId',
logger: ctx.logger,
})
).rejects.toThrow('env variables are not set');
).rejects.toMatchObject({
errorCode: errors.ErrorCode.SERVER_ERROR,
trackingCode: 'EAS_BUILD_UPLOAD_BUILD_ARTIFACTS_FAILED',
message: 'Failed to upload build artifacts.',
cause: expect.objectContaining({
message: expect.stringContaining('env variables are not set'),
}),
});
ctx.env.__WORKFLOW_JOB_ID = randomUUID();
await expect(
uploadBuildArtifactsAsync(ctx, {
@@ -271,7 +291,14 @@ describe(uploadBuildArtifactsAsync.name, () => {
buildId: 'buildId',
logger: ctx.logger,
})
).rejects.toThrow('robot access token is not set');
).rejects.toMatchObject({
errorCode: errors.ErrorCode.SERVER_ERROR,
trackingCode: 'EAS_BUILD_UPLOAD_BUILD_ARTIFACTS_FAILED',
message: 'Failed to upload build artifacts.',
cause: expect.objectContaining({
message: expect.stringContaining('robot access token is not set'),
}),
});
});
it('should upload the build artifacts', async () => {
@@ -348,6 +375,9 @@ describe(uploadBuildArtifactsAsync.name, () => {
headers: {
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
},
retries: 2,
retryIntervalMs: 1000,
logger: ctx.logger,
})
);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
@@ -359,96 +389,83 @@ describe(uploadBuildArtifactsAsync.name, () => {
headers: {
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
},
retries: 2,
retryIntervalMs: 1000,
logger: ctx.logger,
})
);
});
describe('with signed upload url provided via config', () => {
beforeAll(() => {
config.gcsSignedUploadUrlForBuildArtifacts = {
url: 'https://upload.url/from-config',
headers: {
authorization: 'test-signed-upload-authorization',
},
};
it('should throw a system error if the build artifacts upload fails', async () => {
vol.fromJSON({
'./video.mp4': JSON.stringify(randomBytes(20)),
});
afterAll(() => {
config.gcsSignedUploadUrlForBuildArtifacts = null;
});
it('should fall back to signed upload URL if upload session fails', async () => {
vol.fromJSON({
'./video.mp4': JSON.stringify(randomBytes(20)),
});
const workflowJobId = randomUUID();
// @ts-expect-error
const ctx: BuildContext<Job> = {
env: {
__WORKFLOW_JOB_ID: workflowJobId,
const workflowJobId = randomUUID();
// @ts-expect-error
const ctx: BuildContext<Job> = {
env: {
__WORKFLOW_JOB_ID: workflowJobId,
},
job: {
secrets: {
robotAccessToken: 'fake-token',
},
job: {
secrets: {
robotAccessToken: 'fake-token',
},
} as Job,
logger: mockLogger,
};
turtleFetchMock.mockImplementation(async () => {
return {
json: async () => ({
data: {
bucketKey: 'test/bucketKey',
url: 'https://upload.url/from-www',
headers: {
authorization: 'test-signed-upload-authorization',
},
storageType: 'GCS',
} as Job,
logger: mockLogger,
};
const bucketKey = `test/${randomUUID()}/artifact.ipa`;
const uploadUrl = `https://upload.url/${randomUUID()}`;
const testSignedUploadAuthorization = randomUUID();
const uploadError = new Error('upload failed');
turtleFetchMock.mockImplementation(async () => {
return {
ok: true,
status: 200,
json: async () => ({
data: {
bucketKey,
url: uploadUrl,
headers: {
authorization: testSignedUploadAuthorization,
},
}),
ok: true,
} as unknown as Response;
});
// GCS fails to upload to upload session from www.
jest.mocked(GCS.uploadWithSignedUrl).mockImplementation(async ({ signedUrl }) => {
if (signedUrl.url === 'https://upload.url/from-www') {
throw new Error('upload failed');
}
return signedUrl.url;
});
storageType: 'GCS',
},
}),
} as Response;
});
jest.mocked(GCS.uploadWithSignedUrl).mockRejectedValueOnce(uploadError);
let thrownError: unknown;
try {
await uploadBuildArtifactsAsync(ctx, {
artifactPaths: ['./video.mp4'],
buildId: randomUUID(),
logger: ctx.logger,
});
} catch (err) {
thrownError = err;
}
expect(GCS.uploadWithSignedUrl).toHaveBeenCalledWith(
expect.objectContaining({
signedUrl: {
url: 'https://upload.url/from-config',
headers: {
authorization: 'test-signed-upload-authorization',
},
},
})
);
expect(turtleFetchMock).toHaveBeenCalledTimes(1);
expect(thrownError).toBeInstanceOf(errors.SystemError);
expect(thrownError).toMatchObject({
errorCode: errors.ErrorCode.SERVER_ERROR,
trackingCode: 'EAS_BUILD_UPLOAD_BUILD_ARTIFACTS_FAILED',
message: 'Failed to upload build artifacts.',
buildPhase: undefined,
metadata: expect.objectContaining({
filename: expect.stringMatching(/^artifacts-.*\.mp4$/),
size: expect.any(Number),
url: uploadUrl,
headers: {
authorization: testSignedUploadAuthorization,
},
}),
cause: uploadError,
});
});
});
describe('with signed upload url provided via www', () => {
beforeAll(() => {
config.gcsSignedUploadUrlForBuildArtifacts = null;
});
afterAll(() => {
config.gcsSignedUploadUrlForBuildArtifacts = null;
});
it('should use www signed upload url', async () => {
vol.fromJSON({
'./video.mp4': JSON.stringify(randomBytes(20)),
@@ -533,6 +550,9 @@ describe('with signed upload url provided via www', () => {
headers: {
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
},
retries: 2,
retryIntervalMs: 1000,
logger: ctx.logger,
})
);
expect(turtleFetchMock).toHaveBeenNthCalledWith(
@@ -544,6 +564,9 @@ describe('with signed upload url provided via www', () => {
headers: {
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
},
retries: 2,
retryIntervalMs: 1000,
logger: ctx.logger,
})
);
});
@@ -622,6 +645,9 @@ describe(uploadWorkflowArtifactAsync.name, () => {
headers: {
Authorization: `Bearer ${ctx.job.secrets!.robotAccessToken}`,
},
retries: 2,
retryIntervalMs: 1000,
logger: ctx.logger,
})
);
});
-27
View File
@@ -63,17 +63,6 @@ export default {
defaultValue: null,
}),
},
gcsSignedUploadUrlForApplicationArchive: env<GCS.SignedUrl | null>(
'WORKER_RUNTIME_CONFIG_BASE64',
{
transform: createBase64EnvTransformer('gcsSignedUploadUrlForApplicationArchive'),
defaultValue: null,
}
),
gcsSignedUploadUrlForBuildArtifacts: env<GCS.SignedUrl | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('gcsSignedUploadUrlForBuildArtifacts'),
defaultValue: null,
}),
workingdir: env('WORKINGDIR', { defaultValue: path.join(__dirname, '../workingdir') }),
sentry: {
dsn: env('SENTRY_DSN', { defaultValue: '' }),
@@ -82,22 +71,6 @@ export default {
dataPlaneURL: env<string | null>('RUDDERSTACK_DATA_PLANE_URL', { defaultValue: null }),
writeKey: env<string | null>('RUDDERSTACK_WRITE_KEY', { defaultValue: null }),
},
npmCacheUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('npmCacheUrl'),
defaultValue: null,
}),
nodeJsCacheUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('nodeJsCacheUrl'),
defaultValue: null,
}),
mavenCacheUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('mavenCacheUrl'),
defaultValue: null,
}),
cocoapodsCacheUrl: env<string | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('cocoapodsCacheUrl'),
defaultValue: null,
}),
runMetricsServer: env<boolean | null>('WORKER_RUNTIME_CONFIG_BASE64', {
transform: createBase64EnvTransformer('runMetricsServer'),
defaultValue: null,
-10
View File
@@ -24,10 +24,6 @@ export namespace Worker {
type JobRunWorkerRuntimeConfig = {
gcsSignedUploadUrlForLogs: GCS.SignedUrl;
nodeJsCacheUrl: string | undefined;
npmCacheUrl: string | undefined;
mavenCacheUrl: string | undefined;
cocoapodsCacheUrl: string | undefined;
runMetricsServer: boolean;
type: 'jobRun';
@@ -35,17 +31,11 @@ export namespace Worker {
};
type BuildWorkerRuntimeConfig = {
gcsSignedUploadUrlForApplicationArchive: GCS.SignedUrl | null;
gcsSignedUploadUrlForBuildArtifacts: GCS.SignedUrl | null;
gcsSignedUploadUrlForLogs: GCS.SignedUrl;
gcsSignedUploadUrlForXcodeBuildLogs?: GCS.SignedUrl;
gcsSignedUploadUrlForBuildCache?: GCS.SignedUrl;
gcsSignedBuildCacheDownloadUrl?: string;
nodeJsCacheUrl: string | undefined;
npmCacheUrl: string | undefined;
mavenCacheUrl: string | undefined;
cocoapodsCacheUrl: string | undefined;
runMetricsServer: boolean;
type?: never;
+1 -10
View File
@@ -9,16 +9,7 @@ import { prepareWorkingdir } from './workingdir';
import startWsServer from './ws';
async function main(): Promise<void> {
await RuntimeSettings.loadAsync({
environment: config.env,
logger,
cacheUrlFallbacks: {
npm: config.npmCacheUrl,
nodejs: config.nodeJsCacheUrl,
maven: config.mavenCacheUrl,
cocoapods: config.cocoapodsCacheUrl,
},
});
await RuntimeSettings.loadAsync({ environment: config.env, logger });
await prepareRuntimeEnvironmentConfigFiles();
await prepareWorkingdir();
startWsServer();
+25 -51
View File
@@ -1,5 +1,5 @@
import { BuildContext, GCS } from '@expo/build-tools';
import { ArchiveSourceType } from '@expo/eas-build-job';
import { ArchiveSourceType, errors } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import fs from 'fs-extra';
@@ -13,6 +13,9 @@ import { Analytics, Event } from './external/analytics';
import sentry from './sentry';
import { TurtleFetchError, turtleFetch } from './utils/turtleFetch';
const UPLOAD_API_REQUEST_RETRIES = 2;
const UPLOAD_API_REQUEST_RETRY_INTERVAL_MS = 1000;
class ErrorWithMetadata extends Error {
constructor(
message: string,
@@ -65,39 +68,19 @@ export async function uploadApplicationArchiveAsync(
return { filename: null };
} catch (err: any) {
// Otherwise, we log the error and proceed to upload to Launcher's upload URL.
const msg = 'Upload to upload session failed';
logger.error({ err, filename, size }, msg);
sentry.capture(msg, err, {
extras: {
logger.error({ err, filename, size }, 'Upload to upload session failed');
throw new errors.SystemError('Failed to upload application archive.', {
trackingCode: 'EAS_BUILD_UPLOAD_APPLICATION_ARCHIVE_FAILED',
metadata: {
filename,
size,
...uploadSession,
...(err instanceof ErrorWithMetadata ? err.metadata : {}),
},
});
// Lack of `gcsSignedUploadUrlForApplicationArchive` means we're being run by workflow-orchestration.
if (!config.gcsSignedUploadUrlForApplicationArchive) {
throw new Error(`Failed to upload application archive: ${err?.message}\n${err?.stack}`, {
cause: err,
});
}
}
try {
await GCS.uploadWithSignedUrl({
signedUrl: config.gcsSignedUploadUrlForApplicationArchive,
srcGeneratorAsync: async () => {
return fs.createReadStream(localPath);
},
});
} catch (err: any) {
throw new Error(`Failed to upload application archive: ${err?.message}\n${err?.stack}`, {
cause: err,
});
}
return { filename };
}
export async function uploadBuildArtifactsAsync(
@@ -140,39 +123,18 @@ export async function uploadBuildArtifactsAsync(
// The saved artifact has the right filename, we don't need Launcher to rename or store it.
return { filename: null };
} catch (err: any) {
// Lack of `gcsSignedUploadUrlForBuildArtifacts` means we're being run by workflow-orchestration.
if (!config.gcsSignedUploadUrlForBuildArtifacts) {
throw new Error(`Failed to upload build artifact: ${err?.message}\n${err?.stack}`, {
cause: err,
});
}
logger.error({ err, filename, size }, 'Upload to upload session failed');
// Otherwise, we log the error and proceed to upload to Launcher's upload URL.
const msg = 'Upload to upload session failed';
logger.error({ err, filename, size }, msg);
sentry.capture(msg, err, {
extras: {
throw new errors.SystemError('Failed to upload build artifacts.', {
trackingCode: 'EAS_BUILD_UPLOAD_BUILD_ARTIFACTS_FAILED',
metadata: {
filename,
size,
...uploadSession,
},
});
}
try {
await GCS.uploadWithSignedUrl({
signedUrl: config.gcsSignedUploadUrlForBuildArtifacts,
srcGeneratorAsync: async () => {
return fs.createReadStream(localPath);
},
});
} catch (err: any) {
throw new Error(`Failed to upload build artifact: ${err?.message}\n${err?.stack}`, {
cause: err,
});
}
return { filename };
}
export async function uploadWorkflowArtifactAsync(
@@ -308,6 +270,9 @@ async function createUploadSessionAsync(
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
retries: UPLOAD_API_REQUEST_RETRIES,
retryIntervalMs: UPLOAD_API_REQUEST_RETRY_INTERVAL_MS,
logger: ctx.logger,
}
)
);
@@ -322,6 +287,9 @@ async function createUploadSessionAsync(
Authorization: `Bearer ${robotAccessToken}`,
},
shouldThrowOnNotOk: false,
retries: UPLOAD_API_REQUEST_RETRIES,
retryIntervalMs: UPLOAD_API_REQUEST_RETRY_INTERVAL_MS,
logger: ctx.logger,
}
)
);
@@ -429,6 +397,9 @@ async function saveArtifactAsync(
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: UPLOAD_API_REQUEST_RETRIES,
retryIntervalMs: UPLOAD_API_REQUEST_RETRY_INTERVAL_MS,
logger: ctx.logger,
}
)
);
@@ -442,6 +413,9 @@ async function saveArtifactAsync(
headers: {
Authorization: `Bearer ${robotAccessToken}`,
},
retries: UPLOAD_API_REQUEST_RETRIES,
retryIntervalMs: UPLOAD_API_REQUEST_RETRY_INTERVAL_MS,
logger: ctx.logger,
}
)
);