Compare commits

...
7 changed files with 623 additions and 0 deletions

No files matched your search

+1
View File
@@ -39,6 +39,7 @@
"@expo/config-plugins": "55.0.7",
"@expo/downloader": "18.5.0",
"@expo/eas-build-job": "18.8.0",
"@expo/eas-json": "18.8.0",
"@expo/env": "^0.4.0",
"@expo/logger": "18.5.0",
"@expo/package-manager": "1.9.10",
@@ -27,6 +27,7 @@ import { createRepackBuildFunction } from './functions/repack';
import { createReportMaestroTestResultsFunction } from './functions/reportMaestroTestResults';
import { resolveAppleTeamIdFromCredentialsFunction } from './functions/resolveAppleTeamIdFromCredentials';
import { createResolveBuildConfigBuildFunction } from './functions/resolveBuildConfig';
import { createResolveSubmitConfigBuildFunction } from './functions/resolveSubmitConfig';
import {
createCacheStatsBuildFunction,
createRestoreBuildCacheFunction,
@@ -88,6 +89,7 @@ export function getEasFunctions(ctx: CustomBuildContext): BuildFunction[] {
calculateEASUpdateRuntimeVersionFunction(),
createSubmissionEntityFunction(),
createResolveSubmitConfigBuildFunction(ctx),
createUploadToAscBuildFunction(),
createReportMaestroTestResultsFunction(ctx),
@@ -0,0 +1,182 @@
import { Platform, SubmissionConfig, SystemError, UserError } from '@expo/eas-build-job';
import { AndroidReleaseStatus, SubmitProfile } from '@expo/eas-json';
import { bunyan } from '@expo/logger';
import { asyncResult } from '@expo/results';
import spawn from '@expo/turtle-spawn';
import { BuildStepEnv } from '@expo/steps';
import fs from 'fs-extra';
import { graphql } from 'gql.tada';
import path from 'node:path';
import { z } from 'zod';
import { BuildInfo, ResolvedSubmitConfig } from './common';
import { CustomBuildContext } from '../../../customBuildContext';
const ANDROID_APP_CREDENTIALS_QUERY = graphql(`
query ResolveSubmitConfigAndroidAppCredentials(
$appId: String!
$applicationIdentifier: String
) {
app {
byId(appId: $appId) {
id
androidAppCredentials(filter: { applicationIdentifier: $applicationIdentifier }) {
id
googleServiceAccountKeyForSubmissions {
id
keyJson
}
}
}
}
}
`);
const AndroidReleaseStatusToSubmissionReleaseStatus: Record<
AndroidReleaseStatus,
SubmissionConfig.Android.ReleaseStatus
> = {
[AndroidReleaseStatus.completed]: SubmissionConfig.Android.ReleaseStatus.COMPLETED,
[AndroidReleaseStatus.draft]: SubmissionConfig.Android.ReleaseStatus.DRAFT,
[AndroidReleaseStatus.halted]: SubmissionConfig.Android.ReleaseStatus.HALTED,
[AndroidReleaseStatus.inProgress]: SubmissionConfig.Android.ReleaseStatus.IN_PROGRESS,
};
export async function resolveAndroidSubmitConfigAsync({
artifactPath,
build,
ctx,
env,
logger,
profile,
workingDirectory,
}: {
artifactPath: string;
build: BuildInfo;
ctx: CustomBuildContext;
env: BuildStepEnv;
logger: bunyan;
profile: SubmitProfile<Platform.ANDROID>;
workingDirectory: string;
}): Promise<ResolvedSubmitConfig> {
const applicationId =
build.appIdentifier ??
profile.applicationId ??
(await readAndroidApplicationIdAsync(artifactPath, env, logger));
if (!applicationId) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_ANDROID_APPLICATION_ID_NOT_FOUND',
'Could not resolve Android applicationId from build metadata or artifact. Set applicationId in the Android submit profile, or pass an APK/AAB artifact with a readable package name.'
);
}
const googleServiceAccountKeyJson = profile.serviceAccountKeyPath
? await fs.readFile(path.resolve(workingDirectory, profile.serviceAccountKeyPath), 'utf8')
: await getGoogleServiceAccountKeyJsonAsync({
applicationId,
appId: build.appId,
ctx,
});
const releaseStatus = AndroidReleaseStatusToSubmissionReleaseStatus[profile.releaseStatus];
const baseConfigInput = {
changesNotSentForReview: profile.changesNotSentForReview,
googleServiceAccountKeyJson,
track: profile.track,
};
const configInput: z.input<typeof SubmissionConfig.Android.SchemaZ> =
releaseStatus === SubmissionConfig.Android.ReleaseStatus.IN_PROGRESS
? {
...baseConfigInput,
releaseStatus,
rollout: profile.rollout,
}
: {
...baseConfigInput,
releaseStatus,
};
return {
appIdentifier: applicationId,
config: SubmissionConfig.Android.SchemaZ.parse(configInput),
platform: Platform.ANDROID,
};
}
async function getGoogleServiceAccountKeyJsonAsync({
applicationId,
appId,
ctx,
}: {
applicationId: string;
appId: string;
ctx: CustomBuildContext;
}): Promise<string> {
const credentialsResult = await ctx.graphqlClient
.query(ANDROID_APP_CREDENTIALS_QUERY, {
appId,
applicationIdentifier: applicationId,
})
.toPromise();
if (credentialsResult.error) {
throw credentialsResult.error;
}
const key =
credentialsResult.data?.app.byId.androidAppCredentials[0]
?.googleServiceAccountKeyForSubmissions;
if (!key) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_ANDROID_SERVICE_ACCOUNT_KEY_NOT_CONFIGURED',
`Google Service Account Key for submissions is not configured for ${applicationId}. Configure a Google Service Account Key for submissions in EAS credentials, or set serviceAccountKeyPath in the Android submit profile.`
);
}
if (!key.keyJson) {
throw new SystemError(`Google Service Account Key ${key.id} could not be resolved.`);
}
return key.keyJson;
}
async function readAndroidApplicationIdAsync(
artifactPath: string,
env: BuildStepEnv,
logger: bunyan
): Promise<string | undefined> {
if (artifactPath.endsWith('.apk')) {
return await readAndroidApplicationIdFromApkAsync(artifactPath, env, logger);
} else if (artifactPath.endsWith('.aab')) {
return await readAndroidApplicationIdFromAabAsync(artifactPath, env, logger);
}
return undefined;
}
async function readAndroidApplicationIdFromApkAsync(
apkPath: string,
env: BuildStepEnv,
logger: bunyan
): Promise<string | undefined> {
const aapt2Result = await asyncResult(spawn('aapt2', ['dump', 'packagename', apkPath], { env }));
if (aapt2Result.ok) {
return aapt2Result.value.stdout.trim();
}
logger.warn('Failed to read APK package name with aapt2.');
return undefined;
}
async function readAndroidApplicationIdFromAabAsync(
aabPath: string,
env: BuildStepEnv,
logger: bunyan
): Promise<string | undefined> {
const result = await asyncResult(
spawn('bundletool', ['dump', 'manifest', `--bundle=${aabPath}`, '--xpath=/manifest/@package'], {
env,
})
);
if (!result.ok) {
logger.warn('Failed to read AAB package name with bundletool.');
return undefined;
}
return result.value.stdout.trim();
}
@@ -0,0 +1,84 @@
import { Platform, SubmissionConfig, UserError } from '@expo/eas-build-job';
import { EasJsonAccessor, EasJsonUtils, SubmitProfile } from '@expo/eas-json';
import { graphql } from 'gql.tada';
import { CustomBuildContext } from '../../../customBuildContext';
export type AppPlatform = 'ANDROID' | 'IOS';
export const AppPlatformToPlatform: Record<AppPlatform, Platform> = {
ANDROID: Platform.ANDROID,
IOS: Platform.IOS,
};
export type BuildInfo = {
appIdentifier?: string | null;
appId: string;
buildId: string;
projectOwnerAccountId: string;
platform: Platform;
};
export type ResolvedSubmitConfig = {
appIdentifier?: string;
config: SubmissionConfig.Android | SubmissionConfig.Ios;
platform: Platform;
};
const BUILD_BY_ID_QUERY = graphql(`
query ResolveSubmitConfigBuildById($buildId: ID!) {
builds {
byId(buildId: $buildId) {
id
platform
appIdentifier
app {
id
ownerAccount {
id
}
}
}
}
}
`);
export async function getBuildInfoAsync(
ctx: CustomBuildContext,
buildId: string
): Promise<BuildInfo> {
const result = await ctx.graphqlClient.query(BUILD_BY_ID_QUERY, { buildId }).toPromise();
if (result.error) {
throw result.error;
}
const build = result.data?.builds.byId;
if (!build) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_BUILD_NOT_FOUND',
`Could not find build ${buildId}.`
);
}
return {
appIdentifier: build.appIdentifier,
appId: build.app.id,
buildId: build.id,
projectOwnerAccountId: build.app.ownerAccount.id,
platform: AppPlatformToPlatform[build.platform],
};
}
export async function getSubmitProfileAsync<T extends Platform>({
platform,
profileName,
workingDirectory,
}: {
platform: T;
profileName: string;
workingDirectory: string;
}): Promise<SubmitProfile<T>> {
return await EasJsonUtils.getSubmitProfileAsync(
EasJsonAccessor.fromProjectPath(workingDirectory),
platform,
profileName
);
}
@@ -0,0 +1,139 @@
import { Platform, SystemError } from '@expo/eas-build-job';
import { bunyan } from '@expo/logger';
import {
BuildFunction,
BuildStepEnv,
BuildStepInput,
BuildStepInputValueTypeName,
BuildStepOutput,
} from '@expo/steps';
import path from 'node:path';
import { z } from 'zod';
import { resolveAndroidSubmitConfigAsync } from './android';
import { ResolvedSubmitConfig, getBuildInfoAsync, getSubmitProfileAsync } from './common';
import { resolveIosSubmitConfigAsync } from './ios';
import { CustomBuildContext } from '../../../customBuildContext';
export function createResolveSubmitConfigBuildFunction(ctx: CustomBuildContext): BuildFunction {
return new BuildFunction({
namespace: 'eas',
id: 'resolve_submit_config',
name: 'Resolve submit config',
__metricsId: 'eas/resolve_submit_config',
inputProviders: [
BuildStepInput.createProvider({
id: 'build_id',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'profile',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'artifact_path',
required: true,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
],
outputProviders: [
BuildStepOutput.createProvider({
id: 'config_json',
required: true,
}),
BuildStepOutput.createProvider({
id: 'platform',
required: true,
}),
BuildStepOutput.createProvider({
id: 'app_identifier',
required: false,
}),
],
fn: async (stepsCtx, { env, inputs, outputs }) => {
const buildId = z.string().uuid().parse(inputs.build_id.value);
const profileName = z.string().parse(inputs.profile.value);
const artifactPath = path.resolve(
stepsCtx.workingDirectory,
z.string().parse(inputs.artifact_path.value)
);
const { config, platform, appIdentifier } = await resolveSubmitConfigAsync({
artifactPath,
buildId,
ctx,
env,
logger: stepsCtx.logger,
profileName,
workingDirectory: stepsCtx.workingDirectory,
});
outputs.config_json.set(JSON.stringify(config));
outputs.platform.set(platform);
if (appIdentifier) {
outputs.app_identifier.set(appIdentifier);
}
},
});
}
export async function resolveSubmitConfigAsync({
artifactPath,
buildId,
ctx,
env,
logger,
profileName,
workingDirectory,
}: {
artifactPath: string;
buildId: string;
ctx: CustomBuildContext;
env: BuildStepEnv;
logger: bunyan;
profileName: string;
workingDirectory: string;
}): Promise<ResolvedSubmitConfig> {
logger.info('Resolving submit config...');
const build = await getBuildInfoAsync(ctx, buildId);
switch (build.platform) {
case Platform.ANDROID: {
const profile = await getSubmitProfileAsync({
platform: build.platform,
profileName,
workingDirectory,
});
return await resolveAndroidSubmitConfigAsync({
artifactPath,
build,
ctx,
env,
logger,
profile,
workingDirectory,
});
}
case Platform.IOS: {
const profile = await getSubmitProfileAsync({
platform: build.platform,
profileName,
workingDirectory,
});
return await resolveIosSubmitConfigAsync({
artifactPath,
appId: build.appId,
buildAppIdentifier: build.appIdentifier,
ctx,
env,
profile,
projectOwnerAccountId: build.projectOwnerAccountId,
workingDirectory,
});
}
default:
throw new SystemError(`Unsupported platform: ${build.platform}`);
}
}
@@ -0,0 +1,214 @@
import { Platform, SubmissionConfig, UserError } from '@expo/eas-build-job';
import { SubmitProfile } from '@expo/eas-json';
import { BuildStepEnv } from '@expo/steps';
import fs from 'fs-extra';
import { graphql } from 'gql.tada';
import path from 'node:path';
import { z } from 'zod';
import { readIpaInfoAsync } from '../readIpaInfo';
import { ResolvedSubmitConfig } from './common';
import { CustomBuildContext } from '../../../customBuildContext';
const APPLE_APP_IDENTIFIER_QUERY = graphql(`
query ResolveSubmitConfigAppleAppIdentifier(
$accountId: String!
$bundleIdentifier: String!
) {
account {
byId(accountId: $accountId) {
id
appleAppIdentifiers(bundleIdentifier: $bundleIdentifier) {
id
bundleIdentifier
}
}
}
}
`);
const IOS_APP_CREDENTIALS_QUERY = graphql(`
query ResolveSubmitConfigIosAppCredentials(
$appId: String!
$appleAppIdentifierId: String!
) {
app {
byId(appId: $appId) {
id
iosAppCredentials(filter: { appleAppIdentifierId: $appleAppIdentifierId }) {
id
appStoreConnectApiKeyForSubmissions {
id
issuerIdentifier
keyIdentifier
keyP8
}
}
}
}
}
`);
export async function resolveIosSubmitConfigAsync({
appId,
artifactPath,
buildAppIdentifier,
ctx,
env,
profile,
projectOwnerAccountId,
workingDirectory,
}: {
appId: string;
artifactPath: string;
buildAppIdentifier?: string | null;
ctx: CustomBuildContext;
env: BuildStepEnv;
profile: SubmitProfile<Platform.IOS>;
projectOwnerAccountId: string;
workingDirectory: string;
}): Promise<ResolvedSubmitConfig> {
const bundleIdentifier =
buildAppIdentifier ??
profile.bundleIdentifier ??
(await readIpaInfoAsync(artifactPath)).bundleIdentifier;
if (!profile.ascAppId) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_IOS_ASC_APP_ID_NOT_CONFIGURED',
'Set ascAppId in the submit profile in eas.json.'
);
}
const appSpecificPassword = env.EXPO_APPLE_APP_SPECIFIC_PASSWORD;
const configInput: z.input<typeof SubmissionConfig.Ios.SchemaZ> = {
ascAppIdentifier: profile.ascAppId,
groups: profile.groups,
...(appSpecificPassword
? {
appleAppSpecificPassword: validateAppSpecificPassword(appSpecificPassword),
appleIdUsername: requireAppleIdUsername(profile.appleId ?? env.EXPO_APPLE_ID),
}
: {
ascApiJsonKey: await getAscApiJsonKeyAsync({
accountId: projectOwnerAccountId,
appId,
bundleIdentifier,
ctx,
profile,
workingDirectory,
}),
}),
};
return {
appIdentifier: bundleIdentifier,
config: SubmissionConfig.Ios.SchemaZ.parse(configInput),
platform: Platform.IOS,
};
}
async function getAscApiJsonKeyAsync({
accountId,
appId,
bundleIdentifier,
ctx,
profile,
workingDirectory,
}: {
accountId: string;
appId: string;
bundleIdentifier?: string;
ctx: CustomBuildContext;
profile: SubmitProfile<Platform.IOS>;
workingDirectory: string;
}): Promise<string> {
// Prefer explicit local ASC API key fields from eas.json when all are present.
if (profile.ascApiKeyPath && profile.ascApiKeyIssuerId && profile.ascApiKeyId) {
return JSON.stringify({
issuer_id: profile.ascApiKeyIssuerId,
key: await fs.readFile(path.resolve(workingDirectory, profile.ascApiKeyPath), 'utf8'),
key_id: profile.ascApiKeyId,
});
}
if (profile.ascApiKeyPath || profile.ascApiKeyIssuerId || profile.ascApiKeyId) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_IOS_ASC_API_KEY_INCOMPLETE',
'ascApiKeyPath, ascApiKeyIssuerId and ascApiKeyId must all be defined in eas.json.'
);
}
if (!bundleIdentifier) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_IOS_BUNDLE_IDENTIFIER_NOT_FOUND',
'Could not resolve iOS bundleIdentifier from build metadata or artifact. Set bundleIdentifier in the iOS submit profile, or pass an IPA artifact with a readable bundle identifier.'
);
}
// Server credentials are keyed by the Apple App Identifier, so first map the bundle id to it.
const appleAppIdentifierResult = await ctx.graphqlClient
.query(APPLE_APP_IDENTIFIER_QUERY, {
accountId,
bundleIdentifier,
})
.toPromise();
if (appleAppIdentifierResult.error) {
throw appleAppIdentifierResult.error;
}
const appleAppIdentifierId =
appleAppIdentifierResult.data?.account.byId.appleAppIdentifiers[0]?.id;
if (!appleAppIdentifierId) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_IOS_APP_IDENTIFIER_NOT_CONFIGURED',
`Apple App Identifier is not configured for ${bundleIdentifier}. Configure the bundle identifier in EAS credentials for this account.`
);
}
// The app credentials record points at the ASC API key selected for submissions.
const credentialsResult = await ctx.graphqlClient
.query(IOS_APP_CREDENTIALS_QUERY, {
appId,
appleAppIdentifierId,
})
.toPromise();
if (credentialsResult.error) {
throw credentialsResult.error;
}
const ascApiKey =
credentialsResult.data?.app.byId.iosAppCredentials[0]?.appStoreConnectApiKeyForSubmissions;
if (!ascApiKey) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_IOS_ASC_API_KEY_NOT_CONFIGURED',
`App Store Connect API Key for submissions is not configured for ${bundleIdentifier}. Configure an App Store Connect API Key for submissions in EAS credentials, or set ascApiKeyPath, ascApiKeyIssuerId, and ascApiKeyId in the iOS submit profile.`
);
}
return JSON.stringify({
issuer_id: ascApiKey.issuerIdentifier,
key: ascApiKey.keyP8,
key_id: ascApiKey.keyIdentifier,
});
}
function validateAppSpecificPassword(password: string): string {
if (!/^[a-z]{4}-[a-z]{4}-[a-z]{4}-[a-z]{4}$/.test(password)) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_IOS_APP_SPECIFIC_PASSWORD_INVALID',
'EXPO_APPLE_APP_SPECIFIC_PASSWORD must be in the format xxxx-xxxx-xxxx-xxxx, where x is a lowercase letter.'
);
}
return password;
}
function requireAppleIdUsername(appleIdUsername?: string): string {
if (!appleIdUsername) {
throw new UserError(
'EAS_RESOLVE_SUBMIT_CONFIG_IOS_APPLE_ID_NOT_CONFIGURED',
'Set appleId in the submit profile or EXPO_APPLE_ID when using EXPO_APPLE_APP_SPECIFIC_PASSWORD.'
);
}
return appleIdUsername;
}
Generated
+1
View File
@@ -2864,6 +2864,7 @@ __metadata:
"@expo/config-plugins": "npm:55.0.7"
"@expo/downloader": "npm:18.5.0"
"@expo/eas-build-job": "npm:18.8.0"
"@expo/eas-json": "npm:18.8.0"
"@expo/env": "npm:^0.4.0"
"@expo/logger": "npm:18.5.0"
"@expo/package-manager": "npm:1.9.10"