@xemahq/xema 0.40.3 → 0.41.1

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 (46) hide show
  1. package/dist/commands/edition.d.ts.map +1 -1
  2. package/dist/commands/edition.js +1 -0
  3. package/dist/commands/edition.js.map +1 -1
  4. package/dist/edition/verify-image-evidence.bundle.mjs +289 -19
  5. package/dist/edition/verify-image-evidence.bundle.provenance.json +71 -47
  6. package/dist/runtime/edition/verb-map.d.ts +3 -1
  7. package/dist/runtime/edition/verb-map.d.ts.map +1 -1
  8. package/dist/runtime/edition/verb-map.js +4 -0
  9. package/dist/runtime/edition/verb-map.js.map +1 -1
  10. package/edition/README.md +5 -0
  11. package/edition/catalogue-evidence.mjs +225 -0
  12. package/edition/catalogue-fetch.mjs +40 -7
  13. package/edition/customer-edition-verbs.mjs +477 -0
  14. package/edition/customer-flags.mjs +2 -0
  15. package/edition/develop-coverage-readers.mjs +212 -0
  16. package/edition/develop-coverage-run.mjs +48 -0
  17. package/edition/develop-coverage-verdict.mjs +153 -0
  18. package/edition/develop-coverage.mjs +120 -0
  19. package/edition/develop-edition-resolve.mjs +18 -7
  20. package/edition/develop-edition.mjs +47 -220
  21. package/edition/develop-freeze.mjs +50 -13
  22. package/edition/develop-image-probe.mjs +7 -7
  23. package/edition/dispatched-runtime-components.mjs +90 -1
  24. package/edition/edition-artifact.mjs +31 -14
  25. package/edition/edition-constants.mjs +9 -0
  26. package/edition/edition-contract.json +2 -1
  27. package/edition/{develop-seal.mjs → edition-image-seal.mjs} +36 -17
  28. package/edition/edition-workspace.mjs +4 -3
  29. package/edition/evidence-trust-pin.mjs +28 -8
  30. package/edition/evidence-trust-policy.mjs +51 -7
  31. package/edition/image-evidence.mjs +2 -2
  32. package/edition/lib.mjs +20 -11
  33. package/edition/oci-image-source-evidence.mjs +3 -0
  34. package/edition/oras-process.mjs +3 -3
  35. package/edition/oras.mjs +47 -5
  36. package/edition/produce-edition-catalogue-evidence.mjs +252 -0
  37. package/edition/produce-image-source-evidence.mjs +11 -2
  38. package/edition/release-edition.mjs +54 -0
  39. package/edition/repin-evidence-trust.mjs +5 -3
  40. package/edition/resolve-images.mjs +40 -5
  41. package/edition/sigstore-bundle-media-type.mjs +4 -0
  42. package/edition/trust-policy-arming.mjs +14 -1
  43. package/edition/verify-catalogue-evidence.mjs +245 -0
  44. package/edition/verify-image-evidence.mjs +15 -1
  45. package/package.json +2 -2
  46. package/edition/develop-customer-edition.mjs +0 -149
@@ -18,7 +18,7 @@
18
18
  // --sources-root, --cache-dir (no --deploy-root, no --kernel: the kernel is the catalogue's, from npm).
19
19
  // target <id> --pins <file> --registry <root> --engine-image <repository>@sha256:<digest>
20
20
  // print <root>/<develop segment>/xema-edition-<id>:<tag>, the tag being sha256 of the canonical
21
- // { cli, engine, pins, sourceImages } (developEditionTag): every input of the bytes the artifact records,
21
+ // { cli, engine, pins, sourceImages } (snapshotEditionTag): every input of the bytes the artifact records,
22
22
  // so the same pins with another engine, source-image map or CLI version are another artifact. The root is always the caller's
23
23
  // registry root (e.g. registry.example.com/acme); the develop namespace segment and the repository
24
24
  // prefix come from edition-contract.json, and no root is implied.
@@ -38,6 +38,12 @@
38
38
  // the same for a customer artifact; its descriptor's catalogue provenance pin must also equal the
39
39
  // pinned develop catalogue digest, and its own image rows must be the rows the --source-image map composes. It fetches that catalogue (as `resolve --definition` does) to take the
40
40
  // kernel from it. Takes --data-root, --cache-dir.
41
+ // coverage <id> --pins <file> --cosign <absolute-path> [--json]
42
+ // READ-ONLY: for every image subject of the snapshot, whether its develop image is producer-signed
43
+ // in the form T72 resolve accepts (attested | signature-verified | no-row | unsigned |
44
+ // annotations-missing | annotation-mismatch | not-built). Prints a table grouped by producer repo
45
+ // and `covered N/M` (--json: the same as data). Exit 0 only when every subject is covered, 1 when
46
+ // not, 2 on an operational error. Takes the roots `resolve` takes; writes nothing.
41
47
  // check-overrides --overrides <json>
42
48
  // refuse a pins override that would select the tooling this run executes
43
49
  // (the edition tooling's own repository); the workflow runs it before any other checkout.
