@xemahq/xema 0.58.0 → 0.59.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,491 @@
1
+ // develop-follow — the body of `xema edition develop follow`: the pins a dev deployment's follow policy selects.
2
+ //
3
+ // A dev deployment follows a reviewed distribution DEFINITION at its develop head: `--definition-at
4
+ // <owner>/<repo>,<path>,<dataRoot>` names the repository, the distribution directory and the data root inside it,
5
+ // each repository-relative. `follow` writes the COMPLETE frozen pin set a `develop resolve` reads, once (wx), and reads
6
+ // no edition and no catalogue. It reuses, and never restates, two existing rules:
7
+ //
8
+ // members every develop-graph member at the newest commit of its signed build records (select-records'
9
+ // selectRecords / newestCommitOnDevelop), with the pins of the CURRENT input (--current-pins) as the
10
+ // published pins: a member whose records are not ahead of its current pin keeps it, one whose records
11
+ // diverged from it is refused by name (DivergedFromPublishedError).
12
+ // tooling the data and kernel pins and the editions root keep freeze-graph's rules unchanged (freezeGraph):
13
+ // each checked-out root is pinned at what is checked out, and the kernel commit must declare an edition
14
+ // protocol this CLI supports; the deploy pin is chosen as below. The members enter freezeGraph as its
15
+ // member pins (freeze-graph --member-pins: a member that keeps its current resolution is pinned at its
16
+ // develop head, never selected). From current pins, each root is then classified against its current pin by ancestry in the root's OWN
17
+ // checkout (classifyToolingRoot): equal is kept, a descendant advanced, an ancestor refused
18
+ // (ToolingRootBehindError), an unrelated commit refused (ToolingRootDivergedError), and a current pin the
19
+ // checkout does not hold refused (ToolingRootHistoryMissingError: never fetched, never guessed; the follow
20
+ // job checks the roots out with full history). A first follow has nothing to classify against and says so.
21
+ // deploy --deploy-sha is an UPPER BOUND, never the pin (the engine publishes on workflow_run after the deploy
22
+ // root's CI, so the head can be ahead of its engine): the pin is the newest first-parent commit of the deploy
23
+ // checkout at or below it whose engine `<--engine-repository>:sha-<12 hex>` the registry already serves.
24
+ // An unpublished commit is skipped; none published within ENGINE_WALK_LIMIT commits is refused by name
25
+ // (NoPublishedEngineError); any registry answer but 404 is operational, never a skip. The walk is
26
+ // deterministic: the same checkout and registry choose the same commit. The summary names it and the
27
+ // skip count. A walk that ends short of the limit at a shallow boundary or an absent parent is refused
28
+ // (ToolingRootHistoryMissingError), never read as "no engine published". The chosen commit is then classified against the current deploy pin like every tooling
29
+ // root, so a walk that lands behind it is refused (ToolingRootBehindError). Only existence is judged
30
+ // here; the engine's signature stays the apply's check.
31
+ // sources every source the definition's chain declares, at its develop head (freezeSources), then newer-wins
32
+ // against its current pin by the same ancestry rule (newestCommitOnDevelop; a head equal to the pin
33
+ // fetches nothing); a diverged source is refused by name, and a current pin absent from the develop
34
+ // history of the source's declared URL is refused as a URL change (FollowSourceUrlChangedError).
35
+ //
36
+ // seals the previous seals the resolve carries generations forward from, frozen so a retry reads no "newest":
37
+ // the OFFERING's newest signed develop edition, read ONCE here from its edition repository through
38
+ // previous-seal-registry.mjs (--previous-seal-signer, verified offline with the pinned cosign), its
39
+ // images.lock bytes and sha256; and the CUSTOMER edition's own previous seal, a follow input passed through
40
+ // (--previous-lock <file> --previous-lock-sha256 <hex>, held to its sha256, or --first-seal). They are
41
+ // written once into --seals-out: offering.images.lock.json, customer.images.lock.json (absent on a first
42
+ // seal) and seals.json naming each file and its sha256 (followSealsDocument). The catalogue-free resolve
43
+ // takes the offering's as --offering-previous-lock and the customer's as --previous-lock.
44
+ //
45
+ // The data root checkout IS the definition's repository, held for consistency with the caller's checkout (the
46
+ // declared repository URL is read from a file inside that checkout, so this is no trust boundary): the data root
47
+ // declares the definition's repository, the checkout is clean (untracked files included) at --data-sha, the data root
48
+ // and the distribution directory are the definition's dataRoot and path, and the editions root is that same
49
+ // repository (there is no --editions-root-url). The kernel checkout is at --kernel-sha. A follow starts from exactly
50
+ // one of `--first` (a FIRST follow, and the summary it prints says so) or `--current-pins <file>`.
51
+
52
+ import { execFileSync, spawnSync } from 'node:child_process';
53
+ import { existsSync, mkdirSync, realpathSync, writeFileSync } from 'node:fs';
54
+ import { basename, join, normalize, relative } from 'node:path';
55
+
56
+ import { canonicalPublicHttpsRepositoryUrl } from './canonical-repository-url.mjs';
57
+ import { headResolver, writeOnce } from './customer-edition-verbs.mjs';
58
+ import { DEFINITION_AT_FLAG } from './customer-flags.mjs';
59
+ import { engineTagOf } from './develop-engine-image.mjs';
60
+ import { freezeGraph, freezeSources } from './develop-freeze.mjs';
61
+ import { developGraphMembers } from './develop-graph.mjs';
62
+ import { PUBLISHED_PIN_TAG, RecordCommitNotOnDevelopError, newestCommitOnDevelop } from './develop-record-ancestry.mjs';
63
+ import { declaredDataRootRepositoryUrl, withRecordVerifier } from './develop-record-verify.mjs';
64
+ import { selectRecords } from './develop-select-records.mjs';
65
+ import { assertCommitPins } from './develop-selection-pins.mjs';
66
+ import { VERIFY_IMAGE_RECORD_BRANCH as DEVELOP_RECORD_BRANCH } from './develop-verify-image-record.mjs';
67
+ import { assertCanonicalDistributionId } from './distribution-paths.mjs';
68
+ import { editionRepository } from './edition-artifact.mjs';
69
+ import { editionWorkspaceConfig } from './edition-config.mjs';
70
+ import { EditionChannel } from './edition-constants.mjs';
71
+ import { dataRoot, deployRoot, editionsRoot, kernelRepoDir } from './edition-workspace.mjs';
72
+ import { assertKernelCommitSupported } from './kernel-protocol-choice.mjs';
73
+ import { compareStrings, loadKernelDistributionSchemas, serializeJson } from './lib.mjs';
74
+ import { SnapshotPinNamespace, dataPinKey, deployPinKey, kernelPinKey, snapshotPinNamespace, snapshotPinSource, sortedSnapshotPins } from './pin-source.mjs';
75
+ import { PREVIOUS_SEAL_SIGNER_FLAG, previousSealFromRegistry } from './previous-seal-registry.mjs';
76
+ import { splitImageRef } from './registry-credentials.mjs';
77
+ import { composeEdition } from './resolve-distribution.mjs';
78
+ import { readPreviousLockBytes } from './resolve-images.mjs';
79
+ import { isNotPublishedYet } from './seal-image-digests.mjs';
80
+ import { readStrictJsonFile } from './strict-json.mjs';
81
+
82
+ const REPOSITORY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9-]*\/[A-Za-z0-9._-]+$/;
83
+ const RELATIVE_SEGMENT = /^[A-Za-z0-9._-]+$/;
84
+ const SHA_PATTERN = /^[0-9a-f]{40}$/;
85
+ // A repository, never a tag or a digest: `<registry>[:port]/<path>`.
86
+ const ENGINE_REPOSITORY_PATTERN = /^[a-z0-9.-]+(:[0-9]+)?(\/[a-z0-9._-]+)+$/;
87
+
88
+ /** How many first-parent commits of the deploy checkout, the upper bound included, the engine walk reads. */
89
+ export const ENGINE_WALK_LIMIT = 50;
90
+
91
+ /** No commit at or below the upper bound has a published engine within the walk. */
92
+ export class NoPublishedEngineError extends Error {
93
+ constructor({ upperBound, engineRepository, walked }) {
94
+ super(`no deploy-root commit at or below --deploy-sha ${upperBound} has a published engine in ${engineRepository} within ${walked} first-parent commit(s) (limit ${ENGINE_WALK_LIMIT}): publish the engine for a develop commit (the deploy root's CI) and follow again`);
95
+ this.name = 'NoPublishedEngineError';
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The newest commit of `commits` (first-parent order, newest first, the upper bound first) whose engine tag the
101
+ * registry serves: `{ deploySha, upperBound, skipped }`. A 404 skips the commit; any other failure throws.
102
+ */
103
+ export async function selectPublishedEngineCommit({ commits, upperBound, engineRepository, client }) {
104
+ if (commits[0] !== upperBound) {
105
+ throw new Error(`the deploy walk starts at ${commits[0] ?? 'nothing'}, not at --deploy-sha ${upperBound}`);
106
+ }
107
+ for (const [skipped, commit] of commits.slice(0, ENGINE_WALK_LIMIT).entries()) {
108
+ try {
109
+ await client.getManifestByTag(engineRepository, engineTagOf(commit));
110
+ return { deploySha: commit, upperBound, skipped };
111
+ } catch (error) {
112
+ if (!isNotPublishedYet(error)) {
113
+ throw new Error(`follow: the registry did not answer for the engine ${engineRepository}:${engineTagOf(commit)}: ${error.message}`);
114
+ }
115
+ }
116
+ }
117
+ throw new NoPublishedEngineError({ upperBound, engineRepository, walked: Math.min(commits.length, ENGINE_WALK_LIMIT) });
118
+ }
119
+
120
+ /**
121
+ * The first-parent commits of the deploy checkout from `upperBound` back, newest first, at most the walk limit, read
122
+ * from the local object store only (never a lazy fetch). A walk that ends short of the limit must end at the root
123
+ * commit: a shallow checkout, or a commit of the walk whose object is absent (an absent --deploy-sha or an absent first
124
+ * parent, where git refuses the walk), is ToolingRootHistoryMissingError, never a short walk read as "no engine
125
+ * published".
126
+ */
127
+ export function deployCommitsAtOrBelow(upperBound, git = runLocalGitText) {
128
+ if (!SHA_PATTERN.test(upperBound ?? '')) {throw new Error(`--deploy-sha is not a 40-hex commit SHA: ${JSON.stringify(upperBound)}`);}
129
+ const dir = deployRoot();
130
+ let listed;
131
+ try {
132
+ listed = git(['rev-list', '--first-parent', `--max-count=${ENGINE_WALK_LIMIT}`, upperBound], dir);
133
+ } catch (error) {
134
+ throw absentCommitOfWalk(upperBound, dir, git) ?? error;
135
+ }
136
+ const commits = listed.split('\n').filter(Boolean);
137
+ if (commits.length < ENGINE_WALK_LIMIT && git(['rev-parse', '--is-shallow-repository'], dir) === 'true') {
138
+ throw new ToolingRootHistoryMissingError(deployPinKey(), commits.at(-1) ?? upperBound, dir, 'history below the shallow boundary');
139
+ }
140
+ return commits;
141
+ }
142
+
143
+ /** The ToolingRootHistoryMissingError for the first commit of the first-parent walk from `upperBound` the checkout does not hold, or undefined. */
144
+ function absentCommitOfWalk(upperBound, dir, git) {
145
+ const holds = (commit) => {
146
+ try {
147
+ git(['cat-file', '-e', `${commit}^{commit}`], dir);
148
+ return true;
149
+ } catch {
150
+ return false;
151
+ }
152
+ };
153
+ let commit = upperBound;
154
+ for (let walked = 0; walked < ENGINE_WALK_LIMIT; walked += 1) {
155
+ if (!holds(commit)) {
156
+ return new ToolingRootHistoryMissingError(deployPinKey(), commit, dir, walked === 0 ? '--deploy-sha' : 'first parent of the walk\'s last commit');
157
+ }
158
+ // the commit object's own header: rev-list would need the parent object to name it
159
+ const firstParent = /^parent ([0-9a-f]{40})$/m.exec(git(['cat-file', 'commit', commit], dir).split('\n\n')[0])?.[1];
160
+ if (firstParent === undefined) {return undefined;}
161
+ commit = firstParent;
162
+ }
163
+ return undefined;
164
+ }
165
+
166
+ /** How a follow started: from no input (first) or from the pins of the current input. */
167
+ export const FollowStart = Object.freeze({ First: 'first', FromCurrent: 'from-current' });
168
+
169
+ /** The flag naming the followed definition triple (freeze-sources' and resolve's `--definition` is a file). */
170
+ export const FOLLOW_DEFINITION_FLAG = DEFINITION_AT_FLAG;
171
+
172
+ /** A repository-relative path: `.` or `/`-separated plain segments, never absolute, never `..`. */
173
+ function relativePath(text, what, flag) {
174
+ if (text === '.') {return '.';}
175
+ const segments = text.split('/');
176
+ if (text === '' || segments.some((segment) => !RELATIVE_SEGMENT.test(segment) || segment === '.' || segment === '..')) {
177
+ throw new Error(`${flag} ${what} ${JSON.stringify(text)} is not a repository-relative path ("." or plain segments joined by "/")`);
178
+ }
179
+ return segments.join('/');
180
+ }
181
+
182
+ /** `<owner>/<repo>,<path>,<dataRoot>` (of `flag`: follow's --definition-at, or the catalogue-free resolve's --offering) -> `{ repository, path, dataRoot }`. */
183
+ export function parseFollowDefinition(text, flag = FOLLOW_DEFINITION_FLAG) {
184
+ const parts = typeof text === 'string' ? text.split(',') : [];
185
+ if (parts.length !== 3) {
186
+ throw new Error(`${flag} is <owner>/<repo>,<path>,<dataRoot>, got ${JSON.stringify(text)}`);
187
+ }
188
+ const [repository, path, root] = parts;
189
+ if (!REPOSITORY_PATTERN.test(repository)) {
190
+ throw new Error(`${flag} repository ${JSON.stringify(repository)} is not <owner>/<repo>`);
191
+ }
192
+ const definition = { repository, path: relativePath(path, 'path', flag), dataRoot: relativePath(root, 'dataRoot', flag) };
193
+ if (definition.path === '.') {throw new Error(`${flag} path names the distribution directory, never the repository root`);}
194
+ assertCanonicalDistributionId(basename(definition.path));
195
+ return definition;
196
+ }
197
+
198
+ /** The `--current-pins` file (a follow's own output, a bare pin map): key -> 40-hex commit, every key in a snapshot pin namespace. */
199
+ export function readCurrentPins(path) {
200
+ const pins = readStrictJsonFile(path);
201
+ if (pins === null || typeof pins !== 'object' || Array.isArray(pins)) {
202
+ throw new Error(`--current-pins ${path} must be an object mapping pin key -> 40-hex commit`);
203
+ }
204
+ for (const key of Object.keys(pins)) {snapshotPinNamespace(key);}
205
+ return assertCommitPins(pins, `--current-pins ${path}`);
206
+ }
207
+
208
+ const runGitText = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }).trim();
209
+ /** runGitText that never lazily fetches a missing object (a partial clone answers from what it holds). */
210
+ const runLocalGitText = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, GIT_NO_LAZY_FETCH: '1' } }).trim();
211
+
212
+ /**
213
+ * Consistency with the caller's checkout: the data root checkout is the definition, the repository the data root
214
+ * declares, clean (untracked files included) at --data-sha, with the data root and the editions root's distribution
215
+ * directory at the definition's dataRoot and path. Returns the id.
216
+ */
217
+ export function assertDefinitionCheckout({ definition, dataSha, git = runGitText, flag = FOLLOW_DEFINITION_FLAG, shaLabel = '--data-sha' }) {
218
+ const declared = canonicalPublicHttpsRepositoryUrl(declaredDataRootRepositoryUrl(), 'the data root\'s dataRootRepositoryUrl');
219
+ const named = canonicalPublicHttpsRepositoryUrl(`https://github.com/${definition.repository}.git`, `${flag} repository`);
220
+ if (declared !== named) {
221
+ throw new Error(`${flag} names ${definition.repository}, but the data root is the repository ${declared}: the data root is the definition's own`);
222
+ }
223
+ const top = realpathSync(git(['rev-parse', '--show-toplevel'], dataRoot()));
224
+ const head = git(['rev-parse', 'HEAD'], top);
225
+ if (head !== dataSha) {
226
+ throw new Error(`${shaLabel} is ${dataSha}, but the definition checkout ${top} is at ${head}`);
227
+ }
228
+ const dirty = git(['status', '--porcelain', '--untracked-files=all'], top);
229
+ if (dirty !== '') {
230
+ throw new Error(`the definition checkout ${top} has uncommitted or untracked changes: the definition is read only as committed at ${shaLabel}`);
231
+ }
232
+ const at = (dir) => normalize(relative(top, realpathSync(dir)) || '.');
233
+ if (at(dataRoot()) !== normalize(definition.dataRoot)) {
234
+ throw new Error(`--data-root is ${at(dataRoot())} of the definition checkout, but ${flag} names the data root ${definition.dataRoot}`);
235
+ }
236
+ const id = basename(definition.path);
237
+ if (!existsSync(join(editionsRoot(), id))) {
238
+ throw new Error(`the editions root holds no "${id}", but ${flag} names ${definition.path}`);
239
+ }
240
+ if (at(join(editionsRoot(), id)) !== normalize(definition.path)) {
241
+ throw new Error(`the editions root holds "${id}" at ${at(join(editionsRoot(), id))} of the definition checkout, but ${flag} names ${definition.path}`);
242
+ }
243
+ return id;
244
+ }
245
+
246
+ /** How a tooling root's followed commit stands against its current pin. */
247
+ export const ToolingRootVerdict = Object.freeze({ Kept: 'kept', Advanced: 'advanced', Added: 'added' });
248
+
249
+ /** The followed commit of a tooling root is an ancestor of its current pin: the follow would take it backwards. */
250
+ export class ToolingRootBehindError extends Error {
251
+ constructor(key, followed, current) {
252
+ super(`${key}: the followed commit ${followed} is behind the current pin ${current} (an ancestor of it): a follow never takes a tooling root backwards; check out the current commit or a descendant`);
253
+ this.name = 'ToolingRootBehindError';
254
+ }
255
+ }
256
+
257
+ /** The followed commit of a tooling root and its current pin are not ancestor-related. */
258
+ export class ToolingRootDivergedError extends Error {
259
+ constructor(key, followed, current) {
260
+ super(`${key}: the followed commit ${followed} and the current pin ${current} are not ancestor-related`);
261
+ this.name = 'ToolingRootDivergedError';
262
+ }
263
+ }
264
+
265
+ /** The root's checkout does not hold one of the two commits (`role`: the current pin or the followed commit), so they cannot be compared: refused, never fetched. */
266
+ export class ToolingRootHistoryMissingError extends Error {
267
+ constructor(key, commit, dir, role) {
268
+ super(`${key}: the checkout ${dir} does not hold the ${role} ${commit}: check the root out with full history (fetch-depth 0) and follow again`);
269
+ this.role = role;
270
+ this.name = 'ToolingRootHistoryMissingError';
271
+ }
272
+ }
273
+
274
+ /** `{ status, stderr }` of one git run in `dir`, never lazily fetching a missing object. */
275
+ const runGitStatus = (args, dir) => {
276
+ const result = spawnSync('git', args, { cwd: dir, env: { ...process.env, GIT_NO_LAZY_FETCH: '1' }, encoding: 'utf8' });
277
+ if (result.error !== undefined || result.status === null) {throw new Error(`git ${args[0]} could not run in ${dir}: ${result.error?.message ?? `killed by ${result.signal}`}`);}
278
+ return { status: result.status, stderr: result.stderr };
279
+ };
280
+
281
+ /**
282
+ * A tooling root's followed commit against its current pin, by ancestry in the root's own checkout `dir`: kept or
283
+ * advanced, otherwise a typed refusal. Equal commits answer without git.
284
+ */
285
+ export function classifyToolingRoot({ key, dir, followed, current, git = runGitStatus }) {
286
+ if (followed === current) {return ToolingRootVerdict.Kept;}
287
+ for (const [role, commit] of [['current pin', current], ['followed commit', followed]]) {
288
+ if (git(['cat-file', '-e', `${commit}^{commit}`], dir).status !== 0) {throw new ToolingRootHistoryMissingError(key, commit, dir, role);}
289
+ }
290
+ // 0 = ancestor, 1 = not; anything else is git failing, never a verdict
291
+ const isAncestor = (older, newer) => {
292
+ const result = git(['merge-base', '--is-ancestor', older, newer], dir);
293
+ if (result.status === 0) {return true;}
294
+ if (result.status === 1) {return false;}
295
+ throw new Error(`follow: ${key}: git merge-base ${older} ${newer} failed in ${dir} (exit ${result.status}): ${result.stderr.trim()}`);
296
+ };
297
+ if (isAncestor(current, followed)) {return ToolingRootVerdict.Advanced;}
298
+ if (isAncestor(followed, current)) {throw new ToolingRootBehindError(key, followed, current);}
299
+ throw new ToolingRootDivergedError(key, followed, current);
300
+ }
301
+
302
+ /** A source's current pin is not in the develop history of its declared URL: the URL changed (or develop was rewritten). */
303
+ export class FollowSourceUrlChangedError extends Error {
304
+ constructor(key, url, current) {
305
+ super(`follow: ${key}: the current pin ${current} is not in the develop history of the declared URL ${url}: the source's repository URL changed since the current input (or its develop was rewritten); a follow never carries a pin across a URL change, so follow with --first`);
306
+ this.name = 'FollowSourceUrlChangedError';
307
+ }
308
+ }
309
+
310
+ /**
311
+ * The newer of a source's develop head and its current pin, through the one ancestry rule. A head equal to the pin
312
+ * answers without a fetch; the pin missing from the URL's develop history is a URL change, never a record refusal.
313
+ */
314
+ export function followSourceAncestry({ key, url, head, published, newest }) {
315
+ if (head === published) {return published;}
316
+ try {
317
+ return newest({ member: { repo: key, repositoryUrl: url }, candidates: [{ commit: head, tag: 'develop head' }], published, context: 'follow' }).commit;
318
+ } catch (error) {
319
+ if (error instanceof RecordCommitNotOnDevelopError && error.commit === published && error.tags.includes(PUBLISHED_PIN_TAG)) {
320
+ throw new FollowSourceUrlChangedError(key, url, published);
321
+ }
322
+ throw error;
323
+ }
324
+ }
325
+
326
+ /** The tooling root pin keys with the checkout each is classified in. */
327
+ const toolingRoots = () => [
328
+ { key: dataPinKey(), dir: () => dataRoot() },
329
+ { key: kernelPinKey(), dir: () => kernelRepoDir() },
330
+ { key: deployPinKey(), dir: () => deployRoot() },
331
+ ];
332
+
333
+ /** The kernel checkout is at --kernel-sha (consistency with the caller's checkout). */
334
+ export function assertKernelCheckout({ kernelSha, git = runGitText }) {
335
+ const head = git(['rev-parse', 'HEAD'], kernelRepoDir());
336
+ if (head !== kernelSha) {throw new Error(`--kernel-sha is ${kernelSha}, but the kernel checkout ${kernelRepoDir()} is at ${head}`);}
337
+ }
338
+
339
+ /** Exactly one of `--first` and `--current-pins <file>`: the current pins, or undefined for a first follow. */
340
+ export function followStartOf(options) {
341
+ const first = options['--first'] === true;
342
+ const current = options['--current-pins'];
343
+ if (first === (current !== undefined)) {
344
+ throw new Error('follow takes exactly one of --first (no current input) or --current-pins <file> (the pins of the current input)');
345
+ }
346
+ return first ? undefined : readCurrentPins(current);
347
+ }
348
+
349
+ /** What moved between the current pins and the followed ones, sorted. */
350
+ function followSummary(currentPins, pins, tooling) {
351
+ if (currentPins === undefined) {return { follow: FollowStart.First, tooling: 'not classified: a first follow has no current pins', advanced: [], kept: [], added: Object.keys(pins), dropped: [] };}
352
+ const keys = (filter) => Object.keys(pins).filter(filter).sort(compareStrings);
353
+ return {
354
+ follow: FollowStart.FromCurrent,
355
+ tooling,
356
+ advanced: keys((key) => key in currentPins && currentPins[key] !== pins[key]),
357
+ kept: keys((key) => currentPins[key] === pins[key]),
358
+ added: keys((key) => !(key in currentPins)),
359
+ dropped: Object.keys(currentPins).filter((key) => !(key in pins)).sort(compareStrings),
360
+ };
361
+ }
362
+
363
+ /**
364
+ * The follow, over injected readers: `selectDeploy()` answers the deploy pin (selectPublishedEngineCommit), `select(publishedPins)` answers select-records' `{ pins }` for the graph members,
365
+ * `declaredSourcesAt(graphPins)` the sources the definition's chain declares at the graph pins (composeEdition, as
366
+ * freeze-sources reads them), `ancestry({ key, url, head, published })` the newer of a source's head and its current pin
367
+ * (or a refusal), `classifyRoot({ key, followed, current })` a tooling root against its current pin (or a refusal).
368
+ *
369
+ * @returns {Promise<{ pins: Record<string, string>, summary: object }>}
370
+ */
371
+ export async function followDevelop({ dataSha, kernelSha, selectDeploy, currentPins, members, select, resolveHead, holdSource, declaredSourcesAt, ancestry, classifyRoot }) {
372
+ const graph = new Set(members.map((member) => member.repo));
373
+ const published = currentPins === undefined ? undefined : Object.fromEntries(Object.entries(currentPins).filter(([key]) => graph.has(key)));
374
+ const deploy = await selectDeploy();
375
+ const selection = await select(published);
376
+ // The members are freezeGraph's member pins (freeze-graph --member-pins): its tooling rules (checked-out roots, editions
377
+ // root) apply unchanged, and a member that keeps its current resolution is pinned at its develop head.
378
+ const graphPins = freezeGraph({ dataSha, kernelSha, deploySha: deploy.deploySha, memberPins: selection.pins, resolveHead, holdSource });
379
+ // From current pins, no tooling root goes backwards or sideways (a first follow has nothing to compare against).
380
+ const tooling = {};
381
+ if (currentPins !== undefined) {
382
+ for (const key of [dataPinKey(), kernelPinKey(), deployPinKey()].sort(compareStrings)) {
383
+ tooling[key] = currentPins[key] === undefined ? ToolingRootVerdict.Added : classifyRoot({ key, followed: graphPins[key], current: currentPins[key] });
384
+ }
385
+ }
386
+ const declaredSources = declaredSourcesAt(graphPins);
387
+ const headed = freezeSources({ pins: graphPins, declaredSources, resolveHead, holdSource });
388
+ const pins = { ...headed };
389
+ const urlOf = new Map(declaredSources.map((source) => [source.name, source.url]));
390
+ for (const [key, head] of Object.entries(headed)) {
391
+ if (snapshotPinNamespace(key) !== SnapshotPinNamespace.Source || currentPins?.[key] === undefined) {continue;}
392
+ pins[key] = ancestry({ key, url: urlOf.get(key.slice('sources/'.length)), head, published: currentPins[key] });
393
+ }
394
+ const sorted = sortedSnapshotPins(pins);
395
+ return { pins: sorted, summary: { ...followSummary(currentPins, sorted, tooling), deploy } };
396
+ }
397
+
398
+ /** The files of --seals-out. */
399
+ export const FollowSealFile = Object.freeze({ Seals: 'seals.json', Offering: 'offering.images.lock.json', Customer: 'customer.images.lock.json' });
400
+
401
+ /**
402
+ * The customer's own previous seal, a follow input passed through: exactly one of `--previous-lock <file>
403
+ * --previous-lock-sha256 <hex>` (the bytes, held to the sha256; the id is held to the customer edition's by the resolve,
404
+ * which knows it) or `--first-seal` (null).
405
+ */
406
+ export function customerPreviousLockInput(options) {
407
+ const pair = [options['--previous-lock'], options['--previous-lock-sha256']];
408
+ const first = options['--first-seal'] === true;
409
+ if (first === pair.some((value) => value !== undefined) || (!first && pair.includes(undefined))) {
410
+ throw new Error('follow takes the customer edition\'s own previous seal as exactly one of --previous-lock <file> with --previous-lock-sha256 <hex>, or --first-seal');
411
+ }
412
+ return first ? null : { bytes: readPreviousLockBytes(pair[0], pair[1]), sha256: pair[1] };
413
+ }
414
+
415
+ /** seals.json: each frozen previous seal by file and sha256 (the customer's absent file is a first seal). */
416
+ export function followSealsDocument({ offeringId, offering, customer }) {
417
+ return {
418
+ offering: { distributionId: offeringId, edition: offering.edition, previousLock: FollowSealFile.Offering, previousLockSha256: offering.imagesLockSha256 },
419
+ customer: customer === null ? { firstSeal: true } : { previousLock: FollowSealFile.Customer, previousLockSha256: customer.sha256 },
420
+ };
421
+ }
422
+
423
+ /** Writes --seals-out once: a new directory holding the frozen previous-seal bytes and seals.json. */
424
+ export function writeFollowSeals(dir, { offeringId, offering, customer }) {
425
+ mkdirSync(dir);
426
+ writeFileSync(join(dir, FollowSealFile.Offering), offering.bytes, { flag: 'wx' });
427
+ if (customer !== null) {writeFileSync(join(dir, FollowSealFile.Customer), customer.bytes, { flag: 'wx' });}
428
+ writeFileSync(join(dir, FollowSealFile.Seals), serializeJson(followSealsDocument({ offeringId, offering, customer })), { flag: 'wx' });
429
+ }
430
+
431
+ /** The `follow` verb: writes `--out` and `--seals-out` once and prints the summary (it states a first follow). */
432
+ export async function runFollow({
433
+ options,
434
+ env = process.env,
435
+ members = developGraphMembers(),
436
+ runGit,
437
+ git,
438
+ rootGit,
439
+ deployCommits = deployCommitsAtOrBelow,
440
+ heads,
441
+ assertKernel = assertKernelCommitSupported,
442
+ findOfferingSeal = ({ repository, signerIdentity }) => previousSealFromRegistry({ repository, signerIdentity, cosignBinary: options['--cosign'], env }),
443
+ write = writeOnce,
444
+ print = (line) => console.log(line),
445
+ ...verifierInjections
446
+ }) {
447
+ const currentPins = followStartOf(options);
448
+ const customerSeal = customerPreviousLockInput(options);
449
+ if (existsSync(options['--seals-out'])) {throw new Error(`--seals-out ${options['--seals-out']} already exists: follow writes it once`);}
450
+ const definition = parseFollowDefinition(options[FOLLOW_DEFINITION_FLAG]);
451
+ if (!ENGINE_REPOSITORY_PATTERN.test(options['--engine-repository'] ?? '')) {
452
+ throw new Error(`--engine-repository is <registry>/<path> of the deploy engine, with no tag or digest; got ${JSON.stringify(options['--engine-repository'])}`);
453
+ }
454
+ const id = assertDefinitionCheckout({ definition, dataSha: options['--data-sha'], ...(git === undefined ? {} : { git }) });
455
+ assertKernelCheckout({ kernelSha: options['--kernel-sha'], ...(git === undefined ? {} : { git }) });
456
+ assertKernel({ repoDir: kernelRepoDir(), commit: options['--kernel-sha'], label: `--kernel-sha ${options['--kernel-sha']}` });
457
+ const workspace = editionWorkspaceConfig();
458
+ const { parseDistribution } = await loadKernelDistributionSchemas();
459
+ const { pins, summary } = await withRecordVerifier({ cosignBinary: options['--cosign'], env, ...verifierInjections }, (verifier) =>
460
+ followDevelop({
461
+ dataSha: options['--data-sha'],
462
+ kernelSha: options['--kernel-sha'],
463
+ selectDeploy: () => {
464
+ const engineRepository = options['--engine-repository'];
465
+ return selectPublishedEngineCommit({
466
+ commits: deployCommits(options['--deploy-sha']),
467
+ upperBound: options['--deploy-sha'],
468
+ engineRepository,
469
+ client: verifier.clientFor(splitImageRef(engineRepository).authority),
470
+ });
471
+ },
472
+ currentPins,
473
+ members,
474
+ select: (publishedPins) =>
475
+ selectRecords({ branch: DEVELOP_RECORD_BRANCH, registry: options['--registry'], members, ...verifier, publishedPins, workspace, env, runGit }),
476
+ ...(heads ?? headResolver(EditionChannel.Develop, env)),
477
+ declaredSourcesAt: (graphPins) => composeEdition(parseDistribution, id, snapshotPinSource(graphPins)).declaredSources,
478
+ ancestry: ({ key, url, head, published }) =>
479
+ followSourceAncestry({ key, url, head, published, newest: (request) => newestCommitOnDevelop({ ...request, workspace, env, ...(runGit === undefined ? {} : { runGit }) }) }),
480
+ classifyRoot: ({ key, followed, current }) =>
481
+ classifyToolingRoot({ key, dir: toolingRoots().find((root) => root.key === key).dir(), followed, current, ...(rootGit === undefined ? {} : { git: rootGit }) }),
482
+ }),
483
+ );
484
+ // The offering's newest signed develop edition, read once: the only "newest" a dev resolve depends on.
485
+ const offeringSeal = await findOfferingSeal({ repository: editionRepository(id, options['--registry'], EditionChannel.Develop), signerIdentity: options[PREVIOUS_SEAL_SIGNER_FLAG] });
486
+ write(options['--out'], pins);
487
+ writeFollowSeals(options['--seals-out'], { offeringId: id, offering: offeringSeal, customer: customerSeal });
488
+ const seals = followSealsDocument({ offeringId: id, offering: offeringSeal, customer: customerSeal });
489
+ print(JSON.stringify({ distributionId: id, definition, ...summary, seals }));
490
+ return { pins, summary, seals };
491
+ }
@@ -25,27 +25,31 @@ import { withSourceGitEnv } from './git-source-auth.mjs';
25
25
  import { RecordRefused } from './record-refused.mjs';
