ouroboros/scripts/release_proof.py
Anton Razzhigaev 1b7e097e1e
Some checks failed
CI / quick-test (push) Has been cancelled
CI / full-test (macos-latest) (push) Has been cancelled
CI / full-test (ubuntu-latest) (push) Has been cancelled
CI / full-test (windows-latest) (push) Has been cancelled
CI / integration-test (push) Has been cancelled
CI / skill-smoke (macos-latest) (push) Has been cancelled
CI / skill-smoke (ubuntu-latest) (push) Has been cancelled
CI / docker-ui-smoke (push) Has been cancelled
Sync Joi Lab Fork Mirror / sync (push) Has been cancelled
CI / build (tar.gz, ubuntu-latest, linux-x86_64, syft_1.50.0_linux_amd64.tar.gz, syft, bf7b29ff57f06da30918266a0e1c2885a8f99784798d1bdb1628886aa015d788) (push) Has been cancelled
CI / build (zip, windows-latest, windows-x64, syft_1.50.0_windows_amd64.zip, syft.exe, 815ee6973ec5dff6a671d7f41b0e78835a8c45b91d5a39f4743ea1cee833d3be) (push) Has been cancelled
CI / release (push) Has been cancelled
CI / skill-smoke (windows-latest) (push) Has been cancelled
CI / marker-guards (push) Has been cancelled
CI / ui-smoke (push) Has been cancelled
CI / docker-portable-test (push) Has been cancelled
CI / build (dmg, macos-latest, macos-arm64, syft_1.50.0_darwin_arm64.tar.gz, syft, e32fdb9d47823fa633748a1efca2528fd77c37469ea93c9e40ab835da44e4cce) (push) Has been cancelled
CI / release-preflight (push) Has been cancelled
docs: expand public discovery and release proof
2026-08-02 06:13:01 +03:00

401 lines
15 KiB
Python

