@xemahq/xema 0.54.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 (36) hide show
  1. package/dist/edition/verify-image-evidence.bundle.mjs +1989 -1008
  2. package/dist/edition/verify-image-evidence.bundle.provenance.json +583 -499
  3. package/dist/runtime/lock-loader.d.ts +2 -1
  4. package/dist/runtime/lock-loader.d.ts.map +1 -1
  5. package/dist/runtime/lock-loader.js +15 -5
  6. package/dist/runtime/lock-loader.js.map +1 -1
  7. package/edition/README.md +8 -3
  8. package/edition/check-committed-image-evidence.mjs +44 -15
  9. package/edition/customer-edition-verbs.mjs +15 -4
  10. package/edition/develop-current-resolution.mjs +29 -0
  11. package/edition/develop-edition-resolve.mjs +4 -3
  12. package/edition/develop-edition.mjs +30 -9
  13. package/edition/develop-freeze.mjs +24 -1
  14. package/edition/develop-graph.mjs +5 -3
  15. package/edition/develop-image-probe.mjs +6 -4
  16. package/edition/develop-image-record.mjs +194 -0
  17. package/edition/develop-record-ancestry.mjs +23 -5
  18. package/edition/develop-select-records.mjs +29 -21
  19. package/edition/develop-selection-line.mjs +30 -0
  20. package/edition/develop-selection-pins.mjs +30 -0
  21. package/edition/dist-check.mjs +45 -9
  22. package/edition/distribution-evidence-set.mjs +14 -9
  23. package/edition/edition-image-seal.mjs +2 -1
  24. package/edition/edition-protocol.mjs +6 -20
  25. package/edition/image-evidence.mjs +6 -2
  26. package/edition/image-lock-service-rows.mjs +4 -4
  27. package/edition/image-subject-identity.mjs +24 -0
  28. package/edition/images-from-record.mjs +46 -110
  29. package/edition/lib.mjs +24 -26
  30. package/edition/resolve-customer-edition.mjs +1 -1
  31. package/edition/resolve-edition.mjs +2 -1
  32. package/edition/resolve-images.mjs +224 -12
  33. package/edition/seal-image-digests.mjs +24 -13
  34. package/edition/verify-image-platforms.mjs +2 -0
  35. package/edition/workspace-sections.mjs +11 -1
  36. package/package.json +3 -3
@@ -8,7 +8,8 @@
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
 
13
14
  import { RefusalError } from './collected-refusals.mjs';
14
15
  import { developImageRef } from './develop-image-coordinate.mjs';
