run method
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;
}