@@ -51,27 +57,21 @@
51
57
  // Tokens come from the environment (EVIDENCE_REPOSITORIES_TOKEN, XEMA_GHCR_PULL_TOKEN),
52
58
  // never argv. Nothing here writes git or publishes a release edition.
53
59
 
54
- import { writeFileSync } from 'node:fs';
55
60
  import { resolve } from 'node:path';
56
61
 
57
- import { fetchCatalogue } from './catalogue-fetch.mjs';
58
- import { readCataloguePin } from './catalogue-pin.mjs';
59
62
  import { writeCatalogue } from './catalogue.mjs';
60
- import { CATALOGUE_TOKEN_ENV_FLAG, CATALOGUE_USERNAME_FLAG, DEFINITION_FLAG, SOURCE_IMAGE_FLAG } from './customer-flags.mjs';
61
- import { assertOwnImageRowsMatch, customerDevelopFrame, resolveCustomerDevelopEdition } from './develop-customer-edition.mjs';
63
+ import { CUSTOMER_ROOTS, headResolver, parseEditionVerbArgs, runCustomerVerb, writeOnce } from './customer-edition-verbs.mjs';
64
+ import { DEFINITION_FLAG } from './customer-flags.mjs';
65
+ import { runDevelopCoverage } from './develop-coverage-run.mjs';
66
+ import { CoverageExit } from './develop-coverage.mjs';
62
67
  import { resolveDevelopEdition } from './develop-edition-resolve.mjs';
