@xemahq/xema 0.40.3 → 0.41.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 (44) 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 +281 -9
  5. package/dist/edition/verify-image-evidence.bundle.provenance.json +65 -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/edition-artifact.mjs +31 -14
  24. package/edition/edition-constants.mjs +9 -0
  25. package/edition/edition-contract.json +2 -1
  26. package/edition/{develop-seal.mjs → edition-image-seal.mjs} +36 -17
  27. package/edition/edition-workspace.mjs +4 -3
  28. package/edition/evidence-trust-pin.mjs +28 -8
  29. package/edition/evidence-trust-policy.mjs +51 -7
  30. package/edition/image-evidence.mjs +2 -2
  31. package/edition/lib.mjs +7 -0
  32. package/edition/oci-image-source-evidence.mjs +3 -0
  33. package/edition/oras-process.mjs +3 -3
  34. package/edition/oras.mjs +47 -5
  35. package/edition/produce-edition-catalogue-evidence.mjs +252 -0
  36. package/edition/produce-image-source-evidence.mjs +11 -2
  37. package/edition/release-edition.mjs +54 -0
  38. package/edition/repin-evidence-trust.mjs +5 -3
  39. package/edition/sigstore-bundle-media-type.mjs +4 -0
  40. package/edition/trust-policy-arming.mjs +14 -1
  41. package/edition/verify-catalogue-evidence.mjs +245 -0
  42. package/edition/verify-image-evidence.mjs +15 -1
  43. package/package.json +2 -2
  44. 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
 
@@ -5,7 +5,7 @@
5
5
  // from edition-contract.json), addressed by digest, whose titled layers are
6
6
  // `xema-edition.json` (the descriptor) plus the lock files. It is written to a
7
7
  // scratch directory; the workflow pushes it with oras and signs the digest.
8
- // Tags are for humans only (develop: the hash of every input, developEditionTag; release: the
8
+ // Tags are for humans only (an edition resolved from a snapshot: the hash of every input, snapshotEditionTag; vendor release: the
9
9
  // coordinate). Admission rests on the signature of the manifest digest, never on
10
10
  // a channel label: no reader branches on `provenance.channel`.
11
11
  //
@@ -19,6 +19,8 @@ import { assertCanonicalDistributionId } from './distribution-paths.mjs';
19
19
  import { EDITION_CHART_SET_FILE, assertRosteredChartSetLock } from './edition-chart-set.mjs';
