From e58e0a8e4f1ab000d7509361048d1d7c222c7cb7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Czaj=C4=99cki?= Date: Wed, 4 Feb 2026 15:40:03 +0200 Subject: [PATCH 01/12] Add go command --- packages/eas-cli/src/commands/go.ts | 634 ++++++++++++++++++++++++++++ 1 file changed, 634 insertions(+) create mode 100644 packages/eas-cli/src/commands/go.ts diff --git a/packages/eas-cli/src/commands/go.ts b/packages/eas-cli/src/commands/go.ts new file mode 100644 index 0000000000..a4310949f7 --- /dev/null +++ b/packages/eas-cli/src/commands/go.ts @@ -0,0 +1,634 @@ +import { App, Build, User, UserRole } from '@expo/apple-utils'; +import spawnAsync from '@expo/spawn-async'; +import { Flags } from '@oclif/core'; +import chalk from 'chalk'; +import crypto from 'crypto'; +import * as fs from 'fs-extra'; +import * as os from 'os'; +import * as path from 'path'; +import prompts from 'prompts'; + +import { Analytics } from '../analytics/AnalyticsManager'; +import EasCommand from '../commandUtils/EasCommand'; +import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; +import { saveProjectIdToAppConfigAsync } from '../commandUtils/context/contextUtils/getProjectIdAsync'; +import { showWorkflowStatusAsync } from '../commandUtils/workflow/utils'; +import { CredentialsContext } from '../credentials/context'; +import { AppStoreApiKeyPurpose } from '../credentials/ios/actions/AscApiKeyUtils'; +import { getAppFromContextAsync } from '../credentials/ios/actions/BuildCredentialsUtils'; +import { SetUpAscApiKey } from '../credentials/ios/actions/SetUpAscApiKey'; +import { SetUpBuildCredentials } from '../credentials/ios/actions/SetUpBuildCredentials'; +import { ensureAppExistsAsync } from '../credentials/ios/appstore/ensureAppExists'; +import { Target } from '../credentials/ios/types'; +import { WorkflowProjectSourceType, WorkflowRunStatus } from '../graphql/generated'; +import { AppMutation } from '../graphql/mutations/AppMutation'; +import { WorkflowRevisionMutation } from '../graphql/mutations/WorkflowRevisionMutation'; +import { WorkflowRunMutation } from '../graphql/mutations/WorkflowRunMutation'; +import { AppQuery } from '../graphql/queries/AppQuery'; +import Log from '../log'; +import { ora } from '../ora'; +import { getPrivateExpoConfigAsync } from '../project/expoConfig'; +import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync'; +import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync'; +import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync'; +import { Actor } from '../user/User'; +import { resolveVcsClient } from '../vcs'; +import { Client as VcsClient } from '../vcs/vcs'; + +// Expo Go release info - update all three when releasing a new version +const EXPO_GO_IPA_URL = 'https://expo.dev/artifacts/eas/gGwYvTqGd3rHWrbHDBfjDj.ipa'; +const EXPO_GO_SDK_VERSION = '55'; +const EXPO_GO_APP_VERSION = '55.0.11'; +const EXPO_GO_BUILD_NUMBER = '1017799'; + +const TESTFLIGHT_GROUP_NAME = 'Team (Expo)'; + +async function setupTestFlightAsync(ascApp: App): Promise { + // Set encryption compliance on the latest build (required before testers can access) + for (let attempt = 0; attempt < 30; attempt++) { + try { + const builds = await Build.getAsync(ascApp.context, { + query: { + filter: { app: ascApp.id }, + sort: '-uploadedDate', + limit: 1, + }, + }); + + if (builds.length > 0) { + const build = builds[0]; + const { processingState, usesNonExemptEncryption } = build.attributes; + + // Wait for build to finish processing + if (processingState === 'PROCESSING') { + await new Promise(resolve => setTimeout(resolve, 10000)); + continue; + } + + // Set encryption compliance if not already set + if (usesNonExemptEncryption === null || usesNonExemptEncryption === undefined) { + await build.updateAsync({ usesNonExemptEncryption: false }); + } + break; + } + } catch { + // Build might not be ready yet, retry + } + await new Promise(resolve => setTimeout(resolve, 10000)); + } + + // Create or get TestFlight group + 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 new Promise(resolve => setTimeout(resolve, 10000)); + 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); + } +} + +/* eslint-disable no-console */ +async function withSuppressedOutputAsync(fn: () => Promise): Promise { + const originalStdoutWrite = process.stdout.write.bind(process.stdout); + const originalStderrWrite = process.stderr.write.bind(process.stderr); + const originalConsoleLog = console.log; + const originalConsoleError = console.error; + const originalConsoleWarn = console.warn; + + let capturedOutput = ''; + + const capture = (chunk: any): boolean => { + if (typeof chunk === 'string') { + capturedOutput += chunk; + } + return true; + }; + + process.stdout.write = capture as any; + process.stderr.write = capture as any; + console.log = () => {}; + console.error = () => {}; + console.warn = () => {}; + + try { + return await fn(); + } catch (error) { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + console.log = originalConsoleLog; + console.error = originalConsoleError; + console.warn = originalConsoleWarn; + + if (capturedOutput) { + originalConsoleLog(capturedOutput); + } + throw error; + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + console.log = originalConsoleLog; + console.error = originalConsoleError; + console.warn = originalConsoleWarn; + } +} +/* eslint-enable no-console */ + +const WORKFLOW_TEMPLATE = `name: Repack Expo Go + +jobs: + repack: + type: build + params: + platform: ios + profile: production + steps: + - uses: eas/checkout + - uses: eas/use_npm_token + - uses: eas/install_node_modules + - uses: eas/resolve_build_config + - id: download + run: | + curl --output expo-go.ipa -L ${EXPO_GO_IPA_URL} + # Add ITSAppUsesNonExemptEncryption to skip export compliance dialog + unzip -q expo-go.ipa -d ipa_contents + APP_PATH=$(find ipa_contents/Payload -name "*.app" -type d | head -1) + /usr/libexec/PlistBuddy -c "Add :ITSAppUsesNonExemptEncryption bool false" "$APP_PATH/Info.plist" 2>/dev/null || /usr/libexec/PlistBuddy -c "Set :ITSAppUsesNonExemptEncryption false" "$APP_PATH/Info.plist" + cd ipa_contents && zip -qr ../expo-go-modified.ipa . && cd .. + rm -rf ipa_contents + mv expo-go-modified.ipa expo-go.ipa + set-output ipa_path "$PWD/expo-go.ipa" + - uses: eas/repack + id: repack + with: + source_app_path: "\${{ steps.download.outputs.ipa_path }}" + platform: ios + embed_bundle_assets: false + repack_package: "@kudo-chien/repack-app" + repack_version: "0.3.0" + - uses: eas/upload_artifact + with: + path: "\${{ steps.repack.outputs.output_path }}" + + testflight: + type: testflight + needs: [repack] + params: + build_id: \${{ needs.repack.outputs.build_id }} +`; + +export default class Go extends EasCommand { + static override description = 'Create a custom Expo Go and submit to TestFlight'; + + static override flags = { + 'bundle-id': Flags.string({ + description: 'iOS bundle identifier (auto-generated if not provided)', + required: false, + }), + name: Flags.string({ + description: 'App name', + default: 'Custom Expo Go', + }), + credentials: Flags.boolean({ + description: 'Interactively select credentials (default: auto-select)', + default: false, + }), + }; + + static override contextDefinition = { + ...this.ContextOptions.LoggedIn, + ...this.ContextOptions.Analytics, + }; + + async runAsync(): Promise { + Log.log(chalk.bold('Creating custom Expo Go for TestFlight...\n')); + + const { flags } = await this.parse(Go); + + let spinner = ora('Logging in to Expo...').start(); + const { + loggedIn: { actor, graphqlClient }, + analytics, + } = await this.getContextAsync(Go, { + nonInteractive: false, + }); + spinner.succeed(`Logged in as ${chalk.cyan(actor.accounts[0].name)}`); + + const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor); + const appName = flags.name ?? 'Custom Expo Go'; + const slug = bundleId.split('.').pop() || 'custom-expo-go'; + + const projectDir = path.join(os.tmpdir(), `eas-go-${slug}`); + await fs.emptyDir(projectDir); + + const originalCwd = process.cwd(); + process.chdir(projectDir); + + Log.log(`Bundle ID: ${chalk.cyan(bundleId)}`); + + // Step 1: Create project files and initialize git + try { + spinner = ora('Creating project...').start(); + await withSuppressedOutputAsync(async () => { + await this.createProjectFilesAsync(projectDir, bundleId, appName); + await this.initGitRepoAsync(projectDir); + }); + const vcsClient = resolveVcsClient(); + + // Step 2: Create/link EAS project + const projectId = await withSuppressedOutputAsync(() => + this.ensureEasProjectAsync(graphqlClient, actor, projectDir, bundleId) + ); + spinner.succeed( + `Project created: ${chalk.cyan(`@${actor.accounts[0].name}/${bundleId.split('.').pop()}`)}` + ); + + // Step 3: Set up iOS credentials and create App Store Connect app + const ascApp = await this.setupCredentialsAsync( + projectDir, + projectId, + bundleId, + appName, + graphqlClient, + actor, + analytics, + vcsClient, + flags.credentials + ); + + Log.withTick('Credentials and App Store Connect configured'); + + // Step 4: Run workflow + const startTime = Date.now(); + spinner = ora('Starting workflow...').start(); + + await this.runWorkflowAsync(graphqlClient, projectDir, projectId, actor, vcsClient, url => { + spinner.succeed(`Workflow started: ${chalk.cyan(url)}`); + }); + + const elapsed = Math.floor((Date.now() - startTime) / 1000); + const mins = Math.floor(elapsed / 60); + const secs = elapsed % 60; + Log.withTick(`Workflow completed in ${mins}:${secs.toString().padStart(2, '0')}`); + + // Step 5: Set up TestFlight group (after build is submitted) + try { + await setupTestFlightAsync(ascApp); + } catch (error: any) { + Log.warn(`Could not set up TestFlight group: ${error.message}`); + } + + Log.newLine(); + Log.succeed('Done! Your custom Expo Go has been submitted to TestFlight.'); + Log.log( + `App Store Connect: ${chalk.cyan( + `https://appstoreconnect.apple.com/apps/${ascApp.id}/testflight` + )}` + ); + + await fs.remove(projectDir); + } catch (error) { + spinner?.fail(); + Log.gray(`Project files preserved for debugging: ${projectDir}`); + throw error; + } finally { + process.chdir(originalCwd); + } + } + + private generateBundleId(actor: Actor): string { + const username = actor.accounts[0].name; + // Sanitize username for bundle ID: only alphanumeric and hyphens allowed + const sanitizedUsername = username + .toLowerCase() + .replace(/[^a-z0-9-]/g, '') + .replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens + // Generate 6 random bytes and encode as base64, then make it bundle-ID safe + const randomBytes = crypto + .randomBytes(6) + .toString('base64') + .replace(/[^a-zA-Z0-9]/g, '') + .substring(0, 6) + .toLowerCase(); + return `com.${sanitizedUsername || 'app'}.${randomBytes}`; + } + + private async createProjectFilesAsync( + projectDir: string, + bundleId: string, + appName: string + ): Promise { + const slug = bundleId.split('.').pop() || 'custom-expo-go'; + const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`; + + const appJson = { + expo: { + name: appName, + slug, + version: '55.0.11', + ios: { + bundleIdentifier: bundleId, + buildNumber: '1017799', + }, + extra: { + eas: { + build: { + experimental: { + ios: { + appExtensions: [ + { + targetName: 'ExpoNotificationServiceExtension', + bundleIdentifier: extensionBundleId, + }, + ], + }, + }, + }, + }, + }, + }, + }; + + const easJson = { + cli: { + version: '>= 5.0.0', + }, + build: { + production: { + distribution: 'store', + credentialsSource: 'remote', + }, + }, + submit: { + production: { + ios: {}, + }, + }, + }; + + const packageJson = { + name: slug, + version: '1.0.0', + dependencies: { + expo: '~54.0.0', + }, + }; + + await fs.writeJson(path.join(projectDir, 'app.json'), appJson, { spaces: 2 }); + await fs.writeJson(path.join(projectDir, 'eas.json'), easJson, { spaces: 2 }); + await fs.writeJson(path.join(projectDir, 'package.json'), packageJson, { spaces: 2 }); + + const workflowDir = path.join(projectDir, '.eas', 'workflows'); + await fs.ensureDir(workflowDir); + await fs.writeFile(path.join(workflowDir, 'repack.yml'), WORKFLOW_TEMPLATE); + + await spawnAsync('yarn', ['install'], { cwd: projectDir }); + } + + private async initGitRepoAsync(projectDir: string): Promise { + await spawnAsync('git', ['init'], { cwd: projectDir }); + await spawnAsync('git', ['add', '.'], { cwd: projectDir }); + await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectDir }); + } + + private async ensureEasProjectAsync( + graphqlClient: ExpoGraphqlClient, + actor: Actor, + projectDir: string, + bundleId: string + ): Promise { + const slug = bundleId.split('.').pop() || 'custom-expo-go'; + const account = actor.accounts[0]; + + const existingProjectId = await findProjectIdByAccountNameAndSlugNullableAsync( + graphqlClient, + account.name, + slug + ); + + if (existingProjectId) { + await saveProjectIdToAppConfigAsync(projectDir, existingProjectId); + return existingProjectId; + } + + const projectId = await AppMutation.createAppAsync(graphqlClient, { + accountId: account.id, + projectName: slug, + }); + await saveProjectIdToAppConfigAsync(projectDir, projectId); + return projectId; + } + + private async setupCredentialsAsync( + projectDir: string, + projectId: string, + bundleId: string, + appName: string, + graphqlClient: ExpoGraphqlClient, + actor: Actor, + analytics: Analytics, + vcsClient: VcsClient, + customizeCreds: boolean + ): Promise { + const exp = await getPrivateExpoConfigAsync(projectDir); + const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`; + + const credentialsCtx = new CredentialsContext({ + projectInfo: { exp, projectId }, + nonInteractive: false, + projectDir, + user: actor, + graphqlClient, + analytics, + vcsClient, + easJsonCliConfig: { + promptToConfigurePushNotifications: false, + }, + }); + + const userAuthCtx = await credentialsCtx.appStore.ensureUserAuthenticatedAsync(); + + const app = await getAppFromContextAsync(credentialsCtx); + + const targets: Target[] = [ + { + targetName: exp.slug, + bundleIdentifier: bundleId, + entitlements: {}, + }, + { + targetName: 'ExpoNotificationServiceExtension', + bundleIdentifier: extensionBundleId, + parentBundleIdentifier: bundleId, + entitlements: {}, + }, + ]; + + if (!customizeCreds) { + prompts.inject(Array(20).fill(true)); + } + + const ascApp = await withSuppressedOutputAsync(async () => { + await new SetUpBuildCredentials({ + app, + targets, + distribution: 'store', + }).runAsync(credentialsCtx); + + const appLookupParams = { + ...app, + bundleIdentifier: bundleId, + }; + await new SetUpAscApiKey(appLookupParams, AppStoreApiKeyPurpose.SUBMISSION_SERVICE).runAsync( + credentialsCtx + ); + + const ascAppResult = await ensureAppExistsAsync(userAuthCtx, { + name: appName, + bundleIdentifier: bundleId, + }); + + const easJsonPath = path.join(projectDir, 'eas.json'); + const easJson = await fs.readJson(easJsonPath); + easJson.submit = easJson.submit || {}; + easJson.submit.production = easJson.submit.production || {}; + easJson.submit.production.ios = easJson.submit.production.ios || {}; + easJson.submit.production.ios.ascAppId = ascAppResult.id; + await fs.writeJson(easJsonPath, easJson, { spaces: 2 }); + + await spawnAsync('git', ['add', 'eas.json'], { cwd: projectDir }); + await spawnAsync('git', ['commit', '-m', 'Add ascAppId to eas.json'], { cwd: projectDir }); + + return ascAppResult; + }); + + return ascApp; + } + + private async runWorkflowAsync( + graphqlClient: ExpoGraphqlClient, + projectDir: string, + projectId: string, + actor: Actor, + vcsClient: VcsClient, + onWorkflowStarted?: (workflowUrl: string) => void + ): Promise { + const account = actor.accounts[0]; + const workflowFile = path.join(projectDir, '.eas', 'workflows', 'repack.yml'); + const yamlConfig = await fs.readFile(workflowFile, 'utf-8'); + + await WorkflowRevisionMutation.validateWorkflowYamlConfigAsync(graphqlClient, { + appId: projectId, + yamlConfig, + }); + + const { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey } = + await withSuppressedOutputAsync(async () => { + const { projectArchiveBucketKey } = await uploadAccountScopedProjectSourceAsync({ + graphqlClient, + vcsClient, + accountId: account.id, + }); + + const easJsonPath = path.join(projectDir, 'eas.json'); + const packageJsonPath = path.join(projectDir, 'package.json'); + + const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({ + graphqlClient, + accountId: account.id, + filePath: easJsonPath, + maxSizeBytes: 1024 * 1024, + }); + + const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({ + graphqlClient, + accountId: account.id, + filePath: packageJsonPath, + maxSizeBytes: 1024 * 1024, + }); + + return { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey }; + }); + + const { id: workflowRunId } = await WorkflowRunMutation.createWorkflowRunAsync(graphqlClient, { + appId: projectId, + workflowRevisionInput: { + fileName: 'repack.yml', + yamlConfig, + }, + workflowRunInput: { + inputs: {}, + projectSource: { + type: WorkflowProjectSourceType.Gcs, + projectArchiveBucketKey, + easJsonBucketKey, + packageJsonBucketKey, + projectRootDirectory: '.', + }, + }, + }); + + const app = await AppQuery.byIdAsync(graphqlClient, projectId); + const workflowUrl = `https://expo.dev/accounts/${account.name}/projects/${app.slug}/workflows/${workflowRunId}`; + + onWorkflowStarted?.(workflowUrl); + + const { status } = await showWorkflowStatusAsync(graphqlClient, { + workflowRunId, + spinnerUsesStdErr: false, + }); + + if (status === WorkflowRunStatus.Failure) { + throw new Error('Workflow failed'); + } else if (status === WorkflowRunStatus.Canceled) { + throw new Error('Workflow was canceled'); + } + + return workflowUrl; + } +} From 3df5d75015c2fd124b3e8d2fcd68425b41c518c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Czaj=C4=99cki?= Date: Wed, 4 Feb 2026 18:37:47 +0200 Subject: [PATCH 02/12] Apply feedback --- packages/eas-cli/src/commands/go.ts | 29 +++++++++++------------------ 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/packages/eas-cli/src/commands/go.ts b/packages/eas-cli/src/commands/go.ts index a4310949f7..ad999fffb0 100644 --- a/packages/eas-cli/src/commands/go.ts +++ b/packages/eas-cli/src/commands/go.ts @@ -2,7 +2,6 @@ import { App, Build, User, UserRole } from '@expo/apple-utils'; import spawnAsync from '@expo/spawn-async'; import { Flags } from '@oclif/core'; import chalk from 'chalk'; -import crypto from 'crypto'; import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; @@ -210,13 +209,13 @@ jobs: platform: ios embed_bundle_assets: false repack_package: "@kudo-chien/repack-app" - repack_version: "0.3.0" + repack_version: "0.3.1" - uses: eas/upload_artifact with: path: "\${{ steps.repack.outputs.output_path }}" - testflight: - type: testflight + submit: + type: submit needs: [repack] params: build_id: \${{ needs.repack.outputs.build_id }} @@ -232,7 +231,7 @@ export default class Go extends EasCommand { }), name: Flags.string({ description: 'App name', - default: 'Custom Expo Go', + default: 'My Expo Go', }), credentials: Flags.boolean({ description: 'Interactively select credentials (default: auto-select)', @@ -246,7 +245,7 @@ export default class Go extends EasCommand { }; async runAsync(): Promise { - Log.log(chalk.bold('Creating custom Expo Go for TestFlight...\n')); + Log.log(chalk.bold('Creating your personal Expo Go...\n')); const { flags } = await this.parse(Go); @@ -260,8 +259,8 @@ export default class Go extends EasCommand { spinner.succeed(`Logged in as ${chalk.cyan(actor.accounts[0].name)}`); const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor); - const appName = flags.name ?? 'Custom Expo Go'; - const slug = bundleId.split('.').pop() || 'custom-expo-go'; + const appName = flags.name ?? 'My Expo Go'; + const slug = bundleId.split('.').pop() || 'my-expo-go'; const projectDir = path.join(os.tmpdir(), `eas-go-${slug}`); await fs.emptyDir(projectDir); @@ -348,14 +347,8 @@ export default class Go extends EasCommand { .toLowerCase() .replace(/[^a-z0-9-]/g, '') .replace(/^-+|-+$/g, ''); // trim leading/trailing hyphens - // Generate 6 random bytes and encode as base64, then make it bundle-ID safe - const randomBytes = crypto - .randomBytes(6) - .toString('base64') - .replace(/[^a-zA-Z0-9]/g, '') - .substring(0, 6) - .toLowerCase(); - return `com.${sanitizedUsername || 'app'}.${randomBytes}`; + // Deterministic bundle ID per user + SDK version (reuses same ASC app) + return `com.${sanitizedUsername || 'app'}.expogo${EXPO_GO_SDK_VERSION}`; } private async createProjectFilesAsync( @@ -370,10 +363,10 @@ export default class Go extends EasCommand { expo: { name: appName, slug, - version: '55.0.11', + version: EXPO_GO_APP_VERSION, ios: { bundleIdentifier: bundleId, - buildNumber: '1017799', + buildNumber: EXPO_GO_BUILD_NUMBER, }, extra: { eas: { From bc9662ab46869faccc8ce0d2d627dfa197eeefcd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Czaj=C4=99cki?= Date: Wed, 4 Feb 2026 19:13:34 +0200 Subject: [PATCH 03/12] Update script --- packages/eas-cli/src/commands/go.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/eas-cli/src/commands/go.ts b/packages/eas-cli/src/commands/go.ts index ad999fffb0..351549a972 100644 --- a/packages/eas-cli/src/commands/go.ts +++ b/packages/eas-cli/src/commands/go.ts @@ -316,10 +316,12 @@ export default class Go extends EasCommand { Log.withTick(`Workflow completed in ${mins}:${secs.toString().padStart(2, '0')}`); // Step 5: Set up TestFlight group (after build is submitted) + spinner = ora('Setting up TestFlight...').start(); try { await setupTestFlightAsync(ascApp); + spinner.succeed('TestFlight configured'); } catch (error: any) { - Log.warn(`Could not set up TestFlight group: ${error.message}`); + spinner.fail(`Could not set up TestFlight group: ${error.message}`); } Log.newLine(); From 9d2c32542d13ec5e41a14f05ebc119d221c954d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Czaj=C4=99cki?= Date: Wed, 4 Feb 2026 19:28:46 +0200 Subject: [PATCH 04/12] Try use proper expo config for encryption --- packages/eas-cli/src/commands/go.ts | 45 +++-------------------------- 1 file changed, 4 insertions(+), 41 deletions(-) diff --git a/packages/eas-cli/src/commands/go.ts b/packages/eas-cli/src/commands/go.ts index 351549a972..252b8f9407 100644 --- a/packages/eas-cli/src/commands/go.ts +++ b/packages/eas-cli/src/commands/go.ts @@ -1,4 +1,4 @@ -import { App, Build, User, UserRole } from '@expo/apple-utils'; +import { App, User, UserRole } from '@expo/apple-utils'; import spawnAsync from '@expo/spawn-async'; import { Flags } from '@oclif/core'; import chalk from 'chalk'; @@ -43,39 +43,6 @@ const EXPO_GO_BUILD_NUMBER = '1017799'; const TESTFLIGHT_GROUP_NAME = 'Team (Expo)'; async function setupTestFlightAsync(ascApp: App): Promise { - // Set encryption compliance on the latest build (required before testers can access) - for (let attempt = 0; attempt < 30; attempt++) { - try { - const builds = await Build.getAsync(ascApp.context, { - query: { - filter: { app: ascApp.id }, - sort: '-uploadedDate', - limit: 1, - }, - }); - - if (builds.length > 0) { - const build = builds[0]; - const { processingState, usesNonExemptEncryption } = build.attributes; - - // Wait for build to finish processing - if (processingState === 'PROCESSING') { - await new Promise(resolve => setTimeout(resolve, 10000)); - continue; - } - - // Set encryption compliance if not already set - if (usesNonExemptEncryption === null || usesNonExemptEncryption === undefined) { - await build.updateAsync({ usesNonExemptEncryption: false }); - } - break; - } - } catch { - // Build might not be ready yet, retry - } - await new Promise(resolve => setTimeout(resolve, 10000)); - } - // Create or get TestFlight group let group; for (let attempt = 0; attempt < 10; attempt++) { @@ -194,13 +161,6 @@ jobs: - id: download run: | curl --output expo-go.ipa -L ${EXPO_GO_IPA_URL} - # Add ITSAppUsesNonExemptEncryption to skip export compliance dialog - unzip -q expo-go.ipa -d ipa_contents - APP_PATH=$(find ipa_contents/Payload -name "*.app" -type d | head -1) - /usr/libexec/PlistBuddy -c "Add :ITSAppUsesNonExemptEncryption bool false" "$APP_PATH/Info.plist" 2>/dev/null || /usr/libexec/PlistBuddy -c "Set :ITSAppUsesNonExemptEncryption false" "$APP_PATH/Info.plist" - cd ipa_contents && zip -qr ../expo-go-modified.ipa . && cd .. - rm -rf ipa_contents - mv expo-go-modified.ipa expo-go.ipa set-output ipa_path "$PWD/expo-go.ipa" - uses: eas/repack id: repack @@ -369,6 +329,9 @@ export default class Go extends EasCommand { ios: { bundleIdentifier: bundleId, buildNumber: EXPO_GO_BUILD_NUMBER, + config: { + usesNonExemptEncryption: false, + }, }, extra: { eas: { From a3d7353e6a362a7218b7a4515e0f073193f36281 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Czaj=C4=99cki?= Date: Wed, 4 Feb 2026 20:32:40 +0200 Subject: [PATCH 05/12] Add names of steps --- packages/eas-cli/src/commands/go.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/eas-cli/src/commands/go.ts b/packages/eas-cli/src/commands/go.ts index 252b8f9407..2cff887abf 100644 --- a/packages/eas-cli/src/commands/go.ts +++ b/packages/eas-cli/src/commands/go.ts @@ -149,6 +149,7 @@ const WORKFLOW_TEMPLATE = `name: Repack Expo Go jobs: repack: + name: Repack Expo Go type: build params: platform: ios @@ -175,6 +176,7 @@ jobs: path: "\${{ steps.repack.outputs.output_path }}" submit: + name: Submit to TestFlight type: submit needs: [repack] params: From 4b613822337b95d240c26cc13dbd6fcf6adf382d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Czaj=C4=99cki?= Date: Wed, 4 Feb 2026 21:35:00 +0200 Subject: [PATCH 06/12] Switch to NPM --- packages/eas-cli/src/commands/go.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eas-cli/src/commands/go.ts b/packages/eas-cli/src/commands/go.ts index 2cff887abf..65403f314b 100644 --- a/packages/eas-cli/src/commands/go.ts +++ b/packages/eas-cli/src/commands/go.ts @@ -387,7 +387,7 @@ export default class Go extends EasCommand { await fs.ensureDir(workflowDir); await fs.writeFile(path.join(workflowDir, 'repack.yml'), WORKFLOW_TEMPLATE); - await spawnAsync('yarn', ['install'], { cwd: projectDir }); + await spawnAsync('npm', ['install'], { cwd: projectDir }); } private async initGitRepoAsync(projectDir: string): Promise { From 91a934d7caf28985f56d00de95dba00283381114 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Czaj=C4=99cki?= Date: Wed, 4 Feb 2026 21:52:06 +0200 Subject: [PATCH 07/12] Hide the command by default for now --- packages/eas-cli/src/commands/go.ts | 173 ++++++++++++------ packages/eas-cli/src/credentials/context.ts | 3 + .../credentials/ios/actions/SetUpAscApiKey.ts | 8 +- .../actions/SetUpDistributionCertificate.ts | 12 +- packages/eas-cli/src/log.ts | 9 + 5 files changed, 137 insertions(+), 68 deletions(-) diff --git a/packages/eas-cli/src/commands/go.ts b/packages/eas-cli/src/commands/go.ts index 65403f314b..b63f9a3cf8 100644 --- a/packages/eas-cli/src/commands/go.ts +++ b/packages/eas-cli/src/commands/go.ts @@ -5,13 +5,10 @@ import chalk from 'chalk'; import * as fs from 'fs-extra'; import * as os from 'os'; import * as path from 'path'; -import prompts from 'prompts'; - import { Analytics } from '../analytics/AnalyticsManager'; import EasCommand from '../commandUtils/EasCommand'; import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; import { saveProjectIdToAppConfigAsync } from '../commandUtils/context/contextUtils/getProjectIdAsync'; -import { showWorkflowStatusAsync } from '../commandUtils/workflow/utils'; import { CredentialsContext } from '../credentials/context'; import { AppStoreApiKeyPurpose } from '../credentials/ios/actions/AscApiKeyUtils'; import { getAppFromContextAsync } from '../credentials/ios/actions/BuildCredentialsUtils'; @@ -19,18 +16,20 @@ import { SetUpAscApiKey } from '../credentials/ios/actions/SetUpAscApiKey'; import { SetUpBuildCredentials } from '../credentials/ios/actions/SetUpBuildCredentials'; import { ensureAppExistsAsync } from '../credentials/ios/appstore/ensureAppExists'; import { Target } from '../credentials/ios/types'; -import { WorkflowProjectSourceType, WorkflowRunStatus } from '../graphql/generated'; +import { WorkflowJobStatus, WorkflowProjectSourceType, WorkflowRunStatus } from '../graphql/generated'; import { AppMutation } from '../graphql/mutations/AppMutation'; import { WorkflowRevisionMutation } from '../graphql/mutations/WorkflowRevisionMutation'; import { WorkflowRunMutation } from '../graphql/mutations/WorkflowRunMutation'; import { AppQuery } from '../graphql/queries/AppQuery'; -import Log from '../log'; +import { WorkflowRunQuery } from '../graphql/queries/WorkflowRunQuery'; +import Log, { learnMore } from '../log'; import { ora } from '../ora'; import { getPrivateExpoConfigAsync } from '../project/expoConfig'; import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync'; import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync'; import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync'; import { Actor } from '../user/User'; +import { sleepAsync } from '../utils/promise'; import { resolveVcsClient } from '../vcs'; import { Client as VcsClient } from '../vcs/vcs'; @@ -102,7 +101,6 @@ async function setupTestFlightAsync(ascApp: App): Promise { /* eslint-disable no-console */ async function withSuppressedOutputAsync(fn: () => Promise): Promise { const originalStdoutWrite = process.stdout.write.bind(process.stdout); - const originalStderrWrite = process.stderr.write.bind(process.stderr); const originalConsoleLog = console.log; const originalConsoleError = console.error; const originalConsoleWarn = console.warn; @@ -116,8 +114,9 @@ async function withSuppressedOutputAsync(fn: () => Promise): Promise { return true; }; + // Only suppress stdout, not stderr — ora writes spinner frames to stderr and + // patching it would freeze the spinner animation during suppressed async work. process.stdout.write = capture as any; - process.stderr.write = capture as any; console.log = () => {}; console.error = () => {}; console.warn = () => {}; @@ -126,7 +125,6 @@ async function withSuppressedOutputAsync(fn: () => Promise): Promise { return await fn(); } catch (error) { process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; console.log = originalConsoleLog; console.error = originalConsoleError; console.warn = originalConsoleWarn; @@ -137,7 +135,6 @@ async function withSuppressedOutputAsync(fn: () => Promise): Promise { throw error; } finally { process.stdout.write = originalStdoutWrite; - process.stderr.write = originalStderrWrite; console.log = originalConsoleLog; console.error = originalConsoleError; console.warn = originalConsoleWarn; @@ -184,6 +181,7 @@ jobs: `; export default class Go extends EasCommand { + static override hidden = true; static override description = 'Create a custom Expo Go and submit to TestFlight'; static override flags = { @@ -207,11 +205,15 @@ export default class Go extends EasCommand { }; async runAsync(): Promise { - Log.log(chalk.bold('Creating your personal Expo Go...\n')); + Log.log( + chalk.bold( + `Creating your personal Expo Go and deploying to TestFlight. ${learnMore('https://expo.fyi/personal-expo-go')}` + ) + ); const { flags } = await this.parse(Go); - let spinner = ora('Logging in to Expo...').start(); + const spinner = ora('Logging in to Expo...').start(); const { loggedIn: { actor, graphqlClient }, analytics, @@ -230,24 +232,20 @@ export default class Go extends EasCommand { const originalCwd = process.cwd(); process.chdir(projectDir); - Log.log(`Bundle ID: ${chalk.cyan(bundleId)}`); + const setupSpinner = ora('Creating project...').start(); - // Step 1: Create project files and initialize git + // Step 1: Create project files and initialize git (silently) try { - spinner = ora('Creating project...').start(); await withSuppressedOutputAsync(async () => { await this.createProjectFilesAsync(projectDir, bundleId, appName); await this.initGitRepoAsync(projectDir); }); const vcsClient = resolveVcsClient(); - // Step 2: Create/link EAS project + // Step 2: Create/link EAS project (silently) const projectId = await withSuppressedOutputAsync(() => this.ensureEasProjectAsync(graphqlClient, actor, projectDir, bundleId) ); - spinner.succeed( - `Project created: ${chalk.cyan(`@${actor.accounts[0].name}/${bundleId.split('.').pop()}`)}` - ); // Step 3: Set up iOS credentials and create App Store Connect app const ascApp = await this.setupCredentialsAsync( @@ -259,44 +257,53 @@ export default class Go extends EasCommand { actor, analytics, vcsClient, - flags.credentials + flags.credentials, + () => { + setupSpinner.stop(); + Log.markFreshLine(); + } ); - Log.withTick('Credentials and App Store Connect configured'); - - // Step 4: Run workflow - const startTime = Date.now(); - spinner = ora('Starting workflow...').start(); - - await this.runWorkflowAsync(graphqlClient, projectDir, projectId, actor, vcsClient, url => { - spinner.succeed(`Workflow started: ${chalk.cyan(url)}`); - }); + // Step 4: Start workflow and monitor progress + const { workflowUrl, workflowRunId } = await this.runWorkflowAsync( + graphqlClient, + projectDir, + projectId, + actor, + vcsClient + ); + Log.withTick(`Workflow started: ${chalk.cyan(workflowUrl)}`); - const elapsed = Math.floor((Date.now() - startTime) / 1000); - const mins = Math.floor(elapsed / 60); - const secs = elapsed % 60; - Log.withTick(`Workflow completed in ${mins}:${secs.toString().padStart(2, '0')}`); + const status = await this.monitorWorkflowJobsAsync(graphqlClient, workflowRunId); + if (status === WorkflowRunStatus.Failure) { + throw new Error('Workflow failed'); + } else if (status === WorkflowRunStatus.Canceled) { + throw new Error('Workflow was canceled'); + } - // Step 5: Set up TestFlight group (after build is submitted) - spinner = ora('Setting up TestFlight...').start(); + // Step 5: Set up TestFlight group (silently) try { await setupTestFlightAsync(ascApp); - spinner.succeed('TestFlight configured'); - } catch (error: any) { - spinner.fail(`Could not set up TestFlight group: ${error.message}`); + } catch { + // Non-fatal: TestFlight group setup failure shouldn't block the user } Log.newLine(); - Log.succeed('Done! Your custom Expo Go has been submitted to TestFlight.'); + Log.succeed( + `Done! Your custom Expo Go has been submitted to TestFlight. ${learnMore( + `https://appstoreconnect.apple.com/apps/${ascApp.id}/testflight`, + { learnMoreMessage: 'Open it on App Store Connect' } + )}` + ); Log.log( - `App Store Connect: ${chalk.cyan( - `https://appstoreconnect.apple.com/apps/${ascApp.id}/testflight` + `App Store processing may take several minutes to complete. ${learnMore( + 'https://expo.fyi/personal-expo-go', + { learnMoreMessage: 'Learn more about Expo Go on TestFlight' } )}` ); await fs.remove(projectDir); } catch (error) { - spinner?.fail(); Log.gray(`Project files preserved for debugging: ${projectDir}`); throw error; } finally { @@ -433,7 +440,8 @@ export default class Go extends EasCommand { actor: Actor, analytics: Analytics, vcsClient: VcsClient, - customizeCreds: boolean + customizeCreds: boolean, + onBeforeAppleAuth?: () => void ): Promise { const exp = await getPrivateExpoConfigAsync(projectDir); const extensionBundleId = `${bundleId}.ExpoNotificationServiceExtension`; @@ -441,6 +449,7 @@ export default class Go extends EasCommand { const credentialsCtx = new CredentialsContext({ projectInfo: { exp, projectId }, nonInteractive: false, + autoAcceptCredentialReuse: !customizeCreds, projectDir, user: actor, graphqlClient, @@ -451,6 +460,7 @@ export default class Go extends EasCommand { }, }); + onBeforeAppleAuth?.(); const userAuthCtx = await credentialsCtx.appStore.ensureUserAuthenticatedAsync(); const app = await getAppFromContextAsync(credentialsCtx); @@ -469,10 +479,6 @@ export default class Go extends EasCommand { }, ]; - if (!customizeCreds) { - prompts.inject(Array(20).fill(true)); - } - const ascApp = await withSuppressedOutputAsync(async () => { await new SetUpBuildCredentials({ app, @@ -515,9 +521,8 @@ export default class Go extends EasCommand { projectDir: string, projectId: string, actor: Actor, - vcsClient: VcsClient, - onWorkflowStarted?: (workflowUrl: string) => void - ): Promise { + vcsClient: VcsClient + ): Promise<{ workflowUrl: string; workflowRunId: string }> { const account = actor.accounts[0]; const workflowFile = path.join(projectDir, '.eas', 'workflows', 'repack.yml'); const yamlConfig = await fs.readFile(workflowFile, 'utf-8'); @@ -576,19 +581,67 @@ export default class Go extends EasCommand { const app = await AppQuery.byIdAsync(graphqlClient, projectId); const workflowUrl = `https://expo.dev/accounts/${account.name}/projects/${app.slug}/workflows/${workflowRunId}`; - onWorkflowStarted?.(workflowUrl); + return { workflowUrl, workflowRunId }; + } - const { status } = await showWorkflowStatusAsync(graphqlClient, { - workflowRunId, - spinnerUsesStdErr: false, - }); + private async monitorWorkflowJobsAsync( + graphqlClient: ExpoGraphqlClient, + workflowRunId: string + ): Promise { + const buildSpinner = ora('Building Expo Go').start(); + let submitSpinner: ReturnType | null = null; + let buildCompleted = false; + let failedFetchesCount = 0; + + while (true) { + try { + const workflowRun = await WorkflowRunQuery.withJobsByIdAsync(graphqlClient, workflowRunId, { + useCache: false, + }); + failedFetchesCount = 0; + + const repackJob = workflowRun.jobs.find(j => j.name === 'Repack Expo Go'); + const submitJob = workflowRun.jobs.find(j => j.name === 'Submit to TestFlight'); + + if (!buildCompleted) { + if (repackJob?.status === WorkflowJobStatus.Success) { + buildSpinner.succeed('Built Expo Go'); + buildCompleted = true; + } else if ( + repackJob?.status === WorkflowJobStatus.Failure || + repackJob?.status === WorkflowJobStatus.Canceled + ) { + buildSpinner.fail('Build failed'); + return WorkflowRunStatus.Failure; + } + } - if (status === WorkflowRunStatus.Failure) { - throw new Error('Workflow failed'); - } else if (status === WorkflowRunStatus.Canceled) { - throw new Error('Workflow was canceled'); - } + if (buildCompleted && submitSpinner === null && submitJob) { + submitSpinner = ora('Submitting to TestFlight').start(); + } + + if (workflowRun.status === WorkflowRunStatus.Success) { + submitSpinner?.stop(); + return WorkflowRunStatus.Success; + } else if (workflowRun.status === WorkflowRunStatus.Failure) { + buildSpinner.stop(); + submitSpinner?.fail('Submission failed'); + return WorkflowRunStatus.Failure; + } else if (workflowRun.status === WorkflowRunStatus.Canceled) { + buildSpinner.stop(); + submitSpinner?.stop(); + return WorkflowRunStatus.Canceled; + } + } catch { + failedFetchesCount++; + if (failedFetchesCount > 6) { + buildSpinner.fail(); + submitSpinner?.fail(); + throw new Error('Failed to fetch the workflow run status 6 times in a row'); + } + } - return workflowUrl; + await sleepAsync(10 * 1000); + } } } diff --git a/packages/eas-cli/src/credentials/context.ts b/packages/eas-cli/src/credentials/context.ts index 340c63b915..0f7c092918 100644 --- a/packages/eas-cli/src/credentials/context.ts +++ b/packages/eas-cli/src/credentials/context.ts @@ -25,6 +25,7 @@ export class CredentialsContext { public readonly appStore = new AppStoreApi(); public readonly ios = IosGraphqlClient; public readonly nonInteractive: boolean; + public readonly autoAcceptCredentialReuse: boolean; public readonly freezeCredentials: boolean = false; public readonly projectDir: string; public readonly user: Actor; @@ -50,6 +51,7 @@ export class CredentialsContext { analytics: Analytics; vcsClient: Client; freezeCredentials?: boolean; + autoAcceptCredentialReuse?: boolean; env?: Env; } ) { @@ -60,6 +62,7 @@ export class CredentialsContext { this.analytics = options.analytics; this.vcsClient = options.vcsClient; this.nonInteractive = options.nonInteractive ?? false; + this.autoAcceptCredentialReuse = options.autoAcceptCredentialReuse ?? false; this.projectInfo = options.projectInfo; this.freezeCredentials = options.freezeCredentials ?? false; this.usesBroadcastPushNotifications = diff --git a/packages/eas-cli/src/credentials/ios/actions/SetUpAscApiKey.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpAscApiKey.ts index 463197f053..5baeb5052d 100644 --- a/packages/eas-cli/src/credentials/ios/actions/SetUpAscApiKey.ts +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpAscApiKey.ts @@ -90,9 +90,11 @@ export class SetUpAscApiKey { return null; } const [autoselectedKey] = sortAscApiKeysByUpdatedAtDesc(validKeys); - const useAutoselected = await confirmAsync({ - message: `Reuse this App Store Connect API Key?\n${formatAscApiKey(autoselectedKey)}`, - }); + const useAutoselected = + ctx.autoAcceptCredentialReuse || + (await confirmAsync({ + message: `Reuse this App Store Connect API Key?\n${formatAscApiKey(autoselectedKey)}`, + })); if (useAutoselected) { Log.log(`Using App Store Connect API Key with ID ${autoselectedKey.keyIdentifier}`); diff --git a/packages/eas-cli/src/credentials/ios/actions/SetUpDistributionCertificate.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpDistributionCertificate.ts index 2242ccc2f4..45dd6cd8bc 100644 --- a/packages/eas-cli/src/credentials/ios/actions/SetUpDistributionCertificate.ts +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpDistributionCertificate.ts @@ -117,11 +117,13 @@ export class SetUpDistributionCertificate { const validDistCerts = await this.getValidDistCertsAsync(ctx); const autoselectedDistCert = validDistCerts[0]; - const useAutoselected = await confirmAsync({ - message: `Reuse this distribution certificate?\n${formatDistributionCertificate( - autoselectedDistCert - )}`, - }); + const useAutoselected = + ctx.autoAcceptCredentialReuse || + (await confirmAsync({ + message: `Reuse this distribution certificate?\n${formatDistributionCertificate( + autoselectedDistCert + )}`, + })); if (useAutoselected) { Log.log( diff --git a/packages/eas-cli/src/log.ts b/packages/eas-cli/src/log.ts index bc6c2a18c1..4fdd092671 100644 --- a/packages/eas-cli/src/log.ts +++ b/packages/eas-cli/src/log.ts @@ -26,6 +26,15 @@ export default class Log { } } + /** + * Signal that the cursor is already at the start of a fresh line (e.g. after + * a spinner clears itself), so that the next `addNewLineIfNone()` call knows + * it doesn't need to emit an extra blank line. + */ + public static markFreshLine(): void { + Log.isLastLineNewLine = true; + } + public static error(...args: any[]): void { Log.consoleLog(...Log.withTextColor(args, chalk.red)); } From 597a504571af684fe17d6385acdd347a28129c9b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Czaj=C4=99cki?= Date: Thu, 19 Feb 2026 19:44:30 +0200 Subject: [PATCH 08/12] Update mutation used --- packages/eas-cli/src/commands/go.ts | 73 ++----------------- .../graphql/mutations/WorkflowRunMutation.ts | 36 +++++++++ 2 files changed, 43 insertions(+), 66 deletions(-) diff --git a/packages/eas-cli/src/commands/go.ts b/packages/eas-cli/src/commands/go.ts index b63f9a3cf8..e7ab178599 100644 --- a/packages/eas-cli/src/commands/go.ts +++ b/packages/eas-cli/src/commands/go.ts @@ -18,7 +18,6 @@ import { ensureAppExistsAsync } from '../credentials/ios/appstore/ensureAppExist import { Target } from '../credentials/ios/types'; import { WorkflowJobStatus, WorkflowProjectSourceType, WorkflowRunStatus } from '../graphql/generated'; import { AppMutation } from '../graphql/mutations/AppMutation'; -import { WorkflowRevisionMutation } from '../graphql/mutations/WorkflowRevisionMutation'; import { WorkflowRunMutation } from '../graphql/mutations/WorkflowRunMutation'; import { AppQuery } from '../graphql/queries/AppQuery'; import { WorkflowRunQuery } from '../graphql/queries/WorkflowRunQuery'; @@ -33,8 +32,7 @@ import { sleepAsync } from '../utils/promise'; import { resolveVcsClient } from '../vcs'; import { Client as VcsClient } from '../vcs/vcs'; -// Expo Go release info - update all three when releasing a new version -const EXPO_GO_IPA_URL = 'https://expo.dev/artifacts/eas/gGwYvTqGd3rHWrbHDBfjDj.ipa'; +// Expo Go release info - update when releasing a new version const EXPO_GO_SDK_VERSION = '55'; const EXPO_GO_APP_VERSION = '55.0.11'; const EXPO_GO_BUILD_NUMBER = '1017799'; @@ -142,43 +140,6 @@ async function withSuppressedOutputAsync(fn: () => Promise): Promise { } /* eslint-enable no-console */ -const WORKFLOW_TEMPLATE = `name: Repack Expo Go - -jobs: - repack: - name: Repack Expo Go - type: build - params: - platform: ios - profile: production - steps: - - uses: eas/checkout - - uses: eas/use_npm_token - - uses: eas/install_node_modules - - uses: eas/resolve_build_config - - id: download - run: | - curl --output expo-go.ipa -L ${EXPO_GO_IPA_URL} - set-output ipa_path "$PWD/expo-go.ipa" - - uses: eas/repack - id: repack - with: - source_app_path: "\${{ steps.download.outputs.ipa_path }}" - platform: ios - embed_bundle_assets: false - repack_package: "@kudo-chien/repack-app" - repack_version: "0.3.1" - - uses: eas/upload_artifact - with: - path: "\${{ steps.repack.outputs.output_path }}" - - submit: - name: Submit to TestFlight - type: submit - needs: [repack] - params: - build_id: \${{ needs.repack.outputs.build_id }} -`; export default class Go extends EasCommand { static override hidden = true; @@ -390,10 +351,6 @@ export default class Go extends EasCommand { await fs.writeJson(path.join(projectDir, 'eas.json'), easJson, { spaces: 2 }); await fs.writeJson(path.join(projectDir, 'package.json'), packageJson, { spaces: 2 }); - const workflowDir = path.join(projectDir, '.eas', 'workflows'); - await fs.ensureDir(workflowDir); - await fs.writeFile(path.join(workflowDir, 'repack.yml'), WORKFLOW_TEMPLATE); - await spawnAsync('npm', ['install'], { cwd: projectDir }); } @@ -524,13 +481,6 @@ export default class Go extends EasCommand { vcsClient: VcsClient ): Promise<{ workflowUrl: string; workflowRunId: string }> { const account = actor.accounts[0]; - const workflowFile = path.join(projectDir, '.eas', 'workflows', 'repack.yml'); - const yamlConfig = await fs.readFile(workflowFile, 'utf-8'); - - await WorkflowRevisionMutation.validateWorkflowYamlConfigAsync(graphqlClient, { - appId: projectId, - yamlConfig, - }); const { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey } = await withSuppressedOutputAsync(async () => { @@ -540,34 +490,26 @@ export default class Go extends EasCommand { accountId: account.id, }); - const easJsonPath = path.join(projectDir, 'eas.json'); - const packageJsonPath = path.join(projectDir, 'package.json'); - const { fileBucketKey: easJsonBucketKey } = await uploadAccountScopedFileAsync({ graphqlClient, accountId: account.id, - filePath: easJsonPath, + filePath: path.join(projectDir, 'eas.json'), maxSizeBytes: 1024 * 1024, }); const { fileBucketKey: packageJsonBucketKey } = await uploadAccountScopedFileAsync({ graphqlClient, accountId: account.id, - filePath: packageJsonPath, + filePath: path.join(projectDir, 'package.json'), maxSizeBytes: 1024 * 1024, }); return { projectArchiveBucketKey, easJsonBucketKey, packageJsonBucketKey }; }); - const { id: workflowRunId } = await WorkflowRunMutation.createWorkflowRunAsync(graphqlClient, { - appId: projectId, - workflowRevisionInput: { - fileName: 'repack.yml', - yamlConfig, - }, - workflowRunInput: { - inputs: {}, + const { id: workflowRunId } = + await WorkflowRunMutation.createExpoGoRepackWorkflowRunAsync(graphqlClient, { + appId: projectId, projectSource: { type: WorkflowProjectSourceType.Gcs, projectArchiveBucketKey, @@ -575,8 +517,7 @@ export default class Go extends EasCommand { packageJsonBucketKey, projectRootDirectory: '.', }, - }, - }); + }); const app = await AppQuery.byIdAsync(graphqlClient, projectId); const workflowUrl = `https://expo.dev/accounts/${account.name}/projects/${app.slug}/workflows/${workflowRunId}`; diff --git a/packages/eas-cli/src/graphql/mutations/WorkflowRunMutation.ts b/packages/eas-cli/src/graphql/mutations/WorkflowRunMutation.ts index 2ca8bce4aa..f351f9c0d7 100644 --- a/packages/eas-cli/src/graphql/mutations/WorkflowRunMutation.ts +++ b/packages/eas-cli/src/graphql/mutations/WorkflowRunMutation.ts @@ -9,6 +9,7 @@ import { CreateWorkflowRunFromGitRefMutationVariables, CreateWorkflowRunMutation, CreateWorkflowRunMutationVariables, + WorkflowProjectSourceInput, WorkflowRevisionInput, WorkflowRunInput, } from '../generated'; @@ -103,6 +104,41 @@ export namespace WorkflowRunMutation { return { id: data.workflowRun.createWorkflowRunFromGitRef.id }; } + export async function createExpoGoRepackWorkflowRunAsync( + graphqlClient: ExpoGraphqlClient, + { + appId, + projectSource, + }: { + appId: string; + projectSource: WorkflowProjectSourceInput; + } + ): Promise<{ id: string }> { + const data = await withErrorHandlingAsync( + graphqlClient + .mutation< + { workflowRun: { createExpoGoRepackWorkflowRun: { id: string } } }, + { appId: string; projectSource: WorkflowProjectSourceInput } + >( + gql` + mutation CreateExpoGoRepackWorkflowRun( + $appId: ID! + $projectSource: WorkflowProjectSourceInput! + ) { + workflowRun { + createExpoGoRepackWorkflowRun(appId: $appId, projectSource: $projectSource) { + id + } + } + } + `, + { appId, projectSource } + ) + .toPromise() + ); + return { id: data.workflowRun.createExpoGoRepackWorkflowRun.id }; + } + export async function cancelWorkflowRunAsync( graphqlClient: ExpoGraphqlClient, { From 9ccbaa3d6b2aced5863ea04d1bcba0f29573cfba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Czaj=C4=99cki?= Date: Thu, 19 Feb 2026 22:52:38 +0200 Subject: [PATCH 09/12] Use better primary account retrieval --- packages/eas-cli/src/commands/go.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/eas-cli/src/commands/go.ts b/packages/eas-cli/src/commands/go.ts index e7ab178599..11f07caee2 100644 --- a/packages/eas-cli/src/commands/go.ts +++ b/packages/eas-cli/src/commands/go.ts @@ -27,7 +27,7 @@ import { getPrivateExpoConfigAsync } from '../project/expoConfig'; import { findProjectIdByAccountNameAndSlugNullableAsync } from '../project/fetchOrCreateProjectIDForWriteToConfigWithConfirmationAsync'; import { uploadAccountScopedFileAsync } from '../project/uploadAccountScopedFileAsync'; import { uploadAccountScopedProjectSourceAsync } from '../project/uploadAccountScopedProjectSourceAsync'; -import { Actor } from '../user/User'; +import { Actor, getActorDisplayName } from '../user/User'; import { sleepAsync } from '../utils/promise'; import { resolveVcsClient } from '../vcs'; import { Client as VcsClient } from '../vcs/vcs'; @@ -142,7 +142,6 @@ async function withSuppressedOutputAsync(fn: () => Promise): Promise { export default class Go extends EasCommand { - static override hidden = true; static override description = 'Create a custom Expo Go and submit to TestFlight'; static override flags = { @@ -181,7 +180,7 @@ export default class Go extends EasCommand { } = await this.getContextAsync(Go, { nonInteractive: false, }); - spinner.succeed(`Logged in as ${chalk.cyan(actor.accounts[0].name)}`); + spinner.succeed(`Logged in as ${chalk.cyan(getActorDisplayName(actor))}`); const bundleId = flags['bundle-id'] ?? this.generateBundleId(actor); const appName = flags.name ?? 'My Expo Go'; From 198070366dea52e2606783709198adda737ac728 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Czaj=C4=99cki?= Date: Fri, 20 Feb 2026 00:32:16 +0200 Subject: [PATCH 10/12] Add missing ctx.autoAcceptCredentialReuse --- .../src/credentials/ios/actions/SetUpProvisioningProfile.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/eas-cli/src/credentials/ios/actions/SetUpProvisioningProfile.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpProvisioningProfile.ts index 00f90c2476..e5d68bcdc7 100644 --- a/packages/eas-cli/src/credentials/ios/actions/SetUpProvisioningProfile.ts +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpProvisioningProfile.ts @@ -142,7 +142,7 @@ export class SetUpProvisioningProfile { } const isNonInteractiveOrUserDidConfirmAsync = async (): Promise => { - if (ctx.nonInteractive) { + if (ctx.nonInteractive || ctx.autoAcceptCredentialReuse) { return true; } return await confirmAsync({ From 596ac7e43af55a7c4d22f9988c23012a98c90a7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Czaj=C4=99cki?= Date: Fri, 20 Feb 2026 20:46:28 +0200 Subject: [PATCH 11/12] Add changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5e90a8a82..c8dc4291ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ This is the log of notable changes to EAS CLI and related packages. ### 🎉 New features +- Add `eas go` command for creating custom Expo Go builds. ([#3376](https://github.com/expo/eas-cli/pull/3376) by [@tchayen](https://github.com/tchayen)) - Use authorization code flow with PKCE for browser-based login. ([#3398](https://github.com/expo/eas-cli/pull/3398) by [@byronkarlen](https://github.com/byronkarlen)) ### 🐛 Bug fixes From 2e7e0866b78bdb9c60f065540d5dc522a6746b24 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tomasz=20Czaj=C4=99cki?= Date: Fri, 20 Feb 2026 21:02:01 +0200 Subject: [PATCH 12/12] Fix stuck terminal --- .../eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts | 5 +++++ .../eas-cli/src/credentials/ios/actions/SetUpAscApiKey.ts | 6 ++++++ 2 files changed, 11 insertions(+) diff --git a/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts b/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts index 826020b7a9..5b6915115d 100644 --- a/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts +++ b/packages/eas-cli/src/credentials/ios/actions/AscApiKeyUtils.ts @@ -66,6 +66,11 @@ export async function provideOrGenerateAscApiKeyAsync( throw new Error(`A new App Store Connect API Key cannot be created in non-interactive mode.`); } + // When auto-accepting credentials, always auto-generate without asking for user input + if (ctx.autoAcceptCredentialReuse) { + return await generateAscApiKeyAsync(ctx, purpose); + } + const userProvided = await promptForAscApiKeyAsync(ctx); if (!userProvided) { return await generateAscApiKeyAsync(ctx, purpose); diff --git a/packages/eas-cli/src/credentials/ios/actions/SetUpAscApiKey.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpAscApiKey.ts index 5baeb5052d..bb6d782551 100644 --- a/packages/eas-cli/src/credentials/ios/actions/SetUpAscApiKey.ts +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpAscApiKey.ts @@ -65,6 +65,12 @@ export class SetUpAscApiKey { return await new AssignAscApiKey(this.app).runAsync(ctx, maybeAutoselectedKey, this.purpose); } + // When auto-accepting credentials and no valid key was found, generate a new one without prompting + if (ctx.autoAcceptCredentialReuse) { + const ascApiKey = await new CreateAscApiKey(this.app.account).runAsync(ctx, this.purpose); + return await new AssignAscApiKey(this.app).runAsync(ctx, ascApiKey, this.purpose); + } + const availableChoices = keysForAccount.length === 0 ? this.choices.filter(choice => choice.value !== SetupAscApiKeyChoice.USE_EXISTING)