405 lines
14 KiB
Python
Executable File
405 lines
14 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""CI gate for deterministic distribution contract enforcement."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
CONTRACT_PATH = ROOT / "docs" / "distribution-contract.json"
|
|
DOC_PATH = ROOT / "docs" / "distribution.md"
|
|
CHANGELOG_PATH = ROOT / "docs" / "distribution-changelog.md"
|
|
CONFIG_PATH = ROOT / "scripts" / "release-orchestrator.config.json"
|
|
ORCHESTRATOR_PATH = ROOT / "scripts" / "release-orchestrator.py"
|
|
|
|
|
|
def fail(message: str) -> None:
|
|
raise SystemExit(message)
|
|
|
|
|
|
def run(cmd: List[str], *, env: Dict[str, str] | None = None, cwd: Path | None = None) -> str:
|
|
proc = subprocess.run(
|
|
cmd,
|
|
check=True,
|
|
cwd=cwd,
|
|
env=env,
|
|
stdout=subprocess.PIPE,
|
|
stderr=subprocess.PIPE,
|
|
text=True,
|
|
)
|
|
return proc.stdout.strip()
|
|
|
|
|
|
def sha256_file(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as f:
|
|
for chunk in iter(lambda: f.read(1 << 20), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def parse_datetime_utc(value: str) -> datetime:
|
|
# Supports values with trailing Z (e.g. 2026-07-01T00:00:00Z)
|
|
if value.endswith("Z"):
|
|
value = value[:-1] + "+00:00"
|
|
dt = datetime.fromisoformat(value)
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|
return dt.astimezone(timezone.utc)
|
|
|
|
|
|
def load_json(path: Path) -> Dict[str, Any]:
|
|
return json.loads(path.read_text(encoding="utf-8"))
|
|
|
|
|
|
def write_json(path: Path, payload: Dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
|
|
|
|
|
|
def read_version_from_cargo() -> str:
|
|
cargo_toml = ROOT / "Cargo.toml"
|
|
for line in cargo_toml.read_text(encoding="utf-8").splitlines():
|
|
m = re.match(r'^version\s*=\s*"([^"]+)"$', line.strip())
|
|
if m:
|
|
return m.group(1)
|
|
fail("Unable to read package version from Cargo.toml")
|
|
|
|
|
|
def validate_contract(contract: Dict[str, Any]) -> None:
|
|
required_root = {
|
|
"schema_version",
|
|
"contract_version",
|
|
"artifact",
|
|
"compatibility_matrix",
|
|
"assumptions",
|
|
"reproducibility_metadata",
|
|
"provenance_metadata",
|
|
"checksum_policy",
|
|
"compatibility_policy",
|
|
"deprecation_policy",
|
|
"retention_policy",
|
|
"migration_steps",
|
|
"changelog",
|
|
"maintenance_governance",
|
|
}
|
|
missing = sorted(required_root - contract.keys())
|
|
if missing:
|
|
fail(f"distribution contract missing required top-level keys: {', '.join(missing)}")
|
|
|
|
artifact = contract["artifact"]
|
|
required_artifact = {
|
|
"name",
|
|
"entrypoints",
|
|
"distribution_index",
|
|
"versioning",
|
|
"naming",
|
|
"release_artifacts",
|
|
}
|
|
missing_artifact = sorted(required_artifact - artifact.keys())
|
|
if missing_artifact:
|
|
fail(f"distribution contract artifact section missing keys: {', '.join(missing_artifact)}")
|
|
|
|
release_artifacts = artifact["release_artifacts"]
|
|
required_release_artifacts = {
|
|
"formats",
|
|
"required_manifests",
|
|
"index_path_template",
|
|
"artifact_directory_template",
|
|
"orchestrator",
|
|
}
|
|
missing_release_artifacts = sorted(required_release_artifacts - release_artifacts.keys())
|
|
if missing_release_artifacts:
|
|
fail(
|
|
"distribution contract missing required release_artifact keys: "
|
|
f"{', '.join(missing_release_artifacts)}"
|
|
)
|
|
|
|
if contract["artifact"]["distribution_index"]["canonical"] != "dist/index.json":
|
|
fail("canonical distribution index must be dist/index.json")
|
|
|
|
if contract["checksum_policy"].get("algorithm") != "sha256":
|
|
fail("checksum policy must require sha256")
|
|
|
|
deprecation = contract["deprecation_policy"]["legacy_index_path"]
|
|
sunset = parse_datetime_utc(deprecation["supported_until"])
|
|
now = datetime.now(timezone.utc)
|
|
if sunset <= now:
|
|
fail("deprecation supported-until date is in the past")
|
|
|
|
if contract["retention_policy"].get("kept_release_generations", 0) < 6:
|
|
fail("retention policy must keep at least 6 release generations")
|
|
|
|
mg = contract["maintenance_governance"]
|
|
if mg.get("release_ownership", {}).get("ownership_handoff_required") is None:
|
|
fail("maintenance_governance.release_ownership.ownership_handoff_required is required")
|
|
|
|
if mg.get("deprecation_governance", {}).get("required_notice_days", 0) < 30:
|
|
fail("maintenance_governance.deprecation_governance.required_notice_days must be at least 30")
|
|
|
|
# Basic machine-readable compatibility guardrails.
|
|
comp_matrix = contract["compatibility_matrix"]
|
|
if not isinstance(comp_matrix, list) or not comp_matrix:
|
|
fail("compatibility_matrix must be a non-empty array")
|
|
|
|
|
|
def validate_docs_and_changelog() -> None:
|
|
text = DOC_PATH.read_text(encoding="utf-8")
|
|
low = text.lower()
|
|
|
|
required_markers = [
|
|
"dist/index.json",
|
|
"dist/{distribution_contract_version}/index.json",
|
|
"release compatibility matrix",
|
|
"release ownership handoff",
|
|
"deprecation workflow",
|
|
"minimum retention window",
|
|
]
|
|
for marker in required_markers:
|
|
if marker not in low:
|
|
fail(f"docs/distribution.md is missing marker: {marker}")
|
|
|
|
contract = load_json(CONTRACT_PATH)
|
|
major_minor = ".".join(contract["contract_version"].split(".")[:2])
|
|
if f"distribution-contract@{major_minor}" not in text:
|
|
fail(f"docs/distribution.md does not reference contract version distribution-contract@{major_minor}")
|
|
|
|
if "dist" not in CHANGELOG_PATH.read_text(encoding="utf-8").lower():
|
|
fail("docs/distribution-changelog.md appears invalid for distribution contract tracking")
|
|
|
|
|
|
def verify_checksum_manifest(package_root: Path, checksum_payload: Dict[str, Any], artifact_path: Path) -> None:
|
|
if not isinstance(checksum_payload, dict):
|
|
fail("checksums payload is not a JSON object")
|
|
|
|
files = checksum_payload.get("files")
|
|
if not isinstance(files, list) or not files:
|
|
fail("checksums manifest must include a non-empty files array")
|
|
|
|
manifest_map = {
|
|
item.get("path"): item.get("sha256") for item in files
|
|
}
|
|
|
|
for item in files:
|
|
rel = item.get("path")
|
|
expected = item.get("sha256")
|
|
if not rel or not expected:
|
|
fail("invalid checksums file entry")
|
|
computed = sha256_file(package_root / rel)
|
|
if computed != expected:
|
|
fail(f"checksum mismatch for package file {rel}")
|
|
|
|
for file_path in sorted(package_root.rglob("*")):
|
|
if not file_path.is_file():
|
|
continue
|
|
rel = file_path.relative_to(package_root).as_posix()
|
|
if rel not in manifest_map:
|
|
fail(f"checksums manifest missing entry for package file {rel}")
|
|
|
|
expected_artifact = checksum_payload.get("artifact_sha256")
|
|
if expected_artifact != sha256_file(artifact_path):
|
|
fail("artifact sha256 does not match checksums.json payload")
|
|
|
|
|
|
def validate_artifact_entry(
|
|
entry: Dict[str, Any],
|
|
contract: Dict[str, Any],
|
|
source_date_epoch: str,
|
|
) -> Dict[str, str]:
|
|
root = ROOT
|
|
artifact_path = root / entry["artifact_file"]
|
|
manifest_path = root / entry["manifest_file"]
|
|
checksums_path = root / entry["checksums_file"]
|
|
|
|
if not artifact_path.exists():
|
|
fail(f"artifact path missing: {artifact_path}")
|
|
if not manifest_path.exists():
|
|
fail(f"manifest path missing: {manifest_path}")
|
|
if not checksums_path.exists():
|
|
fail(f"checksums path missing: {checksums_path}")
|
|
|
|
manifest = load_json(manifest_path)
|
|
checksums = load_json(checksums_path)
|
|
|
|
required_manifest_keys = {
|
|
"schema_version",
|
|
"contract_version",
|
|
"artifact",
|
|
"artifact_version",
|
|
"target",
|
|
"profile",
|
|
"toolchain",
|
|
"dist_revision",
|
|
"git",
|
|
"build_time_inputs",
|
|
"content",
|
|
"generated_at",
|
|
}
|
|
if not required_manifest_keys <= manifest.keys():
|
|
missing = ", ".join(sorted(required_manifest_keys - manifest.keys()))
|
|
fail(f"manifest missing keys: {missing}")
|
|
|
|
if manifest["artifact_version"] != entry["version"]:
|
|
fail("manifest artifact_version mismatch")
|
|
if manifest["toolchain"] != entry["toolchain"]:
|
|
fail("manifest toolchain mismatch")
|
|
if manifest["git"]["revision"] != entry["git_rev"]:
|
|
fail("manifest git revision mismatch")
|
|
|
|
build_inputs = manifest["build_time_inputs"]
|
|
if build_inputs.get("source_date_epoch") != source_date_epoch:
|
|
fail("manifest source_date_epoch mismatch")
|
|
if build_inputs.get("target") != entry["target"]:
|
|
fail("manifest target mismatch")
|
|
if build_inputs.get("profile") != entry["profile"]:
|
|
fail("manifest profile mismatch")
|
|
|
|
if manifest.get("artifact", {}).get("sha256") != checksums.get("artifact_sha256"):
|
|
fail("manifest artifact sha256 must match checksums.json artifact_sha256")
|
|
|
|
provenance_file = manifest["content"].get("provenance_file")
|
|
if not provenance_file:
|
|
fail("manifest content.provenance_file missing")
|
|
provenance_path = manifest_path.parent / provenance_file
|
|
if not provenance_path.exists():
|
|
fail(f"provenance file missing: {provenance_file}")
|
|
|
|
provenance = load_json(provenance_path)
|
|
required_prov_fields = set(contract["provenance_metadata"]["required_fields"])
|
|
if not required_prov_fields <= provenance.keys():
|
|
missing = ", ".join(sorted(required_prov_fields - provenance.keys()))
|
|
fail(f"provenance missing fields: {missing}")
|
|
|
|
package_root = manifest_path.parent
|
|
verify_checksum_manifest(package_root, checksums, artifact_path)
|
|
|
|
return {
|
|
"artifact_file": entry["artifact_file"],
|
|
"artifact_sha": checksums["artifact_sha256"],
|
|
"manifest_sha": sha256_file(manifest_path),
|
|
"checksums_sha": sha256_file(checksums_path),
|
|
}
|
|
|
|
|
|
def collect_release_entries(index_payload: Dict[str, Any], version: str, dist_revision: str, toolchain: str) -> List[Dict[str, Any]]:
|
|
releases = index_payload.get("releases")
|
|
if not isinstance(releases, list):
|
|
fail("distribution index must contain releases as an array")
|
|
|
|
candidates: List[Dict[str, Any]] = []
|
|
for release in releases:
|
|
if release.get("version") != version:
|
|
continue
|
|
for target in release.get("targets", []):
|
|
for profile in target.get("profiles", []):
|
|
for artifact in profile.get("artifacts", []):
|
|
if (
|
|
artifact.get("dist_revision") == dist_revision
|
|
and artifact.get("toolchain") == toolchain
|
|
):
|
|
candidates.append(artifact)
|
|
|
|
return candidates
|
|
|
|
|
|
def run_release_cycle(
|
|
version: str,
|
|
profile: str,
|
|
target: str | None,
|
|
dist_revision: str,
|
|
source_date_epoch: str,
|
|
toolchain: str,
|
|
contract: Dict[str, Any],
|
|
config: Dict[str, Any],
|
|
) -> Dict[str, str]:
|
|
index_path = (ROOT / config["index_path_template"]).resolve()
|
|
env = os.environ.copy()
|
|
env["SOURCE_DATE_EPOCH"] = source_date_epoch
|
|
|
|
cmd = [
|
|
str(Path(sys.executable)),
|
|
str(ORCHESTRATOR_PATH),
|
|
"--version",
|
|
version,
|
|
"--profile",
|
|
profile,
|
|
"--dist-revision",
|
|
dist_revision,
|
|
"--toolchain",
|
|
toolchain,
|
|
]
|
|
if target:
|
|
cmd.extend(["--target", target])
|
|
|
|
run(cmd, env=env)
|
|
|
|
index_payload = load_json(index_path)
|
|
if index_payload.get("schema_version") != "distribution-index-v1":
|
|
fail("distribution index schema_version mismatch")
|
|
if index_payload.get("contract_version") != contract["contract_version"]:
|
|
fail("distribution index contract_version mismatch")
|
|
|
|
entries = collect_release_entries(index_payload, version, dist_revision, toolchain)
|
|
if not entries:
|
|
fail("no release entries produced for deterministic gate run")
|
|
|
|
state: Dict[str, Dict[str, str]] = {}
|
|
for entry in entries:
|
|
artifact_file = entry.get("artifact_file")
|
|
if not artifact_file:
|
|
fail("index entry missing artifact_file")
|
|
state[artifact_file] = validate_artifact_entry(entry, contract, source_date_epoch)
|
|
|
|
return {k: v["artifact_sha"] for k, v in sorted(state.items())}
|
|
|
|
|
|
def parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(description="Distribution contract CI gate")
|
|
parser.add_argument("--version", default=None, help="artifact version override")
|
|
parser.add_argument("--profile", default="release", help="cargo profile")
|
|
parser.add_argument("--target", default=None, help="target triple (optional)")
|
|
parser.add_argument("--dist-revision", default="r1-ci", help="distribution revision")
|
|
parser.add_argument("--source-date-epoch", default="1700000000", help="SOURCE_DATE_EPOCH")
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = parse_args()
|
|
contract = load_json(CONTRACT_PATH)
|
|
config = load_json(CONFIG_PATH)
|
|
|
|
validate_contract(contract)
|
|
validate_docs_and_changelog()
|
|
|
|
version = args.version or read_version_from_cargo()
|
|
profile = args.profile
|
|
target = args.target
|
|
dist_revision = args.dist_revision
|
|
source_date_epoch = args.source_date_epoch
|
|
toolchain = run(["rustc", "--version"], cwd=ROOT)
|
|
|
|
print("distribution contract gate: running first deterministic build")
|
|
first = run_release_cycle(version, profile, target, dist_revision, source_date_epoch, toolchain, contract, config)
|
|
|
|
print("distribution contract gate: running second deterministic build")
|
|
second = run_release_cycle(version, profile, target, dist_revision, source_date_epoch, toolchain, contract, config)
|
|
|
|
if first != second:
|
|
fail("artifact checksum drift detected between repeated release generations")
|
|
|
|
print("distribution contract gate: deterministic artifact checksums match")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|