20
20
  import {
21
21
  CATALOGUE_PIN_KEY,
22
+ DEFINITION_PIN_KEY,
23
+ DIGEST_PIN_KEYS,
22
24
  EDITION_DESCRIPTOR_FILE,
23
25
  EDITION_DESCRIPTOR_SCHEMA,
24
26
  EDITION_LOCKS,
@@ -81,7 +83,7 @@ export function editionDescriptor({ distributionId, channel, pins, workflow, evi
81
83
  locks: [...EDITION_LOCKS],
82
84
  provenance: {
83
85
  channel,
84
- pins: channel === EditionChannel.Develop ? sortedSnapshotPins(pins) : { ...pins },
86
+ pins: sortedSnapshotPins(pins),
85
87
  workflow: { repository: workflow?.repository, runId: workflow?.runId },
86
88
  },
87
89
  evidenceTrustPolicy,
@@ -90,7 +92,7 @@ export function editionDescriptor({ distributionId, channel, pins, workflow, evi
90
92
  }
91
93
 
92
94
  const COMMIT_PATTERN = /^[0-9a-f]{40}$/;
93
- const CATALOGUE_DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/;
95
+ const DIGEST_PIN_PATTERN = /^sha256:[0-9a-f]{64}$/;
94
96
 
95
97
  const exactKeys = (value, keys, where) => {
96
98
  if (value === null || typeof value !== 'object' || Array.isArray(value) || Object.keys(value).sort().join() !== [...keys].sort().join()) {
@@ -114,13 +116,14 @@ export function parseEditionDescriptor(value) {
114
116
  }
115
117
  const { pins, workflow } = value.provenance;
116
118
  if (pins === null || typeof pins !== 'object' || Array.isArray(pins)) {
117
- 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)`);
119
+ throw new Error(`${EDITION_DESCRIPTOR_FILE}: provenance.pins must map pin keys to 40-hex commits (the ${DIGEST_PIN_KEYS.map((key) => `"${key}"`).join(' and ')} pins to sha256 digests)`);
118
120
  }
119
121
  for (const [key, pin] of Object.entries(pins)) {
120
- const pattern = key === CATALOGUE_PIN_KEY ? CATALOGUE_DIGEST_PATTERN : COMMIT_PATTERN;
122
+ const digestPin = DIGEST_PIN_KEYS.includes(key);
123
+ const pattern = digestPin ? DIGEST_PIN_PATTERN : COMMIT_PATTERN;
121
124
  if (typeof pin !== 'string' || !pattern.test(pin)) {
122
125
  throw new Error(
123
- key === CATALOGUE_PIN_KEY
126
+ digestPin
124
127
  ? `${EDITION_DESCRIPTOR_FILE}: provenance.pins["${key}"] must be a sha256:<64 hex> digest`
125
128
  : `${EDITION_DESCRIPTOR_FILE}: provenance.pins must map pin keys to 40-hex commits (${JSON.stringify(key)} is not)`,
126
129
  );
@@ -238,15 +241,17 @@ export function canonicalSourceImages(sourceImages = new Map()) {
238
241
  }
239
242
 
240
243
  /**
241
- * A develop edition's tag: sha256 of the canonical `{ cli, engine, pins, sourceImages }`. Every input of
242
- * the bytes the artifact records is inside it. The pins fix the locks and the descriptor's pins; the
244
+ * The tag of an edition resolved from a snapshot (a develop edition, a customer release edition): sha256 of the canonical `{ cli, engine, pins, sourceImages }`. Every input of
245
+ * the bytes the artifact records is inside it. The pins fix the locks and the descriptor's pins: a vendor edition's
246
+ * commits fix its definitions; a customer edition's pins carry its sources' commits, the catalogue digest and the
247
+ * digest of its canonical definition (`definition`: vendor selection, image registry mirror, Store publisher anchors); the
243
248
  * descriptor's `engine.image` is not a pin; a customer's `--source-image` map composes its own image rows
244
249
  * (`<registry>/<prefix><subject>`) and is not a pin either; and the CLI version is the writer of every
245
250
  * byte. A tag over the pins alone named two artifacts that differ in any of the others, and a re-run could
246
251
  * only refuse the existing one (`check-existing`) or reuse a stale one. `provenance.workflow` (the run that
247
252
  * wrote the bytes) is the one recorded field outside it: a fact about the artifact, not an input to it.
248
253
  */
249
- export function developEditionTag({ pins, engineImage, sourceImages = new Map(), version = cliVersion() }) {
254
+ export function snapshotEditionTag({ pins, engineImage, sourceImages = new Map(), version = cliVersion() }) {
250
255
  return sha256Hex(
251
256
  serializeJson({
252
257
  cli: version,
@@ -257,9 +262,14 @@ export function developEditionTag({ pins, engineImage, sourceImages = new Map(),
257
262
  );
258
263
  }
259
264
 
260
- /** A develop edition's target: the tag is `developEditionTag` of the pins, the engine and the source images. */
265
+ /** The target of an edition resolved from a snapshot of pins, on either channel: the tag is `snapshotEditionTag`. */
266
+ export function snapshotEditionTarget(channel, distributionId, pins, { registry, engineImage, sourceImages }) {
267
+ return editionTarget(distributionId, registry, channel, snapshotEditionTag({ pins, engineImage, sourceImages }));
268
+ }
269
+
270
+ /** A develop edition's target: the tag is `snapshotEditionTag` of the pins, the engine and the source images. */
261
271
  export function developEditionTarget(distributionId, pins, { registry, engineImage, sourceImages }) {
262
- return editionTarget(distributionId, registry, EditionChannel.Develop, developEditionTag({ pins, engineImage, sourceImages }));
272
+ return snapshotEditionTarget(EditionChannel.Develop, distributionId, pins, { registry, engineImage, sourceImages });
263
273
  }
264
274
 
265
275
  function realpathOfPlanned(path) {
@@ -329,10 +339,11 @@ export function writeEditionArtifact({
329
339
  * An existing develop tag is reused only if it IS this snapshot: the tag
330
340
  * is a hash of its inputs, but a tag is mutable registry state, so the pulled
331
341
  * descriptor must say the same edition, the same channel, exactly the same pins,
332
- * the same engine image, the same evidence trust state and the same lock set. A customer artifact (`catalogueDigest` given) must also record that digest
333
- * as its catalogue provenance pin. Throws naming the first difference.
342
+ * the same engine image, the same evidence trust state and the same lock set. A customer artifact (`catalogueDigest` and
343
+ * `definitionDigest` given) must also record those digests as its catalogue and definition provenance pins. Throws naming
344
+ * the first difference.
334
345
  */
335
- export function assertExistingArtifactMatches({ artifactDir, distributionId, channel, pins, engineImage, evidenceTrustPolicy, catalogueDigest }) {
346
+ export function assertExistingArtifactMatches({ artifactDir, distributionId, channel, pins, engineImage, evidenceTrustPolicy, catalogueDigest, definitionDigest }) {
336
347
  const descriptorPath = join(artifactDir, EDITION_DESCRIPTOR_FILE);
337
348
  if (!existsSync(descriptorPath)) {
338
349
  throw new Error(`the existing artifact holds no ${EDITION_DESCRIPTOR_FILE}; refusing to reuse a tag that is not an edition`);
@@ -354,6 +365,12 @@ export function assertExistingArtifactMatches({ artifactDir, distributionId, cha
354
365
  `the pinned catalogue is ${catalogueDigest}; refusing to reuse it`,
355
366
  );
356
367
  }
368
+ if (definitionDigest !== undefined && existing.provenance.pins[DEFINITION_PIN_KEY] !== definitionDigest) {
369
+ throw new Error(
370
+ `the existing artifact was resolved from customer definition ${JSON.stringify(existing.provenance.pins[DEFINITION_PIN_KEY])}, ` +
371
+ `this definition is ${definitionDigest} (its selection, mirror or Store publisher anchors differ); refusing to reuse it`,
372
+ );
373
+ }
357
374
  if (serializeJson(existing.provenance.pins) !== serializeJson(sortedSnapshotPins(pins))) {
358
375
  throw new Error('the existing artifact was resolved from different pins than this snapshot (tag and content disagree); refusing to reuse it');
359
376
  }
@@ -48,6 +48,15 @@ if (!(CATALOGUE_PIN_KEY in EDITION_CONTRACT.provenancePins)) {
48
48
  throw new Error('edition-contract.json provenancePins does not declare the catalogue pin');
49
49
  }
50
50
 
51
+ /** The digest of the canonical customer definition a customer edition was resolved from: every input the pins of its sources and catalogue do not fix. */
52
+ export const DEFINITION_PIN_KEY = 'definition';
53
+ if (!(DEFINITION_PIN_KEY in EDITION_CONTRACT.provenancePins)) {
54
+ throw new Error('edition-contract.json provenancePins does not declare the definition pin');
55
+ }
56
+
57
+ /** The provenance pins that are sha256 digests, not commits. */
58
+ export const DIGEST_PIN_KEYS = Object.freeze([CATALOGUE_PIN_KEY, DEFINITION_PIN_KEY]);
59
+
51
60
  /** The lock files an edition can carry. */
52
61
  export const EditionLockFile = Object.freeze({
53
62
  Distribution: 'distribution.lock.json',
@@ -23,7 +23,8 @@
23
23
  "catalogue": "catalogue.json"
24
24
  },
25
25
  "provenancePins": {
26
- "catalogue": "the sha256 digest (sha256:<64 hex>) of the offering artifact a customer edition was resolved against; every other pin is a 40-hex commit"
26
+ "catalogue": "the sha256 digest (sha256:<64 hex>) of the offering artifact a customer edition was resolved against",
27
+ "definition": "the sha256 digest (sha256:<64 hex>) of the canonical JSON of the customer definition a customer edition was resolved from (vendor selection, image registry mirror, Store publisher anchors, sources); every other pin is a 40-hex commit"
27
28
  },
28
29
  "imageNamespaceSegment": "dev",
29
30
  "evidenceTrust": {