@xemahq/xema 0.26.0 → 0.28.0

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 (230) hide show
  1. package/README.md +70 -37
  2. package/dist/assets/distribution-assets.manifest.json +1 -2
  3. package/dist/cli.js +2 -2
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/dev.js +1 -0
  6. package/dist/commands/dev.js.map +1 -1
  7. package/dist/commands/down.d.ts.map +1 -1
  8. package/dist/commands/down.js +7 -1
  9. package/dist/commands/down.js.map +1 -1
  10. package/dist/commands/edition.d.ts +3 -0
  11. package/dist/commands/edition.d.ts.map +1 -0
  12. package/dist/commands/edition.js +46 -0
  13. package/dist/commands/edition.js.map +1 -0
  14. package/dist/commands/init.js +2 -2
  15. package/dist/commands/init.js.map +1 -1
  16. package/dist/commands/serve.js +1 -0
  17. package/dist/commands/serve.js.map +1 -1
  18. package/dist/commands/up.js +1 -0
  19. package/dist/commands/up.js.map +1 -1
  20. package/dist/dev/env-defaults.d.ts.map +1 -1
  21. package/dist/dev/env-defaults.js +5 -2
  22. package/dist/dev/env-defaults.js.map +1 -1
  23. package/dist/dev/infra-preflight.d.ts +3 -2
  24. package/dist/dev/infra-preflight.d.ts.map +1 -1
  25. package/dist/dev/infra-preflight.js +18 -8
  26. package/dist/dev/infra-preflight.js.map +1 -1
  27. package/dist/edition/verify-image-evidence.bundle.mjs +54283 -0
  28. package/dist/edition/verify-image-evidence.bundle.provenance.json +2877 -0
  29. package/dist/runtime/config/schema.d.ts +1 -0
  30. package/dist/runtime/config/schema.d.ts.map +1 -1
  31. package/dist/runtime/custody/local-custody.d.ts +12 -0
  32. package/dist/runtime/custody/local-custody.d.ts.map +1 -0
  33. package/dist/runtime/custody/local-custody.js +22 -0
  34. package/dist/runtime/custody/local-custody.js.map +1 -0
  35. package/dist/runtime/custody/openbao-custody-bootstrap.d.ts +18 -0
  36. package/dist/runtime/custody/openbao-custody-bootstrap.d.ts.map +1 -0
  37. package/dist/runtime/custody/openbao-custody-bootstrap.js +50 -0
  38. package/dist/runtime/custody/openbao-custody-bootstrap.js.map +1 -0
  39. package/dist/runtime/custody/openbao-custody-store.d.ts +18 -0
  40. package/dist/runtime/custody/openbao-custody-store.d.ts.map +1 -0
  41. package/dist/runtime/custody/openbao-custody-store.js +55 -0
  42. package/dist/runtime/custody/openbao-custody-store.js.map +1 -0
  43. package/dist/runtime/custody/openbao-http-client.d.ts +34 -0
  44. package/dist/runtime/custody/openbao-http-client.d.ts.map +1 -0
  45. package/dist/runtime/custody/openbao-http-client.js +154 -0
  46. package/dist/runtime/custody/openbao-http-client.js.map +1 -0
  47. package/dist/runtime/custody/openbao-local-constants.d.ts +22 -0
  48. package/dist/runtime/custody/openbao-local-constants.d.ts.map +1 -0
  49. package/dist/runtime/custody/openbao-local-constants.js +48 -0
  50. package/dist/runtime/custody/openbao-local-constants.js.map +1 -0
  51. package/dist/runtime/custody/openbao-local-runner.d.ts +16 -0
  52. package/dist/runtime/custody/openbao-local-runner.d.ts.map +1 -0
  53. package/dist/runtime/custody/openbao-local-runner.js +86 -0
  54. package/dist/runtime/custody/openbao-local-runner.js.map +1 -0
  55. package/dist/runtime/edition/run-entry.d.ts +9 -0
  56. package/dist/runtime/edition/run-entry.d.ts.map +1 -0
  57. package/dist/runtime/edition/run-entry.js +36 -0
  58. package/dist/runtime/edition/run-entry.js.map +1 -0
  59. package/dist/runtime/edition/verb-map.d.ts +60 -0
  60. package/dist/runtime/edition/verb-map.d.ts.map +1 -0
  61. package/dist/runtime/edition/verb-map.js +146 -0
  62. package/dist/runtime/edition/verb-map.js.map +1 -0
  63. package/dist/runtime/env-seeder.d.ts +2 -0
  64. package/dist/runtime/env-seeder.d.ts.map +1 -1
  65. package/dist/runtime/env-seeder.js +18 -3
  66. package/dist/runtime/env-seeder.js.map +1 -1
  67. package/dist/runtime/launch.d.ts +2 -1
  68. package/dist/runtime/launch.d.ts.map +1 -1
  69. package/dist/runtime/launch.js +31 -7
  70. package/dist/runtime/launch.js.map +1 -1
  71. package/dist/runtime/paths.d.ts +1 -2
  72. package/dist/runtime/paths.d.ts.map +1 -1
  73. package/dist/runtime/paths.js +0 -10
  74. package/dist/runtime/paths.js.map +1 -1
  75. package/dist/runtime/runner/chart-local-provenance.d.ts +20 -0
  76. package/dist/runtime/runner/chart-local-provenance.d.ts.map +1 -0
  77. package/dist/runtime/runner/chart-local-provenance.js +114 -0
  78. package/dist/runtime/runner/chart-local-provenance.js.map +1 -0
  79. package/dist/runtime/runner/chart-preflight.d.ts +8 -8
  80. package/dist/runtime/runner/chart-preflight.d.ts.map +1 -1
  81. package/dist/runtime/runner/chart-preflight.js +30 -69
  82. package/dist/runtime/runner/chart-preflight.js.map +1 -1
  83. package/dist/runtime/runner/chart-pull.d.ts +14 -0
  84. package/dist/runtime/runner/chart-pull.d.ts.map +1 -0
  85. package/dist/runtime/runner/chart-pull.js +96 -0
  86. package/dist/runtime/runner/chart-pull.js.map +1 -0
  87. package/dist/runtime/runner/chart-resolver.d.ts +4 -12
  88. package/dist/runtime/runner/chart-resolver.d.ts.map +1 -1
  89. package/dist/runtime/runner/chart-resolver.js +16 -62
  90. package/dist/runtime/runner/chart-resolver.js.map +1 -1
  91. package/dist/runtime/runner/chart-set-lock.d.ts +6 -20
  92. package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
  93. package/dist/runtime/runner/chart-set-lock.js +55 -99
  94. package/dist/runtime/runner/chart-set-lock.js.map +1 -1
  95. package/dist/runtime/runner/helm-cli.d.ts +3 -1
  96. package/dist/runtime/runner/helm-cli.d.ts.map +1 -1
  97. package/dist/runtime/runner/helm-cli.js +9 -7
  98. package/dist/runtime/runner/helm-cli.js.map +1 -1
  99. package/dist/runtime/runner/helm-runner.d.ts +4 -4
  100. package/dist/runtime/runner/helm-runner.d.ts.map +1 -1
  101. package/dist/runtime/runner/helm-runner.js +18 -16
  102. package/dist/runtime/runner/helm-runner.js.map +1 -1
  103. package/dist/runtime/sources/kinds/git.d.ts +1 -2
  104. package/dist/runtime/sources/kinds/git.d.ts.map +1 -1
  105. package/dist/runtime/sources/kinds/git.js +8 -5
  106. package/dist/runtime/sources/kinds/git.js.map +1 -1
  107. package/dist/runtime/sources/manifest-walk.d.ts +3 -1
  108. package/dist/runtime/sources/manifest-walk.d.ts.map +1 -1
  109. package/dist/runtime/sources/manifest-walk.js +8 -3
  110. package/dist/runtime/sources/manifest-walk.js.map +1 -1
  111. package/dist/runtime/sources/schema.d.ts +3 -0
  112. package/dist/runtime/sources/schema.d.ts.map +1 -1
  113. package/dist/runtime/sources/schema.js +6 -1
  114. package/dist/runtime/sources/schema.js.map +1 -1
  115. package/dist/runtime/supervisor.d.ts +2 -0
  116. package/dist/runtime/supervisor.d.ts.map +1 -1
  117. package/dist/runtime/supervisor.js +1 -0
  118. package/dist/runtime/supervisor.js.map +1 -1
  119. package/edition/README.md +313 -0
  120. package/edition/assemble-producer-input.mjs +1251 -0
  121. package/edition/authorization-base.mjs +347 -0
  122. package/edition/biome-content.mjs +268 -0
  123. package/edition/build-biome-index.mjs +175 -0
  124. package/edition/build-platform-service-index.mjs +288 -0
  125. package/edition/build-runtime-image-source-index.mjs +821 -0
  126. package/edition/built-verifier.mjs +55 -0
  127. package/edition/catalogue-edition.mjs +49 -0
  128. package/edition/catalogue-fetch.mjs +64 -0
  129. package/edition/catalogue.mjs +135 -0
  130. package/edition/chart-set-source-commit.mjs +92 -0
  131. package/edition/check-committed-image-evidence.mjs +383 -0
  132. package/edition/checkout-indexed-source-repositories.mjs +105 -0
  133. package/edition/comment-blanker.mjs +75 -0
  134. package/edition/connector-source-authority.mjs +60 -0
  135. package/edition/customer-definition.mjs +183 -0
  136. package/edition/customer-flags.mjs +7 -0
  137. package/edition/develop-customer-edition.mjs +99 -0
  138. package/edition/develop-edition-resolve.mjs +65 -0
  139. package/edition/develop-edition.mjs +274 -0
  140. package/edition/develop-freeze.mjs +173 -0
  141. package/edition/develop-image-coordinate.mjs +54 -0
  142. package/edition/develop-seal.mjs +77 -0
  143. package/edition/develop-snapshot-inputs.mjs +104 -0
  144. package/edition/dist-check.mjs +986 -0
  145. package/edition/distribution-evidence-set.mjs +987 -0
  146. package/edition/distribution-paths.mjs +191 -0
  147. package/edition/edition-artifact.mjs +284 -0
  148. package/edition/edition-biome-manifests.mjs +85 -0
  149. package/edition/edition-channel.mjs +6 -0
  150. package/edition/edition-chart-set.mjs +93 -0
  151. package/edition/edition-config.mjs +205 -0
  152. package/edition/edition-constants.mjs +57 -0
  153. package/edition/edition-contract.json +30 -0
  154. package/edition/edition-list.mjs +35 -0
  155. package/edition/edition-locks.mjs +69 -0
  156. package/edition/edition-protocol.mjs +228 -0
  157. package/edition/edition-workspace.mjs +187 -0
  158. package/edition/evidence-checkout.mjs +439 -0
  159. package/edition/evidence-grammars.mjs +16 -0
  160. package/edition/evidence-trust-pin.mjs +304 -0
  161. package/edition/evidence-trust-policy.mjs +294 -0
  162. package/edition/external-source-walk.mjs +463 -0
  163. package/edition/first-party-source-ids.mjs +59 -0
  164. package/edition/fleet-coordinate-finality.mjs +350 -0
  165. package/edition/git-source-auth.mjs +52 -0
  166. package/edition/image-delivery-entitlement-descriptor.mjs +106 -0
  167. package/edition/image-evidence-replay-state.mjs +242 -0
  168. package/edition/image-evidence.mjs +1233 -0
  169. package/edition/kernel-contract-authority.mjs +443 -0
  170. package/edition/lib.mjs +3021 -0
  171. package/edition/no-follow-file.mjs +94 -0
  172. package/edition/oci-evidence-verbs.mjs +392 -0
  173. package/edition/oci-image-source-evidence.mjs +239 -0
  174. package/edition/oras.mjs +648 -0
  175. package/edition/pin-source.mjs +227 -0
  176. package/edition/plan-image-evidence-refresh.mjs +408 -0
  177. package/edition/platform-lock-readability.mjs +892 -0
  178. package/edition/platform-service-class.mjs +18 -0
  179. package/edition/platform-service-source-authority.mjs +105 -0
  180. package/edition/positive-closure.mjs +162 -0
  181. package/edition/produce-distribution-completeness-evidence.mjs +311 -0
  182. package/edition/produce-image-delivery-entitlement-evidence.mjs +294 -0
  183. package/edition/produce-image-source-evidence.mjs +765 -0
  184. package/edition/producer-evidence-bundle.mjs +157 -0
  185. package/edition/registry-credentials.mjs +193 -0
  186. package/edition/release-catalogue.mjs +39 -0
  187. package/edition/release-channel-guard.mjs +109 -0
  188. package/edition/release-edition-artifact.mjs +157 -0
  189. package/edition/remedy-command.mjs +89 -0
  190. package/edition/repin-evidence-trust.mjs +101 -0
  191. package/edition/required-resource-provider.mjs +58 -0
  192. package/edition/resolve-chart-set.mjs +415 -0
  193. package/edition/resolve-customer-edition.mjs +288 -0
  194. package/edition/resolve-distribution.mjs +2318 -0
  195. package/edition/resolve-edition.mjs +186 -0
  196. package/edition/resolve-images.mjs +1606 -0
  197. package/edition/seal-image-digests.mjs +855 -0
  198. package/edition/sealed-evidence-accept.mjs +82 -0
  199. package/edition/source-authority.mjs +725 -0
  200. package/edition/source-closure.mjs +116 -0
  201. package/edition/strict-json.mjs +121 -0
  202. package/edition/string-order.mjs +5 -0
  203. package/edition/trust-policy-arming.mjs +120 -0
  204. package/edition/trusted-executable.mjs +166 -0
  205. package/edition/verifier-binding.mjs +45 -0
  206. package/edition/verifier-files.mjs +8 -0
  207. package/edition/verifier-kernel-schemas.mjs +22 -0
  208. package/edition/verifier-provenance.mjs +64 -0
  209. package/edition/verify-image-evidence.mjs +458 -0
  210. package/edition/verify-image-platforms.mjs +495 -0
  211. package/edition/why.mjs +56 -0
  212. package/edition/workspace-sections.mjs +195 -0
  213. package/examples/06-biome-sources.yaml +1 -0
  214. package/package.json +14 -6
  215. package/dist/commands/dist.d.ts +0 -16
  216. package/dist/commands/dist.d.ts.map +0 -1
  217. package/dist/commands/dist.js +0 -182
  218. package/dist/commands/dist.js.map +0 -1
  219. package/dist/runtime/distribution/available-biome.d.ts +0 -10
  220. package/dist/runtime/distribution/available-biome.d.ts.map +0 -1
  221. package/dist/runtime/distribution/available-biome.js +0 -156
  222. package/dist/runtime/distribution/available-biome.js.map +0 -1
  223. package/dist/runtime/distribution/compose.d.ts +0 -23
  224. package/dist/runtime/distribution/compose.d.ts.map +0 -1
  225. package/dist/runtime/distribution/compose.js +0 -48
  226. package/dist/runtime/distribution/compose.js.map +0 -1
  227. package/dist/runtime/runner/chart-trust.d.ts +0 -57
  228. package/dist/runtime/runner/chart-trust.d.ts.map +0 -1
  229. package/dist/runtime/runner/chart-trust.js +0 -270
  230. package/dist/runtime/runner/chart-trust.js.map +0 -1