@@ -39,7 +40,7 @@ export function notBuiltYetError(distributionId, missingSubjects, channel = 'dev
39
40
  /**
40
41
  * @param {object} request
41
42
  * @param {string} request.distributionId the edition (named in the refusal)
42
- * @returns {{ observe(subject: object): void, settle(): Promise<void> }}
43
+ * @returns {{ observe(subject: object): void, settle(): Promise<Map<string, string>> }}
43
44
  * `observe` takes a subject with `repo`, `sourceCommit`, `imageRef` (release ref) and `contentHash`
44
45
  * and starts its probe; `settle` waits for all of them and throws when any is absent or unreadable.
45
46
  */
@@ -72,9 +73,9 @@ export function createDevelopImageProbe({
72
73
  const startedAt = Date.now();
73
74
  const { repository } = splitImageRef(subject.imageRef);
74
75
  try {
75
- await clientFor(subject).getManifestByTag(repository, subject.contentHash);
76
+ const { digest } = await clientFor(subject).getManifestByTag(repository, subject.contentHash);
76
77
  progress?.(`[probe] ${subject.kind}:${subject.id} present ${Date.now() - startedAt}ms`);
77
- return { subject };
78
+ return { subject, digest };
78
79
  } catch (error) {
79
80
  progress?.(`[probe] ${subject.kind}:${subject.id} ${isNotPublishedYet(error) ? 'ABSENT' : 'FAILED'} ${Date.now() - startedAt}ms`);
80
81
  return { subject, error };
@@ -113,6 +114,7 @@ export function createDevelopImageProbe({
113
114
  if (aborting) {throw aborting.error;}
114
115
  const missing = settled.filter(({ error }) => error !== undefined).map(({ subject }) => subject);
115
116
  if (missing.length > 0) {throw notBuiltYetError(distributionId, missing);}
117
+ return new Map(settled.map(({ subject, digest }) => [`${subject.kind}:${subject.id}`, digest]));
116
118
  },
117
119
  };
118
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 {
@@ -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
+ }
@@ -50,6 +50,7 @@ import { assertKernelEditionProtocol } from './edition-protocol.mjs';
50
50
  import { describeEditionHolds } from './edition-source-pins.mjs';
51
51
  import { WorkspaceRoot, biomeIndexPath, configureEditionWorkspaceFromProcess, dataRoot, editionsRoot, isEntryModule, kernelPackageDir, platformServiceIndexPath, runtimeImageSourceIndexPath, workspaceArgv } from './edition-workspace.mjs';
52
52
  import { checkEvidenceTrustPolicy } from './evidence-trust-policy.mjs';
53
+ import { imageSubjectIdentity } from './image-subject-identity.mjs';
53
54
  import {
54
55
  compareIndexEntries,
55
56
  comparableDistributionLock,
@@ -58,6 +59,7 @@ import {
58
59
  loadKernelDistributionSchemas,
59
60
  parseResolvedDistributionLock,
60
61
  serializeJson,
62
+ sha256Hex,
61
63
  validateImmutableBiomeIndex,
62
64
  validateImmutableDistributionLock,
63
65
  validateImmutableImageLock,
@@ -69,6 +71,8 @@ import {
69
71
  resolveDistributionDeclaredSources,
70
72
  resolveDistributionRequiredPlatforms,
71
73
  externalSourceTrees,
74
+ carryComposedForwardStructurally,
75
+ readCommittedPreviousLock,
72
76
  resolveImages,
73
77
  resolveComposedProcessStructure,
74
78
  resolveImageStructure,
@@ -237,6 +241,24 @@ async function checkAuthorizationLock(id, kernel, index) {
237
241
  * with the same structure ship the same images and bundle the same web biomes,
238
242
  * regardless of current source content (which the build re-hashes anyway).
239
243
  */
244
+ /**
245
+ * The committed lock with a stand-in `imageDigest` on every UNSEALED composed artifact, so the kernel can carry it forward.
246
+ * The kernel's v5 parse refuses two composed artifacts sharing one imageDigest, so each stand-in is unique to its subject:
247
+ * the sha256 of its identity (`imageSubjectIdentity`: `composed-process:<imageRef>:<contentHash>`, unique per lock).
248
+ * It equals a real manifest digest only by a sha256 preimage, and it never reaches a verdict: both sides of every
249
+ * comparison drop `imageDigest` (`withoutSealedDigests`).
250
+ */
251
+ export function withStandInDigests(lock, schemas) {
252
+ return {
253
+ ...lock,
254
+ subjects: lock.subjects.map((subject) =>
255
+ subject.kind === schemas.RuntimeImageSubjectKind.ComposedProcess && subject.imageDigest === undefined
256
+ ? { ...subject, imageDigest: `sha256:${sha256Hex(imageSubjectIdentity(subject))}` }
257
+ : subject,
258
+ ),
259
+ };
260
+ }
261
+
240
262
  function imageStructure(lock) {
241
263
  return serializeJson({
242
264
  distributionId: lock.distributionId,
@@ -315,12 +337,6 @@ export async function checkImagesLock(id, checkSourceEvidence) {
315
337
  schemas,
316
338
  requiredPlatforms,
317
339
  );
318
- const fresh = {
319
- distributionId: id,
320
- contractAuthority: runtimeSourceIndex.contractAuthority,
321
- ...structure,
322
- subjects: structureSubjects(structure),
323
- };
324
340
  if (!existsSync(imageLockPath)) {
325
341
  console.error(
326
342
  `[dist:check] ${id}: images.lock.json is missing. Run \`${commandFor('resolve-images.mjs', id)}\`.`,
@@ -328,9 +344,29 @@ export async function checkImagesLock(id, checkSourceEvidence) {
328
344
  return false;
329
345
  }
330
346
  const committed = validateImmutableImageLock(
331
- schemas.parseImageLock(JSON.parse(readFileSync(imageLockPath, 'utf8'))),
347
+ (await readCommittedPreviousLock(id, imageLockPath)),
332
348
  schemas,
333
349
  );
350
+ // The committed lock is the previous lock of the fresh resolve, so the fresh view carries the composed artifacts forward
351
+ // exactly as a reseal does (generation, member keys, one artifact per live generation). Its digests are compared away
352
+ // below, so a committed lock that predates sealing is given one stand-in digest per subject to be carried.
353
+ const previousLock = withStandInDigests(committed, schemas);
354
+ let freshSubjects;
355
+ try {
356
+ freshSubjects = carryComposedForwardStructurally({ subjects: structureSubjects(structure), previousLock, schemas });
357
+ } catch (error) {
358
+ console.error(
359
+ `[dist:check] ${id}: images.lock.json is structurally out of date (its composed artifacts do not cover the declared composed processes: ${error.message}). ` +
360
+ `Run \`${commandFor('resolve-images.mjs', id)}\`.`,
361
+ );
362
+ return false;
363
+ }
364
+ const fresh = {
365
+ distributionId: id,
366
+ contractAuthority: runtimeSourceIndex.contractAuthority,
367
+ ...structure,
368
+ subjects: freshSubjects,
369
+ };
334
370
  if (
335
371
  serializeJson(committed.contractAuthority) !==
336
372
  serializeJson(runtimeSourceIndex.contractAuthority)
@@ -350,12 +386,12 @@ export async function checkImagesLock(id, checkSourceEvidence) {
350
386
  return false;
351
387
  }
352
388
  if (checkSourceEvidence) {
353
- const { imageLock: sourceResolved } = await resolveImages(id);
389
+ const { imageLock: sourceResolved } = await resolveImages(id, { previousLock });
354
390
  // The same subtraction as the structural view above: the source resolve
355
391
  // carries no digest, and the committed lock may.
356
392
  if (
357
393
  normalize(withoutSealedDigests(committed)) !==
358
- normalize(sourceResolved)
394
+ normalize(withoutSealedDigests(sourceResolved))
359
395
  ) {
360
396
  console.error(
361
397
  `[dist:check] ${id}: images.lock.json source content hashes are out of date. ` +
@@ -56,6 +56,7 @@ import {
56
56
  sha256Digest,
57
57
  sourceIdentity,
58
58
  } from './image-evidence.mjs';
59
+ import { completenessImageKey } from './image-subject-identity.mjs';
59
60
  import { assertKernelContractAuthorityEqual } from './kernel-contract-authority.mjs';
60
61
  import { compareStrings } from './lib.mjs';
61
62
  import { readBoundedNoFollowFile } from './no-follow-file.mjs';
@@ -133,7 +134,7 @@ export { compareCompletenessImages as compareEvidenceSubjects };
133
134
  * Keyed by the same `kind\0id` identity `image-evidence.mjs` uses everywhere
134
135
  * else, so two subjects can never collide and one subject can never move.
135
136
  */
136
- function subjectStem(subject) {
137
+ export function subjectStem(subject) {
137
138
  return createHash('sha256')
138
139
  .update(sourceIdentity(subject))
139
140
  .digest('hex');
@@ -315,10 +316,9 @@ function indexOneEvidenceDirectory({
315
316
  });
316
317
  continue;
317
318
  }
318
- const subjectKey = sourceIdentity({
319
- kind: entry.subjectKind,
320
- id: entry.subjectId,
321
- });
319
+ // Only named in a defect: the record is indexed by the statement's own source coordinate, which already
320
+ // tells the generations of a composed artifact apart (its sourceTreeHash).
321
+ const subjectKey = `${entry.subjectKind}:${entry.subjectId}`;
322
322
  const descriptor = entry.evidence;
323
323
  let statementBytes;
324
324
  let bundleBytes;
@@ -691,12 +691,17 @@ export function buildDistributionImagesStatement({
691
691
  // Refusing here as well means an incomplete set can never be SIGNED, rather
692
692
  // than being signed and rejected days later at a customer install.
693
693
  const expectedIdentities = imageLock.subjects
694
- .map((subject) => sourceIdentity(subject))
694
+ .map((subject) =>
695
+ completenessImageKey({
696
+ subjectKind: subject.kind,
697
+ subjectId: subject.id,
698
+ imageRepository: subject.imageRef,
699
+ manifestDigest: subject.imageDigest,
700
+ }),
701
+ )
695
702
  .sort(compareStrings);
696
703
  const actualIdentities = images
697
- .map((image) =>
698
- sourceIdentity({ kind: image.subjectKind, id: image.subjectId }),
699
- )
704
+ .map((image) => completenessImageKey(image))
700
705
  .sort(compareStrings);
701
706
  if (
702
707
  new Set(actualIdentities).size !== actualIdentities.length ||