26
26
 
27
27
  const DEVELOP_REF = 'refs/heads/develop';
28
+ /** The tag a published pin carries in a RecordCommitNotOnDevelopError. */
29
+ export const PUBLISHED_PIN_TAG = 'published pin';
28
30
  // One external call to a git remote; a fetch that exceeds it fails naming the member, never a verdict.
29
31
  const GIT_CALL_TIMEOUT_MS = 300_000;
30
32
  const GIT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
31
33
 
32
34
  export class IncomparableRecordCommitsError extends RecordRefused {
33
- constructor(repo, first, second) {
34
- super(repo, `record commits ${first.commit} (${first.tags.join(', ')}) and ${second.commit} (${second.tags.join(', ')}) are not ancestor-related on develop`);
35
+ constructor(repo, first, second, context) {
36
+ super(repo, `record commits ${first.commit} (${first.tags.join(', ')}) and ${second.commit} (${second.tags.join(', ')}) are not ancestor-related on develop`, context);
35
37
  this.name = 'IncomparableRecordCommitsError';
36
38
  }
37
39
  }
38
40
 
39
41
  export class RecordCommitNotOnDevelopError extends RecordRefused {
40
- constructor(repo, commit, tags) {
41
- super(repo, `record commit ${commit} (${tags.join(', ')}) is not in the fetched develop history`);
42
+ constructor(repo, commit, tags, context) {
43
+ super(repo, `record commit ${commit} (${tags.join(', ')}) is not in the fetched develop history`, context);
42
44
  this.name = 'RecordCommitNotOnDevelopError';
45
+ this.commit = commit;
46
+ this.tags = tags;
43
47
  }
44
48
  }
45
49
 
46
50
  export class DivergedFromPublishedError extends RecordRefused {
47
- constructor(repo, selected, published) {
48
- super(repo, `the newest record commit ${selected} and the published commit ${published} are not ancestor-related on develop`);
51
+ constructor(repo, selected, published, context) {
52
+ super(repo, `the newest record commit ${selected} and the published commit ${published} are not ancestor-related on develop`, context);
49
53
  this.name = 'DivergedFromPublishedError';
50
54
  }
51
55
  }
@@ -75,10 +79,11 @@ export function defaultRunGit(args, { cwd, env }) {
75
79
  * @param {object} request.workspace the workspace config the source hold reads
76
80
  * @param {object} request.env
77
81
  * @param {Function} [request.runGit] (args, {cwd, env}) => {status, stdout, stderr}; replaced by tests
82
+ * @param {string} [request.context] the verb its errors name (`select-records` by default)
78
83
  * @returns {{commit: string, head: string, behind: number}} the selected commit, develop's head in the same fetch, and
79
84
  * how many commits the selected one is behind that head (0 when it is the head)
80
85
  */
81
- export function newestCommitOnDevelop({ member, candidates, published, workspace, env, runGit = defaultRunGit }) {
86
+ export function newestCommitOnDevelop({ member, candidates, published, workspace, env, runGit = defaultRunGit, context = 'select-records' }) {
82
87
  const byCommit = new Map();
83
88
  for (const { commit, tag } of candidates) {
84
89
  byCommit.set(commit, [...(byCommit.get(commit) ?? []), tag]);
@@ -89,28 +94,28 @@ export function newestCommitOnDevelop({ member, candidates, published, workspace
89
94
  return withSourceGitEnv({ name: member.repo, url: member.repositoryUrl }, { workspace, env }, (gitEnv) => {
90
95
  const git = (args, extraEnv = {}) => runGit(args, { cwd: scratch, env: { ...gitEnv, ...extraEnv } });
91
96
  const init = git(['init', '--bare', '--quiet']);
92
- if (init.status !== 0) {throw new Error(`select-records: ${member.repo}: git init failed: ${init.stderr.trim()}`);}
97
+ if (init.status !== 0) {throw new Error(`${context}: ${member.repo}: git init failed: ${init.stderr.trim()}`);}
93
98
  const fetched = git(['fetch', '--quiet', '--filter=blob:none', '--no-tags', member.repositoryUrl, `+${DEVELOP_REF}:${DEVELOP_REF}`]);
94
99
  if (fetched.status !== 0) {
95
- throw new Error(`select-records: ${member.repo}: git fetch of develop from ${member.repositoryUrl} failed (exit ${fetched.status}): ${fetched.stderr.trim()}`);
100
+ throw new Error(`${context}: ${member.repo}: git fetch of develop from ${member.repositoryUrl} failed (exit ${fetched.status}): ${fetched.stderr.trim()}`);
96
101
  }
97
102
  // 0 = ancestor (or equal), 1 = not; anything else is git failing, never a verdict
98
103
  const isAncestor = (older, newer) => {
99
104
  const result = git(['merge-base', '--is-ancestor', older, newer]);
100
105
  if (result.status === 0) {return true;}
101
106
  if (result.status === 1) {return false;}
102
- throw new Error(`select-records: ${member.repo}: git merge-base ${older} ${newer} failed (exit ${result.status}): ${result.stderr.trim()}`);
107
+ throw new Error(`${context}: ${member.repo}: git merge-base ${older} ${newer} failed (exit ${result.status}): ${result.stderr.trim()}`);
103
108
  };
104
109
  const assertOnDevelop = (commit, tags) => {
105
110
  // no lazy fetch: a commit the develop fetch did not bring is not on develop
106
111
  const known = git(['cat-file', '-e', `${commit}^{commit}`], { GIT_NO_LAZY_FETCH: '1' });
107
- if (known.status !== 0 || !isAncestor(commit, DEVELOP_REF)) {throw new RecordCommitNotOnDevelopError(member.repo, commit, tags);}
112
+ if (known.status !== 0 || !isAncestor(commit, DEVELOP_REF)) {throw new RecordCommitNotOnDevelopError(member.repo, commit, tags, context);}
108
113
  };
109
114
  for (const { commit, tags } of commits) {assertOnDevelop(commit, tags);}
110
115
  let selected = commits[0];
111
116
  for (const other of commits.slice(1)) {
112
117
  if (isAncestor(other.commit, selected.commit)) {continue;}
113
- if (!isAncestor(selected.commit, other.commit)) {throw new IncomparableRecordCommitsError(member.repo, selected, other);}
118
+ if (!isAncestor(selected.commit, other.commit)) {throw new IncomparableRecordCommitsError(member.repo, selected, other, context);}
114
119
  selected = other;
115
120
  }
116
121
  // develop's head and the chosen commit's distance from it, read in the same scratch the choice was made in
@@ -118,20 +123,20 @@ export function newestCommitOnDevelop({ member, candidates, published, workspace
118
123
  const headResult = git(['rev-parse', DEVELOP_REF]);
119
124
  const head = headResult.stdout.trim();
120
125
  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}`);
126
+ throw new Error(`${context}: ${member.repo}: git rev-parse ${DEVELOP_REF} failed (exit ${headResult.status}): ${headResult.stderr.trim() || head}`);
122
127
  }
123
128
  const counted = git(['rev-list', '--count', `${commit}..${DEVELOP_REF}`]);
124
129
  const behind = Number(counted.stdout.trim());
125
130
  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()}`);
131
+ throw new Error(`${context}: ${member.repo}: git rev-list --count ${commit}..${DEVELOP_REF} failed (exit ${counted.status}): ${counted.stderr.trim() || counted.stdout.trim()}`);
127
132
  }
128
133
  return { commit, head, behind };
129
134
  };
130
135
  if (published === undefined) {return withDistance(selected.commit);}
131
- assertOnDevelop(published, ['published pin']);
136
+ assertOnDevelop(published, [PUBLISHED_PIN_TAG]);
132
137
  if (isAncestor(selected.commit, published)) {return withDistance(published);} // equal, or the records are behind it
133
138
  if (isAncestor(published, selected.commit)) {return withDistance(selected.commit);}
134
- throw new DivergedFromPublishedError(member.repo, selected.commit, published);
139
+ throw new DivergedFromPublishedError(member.repo, selected.commit, published, context);
135
140
  });
136
141
  } finally {
137
142
  rmSync(scratch, { recursive: true, force: true });
@@ -17,14 +17,20 @@ export function parseSelectionPins(document, path, flag, members) {
17
17
  if (pins === null || typeof pins !== 'object' || Array.isArray(pins)) {
18
18
  throw new Error(`${flag} ${path} has no "pins" object`);
19
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
- }
20
+ assertCommitPins(pins, `${flag} ${path}`);
25
21
  if (members !== undefined) {
26
22
  const problem = currentResolutionProblem(document.currentResolution, members);
27
23
  if (problem !== null) {throw new Error(`${flag} ${path}: ${problem}`);}
28
24
  }
29
25
  return pins;
30
26
  }
27
+
28
+ /** A pin map's every value is a 40-hex commit (a selection's pins, follow's --current-pins); returns it. `label` names the input. */
29
+ export function assertCommitPins(pins, label) {
30
+ for (const [key, commit] of Object.entries(pins)) {
31
+ if (typeof commit !== 'string' || !/^[0-9a-f]{40}$/.test(commit)) {
32
+ throw new Error(`${label}: pin ${JSON.stringify(key)} is not a 40-hex commit: ${JSON.stringify(commit)}`);
33
+ }
34
+ }
35
+ return pins;
36
+ }
@@ -829,6 +829,8 @@ export function buildImageDeliveryEntitlementStatement({
829
829
  lockDigest: schemas.distributionLockDigest(distributionLock),
830
830
  // Present-and-nullable: null states the lock carries no catalogue; the lock digest binds the lock either way.
831
831
  catalogueDigest: distributionLock.catalogueDigest ?? null,
832
+ // The field the lock carries, never both: a catalogue-free lock's catalogueContentDigest.
833
+ ...(distributionLock.catalogueContentDigest === undefined ? {} : { catalogueContentDigest: distributionLock.catalogueContentDigest }),
832
834
  distributionStatementDigest: completenessStatementDigest,
833
835
  mode,
834
836
  targetRegistry,