promote static method

Verifies and promotes the exact release candidate described by options.

Implementation

static Future<AndroidShipReleaseResultDto> promote(
  AndroidPromotionOptions 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() == 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.android;
  SmfError.check(
    state.isReleasePending,
    'The Android manifest does not contain a pending release.',
    SmfErrorCode.noPendingRelease,
  );
  final receipt = await ReleaseCandidateReceiptDto.fromJsonFilePath(
    paths.releaseCandidateReceiptPath(
      platform: ReleasePlatform.android,
      version: state.version,
    ),
  );
  SmfError.check(
    receipt.version == state.version && receipt.platform == ReleasePlatform.android,
    'The release candidate receipt does not match the pending Android release.',
    SmfErrorCode.releaseCandidateMismatch,
  );
  SmfError.check(
    await SourceFingerprint.calculate(paths.directory) == receipt.sourceFingerprint,
    'The merged source does not match the tested Google Play release candidate. '
    'Produce a new release candidate before promoting this version.',
    SmfErrorCode.untestedSource,
  );
  final packageName = await options.resolvePackage(
    paths.appRoot,
    config.android,
    flavor: config.flavor,
  );
  SmfError.check(
    receipt.applicationId == packageName && receipt.storeApplicationId == packageName,
    'The release candidate receipt package name does not match the configured Android '
    'application.',
    SmfErrorCode.releaseCandidatePackageMismatch,
  );
  final versionCode = int.tryParse(receipt.artifactId);
  if (versionCode == null || receipt.buildNumber != receipt.artifactId || versionCode <= 0) {
    throw const SmfError(
      'The release candidate receipt does not contain a valid Google Play versionCode.',
      SmfErrorCode.releaseCandidateMismatch,
    );
  }
  final sourceCommitHash = await gitClient.currentCommitHash();
  final tag = ReleaseReference.tag(
    config.appId,
    ReleasePlatform.android,
    state.version,
  );
  await gitClient.verifyRemoteTagCommitIfPresent(
    tag: tag,
    expectedCommitHash: sourceCommitHash,
    token: options.github.token,
  );
  final GitHubReleaseDto? existingGitHubRelease;
  if (options.githubApi case final githubApi?) {
    existingGitHubRelease = await githubApi.releaseByTag(tag);
  } else {
    final githubApi = GitHubRestApi(context: options.github);
    try {
      existingGitHubRelease = await githubApi.releaseByTag(tag);
    } finally {
      githubApi.close();
    }
  }
  if (existingGitHubRelease != null) {
    SmfError.check(
      existingGitHubRelease.tagName == tag && existingGitHubRelease.targetCommitish == sourceCommitHash,
      'GitHub Release $tag does not target the verified release commit.',
      SmfErrorCode.remoteTagMismatch,
    );
  }

  final shouldCloseClient = options.client == null;
  final client = options.client ?? await GooglePlayClient.open(options.googlePlayCredentials);
  final testingTracks = GooglePlayTrackNames.releaseCandidate(
    config.android.googlePlay.releaseCandidate,
  );
  final configuredShip = config.android.googlePlay.ship;
  final shipTracks = configuredShip == null ? const <String>[] : GooglePlayTrackNames.ship(configuredShip);
  final promotedTracks = <String>[];
  try {
    final edit = await client.createEdit(packageName);
    var isEditCommitted = false;
    try {
      final bundles = await client.listBundles(
        packageName: packageName,
        editId: edit.id,
      );
      final bundle = bundles.where((item) => item.versionCode == versionCode).firstOrNull;
      SmfError.check(
        bundle?.sha256 == receipt.artifactSha256,
        'The recorded Android App Bundle is not available with the exact '
        'release candidate SHA-256.',
        SmfErrorCode.releaseCandidateInvalid,
      );
      for (final testingTrackName in testingTracks) {
        final testingTrack = await client.getTrack(
          packageName: packageName,
          editId: edit.id,
          track: testingTrackName,
        );
        SmfError.check(
          testingTrack.containsCompletedVersionCode(versionCode),
          'The exact release candidate versionCode is not on configured Google Play '
          'testing track "$testingTrackName" as a completed release.',
          SmfErrorCode.releaseCandidateNotTesting,
        );
      }

      var hasTrackChanged = false;
      for (final shipTrackName in shipTracks) {
        final destination = await client.getTrack(
          packageName: packageName,
          editId: edit.id,
          track: shipTrackName,
        );
        final existingRelease = destination.releaseForVersionCode(versionCode);
        if (existingRelease != null) {
          SmfError.check(
            existingRelease.status == GooglePlayReleaseStatus.completed,
            'Google Play track "$shipTrackName" contains the release candidate in '
            '${existingRelease.status.value} state, not as a completed '
            'release.',
            SmfErrorCode.googlePlayReleaseInProgress,
          );
        } else {
          SmfError.check(
            destination.releases.every(
              (release) => release.status == GooglePlayReleaseStatus.completed,
            ),
            'Google Play track "$shipTrackName" has an unfinished release. '
            'Finish or halt it in Play Console before SMF replaces it.',
            SmfErrorCode.googlePlayReleaseInProgress,
          );
          final notes = await SmfState.storeReleaseNotes(paths.directory);
          await client.updateTrack(
            packageName: packageName,
            editId: edit.id,
            track: GooglePlayTrack(
              name: shipTrackName,
              releases: <GooglePlayRelease>[
                GooglePlayRelease(
                  status: GooglePlayReleaseStatus.completed,
                  versionCodes: <int>[versionCode],
                  name: state.version,
                  releaseNotes: notes.forRelease(
                    platform: ReleasePlatform.android,
                    version: state.version,
                  ),
                ),
              ],
            ),
          );
          hasTrackChanged = true;
        }
        promotedTracks.add(shipTrackName);
      }
      if (hasTrackChanged) {
        await client.validateEdit(
          packageName: packageName,
          editId: edit.id,
        );
        await client.commitEdit(
          packageName: packageName,
          editId: edit.id,
          areChangesNotSentForReview: false,
        );
        isEditCommitted = true;
      }
    } finally {
      if (!isEditCommitted) {
        try {
          await client.deleteEdit(
            packageName: packageName,
            editId: edit.id,
          );
        } on Object {
          // An abandoned Google Play edit expires automatically. Cleanup must
          // not hide the promotion result or its original failure.
        }
      }
    }
  } finally {
    if (shouldCloseClient) client.close();
  }

  final changelog = await SmfState.changelog(paths.directory);
  final release = changelog.platforms.android.releaseVersion(state.version);
  if (release == null) {
    throw SmfError(
      'Missing changelog for Android ${state.version}',
      SmfErrorCode.missingChangelog,
    );
  }
  GitHubRestApi? ownedGitHubApi;
  final githubApi = options.githubApi ?? (ownedGitHubApi = GitHubRestApi(context: options.github));
  try {
    final githubRelease =
        existingGitHubRelease ??
        await githubApi.createRelease(
          tag: tag,
          targetCommitish: sourceCommitHash,
          name: '${config.appId} Android v${state.version}',
          body: ReleaseChangelog.markdown(
            platform: ReleasePlatform.android,
            release: release,
          ),
        );
    return AndroidShipReleaseResultDto(
      version: state.version,
      tag: tag,
      versionCode: versionCode,
      testingTrack: testingTracks.first,
      testingTracks: testingTracks,
      shippedTracks: promotedTracks,
      productionTrack: promotedTracks.contains(GooglePlayTrackNames.production)
          ? GooglePlayTrackNames.production
          : null,
      githubReleaseUrl: githubRelease.htmlUrl,
    );
  } finally {
    ownedGitHubApi?.close();
  }
}