@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
@@ -1,6 +1,6 @@
1
1
  // develop-edition-resolve — resolve and seal one DEVELOP edition from a snapshot.
2
2
  //
3
- // The whole pipeline, in the order the release runs it, over the snapshot's
3
+ // The whole pipeline, in the order the release runs it (every check reports its refusal; see develop-resolve-report.mjs), over the snapshot's
4
4
  // injected inputs: distribution lock -> plane invariants (reservation table,
5
5
  // confinement; develop-edition-invariants.mjs) -> authorization base (composed, never
6
6
  // written) -> images lock -> develop seal, plus the chart-set lock: the frozen
@@ -12,15 +12,21 @@ import { biomeSourceTreeIds, readBiomeSourceFacts, sealLock } from './biome-cont
12
12
  import { buildCatalogue } from './catalogue.mjs';
13
13
  import { resolveDevelopChartSet } from './develop-chart-set.mjs';
14
14
  import { assertDevelopEditionInvariants } from './develop-edition-invariants.mjs';
15
+ import { resolveDevelopEngineImage } from './develop-engine-image.mjs';
15
16
  import { createDevelopImageProbe } from './develop-image-probe.mjs';
17
+ import { BLOCKED, DevelopResolveCheck, RESOLVE_CHECK_DEPENDENCIES, createResolveReport } from './develop-resolve-report.mjs';
16
18
  import { assertFrozenCheckouts, buildSnapshotInputs } from './develop-snapshot-inputs.mjs';
17
19
  import { EditionChannel } from './edition-constants.mjs';
18
20
  import { sealImages } from './edition-image-seal.mjs';
21
+ import { assertKernelEditionProtocol } from './edition-protocol.mjs';
19
22
  import { dataRoot, kernelPackageDir } from './edition-workspace.mjs';
23
+ import { resolveImagesFromRecord, sealImagesFromRecord } from './images-from-record.mjs';
20
24
  import { kernelPackageOfAuthority } from './kernel-package.mjs';
21
25
  import { loadKernelDistributionSchemas } from './lib.mjs';
26
+ import { assertLockBundleSigners, isBundleSignerRefusal } from './locked-bundle-signer.mjs';
27
+ import { admitDistributionLock, ungrantedOptionalLinesOfRefusal } from './operation-grant-admission.mjs';
22
28
  import { deployPinKey } from './pin-source.mjs';
23
- import { composeAuthorizationBase, resolveDistributionWithIndex } from './resolve-distribution.mjs';
29
+ import { admitResolvedLock, composeAuthorizationBase, prepareDistributionWithIndex } from './resolve-distribution.mjs';
24
30
  import { externalSourceTrees, resolveImages, sourceTreeLocator } from './resolve-images.mjs';
25
31
  import { createPinnedSourceAuthority } from './source-authority.mjs';
26
32
  import { loadTrustedThirdPartySourcePins } from './third-party-pins-trusted.mjs';
@@ -32,12 +38,15 @@ import { loadTrustedThirdPartySourcePins } from './third-party-pins-trusted.mjs'
32
38
  export const DEVELOP_EDITION_STEPS = Object.freeze({
33
39
  assertFrozenCheckouts,
34
40
  buildSnapshotInputs,
35
- resolveDistributionWithIndex,
41
+ assertKernelEditionProtocol,
42
+ prepareDistributionWithIndex,
43
+ resolveDevelopEngineImage,
36
44
  assertDevelopEditionInvariants,
37
45
  assertAdapterBundlesMatchHost,
38
46
  composeAuthorizationBase,
39
47
  resolveImages,
40
48
  sealImages,
49
+ sealImagesFromRecord,
41
50
  resolveDevelopChartSet,
42
51
  });
43
52
 
@@ -45,46 +54,118 @@ const stderrProgress = (line) => process.stderr.write(`${line}\n`);
45
54
 
46
55
  /**
47
56
  * The first half of every develop resolve: the frozen snapshot's inputs and the
48
- * distribution lock composed over them. `resolveDevelopEdition` and the
49
- * read-only `coverage` verb both start here, so the content hash each computes
50
- * for a subject is the same by construction.
57
+ * distribution lock composed over them, NOT yet admitted. `resolveDevelopEdition`
58
+ * admits it as one of its checks; `resolveDevelopSnapshot` admits it at once. Both
59
+ * start here, so the content hash each computes for a subject is the same by construction.
51
60
  */
