run method

  1. @override
void run()
override

Runs this command.

The return value is wrapped in a Future if necessary and returned by CommandRunner.runCommand.

Implementation

@override
void run() {
  final args = argResults!;
  final project = _project(this);
  final config = ProjectConfig.read(project.root);
  final changelog = args.option('changelog') ?? project.changelog;

  // **Declaring the block is what says a project publishes to a store**, and
  // it is what the *requirements* hang off — which locales, which screenshot
  // types. It is deliberately not what decides whether a tree is looked at.
  //
  // A tree that is present is checked against the rules intrinsic to it
  // whether or not anybody declared it, because an upgrade that silently
  // checks less than the version before it is the failure this change exists
  // to remove, committed on the way in. An undeclared tree is reported, and
  // a declared one additionally has to satisfy what it declared.
  final play = args.option('play') ?? project.playMetadata;
  final dataSafety = args.option('data-safety') ?? project.dataSafety;

  // **Which platform's requirements each App Store tree is checked against.**
  //
  // A path carries no platform, so without this there is nothing for
  // `appstore.screenshots.macos` to be selected *by* — and an earlier
  // revision silently applied `ios:` to every tree, failing a macOS listing
  // Apple holds for lacking iPhone screenshots. A declared requirement
  // enforcing the *other* platform's rules is worse than one enforcing none.
  //
  // What changed is where the platform comes from when nobody says. It used
  // to be a refusal — "pass --platform to say which tree this is" — and that
  // was right while `--appstore` was the only way to reach a tree, because
  // one path genuinely cannot be two platforms. It is wrong when the paths
  // are *derived*, because `store/appstore/macos` is not ambiguous about
  // which platform it holds. So a repository with a split layout gets every
  // tree checked, each against its own rules, in one run.
  //
  // The refusal survives for `--appstore`, which is still one path.
  final platform = args.option('platform');
  final named = args.option('appstore');
  final Map<String, String> appStoreTrees;
  if (named != null) {
    final declaredPlatforms =
        config.appstore?.screenshots.keys.toSet() ?? const <String>{};
    if (platform == null && declaredPlatforms.length > 1) {
      stderr.writeln(
        'cux_ship verify: $cuxShipConfigFile declares appstore.screenshots '
        'for ${(declaredPlatforms.toList()..sort()).join(' and ')}, and '
        '--appstore names one tree — pass --platform to say which of them '
        'it is, or the wrong platform\'s requirements would be applied.',
      );
      exitCode = 1;
      return;
    }
    appStoreTrees = {platform ?? 'ios': named};
  } else {
    appStoreTrees = project.appStoreTrees(platform: platform);
  }

  final problems = <ReleaseProblem>[
    ..._declarationProblems(
      config,
      args,
      appStoreTrees.isEmpty ? null : appStoreTrees.values.first,
      play,
    ),
    if (changelog != null) ...checkChangelogFile(changelog),
    // **The version about to ship has a section.** checkChangelogFile walks
    // the headings the file has, so the one it lacks is the one it cannot
    // report — and the uploaders refuse that version late, on Play after the
    // prompt and inside an open edit. Two consumer scripts grepped for the
    // heading themselves before calling anything here; this is that grep,
    // with the version read from the same pubspec the upload will read.
    if (changelog != null && project.versionName != null)
      ?changelogSectionProblem(
        changelog: changelog,
        version: project.versionName!,
      ),
    for (final MapEntry(key: platform, value: tree)
        in appStoreTrees.entries) ...[
      ?_derivationProblem(
        args.multiOption('require-screenshot-type').toSet(),
        config.appstore,
        project,
        platform: platform,
      ),
      ...checkAppStoreTree(
        tree,
        requireScreenshotTypes: _appStoreScreenshotTypes(
          args,
          config.appstore,
          project,
          platform: platform,
        ),
        requireLocales: _requiredLocales(args, config.appstore),
      ),
    ],
    if (play != null)
      ...checkPlayTree(
        play,
        // Play's own vocabulary, from the config only — see
        // _appStoreScreenshotTypes for why the flag does not reach here.
        requireScreenshotTypes:
            config.play?.screenshotsFor(StoreConfig.anyPlatform) ?? const {},
        requireLocales: _requiredLocales(args, config.play),
      ),
    if (dataSafety != null) ...checkDataSafetyFile(dataSafety),
  ];

  // Refused rather than passed: checking nothing and reporting success is the
  // failure this command exists to prevent, so having nothing to check is
  // itself the finding.
  if (changelog == null &&
      appStoreTrees.isEmpty &&
      play == null &&
      dataSafety == null) {
    stderr.writeln(
      'cux_ship verify: nothing to check — no CHANGELOG.md, no App Store or '
      'Play metadata tree, and no data safety declaration were found, and '
      'none was named. Name one with --changelog, --appstore, --play or '
      '--data-safety.',
    );
    exitCode = 1;
    return;
  }

  // **What was checked, named, on the way past.**
  //
  // A clean run used to print one line, so a reader could not tell whether
  // the data safety declaration had been validated or silently skipped —
  // they had to suspect it and go looking. That is the failure this release
  // exists to close, on the success path: absence of output reading as
  // coverage. Every artifact says so itself.
  final checked = <VerifyCheck>[
    if (changelog != null) VerifyCheck(what: 'changelog', where: changelog),
    // What was checked, not what was found: this entry is present on the run
    // that goes on to report the section missing, and once read "has its
    // section" there.
    if (changelog != null && project.versionName != null) ...[
      VerifyCheck(
        what: 'section',
        where: '${project.versionName} (pubspec.yaml)',
      ),
    ],
    for (final tree in appStoreTrees.entries) ...[
      VerifyCheck(what: 'appstore', where: '${tree.value} (${tree.key})'),
    ],
    if (play != null) VerifyCheck(what: 'play', where: play),
    if (dataSafety != null)
      VerifyCheck(what: 'data-safety', where: dataSafety),
  ];

  // **And what was *not*, with why — which `checked` alone cannot say.**
  //
  // With only the list above, a reader notices an omission by already
  // holding the expected set in their head; a check that silently did not
  // run is invisible unless somebody is keeping the list. That is the same
  // data-safety failure the line above closes, moved one level up. Naming
  // the absences makes it impossible to miss rather than merely possible to
  // catch — absence stops being inferred from what is not in a list, which
  // is a thing nobody does reliably.
  //
  // Only the reachable ones: a run that got here has at least one artifact,
  // and `section` is skipped for a reason of its own — there is a changelog
  // but nothing in pubspec.yaml saying which version to look for.
  final skipped = <VerifyCheck>[
    if (changelog == null)
      const VerifyCheck(
        what: 'changelog',
        why: 'no CHANGELOG.md was found, and none was named with --changelog',
      ),
    if (changelog != null && project.versionName == null)
      const VerifyCheck(
        what: 'section',
        why:
            'pubspec.yaml declares no version, so there is no section to '
            'look for',
      ),
    if (appStoreTrees.isEmpty)
      const VerifyCheck(
        what: 'appstore',
        why:
            'no App Store metadata tree was found, and none was named with '
            '--appstore',
      ),
    if (play == null)
      const VerifyCheck(
        what: 'play',
        why:
            'no Play metadata tree was found, and none was named with --play',
      ),
    if (dataSafety == null)
      const VerifyCheck(
        what: 'data-safety',
        why:
            'no data safety declaration was found, and none was named with '
            '--data-safety',
      ),
  ];

  final display = <String>[
    for (final entry in checked) ...[
      '    checked ${entry.what.padRight(12)}${entry.where}',
    ],
    if (problems.isEmpty)
      '==> release inputs are publishable'
    else ...[
      'cux_ship verify: ${problems.length} problem(s)',
      for (final problem in problems) ...['  $problem'],
    ],
  ];

  if (args.flag('json')) {
    writeJsonDocument(
      verifyDocument(
        checked: checked,
        skipped: skipped,
        problems: problems.map((p) => '$p').toList(),
        display: display,
      ),
    );
    // **Exit 1 on problems even under `--json`.** The document says `ok:
    // false` and the status says failure, which is two channels for one
    // fact — allowed here, and not for `matches`, because this is a *check*
    // whose whole purpose is to fail a build. A caller wiring `verify` into
    // CI expects the status to mean what every other checker's does.
    if (problems.isNotEmpty) {
      exitCode = 1;
    }
    return;
  }

  for (final entry in checked) {
    stdout.writeln('    checked ${entry.what.padRight(12)}${entry.where}');
  }

  if (problems.isEmpty) {
    stdout.writeln('==> release inputs are publishable');
    return;
  }
  // Every problem at once. Reported one at a time, the second is found only
  // after the first is fixed and pushed.
  stderr.writeln('cux_ship verify: ${problems.length} problem(s)');
  for (final problem in problems) {
    stderr.writeln('  $problem');
  }
  exitCode = 1;
}