@xemahq/xema 0.53.0 → 0.54.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 (47) hide show
  1. package/README.md +1 -0
  2. package/dist/cli.d.ts.map +1 -1
  3. package/dist/cli.js +2 -0
  4. package/dist/cli.js.map +1 -1
  5. package/dist/commands/auth.d.ts +9 -0
  6. package/dist/commands/auth.d.ts.map +1 -0
  7. package/dist/commands/auth.js +66 -0
  8. package/dist/commands/auth.js.map +1 -0
  9. package/dist/commands/edition.d.ts.map +1 -1
  10. package/dist/commands/edition.js +1 -0
  11. package/dist/commands/edition.js.map +1 -1
  12. package/dist/edition/verify-image-evidence.bundle.mjs +916 -860
  13. package/dist/edition/verify-image-evidence.bundle.provenance.json +495 -489
  14. package/dist/runtime/edition/verb-map.d.ts +2 -1
  15. package/dist/runtime/edition/verb-map.d.ts.map +1 -1
  16. package/dist/runtime/edition/verb-map.js +2 -0
  17. package/dist/runtime/edition/verb-map.js.map +1 -1
  18. package/edition/README.md +54 -2
  19. package/edition/adapter-bundled-versions.mjs +2 -1
  20. package/edition/authorization-base.mjs +9 -8
  21. package/edition/biome-content.mjs +23 -29
  22. package/edition/candidate-edition.mjs +692 -0
  23. package/edition/checkout-indexed-source-repositories.mjs +6 -8
  24. package/edition/collected-refusals.mjs +59 -0
  25. package/edition/contract-authority.mjs +122 -0
  26. package/edition/cosign-certificate-constraints.mjs +18 -8
  27. package/edition/customer-edition-verbs.mjs +30 -8
  28. package/edition/develop-chart-set.mjs +7 -6
  29. package/edition/develop-edition-invariants.mjs +2 -1
  30. package/edition/develop-edition-resolve.mjs +109 -31
  31. package/edition/develop-edition.mjs +49 -8
  32. package/edition/develop-engine-image.mjs +95 -0
  33. package/edition/develop-image-probe.mjs +2 -1
  34. package/edition/develop-resolve-report.mjs +159 -0
  35. package/edition/develop-snapshot-inputs.mjs +4 -3
  36. package/edition/develop-verify-image-record.mjs +23 -4
  37. package/edition/edition-artifact.mjs +17 -7
  38. package/edition/edition-protocol.mjs +5 -2
  39. package/edition/images-from-record.mjs +362 -0
  40. package/edition/lib.mjs +1 -19
  41. package/edition/locked-bundle-signer.mjs +15 -3
  42. package/edition/operation-grant-admission.mjs +51 -9
  43. package/edition/resolve-distribution.mjs +95 -82
  44. package/edition/resolve-images.mjs +12 -5
  45. package/edition/shallow-checkout.mjs +18 -0
  46. package/edition/workspace-sections.mjs +23 -3
  47. package/package.json +7 -3
@@ -2,9 +2,16 @@
2
2
  // develop-edition — the develop edition's command line.
3
3
  //
4
4
  // resolve <id> --pins <file> --out <dir> --registry <root>
5
- // --engine-image <repository>@sha256:<digest> [--local]
5
+ // --engine-repository <repository> --engine-signer <certificate identity> --cosign <absolute-path> [--local]
6
+ // [--images-from <image build record>]
6
7
  // resolve the edition from the snapshot, seal it against the develop image
7
8
  // repositories, write the descriptor and the four locks to <dir>, print the target.
9
+ // The engine is found and proven here (see `engine`), and every refusal of the run is reported
10
+ // in one table (develop-resolve-report.mjs): independent checks all run, a dependent one is
11
+ // reported `blocked by <check>`, and when anything is refused nothing is sealed or written.
12
+ // --images-from takes the digests from the everyday job's image build record instead of the registry probe:
13
+ // no probe and no rehash; satellite entries are cosign-verified against their producer's develop build
14
+ // records, and a subject missing, duplicated or extra in the record refuses (images-from-record.mjs).
8
15
  // The workflow run is recorded from GITHUB_REPOSITORY / GITHUB_RUN_ID;
9
16
  // --local stamps `local` instead, for a resolve outside GitHub Actions.
10
17
  // resolve --definition <file> --pins <file> --out <dir> --registry <root>
@@ -16,10 +23,17 @@
16
23
  // declares (`freeze-sources --definition` writes it); the catalogue digest is frozen into the descriptor's
17
24
  // provenance pins as `catalogue`. The id is the definition's. Takes --data-root, --editions-root,
18
25
  // --sources-root, --cache-dir (no --deploy-root, no --kernel: the kernel is the catalogue's, from npm).
19
- // target <id> --pins <file> --registry <root> --engine-image <repository>@sha256:<digest>
26
+ // engine <id> --pins <file> --engine-repository <repository> --engine-signer <certificate identity> --cosign <absolute-path>
27
+ // print <repository>@sha256:<digest>: the engine xema-deploy CI published (tag sha-<12 hex>) for the
28
+ // snapshot's xema-deploy commit, its signature verified offline against the pinned trust root as made by
29
+ // --engine-signer with the signed annotations commit=<that commit> and branch=develop
30
+ // (develop-engine-image.mjs). The image `target` and `check-existing` take as --engine-image.
31
+ // target <id> --pins <file> --registry <root> --engine-image <repository>@sha256:<digest> [--images-from <image build record>]
20
32
  // print <root>/<develop segment>/xema-edition-<id>:<tag>, the tag being sha256 of the canonical
