techtree 0.1.0__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.
Files changed (174) hide show
  1. techtree/__init__.py +35 -0
  2. techtree/__main__.py +14 -0
  3. techtree/canonical.py +239 -0
  4. techtree/catalog/__init__.py +25 -0
  5. techtree/catalog/repository.py +400 -0
  6. techtree/catalog/service.py +419 -0
  7. techtree/cli/__init__.py +1 -0
  8. techtree/cli/app.py +416 -0
  9. techtree/cli/commands/__init__.py +1 -0
  10. techtree/cli/commands/climb.py +1223 -0
  11. techtree/cli/commands/doctor.py +147 -0
  12. techtree/cli/commands/engine.py +207 -0
  13. techtree/cli/commands/proof.py +556 -0
  14. techtree/cli/commands/publish.py +447 -0
  15. techtree/cli/commands/release.py +303 -0
  16. techtree/cli/commands/run.py +1067 -0
  17. techtree/cli/commands/setup.py +181 -0
  18. techtree/cli/commands/skill.py +221 -0
  19. techtree/cli/commands/uplift.py +698 -0
  20. techtree/cli/commands/withdraw.py +212 -0
  21. techtree/cli/confirm.py +47 -0
  22. techtree/cli/context.py +96 -0
  23. techtree/cli/invoke.py +220 -0
  24. techtree/cli/output.py +280 -0
  25. techtree/constants.py +138 -0
  26. techtree/crypto.py +128 -0
  27. techtree/doctor/__init__.py +1 -0
  28. techtree/doctor/checks.py +675 -0
  29. techtree/doctor/execution_checks.py +435 -0
  30. techtree/doctor/service.py +326 -0
  31. techtree/drafts/__init__.py +32 -0
  32. techtree/drafts/source.py +146 -0
  33. techtree/drafts/store.py +992 -0
  34. techtree/engines/__init__.py +1 -0
  35. techtree/engines/bundle.py +251 -0
  36. techtree/engines/installer.py +679 -0
  37. techtree/engines/registry.py +235 -0
  38. techtree/engines/runner.py +170 -0
  39. techtree/errors.py +262 -0
  40. techtree/fs.py +234 -0
  41. techtree/harness.py +108 -0
  42. techtree/identity/__init__.py +41 -0
  43. techtree/identity/models.py +113 -0
  44. techtree/identity/service.py +199 -0
  45. techtree/identity/store.py +263 -0
  46. techtree/ids.py +85 -0
  47. techtree/manifests/__init__.py +39 -0
  48. techtree/manifests/builder.py +433 -0
  49. techtree/manifests/compare.py +376 -0
  50. techtree/models/__init__.py +282 -0
  51. techtree/models/base.py +201 -0
  52. techtree/models/campaign.py +484 -0
  53. techtree/models/catalog.py +227 -0
  54. techtree/models/cli.py +151 -0
  55. techtree/models/climb.py +254 -0
  56. techtree/models/data_policy.py +130 -0
  57. techtree/models/engine.py +156 -0
  58. techtree/models/episode_receipt.py +130 -0
  59. techtree/models/evaluation_backend.py +113 -0
  60. techtree/models/experiment.py +154 -0
  61. techtree/models/run.py +214 -0
  62. techtree/models/skill.py +156 -0
  63. techtree/models/uplift_report.py +158 -0
  64. techtree/models/validation.py +299 -0
  65. techtree/paths.py +116 -0
  66. techtree/presentation/__init__.py +31 -0
  67. techtree/presentation/build.py +1242 -0
  68. techtree/presentation/compact.py +246 -0
  69. techtree/presentation/evidence.py +169 -0
  70. techtree/presentation/models.py +358 -0
  71. techtree/presentation/rich.py +312 -0
  72. techtree/presentation/sanitize.py +156 -0
  73. techtree/publication/__init__.py +44 -0
  74. techtree/publication/address.py +180 -0
  75. techtree/publication/coordinates.py +26 -0
  76. techtree/publication/journal.py +212 -0
  77. techtree/publication/keccak.py +183 -0
  78. techtree/publication/models.py +209 -0
  79. techtree/publication/offer.py +35 -0
  80. techtree/publication/service.py +618 -0
  81. techtree/publication/transport.py +296 -0
  82. techtree/publication/verify.py +242 -0
  83. techtree/publication/withdraw.py +156 -0
  84. techtree/py.typed +0 -0
  85. techtree/receipts/__init__.py +52 -0
  86. techtree/receipts/bundle.py +578 -0
  87. techtree/receipts/compare.py +1065 -0
  88. techtree/receipts/episode.py +672 -0
  89. techtree/receipts/execution.py +630 -0
  90. techtree/receipts/observed.py +474 -0
  91. techtree/receipts/set.py +336 -0
  92. techtree/receipts/uplift.py +655 -0
  93. techtree/receipts/verify.py +1055 -0
  94. techtree/release/__init__.py +9 -0
  95. techtree/release/bootstrap.py +509 -0
  96. techtree/release/checks.py +376 -0
  97. techtree/release/document.py +125 -0
  98. techtree/release/generate.py +221 -0
  99. techtree/release/models.py +293 -0
  100. techtree/release/provenance.py +109 -0
  101. techtree/resources/catalog/campaigns/hello-world-climb.json +1 -0
  102. techtree/resources/catalog/catalog.json +32 -0
  103. techtree/resources/catalog/climbs/hello-world-climb.json +1 -0
  104. techtree/resources/catalog/data-policies/hello-world-climb.json +1 -0
  105. techtree/resources/catalog/taskset-validations/hello-world-climb.json +1 -0
  106. techtree/resources/catalog/validation-evidence/hello-world-climb.json +1 -0
  107. techtree/resources/engines/default/engine.json +20 -0
  108. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/__init__.py +7 -0
  109. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/algorithm.py +136 -0
  110. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/dataset.py +156 -0
  111. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/env.py +48 -0
  112. techtree/resources/engines/default/packages/procedure-transfer-v1/procedure_transfer_v1/taskset.py +163 -0
  113. techtree/resources/engines/default/packages/procedure-transfer-v1/pyproject.toml +13 -0
  114. techtree/resources/engines/default/pyproject.toml +23 -0
  115. techtree/resources/engines/default/tools/inspect_taskset.py +124 -0
  116. techtree/resources/engines/default/tools/normalize_eval_output.py +470 -0
  117. techtree/resources/engines/default/tools/normalize_validation.py +222 -0
  118. techtree/resources/engines/default/uv.lock +1758 -0
  119. techtree/resources/harness/hermes-agent-0.19.0.json +69 -0
  120. techtree/resources/release/build-provenance.json +4 -0
  121. techtree/resources/release/release-core.json +24 -0
  122. techtree/runs/__init__.py +31 -0
  123. techtree/runs/artifacts.py +750 -0
  124. techtree/runs/child_registry.py +228 -0
  125. techtree/runs/events.py +478 -0
  126. techtree/runs/executor.py +140 -0
  127. techtree/runs/fake.py +741 -0
  128. techtree/runs/launcher.py +253 -0
  129. techtree/runs/machine.py +489 -0
  130. techtree/runs/real.py +789 -0
  131. techtree/runs/service.py +616 -0
  132. techtree/runs/store.py +555 -0
  133. techtree/runs/validation.py +259 -0
  134. techtree/runs/variants.py +684 -0
  135. techtree/settings.py +143 -0
  136. techtree/skills/__init__.py +14 -0
  137. techtree/skills/archive.py +282 -0
  138. techtree/skills/policy.py +62 -0
  139. techtree/skills/scanner.py +394 -0
  140. techtree/skills/service.py +752 -0
  141. techtree/skills/starter.py +434 -0
  142. techtree/tasksets/__init__.py +1 -0
  143. techtree/tasksets/membership.py +269 -0
  144. techtree/tasksets/provider.py +207 -0
  145. techtree/tasksets/resolver.py +311 -0
  146. techtree/tasksets/service.py +484 -0
  147. techtree/tasksets/verifiers_cli.py +538 -0
  148. techtree/uplift/__init__.py +20 -0
  149. techtree/uplift/context.py +544 -0
  150. techtree/uplift/derive.py +203 -0
  151. techtree/uplift/public_tasks.py +151 -0
  152. techtree/uplift/service.py +719 -0
  153. techtree/uplift/source.py +160 -0
  154. techtree/verifiers/__init__.py +31 -0
  155. techtree/verifiers/budget.py +219 -0
  156. techtree/verifiers/child.py +633 -0
  157. techtree/verifiers/compiler.py +432 -0
  158. techtree/verifiers/config.py +365 -0
  159. techtree/verifiers/credentials.py +321 -0
  160. techtree/verifiers/image.py +126 -0
  161. techtree/verifiers/models.py +527 -0
  162. techtree/verifiers/outputs.py +368 -0
  163. techtree/verifiers/progress.py +192 -0
  164. techtree/verifiers/supervisor.py +341 -0
  165. techtree/verifiers/verify.py +782 -0
  166. techtree/version.py +39 -0
  167. techtree/worker/__init__.py +18 -0
  168. techtree/worker/execute.py +487 -0
  169. techtree/worker/main.py +57 -0
  170. techtree-0.1.0.dist-info/METADATA +344 -0
  171. techtree-0.1.0.dist-info/RECORD +174 -0
  172. techtree-0.1.0.dist-info/WHEEL +4 -0
  173. techtree-0.1.0.dist-info/entry_points.txt +3 -0
  174. techtree-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,376 @@
