feat(release): add reproducible signed artifacts (#19)
This commit is contained in:
@@ -0,0 +1,823 @@
|
||||
#!/usr/bin/env python3
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from xml.sax.saxutils import escape
|
||||
|
||||
|
||||
PROJECT_PACKAGE_PREFIX = "finalfactory.rendezvous."
|
||||
CORE_PROPERTIES_PATH = (
|
||||
"package/services/metadata/core-properties/core-properties.psmdcp"
|
||||
)
|
||||
|
||||
|
||||
def fail(message: str) -> None:
|
||||
raise SystemExit(message)
|
||||
|
||||
|
||||
def load_json(path: pathlib.Path):
|
||||
with path.open(encoding="utf-8") as stream:
|
||||
return json.load(stream)
|
||||
|
||||
|
||||
def dependency_inventory(root: pathlib.Path):
|
||||
dependencies = {}
|
||||
for lock_path in sorted(root.glob("**/packages.lock.json")):
|
||||
if any(part in {"bin", "obj", "artifacts"} for part in lock_path.parts):
|
||||
continue
|
||||
lock = load_json(lock_path)
|
||||
for framework in lock.get("dependencies", {}).values():
|
||||
for package_id, details in framework.items():
|
||||
resolved = details.get("resolved")
|
||||
if not resolved or package_id.lower().startswith(PROJECT_PACKAGE_PREFIX):
|
||||
continue
|
||||
key = package_id.lower()
|
||||
previous = dependencies.get(key)
|
||||
if previous is not None and previous[1] != resolved:
|
||||
fail(
|
||||
f"Dependency {package_id} resolves to both {previous[1]} and {resolved}."
|
||||
)
|
||||
dependencies[key] = (package_id, resolved)
|
||||
return [dependencies[key] for key in sorted(dependencies)]
|
||||
|
||||
|
||||
def package_license(package_id: str, version: str, overrides):
|
||||
package_root = pathlib.Path(
|
||||
os.environ.get("NUGET_PACKAGES", pathlib.Path.home() / ".nuget" / "packages")
|
||||
)
|
||||
version_dir = package_root / package_id.lower() / version
|
||||
nuspecs = list(version_dir.glob("*.nuspec"))
|
||||
if len(nuspecs) != 1:
|
||||
fail(f"Expected one restored nuspec for {package_id} {version} in {version_dir}.")
|
||||
root = ET.parse(nuspecs[0]).getroot()
|
||||
license_element = root.find(".//{*}license")
|
||||
if license_element is None or license_element.get("type") != "expression":
|
||||
override = overrides.get(f"{package_id.lower()}/{version}")
|
||||
if override is None:
|
||||
fail(f"{package_id} {version} does not declare an SPDX license expression.")
|
||||
return override["license"]
|
||||
expression = (license_element.text or "").strip()
|
||||
if not expression:
|
||||
fail(f"{package_id} {version} has an empty license expression.")
|
||||
return expression
|
||||
|
||||
|
||||
def command_policy(args) -> None:
|
||||
root = pathlib.Path(args.root).resolve()
|
||||
policy = load_json(root / "eng" / "release-policy.json")
|
||||
allowed = set(policy["allowedLicenseExpressions"])
|
||||
inventory = dependency_inventory(root)
|
||||
observed = []
|
||||
for package_id, version in inventory:
|
||||
expression = package_license(
|
||||
package_id, version, policy.get("dependencyLicenseOverrides", {})
|
||||
)
|
||||
if expression not in allowed:
|
||||
fail(
|
||||
f"Dependency {package_id} {version} uses unapproved license {expression}."
|
||||
)
|
||||
observed.append({"id": package_id, "version": version, "license": expression})
|
||||
lite_net_lib = [item for item in observed if item["id"].lower() == "litenetlib"]
|
||||
if lite_net_lib != [{"id": "LiteNetLib", "version": "2.1.4", "license": "MIT"}]:
|
||||
fail(f"LiteNetLib must resolve exactly to the reviewed 2.1.4 release: {lite_net_lib}")
|
||||
print(json.dumps({"dependencies": observed}, indent=2))
|
||||
|
||||
|
||||
def command_audit(args) -> None:
|
||||
document = load_json(pathlib.Path(args.input))
|
||||
findings = []
|
||||
|
||||
def visit(value):
|
||||
if isinstance(value, dict):
|
||||
if value.get("vulnerabilities"):
|
||||
findings.append(value)
|
||||
for child in value.values():
|
||||
visit(child)
|
||||
elif isinstance(value, list):
|
||||
for child in value:
|
||||
visit(child)
|
||||
|
||||
visit(document)
|
||||
if findings:
|
||||
fail(f"Locked dependency graph contains known vulnerabilities: {findings}")
|
||||
print("Locked dependency graph has no reported vulnerabilities.")
|
||||
|
||||
|
||||
def release_dependency_graph(root: pathlib.Path, server_deps: pathlib.Path):
|
||||
dependencies = {}
|
||||
edges = set()
|
||||
server_document = load_json(server_deps)
|
||||
for package_key, details in server_document.get("libraries", {}).items():
|
||||
if details.get("type") != "package":
|
||||
continue
|
||||
package_id, version = package_key.rsplit("/", 1)
|
||||
dependencies[package_id.lower()] = (package_id, version)
|
||||
server_target = next(iter(server_document.get("targets", {}).values()), {})
|
||||
for source_key, details in server_target.items():
|
||||
source_id = source_key.rsplit("/", 1)[0]
|
||||
source = (
|
||||
"SPDXRef-Server"
|
||||
if source_id == "FinalFactory.Rendezvous.Server"
|
||||
else source_id.lower()
|
||||
)
|
||||
for dependency_id in details.get("dependencies", {}):
|
||||
target = {
|
||||
"FinalFactory.Rendezvous.Contracts": "SPDXRef-Contracts",
|
||||
"FinalFactory.Rendezvous.Client": "SPDXRef-Client",
|
||||
}.get(dependency_id, dependency_id.lower())
|
||||
edges.add((source, target))
|
||||
|
||||
lock_roots = (
|
||||
("SPDXRef-Client", root / "src/FinalFactory.Rendezvous.Client/packages.lock.json"),
|
||||
(
|
||||
"SPDXRef-Contracts",
|
||||
root / "src/FinalFactory.Rendezvous.Contracts/packages.lock.json",
|
||||
),
|
||||
)
|
||||
for root_id, lock_path in lock_roots:
|
||||
lock = load_json(lock_path)
|
||||
for framework in lock.get("dependencies", {}).values():
|
||||
for package_id, details in framework.items():
|
||||
resolved = details.get("resolved")
|
||||
if resolved and not package_id.lower().startswith(PROJECT_PACKAGE_PREFIX):
|
||||
dependencies[package_id.lower()] = (package_id, resolved)
|
||||
if details.get("type") == "Direct":
|
||||
edges.add((root_id, package_id.lower()))
|
||||
source = package_id.lower()
|
||||
for dependency_id in details.get("dependencies", {}):
|
||||
if dependency_id.lower().startswith(PROJECT_PACKAGE_PREFIX):
|
||||
continue
|
||||
edges.add((source, dependency_id.lower()))
|
||||
edges.add(("SPDXRef-Client", "SPDXRef-Contracts"))
|
||||
inventory = [dependencies[key] for key in sorted(dependencies)]
|
||||
return inventory, edges
|
||||
|
||||
|
||||
def command_sbom(args) -> None:
|
||||
root = pathlib.Path(args.root).resolve()
|
||||
policy = load_json(root / "eng" / "release-policy.json")
|
||||
overrides = policy.get("dependencyLicenseOverrides", {})
|
||||
packages = [
|
||||
{
|
||||
"SPDXID": "SPDXRef-Rendezvous",
|
||||
"name": "FinalFactory.Rendezvous",
|
||||
"versionInfo": args.version,
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": False,
|
||||
"licenseConcluded": "NOASSERTION",
|
||||
"licenseDeclared": "NOASSERTION",
|
||||
"copyrightText": "NOASSERTION",
|
||||
}
|
||||
]
|
||||
internal_packages = [
|
||||
("SPDXRef-Server", "FinalFactory.Rendezvous.Server"),
|
||||
("SPDXRef-Client", "FinalFactory.Rendezvous.Client"),
|
||||
("SPDXRef-Contracts", "FinalFactory.Rendezvous.Contracts"),
|
||||
]
|
||||
for spdx_id, package_id in internal_packages:
|
||||
packages.append(
|
||||
{
|
||||
"SPDXID": spdx_id,
|
||||
"name": package_id,
|
||||
"versionInfo": args.version,
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": False,
|
||||
"licenseConcluded": "NOASSERTION",
|
||||
"licenseDeclared": "NOASSERTION",
|
||||
"copyrightText": "NOASSERTION",
|
||||
}
|
||||
)
|
||||
inventory, dependency_edges = release_dependency_graph(
|
||||
root, pathlib.Path(args.server_deps)
|
||||
)
|
||||
dependency_ids = {}
|
||||
for index, (package_id, version) in enumerate(
|
||||
inventory, start=1
|
||||
):
|
||||
expression = package_license(package_id, version, overrides)
|
||||
spdx_id = f"SPDXRef-Package-{index}"
|
||||
dependency_ids[package_id.lower()] = spdx_id
|
||||
packages.append(
|
||||
{
|
||||
"SPDXID": spdx_id,
|
||||
"name": package_id,
|
||||
"versionInfo": version,
|
||||
"downloadLocation": "NOASSERTION",
|
||||
"filesAnalyzed": False,
|
||||
"licenseConcluded": expression,
|
||||
"licenseDeclared": expression,
|
||||
"copyrightText": "NOASSERTION",
|
||||
"externalRefs": [
|
||||
{
|
||||
"referenceCategory": "PACKAGE-MANAGER",
|
||||
"referenceType": "purl",
|
||||
"referenceLocator": f"pkg:nuget/{package_id}@{version}",
|
||||
}
|
||||
],
|
||||
}
|
||||
)
|
||||
created = dt.datetime.fromtimestamp(
|
||||
int(args.source_date_epoch), dt.timezone.utc
|
||||
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
document = {
|
||||
"spdxVersion": "SPDX-2.3",
|
||||
"dataLicense": "CC0-1.0",
|
||||
"SPDXID": "SPDXRef-DOCUMENT",
|
||||
"name": f"FinalFactory.Rendezvous-{args.version}",
|
||||
"documentNamespace": (
|
||||
"https://git.finalfactory.de/HeiKyu/Rendezvous/sbom/"
|
||||
f"{args.version}/{args.commit}"
|
||||
),
|
||||
"creationInfo": {
|
||||
"created": created,
|
||||
"creators": ["Tool: eng/release_artifacts.py"],
|
||||
},
|
||||
"documentDescribes": ["SPDXRef-Rendezvous"],
|
||||
"packages": packages,
|
||||
"relationships": [
|
||||
{
|
||||
"spdxElementId": "SPDXRef-Rendezvous",
|
||||
"relationshipType": "CONTAINS",
|
||||
"relatedSpdxElement": spdx_id,
|
||||
}
|
||||
for spdx_id, _ in internal_packages
|
||||
]
|
||||
+ [
|
||||
{
|
||||
"spdxElementId": dependency_ids.get(source, source),
|
||||
"relationshipType": "DEPENDS_ON",
|
||||
"relatedSpdxElement": dependency_ids.get(target, target),
|
||||
}
|
||||
for source, target in sorted(dependency_edges)
|
||||
if source in dependency_ids or source.startswith("SPDXRef-")
|
||||
if target in dependency_ids or target.startswith("SPDXRef-")
|
||||
],
|
||||
}
|
||||
output = pathlib.Path(args.output)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
output.write_text(json.dumps(document, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def nuspec_metadata(archive: pathlib.Path):
|
||||
with zipfile.ZipFile(archive) as package:
|
||||
names = package.namelist()
|
||||
nuspecs = [name for name in names if name.endswith(".nuspec")]
|
||||
if len(nuspecs) != 1:
|
||||
fail(f"{archive.name} must contain exactly one nuspec.")
|
||||
root = ET.fromstring(package.read(nuspecs[0]))
|
||||
metadata = root.find(".//{*}metadata")
|
||||
if metadata is None:
|
||||
fail(f"{archive.name} has no package metadata.")
|
||||
values = {
|
||||
child.tag.rsplit("}", 1)[-1]: (child.text or "").strip()
|
||||
for child in metadata
|
||||
if len(child) == 0
|
||||
}
|
||||
dependencies = {
|
||||
item.get("id"): item.get("version")
|
||||
for item in metadata.findall(".//{*}dependency")
|
||||
}
|
||||
repository = metadata.find("./{*}repository")
|
||||
repository_attributes = {} if repository is None else dict(repository.attrib)
|
||||
return values, dependencies, repository_attributes
|
||||
|
||||
|
||||
def verify_checksum_file(release_dir: pathlib.Path, excluded=()) -> None:
|
||||
checksum_path = release_dir / "checksums.sha256"
|
||||
lines = checksum_path.read_text(encoding="utf-8").splitlines()
|
||||
if not lines:
|
||||
fail("checksums.sha256 is empty.")
|
||||
referenced = set()
|
||||
for line in lines:
|
||||
digest, marker, relative = line.partition(" ")
|
||||
if marker != " " or not re.fullmatch(r"[0-9a-f]{64}", digest):
|
||||
fail(f"Malformed checksum line: {line}")
|
||||
target = release_dir / relative
|
||||
if not target.is_file():
|
||||
fail(f"Checksum references missing artifact: {relative}")
|
||||
actual = hashlib.sha256(target.read_bytes()).hexdigest()
|
||||
if actual != digest:
|
||||
fail(f"Checksum mismatch for {relative}.")
|
||||
referenced.add(relative)
|
||||
expected = {
|
||||
path.name
|
||||
for path in release_dir.iterdir()
|
||||
if path.is_file()
|
||||
and path.name != "checksums.sha256"
|
||||
and path.name not in excluded
|
||||
}
|
||||
if referenced != expected:
|
||||
fail(
|
||||
"Checksum manifest coverage differs. "
|
||||
f"Missing={expected - referenced}; extra={referenced - expected}"
|
||||
)
|
||||
|
||||
|
||||
def command_verify(args) -> None:
|
||||
release_dir = pathlib.Path(args.release_dir).resolve()
|
||||
version = args.version
|
||||
expected = {
|
||||
f"FinalFactory.Rendezvous.Client.{version}.nupkg",
|
||||
f"FinalFactory.Rendezvous.Client.{version}.snupkg",
|
||||
f"FinalFactory.Rendezvous.Contracts.{version}.nupkg",
|
||||
f"FinalFactory.Rendezvous.Contracts.{version}.snupkg",
|
||||
f"FinalFactory.Rendezvous.Server.{version}.linux-x64.tar.gz",
|
||||
f"FinalFactory.Rendezvous.{version}.spdx.json",
|
||||
"CHANGELOG.md",
|
||||
"checksums.sha256",
|
||||
"release-provenance.json",
|
||||
}
|
||||
checksum_exclusions = set()
|
||||
if args.phase in {"publish-ready", "signing-ready", "published"}:
|
||||
expected.add(f"FinalFactory.Rendezvous.Container.{version}.spdx.json")
|
||||
if args.phase in {"signing-ready", "published"}:
|
||||
expected.update({"container-digest.txt", "cosign.pub"})
|
||||
if args.phase == "published":
|
||||
expected.add("checksums.sha256.bundle")
|
||||
checksum_exclusions.add("checksums.sha256.bundle")
|
||||
actual = {path.name for path in release_dir.iterdir() if path.is_file()}
|
||||
if actual != expected:
|
||||
fail(f"Release artifact set differs. Missing={expected - actual}; extra={actual - expected}")
|
||||
|
||||
if args.phase in {"publish-ready", "signing-ready", "published"}:
|
||||
container_sbom = load_json(
|
||||
release_dir / f"FinalFactory.Rendezvous.Container.{version}.spdx.json"
|
||||
)
|
||||
if container_sbom.get("spdxVersion") != "SPDX-2.3":
|
||||
fail("Container inventory must be an SPDX 2.3 document.")
|
||||
if not container_sbom.get("packages"):
|
||||
fail("Container SPDX inventory contains no packages.")
|
||||
|
||||
policy = load_json(pathlib.Path(args.root) / "eng" / "release-policy.json")
|
||||
forbidden = tuple(fragment.lower() for fragment in policy["forbiddenArtifactNameFragments"])
|
||||
for artifact in actual:
|
||||
if artifact != "release-provenance.json" and any(fragment in artifact.lower() for fragment in forbidden):
|
||||
fail(f"Forbidden secret-like artifact name: {artifact}")
|
||||
|
||||
release_sbom = load_json(
|
||||
release_dir / f"FinalFactory.Rendezvous.{version}.spdx.json"
|
||||
)
|
||||
package_ids = {
|
||||
package.get("name"): package.get("SPDXID")
|
||||
for package in release_sbom.get("packages", [])
|
||||
}
|
||||
relationships = {
|
||||
(
|
||||
relationship.get("spdxElementId"),
|
||||
relationship.get("relationshipType"),
|
||||
relationship.get("relatedSpdxElement"),
|
||||
)
|
||||
for relationship in release_sbom.get("relationships", [])
|
||||
}
|
||||
expected_component_edges = {
|
||||
("SPDXRef-Server", "SPDXRef-Contracts"),
|
||||
("SPDXRef-Server", package_ids.get("LiteNetLib")),
|
||||
("SPDXRef-Server", package_ids.get("Microsoft.AspNetCore.OpenApi")),
|
||||
("SPDXRef-Client", "SPDXRef-Contracts"),
|
||||
("SPDXRef-Client", package_ids.get("LiteNetLib")),
|
||||
("SPDXRef-Contracts", package_ids.get("System.Text.Json")),
|
||||
}
|
||||
for source, target in expected_component_edges:
|
||||
if not target or (source, "DEPENDS_ON", target) not in relationships:
|
||||
fail(f"Release SPDX inventory is missing component edge {source} -> {target}.")
|
||||
bcl_id = package_ids.get("Microsoft.Bcl.AsyncInterfaces")
|
||||
if ("SPDXRef-Server", "DEPENDS_ON", bcl_id) in relationships:
|
||||
fail("Release SPDX inventory incorrectly flattens transitive dependencies onto Server.")
|
||||
|
||||
for package_id in policy["publishedPackages"]:
|
||||
package = release_dir / f"{package_id}.{version}.nupkg"
|
||||
metadata, dependencies, repository = nuspec_metadata(package)
|
||||
if metadata.get("id") != package_id or metadata.get("version") != version:
|
||||
fail(f"{package.name} identity/version metadata is incorrect.")
|
||||
if metadata.get("projectUrl") != "https://git.finalfactory.de/HeiKyu/Rendezvous":
|
||||
fail(f"{package.name} has an incorrect project URL.")
|
||||
if repository.get("url") != "https://git.finalfactory.de/HeiKyu/Rendezvous":
|
||||
fail(f"{package.name} has an incorrect repository URL.")
|
||||
if repository.get("commit") != provenance_commit(release_dir):
|
||||
fail(f"{package.name} does not identify the release commit.")
|
||||
symbol_package = release_dir / f"{package_id}.{version}.snupkg"
|
||||
with zipfile.ZipFile(symbol_package) as symbols:
|
||||
if not any(name.endswith(".pdb") for name in symbols.namelist()):
|
||||
fail(f"{symbol_package.name} contains no portable PDB.")
|
||||
with zipfile.ZipFile(package) as archive:
|
||||
names = set(archive.namelist())
|
||||
required_entries = {
|
||||
"README.md",
|
||||
"CHANGELOG.md",
|
||||
f"lib/netstandard2.1/{package_id}.dll",
|
||||
}
|
||||
if not required_entries <= names:
|
||||
fail(
|
||||
f"{package.name} is missing required package content: "
|
||||
f"{required_entries - names}"
|
||||
)
|
||||
forbidden_entries = [
|
||||
name
|
||||
for name in names
|
||||
if any(
|
||||
fragment in name.lower()
|
||||
for fragment in (
|
||||
"appsettings",
|
||||
"launchsettings",
|
||||
".env",
|
||||
"credential",
|
||||
"signing-key",
|
||||
"private-key",
|
||||
)
|
||||
)
|
||||
]
|
||||
if forbidden_entries:
|
||||
fail(
|
||||
f"{package.name} contains deployable configuration or secrets: "
|
||||
f"{forbidden_entries}"
|
||||
)
|
||||
if package_id.endswith(".Client"):
|
||||
if dependencies.get("LiteNetLib") != "[2.1.4]":
|
||||
fail(f"Client package must pin LiteNetLib exactly to 2.1.4: {dependencies}")
|
||||
contracts_range = dependencies.get("FinalFactory.Rendezvous.Contracts", "")
|
||||
if contracts_range != f"[{version}]":
|
||||
fail(f"Client package does not depend on the matching Contracts version.")
|
||||
|
||||
provenance = load_json(release_dir / "release-provenance.json")
|
||||
if provenance.get("version") != version:
|
||||
fail("Release provenance does not identify the requested version.")
|
||||
if provenance.get("treeState") != "clean" and not args.allow_dirty:
|
||||
fail("Release provenance must identify a clean tree.")
|
||||
container = provenance.get("containerImage", "")
|
||||
if container.endswith(":latest") or ":latest@" in container or f":{version}" not in container:
|
||||
fail(f"Container reference is mutable or not versioned: {container}")
|
||||
if provenance.get("containerPlatform") != "linux/amd64":
|
||||
fail("Release provenance must pin the linux/amd64 container platform.")
|
||||
for field in ("containerBaseDigests", "releaseBuilderBaseDigests"):
|
||||
images = provenance.get(field, [])
|
||||
if not images or any("@sha256:" not in image or image.endswith(":latest") for image in images):
|
||||
fail(f"Release provenance contains an unpinned build image in {field}: {images}")
|
||||
build_tools = provenance.get("buildTools", [])
|
||||
required_tool_prefixes = ("dotnet", "python=", "tar=", "gzip=", "jq=")
|
||||
observed_tools = [f"dotnet={provenance.get('dotnetSdk', '')}", *build_tools]
|
||||
for prefix in required_tool_prefixes:
|
||||
if not any(tool.startswith(prefix) for tool in observed_tools):
|
||||
fail(f"Release provenance is missing an artifact tool version: {prefix}")
|
||||
if args.phase in {"publish-ready", "signing-ready", "published"}:
|
||||
if not re.fullmatch(
|
||||
r"sha256:[0-9a-f]{64}", provenance.get("containerImageId", "")
|
||||
):
|
||||
fail("Release provenance is missing the verified local container image ID.")
|
||||
expected_namespace = (
|
||||
"https://git.finalfactory.de/HeiKyu/Rendezvous/container-sbom/"
|
||||
f"{version}/{provenance_commit(release_dir)}"
|
||||
)
|
||||
if container_sbom.get("documentNamespace") != expected_namespace:
|
||||
fail("Container SPDX inventory does not identify the release commit.")
|
||||
expected_created = dt.datetime.fromtimestamp(
|
||||
int(provenance.get("sourceDateEpoch", 0)), dt.timezone.utc
|
||||
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
if container_sbom.get("creationInfo", {}).get("created") != expected_created:
|
||||
fail("Container SPDX timestamp is not normalized to the source epoch.")
|
||||
if args.phase in {"signing-ready", "published"}:
|
||||
digest = (release_dir / "container-digest.txt").read_text(
|
||||
encoding="utf-8"
|
||||
).strip()
|
||||
if not re.fullmatch(
|
||||
r"git\.finalfactory\.de/heikyu/rendezvous@sha256:[0-9a-f]{64}", digest
|
||||
):
|
||||
fail(f"Published container digest is invalid: {digest}")
|
||||
if provenance.get("containerDigest") != digest:
|
||||
fail("Published provenance and container digest file differ.")
|
||||
for prefix in ("docker-buildx=", "buildkit="):
|
||||
if not any(tool.startswith(prefix) for tool in build_tools):
|
||||
fail(f"Published provenance is missing container tool version: {prefix}")
|
||||
verify_checksum_file(release_dir, checksum_exclusions)
|
||||
print(f"Verified {args.phase} release artifact set for {version}.")
|
||||
|
||||
|
||||
def provenance_commit(release_dir: pathlib.Path) -> str:
|
||||
provenance = load_json(release_dir / "release-provenance.json")
|
||||
commit = provenance.get("commit", "")
|
||||
if not re.fullmatch(r"[0-9a-f]{40}", commit):
|
||||
fail("Release provenance must contain a full Git commit SHA.")
|
||||
return commit
|
||||
|
||||
|
||||
def command_consumer(args) -> None:
|
||||
assets = load_json(pathlib.Path(args.assets))
|
||||
libraries = assets.get("libraries", {})
|
||||
required = {
|
||||
f"FinalFactory.Rendezvous.Client/{args.version}",
|
||||
f"FinalFactory.Rendezvous.Contracts/{args.version}",
|
||||
"LiteNetLib/2.1.4",
|
||||
}
|
||||
missing = required - set(libraries)
|
||||
if missing:
|
||||
fail(f"Consumer restore is missing exact release dependencies: {missing}")
|
||||
forbidden = [name for name in libraries if name.lower().startswith("litenetlib/1.")]
|
||||
if forbidden:
|
||||
fail(f"Consumer resolved forbidden LiteNetLib 1.x assets: {forbidden}")
|
||||
|
||||
|
||||
def command_consumer_config(args) -> None:
|
||||
local_source = escape(str(pathlib.Path(args.local_source).resolve()))
|
||||
configuration = f'''<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<clear />
|
||||
<add key="rendezvous-candidate" value="{local_source}" />
|
||||
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
|
||||
</packageSources>
|
||||
<packageSourceMapping>
|
||||
<packageSource key="rendezvous-candidate">
|
||||
<package pattern="FinalFactory.Rendezvous.*" />
|
||||
</packageSource>
|
||||
<packageSource key="nuget.org">
|
||||
<package pattern="*" />
|
||||
</packageSource>
|
||||
</packageSourceMapping>
|
||||
</configuration>
|
||||
'''
|
||||
pathlib.Path(args.output).write_text(configuration, encoding="utf-8")
|
||||
|
||||
|
||||
def command_source_link(args) -> None:
|
||||
document = load_json(pathlib.Path(args.file))
|
||||
mappings = document.get("documents", {})
|
||||
expected = (
|
||||
"https://git.finalfactory.de/HeiKyu/Rendezvous/raw/commit/"
|
||||
f"{args.commit}/"
|
||||
)
|
||||
if not mappings or any(not value.startswith(expected) for value in mappings.values()):
|
||||
fail(f"SourceLink mappings do not identify commit {args.commit}: {mappings}")
|
||||
|
||||
|
||||
def command_normalize_package(args) -> None:
|
||||
package_path = pathlib.Path(args.package).resolve()
|
||||
if package_path.suffix not in {".nupkg", ".snupkg"}:
|
||||
fail(f"Unsupported NuGet archive extension: {package_path.name}")
|
||||
timestamp = dt.datetime.fromtimestamp(
|
||||
int(args.source_date_epoch), dt.timezone.utc
|
||||
)
|
||||
zip_timestamp = (
|
||||
max(timestamp.year, 1980),
|
||||
timestamp.month,
|
||||
timestamp.day,
|
||||
timestamp.hour,
|
||||
timestamp.minute,
|
||||
timestamp.second - (timestamp.second % 2),
|
||||
)
|
||||
with zipfile.ZipFile(package_path) as source:
|
||||
entries = {name: source.read(name) for name in source.namelist()}
|
||||
if ".signature.p7s" in entries:
|
||||
fail(f"Refusing to rewrite signed package: {package_path.name}")
|
||||
core_paths = [
|
||||
name
|
||||
for name in entries
|
||||
if name.startswith("package/services/metadata/core-properties/")
|
||||
and name.endswith(".psmdcp")
|
||||
]
|
||||
if len(core_paths) != 1:
|
||||
fail(f"Expected one NuGet core-properties part in {package_path.name}.")
|
||||
entries[CORE_PROPERTIES_PATH] = entries.pop(core_paths[0])
|
||||
|
||||
nuspec_paths = [name for name in entries if name.endswith(".nuspec")]
|
||||
if len(nuspec_paths) != 1:
|
||||
fail(f"Expected one nuspec in {package_path.name}.")
|
||||
nuspec = ET.fromstring(entries[nuspec_paths[0]])
|
||||
nuspec_namespace = nuspec.tag.partition("}")[0].removeprefix("{")
|
||||
if nuspec_namespace:
|
||||
ET.register_namespace("", nuspec_namespace)
|
||||
package_id = nuspec.findtext(".//{*}id")
|
||||
if package_id == "FinalFactory.Rendezvous.Client":
|
||||
contracts = nuspec.find(
|
||||
".//{*}dependency[@id='FinalFactory.Rendezvous.Contracts']"
|
||||
)
|
||||
if contracts is None:
|
||||
fail("Client package has no Contracts dependency to pin.")
|
||||
contracts.set("version", f"[{args.version}]")
|
||||
entries[nuspec_paths[0]] = ET.tostring(
|
||||
nuspec, encoding="utf-8", xml_declaration=True
|
||||
)
|
||||
|
||||
relationships_namespace = (
|
||||
"http://schemas.openxmlformats.org/package/2006/relationships"
|
||||
)
|
||||
ET.register_namespace("", relationships_namespace)
|
||||
relationships = ET.fromstring(entries["_rels/.rels"])
|
||||
for relationship in relationships:
|
||||
relationship_type = relationship.get("Type", "")
|
||||
if relationship_type.endswith("/manifest"):
|
||||
relationship.set("Id", "RManifest")
|
||||
elif relationship_type.endswith("/metadata/core-properties"):
|
||||
relationship.set("Id", "RCoreProperties")
|
||||
relationship.set("Target", f"/{CORE_PROPERTIES_PATH}")
|
||||
entries["_rels/.rels"] = ET.tostring(
|
||||
relationships, encoding="utf-8", xml_declaration=True
|
||||
)
|
||||
|
||||
temporary = package_path.with_suffix(package_path.suffix + ".normalized")
|
||||
with zipfile.ZipFile(temporary, "w", compression=zipfile.ZIP_STORED) as target:
|
||||
for name in sorted(entries):
|
||||
info = zipfile.ZipInfo(name, date_time=zip_timestamp)
|
||||
info.compress_type = zipfile.ZIP_STORED
|
||||
info.create_system = 3
|
||||
info.external_attr = 0o100644 << 16
|
||||
target.writestr(info, entries[name])
|
||||
temporary.replace(package_path)
|
||||
|
||||
|
||||
def command_provenance(args) -> None:
|
||||
versions = ET.parse(pathlib.Path(args.root) / "eng" / "Versions.props")
|
||||
|
||||
def version_property(name: str) -> str:
|
||||
element = versions.find(f".//{name}")
|
||||
if element is None or not element.text:
|
||||
fail(f"Missing central release property: {name}")
|
||||
return element.text.strip()
|
||||
|
||||
provenance = {
|
||||
"schemaVersion": 1,
|
||||
"version": args.version,
|
||||
"commit": args.commit,
|
||||
"treeState": args.tree_state,
|
||||
"buildConfiguration": "Release",
|
||||
"sourceDateEpoch": int(args.source_date_epoch),
|
||||
"dotnetSdk": args.dotnet_sdk,
|
||||
"buildTools": sorted(args.build_tool),
|
||||
"containerImage": f"git.finalfactory.de/heikyu/rendezvous:{args.version}",
|
||||
"containerPlatform": "linux/amd64",
|
||||
"containerBaseDigests": args.base_digest,
|
||||
"releaseBuilderBaseDigests": args.builder_base,
|
||||
"packages": [
|
||||
f"FinalFactory.Rendezvous.Client/{args.version}",
|
||||
f"FinalFactory.Rendezvous.Contracts/{args.version}",
|
||||
],
|
||||
"compatibility": {
|
||||
"minimumClientVersion": version_property("MinimumClientVersion"),
|
||||
"maximumClientMajorVersion": int(version_property("MaximumClientMajorVersion")),
|
||||
"httpContractVersions": [int(version_property("HttpContractVersion"))],
|
||||
"udpContractVersions": [int(version_property("UdpContractVersion"))],
|
||||
"connectionTicketFormatVersions": [
|
||||
int(version_property("ConnectionTicketFormatVersion"))
|
||||
],
|
||||
"liteNetLib": version_property("LiteNetLibVersion"),
|
||||
"gameplayProtocol": "exact-per-tenant",
|
||||
},
|
||||
}
|
||||
pathlib.Path(args.output).write_text(
|
||||
json.dumps(provenance, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def command_record_container_build(args) -> None:
|
||||
path = pathlib.Path(args.provenance)
|
||||
provenance = load_json(path)
|
||||
tools = set(provenance.get("buildTools", []))
|
||||
tools.add(f"docker-buildx={args.buildx_version}")
|
||||
tools.add(f"buildkit={args.buildkit_version}")
|
||||
provenance["buildTools"] = sorted(tools)
|
||||
provenance["containerPlatform"] = "linux/amd64"
|
||||
if not re.fullmatch(r"sha256:[0-9a-f]{64}", args.image_id):
|
||||
fail(f"Container image ID is invalid: {args.image_id}")
|
||||
provenance["containerImageId"] = args.image_id
|
||||
path.write_text(json.dumps(provenance, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def command_record_container_digest(args) -> None:
|
||||
if not re.fullmatch(
|
||||
r"git\.finalfactory\.de/heikyu/rendezvous@sha256:[0-9a-f]{64}",
|
||||
args.digest,
|
||||
):
|
||||
fail(f"Published container digest is invalid: {args.digest}")
|
||||
release_dir = pathlib.Path(args.release_dir)
|
||||
provenance_path = release_dir / "release-provenance.json"
|
||||
provenance = load_json(provenance_path)
|
||||
provenance["containerDigest"] = args.digest
|
||||
provenance_path.write_text(
|
||||
json.dumps(provenance, indent=2) + "\n", encoding="utf-8"
|
||||
)
|
||||
(release_dir / "container-digest.txt").write_text(
|
||||
args.digest + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def command_normalize_container_sbom(args) -> None:
|
||||
path = pathlib.Path(args.file)
|
||||
document = load_json(path)
|
||||
created = dt.datetime.fromtimestamp(
|
||||
int(args.source_date_epoch), dt.timezone.utc
|
||||
).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
document["name"] = f"FinalFactory.Rendezvous.Container-{args.version}"
|
||||
document["documentNamespace"] = (
|
||||
"https://git.finalfactory.de/HeiKyu/Rendezvous/container-sbom/"
|
||||
f"{args.version}/{args.commit}"
|
||||
)
|
||||
creation = document.setdefault("creationInfo", {})
|
||||
creation["created"] = created
|
||||
creation["creators"] = ["Tool: Trivy-0.69.3"]
|
||||
if isinstance(document.get("packages"), list):
|
||||
document["packages"] = sorted(
|
||||
document["packages"],
|
||||
key=lambda item: (
|
||||
item.get("SPDXID", ""),
|
||||
item.get("name", ""),
|
||||
item.get("versionInfo", ""),
|
||||
),
|
||||
)
|
||||
if isinstance(document.get("relationships"), list):
|
||||
document["relationships"] = sorted(
|
||||
document["relationships"],
|
||||
key=lambda item: (
|
||||
item.get("spdxElementId", ""),
|
||||
item.get("relationshipType", ""),
|
||||
item.get("relatedSpdxElement", ""),
|
||||
),
|
||||
)
|
||||
path.write_text(
|
||||
json.dumps(document, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser()
|
||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||
policy = subparsers.add_parser("policy")
|
||||
policy.add_argument("--root", required=True)
|
||||
policy.set_defaults(handler=command_policy)
|
||||
audit = subparsers.add_parser("audit")
|
||||
audit.add_argument("--input", required=True)
|
||||
audit.set_defaults(handler=command_audit)
|
||||
sbom = subparsers.add_parser("sbom")
|
||||
sbom.add_argument("--root", required=True)
|
||||
sbom.add_argument("--version", required=True)
|
||||
sbom.add_argument("--commit", required=True)
|
||||
sbom.add_argument("--source-date-epoch", required=True)
|
||||
sbom.add_argument("--server-deps", required=True)
|
||||
sbom.add_argument("--output", required=True)
|
||||
sbom.set_defaults(handler=command_sbom)
|
||||
verify = subparsers.add_parser("verify")
|
||||
verify.add_argument("--root", required=True)
|
||||
verify.add_argument("--release-dir", required=True)
|
||||
verify.add_argument("--version", required=True)
|
||||
verify.add_argument("--allow-dirty", action="store_true")
|
||||
verify.add_argument(
|
||||
"--phase",
|
||||
choices=("build", "publish-ready", "signing-ready", "published"),
|
||||
default="build",
|
||||
)
|
||||
verify.set_defaults(handler=command_verify)
|
||||
consumer = subparsers.add_parser("consumer")
|
||||
consumer.add_argument("--assets", required=True)
|
||||
consumer.add_argument("--version", required=True)
|
||||
consumer.set_defaults(handler=command_consumer)
|
||||
consumer_config = subparsers.add_parser("consumer-config")
|
||||
consumer_config.add_argument("--local-source", required=True)
|
||||
consumer_config.add_argument("--output", required=True)
|
||||
consumer_config.set_defaults(handler=command_consumer_config)
|
||||
source_link = subparsers.add_parser("source-link")
|
||||
source_link.add_argument("--file", required=True)
|
||||
source_link.add_argument("--commit", required=True)
|
||||
source_link.set_defaults(handler=command_source_link)
|
||||
normalize = subparsers.add_parser("normalize-package")
|
||||
normalize.add_argument("--package", required=True)
|
||||
normalize.add_argument("--source-date-epoch", required=True)
|
||||
normalize.add_argument("--version", required=True)
|
||||
normalize.set_defaults(handler=command_normalize_package)
|
||||
provenance = subparsers.add_parser("provenance")
|
||||
provenance.add_argument("--root", required=True)
|
||||
provenance.add_argument("--version", required=True)
|
||||
provenance.add_argument("--commit", required=True)
|
||||
provenance.add_argument("--tree-state", required=True)
|
||||
provenance.add_argument("--source-date-epoch", required=True)
|
||||
provenance.add_argument("--dotnet-sdk", required=True)
|
||||
provenance.add_argument("--build-tool", action="append", default=[])
|
||||
provenance.add_argument("--base-digest", action="append", default=[])
|
||||
provenance.add_argument("--builder-base", action="append", default=[])
|
||||
provenance.add_argument("--output", required=True)
|
||||
provenance.set_defaults(handler=command_provenance)
|
||||
record_container = subparsers.add_parser("record-container-build")
|
||||
record_container.add_argument("--provenance", required=True)
|
||||
record_container.add_argument("--buildx-version", required=True)
|
||||
record_container.add_argument("--buildkit-version", required=True)
|
||||
record_container.add_argument("--image-id", required=True)
|
||||
record_container.set_defaults(handler=command_record_container_build)
|
||||
record_digest = subparsers.add_parser("record-container-digest")
|
||||
record_digest.add_argument("--release-dir", required=True)
|
||||
record_digest.add_argument("--digest", required=True)
|
||||
record_digest.set_defaults(handler=command_record_container_digest)
|
||||
normalize_container_sbom = subparsers.add_parser("normalize-container-sbom")
|
||||
normalize_container_sbom.add_argument("--file", required=True)
|
||||
normalize_container_sbom.add_argument("--version", required=True)
|
||||
normalize_container_sbom.add_argument("--commit", required=True)
|
||||
normalize_container_sbom.add_argument("--source-date-epoch", required=True)
|
||||
normalize_container_sbom.set_defaults(handler=command_normalize_container_sbom)
|
||||
args = parser.parse_args()
|
||||
args.handler(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user