52
- export async function resolveDevelopSnapshot({ id, pins, steps = DEVELOP_EDITION_STEPS, phase = () => {} }) {
61
+ async function prepareDevelopSnapshot({ id, pins, steps, phase }) {
53
62
  const schemas = await loadKernelDistributionSchemas();
54
63
  steps.assertFrozenCheckouts(pins);
55
64
  const inputs = await steps.buildSnapshotInputs({ id, pins, schemas });
56
65
  phase('snapshot inputs built');
57
- const { lock, index } = await steps.resolveDistributionWithIndex(id, { inputs });
58
- return { schemas, inputs, lock, index };
66
+ const prepared = await steps.prepareDistributionWithIndex(id, { inputs });
67
+ // The schemas the lock was composed with are the ones that admit it.
68
+ return { inputs, ...prepared };
69
+ }
70
+
71
+ /** The read-only `coverage` verb's snapshot: prepared as the resolve prepares it, then admitted (a refusal throws). */
72
+ export async function resolveDevelopSnapshot({ id, pins, steps = DEVELOP_EDITION_STEPS, phase = () => {} }) {
73
+ const snapshot = await prepareDevelopSnapshot({ id, pins, steps, phase });
74
+ admitResolvedLock(snapshot.schemas, snapshot.lock);
75
+ return snapshot;
59
76
  }
60
77
 
61
- export async function resolveDevelopEdition({ id, pins, env = process.env, sealOptions = {}, fetchKernelTarball, fetchManifest = fetchRegistryManifest, progress = stderrProgress, steps = DEVELOP_EDITION_STEPS }) {
78
+ /**
79
+ * @param {object} request
80
+ * @param {object} [request.imagesFrom] a parsed image build record (images-from-record.mjs): the digests are the record's,
81
+ * with no probe and no rehash, and `verifySatellites` (createSatelliteVerifier) admits its satellite entries
82
+ * @param {object|null} [request.previousLock] the previous edition's parsed images lock: its composed artifacts carry their generation forward (null: generation 1)
83
+ */
84
+ export async function resolveDevelopEdition({ id, pins, engine, cosignBinary, env = process.env, sealOptions = {}, fetchKernelTarball, fetchManifest = fetchRegistryManifest, progress = stderrProgress, steps = DEVELOP_EDITION_STEPS, imagesFrom, verifySatellites, previousLock = null }) {
85
+ if ((imagesFrom === undefined) !== (verifySatellites === undefined)) {
86
+ throw new Error('resolveDevelopEdition takes an image build record and its satellite verifier together, or neither');
87
+ }
62
88
  const startedAt = Date.now();
63
89
  // One line per phase, with the time since the resolve began, so the step is never silent for long.
64
90
  const phase = (name) => progress(`[develop-edition] ${name} (+${Date.now() - startedAt}ms)`);
65
- const { schemas, inputs, lock, index } = await resolveDevelopSnapshot({ id, pins, steps, phase });
91
+ // Every check runs unless a check it needs was refused; all refusals are reported together, and nothing is sealed or written then.
92
+ const report = createResolveReport();
93
+ const checked = (check, fn, options) => report.run(check, RESOLVE_CHECK_DEPENDENCIES[check], fn, options);
94
+ const chartSetLock = await checked(DevelopResolveCheck.ChartSet, async () => {
95
+ const chartSet = await steps.resolveDevelopChartSet({ xemaDeploySha: pins[deployPinKey()] });
96
+ phase('chart set resolved');
97
+ return chartSet;
98
+ });
99
+ const engineImage = await checked(DevelopResolveCheck.EngineImage, async () => {
100
+ const image = await steps.resolveDevelopEngineImage({ pins, engine, cosignBinary, env });
101
+ phase('engine image resolved');
102
+ return image;
103
+ });
104
+ await checked(DevelopResolveCheck.EditionProtocol, () => steps.assertKernelEditionProtocol(kernelPackageDir()));
105
+ const snapshot = await checked(DevelopResolveCheck.DistributionLock, () => prepareDevelopSnapshot({ id, pins, steps, phase }), {
106
+ classify: (error) => (isBundleSignerRefusal(error) ? DevelopResolveCheck.BundleSigner : DevelopResolveCheck.DistributionLock),
107
+ });
108
+ const lockProduced = snapshot !== BLOCKED;
109
+ const { schemas, inputs, lock, index } = lockProduced ? snapshot : {};
110
+ if (lockProduced) {
111
+ await checked(DevelopResolveCheck.BundleSigner, () => assertLockBundleSigners({ lock, schemas }));
112
+ }
66
113
  // The plane invariants, against the graph this candidate froze (develop-edition-invariants.mjs).
67
- const graded = steps.assertDevelopEditionInvariants({ id, lock, biomeIndex: inputs.biomeIndex, schemas });
68
- phase(`reservation table asserted (${graded.reservedDomains} domains); confinement asserted (${graded.confinedBiomes} declaration(s))`);
69
- await steps.assertAdapterBundlesMatchHost({ lock, hostRow: adapterHostRow(index), fetchManifest });
70
- const authorizationLock = await steps.composeAuthorizationBase(id, lock, index);
71
- phase('distribution lock resolved');
114
+ await checked(DevelopResolveCheck.PlaneInvariants, () => {
115
+ const graded = steps.assertDevelopEditionInvariants({ id, lock, biomeIndex: inputs.biomeIndex, schemas });
116
+ phase(`reservation table asserted (${graded.reservedDomains} domains); confinement asserted (${graded.confinedBiomes} declaration(s))`);
117
+ });
118
+ await checked(DevelopResolveCheck.OperationAdmission, () => {
119
+ try {
120
+ admitDistributionLock(schemas, lock, { report: (line) => report.notice(line) });
121
+ } catch (error) {
122
+ ungrantedOptionalLinesOfRefusal(error).forEach((line) => report.notice(line));
123
+ throw error;
124
+ }
125
+ phase('operation requirements admitted');
126
+ });
127
+ await checked(DevelopResolveCheck.AdapterBundles, () => steps.assertAdapterBundlesMatchHost({ lock, hostRow: adapterHostRow(index), fetchManifest }));
128
+ const authorizationLock = await checked(DevelopResolveCheck.AuthorizationBase, async () => {
129
+ const composed = await steps.composeAuthorizationBase(id, lock, index);
130
+ phase('distribution lock resolved');
131
+ return composed;
132
+ });
72
133
  // Every subject's develop image is probed the moment its hash is known, in
73
134
  // parallel with the hashing of the rest; an absent image refuses here, naming
74
135
  // all of them, before any later phase runs.
75
- const probe = createDevelopImageProbe({
76
- distributionId: id,
77
- env,
78
- ...(sealOptions.distributionsRoot === undefined ? {} : { distributionsRoot: sealOptions.distributionsRoot }),
79
- ...(sealOptions.fetchImpl === undefined ? {} : { fetchImpl: sealOptions.fetchImpl }),
80
- ...(sealOptions.run === undefined ? {} : { run: sealOptions.run }),
81
- progress,
136
+ let recorded;
137
+ const imageLock = await checked(DevelopResolveCheck.DevelopImages, async () => {
138
+ if (imagesFrom !== undefined) {
139
+ const fromRecord = await resolveImagesFromRecord({ id, inputs, lock, record: imagesFrom, resolveImages: steps.resolveImages, verifySatellites, previousLock });
140
+ ({ recorded } = fromRecord);
141
+ phase(`image subjects taken from the build record (${fromRecord.imageLock.subjects.length} subjects); satellites verified`);
142
+ return fromRecord.imageLock;
143
+ }
144
+ const probe = createDevelopImageProbe({
145
+ distributionId: id,
146
+ env,
147
+ ...(sealOptions.distributionsRoot === undefined ? {} : { distributionsRoot: sealOptions.distributionsRoot }),
148
+ ...(sealOptions.fetchImpl === undefined ? {} : { fetchImpl: sealOptions.fetchImpl }),
149
+ ...(sealOptions.run === undefined ? {} : { run: sealOptions.run }),
150
+ progress,
151
+ });
152
+ const resolved = await steps.resolveImages(id, { inputs, lock, onSubjectHashed: probe.observe, previousLock });
153
+ phase(`image content hashes computed (${resolved.imageLock.subjects.length} subjects)`);
154
+ await probe.settle();
155
+ phase('every develop image is present');
156
+ return resolved.imageLock;
82
157
  });
83
- const { imageLock } = await steps.resolveImages(id, { inputs, lock, onSubjectHashed: probe.observe });
84
- phase(`image content hashes computed (${imageLock.subjects.length} subjects)`);
85
- await probe.settle();
86
- phase('every develop image is present');
87
- const sealedImageLock = await steps.sealImages({ channel: EditionChannel.Develop, imageLock, env, progress, thirdPartyPins: loadTrustedThirdPartySourcePins(dataRoot()), ...sealOptions });
158
+ try {
159
+ report.settle();
160
+ } catch (error) {
161
+ progress(error.message);
162
+ throw error;
163
+ }
164
+ if (report.noticeText() !== '') {progress(report.noticeText());}
165
+ const sealArguments = { imageLock, env, progress, thirdPartyPins: loadTrustedThirdPartySourcePins(dataRoot()), ...sealOptions };
166
+ const sealedImageLock = recorded === undefined
167
+ ? await steps.sealImages({ channel: EditionChannel.Develop, ...sealArguments })
168
+ : await steps.sealImagesFromRecord({ ...sealArguments, recorded });
88
169
  phase('images sealed');
89
170
  // The unsealed lock becomes the one v5 grammar only here, once the images it
90
171
  // owns are sealed: each biomeContentHash is recomputed from the frozen sources.
@@ -100,8 +181,6 @@ export async function resolveDevelopEdition({ id, pins, env = process.env, sealO
100
181
  phase(`biome sources read (${sourceFactsById.size} biomes)`);
101
182
  const sealedLock = await sealLock({ ...biomeSources, unsealed: lock, sourceFactsById });
102
183
  phase('distribution lock sealed');
103
- const chartSetLock = await steps.resolveDevelopChartSet({ xemaDeploySha: pins[deployPinKey()] });
104
- phase('chart set resolved');
105
184
  const sourceTreeIds = await biomeSourceTreeIds({ ...biomeSources, lock, sourceFactsById });
106
185
  const catalogue = buildCatalogue({
107
186
  sourceTreeIds,
@@ -121,5 +200,5 @@ export async function resolveDevelopEdition({ id, pins, env = process.env, sealO
121
200
  leaf: inputs.composed.leaf,
122
201
  });
123
202
  phase('catalogue built');
124
- return { distributionLock: sealedLock, imageLock: sealedImageLock, authorizationLock, chartSetLock, catalogue };
203
+ return { distributionLock: sealedLock, imageLock: sealedImageLock, authorizationLock, chartSetLock, catalogue, engineImage };
125
204
  }
@@ -2,9 +2,16 @@
2
2
  // develop-edition — the develop edition's command line.
3
3
  //
4
4
  // resolve <id> --pins <file> --out <dir> --registry <root>
5
- // --engine-image <repository>@sha256:<digest> [--local]
5
+ // --engine-repository <repository> --engine-signer <certificate identity> --cosign <absolute-path> [--local]
6
+ // [--images-from <image build record>]
6
7
  // resolve the edition from the snapshot, seal it against the develop image
7
8
  // repositories, write the descriptor and the four locks to <dir>, print the target.
9
+ // The engine is found and proven here (see `engine`), and every refusal of the run is reported
10
+ // in one table (develop-resolve-report.mjs): independent checks all run, a dependent one is
11
+ // reported `blocked by <check>`, and when anything is refused nothing is sealed or written.
12
+ // --images-from takes the digests from the everyday job's image build record instead of the registry probe:
13
+ // no probe and no rehash; satellite entries are cosign-verified against their producer's develop build
14
+ // records, and a subject missing, duplicated or extra in the record refuses (images-from-record.mjs).
8
15
  // The workflow run is recorded from GITHUB_REPOSITORY / GITHUB_RUN_ID;
9
16
  // --local stamps `local` instead, for a resolve outside GitHub Actions.
10
17
  // resolve --definition <file> --pins <file> --out <dir> --registry <root>
@@ -16,10 +23,17 @@
16
23
  // declares (`freeze-sources --definition` writes it); the catalogue digest is frozen into the descriptor's
17
24
  // provenance pins as `catalogue`. The id is the definition's. Takes --data-root, --editions-root,
18
25
  // --sources-root, --cache-dir (no --deploy-root, no --kernel: the kernel is the catalogue's, from npm).
19
- // target <id> --pins <file> --registry <root> --engine-image <repository>@sha256:<digest>
26
+ // engine <id> --pins <file> --engine-repository <repository> --engine-signer <certificate identity> --cosign <absolute-path>
27
+ // print <repository>@sha256:<digest>: the engine xema-deploy CI published (tag sha-<12 hex>) for the
28
+ // snapshot's xema-deploy commit, its signature verified offline against the pinned trust root as made by
29
+ // --engine-signer with the signed annotations commit=<that commit> and branch=develop
30
+ // (develop-engine-image.mjs). The image `target` and `check-existing` take as --engine-image.
31
+ // target <id> --pins <file> --registry <root> --engine-image <repository>@sha256:<digest> [--images-from <image build record>]
20
32
  // print <root>/<develop segment>/xema-edition-<id>:<tag>, the tag being sha256 of the canonical
21
33
  // { cli, engine, pins, sourceImages } (snapshotEditionTag): every input of the bytes the artifact records,
22
- // so the same pins with another engine, source-image map or CLI version are another artifact. The root is always the caller's
34
+ // so the same pins with another engine, source-image map or CLI version are another artifact. With
35
+ // --images-from the record's digest (imageBuildRecordDigest) is one more input, named imageBuildRecord; resolve
36
+ // --images-from prints the same target. The root is always the caller's
23
37
  // registry root (e.g. registry.example.com/acme); the develop namespace segment and the repository
24
38
  // prefix come from edition-contract.json, and no root is implied.
25
39
  // target --definition <file> --pins <file> --registry <root> --engine-image <repository>@sha256:<digest>
@@ -27,10 +41,14 @@
27
41
  // the same for a customer edition (id = the definition's; the tag covers the pinned catalogue digest).
28
42
  // freeze-graph --data-sha <sha> --kernel-sha <sha>
29
43
  // --deploy-sha <sha> [--editions-root-url <https url>]
30
- // [--overrides <json>] --out <file>
44
+ // [--overrides <json> | --member-pins <selection file>] --out <file>
31
45
  // resolve refs/heads/develop once per develop-graph repository (the data root's
32
46
  // firstPartySources, at each declared URL) and the editions root; the three checked-out
33
- // repositories are pinned at what the workflow checked out; write the pins. --kernel-sha is refused
47
+ // repositories are pinned at what the workflow checked out; write the pins. --member-pins takes a select-records
48
+ // selection (only .pins, 40-hex, is read) and pins each graph member at its selected record commit instead of its
49
+ // develop head: the selection must pin exactly the graph minus its currentResolution members (which must equal
50
+ // the graph members' declared currentResolution), a missing or extra member is refused by name, and a currentResolution
51
+ // member is pinned at its develop head. --member-pins together with --overrides is refused. --kernel-sha is refused
34
52
  // unless its declared edition protocol is one this CLI supports (the check choose-kernel applies to a pin).
35
53
  // check-existing <id> --pins <file> --artifact <dir> --engine-image <repository>@sha256:<digest>
36
54
  // refuse to reuse a pulled artifact whose descriptor disagrees with this snapshot.
@@ -57,7 +75,7 @@
57
75
  // for every develop-graph member, take the newest build record the data root's record workflow signed for
58
76
  // the branch (<root>/build-records/<name>, verified offline by digest against the pinned trusted root, as
59
77
  // signed by <data root>/.github/workflows/record-build.yaml at refs/heads/<branch>, called by the member at the
60
- // record's own commit) and write { pins, snapshot, images }, once (wx). Any missing, unsigned, mismatched or
78
+ // record's own commit) and write { pins, snapshot, behind, images, currentResolution }, once (wx); a member that keeps its current resolution (a declared currentResolution, develop-current-resolution.mjs) is skipped with no registry read, listed in currentResolution, and printed as `xema-docs: current resolution (leaves the edition, XEMA-2373)`; behind is { <repo>: { head, commits } }, develop's head and how many commits the selected one trails it by, and one line per member `<short name>: record <commit>, <N> commits behind HEAD` goes to stdout (always `commits`; short name = the repo's last path segment, refused when two members share one); images[] entries carry `member`, the owning repo handle. Any missing, unsigned, mismatched or
61
79
  // unreadable record refuses the whole run and writes nothing: a newer bad record is never skipped for an older
62
80
  // one. --at takes the newest record completed at or before that time. Only `develop` has a declared signer; the
63
81
  // data root must declare dataRootRepositoryUrl. Takes the roots `resolve` takes (no registry root is implied).
@@ -69,6 +87,14 @@
69
87
  // part 07 passes the flag.
70
88
  // Residual: anyone who can delete registry tags can remove newer records, and this then pins the newest remaining
71
89
  // valid one; the guard is part 07's, deploy-latest's owner refuses a snapshot that goes backwards.
90
+ // image-record <id> --selection <file> --pins <file> --out <file>
91
+ // write the edition's image build record (the file `resolve --images-from` reads) from a select-records selection:
92
+ // the subjects are the checked-out snapshot's first-party image subjects (enumerated as `coverage` does). A subject of
93
+ // a member that keeps its current resolution (a declared currentResolution, develop-current-resolution.mjs) is probed as resolve probes it
94
+ // and goes in `images`; every other first-party subject takes the selection's image of its develop repository, whose
95
+ // selected commit must be the snapshot's, and goes in `satellites`. Platforms are read from the registry by digest.
96
+ // Any subject without an image, any commit mismatch refuses the whole run and writes nothing, once (wx). Third-party
97
+ // subjects are ignored. Takes the roots `resolve` takes.
72
98
  // choose-kernel [--kernel-pin <40-hex>] --out <file>
73
99
  // choose the develop kernel commit in the --kernel checkout (full history; its refs/remotes/origin/develop is the develop head):
74
100
  // the newest first-parent commit whose declared edition protocol this CLI supports
@@ -92,22 +118,28 @@
92
118
  import { resolve } from 'node:path';
93
119
 
94
120
  import { writeCatalogue } from './catalogue.mjs';
95
- import { CUSTOMER_ROOTS, headResolver, parseEditionVerbArgs, runCustomerVerb, writeOnce } from './customer-edition-verbs.mjs';
121
+ import { CUSTOMER_ROOTS, FIRST_SEAL_FLAG, IMAGES_FROM_FLAG, PREVIOUS_LOCK_FLAG, PREVIOUS_LOCK_SHA256_FLAG, headResolver, parseEditionVerbArgs, runCustomerVerb, writeOnce } from './customer-edition-verbs.mjs';
96
122
  import { DEFINITION_FLAG } from './customer-flags.mjs';
97
123
  import { runDevelopCoverage } from './develop-coverage-run.mjs';
98
124
  import { CoverageExit } from './develop-coverage.mjs';
99
125
  import { resolveDevelopEdition } from './develop-edition-resolve.mjs';
126
+ import { resolveDevelopEngineImage } from './develop-engine-image.mjs';
100
127
  import { assertNoToolingOverride, freezeGraph, freezeSources } from './develop-freeze.mjs';
128
+ import { developGraphMembers } from './develop-graph.mjs';
129
+ import { runImageRecord } from './develop-image-record.mjs';
101
130
  import { runSelectRecords } from './develop-select-records.mjs';
131
+ import { readSelectionPins } from './develop-selection-pins.mjs';
102
132
  import { runVerifyImageRecord } from './develop-verify-image-record.mjs';
103
133
  import { LOCAL_WORKFLOW, assertExistingArtifactMatches, developEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
104
134
  import { EditionChannel, EditionKind } from './edition-constants.mjs';
105
135
  import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, kernelRepoDir } from './edition-workspace.mjs';
106
136
  import { descriptorEvidenceTrust } from './evidence-trust-policy.mjs';
137
+ import { createSatelliteVerifier, imageBuildRecordDigest, readImageBuildRecord } from './images-from-record.mjs';
107
138
  import { assertKernelCommitSupported, chooseKernelCommit } from './kernel-protocol-choice.mjs';
108
139
  import { loadKernelDistributionSchemas } from './lib.mjs';
109
140
  import { snapshotPinSource } from './pin-source.mjs';
110
141
  import { composeEdition } from './resolve-distribution.mjs';
142
+ import { resolvePreviousSeal } from './resolve-images.mjs';
111
143
  import { readStrictJsonFile } from './strict-json.mjs';
112
144
 
113
145
  // The roots a verb takes. A customer form (it names --definition) takes fewer, and never the kernel (the
@@ -150,21 +182,37 @@ function parseOverrides(text) {
150
182
  return parsed;
151
183
  }
152
184
 
185
+ /** The target-tag input of `--images-from`: the record's digest, or nothing on the probe path. */
186
+ async function imageBuildRecordTagInput(options) {
187
+ return options[IMAGES_FROM_FLAG] === undefined ? {} : { imageBuildRecordDigest: imageBuildRecordDigest(await readImageBuildRecord(options[IMAGES_FROM_FLAG])) };
188
+ }
189
+
153
190
  async function run(argv, env) {
154
191
  const { command, id, options, customer } = parseDevelopEditionArgs(argv);
155
192
  switch (command) {
156
193
  case 'target': {
157
194
  if (customer) {return runCustomerVerb({ channel: EditionChannel.Develop, command, options, env });}
158
- console.log(developEditionTarget(id, readStrictJsonFile(options['--pins']), { registry: options['--registry'], engineImage: options['--engine-image'] }));
195
+ console.log(developEditionTarget(id, readStrictJsonFile(options['--pins']), { registry: options['--registry'], engineImage: options['--engine-image'], ...(await imageBuildRecordTagInput(options)) }));
159
196
  return;
160
197
  }
161
198
  case 'resolve': {
162
199
  if (customer) {return runCustomerVerb({ channel: EditionChannel.Develop, command, options, env });}
163
200
  const pins = readStrictJsonFile(options['--pins']);
164
201
  const workflow = options['--local'] ? LOCAL_WORKFLOW : workflowFromEnv(env);
165
- const target = developEditionTarget(id, pins, { registry: options['--registry'], engineImage: options['--engine-image'] });
166
202
  const evidenceTrustPolicy = await descriptorEvidenceTrust({ kind: EditionKind.Resolved, channel: EditionChannel.Develop });
167
- const { catalogue, ...resolved } = await resolveDevelopEdition({ id, pins, env });
203
+ const { catalogue, engineImage, ...resolved } = await resolveDevelopEdition({
204
+ id,
205
+ pins,
206
+ env,
207
+ engine: { repository: options['--engine-repository'], signer: options['--engine-signer'] },
208
+ cosignBinary: options['--cosign'],
209
+ previousLock: await resolvePreviousSeal({ distributionId: id, previousLockPath: options[PREVIOUS_LOCK_FLAG], previousLockSha256: options[PREVIOUS_LOCK_SHA256_FLAG], firstSeal: Boolean(options[FIRST_SEAL_FLAG]) }),
210
+ ...(options[IMAGES_FROM_FLAG] === undefined ? {} : {
211
+ imagesFrom: await readImageBuildRecord(options[IMAGES_FROM_FLAG]),
212
+ verifySatellites: createSatelliteVerifier({ registry: options['--registry'], cosignBinary: options['--cosign'], env }),
213
+ }),
214
+ });
215
+ const target = developEditionTarget(id, pins, { registry: options['--registry'], engineImage, ...(await imageBuildRecordTagInput(options)) });
168
216
  const outDir = resolve(options['--out']);
169
217
  writeEditionArtifact({
170
218
  outDir,
@@ -173,13 +221,23 @@ async function run(argv, env) {
173
221
  pins,
174
222
  workflow,
175
223
  evidenceTrustPolicy,
176
- engineImage: options['--engine-image'],
224
+ engineImage,
177
225
  ...resolved,
178
226
  });
179
227
  writeCatalogue(outDir, catalogue);
180
228
  console.log(target);
181
229
  return;
182
230
  }
231
+ case 'engine':
232
+ console.log(
233
+ await resolveDevelopEngineImage({
234
+ pins: readStrictJsonFile(options['--pins']),
235
+ engine: { repository: options['--engine-repository'], signer: options['--engine-signer'] },
236
+ cosignBinary: options['--cosign'],
237
+ env,
238
+ }),
239
+ );
240
+ return;
183
241
  case 'coverage':
184
242
  return runDevelopCoverage({
185
243
  id,
@@ -191,6 +249,9 @@ async function run(argv, env) {
191
249
  case 'select-records':
192
250
  await runSelectRecords({ options, env });
193
251
  return;
252
+ case 'image-record':
253
+ await runImageRecord({ id, options, env });
254
+ return;
194
255
  case 'verify-image-record':
195
256
  await runVerifyImageRecord({ options, env });
196
257
  return;
@@ -224,6 +285,7 @@ async function run(argv, env) {
224
285
  deploySha: options['--deploy-sha'],
225
286
  editionsRootUrl: options['--editions-root-url'],
226
287
  overrides: parseOverrides(options['--overrides']),
288
+ ...(options['--member-pins'] === undefined ? {} : { memberPins: readSelectionPins(options['--member-pins'], '--member-pins', developGraphMembers()) }),
227
289
  ...headResolver(EditionChannel.Develop, env),
228
290
  });
229
291
  writeOnce(options['--out'], pins);
@@ -0,0 +1,95 @@
1
+ // develop-engine-image — the engine image a develop edition is deployed with, found and proven.
2
+ //
3
+ // The edition's engine is the one the deploy repository's CI published for EXACTLY the deploy commit the snapshot pins
4
+ // (tag `sha-<12 hex>`, keyless signature by a `workflow_run` follower of a green push CI). The tag only finds the digest.
5
+ // The signature is checked offline against the pinned trust root through the CLI's one cosign path (pinned-cosign.mjs)
6
+ // with the publisher's EXACT identity, issuer, workflow repository and ref (cosign-certificate-constraints.mjs); never a
7
+ // pattern. A follower runs the default branch's copy of the workflow, so the certificate's own workflow commit is not the
8
+ // built one: the built commit is bound by the signed annotations commit=<40 hex> and branch=develop.
9
+ // The digest is what the edition records.
10
+
11
+ import { RefusalError } from './collected-refusals.mjs';
12
+ import { certificateIdentityArgs } from './cosign-certificate-constraints.mjs';
13
+ import { assertEngineImage } from './edition-artifact.mjs';
14
+ import { assertPublisher, publisherIdentity } from './edition-publisher.mjs';
15
+ import { deployPinKey } from './pin-source.mjs';
16
+ import { cosignEnvironment, withPinnedCosign } from './pinned-cosign.mjs';
17
+ import { splitImageRef } from './registry-credentials.mjs';
18
+ import { isNotPublishedYet } from './seal-image-digests.mjs';
19
+ import { GITHUB_ACTIONS_OIDC_ISSUER } from './third-party-source-evidence.mjs';
20
+
21
+ const COMMIT_PATTERN = /^[a-f0-9]{40}$/;
22
+ const TAG_PREFIX = 'sha-';
23
+ const TAG_COMMIT_LENGTH = 12;
24
+ const DEVELOP_BRANCH = 'develop';
25
+ const STDERR_TAIL_BYTES = 800;
26
+
27
+ /** The tag xema-deploy CI gives the engine it publishes for `deploySha`. */
28
+ export const engineTagOf = (deploySha) => `${TAG_PREFIX}${deploySha.slice(0, TAG_COMMIT_LENGTH)}`;
29
+
30
+ const IDENTITY_PATTERN = /^https:\/\/github\.com\/([^/@]+\/[^/@]+)\/(\.github\/workflows\/[^/@]+)@(.+)$/;
31
+
32
+ /**
33
+ * The signer an exact certificate identity `https://github.com/<owner>/<name>/.github/workflows/<file>@<ref>` names, held to
34
+ * the publisher grammar of edition-contract.json (so no pattern, no wildcard).
35
+ */
36
+ export function engineSignerOf(identity) {
37
+ const match = typeof identity === 'string' ? IDENTITY_PATTERN.exec(identity) : null;
38
+ if (match === null) {
39
+ throw new RefusalError(`--engine-signer must be the exact certificate identity https://github.com/<owner>/<name>/.github/workflows/<file>@<ref>; got ${JSON.stringify(identity)}`);
40
+ }
41
+ const publisher = assertPublisher({ repository: match[1], workflow: match[2], ref: match[3] }, '--engine-signer');
42
+ return { certificateIdentity: publisherIdentity(publisher), oidcIssuer: GITHUB_ACTIONS_OIDC_ISSUER, githubWorkflow: { repository: publisher.repository, ref: publisher.ref } };
43
+ }
44
+
45
+ const tail = (text) => String(text).trim().slice(-STDERR_TAIL_BYTES);
46
+
47
+ /**
48
+ * @param {object} request
49
+ * @param {Record<string, string>} request.pins the snapshot's pins; the xema-deploy commit is read from them
50
+ * @param {{repository: string, signer: string}} request.engine the engine repository and the signer's exact certificate identity
51
+ * @param {string} request.cosignBinary absolute path of cosign
52
+ * @param {object} request.env the environment the registry credentials are named in
53
+ * @param {object} [request.pinned] withPinnedCosign's injections (tests)
54
+ * @returns {Promise<string>} `<repository>@sha256:<digest>`
55
+ */
56
+ export async function resolveDevelopEngineImage({ pins, engine, cosignBinary, env, pinned = {} }) {
57
+ const deploySha = pins[deployPinKey()];
58
+ if (typeof deploySha !== 'string' || !COMMIT_PATTERN.test(deploySha)) {
59
+ throw new RefusalError(`the pin ${deployPinKey()} is ${JSON.stringify(deploySha)}, not a 40-hex commit`);
60
+ }
61
+ const { repository } = engine;
62
+ const signer = engineSignerOf(engine.signer);
63
+ const tag = engineTagOf(deploySha);
64
+ const { authority, repository: path } = splitImageRef(repository);
65
+ return withPinnedCosign({ cosignBinary, env, ...pinned }, async ({ runCosign, cosignEnvFor, clientFor, trustedRootPath }) => {
66
+ let digest;
67
+ try {
68
+ digest = (await clientFor(authority).getManifestByTag(path, tag)).digest;
69
+ } catch (error) {
70
+ if (!isNotPublishedYet(error)) {throw error;}
71
+ throw new RefusalError(
72
+ `no engine ${repository}:${tag} for the ${deployPinKey()} commit ${deploySha}. The deploy repository's CI publishes one for a green push to develop or main, ` +
73
+ 'or a CI dispatch at that branch; a commit pushed with GITHUB_TOKEN has none until CI is dispatched. ' +
74
+ `Dispatch that CI at develop, or pin ${deployPinKey()} to a commit that has an engine.`,
75
+ );
76
+ }
77
+ const image = `${repository}@${digest}`;
78
+ const result = await runCosign(
79
+ [
80
+ 'verify', '--offline', '--trusted-root', trustedRootPath,
81
+ ...certificateIdentityArgs(signer),
82
+ '-a', `commit=${deploySha}`, '-a', `branch=${DEVELOP_BRANCH}`,
83
+ image,
84
+ ],
85
+ cosignEnvironment(cosignEnvFor, authority),
86
+ );
87
+ if (result.code !== 0) {
88
+ throw new RefusalError(
89
+ `${image} (${tag}) is not signed by the engine signer ${signer.certificateIdentity} with commit=${deploySha} branch=${DEVELOP_BRANCH}; ` +
90
+ `refusing it as this edition's engine (cosign exit ${result.code}): ${tail(result.stderr)}`,
91
+ );
92
+ }
93
+ return assertEngineImage(image);
94
+ });
95
+ }
@@ -9,6 +9,9 @@
9
9
  // freezeGraph the graph repositories, the data root, the kernel, the deploy checkout, the editions root
10
10
  // freezeSources one pin per `sources[]` entry the chain declares
11
11
  //
12
+ // With `memberPins` (a select-records selection) the members are pinned at the selected build records instead; the one
13
+ // declared exception, a member that keeps its current resolution, is pinned at its develop head.
14
+ //
12
15
  // `resolveHead({ name, url })` is the one network seam (default: `git ls-remote` through
13
16
  // git-source-auth: the data root's token only for a repository under its
14
17
  // `sourceRepositoryUrlPrefix`, no credential for any other, never argv).
@@ -16,6 +19,7 @@
16
19
  import { execFileSync } from 'node:child_process';
17
20
 
18
21
  import { canonicalPublicHttpsRepositoryUrl } from './canonical-repository-url.mjs';
22
+ import { currentResolutionOf } from './develop-current-resolution.mjs';
19
23
  import { developGraphMembers } from './develop-graph.mjs';
20
24
  import { EditionChannel } from './edition-channel.mjs';
21
25
  import { assertCanonicalSourceUrl, assertSourceDeclaresNoCredential, isUnderSourcePrefix, withSourceGitEnv } from './git-source-auth.mjs';
@@ -124,6 +128,17 @@ function assertOverrides(overrides, allowed) {
124
128
  }
125
129
  }
126
130
 
131
+ /** The member pins name exactly the graph minus the current-resolution members; a missing or extra member is refused by name. */
132
+ function assertMemberPinsAreTheGraph(memberPins, members) {
133
+ const current = new Set(Object.keys(currentResolutionOf(members)));
134
+ const expected = members.map(({ repo }) => repo).filter((repo) => !current.has(repo));
135
+ const missing = expected.filter((repo) => !Object.hasOwn(memberPins, repo));
136
+ const extra = Object.keys(memberPins).filter((repo) => !expected.includes(repo));
137
+ if (missing.length > 0 || extra.length > 0) {
138
+ throw new Error(`--member-pins must pin exactly the develop graph minus ${JSON.stringify([...current])}: missing ${JSON.stringify(missing)}, extra ${JSON.stringify(extra)}`);
139
+ }
140
+ }
141
+
127
142
  const GRAPH_PHASE = new Set([
128
143
  SnapshotPinNamespace.Graph,
129
144
  SnapshotPinNamespace.Distributions,
@@ -149,6 +164,9 @@ function assertCheckedOutPin({ key, sha, flag, what, overrides }) {
149
164
  * @param {string} request.kernelSha the commit the kernel checkout is at
150
165
  * @param {string} request.deploySha the commit the deploy checkout is at
151
166
  * @param {string} [request.editionsRootUrl] https URL of the editions root; absent = this repository
167
+ * @param {Record<string, string>} [request.memberPins] the graph members' pins from a selection (`--member-pins`): exactly the
168
+ * graph minus the members that keep their current resolution (develop-current-resolution.mjs), which are pinned at their
169
+ * develop head; refused together with overrides
152
170
  * @param {(source: {name: string, url: string}) => void} request.holdSource the hold of one source; runs for ALL graph sources before the first head
153
171
  */
154
172
  export function freezeGraph({
@@ -157,9 +175,13 @@ export function freezeGraph({
157
175
  deploySha,
158
176
  editionsRootUrl,
159
177
  overrides = {},
178
+ memberPins,
160
179
  resolveHead,
161
180
  holdSource,
162
181
  }) {
182
+ if (memberPins !== undefined && Object.keys(overrides).length > 0) {
183
+ throw new Error('--member-pins and --overrides are exclusive: the member pins are the selected build records, never overridden');
184
+ }
163
185
  assertNoToolingOverride(overrides);
164
186
  assertOverrides(overrides, GRAPH_PHASE);
165
187
  const head = memoizedHeads(resolveHead);
@@ -171,6 +193,7 @@ export function freezeGraph({
171
193
  throw new Error(`override names "${key}", which is not a repository in the graph`);
172
194
  }
173
195
  }
196
+ if (memberPins !== undefined) {assertMemberPinsAreTheGraph(memberPins, members);}
174
197
  const checkedOut = [
175
198
  { key: dataPinKey(), sha: dataSha, flag: '--data-sha', what: 'the data root checkout' },
176
199
  { key: kernelPinKey(), sha: kernelSha, flag: '--kernel-sha', what: 'the kernel checkout' },
@@ -188,7 +211,7 @@ export function freezeGraph({
188
211
  }
189
212
  const pins = Object.fromEntries(checkedOut.map(({ key, sha }) => [key, sha]));
190
213
  for (const member of members) {
191
- pins[member.repo] = overrides[member.repo] ?? head({ name: member.repo, url: member.repositoryUrl });
214
+ pins[member.repo] = overrides[member.repo] ?? memberPins?.[member.repo] ?? head({ name: member.repo, url: member.repositoryUrl });
192
215
  }
193
216
  if (editionsRootUrl !== undefined) {
194
217
  const url = canonicalPublicHttpsRepositoryUrl(editionsRootUrl, '--editions-root-url');
@@ -13,14 +13,16 @@ import { compareStrings } from './lib.mjs';
13
13
 
14
14
  /**
15
15
  * The develop graph: `{ repo, repositoryUrl }` per declared first-party
16
- * repository, sorted by repo handle.
16
+ * repository, sorted by repo handle, plus `currentResolution` (its ticket) on a
17
+ * member the data root declares one for (develop-current-resolution.mjs).
17
18
  *
18
- * @returns {readonly { repo: string, repositoryUrl: string }[]}
19
+ * @returns {readonly { repo: string, repositoryUrl: string, currentResolution?: string }[]}
19
20
  */
20
21
  export function developGraphMembers() {
21
22
  return Object.freeze(
22
23
  Object.entries(firstPartySources('the develop graph'))
23
- .map(([repo, { repositoryUrl }]) => Object.freeze({ repo, repositoryUrl }))
24
+ .map(([repo, { repositoryUrl, currentResolution }]) =>
25
+ Object.freeze({ repo, repositoryUrl, ...(currentResolution === undefined ? {} : { currentResolution }) }))
24
26
  .sort((a, b) => compareStrings(a.repo, b.repo)),
25
27
  );
26
28
  }