create static method
Builds, signs, uploads, and records an exact internal-testing release candidate.
Creates or reuses the exact release candidate described by options.
Implementation
static Future<ReleaseCandidateReceiptDto> create(
AndroidReleaseCandidateOptions 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.android.isEnabled,
'Android delivery is disabled in configuration.',
SmfErrorCode.androidDisabled,
);
SmfError.check(
await gitClient.currentBranch() == ReleaseReference.branch(config.appId),
'Release candidate creation only runs on '
'${ReleaseReference.branch(config.appId)}.',
SmfErrorCode.releaseCandidateBranch,
);
SmfError.check(
await gitClient.isClean(),
'The release candidate checkout must be clean before repository hooks run.',
SmfErrorCode.dirtyWorktree,
);
final state = manifest.platforms.android;
SmfError.check(
state.isReleasePending,
'The Android 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.android,
version: state.version,
startingCommitHash: hookStartingCommitHash,
github: options.github,
);
}
final packageName = await options.dependencies.resolvePackage(
paths.appRoot,
config.android,
flavor: config.flavor,
);
final fingerprint = await SourceFingerprint.calculate(paths.directory);
final receiptPath = paths.releaseCandidateReceiptPath(
platform: ReleasePlatform.android,
version: state.version,
);
final intentPath = paths.releaseCandidateIntentPath(
platform: ReleasePlatform.android,
version: state.version,
);
final shouldCloseClient = options.client == null;
final client = options.client ?? await GooglePlayClient.open(options.googlePlayCredentials);
try {
final testingTracks = GooglePlayTrackNames.releaseCandidate(
config.android.googlePlay.releaseCandidate,
);
final reusable = await _reusableReleaseCandidate(
receiptPath: receiptPath,
fingerprint: fingerprint,
packageName: packageName,
testingTracks: testingTracks,
client: client,
);
if (reusable != null) {
final refreshedReceipt = reusable.copyWith(
testingDestinations: testingTracks,
);
await _recordReceipt(
repositoryRoot: 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,
packageName: packageName,
version: state.version,
);
final edit = await client.createEdit(packageName);
var isEditCommitted = false;
try {
final existingBundles = await client.listBundles(
packageName: packageName,
editId: edit.id,
);
var uploadIntent = previousIntent;
final intentVersionCode = previousIntent == null ? null : int.parse(previousIntent.buildNumber);
var uploaded = intentVersionCode == null
? null
: existingBundles
.where(
(bundle) => bundle.versionCode == intentVersionCode,
)
.firstOrNull;
if (uploaded != null) {
final matchingIntent = previousIntent;
if (matchingIntent == null) {
throw const SmfError(
'Google Play matched a release candidate build without a recorded '
'release candidate intent.',
SmfErrorCode.releaseCandidateIntentMissing,
);
}
SmfError.check(
uploaded.sha256 == matchingIntent.artifactSha256,
'Google Play contains versionCode $intentVersionCode, but its '
'bundle checksum does not match the committed release candidate '
'intent.',
SmfErrorCode.releaseCandidateIntentArtifactMismatch,
);
}
if (uploaded == null) {
final existingVersionCodes = await client.listArtifactVersionCodes(
packageName: packageName,
editId: edit.id,
);
final nextVersionCode = previousIntent == null
? existingVersionCodes.fold<int>(
0,
(maximum, versionCode) => versionCode > maximum ? versionCode : maximum,
) +
1
: int.parse(previousIntent.buildNumber);
SmfError.check(
nextVersionCode <= 2100000000,
'Google Play versionCode has reached its supported maximum.',
SmfErrorCode.versionCodeExhausted,
);
final signing = await options.dependencies.installSigning(
options.signingCredentials,
);
late final String aabPath;
try {
aabPath = await options.dependencies.buildAab(
projectRoot: paths.appRoot,
command: await AndroidBuild.resolveCommand(
paths.appRoot,
configuredCommand: config.android.buildCommand,
),
aabOutputPath: config.android.aabOutputPath,
version: state.version,
buildNumber: nextVersionCode.toString(),
signing: signing,
credentials: options.signingCredentials,
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 AAB.',
SmfErrorCode.buildInputChanged,
);
} finally {
await signing.cleanup();
}
final localSha256 = await FileDigest.sha256(aabPath);
uploadIntent = ReleaseCandidateIntentDto(
schemaVersion: 1,
platform: ReleasePlatform.android,
version: state.version,
buildNumber: nextVersionCode.toString(),
applicationId: packageName,
storeApplicationId: packageName,
sourceCommitHash: sourceCommitHash,
sourceFingerprint: fingerprint,
artifactSha256: localSha256,
preparedAt: options.dependencies.currentTime().toUtc(),
);
await _recordIntent(
repositoryRoot: repositoryRoot,
intentPath: intentPath,
intent: uploadIntent,
shouldCommitIntent: options.shouldCommitReceipt,
github: options.github,
);
uploaded = await client.uploadBundle(
packageName: packageName,
editId: edit.id,
aabPath: aabPath,
);
SmfError.check(
uploaded.versionCode == nextVersionCode && uploaded.sha256 == localSha256,
'Google Play bundle evidence does not match the signed local AAB.',
SmfErrorCode.googlePlayBundleMismatch,
);
}
final finalizedIntent = uploadIntent;
if (finalizedIntent == null) {
throw const SmfError(
'Google Play release candidate creation completed without a recorded '
'release candidate intent.',
SmfErrorCode.releaseCandidateIntentMissing,
);
}
final versionCode = uploaded.versionCode;
final notes = await SmfState.storeReleaseNotes(paths.directory);
for (final testingTrack in testingTracks) {
await client.updateTrack(
packageName: packageName,
editId: edit.id,
track: GooglePlayTrack(
name: testingTrack,
releases: <GooglePlayRelease>[
_testingRelease(
state.version,
versionCode,
notes.forRelease(
platform: ReleasePlatform.android,
version: state.version,
),
),
],
),
);
}
await client.validateEdit(
packageName: packageName,
editId: edit.id,
);
await client.commitEdit(
packageName: packageName,
editId: edit.id,
areChangesNotSentForReview: false,
);
isEditCommitted = true;
final receipt = ReleaseCandidateReceiptDto(
schemaVersion: 1,
platform: ReleasePlatform.android,
version: state.version,
buildNumber: finalizedIntent.buildNumber,
artifactId: finalizedIntent.buildNumber,
applicationId: packageName,
storeApplicationId: packageName,
sourceCommitHash: finalizedIntent.sourceCommitHash,
sourceFingerprint: finalizedIntent.sourceFingerprint,
artifactSha256: finalizedIntent.artifactSha256,
uploadedAt: finalizedIntent.preparedAt,
testingDestinations: testingTracks,
processingState: 'VALID',
);
await _recordReceipt(
repositoryRoot: repositoryRoot,
intentPath: intentPath,
receiptPath: receiptPath,
receipt: receipt,
shouldCommitReceipt: options.shouldCommitReceipt,
github: options.github,
isRefreshed: false,
);
return receipt;
} finally {
if (!isEditCommitted) {
await _discardEdit(client, packageName, edit.id);
}
}
} finally {
if (shouldCloseClient) client.close();
}
}