@xemahq/xema 0.53.0 → 0.55.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 (70) 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 +2062 -1025
  13. package/dist/edition/verify-image-evidence.bundle.provenance.json +595 -505
  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/dist/runtime/lock-loader.d.ts +2 -1
  19. package/dist/runtime/lock-loader.d.ts.map +1 -1
  20. package/dist/runtime/lock-loader.js +15 -5
  21. package/dist/runtime/lock-loader.js.map +1 -1
  22. package/edition/README.md +61 -4
  23. package/edition/adapter-bundled-versions.mjs +2 -1
  24. package/edition/authorization-base.mjs +9 -8
  25. package/edition/biome-content.mjs +23 -29
  26. package/edition/candidate-edition.mjs +692 -0
  27. package/edition/check-committed-image-evidence.mjs +44 -15
  28. package/edition/checkout-indexed-source-repositories.mjs +6 -8
  29. package/edition/collected-refusals.mjs +59 -0
  30. package/edition/contract-authority.mjs +122 -0
  31. package/edition/cosign-certificate-constraints.mjs +18 -8
  32. package/edition/customer-edition-verbs.mjs +43 -10
  33. package/edition/develop-chart-set.mjs +7 -6
  34. package/edition/develop-current-resolution.mjs +29 -0
  35. package/edition/develop-edition-invariants.mjs +2 -1
  36. package/edition/develop-edition-resolve.mjs +110 -31
  37. package/edition/develop-edition.mjs +73 -11
  38. package/edition/develop-engine-image.mjs +95 -0
  39. package/edition/develop-freeze.mjs +24 -1
  40. package/edition/develop-graph.mjs +5 -3
  41. package/edition/develop-image-probe.mjs +8 -5
  42. package/edition/develop-image-record.mjs +194 -0
  43. package/edition/develop-record-ancestry.mjs +23 -5
  44. package/edition/develop-resolve-report.mjs +159 -0
  45. package/edition/develop-select-records.mjs +29 -21
  46. package/edition/develop-selection-line.mjs +30 -0
  47. package/edition/develop-selection-pins.mjs +30 -0
  48. package/edition/develop-snapshot-inputs.mjs +4 -3
  49. package/edition/develop-verify-image-record.mjs +23 -4
  50. package/edition/dist-check.mjs +45 -9
  51. package/edition/distribution-evidence-set.mjs +14 -9
  52. package/edition/edition-artifact.mjs +17 -7
  53. package/edition/edition-image-seal.mjs +2 -1
  54. package/edition/edition-protocol.mjs +8 -19
  55. package/edition/image-evidence.mjs +6 -2
  56. package/edition/image-lock-service-rows.mjs +4 -4
  57. package/edition/image-subject-identity.mjs +24 -0
  58. package/edition/images-from-record.mjs +298 -0
  59. package/edition/lib.mjs +25 -45
  60. package/edition/locked-bundle-signer.mjs +15 -3
  61. package/edition/operation-grant-admission.mjs +51 -9
  62. package/edition/resolve-customer-edition.mjs +1 -1
  63. package/edition/resolve-distribution.mjs +95 -82
  64. package/edition/resolve-edition.mjs +2 -1
  65. package/edition/resolve-images.mjs +233 -14
  66. package/edition/seal-image-digests.mjs +24 -13
  67. package/edition/shallow-checkout.mjs +18 -0
  68. package/edition/verify-image-platforms.mjs +2 -0
  69. package/edition/workspace-sections.mjs +33 -3
  70. package/package.json +7 -3
@@ -8,8 +8,10 @@
8
8
  // hash-verified manifest — as soon as that subject's hash exists, in parallel
9
9
  // with the hashing of the rest, and `settle()` refuses with ONE error naming
10
10
  // every missing coordinate. It verifies existence only: the platform check, the
11
- // digest decision and the size bounds stay in the seal, which still runs.
11
+ // digest decision and the size bounds stay in the seal, which still runs. `settle()` also answers the digest each
12
+ // present coordinate resolved to (`<kind>:<id>` -> digest), for a caller that records it (develop image-record).
12
13
 
14
+ import { RefusalError } from './collected-refusals.mjs';
13
15
  import { developImageRef } from './develop-image-coordinate.mjs';
14
16
  import { editionsRoot } from './edition-workspace.mjs';
15
17
  import { loadRegistryCredentialDeclarations, splitImageRef } from './registry-credentials.mjs';
