mirror of
https://github.com/expo/eas-cli.git
synced 2026-11-14 10:08:06 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b92a9cc5a7 | ||
|
|
763cbadaaa | ||
|
|
3cc58e500f | ||
|
|
2a7cb5e1fe | ||
|
|
289d579869 | ||
|
|
067c0e578f | ||
|
|
520c63fd3e | ||
|
|
221e01568c |
No files matched your search
+16
-4
@@ -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
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"STAGING": "21.7.1",
|
||||
"STAGING": "21.8.0",
|
||||
"PRODUCTION": "21.5.0"
|
||||
}
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"npmClient": "yarn",
|
||||
"version": "21.8.0",
|
||||
"version": "22.0.0",
|
||||
"$schema": "node_modules/lerna/schemas/lerna-schema.json"
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
export enum ManagedArtifactType {
|
||||
APPLICATION_ARCHIVE = 'APPLICATION_ARCHIVE',
|
||||
BUILD_ARTIFACTS = 'BUILD_ARTIFACTS',
|
||||
SOURCE_MAP = 'SOURCE_MAP',
|
||||
/**
|
||||
* @deprecated
|
||||
*/
|
||||
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
Generated
+1199
-49
File diff suppressed because it is too large.
Load diff
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
+82
-11
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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 } } : {}),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
+55
-11
@@ -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,
|
||||
|
||||
+105
@@ -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
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
+7
-1
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+4
-11
@@ -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) {
|
||||
|
||||
@@ -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}` : '')
|
||||
);
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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) },
|
||||
|
||||
+1
@@ -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
Reference in new issue
Block a user