#!/usr/bin/env python3
"""Build and verify the public proof capsule for an Ouroboros release."""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import re
from pathlib import Path
from typing import Iterable
ARCHIVE_SUFFIXES = (".dmg", ".tar.gz", ".zip")
PROOF_IDS = {
"macos-arm64": lambda version: f"Ouroboros-{version}.dmg",
"linux-x86_64": lambda version: f"Ouroboros-{version}-linux-x86_64.tar.gz",
"windows-x64": lambda version: f"Ouroboros-{version}-windows-x64.zip",
}
RELEASE_GATES = (
"full-test",
"marker-guards",
"ui-smoke",
"docker-ui-smoke",
"docker-portable-test",
"skill-smoke",
"packaged-artifact-smoke",
)
COMMON_SMOKE_CHECKS = frozenset({"embedded_repo_bundle", "packaged_cli_help"})
REQUIRED_SMOKE_CHECKS = {
"macos-arm64": COMMON_SMOKE_CHECKS
| frozenset({"install_cli_command", "arm64_main_executable"}),
"linux-x86_64": COMMON_SMOKE_CHECKS,
"windows-x64": COMMON_SMOKE_CHECKS,
}
def sha256_file(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 _release_archives(directory: Path) -> list[Path]:
return sorted(
path
for path in directory.iterdir()
if path.is_file()
and path.name.startswith("Ouroboros-")
and path.name.endswith(ARCHIVE_SUFFIXES)
)
def _write_json(path: Path, value: object) -> None:
path.write_text(
json.dumps(value, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
encoding="utf-8",
)
def _append_github_output(path: Path, values: dict[str, str]) -> None:
with path.open("a", encoding="utf-8") as handle:
for key, value in values.items():
if "\n" in value or "\r" in value:
raise ValueError(f"GitHub output {key!r} contains a newline")
handle.write(f"{key}={value}\n")
def locate_artifact(directory: Path) -> Path:
archives = _release_archives(directory)
if len(archives) != 1:
names = ", ".join(path.name for path in archives) or "none"
raise ValueError(f"expected exactly one release archive in {directory}, found: {names}")
return archives[0]
def command_locate(args: argparse.Namespace) -> None:
artifact = locate_artifact(args.directory)
values = {
"path": artifact.as_posix(),
"name": artifact.name,
"sha256": sha256_file(artifact),
}
if args.github_output:
_append_github_output(args.github_output, values)
print(json.dumps(values, sort_keys=True))
def command_record_smoke(args: argparse.Namespace) -> None:
if args.proof_id not in PROOF_IDS:
raise ValueError(f"unsupported proof id: {args.proof_id}")
if not args.artifact.is_file():
raise ValueError(f"artifact does not exist: {args.artifact}")
if not args.check:
raise ValueError("at least one completed smoke check is required")
receipt = {
"schemaVersion": 1,
"kind": "packaged_artifact_smoke",
"status": "passed",
"proofId": args.proof_id,
"artifact": args.artifact.name,
"sha256": sha256_file(args.artifact),
"sourceCommit": args.commit,
"releaseTag": args.tag,
"checks": sorted(set(args.check)),
}
_write_json(args.output, receipt)
def _read_release_description(readme: Path, version: str) -> tuple[str, str]:
prefix = f"| {version} |"
for line in readme.read_text(encoding="utf-8").splitlines():
if not line.startswith(prefix):
continue
cells = [cell.strip() for cell in line.strip().strip("|").split("|")]
if len(cells) != 3:
break
description = re.sub(r"\*\*", "", cells[2]).strip()
return cells[1], description
raise ValueError(f"README Version History has no exact row for {version}")
def _load_json(path: Path) -> dict:
value = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(value, dict):
raise ValueError(f"expected a JSON object in {path}")
return value
def _proof_files(
directory: Path,
version: str,
*,
commit: str,
tag: str,
) -> list[dict]:
archives = {path.name: path for path in _release_archives(directory)}
expected = {proof_id: factory(version) for proof_id, factory in PROOF_IDS.items()}
if set(archives) != set(expected.values()):
raise ValueError(
"release archive set does not match the three expected platform assets: "
f"expected {sorted(expected.values())}, found {sorted(archives)}"
)
records: list[dict] = []
for proof_id, artifact_name in expected.items():
artifact = archives[artifact_name]
digest = sha256_file(artifact)
smoke_path = directory / f"release-smoke-{proof_id}.json"
sbom_path = directory / f"sbom-{proof_id}.cdx.json"
if not smoke_path.is_file() or not sbom_path.is_file():
raise ValueError(f"missing smoke receipt or SBOM for {proof_id}")
smoke = _load_json(smoke_path)
sbom = _load_json(sbom_path)
if smoke.get("status") != "passed":
raise ValueError(f"smoke receipt is not passed: {smoke_path}")
expected_identity = {
"schemaVersion": 1,
"kind": "packaged_artifact_smoke",
"proofId": proof_id,
"sourceCommit": commit,
"releaseTag": tag,
}
if any(smoke.get(key) != value for key, value in expected_identity.items()):
raise ValueError(f"smoke receipt identity does not match {proof_id}")
if smoke.get("artifact") != artifact.name or smoke.get("sha256") != digest:
raise ValueError(f"smoke receipt is not bound to {artifact.name}")
checks = smoke.get("checks")
if not isinstance(checks, list) or not all(isinstance(item, str) for item in checks):
raise ValueError(f"smoke receipt checks are invalid: {smoke_path}")
missing_checks = REQUIRED_SMOKE_CHECKS[proof_id] - set(checks)
if missing_checks:
raise ValueError(
f"smoke receipt is missing required checks for {proof_id}: "
f"{sorted(missing_checks)}"
)
if (
sbom.get("bomFormat") != "CycloneDX"
or not isinstance(sbom.get("specVersion"), str)
or not isinstance(sbom.get("serialNumber"), str)
):
raise ValueError(f"SBOM is not CycloneDX JSON: {sbom_path}")
records.append(
{
"proofId": proof_id,
"name": artifact.name,
"size": artifact.stat().st_size,
"sha256": digest,
"smokeReceipt": smoke_path.name,
"sbom": sbom_path.name,
}
)
return records
def _checksum_targets(directory: Path, records: Iterable[dict]) -> list[Path]:
paths: list[Path] = []
for record in records:
paths.extend(
directory / name
for name in (record["name"], record["smokeReceipt"], record["sbom"])
)
return sorted(paths, key=lambda path: path.name)
def _release_notes(
*,
version: str,
description: str,
repository: str,
commit: str,
tag: str,
previous_tag: str | None,
) -> str:
short_commit = commit[:12]
verify_base = (
f"gh attestation verify <file> --repo {repository} "
f"--signer-workflow {repository}/.github/workflows/ci.yml "
f"--source-digest {commit} --source-ref refs/tags/{tag}"
)
lines = [
f"# Ouroboros {tag}",
"",
description,
"",
"## Install",
"",
"Download the package for your platform below. macOS builds currently target Apple silicon.",
"",
"## Release proof",
"",
f"This release was built from [`{short_commit}`](https://github.com/{repository}/commit/{commit}).",
"The release workflow passed the full test matrix, UI and Docker smoke tests, skill smoke tests, and packaged artifact smoke tests before publication.",
"",
"- `SHA256SUMS` covers every platform archive, SBOM, and smoke receipt.",
"- `release-evidence.json` binds the tag, commit, workflow run, artifact hashes, SBOMs, and smoke receipts.",
"- Each platform archive has GitHub build provenance and SBOM attestations.",
f"- Verify build provenance with `{verify_base}`.",
f"- Verify the CycloneDX attestation with `{verify_base} --predicate-type https://cyclonedx.org/bom`.",
]
if previous_tag:
lines.extend(
[
"",
f"[Compare {previous_tag}...{tag}](https://github.com/{repository}/compare/{previous_tag}...{tag})",
]
)
lines.append("")
return "\n".join(lines)
def command_assemble(args: argparse.Namespace) -> None:
version = args.version_file.read_text(encoding="utf-8").strip()
if args.tag != f"v{version}":
raise ValueError(f"tag/version mismatch: {args.tag} != v{version}")
release_date, description = _read_release_description(args.readme, version)
if not re.fullmatch(r"[0-9a-f]{40}", args.commit):
raise ValueError("commit must be a full lowercase Git SHA")
records = _proof_files(
args.directory,
version,
commit=args.commit,
tag=args.tag,
)
checksum_targets = _checksum_targets(args.directory, records)
checksums = "".join(
f"{sha256_file(path)} {path.name}\n" for path in checksum_targets
)
(args.directory / "SHA256SUMS").write_text(checksums, encoding="utf-8")
generated_at = args.generated_at or dt.datetime.now(dt.timezone.utc).isoformat()
evidence = {
"schemaVersion": 1,
"kind": "build_time_release_proof",
"product": "Ouroboros",
"version": version,
"releaseDate": release_date,
"source": {
"repository": f"https://github.com/{args.repository}",
"tag": args.tag,
"commit": args.commit,
},
"workflow": {
"runUrl": args.run_url,
"gates": [{"name": name, "status": "passed"} for name in RELEASE_GATES],
},
"generatedAt": generated_at,
"artifacts": records,
"verification": {
"checksums": "SHA256SUMS",
"attestationCommands": [
(
f"gh attestation verify <file> --repo {args.repository} "
f"--signer-workflow {args.repository}/.github/workflows/ci.yml "
f"--source-digest {args.commit} --source-ref refs/tags/{args.tag}"
),
(
f"gh attestation verify <file> --repo {args.repository} "
f"--signer-workflow {args.repository}/.github/workflows/ci.yml "
f"--source-digest {args.commit} --source-ref refs/tags/{args.tag} "
"--predicate-type https://cyclonedx.org/bom"
),
],
},
}
_write_json(args.directory / "release-evidence.json", evidence)
args.notes_output.write_text(
_release_notes(
version=version,
description=description,
repository=args.repository,
commit=args.commit,
tag=args.tag,
previous_tag=args.previous_tag,
),
encoding="utf-8",
)
def command_verify_uploaded(args: argparse.Namespace) -> None:
metadata = _load_json(args.metadata)
remote_rows = metadata.get("assets")
if not isinstance(remote_rows, list):
raise ValueError("release metadata has no assets list")
remote = {
row.get("name"): row
for row in remote_rows
if isinstance(row, dict) and isinstance(row.get("name"), str)
}
local_names = {
path.name
for path in args.directory.iterdir()
if path.is_file() and path.name != args.metadata.name
}
if set(remote) != local_names:
raise ValueError(
"uploaded asset set differs from the local allowlist: "
f"local={sorted(local_names)}, remote={sorted(remote)}"
)
for name in sorted(local_names):
path = args.directory / name
row = remote[name]
if row.get("size") != path.stat().st_size:
raise ValueError(f"uploaded size mismatch for {name}")
if row.get("digest") != f"sha256:{sha256_file(path)}":
raise ValueError(f"uploaded digest mismatch for {name}")
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description=__doc__)
commands = parser.add_subparsers(dest="command", required=True)
locate = commands.add_parser("locate", help="locate and hash one built archive")
locate.add_argument("--directory", type=Path, default=Path("dist"))
locate.add_argument("--github-output", type=Path)
locate.set_defaults(func=command_locate)
smoke = commands.add_parser("record-smoke", help="write a passed smoke receipt")
smoke.add_argument("--proof-id", required=True)
smoke.add_argument("--artifact", type=Path, required=True)
smoke.add_argument("--output", type=Path, required=True)
smoke.add_argument("--commit", required=True)
smoke.add_argument("--tag", required=True)
smoke.add_argument("--check", action="append", default=[])
smoke.set_defaults(func=command_record_smoke)
assemble = commands.add_parser("assemble", help="assemble the release proof capsule")
assemble.add_argument("--directory", type=Path, required=True)
assemble.add_argument("--version-file", type=Path, default=Path("VERSION"))
assemble.add_argument("--readme", type=Path, default=Path("README.md"))
assemble.add_argument("--repository", required=True)
assemble.add_argument("--tag", required=True)
assemble.add_argument("--commit", required=True)
assemble.add_argument("--run-url", required=True)
assemble.add_argument("--previous-tag")
assemble.add_argument("--generated-at")
assemble.add_argument("--notes-output", type=Path, required=True)
assemble.set_defaults(func=command_assemble)
verify = commands.add_parser(
"verify-uploaded", help="verify a draft release against the local allowlist"
)
verify.add_argument("--directory", type=Path, required=True)
verify.add_argument("--metadata", type=Path, required=True)
verify.set_defaults(func=command_verify_uploaded)
return parser
def main() -> None:
args = build_parser().parse_args()
try:
args.func(args)
except (OSError, ValueError, json.JSONDecodeError) as exc:
raise SystemExit(f"release proof error: {exc}") from exc
if __name__ == "__main__":
main()