1
+ """Whether one release still agrees with itself. Spec sections 9.5 and 9.7.
2
+
3
+ A ReleaseCore is a set of claims about things that live elsewhere: an engine
4
+ bundle, a catalog index, an installed package, a Climb, two Skills. Each
5
+ claim is checked here against the thing it names, one check per claim, so that
6
+ a failure says which coordinate drifted rather than that "the release is
7
+ broken".
8
+
9
+ Two verdicts are not enough. A check that could not run is not a check that
10
+ passed, and reporting it as one is how a release ships with something nobody
11
+ looked at. So a check is ``passed``, ``failed``, or ``skipped``, and a
12
+ verification is verified when nothing failed — with every skip still visible in
13
+ the output, named and explained.
14
+
15
+ Two coordinates cannot be settled from inside an installed CLI at all: the two
16
+ founder Skill digests, whose bytes live with the plugin and the website, and
17
+ the address the starter Skill is published at, which a verification that
18
+ contacts nothing must not go and fetch. Those are reported as skips with their
19
+ values in the detail, so a reader sees the coordinate and sees that nobody
20
+ here checked it.
21
+
22
+ Every coordinate a release names is a real one (decisions 0026), so there is
23
+ nothing here that treats a value as provisional: a document that parsed is a
24
+ document whose every coordinate was chosen, and each one is compared against
25
+ the thing it names.
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ from collections.abc import Mapping
31
+ from dataclasses import dataclass
32
+ from typing import Final, Literal, Self
33
+
34
+ from pydantic import model_validator
35
+
36
+ from techtree.catalog.repository import EmbeddedCatalogRepository
37
+ from techtree.engines.bundle import engine_bundle_digest
38
+ from techtree.errors import ValidationError
39
+ from techtree.models.base import Digest, NonEmptyString, ProtocolModel
40
+ from techtree.release.document import (
41
+ document_digest,
42
+ is_canonical_document,
43
+ parse_release_core,
44
+ )
45
+ from techtree.release.generate import ReleaseSources
46
+ from techtree.release.models import ReleaseCore
47
+ from techtree.version import package_version
48
+
49
+ __all__ = [
50
+ "RELEASE_COORDINATE_MISMATCH",
51
+ "RELEASE_CORE_DIGEST_MISMATCH",
52
+ "RELEASE_CORE_INVALID",
53
+ "RELEASE_CORE_NOT_CANONICAL",
54
+ "ReleaseCheck",
55
+ "ReleaseFacts",
56
+ "ReleaseVerification",
57
+ "local_release_facts",
58
+ "verify_release_core",
59
+ ]
60
+
61
+ #: Stable failure codes. A caller branches on these, never on wording.
62
+ RELEASE_CORE_INVALID: Final = "release_core_invalid"
63
+ RELEASE_CORE_NOT_CANONICAL: Final = "release_core_not_canonical"
64
+ RELEASE_CORE_DIGEST_MISMATCH: Final = "release_core_digest_mismatch"
65
+ RELEASE_COORDINATE_MISMATCH: Final = "release_coordinate_mismatch"
66
+
67
+ #: Why a check could not be run. Never a failure and never a pass.
68
+ RELEASE_CHECK_NOT_APPLICABLE: Final = "release_check_not_applicable"
69
+
70
+ type ReleaseCheckStatus = Literal["passed", "failed", "skipped"]
71
+
72
+
73
+ class ReleaseCheck(ProtocolModel):
74
+ """One named claim, and what comparing it against reality found."""
75
+
76
+ id: NonEmptyString
77
+ status: ReleaseCheckStatus
78
+ code: NonEmptyString
79
+ detail: NonEmptyString
80
+
81
+
82
+ class ReleaseVerification(ProtocolModel):
83
+ """Every check a release verification ran, and its single verdict."""
84
+
85
+ verified: bool
86
+ checks: list[ReleaseCheck]
87
+
88
+ @property
89
+ def failures(self) -> list[ReleaseCheck]:
90
+ """Return every check that failed."""
91
+ return [check for check in self.checks if check.status == "failed"]
92
+
93
+ @property
94
+ def skipped(self) -> list[ReleaseCheck]:
95
+ """Return every check that could not be run."""
96
+ return [check for check in self.checks if check.status == "skipped"]
97
+
98
+ @model_validator(mode="after")
99
+ def _check_verdict_follows_the_checks(self) -> Self:
100
+ """Refuse a verdict the checks do not support."""
101
+ if self.verified != (not self.failures):
102
+ raise ValueError(
103
+ "a release verifies exactly when no check failed; this one "
104
+ f"claims verified={self.verified} with {len(self.failures)} "
105
+ "failed checks"
106
+ )
107
+ return self
108
+
109
+
110
+ @dataclass(frozen=True)
111
+ class ReleaseFacts:
112
+ """What this machine and this source tree actually contain.
113
+
114
+ Plain values rather than live objects, so that a verification is a pure
115
+ comparison and a test can break exactly one fact at a time.
116
+ """
117
+
118
+ package_version: str
119
+ protocol_version: str
120
+ engine_digest: Digest
121
+ catalog_digest: Digest
122
+ climb_references: tuple[str, ...]
123
+ subject_hermes_versions: Mapping[str, str]
124
+
125
+
126
+ def local_release_facts(sources: ReleaseSources) -> ReleaseFacts:
127
+ """Read the facts a release is checked against out of one source tree."""
128
+ catalog = EmbeddedCatalogRepository(sources.catalog_root)
129
+ references = tuple(catalog.list_climb_references())
130
+ harnesses: dict[str, str] = {}
131
+ for reference in references:
132
+ climb = catalog.load_climb(reference)
133
+ campaign = catalog.load_campaign(climb.campaign_spec_digest)
134
+ harnesses[reference] = campaign.subject.harness.version
135
+
136
+ return ReleaseFacts(
137
+ package_version=package_version(),
138
+ protocol_version=sources.protocol_version,
139
+ engine_digest=engine_bundle_digest(sources.engine_root),
140
+ catalog_digest=document_digest(sources.catalog_index_bytes()),
141
+ climb_references=references,
142
+ subject_hermes_versions=harnesses,
143
+ )
144
+
145
+
146
+ def verify_release_core(
147
+ raw: bytes,
148
+ facts: ReleaseFacts,
149
+ *,
150
+ expected_digest: Digest | None = None,
151
+ ) -> ReleaseVerification:
152
+ """Check a stored ReleaseCore against the build it claims to describe."""
153
+ checks = [_document_check(raw)]
154
+ if checks[0].status == "failed":
155
+ # Nothing below can be compared against a document that is not one.
156
+ return _verification(checks)
157
+
158
+ core = parse_release_core(raw)
159
+ checks.extend(
160
+ [
161
+ _canonical_bytes_check(raw),
162
+ _digest_check(raw, expected_digest),
163
+ _cli_version_check(core, facts),
164
+ _protocol_version_check(core, facts),
165
+ _engine_digest_check(core, facts),
166
+ _catalog_digest_check(core, facts),
167
+ _intro_climb_check(core, facts),
168
+ _subject_hermes_check(core, facts),
169
+ *_founder_skill_checks(core),
170
+ _starter_skill_source_check(core),
171
+ ]
172
+ )
173
+ return _verification(checks)
174
+
175
+
176
+ # ---------------------------------------------------------------------------
177
+ # The checks
178
+ # ---------------------------------------------------------------------------
179
+
180
+
181
+ def _document_check(raw: bytes) -> ReleaseCheck:
182
+ try:
183
+ core = parse_release_core(raw)
184
+ except ValidationError as error:
185
+ return _failed("release_core_document", RELEASE_CORE_INVALID, str(error))
186
+
187
+ return _passed(
188
+ "release_core_document",
189
+ f"the ReleaseCore is valid: release {core.release_id}, with every "
190
+ "coordinate concrete.",
191
+ )
192
+
193
+
194
+ def _canonical_bytes_check(raw: bytes) -> ReleaseCheck:
195
+ if is_canonical_document(raw):
196
+ return _passed(
197
+ "release_core_canonical_bytes",
198
+ "the stored bytes are in the one published spelling.",
199
+ )
200
+ return _failed(
201
+ "release_core_canonical_bytes",
202
+ RELEASE_CORE_NOT_CANONICAL,
203
+ "the stored bytes are not in the published spelling, so their digest "
204
+ "is not the digest the generator would produce; regenerate rather "
205
+ "than edit this file.",
206
+ )
207
+
208
+
209
+ def _digest_check(raw: bytes, expected: Digest | None) -> ReleaseCheck:
210
+ actual = document_digest(raw)
211
+ if expected is None:
212
+ return _skipped(
213
+ "release_core_digest",
214
+ f"no expected digest was given; these bytes are {actual}.",
215
+ )
216
+ if actual == expected:
217
+ return _passed(
218
+ "release_core_digest",
219
+ f"the embedded ReleaseCore is {actual}, as expected.",
220
+ )
221
+ return _failed(
222
+ "release_core_digest",
223
+ RELEASE_CORE_DIGEST_MISMATCH,
224
+ f"the embedded ReleaseCore is {actual}, not the expected {expected}.",
225
+ )
226
+
227
+
228
+ def _cli_version_check(core: ReleaseCore, facts: ReleaseFacts) -> ReleaseCheck:
229
+ if core.cli_version == facts.package_version:
230
+ return _passed(
231
+ "cli_version",
232
+ f"the installed package is {facts.package_version}, the version "
233
+ "this release names.",
234
+ )
235
+ return _failed(
236
+ "cli_version",
237
+ RELEASE_COORDINATE_MISMATCH,
238
+ f"this release names CLI version {core.cli_version}, but the "
239
+ f"installed package is {facts.package_version}.",
240
+ )
241
+
242
+
243
+ def _protocol_version_check(core: ReleaseCore, facts: ReleaseFacts) -> ReleaseCheck:
244
+ return _equality(
245
+ "protocol_version",
246
+ claimed=core.protocol_version,
247
+ actual=facts.protocol_version,
248
+ subject="the protocol version",
249
+ )
250
+
251
+
252
+ def _engine_digest_check(core: ReleaseCore, facts: ReleaseFacts) -> ReleaseCheck:
253
+ return _equality(
254
+ "engine_digest",
255
+ claimed=core.engine_digest,
256
+ actual=facts.engine_digest,
257
+ subject="the managed engine bundle",
258
+ )
259
+
260
+
261
+ def _catalog_digest_check(core: ReleaseCore, facts: ReleaseFacts) -> ReleaseCheck:
262
+ return _equality(
263
+ "catalog_digest",
264
+ claimed=core.catalog_digest,
265
+ actual=facts.catalog_digest,
266
+ subject="the catalog index",
267
+ )
268
+
269
+
270
+ def _intro_climb_check(core: ReleaseCore, facts: ReleaseFacts) -> ReleaseCheck:
271
+ if core.intro_climb_reference in facts.climb_references:
272
+ return _passed(
273
+ "intro_climb_reference",
274
+ f"this build ships {core.intro_climb_reference}, the introductory "
275
+ "Climb the release names.",
276
+ )
277
+ return _failed(
278
+ "intro_climb_reference",
279
+ RELEASE_COORDINATE_MISMATCH,
280
+ f"this release leads with {core.intro_climb_reference}, which this "
281
+ f"build does not ship; it ships {list(facts.climb_references)}.",
282
+ )
283
+
284
+
285
+ def _subject_hermes_check(core: ReleaseCore, facts: ReleaseFacts) -> ReleaseCheck:
286
+ pinned = facts.subject_hermes_versions.get(core.intro_climb_reference)
287
+ if pinned is None:
288
+ return _failed(
289
+ "subject_hermes_version",
290
+ RELEASE_COORDINATE_MISMATCH,
291
+ "the subject harness version cannot be compared because this "
292
+ f"build ships no Climb called {core.intro_climb_reference}.",
293
+ )
294
+ return _equality(
295
+ "subject_hermes_version",
296
+ claimed=core.subject_hermes_version,
297
+ actual=pinned,
298
+ subject="the subject harness the introductory Campaign pins",
299
+ )
300
+
301
+
302
+ def _founder_skill_checks(core: ReleaseCore) -> list[ReleaseCheck]:
303
+ """Report each founder Skill digest, which this package cannot resolve.
304
+
305
+ The CLI ships no Skill bytes — the starter Skill is served by the website
306
+ and the operator Skill is bundled by the plugin — so each digest is checked
307
+ where those bytes are, not here (spec section 9.6).
308
+ """
309
+ return [
310
+ _skipped(
311
+ field,
312
+ f"this release binds {field.removesuffix('_digest')} to "
313
+ f"{getattr(core, field)}; the CLI package carries no Skill bytes, "
314
+ "so the plugin and the website verify it against the Skill they "
315
+ "serve.",
316
+ )
317
+ for field in ("starter_skill_digest", "skill_improver_digest")
318
+ ]
319
+
320
+
321
+ def _starter_skill_source_check(core: ReleaseCore) -> ReleaseCheck:
322
+ """Report where the starter Skill is published, without going to look.
323
+
324
+ ``release verify`` contacts nothing, which is the property that lets a host
325
+ agent run it on a machine that has just been installed and may not be
326
+ online. So a bound address is reported rather than resolved: what the bytes
327
+ at the other end have to be is already settled by the digest beside it, and
328
+ that comparison happens where the fetch does (spec section 10.5).
329
+ """
330
+ return _skipped(
331
+ "starter_skill_object_url",
332
+ f"this release publishes the starter Skill at "
333
+ f"{core.starter_skill_object_url}; nothing is fetched here, and what "
334
+ "is served is checked against the digest when it is obtained.",
335
+ )
336
+
337
+
338
+ # ---------------------------------------------------------------------------
339
+ # Building checks
340
+ # ---------------------------------------------------------------------------
341
+
342
+
343
+ def _equality(
344
+ identifier: str, *, claimed: str, actual: str, subject: str
345
+ ) -> ReleaseCheck:
346
+ if claimed == actual:
347
+ return _passed(identifier, f"{subject} is {actual}, as the release says.")
348
+ return _failed(
349
+ identifier,
350
+ RELEASE_COORDINATE_MISMATCH,
351
+ f"the release says {subject} is {claimed}; it is {actual}.",
352
+ )
353
+
354
+
355
+ def _passed(identifier: str, detail: str) -> ReleaseCheck:
356
+ return ReleaseCheck(id=identifier, status="passed", code="ok", detail=detail)
357
+
358
+
359
+ def _failed(identifier: str, code: str, detail: str) -> ReleaseCheck:
360
+ return ReleaseCheck(id=identifier, status="failed", code=code, detail=detail)
361
+
362
+
363
+ def _skipped(identifier: str, detail: str) -> ReleaseCheck:
364
+ return ReleaseCheck(
365
+ id=identifier,
366
+ status="skipped",
367
+ code=RELEASE_CHECK_NOT_APPLICABLE,
368
+ detail=detail,
369
+ )
370
+
371
+
372
+ def _verification(checks: list[ReleaseCheck]) -> ReleaseVerification:
373
+ return ReleaseVerification(
374
+ verified=not any(check.status == "failed" for check in checks),
375
+ checks=checks,
376
+ )
@@ -0,0 +1,125 @@
1
+ """The bytes of a release document, and the digest taken over them.
2
+
3
+ Spec sections 9.3.1 and 9.5.
4
+
5
+ Every other digest in Techtree is taken over the RFC 8785 canonical form of a
6
+ parsed object, because the object is what two independent builds have to agree
7
+ about. A ReleaseCore is different in one respect that decides this module: the
8
+ specification requires *identical bytes* in the CLI package, in the plugin
9
+ repository, and in the website wrapper, and two of those three consumers are
10
+ not Python. So the ReleaseCore digest is the SHA-256 of the file, exactly as
11
+ the file is stored, which is the same construction the website already uses for
12
+ the catalog index and is checkable with ``shasum`` in any repository.
13
+
14
+ That only works if the file has one spelling, so this module owns it: keys
15
+ sorted, two-space indent, no ASCII escaping, one trailing newline. It is the
16
+ house style of every other generated JSON document in the tree, and
17
+ :func:`is_canonical_document` lets a verification prove a stored file is
18
+ still in it — a hand-edit that changes nothing but whitespace still changes the
19
+ digest, and that has to be reported as tampering rather than tolerated.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ from collections.abc import Mapping
26
+ from importlib.resources import files as resource_files
27
+ from importlib.resources.abc import Traversable
28
+ from typing import Final
29
+
30
+ from techtree.canonical import sha256_digest_bytes
31
+ from techtree.errors import ValidationError
32
+ from techtree.models.base import Digest, JsonValue
33
+ from techtree.release.models import ReleaseCore, ReleaseInputs
34
+
35
+ __all__ = [
36
+ "RELEASE_CORE_FILENAME",
37
+ "RELEASE_RESOURCE_DIRECTORY",
38
+ "document_digest",
39
+ "is_canonical_document",
40
+ "packaged_release_core_bytes",
41
+ "packaged_release_root",
42
+ "parse_release_core",
43
+ "parse_release_inputs",
44
+ "render_document",
45
+ "render_release_core",
46
+ ]
47
+
48
+ #: The file, wherever it is stored. The plugin repository and the website use
49
+ #: the same name, so a person moving bytes between repositories never has to
50
+ #: translate.
51
+ RELEASE_CORE_FILENAME: Final = "release-core.json"
52
+
53
+ #: Where the shipped copy lives inside the installed distribution.
54
+ RELEASE_RESOURCE_DIRECTORY: Final = "release"
55
+
56
+
57
+ def render_document(payload: Mapping[str, JsonValue]) -> bytes:
58
+ """Return the one byte spelling of a release document."""
59
+ text = json.dumps(payload, indent=2, sort_keys=True, ensure_ascii=False)
60
+ return f"{text}\n".encode()
61
+
62
+
63
+ def is_canonical_document(raw: bytes) -> bool:
64
+ """Return whether these bytes are already in the one stored spelling."""
65
+ try:
66
+ payload = json.loads(raw)
67
+ except ValueError:
68
+ return False
69
+ if not isinstance(payload, dict):
70
+ return False
71
+ return render_document(payload) == raw
72
+
73
+
74
+ def document_digest(raw: bytes) -> Digest:
75
+ """Return the digest a release document is published under."""
76
+ return sha256_digest_bytes(raw)
77
+
78
+
79
+ def render_release_core(core: ReleaseCore) -> bytes:
80
+ """Return the stored bytes of a ReleaseCore."""
81
+ return render_document(core.model_dump(mode="json"))
82
+
83
+
84
+ def parse_release_core(raw: bytes) -> ReleaseCore:
85
+ """Load and validate a ReleaseCore from stored bytes."""
86
+ try:
87
+ return ReleaseCore.model_validate_json(raw)
88
+ except ValueError as error:
89
+ raise ValidationError(
90
+ f"this is not a valid ReleaseCore: {error}",
91
+ code="release_core_invalid",
92
+ ) from error
93
+
94
+
95
+ def parse_release_inputs(raw: bytes) -> ReleaseInputs:
96
+ """Load and validate the founder-owned release inputs."""
97
+ try:
98
+ return ReleaseInputs.model_validate_json(raw)
99
+ except ValueError as error:
100
+ raise ValidationError(
101
+ f"this is not a valid release input file: {error}",
102
+ code="release_inputs_invalid",
103
+ ) from error
104
+
105
+
106
+ def packaged_release_root() -> Traversable:
107
+ """Return the release directory shipped inside the installed package."""
108
+ return resource_files("techtree") / "resources" / RELEASE_RESOURCE_DIRECTORY
109
+
110
+
111
+ def packaged_release_core_bytes() -> bytes:
112
+ """Return the exact ReleaseCore bytes this build ships.
113
+
114
+ A build with no ReleaseCore is not a release, so the absence is a typed
115
+ failure rather than an empty answer.
116
+ """
117
+ document = packaged_release_root() / RELEASE_CORE_FILENAME
118
+ if not document.is_file():
119
+ raise ValidationError(
120
+ "this build ships no ReleaseCore; it was not produced by the "
121
+ "release generator",
122
+ code="release_core_missing",
123
+ details={"file": RELEASE_CORE_FILENAME},
124
+ )
125
+ return document.read_bytes()