create static method
Creates or reuses the exact release candidate described by options.
Implementation
static Future<ReleaseCandidateReceiptDto> create(AppleReleaseCandidateOptions 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,
);
final branch = await gitClient.currentBranch();
final releaseBranch = ReleaseReference.branch(config.appId);
SmfError.check(
branch == releaseBranch,
'Release candidate creation only runs on $releaseBranch.',
SmfErrorCode.releaseCandidateBranch,
);
SmfError.check(
await gitClient.isClean(),
'The release candidate checkout must be clean before repository hooks run.',
SmfErrorCode.dirtyWorktree,
);
final state = manifest.platforms.ios;
SmfError.check(
state.isReleasePending,
'The iOS manifest does not contain a pending release.',
SmfErrorCode.noPendingRelease,
);
final hookStartingCommitHash = await gitClient.currentCommitHash();
final hookRan = await options.dependencies.runBeforeBuild(
workingDirectory: paths.directory,
);
if (hookRan) {
await ReleaseCandidateGit.commitBeforeBuildChanges(
repositoryRoot: repositoryRoot,
platform: ReleasePlatform.ios,
version: state.version,
startingCommitHash: hookStartingCommitHash,
github: options.github,
);
}
final projectRoot = paths.appRoot;
final bundleId = await options.dependencies.resolveBundleIdentifier(
projectRoot,
config.ios,
flavor: config.flavor,
);
final shouldCloseClient = options.client == null;
final client = options.client ?? AppStoreConnectClient(options.appleCredentials);
try {
final app = await client.findApp(bundleId);
final fingerprint = await SourceFingerprint.calculate(paths.directory);
final receiptPath = paths.releaseCandidateReceiptPath(
platform: ReleasePlatform.ios,
version: state.version,
);
final intentPath = paths.releaseCandidateIntentPath(
platform: ReleasePlatform.ios,
version: state.version,
);
final reusable = await _reusableReleaseCandidate(
receiptPath,
fingerprint: fingerprint,
version: state.version,
bundleId: bundleId,
appId: app.id,
client: client,
);
if (reusable != null) {
await _applyTestflightMetadata(
root: paths.directory,
version: state.version,
appId: app.id,
buildId: reusable.artifactId,
config: config.ios.appStore.releaseCandidate,
client: client,
);
final refreshedReceipt = reusable.copyWith(
testingDestinations: config.ios.appStore.releaseCandidate.groups,
);
await _recordReleaseCandidateReceipt(
root: repositoryRoot,
intentPath: intentPath,
receiptPath: receiptPath,
receipt: refreshedReceipt,
shouldCommitReceipt: options.shouldCommitReceipt,
github: options.github,
isRefreshed: true,
);
return refreshedReceipt;
}
final sourceCommitHash = await gitClient.currentCommitHash();
final previousIntent = await _matchingIntent(
intentPath,
fingerprint: fingerprint,
version: state.version,
bundleId: bundleId,
appId: app.id,
);
if (previousIntent != null) {
final existingBuilds = await client.buildsForVersion(
appId: app.id,
version: state.version,
);
final existing = existingBuilds
.where(
(build) => build.attributes.version == previousIntent.buildNumber,
)
.firstOrNull;
if (existing != null) {
final build = existing.attributes.processingState == BuildProcessingState.valid
? existing
: await client.waitForBuild(
appId: app.id,
version: state.version,
buildNumber: previousIntent.buildNumber,
timeoutMinutes: config.ios.appStore.releaseCandidate.waitTimeoutMinutes,
);
await _applyTestflightMetadata(
root: paths.directory,
version: state.version,
appId: app.id,
buildId: build.id,
config: config.ios.appStore.releaseCandidate,
client: client,
);
final recovered = ReleaseCandidateReceiptDto(
schemaVersion: 1,
platform: ReleasePlatform.ios,
version: previousIntent.version,
buildNumber: previousIntent.buildNumber,
artifactId: build.id,
applicationId: previousIntent.applicationId,
storeApplicationId: previousIntent.storeApplicationId,
sourceCommitHash: previousIntent.sourceCommitHash,
sourceFingerprint: previousIntent.sourceFingerprint,
artifactSha256: previousIntent.artifactSha256,
uploadedAt: previousIntent.preparedAt,
testingDestinations: config.ios.appStore.releaseCandidate.groups,
processingState: 'VALID',
);
await _recordReleaseCandidateReceipt(
root: repositoryRoot,
intentPath: intentPath,
receiptPath: receiptPath,
receipt: recovered,
shouldCommitReceipt: options.shouldCommitReceipt,
github: options.github,
isRefreshed: false,
);
return recovered;
}
}
final buildNumber =
previousIntent?.buildNumber ??
await client.nextBuildNumber(
appId: app.id,
version: state.version,
);
final signingBundleIds = await options.dependencies.resolveSigningBundleIdentifiers(
projectRoot,
mainBundleId: bundleId,
flavor: config.flavor,
);
final signingAssets = await options.dependencies.resolveProvisioning(
credentials: options.signingCredentials,
bundleIds: signingBundleIds,
client: client,
);
final signing = await options.dependencies.installSigning(
signingAssets,
bundleId,
);
late final String ipaPath;
late final ReleaseCandidateIntentDto uploadIntent;
try {
ipaPath = await options.dependencies.buildIpa(
projectRoot: projectRoot,
command: await AppleBuild.resolveCommand(
projectRoot,
configuredCommand: config.ios.buildCommand,
),
ipaOutputPath: config.ios.ipaOutputPath,
version: state.version,
buildNumber: buildNumber,
exportOptionsPath: signing.exportOptionsPath,
flavor: config.flavor,
);
SmfError.check(
await gitClient.isClean(),
'The Flutter build changed tracked or unignored repository files. '
'Commit deterministic generated inputs before producing a '
'release candidate.',
SmfErrorCode.buildDirtyWorktree,
);
SmfError.check(
await SourceFingerprint.calculate(paths.directory) == fingerprint,
'A tracked build input changed while producing the IPA.',
SmfErrorCode.buildInputChanged,
);
uploadIntent = ReleaseCandidateIntentDto(
schemaVersion: 1,
platform: ReleasePlatform.ios,
version: state.version,
buildNumber: buildNumber,
applicationId: bundleId,
storeApplicationId: app.id,
sourceCommitHash: sourceCommitHash,
sourceFingerprint: fingerprint,
artifactSha256: await FileDigest.sha256(ipaPath),
preparedAt: options.dependencies.currentTime().toUtc(),
);
await _recordIntent(
root: repositoryRoot,
intentPath: intentPath,
intent: uploadIntent,
shouldCommitIntent: options.shouldCommitReceipt,
github: options.github,
);
await options.dependencies.upload(
ipaPath: ipaPath,
credentials: options.appleCredentials,
);
} finally {
await signing.cleanup();
}
final build = await client.waitForBuild(
appId: app.id,
version: state.version,
buildNumber: buildNumber,
timeoutMinutes: config.ios.appStore.releaseCandidate.waitTimeoutMinutes,
);
await _applyTestflightMetadata(
root: paths.directory,
version: state.version,
appId: app.id,
buildId: build.id,
config: config.ios.appStore.releaseCandidate,
client: client,
);
final receipt = ReleaseCandidateReceiptDto(
schemaVersion: 1,
platform: ReleasePlatform.ios,
version: state.version,
buildNumber: buildNumber,
artifactId: build.id,
applicationId: bundleId,
storeApplicationId: app.id,
sourceCommitHash: sourceCommitHash,
sourceFingerprint: fingerprint,
artifactSha256: uploadIntent.artifactSha256,
uploadedAt: uploadIntent.preparedAt,
testingDestinations: config.ios.appStore.releaseCandidate.groups,
processingState: 'VALID',
);
await _recordReleaseCandidateReceipt(
root: repositoryRoot,
intentPath: intentPath,
receiptPath: receiptPath,
receipt: receipt,
shouldCommitReceipt: options.shouldCommitReceipt,
github: options.github,
isRefreshed: false,
);
return receipt;
} finally {
if (shouldCloseClient) client.close();
}
}