distributeYaml static method

Future<ConfigParser> distributeYaml(
  1. String path,
  2. ArgResults? globalResults
)

Creates a ConfigParser instance by parsing a YAML file.

This method reads and parses a YAML configuration file, processes variables, validates required fields, and creates a complete ConfigParser instance.

Parameters:

  • path - The path to the YAML configuration file
  • globalResults - Global command line argument results

Returns a new ConfigParser instance with the parsed configuration

Throws a ConfigException describing the offending key when the file is missing, is not valid YAML, or does not match the expected structure.

Implementation

static Future<ConfigParser> distributeYaml(
  String path,
  ArgResults? globalResults,
) async {
  final file = File(path);
  if (!file.existsSync()) {
    // A directory here is almost always a `--config` pointed one level too
    // high; saying "not found" about something that is plainly there sends
    // the reader looking for the wrong problem.
    if (FileSystemEntity.isDirectorySync(path)) {
      throw ConfigException(
        "'$path' is a directory, not a configuration file. "
        "Point --config at the YAML file itself.",
      );
    }
    throw ConfigException(
      "Configuration file '$path' not found. Run `distribute init` to create one.",
    );
  }

  final Map<String, dynamic> configJson;
  try {
    final decoded = loadYaml(file.readAsStringSync());
    if (decoded == null) {
      throw ConfigException("Configuration file '$path' is empty.");
    }
    if (decoded is! Map) {
      throw ConfigException(
        "Configuration file '$path' must contain a YAML mapping at the root.",
      );
    }
    configJson = Map<String, dynamic>.from(jsonDecode(jsonEncode(decoded)));
  } on YamlException catch (e) {
    throw ConfigException("Invalid YAML in '$path': ${e.message}");
  }

  _requireString(configJson, "name", path);
  _requireString(configJson, "description", path);

  final rawVariables = configJson["variables"];
  if (rawVariables != null && rawVariables is! Map) {
    throw ConfigException(
      "'variables' in '$path' must be a mapping of KEY: value pairs.",
    );
  }

  // `variables` is optional: an empty map keeps every downstream lookup valid.
  final yamlVariables = Map<String, dynamic>.from(
    (rawVariables as Map?) ?? const {},
  );
  final environments = Map<String, dynamic>.from(Platform.environment.cast());
  // Iterating a copy of the keys: the loop removes entries as it goes.
  for (final key in yamlVariables.keys.toList()) {
    // A key written with no value — `FIREBASE_TOKEN:` — is a declaration, not
    // an assignment. Overwriting the exported variable with an empty string
    // used to blank the real credential, resolve the placeholder to nothing,
    // and let `validate` report no problems.
    if (yamlVariables[key] == null) {
      yamlVariables.remove(key);
      continue;
    }
    yamlVariables[key] = await Variables.processBySystem(
      yamlVariables[key]?.toString(),
      globalResults,
    );
  }
  environments.addAll(yamlVariables);

  // `${{CHANGELOG}}` reads the history lazily, but it has to know the range
  // and formatting the project asked for before anything resolves it.
  final changelogSection =
      _parseSection(configJson["changelog"], "changelog", path);
  BuiltinVariables.changelogOptions = _changelogOptions(
    changelogSection,
    path,
  );
  // `changelog: ai: true` means the variable is polished on every publish.
  // Installed through a callback so this file keeps no dependency on the AI
  // adapters, and a project that never asks for it never loads them.
  try {
    installChangelogPolisher(
      changelogSection: changelogSection,
      aiSection: _parseAi(configJson["ai"], path),
    );
  } on ArgumentError catch (e) {
    throw ConfigException("${e.message} (in '$path')");
  }

  final variables = Variables(environments, globalResults);

  final rawTasks = configJson["tasks"];
  if (rawTasks == null) {
    throw ConfigException("'tasks' key not found in '$path'.");
  }
  if (rawTasks is! List) {
    throw ConfigException("'tasks' in '$path' must be a list.");
  }
  if (rawTasks.isEmpty) {
    throw ConfigException(
        "'tasks' in '$path' must contain at least one task.");
  }

  final jobTasks = <Task>[];
  final seenTaskKeys = <String>{};

  for (var taskIndex = 0; taskIndex < rawTasks.length; taskIndex++) {
    final rawTask = rawTasks[taskIndex];
    final taskLabel = "tasks[$taskIndex]";
    if (rawTask is! Map) {
      throw ConfigException("$taskLabel in '$path' must be a mapping.");
    }
    final task = Map<String, dynamic>.from(rawTask);

    final taskName = _requireString(task, "name", path, context: taskLabel);
    final taskKey = _requireString(task, "key", path, context: taskLabel);

    if (!seenTaskKeys.add(taskKey)) {
      throw ConfigException(
        "Duplicate task key '$taskKey' in '$path'. Task keys must be unique.",
      );
    }

    final rawJobs = task["jobs"];
    if (rawJobs is! List || rawJobs.isEmpty) {
      throw ConfigException(
        "$taskLabel ('$taskKey') in '$path' must define a non-empty 'jobs' list.",
      );
    }

    final jobs = <Job>[];
    final seenJobKeys = <String>{};
    for (var jobIndex = 0; jobIndex < rawJobs.length; jobIndex++) {
      final rawJob = rawJobs[jobIndex];
      final jobLabel = "$taskLabel.jobs[$jobIndex]";
      if (rawJob is! Map) {
        throw ConfigException("$jobLabel in '$path' must be a mapping.");
      }
      final job = _parseJob(
        Map<String, dynamic>.from(rawJob),
        variables: variables,
        environments: environments,
        path: path,
        label: jobLabel,
      );
      if (job.key != null && !seenJobKeys.add(job.key!)) {
        throw ConfigException(
          "Duplicate job key '${job.key}' in task '$taskKey' of '$path'. "
          "Job keys must be unique within a task.",
        );
      }
      jobs.add(job);
    }

    final rawWorkflows = task["workflows"];
    if (rawWorkflows != null && rawWorkflows is! List) {
      throw ConfigException(
        "Task '$taskKey' in '$path' has a 'workflows' that is not a list. "
        "Write it as a list of job keys.",
      );
    }
    final workflows = rawWorkflows == null
        ? null
        : [
            for (final entry in rawWorkflows as List)
              if (entry is String)
                entry
              else
                throw ConfigException(
                  "Task '$taskKey' in '$path' lists a workflow entry that is "
                  "not a job key: '$entry'.",
                ),
          ];

    if (workflows != null) {
      for (final workflow in workflows) {
        if (!jobs.any((job) => job.key == workflow)) {
          throw ConfigException(
            "Task '$taskKey' in '$path' lists workflow '$workflow' but no job "
            "with that key exists. Available job keys: "
            "${jobs.map((job) => job.key).whereType<String>().join(', ')}.",
          );
        }
      }
    }

    jobTasks.add(
      Task(
        name: taskName,
        key: taskKey,
        jobs: jobs,
        workflows: workflows,
        description:
            _asString(task["description"], "$taskLabel.description", path),
      ),
    );
  }

  final rawArguments = configJson["arguments"];
  if (rawArguments != null && rawArguments is! Map) {
    throw ConfigException("'arguments' in '$path' must be a mapping.");
  }

  return ConfigParser(
    globalResults: globalResults,
    tasks: jobTasks,
    arguments: (rawArguments as Map?)?.map(
      (key, value) => MapEntry(key.toString(), value as dynamic),
    ),
    environments: environments,
    variables: variables,
    notifications: _parseNotifications(configJson["notifications"], path),
    ai: _parseAi(configJson["ai"], path),
    changelog: changelogSection,
  );
}