@xemahq/xema 0.27.0 → 0.29.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 (63) hide show
  1. package/dist/commands/edition.d.ts.map +1 -1
  2. package/dist/commands/edition.js +8 -7
  3. package/dist/commands/edition.js.map +1 -1
  4. package/dist/dev/env-defaults.d.ts.map +1 -1
  5. package/dist/dev/env-defaults.js +0 -1
  6. package/dist/dev/env-defaults.js.map +1 -1
  7. package/dist/edition/verify-image-evidence.bundle.mjs +231 -50
  8. package/dist/edition/verify-image-evidence.bundle.provenance.json +55 -43
  9. package/dist/runtime/edition/verb-map.d.ts +2 -1
  10. package/dist/runtime/edition/verb-map.d.ts.map +1 -1
  11. package/dist/runtime/edition/verb-map.js +2 -0
  12. package/dist/runtime/edition/verb-map.js.map +1 -1
  13. package/dist/runtime/runner/chart-set-lock.d.ts +4 -1
  14. package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
  15. package/dist/runtime/runner/chart-set-lock.js +39 -4
  16. package/dist/runtime/runner/chart-set-lock.js.map +1 -1
  17. package/edition/README.md +76 -41
  18. package/edition/build-biome-index.mjs +1 -1
  19. package/edition/build-platform-service-index.mjs +1 -1
  20. package/edition/build-runtime-image-source-index.mjs +64 -20
  21. package/edition/catalogue-fetch.mjs +36 -9
  22. package/edition/catalogue-pin.mjs +71 -0
  23. package/edition/catalogue.mjs +11 -0
  24. package/edition/chart-set-source-commit.mjs +17 -7
  25. package/edition/checkout-indexed-source-repositories.mjs +17 -17
  26. package/edition/customer-flags.mjs +0 -1
  27. package/edition/develop-customer-edition.mjs +23 -5
  28. package/edition/develop-edition.mjs +147 -48
  29. package/edition/develop-freeze.mjs +69 -25
  30. package/edition/develop-seal.mjs +39 -25
  31. package/edition/dist-check.mjs +1 -1
  32. package/edition/edition-artifact.mjs +79 -18
  33. package/edition/edition-biome-manifests.mjs +2 -3
  34. package/edition/edition-chart-set.mjs +59 -6
  35. package/edition/edition-config.mjs +45 -21
  36. package/edition/edition-constants.mjs +10 -4
  37. package/edition/edition-contract-parse.mjs +52 -0
  38. package/edition/edition-contract.json +21 -5
  39. package/edition/edition-workspace.mjs +23 -9
  40. package/edition/evidence-checkout.mjs +22 -1
  41. package/edition/evidence-trust-policy.mjs +142 -78
  42. package/edition/external-source-walk.mjs +48 -61
  43. package/edition/git-source-auth.mjs +104 -25
  44. package/edition/image-delivery-entitlement-descriptor.mjs +106 -0
  45. package/edition/image-evidence.mjs +21 -3
  46. package/edition/kernel-contract-authority.mjs +23 -1
  47. package/edition/lib.mjs +16 -24
  48. package/edition/oras.mjs +1 -1
  49. package/edition/pin-source.mjs +6 -1
  50. package/edition/produce-image-delivery-entitlement-evidence.mjs +12 -12
  51. package/edition/registry-credentials.mjs +11 -1
  52. package/edition/release-channel-guard.mjs +11 -1
  53. package/edition/release-edition-artifact.mjs +5 -6
  54. package/edition/resolve-chart-set.mjs +50 -30
  55. package/edition/resolve-customer-edition.mjs +63 -7
  56. package/edition/resolve-distribution.mjs +1 -1
  57. package/edition/resolve-edition.mjs +12 -11
  58. package/edition/resolve-images.mjs +36 -15
  59. package/edition/seal-image-digests.mjs +31 -7
  60. package/edition/source-closure.mjs +1 -2
  61. package/edition/trust-policy-arming-verb.mjs +68 -0
  62. package/edition/verify-image-evidence.mjs +40 -12
  63. package/package.json +3 -3
@@ -9,12 +9,13 @@
9
9
  // freezeGraph the graph repositories, the data root, the kernel, the deploy checkout, the editions root
10
10
  // freezeSources one pin per `sources[]` entry the chain declares
11
11
  //
12
- // `resolveHead(url)` is the one network seam (default: `git ls-remote`
13
- // authenticated through git-source-auth, token from the environment, never argv).
12
+ // `resolveHead({ name, url })` is the one network seam (default: `git ls-remote` through
13
+ // git-source-auth: the data root's token only for a repository under its
14
+ // `sourceRepositoryUrlPrefix`, no credential for any other, never argv).
14
15
 
15
16
  import { execFileSync } from 'node:child_process';
16
17
 
