Add reproducible release artifact proof
This commit is contained in:
parent
dc9500e50e
commit
7b79d65dae
3 changed files with 342 additions and 1 deletions
|
|
@ -8,13 +8,17 @@ dynamic = ["version"]
|
|||
description = "Project-scoped documentation indexing and context service"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "MIT" }
|
||||
license = "MIT"
|
||||
authors = [{ name = "Worldforge contributors" }]
|
||||
dependencies = [
|
||||
"markdown-it-py>=4.2,<5",
|
||||
"mcp>=1.28,<2",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Repository = "https://repo.andraxion.net/administrator/DocForge2"
|
||||
Issues = "https://repo.andraxion.net/administrator/DocForge2/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
javascript = [
|
||||
"tree-sitter>=0.25,<0.26",
|
||||
|
|
|
|||
30
tests/test_release_identity.py
Normal file
30
tests/test_release_identity.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from tools.check_release_identity import build_release_identity_evidence
|
||||
|
||||
|
||||
class ReleaseIdentityTests(unittest.TestCase):
|
||||
def test_smoke_identity_is_consistent_before_tagging(self) -> None:
|
||||
evidence = build_release_identity_evidence(
|
||||
mode="smoke",
|
||||
require_clean=False,
|
||||
tag_state="absent",
|
||||
)
|
||||
|
||||
self.assertEqual(1, evidence["schema_version"])
|
||||
self.assertEqual("1.4.0", evidence["version"])
|
||||
self.assertEqual(
|
||||
{
|
||||
"docforge.cli": "docforge 1.4.0",
|
||||
"docforge.mcp_server": "docforge-mcp 1.4.0",
|
||||
"docforge.reference_mcp": "python -m docforge.reference_mcp 1.4.0",
|
||||
"docforge.viewer_manager": "docforge-viewer-manager 1.4.0",
|
||||
},
|
||||
evidence["surfaces"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
307
tools/check_release_identity.py
Normal file
307
tools/check_release_identity.py
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
"""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())
|
||||
Loading…
Add table
Add a link
Reference in a new issue