promote static method
Verifies and promotes the exact release candidate described by options.
Implementation
static Future<AppleShipReleaseResultDto> promote(
ApplePromotionOptions options,
) async {
final workingDirectory = p.normalize(p.absolute(options.workingDirectory));
final paths = SmfPaths.resolve(
workingDirectory,
smfPath: options.smfPath,
);
final repositoryRoot = paths.repositoryRoot;
final gitClient = GitClient(root: repositoryRoot);
await RepositoryValidator.validate(paths.directory);
final (config, manifest) = await (
SmfState.config(paths.directory),
SmfState.manifest(paths.directory),
).wait;
SmfError.check(
config.ios.isEnabled,
'iOS delivery is disabled in configuration.',
SmfErrorCode.iosDisabled,
);
SmfError.check(
await gitClient.currentBranch() == config.targetBranch,
'Shipping only runs on ${config.targetBranch}.',
SmfErrorCode.promotionBranch,
);
SmfError.check(
await gitClient.isClean(),
'The promotion checkout must be clean before its source is verified.',
SmfErrorCode.dirtyWorktree,
);
final state = manifest.platforms.ios;
SmfError.check(
state.isReleasePending,
'The iOS manifest does not contain a pending release.',
SmfErrorCode.noPendingRelease,
);
final receipt = await ReleaseCandidateReceiptDto.fromJsonFilePath(
paths.releaseCandidateReceiptPath(
platform: ReleasePlatform.ios,
version: state.version,
),
);
SmfError.check(
receipt.version == state.version && receipt.platform == ReleasePlatform.ios,
'The release candidate receipt does not match the pending iOS release.',
SmfErrorCode.releaseCandidateMismatch,
);
SmfError.check(
await SourceFingerprint.calculate(paths.directory) == receipt.sourceFingerprint,
'The merged source does not match the tested TestFlight release candidate. '
'Produce a new release candidate before promoting this version.',
SmfErrorCode.untestedSource,
);
final sourceCommitHash = await gitClient.currentCommitHash();
final tag = ReleaseReference.tag(
config.appId,
ReleasePlatform.ios,
state.version,
);
await gitClient.verifyRemoteTagCommitIfPresent(
tag: tag,
expectedCommitHash: sourceCommitHash,
token: options.github.token,
);
final shouldCloseClient = options.client == null;
final client = options.client ?? AppStoreConnectClient(options.appleCredentials);
GitHubRestApi? ownedGitHubApi;
final githubApi = options.githubApi ?? (ownedGitHubApi = GitHubRestApi(context: options.github));
try {
final existingGitHubRelease = await githubApi.releaseByTag(tag);
if (existingGitHubRelease != null) {
SmfError.check(
existingGitHubRelease.tagName == tag && existingGitHubRelease.targetCommitish == sourceCommitHash,
'GitHub Release $tag does not target the verified release commit.',
SmfErrorCode.remoteTagMismatch,
);
}
final bundleId = await options.resolveBundleIdentifier(
paths.appRoot,
config.ios,
flavor: config.flavor,
);
SmfError.check(
receipt.applicationId == bundleId,
'The release candidate receipt bundle identifier does not match the current iOS '
'configuration.',
SmfErrorCode.releaseCandidateBundleMismatch,
);
final app = await client.findApp(bundleId);
SmfError.check(
receipt.storeApplicationId == app.id,
'The release candidate receipt App Store app does not match the configured '
'bundle identifier.',
SmfErrorCode.releaseCandidateAppMismatch,
);
final versionBuilds = await client.buildsForVersion(
appId: app.id,
version: state.version,
);
final build = versionBuilds.where((item) => item.id == receipt.artifactId).firstOrNull;
SmfError.check(
build?.attributes.processingState == BuildProcessingState.valid &&
build?.attributes.version == receipt.buildNumber,
'The recorded Apple build is not a valid build for the configured app '
'and marketing version.',
SmfErrorCode.releaseCandidateInvalid,
);
String? appStoreVersionId;
String? reviewSubmissionId;
String? betaReviewSubmissionId;
final ship = config.ios.appStore.ship;
if (ship case AppleShipConfig(target: AppleShipTarget.externalTesting)) {
final notes = await SmfState.storeReleaseNotes(paths.directory);
for (final entry in notes.forRelease(platform: ReleasePlatform.ios, version: state.version).entries) {
await client.setBetaBuildLocalization(
buildId: receipt.artifactId,
locale: entry.key,
whatsNew: entry.value,
);
}
await client.addBuildToGroups(
appId: app.id,
buildId: receipt.artifactId,
names: ship.groups,
isInternal: false,
);
betaReviewSubmissionId = await client.submitBuildForBetaReview(
receipt.artifactId,
);
} else if (ship != null) {
final appStoreVersion = await client.findOrCreateAppStoreVersion(
appId: app.id,
version: state.version,
shouldReleaseAutomatically: ship.target == AppleShipTarget.production,
);
appStoreVersionId = appStoreVersion.id;
SmfError.check(
!appStoreVersion.attributes.appVersionState.isRejected,
'App Store version ${state.version} is '
'${appStoreVersion.attributes.appVersionState.name}. Resolve the '
'version in App Store Connect before retrying.',
SmfErrorCode.appStoreVersionRejected,
);
if (appStoreVersion.attributes.appVersionState.isEditable) {
await client.attachBuildToVersion(
appStoreVersionId: appStoreVersion.id,
buildId: receipt.artifactId,
);
final notes = await SmfState.storeReleaseNotes(paths.directory);
for (final entry in notes.forRelease(platform: ReleasePlatform.ios, version: state.version).entries) {
await client.setAppStoreReleaseNotes(
appStoreVersionId: appStoreVersion.id,
locale: entry.key,
whatsNew: entry.value,
);
}
}
SmfError.check(
await client.appStoreVersionBuildId(appStoreVersion.id) == receipt.artifactId,
'The App Store version is not attached to the exact tested release candidate '
'build.',
SmfErrorCode.appStoreBuildMismatch,
);
reviewSubmissionId = await client.submitVersionForReview(
appId: app.id,
appStoreVersionId: appStoreVersion.id,
);
}
final changelog = await SmfState.changelog(paths.directory);
final release = changelog.platforms.ios.releaseVersion(state.version);
if (release == null) {
throw SmfError(
'Missing changelog for iOS ${state.version}',
SmfErrorCode.missingChangelog,
);
}
final githubRelease =
existingGitHubRelease ??
await githubApi.createRelease(
tag: tag,
targetCommitish: sourceCommitHash,
name: '${config.appId} iOS v${state.version}',
body: ReleaseChangelog.markdown(
platform: ReleasePlatform.ios,
release: release,
),
);
return AppleShipReleaseResultDto(
version: state.version,
tag: tag,
artifactId: receipt.artifactId,
buildNumber: receipt.buildNumber,
appStoreVersionId: appStoreVersionId,
reviewSubmissionId: reviewSubmissionId,
betaReviewSubmissionId: betaReviewSubmissionId,
githubReleaseUrl: githubRelease.htmlUrl,
);
} finally {
ownedGitHubApi?.close();
if (shouldCloseClient) client.close();
}
}