17
- import { gitAuthEnv } from './git-source-auth.mjs';
18
+ import { assertCanonicalSourceUrl, assertSourceDeclaresNoCredential, isUnderSourcePrefix, withSourceGitEnv } from './git-source-auth.mjs';
18
19
  import { canonicalPublicHttpsRepositoryUrl } from './lib.mjs';
19
20
  import {
20
21
  dataPinKey,
@@ -31,26 +32,32 @@ import {
31
32
  const DEVELOP_REF = 'refs/heads/develop';
32
33
  const SHA_PATTERN = /^[0-9a-f]{40}$/;
33
34
 
34
- /** `git ls-remote <url> refs/heads/develop`, authenticated as `username` with `token`; throws naming the repository when absent. */
35
- export function lsRemoteDevelopHead(url, { username, token, runGit = execFileSync } = {}) {
36
- const output = runGit('git', ['ls-remote', url, DEVELOP_REF], {
37
- env: gitAuthEnv(url, username, token),
38
- encoding: 'utf8',
39
- stdio: ['ignore', 'pipe', 'pipe'],
40
- }).trim();
35
+ /**
36
+ * `git ls-remote <url> refs/heads/develop` for one source `{ name, url }`; throws naming the repository when
37
+ * absent. The source is held and its git environment built by `withSourceGitEnv`: the workspace credential
38
+ * reaches only a repository under `sourceRepositoryUrlPrefix`.
39
+ */
40
+ export function lsRemoteDevelopHead(source, { workspace, env = process.env, runGit = execFileSync }) {
41
+ const output = withSourceGitEnv(source, { workspace, env }, (gitEnv) =>
42
+ runGit('git', ['ls-remote', source.url, DEVELOP_REF], {
43
+ env: gitEnv,
44
+ encoding: 'utf8',
45
+ stdio: ['ignore', 'pipe', 'pipe'],
46
+ }),
47
+ ).trim();
41
48
  const [sha, ref] = output.split(/\s+/);
42
49
  if (!SHA_PATTERN.test(sha ?? '') || ref !== DEVELOP_REF) {
43
- throw new Error(`${url} has no ${DEVELOP_REF}; a develop edition cannot be frozen without it`);
50
+ throw new Error(`${source.url} has no ${DEVELOP_REF}; a develop edition cannot be frozen without it`);
44
51
  }
45
52
  return sha;
46
53
  }
47
54
 
48
- /** A resolver that asks `resolveHead` at most once per distinct URL. */
55
+ /** A resolver that asks `resolveHead` at most once per distinct URL; it takes the source `{ name, url }`. */
49
56
  function memoizedHeads(resolveHead) {
50
57
  const heads = new Map();
51
- return (url) => {
52
- if (!heads.has(url)) {heads.set(url, resolveHead(url));}
53
- return heads.get(url);
58
+ return (source) => {
59
+ if (!heads.has(source.url)) {heads.set(source.url, resolveHead(source));}
60
+ return heads.get(source.url);
54
61
  };
55
62
  }
56
63
 
@@ -135,11 +142,11 @@ export function freezeGraph({
135
142
  }
136
143
  const pins = Object.fromEntries(checkedOut.map(({ key, sha }) => [key, sha]));
137
144
  for (const pin of runtimeSourceIndex.repositoryPins) {
138
- pins[pin.repo] = overrides[pin.repo] ?? head(pin.repositoryUrl);
145
+ pins[pin.repo] = overrides[pin.repo] ?? head({ name: pin.repo, url: pin.repositoryUrl });
139
146
  }
140
147
  if (editionsRootUrl !== undefined) {
141
148
  const url = canonicalPublicHttpsRepositoryUrl(editionsRootUrl, '--editions-root-url');
142
- pins[EDITIONS_ROOT_PIN_KEY] = overrides[EDITIONS_ROOT_PIN_KEY] ?? head(url);
149
+ pins[EDITIONS_ROOT_PIN_KEY] = overrides[EDITIONS_ROOT_PIN_KEY] ?? head({ name: EDITIONS_ROOT_PIN_KEY, url });
143
150
  }
144
151
  return sortedSnapshotPins(pins);
145
152
  }
@@ -152,22 +159,59 @@ export function freezeGraph({
152
159
  export function freezeSources({ pins, declaredSources, overrides = {}, resolveHead }) {
153
160
  assertOverrides(overrides, SOURCE_PHASE);
154
161
  const head = memoizedHeads(resolveHead);
155
- const urlByName = new Map();
162
+ const sourceByName = new Map();
156
163
  for (const source of declaredSources) {
157
- const known = urlByName.get(source.name);
158
- if (known !== undefined && known !== source.url) {
159
- throw new Error(`source "${source.name}" is declared at two URLs (${known}, ${source.url})`);
164
+ const known = sourceByName.get(source.name);
165
+ if (known !== undefined && known.url !== source.url) {
166
+ throw new Error(`source "${source.name}" is declared at two URLs (${known.url}, ${source.url})`);
160
167
  }
161
- urlByName.set(source.name, source.url);
168
+ sourceByName.set(source.name, source);
162
169
  }
163
170
  for (const key of Object.keys(overrides)) {
164
- if (snapshotPinNamespace(key) === SnapshotPinNamespace.Source && !urlByName.has(sourceNameOfPinKey(key))) {
171
+ if (snapshotPinNamespace(key) === SnapshotPinNamespace.Source && !sourceByName.has(sourceNameOfPinKey(key))) {
165
172
  throw new Error(`override names "${key}", but the chain declares no such source`);
166
173
  }
167
174
  }
168
175
  const frozen = { ...pins };
169
- for (const [name, url] of urlByName) {
170
- frozen[sourcePinKey(name)] = overrides[sourcePinKey(name)] ?? head(url);
176
+ for (const [name, source] of sourceByName) {
177
+ frozen[sourcePinKey(name)] = overrides[sourcePinKey(name)] ?? head(source);
171
178
  }
172
179
  return sortedSnapshotPins(frozen);
173
180
  }
181
+
182
+ /**
183
+ * The declared source URLs a customer freeze may ask, held BEFORE any network call: the token of the
184
+ * run (EVIDENCE_REPOSITORIES_TOKEN) is sent to the origin of each URL, so a URL is refused unless it is
185
+ * a canonical public HTTPS repository URL written in its canonical form and lies under the workspace's
186
+ * `sourceRepositoryUrlPrefix`. A definition that merely names another origin never receives the token.
187
+ */
188
+ export function assertCustomerSourceUrls(sources, sourceRepositoryUrlPrefix) {
189
+ if (typeof sourceRepositoryUrlPrefix !== 'string' || sourceRepositoryUrlPrefix.length === 0) {
190
+ throw new Error('a customer freeze takes the workspace\'s sourceRepositoryUrlPrefix');
191
+ }
192
+ for (const source of sources) {
193
+ assertSourceDeclaresNoCredential(source);
194
+ const canonical = assertCanonicalSourceUrl(source);
195
+ const context = `source "${source.name}" url`;
196
+ if (!isUnderSourcePrefix(canonical, sourceRepositoryUrlPrefix)) {
197
+ throw new Error(
198
+ `${context} ${JSON.stringify(source.url)} is not under the workspace's sourceRepositoryUrlPrefix ${sourceRepositoryUrlPrefix}: ` +
199
+ 'the source token is sent only to repositories under that prefix',
200
+ );
201
+ }
202
+ }
203
+ }
204
+
205
+ /**
206
+ * The pins of a CUSTOMER edition: exactly one `sources/<name>` commit per source its definition declares,
207
+ * each the head of refs/heads/develop of that source's repository, resolved once. Every URL is held by
208
+ * `assertCustomerSourceUrls` before the first head is asked.
209
+ */
210
+ export function freezeCustomerSources({ definition, resolveHead, sourceRepositoryUrlPrefix }) {
211
+ const sources = definition?.sources ?? [];
212
+ if (!Array.isArray(sources) || sources.some((source) => typeof source?.name !== 'string' || typeof source?.url !== 'string')) {
213
+ throw new Error('the definition\'s sources must be an array of {name, url}');
214
+ }
215
+ assertCustomerSourceUrls(sources, sourceRepositoryUrlPrefix);
216
+ return freezeSources({ pins: {}, declaredSources: sources, resolveHead });
217
+ }
@@ -25,26 +25,30 @@ function notBuiltYetMessage(subject) {
25
25
  }
26
26
 
27
27
  /**
28
+ * The row-level seal: every subject re-homed to its develop repository, verified
29
+ * there for its required platforms, and given the digest the registry served.
30
+ * It takes SUBJECTS, not a lock, so a caller that owns only some rows of a lock
31
+ * (a customer's own image rows) seals exactly those.
32
+ *
28
33
  * @param {object} request
29
- * @param {object} request.imageLock the snapshot images lock (release refs, unsealed)
30
- * @returns the images lock with develop refs and verified digests, kernel-validated
34
+ * @param {string} request.distributionId the edition the subjects belong to (named in refusals)
35
+ * @param {object[]} request.subjects image subjects (release refs, unsealed)
36
+ * @returns the subjects with develop refs and verified digests
31
37
  */
32
- export async function sealDevelopImages({
33
- imageLock,
38
+ export async function sealDevelopImageSubjects({
39
+ distributionId,
40
+ subjects,
34
41
  distributionsRoot = editionsRoot(),
35
42
  env = process.env,
36
43
  fetchImpl = globalThis.fetch,
37
44
  run = null,
38
45
  }) {
39
- const developLock = {
40
- ...imageLock,
41
- subjects: imageLock.subjects.map((subject) => ({
42
- ...subject,
43
- imageRef: developImageRef(subject.imageRef),
44
- })),
45
- };
46
+ const developSubjects = subjects.map((subject) => ({
47
+ ...subject,
48
+ imageRef: developImageRef(subject.imageRef),
49
+ }));
46
50
  const report = await verifyDistributionPlatforms({
47
- imageLock: developLock,
51
+ imageLock: { distributionId, subjects: developSubjects },
48
52
  distributionsRoot,
49
53
  env,
50
54
  fetchImpl,
@@ -55,23 +59,33 @@ export async function sealDevelopImages({
55
59
  if (aborting) {throw aborting.error;}
56
60
  if (report.failures.length > 0) {
57
61
  throw new Error(
58
- `${report.failures.length} develop image(s) of "${developLock.distributionId}" are not built yet ` +
62
+ `${report.failures.length} develop image(s) of "${distributionId}" are not built yet ` +
59
63
  '(the producer\'s CI is not green or still running):\n ' +
60
64
  report.failures.map(({ subject }) => notBuiltYetMessage(subject)).join('\n '),
61
65
  );
62
66
  }
63
- const { writes, unchanged } = planDigestSeal({ subjects: developLock.subjects, verified: report.subjects });
67
+ const { writes, unchanged } = planDigestSeal({ subjects: developSubjects, verified: report.subjects });
64
68
  const digests = new Map([...writes, ...unchanged].map(({ key, digest }) => [key, digest]));
69
+ return developSubjects.map((subject) => ({
70
+ ...subject,
71
+ imageDigest: digests.get(`${subject.kind}:${subject.id}`),
72
+ }));
73
+ }
74
+
75
+ /**
76
+ * The seal of a WHOLE images lock (the vendor snapshot): every subject sealed,
77
+ * and the result parsed as the kernel's image lock.
78
+ *
79
+ * @param {object} request
80
+ * @param {object} request.imageLock the snapshot images lock (release refs, unsealed)
81
+ * @returns the images lock with develop refs and verified digests, kernel-validated
82
+ */
83
+ export async function sealDevelopImages({ imageLock, ...sealOptions }) {
84
+ const subjects = await sealDevelopImageSubjects({
85
+ distributionId: imageLock.distributionId,
86
+ subjects: imageLock.subjects,
87
+ ...sealOptions,
88
+ });
65
89
  const schemas = await loadKernelDistributionSchemas();
66
- const { parseImageLock } = schemas;
67
- return validateImmutableImageLock(
68
- parseImageLock({
69
- ...developLock,
70
- subjects: developLock.subjects.map((subject) => ({
71
- ...subject,
72
- imageDigest: digests.get(`${subject.kind}:${subject.id}`),
73
- })),
74
- }),
75
- schemas,
76
- );
90
+ return validateImmutableImageLock(schemas.parseImageLock({ ...imageLock, subjects }), schemas);
77
91
  }
@@ -74,7 +74,7 @@ import { withoutSealedDigests } from './seal-image-digests.mjs';
74
74
  import { committedRepositoryPins, createPinnedSourceAuthority } from './source-authority.mjs';
75
75
 
76
76
  if (isEntryModule(import.meta.url)) {
77
- configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Deploy, WorkspaceRoot.Cache, WorkspaceRoot.SourceHash]);
77
+ configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Deploy, WorkspaceRoot.Cache]);
78
78
  }
79
79
 
80
80
  const HERE = dirname(fileURLToPath(import.meta.url));
@@ -1,7 +1,8 @@
1
1
  // edition-artifact — the files of an edition and where they may go.
2
2
  //
3
3
  // ONE layout for develop and release: an OCI artifact at
4
- // `<registry>/xema-edition-<id>`, addressed by digest, whose titled layers are
4
+ // `<registry root>/<channel segment>/xema-edition-<id>` (release: `editions`, develop: `develop-editions`,
5
+ // from edition-contract.json), addressed by digest, whose titled layers are
5
6
  // `xema-edition.json` (the descriptor) plus the lock files. It is written to a
6
7
  // scratch directory; the workflow pushes it with oras and signs the digest.
7
8
  // Tags are for humans only (develop: the snapshot hash; release: the
@@ -22,6 +23,7 @@ import {
22
23
  EDITION_DESCRIPTOR_SCHEMA,
23
24
  EDITION_LOCKS,
24
25
  EDITION_REPOSITORY_PREFIX,
26
+ EDITION_REPOSITORY_SEGMENTS,
25
27
  EditionChannel,
26
28
  EditionLockFile,
27
29
  EvidenceTrustState,
@@ -66,6 +68,13 @@ export function assertEngineImage(image) {
66
68
  */
67
69
  export function editionDescriptor({ distributionId, channel, pins, workflow, evidenceTrustPolicy, engineImage }) {
68
70
  assertCanonicalDistributionId(distributionId);
71
+ // Every writer of a descriptor passes here: no route writes a release
72
+ // edition that is not armed, whatever state its caller computed.
73
+ if (channel === EditionChannel.Release && evidenceTrustPolicy !== EvidenceTrustState.Armed) {
74
+ throw new Error(
75
+ `a release edition requires an armed evidence trust policy; "${distributionId}" was to be written ${JSON.stringify(evidenceTrustPolicy)}`,
76
+ );
77
+ }
69
78
  return parseEditionDescriptor({
70
79
  schema: EDITION_DESCRIPTOR_SCHEMA,
71
80
  distributionId,
@@ -124,48 +133,93 @@ export function parseEditionDescriptor(value) {
124
133
  if (!Object.values(EvidenceTrustState).includes(value.evidenceTrustPolicy)) {
125
134
  throw new Error(`${EDITION_DESCRIPTOR_FILE}: evidenceTrustPolicy must be one of ${Object.values(EvidenceTrustState).join(' | ')}`);
126
135
  }
136
+ // The reader holds the writer's rule: a release edition that is not armed is
137
+ // not an edition this plane writes, so it is not one it reads.
138
+ if (value.provenance.channel === EditionChannel.Release && value.evidenceTrustPolicy !== EvidenceTrustState.Armed) {
139
+ throw new Error(
140
+ `${EDITION_DESCRIPTOR_FILE}: a release edition requires an armed evidence trust policy; "${value.distributionId}" records ${JSON.stringify(value.evidenceTrustPolicy)}`,
141
+ );
142
+ }
127
143
  exactKeys(value.engine, ['image'], `${EDITION_DESCRIPTOR_FILE} engine`);
128
144
  assertEngineImage(value.engine.image);
129
145
  return value;
130
146
  }
131
147
 
132
148
  const REGISTRY_PATTERN = /^[a-z0-9.-]+(:[0-9]+)?(\/[a-z0-9._-]+)+$/;
149
+ /** `.` and `..` match the segment grammar and name no namespace: a registry resolves them away. */
150
+ const isDotSegment = (segment) => segment === '.' || segment === '..';
133
151
  const TAG_PATTERN = /^[A-Za-z0-9_][A-Za-z0-9._-]{0,127}$/;
134
152
 
135
- /** The registry is the caller's namespace; the CLI appends `xema-edition-<id>` itself, so a registry already naming an edition repository is refused. */
136
- export function assertRegistry(registry) {
137
- if (typeof registry !== 'string' || !REGISTRY_PATTERN.test(registry)) {
153
+ /**
154
+ * The registry ROOT is the caller's `<host>[:port]/<namespace>[/...]`; the CLI appends the channel's
155
+ * namespace segment AND `xema-edition-<id>` itself, so a root already ending in either is refused.
156
+ */
157
+ export function assertRegistry(registryRoot) {
158
+ if (typeof registryRoot !== 'string' || !REGISTRY_PATTERN.test(registryRoot) || registryRoot.split('/').slice(1).some(isDotSegment)) {
138
159
  throw new Error(
139
- `--registry ${JSON.stringify(registry)} is not <registry-host>[:port]/<namespace>[/...] (lowercase, e.g. registry.example.com/acme)`,
160
+ `--registry ${JSON.stringify(registryRoot)} is not <registry-host>[:port]/<namespace>[/...] (lowercase, e.g. registry.example.com/acme)`,
140
161
  );
141
162
  }
142
- const last = registry.split('/').pop();
143
- if (last.startsWith(EDITION_REPOSITORY_PREFIX)) {
163
+ const last = registryRoot.split('/').pop();
164
+ if (Object.values(EDITION_REPOSITORY_SEGMENTS).includes(last) || last.startsWith(EDITION_REPOSITORY_PREFIX)) {
144
165
  throw new Error(
145
- `--registry ${registry} ends in "${last}", which the CLI appends itself: ` +
146
- `the repository would be ${registry}/${EDITION_REPOSITORY_PREFIX}<id>. Pass the namespace only`,
166
+ `--registry ${registryRoot} ends in "${last}", which the CLI appends itself: pass the registry root; ` +
167
+ `the CLI appends <${Object.values(EDITION_REPOSITORY_SEGMENTS).join('|')}>/${EDITION_REPOSITORY_PREFIX}<id>`,
147
168
  );
148
169
  }
149
170
  }
150
171
 
151
- /** `<registry>/xema-edition-<id>`: the one repository of an edition, for either channel. */
152
- export function editionRepository(distributionId, registry) {
172
+ function assertChannel(channel) {
173
+ if (!Object.values(EditionChannel).includes(channel)) {
174
+ throw new Error(`edition channel ${JSON.stringify(channel)} must be one of ${Object.values(EditionChannel).join(' | ')}`);
175
+ }
176
+ }
177
+
178
+ /** `<registry root>/<channel segment>/xema-edition-<id>`: the repository of an edition produced for `channel`. */
179
+ export function editionRepository(distributionId, registryRoot, channel) {
153
180
  assertCanonicalDistributionId(distributionId);
154
- assertRegistry(registry);
155
- return `${registry}/${EDITION_REPOSITORY_PREFIX}${distributionId}`;
181
+ assertRegistry(registryRoot);
182
+ assertChannel(channel);
183
+ return `${registryRoot}/${EDITION_REPOSITORY_SEGMENTS[channel]}/${EDITION_REPOSITORY_PREFIX}${distributionId}`;
184
+ }
185
+
186
+ /**
187
+ * The one parser of an edition repository `<root>/<segment>/xema-edition-<id>`, returning
188
+ * `{ registryRoot, channel, distributionId }`. The penultimate segment must be one of the contract's
189
+ * channel segments and the last must carry the repository prefix.
190
+ */
191
+ export function parseEditionRepository(repository) {
192
+ const segments = typeof repository === 'string' ? repository.split('/') : [];
193
+ const last = segments.at(-1) ?? '';
194
+ const segment = segments.at(-2);
195
+ const channel = Object.values(EditionChannel).find((candidate) => EDITION_REPOSITORY_SEGMENTS[candidate] === segment);
196
+ if (channel === undefined) {
197
+ throw new Error(
198
+ `edition repository ${JSON.stringify(repository)}: its namespace segment ${JSON.stringify(segment)} is not one of ` +
199
+ Object.values(EDITION_REPOSITORY_SEGMENTS).map((name) => `"${name}"`).join(', '),
200
+ );
201
+ }
202
+ if (!last.startsWith(EDITION_REPOSITORY_PREFIX)) {
203
+ throw new Error(`edition repository ${JSON.stringify(repository)}: its name ${JSON.stringify(last)} lacks the "${EDITION_REPOSITORY_PREFIX}" prefix`);
204
+ }
205
+ const distributionId = last.slice(EDITION_REPOSITORY_PREFIX.length);
206
+ assertCanonicalDistributionId(distributionId);
207
+ const registryRoot = segments.slice(0, -2).join('/');
208
+ assertRegistry(registryRoot);
209
+ return { registryRoot, channel, distributionId };
156
210
  }
157
211
 
158
212
  /** `<repository>:<tag>`; the tag is for humans, the artifact is addressed by its digest. */
159
- export function editionTarget(distributionId, registry, tag) {
213
+ export function editionTarget(distributionId, registryRoot, channel, tag) {
160
214
  if (typeof tag !== 'string' || !TAG_PATTERN.test(tag)) {
161
215
  throw new Error(`tag ${JSON.stringify(tag)} is not an OCI tag`);
162
216
  }
163
- return `${editionRepository(distributionId, registry)}:${tag}`;
217
+ return `${editionRepository(distributionId, registryRoot, channel)}:${tag}`;
164
218
  }
165
219
 
166
220
  /** A develop edition's target: the tag is its snapshot hash. */
167
221
  export function developEditionTarget(distributionId, pins, { registry }) {
168
- return editionTarget(distributionId, registry, snapshotHash(pins));
222
+ return editionTarget(distributionId, registry, EditionChannel.Develop, snapshotHash(pins));
169
223
  }
170
224
 
171
225
  function realpathOfPlanned(path) {
@@ -235,9 +289,10 @@ export function writeEditionArtifact({
235
289
  * An existing snapshot-hash tag is reused only if it IS this snapshot: the tag
236
290
  * is a hash of the pins, but a tag is mutable registry state, so the pulled
237
291
  * descriptor must say the same edition, the same channel, exactly the same pins,
238
- * the same engine image, the same evidence trust state and the same lock set. Throws naming the first difference.
292
+ * the same engine image, the same evidence trust state and the same lock set. A customer artifact (`catalogueDigest` given) must also record that digest
293
+ * as its catalogue provenance pin. Throws naming the first difference.
239
294
  */
240
- export function assertExistingArtifactMatches({ artifactDir, distributionId, channel, pins, engineImage, evidenceTrustPolicy }) {
295
+ export function assertExistingArtifactMatches({ artifactDir, distributionId, channel, pins, engineImage, evidenceTrustPolicy, catalogueDigest }) {
241
296
  const descriptorPath = join(artifactDir, EDITION_DESCRIPTOR_FILE);
242
297
  if (!existsSync(descriptorPath)) {
243
298
  throw new Error(`the existing artifact holds no ${EDITION_DESCRIPTOR_FILE}; refusing to reuse a tag that is not an edition`);
@@ -253,6 +308,12 @@ export function assertExistingArtifactMatches({ artifactDir, distributionId, cha
253
308
  if (existing.evidenceTrustPolicy !== evidenceTrustPolicy) {
254
309
  throw new Error(`the existing artifact records evidenceTrustPolicy ${JSON.stringify(existing.evidenceTrustPolicy)}, this data root's is ${JSON.stringify(evidenceTrustPolicy)}; refusing to reuse it`);
255
310
  }
311
+ if (catalogueDigest !== undefined && existing.provenance.pins[CATALOGUE_PIN_KEY] !== catalogueDigest) {
312
+ throw new Error(
313
+ `the existing artifact's catalogue provenance pin is ${JSON.stringify(existing.provenance.pins[CATALOGUE_PIN_KEY])}, ` +
314
+ `the pinned catalogue is ${catalogueDigest}; refusing to reuse it`,
315
+ );
316
+ }
256
317
  if (serializeJson(existing.provenance.pins) !== serializeJson(sortedSnapshotPins(pins))) {
257
318
  throw new Error('the existing artifact was resolved from different pins than this snapshot (tag and content disagree); refusing to reuse it');
258
319
  }
@@ -3,7 +3,7 @@
3
3
  // biome of one edition, as JSON.
4
4
  //
5
5
  // usage: xema edition biome-manifests <id> --json --data-root <dir> --editions-root <dir>
6
- // --sources-root <dir> --source-hash <dir> --kernel <dir>
6
+ // --sources-root <dir> --kernel <dir>
7
7
  //
8
8
  // For every biome of the edition's resolved lock this reads the manifest AT the
9
9
  // commit the lock pins (the way the seal and `xema edition check` read it) and
@@ -31,7 +31,7 @@ import { committedRepositoryPins, createPinnedSourceAuthority } from './source-a
31
31
  export const EDITION_BIOME_MANIFESTS_SCHEMA = 'xema.edition-biome-manifests/v1';
32
32
 
33
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>';
34
+ 'usage: xema edition biome-manifests <id> --json --data-root <dir> --editions-root <dir> --sources-root <dir> --kernel <dir>';
35
35
 
36
36
  function readLock(distributionId, file) {
37
37
  try {
@@ -64,7 +64,6 @@ async function main() {
64
64
  WorkspaceRoot.Data,
65
65
  WorkspaceRoot.Editions,
66
66
  WorkspaceRoot.Sources,
67
- WorkspaceRoot.SourceHash,
68
67
  WorkspaceRoot.Kernel,
69
68
  ]);
70
69
  const operands = rest.filter((arg) => arg !== '--json');
@@ -11,7 +11,7 @@
11
11
  //
12
12
  // So each edition directory holds `<id>/chart-set.lock.json`: a byte-identical
13
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
14
+ // ROSTER (chart name → {version, digest}) read from the index, and the index chart's own manifest digest (`indexDigest`), so a
15
15
  // consumer selects every chart without asking the registry. It is part of the
16
16
  // edition's file set (compute-edition-coordinates.sh), hashed into its
17
17
  // coordinate and published with it, and xema-deploy vendors it beside the other
@@ -51,7 +51,38 @@ const CHART_VERSION_COORDINATE = Object.freeze({
51
51
  [EditionChannel.Develop]: /-dev\.[0-9a-f]{12}$/,
52
52
  });
53
53
 
54
- /** A chart-set lock (schemaVersion 3) that pins every member by version and OCI manifest digest, with `channel`'s version coordinate. */
54
+ /** The chart-set lock schema every reader accepts; a lock of any other schema is re-resolved, never migrated. */
55
+ export const CHART_SET_LOCK_SCHEMA_VERSION = 4;
56
+
57
+ /** The version prefix of a develop snapshot chart set (`0.0.0-dev.<sha12>`): xema-deploy's frozen-SHA charts, which have no index chart. */
58
+ export const DEVELOP_CHART_VERSION_PREFIX = '0.0.0-dev.';
59
+
60
+ /** The length of the lowercase-hex xema-deploy SHA a develop snapshot version carries. */
61
+ export const DEVELOP_CHART_SHA_LENGTH = 12;
62
+
63
+ /** The full develop snapshot version grammar: `0.0.0-dev.<12 lowercase hex>`, anchored. */
64
+ export const DEVELOP_CHART_VERSION_PATTERN = new RegExp(
65
+ `^${DEVELOP_CHART_VERSION_PREFIX.replaceAll('.', '\\.')}[0-9a-f]{${DEVELOP_CHART_SHA_LENGTH}}$`,
66
+ );
67
+
68
+ /** Does `version` start with the develop prefix, whether or not it is the full develop grammar? */
69
+ export const hasDevelopChartVersionPrefix = (version) => typeof version === 'string' && version.startsWith(DEVELOP_CHART_VERSION_PREFIX);
70
+
71
+ /** Is `version` a develop snapshot version? The one predicate for "this publisher has no index chart". */
72
+ export const isDevelopChartVersion = (version) => typeof version === 'string' && DEVELOP_CHART_VERSION_PATTERN.test(version);
73
+
74
+ /** An OCI manifest digest: `sha256:` and 64 lowercase hex. */
75
+ export const OCI_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
76
+
77
+ /** The instruction every refusal of an outdated lock carries. */
78
+ export const RESOLVE_CHART_SET_INSTRUCTION = 're-resolve the chart set with `xema edition chart-set resolve`';
79
+
80
+ /**
81
+ * A chart-set lock (schemaVersion 4) that pins every member by version and OCI
82
+ * manifest digest, with `channel`'s version coordinate, and pins each publisher's
83
+ * index chart by `indexDigest` (null exactly for a develop snapshot version,
84
+ * which has no index chart; the release channel refuses null).
85
+ */
55
86
  export function assertRosteredChartSetLock(lock, label, channel) {
56
87
  const coordinate = CHART_VERSION_COORDINATE[channel];
57
88
  if (coordinate === undefined) {
@@ -60,24 +91,46 @@ export function assertRosteredChartSetLock(lock, label, channel) {
60
91
  const fail = (why) => {
61
92
  throw new Error(`${label} is not a rostered chart-set lock: ${why}`);
62
93
  };
63
- if (lock?.schemaVersion !== 3) {fail('schemaVersion must be 3');}
94
+ if (lock?.schemaVersion !== CHART_SET_LOCK_SCHEMA_VERSION) {
95
+ fail(`schemaVersion must be ${CHART_SET_LOCK_SCHEMA_VERSION} (got ${JSON.stringify(lock?.schemaVersion)}); ${RESOLVE_CHART_SET_INSTRUCTION}`);
96
+ }
64
97
  if (!Array.isArray(lock.publishers) || lock.publishers.length === 0) {fail('publishers must be a non-empty array');}
65
- for (const pin of lock.publishers) {
98
+ for (const [position, pin] of lock.publishers.entries()) {
66
99
  if (!pin?.charts || typeof pin.charts !== 'object' || Array.isArray(pin.charts) || Object.keys(pin.charts).length === 0) {
67
100
  fail(`${pin?.index ?? '?'} carries no roster (charts: name -> member version) — run resolve-chart-set.mjs`);
68
101
  }
69
102
  for (const [name, member] of Object.entries(pin.charts)) {
70
103
  if (!member || typeof member !== 'object' || Array.isArray(member)) {fail(`${pin.index}: ${name} must be {version, digest}`);}
71
104
  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`);}
105
+ if (!OCI_DIGEST_PATTERN.test(String(member.digest))) {fail(`${pin.index}: ${name} has no sha256 manifest digest`);}
73
106
  }
107
+ assertIndexDigest(pin, position, channel, fail);
74
108
  }
75
109
  return lock;
76
110
  }
77
111
 
112
+ /** The `indexDigest` rule: present, a sha256 digest unless the version is a develop snapshot (then null), and never null in a release lock. */
113
+ function assertIndexDigest(pin, position, channel, fail) {
114
+ const where = `publisher ${position} (${pin?.index ?? '?'})`;
115
+ if (!pin || typeof pin !== 'object' || !Object.hasOwn(pin, 'indexDigest')) {
116
+ fail(`${where} carries no indexDigest key (required: "sha256:<64 hex>" for a release index, null for a develop snapshot version)`);
117
+ }
118
+ if (hasDevelopChartVersionPrefix(pin.version) && !isDevelopChartVersion(pin.version)) {
119
+ fail(`${where} has malformed develop chart version ${JSON.stringify(pin.version)}; a develop version is exactly ${DEVELOP_CHART_VERSION_PREFIX}<${DEVELOP_CHART_SHA_LENGTH} lowercase hex>`);
120
+ }
121
+ const develop = isDevelopChartVersion(pin.version);
122
+ if (pin.indexDigest === null) {
123
+ if (!develop) {fail(`${where} has indexDigest null with version ${JSON.stringify(pin.version)}; null is only for a develop snapshot version (${DEVELOP_CHART_VERSION_PREFIX}<sha12>)`);}
124
+ if (channel === EditionChannel.Release) {fail(`${where} has indexDigest null, which a release chart set refuses; a release index chart is pinned by digest`);}
125
+ return;
126
+ }
127
+ if (develop) {fail(`${where} has an indexDigest with develop snapshot version ${pin.version}; a develop snapshot has no index chart, so indexDigest must be null`);}
128
+ if (!OCI_DIGEST_PATTERN.test(String(pin.indexDigest))) {fail(`${where} has a malformed indexDigest (${JSON.stringify(pin.indexDigest)}); expected "sha256:<64 lowercase hex>"`);}
129
+ }
130
+
78
131
  /**
79
132
  * Write the RELEASE edition's copy of the fleet pin — byte-identical to
80
- * `pinText`. Only a rostered pin (schemaVersion 3) with release chart
133
+ * `pinText`. Only a rostered pin (schemaVersion 4) with release chart
81
134
  * coordinates is written.
82
135
  */
83
136
  export function writeEditionChartSet(editionDir, pinText) {