from __future__ import annotations
from contextlib import contextmanager
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import tomllib
TELEMETRY = {f"opentelemetry_0_{version}" for version in (30, 31, 32)}
FEATURE_FLAGS = {
"--features", "--baseline-features", "--current-features", "--all-features",
"--default-features", "--only-explicit-features",
}
def option(args: list[str], name: str) -> str | None:
found = []
for index, value in enumerate(args):
if value == name:
if index + 1 == len(args):
raise ValueError(f"missing value for {name}")
found.append(args[index + 1])
elif value.startswith(name + "="):
found.append(value.split("=", 1)[1])
if len(found) > 1:
raise ValueError(f"duplicate {name} is not supported by the release adapter")
return found[0] if found else None
def replace_option(args: list[str], name: str, value: str) -> list[str]:
result = list(args)
for index, arg in enumerate(result):
if arg == name:
result[index + 1] = value
return result
if arg.startswith(name + "="):
result[index] = name + "=" + value
return result
raise ValueError(f"missing required {name}")
def metadata(manifest: Path, package: str) -> dict:
result = subprocess.run(
["cargo", "metadata", "--no-deps", "--format-version", "1",
"--manifest-path", str(manifest)],
check=True, capture_output=True, text=True,
)
matches = [item for item in json.loads(result.stdout)["packages"]
if item["name"] == package]
if len(matches) != 1:
raise ValueError(f"expected one package named {package} in {manifest}")
return matches[0]
def stable_features(features: dict) -> set[str]:
return {name for name in features
if name not in {"unstable", "nightly", "bench", "no_std"}
and not name.startswith(("_", "unstable-", "unstable_"))}
def profiles(current: dict, baseline: dict) -> list[tuple[str, set[str], set[str]]]:
current_features = stable_features(current["features"])
baseline_features = stable_features(baseline["features"])
versions = (current_features | baseline_features) & TELEMETRY
if not versions:
return []
result = []
for version in [None, *sorted(versions)]:
current_set = current_features - TELEMETRY
baseline_set = baseline_features - TELEMETRY
if version in current_features:
current_set.add(version)
if version in baseline_features:
baseline_set.add(version)
result.append((version or "without telemetry", current_set, baseline_set))
return result
@contextmanager
def buildable_baseline(manifest: Path, scratch: Path):
source = manifest.read_text()
data = tomllib.loads(source)
package = data.get("package", {})
if (package.get("name"), package.get("version")) != (
"graphile_worker_database", "0.1.6"
):
yield manifest
return
features = data["dependencies"]["tokio"].get("features", [])
missing = sorted({"macros", "time"} - set(features))
if not missing:
yield manifest
return
section = re.search(r"(?ms)^\[dependencies\.tokio\]\s*\n(.*?)(?=^\[|\Z)", source)
if not section:
raise ValueError("published database baseline has no normalized Tokio dependency")
feature_list = re.search(r"(?ms)^features\s*=\s*\[[^\]]*\]", section[1])
if not feature_list:
raise ValueError("published database baseline has no Tokio feature array")
start = section.start(1) + feature_list.start()
end = section.start(1) + feature_list.end()
repaired = source[:start] + "features = " + json.dumps(features + missing) + source[end:]
if "workspace" in package:
raise ValueError("published database baseline unexpectedly inherits a workspace")
if "workspace" not in data:
repaired += "\n[workspace]\n"
scratch.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix="database-0.1.6-", dir=scratch) as directory:
root = Path(directory) / "baseline"
shutil.copytree(manifest.parent, root, ignore=shutil.ignore_patterns("target", ".git"))
destination = root / manifest.name
destination.write_text(repaired)
print("API baseline: enabling missing Tokio macros/time in a temporary 0.1.6 "
"manifest; published Rust source is unchanged", file=sys.stderr)
yield destination
def published_database_dependency(manifest: Path) -> Path | None:
package = tomllib.loads(manifest.read_text()).get("package", {})
name = package.get("name", "")
if not name.startswith("graphile_worker") or name == "graphile_worker_database":
return None
result = subprocess.run(
["cargo", "metadata", "--all-features", "--format-version", "1",
"--manifest-path", str(manifest)],
check=True, capture_output=True, text=True,
)
matches = [item for item in json.loads(result.stdout)["packages"]
if item["name"] == "graphile_worker_database" and item["version"] == "0.1.6"
and item.get("source") == "registry+https://github.com/rust-lang/crates.io-index"]
if len(matches) > 1:
raise ValueError("ambiguous published database baseline dependency")
return Path(matches[0]["manifest_path"]) if matches else None
@contextmanager
def baseline_environment(manifest: Path, scratch: Path):
dependency = published_database_dependency(manifest)
if dependency is None:
yield None
return
with buildable_baseline(dependency, scratch) as repaired:
if repaired == dependency:
yield None
return
with tempfile.TemporaryDirectory(prefix="api-environment-", dir=scratch) as directory:
cwd = Path(directory)
(cwd / ".cargo").mkdir()
(cwd / ".cargo/config.toml").write_text(
"[patch.crates-io.graphile_worker_database]\npath = "
+ json.dumps(str(repaired.parent)) + "\n"
)
print("API baseline: applying the 0.1.6 build-feature repair transitively",
file=sys.stderr)
yield cwd
def run_checks(commands: list[tuple[str, list[str]]], run=subprocess.run,
cwd: Path | None = None) -> int:
outcome = 0
for label, command in commands:
print(f"API profile: {label}", file=sys.stderr)
result = run(command, check=False, **({"cwd": cwd} if cwd is not None else {}))
if result.returncode == 100:
outcome = 100
elif result.returncode != 0:
return result.returncode if result.returncode > 0 else 128 - result.returncode
return outcome
def main(args: list[str]) -> int:
real = os.environ.get("GRAPHILE_SEMVER_CHECKS_BIN")
if not real:
raise ValueError("GRAPHILE_SEMVER_CHECKS_BIN must identify the installed real checker")
real_path = Path(real).resolve()
wrapper = Path(__file__).parent / "semver-bin" / "cargo-semver-checks"
if real_path == wrapper.resolve() or real_path == Path(__file__).resolve():
raise ValueError("the real checker must not be the adapter")
if args[:2] != ["semver-checks", "check-release"]:
return subprocess.run([str(real_path), *args], check=False).returncode
if any(arg.split("=", 1)[0] in FEATURE_FLAGS for arg in args):
raise ValueError("release-plz supplied feature overrides; review the adapter configuration")
manifest_arg = option(args, "--manifest-path")
baseline_arg = option(args, "--baseline-root")
package = option(args, "--package")
if not all((manifest_arg, baseline_arg, package)):
raise ValueError("release-plz must supply manifest, baseline and one explicit package")
manifest = Path(manifest_arg).resolve()
baseline = Path(baseline_arg).resolve()
if baseline.is_dir():
baseline /= "Cargo.toml"
scratch = Path(os.environ.get("GRAPHILE_SEMVER_SCRATCH_ROOT", "target/release-api-baselines"))
scratch = scratch.resolve()
with buildable_baseline(baseline, scratch) as checked_baseline, \
baseline_environment(checked_baseline, scratch) as cwd:
checked_args = replace_option(args, "--baseline-root", str(checked_baseline))
checked_args = replace_option(checked_args, "--manifest-path", str(manifest))
current = metadata(manifest, package)
previous = metadata(checked_baseline, package)
variants = profiles(current, previous)
if not variants:
return run_checks([("native stable-feature selection", [str(real_path), *checked_args])], cwd=cwd)
commands = []
for label, current_features, baseline_features in variants:
command = [str(real_path), *checked_args, "--only-explicit-features"]
if current_features:
command.extend(["--current-features", ",".join(sorted(current_features))])
if baseline_features:
command.extend(["--baseline-features", ",".join(sorted(baseline_features))])
commands.append((label, command))
return run_checks(commands, cwd=cwd)
if __name__ == "__main__":
try:
sys.exit(main(sys.argv[1:]))
except (OSError, ValueError, subprocess.CalledProcessError) as error:
if isinstance(error, subprocess.CalledProcessError) and error.stderr:
print(error.stderr, file=sys.stderr, end="")
print(f"API check adapter failed: {error}", file=sys.stderr)
sys.exit(101)