307 lines
12 KiB
Python
307 lines
12 KiB
Python
"""Validate one authoritative version and reproducible DocForge release artifacts."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
import tarfile
|
|
import tempfile
|
|
import tomllib
|
|
import zipfile
|
|
from email.message import Message
|
|
from email.parser import BytesParser
|
|
from email.policy import default
|
|
from pathlib import Path
|
|
from typing import cast
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
VERSION_PATH = ROOT / "src/docforge/_version.py"
|
|
PYPROJECT_PATH = ROOT / "pyproject.toml"
|
|
VERSION_PATTERN = re.compile(r'^__version__ = "([0-9]+\.[0-9]+\.[0-9]+)"$', re.MULTILINE)
|
|
EXPECTED_ENTRY_POINTS = {
|
|
"docforge": "docforge.cli:main",
|
|
"docforge-mcp": "docforge.mcp_server:main",
|
|
"docforge-viewer-manager": "docforge.viewer_manager:main",
|
|
}
|
|
VERSION_SURFACES = {
|
|
"docforge.cli": "docforge {version}\n",
|
|
"docforge.mcp_server": "docforge-mcp {version}\n",
|
|
"docforge.reference_mcp": "python -m docforge.reference_mcp {version}\n",
|
|
"docforge.viewer_manager": "docforge-viewer-manager {version}\n",
|
|
}
|
|
|
|
|
|
class ReleaseIdentityError(RuntimeError):
|
|
"""Release identity or artifact evidence is inconsistent."""
|
|
|
|
|
|
def _run(arguments: list[str], *, environment: dict[str, str] | None = None) -> str:
|
|
completed = subprocess.run(
|
|
arguments,
|
|
cwd=ROOT,
|
|
env=environment,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
if completed.returncode != 0:
|
|
raise ReleaseIdentityError(
|
|
f"Command failed ({' '.join(arguments)}): {completed.stderr.strip()}"
|
|
)
|
|
return completed.stdout
|
|
|
|
|
|
def _source_version() -> str:
|
|
match = VERSION_PATTERN.search(VERSION_PATH.read_text(encoding="utf-8"))
|
|
if match is None:
|
|
raise ReleaseIdentityError("The authoritative version file is not a fixed semantic version")
|
|
return match.group(1)
|
|
|
|
|
|
def _configuration(version: str) -> dict[str, object]:
|
|
document = tomllib.loads(PYPROJECT_PATH.read_text(encoding="utf-8"))
|
|
project = cast(dict[str, object], document.get("project"))
|
|
if project.get("name") != "docforge":
|
|
raise ReleaseIdentityError("Distribution name is not docforge")
|
|
if project.get("dynamic") != ["version"] or "version" in project:
|
|
raise ReleaseIdentityError("Project version is not exclusively dynamic")
|
|
tool = cast(dict[str, object], document.get("tool"))
|
|
hatch = cast(dict[str, object], tool.get("hatch"))
|
|
hatch_version = cast(dict[str, object], hatch.get("version"))
|
|
if hatch_version.get("path") != "src/docforge/_version.py":
|
|
raise ReleaseIdentityError("Hatch does not use the authoritative version file")
|
|
if project.get("license") != "MIT":
|
|
raise ReleaseIdentityError("Project metadata does not use the MIT SPDX expression")
|
|
scripts = cast(dict[str, object], project.get("scripts"))
|
|
if scripts != EXPECTED_ENTRY_POINTS:
|
|
raise ReleaseIdentityError("Distribution entry points changed")
|
|
urls = cast(dict[str, object], project.get("urls"))
|
|
repository = urls.get("Repository")
|
|
if repository != "https://repo.andraxion.net/administrator/DocForge2":
|
|
raise ReleaseIdentityError("Release repository URL is not the public successor")
|
|
if version != _source_version():
|
|
raise ReleaseIdentityError("Release version changed during configuration inspection")
|
|
return project
|
|
|
|
|
|
def _surface_versions(version: str) -> dict[str, str]:
|
|
evidence: dict[str, str] = {}
|
|
for module_name, expected_template in VERSION_SURFACES.items():
|
|
output = _run([sys.executable, "-m", module_name, "--version"])
|
|
expected = expected_template.format(version=version)
|
|
if output != expected:
|
|
raise ReleaseIdentityError(
|
|
f"{module_name} reported {output.strip()!r}, expected {expected.strip()!r}"
|
|
)
|
|
evidence[module_name] = output.strip()
|
|
return evidence
|
|
|
|
|
|
def _git_evidence(tag_state: str, version: str, *, require_clean: bool) -> dict[str, object]:
|
|
commit = _run(["git", "rev-parse", "HEAD"]).strip()
|
|
status = _run(["git", "status", "--porcelain"])
|
|
if require_clean and status:
|
|
raise ReleaseIdentityError("Release artifact proof requires a clean working tree")
|
|
tag_name = f"v{version}"
|
|
tag_result = subprocess.run(
|
|
["git", "rev-parse", "-q", "--verify", f"refs/tags/{tag_name}^{{commit}}"],
|
|
cwd=ROOT,
|
|
check=False,
|
|
capture_output=True,
|
|
text=True,
|
|
)
|
|
tag_commit = tag_result.stdout.strip() if tag_result.returncode == 0 else None
|
|
if tag_state == "absent" and tag_commit is not None:
|
|
raise ReleaseIdentityError(f"{tag_name} already exists")
|
|
if tag_state == "head" and tag_commit != commit:
|
|
raise ReleaseIdentityError(f"{tag_name} does not identify the release commit")
|
|
return {
|
|
"commit": commit,
|
|
"clean": not bool(status),
|
|
"tag": tag_name,
|
|
"tag_commit": tag_commit,
|
|
}
|
|
|
|
|
|
def _sha256(path: Path) -> str:
|
|
digest = hashlib.sha256()
|
|
with path.open("rb") as handle:
|
|
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
digest.update(chunk)
|
|
return digest.hexdigest()
|
|
|
|
|
|
def _metadata(wheel: Path, version: str) -> dict[str, object]:
|
|
with zipfile.ZipFile(wheel) as archive:
|
|
members = set(archive.namelist())
|
|
prefix = f"docforge-{version}.dist-info"
|
|
metadata_name = f"{prefix}/METADATA"
|
|
entry_points_name = f"{prefix}/entry_points.txt"
|
|
if metadata_name not in members or entry_points_name not in members:
|
|
raise ReleaseIdentityError("Wheel metadata or entry points are missing")
|
|
if f"{prefix}/licenses/LICENSE" not in members:
|
|
raise ReleaseIdentityError("Wheel does not contain the MIT license")
|
|
if "docforge/_version.py" not in members:
|
|
raise ReleaseIdentityError("Wheel does not contain the authoritative version module")
|
|
message = cast(Message, BytesParser(policy=default).parsebytes(archive.read(metadata_name)))
|
|
entry_points = archive.read(entry_points_name).decode("utf-8")
|
|
if message["Name"] != "docforge" or message["Version"] != version:
|
|
raise ReleaseIdentityError("Wheel name or version metadata is inconsistent")
|
|
if message["License-Expression"] != "MIT":
|
|
raise ReleaseIdentityError("Wheel does not publish the MIT SPDX expression")
|
|
for name, target in EXPECTED_ENTRY_POINTS.items():
|
|
if f"{name} = {target}" not in entry_points:
|
|
raise ReleaseIdentityError(f"Wheel entry point {name} is inconsistent")
|
|
project_urls = sorted(message.get_all("Project-URL", []))
|
|
if "Repository, https://repo.andraxion.net/administrator/DocForge2" not in project_urls:
|
|
raise ReleaseIdentityError("Wheel repository metadata is inconsistent")
|
|
return {
|
|
"name": message["Name"],
|
|
"version": message["Version"],
|
|
"license_expression": message["License-Expression"],
|
|
"project_urls": project_urls,
|
|
"license_member": f"{prefix}/licenses/LICENSE",
|
|
}
|
|
|
|
|
|
def _sdist(sdist: Path, version: str) -> dict[str, object]:
|
|
prefix = f"docforge-{version}"
|
|
with tarfile.open(sdist, mode="r:gz") as archive:
|
|
members = {member.name for member in archive.getmembers()}
|
|
license_name = f"{prefix}/LICENSE"
|
|
version_name = f"{prefix}/src/docforge/_version.py"
|
|
if license_name not in members or version_name not in members:
|
|
raise ReleaseIdentityError("Source distribution omits release identity or license files")
|
|
return {
|
|
"license_member": license_name,
|
|
"version_member": version_name,
|
|
"member_count": len(members),
|
|
}
|
|
|
|
|
|
def _artifact_files(directory: Path, version: str) -> tuple[Path, Path]:
|
|
wheel = directory / f"docforge-{version}-py3-none-any.whl"
|
|
sdist = directory / f"docforge-{version}.tar.gz"
|
|
if not wheel.is_file() or not sdist.is_file():
|
|
raise ReleaseIdentityError(
|
|
"Build did not create the expected wheel and source distribution"
|
|
)
|
|
allowed = {wheel.name, sdist.name, ".gitignore"}
|
|
if {path.name for path in directory.iterdir()} != allowed:
|
|
raise ReleaseIdentityError("Build output contains an unexpected artifact")
|
|
return wheel, sdist
|
|
|
|
|
|
def _build_evidence(version: str) -> dict[str, object]:
|
|
epoch = _run(["git", "show", "-s", "--format=%ct", "HEAD"]).strip()
|
|
if not epoch.isdigit():
|
|
raise ReleaseIdentityError("Release commit timestamp is invalid")
|
|
environment = dict(os.environ)
|
|
environment["SOURCE_DATE_EPOCH"] = epoch
|
|
with tempfile.TemporaryDirectory(prefix="docforge-release-proof-") as directory_name:
|
|
root = Path(directory_name)
|
|
first = root / "first"
|
|
second = root / "second"
|
|
first.mkdir()
|
|
second.mkdir()
|
|
_run(["uv", "build", "--out-dir", str(first)], environment=environment)
|
|
_run(["uv", "build", "--out-dir", str(second)], environment=environment)
|
|
first_wheel, first_sdist = _artifact_files(first, version)
|
|
second_wheel, second_sdist = _artifact_files(second, version)
|
|
hashes = {
|
|
"wheel": _sha256(first_wheel),
|
|
"sdist": _sha256(first_sdist),
|
|
}
|
|
repeated = {
|
|
"wheel": _sha256(second_wheel),
|
|
"sdist": _sha256(second_sdist),
|
|
}
|
|
if hashes != repeated:
|
|
raise ReleaseIdentityError("Repeated release builds are not byte-for-byte reproducible")
|
|
wheel_metadata = _metadata(first_wheel, version)
|
|
sdist_metadata = _sdist(first_sdist, version)
|
|
sizes = {
|
|
"wheel": first_wheel.stat().st_size,
|
|
"sdist": first_sdist.stat().st_size,
|
|
}
|
|
return {
|
|
"source_date_epoch": int(epoch),
|
|
"reproducible": True,
|
|
"sha256": hashes,
|
|
"bytes": sizes,
|
|
"wheel": wheel_metadata,
|
|
"sdist": sdist_metadata,
|
|
}
|
|
|
|
|
|
def build_release_identity_evidence(
|
|
*,
|
|
mode: str,
|
|
require_clean: bool,
|
|
tag_state: str,
|
|
) -> dict[str, object]:
|
|
"""Return validated, deterministic release identity evidence."""
|
|
|
|
version = _source_version()
|
|
_configuration(version)
|
|
evidence: dict[str, object] = {
|
|
"schema_version": 1,
|
|
"version": version,
|
|
"git": _git_evidence(tag_state, version, require_clean=require_clean),
|
|
"surfaces": _surface_versions(version),
|
|
}
|
|
if mode == "full":
|
|
evidence["artifacts"] = _build_evidence(version)
|
|
return evidence
|
|
|
|
|
|
def _publish_output(path: Path, payload: bytes) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
descriptor, temporary_name = tempfile.mkstemp(prefix=".release-identity-", dir=path.parent)
|
|
temporary = Path(temporary_name)
|
|
try:
|
|
with os.fdopen(descriptor, "wb") as handle:
|
|
handle.write(payload)
|
|
handle.flush()
|
|
os.fsync(handle.fileno())
|
|
os.replace(temporary, path)
|
|
directory_fd = os.open(path.parent, os.O_RDONLY)
|
|
try:
|
|
os.fsync(directory_fd)
|
|
finally:
|
|
os.close(directory_fd)
|
|
except Exception:
|
|
temporary.unlink(missing_ok=True)
|
|
raise
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser()
|
|
parser.add_argument("--mode", choices=("smoke", "full"), default="smoke")
|
|
parser.add_argument("--output", type=Path)
|
|
parser.add_argument("--require-clean", action="store_true")
|
|
parser.add_argument("--tag-state", choices=("ignore", "absent", "head"), default="ignore")
|
|
arguments = parser.parse_args()
|
|
try:
|
|
evidence = build_release_identity_evidence(
|
|
mode=arguments.mode,
|
|
require_clean=arguments.require_clean,
|
|
tag_state=arguments.tag_state,
|
|
)
|
|
except (OSError, ReleaseIdentityError, subprocess.SubprocessError) as error:
|
|
print(str(error), file=sys.stderr)
|
|
return 2
|
|
payload = json.dumps(evidence, sort_keys=True, separators=(",", ":")).encode("utf-8") + b"\n"
|
|
if arguments.output is not None:
|
|
_publish_output(arguments.output, payload)
|
|
print(payload.decode("utf-8"), end="")
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|