manifest.py python
149 lines 5.5 KB
Raw
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1 docs: MuseHub-first before ISR #74 — staging solidify NEXT Human 1 day ago
1 """Release manifest builder and validator (§QR.7.1)."""
2
3 from __future__ import annotations
4
5 import json
6 import re
7 from typing import Any, Mapping, Sequence
8
9 from tools.desktop_release.constants import (
10 MANIFEST_SCHEMA_VERSION,
11 PLATFORMS,
12 PRODUCT_IDENTIFIER,
13 PRODUCT_NAME,
14 SIGNED_METHODS_BY_PLATFORM,
15 SIGNING_METHODS,
16 SIGNING_STATUSES,
17 )
18
19 _SHA256_RE = re.compile(r"^[0-9a-f]{64}$")
20 _GIT_SHA_RE = re.compile(r"^[0-9a-f]{40}$")
21
22
23 class ManifestError(ValueError):
24 """Raised when a release manifest fails schema or signing rules."""
25
26
27 def _require_str(obj: Mapping[str, Any], key: str) -> str:
28 value = obj.get(key)
29 if not isinstance(value, str) or not value.strip():
30 raise ManifestError(f"manifest missing string field: {key}")
31 return value
32
33
34 def validate_artifact(artifact: Mapping[str, Any]) -> None:
35 """Validate one ``artifacts[]`` entry."""
36 if not isinstance(artifact, Mapping):
37 raise ManifestError("artifact must be an object")
38 platform = _require_str(artifact, "platform")
39 if platform not in PLATFORMS:
40 raise ManifestError(f"unknown platform: {platform!r}")
41 filename = _require_str(artifact, "filename")
42 if "/" in filename or "\\" in filename:
43 raise ManifestError(f"filename must be basename only: {filename!r}")
44 sha256 = _require_str(artifact, "sha256").lower()
45 if not _SHA256_RE.match(sha256):
46 raise ManifestError(f"invalid sha256: {sha256!r}")
47 signing = artifact.get("signing")
48 if not isinstance(signing, Mapping):
49 raise ManifestError("artifact.signing must be an object")
50 status = _require_str(signing, "status")
51 if status not in SIGNING_STATUSES:
52 raise ManifestError(f"unknown signing.status: {status!r}")
53 method = _require_str(signing, "method")
54 if method not in SIGNING_METHODS:
55 raise ManifestError(f"unknown signing.method: {method!r}")
56 if status == "signed":
57 if method == "none":
58 raise ManifestError("signed + method none refused")
59 allowed = SIGNED_METHODS_BY_PLATFORM[platform]
60 if method not in allowed:
61 raise ManifestError(
62 f"signing.method {method!r} not allowed for platform {platform!r}"
63 )
64
65
66 def validate_manifest(data: Mapping[str, Any]) -> None:
67 """Validate a full release manifest document (§QR.7.1 enums + rules)."""
68 if not isinstance(data, Mapping):
69 raise ManifestError("manifest must be an object")
70 schema = data.get("schema_version")
71 if schema != MANIFEST_SCHEMA_VERSION:
72 raise ManifestError(f"unsupported schema_version: {schema!r}")
73 if _require_str(data, "product") != PRODUCT_NAME:
74 raise ManifestError(f"product must be {PRODUCT_NAME!r}")
75 if _require_str(data, "identifier") != PRODUCT_IDENTIFIER:
76 raise ManifestError(f"identifier must be {PRODUCT_IDENTIFIER!r}")
77 _require_str(data, "version")
78 git_tag = _require_str(data, "git_tag")
79 if not git_tag.startswith("v"):
80 raise ManifestError(f"git_tag must start with v: {git_tag!r}")
81 git_sha = _require_str(data, "git_sha").lower()
82 if not _GIT_SHA_RE.match(git_sha):
83 raise ManifestError(f"git_sha must be 40-char hex: {git_sha!r}")
84 artifacts = data.get("artifacts")
85 if not isinstance(artifacts, Sequence) or isinstance(artifacts, (str, bytes)):
86 raise ManifestError("artifacts must be an array")
87 if len(artifacts) == 0:
88 raise ManifestError("artifacts must be non-empty")
89 for item in artifacts:
90 validate_artifact(item)
91
92
93 def build_manifest(
94 *,
95 version: str,
96 git_sha: str,
97 artifacts: Sequence[Mapping[str, Any]],
98 git_tag: str | None = None,
99 product: str = PRODUCT_NAME,
100 identifier: str = PRODUCT_IDENTIFIER,
101 ) -> dict[str, Any]:
102 """Build a schema_version=1 release manifest and validate it.
103
104 Artifact dicts must include ``platform``, ``filename``, ``sha256``, and
105 ``signing`` (``status`` + ``method``). Optional ``arch`` is preserved when
106 present and in ``{aarch64, x86_64}``.
107 """
108 version = version.strip()
109 tag = (git_tag or f"v{version}").strip()
110 normalized: list[dict[str, Any]] = []
111 for raw in artifacts:
112 if not isinstance(raw, Mapping):
113 raise ManifestError("artifact must be an object")
114 entry: dict[str, Any] = {
115 "platform": str(raw["platform"]).strip(),
116 "filename": str(raw["filename"]).strip(),
117 "sha256": str(raw["sha256"]).strip().lower(),
118 "signing": {
119 "status": str(raw["signing"]["status"]).strip(),
120 "method": str(raw["signing"]["method"]).strip(),
121 },
122 }
123 arch = raw.get("arch")
124 if arch is not None:
125 arch_s = str(arch).strip()
126 if arch_s not in {"aarch64", "x86_64"}:
127 raise ManifestError(f"unsupported arch: {arch_s!r}")
128 entry["arch"] = arch_s
129 normalized.append(entry)
130
131 doc: dict[str, Any] = {
132 "schema_version": MANIFEST_SCHEMA_VERSION,
133 "product": product,
134 "identifier": identifier,
135 "version": version,
136 "git_tag": tag,
137 "git_sha": git_sha.strip().lower(),
138 "artifacts": normalized,
139 }
140 validate_manifest(doc)
141 return doc
142
143
144 def canonical_manifest_bytes(data: Mapping[str, Any]) -> bytes:
145 """Serialize manifest with frozen key order for data-integrity twins."""
146 validate_manifest(data)
147 return json.dumps(data, sort_keys=True, separators=(",", ":"), ensure_ascii=True).encode(
148 "utf-8"
149 )
File History 1 commit
sha256:a78e7e5a8740e03315f325d19edeb3aa1b306b3337d04abbaa9a9e0f3bbeb7a1 docs: MuseHub-first before ISR #74 — staging solidify NEXT Human 1 day ago