runAndroidDeploy function
Future<void>
runAndroidDeploy(
- ArgResults args
)
Implementation
Future<void> runAndroidDeploy(ArgResults args) async {
if (args['help'] as bool) {
print('''
Usage: openci_vm android-deploy [options]
Deploy an AAB or APK to Google Play Console.
${androidDeployParser().usage}
Environment variables:
GOOGLE_PLAY_SERVICE_ACCOUNT_JSON Service account JSON key content or file path
''');
return;
}
// Resolve artifact path: --artifact takes priority, then --aab
final artifactPath = args['artifact'] as String? ?? args['aab'] as String?;
if (artifactPath == null) {
_error('No artifact specified. Use --artifact <path> or --aab <path>.');
exit(1);
}
final packageName = args['package-name'] as String;
final track = args['track'] as String;
final status = args['status'] as String;
final releaseName = args['release-name'] as String?;
final releaseNotesJson = args['release-notes'] as String?;
// ── Determine artifact type ────────────────────────────────
final isAab = artifactPath.toLowerCase().endsWith('.aab');
final isApk = artifactPath.toLowerCase().endsWith('.apk');
if (!isAab && !isApk) {
_error(
'Unknown file type: $artifactPath\n'
'Supported formats: .aab (Android App Bundle), .apk',
);
exit(1);
}
final artifactType = isAab ? 'AAB' : 'APK';
// ── Validate artifact file ─────────────────────────────────
_log('🤖 OpenCI Android Deploy');
_log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
final artifactFile = File(artifactPath);
if (!artifactFile.existsSync()) {
_error('$artifactType file not found: $artifactPath');
exit(1);
}
final fileSizeMb = artifactFile.lengthSync() / (1024 * 1024);
_log('📦 $artifactType: $artifactPath (${fileSizeMb.toStringAsFixed(1)} MB)');
_log('📱 Package: $packageName');
_log('🛤️ Track: $track');
_log('📋 Status: $status');
// ── Resolve service account credentials ────────────────────
final serviceAccountJson = _resolveServiceAccountJson(
args['service-account-json'] as String?,
);
// ── Authenticate ───────────────────────────────────────────
_log('');
_log('🔐 Authenticating with Google Play Developer API...');
final credentials = ServiceAccountCredentials.fromJson(serviceAccountJson);
final httpClient = await clientViaServiceAccount(credentials, [
AndroidPublisherApi.androidpublisherScope,
]);
try {
final api = AndroidPublisherApi(httpClient);
// ── Create edit session ────────────────────────────────────
_log('📝 Creating edit session...');
final edit = await api.edits.insert(AppEdit(), packageName);
final editId = edit.id!;
_log(' Edit ID: $editId');
// ── Upload artifact ────────────────────────────────────────
_log('⬆️ Uploading $artifactType (this may take a while)...');
final uploadMedia = Media(
artifactFile.openRead(),
artifactFile.lengthSync(),
contentType: 'application/octet-stream',
);
int? versionCode;
if (isAab) {
final bundle = await api.edits.bundles.upload(
packageName,
editId,
uploadMedia: uploadMedia,
);
versionCode = bundle.versionCode;
} else {
final apk = await api.edits.apks.upload(
packageName,
editId,
uploadMedia: uploadMedia,
);
versionCode = apk.versionCode;
}
_log(' ✅ Upload complete! Version code: $versionCode');
// ── Assign to track ────────────────────────────────────────
_log('🛤️ Assigning to $track track...');
// Parse release notes if provided
List<LocalizedText>? releaseNotes;
if (releaseNotesJson != null) {
try {
final notesList = (jsonDecode(releaseNotesJson) as List)
.cast<Map<String, dynamic>>();
releaseNotes = notesList
.map(
(n) => LocalizedText(
language: n['language'] as String?,
text: n['text'] as String?,
),
)
.toList();
} catch (e) {
_error(
'Failed to parse release notes JSON: $e\n'
'Expected format: [{"language":"en-US","text":"Bug fixes"}]',
);
exit(1);
}
}
var effectiveStatus = status;
final trackConfig = Track(
track: track,
releases: [
TrackRelease(
versionCodes: ['$versionCode'],
status: effectiveStatus,
name: releaseName,
releaseNotes: releaseNotes,
),
],
);
await api.edits.tracks.update(trackConfig, packageName, editId, track);
_log(' ✅ Track updated');
// ── Commit ─────────────────────────────────────────────────
_log('🚀 Committing changes...');
try {
await api.edits.commit(packageName, editId);
} on DetailedApiRequestError catch (e) {
// Draft apps only accept releases with status 'draft'.
// Auto-fallback: re-create edit with status 'draft' and retry.
if (e.status == 400 &&
(e.message?.contains('draft') ?? false) &&
effectiveStatus != 'draft') {
_log('');
_log('⚠️ App is in draft state on Google Play Console.');
_log(' Retrying with status: draft ...');
effectiveStatus = 'draft';
// Previous edit is invalidated after a failed commit, create a new one.
final retryEdit = await api.edits.insert(AppEdit(), packageName);
final retryEditId = retryEdit.id!;
// Re-upload artifact
final retryMedia = Media(
artifactFile.openRead(),
artifactFile.lengthSync(),
contentType: 'application/octet-stream',
);
if (isAab) {
await api.edits.bundles.upload(
packageName,
retryEditId,
uploadMedia: retryMedia,
);
} else {
await api.edits.apks.upload(
packageName,
retryEditId,
uploadMedia: retryMedia,
);
}
final retryTrackConfig = Track(
track: track,
releases: [
TrackRelease(
versionCodes: ['$versionCode'],
status: 'draft',
name: releaseName,
releaseNotes: releaseNotes,
),
],
);
await api.edits.tracks.update(
retryTrackConfig,
packageName,
retryEditId,
track,
);
await api.edits.commit(packageName, retryEditId);
_log(' ✅ Committed as draft');
} else {
rethrow;
}
}
_log('');
_log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
_log('✅ Successfully deployed to Google Play Console!');
_log(' Package: $packageName');
_log(' Version code: $versionCode');
_log(' Track: $track');
_log(' Status: $effectiveStatus');
_log(' Type: $artifactType');
if (effectiveStatus == 'draft' && status != 'draft') {
_log('');
_log('📋 Note: Release was created as draft because the app');
_log(' has not been published yet. Go to Google Play Console');
_log(' to review and publish the release.');
}
_log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
} on DetailedApiRequestError catch (e) {
_error('Google Play API error: ${e.status} - ${e.message}');
for (final err in e.errors) {
_error(' • ${err.message} (${err.reason})');
}
exit(1);
} catch (e) {
_error('Unexpected error: $e');
exit(1);
} finally {
httpClient.close();
}
}