Compare commits

...
8 Commits
Author SHA1 Message Date
Expo CI b92a9cc5a7 v22.0.0 2026-08-14 14:24:53 +00:00
763cbadaaa [eas-cli] remove deprecated GraphQL field usage (#4021)
* Remove deprecated GraphQL field usage

* Keep flat runtimeVersion in update:republish --json output

* Revert "Keep flat runtimeVersion in update:republish --json output"

This reverts commit 32264aea2d5c200bba7ee6be46d80598433fee08.

* Clarify CHANGELOG entry for update:republish --json shape

* Limit breaking-changes note to user-facing output changes

* Migrate account fixtures in accountSelection tests

* Migrate remaining account fixtures in getProjectIdAsync tests

Co-authored-by: tchayen <[email protected]>

* [eas-cli] Restore submission Application Identifier from the submitted build

Read it from Build.appIdentifier instead of the deprecated
androidConfig.applicationIdentifier.

Co-authored-by: tchayen <[email protected]>

* Regenerate GraphQL code and update channel fixture after rebase

---------

Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com>
Co-authored-by: tchayen <[email protected]>
2026-08-14 17:21:27 +03:00
Tomek Czajecki 3cc58e500f [eas-cli] set up TestFlight group for existing ASC apps (#4136)
Set up the internal TestFlight group during interactive iOS submissions when ascAppId is configured, with an explicit opt-out flag. Skip setup in non-interactive mode, reuse the shared setup implementation, and fix the TestFlight group URL shown on partial tester failures.
2026-08-14 17:04:46 +03:00
expo-tuft[bot]andtchayen 2a7cb5e1fe [eas-cli] Set the project icon from the app config on init (#4177)
* [eas-cli] Set the project icon from the app config on init

Reuses the project:icon:set upload path, and only fills an icon when the
project does not have one yet.

Co-authored-by: tchayen <[email protected]>

* [eas-cli] Update CHANGELOG.md

Co-authored-by: tchayen <[email protected]>

---------

Co-authored-by: expo-tuft[bot] <288127324+expo-tuft[bot]@users.noreply.github.com>
Co-authored-by: tchayen <[email protected]>
2026-08-14 14:18:52 +03:00
Stanisław Chmiela 289d579869 [build-tools] Add maestro-runner installation backend (#4186)
<!-- You can skip the changelog check by labeling the PR with "no changelog". -->

# Why

I want to try `maestro-runner` as Maestro test runner.

# How

Added support for installation of `maestro-runner` through `eas/install_maestro`.

I can see how icky this is, but I want to allow easy opt-in with `EAS_MAESTRO_BACKEND=maestro-runner` and we won't have easy access to all environment variables in `WorkflowMaestroTestJob.ts`.

# Test Plan

CI should pass. Verified `maestro-runner --version` output.
2026-08-14 09:28:05 +02:00
Mustafa Abban 067c0e578f [build-tools] upload source maps from builds for Observe (#4170)
* [build-tools] Upload Observe source maps from EAS builds

* [build-tools] Fix source map upload after storage refactor

* [build] configure source map uploads in build profiles

* fix CHANGELOG formatting

* use SystemError in source map upload, rename prepare to resolve
2026-08-12 14:36:55 -04:00
William Grosset 520c63fd3e [eas-cli] Improve non-interactive error for unconfigured projects (#4153)
* [eas-cli] Improve non-interactive error for unconfigured projects

* [eas-cli] Format non-interactive project error with indented commands

* [eas-cli] Skip redundant "not configured" warning in non-interactive mode

* [eas-cli] Show the non-interactive project error on all unconfigured paths
2026-08-11 10:55:31 -07:00
Expo CI 221e01568c update CHANGELOG.md and bump STAGING in cli-versions.json 2026-08-11 16:01:58 +00:00
121 changed files with 3887 additions and 1139 deletions

No files matched your search

+16 -4
View File
@@ -6,6 +6,22 @@ This is the log of notable changes to EAS CLI and related packages.
### 🛠 Breaking changes
- [eas-cli] Build objects in `--json` output (`eas build`, `eas build:list`, `eas build:view`, `eas build:resign`) now have `app`, `updateChannel` (object with `id` and `name`), and `runtime` (object with `id` and `version`) instead of `project`, `channel`, and `runtimeVersion`. `eas update:republish --json` output likewise has `runtime` instead of `runtimeVersion`. Other update commands' `--json` output and `eas-update-metadata.json` are unchanged. ([#4021](https://github.com/expo/eas-cli/pull/4021) by [@tchayen](https://github.com/tchayen))
### 🎉 New features
- [build-tools] Upload JavaScript source maps generated by builds. ([#4170](https://github.com/expo/eas-cli/pull/4170) by [@AbbanMustafa](https://github.com/AbbanMustafa))
- [eas-cli] Set the project icon on the EAS dashboard from the app config when running `eas init`, if the project does not have one yet. ([#4177](https://github.com/expo/eas-cli/pull/4177) by [@tchayen](https://github.com/tchayen))
### 🐛 Bug fixes
- [eas-cli] Make the non-interactive "EAS project not configured" error actionable on every path: list the exact `eas init --id` / `eas init --account` commands and the accounts you can create projects in, instead of suggesting bare `eas init`, which fails the same way. ([#4153](https://github.com/expo/eas-cli/pull/4153) by [@williamgrosset](https://github.com/williamgrosset))
- [eas-cli] Set up the internal TestFlight group and invite admin testers when submitting interactively with an existing `ascAppId`, with `--no-auto-testflight-setup` to disable it, and fix the TestFlight group URL printed when adding testers partially fails. ([#4136](https://github.com/expo/eas-cli/pull/4136) by [@tchayen](https://github.com/tchayen))
### 🧹 Chores
## [21.8.0](https://github.com/expo/eas-cli/releases/tag/v21.8.0) - 2026-08-11
### 🎉 New features
- [eas-cli] Add Supabase integration foundation: GraphQL client, shared integration helpers, and provisioning utilities. ([#4130](https://github.com/expo/eas-cli/pull/4130) by [@gwdp](https://github.com/gwdp))
@@ -13,10 +29,6 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Add `eas integrations:supabase:dashboard` command. ([#4107](https://github.com/expo/eas-cli/pull/4107) by [@gwdp](https://github.com/gwdp))
- [eas-cli] Add `eas integrations:supabase:disconnect` command. ([#4108](https://github.com/expo/eas-cli/pull/4108) by [@gwdp](https://github.com/gwdp))
### 🐛 Bug fixes
### 🧹 Chores
## [21.7.1](https://github.com/expo/eas-cli/releases/tag/v21.7.1) - 2026-08-10
## [21.7.0](https://github.com/expo/eas-cli/releases/tag/v21.7.0) - 2026-08-07
+1 -1
View File
@@ -1,4 +1,4 @@
{
"STAGING": "21.7.1",
"STAGING": "21.8.0",
"PRODUCTION": "21.5.0"
}
+1 -1
View File
@@ -1,5 +1,5 @@
{
"npmClient": "yarn",
"version": "21.8.0",
"version": "22.0.0",
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
}
+7 -7
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/build-tools",
"version": "21.8.0",
"version": "22.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -38,17 +38,17 @@
"dependencies": {
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "21.0.0",
"@expo/eas-build-job": "21.6.0",
"@expo/downloader": "22.0.0",
"@expo/eas-build-job": "22.0.0",
"@expo/env": "^0.4.0",
"@expo/logger": "21.0.0",
"@expo/logger": "22.0.0",
"@expo/package-manager": "1.9.10",
"@expo/plist": "^0.3.5",
"@expo/results": "^1.0.0",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "21.7.1",
"@expo/template-file": "21.0.2",
"@expo/turtle-spawn": "21.0.0",
"@expo/steps": "22.0.0",
"@expo/template-file": "22.0.0",
"@expo/turtle-spawn": "22.0.0",
"@expo/xcpretty": "^4.3.1",
"@ngrok/ngrok": "1.7.0",
"@sentry/node": "7.77.0",
@@ -4,6 +4,7 @@ import { Artifacts, BuildContext } from '../context';
import { findAndUploadXcodeBuildLogsAsync } from '../ios/xcodeBuildLogs';
import { maybeFindAndUploadBuildArtifacts } from '../utils/artifacts';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { maybeUploadSourceMapAsync } from '../utils/sourceMaps';
export async function runBuilderWithHooksAsync<T extends BuildJob>(
ctx: BuildContext<T>,
@@ -38,6 +39,9 @@ export async function runBuilderWithHooksAsync<T extends BuildJob>(
}
await ctx.runBuildPhase(BuildPhase.UPLOAD_BUILD_ARTIFACTS, async () => {
if (buildSuccess) {
await maybeUploadSourceMapAsync(ctx);
}
await maybeFindAndUploadBuildArtifacts(ctx, {
logger: ctx.logger,
});
+11 -6
View File
@@ -29,6 +29,7 @@ import { uploadEmbeddedBundleAsync } from '../utils/expoUpdatesEmbedded';
import { Hook, runHookIfPresent } from '../utils/hooks';
import { prepareExecutableAsync } from '../utils/prepareBuildExecutable';
import { getParentAndDescendantProcessPidsAsync } from '../utils/processes';
import { isSourceMapUploadEnabled, resolveIosSourceMapPathAsync } from '../utils/sourceMaps';
const INSTALL_PODS_WARN_TIMEOUT_MS = 15 * 60 * 1000; // 15 minutes
const INSTALL_PODS_KILL_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes
@@ -168,20 +169,24 @@ async function buildInnerAsync(
fastlaneResult = await ctx.runBuildPhase(BuildPhase.RUN_FASTLANE, async () => {
const scheme = resolveScheme(ctx);
const entitlements = await readEntitlementsAsync(ctx, { scheme, buildConfiguration });
const sourceMapPath = isSourceMapUploadEnabled(ctx)
? await resolveIosSourceMapPathAsync(ctx)
: null;
return await runFastlaneGym(ctx, {
credentials,
scheme,
buildConfiguration,
entitlements,
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
? {
extraEnv: {
extraEnv: {
...(resolvedExpoUpdatesRuntimeVersion?.runtimeVersion
? {
EXPO_UPDATES_FINGERPRINT_OVERRIDE:
resolvedExpoUpdatesRuntimeVersion?.runtimeVersion,
EXPO_UPDATES_WORKFLOW_OVERRIDE: ctx.job.type,
},
}
: null),
}
: null),
...(sourceMapPath ? { SOURCEMAP_FILE: sourceMapPath } : null),
},
});
});
} finally {
@@ -38,6 +38,7 @@ describe('createInstallMaestroBuildFunction', () => {
expect(step.getOutputValueByName('maestro_version')).toBe('1.41.0');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '1.41.0',
maestro_backend: 'maestro',
});
});
@@ -70,6 +71,7 @@ describe('createInstallMaestroBuildFunction', () => {
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '2.0.10',
maestro_backend: 'maestro',
});
});
@@ -87,4 +89,141 @@ describe('createInstallMaestroBuildFunction', () => {
expect(step.getOutputValueByName('maestro_version')).toBe('2.0.10');
});
it('checks maestro-runner when selected by input', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout:
command === 'maestro-runner'
? 'maestro-runner 1.2.3\n Commit: abc123\n Built: 2026-08-13\n Go: go1.26.5\n OS/Arch: darwin/arm64\n'
: '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({ EAS_BUILD_RUNNER: 'eas-build' });
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledTimes(2);
expect(mockedSpawn.mock.calls.map(([command]) => command)).toEqual([
'maestro-runner',
'maestro-runner',
]);
expect(step.getOutputValueByName('maestro_version')).toBe('1.2.3');
expect(Datadog.distribution).toHaveBeenCalledWith('eas.maestro.install', 1, {
maestro_version: '1.2.3',
maestro_backend: 'maestro-runner',
});
});
it('selects maestro-runner from EAS_MAESTRO_BACKEND', async () => {
mockedSpawn.mockImplementation((async (command: string) => ({
stdout: command === 'maestro-runner' ? 'maestro-runner 1.2.3\n' : '',
})) as any);
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
EAS_MAESTRO_BACKEND: 'maestro-runner',
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, { callInputs: {} });
await step.executeAsync();
expect(mockedSpawn.mock.calls.map(([command]) => command)).toEqual([
'maestro-runner',
'maestro-runner',
]);
});
it('installs the latest maestro-runner version when it is missing', async () => {
let versionChecks = 0;
mockedSpawn.mockImplementation((async (command: string) => {
if (command === 'maestro-runner' && versionChecks++ === 0) {
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
}
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.2.3\n' : '' };
}) as any);
const fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
ok: true,
text: async () => '#!/bin/sh\n',
} as Response);
const originalPath = process.env.PATH;
try {
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
HOME: '/home/expo',
PATH: '/usr/bin',
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner' },
});
await step.executeAsync();
expect(fetchSpy).toHaveBeenCalledWith('https://open.devicelab.dev/install/maestro-runner');
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
[],
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
);
expect(mockedSpawn).toHaveBeenLastCalledWith(
'maestro-runner',
['--version'],
expect.objectContaining({
env: expect.objectContaining({
PATH: expect.stringMatching(/:\/home\/expo\/\.maestro-runner\/bin$/),
}),
})
);
} finally {
fetchSpy.mockRestore();
process.env.PATH = originalPath;
}
});
it('installs the requested maestro-runner version', async () => {
let versionChecks = 0;
mockedSpawn.mockImplementation((async (command: string) => {
if (command === 'maestro-runner' && versionChecks++ === 0) {
throw Object.assign(new Error('not found'), { code: 'ENOENT' });
}
return { stdout: command === 'maestro-runner' ? 'maestro-runner 1.0.9\n' : '' };
}) as any);
jest.spyOn(global, 'fetch').mockResolvedValue({
text: async () => '#!/bin/sh\n',
} as Response);
const originalPath = process.env.PATH;
try {
const installMaestro = createInstallMaestroBuildFunction();
const globalCtx = createGlobalContextMock();
globalCtx.updateEnv({
EAS_BUILD_RUNNER: 'eas-build',
HOME: '/home/expo',
PATH: '/usr/bin',
});
const step = installMaestro.createBuildStepFromFunctionCall(globalCtx, {
callInputs: { backend: 'maestro-runner', maestro_version: '1.0.9' },
});
await step.executeAsync();
expect(mockedSpawn).toHaveBeenCalledWith(
expect.stringMatching(/install_maestro_runner.*\/install_maestro_runner\.sh$/),
['--version', '1.0.9'],
expect.objectContaining({ env: expect.objectContaining({ HOME: '/home/expo' }) })
);
} finally {
jest.restoreAllMocks();
process.env.PATH = originalPath;
}
});
});
@@ -15,6 +15,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { MaestroBackend, resolveMaestroBackend } from './maestroBackend';
import { Datadog } from '../../datadog';
export function createInstallMaestroBuildFunction(): BuildFunction {
@@ -29,6 +30,11 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'backend',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
@@ -37,22 +43,28 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
}),
],
fn: async ({ logger, global }, { inputs, env, outputs }) => {
const requestedMaestroVersion = inputs.maestro_version.value as string | undefined;
const { value: currentMaestroVersion } = await asyncResult(getMaestroVersion({ env }));
const backend = resolveMaestroBackend({
input: inputs.backend.value,
env,
});
const requestedVersion = inputs.maestro_version.value as string | undefined;
const { value: currentMaestroVersion } = await asyncResult(
getMaestroVersion({ env, backend })
);
// When not running in EAS Build VM, do not modify local environment.
if (env.EAS_BUILD_RUNNER !== 'eas-build') {
const currentIsJavaInstalled = await isJavaInstalled({ env });
const currentIsIdbInstalled = await isIdbInstalled({ env });
const needsToInstallJava = backend === 'maestro' && !(await isJavaInstalled({ env }));
const needsToInstallIdb = backend === 'maestro' && !(await isIdbInstalled({ env }));
if (!currentIsJavaInstalled) {
if (needsToInstallJava) {
logger.warn(
'It seems Java is not installed. It is required to run Maestro. If the job fails, this may be the reason.'
);
logger.info('');
}
if (!currentIsIdbInstalled) {
if (needsToInstallIdb) {
logger.warn(
'It seems IDB is not installed. Maestro requires it to run flows on iOS Simulator. If the job fails, this may be the reason.'
);
@@ -61,27 +73,32 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
if (!currentMaestroVersion) {
logger.warn(
'It seems Maestro is not installed. Please install Maestro manually and rerun the job.'
`It seems ${backend} is not installed. Please install it manually and rerun the job.`
);
logger.info('');
}
// Guide is helpful in these two cases, it doesn't mention Java.
if (!currentIsIdbInstalled || !currentMaestroVersion) {
if (backend === 'maestro' && (needsToInstallIdb || !currentMaestroVersion)) {
logger.warn(
'For more info, check out Maestro installation guide: https://maestro.mobile.dev/getting-started/installing-maestro'
);
} else if (backend === 'maestro-runner' && !currentMaestroVersion) {
logger.warn(
'For more info, check out maestro-runner installation guide: https://github.com/devicelab-dev/maestro-runner#install'
);
}
if (currentMaestroVersion) {
outputs.maestro_version.set(currentMaestroVersion);
logger.info(`Maestro ${currentMaestroVersion} is ready.`);
logger.info(`${backend} ${currentMaestroVersion} is ready.`);
}
return;
}
if (!(await isJavaInstalled({ env }))) {
const needsToInstallJava = backend === 'maestro' && !(await isJavaInstalled({ env }));
if (needsToInstallJava) {
if (global.runtimePlatform === BuildRuntimePlatform.DARWIN) {
logger.info('Installing Java');
await installJavaFromGcs({ logger, env });
@@ -94,10 +111,11 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
}
// IDB is only a requirement on macOS.
if (
const needsToInstallIdb =
backend === 'maestro' &&
global.runtimePlatform === BuildRuntimePlatform.DARWIN &&
!(await isIdbInstalled({ env }))
) {
!(await isIdbInstalled({ env }));
if (needsToInstallIdb) {
logger.info('Installing IDB');
await installIdbFromBrew({ logger, env });
}
@@ -106,43 +124,100 @@ export function createInstallMaestroBuildFunction(): BuildFunction {
// and it is already installed which happens when developing on a local computer.
if (
!currentMaestroVersion ||
(requestedMaestroVersion && requestedMaestroVersion !== currentMaestroVersion)
(requestedVersion && requestedVersion !== currentMaestroVersion)
) {
await installMaestro({
version: requestedMaestroVersion,
global,
logger,
env,
});
switch (backend) {
case 'maestro':
await installMaestro({ version: requestedVersion, global, logger, env });
break;
case 'maestro-runner':
await installMaestroRunner({ version: requestedVersion, global, logger, env });
break;
}
}
const maestroVersionResult = await asyncResult(getMaestroVersion({ env }));
const maestroVersionResult = await asyncResult(getMaestroVersion({ env, backend }));
if (!maestroVersionResult.ok) {
logger.error(maestroVersionResult.reason, 'Failed to get Maestro version.');
throw new Error('Failed to ensure Maestro is installed.');
throw new Error(`Failed to ensure ${backend} is installed.`);
}
logger.info(`Maestro ${maestroVersionResult.value} is ready.`);
logger.info(`${backend} ${maestroVersionResult.value} is ready.`);
outputs.maestro_version.set(maestroVersionResult.value);
Datadog.distribution('eas.maestro.install', 1, {
maestro_version: maestroVersionResult.value,
maestro_backend: backend,
});
},
});
}
async function getMaestroVersion({ env }: { env: BuildStepEnv }): Promise<string> {
const { stdout } = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
// `maestro --version` can print an analytics notice to stdout before the version,
// e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
// Take the last version-looking token: the real version is printed after the notice, so
// this stays correct even if the notice itself contains a version-like string. Keeps the
// step output and the eas.maestro.install metric tag clean. Best-effort only: fall back to
// the raw output if none is found, so we never fail the build over a version string.
const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
return versions?.at(-1) ?? stdout.trim();
async function getMaestroVersion({
env,
backend,
}: {
env: BuildStepEnv;
backend: MaestroBackend;
}): Promise<string> {
switch (backend) {
case 'maestro': {
const { stdout } = await spawn('maestro', ['--version'], { stdio: 'pipe', env });
// `maestro --version` can print an analytics notice to stdout before the version,
// e.g. "Anonymous analytics enabled. To opt out, set MAESTRO_CLI_NO_ANALYTICS...\n2.0.10".
// Take the last version-looking token: the real version is printed after the notice.
const versions = stdout.match(/\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?/g);
return versions?.at(-1) ?? stdout.trim();
}
case 'maestro-runner': {
const { stdout } = await spawn('maestro-runner', ['--version'], { stdio: 'pipe', env });
// maestro-runner prints build information after its version. The Go runtime version in
// that output is also semver-shaped, so read only the prefixed runner version.
return /^maestro-runner\s+(\S+)/m.exec(stdout)?.[1] ?? stdout.trim();
}
}
}
async function installMaestroRunner({
global,
version,
logger,
env,
}: {
version?: string;
logger: bunyan;
global: BuildStepGlobalContext;
env: BuildStepEnv;
}): Promise<void> {
logger.info('Fetching maestro-runner install script');
const tempDirectory = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'install_maestro_runner'));
try {
const installMaestroRunnerScriptResponse = await fetch(
'https://open.devicelab.dev/install/maestro-runner'
);
const installMaestroRunnerScript = await installMaestroRunnerScriptResponse.text();
const scriptPath = path.join(tempDirectory, 'install_maestro_runner.sh');
await fs.promises.writeFile(scriptPath, installMaestroRunnerScript, { mode: 0o777 });
logger.info('Installing maestro-runner');
assert(
env.HOME,
'Failed to infer directory to install maestro-runner in: $HOME environment variable is empty.'
);
await spawn(scriptPath, version && version !== 'latest' ? ['--version', version] : [], {
logger,
env,
});
const binDir = path.join(env.HOME, '.maestro-runner', 'bin');
global.updateEnv({
...global.env,
PATH: `${global.env.PATH}:${binDir}`,
});
env.PATH = `${env.PATH}:${binDir}`;
process.env.PATH = `${process.env.PATH}:${binDir}`;
} finally {
await fs.promises.rm(tempDirectory, { force: true, recursive: true });
}
}
async function installMaestro({
@@ -0,0 +1,25 @@
import { UserError } from '@expo/eas-build-job';
import { BuildStepEnv } from '@expo/steps';
import { z } from 'zod';
export const MaestroBackendSchema = z.enum(['maestro', 'maestro-runner']).default('maestro');
export type MaestroBackend = z.output<typeof MaestroBackendSchema>;
export function resolveMaestroBackend({
input,
env,
}: {
input: unknown;
env: BuildStepEnv;
}): MaestroBackend {
const result = MaestroBackendSchema.safeParse(input || env.EAS_MAESTRO_BACKEND || undefined);
if (!result.success) {
throw new UserError(
'ERR_MAESTRO_INVALID_INPUT',
'backend and EAS_MAESTRO_BACKEND must be either "maestro" or "maestro-runner".',
{ cause: result.error }
);
}
return result.data;
}
@@ -0,0 +1,138 @@
import { BuildJob, ManagedArtifactType } from '@expo/eas-build-job';
import fs from 'fs-extra';
import { vol } from 'memfs';
import { createTestAndroidJob, createTestIosJob } from '../../__tests__/utils/job';
import { createMockLogger } from '../../__tests__/utils/logger';
import { BuildContext } from '../../context';
import { maybeUploadSourceMapAsync, resolveIosSourceMapPathAsync } from '../sourceMaps';
function enableSourceMapUpload<TJob extends BuildJob>(job: TJob): TJob {
return {
...job,
experimental: {
...job.experimental,
uploadSourceMaps: true,
},
};
}
function createContext<TJob extends BuildJob>(
job: TJob,
env: Record<string, string> = {}
): { ctx: BuildContext<TJob>; uploadArtifact: jest.Mock } {
const uploadArtifact = jest.fn(async () => ({ filename: null }));
const ctx = new BuildContext(job, {
workingdir: '/workingdir',
logBuffer: { getLogs: () => [], getPhaseLogs: () => [] },
logger: createMockLogger(),
env: {
__API_SERVER_URL: 'http://api.expo.test',
EAS_BUILD_RUNNER: 'eas-build',
...env,
},
uploadArtifact,
});
return { ctx, uploadArtifact };
}
describe(maybeUploadSourceMapAsync.name, () => {
it('uploads the final Android source map without sourcesContent', async () => {
const finalSourceMapPath =
'/workingdir/build/android/app/build/generated/sourcemaps/react/release/index.android.bundle.map';
vol.fromJSON({
[finalSourceMapPath]: JSON.stringify({
version: 3,
sources: ['App.tsx'],
sourcesContent: ['customer source'],
names: [],
mappings: '',
sections: [
{
map: {
version: 3,
sources: ['nested.ts'],
sourcesContent: ['nested customer source'],
names: [],
mappings: '',
},
},
],
}),
[`${finalSourceMapPath.slice(0, -4)}.packager.map`]: JSON.stringify({ version: 3 }),
[`${finalSourceMapPath.slice(0, -4)}.compiler.map`]: JSON.stringify({ version: 3 }),
});
const { ctx, uploadArtifact } = createContext(enableSourceMapUpload(createTestAndroidJob()));
await maybeUploadSourceMapAsync(ctx);
expect(uploadArtifact).toHaveBeenCalledWith({
artifact: {
type: ManagedArtifactType.SOURCE_MAP,
paths: ['/workingdir/observe-source-maps/android.map'],
},
logger: ctx.logger,
});
const uploadedSourceMap = await fs.readJson('/workingdir/observe-source-maps/android.map');
expect(uploadedSourceMap).not.toHaveProperty('sourcesContent');
expect(uploadedSourceMap.sections[0].map).not.toHaveProperty('sourcesContent');
});
it('does not guess when an Android build produces multiple final source maps', async () => {
vol.fromJSON({
'/workingdir/build/android/app/build/generated/sourcemaps/react/release/one.map':
JSON.stringify({ version: 3 }),
'/workingdir/build/android/other/build/generated/sourcemaps/react/release/two.map':
JSON.stringify({ version: 3 }),
});
const { ctx, uploadArtifact } = createContext(enableSourceMapUpload(createTestAndroidJob()));
const markBuildPhaseHasWarnings = jest.spyOn(ctx, 'markBuildPhaseHasWarnings');
await expect(maybeUploadSourceMapAsync(ctx)).resolves.toBeUndefined();
expect(uploadArtifact).not.toHaveBeenCalled();
expect(markBuildPhaseHasWarnings).toHaveBeenCalled();
expect(ctx.logger.warn).toHaveBeenCalledWith(
expect.objectContaining({ err: expect.any(Error) }),
'Failed to upload source map.'
);
});
it('does nothing when source-map upload is disabled', async () => {
const { ctx, uploadArtifact } = createContext(createTestAndroidJob());
await maybeUploadSourceMapAsync(ctx);
expect(uploadArtifact).not.toHaveBeenCalled();
});
it('does not upload source maps from local builds', async () => {
const { ctx, uploadArtifact } = createContext(enableSourceMapUpload(createTestAndroidJob()), {
EAS_BUILD_RUNNER: 'local-build-plugin',
});
await maybeUploadSourceMapAsync(ctx);
expect(uploadArtifact).not.toHaveBeenCalled();
});
});
describe(resolveIosSourceMapPathAsync.name, () => {
it('uses an absolute worker-owned path by default', async () => {
const { ctx } = createContext(createTestIosJob());
await expect(resolveIosSourceMapPathAsync(ctx)).resolves.toBe(
'/workingdir/observe-source-maps/main.jsbundle.map'
);
});
it('preserves a configured path relative to the iOS project', async () => {
const { ctx } = createContext(createTestIosJob(), {
SOURCEMAP_FILE: 'build/custom/main.jsbundle.map',
});
await expect(resolveIosSourceMapPathAsync(ctx)).resolves.toBe(
'/workingdir/build/ios/build/custom/main.jsbundle.map'
);
});
});
@@ -0,0 +1,123 @@
import { BuildJob, ManagedArtifactType, Platform, SystemError } from '@expo/eas-build-job';
import fg from 'fast-glob';
import fs from 'fs-extra';
import path from 'path';
import { BuildContext } from '../context';
const ANDROID_SOURCE_MAP_PATTERN = 'android/**/build/generated/sourcemaps/react/**/*.map';
const SOURCE_MAP_UPLOAD_DIRECTORY = 'observe-source-maps';
export function isSourceMapUploadEnabled(ctx: BuildContext<BuildJob>): boolean {
return !ctx.isLocal && ctx.job.experimental?.uploadSourceMaps === true;
}
export async function resolveIosSourceMapPathAsync(ctx: BuildContext<BuildJob>): Promise<string> {
const configuredPath = ctx.env.SOURCEMAP_FILE;
const sourceMapPath = configuredPath
? path.resolve(ctx.getReactNativeProjectDirectory(), 'ios', configuredPath)
: path.join(ctx.workingdir, SOURCE_MAP_UPLOAD_DIRECTORY, 'main.jsbundle.map');
await fs.ensureDir(path.dirname(sourceMapPath));
return sourceMapPath;
}
export async function maybeUploadSourceMapAsync(ctx: BuildContext<BuildJob>): Promise<void> {
if (!isSourceMapUploadEnabled(ctx)) {
return;
}
try {
const sourceMapPath = await resolveSourceMapPathAsync(ctx);
const strippedSourceMapPath = await stripSourcesContentAsync(ctx, sourceMapPath);
ctx.logger.info(`Uploading source map: ${sourceMapPath}`);
await ctx.uploadArtifact({
artifact: {
type: ManagedArtifactType.SOURCE_MAP,
paths: [strippedSourceMapPath],
},
logger: ctx.logger,
});
} catch (err: any) {
ctx.logger.warn({ err }, 'Failed to upload source map.');
ctx.markBuildPhaseHasWarnings();
}
}
async function resolveSourceMapPathAsync(ctx: BuildContext<BuildJob>): Promise<string> {
if (ctx.job.platform === Platform.IOS) {
const sourceMapPath = await resolveIosSourceMapPathAsync(ctx);
if (!(await fs.pathExists(sourceMapPath))) {
throw new SystemError(`The iOS source map was not generated at ${sourceMapPath}.`);
}
return sourceMapPath;
}
if (ctx.job.platform === Platform.ANDROID) {
const projectDir = ctx.getReactNativeProjectDirectory();
const sourceMapPaths = (
await fg(ANDROID_SOURCE_MAP_PATTERN, {
absolute: true,
cwd: projectDir,
onlyFiles: true,
})
).filter(sourceMapPath => !isIntermediateAndroidSourceMap(sourceMapPath));
if (sourceMapPaths.length === 0) {
throw new SystemError('The Android build did not generate a final composed source map.');
}
if (sourceMapPaths.length > 1) {
throw new SystemError(
`Found multiple final Android source maps: ${sourceMapPaths.join(', ')}. ` +
'Refusing to upload a source map that may not match the application archive.'
);
}
return sourceMapPaths[0];
}
throw new SystemError('Source-map upload is not supported for this build platform.');
}
function isIntermediateAndroidSourceMap(sourceMapPath: string): boolean {
return sourceMapPath.endsWith('.packager.map') || sourceMapPath.endsWith('.compiler.map');
}
async function stripSourcesContentAsync(
ctx: BuildContext<BuildJob>,
sourceMapPath: string
): Promise<string> {
const sourceMap = JSON.parse(await fs.readFile(sourceMapPath, 'utf8')) as unknown;
if (!isRecord(sourceMap) || sourceMap.version !== 3) {
throw new SystemError(`Invalid source map at ${sourceMapPath}.`);
}
removeSourcesContent(sourceMap);
const uploadDirectory = path.join(ctx.workingdir, SOURCE_MAP_UPLOAD_DIRECTORY);
const uploadPath = path.join(uploadDirectory, `${ctx.job.platform}.map`);
await fs.ensureDir(uploadDirectory);
await fs.writeFile(uploadPath, JSON.stringify(sourceMap), 'utf8');
return uploadPath;
}
function removeSourcesContent(value: unknown): void {
if (Array.isArray(value)) {
for (const child of value) {
removeSourcesContent(child);
}
return;
}
if (!isRecord(value)) {
return;
}
delete value.sourcesContent;
for (const child of Object.values(value)) {
removeSourcesContent(child);
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value);
}
@@ -1,6 +1,6 @@
{
"name": "create-eas-build-function",
"version": "21.7.1",
"version": "22.0.0",
"description": "Create functions for use in EAS Build custom builds.",
"keywords": [
"expo",
@@ -35,7 +35,7 @@
"clean": "rimraf ./build/ \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/steps": "21.7.1"
"@expo/steps": "22.0.0"
},
"devDependencies": {
"@expo/package-manager": "1.9.10",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/downloader",
"version": "21.0.0",
"version": "22.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "BUSL-1.1",
"author": "Expo <[email protected]>",
@@ -1,6 +1,6 @@
{
"name": "eas-build-cache-provider",
"version": "21.0.0",
"version": "22.0.0",
"description": "A build cache provider plugin for the Expo CLI",
"homepage": "https://github.com/expo/eas-cli",
"bugs": "https://github.com/expo/eas-cli/issues",
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@expo/eas-build-job",
"version": "21.6.0",
"version": "22.0.0",
"bugs": "https://github.com/expo/eas-cli/issues",
"license": "MIT",
"author": "Expo <[email protected]>",
@@ -25,9 +25,9 @@
"clean": "rimraf node_modules dist coverage \"*.tsbuildinfo\""
},
"dependencies": {
"@expo/logger": "21.0.0",
"@expo/logger": "22.0.0",
"@expo/results": "1.0.0",
"@expo/turtle-spawn": "21.0.0",
"@expo/turtle-spawn": "22.0.0",
"joi": "^17.13.1",
"semver": "^7.6.2",
"zod": "^4.3.5"
@@ -47,6 +47,9 @@ describe('Android.JobSchema', () => {
},
},
expoBuildUrl: 'https://expo.dev/fake/build/url',
experimental: {
uploadSourceMaps: true,
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
@@ -49,6 +49,9 @@ describe('Ios.JobSchema', () => {
},
},
expoBuildUrl: 'https://expo.dev/fake/build/url',
experimental: {
uploadSourceMaps: true,
},
initiatingUserId: randomUUID(),
appId: randomUUID(),
};
+2
View File
@@ -119,6 +119,7 @@ export interface Job {
experimental?: {
prebuildCommand?: string;
uploadSourceMaps?: boolean;
};
expoBuildUrl?: string;
githubTriggerOptions?: {
@@ -186,6 +187,7 @@ export const JobSchema = Joi.object({
experimental: Joi.object({
prebuildCommand: Joi.string(),
uploadSourceMaps: Joi.boolean(),
}),
expoBuildUrl: Joi.string().uri().optional(),
githubTriggerOptions: Joi.object({
+1
View File
@@ -1,6 +1,7 @@
export enum ManagedArtifactType {
APPLICATION_ARCHIVE = 'APPLICATION_ARCHIVE',
BUILD_ARTIFACTS = 'BUILD_ARTIFACTS',
SOURCE_MAP = 'SOURCE_MAP',
/**
* @deprecated
*/
+2
View File
@@ -133,6 +133,7 @@ export interface Job {
experimental?: {
prebuildCommand?: string;
uploadSourceMaps?: boolean;
};
expoBuildUrl?: string;
githubTriggerOptions?: {
@@ -222,6 +223,7 @@ export const JobSchema = Joi.object({
experimental: Joi.object({
prebuildCommand: Joi.string(),
uploadSourceMaps: Joi.boolean(),
}),
expoBuildUrl: Joi.string().uri().optional(),
githubTriggerOptions: Joi.object({
+162 -152
View File
File diff suppressed because it is too large. Load diff
+1199 -49
View File
File diff suppressed because it is too large. Load diff
+5 -5
View File
@@ -1,6 +1,6 @@
{
"name": "eas-cli",
"version": "21.8.0",
"version": "22.0.0",
"description": "EAS command line tool",
"keywords": [
"cli",
@@ -48,11 +48,11 @@
"@expo/code-signing-certificates": "0.0.5",
"@expo/config": "55.0.10",
"@expo/config-plugins": "55.0.7",
"@expo/eas-build-job": "21.6.0",
"@expo/eas-json": "21.6.0",
"@expo/eas-build-job": "22.0.0",
"@expo/eas-json": "22.0.0",
"@expo/env": "^1.0.0",
"@expo/json-file": "8.3.3",
"@expo/logger": "21.0.0",
"@expo/logger": "22.0.0",
"@expo/multipart-body-parser": "2.0.0",
"@expo/osascript": "2.1.4",
"@expo/package-manager": "1.9.10",
@@ -64,7 +64,7 @@
"@expo/results": "1.0.0",
"@expo/rudder-sdk-node": "1.1.1",
"@expo/spawn-async": "1.7.2",
"@expo/steps": "21.7.1",
"@expo/steps": "22.0.0",
"@expo/timeago.js": "1.0.0",
"@oclif/core": "^4.8.3",
"@oclif/plugin-autocomplete": "^3.2.40",
@@ -38,14 +38,9 @@ export function getMockAppFragment(): AppFragment {
ownerAccount: {
id: 'test-account-id',
name: 'testuser',
users: [
{
role: Role.Owner,
actor: {
id: 'test-user-id',
},
},
],
viewerUserPermission: {
role: Role.Owner,
},
},
};
}
@@ -180,7 +180,7 @@ describe(WorkflowLogView, () => {
createdAt: '2022-01-01T00:00:00.000Z',
updatedAt: '2022-01-01T00:00:00.000Z',
isForIosSimulator: false,
project: {
app: {
id: mockProjectId,
__typename: 'App',
name: 'App',
@@ -74,7 +74,7 @@ function createMockBuildFragment({
id: buildId ?? uuid(),
logFiles: [],
priority: BuildPriority.Normal,
project: {
app: {
__typename: 'App',
slug: 'test-project',
id: projectId,
@@ -74,7 +74,7 @@ function createMockBuildFragment({
platform: AppPlatform.Android,
id: buildId ?? uuid(),
priority: BuildPriority.Normal,
project: {
app: {
__typename: 'App',
slug: 'test-project',
id: projectId,
@@ -104,6 +104,7 @@ export async function prepareJobAsync(
}),
experimental: {
prebuildCommand: buildProfile.prebuildCommand,
uploadSourceMaps: buildProfile.uploadSourceMaps,
},
mode: buildMode,
triggeredBy: BuildTrigger.EAS_CLI,
@@ -99,6 +99,7 @@ export async function prepareJobAsync(
}),
experimental: {
prebuildCommand: buildProfile.prebuildCommand,
uploadSourceMaps: buildProfile.uploadSourceMaps,
},
mode: buildMode,
triggeredBy: BuildTrigger.EAS_CLI,
-1
View File
@@ -76,7 +76,6 @@ export async function collectMetadataAsync<T extends Platform>(
customWorkflowName: ctx.customBuildConfigMetadata?.workflowName,
developmentClient: ctx.developmentClient,
requiredPackageManager: ctx.requiredPackageManager ?? undefined,
selectedImage: ctx.buildProfile.image,
customNodeVersion: ctx.buildProfile.node,
simulator: 'simulator' in ctx.buildProfile && ctx.buildProfile.simulator,
};
@@ -597,7 +597,7 @@ export async function downloadAndRunAsync(
simulator?: SimulatorRunTarget
): Promise<void> {
assert(build.artifacts?.applicationArchiveUrl);
const cachedAppPath = getEasBuildRunCachedAppPath(build.project.id, build.id, build.platform);
const cachedAppPath = getEasBuildRunCachedAppPath(build.app.id, build.id, build.platform);
if (await pathExists(cachedAppPath)) {
Log.newLine();
@@ -93,7 +93,7 @@ export function formatGraphQLBuild(build: BuildFragment): string {
},
{
label: 'Channel',
value: build.channel,
value: build.updateChannel?.name,
},
{
label: 'SDK Version',
@@ -101,7 +101,7 @@ export function formatGraphQLBuild(build: BuildFragment): string {
},
{
label: 'Runtime Version',
value: build.runtimeVersion,
value: build.runtime?.version,
},
{
label: 'Version',
+5 -8
View File
@@ -19,13 +19,10 @@ export function getProjectPageUrl(
}
export function getBuildLogsUrl(build: BuildFragment, hash?: string): string {
const { project } = build;
const url =
project.__typename !== 'App'
? `/builds/${build.id}`
: `/accounts/${project.ownerAccount.name}/projects/${project.slug}/builds/${build.id}${
hash ? `#${hash}` : ''
}`;
const { app } = build;
const url = `/accounts/${app.ownerAccount.name}/projects/${app.slug}/builds/${build.id}${
hash ? `#${hash}` : ''
}`;
return new URL(url, getExpoWebsiteBaseUrl()).toString();
}
@@ -37,7 +34,7 @@ export function getArtifactUrl(artifactId: string): string {
export function getInternalDistributionInstallUrl(build: BuildFragment): string {
if (build.platform === AppPlatform.Ios) {
return `itms-services://?action=download-manifest;url=${getExpoApiBaseUrl()}/v2/projects/${
build.project.id
build.app.id
}/builds/${build.id}/manifest.plist`;
}
@@ -11,7 +11,7 @@ export const testUpdateBranch1: UpdateBranchObject = {
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
message: 'fix bug',
createdAt: '2023-07-17T22:48:59.278Z',
runtimeVersion: 'exposdk:48.0.0',
runtime: { id: 'runtime-exposdk-48', version: 'exposdk:48.0.0' },
platform: 'ios',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"7d40544b395c5949f4646f5e150fe020","fileSHA256":"LN_rjlzN55dvcBL7jM5zryIpAp7iHU8VCf7RFIccbNg","storageKey":"Pi65ns2w9xLI5G_m9aGrJ5wK5FpWcGcInSSFwnA_0aA","contentType":"image/png","fileExtension":".png"},{"bundleKey":"cdd04e13d4ec83ff0cd13ec8dabdc341","fileSHA256":"irHji01LPI9giDYxtJo9bTPHaEHzuUcCMTlb4xn90Ks","storageKey":"oJZ9JX1m8IKNDgnIqcKOo5KUFOoa_NCaxruigZu65E0","contentType":"image/png","fileExtension":".png"},{"bundleKey":"a132ecc4ba5c1517ff83c0fb321bc7fc","fileSHA256":"K3RDqaWOksoRpXW9wRWYYVpX3eMFzYznDWAmBrMCzZg","storageKey":"X3OAkUu5P_LqyKFx1bNkA_qDQFLlSjCYNAlVEaOamHE","contentType":"image/png","fileExtension":".png"},{"bundleKey":"0ea69b5077e7c4696db85dbcba75b0e1","fileSHA256":"Pd0Hc-0n4j0leJ8wFzGzrEVa2L5aonoKG4OPicbSciU","storageKey":"FwJkAbrRNahBGX1KXnTAgiiwyzENu3D7DeEzCwmInyo","contentType":"image/png","fileExtension":".png"},{"bundleKey":"f5b790e2ac193b3d41015edb3551f9b8","fileSHA256":"vCfIss7SLYKt1AAi0hxjsgOMEDceLJB6ZWjQf6gTU4k","storageKey":"GQtYm7r0G9N3WJyrymjZ26p8eNMIb7rzqwSVgGco5EU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"3d0ac172fe41ab941a8c68cd7e3a17ee","fileSHA256":"jjnar-ZPrb_g0Bx5tcFfjJrP3v8HdeAlXMk__xN7I1U","storageKey":"bimf-UXq5IFH5nNHCIHdcQ2MyJwTnwDVBb-P8hr2CZw","contentType":"application/javascript","fileExtension":".bundle"}}',
@@ -37,7 +37,7 @@ export const testUpdateBranch1: UpdateBranchObject = {
group: '16ca6dba-e63b-48b0-baa3-15a894ee9434',
message: 'fix bug',
createdAt: '2023-07-17T22:48:59.278Z',
runtimeVersion: 'exposdk:48.0.0',
runtime: { id: 'runtime-exposdk-48', version: 'exposdk:48.0.0' },
platform: 'android',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"778ffc9fe8773a878e9c30a6304784de","fileSHA256":"i2Gkx-9w3JJ1PwSUl2SC9m_UFQ7CPfx3KrZeEDc6-lU","storageKey":"idnX8z03q4vGLzhYMPaQGBADjhqvWe67_afPqJ1vGzM","contentType":"image/png","fileExtension":".png"},{"bundleKey":"376d6a4c7f622917c39feb23671ef71d","fileSHA256":"QJsG0VpGM-5viirUgoKq0M4zQ_TYyPfLhAhN5mrM54I","storageKey":"46DQ95-UdSKiRkQgt5FyXkE1FrKOT9tBZP3nS7auKlg","contentType":"image/png","fileExtension":".png"},{"bundleKey":"c79c3606a1cf168006ad3979763c7e0c","fileSHA256":"kGZm3WiTRq2iMs42ia3vkHtCV_obWT8DY40rlJf2SIQ","storageKey":"k-3xHp3vP8mR36WdccUZVPTRJKpP-zlyboWdkXCIQpQ","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02bc1fa7c0313217bde2d65ccbff40c9","fileSHA256":"_6fuRbdkBbpzkhSVAI99aMneY5X0tpQdsGNGX244IyA","storageKey":"lfqeUjiWFXwowhxVDBEzxsbTOEzYxMLebljy5Bh1mpU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"35ba0eaec5a4f5ed12ca16fabeae451d","fileSHA256":"hM9es7ICUPaeDkczvrTaX0F_BoKJKlrRteYlcHU8IbE","storageKey":"fMoMrsUeB5xWw_Ugn8FqGJf-M2pld2hkXKR704z0MM8","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"4cc2ee49988e438ac066827b809721de","fileSHA256":"ctfK72f8EdS1IQmDvJLya38RMSma16beJcMoQToZIe0","storageKey":"QdEoKuCQPvVPA71gY52wqJ9vmtV4ObnCcPE2FP4SIdo","contentType":"application/javascript","fileExtension":".bundle"}}',
@@ -73,7 +73,7 @@ export const testUpdateBranch2: UpdateBranchObject = {
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
message: 'fix bug',
createdAt: '2023-06-23T23:37:10.004Z',
runtimeVersion: 'exposdk:48.0.0',
runtime: { id: 'runtime-exposdk-48', version: 'exposdk:48.0.0' },
platform: 'ios',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"7d40544b395c5949f4646f5e150fe020","fileSHA256":"LN_rjlzN55dvcBL7jM5zryIpAp7iHU8VCf7RFIccbNg","storageKey":"Pi65ns2w9xLI5G_m9aGrJ5wK5FpWcGcInSSFwnA_0aA","contentType":"image/png","fileExtension":".png"},{"bundleKey":"cdd04e13d4ec83ff0cd13ec8dabdc341","fileSHA256":"irHji01LPI9giDYxtJo9bTPHaEHzuUcCMTlb4xn90Ks","storageKey":"oJZ9JX1m8IKNDgnIqcKOo5KUFOoa_NCaxruigZu65E0","contentType":"image/png","fileExtension":".png"},{"bundleKey":"a132ecc4ba5c1517ff83c0fb321bc7fc","fileSHA256":"K3RDqaWOksoRpXW9wRWYYVpX3eMFzYznDWAmBrMCzZg","storageKey":"X3OAkUu5P_LqyKFx1bNkA_qDQFLlSjCYNAlVEaOamHE","contentType":"image/png","fileExtension":".png"},{"bundleKey":"0ea69b5077e7c4696db85dbcba75b0e1","fileSHA256":"Pd0Hc-0n4j0leJ8wFzGzrEVa2L5aonoKG4OPicbSciU","storageKey":"FwJkAbrRNahBGX1KXnTAgiiwyzENu3D7DeEzCwmInyo","contentType":"image/png","fileExtension":".png"},{"bundleKey":"f5b790e2ac193b3d41015edb3551f9b8","fileSHA256":"vCfIss7SLYKt1AAi0hxjsgOMEDceLJB6ZWjQf6gTU4k","storageKey":"GQtYm7r0G9N3WJyrymjZ26p8eNMIb7rzqwSVgGco5EU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"76ec49faddaef30ba78fe50edc5ab3d6","fileSHA256":"OLUdgP-fZI2shwGS5qlIk7nWnybL_13RJl5xR85CDBU","storageKey":"OCvI0-34zApbdo3eSGfIaQUbWdZeLeKH-nJSeedKTFs","contentType":"application/javascript","fileExtension":".bundle"}}',
@@ -99,7 +99,7 @@ export const testUpdateBranch2: UpdateBranchObject = {
group: 'e40ad156-e9af-4cc2-8e9d-c7b5c328db48',
message: 'fix bug',
createdAt: '2023-06-23T23:37:10.004Z',
runtimeVersion: 'exposdk:48.0.0',
runtime: { id: 'runtime-exposdk-48', version: 'exposdk:48.0.0' },
platform: 'android',
manifestFragment:
'{"extra":{"expoClient":{"ios":{"supportsTablet":true,"bundleIdentifier":"com.quintest113.updatestest4"},"web":{"favicon":"./assets/favicon.png"},"icon":"./assets/icon.png","name":"updates-test-4","slug":"updates-test-4","extra":{"eas":{"projectId":"789a8784-0186-4f41-8bd2-7f219291a2b8"}},"splash":{"image":"./assets/splash.png","resizeMode":"contain","backgroundColor":"#ffffff"},"android":{"package":"com.quintest113.updatestest4","adaptiveIcon":{"backgroundColor":"#ffffff","foregroundImage":"./assets/adaptive-icon.png"}},"updates":{"url":"https://u.expo.dev/789a8784-0186-4f41-8bd2-7f219291a2b8"},"version":"1.0.0","platforms":["ios","android"],"sdkVersion":"48.0.0","orientation":"portrait","runtimeVersion":{"policy":"sdkVersion"},"currentFullName":"@quintest113/updates-test-4","originalFullName":"@quintest113/updates-test-4","userInterfaceStyle":"light","assetBundlePatterns":["**/*"]}},"assets":[{"bundleKey":"778ffc9fe8773a878e9c30a6304784de","fileSHA256":"i2Gkx-9w3JJ1PwSUl2SC9m_UFQ7CPfx3KrZeEDc6-lU","storageKey":"idnX8z03q4vGLzhYMPaQGBADjhqvWe67_afPqJ1vGzM","contentType":"image/png","fileExtension":".png"},{"bundleKey":"376d6a4c7f622917c39feb23671ef71d","fileSHA256":"QJsG0VpGM-5viirUgoKq0M4zQ_TYyPfLhAhN5mrM54I","storageKey":"46DQ95-UdSKiRkQgt5FyXkE1FrKOT9tBZP3nS7auKlg","contentType":"image/png","fileExtension":".png"},{"bundleKey":"c79c3606a1cf168006ad3979763c7e0c","fileSHA256":"kGZm3WiTRq2iMs42ia3vkHtCV_obWT8DY40rlJf2SIQ","storageKey":"k-3xHp3vP8mR36WdccUZVPTRJKpP-zlyboWdkXCIQpQ","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02bc1fa7c0313217bde2d65ccbff40c9","fileSHA256":"_6fuRbdkBbpzkhSVAI99aMneY5X0tpQdsGNGX244IyA","storageKey":"lfqeUjiWFXwowhxVDBEzxsbTOEzYxMLebljy5Bh1mpU","contentType":"image/png","fileExtension":".png"},{"bundleKey":"35ba0eaec5a4f5ed12ca16fabeae451d","fileSHA256":"hM9es7ICUPaeDkczvrTaX0F_BoKJKlrRteYlcHU8IbE","storageKey":"fMoMrsUeB5xWw_Ugn8FqGJf-M2pld2hkXKR704z0MM8","contentType":"image/png","fileExtension":".png"},{"bundleKey":"5223c8d9b0d08b82a5670fb5f71faf78","fileSHA256":"LJXowmdeeZM1_QBGuwfb2gbLfs_1HtN5IsYwnWGcxNM","storageKey":"jeqOrdQBNY4hgl04B3wtGs8QNWMDmjvbjIMUXPtOBpw","contentType":"image/png","fileExtension":".png"},{"bundleKey":"02d5848f62ade9617cf96894727d32e7","fileSHA256":"u1-8zHOj9oxS8maH-7JbShJIq1-KEV7FXnrGpEUcR-4","storageKey":"LMUvUZZpdGFb7KqaB_l9N4TXTkFn1Pw92ws9fWDNZPw","contentType":"model/gltf-binary","fileExtension":".glb"},{"bundleKey":"6013c27e421ab23c826713af3ebec891","fileSHA256":"dutQNnvzTCS4NgwvbeMbvhro5QnW0GJUeY_4rrvlcLY","storageKey":"85KB9Jr7eqz1Jwt1mYQGRzTy6ZXyO0j7ZcaLImCbzkU","contentType":"image/jpeg","fileExtension":".jpg"}],"launchAsset":{"bundleKey":"f4c6981626dbabcf51078d07a1611f4e","fileSHA256":"q-k1hSWl49aXpSg4jOSaVVnYFSFL6xf7HVl_TQE-ObM","storageKey":"FwK1PaZqtpL0QZIgaHsxU1AV93JhMdu9K1wZizXROAc","contentType":"application/javascript","fileExtension":".bundle"}}',
@@ -46,7 +46,11 @@ function makeInsights(
id: u.id,
group: u.group,
message: u.message,
runtimeVersion: u.runtimeVersion,
runtime: {
__typename: 'Runtime' as const,
id: `${u.id}-runtime`,
version: u.runtimeVersion,
},
platform: u.platform,
insights: {
__typename: 'UpdateInsights' as const,
@@ -4,7 +4,7 @@ import { anything, instance, mock, when } from 'ts-mockito';
import { Role } from '../../../../graphql/generated';
import { AppQuery } from '../../../../graphql/queries/AppQuery';
import { learnMore } from '../../../../log';
import Log, { learnMore } from '../../../../log';
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { isExpoInstalled } from '../../../../project/projectUtils';
import { promptAsync } from '../../../../prompts';
@@ -32,6 +32,8 @@ describe(getProjectIdAsync, () => {
beforeEach(() => {
jest.clearAllMocks();
jest.spyOn(Log, 'warn').mockImplementation(() => {});
jest
.mocked(getConfigFilePaths)
.mockReturnValue({ staticConfigPath: null, dynamicConfigPath: null });
@@ -46,18 +48,18 @@ describe(getProjectIdAsync, () => {
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
viewerUserPermission: { role: Role.Owner },
},
{
id: 'account_id_2',
name: 'dominik',
users: [{ role: Role.ViewOnly, actor: { id: 'user_id' } }],
viewerUserPermission: { role: Role.ViewOnly },
},
],
isExpoAdmin: false,
@@ -196,12 +198,12 @@ describe(getProjectIdAsync, () => {
{
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Admin, actor: { id: 'robot_id' } }],
viewerUserPermission: { role: Role.Admin },
},
{
id: 'account_id_2',
name: 'dominik',
users: [{ role: Role.ViewOnly, actor: { id: 'robot_id' } }],
viewerUserPermission: { role: Role.ViewOnly },
},
],
isExpoAdmin: false,
@@ -379,6 +381,7 @@ describe(getProjectIdAsync, () => {
).resolves.toEqual('2345');
expect(promptAsync).toHaveBeenCalledTimes(1);
expect(Log.warn).toHaveBeenCalledWith('EAS project not configured.');
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).toHaveBeenCalledWith(
expect.anything(),
{ accountName: 'dominik', projectName: 'test' },
@@ -420,13 +423,13 @@ describe(getProjectIdAsync, () => {
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
viewerUserPermission: { role: Role.Owner },
},
],
isExpoAdmin: false,
@@ -462,7 +465,7 @@ describe(getProjectIdAsync, () => {
{
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Admin, actor: { id: 'robot_id' } }],
viewerUserPermission: { role: Role.Admin },
},
],
isExpoAdmin: false,
@@ -478,7 +481,14 @@ describe(getProjectIdAsync, () => {
{ nonInteractive: false }
)
).rejects.toThrow(
'Project is not configured. When using a robot access token, run "eas init --account <name> --non-interactive" or set the "owner" field in your app config before managing the project. Accounts this token can create projects in: notnotbrent'
`EAS project not configured. A robot access token cannot configure it interactively. ` +
`Run one of the following, then re-run this command:\n\n` +
`To link an existing project:\n\n` +
` eas init --id <project-id> --non-interactive\n\n` +
`To create a new project:\n\n` +
` eas init --account <account-name> --non-interactive\n\n` +
`Accounts you can create projects in: notnotbrent\n\n` +
`Alternatively, set the "owner" field in your app config.`
);
expect(promptAsync).not.toHaveBeenCalled();
@@ -493,12 +503,73 @@ describe(getProjectIdAsync, () => {
{ nonInteractive: true }
)
).rejects.toThrow(
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
`EAS project not configured. This command cannot configure it in non-interactive mode. ` +
`Run one of the following, then re-run this command:\n\n` +
`To link an existing project:\n\n` +
` eas init --id <project-id> --non-interactive\n\n` +
`To create a new project:\n\n` +
` eas init --account <account-name> --non-interactive\n\n` +
`Accounts you can create projects in: notnotbrent`
);
expect(promptAsync).not.toHaveBeenCalled();
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).not.toHaveBeenCalled();
});
it('defers to the project fetch in non-interactive mode when the account is already known', async () => {
const sessionManagerMock = mock<SessionManager>();
when(sessionManagerMock.ensureLoggedInAsync(anything())).thenResolve({
actor: {
__typename: 'User',
id: 'user_id',
email: '[email protected]',
username: 'notnotbrent',
primaryAccount: {
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
accounts: [
{
id: 'account_id_1',
name: 'notnotbrent',
users: [{ role: Role.Owner, actor: { id: 'user_id' } }],
},
],
isExpoAdmin: false,
featureGates: {},
},
authenticationInfo: { accessToken: 'fake', sessionSecret: null },
} as any);
await expect(
getProjectIdAsync(
instance(sessionManagerMock),
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
{ nonInteractive: true }
)
).resolves.toEqual('2345');
expect(promptAsync).not.toHaveBeenCalled();
expect(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync).toHaveBeenCalledWith(
expect.anything(),
{ accountName: 'notnotbrent', projectName: 'test' },
{ nonInteractive: true },
expect.anything()
);
});
it('does not warn that the project is not configured in non-interactive mode', async () => {
await expect(
getProjectIdAsync(
sessionManager,
{ sdkVersion: '52.0.0', name: 'test', slug: 'test' },
{ nonInteractive: true }
)
).rejects.toThrow();
expect(Log.warn).not.toHaveBeenCalledWith('EAS project not configured.');
});
});
it('throws if writing the ID back to the config fails', async () => {
@@ -17,9 +17,10 @@ import {
getPrivateExpoConfigAsync,
} from '../../../project/expoConfig';
import { fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { getUnconfiguredProjectError } from '../../../project/projectNotConfiguredError';
import { promptAsync } from '../../../prompts';
import SessionManager from '../../../user/SessionManager';
import { Actor, getActorUsername, getCreatableAccountNames } from '../../../user/User';
import { Actor, getActorUsername } from '../../../user/User';
/**
* Save an EAS project ID to the appropriate field in the app config.
@@ -183,7 +184,9 @@ export async function validateOrSetProjectIdAsync({
throw new Error('This command must be run inside a project directory.');
}
Log.warn('EAS project not configured.');
if (!options.nonInteractive) {
Log.warn('EAS project not configured.');
}
const accountName = await chooseAccountNameForEASProjectAsync(exp, actor, options);
@@ -226,11 +229,11 @@ async function chooseAccountNameForEASProjectAsync(
}
if (actor.__typename === 'Robot') {
throw new Error(
`Project is not configured. When using a robot access token, run "eas init --account <name> --non-interactive" or set the "owner" field in your app config before managing the project. Accounts this token can create projects in: ${getCreatableAccountNames(
actor
).join(', ')}`
);
throw getUnconfiguredProjectError({
actor,
reason: 'A robot access token cannot configure it interactively.',
additionalFix: 'Alternatively, set the "owner" field in your app config.',
});
}
const allAccounts = actor.accounts;
@@ -239,9 +242,7 @@ async function chooseAccountNameForEASProjectAsync(
}
if (options.nonInteractive) {
throw new Error(
`Must configure EAS project by running 'eas init' before this command can be run in non-interactive mode.`
);
throw getUnconfiguredProjectError({ actor });
}
const choices = getAccountChoices(
@@ -2,7 +2,7 @@ import { ExpoConfig } from '@expo/config';
import fs from 'fs-extra';
import { getEASUpdateURL } from '../../../api';
import { AppFragment } from '../../../graphql/generated';
import { AppFragment, Role } from '../../../graphql/generated';
import { easCliVersion } from '../../../utils/easCli';
import {
cleanAndPrefix,
@@ -62,7 +62,7 @@ describe('projectFiles', () => {
ownerAccount: {
id: 'testowner-account-id',
name: 'testowner',
users: [],
viewerUserPermission: { role: Role.Owner },
},
...overrides,
});
@@ -126,7 +126,7 @@ describe('projectFiles', () => {
ownerAccount: {
id: 'test-owner-account-id',
name: 'test-owner',
users: [],
viewerUserPermission: { role: Role.Owner },
},
});
@@ -151,7 +151,7 @@ describe('projectFiles', () => {
ownerAccount: {
id: 'test-owner-123-account-id',
name: 'test-owner-123',
users: [],
viewerUserPermission: { role: Role.Owner },
},
});
@@ -80,8 +80,7 @@ function getAccountPermissionsMap(actor: Actor): Map<string, boolean> {
const permissionsMap = new Map<string, boolean>();
for (const account of actor.accounts) {
const hasPermission =
account.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly;
const hasPermission = account.viewerUserPermission.role !== Role.ViewOnly;
permissionsMap.set(account.name, hasPermission);
}
@@ -1,5 +1,4 @@
import chalk from 'chalk';
import nullthrows from 'nullthrows';
import EasCommand from '../../commandUtils/EasCommand';
import { Role } from '../../graphql/generated';
@@ -52,7 +51,7 @@ export default class AccountView extends EasCommand {
return Role.Owner;
}
return nullthrows(account.users.find(user => user.actor.id === actor.id)?.role);
return account.viewerUserPermission.role;
}
private static getLabelForRole(role: Role): string {
@@ -27,8 +27,8 @@ async function cancelBuildAsync(
.mutation<CancelBuildMutation, CancelBuildMutationVariables>(
gql`
mutation CancelBuildMutation($buildId: ID!) {
build(buildId: $buildId) {
cancel {
build {
cancelBuild(buildId: $buildId) {
id
status
}
@@ -39,7 +39,7 @@ async function cancelBuildAsync(
)
.toPromise()
);
return data.build.cancel;
return data.build.cancelBuild;
}
export async function selectBuildToCancelAsync(
@@ -235,7 +235,7 @@ export default class Download extends EasCommand {
async getPathToBuildArtifactAsync(build: BuildFragment, platform: AppPlatform): Promise<string> {
const cachedBuildArtifactPath = getEasBuildRunCachedAppPath(
build.project.id,
build.app.id,
build.id,
platform
);
@@ -26,7 +26,7 @@ const insightsResponse = {
id: 'u1',
group: 'group-a',
message: 'first',
runtimeVersion: '1.0.0',
runtime: { __typename: 'Runtime' as const, id: 'u1-runtime', version: '1.0.0' },
platform: 'ios',
insights: { __typename: 'UpdateInsights' as const, id: 'u1-i', totalUniqueUsers: 200 },
},
+3 -56
View File
@@ -1,5 +1,5 @@
import { ExpoConfig, getConfigFilePaths } from '@expo/config';
import { App, User, UserRole } from '@expo/apple-utils';
import { App } from '@expo/apple-utils';
import { Flags } from '@oclif/core';
import chalk from 'chalk';
import * as fs from 'fs-extra';
@@ -15,6 +15,7 @@ import { SetUpAscApiKey } from '../credentials/ios/actions/SetUpAscApiKey';
import { SetUpBuildCredentials } from '../credentials/ios/actions/SetUpBuildCredentials';
import { SetUpPushKey } from '../credentials/ios/actions/SetUpPushKey';
import { ensureAppExistsAsync } from '../credentials/ios/appstore/ensureAppExists';
import { ensureTestFlightGroupExistsAsync } from '../credentials/ios/appstore/ensureTestFlightGroup';
import { Target } from '../credentials/ios/types';
import {
WorkflowJobStatus,
@@ -60,62 +61,8 @@ export async function detectProjectSdkVersionAsync(
}
}
const TESTFLIGHT_GROUP_NAME = 'Team (Expo)';
async function setupTestFlightAsync(ascApp: App): Promise<void> {
let group;
for (let attempt = 0; attempt < 10; attempt++) {
try {
const groups = await ascApp.getBetaGroupsAsync({
query: { includes: ['betaTesters'] },
});
group = groups.find(
g => g.attributes.isInternalGroup && g.attributes.name === TESTFLIGHT_GROUP_NAME
);
if (!group) {
group = await ascApp.createBetaGroupAsync({
name: TESTFLIGHT_GROUP_NAME,
isInternalGroup: true,
hasAccessToAllBuilds: true,
});
}
break;
} catch (error: any) {
// Apple returns this error when the app isn't ready yet
if (error?.data?.errors?.some((e: any) => e.code === 'ENTITY_ERROR.RELATIONSHIP.INVALID')) {
if (attempt < 9) {
await sleepAsync(10_000);
continue;
}
}
throw error;
}
}
if (!group) {
throw new Error('Failed to create TestFlight group');
}
const users = await User.getAsync(ascApp.context);
const admins = users.filter(u => u.attributes.roles?.includes(UserRole.ADMIN));
const existingEmails = new Set(
group.attributes.betaTesters?.map((t: any) => t.attributes.email?.toLowerCase()) ?? []
);
const newTesters = admins
.filter(u => u.attributes.email && !existingEmails.has(u.attributes.email.toLowerCase()))
.map(u => ({
email: u.attributes.email!,
firstName: u.attributes.firstName ?? '',
lastName: u.attributes.lastName ?? '',
}));
if (newTesters.length > 0) {
await group.createBulkBetaTesterAssignmentsAsync(newTesters);
}
await ensureTestFlightGroupExistsAsync(ascApp);
}
/* eslint-disable no-console */
@@ -6,6 +6,7 @@ import { AppStoreConnectApiKeyQuery } from '../../../../credentials/ios/api/grap
import { AscAppLinkMutation } from '../../../../graphql/mutations/AscAppLinkMutation';
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
import IntegrationsAscConnect from '../connect';
import { Role } from '../../../../graphql/generated';
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
@@ -18,7 +19,12 @@ const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
@@ -34,7 +40,12 @@ const mockMetadataConnected = {
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
appStoreConnectApp: null,
};
@@ -8,6 +8,7 @@ import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../../log';
import { toggleConfirmAsync } from '../../../../prompts';
import IntegrationsAscDisconnect from '../disconnect';
import { Role } from '../../../../graphql/generated';
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
jest.mock('../../../../graphql/mutations/AscAppLinkMutation');
@@ -19,7 +20,12 @@ const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
@@ -35,7 +41,12 @@ const mockMetadataConnected = {
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
appStoreConnectApp: null,
};
@@ -6,6 +6,7 @@ import { ExpoGraphqlClient } from '../../../../commandUtils/context/contextUtils
import { AscAppLinkQuery } from '../../../../graphql/queries/AscAppLinkQuery';
import Log from '../../../../log';
import IntegrationsAscStatus from '../status';
import { Role } from '../../../../graphql/generated';
jest.mock('../../../../graphql/queries/AscAppLinkQuery');
jest.mock('../../../../log');
@@ -15,7 +16,12 @@ const testProjectId = 'test-project-id';
const mockMetadataConnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
@@ -31,7 +37,12 @@ const mockMetadataConnected = {
const mockMetadataDisconnected = {
id: testProjectId,
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
appStoreConnectApp: null,
};
@@ -9,6 +9,7 @@ import {
EnvironmentSecretType,
EnvironmentVariableScope,
EnvironmentVariableVisibility,
Role,
} from '../../../../graphql/generated';
import { ConvexMutation } from '../../../../graphql/mutations/ConvexMutation';
import { EnvironmentVariableMutation } from '../../../../graphql/mutations/EnvironmentVariableMutation';
@@ -59,7 +60,7 @@ describe(IntegrationsConvexConnect, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: null,
users: [],
viewerUserPermission: { role: Role.Owner },
},
accounts: [],
};
@@ -76,7 +77,7 @@ describe(IntegrationsConvexConnect, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
viewerUserPermission: { role: 'OWNER' as any },
};
const mockConnection: ConvexTeamConnectionData = {
@@ -32,7 +32,7 @@ describe(IntegrationsConvexTeamDelete, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
viewerUserPermission: { role: 'OWNER' as any },
};
const mockConnection: ConvexTeamConnectionData = {
@@ -41,7 +41,7 @@ describe(IntegrationsConvexTeamInvite, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
viewerUserPermission: { role: 'OWNER' as any },
};
const mockConnection: ConvexTeamConnectionData = {
@@ -21,7 +21,7 @@ describe(IntegrationsConvexTeam, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: 'OWNER' as any, actor: { id: 'test-user-id' } }],
viewerUserPermission: { role: 'OWNER' as any },
};
const mockConnection: ConvexTeamConnectionData = {
@@ -62,7 +62,12 @@ describe(IntegrationsPostHogConnect, () => {
email: '[email protected]',
featureGates: {},
isExpoAdmin: false,
primaryAccount: { id: testAccountId, name: testAccountName, ownerUserActor: null, users: [] },
primaryAccount: {
id: testAccountId,
name: testAccountName,
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
accounts: [],
};
@@ -70,7 +75,7 @@ describe(IntegrationsPostHogConnect, () => {
id: testAccountId,
name: testAccountName,
ownerUserActor: { id: 'test-user-id', username: testAccountName },
users: [{ role: Role.Owner, actor: { id: 'test-user-id' } }],
viewerUserPermission: { role: Role.Owner },
};
const mockConnection: PostHogOrganizationConnectionData = {
@@ -15,6 +15,7 @@ import { AppMutation } from '../../../graphql/mutations/AppMutation';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { createOrModifyExpoConfigAsync } from '../../../project/expoConfig';
import { findProjectIdByAccountNameAndSlugNullableAsync } from '../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync';
import { maybeSetProjectIconFromAppConfigAsync } from '../../../project/projectIcon';
import { isExpoInstalled } from '../../../project/projectUtils';
import { confirmAsync, promptAsync } from '../../../prompts';
import { enableJsonOutput, printJsonOnlyOutput } from '../../../utils/json';
@@ -36,6 +37,7 @@ jest.mock('../../../ora', () => ({
}),
}));
jest.mock('../../../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync');
jest.mock('../../../project/projectIcon');
jest.mock('../../../commandUtils/context/contextUtils/getProjectIdAsync');
jest.mock('../../../project/projectUtils');
jest.mock('../../../utils/json');
@@ -887,4 +889,67 @@ describe(ProjectInit.name, () => {
expect(printJsonOnlyOutput).not.toHaveBeenCalled();
});
});
describe('project icon', () => {
beforeEach(() => {
mockTestProject({ configuredProjectId: '1234', configuredOwner: jester.accounts[0].name });
jest.mocked(AppQuery.byIdAsync).mockResolvedValue({
id: '1234',
slug: 'testing-123',
name: 'testing-123',
fullName: '@jester/testing-123',
ownerAccount: jester.accounts[0],
});
});
it('sets the icon from the app config', async () => {
await new ProjectInit([], commandOptions).run();
expect(jest.mocked(maybeSetProjectIconFromAppConfigAsync).mock.calls[0][1]).toEqual({
projectId: '1234',
projectDir: '/test-project',
});
});
it('does not set the icon with --no-icon', async () => {
await new ProjectInit(['--no-icon'], commandOptions).run();
expect(maybeSetProjectIconFromAppConfigAsync).not.toHaveBeenCalled();
});
it('reports the icon source in JSON output', async () => {
jest.mocked(maybeSetProjectIconFromAppConfigAsync).mockResolvedValue({
status: 'set',
icon: { field: 'icon', path: '/test-project/assets/icon.png' },
});
await new ProjectInit(['--json', '--non-interactive'], commandOptions).run();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
status: 'already-linked',
projectId: '1234',
owner: 'jester',
slug: 'testing-123',
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
icon: { source: 'icon' },
});
});
it('omits the icon from JSON output when it was not set', async () => {
jest.mocked(maybeSetProjectIconFromAppConfigAsync).mockResolvedValue({
status: 'skipped',
reason: 'no-icon-in-app-config',
});
await new ProjectInit(['--json', '--non-interactive'], commandOptions).run();
expect(printJsonOnlyOutput).toHaveBeenCalledWith({
status: 'already-linked',
projectId: '1234',
owner: 'jester',
slug: 'testing-123',
dashboardUrl: 'https://expo.dev/accounts/jester/projects/testing-123',
});
});
});
});
@@ -1,23 +1,17 @@
import { Args } from '@oclif/core';
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { getProjectDashboardUrl } from '../../../build/utils/url';
import EasCommand from '../../../commandUtils/EasCommand';
import { ExpoGraphqlClient } from '../../../commandUtils/context/contextUtils/createGraphqlClient';
import { EASNonInteractiveFlag } from '../../../commandUtils/flags';
import { AppUploadSessionType } from '../../../graphql/generated';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import Log, { link } from '../../../log';
import { ora } from '../../../ora';
import { uploadAppScopedFileAtPathToGCSAsync } from '../../../uploads';
import { sleepAsync } from '../../../utils/promise';
const ALLOWED_EXTENSIONS = ['.png', '.jpg', '.jpeg'];
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; // matches the upload session's GCS limit
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 90_000;
import {
pollForProfileImageChangeAsync,
uploadProjectIconAsync,
validateIconAsync,
} from '../../../project/projectIcon';
export default class ProjectIconSet extends EasCommand {
static override description = 'set the project icon displayed on the EAS dashboard';
@@ -51,7 +45,7 @@ export default class ProjectIconSet extends EasCommand {
nonInteractive: flags['non-interactive'],
});
await validateImageAsync(imagePath);
await validateIconAsync(imagePath);
const app = await AppQuery.byIdAsync(graphqlClient, projectId);
const projectDashboardUrl = getProjectDashboardUrl(app.ownerAccount.name, app.slug);
@@ -62,11 +56,7 @@ export default class ProjectIconSet extends EasCommand {
const spinner = ora('Uploading project icon').start();
try {
await uploadAppScopedFileAtPathToGCSAsync(graphqlClient, {
type: AppUploadSessionType.ProfileImageUpload,
appId: projectId,
path: imagePath,
});
await uploadProjectIconAsync(graphqlClient, { projectId, imagePath });
// The icon is processed asynchronously (resized and assigned to the
// project by the server), so poll until the icon URL changes.
@@ -84,48 +74,3 @@ export default class ProjectIconSet extends EasCommand {
}
}
}
async function validateImageAsync(imagePath: string): Promise<void> {
if (!(await fs.pathExists(imagePath))) {
throw new Error(`No file found at ${imagePath}`);
}
const extension = path.extname(imagePath).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(extension)) {
throw new Error(
`Unsupported image format "${extension}". The icon must be a PNG or JPEG file.`
);
}
const { size } = await fs.stat(imagePath);
if (size > MAX_IMAGE_SIZE_BYTES) {
throw new Error(
`The image is ${(size / 1024 / 1024).toFixed(1)} MB, but the maximum allowed size is 10 MB.`
);
}
}
async function pollForProfileImageChangeAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
previousProfileImageUrl,
projectDashboardUrl,
}: {
projectId: string;
previousProfileImageUrl: string | null;
projectDashboardUrl: string;
}
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
await sleepAsync(POLL_INTERVAL_MS);
const profileImageUrl = await AppQuery.byIdProfileImageUrlAsync(graphqlClient, projectId);
if (profileImageUrl && profileImageUrl !== previousProfileImageUrl) {
return;
}
}
throw new Error(
`Timed out waiting for the icon to be processed. It may still appear on the project page shortly: ${chalk.underline(
projectDashboardUrl
)}`
);
}
@@ -6,6 +6,7 @@ import {
EasNonInteractiveAndJsonFlags,
resolveNonInteractiveAndJsonFlags,
} from '../../commandUtils/flags';
import { maybeSetProjectIconFromAppConfigAsync } from '../../project/projectIcon';
import {
ProjectInitResult,
ensureOwnerSlugConsistencyAsync,
@@ -37,6 +38,12 @@ export default class ProjectInit extends EasCommand {
description:
'Whether to create a new project/link an existing project without additional prompts or overwrite any existing project ID when running with --id flag',
}),
icon: Flags.boolean({
description:
'Set the icon shown on the EAS dashboard from the app config, when the project does not have one yet',
default: true,
allowNo: true,
}),
...EasNonInteractiveAndJsonFlags,
};
@@ -82,6 +89,13 @@ export default class ProjectInit extends EasCommand {
}
);
const iconResult = flags.icon
? await maybeSetProjectIconFromAppConfigAsync(graphqlClient, {
projectId: result.projectId,
projectDir,
})
: null;
if (jsonFlag) {
printJsonOnlyOutput({
status: result.status,
@@ -89,6 +103,7 @@ export default class ProjectInit extends EasCommand {
owner,
slug,
dashboardUrl: getProjectDashboardUrl(owner, slug),
...(iconResult?.status === 'set' ? { icon: { source: iconResult.icon.field } } : {}),
});
}
}
+10
View File
@@ -34,6 +34,7 @@ interface RawCommandFlags {
verbose: boolean;
wait: boolean;
'non-interactive': boolean;
'auto-testflight-setup': boolean;
'verbose-fastlane': boolean;
groups?: string[];
}
@@ -46,6 +47,7 @@ interface CommandFlags {
verbose: boolean;
wait: boolean;
nonInteractive: boolean;
autoTestFlightSetup: boolean;
isVerboseFastlaneEnabled: boolean;
groups?: string[];
}
@@ -106,6 +108,11 @@ export default class Submit extends EasCommand {
default: false,
description: 'Run command in non-interactive mode',
}),
'auto-testflight-setup': Flags.boolean({
default: true,
allowNo: true,
description: 'Set up an internal TestFlight group for the app (iOS only)',
}),
};
static override contextDefinition = {
@@ -152,6 +159,7 @@ export default class Submit extends EasCommand {
profile: submissionProfile.profile,
archiveFlags: flagsWithPlatform.archiveFlags,
nonInteractive: flagsWithPlatform.nonInteractive,
autoTestFlightSetup: flagsWithPlatform.autoTestFlightSetup,
isVerboseFastlaneEnabled: flagsWithPlatform.isVerboseFastlaneEnabled,
groups: flagsWithPlatform.groups,
actor,
@@ -198,6 +206,7 @@ export default class Submit extends EasCommand {
wait,
profile,
'non-interactive': nonInteractive,
'auto-testflight-setup': autoTestFlightSetup,
'verbose-fastlane': isVerboseFastlaneEnabled,
groups,
'what-to-test': whatToTest,
@@ -221,6 +230,7 @@ export default class Submit extends EasCommand {
wait,
profile,
nonInteractive,
autoTestFlightSetup,
whatToTest,
isVerboseFastlaneEnabled,
groups,
@@ -35,7 +35,7 @@ const updateStub: UpdateFragment = {
group: 'group-1234',
branch: { id: 'branch-1234', name: 'main' },
message: 'test message',
runtimeVersion: 'exposdk:47.0.0',
runtime: { id: 'runtime-1234', version: 'exposdk:47.0.0' },
platform: 'ios',
gitCommitHash: 'commit',
isGitWorkingTreeDirty: false,
@@ -30,7 +30,7 @@ const updateStub: UpdateFragment = {
group: 'group-1234',
branch: { id: 'branch-1234', name: 'main' },
message: 'test message',
runtimeVersion: 'exposdk:47.0.0',
runtime: { id: 'runtime-1234', version: 'exposdk:47.0.0' },
platform: 'ios',
gitCommitHash: 'commit',
isGitWorkingTreeDirty: false,
@@ -154,7 +154,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: updateStub.branch.id,
runtimeVersion: updateStub.runtimeVersion,
runtimeVersion: updateStub.runtime.version,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -225,7 +225,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: updateStub.branch.id,
runtimeVersion: updateStub.runtimeVersion,
runtimeVersion: updateStub.runtime.version,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -270,7 +270,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: updateStub.branch.id,
runtimeVersion: updateStub.runtimeVersion,
runtimeVersion: updateStub.runtime.version,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -313,7 +313,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: updateStub.branch.id,
runtimeVersion: updateStub.runtimeVersion,
runtimeVersion: updateStub.runtime.version,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -357,7 +357,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: 'other-id',
runtimeVersion: updateStub.runtimeVersion,
runtimeVersion: updateStub.runtime.version,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -401,7 +401,7 @@ describe(UpdateRepublish.name, () => {
expect.arrayContaining([
expect.objectContaining({
branchId: 'blah-id',
runtimeVersion: updateStub.runtimeVersion,
runtimeVersion: updateStub.runtime.version,
updateInfoGroup: expect.objectContaining({
ios: expect.any(Object),
}),
@@ -30,7 +30,7 @@ const updateStub: UpdateFragment = {
group: 'group-1234',
branch: { id: 'branch-1234', name: 'main' },
message: 'test message',
runtimeVersion: 'exposdk:47.0.0',
runtime: { id: 'runtime-1234', version: 'exposdk:47.0.0' },
platform: 'ios',
gitCommitHash: 'commit',
isGitWorkingTreeDirty: false,
@@ -24,7 +24,7 @@ const updateStub: UpdateFragment = {
group: 'group-source',
branch: { id: 'branch-1234', name: 'main' },
message: 'source message',
runtimeVersion: 'exposdk:47.0.0',
runtime: { id: 'runtime-1234', version: 'exposdk:47.0.0' },
platform: 'ios',
gitCommitHash: 'commit',
isGitWorkingTreeDirty: false,
@@ -33,7 +33,7 @@ const updateGroup = [
actor: null,
createdAt: '2026-04-09T00:00:00.000Z',
message: 'first',
runtimeVersion: '1.0.0',
runtime: { __typename: 'Runtime' as const, id: 'runtime-1', version: '1.0.0' },
platform: 'ios',
manifestPermalink: 'https://expo.dev/manifest/u1',
isRollBackToEmbedded: false,
@@ -670,7 +670,7 @@ export default class UpdatePublish extends EasCommand {
version => version.runtimeVersion
)) {
const newUpdatesForRuntimeVersion = newUpdates.filter(
update => update.runtimeVersion === runtime.runtimeVersion
update => update.runtime.version === runtime.runtimeVersion
);
if (newUpdatesForRuntimeVersion.length === 0) {
throw new Error(
@@ -322,7 +322,7 @@ export default class UpdateRevertUpdateRollout extends EasCommand {
});
const platforms = rolloutUpdateGroup.map(update => update.platform) as UpdatePublishPlatform[];
const runtimeVersion = rolloutUpdateGroup[0].runtimeVersion;
const runtimeVersion = rolloutUpdateGroup[0].runtime.version;
const targetBranch = {
name: rolloutUpdateGroup[0].branchName,
id: rolloutUpdateGroup[0].branchId,
@@ -147,7 +147,7 @@ async function getSourceUpdateGroupAsync(
return {
groupId,
branchName: arbitraryUpdate.branch.name,
runtimeVersion: arbitraryUpdate.runtimeVersion,
runtimeVersion: arbitraryUpdate.runtime.version,
};
}
@@ -60,14 +60,7 @@ export const testAppFragment: AppFragment = {
ownerAccount: {
id: 'test-account-id',
name: 'testuser',
users: [
{
role: Role.Owner,
actor: {
id: 'test-user-id',
},
},
],
viewerUserPermission: { role: Role.Owner },
},
};
@@ -11,18 +11,18 @@ export const jester: Actor = {
primaryAccount: {
id: 'jester-account-id',
name: 'jester',
users: [{ role: Role.Admin, actor: { id: 'jester-id' } }],
viewerUserPermission: { role: Role.Admin },
},
accounts: [
{
id: 'jester-account-id',
name: 'jester',
users: [{ role: Role.Admin, actor: { id: 'jester-id' } }],
viewerUserPermission: { role: Role.Admin },
},
{
id: 'other-account-id',
name: 'other',
users: [{ role: Role.ViewOnly, actor: { id: 'jester-id' } }],
viewerUserPermission: { role: Role.ViewOnly },
},
],
isExpoAdmin: false,
@@ -37,7 +37,7 @@ export const jester2 = {
{
id: 'jester2-account-id',
name: 'jester2',
users: [{ role: Role.Admin, actor: { id: 'jester2-id' } }],
viewerUserPermission: { role: Role.Admin },
},
],
isExpoAdmin: false,
@@ -52,7 +52,7 @@ export const robot: Actor = {
{
id: 'robot-account-id',
name: 'robot-account',
users: [],
viewerUserPermission: { role: Role.Owner },
},
],
isExpoAdmin: false,
@@ -60,14 +60,7 @@ export const testAppFragment: AppFragment = {
ownerAccount: {
id: 'test-account-id',
name: 'testuser',
users: [
{
role: Role.Owner,
actor: {
id: 'test-user-id',
},
},
],
viewerUserPermission: { role: Role.Owner },
},
};
@@ -1,7 +1,7 @@
import { CredentialsSource } from '@expo/eas-json';
import { vol } from 'memfs';
import { IosAppBuildCredentialsFragment } from '../../../graphql/generated';
import { IosAppBuildCredentialsFragment, Role } from '../../../graphql/generated';
import { AppQuery } from '../../../graphql/queries/AppQuery';
import { findApplicationTarget } from '../../../project/ios/target';
import { getAppstoreMock } from '../../__tests__/fixtures-appstore';
@@ -58,7 +58,7 @@ describe(IosCredentialsProvider, () => {
account: {
id: `id-${appLookupParams.account.name}`,
name: appLookupParams.account.name,
users: [],
viewerUserPermission: { role: Role.Owner },
},
projectName: appLookupParams.projectName,
},
@@ -102,7 +102,7 @@ describe(IosCredentialsProvider, () => {
account: {
id: `id-${appLookupParams.account.name}`,
name: appLookupParams.account.name,
users: [],
viewerUserPermission: { role: Role.Owner },
},
projectName: appLookupParams.projectName,
},
@@ -1,4 +1,8 @@
import { IosAppBuildCredentialsFragment, IosDistributionType } from '../../../../graphql/generated';
import {
IosAppBuildCredentialsFragment,
IosDistributionType,
Role,
} from '../../../../graphql/generated';
import { promptAsync } from '../../../../prompts';
import { getAppstoreMock, testAuthCtx } from '../../../__tests__/fixtures-appstore';
import { createCtxMock } from '../../../__tests__/fixtures-context';
@@ -43,7 +47,11 @@ describe(SetUpInternalProvisioningProfile, () => {
});
const action = new SetUpInternalProvisioningProfile({
app: {
account: { id: 'account-id', name: 'account-name', users: [] },
account: {
id: 'account-id',
name: 'account-name',
viewerUserPermission: { role: Role.Owner },
},
bundleIdentifier: 'com.expo.test',
projectName: 'testproject',
},
@@ -77,7 +85,11 @@ describe(SetUpInternalProvisioningProfile, () => {
const action = new SetUpInternalProvisioningProfile({
app: {
account: { id: 'account-id', name: 'account-name', users: [] },
account: {
id: 'account-id',
name: 'account-name',
viewerUserPermission: { role: Role.Owner },
},
bundleIdentifier: 'com.expo.test',
projectName: 'testproject',
},
@@ -118,7 +130,11 @@ describe(SetUpInternalProvisioningProfile, () => {
const action = new SetUpInternalProvisioningProfile({
app: {
account: { id: 'account-id', name: 'account-name', users: [] },
account: {
id: 'account-id',
name: 'account-name',
viewerUserPermission: { role: Role.Owner },
},
bundleIdentifier: 'com.expo.test',
projectName: 'testproject',
},
@@ -152,7 +168,11 @@ describe(SetUpInternalProvisioningProfile, () => {
const action = new SetUpInternalProvisioningProfile({
app: {
account: { id: 'account-id', name: 'account-name', users: [] },
account: {
id: 'account-id',
name: 'account-name',
viewerUserPermission: { role: Role.Owner },
},
bundleIdentifier: 'com.expo.test',
projectName: 'testproject',
},
@@ -188,7 +208,11 @@ describe(SetUpInternalProvisioningProfile, () => {
const action = new SetUpInternalProvisioningProfile({
app: {
account: { id: 'account-id', name: 'account-name', users: [] },
account: {
id: 'account-id',
name: 'account-name',
viewerUserPermission: { role: Role.Owner },
},
bundleIdentifier: 'com.expo.test',
projectName: 'testproject',
},
@@ -222,7 +246,11 @@ describe(SetUpInternalProvisioningProfile, () => {
const action = new SetUpInternalProvisioningProfile({
app: {
account: { id: 'account-id', name: 'account-name', users: [] },
account: {
id: 'account-id',
name: 'account-name',
viewerUserPermission: { role: Role.Owner },
},
bundleIdentifier: 'com.expo.test',
projectName: 'testproject',
},
@@ -255,7 +283,11 @@ describe(SetUpInternalProvisioningProfile, () => {
});
const action = new SetUpInternalProvisioningProfile({
app: {
account: { id: 'account-id', name: 'account-name', users: [] },
account: {
id: 'account-id',
name: 'account-name',
viewerUserPermission: { role: Role.Owner },
},
bundleIdentifier: 'com.expo.test',
projectName: 'testproject',
},
@@ -276,7 +308,11 @@ describe(SetUpInternalProvisioningProfile, () => {
});
const action = new SetUpInternalProvisioningProfile({
app: {
account: { id: 'account-id', name: 'account-name', users: [] },
account: {
id: 'account-id',
name: 'account-name',
viewerUserPermission: { role: Role.Owner },
},
bundleIdentifier: 'com.expo.test',
projectName: 'testproject',
},
@@ -295,7 +331,11 @@ describe(SetUpInternalProvisioningProfile, () => {
});
const action = new SetUpInternalProvisioningProfile({
app: {
account: { id: 'account-id', name: 'account-name', users: [] },
account: {
id: 'account-id',
name: 'account-name',
viewerUserPermission: { role: Role.Owner },
},
bundleIdentifier: 'com.expo.test',
projectName: 'testproject',
},
@@ -320,7 +360,11 @@ describe(SetUpInternalProvisioningProfile, () => {
});
const action = new SetUpInternalProvisioningProfile({
app: {
account: { id: 'account-id', name: 'account-name', users: [] },
account: {
id: 'account-id',
name: 'account-name',
viewerUserPermission: { role: Role.Owner },
},
bundleIdentifier: 'com.expo.test',
projectName: 'testproject',
},
@@ -76,18 +76,22 @@ export const AppleDeviceQuery = {
account {
byName(accountName: $accountName) {
id
appleTeams(appleTeamIdentifier: $appleTeamIdentifier) {
id
appleTeamIdentifier
appleTeamName
appleDevices(offset: $offset, limit: $limit) {
id
identifier
name
deviceClass
enabled
model
createdAt
appleTeamsPaginated(first: 1, filter: { appleTeamIdentifier: $appleTeamIdentifier }) {
edges {
node {
id
appleTeamIdentifier
appleTeamName
appleDevices(offset: $offset, limit: $limit) {
id
identifier
name
deviceClass
enabled
model
createdAt
}
}
}
}
}
@@ -101,7 +105,12 @@ export const AppleDeviceQuery = {
)
.toPromise()
);
const [appleTeam] = data.account.byName.appleTeams;
const appleTeam = data.account.byName.appleTeamsPaginated.edges[0]?.node;
if (!appleTeam) {
throw new Error(
`Could not find Apple team with the identifier ${appleTeamIdentifier} on account ${accountName}`
);
}
const { appleDevices } = appleTeam;
if (!appleDevices) {
throw new Error(`Could not find devices on Apple team -- ${formatAppleTeam(appleTeam)}`);
@@ -5,35 +5,100 @@ import { ExpoGraphqlClient } from '../../../../../commandUtils/context/contextUt
import { withErrorHandlingAsync } from '../../../../../graphql/client';
import {
AppleTeamByIdentifierQuery,
AppleTeamFilterInput,
AppleTeamFragment,
AppleTeamsByAccountNameQuery,
AppleTeamsByAccountNameQueryVariables,
AppleTeamsPaginatedByAccountQuery,
} from '../../../../../graphql/generated';
import { AppleTeamFragmentNode } from '../../../../../graphql/types/credentials/AppleTeam';
import { Connection } from '../../../../../utils/relay';
export const AppleTeamQuery = {
async getAllForAccountAsync(
graphqlClient: ExpoGraphqlClient,
{ accountName, offset, limit }: AppleTeamsByAccountNameQueryVariables
{
accountName,
offset,
limit,
}: { accountName: string; offset?: number | null; limit?: number | null }
): Promise<AppleTeamFragment[]> {
// appleTeamsPaginated is cursor-based, so emulate offset/limit by fetching
// pages from the start until enough nodes are collected
const start = offset ?? 0;
const teams: AppleTeamFragment[] = [];
let after: string | undefined;
while (limit == null || teams.length < start + limit) {
const connection = await AppleTeamQuery.getAllForAccountPaginatedAsync(
graphqlClient,
accountName,
{ first: 100, after }
);
teams.push(...connection.edges.map(edge => edge.node));
if (!connection.pageInfo.hasNextPage) {
break;
}
after = connection.pageInfo.endCursor ?? undefined;
}
return limit == null ? teams.slice(start) : teams.slice(start, start + limit);
},
async getAllForAccountPaginatedAsync(
graphqlClient: ExpoGraphqlClient,
accountName: string,
{
after,
first,
before,
last,
filter,
}: {
after?: string;
first?: number;
before?: string;
last?: number;
filter?: AppleTeamFilterInput;
}
): Promise<Connection<AppleTeamFragment>> {
const data = await withErrorHandlingAsync(
graphqlClient
.query<AppleTeamsByAccountNameQuery>(
.query<AppleTeamsPaginatedByAccountQuery>(
gql`
query AppleTeamsByAccountName($accountName: String!, $offset: Int, $limit: Int) {
query AppleTeamsPaginatedByAccountQuery(
$accountName: String!
$after: String
$first: Int
$before: String
$last: Int
$filter: AppleTeamFilterInput
) {
account {
byName(accountName: $accountName) {
id
appleTeams(offset: $offset, limit: $limit) {
id
...AppleTeamFragment
appleTeamsPaginated(
after: $after
first: $first
before: $before
last: $last
filter: $filter
) {
edges {
cursor
node {
id
...AppleTeamFragment
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
}
${print(AppleTeamFragmentNode)}
`,
{ accountName, offset, limit },
{ accountName, after, first, before, last, filter },
{
additionalTypenames: ['AppleTeam'],
}
@@ -41,7 +106,7 @@ export const AppleTeamQuery = {
.toPromise()
);
return data.account.byName.appleTeams ?? [];
return data.account.byName.appleTeamsPaginated;
},
async getByAppleTeamIdentifierAsync(
graphqlClient: ExpoGraphqlClient,
@@ -0,0 +1,105 @@
import { App, BetaGroup, User } from '@expo/apple-utils';
import { ensureTestFlightGroupExistsAsync } from '../ensureTestFlightGroup';
import { confirmAsync } from '../../../../prompts';
jest.mock('../../../../ora');
jest.mock('../../../../prompts', () => ({
confirmAsync: jest.fn(),
}));
jest.mock('@expo/apple-utils', () => ({
...jest.requireActual('@expo/apple-utils'),
User: { getAsync: jest.fn() },
BetaGroup: { deleteAsync: jest.fn() },
}));
function mockApp({
groups,
createdGroup,
}: {
groups: Partial<BetaGroup>[];
createdGroup?: Partial<BetaGroup>;
}): App {
return {
id: '1234567890',
context: {},
getBetaGroupsAsync: jest.fn().mockResolvedValue(groups),
createBetaGroupAsync: jest.fn().mockResolvedValue(createdGroup),
} as unknown as App;
}
function mockGroup({
hasAccessToAllBuilds,
}: {
hasAccessToAllBuilds: boolean;
}): Partial<BetaGroup> {
return {
id: 'group-id',
context: {} as BetaGroup['context'],
attributes: {
name: 'Team (Expo)',
isInternalGroup: true,
hasAccessToAllBuilds,
betaTesters: [],
} as unknown as BetaGroup['attributes'],
createBulkBetaTesterAssignmentsAsync: jest.fn(),
};
}
beforeEach(() => {
jest.mocked(confirmAsync).mockReset();
jest.mocked(User.getAsync).mockReset().mockResolvedValue([]);
jest.mocked(BetaGroup.deleteAsync).mockReset();
});
describe(ensureTestFlightGroupExistsAsync, () => {
it('skips setup when the app already has beta groups', async () => {
const app = mockApp({ groups: [mockGroup({ hasAccessToAllBuilds: true })] });
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: true });
expect(app.createBetaGroupAsync).not.toHaveBeenCalled();
expect(User.getAsync).not.toHaveBeenCalled();
});
it('creates a group and adds admins without prompting in non-interactive mode', async () => {
const app = mockApp({
groups: [],
createdGroup: mockGroup({ hasAccessToAllBuilds: true }),
});
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: true });
expect(app.createBetaGroupAsync).toHaveBeenCalledWith({
name: 'Team (Expo)',
isInternalGroup: true,
hasAccessToAllBuilds: true,
});
expect(confirmAsync).not.toHaveBeenCalled();
});
it('does not prompt or delete the group in non-interactive mode when it lacks access to all builds', async () => {
const app = mockApp({
groups: [],
createdGroup: mockGroup({ hasAccessToAllBuilds: false }),
});
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: true });
expect(confirmAsync).not.toHaveBeenCalled();
expect(BetaGroup.deleteAsync).not.toHaveBeenCalled();
});
it('prompts to regenerate the group in interactive mode when it lacks access to all builds', async () => {
jest.mocked(confirmAsync).mockResolvedValue(false);
const app = mockApp({
groups: [],
createdGroup: mockGroup({ hasAccessToAllBuilds: false }),
});
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: false });
expect(confirmAsync).toHaveBeenCalled();
expect(BetaGroup.deleteAsync).not.toHaveBeenCalled();
});
});
@@ -12,12 +12,10 @@ const AUTO_GROUP_NAME = 'Team (Expo)';
* Ensure a TestFlight internal group with access to all builds exists for the app and has all admin users invited to it.
* This allows users to instantly access their builds from TestFlight after it finishes processing.
*/
export async function ensureTestFlightGroupExistsAsync(app: App): Promise<void> {
if (process.env.EAS_NO_AUTO_TESTFLIGHT_SETUP) {
Log.debug('EAS_NO_AUTO_TESTFLIGHT_SETUP is set, skipping TestFlight setup');
return;
}
export async function ensureTestFlightGroupExistsAsync(
app: App,
{ nonInteractive = false }: { nonInteractive?: boolean } = {}
): Promise<void> {
const groups = await app.getBetaGroupsAsync({
query: {
includes: ['betaTesters'],
@@ -33,19 +31,22 @@ export async function ensureTestFlightGroupExistsAsync(app: App): Promise<void>
const group = await ensureInternalGroupAsync({
app,
groups,
nonInteractive,
});
const users = await User.getAsync(app.context);
const admins = users.filter(user => user.attributes.roles?.includes(UserRole.ADMIN));
await addAllUsersToInternalGroupAsync(group, admins);
await addAllUsersToInternalGroupAsync(group, admins, app);
}
async function ensureInternalGroupAsync({
groups,
app,
nonInteractive,
}: {
groups: BetaGroup[];
app: App;
nonInteractive: boolean;
}): Promise<BetaGroup> {
let betaGroup = groups.find(group => group.attributes.name === AUTO_GROUP_NAME);
if (!betaGroup) {
@@ -88,6 +89,13 @@ async function ensureInternalGroupAsync({
// `hasAccessToAllBuilds` is a newer feature that allows the group to automatically have access to all builds. This cannot be patched so we need to recreate the group.
if (!betaGroup.attributes.hasAccessToAllBuilds) {
if (nonInteractive) {
// Deleting a group is destructive, so it needs explicit confirmation.
Log.warn(
`TestFlight group "${AUTO_GROUP_NAME}" does not have automatic access to new builds. Re-run in interactive mode to regenerate it, or recreate it in App Store Connect.`
);
return betaGroup;
}
if (
await confirmAsync({
message: 'Regenerate internal TestFlight group to allow automatic access to all builds?',
@@ -101,6 +109,7 @@ async function ensureInternalGroupAsync({
includes: ['betaTesters'],
},
}),
nonInteractive,
});
}
}
@@ -108,7 +117,11 @@ async function ensureInternalGroupAsync({
return betaGroup;
}
async function addAllUsersToInternalGroupAsync(group: BetaGroup, users: User[]): Promise<void> {
async function addAllUsersToInternalGroupAsync(
group: BetaGroup,
users: User[],
app: App
): Promise<void> {
let emails = users
.filter(user => user.attributes.email)
.map(user => ({
@@ -162,7 +175,7 @@ async function addAllUsersToInternalGroupAsync(group: BetaGroup, users: User[]):
});
if (!success) {
const groupUrl = await getTestFlightGroupUrlAsync(group);
const groupUrl = await getTestFlightGroupUrlAsync(group, app);
Log.error(
`Unable to add all admins to TestFlight group "${
@@ -181,12 +194,12 @@ async function addAllUsersToInternalGroupAsync(group: BetaGroup, users: User[]):
}
}
async function getTestFlightGroupUrlAsync(group: BetaGroup): Promise<string | null> {
async function getTestFlightGroupUrlAsync(group: BetaGroup, app: App): Promise<string | null> {
if (group.context.providerId) {
try {
const session = await Session.getSessionForProviderIdAsync(group.context.providerId);
return `https://appstoreconnect.apple.com/teams/${session.provider.publicProviderId}/apps/6741088859/testflight/groups/${group.id}`;
return `https://appstoreconnect.apple.com/teams/${session.provider.publicProviderId}/apps/${app.id}/testflight/groups/${group.id}`;
} catch (error) {
// Avoid crashing if we can't get the session.
Log.debug('Failed to get session for provider ID', error);
@@ -7,6 +7,7 @@ import Log from '../../../../log';
import { IosAppCredentialsQuery } from '../../api/graphql/queries/IosAppCredentialsQuery';
import { App, Target } from '../../types';
import { displayIosCredentials } from '../printCredentials';
import { Role } from '../../../../graphql/generated';
jest.mock('../../../../log');
jest.mock('chalk', () => ({
@@ -29,7 +30,7 @@ describe('print credentials', () => {
account: {
id: 'account-id',
name: 'quinlanj',
users: [],
viewerUserPermission: { role: Role.Owner },
},
projectName: 'test52',
};
@@ -29,18 +29,18 @@ describe(AccountResolver, () => {
primaryAccount: {
id: 'account_id_777',
name: 'dominik',
users: [{ role: Role.Owner, actor: { id: 'user_id_666' } }],
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account_id_777',
name: 'dominik',
users: [{ role: Role.Owner, actor: { id: 'user_id_666' } }],
viewerUserPermission: { role: Role.Owner },
},
{
id: 'account_id_888',
name: 'foo',
users: [{ role: Role.Owner, actor: { id: 'user_id_666' } }],
viewerUserPermission: { role: Role.Owner },
},
],
isExpoAdmin: false,
@@ -57,7 +57,7 @@ describe(AccountResolver, () => {
ownerAccount: {
id: 'account_id_888',
name: 'foo',
users: [{ role: Role.Owner, actor: { id: 'user_id_666' } }],
viewerUserPermission: { role: Role.Owner },
},
});
});
@@ -73,7 +73,7 @@ describe(AccountResolver, () => {
expect(account).toEqual({
id: user.accounts[1].id,
name: user.accounts[1].name,
users: user.accounts[1].users,
viewerUserPermission: user.accounts[1].viewerUserPermission,
});
});
@@ -40,14 +40,7 @@ describe(DeviceCreateAction, () => {
const account: AccountFragment = {
id: 'account_id',
name: 'foobar',
users: [
{
role: Role.Owner,
actor: {
id: 'user_id',
},
},
],
viewerUserPermission: { role: Role.Owner },
};
const appleTeam = {
id: 'apple-team-id',
@@ -71,14 +64,7 @@ describe(DeviceCreateAction, () => {
const account: AccountFragment = {
id: 'account_id',
name: 'foobar',
users: [
{
role: Role.Owner,
actor: {
id: 'user_id',
},
},
],
viewerUserPermission: { role: Role.Owner },
};
const appleTeam = {
id: 'apple-team-id',
@@ -102,14 +88,7 @@ describe(DeviceCreateAction, () => {
const account: AccountFragment = {
id: 'account_id',
name: 'foobar',
users: [
{
role: Role.Owner,
actor: {
id: 'user_id',
},
},
],
viewerUserPermission: { role: Role.Owner },
};
const appleTeam = {
id: 'apple-team-id',
@@ -133,14 +112,7 @@ describe(DeviceCreateAction, () => {
const account: AccountFragment = {
id: 'account_id',
name: 'foobar',
users: [
{
role: Role.Owner,
actor: {
id: 'user_id',
},
},
],
viewerUserPermission: { role: Role.Owner },
};
const appleTeam = {
id: 'apple-team-id',
+324 -421
View File
File diff suppressed because it is too large. Load diff
@@ -52,7 +52,10 @@ export const ChannelInsightsQuery = {
id
group
message
runtimeVersion
runtime {
id
version
}
platform
insights {
id
@@ -10,10 +10,7 @@ export const AccountFragmentNode = gql`
id
username
}
users {
actor {
id
}
viewerUserPermission {
role
}
}
+13 -8
View File
@@ -30,26 +30,31 @@ export const BuildFragmentNode = gql`
displayName
}
logFiles
project {
app {
__typename
id
name
slug
... on App {
ownerAccount {
id
name
}
ownerAccount {
id
name
}
}
channel
updateChannel {
id
name
}
distribution
iosEnterpriseProvisioning
buildProfile
appIdentifier
sdkVersion
appVersion
appBuildVersion
runtimeVersion
runtime {
id
version
}
gitCommitHash
gitCommitMessage
initialQueuePosition
@@ -15,7 +15,6 @@ export const SubmissionFragmentNode = gql`
}
}
androidConfig {
applicationIdentifier
track
releaseStatus
rollout
+4 -1
View File
@@ -6,7 +6,10 @@ export const UpdateFragmentNode = gql`
group
message
createdAt
runtimeVersion
runtime {
id
version
}
platform
manifestFragment
isRollBackToEmbedded
@@ -7,11 +7,17 @@ import {
formatAscAppLinkStatus,
isAscAuthenticationError,
} from '../utils';
import { Role } from '../../../graphql/generated';
const mockMetadataConnected = {
id: 'app-id',
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
appStoreConnectApp: {
id: 'asc-app-link-id',
ascAppIdentifier: '1234567890',
@@ -27,7 +33,12 @@ const mockMetadataConnected = {
const mockMetadataDisconnected = {
id: 'app-id',
fullName: '@testuser/testapp',
ownerAccount: { id: 'account-id', name: 'testuser', ownerUserActor: null, users: [] },
ownerAccount: {
id: 'account-id',
name: 'testuser',
ownerUserActor: null,
viewerUserPermission: { role: Role.Owner },
},
appStoreConnectApp: null,
};
@@ -13,26 +13,26 @@ const userActor: Actor = {
primaryAccount: {
id: 'account-id-personal',
name: 'jane',
users: [{ role: Role.Owner, actor: { id: 'user-id' } }],
viewerUserPermission: { role: Role.Owner },
},
accounts: [
{
id: 'account-id-org',
name: 'acme',
ownerUserActor: null,
users: [{ role: Role.ViewOnly, actor: { id: 'user-id' } }],
viewerUserPermission: { role: Role.ViewOnly },
},
{
id: 'account-id-personal',
name: 'jane',
ownerUserActor: { id: 'user-id' },
users: [{ role: Role.Owner, actor: { id: 'user-id' } }],
viewerUserPermission: { role: Role.Owner },
},
{
id: 'account-id-team',
name: 'bob',
ownerUserActor: { id: 'other-user-id' },
users: [{ role: Role.Admin, actor: { id: 'user-id' } }],
viewerUserPermission: { role: Role.Admin },
},
],
isExpoAdmin: false,
@@ -46,12 +46,12 @@ const robotActor: Actor = {
{
id: 'account-id-1',
name: 'acme',
users: [{ role: Role.Admin, actor: { id: 'robot-id' } }],
viewerUserPermission: { role: Role.Admin },
},
{
id: 'account-id-2',
name: 'other-org',
users: [{ role: Role.ViewOnly, actor: { id: 'robot-id' } }],
viewerUserPermission: { role: Role.ViewOnly },
},
],
isExpoAdmin: false,
@@ -87,7 +87,7 @@ describe(getAccountChoices, () => {
id: 'account-id-personal',
name: 'jane',
ownerUserActor: { id: 'user-id' },
users: [{ role: Role.ViewOnly, actor: { id: 'user-id' } }],
viewerUserPermission: { role: Role.ViewOnly },
},
],
} as any as Actor;
@@ -15,7 +15,13 @@ describe(fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync, () => {
jester
)
).rejects.toThrow(
`EAS project not configured. To configure it non-interactively, choose the account that should own the project and run:\n\n eas init --account <name> --non-interactive\n\nAccounts you have permissions to create projects in: jester\n\nAlternatively, set the "owner" field in your app config, or run "eas init" to configure it interactively.`
`EAS project not configured. This command cannot configure it in non-interactive mode. ` +
`Run one of the following, then re-run this command:\n\n` +
`To link an existing project:\n\n` +
` eas init --id <project-id> --non-interactive\n\n` +
`To create a new project:\n\n` +
` eas init --account fake --non-interactive\n\n` +
`Accounts you can create projects in: jester`
);
});
});
@@ -0,0 +1,171 @@
import { vol } from 'memfs';
import { getPrivateExpoConfigAsync } from '../expoConfig';
import { ExpoGraphqlClient } from '../../commandUtils/context/contextUtils/createGraphqlClient';
import { AppUploadSessionType } from '../../graphql/generated';
import { AppQuery } from '../../graphql/queries/AppQuery';
import { uploadAppScopedFileAtPathToGCSAsync } from '../../uploads';
import { maybeSetProjectIconFromAppConfigAsync, resolveAppConfigIconAsync } from '../projectIcon';
jest.mock('fs');
jest.mock('../expoConfig');
jest.mock('../../graphql/queries/AppQuery');
jest.mock('../../log');
jest.mock('../../uploads');
const mockGetPrivateExpoConfigAsync = jest.mocked(getPrivateExpoConfigAsync);
const mockByIdProfileImageUrlAsync = jest.mocked(AppQuery.byIdProfileImageUrlAsync);
const mockUploadAsync = jest.mocked(uploadAppScopedFileAtPathToGCSAsync);
const graphqlClient = {} as ExpoGraphqlClient;
const projectDir = '/app';
const projectId = 'project-123';
function mockAppConfig(exp: Record<string, any>): void {
mockGetPrivateExpoConfigAsync.mockResolvedValue({
name: 'testing 123',
slug: 'testing-123',
...exp,
} as any);
}
beforeEach(() => {
vol.reset();
jest.clearAllMocks();
});
describe(resolveAppConfigIconAsync, () => {
it('resolves the icon field relative to the project directory', async () => {
vol.fromJSON({ '/app/assets/icon.png': 'fake-png-bytes' });
mockAppConfig({ icon: './assets/icon.png' });
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toEqual({
field: 'icon',
path: '/app/assets/icon.png',
});
});
it('falls back to ios.icon when the top-level icon file is missing', async () => {
vol.fromJSON({ '/app/assets/ios-icon.png': 'fake-png-bytes' });
mockAppConfig({ icon: './assets/missing.png', ios: { icon: './assets/ios-icon.png' } });
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toEqual({
field: 'ios.icon',
path: '/app/assets/ios-icon.png',
});
});
it('uses the light variant when ios.icon is a per-appearance map', async () => {
vol.fromJSON({ '/app/assets/light.png': 'fake-png-bytes' });
mockAppConfig({
ios: { icon: { light: './assets/light.png', dark: './assets/dark.png' } },
});
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toEqual({
field: 'ios.icon',
path: '/app/assets/light.png',
});
});
it('falls back to the Android adaptive icon foreground', async () => {
vol.fromJSON({ '/app/assets/foreground.png': 'fake-png-bytes' });
mockAppConfig({
android: { adaptiveIcon: { foregroundImage: './assets/foreground.png' } },
});
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toEqual({
field: 'android.adaptiveIcon.foregroundImage',
path: '/app/assets/foreground.png',
});
});
it('ignores a remote icon URL', async () => {
mockAppConfig({ icon: 'https://example.com/icon.png' });
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toBeNull();
});
it('returns null when the app config has no icon', async () => {
mockAppConfig({});
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toBeNull();
});
it('returns null when the app config cannot be read', async () => {
mockGetPrivateExpoConfigAsync.mockRejectedValue(new Error('Invalid app config.'));
await expect(resolveAppConfigIconAsync(projectDir)).resolves.toBeNull();
});
});
describe(maybeSetProjectIconFromAppConfigAsync, () => {
it('uploads the app config icon when the project has none', async () => {
vol.fromJSON({ '/app/assets/icon.png': 'fake-png-bytes' });
mockAppConfig({ icon: './assets/icon.png' });
mockByIdProfileImageUrlAsync.mockResolvedValue(null);
await expect(
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
).resolves.toEqual({
status: 'set',
icon: { field: 'icon', path: '/app/assets/icon.png' },
});
expect(mockUploadAsync).toHaveBeenCalledWith(graphqlClient, {
type: AppUploadSessionType.ProfileImageUpload,
appId: projectId,
path: '/app/assets/icon.png',
});
});
it('does not overwrite an icon the project already has', async () => {
vol.fromJSON({ '/app/assets/icon.png': 'fake-png-bytes' });
mockAppConfig({ icon: './assets/icon.png' });
mockByIdProfileImageUrlAsync.mockResolvedValue('https://example.com/existing.png');
await expect(
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
).resolves.toEqual({ status: 'skipped', reason: 'icon-already-set' });
expect(mockUploadAsync).not.toHaveBeenCalled();
});
it('skips without a server round-trip when the app config has no icon', async () => {
mockAppConfig({});
await expect(
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
).resolves.toEqual({ status: 'skipped', reason: 'no-icon-in-app-config' });
expect(mockByIdProfileImageUrlAsync).not.toHaveBeenCalled();
expect(mockUploadAsync).not.toHaveBeenCalled();
});
it('skips an unsupported image format instead of throwing', async () => {
vol.fromJSON({ '/app/assets/icon.gif': 'fake-gif-bytes' });
mockAppConfig({ icon: './assets/icon.gif' });
await expect(
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
).resolves.toEqual({ status: 'skipped', reason: 'invalid-icon' });
expect(mockUploadAsync).not.toHaveBeenCalled();
});
it('skips an image over the size limit instead of throwing', async () => {
vol.fromJSON({ '/app/assets/icon.png': 'x'.repeat(10 * 1024 * 1024 + 1) });
mockAppConfig({ icon: './assets/icon.png' });
await expect(
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
).resolves.toEqual({ status: 'skipped', reason: 'invalid-icon' });
expect(mockUploadAsync).not.toHaveBeenCalled();
});
it('does not propagate an upload failure', async () => {
vol.fromJSON({ '/app/assets/icon.png': 'fake-png-bytes' });
mockAppConfig({ icon: './assets/icon.png' });
mockByIdProfileImageUrlAsync.mockResolvedValue(null);
mockUploadAsync.mockRejectedValue(new Error('Upload session expired'));
await expect(
maybeSetProjectIconFromAppConfigAsync(graphqlClient, { projectId, projectDir })
).resolves.toEqual({ status: 'skipped', reason: 'upload-failed' });
});
});
@@ -6,9 +6,7 @@ export function getAccountNamesWhereUserHasSufficientPermissionsToCreateApp(
actor: Actor
): Set<string> {
return new Set(
actor.accounts
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
.map(it => it.name)
actor.accounts.filter(a => a.viewerUserPermission.role !== Role.ViewOnly).map(it => it.name)
);
}
@@ -1,5 +1,6 @@
import chalk from 'chalk';
import { getUnconfiguredProjectError } from './projectNotConfiguredError';
import { getProjectDashboardUrl } from '../build/utils/url';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { Role } from '../graphql/generated';
@@ -8,7 +9,7 @@ import { AppQuery } from '../graphql/queries/AppQuery';
import { link } from '../log';
import { ora } from '../ora';
import { confirmAsync } from '../prompts';
import { Actor, getCreatableAccountNames } from '../user/User';
import { Actor } from '../user/User';
/**
* 1. Looks for an existing project on EAS servers. If found, ask the user whether this is the
@@ -32,19 +33,11 @@ export async function fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsyn
const allAccounts = actor.accounts;
const accountNamesWhereUserHasSufficientPublishPermissions = new Set(
allAccounts
.filter(a => a.users.find(it => it.actor.id === actor.id)?.role !== Role.ViewOnly)
.map(it => it.name)
allAccounts.filter(a => a.viewerUserPermission.role !== Role.ViewOnly).map(it => it.name)
);
if (options.nonInteractive) {
throw new Error(
`EAS project not configured. To configure it non-interactively, choose the account that should own the project and run:\n\n eas init --account <name> --non-interactive\n\nAccounts you have permissions to create projects in: ${getCreatableAccountNames(
actor
).join(
', '
)}\n\nAlternatively, set the "owner" field in your app config, or run "eas init" to configure it interactively.`
);
throw getUnconfiguredProjectError({ actor, accountName });
}
const account = allAccounts.find(a => a.name === accountName);
if (!account) {
+176
View File
@@ -0,0 +1,176 @@
import chalk from 'chalk';
import fs from 'fs-extra';
import path from 'path';
import { getPrivateExpoConfigAsync } from './expoConfig';
import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient';
import { AppUploadSessionType } from '../graphql/generated';
import { AppQuery } from '../graphql/queries/AppQuery';
import Log from '../log';
import { uploadAppScopedFileAtPathToGCSAsync } from '../uploads';
import { sleepAsync } from '../utils/promise';
const ALLOWED_EXTENSIONS = ['.png', '.jpg', '.jpeg'];
const MAX_IMAGE_SIZE_BYTES = 10 * 1024 * 1024; // matches the upload session's GCS limit
const POLL_INTERVAL_MS = 2000;
const POLL_TIMEOUT_MS = 90_000;
/** An icon found in the app config, with the field it came from for display purposes. */
export interface AppConfigIcon {
field: string;
path: string;
}
export type SetProjectIconFromAppConfigResult =
| { status: 'set'; icon: AppConfigIcon }
| {
status: 'skipped';
reason: 'no-icon-in-app-config' | 'icon-already-set' | 'invalid-icon' | 'upload-failed';
};
/**
* Set the project icon from the app config, but only when the project does not have one yet.
* Overwriting an icon picked on the dashboard would be surprising, and the app config is not the
* source of truth for it.
*
* Never throws. The project is already created or linked by the time this runs, so a failed icon
* upload must not fail the command that called it.
*/
export async function maybeSetProjectIconFromAppConfigAsync(
graphqlClient: ExpoGraphqlClient,
{ projectId, projectDir }: { projectId: string; projectDir: string }
): Promise<SetProjectIconFromAppConfigResult> {
const icon = await resolveAppConfigIconAsync(projectDir);
if (!icon) {
return { status: 'skipped', reason: 'no-icon-in-app-config' };
}
try {
await validateIconAsync(icon.path);
} catch (error: any) {
Log.warn(`Did not set the project icon from "${icon.field}": ${error.message}`);
return { status: 'skipped', reason: 'invalid-icon' };
}
const existingIconUrl = await AppQuery.byIdProfileImageUrlAsync(graphqlClient, projectId);
if (existingIconUrl) {
Log.debug('The project already has an icon, skipping the icon from the app config.');
return { status: 'skipped', reason: 'icon-already-set' };
}
try {
await uploadProjectIconAsync(graphqlClient, { projectId, imagePath: icon.path });
} catch (error: any) {
Log.warn(`Failed to set the project icon from "${icon.field}": ${error.message}`);
return { status: 'skipped', reason: 'upload-failed' };
}
// The server resizes the image and assigns it to the project asynchronously. Callers that need
// the icon to be visible before they return should poll with pollForProfileImageChangeAsync.
Log.withTick(`Set the project icon from ${chalk.bold(icon.field)} in your app config`);
return { status: 'set', icon };
}
/**
* Find the app icon on disk. Falls through to the platform-specific icons so that a project that
* only sets `ios.icon` or an Android adaptive icon still gets one.
*/
export async function resolveAppConfigIconAsync(projectDir: string): Promise<AppConfigIcon | null> {
let exp;
try {
exp = await getPrivateExpoConfigAsync(projectDir);
} catch (error: any) {
Log.debug(`Could not read the app config to resolve the project icon: ${error.message}`);
return null;
}
const candidates: [string, unknown][] = [
['icon', exp.icon],
['ios.icon', exp.ios?.icon],
['android.adaptiveIcon.foregroundImage', exp.android?.adaptiveIcon?.foregroundImage],
];
for (const [field, value] of candidates) {
const iconPath = resolveLocalIconPath(projectDir, value);
if (iconPath && (await fs.pathExists(iconPath))) {
return { field, path: iconPath };
}
}
return null;
}
function resolveLocalIconPath(projectDir: string, value: unknown): string | null {
// SDK 52 and above allow `ios.icon` to be a per-appearance map. The light icon is the one shown
// in most places, so it is the closest match for a single project icon.
let rawPath: string | null = null;
if (typeof value === 'string') {
rawPath = value;
} else if (value && typeof value === 'object' && typeof (value as any).light === 'string') {
rawPath = (value as any).light;
}
// Remote icons (http://, https://, data:) are not files we can upload. The scheme must be at
// least two characters so that a Windows drive letter is still treated as a path.
if (!rawPath || /^[a-z][a-z0-9+.-]+:/i.test(rawPath)) {
return null;
}
return path.resolve(projectDir, rawPath);
}
export async function validateIconAsync(imagePath: string): Promise<void> {
if (!(await fs.pathExists(imagePath))) {
throw new Error(`No file found at ${imagePath}`);
}
const extension = path.extname(imagePath).toLowerCase();
if (!ALLOWED_EXTENSIONS.includes(extension)) {
throw new Error(
`Unsupported image format "${extension}". The icon must be a PNG or JPEG file.`
);
}
const { size } = await fs.stat(imagePath);
if (size > MAX_IMAGE_SIZE_BYTES) {
throw new Error(
`The image is ${(size / 1024 / 1024).toFixed(1)} MB, but the maximum allowed size is 10 MB.`
);
}
}
export async function uploadProjectIconAsync(
graphqlClient: ExpoGraphqlClient,
{ projectId, imagePath }: { projectId: string; imagePath: string }
): Promise<void> {
await uploadAppScopedFileAtPathToGCSAsync(graphqlClient, {
type: AppUploadSessionType.ProfileImageUpload,
appId: projectId,
path: imagePath,
});
}
export async function pollForProfileImageChangeAsync(
graphqlClient: ExpoGraphqlClient,
{
projectId,
previousProfileImageUrl,
projectDashboardUrl,
}: {
projectId: string;
previousProfileImageUrl: string | null;
projectDashboardUrl: string;
}
): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < POLL_TIMEOUT_MS) {
await sleepAsync(POLL_INTERVAL_MS);
const profileImageUrl = await AppQuery.byIdProfileImageUrlAsync(graphqlClient, projectId);
if (profileImageUrl && profileImageUrl !== previousProfileImageUrl) {
return;
}
}
throw new Error(
`Timed out waiting for the icon to be processed. It may still appear on the project page shortly: ${chalk.underline(
projectDashboardUrl
)}`
);
}
@@ -0,0 +1,34 @@
import { Actor, getCreatableAccountNames } from '../user/User';
/**
* Build the error shown when an EAS project is not configured and the CLI cannot configure it
* without more input. Lists the exact commands to run, because bare "eas init" also needs
* interaction and fails in the same situation.
*
* @param actor the logged-in actor
* @param accountName the account that should own the project, when it is already known
* @param reason why the CLI cannot configure the project here
* @param additionalFix an extra way to resolve the error, appended at the end
*/
export function getUnconfiguredProjectError({
actor,
accountName,
reason = 'This command cannot configure it in non-interactive mode.',
additionalFix,
}: {
actor: Actor;
accountName?: string;
reason?: string;
additionalFix?: string;
}): Error {
return new Error(
`EAS project not configured. ${reason} ` +
`Run one of the following, then re-run this command:\n\n` +
`To link an existing project:\n\n` +
` eas init --id <project-id> --non-interactive\n\n` +
`To create a new project:\n\n` +
` eas init --account ${accountName ?? '<account-name>'} --non-interactive\n\n` +
`Accounts you can create projects in: ${getCreatableAccountNames(actor).join(', ')}` +
(additionalFix ? `\n\n${additionalFix}` : '')
);
}
+4 -4
View File
@@ -348,11 +348,11 @@ function formatBuildChoice(build: BuildFragment): prompts.Choice {
const {
id,
updatedAt,
runtimeVersion,
runtime,
buildProfile,
gitCommitHash,
gitCommitMessage,
channel,
updateChannel,
message,
status,
} = build;
@@ -370,8 +370,8 @@ function formatBuildChoice(build: BuildFragment): prompts.Choice {
const descriptionItems: { name: string; value: string | null }[] = [
{ name: 'Profile', value: buildProfile ? chalk.bold(buildProfile) : null },
{ name: 'Channel', value: channel ? chalk.bold(channel) : null },
{ name: 'Runtime version', value: runtimeVersion ? chalk.bold(runtimeVersion) : null },
{ name: 'Channel', value: updateChannel ? chalk.bold(updateChannel.name) : null },
{ name: 'Runtime version', value: runtime ? chalk.bold(runtime.version) : null },
{ name: 'Commit', value: formattedCommitData },
{
name: 'Message',
+3
View File
@@ -21,6 +21,7 @@ export interface SubmissionContext<T extends Platform> {
analyticsEventProperties: AnalyticsEventProperties;
exp: ExpoConfig;
nonInteractive: boolean;
autoTestFlightSetup: boolean;
isVerboseFastlaneEnabled: boolean;
groups: T extends Platform.IOS ? string[] : undefined;
platform: T;
@@ -49,6 +50,7 @@ export async function createSubmissionContextAsync<T extends Platform>(params: {
credentialsCtx?: CredentialsContext;
env?: Env;
nonInteractive: boolean;
autoTestFlightSetup?: boolean;
isVerboseFastlaneEnabled: boolean;
groups: string[] | undefined;
platform: T;
@@ -116,6 +118,7 @@ export async function createSubmissionContextAsync<T extends Platform>(params: {
return {
...rest,
autoTestFlightSetup: params.autoTestFlightSetup ?? true,
accountName: account.name,
credentialsCtx,
groups,
@@ -94,7 +94,7 @@ async function createAppStoreConnectAppAsync(
});
try {
await ensureTestFlightGroupExistsAsync(app);
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: ctx.nonInteractive });
} catch (error: any) {
// This process is not critical to the app submission so we shouldn't let it fail the entire process.
Log.error(
@@ -11,6 +11,7 @@ import {
} from './AppSpecificPasswordSource';
import { AscApiKeySource, AscApiKeySourceType } from './AscApiKeySource';
import IosSubmitter, { IosSubmissionOptions } from './IosSubmitter';
import { ensureTestFlightSetupForExistingAppAsync } from './ensureTestFlightSetup';
import { MissingCredentialsError } from '../../credentials/errors';
import Log, { learnMore } from '../../log';
import { ArchiveSource, ArchiveSourceType, getArchiveAsync } from '../ArchiveSource';
@@ -171,6 +172,7 @@ export default class IosSubmitCommand {
private async resolveAscAppIdentifierAsync(): Promise<Result<string>> {
const { ascAppId } = this.ctx.profile;
if (ascAppId) {
await ensureTestFlightSetupForExistingAppAsync(this.ctx, ascAppId);
return result(ascAppId);
} else if (this.ctx.nonInteractive) {
return result(
@@ -23,6 +23,7 @@ import {
import { refreshContextSubmitProfileAsync } from '../../commons';
import { SubmissionContext, createSubmissionContextAsync } from '../../context';
import IosSubmitCommand from '../IosSubmitCommand';
import { ensureTestFlightSetupForExistingAppAsync } from '../ensureTestFlightSetup';
jest.mock('fs');
jest.mock('../../../ora');
@@ -52,6 +53,9 @@ jest.mock('../../commons', () => {
refreshContextSubmitProfileAsync: jest.fn(),
};
});
jest.mock('../ensureTestFlightSetup', () => ({
ensureTestFlightSetupForExistingAppAsync: jest.fn(),
}));
const vcsClient = resolveVcsClient();
@@ -203,6 +207,11 @@ describe(IosSubmitCommand, () => {
submittedBuildId: undefined,
});
expect(ensureTestFlightSetupForExistingAppAsync).toHaveBeenCalledWith(
expect.anything(),
'12345678'
);
delete process.env.EXPO_APPLE_APP_SPECIFIC_PASSWORD;
});
describe('build selected from EAS', () => {
@@ -0,0 +1,153 @@
import { App } from '@expo/apple-utils';
import { Platform } from '@expo/eas-build-job';
import { resolveAscApiKeyForAppCredentialsAsync } from '../../../credentials/ios/actions/AscApiKeyUtils';
import { AuthenticationMode } from '../../../credentials/ios/appstore/authenticateTypes';
import { ensureTestFlightGroupExistsAsync } from '../../../credentials/ios/appstore/ensureTestFlightGroup';
import { hasAscEnvVars } from '../../../credentials/ios/appstore/resolveCredentials';
import { SubmissionContext } from '../../context';
import { ensureTestFlightSetupForExistingAppAsync } from '../ensureTestFlightSetup';
jest.mock('@expo/apple-utils', () => ({
...jest.requireActual('@expo/apple-utils'),
App: { infoAsync: jest.fn() },
}));
jest.mock('../../../credentials/ios/actions/AscApiKeyUtils', () => ({
resolveAscApiKeyForAppCredentialsAsync: jest.fn(),
}));
jest.mock('../../../credentials/ios/appstore/ensureTestFlightGroup', () => ({
ensureTestFlightGroupExistsAsync: jest.fn(),
}));
jest.mock('../../../credentials/ios/appstore/resolveCredentials', () => ({
...jest.requireActual('../../../credentials/ios/appstore/resolveCredentials'),
hasAscEnvVars: jest.fn(),
}));
function createContext({
nonInteractive = false,
autoTestFlightSetup = true,
}: {
nonInteractive?: boolean;
autoTestFlightSetup?: boolean;
}): {
ctx: SubmissionContext<Platform.IOS>;
ensureAuthenticatedAsync: jest.Mock;
} {
const authCtx = {
team: { id: 'team-id' },
ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' },
authState: { context: {} },
};
const appStore: any = { authCtx: undefined };
const ensureAuthenticatedAsync = jest.fn(async () => {
appStore.authCtx = authCtx;
return authCtx;
});
appStore.ensureAuthenticatedAsync = ensureAuthenticatedAsync;
return {
ctx: {
nonInteractive,
autoTestFlightSetup,
applicationIdentifierOverride: 'com.example.app',
profile: {},
user: { accounts: [{ name: 'account' }] },
accountName: 'account',
projectName: 'project',
credentialsCtx: { appStore },
graphqlClient: {},
} as SubmissionContext<Platform.IOS>,
ensureAuthenticatedAsync,
};
}
describe(ensureTestFlightSetupForExistingAppAsync, () => {
beforeEach(() => {
delete process.env.EXPO_ASC_API_KEY_PATH;
delete process.env.EXPO_ASC_KEY_ID;
delete process.env.EXPO_ASC_ISSUER_ID;
delete process.env.EXPO_APPLE_TEAM_ID;
jest.mocked(hasAscEnvVars).mockReset().mockReturnValue(false);
jest.mocked(resolveAscApiKeyForAppCredentialsAsync).mockReset().mockResolvedValue(null);
jest
.mocked(App.infoAsync)
.mockReset()
.mockResolvedValue({ id: '12345678' } as App);
jest.mocked(ensureTestFlightGroupExistsAsync).mockReset();
});
it('sets up TestFlight in non-interactive mode when stored credentials are complete', async () => {
jest.mocked(resolveAscApiKeyForAppCredentialsAsync).mockResolvedValue({
ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' },
teamId: 'team-id',
teamName: 'Team',
});
const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true });
await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678');
expect(ensureAuthenticatedAsync).toHaveBeenCalledWith({
mode: AuthenticationMode.API_KEY,
ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' },
teamId: 'team-id',
teamName: 'Team',
teamType: expect.any(String),
});
expect(ensureTestFlightGroupExistsAsync).toHaveBeenCalledWith(expect.anything(), {
nonInteractive: true,
});
});
it('sets up TestFlight in non-interactive mode when environment credentials are complete', async () => {
jest.mocked(hasAscEnvVars).mockReturnValue(true);
process.env.EXPO_ASC_API_KEY_PATH = '/path/to/key.p8';
process.env.EXPO_ASC_KEY_ID = 'key-id';
process.env.EXPO_ASC_ISSUER_ID = 'issuer-id';
process.env.EXPO_APPLE_TEAM_ID = 'team-id';
const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true });
await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678');
expect(ensureAuthenticatedAsync).toHaveBeenCalledWith({
mode: AuthenticationMode.API_KEY,
teamId: 'team-id',
teamType: expect.any(String),
});
expect(ensureTestFlightGroupExistsAsync).toHaveBeenCalledWith(expect.anything(), {
nonInteractive: true,
});
});
it('skips setup when environment credentials are incomplete', async () => {
jest.mocked(hasAscEnvVars).mockReturnValue(true);
process.env.EXPO_ASC_KEY_ID = 'key-id';
const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true });
await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678');
expect(ensureAuthenticatedAsync).not.toHaveBeenCalled();
expect(resolveAscApiKeyForAppCredentialsAsync).not.toHaveBeenCalled();
expect(ensureTestFlightGroupExistsAsync).not.toHaveBeenCalled();
});
it('skips setup when stored credentials have no Apple Team ID', async () => {
jest.mocked(resolveAscApiKeyForAppCredentialsAsync).mockResolvedValue({
ascApiKey: { keyP8: 'key', keyId: 'key-id', issuerId: 'issuer-id' },
});
const { ctx, ensureAuthenticatedAsync } = createContext({ nonInteractive: true });
await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678');
expect(ensureAuthenticatedAsync).not.toHaveBeenCalled();
expect(ensureTestFlightGroupExistsAsync).not.toHaveBeenCalled();
});
it('skips setup when automatic setup is disabled', async () => {
const { ctx, ensureAuthenticatedAsync } = createContext({ autoTestFlightSetup: false });
await ensureTestFlightSetupForExistingAppAsync(ctx, '12345678');
expect(ensureAuthenticatedAsync).not.toHaveBeenCalled();
expect(resolveAscApiKeyForAppCredentialsAsync).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,100 @@
import { App } from '@expo/apple-utils';
import { Platform } from '@expo/eas-build-job';
import nullthrows from 'nullthrows';
import {
AppleTeamType,
AuthenticationMode,
} from '../../credentials/ios/appstore/authenticateTypes';
import { getRequestContext } from '../../credentials/ios/appstore/authenticate';
import { ensureTestFlightGroupExistsAsync } from '../../credentials/ios/appstore/ensureTestFlightGroup';
import {
hasAscEnvVars,
resolveAppleTeamTypeFromEnvironment,
} from '../../credentials/ios/appstore/resolveCredentials';
import { resolveAscApiKeyForAppCredentialsAsync } from '../../credentials/ios/actions/AscApiKeyUtils';
import Log from '../../log';
import { getBundleIdentifierAsync } from '../../project/ios/bundleIdentifier';
import { SubmissionContext } from '../context';
/**
* Best-effort TestFlight internal group setup for an App Store Connect app
* that already exists with `ascAppId` provided.
*/
export async function ensureTestFlightSetupForExistingAppAsync(
ctx: SubmissionContext<Platform.IOS>,
ascAppIdentifier: string
): Promise<void> {
if (!ctx.autoTestFlightSetup) {
return;
}
try {
const bundleIdentifier =
ctx.applicationIdentifierOverride ??
ctx.profile.bundleIdentifier ??
(await getBundleIdentifierAsync(ctx.projectDir, ctx.exp, ctx.vcsClient));
const appLookupParams = {
account: nullthrows(
ctx.user.accounts.find(a => a.name === ctx.accountName),
`You do not have access to account: ${ctx.accountName}`
),
projectName: ctx.projectName,
bundleIdentifier,
};
if (!ctx.credentialsCtx.appStore.authCtx) {
const teamType =
resolveAppleTeamTypeFromEnvironment() ?? AppleTeamType.COMPANY_OR_ORGANIZATION;
if (hasAscEnvVars()) {
const teamId = process.env.EXPO_APPLE_TEAM_ID;
if (
!process.env.EXPO_ASC_API_KEY_PATH ||
!process.env.EXPO_ASC_KEY_ID ||
!process.env.EXPO_ASC_ISSUER_ID ||
!teamId
) {
Log.log('App Store Connect credentials are incomplete, skipping TestFlight setup');
return;
}
await ctx.credentialsCtx.appStore.ensureAuthenticatedAsync({
mode: AuthenticationMode.API_KEY,
teamId,
teamType,
});
} else {
const resolvedKey = await resolveAscApiKeyForAppCredentialsAsync({
graphqlClient: ctx.graphqlClient,
app: appLookupParams,
});
const teamId = resolvedKey?.teamId ?? process.env.EXPO_APPLE_TEAM_ID;
if (!resolvedKey || !teamId) {
Log.log('No complete App Store Connect credentials, skipping TestFlight setup');
return;
}
Log.log('Using App Store Connect API Key from EAS credentials service.');
await ctx.credentialsCtx.appStore.ensureAuthenticatedAsync({
mode: AuthenticationMode.API_KEY,
ascApiKey: resolvedKey.ascApiKey,
teamId,
teamName: resolvedKey.teamName,
teamType,
});
}
}
const authCtx = ctx.credentialsCtx.appStore.authCtx;
if (!authCtx) {
Log.debug('No App Store Connect API key available, skipping TestFlight setup');
return;
}
const app = await App.infoAsync(getRequestContext(authCtx), { id: ascAppIdentifier });
await ensureTestFlightGroupExistsAsync(app, { nonInteractive: ctx.nonInteractive });
} catch (error: any) {
// Group setup is a convenience on top of the submission and must never
// block it.
Log.warn('Skipping TestFlight group setup:', error);
}
}
+1 -1
View File
@@ -99,7 +99,7 @@ function toEasBuildLinkage(
return {
easSubmissionId: easSubmission?.id ?? null,
easBuildId: easSubmission?.submittedBuild?.id ?? null,
runtimeVersion: easSubmission?.submittedBuild?.runtimeVersion ?? null,
runtimeVersion: easSubmission?.submittedBuild?.runtime?.version ?? null,
fingerprintHash: easSubmission?.submittedBuild?.fingerprint?.hash ?? null,
};
}
@@ -49,7 +49,7 @@ export function formatGraphQLSubmission(submission: SubmissionWithSubmittedBuild
? [
{
label: 'Application Identifier',
value: sanitize(submission.androidConfig?.applicationIdentifier),
value: sanitize(submittedBuild?.appIdentifier),
},
{ label: 'Track', value: sanitize(submission.androidConfig?.track)?.toLowerCase() },
{
@@ -65,7 +65,7 @@ export function formatGraphQLSubmission(submission: SubmissionWithSubmittedBuild
label: submission.platform === AppPlatform.Android ? 'Version code' : 'Build number',
value: sanitize(submittedBuild?.appBuildVersion),
},
{ label: 'Runtime Version', value: sanitize(submittedBuild?.runtimeVersion) },
{ label: 'Runtime Version', value: sanitize(submittedBuild?.runtime?.version) },
{ label: 'Fingerprint', value: submittedBuild?.fingerprint?.hash },
{ label: 'Commit', value: sanitize(submittedBuild?.gitCommitHash) },
{ label: 'Error Code', value: sanitize(submission.error?.errorCode) },
@@ -161,6 +161,7 @@ function mockUpdateChannel({
pageInfo: { hasNextPage: false, hasPreviousPage: false },
},
embeddedUpdateCount: 0,
isProtected: false,
latestRuntimes: { edges: [], pageInfo: { hasNextPage: false, hasPreviousPage: false } },
runtimeInsights: {} as UpdateChannelRuntimeInsights,
name: channelName ?? 'default-channel-name',
Loaded 100 of 121 files, more files were not shown because too many files have changed in this diff. Show more