63
- import { assertNoToolingOverride, freezeCustomerSources, freezeGraph, freezeSources, lsRemoteDevelopHead } from './develop-freeze.mjs';
64
- import { assertCanonicalDistributionId } from './distribution-paths.mjs';
65
- import { LOCAL_WORKFLOW, assertEngineImage, assertExistingArtifactMatches, developEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
66
- import { editionWorkspaceConfig } from './edition-config.mjs';
68
+ import { assertNoToolingOverride, freezeGraph, freezeSources } from './develop-freeze.mjs';
69
+ import { LOCAL_WORKFLOW, assertExistingArtifactMatches, developEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
67
70
  import { EditionChannel } from './edition-constants.mjs';
68
71
  import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
69
72
  import { descriptorEvidenceTrust } from './evidence-trust-policy.mjs';
70
- import { holdSource } from './git-source-auth.mjs';
71
- import { adoptCatalogueKernel } from './kernel-package.mjs';
72
- import { loadKernelDistributionSchemas, serializeJson } from './lib.mjs';
73
+ import { loadKernelDistributionSchemas } from './lib.mjs';
73
74
  import { snapshotPinSource } from './pin-source.mjs';
74
- import { assertSourceImagesCoverDefinition, parseSourceImages } from './resolve-customer-edition.mjs';
75
75
  import { composeEdition } from './resolve-distribution.mjs';
76
76
  import { readStrictJsonFile } from './strict-json.mjs';
77
77
 
@@ -81,12 +81,9 @@ import { readStrictJsonFile } from './strict-json.mjs';
81
81
  // takes only the data root in either form: it judges its --overrides JSON against the pin roles the data
82
82
  // root declares, so it reads nothing else and refuses every other root flag.
83
83
  const VENDOR_ROOTS = Object.freeze([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Deploy, WorkspaceRoot.Cache]);
84
- const CUSTOMER_ROOTS = Object.freeze({
85
- resolve: [WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Cache],
86
- target: [],
87
- 'check-existing': [WorkspaceRoot.Data, WorkspaceRoot.Cache],
88
- 'freeze-sources': [WorkspaceRoot.Data],
89
- });
84
+ // `coverage` is the P2b gate: 1 means "not covered", so an operational error is a distinct 2.
85
+ const COVERAGE_COMMAND = 'coverage';
86
+ const COVERAGE_OPERATIONAL_EXIT = CoverageExit.Operational;
90
87
  const DATA_ROOT_ONLY_COMMANDS = Object.freeze(['check-overrides']);
91
88
 
92
89
  if (isEntryModule(import.meta.url)) {
@@ -95,103 +92,15 @@ if (isEntryModule(import.meta.url)) {
95
92
  configureEditionWorkspaceFromProcess([WorkspaceRoot.Data], { refuseOthers: true });
96
93
  } else {
97
94
  const customerForm = process.argv.includes(DEFINITION_FLAG);
98
- configureEditionWorkspaceFromProcess(customerForm ? (CUSTOMER_ROOTS[command] ?? VENDOR_ROOTS) : VENDOR_ROOTS, { refuseOthers: customerForm && CUSTOMER_ROOTS[command] !== undefined });
95
+ configureEditionWorkspaceFromProcess(customerForm ? (CUSTOMER_ROOTS[command] ?? VENDOR_ROOTS) : VENDOR_ROOTS, {
96
+ refuseOthers: customerForm && CUSTOMER_ROOTS[command] !== undefined,
97
+ ...(command === COVERAGE_COMMAND ? { failureExitCode: COVERAGE_OPERATIONAL_EXIT } : {}),
98
+ });
99
99
  }
100
100
  }
101
101
 
102
- // Per verb: the vendor form (`positional` id, `values`, `switches`) and, where the verb has one, the customer
103
- // form: the flags only it takes, the further flags it requires, and the vendor flags it refuses.
104
- const CREDENTIAL_FLAGS = Object.freeze([CATALOGUE_USERNAME_FLAG, CATALOGUE_TOKEN_ENV_FLAG]);
105
- const REPEATABLE = Object.freeze([SOURCE_IMAGE_FLAG]);
106
-
107
- const COMMANDS = Object.freeze({
108
- resolve: {
109
- positional: true,
110
- values: ['--pins', '--out', '--registry', '--engine-image'],
111
- switches: ['--local'],
112
- required: ['--pins', '--out', '--registry', '--engine-image'],
113
- customer: { values: [DEFINITION_FLAG, ...CREDENTIAL_FLAGS, SOURCE_IMAGE_FLAG], required: ['--pins', '--out', '--registry', '--engine-image'], refused: [] },
114
- },
115
- target: {
116
- positional: true,
117
- values: ['--pins', '--registry', '--engine-image'],
118
- switches: [],
119
- required: ['--pins', '--registry', '--engine-image'],
120
- customer: { values: [DEFINITION_FLAG, SOURCE_IMAGE_FLAG], required: ['--pins', '--registry', '--engine-image'], refused: [] },
121
- },
122
- 'freeze-graph': {
123
- positional: false,
124
- values: ['--data-sha', '--kernel-sha', '--deploy-sha', '--editions-root-url', '--overrides', '--out'],
125
- switches: [],
126
- required: ['--data-sha', '--kernel-sha', '--deploy-sha', '--out'],
127
- },
128
- 'freeze-sources': {
129
- positional: true,
130
- values: ['--pins', '--overrides', '--out'],
131
- switches: [],
132
- required: ['--pins', '--out'],
133
- customer: { values: [DEFINITION_FLAG], required: ['--out'], refused: ['--pins', '--overrides'] },
134
- },
135
- 'check-overrides': { positional: false, values: ['--overrides'], switches: [], required: ['--overrides'] },
136
- 'check-existing': {
137
- positional: true,
138
- values: ['--pins', '--artifact', '--engine-image'],
139
- switches: [],
140
- required: ['--pins', '--artifact', '--engine-image'],
141
- customer: { values: [DEFINITION_FLAG, ...CREDENTIAL_FLAGS, SOURCE_IMAGE_FLAG], required: ['--pins', '--artifact', '--engine-image'], refused: [] },
142
- },
143
- });
144
-
145
- /** Parse `<command> [<id>] --flag value ...` against the closed vocabulary of the command. */
146
- export function parseDevelopEditionArgs(argv) {
147
- const [command, ...rest] = argv;
148
- const spec = COMMANDS[command];
149
- if (spec === undefined) {
150
- throw new Error(`unknown command ${JSON.stringify(command)}; expected ${Object.keys(COMMANDS).join(' | ')}`);
151
- }
152
- const customerValues = spec.customer?.values ?? [];
153
- const options = {};
154
- const positional = [];
155
- for (let i = 0; i < rest.length; i += 1) {
156
- const arg = rest[i];
157
- if (spec.switches.includes(arg)) {
158
- options[arg] = true;
159
- } else if (spec.values.includes(arg) || customerValues.includes(arg)) {
160
- const value = rest[i + 1];
161
- if (value === undefined || (value.startsWith('--') && value.length > 2)) {
162
- throw new Error(`${arg} requires a value`);
163
- }
164
- if (REPEATABLE.includes(arg)) {
165
- options[arg] = [...(options[arg] ?? []), value];
166
- } else if (options[arg] !== undefined) {
167
- throw new Error(`${arg} given twice`);
168
- } else {
169
- options[arg] = value;
170
- }
171
- i += 1;
172
- } else if (arg.startsWith('--')) {
173
- throw new Error(`unknown argument ${arg} for ${command}`);
174
- } else {
175
- positional.push(arg);
176
- }
177
- }
178
- const customer = spec.customer !== undefined && customerValues.some((flag) => options[flag] !== undefined);
179
- if (customer) {
180
- if (options[DEFINITION_FLAG] === undefined) {throw new Error(`a customer ${command} requires ${DEFINITION_FLAG}`);}
181
- for (const refused of spec.customer.refused) {
182
- if (options[refused] !== undefined) {throw new Error(`a customer ${command} takes no ${refused}`);}
183
- }
184
- if (positional.length !== 0) {throw new Error(`a customer ${command} takes no <distribution-id>: the id is the definition's`);}
185
- } else if (spec.positional ? positional.length !== 1 : positional.length !== 0) {
186
- throw new Error(`${command} takes ${spec.positional ? 'exactly one <distribution-id>' : 'no positional argument'}`);
187
- }
188
- for (const required of customer ? spec.customer.required : spec.required) {
189
- if (options[required] === undefined) {throw new Error(`${command} requires ${required}`);}
190
- }
191
- if (spec.positional && !customer) {assertCanonicalDistributionId(positional[0]);}
192
- if (options['--engine-image'] !== undefined) {assertEngineImage(options['--engine-image']);}
193
- return { command, id: positional[0], options, customer };
194
- }
102
+ /** Parse `<command> [<id>] --flag value ...` against the closed vocabulary of the develop command. */
103
+ export const parseDevelopEditionArgs = (argv) => parseEditionVerbArgs(EditionChannel.Develop, argv);
195
104
 
196
105
  function parseOverrides(text) {
197
106
  if (text === undefined || text.trim() === '') {return {};}
@@ -202,102 +111,16 @@ function parseOverrides(text) {
202
111
  return parsed;
203
112
  }
204
113
 
205
- function writeOnce(path, value) {
206
- writeFileSync(path, serializeJson(value), { flag: 'wx' });
207
- }
208
-
209
- function headResolver(env) {
210
- const workspace = editionWorkspaceConfig();
211
- return { resolveHead: (source) => lsRemoteDevelopHead(source, { workspace, env }), holdSource: (source) => holdSource(source, workspace, env) };
212
- }
213
-
214
- /** The pinned develop catalogue of a customer definition: fetched by its digest, as every customer develop verb does. */
215
- function fetchPinnedDevelopCatalogue(options, env) {
216
- return fetchCatalogue({
217
- ref: readCataloguePin({ definitionFile: options[DEFINITION_FLAG], channel: EditionChannel.Develop }),
218
- channel: EditionChannel.Develop,
219
- username: options[CATALOGUE_USERNAME_FLAG],
220
- tokenEnv: options[CATALOGUE_TOKEN_ENV_FLAG],
221
- env,
222
- });
223
- }
224
-
225
- /** A customer develop resolve: the artifact (descriptor and four locks, no catalogue: it is not an offering) goes to --out. */
226
- async function resolveCustomer(options, env) {
227
- const { catalogue, digest } = await fetchPinnedDevelopCatalogue(options, env);
228
- const rawDefinition = readStrictJsonFile(options[DEFINITION_FLAG]);
229
- const sourceImages = parseSourceImages(options[SOURCE_IMAGE_FLAG] ?? []);
230
- // The catalogue's kernel is adopted inside the resolve, before any kernel schema is loaded; the evidence trust
231
- // state loads kernel schemas, so it is read after.
232
- const { id, pins, ...resolved } = await resolveCustomerDevelopEdition({
233
- rawDefinition,
234
- catalogue,
235
- catalogueDigest: digest,
236
- pins: readStrictJsonFile(options['--pins']),
237
- sourceImages,
238
- env,
239
- });
240
- const evidenceTrustPolicy = await descriptorEvidenceTrust({ channel: EditionChannel.Develop });
241
- const target = developEditionTarget(id, pins, { registry: options['--registry'], engineImage: options['--engine-image'], sourceImages });
242
- writeEditionArtifact({
243
- outDir: resolve(options['--out']),
244
- channel: EditionChannel.Develop,
245
- distributionId: id,
246
- pins,
247
- workflow: options['--local'] ? LOCAL_WORKFLOW : workflowFromEnv(env),
248
- evidenceTrustPolicy,
249
- engineImage: options['--engine-image'],
250
- distributionLock: resolved.distributionLock,
251
- imageLock: resolved.imageLock,
252
- authorizationLock: resolved.authorizationLock,
253
- chartSetLock: resolved.chartSetLock,
254
- });
255
- console.log(target);
256
- }
257
-
258
- /** A customer check-existing: the pinned catalogue is fetched to take its kernel; the digest fetched is the digest the artifact is compared with. */
259
- export async function checkExistingCustomer(options, env, { adopt = adoptCatalogueKernel } = {}) {
260
- const frame = customerDevelopFrame({ definitionFile: options[DEFINITION_FLAG], pinsFile: options['--pins'] });
261
- const sourceImages = customerSourceImages(options, frame.rawDefinition);
262
- const { catalogue, digest } = await fetchPinnedDevelopCatalogue(options, env);
263
- if (digest !== frame.catalogueDigest) {
264
- throw new Error(`the fetched catalogue is ${digest}, not the pinned ${frame.catalogueDigest}`);
265
- }
266
- await adopt(catalogue);
267
- assertExistingArtifactMatches({
268
- artifactDir: resolve(options['--artifact']),
269
- distributionId: frame.id,
270
- channel: EditionChannel.Develop,
271
- evidenceTrustPolicy: await descriptorEvidenceTrust({ channel: EditionChannel.Develop }),
272
- pins: frame.pins,
273
- engineImage: options['--engine-image'],
274
- catalogueDigest: frame.catalogueDigest,
275
- });
276
- assertOwnImageRowsMatch({ artifactDir: resolve(options['--artifact']), sourceImages });
277
- }
278
-
279
- /** The parsed --source-image map of a customer verb, covering exactly the definition's declared sources. */
280
- function customerSourceImages(options, rawDefinition) {
281
- const sourceImages = parseSourceImages(options[SOURCE_IMAGE_FLAG] ?? []);
282
- assertSourceImagesCoverDefinition(rawDefinition, sourceImages);
283
- return sourceImages;
284
- }
285
-
286
114
  async function run(argv, env) {
287
115
  const { command, id, options, customer } = parseDevelopEditionArgs(argv);
288
116
  switch (command) {
289
117
  case 'target': {
290
- if (customer) {
291
- const frame = customerDevelopFrame({ definitionFile: options[DEFINITION_FLAG], pinsFile: options['--pins'] });
292
- const sourceImages = customerSourceImages(options, frame.rawDefinition);
293
- console.log(developEditionTarget(frame.id, frame.pins, { registry: options['--registry'], engineImage: options['--engine-image'], sourceImages }));
294
- return;
295
- }
118
+ if (customer) {return runCustomerVerb({ channel: EditionChannel.Develop, command, options, env });}
296
119
  console.log(developEditionTarget(id, readStrictJsonFile(options['--pins']), { registry: options['--registry'], engineImage: options['--engine-image'] }));
297
120
  return;
298
121
  }
299
122
  case 'resolve': {
300
- if (customer) {return resolveCustomer(options, env);}
123
+ if (customer) {return runCustomerVerb({ channel: EditionChannel.Develop, command, options, env });}
301
124
  const pins = readStrictJsonFile(options['--pins']);
302
125
  const workflow = options['--local'] ? LOCAL_WORKFLOW : workflowFromEnv(env);
303
126
  const target = developEditionTarget(id, pins, { registry: options['--registry'], engineImage: options['--engine-image'] });
@@ -318,8 +141,16 @@ async function run(argv, env) {
318
141
  console.log(target);
319
142
  return;
320
143
  }
144
+ case 'coverage':
145
+ return runDevelopCoverage({
146
+ id,
147
+ pins: readStrictJsonFile(options['--pins']),
148
+ cosignBinary: options['--cosign'],
149
+ json: options['--json'] === true,
150
+ env,
151
+ });
321
152
  case 'check-existing': {
322
- if (customer) {return checkExistingCustomer(options, env);}
153
+ if (customer) {return runCustomerVerb({ channel: EditionChannel.Develop, command, options, env });}
323
154
  assertExistingArtifactMatches({
324
155
  artifactDir: resolve(options['--artifact']),
325
156
  distributionId: id,
@@ -342,29 +173,19 @@ async function run(argv, env) {
342
173
  deploySha: options['--deploy-sha'],
343
174
  editionsRootUrl: options['--editions-root-url'],
344
175
  overrides: parseOverrides(options['--overrides']),
345
- ...headResolver(env),
176
+ ...headResolver(EditionChannel.Develop, env),
346
177
  });
347
178
  writeOnce(options['--out'], pins);
348
179
  return;
349
180
  }
350
181
  case 'freeze-sources': {
351
- if (customer) {
352
- writeOnce(
353
- options['--out'],
354
- freezeCustomerSources({
355
- definition: readStrictJsonFile(options[DEFINITION_FLAG]),
356
- ...headResolver(env),
357
- sourceRepositoryUrlPrefix: editionWorkspaceConfig().sourceRepositoryUrlPrefix,
358
- }),
359
- );
360
- return;
361
- }
182
+ if (customer) {return runCustomerVerb({ channel: EditionChannel.Develop, command, options, env });}
362
183
  const pins = readStrictJsonFile(options['--pins']);
363
184
  const { parseDistribution } = await loadKernelDistributionSchemas();
364
185
  const declaredSources = composeEdition(parseDistribution, id, snapshotPinSource(pins)).declaredSources;
365
186
  writeOnce(
366
187
  options['--out'],
367
- freezeSources({ pins, declaredSources, overrides: parseOverrides(options['--overrides']), ...headResolver(env) }),
188
+ freezeSources({ pins, declaredSources, overrides: parseOverrides(options['--overrides']), ...headResolver(EditionChannel.Develop, env) }),
368
189
  );
369
190
  return;
370
191
  }
@@ -374,8 +195,14 @@ async function run(argv, env) {
374
195
  }
375
196
 
376
197
  if (isEntryModule(import.meta.url)) {
377
- run(process.argv.slice(2), process.env).catch((error) => {
378
- console.error(`[develop-edition] ${error.message}`);
379
- process.exit(1);
380
- });
198
+ const argv = process.argv.slice(2);
199
+ run(argv, process.env).then(
200
+ (code) => {
201
+ if (typeof code === 'number') {process.exitCode = code;}
202
+ },
203
+ (error) => {
204
+ console.error(`[develop-edition] ${error.message}`);
205
+ process.exit(argv[0] === COVERAGE_COMMAND ? COVERAGE_OPERATIONAL_EXIT : 1);
206
+ },
207
+ );
381
208
  }
@@ -15,6 +15,7 @@
15
15
 
16
16
  import { execFileSync } from 'node:child_process';
17
17
 
18
+ import { EditionChannel } from './edition-channel.mjs';
18
19
  import { assertCanonicalSourceUrl, assertSourceDeclaresNoCredential, isUnderSourcePrefix, withSourceGitEnv } from './git-source-auth.mjs';
19
20
  import { canonicalPublicHttpsRepositoryUrl } from './lib.mjs';
20
21
  import {
@@ -29,35 +30,71 @@ import {
29
30
  sourcePinKey,
30
31
  } from './pin-source.mjs';
31
32
 
32
- const DEVELOP_REF = 'refs/heads/develop';
33
33
  const SHA_PATTERN = /^[0-9a-f]{40}$/;
34
34
 
35
+ /** The ref namespaces a freeze may resolve a ref in. */
36
+ export const RefKind = Object.freeze({ Branch: 'branch', Tag: 'tag' });
37
+
38
+ const REF_PREFIX = Object.freeze({ [RefKind.Branch]: 'refs/heads/', [RefKind.Tag]: 'refs/tags/' });
39
+ const PEELED_SUFFIX = '^{}';
40
+
41
+ /**
42
+ * What each channel freezes a source at. A develop edition freezes the head of the `develop` BRANCH of every
43
+ * repository; a release edition freezes each source at the ref its definition declares, a branch or a tag.
44
+ */
45
+ const FREEZE_REF_KINDS = Object.freeze({
46
+ [EditionChannel.Develop]: Object.freeze([RefKind.Branch]),
47
+ [EditionChannel.Release]: Object.freeze([RefKind.Branch, RefKind.Tag]),
48
+ });
49
+
35
50
  /**
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`.
51
+ * The commit `ref` names in one source `{ name, url }`, by `git ls-remote <url> <refs/heads/ref | refs/tags/ref |
52
+ * refs/tags/ref^{}>` (only the namespaces the channel freezes at). A tag resolves to its peeled commit when it is annotated.
53
+ * Throws naming the repository when the ref is absent, or when it is both a branch and a tag. The source is held and its
54
+ * git environment built by `withSourceGitEnv`: the workspace credential reaches only a repository under `sourceRepositoryUrlPrefix`.
39
55
  */
40
- export function lsRemoteDevelopHead(source, { workspace, env = process.env, runGit = execFileSync }) {
56
+ export function lsRemoteRef(source, ref, { channel, workspace, env = process.env, runGit = execFileSync }) {
57
+ const kinds = FREEZE_REF_KINDS[channel];
58
+ if (kinds === undefined) {throw new Error(`unknown edition channel ${JSON.stringify(channel)}`);}
59
+ if (typeof ref !== 'string' || ref.length === 0) {
60
+ throw new Error(`source "${source.name}" declares no ref; a ${channel} edition cannot be frozen without one`);
61
+ }
62
+ const names = kinds.flatMap((kind) => (kind === RefKind.Tag ? [`${REF_PREFIX[kind]}${ref}`, `${REF_PREFIX[kind]}${ref}${PEELED_SUFFIX}`] : [`${REF_PREFIX[kind]}${ref}`]));
41
63
  const output = withSourceGitEnv(source, { workspace, env }, (gitEnv) =>
42
- runGit('git', ['ls-remote', source.url, DEVELOP_REF], {
64
+ runGit('git', ['ls-remote', source.url, ...names], {
43
65
  env: gitEnv,
44
66
  encoding: 'utf8',
45
67
  stdio: ['ignore', 'pipe', 'pipe'],
46
68
  }),
47
69
  ).trim();
48
- const [sha, ref] = output.split(/\s+/);
49
- if (!SHA_PATTERN.test(sha ?? '') || ref !== DEVELOP_REF) {
50
- throw new Error(`${source.url} has no ${DEVELOP_REF}; a develop edition cannot be frozen without it`);
70
+ const served = new Map();
71
+ for (const line of output.split('\n')) {
72
+ const [sha, name] = line.trim().split(/\s+/);
73
+ if (names.includes(name)) {
74
+ if (!SHA_PATTERN.test(sha ?? '')) {throw new Error(`${source.url}: git ls-remote answered ${JSON.stringify(line)} for ${name}`);}
75
+ served.set(name, sha);
76
+ }
77
+ }
78
+ const branch = served.get(`${REF_PREFIX[RefKind.Branch]}${ref}`);
79
+ const tagName = `${REF_PREFIX[RefKind.Tag]}${ref}`;
80
+ const tag = served.get(`${tagName}${PEELED_SUFFIX}`) ?? served.get(tagName);
81
+ if (branch !== undefined && tag !== undefined) {
82
+ throw new Error(`${source.url}: ${JSON.stringify(ref)} is both a branch and a tag; a ${channel} edition cannot be frozen at an ambiguous ref`);
83
+ }
84
+ const commit = branch ?? tag;
85
+ if (commit === undefined) {
86
+ throw new Error(`${source.url} has no ${kinds.map((kind) => `${REF_PREFIX[kind]}${ref}`).join(' or ')}; a ${channel} edition cannot be frozen without it`);
51
87
  }
52
- return sha;
88
+ return commit;
53
89
  }
54
90
 
55
- /** A resolver that asks `resolveHead` at most once per distinct URL; it takes the source `{ name, url }`. */
91
+ /** A resolver that asks `resolveHead` at most once per distinct URL and ref; it takes the source `{ name, url, ref? }`. */
56
92
  function memoizedHeads(resolveHead) {
57
93
  const heads = new Map();
58
94
  return (source) => {
59
- if (!heads.has(source.url)) {heads.set(source.url, resolveHead(source));}
60
- return heads.get(source.url);
95
+ const key = JSON.stringify([source.url, source.ref]);
96
+ if (!heads.has(key)) {heads.set(key, resolveHead(source));}
97
+ return heads.get(key);
61
98
  };
62
99
  }
63
100
 
@@ -18,20 +18,20 @@ import { registryClientsFor } from './verify-image-platforms.mjs';
18
18
 
19
19
  const DEFAULT_PROBE_CONCURRENCY = 6;
20
20
 
21
- /** One subject's line in the "not built yet" refusal. */
22
- export function notBuiltYetMessage(subject) {
21
+ /** One subject's line in the "not built yet" refusal of `channel` (develop unless named). */
22
+ export function notBuiltYetMessage(subject, channel = 'develop') {
23
23
  return (
24
- `${subject.repo}@${subject.sourceCommit} develop image ` +
24
+ `${subject.repo}@${subject.sourceCommit} ${channel} image ` +
25
25
  `${subject.imageRef}:${subject.contentHash} not built yet`
26
26
  );
27
27
  }
28
28
 
29
- /** The ONE refusal naming every develop subject whose coordinate is absent. */
30
- export function notBuiltYetError(distributionId, missingSubjects) {
29
+ /** The ONE refusal naming every subject of `channel` (develop unless named) whose coordinate is absent. */
30
+ export function notBuiltYetError(distributionId, missingSubjects, channel = 'develop') {
31
31
  return new Error(
32
- `${missingSubjects.length} develop image(s) of "${distributionId}" are not built yet ` +
32
+ `${missingSubjects.length} ${channel} image(s) of "${distributionId}" are not built yet ` +
33
33
  "(the producer's CI is not green or still running):\n " +
34
- missingSubjects.map(notBuiltYetMessage).join('\n '),
34
+ missingSubjects.map((subject) => notBuiltYetMessage(subject, channel)).join('\n '),
35
35
  );
36
36
  }
37
37
 
@@ -1,5 +1,70 @@
1
1
  import { compareStrings } from './string-order.mjs';
2
2
 
3
+ /** A `runtime-image` edge target that is neither `{biomeId, componentKey}` nor `{supplementalRuntimeComponentId}`, or is both. */
4
+ export class DispatchedEdgeError extends Error {
5
+ constructor(message) {
6
+ super(message);
7
+ this.name = 'DispatchedEdgeError';
8
+ }
9
+ }
10
+
11
+ /** A dispatched `supplemental:<id>` the runtime-image source index does not declare as a supplemental subject. */
12
+ export class UndeclaredSupplementalRuntimeComponentError extends Error {
13
+ constructor(supplementalRuntimeComponentId, dispatchingBiomes, context) {
14
+ super(
15
+ `${context}: supplemental runtime component "${supplementalRuntimeComponentId}", dispatched by biome ` +
16
+ `${dispatchingBiomes.map((id) => `"${id}"`).join(', ')}, is not declared as a runtime-component subject ` +
17
+ 'by the runtime image source index — refusing rather than shipping the dispatcher without the image it launches.',
18
+ );
19
+ this.name = 'UndeclaredSupplementalRuntimeComponentError';
20
+ this.supplementalRuntimeComponentId = supplementalRuntimeComponentId;
21
+ this.dispatchingBiomes = dispatchingBiomes;
22
+ }
23
+ }
24
+
25
+ // The `supplementalRuntimeComponentId` field is read as plain data: this CLI is on
26
+ // @xemahq/contracts 0.83, which does not type it. XEMA-171 (CLI edition protocol
27
+ // v9) adopts the typed field from @xemahq/contracts >= 0.91.
28
+ const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0;
29
+
30
+ /** True for the supplemental form of a projected edge. */
31
+ export const isSupplementalEdge = (edge) => edge.supplementalRuntimeComponentId !== undefined;
32
+
33
+ /** The identity of a projected edge: `supplemental:<id>` or `<biomeId>\0<componentKey>`. */
34
+ export const dispatchedEdgeIdentity = (edge) =>
35
+ isSupplementalEdge(edge)
36
+ ? `supplemental:${edge.supplementalRuntimeComponentId}`
37
+ : `${edge.biomeId}\0${edge.componentKey}`;
38
+
39
+ /**
40
+ * Project one generated-index `runtime-image` edge target into the typed union
41
+ * the index entry carries. A target that is neither form, or both, is refused
42
+ * naming the dispatching biome; no `undefined` identity can form.
43
+ */
44
+ export function projectDispatchedTarget(dispatchingBiomeId, target) {
45
+ const supplemental = target?.supplementalRuntimeComponentId;
46
+ const biomeForm = target?.biomeId !== undefined || target?.componentKey !== undefined;
47
+ if (supplemental !== undefined) {
48
+ if (biomeForm || !isNonEmptyString(supplemental)) {
49
+ throw new DispatchedEdgeError(
50
+ `biome "${dispatchingBiomeId}" has a runtime-image edge target that mixes supplementalRuntimeComponentId with ` +
51
+ 'biomeId/componentKey, or names an empty id — a target is exactly one form',
52
+ );
53
+ }
54
+ return { supplementalRuntimeComponentId: supplemental };
55
+ }
56
+ if (!isNonEmptyString(target?.biomeId) || !isNonEmptyString(target?.componentKey)) {
57
+ throw new DispatchedEdgeError(
58
+ `biome "${dispatchingBiomeId}" has a runtime-image edge target that is neither { biomeId, componentKey } nor ` +
59
+ '{ supplementalRuntimeComponentId }',
60
+ );
61
+ }
62
+ return { biomeId: target.biomeId, componentKey: target.componentKey };
63
+ }
64
+
65
+ const edgeLabel = (edge) =>
66
+ isSupplementalEdge(edge) ? `supplemental:${edge.supplementalRuntimeComponentId}` : `${edge.biomeId}/${edge.componentKey}`;
67
+
3
68
  /**
4
69
  * The runtime components an edition must carry because a biome it rosters
5
70
  * DISPATCHES them: the kernel's `runtime-image` edges, projected per biome into
@@ -21,6 +86,10 @@ import { compareStrings } from './string-order.mjs';
21
86
  * no biome drawn from a declared source. A biome drawn from a declared source
22
87
  * that dispatches is refused for the same reason.
23
88
  *
89
+ * A supplemental edge (`{supplementalRuntimeComponentId}`) names no biome: it
90
+ * is carried as a row `{ supplementalRuntimeComponentId, consumers }` without
91
+ * a biome lookup.
92
+ *
24
93
  * Returns one row per distinct (target biome, component key), sorted, with the
25
94
  * rostered biomes that dispatch it. Every unresolvable edge is refused, naming
26
95
  * the consumer and the target.
@@ -37,12 +106,24 @@ export function resolveDispatchedRuntimeComponents(context, consumers, index) {
37
106
  if (consumer.source !== undefined) {
38
107
  throw new Error(
39
108
  `${context}: biome "${consumer.id}", drawn from declared source "${consumer.source}", dispatches ` +
40
- `runtime components (${edges.map((edge) => `${edge.biomeId}/${edge.componentKey}`).join(', ')}). ` +
109
+ `runtime components (${edges.map(edgeLabel).join(', ')}). ` +
41
110
  'A dispatched image is resolved from the first-party biome index, which carries no biome from a ' +
42
111
  'declared source — refusing rather than shipping the dispatcher without the images it launches.',
43
112
  );
44
113
  }
45
114
  for (const edge of edges) {
115
+ if (isSupplementalEdge(edge)) {
116
+ // Carried as a requirement owned by the dispatching biome. Whether the
117
+ // runtime image source index declares it is the image plane's refusal.
118
+ const supplementalKey = dispatchedEdgeIdentity(edge);
119
+ const row = rows.get(supplementalKey) ?? {
120
+ supplementalRuntimeComponentId: edge.supplementalRuntimeComponentId,
121
+ consumers: [],
122
+ };
123
+ if (!row.consumers.includes(consumer.id)) {row.consumers.push(consumer.id);}
124
+ rows.set(supplementalKey, row);
125
+ continue;
126
+ }
46
127
  const edgeText = `biome "${consumer.id}" dispatches runtime component "${edge.componentKey}" of biome "${edge.biomeId}"`;
47
128
  const candidates = index.filter(
48
129
  (entry) => entry.id === edge.biomeId && entry.target === 'server' && entry.source === undefined,
@@ -80,3 +161,11 @@ export function resolveDispatchedRuntimeComponents(context, consumers, index) {
80
161
  .sort(([a], [b]) => compareStrings(a, b))
81
162
  .map(([, row]) => row);
82
163
  }
164
+
165
+ /** Sort order of projected edges: biome edges by (biomeId, componentKey), then supplemental edges by id. */
166
+ export function compareDispatchedEdges(a, b) {
167
+ if (isSupplementalEdge(a) !== isSupplementalEdge(b)) {return isSupplementalEdge(a) ? 1 : -1;}
168
+ return isSupplementalEdge(a)
169
+ ? compareStrings(a.supplementalRuntimeComponentId, b.supplementalRuntimeComponentId)
170
+ : compareStrings(a.biomeId, b.biomeId) || compareStrings(a.componentKey, b.componentKey);
171
+ }