@@ -0,0 +1,191 @@
1
+ import { existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
2
+ import { isAbsolute, join, relative, resolve, sep } from 'node:path';
3
+
4
+ const DISTRIBUTION_ID_PATTERN =
5
+ /^[a-zA-Z0-9](?:[a-zA-Z0-9._-]{0,126}[a-zA-Z0-9])?$/;
6
+
7
+ export function assertCanonicalDistributionId(distributionId) {
8
+ if (
9
+ typeof distributionId !== 'string' ||
10
+ !DISTRIBUTION_ID_PATTERN.test(distributionId)
11
+ ) {
12
+ throw new Error(
13
+ 'distribution id must be a canonical 1-128 character identifier',
14
+ );
15
+ }
16
+ return distributionId;
17
+ }
18
+
19
+ function assertContained(root, candidate, label) {
20
+ const rel = relative(root, candidate);
21
+ if (rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
22
+ throw new Error(`${label} escapes the distributions root`);
23
+ }
24
+ }
25
+
26
+ export function resolveDistributionDirectory(
27
+ distributionsRoot,
28
+ distributionId,
29
+ ) {
30
+ assertCanonicalDistributionId(distributionId);
31
+ const root = realpathSync(distributionsRoot);
32
+ const expectedDirectory = join(root, distributionId);
33
+ if (!existsSync(expectedDirectory)) {
34
+ throw new Error(`distribution "${distributionId}" does not exist`);
35
+ }
36
+ const directory = realpathSync(expectedDirectory);
37
+ assertContained(root, directory, `distribution "${distributionId}"`);
38
+ if (directory !== expectedDirectory) {
39
+ throw new Error(
40
+ `distribution "${distributionId}" directory must not be a symlink`,
41
+ );
42
+ }
43
+ return { root, directory };
44
+ }
45
+
46
+ export function resolveDistributionFile(
47
+ distributionsRoot,
48
+ distributionId,
49
+ fileName,
50
+ ) {
51
+ const { directory } = resolveDistributionDirectory(
52
+ distributionsRoot,
53
+ distributionId,
54
+ );
55
+ const expectedPath = join(directory, fileName);
56
+ if (!existsSync(expectedPath)) {
57
+ throw new Error(
58
+ `distribution "${distributionId}" has no ${fileName} (${expectedPath})`,
59
+ );
60
+ }
61
+ const path = realpathSync(expectedPath);
62
+ assertContained(directory, path, `${fileName} for "${distributionId}"`);
63
+ if (path !== expectedPath) {
64
+ throw new Error(
65
+ `${fileName} for distribution "${distributionId}" must not be a symlink`,
66
+ );
67
+ }
68
+ return path;
69
+ }
70
+
71
+ export function readBoundDistributionFile({
72
+ distributionsRoot,
73
+ distributionId,
74
+ fileName,
75
+ parse,
76
+ }) {
77
+ const path = resolveDistributionFile(
78
+ distributionsRoot,
79
+ distributionId,
80
+ fileName,
81
+ );
82
+ const value = parse(JSON.parse(readFileSync(path, 'utf8')));
83
+ if (value.distributionId !== distributionId) {
84
+ throw new Error(
85
+ `${fileName} distributionId "${value.distributionId}" does not match requested distribution "${distributionId}"`,
86
+ );
87
+ }
88
+ return { path, value };
89
+ }
90
+
91
+ /**
92
+ * Every edition under `distributionsRoot`, sorted — DISCOVERED, never listed.
93
+ *
94
+ * An edition is a directory carrying `xema-distribution.json`; that file is
95
+ * what makes the directory an edition, so nothing else can be mistaken for one
96
+ * and a new edition needs no registration anywhere.
97
+ *
98
+ * Throws on an empty scan. A gate that iterates editions and finds none must
99
+ * fail rather than report success over nothing — the repeated defect in this
100
+ * tree is a check that reports green while executing nothing, and a literal
101
+ * id list is the other half of it: a narrowed list reads as coverage.
102
+ *
103
+ * `refresh-distribution-plane.mjs` carried this three-line scan privately;
104
+ * it lives here so the write path and the read paths cannot disagree about
105
+ * what an edition is.
106
+ */
107
+ export function discoverDistributionIds(distributionsRoot) {
108
+ const root = realpathSync(distributionsRoot);
109
+ const ids = readdirSync(root, { withFileTypes: true })
110
+ .filter((entry) => entry.isDirectory())
111
+ .map((entry) => entry.name)
112
+ .filter((name) => existsSync(join(root, name, 'xema-distribution.json')))
113
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
114
+ if (ids.length === 0) {
115
+ throw new Error(
116
+ `found no edition under ${root} — an empty scan is not a result`,
117
+ );
118
+ }
119
+ return ids;
120
+ }
121
+
122
+ export function distributionOutputPath(
123
+ distributionsRoot,
124
+ distributionId,
125
+ fileName,
126
+ ) {
127
+ const { directory } = resolveDistributionDirectory(
128
+ distributionsRoot,
129
+ distributionId,
130
+ );
131
+ const path = resolve(directory, fileName);
132
+ assertContained(directory, path, `${fileName} output`);
133
+ if (existsSync(path) && realpathSync(path) !== path) {
134
+ throw new Error(
135
+ `${fileName} output for distribution "${distributionId}" must not be a symlink`,
136
+ );
137
+ }
138
+ return path;
139
+ }
140
+
141
+ /**
142
+ * The directory name holding editions that are NOT yet promoted.
143
+ *
144
+ * `discoverDistributionIds` is deliberately a single-level scan, so an edition
145
+ * one level down is invisible to it and to `dist:check` — which is the whole
146
+ * mechanism of the holding pen, not an oversight.
147
+ */
148
+ export const PENDING_EDITIONS_DIRNAME = 'editions-pending-merge';
149
+
150
+ /**
151
+ * Every edition under `distributionsRoot`, in BOTH tiers, as
152
+ * `{ id, directory, pending }` — sorted, promoted tier first.
153
+ *
154
+ * WHY BOTH TIERS. A generic test suite must be able to name its subjects
155
+ * without naming a customer, and a customer's edition is exactly the thing that
156
+ * is either pending here or has moved to a root of its own. Discovering both
157
+ * means one expression covers the edition wherever it currently sits, so
158
+ * promoting it — or removing it — changes no test.
159
+ *
160
+ * THE EMPTY CASES ARE ASYMMETRIC ON PURPOSE.
161
+ *
162
+ * * The PROMOTED tier delegates to {@link discoverDistributionIds}, which
163
+ * THROWS on an empty scan. An empty promoted tier means the root is wrong,
164
+ * and a checker that iterated zero editions and reported success is this
165
+ * tree's most-repeated defect.
166
+ * * The PENDING tier may legitimately be empty — that is what a fully
167
+ * promoted fleet looks like — so it is not an error. A CALLER that narrows
168
+ * to a subset of these and finds nothing must say so out loud rather than
169
+ * pass in silence; that reporting duty is the caller's, because only the
170
+ * caller knows what it narrowed to.
171
+ */
172
+ export function discoverEditionDirectories(distributionsRoot) {
173
+ const root = realpathSync(distributionsRoot);
174
+ const promoted = discoverDistributionIds(root).map((id) => ({
175
+ id,
176
+ directory: join(root, id),
177
+ pending: false,
178
+ }));
179
+ const pendingRoot = join(root, PENDING_EDITIONS_DIRNAME);
180
+ const pending = !existsSync(pendingRoot)
181
+ ? []
182
+ : readdirSync(pendingRoot, { withFileTypes: true })
183
+ .filter((entry) => entry.isDirectory())
184
+ .map((entry) => entry.name)
185
+ .filter((name) =>
186
+ existsSync(join(pendingRoot, name, 'xema-distribution.json')),
187
+ )
188
+ .sort((a, b) => (a < b ? -1 : a > b ? 1 : 0))
189
+ .map((id) => ({ id, directory: join(pendingRoot, id), pending: true }));
190
+ return [...promoted, ...pending];
191
+ }
@@ -0,0 +1,284 @@
1
+ // edition-artifact — the files of an edition and where they may go.
2
+ //
3
+ // ONE layout for develop and release: an OCI artifact at
4
+ // `<registry>/xema-edition-<id>`, addressed by digest, whose titled layers are
5
+ // `xema-edition.json` (the descriptor) plus the lock files. It is written to a
6
+ // scratch directory; the workflow pushes it with oras and signs the digest.
7
+ // Tags are for humans only (develop: the snapshot hash; release: the
8
+ // coordinate). Admission rests on the signature of the manifest digest, never on
9
+ // a channel label: no reader branches on `provenance.channel`.
10
+ //
11
+ // The artifact is never written to git, so the writer REFUSES a target inside
12
+ // either the data tree or the editions root.
13
+
14
+ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
15
+ import { basename, dirname, join, relative, resolve, sep, isAbsolute } from 'node:path';
16
+
17
+ import { assertCanonicalDistributionId } from './distribution-paths.mjs';
18
+ import { EDITION_CHART_SET_FILE, assertRosteredChartSetLock } from './edition-chart-set.mjs';
19
+ import {
20
+ CATALOGUE_PIN_KEY,
21
+ EDITION_DESCRIPTOR_FILE,
22
+ EDITION_DESCRIPTOR_SCHEMA,
23
+ EDITION_LOCKS,
24
+ EDITION_REPOSITORY_PREFIX,
25
+ EditionChannel,
26
+ EditionLockFile,
27
+ EvidenceTrustState,
28
+ } from './edition-constants.mjs';
29
+ import { dataRoot, editionsRoot } from './edition-workspace.mjs';
30
+ import { serializeJson } from './lib.mjs';
31
+ import { snapshotHash, sortedSnapshotPins } from './pin-source.mjs';
32
+ import { readStrictJsonFile } from './strict-json.mjs';
33
+
34
+ /** Which GitHub Actions run produced the artifact; recorded in its descriptor. */
35
+ export function workflowFromEnv(env) {
36
+ const repository = env.GITHUB_REPOSITORY;
37
+ const runId = env.GITHUB_RUN_ID;
38
+ if (!repository || !runId) {
39
+ throw new Error(
40
+ 'an edition records the workflow run that produced it: GITHUB_REPOSITORY and GITHUB_RUN_ID are required ' +
41
+ '(pass --local to write outside GitHub Actions)',
42
+ );
43
+ }
44
+ return { repository, runId };
45
+ }
46
+
47
+ /** The workflow stamp of a write that is explicitly not from CI. */
48
+ export const LOCAL_WORKFLOW = Object.freeze({ repository: 'local', runId: 'local' });
49
+
50
+ const ENGINE_IMAGE_PATTERN = /^[a-z0-9.-]+(:[0-9]+)?(\/[a-z0-9._-]+)+@sha256:[a-f0-9]{64}$/;
51
+
52
+ /** The deploy engine image, by digest only: `<repository>@sha256:<64 hex>`. No tag form, no default. */
53
+ export function assertEngineImage(image) {
54
+ if (typeof image !== 'string' || !ENGINE_IMAGE_PATTERN.test(image)) {
55
+ throw new Error(
56
+ `--engine-image ${JSON.stringify(image)} is not <repository>@sha256:<64 hex>: the deploy engine is pinned by digest, never by tag`,
57
+ );
58
+ }
59
+ return image;
60
+ }
61
+
62
+ /**
63
+ * The descriptor: what this artifact is, which locks it carries, where it came
64
+ * from, whether the data root's editions are evidence-gated, and the deploy
65
+ * engine image that consumes it.
66
+ */
67
+ export function editionDescriptor({ distributionId, channel, pins, workflow, evidenceTrustPolicy, engineImage }) {
68
+ assertCanonicalDistributionId(distributionId);
69
+ // Every writer of a descriptor passes here: no route writes a release
70
+ // edition that is not armed, whatever state its caller computed.
71
+ if (channel === EditionChannel.Release && evidenceTrustPolicy !== EvidenceTrustState.Armed) {
72
+ throw new Error(
73
+ `a release edition requires an armed evidence trust policy; "${distributionId}" was to be written ${JSON.stringify(evidenceTrustPolicy)}`,
74
+ );
75
+ }
76
+ return parseEditionDescriptor({
77
+ schema: EDITION_DESCRIPTOR_SCHEMA,
78
+ distributionId,
79
+ locks: [...EDITION_LOCKS],
80
+ provenance: {
81
+ channel,
82
+ pins: channel === EditionChannel.Develop ? sortedSnapshotPins(pins) : { ...pins },
83
+ workflow: { repository: workflow?.repository, runId: workflow?.runId },
84
+ },
85
+ evidenceTrustPolicy,
86
+ engine: { image: engineImage },
87
+ });
88
+ }
89
+
90
+ const COMMIT_PATTERN = /^[0-9a-f]{40}$/;
91
+ const CATALOGUE_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
92
+
93
+ const exactKeys = (value, keys, where) => {
94
+ if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).sort().join() !== [...keys].sort().join()) {
95
+ throw new Error(`${where} must be exactly {${keys.join(', ')}}`);
96
+ }
97
+ };
98
+
99
+ /** The strict parse of a descriptor: exact keys, closed vocabularies, an engine image by digest. Returns it unchanged. */
100
+ export function parseEditionDescriptor(value) {
101
+ exactKeys(value, ['schema', 'distributionId', 'locks', 'provenance', 'evidenceTrustPolicy', 'engine'], EDITION_DESCRIPTOR_FILE);
102
+ if (value.schema !== EDITION_DESCRIPTOR_SCHEMA) {
103
+ throw new Error(`${EDITION_DESCRIPTOR_FILE}: schema is ${JSON.stringify(value.schema)}, expected ${JSON.stringify(EDITION_DESCRIPTOR_SCHEMA)}`);
104
+ }
105
+ assertCanonicalDistributionId(value.distributionId);
106
+ if (!Array.isArray(value.locks) || value.locks.length === 0 || value.locks.some((file) => typeof file !== 'string' || file === '')) {
107
+ throw new Error(`${EDITION_DESCRIPTOR_FILE}: locks must be a non-empty array of file names`);
108
+ }
109
+ exactKeys(value.provenance, ['channel', 'pins', 'workflow'], `${EDITION_DESCRIPTOR_FILE} provenance`);
110
+ if (!Object.values(EditionChannel).includes(value.provenance.channel)) {
111
+ throw new Error(`${EDITION_DESCRIPTOR_FILE}: provenance.channel must be one of ${Object.values(EditionChannel).join(' | ')}`);
112
+ }
113
+ const { pins, workflow } = value.provenance;
114
+ if (pins === null || typeof pins !== 'object' || Array.isArray(pins)) {
115
+ throw new Error(`${EDITION_DESCRIPTOR_FILE}: provenance.pins must map pin keys to 40-hex commits (the "${CATALOGUE_PIN_KEY}" pin to a sha256 digest)`);
116
+ }
117
+ for (const [key, pin] of Object.entries(pins)) {
118
+ const pattern = key === CATALOGUE_PIN_KEY ? CATALOGUE_DIGEST_PATTERN : COMMIT_PATTERN;
119
+ if (typeof pin !== 'string' || !pattern.test(pin)) {
120
+ throw new Error(
121
+ key === CATALOGUE_PIN_KEY
122
+ ? `${EDITION_DESCRIPTOR_FILE}: provenance.pins["${key}"] must be a sha256:<64 hex> digest`
123
+ : `${EDITION_DESCRIPTOR_FILE}: provenance.pins must map pin keys to 40-hex commits (${JSON.stringify(key)} is not)`,
124
+ );
125
+ }
126
+ }
127
+ exactKeys(workflow, ['repository', 'runId'], `${EDITION_DESCRIPTOR_FILE} provenance.workflow`);
128
+ if (!workflow.repository || !workflow.runId || typeof workflow.repository !== 'string' || typeof workflow.runId !== 'string') {
129
+ throw new Error(`${EDITION_DESCRIPTOR_FILE}: provenance.workflow is {repository, runId}, both non-empty strings`);
130
+ }
131
+ if (!Object.values(EvidenceTrustState).includes(value.evidenceTrustPolicy)) {
132
+ throw new Error(`${EDITION_DESCRIPTOR_FILE}: evidenceTrustPolicy must be one of ${Object.values(EvidenceTrustState).join(' | ')}`);
133
+ }
134
+ // The reader holds the writer's rule: a release edition that is not armed is
135
+ // not an edition this plane writes, so it is not one it reads.
136
+ if (value.provenance.channel === EditionChannel.Release && value.evidenceTrustPolicy !== EvidenceTrustState.Armed) {
137
+ throw new Error(
138
+ `${EDITION_DESCRIPTOR_FILE}: a release edition requires an armed evidence trust policy; "${value.distributionId}" records ${JSON.stringify(value.evidenceTrustPolicy)}`,
139
+ );
140
+ }
141
+ exactKeys(value.engine, ['image'], `${EDITION_DESCRIPTOR_FILE} engine`);
142
+ assertEngineImage(value.engine.image);
143
+ return value;
144
+ }
145
+
146
+ const REGISTRY_PATTERN = /^[a-z0-9.-]+(:[0-9]+)?(\/[a-z0-9._-]+)+$/;
147
+ const TAG_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
148
+
149
+ /** The registry is the caller's namespace; the CLI appends `xema-edition-<id>` itself, so a registry already naming an edition repository is refused. */
150
+ export function assertRegistry(registry) {
151
+ if (typeof registry !== 'string' || !REGISTRY_PATTERN.test(registry)) {
152
+ throw new Error(
153
+ `--registry ${JSON.stringify(registry)} is not <registry-host>[:port]/<namespace>[/...] (lowercase, e.g. registry.example.com/acme)`,
154
+ );
155
+ }
156
+ const last = registry.split('/').pop();
157
+ if (last.startsWith(EDITION_REPOSITORY_PREFIX)) {
158
+ throw new Error(
159
+ `--registry ${registry} ends in "${last}", which the CLI appends itself: ` +
160
+ `the repository would be ${registry}/${EDITION_REPOSITORY_PREFIX}<id>. Pass the namespace only`,
161
+ );
162
+ }
163
+ }
164
+
165
+ /** `<registry>/xema-edition-<id>`: the one repository of an edition, for either channel. */
166
+ export function editionRepository(distributionId, registry) {
167
+ assertCanonicalDistributionId(distributionId);
168
+ assertRegistry(registry);
169
+ return `${registry}/${EDITION_REPOSITORY_PREFIX}${distributionId}`;
170
+ }
171
+
172
+ /** `<repository>:<tag>`; the tag is for humans, the artifact is addressed by its digest. */
173
+ export function editionTarget(distributionId, registry, tag) {
174
+ if (typeof tag !== 'string' || !TAG_PATTERN.test(tag)) {
175
+ throw new Error(`tag ${JSON.stringify(tag)} is not an OCI tag`);
176
+ }
177
+ return `${editionRepository(distributionId, registry)}:${tag}`;
178
+ }
179
+
180
+ /** A develop edition's target: the tag is its snapshot hash. */
181
+ export function developEditionTarget(distributionId, pins, { registry }) {
182
+ return editionTarget(distributionId, registry, snapshotHash(pins));
183
+ }
184
+
185
+ function realpathOfPlanned(path) {
186
+ const absolute = resolve(path);
187
+ let existing = absolute;
188
+ const rest = [];
189
+ while (!existsSync(existing)) {
190
+ rest.unshift(basename(existing));
191
+ existing = dirname(existing);
192
+ }
193
+ return join(realpathSync(existing), ...rest);
194
+ }
195
+
196
+ function isWithin(path, directory) {
197
+ const inside = relative(realpathOfPlanned(directory), path);
198
+ return !(inside === '..' || inside.startsWith(`..${sep}`) || isAbsolute(inside));
199
+ }
200
+
201
+ function assertOutsideCommittedTrees(outDir) {
202
+ const planned = realpathOfPlanned(outDir);
203
+ for (const [label, tree] of [
204
+ ['the data tree', dataRoot()],
205
+ ['the editions root', editionsRoot()],
206
+ ]) {
207
+ if (isWithin(planned, tree)) {
208
+ throw new Error(`refusing to write an edition artifact to ${outDir}: it lies inside ${label} (${tree}), which is committed content`);
209
+ }
210
+ }
211
+ }
212
+
213
+ /** Write the descriptor and the locks of an edition of `channel` to `outDir`; returns the descriptor. */
214
+ export function writeEditionArtifact({
215
+ outDir,
216
+ channel,
217
+ distributionId,
218
+ pins,
219
+ workflow,
220
+ evidenceTrustPolicy,
221
+ engineImage,
222
+ distributionLock,
223
+ imageLock,
224
+ authorizationLock,
225
+ chartSetLock,
226
+ }) {
227
+ assertOutsideCommittedTrees(outDir);
228
+ const byFile = [
229
+ [EditionLockFile.Distribution, distributionLock],
230
+ [EditionLockFile.Images, imageLock],
231
+ [EditionLockFile.Authorization, authorizationLock],
232
+ [EditionLockFile.ChartSet, chartSetLock],
233
+ ];
234
+ for (const [file, lock] of byFile) {
235
+ if (lock === null || lock === undefined) {
236
+ throw new Error(`edition "${distributionId}" has no ${file}: an edition carries all four locks`);
237
+ }
238
+ }
239
+ assertRosteredChartSetLock(chartSetLock, EDITION_CHART_SET_FILE, channel);
240
+ const descriptor = editionDescriptor({ distributionId, channel, pins, workflow, evidenceTrustPolicy, engineImage });
241
+ mkdirSync(outDir, { recursive: true });
242
+ for (const [file, value] of [[EDITION_DESCRIPTOR_FILE, descriptor], ...byFile]) {
243
+ writeFileSync(join(outDir, file), serializeJson(value));
244
+ }
245
+ return descriptor;
246
+ }
247
+
248
+ /**
249
+ * An existing snapshot-hash tag is reused only if it IS this snapshot: the tag
250
+ * is a hash of the pins, but a tag is mutable registry state, so the pulled
251
+ * descriptor must say the same edition, the same channel, exactly the same pins,
252
+ * the same engine image, the same evidence trust state and the same lock set. Throws naming the first difference.
253
+ */
254
+ export function assertExistingArtifactMatches({ artifactDir, distributionId, channel, pins, engineImage, evidenceTrustPolicy }) {
255
+ const descriptorPath = join(artifactDir, EDITION_DESCRIPTOR_FILE);
256
+ if (!existsSync(descriptorPath)) {
257
+ throw new Error(`the existing artifact holds no ${EDITION_DESCRIPTOR_FILE}; refusing to reuse a tag that is not an edition`);
258
+ }
259
+ const existing = parseEditionDescriptor(readStrictJsonFile(descriptorPath));
260
+ assertCanonicalDistributionId(distributionId);
261
+ if (existing.distributionId !== distributionId) {
262
+ throw new Error(`the existing artifact's distributionId is ${JSON.stringify(existing.distributionId)}, expected ${JSON.stringify(distributionId)}; refusing to reuse it`);
263
+ }
264
+ if (existing.provenance.channel !== channel) {
265
+ throw new Error(`the existing artifact was produced on channel ${JSON.stringify(existing.provenance.channel)}, expected ${JSON.stringify(channel)}; refusing to reuse it`);
266
+ }
267
+ if (existing.evidenceTrustPolicy !== evidenceTrustPolicy) {
268
+ throw new Error(`the existing artifact records evidenceTrustPolicy ${JSON.stringify(existing.evidenceTrustPolicy)}, this data root's is ${JSON.stringify(evidenceTrustPolicy)}; refusing to reuse it`);
269
+ }
270
+ if (serializeJson(existing.provenance.pins) !== serializeJson(sortedSnapshotPins(pins))) {
271
+ throw new Error('the existing artifact was resolved from different pins than this snapshot (tag and content disagree); refusing to reuse it');
272
+ }
273
+ if (existing.engine.image !== assertEngineImage(engineImage)) {
274
+ throw new Error(`the existing artifact names engine ${existing.engine.image}, not ${engineImage}; refusing to reuse it`);
275
+ }
276
+ if (serializeJson(existing.locks) !== serializeJson(EDITION_LOCKS)) {
277
+ throw new Error('the existing artifact lists a different lock set than an edition carries; refusing to reuse it');
278
+ }
279
+ for (const file of existing.locks) {
280
+ if (!existsSync(join(artifactDir, file))) {
281
+ throw new Error(`the existing artifact lacks ${file}, which its descriptor lists; refusing to reuse it`);
282
+ }
283
+ }
284
+ }
@@ -0,0 +1,85 @@
1
+ #!/usr/bin/env node
2
+ // `xema edition biome-manifests <id> --json`: the PINNED xema-biome.json of every
3
+ // biome of one edition, as JSON.
4
+ //
5
+ // usage: xema edition biome-manifests <id> --json --data-root <dir> --editions-root <dir>
6
+ // --sources-root <dir> --source-hash <dir> --kernel <dir>
7
+ //
8
+ // For every biome of the edition's resolved lock this reads the manifest AT the
9
+ // commit the lock pins (the way the seal and `xema edition check` read it) and
10
+ // prints one document on stdout:
11
+ // {"schema":"xema.edition-biome-manifests/v1","distributionId","biomes":[{"id","repo","sourceCommit","manifest"}]}
12
+ // sorted by biome id. A consumer (the store catalogue) reads this instead of
13
+ // reading the sources itself. Read-only. When the lock is absent or a pinned
14
+ // manifest cannot be read it prints NOTHING on stdout, a message on stderr, and exits non-zero.
15
+
16
+ import { readFileSync } from 'node:fs';
17
+ import { join } from 'node:path';
18
+
19
+ import { pinnedBiomeManifests } from './biome-content.mjs';
20
+ import { assertCanonicalDistributionId } from './distribution-paths.mjs';
21
+ import {
22
+ WorkspaceRoot,
23
+ configureEditionWorkspaceFromArgv,
24
+ editionsRoot,
25
+ isEntryModule,
26
+ } from './edition-workspace.mjs';
27
+ import { loadKernelDistributionSchemas, serializeJson } from './lib.mjs';
28
+ import { externalSourceTrees, sourceTreeLocator } from './resolve-images.mjs';
29
+ import { committedRepositoryPins, createPinnedSourceAuthority } from './source-authority.mjs';
30
+
31
+ export const EDITION_BIOME_MANIFESTS_SCHEMA = 'xema.edition-biome-manifests/v1';
32
+
33
+ const USAGE =
34
+ 'usage: xema edition biome-manifests <id> --json --data-root <dir> --editions-root <dir> --sources-root <dir> --source-hash <dir> --kernel <dir>';
35
+
36
+ function readLock(distributionId, file) {
37
+ try {
38
+ return JSON.parse(readFileSync(join(editionsRoot(), distributionId, file), 'utf8'));
39
+ } catch (error) {
40
+ throw new Error(`edition "${distributionId}" has no readable ${file} under ${editionsRoot()}: ${error.message}`);
41
+ }
42
+ }
43
+
44
+ export async function editionBiomeManifests({ distributionId, schemas }) {
45
+ assertCanonicalDistributionId(distributionId);
46
+ const lock = schemas.parseDistributionLock(readLock(distributionId, 'distribution.lock.json'));
47
+ const imageLock = schemas.parseImageLock(readLock(distributionId, 'images.lock.json'));
48
+ const authority = createPinnedSourceAuthority({ pins: committedRepositoryPins() });
49
+ return {
50
+ schema: EDITION_BIOME_MANIFESTS_SCHEMA,
51
+ distributionId,
52
+ biomes: await pinnedBiomeManifests({
53
+ schemas,
54
+ lock,
55
+ imageLock,
56
+ locate: sourceTreeLocator(externalSourceTrees(lock)),
57
+ vendoredCommit: (repo) => authority.vendoredSourceCommitForRepository(repo),
58
+ }),
59
+ };
60
+ }
61
+
62
+ async function main() {
63
+ const rest = configureEditionWorkspaceFromArgv(process.argv.slice(2), [
64
+ WorkspaceRoot.Data,
65
+ WorkspaceRoot.Editions,
66
+ WorkspaceRoot.Sources,
67
+ WorkspaceRoot.SourceHash,
68
+ WorkspaceRoot.Kernel,
69
+ ]);
70
+ const operands = rest.filter((arg) => arg !== '--json');
71
+ if (operands.length !== 1 || rest.length - operands.length !== 1) {
72
+ throw new Error(USAGE);
73
+ }
74
+ const schemas = await loadKernelDistributionSchemas();
75
+ // Built whole before anything is written, so a refusal leaves stdout empty.
76
+ const document = await editionBiomeManifests({ distributionId: operands[0], schemas });
77
+ process.stdout.write(serializeJson(document));
78
+ }
79
+
80
+ if (isEntryModule(import.meta.url)) {
81
+ main().catch((error) => {
82
+ console.error(`[edition-biome-manifests] ${error.message}`);
83
+ process.exitCode = 1;
84
+ });
85
+ }
@@ -0,0 +1,6 @@
1
+ // edition-channel — where an edition was produced. Its own module so the chart-set
2
+ // validator (which a channel selects) and edition-constants (which imports the
3
+ // chart-set file name) do not import each other.
4
+
5
+ /** Where an edition was produced. Recorded in the descriptor's provenance; readers never branch on it. */
6
+ export const EditionChannel = Object.freeze({ Develop: 'develop', Release: 'release' });
@@ -0,0 +1,93 @@
1
+ // edition-chart-set.mjs — every edition carries the chart generation it is
2
+ // rendered with.
3
+ //
4
+ // Owner ruling, 2026-10-04: everything that affects a deployment's rendered
5
+ // runtime manifest is selected by its sealed edition — image digests, the
6
+ // authorization lock, AND the chart generation. Until then the chart set was one
7
+ // fleet-wide pin (chart-set.lock.json at this repository's root) that no edition
8
+ // carried, so xema-deploy rendered every deployment through whichever charts it
9
+ // happened to hold, and an appliance sealed on 2026-09-27 was rendered with
10
+ // charts that assume images built on 2026-10-01.
11
+ //
12
+ // So each edition directory holds `<id>/chart-set.lock.json`: a byte-identical
13
+ // copy of the fleet pin AT THE MOMENT IT IS WRITTEN, with each publisher's
14
+ // ROSTER (chart name → {version, digest}) read from the index, so a
15
+ // consumer selects every chart without asking the registry. It is part of the
16
+ // edition's file set (compute-edition-coordinates.sh), hashed into its
17
+ // coordinate and published with it, and xema-deploy vendors it beside the other
18
+ // halves.
19
+ //
20
+ // ONE FACT, STILL. The fleet pin stays the single answer to "which chart set do
21
+ // editions take now"; resolve-chart-set.mjs is its one writer and writes every
22
+ // edition's copy in the same run, and dist-check refuses a copy that is missing
23
+ // or differs from the pin. N copies of one fact cannot disagree when one writer
24
+ // writes them all and a check refuses any that does.
25
+
26
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
27
+ import { join } from 'node:path';
28
+
29
+ import { EditionChannel } from './edition-channel.mjs';
30
+ import { dataRoot } from './edition-workspace.mjs';
31
+
32
+ export const EDITION_CHART_SET_FILE = 'chart-set.lock.json';
33
+ /** The fleet pin: this repository's root chart-set.lock.json, beside this file in every layout. */
34
+ export const fleetChartSetLockPath = () => join(dataRoot(), EDITION_CHART_SET_FILE);
35
+
36
+ export const EditionChartSetCheck = Object.freeze({
37
+ GREEN: 'green',
38
+ MISSING: 'missing',
39
+ STALE: 'stale',
40
+ });
41
+
42
+ /**
43
+ * A published chart's version coordinate, per channel: `-h<16 hex>` (content
44
+ * hash) in a release chart set, `-dev.<12 hex>` (the frozen xema-deploy SHA) in a
45
+ * develop one. One lock SHAPE serves both; the coordinate is what the producer
46
+ * of each channel writes, so each producer validates its own channel's (this is
47
+ * a check of what is being written, not a branch of any reader).
48
+ */
49
+ const CHART_VERSION_COORDINATE = Object.freeze({
50
+ [EditionChannel.Release]: /-h[0-9a-f]{16}$/,
51
+ [EditionChannel.Develop]: /-dev\.[0-9a-f]{12}$/,
52
+ });
53
+
54
+ /** A chart-set lock (schemaVersion 3) that pins every member by version and OCI manifest digest, with `channel`'s version coordinate. */
55
+ export function assertRosteredChartSetLock(lock, label, channel) {
56
+ const coordinate = CHART_VERSION_COORDINATE[channel];
57
+ if (coordinate === undefined) {
58
+ throw new Error(`${label}: cannot validate a chart-set lock for channel ${JSON.stringify(channel)}; expected ${Object.values(EditionChannel).join(' | ')}`);
59
+ }
60
+ const fail = (why) => {
61
+ throw new Error(`${label} is not a rostered chart-set lock: ${why}`);
62
+ };
63
+ if (lock?.schemaVersion !== 3) {fail('schemaVersion must be 3');}
64
+ if (!Array.isArray(lock.publishers) || lock.publishers.length === 0) {fail('publishers must be a non-empty array');}
65
+ for (const pin of lock.publishers) {
66
+ if (!pin?.charts || typeof pin.charts !== 'object' || Array.isArray(pin.charts) || Object.keys(pin.charts).length === 0) {
67
+ fail(`${pin?.index ?? '?'} carries no roster (charts: name -> member version) — run resolve-chart-set.mjs`);
68
+ }
69
+ for (const [name, member] of Object.entries(pin.charts)) {
70
+ if (!member || typeof member !== 'object' || Array.isArray(member)) {fail(`${pin.index}: ${name} must be {version, digest}`);}
71
+ if (!coordinate.test(String(member.version))) {fail(`${pin.index}: ${name} has no ${channel} content coordinate (${member.version}); a ${channel} chart set carries ${coordinate}`);}
72
+ if (!/^sha256:[a-f0-9]{64}$/.test(String(member.digest))) {fail(`${pin.index}: ${name} has no sha256 manifest digest`);}
73
+ }
74
+ }
75
+ return lock;
76
+ }
77
+
78
+ /**
79
+ * Write the RELEASE edition's copy of the fleet pin — byte-identical to
80
+ * `pinText`. Only a rostered pin (schemaVersion 3) with release chart
81
+ * coordinates is written.
82
+ */
83
+ export function writeEditionChartSet(editionDir, pinText) {
84
+ assertRosteredChartSetLock(JSON.parse(pinText), EDITION_CHART_SET_FILE, EditionChannel.Release);
85
+ writeFileSync(join(editionDir, EDITION_CHART_SET_FILE), pinText);
86
+ }
87
+
88
+ /** Does the edition carry the fleet pin, byte for byte? */
89
+ export function checkEditionChartSet(editionDir, pinText) {
90
+ const file = join(editionDir, EDITION_CHART_SET_FILE);
91
+ if (!existsSync(file)) {return EditionChartSetCheck.MISSING;}
92
+ return readFileSync(file, 'utf8') === pinText ? EditionChartSetCheck.GREEN : EditionChartSetCheck.STALE;
93
+ }