finalize.py python
166 lines 5.5 KB
Raw
sha256:0e9549ec7b463911bc08b7d586dc320b1ac9b1f5c943ee7e3865dcc6cb0f6f83 chore(governance): sync handover+roadmap to 84db8c8 (drift:… Human 1 day ago
1 """Fixture finalize path for release artifacts (§QR.12 / §QR.13 e2e)."""
2
3 from __future__ import annotations
4
5 import json
6 from dataclasses import dataclass
7 from pathlib import Path
8 from typing import Any, Mapping, Sequence
9
10 from tools.desktop_release.allowlist import refuse_disallowed_asset
11 from tools.desktop_release.checksums import parse_sha256sums, sha256_file, write_sha256sums
12 from tools.desktop_release.constants import (
13 MANIFEST_ARTIFACT_CAP,
14 MANIFEST_FILENAME_TEMPLATE,
15 SHA256SUMS_FILENAME,
16 )
17 from tools.desktop_release.manifest import ManifestError, build_manifest, canonical_manifest_bytes
18
19
20 class FinalizeError(ValueError):
21 """Raised when release finalize refuses (missing secrets, bad schema, etc.)."""
22
23
24 @dataclass(frozen=True)
25 class ArtifactInput:
26 """One platform artifact for finalize."""
27
28 platform: str
29 path: Path
30 signing_status: str
31 signing_method: str
32 arch: str | None = None
33
34
35 def require_signing_secrets(
36 *,
37 publish: bool,
38 platform: str,
39 secrets_present: Mapping[str, bool],
40 ) -> None:
41 """Fail closed when release publish lacks required platform secrets."""
42 if not publish:
43 return
44 required: tuple[str, ...]
45 if platform == "macos":
46 # Password mode OR API-key mode (§QR.6.2).
47 password_mode = all(
48 secrets_present.get(name, False)
49 for name in (
50 "APPLE_CERTIFICATE",
51 "APPLE_CERTIFICATE_PASSWORD",
52 "APPLE_ID",
53 "APPLE_TEAM_ID",
54 "APPLE_APP_SPECIFIC_PASSWORD",
55 "APPLE_SIGNING_IDENTITY",
56 )
57 )
58 api_mode = all(
59 secrets_present.get(name, False)
60 for name in (
61 "APPLE_CERTIFICATE",
62 "APPLE_CERTIFICATE_PASSWORD",
63 "APPLE_API_KEY",
64 "APPLE_API_KEY_ID",
65 "APPLE_API_ISSUER",
66 "APPLE_TEAM_ID",
67 "APPLE_SIGNING_IDENTITY",
68 )
69 )
70 if not (password_mode or api_mode):
71 raise FinalizeError("missing Apple signing secrets — fail closed")
72 return
73 if platform == "windows":
74 required = ("WINDOWS_CERTIFICATE", "WINDOWS_CERTIFICATE_PASSWORD")
75 elif platform == "linux":
76 required = ("LINUX_SIGNING_KEY",)
77 else:
78 raise FinalizeError(f"unknown platform: {platform!r}")
79 missing = [name for name in required if not secrets_present.get(name, False)]
80 if missing:
81 raise FinalizeError(f"missing {platform} signing secrets: {', '.join(missing)}")
82
83
84 def finalize_release_artifacts(
85 *,
86 version: str,
87 git_sha: str,
88 artifacts: Sequence[ArtifactInput],
89 output_dir: Path,
90 publish: bool = True,
91 allow_partial: bool = False,
92 secrets_present: Mapping[str, bool] | None = None,
93 ) -> dict[str, Any]:
94 """Build manifest + SHA256SUMS from artifact files (fixture-friendly).
95
96 When ``publish`` is True, requires platform signing secrets via
97 ``secrets_present``. When ``allow_partial`` is False, every artifact must
98 have ``signing_status == "signed"``.
99 """
100 secrets = secrets_present or {}
101 if len(artifacts) > MANIFEST_ARTIFACT_CAP:
102 raise FinalizeError(
103 f"artifact count {len(artifacts)} exceeds cap {MANIFEST_ARTIFACT_CAP}"
104 )
105
106 for artifact in artifacts:
107 require_signing_secrets(
108 publish=publish,
109 platform=artifact.platform,
110 secrets_present=secrets,
111 )
112 if publish and not allow_partial and artifact.signing_status != "signed":
113 raise FinalizeError(
114 f"artifact {artifact.path.name} not signed — fail closed "
115 f"(status={artifact.signing_status!r})"
116 )
117
118 output_dir.mkdir(parents=True, exist_ok=True)
119 manifest_artifacts: list[dict[str, Any]] = []
120 sums_entries: list[tuple[str, str]] = []
121
122 for artifact in artifacts:
123 if not artifact.path.is_file():
124 raise FinalizeError(f"artifact missing: {artifact.path}")
125 digest = sha256_file(artifact.path)
126 filename = artifact.path.name
127 refuse_disallowed_asset(filename, version=version)
128 entry: dict[str, Any] = {
129 "platform": artifact.platform,
130 "filename": filename,
131 "sha256": digest,
132 "signing": {
133 "status": artifact.signing_status,
134 "method": artifact.signing_method,
135 },
136 }
137 if artifact.arch is not None:
138 entry["arch"] = artifact.arch
139 manifest_artifacts.append(entry)
140 sums_entries.append((digest, filename))
141
142 try:
143 manifest = build_manifest(
144 version=version,
145 git_sha=git_sha,
146 artifacts=manifest_artifacts,
147 )
148 except ManifestError as exc:
149 raise FinalizeError(str(exc)) from exc
150
151 manifest_name = MANIFEST_FILENAME_TEMPLATE.format(version=version)
152 refuse_disallowed_asset(manifest_name, version=version)
153 manifest_path = output_dir / manifest_name
154 manifest_path.write_bytes(canonical_manifest_bytes(manifest))
155
156 sums_path = output_dir / SHA256SUMS_FILENAME
157 write_sha256sums(sums_path, sums_entries)
158
159 # Integrity: sha256 fields must match SHA256SUMS.txt
160 parsed = parse_sha256sums(sums_path.read_text(encoding="utf-8"))
161 for entry in manifest["artifacts"]:
162 filename = entry["filename"]
163 if parsed.get(filename) != entry["sha256"]:
164 raise FinalizeError(f"checksum mismatch for {filename}")
165
166 return manifest
File History 1 commit
sha256:6abcf1fa82a7a621ccbc945f19acdba5bc0db54569599404a1452fb4a096a199 fix(ISR): default require_independent_second_reviewer to require Human minor 1 day ago