From c90391d802084655aab91fc8ca045c623b5e2ead Mon Sep 17 00:00:00 2001 From: Jakov Glavina Date: Thu, 28 Sep 2023 23:14:30 +0200 Subject: [PATCH 1/4] feat: initial steps to support development provisioning profiles --- .../SetUpDevelopmentProvisioningProfile.ts | 347 ++++++++++++++++++ .../credentials/ios/appstore/AppStoreApi.ts | 17 + .../provisioningProfileDevelopment.ts | 280 ++++++++++++++ 3 files changed, 644 insertions(+) create mode 100644 packages/eas-cli/src/credentials/ios/actions/SetUpDevelopmentProvisioningProfile.ts create mode 100644 packages/eas-cli/src/credentials/ios/appstore/provisioningProfileDevelopment.ts diff --git a/packages/eas-cli/src/credentials/ios/actions/SetUpDevelopmentProvisioningProfile.ts b/packages/eas-cli/src/credentials/ios/actions/SetUpDevelopmentProvisioningProfile.ts new file mode 100644 index 0000000000..606c415e89 --- /dev/null +++ b/packages/eas-cli/src/credentials/ios/actions/SetUpDevelopmentProvisioningProfile.ts @@ -0,0 +1,347 @@ +import { ProfileType } from '@expo/apple-utils'; +import assert from 'assert'; +import chalk from 'chalk'; +import nullthrows from 'nullthrows'; + +import DeviceCreateAction, { RegistrationMethod } from '../../../devices/actions/create/action'; +import { + AppleDeviceFragment, + AppleDistributionCertificateFragment, + AppleProvisioningProfileFragment, + AppleTeamFragment, + IosAppBuildCredentialsFragment, + IosDistributionType, +} from '../../../graphql/generated'; +import Log from '../../../log'; +import { getApplePlatformFromTarget } from '../../../project/ios/target'; +import { confirmAsync, pressAnyKeyToContinueAsync, promptAsync } from '../../../prompts'; +import differenceBy from '../../../utils/expodash/differenceBy'; +import { CredentialsContext } from '../../context'; +import { MissingCredentialsNonInteractiveError } from '../../errors'; +import { AppLookupParams } from '../api/graphql/types/AppLookupParams'; +import { ApplePlatform } from '../appstore/constants'; +import { Target } from '../types'; +import { validateProvisioningProfileAsync } from '../validators/validateProvisioningProfile'; +import { resolveAppleTeamIfAuthenticatedAsync } from './AppleTeamUtils'; +import { assignBuildCredentialsAsync, getBuildCredentialsAsync } from './BuildCredentialsUtils'; +import { chooseDevicesAsync, formatDeviceLabel } from './DeviceUtils'; +import { SetUpDistributionCertificate } from './SetUpDistributionCertificate'; + +enum ReuseAction { + Yes, + ShowDevices, + No, +} + +interface Options { + app: AppLookupParams; + target: Target; +} + +export class SetUpDevelopmentProvisioningProfile { + constructor(private options: Options) {} + + async runAsync(ctx: CredentialsContext): Promise { + const { app } = this.options; + const distCert = await new SetUpDistributionCertificate( + app, + IosDistributionType.Development + ).runAsync(ctx); + + const areBuildCredentialsSetup = await this.areBuildCredentialsSetupAsync(ctx); + + if (ctx.nonInteractive) { + if (areBuildCredentialsSetup) { + return nullthrows( + await getBuildCredentialsAsync(ctx, app, IosDistributionType.Development) + ); + } else { + throw new MissingCredentialsNonInteractiveError( + 'Provisioning profile is not configured correctly. Run this command again in interactive mode.' + ); + } + } + + const currentBuildCredentials = await getBuildCredentialsAsync( + ctx, + app, + IosDistributionType.Development + ); + if (areBuildCredentialsSetup) { + const buildCredentials = nullthrows(currentBuildCredentials); + if (await this.shouldUseExistingProfileAsync(ctx, buildCredentials)) { + return buildCredentials; + } + } + + return await this.runWithDistributionCertificateAsync(ctx, distCert); + } + + async runWithDistributionCertificateAsync( + ctx: CredentialsContext, + distCert: AppleDistributionCertificateFragment + ): Promise { + const { app, target } = this.options; + + const currentBuildCredentials = await getBuildCredentialsAsync( + ctx, + app, + IosDistributionType.Development + ); + + // 1. Resolve Apple Team + let appleTeam: AppleTeamFragment | null = + distCert.appleTeam ?? currentBuildCredentials?.provisioningProfile?.appleTeam ?? null; + if (!appleTeam) { + await ctx.appStore.ensureAuthenticatedAsync(); + appleTeam = await resolveAppleTeamIfAuthenticatedAsync(ctx, app); + } + assert(appleTeam, 'Apple Team must be defined here'); + + // 2. Fetch devices registered on EAS servers + let registeredAppleDevices = await ctx.ios.getDevicesForAppleTeamAsync( + ctx.graphqlClient, + app, + appleTeam + ); + if (registeredAppleDevices.length === 0) { + const shouldRegisterDevices = await confirmAsync({ + message: `You don't have any registered devices yet. Would you like to register them now?`, + initial: true, + }); + + if (shouldRegisterDevices) { + registeredAppleDevices = await this.registerDevicesAsync(ctx, appleTeam); + } else { + throw new Error(`Run 'eas device:create' to register your devices first`); + } + } + + // 3. Choose devices for internal distribution + const provisionedDeviceIdentifiers = ( + currentBuildCredentials?.provisioningProfile?.appleDevices ?? [] + ).map(i => i.identifier); + const chosenDevices = await chooseDevicesAsync( + registeredAppleDevices, + provisionedDeviceIdentifiers + ); + + // 4. Reuse or create the profile on Apple Developer Portal + const applePlatform = await getApplePlatformFromTarget(target); + const profileType = + applePlatform === ApplePlatform.TV_OS + ? ProfileType.TVOS_APP_DEVELOPMENT + : ProfileType.IOS_APP_DEVELOPMENT; + const provisioningProfileStoreInfo = + await ctx.appStore.createOrReuseDevelopmentProvisioningProfileAsync( + chosenDevices.map(({ identifier }) => identifier), + app.bundleIdentifier, + distCert.serialNumber, + profileType + ); + + // 5. Create or update the profile on servers + const appleAppIdentifier = await ctx.ios.createOrGetExistingAppleAppIdentifierAsync( + ctx.graphqlClient, + app, + appleTeam + ); + let appleProvisioningProfile: AppleProvisioningProfileFragment | null = null; + if (currentBuildCredentials?.provisioningProfile) { + if ( + currentBuildCredentials.provisioningProfile.developerPortalIdentifier !== + provisioningProfileStoreInfo.provisioningProfileId + ) { + await ctx.ios.deleteProvisioningProfilesAsync(ctx.graphqlClient, [ + currentBuildCredentials.provisioningProfile.id, + ]); + appleProvisioningProfile = await ctx.ios.createProvisioningProfileAsync( + ctx.graphqlClient, + app, + appleAppIdentifier, + { + appleProvisioningProfile: provisioningProfileStoreInfo.provisioningProfile, + developerPortalIdentifier: provisioningProfileStoreInfo.provisioningProfileId, + } + ); + } else { + appleProvisioningProfile = currentBuildCredentials.provisioningProfile; + } + } else { + appleProvisioningProfile = await ctx.ios.createProvisioningProfileAsync( + ctx.graphqlClient, + app, + appleAppIdentifier, + { + appleProvisioningProfile: provisioningProfileStoreInfo.provisioningProfile, + developerPortalIdentifier: provisioningProfileStoreInfo.provisioningProfileId, + } + ); + } + + // 6. Compare selected devices with the ones actually provisioned + const diffList = differenceBy( + chosenDevices, + appleProvisioningProfile.appleDevices, + 'identifier' + ); + if (diffList && diffList.length > 0) { + Log.warn(`Failed to provision ${diffList.length} of the selected devices:`); + for (const missingDevice of diffList) { + Log.warn(`- ${formatDeviceLabel(missingDevice)}`); + } + Log.log( + 'Most commonly devices fail to to be provisioned while they are still being processed by Apple, which can take up to 24-72 hours. Check your Apple Developer Portal page at https://developer.apple.com/account/resources/devices/list, the devices in "Processing" status cannot be provisioned yet' + ); + } + + // 7. Create (or update) app build credentials + assert(appleProvisioningProfile); + return await assignBuildCredentialsAsync( + ctx, + app, + IosDistributionType.Development, + distCert, + appleProvisioningProfile, + appleTeam + ); + } + + private async areBuildCredentialsSetupAsync(ctx: CredentialsContext): Promise { + const { app, target } = this.options; + const buildCredentials = await getBuildCredentialsAsync( + ctx, + app, + IosDistributionType.Development + ); + return await validateProvisioningProfileAsync(ctx, target, app, buildCredentials); + } + + private async shouldUseExistingProfileAsync( + ctx: CredentialsContext, + buildCredentials: IosAppBuildCredentialsFragment + ): Promise { + const { app } = this.options; + const provisioningProfile = nullthrows(buildCredentials.provisioningProfile); + + const appleTeam = nullthrows(provisioningProfile.appleTeam); + const registeredAppleDevices = await ctx.ios.getDevicesForAppleTeamAsync( + ctx.graphqlClient, + app, + appleTeam + ); + + const provisionedDevices = provisioningProfile.appleDevices; + + const allRegisteredDevicesAreProvisioned = doUDIDsMatch( + registeredAppleDevices.map(({ identifier }) => identifier), + provisionedDevices.map(({ identifier }) => identifier) + ); + + if (allRegisteredDevicesAreProvisioned) { + const reuseAction = await this.promptForReuseActionAsync(); + if (reuseAction === ReuseAction.Yes) { + return true; + } else if (reuseAction === ReuseAction.No) { + return false; + } else { + Log.newLine(); + Log.log('Devices registered in the Provisioning Profile:'); + for (const device of provisionedDevices) { + Log.log(`- ${formatDeviceLabel(device)}`); + } + Log.newLine(); + return ( + (await this.promptForReuseActionAsync({ showShowDevicesOption: false })) === + ReuseAction.Yes + ); + } + } else { + const missingDevices = differenceBy(registeredAppleDevices, provisionedDevices, 'identifier'); + Log.warn(`The provisioning profile is missing the following devices:`); + for (const missingDevice of missingDevices) { + Log.warn(`- ${formatDeviceLabel(missingDevice)}`); + } + return !(await confirmAsync({ + message: `Would you like to choose the devices to provision again?`, + initial: true, + })); + } + } + + private async promptForReuseActionAsync({ + showShowDevicesOption = true, + } = {}): Promise { + const { selected } = await promptAsync({ + type: 'select', + name: 'selected', + message: `${ + showShowDevicesOption + ? 'All your registered devices are present in the Provisioning Profile. ' + : '' + }Would you like to reuse the profile?`, + choices: [ + { title: 'Yes', value: ReuseAction.Yes }, + ...(showShowDevicesOption + ? [ + { + title: 'Show devices and ask me again', + value: ReuseAction.ShowDevices, + }, + ] + : []), + { + title: 'No, let me choose devices again', + value: ReuseAction.No, + }, + ], + }); + return selected; + } + + private async registerDevicesAsync( + ctx: CredentialsContext, + appleTeam: AppleTeamFragment + ): Promise { + const { app } = this.options; + const action = new DeviceCreateAction(ctx.graphqlClient, ctx.appStore, app.account, appleTeam); + const method = await action.runAsync(); + + while (true) { + if (method === RegistrationMethod.WEBSITE) { + Log.newLine(); + Log.log(chalk.bold("Press any key if you've already finished device registration.")); + await pressAnyKeyToContinueAsync(); + } + Log.newLine(); + + const devices = await ctx.ios.getDevicesForAppleTeamAsync(ctx.graphqlClient, app, appleTeam, { + useCache: false, + }); + if (devices.length === 0) { + Log.warn('There are still no registered devices.'); + // if the user used the input method there should be some devices available + if (method === RegistrationMethod.INPUT) { + throw new Error('Input registration method has failed'); + } + } else { + return devices; + } + } + } +} + +export function doUDIDsMatch(udidsA: string[], udidsB: string[]): boolean { + const setA = new Set(udidsA); + const setB = new Set(udidsB); + + if (setA.size !== setB.size) { + return false; + } + for (const a of setA) { + if (!setB.has(a)) { + return false; + } + } + return true; +} diff --git a/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts b/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts index 25cee8d934..e3566ba520 100644 --- a/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts +++ b/packages/eas-cli/src/credentials/ios/appstore/AppStoreApi.ts @@ -44,6 +44,7 @@ import { useExistingProvisioningProfileAsync, } from './provisioningProfile'; import { createOrReuseAdhocProvisioningProfileAsync } from './provisioningProfileAdhoc'; +import { createOrReuseDevelopmentProvisioningProfileAsync } from './provisioningProfileDevelopment'; import { createPushKeyAsync, listPushKeysAsync, revokePushKeyAsync } from './pushKey'; import { hasAscEnvVars } from './resolveCredentials'; @@ -183,6 +184,22 @@ export default class AppStoreApi { ); } + public async createOrReuseDevelopmentProvisioningProfileAsync( + udids: string[], + bundleIdentifier: string, + distCertSerialNumber: string, + profileType: ProfileType + ): Promise { + const ctx = await this.ensureAuthenticatedAsync(); + return await createOrReuseDevelopmentProvisioningProfileAsync( + ctx, + udids, + bundleIdentifier, + distCertSerialNumber, + profileType + ); + } + public async listAscApiKeysAsync(): Promise { const userCtx = await this.ensureUserAuthenticatedAsync(); return await listAscApiKeysAsync(userCtx); diff --git a/packages/eas-cli/src/credentials/ios/appstore/provisioningProfileDevelopment.ts b/packages/eas-cli/src/credentials/ios/appstore/provisioningProfileDevelopment.ts new file mode 100644 index 0000000000..fe1ae48aa9 --- /dev/null +++ b/packages/eas-cli/src/credentials/ios/appstore/provisioningProfileDevelopment.ts @@ -0,0 +1,280 @@ +import { Device, Profile, ProfileState, ProfileType, RequestContext } from '@expo/apple-utils'; + +import { ora } from '../../../ora'; +import { isAppStoreConnectTokenOnlyContext } from '../utils/authType'; +import { ProvisioningProfile } from './Credentials.types'; +import { getRequestContext } from './authenticate'; +import { AuthCtx } from './authenticateTypes'; +import { getBundleIdForIdentifierAsync, getProfilesForBundleIdAsync } from './bundleId'; +import { getDistributionCertificateAsync } from './distributionCertificate'; + +interface ProfileResults { + didUpdate?: boolean; + didCreate?: boolean; + profileName?: string; + provisioningProfileId: string; + provisioningProfile: any; +} + +function uniqueItems(items: T[]): T[] { + const set = new Set(items); + return [...set]; +} + +async function registerMissingDevicesAsync( + context: RequestContext, + udids: string[] +): Promise { + const allDevices = await Device.getAsync(context); + const alreadyAdded = allDevices.filter(device => udids.includes(device.attributes.udid)); + const alreadyAddedUdids = alreadyAdded.map(i => i.attributes.udid); + + await Promise.all( + udids.map(async udid => { + if (!alreadyAddedUdids.includes(udid)) { + const device = await Device.createAsync(context, { + name: 'iOS Device (added by Expo)', + udid, + }); + alreadyAdded.push(device); + } + }) + ); + + return alreadyAdded; +} + +async function findProfileAsync( + context: RequestContext, + { + bundleId, + certSerialNumber, + profileType, + }: { bundleId: string; certSerialNumber: string; profileType: ProfileType } +): Promise<{ + profile: Profile | null; + didUpdate: boolean; +}> { + const expoProfiles = (await getProfilesForBundleIdAsync(context, bundleId)).filter(profile => { + return ( + profile.attributes.profileType === profileType && + profile.attributes.name.startsWith('*[expo]') && + profile.attributes.profileState !== ProfileState.EXPIRED + ); + }); + + const expoProfilesWithCertificate: Profile[] = []; + // find profiles associated with our development cert + for (const profile of expoProfiles) { + const certificates = await profile.getCertificatesAsync(); + if (certificates.some(cert => cert.attributes.serialNumber === certSerialNumber)) { + expoProfilesWithCertificate.push(profile); + } + } + + if (expoProfilesWithCertificate) { + // there is an expo managed profile with our desired certificate + // return the profile that will be valid for the longest duration + return { + profile: + expoProfilesWithCertificate.sort(sortByExpiration)[expoProfilesWithCertificate.length - 1], + didUpdate: false, + }; + } else if (expoProfiles) { + // there is an expo managed profile, but it doesn't have our desired certificate + // append the certificate and update the profile + const distributionCertificate = await getDistributionCertificateAsync( + context, + certSerialNumber + ); + if (!distributionCertificate) { + throw new Error(`Certificate for serial number "${certSerialNumber}" does not exist`); + } + const profile = expoProfiles.sort(sortByExpiration)[expoProfiles.length - 1]; + profile.attributes.certificates = [distributionCertificate]; + + return { + profile: isAppStoreConnectTokenOnlyContext(profile.context) + ? // Experimentally regenerate the provisioning profile using App Store Connect API. + await profile.regenerateManuallyAsync() + : // This method does not support App Store Connect API. + await profile.regenerateAsync(), + didUpdate: true, + }; + } + + // there is no valid provisioning profile available + return { profile: null, didUpdate: false }; +} + +function sortByExpiration(a: Profile, b: Profile): number { + return ( + new Date(a.attributes.expirationDate).getTime() - + new Date(b.attributes.expirationDate).getTime() + ); +} + +async function findProfileByIdAsync( + context: RequestContext, + profileId: string, + bundleId: string +): Promise { + let profiles = await getProfilesForBundleIdAsync(context, bundleId); + profiles = profiles.filter( + profile => profile.attributes.profileType === ProfileType.IOS_APP_DEVELOPMENT + ); + return profiles.find(profile => profile.id === profileId) ?? null; +} + +async function manageDevelopmentProfilesAsync( + context: RequestContext, + { + udids, + bundleId, + certSerialNumber, + profileId, + profileType, + }: { + udids: string[]; + bundleId: string; + certSerialNumber: string; + profileId?: string; + profileType: ProfileType; + } +): Promise { + // We register all missing devices on the Apple Developer Portal. They are identified by UDIDs. + const devices = await registerMissingDevicesAsync(context, udids); + + let existingProfile: Profile | null; + let didUpdate = false; + + if (profileId) { + existingProfile = await findProfileByIdAsync(context, profileId, bundleId); + // Fail if we cannot find the profile that was specifically requested + if (!existingProfile) { + throw new Error( + `Could not find profile with profile id "${profileId}" for bundle id "${bundleId}"` + ); + } + } else { + // If no profile id is passed, try to find a suitable provisioning profile for the App ID. + const results = await findProfileAsync(context, { bundleId, certSerialNumber, profileType }); + existingProfile = results.profile; + didUpdate = results.didUpdate; + } + + if (existingProfile) { + // We need to verify whether the existing profile includes all user's devices. + let deviceUdidsInProfile = + existingProfile?.attributes?.devices?.map?.(i => i.attributes.udid) ?? []; + deviceUdidsInProfile = uniqueItems(deviceUdidsInProfile.filter(Boolean)); + const allDeviceUdids = uniqueItems(udids); + const hasEqualUdids = + deviceUdidsInProfile.length === allDeviceUdids.length && + deviceUdidsInProfile.every(udid => allDeviceUdids.includes(udid)); + if (hasEqualUdids && existingProfile.isValid()) { + const result: ProfileResults = { + profileName: existingProfile?.attributes?.name, + provisioningProfileId: existingProfile?.id, + provisioningProfile: existingProfile?.attributes.profileContent, + }; + if (didUpdate) { + result.didUpdate = true; + } + + return result; + } + // We need to add new devices to the list and create a new provisioning profile. + existingProfile.attributes.devices = devices; + + if (isAppStoreConnectTokenOnlyContext(existingProfile.context)) { + // Experimentally regenerate the provisioning profile using App Store Connect API. + await existingProfile.regenerateManuallyAsync(); + } else { + // This method does not support App Store Connect API. + await existingProfile.regenerateAsync(); + } + + const updatedProfile = ( + await findProfileAsync(context, { bundleId, certSerialNumber, profileType }) + ).profile; + if (!updatedProfile) { + throw new Error( + `Failed to locate updated profile for bundle identifier "${bundleId}" and serial number "${certSerialNumber}"` + ); + } + return { + didUpdate: true, + profileName: updatedProfile.attributes.name, + provisioningProfileId: updatedProfile.id, + provisioningProfile: updatedProfile.attributes.profileContent, + }; + } + + // No existing profile... + + // We need to find user's distribution certificate to make a provisioning profile for it. + const distributionCertificate = await getDistributionCertificateAsync(context, certSerialNumber); + + if (!distributionCertificate) { + // If the distribution certificate doesn't exist, the user must have deleted it, we can't do anything here :( + throw new Error( + `No distribution certificate for serial number "${certSerialNumber}" is available to make a provisioning profile against` + ); + } + const bundleIdItem = await getBundleIdForIdentifierAsync(context, bundleId); + // If the provisioning profile for the App ID doesn't exist, we just need to create a new one! + const newProfile = await Profile.createAsync(context, { + bundleId: bundleIdItem.id, + // apple drops [ if its the first char (!!), + name: `*[expo] ${bundleId} Development ${Date.now()}`, + certificates: [distributionCertificate.id], + devices: devices.map(device => device.id), + profileType, + }); + + return { + didUpdate: true, + didCreate: true, + profileName: newProfile.attributes.name, + provisioningProfileId: newProfile.id, + provisioningProfile: newProfile.attributes.profileContent, + }; +} + +export async function createOrReuseDevelopmentProvisioningProfileAsync( + authCtx: AuthCtx, + udids: string[], + bundleIdentifier: string, + distCertSerialNumber: string, + profileType: ProfileType +): Promise { + const spinner = ora(`Handling Apple development provisioning profiles`).start(); + try { + const context = getRequestContext(authCtx); + const { didUpdate, didCreate, profileName, ...developmentProvisioningProfile } = + await manageDevelopmentProfilesAsync(context, { + udids, + bundleId: bundleIdentifier, + certSerialNumber: distCertSerialNumber, + profileType, + }); + + if (didCreate) { + spinner.succeed(`Created new profile: ${profileName}`); + } else if (didUpdate) { + spinner.succeed(`Updated existing profile: ${profileName}`); + } else { + spinner.succeed(`Used existing profile: ${profileName}`); + } + + return { + ...developmentProvisioningProfile, + teamId: authCtx.team.id, + teamName: authCtx.team.name, + }; + } catch (error) { + spinner.fail(`Failed to handle Apple profiles`); + throw error; + } +} From 6cf0fbe4a44e1a4ef657027a6d288083d71149ba Mon Sep 17 00:00:00 2001 From: Jakov Glavina Date: Thu, 28 Sep 2023 23:27:46 +0200 Subject: [PATCH 2/4] chore: add support for development provisioning profiles in more places --- packages/eas-json/schema/eas.schema.json | 3 ++- packages/eas-json/src/build/schema.ts | 2 +- packages/eas-json/src/build/types.ts | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/eas-json/schema/eas.schema.json b/packages/eas-json/schema/eas.schema.json index d0af64ebda..e01b23eba2 100644 --- a/packages/eas-json/schema/eas.schema.json +++ b/packages/eas-json/schema/eas.schema.json @@ -133,11 +133,12 @@ }, "distribution": { "enum": [ + "development", "internal", "store" ], "description": "The method of distributing your app. Learn more: https://docs.expo.dev/build/internal-distribution/", - "markdownDescription": "The method of distributing your app.\n\n- `internal` - with this option you'll be able to share your build URLs with anyone, and they will be able to install the builds to their devices straight from the Expo website. When using `internal`, make sure the build produces an APK or IPA file. Otherwise, the shareable URL will be useless. [Learn more](https://docs.expo.dev/build/internal-distribution/)\n- `store` - produces builds for store uploads, your build URLs won't be shareable.", + "markdownDescription": "The method of distributing your app.\n\n- `internal` - with this option you'll be able to share your build URLs with anyone, and they will be able to install the builds to their devices straight from the Expo website. When using `internal`, make sure the build produces an APK or IPA file. Otherwise, the shareable URL will be useless. [Learn more](https://docs.expo.dev/build/internal-distribution/)\n`development` - with this option you'll be able to create builds intended for development-only purposes. Common situations include e.g. the development of Tap to Pay on iPhone capability on iOS until Apple grants you wider permissions in regards on what provisioning profiles can support that capability. Similarly to the `internal` distribution method, it is preferable that the build produces an IPA or APK so that it can be shared with the development members. t\n- `store` - produces builds for store uploads, your build URLs won't be shareable.", "markdownEnumDescriptions": [ "With this option you'll be able to share your build URLs with anyone, and they will be able to install the builds to their devices straight from the Expo website. When using `internal`, make sure the build produces an APK or IPA file. Otherwise, the shareable URL will be useless. [Learn more](https://docs.expo.dev/build/internal-distribution/)", "Produces builds for store uploads, your build URLs won't be shareable." diff --git a/packages/eas-json/src/build/schema.ts b/packages/eas-json/src/build/schema.ts index e7b2558dfe..f35bd6cf73 100644 --- a/packages/eas-json/src/build/schema.ts +++ b/packages/eas-json/src/build/schema.ts @@ -46,7 +46,7 @@ const CommonBuildProfileSchema = Joi.object({ // credentials credentialsSource: Joi.string().valid('local', 'remote').default('remote'), - distribution: Joi.string().valid('store', 'internal').default('store'), + distribution: Joi.string().valid('store', 'internal', 'development').default('store'), // updates releaseChannel: Joi.string().regex(/^[a-z\d][a-z\d._-]*$/), diff --git a/packages/eas-json/src/build/types.ts b/packages/eas-json/src/build/types.ts index 60f503a3cf..855bf9ff24 100644 --- a/packages/eas-json/src/build/types.ts +++ b/packages/eas-json/src/build/types.ts @@ -29,7 +29,7 @@ export enum ResourceClass { M_LARGE = 'm-large', } -export type DistributionType = 'store' | 'internal'; +export type DistributionType = 'store' | 'internal' | 'development'; export type IosEnterpriseProvisioning = 'adhoc' | 'universal'; From dfd1d6a10e9ededc4de9f521462cf3cdbae1ef7a Mon Sep 17 00:00:00 2001 From: Jakov Glavina Date: Thu, 28 Sep 2023 23:54:44 +0200 Subject: [PATCH 3/4] feat: distribution provisioning profiles - WIP --- packages/eas-cli/src/build/android/build.ts | 7 +++++-- .../eas-cli/src/build/android/prepareJob.ts | 6 +++++- packages/eas-cli/src/build/graphql.ts | 6 +++++- packages/eas-cli/src/build/types.ts | 1 + .../eas-cli/src/build/utils/printBuildInfo.ts | 5 ++++- packages/eas-cli/src/commands/build/list.ts | 1 + packages/eas-cli/src/commands/build/resign.ts | 9 ++++++--- .../credentials/ios/IosCredentialsProvider.ts | 18 +++++++++++++++++- .../ios/utils/provisioningProfile.ts | 14 ++++++++++++++ packages/eas-cli/src/graphql/generated.ts | 3 ++- 10 files changed, 60 insertions(+), 10 deletions(-) diff --git a/packages/eas-cli/src/build/android/build.ts b/packages/eas-cli/src/build/android/build.ts index 6b1d8b6398..735159e76f 100644 --- a/packages/eas-cli/src/build/android/build.ts +++ b/packages/eas-cli/src/build/android/build.ts @@ -39,10 +39,13 @@ export async function createAndroidContextAsync( ): Promise { const { buildProfile } = ctx; - if (buildProfile.distribution === 'internal' && buildProfile.gradleCommand?.match(/bundle/)) { + if ( + (buildProfile.distribution === 'internal' || buildProfile.distribution === 'development') && + buildProfile.gradleCommand?.match(/bundle/) + ) { Log.addNewLineIfNone(); Log.warn( - `You're building your Android app for internal distribution. However, we've detected that the Gradle command you defined (${chalk.underline( + `You're building your Android app for internal or development distribution. However, we've detected that the Gradle command you defined (${chalk.underline( buildProfile.gradleCommand )}) includes string 'bundle'. This means that it will most likely produce an AAB and you will not be able to install it on your Android devices straight from the Expo website.` diff --git a/packages/eas-cli/src/build/android/prepareJob.ts b/packages/eas-cli/src/build/android/prepareJob.ts index eebb7cb3d7..b0ecf60aa9 100644 --- a/packages/eas-cli/src/build/android/prepareJob.ts +++ b/packages/eas-cli/src/build/android/prepareJob.ts @@ -50,7 +50,11 @@ export async function prepareJobAsync( : {}; let buildType = buildProfile.buildType; - if (!buildType && !buildProfile.gradleCommand && buildProfile.distribution === 'internal') { + if ( + !buildType && + !buildProfile.gradleCommand && + (buildProfile.distribution === 'internal' || buildProfile.distribution === 'development') + ) { buildType = Android.BuildType.APK; } diff --git a/packages/eas-cli/src/build/graphql.ts b/packages/eas-cli/src/build/graphql.ts index 0ba65b6553..0877d8e0df 100644 --- a/packages/eas-cli/src/build/graphql.ts +++ b/packages/eas-cli/src/build/graphql.ts @@ -64,7 +64,11 @@ function transformCredentialsSource( } function transformDistribution(distribution: Metadata['distribution']): DistributionType { - if (distribution === 'internal') { + //TODO: remove when change is added upstream to eas-build-job + //@ts-ignore + if (distribution === 'development') { + return DistributionType.Development; + } else if (distribution === 'internal') { return DistributionType.Internal; } else if (distribution === 'simulator') { return DistributionType.Simulator; diff --git a/packages/eas-cli/src/build/types.ts b/packages/eas-cli/src/build/types.ts index 5f6852c9eb..892c73a8f9 100644 --- a/packages/eas-cli/src/build/types.ts +++ b/packages/eas-cli/src/build/types.ts @@ -9,6 +9,7 @@ export enum BuildStatus { } export enum BuildDistributionType { + DEVELOPMENT = 'development', STORE = 'store', INTERNAL = 'internal', SIMULATOR = 'simulator', diff --git a/packages/eas-cli/src/build/utils/printBuildInfo.ts b/packages/eas-cli/src/build/utils/printBuildInfo.ts index 93579662fb..647cade860 100644 --- a/packages/eas-cli/src/build/utils/printBuildInfo.ts +++ b/packages/eas-cli/src/build/utils/printBuildInfo.ts @@ -90,7 +90,10 @@ function printBuildResult(build: BuildFragment): void { return; } - if (build.distribution === DistributionType.Internal) { + if ( + build.distribution === DistributionType.Internal || + build.distribution === DistributionType.Development + ) { Log.addNewLineIfNone(); const logsUrl = getBuildLogsUrl(build); // It's tricky to install the .apk file directly on Android so let's fallback diff --git a/packages/eas-cli/src/commands/build/list.ts b/packages/eas-cli/src/commands/build/list.ts index a929bca7bb..103b3bb764 100644 --- a/packages/eas-cli/src/commands/build/list.ts +++ b/packages/eas-cli/src/commands/build/list.ts @@ -36,6 +36,7 @@ export default class BuildList extends EasCommand { distribution: Flags.enum({ options: [ BuildDistributionType.STORE, + BuildDistributionType.DEVELOPMENT, BuildDistributionType.INTERNAL, BuildDistributionType.SIMULATOR, ], diff --git a/packages/eas-cli/src/commands/build/resign.ts b/packages/eas-cli/src/commands/build/resign.ts index a006a06912..3bc8eb5a68 100644 --- a/packages/eas-cli/src/commands/build/resign.ts +++ b/packages/eas-cli/src/commands/build/resign.ts @@ -262,7 +262,7 @@ export default class BuildResign extends EasCommand { json: false, }, filter: { - distribution: DistributionType.Internal, + distribution: DistributionType.Internal || DistributionType.Development, platform: toAppPlatform(platform), status: BuildStatus.Finished, }, @@ -280,8 +280,11 @@ export default class BuildResign extends EasCommand { ): Promise { if (maybeBuildId) { const build = await BuildQuery.byIdAsync(graphqlClient, maybeBuildId); - if (build.distribution !== DistributionType.Internal) { - throw new Error('This is not an internal distribution build.'); + if ( + build.distribution !== DistributionType.Internal && + build.distribution !== DistributionType.Development + ) { + throw new Error('This is not an internal or development distribution build.'); } if (build.status !== BuildStatus.Finished) { throw new Error('Only builds that finished successfully can be re-signed.'); diff --git a/packages/eas-cli/src/credentials/ios/IosCredentialsProvider.ts b/packages/eas-cli/src/credentials/ios/IosCredentialsProvider.ts index cb9494b6ed..4a949010fa 100644 --- a/packages/eas-cli/src/credentials/ios/IosCredentialsProvider.ts +++ b/packages/eas-cli/src/credentials/ios/IosCredentialsProvider.ts @@ -17,7 +17,11 @@ import { getAppFromContextAsync } from './actions/BuildCredentialsUtils'; import { SetUpBuildCredentials } from './actions/SetUpBuildCredentials'; import { SetUpPushKey } from './actions/SetUpPushKey'; import { App, IosCredentials, Target } from './types'; -import { isAdHocProfile, isEnterpriseUniversalProfile } from './utils/provisioningProfile'; +import { + isAdHocProfile, + isDevelopmentProfile, + isEnterpriseUniversalProfile, +} from './utils/provisioningProfile'; interface Options { app: App; @@ -144,6 +148,18 @@ export default class IosCredentialsProvider { private assertProvisioningProfileType(provisioningProfile: string, targetName?: string): void { const isAdHoc = isAdHocProfile(provisioningProfile); const isEnterprise = isEnterpriseUniversalProfile(provisioningProfile); + const isDevelopment = isDevelopmentProfile(provisioningProfile); + + if (this.options.distribution === 'development') { + if (!isDevelopment) { + throw new Error( + `You must use a development provisioning profile${ + targetName ? ` (target '${targetName})'` : '' + } for development distribution.` + ); + } + } + if (this.options.distribution === 'internal') { if (this.options.enterpriseProvisioning === 'universal' && !isEnterprise) { throw new Error( diff --git a/packages/eas-cli/src/credentials/ios/utils/provisioningProfile.ts b/packages/eas-cli/src/credentials/ios/utils/provisioningProfile.ts index 74507389f4..bcbd45572f 100644 --- a/packages/eas-cli/src/credentials/ios/utils/provisioningProfile.ts +++ b/packages/eas-cli/src/credentials/ios/utils/provisioningProfile.ts @@ -26,6 +26,20 @@ export function isAdHocProfile(dataBase64: string): boolean { return Array.isArray(provisionedDevices); } +export function isDevelopmentProfile(dataBase64: string): boolean { + const profilePlist = parse(dataBase64); + + // Usually, aps-environment is set to 'development' for development profiles and 'production' for any others. + // https://developer.apple.com/documentation/bundleresources/entitlements/aps-environment#discussion + //@ts-ignore + const apsEnvironment = profilePlist['Entitlements']['aps-environment'] as string | undefined; + + const provisionedDevices = profilePlist['ProvisionedDevices'] as string[] | undefined; + + // We can assume that the profile is development if it has provisioned devices and has aps-environment set to 'development'. + return apsEnvironment === 'development' && Array.isArray(provisionedDevices); +} + export function isEnterpriseUniversalProfile(dataBase64: string): boolean { const profilePlist = parse(dataBase64); return !!profilePlist['ProvisionsAllDevices']; diff --git a/packages/eas-cli/src/graphql/generated.ts b/packages/eas-cli/src/graphql/generated.ts index 271b32a2cd..a4c8799586 100644 --- a/packages/eas-cli/src/graphql/generated.ts +++ b/packages/eas-cli/src/graphql/generated.ts @@ -2964,8 +2964,9 @@ export type DiscordUserMutationDeleteDiscordUserArgs = { export enum DistributionType { Internal = 'INTERNAL', + Development = "DEVELOPMENT", Simulator = 'SIMULATOR', - Store = 'STORE' + Store = 'STORE', } export enum EasBuildBillingResourceClass { From a0233249732c02dc7dd971470f6393f4e7a32ad7 Mon Sep 17 00:00:00 2001 From: Jakov Glavina Date: Fri, 27 Sep 2024 16:09:57 +0200 Subject: [PATCH 4/4] add todo notice --- packages/eas-cli/src/build/metadata.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/eas-cli/src/build/metadata.ts b/packages/eas-cli/src/build/metadata.ts index c81e15c8ea..66a864adb7 100644 --- a/packages/eas-cli/src/build/metadata.ts +++ b/packages/eas-cli/src/build/metadata.ts @@ -34,6 +34,8 @@ export async function collectMetadataAsync( fingerprintSource: runtimeMetadata?.fingerprintSource, reactNativeVersion: await getReactNativeVersionAsync(ctx.projectDir), ...channelObject, + //TODO: needs to be updated in @expo/eas-build-job + //@ts-expect-error distribution, appName: ctx.exp.name, appIdentifier: resolveAppIdentifier(ctx),