cbm-attach 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cbm_attach/__init__.py +3 -0
- cbm_attach/__main__.py +3 -0
- cbm_attach/artifacts.py +397 -0
- cbm_attach/body.py +173 -0
- cbm_attach/cli.py +904 -0
- cbm_attach/evidence.py +63 -0
- cbm_attach/gates.py +240 -0
- cbm_attach/intoto.py +40 -0
- cbm_attach/reasons.py +171 -0
- cbm_attach/reports.py +482 -0
- cbm_attach/uploader.py +281 -0
- cbm_attach-0.1.1.dist-info/METADATA +288 -0
- cbm_attach-0.1.1.dist-info/RECORD +17 -0
- cbm_attach-0.1.1.dist-info/WHEEL +5 -0
- cbm_attach-0.1.1.dist-info/entry_points.txt +2 -0
- cbm_attach-0.1.1.dist-info/licenses/LICENSE +202 -0
- cbm_attach-0.1.1.dist-info/top_level.txt +1 -0
cbm_attach/__init__.py
ADDED
cbm_attach/__main__.py
ADDED
cbm_attach/artifacts.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
"""Build-once artifact handling and GitHub artifact download.
|
|
2
|
+
|
|
3
|
+
Hard rule: CBM never builds the package. It downloads the artifact produced by
|
|
4
|
+
the build-once job, verifies every hash in ``WHEEL_SHA256`` and
|
|
5
|
+
``RELEASE_ASSETS.sha256``, and only then may the uploader see the file.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import hashlib
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
import shutil
|
|
13
|
+
import subprocess
|
|
14
|
+
import tempfile
|
|
15
|
+
import zipfile
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from pathlib import Path, PurePosixPath
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .reasons import GateError, InfraError, InputError, Reason
|
|
21
|
+
from .reports import Report, find_report_file, load_report
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
HEX64_RE = re.compile(r"^[a-fA-F0-9]{64}$")
|
|
25
|
+
MAX_EXTRACT_BYTES = 512 * 1024 * 1024
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class Artifact:
|
|
30
|
+
name: str
|
|
31
|
+
path: Path
|
|
32
|
+
sha256: str
|
|
33
|
+
assets: dict[str, str]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _normalise_hash(value: str) -> str:
|
|
37
|
+
value = value.strip()
|
|
38
|
+
if value.startswith("sha256:"):
|
|
39
|
+
value = value[len("sha256:") :]
|
|
40
|
+
return value.lower()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _safe_relative(raw: str) -> PurePosixPath:
|
|
44
|
+
text = raw.replace("\\", "/").strip()
|
|
45
|
+
if not text:
|
|
46
|
+
raise InputError("empty path in hash manifest", reason=Reason.SCHEMA_VIOLATION)
|
|
47
|
+
path = PurePosixPath(text)
|
|
48
|
+
if (
|
|
49
|
+
path.is_absolute()
|
|
50
|
+
or any(part in {"", ".", ".."} for part in path.parts)
|
|
51
|
+
or any(":" in part for part in path.parts)
|
|
52
|
+
):
|
|
53
|
+
raise InputError(
|
|
54
|
+
f"unsafe path in hash manifest: {raw!r}",
|
|
55
|
+
reason=Reason.SCHEMA_VIOLATION,
|
|
56
|
+
)
|
|
57
|
+
return path
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_hash_manifest(path: Path) -> dict[str, str]:
|
|
61
|
+
"""Parse ``<sha256> <path>`` lines, allowing a ``sha256:`` prefix."""
|
|
62
|
+
if not path.is_file():
|
|
63
|
+
raise InputError(
|
|
64
|
+
f"{path}: hash manifest not found",
|
|
65
|
+
reason=Reason.MISSING_BUILD_ONCE_ARTIFACT,
|
|
66
|
+
)
|
|
67
|
+
result: dict[str, str] = {}
|
|
68
|
+
for line_number, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
|
|
69
|
+
text = line.strip()
|
|
70
|
+
if not text or text.startswith("#"):
|
|
71
|
+
continue
|
|
72
|
+
parts = text.split(None, 1)
|
|
73
|
+
digest = _normalise_hash(parts[0])
|
|
74
|
+
if not HEX64_RE.fullmatch(digest):
|
|
75
|
+
raise InputError(
|
|
76
|
+
f"{path}:{line_number}: invalid sha256 {parts[0]!r}",
|
|
77
|
+
reason=Reason.SCHEMA_VIOLATION,
|
|
78
|
+
)
|
|
79
|
+
if len(parts) == 1:
|
|
80
|
+
key = ""
|
|
81
|
+
else:
|
|
82
|
+
raw_path = parts[1].strip().lstrip("*").strip()
|
|
83
|
+
key = str(_safe_relative(raw_path))
|
|
84
|
+
if key in result and result[key] != digest:
|
|
85
|
+
raise InputError(
|
|
86
|
+
f"{path}:{line_number}: conflicting digests for {key!r}",
|
|
87
|
+
reason=Reason.REPORT_INCONSISTENT,
|
|
88
|
+
)
|
|
89
|
+
result[key] = digest
|
|
90
|
+
if not result:
|
|
91
|
+
raise InputError(
|
|
92
|
+
f"{path}: no hash entries",
|
|
93
|
+
reason=Reason.MISSING_BUILD_ONCE_ARTIFACT,
|
|
94
|
+
)
|
|
95
|
+
return result
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _hash_file(path: Path) -> str:
|
|
99
|
+
digest = hashlib.sha256()
|
|
100
|
+
with path.open("rb") as handle:
|
|
101
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
102
|
+
digest.update(chunk)
|
|
103
|
+
return digest.hexdigest()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _lookup(mapping: dict[str, str], wheel: Path, root: Path) -> str | None:
|
|
107
|
+
candidates = []
|
|
108
|
+
try:
|
|
109
|
+
candidates.append(str(wheel.resolve().relative_to(root.resolve())).replace("\\", "/"))
|
|
110
|
+
except ValueError:
|
|
111
|
+
pass
|
|
112
|
+
candidates.extend([wheel.name, f"dist/{wheel.name}", f"./{wheel.name}"])
|
|
113
|
+
for key in candidates:
|
|
114
|
+
if key in mapping:
|
|
115
|
+
return mapping[key]
|
|
116
|
+
return mapping.get("")
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def locate_build_once_root(root: str | Path) -> Path:
|
|
120
|
+
"""Find the directory containing ``WHEEL_SHA256`` inside an artifact zip."""
|
|
121
|
+
base = Path(root)
|
|
122
|
+
direct = base / "WHEEL_SHA256"
|
|
123
|
+
if direct.is_file():
|
|
124
|
+
return base
|
|
125
|
+
matches = sorted(base.rglob("WHEEL_SHA256"))
|
|
126
|
+
if len(matches) == 1:
|
|
127
|
+
return matches[0].parent
|
|
128
|
+
raise InputError(
|
|
129
|
+
f"{base}: expected exactly one WHEEL_SHA256, found {len(matches)}",
|
|
130
|
+
reason=Reason.MISSING_BUILD_ONCE_ARTIFACT,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def verify_build_once(directory: str | Path) -> Artifact:
|
|
135
|
+
"""Verify the build-once directory and return the single wheel."""
|
|
136
|
+
root = Path(directory)
|
|
137
|
+
if not root.is_dir():
|
|
138
|
+
raise InputError(
|
|
139
|
+
f"{root}: build-once artifact directory not found",
|
|
140
|
+
reason=Reason.MISSING_BUILD_ONCE_ARTIFACT,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
wheel_hashes = _parse_hash_manifest(root / "WHEEL_SHA256")
|
|
144
|
+
asset_hashes = _parse_hash_manifest(root / "RELEASE_ASSETS.sha256")
|
|
145
|
+
|
|
146
|
+
search_root = root / "dist" if (root / "dist").is_dir() else root
|
|
147
|
+
wheels = sorted(search_root.glob("*.whl"))
|
|
148
|
+
if len(wheels) != 1:
|
|
149
|
+
raise InputError(
|
|
150
|
+
f"{root}: expected exactly one .whl, found {len(wheels)}",
|
|
151
|
+
reason=Reason.MISSING_BUILD_ONCE_ARTIFACT,
|
|
152
|
+
)
|
|
153
|
+
wheel = wheels[0]
|
|
154
|
+
if wheel.is_symlink():
|
|
155
|
+
raise InputError(f"{wheel}: symlink is not allowed", reason=Reason.SCHEMA_VIOLATION)
|
|
156
|
+
|
|
157
|
+
actual = _hash_file(wheel)
|
|
158
|
+
expected = _lookup(wheel_hashes, wheel, root)
|
|
159
|
+
if expected is None:
|
|
160
|
+
raise InputError(
|
|
161
|
+
f"{wheel.name}: not listed in WHEEL_SHA256",
|
|
162
|
+
reason=Reason.MISSING_BUILD_ONCE_ARTIFACT,
|
|
163
|
+
)
|
|
164
|
+
if actual != expected:
|
|
165
|
+
raise GateError(
|
|
166
|
+
f"{wheel.name}: sha256 {actual} != WHEEL_SHA256 {expected}",
|
|
167
|
+
reason=Reason.ARTIFACT_HASH_MISMATCH,
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
release_expected = _lookup(asset_hashes, wheel, root)
|
|
171
|
+
if release_expected is None:
|
|
172
|
+
raise GateError(
|
|
173
|
+
f"{wheel.name}: not listed in RELEASE_ASSETS.sha256",
|
|
174
|
+
reason=Reason.ARTIFACT_HASH_MISMATCH,
|
|
175
|
+
)
|
|
176
|
+
if release_expected != actual:
|
|
177
|
+
raise GateError(
|
|
178
|
+
f"{wheel.name}: sha256 {actual} != RELEASE_ASSETS.sha256 {release_expected}",
|
|
179
|
+
reason=Reason.ARTIFACT_HASH_MISMATCH,
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
for relative, expected_hash in asset_hashes.items():
|
|
183
|
+
if not relative:
|
|
184
|
+
continue
|
|
185
|
+
# §11: the inventory lists published assets except itself. A manifest
|
|
186
|
+
# that still self-lists must not be self-hashed.
|
|
187
|
+
if relative == "RELEASE_ASSETS.sha256":
|
|
188
|
+
continue
|
|
189
|
+
parts = _safe_relative(relative).parts
|
|
190
|
+
candidate = root.joinpath(*parts)
|
|
191
|
+
if not candidate.is_file() and len(parts) == 1 and (root / "dist").is_dir():
|
|
192
|
+
# §11 uses bare basenames; the wheel may live under dist/.
|
|
193
|
+
candidate = root / "dist" / parts[0]
|
|
194
|
+
resolved_root = root.resolve()
|
|
195
|
+
resolved_candidate = candidate.resolve()
|
|
196
|
+
if (
|
|
197
|
+
resolved_root != resolved_candidate
|
|
198
|
+
and resolved_root not in resolved_candidate.parents
|
|
199
|
+
):
|
|
200
|
+
raise InputError(
|
|
201
|
+
f"{candidate}: listed path escapes the build-once directory",
|
|
202
|
+
reason=Reason.SCHEMA_VIOLATION,
|
|
203
|
+
)
|
|
204
|
+
if not candidate.is_file():
|
|
205
|
+
raise InputError(
|
|
206
|
+
f"{candidate}: listed in RELEASE_ASSETS.sha256 but missing",
|
|
207
|
+
reason=Reason.MISSING_BUILD_ONCE_ARTIFACT,
|
|
208
|
+
)
|
|
209
|
+
if candidate.is_symlink():
|
|
210
|
+
raise InputError(
|
|
211
|
+
f"{candidate}: symlink is not allowed",
|
|
212
|
+
reason=Reason.SCHEMA_VIOLATION,
|
|
213
|
+
)
|
|
214
|
+
if _hash_file(candidate) != expected_hash:
|
|
215
|
+
raise GateError(
|
|
216
|
+
f"{candidate}: sha256 mismatch against RELEASE_ASSETS.sha256",
|
|
217
|
+
reason=Reason.ARTIFACT_HASH_MISMATCH,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
return Artifact(
|
|
221
|
+
name=wheel.name,
|
|
222
|
+
path=wheel,
|
|
223
|
+
sha256=actual,
|
|
224
|
+
assets=asset_hashes,
|
|
225
|
+
)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def extract_zip(zip_path: str | Path, destination: str | Path) -> Path:
|
|
229
|
+
"""Extract an artifact zip without path traversal or symlinks."""
|
|
230
|
+
archive = Path(zip_path)
|
|
231
|
+
root = Path(destination)
|
|
232
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
233
|
+
if not zipfile.is_zipfile(archive):
|
|
234
|
+
raise InputError(
|
|
235
|
+
f"{archive}: not a zip archive",
|
|
236
|
+
reason=Reason.SCHEMA_VIOLATION,
|
|
237
|
+
)
|
|
238
|
+
total = 0
|
|
239
|
+
with zipfile.ZipFile(archive) as handle:
|
|
240
|
+
for info in handle.infolist():
|
|
241
|
+
name = info.filename.replace("\\", "/")
|
|
242
|
+
relative = _safe_relative(name)
|
|
243
|
+
mode = (info.external_attr >> 16) & 0o170000
|
|
244
|
+
if mode == 0o120000:
|
|
245
|
+
raise InputError(
|
|
246
|
+
f"{archive}: symlink member is not allowed: {name!r}",
|
|
247
|
+
reason=Reason.SCHEMA_VIOLATION,
|
|
248
|
+
)
|
|
249
|
+
total += max(0, info.file_size)
|
|
250
|
+
if total > MAX_EXTRACT_BYTES:
|
|
251
|
+
raise InputError(
|
|
252
|
+
f"{archive}: extracted size exceeds {MAX_EXTRACT_BYTES} bytes",
|
|
253
|
+
reason=Reason.SCHEMA_VIOLATION,
|
|
254
|
+
)
|
|
255
|
+
target = root.joinpath(*relative.parts)
|
|
256
|
+
resolved_root = root.resolve()
|
|
257
|
+
resolved_target = target.resolve()
|
|
258
|
+
if resolved_root != resolved_target and resolved_root not in resolved_target.parents:
|
|
259
|
+
raise InputError(
|
|
260
|
+
f"{archive}: path escapes destination: {name!r}",
|
|
261
|
+
reason=Reason.SCHEMA_VIOLATION,
|
|
262
|
+
)
|
|
263
|
+
if info.is_dir():
|
|
264
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
continue
|
|
266
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
267
|
+
with handle.open(info) as source, target.open("wb") as sink:
|
|
268
|
+
shutil.copyfileobj(source, sink)
|
|
269
|
+
return root
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _classify_gh_error(stderr: str, *, missing_reason: Reason) -> Exception:
|
|
273
|
+
text = stderr.lower()
|
|
274
|
+
if "http 404" in text or "not found" in text:
|
|
275
|
+
return InputError(f"artifact not found: {stderr.strip()}", reason=missing_reason)
|
|
276
|
+
if "http 403" in text or "http 401" in text or "forbidden" in text:
|
|
277
|
+
return InputError(
|
|
278
|
+
f"artifact access forbidden: {stderr.strip()}",
|
|
279
|
+
reason=Reason.ARTIFACT_FORBIDDEN,
|
|
280
|
+
)
|
|
281
|
+
return InfraError(
|
|
282
|
+
f"artifact fetch failed: {stderr.strip()[:500]}",
|
|
283
|
+
reason=Reason.INFRA_FETCH_FAILED,
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def fetch_artifact(
|
|
288
|
+
*,
|
|
289
|
+
repository: str,
|
|
290
|
+
run_id: str,
|
|
291
|
+
artifact_name: str,
|
|
292
|
+
destination: str | Path,
|
|
293
|
+
missing_reason: Reason,
|
|
294
|
+
gh_bin: str = "gh",
|
|
295
|
+
) -> Path:
|
|
296
|
+
"""Download one run artifact through ``gh api`` and extract it."""
|
|
297
|
+
if not re.fullmatch(r"[0-9]+", str(run_id)):
|
|
298
|
+
raise InputError(f"invalid run id: {run_id!r}", reason=Reason.MISSING_ARGUMENT)
|
|
299
|
+
if not artifact_name:
|
|
300
|
+
raise InputError("empty artifact name", reason=Reason.MISSING_ARGUMENT)
|
|
301
|
+
|
|
302
|
+
try:
|
|
303
|
+
listed = subprocess.run(
|
|
304
|
+
[
|
|
305
|
+
gh_bin,
|
|
306
|
+
"api",
|
|
307
|
+
f"repos/{repository}/actions/runs/{run_id}/artifacts?per_page=100",
|
|
308
|
+
],
|
|
309
|
+
capture_output=True,
|
|
310
|
+
text=True,
|
|
311
|
+
timeout=120,
|
|
312
|
+
check=False,
|
|
313
|
+
)
|
|
314
|
+
except FileNotFoundError as exc:
|
|
315
|
+
raise InfraError(
|
|
316
|
+
f"{gh_bin}: command not found",
|
|
317
|
+
reason=Reason.INFRA_FETCH_FAILED,
|
|
318
|
+
) from exc
|
|
319
|
+
except subprocess.TimeoutExpired as exc:
|
|
320
|
+
raise InfraError(
|
|
321
|
+
f"{gh_bin} timed out listing artifacts",
|
|
322
|
+
reason=Reason.INFRA_FETCH_FAILED,
|
|
323
|
+
) from exc
|
|
324
|
+
|
|
325
|
+
if listed.returncode != 0:
|
|
326
|
+
raise _classify_gh_error(listed.stderr, missing_reason=missing_reason)
|
|
327
|
+
|
|
328
|
+
try:
|
|
329
|
+
payload: dict[str, Any] = json.loads(listed.stdout)
|
|
330
|
+
except json.JSONDecodeError as exc:
|
|
331
|
+
raise InfraError(
|
|
332
|
+
f"cannot parse artifact listing: {exc}",
|
|
333
|
+
reason=Reason.INFRA_FETCH_FAILED,
|
|
334
|
+
) from exc
|
|
335
|
+
match = None
|
|
336
|
+
for item in payload.get("artifacts", []):
|
|
337
|
+
if isinstance(item, dict) and item.get("name") == artifact_name:
|
|
338
|
+
match = item
|
|
339
|
+
break
|
|
340
|
+
if match is None:
|
|
341
|
+
raise InputError(
|
|
342
|
+
f"run {run_id}: artifact {artifact_name!r} not found",
|
|
343
|
+
reason=missing_reason,
|
|
344
|
+
)
|
|
345
|
+
artifact_id = match.get("id")
|
|
346
|
+
if not isinstance(artifact_id, int):
|
|
347
|
+
raise InfraError(
|
|
348
|
+
f"run {run_id}: artifact {artifact_name!r} has no id",
|
|
349
|
+
reason=Reason.INFRA_FETCH_FAILED,
|
|
350
|
+
)
|
|
351
|
+
|
|
352
|
+
root = Path(destination)
|
|
353
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
354
|
+
with tempfile.NamedTemporaryFile(suffix=".zip", delete=False) as handle:
|
|
355
|
+
zip_path = Path(handle.name)
|
|
356
|
+
try:
|
|
357
|
+
with zip_path.open("wb") as sink:
|
|
358
|
+
download = subprocess.run(
|
|
359
|
+
[
|
|
360
|
+
gh_bin,
|
|
361
|
+
"api",
|
|
362
|
+
f"repos/{repository}/actions/artifacts/{artifact_id}/zip",
|
|
363
|
+
],
|
|
364
|
+
stdout=sink,
|
|
365
|
+
stderr=subprocess.PIPE,
|
|
366
|
+
timeout=300,
|
|
367
|
+
check=False,
|
|
368
|
+
)
|
|
369
|
+
if download.returncode != 0:
|
|
370
|
+
stderr = download.stderr.decode("utf-8", "replace") if download.stderr else ""
|
|
371
|
+
raise _classify_gh_error(stderr, missing_reason=missing_reason)
|
|
372
|
+
return extract_zip(zip_path, root)
|
|
373
|
+
finally:
|
|
374
|
+
zip_path.unlink(missing_ok=True)
|
|
375
|
+
|
|
376
|
+
|
|
377
|
+
def fetch_report(
|
|
378
|
+
*,
|
|
379
|
+
repository: str,
|
|
380
|
+
run_id: str,
|
|
381
|
+
artifact_name: str,
|
|
382
|
+
kind: str,
|
|
383
|
+
destination: str | Path,
|
|
384
|
+
gh_bin: str = "gh",
|
|
385
|
+
) -> Report:
|
|
386
|
+
"""Fetch an artifact and load the single report JSON it contains."""
|
|
387
|
+
missing = Reason.MISSING_CBE_REPORT if kind == "cbe" else Reason.MISSING_CBP_REPORT
|
|
388
|
+
root = fetch_artifact(
|
|
389
|
+
repository=repository,
|
|
390
|
+
run_id=run_id,
|
|
391
|
+
artifact_name=artifact_name,
|
|
392
|
+
destination=destination,
|
|
393
|
+
missing_reason=missing,
|
|
394
|
+
gh_bin=gh_bin,
|
|
395
|
+
)
|
|
396
|
+
report_path = find_report_file(root, kind)
|
|
397
|
+
return load_report(report_path, kind)
|
cbm_attach/body.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""Release-body fragments for CBM.
|
|
2
|
+
|
|
3
|
+
CBM never writes the whole CBS Release body. It emits one marker-delimited
|
|
4
|
+
fragment. The CBS body generator merges it after publish; an asynchronous CBM
|
|
5
|
+
run may replace exactly this marker block only after a verified upload, using a
|
|
6
|
+
read-modify-write against the latest body. Failure leaves the body untouched.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
MARKER_START = "<!-- CBM:START -->"
|
|
15
|
+
MARKER_END = "<!-- CBM:END -->"
|
|
16
|
+
P2_04_PHRASE = "declared as attached but not uploaded"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _text(value: Any, *, fallback: str = "not available") -> str:
|
|
20
|
+
if value is None:
|
|
21
|
+
return fallback
|
|
22
|
+
text = str(value).replace("`", "'").replace("\r", " ").replace("\n", " ").strip()
|
|
23
|
+
return text or fallback
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _hash(value: Any) -> str:
|
|
27
|
+
return _text(value, fallback="not available")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _declared(report: dict[str, Any]) -> bool:
|
|
31
|
+
return report.get("declared_attached") is True
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def render_not_uploaded(report: dict[str, Any]) -> str:
|
|
35
|
+
declared = _declared(report)
|
|
36
|
+
lines = [
|
|
37
|
+
"## NOT_UPLOADED",
|
|
38
|
+
"",
|
|
39
|
+
"- CBM: NOT_UPLOADED",
|
|
40
|
+
f" - cbm_gate_status: `{_text(report.get('cbm_gate_status'))}`",
|
|
41
|
+
" - upload_status: `NOT_UPLOADED`",
|
|
42
|
+
f" - reason: `{_text(report.get('reason'))}`",
|
|
43
|
+
f" - declared_attached: `{str(declared).lower()}`",
|
|
44
|
+
f" - cbe_report_hash: `{_hash(report.get('cbe_report_hash'))}`",
|
|
45
|
+
f" - cbp_report_hash: `{_hash(report.get('cbp_report_hash'))}`",
|
|
46
|
+
f" - cbp_release_body_hash: `{_hash(report.get('cbp_release_body_hash'))}`",
|
|
47
|
+
f" - cbp_body_hash_scope: `{_text(report.get('cbp_body_hash_scope'))}`",
|
|
48
|
+
"- Skip accounting: `NOT_UPLOADED` is not counted in the SKIP ratio (F6).",
|
|
49
|
+
]
|
|
50
|
+
if declared:
|
|
51
|
+
lines += [
|
|
52
|
+
"- Publish impact: `NOT_UPLOADED` does not block the CBS main package by itself (F6).",
|
|
53
|
+
"",
|
|
54
|
+
"CBM is declared as attached in this release train but was not uploaded:",
|
|
55
|
+
f"{P2_04_PHRASE}(声明附带但未上传)。",
|
|
56
|
+
]
|
|
57
|
+
else:
|
|
58
|
+
lines += [
|
|
59
|
+
"- Publish impact: CBM is not claimed by this release train; the CBS main package is unaffected (F6).",
|
|
60
|
+
]
|
|
61
|
+
detail = report.get("reason_detail")
|
|
62
|
+
if detail:
|
|
63
|
+
lines += ["", f"> {_text(detail)}"]
|
|
64
|
+
return "\n".join(lines) + "\n"
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def render_infra(report: dict[str, Any]) -> str:
|
|
68
|
+
declared = _declared(report)
|
|
69
|
+
lines = [
|
|
70
|
+
"## Infrastructure failures",
|
|
71
|
+
"",
|
|
72
|
+
"- CBM: NOT_UPLOADED",
|
|
73
|
+
f" - cbm_gate_status: `{_text(report.get('cbm_gate_status'))}`",
|
|
74
|
+
" - upload_status: `NOT_UPLOADED`",
|
|
75
|
+
f" - reason: `{_text(report.get('reason'))}`",
|
|
76
|
+
f" - cbe_report_hash: `{_hash(report.get('cbe_report_hash'))}`",
|
|
77
|
+
f" - cbp_report_hash: `{_hash(report.get('cbp_report_hash'))}`",
|
|
78
|
+
f" - cbp_release_body_hash: `{_hash(report.get('cbp_release_body_hash'))}`",
|
|
79
|
+
f" - cbp_body_hash_scope: `{_text(report.get('cbp_body_hash_scope'))}`",
|
|
80
|
+
"- Skip accounting: `SKIPPED_INFRA` is not counted in the SKIP ratio (F5).",
|
|
81
|
+
"- Publish impact: CBM upload is blocked; the CBS main package is not blocked by CBM (F6).",
|
|
82
|
+
]
|
|
83
|
+
detail = report.get("reason_detail")
|
|
84
|
+
if detail:
|
|
85
|
+
lines += ["", f"> {_text(detail)}"]
|
|
86
|
+
if declared:
|
|
87
|
+
lines += [
|
|
88
|
+
"",
|
|
89
|
+
"CBM is declared as attached in this release train but was not uploaded:",
|
|
90
|
+
f"{P2_04_PHRASE}(声明附带但未上传)。",
|
|
91
|
+
]
|
|
92
|
+
return "\n".join(lines) + "\n"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def render_unverified(report: dict[str, Any]) -> str:
|
|
96
|
+
lines = [
|
|
97
|
+
"## Upload verification failures",
|
|
98
|
+
"",
|
|
99
|
+
"- CBM: UPLOADED_UNVERIFIED",
|
|
100
|
+
f" - cbm_gate_status: `{_text(report.get('cbm_gate_status'))}`",
|
|
101
|
+
" - upload_status: `UPLOADED_UNVERIFIED`",
|
|
102
|
+
f" - reason: `{_text(report.get('reason'))}`",
|
|
103
|
+
f" - asset: `{_text(report.get('artifact_name'))}`",
|
|
104
|
+
f" - artifact_sha256: `{_hash(report.get('artifact_sha256'))}`",
|
|
105
|
+
f" - uploaded_asset_digest: `{_hash(report.get('uploaded_asset_digest'))}`",
|
|
106
|
+
f" - cbe_report_hash: `{_hash(report.get('cbe_report_hash'))}`",
|
|
107
|
+
f" - cbp_report_hash: `{_hash(report.get('cbp_report_hash'))}`",
|
|
108
|
+
f" - cbp_release_body_hash: `{_hash(report.get('cbp_release_body_hash'))}`",
|
|
109
|
+
f" - cbp_body_hash_scope: `{_text(report.get('cbp_body_hash_scope'))}`",
|
|
110
|
+
"",
|
|
111
|
+
"This asset must not be treated as PASS. Re-run the upload or regenerate the release asset.",
|
|
112
|
+
]
|
|
113
|
+
detail = report.get("reason_detail")
|
|
114
|
+
if detail:
|
|
115
|
+
lines += ["", f"> {_text(detail)}"]
|
|
116
|
+
return "\n".join(lines) + "\n"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def render_uploaded(report: dict[str, Any]) -> str:
|
|
120
|
+
lines = [
|
|
121
|
+
"## Optional component attachments",
|
|
122
|
+
"",
|
|
123
|
+
"- CBM: UPLOADED_VERIFIED",
|
|
124
|
+
" - cbm_gate_status: `PASS`",
|
|
125
|
+
" - upload_status: `UPLOADED_VERIFIED`",
|
|
126
|
+
f" - asset: `{_text(report.get('artifact_name'))}`",
|
|
127
|
+
f" - artifact_sha256: `{_hash(report.get('artifact_sha256'))}`",
|
|
128
|
+
f" - uploaded_asset_digest: `{_hash(report.get('uploaded_asset_digest'))}`",
|
|
129
|
+
f" - cbe_report_hash: `{_hash(report.get('cbe_report_hash'))}`",
|
|
130
|
+
f" - cbp_report_hash: `{_hash(report.get('cbp_report_hash'))}`",
|
|
131
|
+
f" - cbp_release_body_hash: `{_hash(report.get('cbp_release_body_hash'))}`",
|
|
132
|
+
f" - cbp_body_hash_scope: `{_text(report.get('cbp_body_hash_scope'))}`",
|
|
133
|
+
]
|
|
134
|
+
url = report.get("uploaded_asset_url")
|
|
135
|
+
if url:
|
|
136
|
+
lines.append(f" - uploaded_asset_url: {_text(url)}")
|
|
137
|
+
return "\n".join(lines) + "\n"
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def render(report: dict[str, Any]) -> str:
|
|
141
|
+
"""Render the body section for this report, without markers."""
|
|
142
|
+
status = report.get("upload_status")
|
|
143
|
+
if status == "UPLOADED_VERIFIED":
|
|
144
|
+
return render_uploaded(report)
|
|
145
|
+
if status == "UPLOADED_UNVERIFIED":
|
|
146
|
+
return render_unverified(report)
|
|
147
|
+
if report.get("cbm_gate_status") == "SKIPPED_INFRA":
|
|
148
|
+
return render_infra(report)
|
|
149
|
+
return render_not_uploaded(report)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def wrap_marker(text: str) -> str:
|
|
153
|
+
return f"{MARKER_START}\n{text.rstrip()}\n{MARKER_END}\n"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def render_marked(report: dict[str, Any]) -> str:
|
|
157
|
+
return wrap_marker(render(report))
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def replace_marker_block(existing: str, block: str) -> str | None:
|
|
161
|
+
"""Replace exactly one marker block. Return None when markers are absent.
|
|
162
|
+
|
|
163
|
+
The caller must not append a second section by guesswork. A missing marker
|
|
164
|
+
means the CBS body generator did not reserve the block; regenerate the body
|
|
165
|
+
instead of risking duplicate or stale evidence.
|
|
166
|
+
"""
|
|
167
|
+
pattern = re.compile(
|
|
168
|
+
re.escape(MARKER_START) + r".*?" + re.escape(MARKER_END),
|
|
169
|
+
re.DOTALL,
|
|
170
|
+
)
|
|
171
|
+
if not pattern.search(existing):
|
|
172
|
+
return None
|
|
173
|
+
return pattern.sub(lambda _match: wrap_marker(block).rstrip(), existing, count=1)
|