From 056db2e7a64d0f85605b43f1790f0d113a655e69 Mon Sep 17 00:00:00 2001 From: sswrk Date: Fri, 19 Jun 2026 18:11:17 +0200 Subject: [PATCH] [eas-json][eas-cli] add support for refreshAdHocProvisioningProfile to iOS build profile configuration in eas.json --- CHANGELOG.md | 1 + packages/eas-cli/src/build/createContext.ts | 14 +- .../refreshAdHocProvisioningProfile.test.ts | 129 ++++++++++++++++++ .../utils/refreshAdHocProvisioningProfile.ts | 57 ++++++++ packages/eas-cli/src/commands/build/index.ts | 26 ++-- .../eas-cli/src/commands/build/internal.ts | 2 +- .../src/__tests__/buildProfiles-test.ts | 87 ++++++++++++ packages/eas-json/src/build/schema.ts | 1 + packages/eas-json/src/build/types.ts | 1 + 9 files changed, 301 insertions(+), 17 deletions(-) create mode 100644 packages/eas-cli/src/build/utils/__tests__/refreshAdHocProvisioningProfile.test.ts create mode 100644 packages/eas-cli/src/build/utils/refreshAdHocProvisioningProfile.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ef8a1f4bde..040457392e 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 +- [eas-cli] Add optional `ios.refreshAdHocProvisioningProfile` eas.json setting to enable ad-hoc profile refresh in CI without passing `--refresh-ad-hoc-provisioning-profile` on every run. ([#3886](https://github.com/expo/eas-cli/pull/3886) by [@sswrk](https://github.com/sswrk)) - [eas-cli] New command `observe:session` for inspecting events by session ID. ([#3868](https://github.com/expo/eas-cli/pull/3868) by [@douglowder](https://github.com/douglowder)) ### 🐛 Bug fixes diff --git a/packages/eas-cli/src/build/createContext.ts b/packages/eas-cli/src/build/createContext.ts index fb839faab2..eb217b80c8 100644 --- a/packages/eas-cli/src/build/createContext.ts +++ b/packages/eas-cli/src/build/createContext.ts @@ -12,6 +12,10 @@ import { BuildContext, CommonContext } from './context'; import { createIosContextAsync } from './ios/build'; import { LocalBuildMode, LocalBuildOptions } from './local'; import { resolveBuildResourceClass } from './utils/resourceClass'; +import { + assertRefreshAdHocProvisioningProfileCompatibleWithFreezeCredentials, + resolveRefreshAdHocProvisioningProfile, +} from './utils/refreshAdHocProvisioningProfile'; import { Analytics, AnalyticsEventProperties, BuildEvent } from '../analytics/AnalyticsManager'; import { DynamicConfigContextFn } from '../commandUtils/context/DynamicProjectConfigContextField'; import { ExpoGraphqlClient } from '../commandUtils/context/contextUtils/createGraphqlClient'; @@ -92,7 +96,15 @@ export async function createBuildContextAsync({ const requiredPackageManager = resolvePackageManager(projectDir); - const refreshAdHocProvisioningProfile = refreshAdHocProvisioningProfileFlag ?? false; + const refreshAdHocProvisioningProfile = resolveRefreshAdHocProvisioningProfile({ + platform, + buildProfile, + refreshAdHocProvisioningProfileFlag, + }); + assertRefreshAdHocProvisioningProfileCompatibleWithFreezeCredentials( + refreshAdHocProvisioningProfile, + freezeCredentials + ); const credentialsCtx = new CredentialsContext({ projectInfo: { exp, projectId }, diff --git a/packages/eas-cli/src/build/utils/__tests__/refreshAdHocProvisioningProfile.test.ts b/packages/eas-cli/src/build/utils/__tests__/refreshAdHocProvisioningProfile.test.ts new file mode 100644 index 0000000000..c70b7a813d --- /dev/null +++ b/packages/eas-cli/src/build/utils/__tests__/refreshAdHocProvisioningProfile.test.ts @@ -0,0 +1,129 @@ +import { Platform } from '@expo/eas-build-job'; +import { BuildProfile } from '@expo/eas-json'; + +import { + assertExplicitRefreshAdHocProvisioningProfileFlagValid, + assertRefreshAdHocProvisioningProfileCompatibleWithFreezeCredentials, + resolveRefreshAdHocProvisioningProfile, +} from '../refreshAdHocProvisioningProfile'; + +const iosProfileWithRefresh = { + distribution: 'internal', + credentialsSource: 'remote', + refreshAdHocProvisioningProfile: true, +} as BuildProfile; + +const iosProfileWithoutRefresh = { + distribution: 'internal', + credentialsSource: 'remote', +} as BuildProfile; + +const androidProfileWithRefresh = { + distribution: 'internal', + credentialsSource: 'remote', + refreshAdHocProvisioningProfile: true, +} as BuildProfile; + +describe(resolveRefreshAdHocProvisioningProfile, () => { + it('enables refresh when eas.json sets refreshAdHocProvisioningProfile and no flag is passed', () => { + expect( + resolveRefreshAdHocProvisioningProfile({ + platform: Platform.IOS, + buildProfile: iosProfileWithRefresh, + }) + ).toBe(true); + }); + + it('disables refresh when eas.json omits refreshAdHocProvisioningProfile and no flag is passed', () => { + expect( + resolveRefreshAdHocProvisioningProfile({ + platform: Platform.IOS, + buildProfile: iosProfileWithoutRefresh, + }) + ).toBe(false); + }); + + it('disables refresh when eas.json sets true but explicit --no-refresh-ad-hoc-provisioning-profile is passed', () => { + expect( + resolveRefreshAdHocProvisioningProfile({ + platform: Platform.IOS, + buildProfile: iosProfileWithRefresh, + refreshAdHocProvisioningProfileFlag: false, + }) + ).toBe(false); + }); + + it('enables refresh when explicit --refresh-ad-hoc-provisioning-profile is passed', () => { + expect( + resolveRefreshAdHocProvisioningProfile({ + platform: Platform.IOS, + buildProfile: iosProfileWithoutRefresh, + refreshAdHocProvisioningProfileFlag: true, + }) + ).toBe(true); + }); + + it('ignores refreshAdHocProvisioningProfile on Android profiles', () => { + expect( + resolveRefreshAdHocProvisioningProfile({ + platform: Platform.ANDROID, + buildProfile: androidProfileWithRefresh, + }) + ).toBe(false); + }); +}); + +describe(assertRefreshAdHocProvisioningProfileCompatibleWithFreezeCredentials, () => { + it('throws when resolved refresh is enabled with freeze-credentials', () => { + expect(() => + assertRefreshAdHocProvisioningProfileCompatibleWithFreezeCredentials(true, true) + ).toThrow(/Cannot refresh ad-hoc provisioning profile when credentials are frozen/); + }); + + it('does not throw when refresh is disabled with freeze-credentials', () => { + expect(() => + assertRefreshAdHocProvisioningProfileCompatibleWithFreezeCredentials(false, true) + ).not.toThrow(); + }); +}); + +describe(assertExplicitRefreshAdHocProvisioningProfileFlagValid, () => { + it('throws when explicit flag is used in interactive mode', () => { + expect(() => + assertExplicitRefreshAdHocProvisioningProfileFlagValid({ + refreshAdHocProvisioningProfileFlag: true, + nonInteractive: false, + freezeCredentials: false, + }) + ).toThrow('--refresh-ad-hoc-provisioning-profile can only be used in non-interactive mode.'); + }); + + it('throws when explicit flag is used with freeze-credentials', () => { + expect(() => + assertExplicitRefreshAdHocProvisioningProfileFlagValid({ + refreshAdHocProvisioningProfileFlag: true, + nonInteractive: true, + freezeCredentials: true, + }) + ).toThrow('Cannot use --refresh-ad-hoc-provisioning-profile with --freeze-credentials.'); + }); + + it('does not throw when flag is omitted', () => { + expect(() => + assertExplicitRefreshAdHocProvisioningProfileFlagValid({ + nonInteractive: false, + freezeCredentials: false, + }) + ).not.toThrow(); + }); + + it('does not throw when explicit --no-refresh-ad-hoc-provisioning-profile is passed', () => { + expect(() => + assertExplicitRefreshAdHocProvisioningProfileFlagValid({ + refreshAdHocProvisioningProfileFlag: false, + nonInteractive: false, + freezeCredentials: true, + }) + ).not.toThrow(); + }); +}); diff --git a/packages/eas-cli/src/build/utils/refreshAdHocProvisioningProfile.ts b/packages/eas-cli/src/build/utils/refreshAdHocProvisioningProfile.ts new file mode 100644 index 0000000000..d12f6bcf80 --- /dev/null +++ b/packages/eas-cli/src/build/utils/refreshAdHocProvisioningProfile.ts @@ -0,0 +1,57 @@ +import { Platform } from '@expo/eas-build-job'; +import { BuildProfile } from '@expo/eas-json'; + +export function resolveRefreshAdHocProvisioningProfile({ + platform, + buildProfile, + refreshAdHocProvisioningProfileFlag, +}: { + platform: T; + buildProfile: BuildProfile; + refreshAdHocProvisioningProfileFlag?: boolean; +}): boolean { + if (refreshAdHocProvisioningProfileFlag !== undefined) { + return refreshAdHocProvisioningProfileFlag; + } + if (platform === Platform.IOS) { + return ( + (buildProfile as BuildProfile).refreshAdHocProvisioningProfile ?? false + ); + } + return false; +} + +export function assertExplicitRefreshAdHocProvisioningProfileFlagValid({ + refreshAdHocProvisioningProfileFlag, + nonInteractive, + freezeCredentials, +}: { + refreshAdHocProvisioningProfileFlag?: boolean; + nonInteractive: boolean; + freezeCredentials: boolean; +}): void { + if (refreshAdHocProvisioningProfileFlag !== true) { + return; + } + if (!nonInteractive) { + throw new Error( + '--refresh-ad-hoc-provisioning-profile can only be used in non-interactive mode.' + ); + } + if (freezeCredentials) { + throw new Error( + 'Cannot use --refresh-ad-hoc-provisioning-profile with --freeze-credentials.' + ); + } +} + +export function assertRefreshAdHocProvisioningProfileCompatibleWithFreezeCredentials( + refreshAdHocProvisioningProfile: boolean, + freezeCredentials: boolean +): void { + if (refreshAdHocProvisioningProfile && freezeCredentials) { + throw new Error( + 'Cannot refresh ad-hoc provisioning profile when credentials are frozen. Remove --freeze-credentials or disable refreshAdHocProvisioningProfile in eas.json.' + ); + } +} diff --git a/packages/eas-cli/src/commands/build/index.ts b/packages/eas-cli/src/commands/build/index.ts index 77460b0d0a..eeebe06157 100644 --- a/packages/eas-cli/src/commands/build/index.ts +++ b/packages/eas-cli/src/commands/build/index.ts @@ -9,6 +9,7 @@ import path from 'path'; import { LocalBuildMode } from '../../build/local'; import { BuildFlags, runBuildAndSubmitAsync } from '../../build/runBuildAndSubmit'; +import { assertExplicitRefreshAdHocProvisioningProfileFlagValid } from '../../build/utils/refreshAdHocProvisioningProfile'; import EasCommand from '../../commandUtils/EasCommand'; import { EasNonInteractiveAndJsonFlags, @@ -40,7 +41,7 @@ interface RawBuildFlags { message?: string; 'build-logger-level'?: LoggerLevel; 'freeze-credentials': boolean; - 'refresh-ad-hoc-provisioning-profile': boolean; + 'refresh-ad-hoc-provisioning-profile'?: boolean; 'verbose-logs'?: boolean; 'what-to-test'?: string; } @@ -123,7 +124,7 @@ export default class Build extends EasCommand { description: 'Prevent the build from updating credentials in non-interactive mode', }), 'refresh-ad-hoc-provisioning-profile': Flags.boolean({ - default: false, + allowNo: true, description: 'Refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials', }), @@ -194,19 +195,14 @@ export default class Build extends EasCommand { flags: RawBuildFlags ): Omit & { requestedPlatform?: RequestedPlatform } { const { json, nonInteractive } = resolveNonInteractiveAndJsonFlags(flags); - if (flags['refresh-ad-hoc-provisioning-profile']) { - if (!nonInteractive) { - Errors.error( - '--refresh-ad-hoc-provisioning-profile can only be used in non-interactive mode.', - { exit: 1 } - ); - } - if (flags['freeze-credentials']) { - Errors.error( - 'Cannot use --refresh-ad-hoc-provisioning-profile with --freeze-credentials.', - { exit: 1 } - ); - } + try { + assertExplicitRefreshAdHocProvisioningProfileFlagValid({ + refreshAdHocProvisioningProfileFlag: flags['refresh-ad-hoc-provisioning-profile'], + nonInteractive, + freezeCredentials: flags['freeze-credentials'], + }); + } catch (error) { + Errors.error(error instanceof Error ? error.message : String(error), { exit: 1 }); } if (!flags.local && flags.output) { Errors.error('--output is allowed only for local builds', { exit: 1 }); diff --git a/packages/eas-cli/src/commands/build/internal.ts b/packages/eas-cli/src/commands/build/internal.ts index 20e943b459..aec992521a 100644 --- a/packages/eas-cli/src/commands/build/internal.ts +++ b/packages/eas-cli/src/commands/build/internal.ts @@ -41,7 +41,7 @@ export default class BuildInternal extends EasCommand { exclusive: ['auto-submit'], }), 'refresh-ad-hoc-provisioning-profile': Flags.boolean({ - default: false, + allowNo: true, description: 'Refresh managed ad-hoc provisioning profiles from App Store Connect before gathering build credentials', }), diff --git a/packages/eas-json/src/__tests__/buildProfiles-test.ts b/packages/eas-json/src/__tests__/buildProfiles-test.ts index e3e5cf66d1..9087121e45 100644 --- a/packages/eas-json/src/__tests__/buildProfiles-test.ts +++ b/packages/eas-json/src/__tests__/buildProfiles-test.ts @@ -981,3 +981,90 @@ test('invalid build profile with caching with both paths and customPaths - error await EasJsonUtils.getBuildProfileAsync(accessor, Platform.ANDROID, 'production'); }).rejects.toThrow(expectedError); }); + +test('valid eas.json with ios.refreshAdHocProvisioningProfile', async () => { + await fs.writeJson('/project/eas.json', { + build: { + preview: { + distribution: 'internal', + ios: { + refreshAdHocProvisioningProfile: true, + }, + }, + }, + }); + + const accessor = EasJsonAccessor.fromProjectPath('/project'); + const iosProfile = await EasJsonUtils.getBuildProfileAsync(accessor, Platform.IOS, 'preview'); + + expect(iosProfile).toEqual( + expect.objectContaining({ + distribution: 'internal', + refreshAdHocProvisioningProfile: true, + }) + ); +}); + +test('invalid eas.json with non-boolean ios.refreshAdHocProvisioningProfile', async () => { + await fs.writeJson('/project/eas.json', { + build: { + preview: { + ios: { + refreshAdHocProvisioningProfile: 'yes', + }, + }, + }, + }); + + const accessor = EasJsonAccessor.fromProjectPath('/project'); + + await expect(async () => { + await EasJsonUtils.getBuildProfileAsync(accessor, Platform.IOS, 'preview'); + }).rejects.toThrow(InvalidEasJsonError); +}); + +test('valid profile extending other profile with ios.refreshAdHocProvisioningProfile', async () => { + await fs.writeJson('/project/eas.json', { + build: { + base: { + ios: { + refreshAdHocProvisioningProfile: true, + }, + }, + extension: { + extends: 'base', + distribution: 'internal', + }, + override: { + extends: 'base', + distribution: 'internal', + ios: { + refreshAdHocProvisioningProfile: false, + }, + }, + }, + }); + + const accessor = EasJsonAccessor.fromProjectPath('/project'); + const extendedIosProfile = await EasJsonUtils.getBuildProfileAsync( + accessor, + Platform.IOS, + 'extension' + ); + const overrideIosProfile = await EasJsonUtils.getBuildProfileAsync( + accessor, + Platform.IOS, + 'override' + ); + + expect(extendedIosProfile).toEqual({ + distribution: 'internal', + credentialsSource: 'remote', + refreshAdHocProvisioningProfile: true, + }); + expect(overrideIosProfile).toEqual({ + distribution: 'internal', + credentialsSource: 'remote', + refreshAdHocProvisioningProfile: false, + }); +}); diff --git a/packages/eas-json/src/build/schema.ts b/packages/eas-json/src/build/schema.ts index 69117905a8..21c016e841 100644 --- a/packages/eas-json/src/build/schema.ts +++ b/packages/eas-json/src/build/schema.ts @@ -133,6 +133,7 @@ const IosBuildProfileSchema = PlatformBuildProfileSchema.concat( // credentials enterpriseProvisioning: Joi.string().valid('adhoc', 'universal'), + refreshAdHocProvisioningProfile: Joi.boolean(), // build configuration simulator: Joi.boolean(), diff --git a/packages/eas-json/src/build/types.ts b/packages/eas-json/src/build/types.ts index 76f2981e50..1a64d90c50 100644 --- a/packages/eas-json/src/build/types.ts +++ b/packages/eas-json/src/build/types.ts @@ -107,6 +107,7 @@ export interface IosBuildProfile extends PlatformBuildProfile { // credentials enterpriseProvisioning?: IosEnterpriseProvisioning; + refreshAdHocProvisioningProfile?: boolean; // build configuration simulator?: boolean;