mirror of
https://github.com/razzant/ouroboros.git
synced 2026-08-04 16:19:50 +00:00
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
401 lines
15 KiB
Python
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()
|