Extend the existing release gates and ship an image Compose bundle. Refs QRun-IO/qqq#921.
297 lines
14 KiB
Python
297 lines
14 KiB
Python
"""Fail-closed checks for the manual 4.1.0 GA / exact RC.1 release workflow."""
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import re
|
|
import sys
|
|
import urllib.error
|
|
import urllib.request
|
|
from pathlib import Path
|
|
from xml.etree import ElementTree
|
|
|
|
|
|
QBITS = (
|
|
"quick-search", "user-role-permissions", "customizable-table-views",
|
|
"standard-process-trace", "webhooks", "workflows", "geo-data",
|
|
"sftp-data-integration",
|
|
)
|
|
RELEASE_VERSIONS = ("4.1.0", "4.1.0-RC.1")
|
|
|
|
|
|
def immutable_version(value):
|
|
return re.fullmatch(r"[0-9]+(?:\.[0-9]+){2,3}(?:-RC\.[1-9][0-9]*)?", value) is not None
|
|
|
|
|
|
def validate_version(version):
|
|
if version not in RELEASE_VERSIONS:
|
|
raise GateError("This release workflow is limited to 4.1.0 and 4.1.0-RC.1")
|
|
|
|
|
|
class GateError(ValueError):
|
|
pass
|
|
|
|
|
|
def properties(path):
|
|
root = ElementTree.parse(path).getroot()
|
|
namespace = xml_namespace(root)
|
|
values = root.find(f"{namespace}properties")
|
|
if values is None:
|
|
raise GateError(f"Missing Maven properties in {path}")
|
|
return {child.tag.removeprefix(namespace): (child.text or "").strip() for child in values}
|
|
|
|
|
|
def xml_namespace(root):
|
|
return root.tag.partition("}")[0] + "}" if root.tag.startswith("{") else ""
|
|
|
|
|
|
def coordinates(path):
|
|
root = ElementTree.parse(path).getroot()
|
|
namespace = xml_namespace(root)
|
|
parent = root.find(f"{namespace}parent")
|
|
|
|
def value(name):
|
|
direct = root.findtext(f"{namespace}{name}")
|
|
inherited = parent.findtext(f"{namespace}{name}") if parent is not None else None
|
|
return direct or inherited
|
|
|
|
return value("groupId"), value("artifactId"), value("version")
|
|
|
|
|
|
def managed_dependencies(path):
|
|
root = ElementTree.parse(path).getroot()
|
|
namespace = xml_namespace(root)
|
|
values = properties(path)
|
|
if root.findall(f"{namespace}profiles/{namespace}profile/{namespace}dependencyManagement"):
|
|
raise GateError(f"Profile-managed dependency override in {path}")
|
|
dependencies = root.find(f"{namespace}dependencyManagement/{namespace}dependencies")
|
|
if dependencies is None:
|
|
raise GateError(f"Missing dependencyManagement in {path}")
|
|
|
|
def field(dependency, name, default=""):
|
|
return (dependency.findtext(f"{namespace}{name}") or default).strip()
|
|
|
|
managed = []
|
|
for dependency in dependencies.findall(f"{namespace}dependency"):
|
|
version = field(dependency, "version")
|
|
reference = re.fullmatch(r"\$\{([^}]+)\}", version)
|
|
if reference:
|
|
version = values.get(reference.group(1), "")
|
|
if not immutable_version(version):
|
|
raise GateError(f"Unreleased managed dependency in {path}: "
|
|
f"{field(dependency, 'artifactId')}")
|
|
managed.append((field(dependency, "groupId"), field(dependency, "artifactId"),
|
|
version, field(dependency, "type", "jar"),
|
|
field(dependency, "scope", "compile"),
|
|
field(dependency, "classifier")))
|
|
if not managed:
|
|
raise GateError(f"No managed dependencies in {path}")
|
|
return managed
|
|
|
|
|
|
def validate_managed_bom(path, version):
|
|
managed = managed_dependencies(path)
|
|
entries = {(group, artifact): (pin, kind, scope) for group, artifact, pin, kind, scope, _ in managed}
|
|
if len(entries) != len(managed):
|
|
raise GateError(f"Duplicate managed dependency in {path}")
|
|
if entries.get(("com.kingsrook.qqq", "qqq-bom-pom")) != (version, "pom", "import"):
|
|
raise GateError(f"QQQ BOM import differs from {version} in {path}")
|
|
values = properties(path)
|
|
if entries.get(("com.kingsrook.qqq", "qqq-frontend-next")) != (
|
|
values.get("qqq-frontend-next.version"), "jar", "compile"):
|
|
raise GateError(f"Managed Next differs from its release pin in {path}")
|
|
for name in QBITS:
|
|
artifact = f"qbit-{name}"
|
|
if entries.get(("com.kingsrook.qbits", artifact)) != (
|
|
values.get(f"{artifact}.version"), "jar", "compile"):
|
|
raise GateError(f"Managed {artifact} differs from its release pin in {path}")
|
|
return managed
|
|
|
|
|
|
def validate_published_poms(parent_path, bom_path, version, local_bom):
|
|
expected = (
|
|
(parent_path, "qqq-all-parent"),
|
|
(bom_path, "qqq-all-bom"),
|
|
)
|
|
for path, artifact in expected:
|
|
if coordinates(path) != ("com.kingsrook.qqq", artifact, version):
|
|
raise GateError(f"Published {artifact} POM is not the {version} release")
|
|
published = properties(bom_path)
|
|
if published.get("qqq.version") != version:
|
|
raise GateError(f"Published BOM does not import QQQ {version}")
|
|
for name in QBITS:
|
|
key = f"qbit-{name}.version"
|
|
value = published.get(key, "")
|
|
if not immutable_version(value):
|
|
raise GateError(f"Published BOM {key} is not a release")
|
|
local = properties(local_bom)
|
|
for key in ("qqq.version", "qqq-frontend-next.version", *(f"qbit-{name}.version" for name in QBITS)):
|
|
if published.get(key) != local.get(key):
|
|
raise GateError(f"Published BOM {key} differs from release source")
|
|
if validate_managed_bom(bom_path, version) != validate_managed_bom(local_bom, version):
|
|
raise GateError("Published BOM dependencyManagement differs from release source")
|
|
|
|
|
|
def validate_poms(root, version):
|
|
validate_version(version)
|
|
parent = properties(root / "pom.xml")
|
|
bom = properties(root / "qqq-all-bom/pom.xml")
|
|
if parent.get("revision") != version or bom.get("qqq.version") != version:
|
|
raise GateError(f"Parent revision and QQQ BOM pin must both equal {version}")
|
|
for name in QBITS:
|
|
key = f"qbit-{name}.version"
|
|
value = bom.get(key, "")
|
|
if not immutable_version(value):
|
|
raise GateError(f"{key} must be a published non-snapshot version")
|
|
for key, value in bom.items():
|
|
if key.endswith(".version") and not immutable_version(value):
|
|
raise GateError(f"{key} must be an immutable release version")
|
|
validate_managed_bom(root / "qqq-all-bom/pom.xml", version)
|
|
|
|
|
|
def validate_smoke(run, jobs, sha):
|
|
if not re.fullmatch(r"[0-9a-f]{40}", sha):
|
|
raise GateError("Release SHA must be a full lowercase Git commit ID")
|
|
expected = {
|
|
"name": "Smoke", "path": ".github/workflows/smoke.yml",
|
|
"head_sha": sha, "head_branch": "develop", "event": "push",
|
|
"conclusion": "success",
|
|
}
|
|
for key, value in expected.items():
|
|
if run.get(key) != value:
|
|
raise GateError(f"Smoke run {key} must equal {value}")
|
|
observed = {"core": False, "full": False}
|
|
for job in jobs.get("jobs", []):
|
|
name = job.get("name")
|
|
if name in observed:
|
|
if job.get("conclusion") != "success":
|
|
raise GateError(f"Smoke job {name} did not pass")
|
|
observed[name] = True
|
|
if not all(observed.values()):
|
|
raise GateError("Smoke run must include successful core and full jobs")
|
|
|
|
|
|
def validate_upstream_release(release, version):
|
|
if (not immutable_version(version) or release.get("tag_name") != "v" + version
|
|
or release.get("draft") is not False
|
|
or release.get("prerelease") is not ("-RC." in version)):
|
|
raise GateError(f"Upstream release must be public v{version} with matching prerelease state")
|
|
|
|
|
|
def validate_tag(tag, version, sha, tag_sha):
|
|
validate_version(version)
|
|
if not all(re.fullmatch(r"[0-9a-f]{40}", value) for value in (sha, tag_sha)):
|
|
raise GateError("Tag and commit IDs must be full Git SHAs")
|
|
if (tag.get("sha") != tag_sha or tag.get("tag") != version
|
|
or tag.get("object", {}).get("type") != "commit"
|
|
or tag.get("object", {}).get("sha") != sha
|
|
or tag.get("verification", {}).get("verified") is not True
|
|
or tag.get("verification", {}).get("reason") != "valid"):
|
|
raise GateError("Release tag must be signed, verified, annotated and point to the exact release commit")
|
|
|
|
|
|
def download_public_inputs(bom, version, destination, central="https://repo.maven.apache.org/maven2"):
|
|
"""Fetch the declared first-party inputs and their parents without credentials."""
|
|
validate_version(version)
|
|
destination.mkdir(parents=True, exist_ok=True)
|
|
receipts = []
|
|
visited = set()
|
|
|
|
def fetch(group, artifact, pin, kind):
|
|
coordinate = (group, artifact, pin, kind)
|
|
if coordinate in visited:
|
|
return
|
|
if (not re.fullmatch(r"[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*", group)
|
|
or not re.fullmatch(r"[A-Za-z0-9_-]+", artifact) or not immutable_version(pin)):
|
|
raise GateError(f"Invalid or mutable public coordinate: {coordinate}")
|
|
visited.add(coordinate)
|
|
relative = f"{group.replace('.', '/')}/{artifact}/{pin}/{artifact}-{pin}.{kind}"
|
|
url = central + "/" + relative
|
|
try:
|
|
with urllib.request.urlopen(url, timeout=30) as response:
|
|
data = response.read()
|
|
except (urllib.error.URLError, OSError) as error:
|
|
if isinstance(error, urllib.error.HTTPError):
|
|
error.close()
|
|
raise GateError(f"Public input unavailable: {relative}") from error
|
|
if not data:
|
|
raise GateError(f"Empty public input: {relative}")
|
|
path = destination / relative
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_bytes(data)
|
|
if kind == "pom":
|
|
if coordinates(path) != (group, artifact, pin):
|
|
raise GateError(f"Public POM coordinates differ: {relative}")
|
|
root = ElementTree.fromstring(data)
|
|
ns = xml_namespace(root)
|
|
parent = root.find(f"{ns}parent")
|
|
if parent is not None:
|
|
parent_group = parent.findtext(f"{ns}groupId", "")
|
|
parent_pin = parent.findtext(f"{ns}version", "")
|
|
if not immutable_version(parent_pin):
|
|
raise GateError(f"Mutable parent in {relative}")
|
|
if parent_group.startswith("com.kingsrook"):
|
|
fetch(parent_group, parent.findtext(f"{ns}artifactId", ""), parent_pin, "pom")
|
|
if artifact == "qbit-build-parent":
|
|
imports = root.findall(f"{ns}dependencyManagement/{ns}dependencies/{ns}dependency")
|
|
props = root.find(f"{ns}properties")
|
|
values = {child.tag.removeprefix(ns): child.text for child in props} if props is not None else {}
|
|
qqq_imports = [dep for dep in imports if dep.findtext(f"{ns}groupId") == "com.kingsrook.qqq"
|
|
and dep.findtext(f"{ns}artifactId") == "qqq-bom-pom"]
|
|
if len(qqq_imports) != 1:
|
|
raise GateError("Public qbit-build-parent must import the candidate QQQ BOM")
|
|
dep = qqq_imports[0]
|
|
actual = dep.findtext(f"{ns}version", "")
|
|
actual = values.get(actual[2:-1], "") if actual.startswith("${") else actual
|
|
if (actual != version or dep.findtext(f"{ns}type") != "pom"
|
|
or dep.findtext(f"{ns}scope") != "import"):
|
|
raise GateError("Public qbit-build-parent QQQ import differs from the candidate")
|
|
receipts.append({"url": url, "sha256": hashlib.sha256(data).hexdigest(), "bytes": len(data)})
|
|
|
|
for group, artifact, pin, kind, _, classifier in validate_managed_bom(bom, version):
|
|
if group.startswith("com.kingsrook"):
|
|
if classifier or kind not in ("jar", "pom"):
|
|
raise GateError(f"Unsupported first-party release input: {artifact}")
|
|
fetch(group, artifact, pin, "pom")
|
|
if kind == "jar":
|
|
fetch(group, artifact, pin, "jar")
|
|
(destination / "receipts.json").write_text(json.dumps(receipts, indent=2) + "\n")
|
|
return receipts
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument("--root", type=Path, required=True)
|
|
parser.add_argument("--version", required=True)
|
|
parser.add_argument("--sha", required=True)
|
|
parser.add_argument("--smoke-run", type=Path, required=True)
|
|
parser.add_argument("--smoke-jobs", type=Path, required=True)
|
|
parser.add_argument("--published-parent", type=Path, required=True)
|
|
parser.add_argument("--published-bom", type=Path, required=True)
|
|
parser.add_argument("--upstream-release", type=Path, required=True)
|
|
parser.add_argument("--next-release", type=Path, required=True)
|
|
parser.add_argument("--tag-object", type=Path, required=True)
|
|
parser.add_argument("--tag-sha", required=True)
|
|
parser.add_argument("--public-inputs", type=Path, required=True)
|
|
args = parser.parse_args()
|
|
try:
|
|
validate_poms(args.root, args.version)
|
|
validate_upstream_release(json.loads(args.upstream_release.read_text()), args.version)
|
|
validate_upstream_release(json.loads(args.next_release.read_text()),
|
|
properties(args.root / "qqq-all-bom/pom.xml")["qqq-frontend-next.version"])
|
|
validate_tag(json.loads(args.tag_object.read_text()), args.version, args.sha, args.tag_sha)
|
|
validate_smoke(json.loads(args.smoke_run.read_text()),
|
|
json.loads(args.smoke_jobs.read_text()), args.sha)
|
|
validate_published_poms(args.published_parent, args.published_bom, args.version,
|
|
args.root / "qqq-all-bom/pom.xml")
|
|
download_public_inputs(args.root / "qqq-all-bom/pom.xml", args.version, args.public_inputs)
|
|
except (GateError, OSError, ElementTree.ParseError, json.JSONDecodeError) as error:
|
|
print(f"Release gate failed: {error}", file=sys.stderr)
|
|
return 1
|
|
print("Local POM and exact-commit smoke gates passed")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|