#!/usr/bin/env python3 """Create a deterministic GuestOps source package from one committed Git tree.""" from __future__ import annotations import argparse import gzip import hashlib import io import json from pathlib import Path import re import subprocess import sys import xml.etree.ElementTree as ET SEMVER = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+") FULL_SHA = re.compile(r"[0-9a-f]{40}") def git(repository: Path, *arguments: str, binary: bool = False) -> bytes | str: result = subprocess.run( ["git", "-C", str(repository), *arguments], check=True, capture_output=True, text=not binary, ) return result.stdout if binary else result.stdout.strip() def committed_text(repository: Path, commit: str, path: str) -> str: return str(git(repository, "show", f"{commit}:{path}")) def versions(repository: Path, commit: str) -> tuple[str, str]: props = ET.fromstring(committed_text(repository, commit, "Directory.Build.props")) version_node = props.find(".//Version") if version_node is None or not version_node.text: raise ValueError("The committed Directory.Build.props has no Version element.") dotnet_version = version_node.text.strip() package = json.loads(committed_text(repository, commit, "web/package.json")) web_version = str(package.get("version", "")) return dotnet_version, web_version def sha256_bytes(value: bytes) -> str: return hashlib.sha256(value).hexdigest() def build_package(repository: Path, ref: str, output_directory: Path) -> tuple[Path, Path, dict[str, object]]: repository = repository.resolve() output_directory = output_directory.resolve() if not (repository / ".git").exists(): raise ValueError("--repository must be a Git working tree.") commit = str(git(repository, "rev-parse", "--verify", f"{ref}^{{commit}}")).lower() if FULL_SHA.fullmatch(commit) is None: raise ValueError("The selected ref did not resolve to a full Git commit SHA.") dotnet_version, web_version = versions(repository, commit) if dotnet_version != web_version: raise ValueError( f"Committed release versions differ: .NET={dotnet_version}, web={web_version}." ) if SEMVER.fullmatch(dotnet_version) is None: raise ValueError("The committed version must use MAJOR.MINOR.PATCH.") prefix = f"GuestOps-{dotnet_version}/" tar_bytes = bytes( git( repository, "archive", "--format=tar", f"--prefix={prefix}", commit, binary=True, ) ) compressed = io.BytesIO() with gzip.GzipFile(fileobj=compressed, mode="wb", filename="", mtime=0) as stream: stream.write(tar_bytes) archive_bytes = compressed.getvalue() stem = f"GuestOps-{dotnet_version}-{commit[:12]}" archive = output_directory / f"{stem}.tar.gz" record_path = output_directory / f"{stem}.source.json" if archive.exists() or record_path.exists(): raise ValueError("The output package or source record already exists; releases are not overwritten.") output_directory.mkdir(parents=True, exist_ok=True) archive.write_bytes(archive_bytes) record: dict[str, object] = { "artifact": { "name": archive.name, "sha256": sha256_bytes(archive_bytes), "size": len(archive_bytes), }, "commit": commit, "schemaVersion": 1, "version": dotnet_version, } record_path.write_text( json.dumps(record, indent=2, sort_keys=True) + "\n", encoding="utf-8" ) return archive, record_path, record def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--ref", required=True, help="Exact tag, branch, or full commit to package.") parser.add_argument("--repository", type=Path, default=Path(__file__).resolve().parents[1]) parser.add_argument("--output-directory", required=True, type=Path) args = parser.parse_args() try: archive, record, details = build_package(args.repository, args.ref, args.output_directory) except (OSError, ValueError, ET.ParseError, json.JSONDecodeError, subprocess.SubprocessError) as error: print(f"Source packaging failed: {error}", file=sys.stderr) raise SystemExit(1) print(json.dumps({"archive": str(archive), "record": str(record), **details}, indent=2)) if __name__ == "__main__": main()