| 1 | #!/usr/bin/env python3 |
| 2 | """Validate the maintained crates.io publication order against Cargo metadata. |
| 3 | |
| 4 | On success, emit the workspace inventory consumed by publish-crates.sh. |
| 5 | """ |
| 6 | |
| 7 | from __future__ import annotations |
| 8 | |
| 9 | import argparse |
| 10 | import json |
| 11 | import subprocess |
| 12 | import sys |
| 13 | from pathlib import Path |
| 14 | from typing import Any |
| 15 | |
| 16 | |
| 17 | class ValidationError(Exception): |
| 18 | """A release-order or Cargo metadata contract violation.""" |
| 19 | |
| 20 | |
| 21 | def parse_args() -> argparse.Namespace: |
| 22 | parser = argparse.ArgumentParser() |
| 23 | parser.add_argument( |
| 24 | "--metadata-file", |
| 25 | type=Path, |
| 26 | help="Read Cargo metadata from this file instead of invoking cargo (tests only).", |
| 27 | ) |
| 28 | parser.add_argument("crates", nargs="+", help="Maintained publication order") |
| 29 | return parser.parse_args() |
| 30 | |
| 31 | |
| 32 | def load_metadata(metadata_file: Path | None) -> dict[str, Any]: |
| 33 | if metadata_file is not None: |
| 34 | try: |
| 35 | raw = metadata_file.read_text(encoding="utf-8") |
| 36 | except OSError as error: |
| 37 | raise ValidationError(f"could not read Cargo metadata: {error}") from error |
| 38 | else: |
| 39 | process = subprocess.run( |
| 40 | ["cargo", "metadata", "--locked", "--format-version", "1", "--no-deps"], |
| 41 | check=False, |
| 42 | capture_output=True, |
| 43 | text=True, |
| 44 | ) |
| 45 | if process.returncode != 0: |
| 46 | detail = process.stderr.strip() |
| 47 | suffix = f": {detail}" if detail else "" |
| 48 | raise ValidationError( |
| 49 | f"cargo metadata failed with exit code {process.returncode}{suffix}" |
| 50 | ) |
| 51 | raw = process.stdout |
| 52 | |
| 53 | try: |
| 54 | metadata = json.loads(raw) |
| 55 | except json.JSONDecodeError as error: |
| 56 | raise ValidationError(f"Cargo metadata is not valid JSON: {error}") from error |
| 57 | if not isinstance(metadata, dict): |
| 58 | raise ValidationError("Cargo metadata root must be an object") |
| 59 | return metadata |
| 60 | |
| 61 | |
| 62 | def workspace_packages(metadata: dict[str, Any]) -> list[dict[str, Any]]: |
| 63 | members = metadata.get("workspace_members") |
| 64 | packages = metadata.get("packages") |
| 65 | if not isinstance(members, list) or not isinstance(packages, list): |
| 66 | raise ValidationError("Cargo metadata must contain workspace_members and packages lists") |
| 67 | |
| 68 | packages_by_id = { |
| 69 | package.get("id"): package |
| 70 | for package in packages |
| 71 | if isinstance(package, dict) and isinstance(package.get("id"), str) |
| 72 | } |
| 73 | missing_ids = [member for member in members if member not in packages_by_id] |
| 74 | if missing_ids: |
| 75 | raise ValidationError( |
| 76 | "Cargo metadata omits workspace package ids: " + ", ".join(missing_ids) |
| 77 | ) |
| 78 | return [packages_by_id[member] for member in members] |
| 79 | |
| 80 | |
| 81 | def validate_order( |
| 82 | packages: list[dict[str, Any]], ordered_crates: list[str] |
| 83 | ) -> tuple[str, dict[str, bool]]: |
| 84 | duplicate_crates = sorted( |
| 85 | {name for name in ordered_crates if ordered_crates.count(name) > 1} |
| 86 | ) |
| 87 | if duplicate_crates: |
| 88 | raise ValidationError( |
| 89 | "publish package list contains duplicates: " + ", ".join(duplicate_crates) |
| 90 | ) |
| 91 | |
| 92 | names = [package.get("name") for package in packages] |
| 93 | if any(not isinstance(name, str) or not name for name in names): |
| 94 | raise ValidationError("workspace package is missing a name") |
| 95 | if len(set(names)) != len(names): |
| 96 | raise ValidationError("Cargo metadata contains duplicate workspace package names") |
| 97 | |
| 98 | versions = sorted({package.get("version") for package in packages}) |
| 99 | if len(versions) != 1 or not isinstance(versions[0], str) or not versions[0]: |
| 100 | rendered = ", ".join(str(version) for version in versions) |
| 101 | raise ValidationError(f"workspace packages have mixed versions: {rendered}") |
| 102 | |
| 103 | workspace_by_name = {package["name"]: package for package in packages} |
| 104 | # `cargo metadata` renders `publish = false` as an empty registry list. |
| 105 | # Such a crate cannot be published, so demanding it appear in the publish |
| 106 | # order is a contradiction: `crates.sh` drives `cargo publish`, which would |
| 107 | # refuse it. Vendored, workspace-internal crates live here. |
| 108 | release_names = sorted( |
| 109 | name |
| 110 | for name, package in workspace_by_name.items() |
| 111 | if name.startswith("codewhale-") and package.get("publish") != [] |
| 112 | ) |
| 113 | ordered_set = set(ordered_crates) |
| 114 | missing = sorted(set(release_names) - ordered_set) |
| 115 | extra = sorted(ordered_set - set(release_names)) |
| 116 | if missing or extra: |
| 117 | messages = [] |
| 118 | if missing: |
| 119 | messages.append("publish package list is missing workspace crates: " + " ".join(missing)) |
| 120 | if extra: |
| 121 | messages.append( |
| 122 | "publish package list contains non-workspace crates: " + " ".join(extra) |
| 123 | ) |
| 124 | raise ValidationError("\n".join(messages)) |
| 125 | |
| 126 | positions = {name: index for index, name in enumerate(ordered_crates)} |
| 127 | has_workspace_dependencies = {name: False for name in release_names} |
| 128 | publish_edges: set[tuple[str, str, str]] = set() |
| 129 | for dependent in release_names: |
| 130 | dependencies = workspace_by_name[dependent].get("dependencies", []) |
| 131 | if not isinstance(dependencies, list): |
| 132 | raise ValidationError(f"Cargo metadata dependencies for {dependent} must be a list") |
| 133 | for dependency in dependencies: |
| 134 | if not isinstance(dependency, dict) or dependency.get("path") is None: |
| 135 | continue |
| 136 | dependency_name = dependency.get("name") |
| 137 | if dependency_name not in workspace_by_name: |
| 138 | continue |
| 139 | has_workspace_dependencies[dependent] = True |
| 140 | kind = dependency.get("kind") or "normal" |
| 141 | # Cargo does not compile dev-dependencies while verifying a publish. |
| 142 | # They may legitimately point back across the publication DAG. |
| 143 | if kind == "dev": |
| 144 | continue |
| 145 | if dependency_name not in positions: |
| 146 | raise ValidationError( |
| 147 | f"{dependent} depends on workspace crate {dependency_name} " |
| 148 | f"[{kind}], which is not in the codewhale-* release inventory" |
| 149 | ) |
| 150 | publish_edges.add((dependency_name, dependent, str(kind))) |
| 151 | |
| 152 | violations = sorted( |
| 153 | ( |
| 154 | dependency, |
| 155 | dependent, |
| 156 | kind, |
| 157 | ) |
| 158 | for dependency, dependent, kind in publish_edges |
| 159 | if positions[dependency] >= positions[dependent] |
| 160 | ) |
| 161 | if violations: |
| 162 | lines = ["crate publication order is not topological:"] |
| 163 | for dependency, dependent, kind in violations: |
| 164 | lines.append( |
| 165 | f" {dependent} (position {positions[dependent] + 1}) depends on " |
| 166 | f"{dependency} (position {positions[dependency] + 1}) [{kind}]" |
| 167 | ) |
| 168 | lines.append( |
| 169 | "Move every workspace dependency before its dependent in " |
| 170 | "scripts/release/crates.sh." |
| 171 | ) |
| 172 | raise ValidationError("\n".join(lines)) |
| 173 | |
| 174 | return versions[0], has_workspace_dependencies |
| 175 | |
| 176 | |
| 177 | def main() -> int: |
| 178 | args = parse_args() |
| 179 | try: |
| 180 | packages = workspace_packages(load_metadata(args.metadata_file)) |
| 181 | version, dependency_flags = validate_order(packages, args.crates) |
| 182 | except ValidationError as error: |
| 183 | print(error, file=sys.stderr) |
| 184 | return 1 |
| 185 | |
| 186 | print(f"version\t{version}\t") |
| 187 | for name in sorted(dependency_flags): |
| 188 | print(f"crate\t{name}\t{1 if dependency_flags[name] else 0}") |
| 189 | return 0 |
| 190 | |
| 191 | |
| 192 | if __name__ == "__main__": |
| 193 | raise SystemExit(main()) |
| 194 |