GuestOps/deploy/package_source.py

125 lines
4.4 KiB
Python

#!/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()