125 lines
4.4 KiB
Python
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()
|