@@ -28,7 +30,7 @@ export function notBuiltYetMessage(subject, channel = 'develop') {
28
30
 
29
31
  /** The ONE refusal naming every subject of `channel` (develop unless named) whose coordinate is absent. */
30
32
  export function notBuiltYetError(distributionId, missingSubjects, channel = 'develop') {
31
- return new Error(
33
+ return new RefusalError(
32
34
  `${missingSubjects.length} ${channel} image(s) of "${distributionId}" are not built yet ` +
33
35
  "(the producer's CI is not green or still running):\n " +
34
36
  missingSubjects.map((subject) => notBuiltYetMessage(subject, channel)).join('\n '),
@@ -38,7 +40,7 @@ export function notBuiltYetError(distributionId, missingSubjects, channel = 'dev
38
40
  /**
39
41
  * @param {object} request
40
42
  * @param {string} request.distributionId the edition (named in the refusal)
41
- * @returns {{ observe(subject: object): void, settle(): Promise<void> }}
43
+ * @returns {{ observe(subject: object): void, settle(): Promise<Map<string, string>> }}
42
44
  * `observe` takes a subject with `repo`, `sourceCommit`, `imageRef` (release ref) and `contentHash`
43
45
  * and starts its probe; `settle` waits for all of them and throws when any is absent or unreadable.
44
46
  */
@@ -71,9 +73,9 @@ export function createDevelopImageProbe({
71
73
  const startedAt = Date.now();
72
74
  const { repository } = splitImageRef(subject.imageRef);
73
75
  try {
74
- await clientFor(subject).getManifestByTag(repository, subject.contentHash);
76
+ const { digest } = await clientFor(subject).getManifestByTag(repository, subject.contentHash);
75
77
  progress?.(`[probe] ${subject.kind}:${subject.id} present ${Date.now() - startedAt}ms`);
76
- return { subject };
78
+ return { subject, digest };
77
79
  } catch (error) {
78
80
  progress?.(`[probe] ${subject.kind}:${subject.id} ${isNotPublishedYet(error) ? 'ABSENT' : 'FAILED'} ${Date.now() - startedAt}ms`);
79
81
  return { subject, error };
@@ -112,6 +114,7 @@ export function createDevelopImageProbe({
112
114
  if (aborting) {throw aborting.error;}
113
115
  const missing = settled.filter(({ error }) => error !== undefined).map(({ subject }) => subject);
114
116
  if (missing.length > 0) {throw notBuiltYetError(distributionId, missing);}
117
+ return new Map(settled.map(({ subject, digest }) => [`${subject.kind}:${subject.id}`, digest]));
115
118
  },
116
119
  };
117
120
  }
@@ -0,0 +1,194 @@
1
+ // develop-image-record — the body of `xema edition develop image-record <id> --selection <file> --pins <file> --out <file>`.
2
+ //
3
+ // Writes the edition's image build record (images-from-record.mjs, the file `resolve --images-from` reads) from the
4
+ // records `select-records` selected. Its subjects are per-edition (`<kind>:<id>`), so they come from the checked-out
5
+ // snapshot, which select-records cannot know: the subjects are enumerated as `coverage` enumerates them
6
+ // (resolveDevelopSnapshot, then resolveImages on the normal hashing path, no recorded images).
7
+ //
8
+ // a first-party subject of a currentResolution member (develop-current-resolution.mjs) is probed as resolve probes it
9
+ // (createDevelopImageProbe): the digest `<develop repository>:<contentHash>` resolves to goes in `images`, with the
10
+ // computed hash and the snapshot's source commit.
11
+ // every other first-party subject takes the selection's image named by its develop repository: digest and hash from
12
+ // the selection, source commit the one selection.pins names for the repository, which must be the snapshot's. It goes
13
+ // in `satellites`. Several subjects may share one image.
14
+ // a third-party subject is ignored: it keeps its pinned seal at resolve.
15
+ //
16
+ // Each entry's platforms are read from the registry BY DIGEST (resolvePublishedPlatforms: the index's runnable
17
+ // children, attestations skipped, each proven by its image config; or the single manifest's config). Every problem is
18
+ // collected and refused at once (ImageBuildRecordError); nothing is written then. The output passes parseImageBuildRecord.
19
+
20
+
21
+ import { writeOnce } from './customer-edition-verbs.mjs';
22
+ import { keepsCurrentResolution } from './develop-current-resolution.mjs';
23
+ import { DEVELOP_EDITION_STEPS, resolveDevelopSnapshot } from './develop-edition-resolve.mjs';
24
+ import { developGraphMembers } from './develop-graph.mjs';
25
+ import { developImageRef } from './develop-image-coordinate.mjs';
26
+ import { createDevelopImageProbe } from './develop-image-probe.mjs';
27
+ import { parseSelectionPins } from './develop-selection-pins.mjs';
28
+ import { editionsRoot } from './edition-workspace.mjs';
29
+ import { firstPartySources } from './first-party-source-ids.mjs';
30
+ import { ImageBuildRecordError, parseImageBuildRecord, requireImageBuildRecordKernel, subjectKeyOf } from './images-from-record.mjs';
31
+ import { loadKernelDistributionSchemas } from './lib.mjs';
32
+ import { loadRegistryCredentialDeclarations, splitImageRef } from './registry-credentials.mjs';
33
+ import { readStrictJsonFile } from './strict-json.mjs';
34
+ import { compareStrings } from './string-order.mjs';
35
+ import { registryClientsFor, resolvePublishedPlatforms } from './verify-image-platforms.mjs';
36
+
37
+ const SELECTION_REQUIRED = Object.freeze(['pins', 'snapshot', 'images', 'behind']);
38
+ const IMAGE_TEXT_KEYS = Object.freeze(['name', 'digest', 'contentHash', 'member']);
39
+ const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
40
+
41
+ /**
42
+ * A selection `select-records` wrote, checked strictly: its four sections are present and shaped, each image names its
43
+ * owning `member`, and `pins` and `currentResolution` pass the one selection-pins validation (40-hex pins, and
44
+ * `currentResolution` exactly what the graph members declare).
45
+ *
46
+ * @param {unknown} document
47
+ * @param {readonly {repo: string}[]} members the develop graph
48
+ * @param {string} source the file, named in the refusal
49
+ */
50
+ export function parseSelection(document, members, source) {
51
+ if (!isPlainObject(document)) {throw new ImageBuildRecordError(`selection ${source} must be a JSON object`);}
52
+ const problems = SELECTION_REQUIRED.filter((key) => document[key] === undefined).map((key) => `has no "${key}"`);
53
+ for (const key of ['snapshot', 'behind']) {
54
+ if (document[key] !== undefined && !isPlainObject(document[key])) {problems.push(`"${key}" must be an object`);}
55
+ }
56
+ if (document.images !== undefined && !Array.isArray(document.images)) {problems.push('"images" must be an array');}
57
+ (Array.isArray(document.images) ? document.images : []).forEach((image, index) => {
58
+ for (const key of IMAGE_TEXT_KEYS) {
59
+ if (!isPlainObject(image) || typeof image[key] !== 'string') {problems.push(`images[${index}].${key} must be a string`);}
60
+ }
61
+ if (isPlainObject(image) && image.members !== undefined && !Array.isArray(image.members)) {problems.push(`images[${index}].members must be an array`);}
62
+ });
63
+ if (problems.length > 0) {throw new ImageBuildRecordError(`selection ${source} is refused:\n ${problems.join('\n ')}`);}
64
+ parseSelectionPins(document, source, '--selection', members);
65
+ return document;
66
+ }
67
+
68
+ const platformOf = ({ platform }) => ({ os: platform.os, architecture: platform.architecture, ...(platform.variant === undefined ? {} : { variant: platform.variant }) });
69
+
70
+ /** A cached reader of the platforms a develop image serves, read by digest through one registry client per authority. */
71
+ export function createPlatformReader(clientFor) {
72
+ const cache = new Map();
73
+ return (repository, digest) => {
74
+ const key = `${repository}@${digest}`;
75
+ if (!cache.has(key)) {
76
+ const { authority, repository: path } = splitImageRef(repository);
77
+ cache.set(key, resolvePublishedPlatforms({ client: clientFor(authority), repository: path, digest, coordinate: key })
78
+ .then(({ platforms }) => platforms.map(platformOf)));
79
+ }
80
+ return cache.get(key);
81
+ };
82
+ }
83
+
84
+ /**
85
+ * @param {object} request
86
+ * @param {string} request.id the edition
87
+ * @param {object} request.pins the snapshot pins
88
+ * @param {object} request.selection a parsed selection
89
+ * @param {object} request.steps resolve steps (tests replace them)
90
+ * @param {Function} [request.resolveSnapshot] ({id, pins, steps}) => {inputs, lock}; replaced by tests
91
+ * @param {Function} request.createProbe ({distributionId}) => the develop image probe
92
+ * @param {(repository: string, digest: string) => Promise<object[]>} request.platformsOf
93
+ * @param {object} request.declaredSources `firstPartySources`
94
+ * @param {Function} [request.loadKernel] the kernel loader (tests replace it)
95
+ * @returns {Promise<{schemaVersion: 1, images: object[], satellites: object[]}>}
96
+ */
97
+ export async function buildImageRecord({ id, pins, selection, steps = DEVELOP_EDITION_STEPS, resolveSnapshot = resolveDevelopSnapshot, createProbe, platformsOf, declaredSources, loadKernel = loadKernelDistributionSchemas }) {
98
+ const kernel = requireImageBuildRecordKernel(await loadKernel());
99
+ const { inputs, lock } = await resolveSnapshot({ id, pins, steps });
100
+ const probe = createProbe({ distributionId: id });
101
+ const isFirstParty = (subject) => Object.hasOwn(declaredSources, subject.repo);
102
+ const { imageLock } = await steps.resolveImages(id, {
103
+ inputs,
104
+ lock,
105
+ onSubjectHashed: (subject) => {
106
+ if (isFirstParty(subject) && keepsCurrentResolution(declaredSources[subject.repo])) {probe.observe(subject);}
107
+ },
108
+ });
109
+ const probed = await probe.settle();
110
+ const problems = [];
111
+ const built = [];
112
+ const satellites = [];
113
+ for (const subject of imageLock.subjects.filter(isFirstParty)) {
114
+ const key = subjectKeyOf(subject);
115
+ const repository = developImageRef(subject.imageRef);
116
+ if (keepsCurrentResolution(declaredSources[subject.repo])) {
117
+ built.push({ subject: key, repository, contentHash: subject.contentHash, digest: probed.get(key), sourceSha: subject.sourceCommit });
118
+ continue;
119
+ }
120
+ const image = selection.images.find(({ name }) => name === repository);
121
+ if (image === undefined) {
122
+ problems.push(`${key} (${subject.repo}): the selection has no image ${repository}`);
123
+ continue;
124
+ }
125
+ if (image.member !== subject.repo) {
126
+ problems.push(`${key} (${subject.repo}): the selection's image ${repository} belongs to member ${image.member}, not ${subject.repo}`);
127
+ continue;
128
+ }
129
+ const selected = selection.pins[image.member];
130
+ if (selected !== subject.sourceCommit) {
131
+ problems.push(`${key} (${subject.repo}): the selection pins ${selected}, not ${subject.sourceCommit}, the commit the snapshot pins`);
132
+ continue;
133
+ }
134
+ // The subject's COMPUTED hash is the one recorded: the heads path's probe already finds dev/<image>:<computed hash>, so
135
+ // producer CI and this CLI agree on the hash, and a selected hash that differs is a real mismatch, never overwritten.
136
+ if (image.contentHash !== subject.contentHash) {
137
+ problems.push(`${key} (${subject.repo}): the selection's content hash ${image.contentHash} is not the computed ${subject.contentHash}`);
138
+ continue;
139
+ }
140
+ // A composed image's members[{service, key}] are the build record's own, copied unchanged and never recomputed; the kernel's
141
+ // ImageBuildRecord parse refuses them on any other entry.
142
+ satellites.push({ subject: key, repository, contentHash: subject.contentHash, digest: image.digest, sourceSha: selected, ...(image.members === undefined ? {} : { members: image.members }) });
143
+ }
144
+ const withPlatforms = async (entries) => {
145
+ const out = [];
146
+ for (const entry of entries) {
147
+ try {
148
+ out.push({ ...entry, platforms: await platformsOf(entry.repository, entry.digest) });
149
+ } catch (error) {
150
+ problems.push(`${entry.subject}: the platforms of ${entry.repository}@${entry.digest} could not be read: ${error.message}`);
151
+ }
152
+ }
153
+ return out.sort((a, b) => compareStrings(a.subject, b.subject));
154
+ };
155
+ const record = { schemaVersion: kernel.IMAGE_BUILD_RECORD_SCHEMA_VERSION, images: await withPlatforms(built), satellites: await withPlatforms(satellites) };
156
+ if (problems.length > 0) {
157
+ throw new ImageBuildRecordError(`the image build record of "${id}" cannot be written (${problems.length} problem(s)):\n ${problems.join('\n ')}`);
158
+ }
159
+ return parseImageBuildRecord(record, 'develop image-record', { loadKernel: async () => kernel });
160
+ }
161
+
162
+ /** The `image-record` verb. Writes `--out` once, only after every subject is covered. */
163
+ export async function runImageRecord({
164
+ id,
165
+ options,
166
+ env = process.env,
167
+ steps = DEVELOP_EDITION_STEPS,
168
+ members = developGraphMembers(),
169
+ declaredSources = firstPartySources('enumerating the first-party image subjects'),
170
+ createProbe = (request) => createDevelopImageProbe({ ...request, env }),
171
+ resolveSnapshot,
172
+ clientFor,
173
+ write = writeOnce,
174
+ }) {
175
+ const selection = parseSelection(readStrictJsonFile(options['--selection']), members, options['--selection']);
176
+ const declarations = clientFor === undefined ? loadRegistryCredentialDeclarations(editionsRoot()) : null;
177
+ const clients = new Map();
178
+ const registryClient = clientFor ?? ((authority) => {
179
+ if (!clients.has(authority)) {clients.set(authority, registryClientsFor([{ imageRef: `${authority}/x` }], { declarations, env }).get(authority));}
180
+ return clients.get(authority);
181
+ });
182
+ const record = await buildImageRecord({
183
+ id,
184
+ pins: readStrictJsonFile(options['--pins']),
185
+ selection,
186
+ steps,
187
+ ...(resolveSnapshot === undefined ? {} : { resolveSnapshot }),
188
+ createProbe,
189
+ platformsOf: createPlatformReader(registryClient),
190
+ declaredSources,
191
+ });
192
+ write(options['--out'], record);
193
+ return record;
194
+ }
@@ -9,9 +9,12 @@
9
9
  // With a published commit for the member, the records may not take it backwards: behind or equal keeps the published
10
10
  // commit (its own record must then exist), ahead selects the records' commit, otherwise the run is refused.
11
11
  //
12
+ // The result also carries develop's head (`git rev-parse refs/heads/develop`) and `behind`, the commits the selected
13
+ // one trails that head by (`git rev-list --count <selected>..refs/heads/develop`), both from the same fetch.
14
+ //
12
15
  // Refusals (`RecordRefused`): a commit absent from the fetched develop history, two commits that are not
13
16
  // ancestor-related. Operational (plain Error): git cannot be spawned, a fetch fails, or merge-base answers
14
- // anything but 0 (ancestor) or 1 (not an ancestor).
17
+ // anything but 0 (ancestor) or 1 (not an ancestor), or rev-parse or rev-list fails or answers a non-number.
15
18
 
16
19
  import { spawnSync } from 'node:child_process';
17
20
  import { mkdtempSync, rmSync } from 'node:fs';
@@ -72,7 +75,8 @@ export function defaultRunGit(args, { cwd, env }) {
72
75
  * @param {object} request.workspace the workspace config the source hold reads
73
76
  * @param {object} request.env
74
77
  * @param {Function} [request.runGit] (args, {cwd, env}) => {status, stdout, stderr}; replaced by tests
75
- * @returns {string} the selected commit
78
+ * @returns {{commit: string, head: string, behind: number}} the selected commit, develop's head in the same fetch, and
79
+ * how many commits the selected one is behind that head (0 when it is the head)
76
80
  */
77
81
  export function newestCommitOnDevelop({ member, candidates, published, workspace, env, runGit = defaultRunGit }) {
78
82
  const byCommit = new Map();
@@ -109,10 +113,24 @@ export function newestCommitOnDevelop({ member, candidates, published, workspace
109
113
  if (!isAncestor(selected.commit, other.commit)) {throw new IncomparableRecordCommitsError(member.repo, selected, other);}
110
114
  selected = other;
111
115
  }
112
- if (published === undefined) {return selected.commit;}
116
+ // develop's head and the chosen commit's distance from it, read in the same scratch the choice was made in
117
+ const withDistance = (commit) => {
118
+ const headResult = git(['rev-parse', DEVELOP_REF]);
119
+ const head = headResult.stdout.trim();
120
+ if (headResult.status !== 0 || !/^[0-9a-f]{40}$/.test(head)) {
121
+ throw new Error(`select-records: ${member.repo}: git rev-parse ${DEVELOP_REF} failed (exit ${headResult.status}): ${headResult.stderr.trim() || head}`);
122
+ }
123
+ const counted = git(['rev-list', '--count', `${commit}..${DEVELOP_REF}`]);
124
+ const behind = Number(counted.stdout.trim());
125
+ if (counted.status !== 0 || !/^\d+$/.test(counted.stdout.trim())) {
126
+ throw new Error(`select-records: ${member.repo}: git rev-list --count ${commit}..${DEVELOP_REF} failed (exit ${counted.status}): ${counted.stderr.trim() || counted.stdout.trim()}`);
127
+ }
128
+ return { commit, head, behind };
129
+ };
130
+ if (published === undefined) {return withDistance(selected.commit);}
113
131
  assertOnDevelop(published, ['published pin']);
114
- if (isAncestor(selected.commit, published)) {return published;} // equal, or the records are behind it
115
- if (isAncestor(published, selected.commit)) {return selected.commit;}
132
+ if (isAncestor(selected.commit, published)) {return withDistance(published);} // equal, or the records are behind it
133
+ if (isAncestor(published, selected.commit)) {return withDistance(selected.commit);}
116
134
  throw new DivergedFromPublishedError(member.repo, selected.commit, published);
117
135
  });
118
136
  } finally {
@@ -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
+ }
@@ -13,6 +13,15 @@
13
13
  // when the records are not ahead of it, and a diverged one is refused; without it the rule applies among the
14
14
  // records only (part 07 passes the flag).
15
15
  //
16
+ // The output also carries `behind: { <repo>: { head, commits } }`: develop's head and how many commits the selected
17
+ // commit trails it by, from the same fetch (0 when the record is the head). The verb prints one line per member, in
18
+ // repo order, after writing --out, formatted by develop-selection-line.mjs (`<short name>: record <commit>, <N> commits behind HEAD`,
19
+ // always `commits`, never singular; short name = the repo's last path segment, unambiguous across members).
20
+ //
21
+ // A member that declares a currentResolution (develop-current-resolution.mjs) is skipped, its registry never read; the
22
+ // selection lists it as `currentResolution: { <repo>: <ticket> }` and the verb prints `<short name>: current resolution
23
+ // (leaves the edition, <ticket>)` in its place. Every other member without a record still refuses.
24
+ //
16
25
  // Every failure is a hard refusal naming the repository, and nothing is written. Two classes: `RecordRefused`
17
26
  // (the record is missing, or cosign or the record itself says no) and a plain Error (the registry or cosign
18
27
  // could not answer), which the operator retries rather than reads.
@@ -22,6 +31,7 @@
22
31
  // backwards. This verb checks nothing against current pins.
23
32
 
24
33
  import { writeOnce } from './customer-edition-verbs.mjs';
34
+ import { currentResolutionOf, keepsCurrentResolution } from './develop-current-resolution.mjs';
25
35
  import { developGraphMembers } from './develop-graph.mjs';
26
36
  import { PublishedRecordMissingError, newestCommitOnDevelop } from './develop-record-ancestry.mjs';
27
37
  import {
@@ -32,10 +42,11 @@ import {
32
42
  requireBuildRecordKernel,
33
43
  withRecordVerifier,
34
44
  } from './develop-record-verify.mjs';
45
+ import { assertShortNamesUnambiguous, formatSelectionLine } from './develop-selection-line.mjs';
46
+ import { readSelectionPins } from './develop-selection-pins.mjs';
35
47
  import { editionWorkspaceConfig } from './edition-config.mjs';
36
48
  import { compareStrings } from './lib.mjs';
37
49
  import { RecordRefused } from './record-refused.mjs';
38
- import { readStrictJsonFile } from './strict-json.mjs';
39
50
 
40
51
  // The record-verification names this verb's tests and callers read from here, owned by the shared core.
41
52
  export {
@@ -64,16 +75,19 @@ const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$/;
64
75
  * @param {object} request.workspace the workspace config the source hold reads
65
76
  * @param {object} request.env
66
77
  * @param {Function} [request.runGit] replaced by tests
67
- * @returns {Promise<{pins: object, snapshot: object, images: object[]}>}
78
+ * @returns {Promise<{pins: object, snapshot: object, behind: object, images: object[], currentResolution: object}>}
68
79
  */
69
80
  export async function selectRecords({ branch, registry, at, members, dataRootRepositoryUrl, kernel, clientFor, runCosign, cosignEnvFor, trustedRootPath, publishedPins, workspace, env, runGit }) {
70
81
  requireBuildRecordKernel(kernel);
82
+ assertShortNamesUnambiguous(members);
71
83
  recordSignerOrigin({ branch, dataRootRepositoryUrl });
72
84
  if (at !== undefined && (!ISO_UTC.test(at) || Number.isNaN(Date.parse(at)))) {
73
85
  throw new Error(`--at must be an ISO-8601 UTC time (YYYY-MM-DDTHH:MM:SSZ); got ${JSON.stringify(at)}`);
74
86
  }
75
87
  const selected = [];
76
88
  for (const member of members) {
89
+ // the one declared exception (develop-current-resolution.mjs): no registry read, no record
90
+ if (keepsCurrentResolution(member)) {continue;}
77
91
  const { repository, authority, client, runs } = await listBuildRecordRuns({ kernel, clientFor, registry, member, branch });
78
92
  // EVERY candidate is read and verified; any that fails refuses the run. None is skipped.
79
93
  const verified = [];
@@ -87,7 +101,7 @@ export async function selectRecords({ branch, registry, at, members, dataRootRep
87
101
  const eligible = at === undefined ? verified : verified.filter(({ record }) => Date.parse(record.completedAt) <= Date.parse(at));
88
102
  if (eligible.length === 0) {throw new RecordRefused(member.repo, `no build record for branch ${branch} completed at or before ${at}`);}
89
103
  const published = publishedPins?.[member.repo];
90
- const commit = newestCommitOnDevelop({
104
+ const { commit, head, behind } = newestCommitOnDevelop({
91
105
  member, candidates: eligible.map(({ record, tag }) => ({ commit: record.commit, tag })), published, workspace, env, runGit,
92
106
  });
93
107
  // records of one commit are re-runs of it: the highest run, then attempt
@@ -96,46 +110,36 @@ export async function selectRecords({ branch, registry, at, members, dataRootRep
96
110
  const ofCommit = ofCommitIn(eligible).length > 0 ? ofCommitIn(eligible) : ofCommitIn(verified);
97
111
  if (ofCommit.length === 0) {throw new PublishedRecordMissingError(member.repo, commit);}
98
112
  const chosen = ofCommit[0];
99
- selected.push({ member, ...chosen });
113
+ selected.push({ member, head, behind, ...chosen });
100
114
  }
101
- return composeSelection(selected);
115
+ return { ...composeSelection(selected), currentResolution: currentResolutionOf(members) };
102
116
  }
103
117
 
104
118
  /** The snapshot a selection writes. A name built by two records is refused: an image has one builder. */
105
119
  export function composeSelection(selected) {
106
120
  const pins = {};
107
121
  const snapshot = {};
122
+ const behind = {};
108
123
  const owners = new Map();
109
124
  const images = [];
110
- for (const { member, record, snapshot: run } of selected) {
125
+ for (const { member, record, snapshot: run, head, behind: commits } of selected) {
111
126
  pins[member.repo] = record.commit;
127
+ behind[member.repo] = { head, commits };
112
128
  snapshot[member.repo] = run;
113
129
  for (const image of record.images) {
114
130
  if (owners.has(image.name)) {
115
131
  throw new RecordRefused(member.repo, `image ${image.name} is also built by ${owners.get(image.name)}; an image has one builder`);
116
132
  }
117
133
  owners.set(image.name, member.repo);
118
- images.push({ name: image.name, digest: image.digest, contentHash: image.contentHash });
134
+ images.push({ name: image.name, digest: image.digest, contentHash: image.contentHash, member: member.repo, ...(image.members === undefined ? {} : { members: image.members }) });
119
135
  }
120
136
  }
121
137
  const sorted = (object) => Object.fromEntries(Object.entries(object).sort(([a], [b]) => compareStrings(a, b)));
122
- return { pins: sorted(pins), snapshot: sorted(snapshot), images: images.sort((a, b) => compareStrings(a.name, b.name)) };
138
+ return { pins: sorted(pins), snapshot: sorted(snapshot), behind: sorted(behind), images: images.sort((a, b) => compareStrings(a.name, b.name)) };
123
139
  }
124
140
 
125
141
  /** The `pins` of a file this verb wrote: member repo -> 40-hex commit. Nothing else of the file is read. */
126
- export function readPublishedPins(path) {
127
- const document = readStrictJsonFile(path);
128
- const { pins } = document ?? {};
129
- if (pins === null || typeof pins !== 'object' || Array.isArray(pins)) {
130
- throw new Error(`--published-pins ${path} has no "pins" object`);
131
- }
132
- for (const [repo, commit] of Object.entries(pins)) {
133
- if (typeof commit !== 'string' || !/^[0-9a-f]{40}$/.test(commit)) {
134
- throw new Error(`--published-pins ${path}: pin ${JSON.stringify(repo)} is not a 40-hex commit: ${JSON.stringify(commit)}`);
135
- }
136
- }
137
- return pins;
138
- }
142
+ export const readPublishedPins = (path) => readSelectionPins(path, '--published-pins');
139
143
 
140
144
  /** The `select-records` verb. Writes `--out` once, only after every member was selected. */
141
145
  export async function runSelectRecords({
@@ -144,6 +148,7 @@ export async function runSelectRecords({
144
148
  members = developGraphMembers(),
145
149
  runGit,
146
150
  write = writeOnce,
151
+ print = (text) => process.stdout.write(text),
147
152
  ...verifierInjections
148
153
  }) {
149
154
  return withRecordVerifier({ cosignBinary: options['--cosign'], env, ...verifierInjections }, async (verifier) => {
@@ -159,6 +164,9 @@ export async function runSelectRecords({
159
164
  runGit,
160
165
  });
161
166
  write(options['--out'], selection);
167
+ for (const member of members) {
168
+ print(`${formatSelectionLine(member, selection)}\n`);
169
+ }
162
170
  return selection;
163
171
  });
164
172
  }
@@ -0,0 +1,30 @@
1
+ // develop-selection-line — the one stdout line `select-records` prints per member, and the check that lines are unambiguous.
2
+ //
3
+ // <short name>: record <40-hex commit>, <N> commits behind HEAD
4
+ // <short name>: current resolution (leaves the edition, <ticket>)
5
+ //
6
+ // The short name is the repo handle's last path segment. The develop-edition workflow in xema-distributions
7
+ // (develop-edition.yaml) parses these lines with the same pattern; change both together.
8
+
9
+ import { keepsCurrentResolution } from './develop-current-resolution.mjs';
10
+
11
+ export const shortNameOf = (repo) => repo.split('/').at(-1);
12
+
13
+ /** One graph member's line (no newline): its current-resolution line, or its record, commit and distance from develop HEAD. */
14
+ export function formatSelectionLine(member, selection) {
15
+ const { repo } = member;
16
+ if (keepsCurrentResolution(member)) {
17
+ return `${shortNameOf(repo)}: current resolution (leaves the edition, ${member.currentResolution})`;
18
+ }
19
+ return `${shortNameOf(repo)}: record ${selection.pins[repo]}, ${selection.behind[repo].commits} commits behind HEAD`;
20
+ }
21
+
22
+ /** Two members with one short name would print indistinguishable lines: refused by name. */
23
+ export function assertShortNamesUnambiguous(members) {
24
+ const seen = new Map();
25
+ for (const { repo } of members) {
26
+ const name = shortNameOf(repo);
27
+ if (seen.has(name)) {throw new Error(`select-records: members ${seen.get(name)} and ${repo} share the short name "${name}"; the printed lines would be ambiguous`);}
28
+ seen.set(name, repo);
29
+ }
30
+ }
@@ -0,0 +1,30 @@
1
+ // develop-selection-pins — the `pins` of a file `select-records` wrote: member repo -> 40-hex commit. Nothing else of
2
+ // the file is read here. `flag` names the option the file came in on (--published-pins, --member-pins), for the refusal.
3
+
4
+ import { currentResolutionProblem } from './develop-current-resolution.mjs';
5
+ import { readStrictJsonFile } from './strict-json.mjs';
6
+
7
+ /**
8
+ * @param {readonly {repo: string}[]} [members] when given, the file's `currentResolution` must also be exactly
9
+ * what these graph members declare (develop-current-resolution.mjs)
10
+ * @returns {Record<string, string>} the file's `pins`, each a 40-hex commit
11
+ */
12
+ export const readSelectionPins = (path, flag, members) => parseSelectionPins(readStrictJsonFile(path), path, flag, members);
13
+
14
+ /** The same over a document already read: the ONE validation of a selection's `pins` (and `currentResolution`). */
15
+ export function parseSelectionPins(document, path, flag, members) {
16
+ const { pins } = document ?? {};
17
+ if (pins === null || typeof pins !== 'object' || Array.isArray(pins)) {
18
+ throw new Error(`${flag} ${path} has no "pins" object`);
19
+ }
20
+ for (const [repo, commit] of Object.entries(pins)) {
21
+ if (typeof commit !== 'string' || !/^[0-9a-f]{40}$/.test(commit)) {
22
+ throw new Error(`${flag} ${path}: pin ${JSON.stringify(repo)} is not a 40-hex commit: ${JSON.stringify(commit)}`);
23
+ }
24
+ }
25
+ if (members !== undefined) {
26
+ const problem = currentResolutionProblem(document.currentResolution, members);
27
+ if (problem !== null) {throw new Error(`${flag} ${path}: ${problem}`);}
28
+ }
29
+ return pins;
30
+ }