21
33
  // { cli, engine, pins, sourceImages } (snapshotEditionTag): every input of the bytes the artifact records,
22
- // so the same pins with another engine, source-image map or CLI version are another artifact. The root is always the caller's
34
+ // so the same pins with another engine, source-image map or CLI version are another artifact. With
35
+ // --images-from the record's digest (imageBuildRecordDigest) is one more input, named imageBuildRecord; resolve
36
+ // --images-from prints the same target. The root is always the caller's
23
37
  // registry root (e.g. registry.example.com/acme); the develop namespace segment and the repository
24
38
  // prefix come from edition-contract.json, and no root is implied.
25
39
  // target --definition <file> --pins <file> --registry <root> --engine-image <repository>@sha256:<digest>
@@ -92,11 +106,12 @@
92
106
  import { resolve } from 'node:path';
93
107
 
94
108
  import { writeCatalogue } from './catalogue.mjs';
95
- import { CUSTOMER_ROOTS, headResolver, parseEditionVerbArgs, runCustomerVerb, writeOnce } from './customer-edition-verbs.mjs';
109
+ import { CUSTOMER_ROOTS, IMAGES_FROM_FLAG, headResolver, parseEditionVerbArgs, runCustomerVerb, writeOnce } from './customer-edition-verbs.mjs';
96
110
  import { DEFINITION_FLAG } from './customer-flags.mjs';
97
111
  import { runDevelopCoverage } from './develop-coverage-run.mjs';
98
112
  import { CoverageExit } from './develop-coverage.mjs';
99
113
  import { resolveDevelopEdition } from './develop-edition-resolve.mjs';
114
+ import { resolveDevelopEngineImage } from './develop-engine-image.mjs';
100
115
  import { assertNoToolingOverride, freezeGraph, freezeSources } from './develop-freeze.mjs';
101
116
  import { runSelectRecords } from './develop-select-records.mjs';
102
117
  import { runVerifyImageRecord } from './develop-verify-image-record.mjs';
@@ -104,6 +119,7 @@ import { LOCAL_WORKFLOW, assertExistingArtifactMatches, developEditionTarget, wo
104
119
  import { EditionChannel, EditionKind } from './edition-constants.mjs';
105
120
  import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, kernelRepoDir } from './edition-workspace.mjs';
106
121
  import { descriptorEvidenceTrust } from './evidence-trust-policy.mjs';
122
+ import { createSatelliteVerifier, imageBuildRecordDigest, readImageBuildRecord } from './images-from-record.mjs';
107
123
  import { assertKernelCommitSupported, chooseKernelCommit } from './kernel-protocol-choice.mjs';
108
124
  import { loadKernelDistributionSchemas } from './lib.mjs';
109
125
  import { snapshotPinSource } from './pin-source.mjs';
@@ -150,21 +166,36 @@ function parseOverrides(text) {
150
166
  return parsed;
151
167
  }
152
168
 
169
+ /** The target-tag input of `--images-from`: the record's digest, or nothing on the probe path. */
170
+ function imageBuildRecordTagInput(options) {
171
+ return options[IMAGES_FROM_FLAG] === undefined ? {} : { imageBuildRecordDigest: imageBuildRecordDigest(readImageBuildRecord(options[IMAGES_FROM_FLAG])) };
172
+ }
173
+
153
174
  async function run(argv, env) {
154
175
  const { command, id, options, customer } = parseDevelopEditionArgs(argv);
155
176
  switch (command) {
156
177
  case 'target': {
157
178
  if (customer) {return runCustomerVerb({ channel: EditionChannel.Develop, command, options, env });}
158
- console.log(developEditionTarget(id, readStrictJsonFile(options['--pins']), { registry: options['--registry'], engineImage: options['--engine-image'] }));
179
+ console.log(developEditionTarget(id, readStrictJsonFile(options['--pins']), { registry: options['--registry'], engineImage: options['--engine-image'], ...imageBuildRecordTagInput(options) }));
159
180
  return;
160
181
  }
161
182
  case 'resolve': {
162
183
  if (customer) {return runCustomerVerb({ channel: EditionChannel.Develop, command, options, env });}
163
184
  const pins = readStrictJsonFile(options['--pins']);
164
185
  const workflow = options['--local'] ? LOCAL_WORKFLOW : workflowFromEnv(env);
165
- const target = developEditionTarget(id, pins, { registry: options['--registry'], engineImage: options['--engine-image'] });
166
186
  const evidenceTrustPolicy = await descriptorEvidenceTrust({ kind: EditionKind.Resolved, channel: EditionChannel.Develop });
167
- const { catalogue, ...resolved } = await resolveDevelopEdition({ id, pins, env });
187
+ const { catalogue, engineImage, ...resolved } = await resolveDevelopEdition({
188
+ id,
189
+ pins,
190
+ env,
191
+ engine: { repository: options['--engine-repository'], signer: options['--engine-signer'] },
192
+ cosignBinary: options['--cosign'],
193
+ ...(options[IMAGES_FROM_FLAG] === undefined ? {} : {
194
+ imagesFrom: readImageBuildRecord(options[IMAGES_FROM_FLAG]),
195
+ verifySatellites: createSatelliteVerifier({ registry: options['--registry'], cosignBinary: options['--cosign'], env }),
196
+ }),
197
+ });
198
+ const target = developEditionTarget(id, pins, { registry: options['--registry'], engineImage, ...imageBuildRecordTagInput(options) });
168
199
  const outDir = resolve(options['--out']);
169
200
  writeEditionArtifact({
170
201
  outDir,
@@ -173,13 +204,23 @@ async function run(argv, env) {
173
204
  pins,
174
205
  workflow,
175
206
  evidenceTrustPolicy,
176
- engineImage: options['--engine-image'],
207
+ engineImage,
177
208
  ...resolved,
178
209
  });
179
210
  writeCatalogue(outDir, catalogue);
180
211
  console.log(target);
181
212
  return;
182
213
  }
214
+ case 'engine':
215
+ console.log(
216
+ await resolveDevelopEngineImage({
217
+ pins: readStrictJsonFile(options['--pins']),
218
+ engine: { repository: options['--engine-repository'], signer: options['--engine-signer'] },
219
+ cosignBinary: options['--cosign'],
220
+ env,
221
+ }),
222
+ );
223
+ return;
183
224
  case 'coverage':
184
225
  return runDevelopCoverage({
185
226
  id,
@@ -0,0 +1,95 @@
1
+ // develop-engine-image — the engine image a develop edition is deployed with, found and proven.
2
+ //
3
+ // The edition's engine is the one the deploy repository's CI published for EXACTLY the deploy commit the snapshot pins
4
+ // (tag `sha-<12 hex>`, keyless signature by a `workflow_run` follower of a green push CI). The tag only finds the digest.
5
+ // The signature is checked offline against the pinned trust root through the CLI's one cosign path (pinned-cosign.mjs)
6
+ // with the publisher's EXACT identity, issuer, workflow repository and ref (cosign-certificate-constraints.mjs); never a
7
+ // pattern. A follower runs the default branch's copy of the workflow, so the certificate's own workflow commit is not the
8
+ // built one: the built commit is bound by the signed annotations commit=<40 hex> and branch=develop.
9
+ // The digest is what the edition records.
10
+
11
+ import { RefusalError } from './collected-refusals.mjs';
12
+ import { certificateIdentityArgs } from './cosign-certificate-constraints.mjs';
13
+ import { assertEngineImage } from './edition-artifact.mjs';
14
+ import { assertPublisher, publisherIdentity } from './edition-publisher.mjs';
15
+ import { deployPinKey } from './pin-source.mjs';
16
+ import { cosignEnvironment, withPinnedCosign } from './pinned-cosign.mjs';
17
+ import { splitImageRef } from './registry-credentials.mjs';
18
+ import { isNotPublishedYet } from './seal-image-digests.mjs';
19
+ import { GITHUB_ACTIONS_OIDC_ISSUER } from './third-party-source-evidence.mjs';
20
+
21
+ const COMMIT_PATTERN = /^[a-f0-9]{40}$/;
22
+ const TAG_PREFIX = 'sha-';
23
+ const TAG_COMMIT_LENGTH = 12;
24
+ const DEVELOP_BRANCH = 'develop';
25
+ const STDERR_TAIL_BYTES = 800;
26
+
27
+ /** The tag xema-deploy CI gives the engine it publishes for `deploySha`. */
28
+ export const engineTagOf = (deploySha) => `${TAG_PREFIX}${deploySha.slice(0, TAG_COMMIT_LENGTH)}`;
29
+
30
+ const IDENTITY_PATTERN = /^https:\/\/github\.com\/([^/@]+\/[^/@]+)\/(\.github\/workflows\/[^/@]+)@(.+)$/;
31
+
32
+ /**
33
+ * The signer an exact certificate identity `https://github.com/<owner>/<name>/.github/workflows/<file>@<ref>` names, held to
34
+ * the publisher grammar of edition-contract.json (so no pattern, no wildcard).
35
+ */
36
+ export function engineSignerOf(identity) {
37
+ const match = typeof identity === 'string' ? IDENTITY_PATTERN.exec(identity) : null;
38
+ if (match === null) {
39
+ throw new RefusalError(`--engine-signer must be the exact certificate identity https://github.com/<owner>/<name>/.github/workflows/<file>@<ref>; got ${JSON.stringify(identity)}`);
40
+ }
41
+ const publisher = assertPublisher({ repository: match[1], workflow: match[2], ref: match[3] }, '--engine-signer');
42
+ return { certificateIdentity: publisherIdentity(publisher), oidcIssuer: GITHUB_ACTIONS_OIDC_ISSUER, githubWorkflow: { repository: publisher.repository, ref: publisher.ref } };
43
+ }
44
+
45
+ const tail = (text) => String(text).trim().slice(-STDERR_TAIL_BYTES);
46
+
47
+ /**
48
+ * @param {object} request
49
+ * @param {Record<string, string>} request.pins the snapshot's pins; the xema-deploy commit is read from them
50
+ * @param {{repository: string, signer: string}} request.engine the engine repository and the signer's exact certificate identity
51
+ * @param {string} request.cosignBinary absolute path of cosign
52
+ * @param {object} request.env the environment the registry credentials are named in
53
+ * @param {object} [request.pinned] withPinnedCosign's injections (tests)
54
+ * @returns {Promise<string>} `<repository>@sha256:<digest>`
55
+ */
56
+ export async function resolveDevelopEngineImage({ pins, engine, cosignBinary, env, pinned = {} }) {
57
+ const deploySha = pins[deployPinKey()];
58
+ if (typeof deploySha !== 'string' || !COMMIT_PATTERN.test(deploySha)) {
59
+ throw new RefusalError(`the pin ${deployPinKey()} is ${JSON.stringify(deploySha)}, not a 40-hex commit`);
60
+ }
61
+ const { repository } = engine;
62
+ const signer = engineSignerOf(engine.signer);
63
+ const tag = engineTagOf(deploySha);
64
+ const { authority, repository: path } = splitImageRef(repository);
65
+ return withPinnedCosign({ cosignBinary, env, ...pinned }, async ({ runCosign, cosignEnvFor, clientFor, trustedRootPath }) => {
66
+ let digest;
67
+ try {
68
+ digest = (await clientFor(authority).getManifestByTag(path, tag)).digest;
69
+ } catch (error) {
70
+ if (!isNotPublishedYet(error)) {throw error;}
71
+ throw new RefusalError(
72
+ `no engine ${repository}:${tag} for the ${deployPinKey()} commit ${deploySha}. The deploy repository's CI publishes one for a green push to develop or main, ` +
73
+ 'or a CI dispatch at that branch; a commit pushed with GITHUB_TOKEN has none until CI is dispatched. ' +
74
+ `Dispatch that CI at develop, or pin ${deployPinKey()} to a commit that has an engine.`,
75
+ );
76
+ }
77
+ const image = `${repository}@${digest}`;
78
+ const result = await runCosign(
79
+ [
80
+ 'verify', '--offline', '--trusted-root', trustedRootPath,
81
+ ...certificateIdentityArgs(signer),
82
+ '-a', `commit=${deploySha}`, '-a', `branch=${DEVELOP_BRANCH}`,
83
+ image,
84
+ ],
85
+ cosignEnvironment(cosignEnvFor, authority),
86
+ );
87
+ if (result.code !== 0) {
88
+ throw new RefusalError(
89
+ `${image} (${tag}) is not signed by the engine signer ${signer.certificateIdentity} with commit=${deploySha} branch=${DEVELOP_BRANCH}; ` +
90
+ `refusing it as this edition's engine (cosign exit ${result.code}): ${tail(result.stderr)}`,
91
+ );
92
+ }
93
+ return assertEngineImage(image);
94
+ });
95
+ }
@@ -10,6 +10,7 @@
10
10
  // every missing coordinate. It verifies existence only: the platform check, the
11
11
  // digest decision and the size bounds stay in the seal, which still runs.
12
12
 
13
+ import { RefusalError } from './collected-refusals.mjs';
13
14
  import { developImageRef } from './develop-image-coordinate.mjs';
14
15
  import { editionsRoot } from './edition-workspace.mjs';
15
16
  import { loadRegistryCredentialDeclarations, splitImageRef } from './registry-credentials.mjs';
@@ -28,7 +29,7 @@ export function notBuiltYetMessage(subject, channel = 'develop') {
28
29
 
29
30
  /** The ONE refusal naming every subject of `channel` (develop unless named) whose coordinate is absent. */
30
31
  export function notBuiltYetError(distributionId, missingSubjects, channel = 'develop') {
31
- return new Error(
32
+ return new RefusalError(
32
33
  `${missingSubjects.length} ${channel} image(s) of "${distributionId}" are not built yet ` +
33
34
  "(the producer's CI is not green or still running):\n " +
34
35
  missingSubjects.map((subject) => notBuiltYetMessage(subject, channel)).join('\n '),
@@ -0,0 +1,159 @@
1
+ import { isRefusal } from './collected-refusals.mjs';
2
+
3
+ // develop-resolve-report — every refusal of one develop resolve, in one report.
4
+ //
5
+ // `xema edition develop resolve` runs a set of checks. A check that needs the output of another is a DEPENDENT of
6
+ // it; every other check is independent. The collector runs each check, records a refusal instead of throwing it, and
7
+ // skips (records as blocked) every check whose dependency was refused or blocked. `settle()` then throws ONE error whose
8
+ // message is the table, so an operator sees what to fix, and who owns each fix, after a single run. Nothing is
9
+ // downgraded: every refusal stays a refusal, and a run with a row writes nothing.
10
+
11
+ /** The checks of a develop resolve, in the order the report lists them. */
12
+ export const DevelopResolveCheck = Object.freeze({
13
+ EditionProtocol: 'edition-protocol',
14
+ DistributionLock: 'distribution-lock',
15
+ BundleSigner: 'bundle-signer',
16
+ PlaneInvariants: 'plane-invariants',
17
+ OperationAdmission: 'operation-admission',
18
+ AdapterBundles: 'adapter-bundles',
19
+ AuthorizationBase: 'authorization-base',
20
+ DevelopImages: 'develop-images',
21
+ EngineImage: 'engine-image',
22
+ ChartSet: 'chart-set',
23
+ });
24
+
25
+ /** Who fixes a refused check. */
26
+ export const ResolveCheckOwner = Object.freeze({
27
+ Producer: 'producer (the repository the refusal names)',
28
+ XemaCli: 'xema-cli',
29
+ EditionOwner: 'edition-owner (the repository that declares the edition)',
30
+ DeployOwner: 'deploy-owner (the repository that builds the engine and the charts)',
31
+ });
32
+
33
+ export const RESOLVE_CHECK_OWNERS = Object.freeze({
34
+ [DevelopResolveCheck.EditionProtocol]: ResolveCheckOwner.XemaCli,
35
+ [DevelopResolveCheck.DistributionLock]: ResolveCheckOwner.EditionOwner,
36
+ [DevelopResolveCheck.BundleSigner]: ResolveCheckOwner.EditionOwner,
37
+ [DevelopResolveCheck.PlaneInvariants]: ResolveCheckOwner.EditionOwner,
38
+ [DevelopResolveCheck.OperationAdmission]: ResolveCheckOwner.Producer,
39
+ [DevelopResolveCheck.AdapterBundles]: ResolveCheckOwner.Producer,
40
+ [DevelopResolveCheck.AuthorizationBase]: ResolveCheckOwner.EditionOwner,
41
+ [DevelopResolveCheck.DevelopImages]: ResolveCheckOwner.Producer,
42
+ [DevelopResolveCheck.EngineImage]: ResolveCheckOwner.DeployOwner,
43
+ [DevelopResolveCheck.ChartSet]: ResolveCheckOwner.DeployOwner,
44
+ });
45
+
46
+ const C = DevelopResolveCheck;
47
+
48
+ /**
49
+ * What each check needs verified before it runs: a check that consumes a predecessor's result is not run on the state a
50
+ * refused predecessor left behind, it is reported `blocked by` the refusal at the root.
51
+ * The checks that read the lock but consume no other check's verdict are independent of each other on purpose, so one run
52
+ * names all of them: the plane invariants (the graph), the operation admission (the lock's requirements) and the adapter
53
+ * bundles (the lock against the registry). The authorization base is composed from, and the images are hashed from, the
54
+ * lock AFTER those verified it. Chart set and engine image read only the pins.
55
+ */
56
+ export const RESOLVE_CHECK_DEPENDENCIES = Object.freeze({
57
+ [C.EditionProtocol]: [],
58
+ [C.DistributionLock]: [C.EditionProtocol],
59
+ [C.BundleSigner]: [C.DistributionLock],
60
+ [C.PlaneInvariants]: [C.DistributionLock],
61
+ [C.OperationAdmission]: [C.DistributionLock],
62
+ [C.AdapterBundles]: [C.DistributionLock],
63
+ [C.AuthorizationBase]: [C.DistributionLock, C.BundleSigner, C.PlaneInvariants, C.OperationAdmission],
64
+ [C.DevelopImages]: [C.DistributionLock, C.BundleSigner, C.PlaneInvariants, C.OperationAdmission],
65
+ [C.EngineImage]: [],
66
+ [C.ChartSet]: [],
67
+ });
68
+
69
+ /** How a recorded check ended. */
70
+ export const ResolveRowStatus = Object.freeze({ Refused: 'refused', Blocked: 'blocked' });
71
+
72
+ /** What `run` returns for a check that did not produce a value: refused, or blocked by a dependency. */
73
+ export const BLOCKED = Symbol('develop-resolve-blocked');
74
+
75
+ const CHECK_ORDER = Object.values(DevelopResolveCheck);
76
+ const COLUMNS = Object.freeze(['CHECK', 'OWNER', 'STATUS', 'DETAIL']);
77
+ const DETAIL_INDENT = ' ';
78
+
79
+ const statusOf = (row) => (row.status === ResolveRowStatus.Blocked ? `blocked by ${row.blockedBy}` : ResolveRowStatus.Refused);
80
+
81
+ const NOTICE_HEADING = 'NOTICE: ungranted optional operations';
82
+
83
+ /** The notices as one section: never a refusal, so it is separate from the table. Empty when there are none. */
84
+ export const renderResolveNotices = (notices) =>
85
+ notices.length === 0 ? '' : [`${NOTICE_HEADING} (${notices.length})`, ...notices.map((notice) => `${DETAIL_INDENT}${notice}`)].join('\n');
86
+
87
+ /** The report rows as one plain-text table, in the order of {@link DevelopResolveCheck}; a multi-line detail is indented under its row. */
88
+ export function renderResolveReport(rows) {
89
+ const sorted = [...rows].sort((a, b) => CHECK_ORDER.indexOf(a.check) - CHECK_ORDER.indexOf(b.check));
90
+ const cells = sorted.map((row) => {
91
+ const [firstLine = '', ...more] = (row.detail ?? '').split('\n');
92
+ return { columns: [row.check, row.owner, statusOf(row), firstLine], more };
93
+ });
94
+ const widths = COLUMNS.slice(0, -1).map((name, i) => Math.max(name.length, ...cells.map((c) => c.columns[i].length)));
95
+ const line = (values) => [...values.slice(0, -1).map((v, i) => v.padEnd(widths[i])), values.at(-1)].join(' ').trimEnd();
96
+ const body = cells.flatMap(({ columns, more }) => [line(columns), ...more.map((m) => `${DETAIL_INDENT}${m}`.trimEnd())]);
97
+ return [`develop resolve refused: ${rows.length} check(s) did not pass; nothing was sealed or written`, line(COLUMNS), ...body].join('\n');
98
+ }
99
+
100
+ /** The one error of a develop resolve that had a refusal; `.rows` are the recorded rows, the message is the table. */
101
+ export class DevelopResolveRefusedError extends Error {
102
+ constructor(rows, notices = []) {
103
+ super([renderResolveReport(rows), renderResolveNotices(notices)].filter((part) => part !== '').join('\n\n'));
104
+ this.name = 'DevelopResolveRefusedError';
105
+ this.rows = rows;
106
+ this.notices = notices;
107
+ }
108
+ }
109
+
110
+ export function createResolveReport() {
111
+ const rows = [];
112
+ const byCheck = new Map();
113
+ const notices = [];
114
+ const record = (row) => {
115
+ rows.push(row);
116
+ byCheck.set(row.check, row);
117
+ };
118
+
119
+ return {
120
+ rows,
121
+ notices,
122
+ /** Add one line to the NOTICE section; it never refuses and never changes the exit code. */
123
+ notice(line) {
124
+ notices.push(line);
125
+ },
126
+ /** The NOTICE section as text ('' when there is none). */
127
+ noticeText: () => renderResolveNotices(notices),
128
+ /**
129
+ * Run `fn` as `check`, unless a check it depends on was refused or blocked (it is then blocked by the root refusal).
130
+ * `classify(error)` may name another check the thrown error belongs to; that check is then the one refused,
131
+ * and `check` is blocked by it.
132
+ */
133
+ async run(check, dependsOn, fn, { classify = () => check } = {}) {
134
+ const blocker = dependsOn.map((dependency) => byCheck.get(dependency)).find((row) => row !== undefined);
135
+ if (blocker !== undefined) {
136
+ // Always the refusal at the root: a check blocked by a blocked check names the check that was refused.
137
+ const blockedBy = blocker.status === ResolveRowStatus.Blocked ? blocker.blockedBy : blocker.check;
138
+ record({ check, owner: RESOLVE_CHECK_OWNERS[check], status: ResolveRowStatus.Blocked, blockedBy });
139
+ return BLOCKED;
140
+ }
141
+ try {
142
+ return await fn();
143
+ } catch (error) {
144
+ // A defect (a TypeError, a failed system call, a registry answering 5xx) is not a refusal: it propagates as thrown.
145
+ if (!isRefusal(error)) {throw error;}
146
+ const filedUnder = classify(error);
147
+ record({ check: filedUnder, owner: RESOLVE_CHECK_OWNERS[filedUnder], status: ResolveRowStatus.Refused, detail: error.message, error });
148
+ if (filedUnder !== check) {
149
+ record({ check, owner: RESOLVE_CHECK_OWNERS[check], status: ResolveRowStatus.Blocked, blockedBy: filedUnder });
150
+ }
151
+ return BLOCKED;
152
+ }
153
+ },
154
+ /** Throw the one error when any check was refused or blocked. */
155
+ settle() {
156
+ if (rows.length > 0) {throw new DevelopResolveRefusedError(rows, notices);}
157
+ },
158
+ };
159
+ }
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { execFileSync } from 'node:child_process';
19
19
 
20
+ import { RefusalError } from './collected-refusals.mjs';
20
21
  import { dataRoot, deployRoot, editionsRoot, kernelRepoDir, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
21
22
  import { buildIndexesAtPins } from './indexes-at-pins.mjs';
22
23
  import {
@@ -45,14 +46,14 @@ function git(dir, args) {
45
46
  */
46
47
  export function assertFrozenCheckout({ dir, pins, key }) {
47
48
  const pinned = pins[key];
48
- if (pinned === undefined) {throw new Error(`the snapshot has no pin "${key}" for ${dir}`);}
49
+ if (pinned === undefined) {throw new RefusalError(`the snapshot has no pin "${key}" for ${dir}`);}
49
50
  const head = git(dir, ['rev-parse', 'HEAD']);
50
51
  if (head !== pinned) {
51
- throw new Error(`${dir} is at ${head}, but the snapshot pins "${key}" at ${pinned}`);
52
+ throw new RefusalError(`${dir} is at ${head}, but the snapshot pins "${key}" at ${pinned}`);
52
53
  }
53
54
  const dirty = git(dir, ['status', '--porcelain']);
54
55
  if (dirty.length > 0) {
55
- throw new Error(`${dir} is not clean, so it is not the frozen commit ${pinned} ("${key}"):\n${dirty}`);
56
+ throw new RefusalError(`${dir} is not clean, so it is not the frozen commit ${pinned} ("${key}"):\n${dirty}`);
56
57
  }
57
58
  }
58
59
 
@@ -58,8 +58,28 @@ function producerMember(producer) {
58
58
  * @param {string} request.registry the registry root that holds `build-records/<repo>`
59
59
  * @returns {Promise<{image: string, digest: string, producer: string, commit: string}>}
60
60
  */
61
- export async function verifyImageRecord({ image, producer, registry, dataRootRepositoryUrl, kernel, clientFor, runCosign, cosignEnvFor, trustedRootPath }) {
61
+ export async function verifyImageRecord({ image, producer, ...verifier }) {
62
62
  const { name, digest } = parseImageReference(image);
63
+ const record = await findVerifiedProducerImageRecord({
64
+ producer,
65
+ ...verifier,
66
+ wanted: `lists ${name}@${digest}`,
67
+ matches: (candidate) => candidate.images.some((listed) => listed.name === name && listed.digest === digest),
68
+ });
69
+ return { image: name, digest, producer, commit: record.commit };
70
+ }
71
+
72
+ /**
73
+ * The newest verified develop ci.yaml record of `producer` (newest run first) for which `matches(record)` holds.
74
+ * Every record read on the way is cosign-verified, so a newer bad record refuses before an older one is read.
75
+ * Refused, naming `wanted`, when no verified ci.yaml record matches.
76
+ *
77
+ * @param {object} request
78
+ * @param {string} request.producer `<owner>/<repo>`
79
+ * @param {(record: object) => boolean} request.matches
80
+ * @param {string} request.wanted what a match is, for the refusal ("lists <name>@<digest>")
81
+ */
82
+ export async function findVerifiedProducerImageRecord({ producer, matches, wanted, registry, dataRootRepositoryUrl, kernel, clientFor, runCosign, cosignEnvFor, trustedRootPath }) {
63
83
  const member = producerMember(producer);
64
84
  const branch = VERIFY_IMAGE_RECORD_BRANCH;
65
85
  const { repository, authority, client, runs } = await listBuildRecordRuns({ kernel, clientFor, registry, member, branch });
@@ -69,10 +89,9 @@ export async function verifyImageRecord({ image, producer, registry, dataRootRep
69
89
  repository, member, branch, dataRootRepositoryUrl, tag,
70
90
  });
71
91
  if (record.producer.workflow !== PRODUCER_IMAGE_WORKFLOW) {continue;}
72
- if (!record.images.some((listed) => listed.name === name && listed.digest === digest)) {continue;}
73
- return { image: name, digest, producer, commit: record.commit };
92
+ if (matches(record)) {return record;}
74
93
  }
75
- throw new RecordRefused(producer, `no verified ${PRODUCER_IMAGE_WORKFLOW} build record of ${repository} for branch ${branch} lists ${name}@${digest}`);
94
+ throw new RecordRefused(producer, `no verified ${PRODUCER_IMAGE_WORKFLOW} build record of ${repository} for branch ${branch} ${wanted}`);
76
95
  }
77
96
 
78
97
  /** The `verify-image-record` verb. */
@@ -290,7 +290,7 @@ export function canonicalSourceImages(sourceImages = new Map()) {
290
290
  }
291
291
 
292
292
  /**
293
- * 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
293
+ * The tag of an edition resolved from a snapshot (a develop edition, a customer release edition): sha256 of the canonical `{ cli, engine, pins, sourceImages }`, plus `imageBuildRecord` (the record's digest) for a develop resolve --images-from. Every input of
294
294
  * the bytes the artifact records is inside it. The pins fix the locks and the descriptor's pins: a vendor edition's
295
295
  * commits fix its definitions; a customer edition's pins carry its sources' commits, the catalogue digest and the
296
296
  * digest of its canonical definition (`definition`: vendor selection, image registry mirror, Store publisher anchors); the
@@ -300,25 +300,35 @@ export function canonicalSourceImages(sourceImages = new Map()) {
300
300
  * only refuse the existing one (`check-existing`) or reuse a stale one. `provenance.workflow` (the run that
301
301
  * wrote the bytes) is the one recorded field outside it: a fact about the artifact, not an input to it.
302
302
  */
303
- export function snapshotEditionTag({ pins, engineImage, sourceImages = new Map(), version = cliVersion() }) {
303
+ export function snapshotEditionTag({ pins, engineImage, sourceImages = new Map(), imageBuildRecordDigest, version = cliVersion() }) {
304
304
  return sha256Hex(
305
305
  serializeJson({
306
306
  cli: version,
307
307
  engine: assertEngineImage(engineImage),
308
308
  pins: sortedSnapshotPins(pins),
309
309
  sourceImages: canonicalSourceImages(sourceImages),
310
+ // A develop resolve from an image build record (--images-from): the record fixes the image rows, and a satellite
311
+ // digest changes independently of the pins. Absent on the probe path, whose tag inputs are unchanged.
312
+ ...(imageBuildRecordDigest === undefined ? {} : { imageBuildRecord: assertImageBuildRecordDigest(imageBuildRecordDigest) }),
310
313
  }),
311
314
  );
312
315
  }
313
316
 
317
+ function assertImageBuildRecordDigest(digest) {
318
+ if (typeof digest !== 'string' || !/^[0-9a-f]{64}$/.test(digest)) {
319
+ throw new Error(`an image build record digest must be 64 lowercase hex; got ${JSON.stringify(digest)}`);
320
+ }
321
+ return digest;
322
+ }
323
+
314
324
  /** The target of an edition resolved from a snapshot of pins, on either channel: the tag is `snapshotEditionTag`. */
315
- export function snapshotEditionTarget(channel, distributionId, pins, { registry, engineImage, sourceImages }) {
316
- return editionTarget(distributionId, registry, channel, snapshotEditionTag({ pins, engineImage, sourceImages }));
325
+ export function snapshotEditionTarget(channel, distributionId, pins, { registry, engineImage, sourceImages, imageBuildRecordDigest }) {
326
+ return editionTarget(distributionId, registry, channel, snapshotEditionTag({ pins, engineImage, sourceImages, imageBuildRecordDigest }));
317
327
  }
318
328
 
319
- /** A develop edition's target: the tag is `snapshotEditionTag` of the pins, the engine and the source images. */
320
- export function developEditionTarget(distributionId, pins, { registry, engineImage, sourceImages }) {
321
- return snapshotEditionTarget(EditionChannel.Develop, distributionId, pins, { registry, engineImage, sourceImages });
329
+ /** A develop edition's target: the tag is `snapshotEditionTag` of the pins, the engine, the source images and any image build record. */
330
+ export function developEditionTarget(distributionId, pins, { registry, engineImage, sourceImages, imageBuildRecordDigest }) {
331
+ return snapshotEditionTarget(EditionChannel.Develop, distributionId, pins, { registry, engineImage, sourceImages, imageBuildRecordDigest });
322
332
  }
323
333
 
324
334
  function realpathOfPlanned(path) {
@@ -26,6 +26,7 @@
26
26
 
27
27
  import { realpathSync } from 'node:fs';
28
28
 
29
+ import { RefusalError } from './collected-refusals.mjs';
29
30
  import { readBoundedNoFollowFile } from './no-follow-file.mjs';
30
31
 
31
32
  /** The edition protocols this library interprets: version -> content digest. A protocol is listed only
@@ -35,7 +36,7 @@ import { readBoundedNoFollowFile } from './no-follow-file.mjs';
35
36
  * bundle-signer protocol (`bundleSigner` on every OCI bundle row); v34 is the kernel's output-mapping, JSON Pointer and
36
37
  * handoff-repair-turns closure additions (XEMA-2122); v35 is the kernel's independent releases on a resolved deployment (XEMA-2244);
37
38
  * v36 is the kernel's JobFailureReason workflow closure addition (XEMA-2282);
38
- * v37 is the kernel's DistributionLock.catalogueContentDigest and effective-timeout wording (XEMA-2254, XEMA-2282); v38 is the kernel's optionalPeers service-manifest field (XEMA-2315); v33 to v38 are not yet published: their identities and contracts are captured from the
39
+ * v37 is the kernel's DistributionLock.catalogueContentDigest and effective-timeout wording (XEMA-2254, XEMA-2282); v38 is the kernel's optionalPeers service-manifest field (XEMA-2315); v39 admits an optional operation on an undeclared domain as ungranted (`owner-undeclared`, TEAM-1 EDR-2); v40 is the kernel's resource `sharing` field (owner or shared; one owner row per resource); v33 to v39 are not yet published: their identities and contracts are captured from the
39
40
  * linked kernel), so v29 is not listed either. */
40
41
  export const SUPPORTED_EDITION_PROTOCOLS = Object.freeze([
41
42
  Object.freeze({ version: 28, contentDigest: 'f7795478b1bfe9a41bcbea9efe496011fcd209a56c90294029a08801e84cf92c' }),
@@ -48,6 +49,8 @@ export const SUPPORTED_EDITION_PROTOCOLS = Object.freeze([
48
49
  Object.freeze({ version: 36, contentDigest: 'b6b0c8ec3fc4182c6c3b623685af6293cfed1321f8d7c1891a83652d864c3df0' }),
49
50
  Object.freeze({ version: 37, contentDigest: 'f10adca1ca5b6f51f3971756261032eeddd60e372ebfcff06a2a9d8a796d0fef' }),
50
51
  Object.freeze({ version: 38, contentDigest: 'f670cb922c989fd40ac03703a612e7a6df2937233a1a20a394a859ec835d47d4' }),
52
+ Object.freeze({ version: 39, contentDigest: 'b2159ede1755526cb63b6c533bc00caaead8151784864caf1a4d682281f47808' }),
53
+ Object.freeze({ version: 40, contentDigest: 'e26613bfccc87e2ccc0d94ac2c412cc35d1f48f48559bb8d0606f14d499db528' }),
51
54
  ]);
52
55
 
53
56
  /** Where a kernel package publishes its identity and its contract, package-relative. */
@@ -86,7 +89,7 @@ export const EditionProtocolMismatchReason = Object.freeze({
86
89
  NotOnDevelop: 'not-on-develop',
87
90
  });
88
91
 
89
- export class EditionProtocolMismatchError extends Error {
92
+ export class EditionProtocolMismatchError extends RefusalError {
90
93
  constructor(reason, message) {
91
94
  super(message);
92
95
  this.name = 'EditionProtocolMismatchError';