@xemahq/xema 0.41.0 → 0.42.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.
- package/dist/commands/edition.js +1 -1
- package/dist/commands/edition.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +1297 -850
- package/dist/edition/verify-image-evidence.bundle.provenance.json +498 -456
- package/edition/README.md +3 -1
- package/edition/biome-content.mjs +4 -0
- package/edition/customer-edition-verbs.mjs +1 -0
- package/edition/develop-edition.mjs +22 -2
- package/edition/dispatched-runtime-components.mjs +91 -1
- package/edition/edition-protocol.mjs +36 -9
- package/edition/edition-workspace.mjs +2 -0
- package/edition/kernel-protocol-choice.mjs +239 -0
- package/edition/lib.mjs +113 -14
- package/edition/operation-grant-admission.mjs +65 -1
- package/edition/operation-requirements.mjs +18 -1
- package/edition/resolve-customer-edition.mjs +4 -3
- package/edition/resolve-distribution.mjs +16 -0
- package/edition/resolve-images.mjs +164 -20
- package/package.json +4 -4
package/edition/README.md
CHANGED
|
@@ -100,7 +100,9 @@ The edition library builds, validates and verifies evidence statements; it never
|
|
|
100
100
|
|
|
101
101
|
## Kernel protocol and pins
|
|
102
102
|
|
|
103
|
-
The CLI is coupled to the kernel's edition protocol, not to the kernel package. `@xemahq/contracts` exports the protocol identity as data (`dist/edition-protocol/edition-protocol.json`: `{contentDigest, provisional, version}`). This CLI supports
|
|
103
|
+
The CLI is coupled to the kernel's edition protocol, not to the kernel package. `@xemahq/contracts` exports the protocol identity as data (`dist/edition-protocol/edition-protocol.json`: `{contentDigest, provisional, version}`). This CLI supports protocols v9 to v14, each pinned to its digest and vendored (v10 to v14 grow the kernel source closure only; a data root whose pinned kernel speaks v8 stays on CLI 0.41.x, v5 on 0.33.x) and REFUSES an identity with `provisional: true`; every loader that executes the kernel, `xema edition check` first, and the evidence verifier compare only that identity and refuse an unsupported one, naming the supported and the declared versions. A protocol change needs a CLI release first; only then does the data root repin its kernel authority.
|
|
104
|
+
|
|
105
|
+
A develop edition does not take the kernel's develop head blindly: `xema edition develop choose-kernel --kernel <package dir> [--kernel-pin <40-hex>] --out <file>` reads the declared protocol of each commit on the checkout's first-parent history (from source, unbuilt) and chooses the newest one this CLI supports. It writes `{chosenSha, headSha, held, reason}` (`reason`: `head-supported` | `head-ahead` | `explicit-pin`; `held` is `head-ahead`), and when held prints `kernel held at v<N> (<sha12>), head at v<M> (<sha12>): raise the CLI` (stdout only, never persisted). The head is the checkout's `refs/remotes/origin/develop` (a full-history checkout). An explicit pin wins but is still checked, and must be on that head's first-parent history (`not-on-develop` otherwise); when no commit is supported it refuses (`EditionProtocolMismatchError`, `no-supported-commit`; the walk ends at the first commit with no declaration file) and never falls back to the head; a declaration that exists but does not parse, at the head or any walked commit, is refused by name (`unparseable`), never skipped. `freeze-graph` applies the same check to `--kernel-sha`.
|
|
104
106
|
|
|
105
107
|
- `xema edition evidence statement entitlement stage` writes entitlement predicate v2 (`lockDigest`, `catalogueDigest`, read from the edition's committed locks), so it takes `--editions-root` besides `--data-root` and `--kernel`.
|
|
106
108
|
- A source repository JOINS a data root that has committed pins with one command: `xema edition index runtime-image-source --trusted-write --repo <repository> [--at <40-hex>] [--kernel-root <checkout>]`. The repository is resolved once and the runtime image source, biome and platform-service indexes are all written from the committed pins plus that one pin; every committed pin is kept field for field (a differing one is refused by name), a second declared repository with no pin is refused, and the summary names each pin added, moved and dropped. Then `xema edition index biome --check`, `xema edition index runtime-image-source --check` and `pnpm check`. The biome builder takes no `--repo`: it would resolve the repository a second time.
|
|
@@ -22,8 +22,10 @@ import { relative, sep } from 'node:path';
|
|
|
22
22
|
import { assertCustomerLockWithinCatalogueCeiling } from './catalogue.mjs';
|
|
23
23
|
import { readGitJsonAtCommit } from './lib.mjs';
|
|
24
24
|
import {
|
|
25
|
+
assertInstalledOperationCeilingAdmitted,
|
|
25
26
|
assertOperationGrantsAdmitted,
|
|
26
27
|
assertOrganizationSweepsAdmitted,
|
|
28
|
+
assertStoreWorkloadCeilingAdmitted,
|
|
27
29
|
} from './operation-grant-admission.mjs';
|
|
28
30
|
|
|
29
31
|
/** A biome whose recomputed content hash is not the lock's, or whose inputs cannot be recomputed. */
|
|
@@ -289,6 +291,8 @@ export async function sealLock({ schemas, unsealed, imageLock, locate, vendoredC
|
|
|
289
291
|
const hashes = await computeBiomeContentHashes({ schemas, lock: unsealed, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, storePublisherAnchorKeys, sourceFactsById });
|
|
290
292
|
assertOperationGrantsAdmitted(schemas, unsealed);
|
|
291
293
|
assertOrganizationSweepsAdmitted(schemas, unsealed);
|
|
294
|
+
assertInstalledOperationCeilingAdmitted(schemas, unsealed);
|
|
295
|
+
assertStoreWorkloadCeilingAdmitted(schemas, unsealed);
|
|
292
296
|
return schemas.sealDistributionLock(unsealed, hashes);
|
|
293
297
|
}
|
|
294
298
|
|
|
@@ -214,6 +214,7 @@ const COMMANDS = Object.freeze({
|
|
|
214
214
|
required: ['--pins', '--cosign'],
|
|
215
215
|
},
|
|
216
216
|
'check-overrides': { positional: false, values: ['--overrides'], switches: [], required: ['--overrides'] },
|
|
217
|
+
'choose-kernel': { positional: false, values: ['--kernel-pin', '--out'], switches: [], required: ['--out'] },
|
|
217
218
|
'check-existing': {
|
|
218
219
|
positional: true,
|
|
219
220
|
values: ['--pins', '--artifact', '--engine-image'],
|
|
@@ -30,7 +30,8 @@
|
|
|
30
30
|
// [--overrides <json>] --out <file>
|
|
31
31
|
// resolve refs/heads/develop once per graph repository (and the editions
|
|
32
32
|
// root) from this checkout's runtime-image-source-index.json; the three checked-out
|
|
33
|
-
// repositories are pinned at what the workflow checked out; write the pins.
|
|
33
|
+
// repositories are pinned at what the workflow checked out; write the pins. --kernel-sha is refused
|
|
34
|
+
// unless its declared edition protocol is one this CLI supports (the check choose-kernel applies to a pin).
|
|
34
35
|
// check-existing <id> --pins <file> --artifact <dir> --engine-image <repository>@sha256:<digest>
|
|
35
36
|
// refuse to reuse a pulled artifact whose descriptor disagrees with this snapshot.
|
|
36
37
|
// check-existing --definition <file> --pins <file> --artifact <dir> --engine-image <repository>@sha256:<digest>
|
|
@@ -44,6 +45,13 @@
|
|
|
44
45
|
// annotations-missing | annotation-mismatch | not-built). Prints a table grouped by producer repo
|
|
45
46
|
// and `covered N/M` (--json: the same as data). Exit 0 only when every subject is covered, 1 when
|
|
46
47
|
// not, 2 on an operational error. Takes the roots `resolve` takes; writes nothing.
|
|
48
|
+
// choose-kernel [--kernel-pin <40-hex>] --out <file>
|
|
49
|
+
// choose the develop kernel commit in the --kernel checkout (full history; its refs/remotes/origin/develop is the develop head):
|
|
50
|
+
// the newest first-parent commit whose declared edition protocol this CLI supports
|
|
51
|
+
// (kernel-protocol-choice.mjs). Writes {chosenSha, headSha, held, reason} (reason: head-supported |
|
|
52
|
+
// head-ahead | explicit-pin); when held, prints `kernel held at v<N> (<sha12>), head at v<M> (<sha12>):
|
|
53
|
+
// raise the CLI` to stdout (never persisted). A pin wins and is still checked; nothing supported is refused.
|
|
54
|
+
// Takes only --kernel (the checkout need not be built).
|
|
47
55
|
// check-overrides --overrides <json>
|
|
48
56
|
// refuse a pins override that would select the tooling this run executes
|
|
49
57
|
// (the edition tooling's own repository); the workflow runs it before any other checkout.
|
|
@@ -68,8 +76,9 @@ import { resolveDevelopEdition } from './develop-edition-resolve.mjs';
|
|
|
68
76
|
import { assertNoToolingOverride, freezeGraph, freezeSources } from './develop-freeze.mjs';
|
|
69
77
|
import { LOCAL_WORKFLOW, assertExistingArtifactMatches, developEditionTarget, workflowFromEnv, writeEditionArtifact } from './edition-artifact.mjs';
|
|
70
78
|
import { EditionChannel } from './edition-constants.mjs';
|
|
71
|
-
import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
|
|
79
|
+
import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, kernelRepoDir, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
|
|
72
80
|
import { descriptorEvidenceTrust } from './evidence-trust-policy.mjs';
|
|
81
|
+
import { assertKernelCommitSupported, chooseKernelCommit } from './kernel-protocol-choice.mjs';
|
|
73
82
|
import { loadKernelDistributionSchemas } from './lib.mjs';
|
|
74
83
|
import { snapshotPinSource } from './pin-source.mjs';
|
|
75
84
|
import { composeEdition } from './resolve-distribution.mjs';
|
|
@@ -85,11 +94,15 @@ const VENDOR_ROOTS = Object.freeze([WorkspaceRoot.Data, WorkspaceRoot.Editions,
|
|
|
85
94
|
const COVERAGE_COMMAND = 'coverage';
|
|
86
95
|
const COVERAGE_OPERATIONAL_EXIT = CoverageExit.Operational;
|
|
87
96
|
const DATA_ROOT_ONLY_COMMANDS = Object.freeze(['check-overrides']);
|
|
97
|
+
// `choose-kernel` reads the kernel checkout's git history only, before the kernel is built.
|
|
98
|
+
const KERNEL_ROOT_ONLY_COMMANDS = Object.freeze(['choose-kernel']);
|
|
88
99
|
|
|
89
100
|
if (isEntryModule(import.meta.url)) {
|
|
90
101
|
const [command] = process.argv.slice(2);
|
|
91
102
|
if (DATA_ROOT_ONLY_COMMANDS.includes(command)) {
|
|
92
103
|
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data], { refuseOthers: true });
|
|
104
|
+
} else if (KERNEL_ROOT_ONLY_COMMANDS.includes(command)) {
|
|
105
|
+
configureEditionWorkspaceFromProcess([WorkspaceRoot.Kernel], { refuseOthers: true });
|
|
93
106
|
} else {
|
|
94
107
|
const customerForm = process.argv.includes(DEFINITION_FLAG);
|
|
95
108
|
configureEditionWorkspaceFromProcess(customerForm ? (CUSTOMER_ROOTS[command] ?? VENDOR_ROOTS) : VENDOR_ROOTS, {
|
|
@@ -165,7 +178,14 @@ async function run(argv, env) {
|
|
|
165
178
|
assertNoToolingOverride(parseOverrides(options['--overrides']));
|
|
166
179
|
return;
|
|
167
180
|
}
|
|
181
|
+
case 'choose-kernel': {
|
|
182
|
+
const { choice, notice } = chooseKernelCommit({ repoDir: kernelRepoDir(), pin: options['--kernel-pin'] });
|
|
183
|
+
writeOnce(options['--out'], choice);
|
|
184
|
+
if (notice !== undefined) {console.log(notice);}
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
168
187
|
case 'freeze-graph': {
|
|
188
|
+
assertKernelCommitSupported({ repoDir: kernelRepoDir(), commit: options['--kernel-sha'], label: `--kernel-sha ${options['--kernel-sha']}` });
|
|
169
189
|
const pins = freezeGraph({
|
|
170
190
|
runtimeSourceIndex: readStrictJsonFile(runtimeImageSourceIndexPath()),
|
|
171
191
|
dataSha: options['--data-sha'],
|
|
@@ -1,5 +1,71 @@
|
|
|
1
1
|
import { compareStrings } from './string-order.mjs';
|
|
2
2
|
|
|
3
|
+
/** A `runtime-image` edge target that is neither `{biomeId, componentKey}` nor `{supplementalRuntimeComponentId}`, or is both. */
|
|
4
|
+
export class DispatchedEdgeError extends Error {
|
|
5
|
+
constructor(message) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.name = 'DispatchedEdgeError';
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** A dispatched `supplemental:<id>` the runtime-image source index does not declare as a supplemental subject. */
|
|
12
|
+
export class UndeclaredSupplementalRuntimeComponentError extends Error {
|
|
13
|
+
constructor(supplementalRuntimeComponentId, dispatchingBiomes, context) {
|
|
14
|
+
super(
|
|
15
|
+
`${context}: supplemental runtime component "${supplementalRuntimeComponentId}", dispatched by biome ` +
|
|
16
|
+
`${dispatchingBiomes.map((id) => `"${id}"`).join(', ')}, is not declared as a runtime-component subject ` +
|
|
17
|
+
'by the runtime image source index — refusing rather than shipping the dispatcher without the image it launches.',
|
|
18
|
+
);
|
|
19
|
+
this.name = 'UndeclaredSupplementalRuntimeComponentError';
|
|
20
|
+
this.supplementalRuntimeComponentId = supplementalRuntimeComponentId;
|
|
21
|
+
this.dispatchingBiomes = dispatchingBiomes;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// The `supplementalRuntimeComponentId` field is read as plain data. Since edition
|
|
26
|
+
// protocol v10 the kernel types it (`DispatchedRuntimeComponentSchema`, a union of
|
|
27
|
+
// two strict objects); this projection keeps reading it as data and refuses a target
|
|
28
|
+
// that is neither form, or both.
|
|
29
|
+
const isNonEmptyString = (value) => typeof value === 'string' && value.length > 0;
|
|
30
|
+
|
|
31
|
+
/** True for the supplemental form of a projected edge. */
|
|
32
|
+
export const isSupplementalEdge = (edge) => edge.supplementalRuntimeComponentId !== undefined;
|
|
33
|
+
|
|
34
|
+
/** The identity of a projected edge: `supplemental:<id>` or `<biomeId>\0<componentKey>`. */
|
|
35
|
+
export const dispatchedEdgeIdentity = (edge) =>
|
|
36
|
+
isSupplementalEdge(edge)
|
|
37
|
+
? `supplemental:${edge.supplementalRuntimeComponentId}`
|
|
38
|
+
: `${edge.biomeId}\0${edge.componentKey}`;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Project one generated-index `runtime-image` edge target into the typed union
|
|
42
|
+
* the index entry carries. A target that is neither form, or both, is refused
|
|
43
|
+
* naming the dispatching biome; no `undefined` identity can form.
|
|
44
|
+
*/
|
|
45
|
+
export function projectDispatchedTarget(dispatchingBiomeId, target) {
|
|
46
|
+
const supplemental = target?.supplementalRuntimeComponentId;
|
|
47
|
+
const biomeForm = target?.biomeId !== undefined || target?.componentKey !== undefined;
|
|
48
|
+
if (supplemental !== undefined) {
|
|
49
|
+
if (biomeForm || !isNonEmptyString(supplemental)) {
|
|
50
|
+
throw new DispatchedEdgeError(
|
|
51
|
+
`biome "${dispatchingBiomeId}" has a runtime-image edge target that mixes supplementalRuntimeComponentId with ` +
|
|
52
|
+
'biomeId/componentKey, or names an empty id — a target is exactly one form',
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
return { supplementalRuntimeComponentId: supplemental };
|
|
56
|
+
}
|
|
57
|
+
if (!isNonEmptyString(target?.biomeId) || !isNonEmptyString(target?.componentKey)) {
|
|
58
|
+
throw new DispatchedEdgeError(
|
|
59
|
+
`biome "${dispatchingBiomeId}" has a runtime-image edge target that is neither { biomeId, componentKey } nor ` +
|
|
60
|
+
'{ supplementalRuntimeComponentId }',
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return { biomeId: target.biomeId, componentKey: target.componentKey };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const edgeLabel = (edge) =>
|
|
67
|
+
isSupplementalEdge(edge) ? `supplemental:${edge.supplementalRuntimeComponentId}` : `${edge.biomeId}/${edge.componentKey}`;
|
|
68
|
+
|
|
3
69
|
/**
|
|
4
70
|
* The runtime components an edition must carry because a biome it rosters
|
|
5
71
|
* DISPATCHES them: the kernel's `runtime-image` edges, projected per biome into
|
|
@@ -21,6 +87,10 @@ import { compareStrings } from './string-order.mjs';
|
|
|
21
87
|
* no biome drawn from a declared source. A biome drawn from a declared source
|
|
22
88
|
* that dispatches is refused for the same reason.
|
|
23
89
|
*
|
|
90
|
+
* A supplemental edge (`{supplementalRuntimeComponentId}`) names no biome: it
|
|
91
|
+
* is carried as a row `{ supplementalRuntimeComponentId, consumers }` without
|
|
92
|
+
* a biome lookup.
|
|
93
|
+
*
|
|
24
94
|
* Returns one row per distinct (target biome, component key), sorted, with the
|
|
25
95
|
* rostered biomes that dispatch it. Every unresolvable edge is refused, naming
|
|
26
96
|
* the consumer and the target.
|
|
@@ -37,12 +107,24 @@ export function resolveDispatchedRuntimeComponents(context, consumers, index) {
|
|
|
37
107
|
if (consumer.source !== undefined) {
|
|
38
108
|
throw new Error(
|
|
39
109
|
`${context}: biome "${consumer.id}", drawn from declared source "${consumer.source}", dispatches ` +
|
|
40
|
-
`runtime components (${edges.map(
|
|
110
|
+
`runtime components (${edges.map(edgeLabel).join(', ')}). ` +
|
|
41
111
|
'A dispatched image is resolved from the first-party biome index, which carries no biome from a ' +
|
|
42
112
|
'declared source — refusing rather than shipping the dispatcher without the images it launches.',
|
|
43
113
|
);
|
|
44
114
|
}
|
|
45
115
|
for (const edge of edges) {
|
|
116
|
+
if (isSupplementalEdge(edge)) {
|
|
117
|
+
// Carried as a requirement owned by the dispatching biome. Whether the
|
|
118
|
+
// runtime image source index declares it is the image plane's refusal.
|
|
119
|
+
const supplementalKey = dispatchedEdgeIdentity(edge);
|
|
120
|
+
const row = rows.get(supplementalKey) ?? {
|
|
121
|
+
supplementalRuntimeComponentId: edge.supplementalRuntimeComponentId,
|
|
122
|
+
consumers: [],
|
|
123
|
+
};
|
|
124
|
+
if (!row.consumers.includes(consumer.id)) {row.consumers.push(consumer.id);}
|
|
125
|
+
rows.set(supplementalKey, row);
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
46
128
|
const edgeText = `biome "${consumer.id}" dispatches runtime component "${edge.componentKey}" of biome "${edge.biomeId}"`;
|
|
47
129
|
const candidates = index.filter(
|
|
48
130
|
(entry) => entry.id === edge.biomeId && entry.target === 'server' && entry.source === undefined,
|
|
@@ -80,3 +162,11 @@ export function resolveDispatchedRuntimeComponents(context, consumers, index) {
|
|
|
80
162
|
.sort(([a], [b]) => compareStrings(a, b))
|
|
81
163
|
.map(([, row]) => row);
|
|
82
164
|
}
|
|
165
|
+
|
|
166
|
+
/** Sort order of projected edges: biome edges by (biomeId, componentKey), then supplemental edges by id. */
|
|
167
|
+
export function compareDispatchedEdges(a, b) {
|
|
168
|
+
if (isSupplementalEdge(a) !== isSupplementalEdge(b)) {return isSupplementalEdge(a) ? 1 : -1;}
|
|
169
|
+
return isSupplementalEdge(a)
|
|
170
|
+
? compareStrings(a.supplementalRuntimeComponentId, b.supplementalRuntimeComponentId)
|
|
171
|
+
: compareStrings(a.biomeId, b.biomeId) || compareStrings(a.componentKey, b.componentKey);
|
|
172
|
+
}
|
|
@@ -28,12 +28,15 @@ import { realpathSync } from 'node:fs';
|
|
|
28
28
|
|
|
29
29
|
import { readBoundedNoFollowFile } from './no-follow-file.mjs';
|
|
30
30
|
|
|
31
|
-
/** The edition protocols this library interprets: version -> content digest.
|
|
31
|
+
/** The edition protocols this library interprets: version -> content digest. v10..v14 grow the kernel
|
|
32
|
+
* source closure only (entry subpaths and hashing unchanged), so one library speaks every row. */
|
|
32
33
|
export const SUPPORTED_EDITION_PROTOCOLS = Object.freeze([
|
|
33
|
-
Object.freeze({
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}),
|
|
34
|
+
Object.freeze({ version: 9, contentDigest: '7cd6d760e7e7caaec3634a08c739debbdeeeaac4f1bad6791246a12f4f248e37' }),
|
|
35
|
+
Object.freeze({ version: 10, contentDigest: '660129065650a8300f7c0ad54f957b4d43507e1a44e093c9812d2b1bbf143104' }),
|
|
36
|
+
Object.freeze({ version: 11, contentDigest: '600451d2253c470c7d21bafe87260dfc3bfd43b6ad3eee2ef4d8b1f7ad08eb6e' }),
|
|
37
|
+
Object.freeze({ version: 12, contentDigest: 'cff51ea06b6ba7d95d02dcd6305b6a55061b428da48ae74dcbff8a5c8107ce41' }),
|
|
38
|
+
Object.freeze({ version: 13, contentDigest: '5f69fe73d1dea1650ef1757204a53ccd4030ade78bfa91959c2d01c937fc0e16' }),
|
|
39
|
+
Object.freeze({ version: 14, contentDigest: '403b6a491b5922df1a423ba8a2d6ae3d3900bb1caff4e8c0caa48de90a47eb9b' }),
|
|
37
40
|
]);
|
|
38
41
|
|
|
39
42
|
/** Where a kernel package publishes its identity and its contract, package-relative. */
|
|
@@ -64,6 +67,12 @@ export const EditionProtocolMismatchReason = Object.freeze({
|
|
|
64
67
|
ContractMismatch: 'contract-mismatch',
|
|
65
68
|
/** The kernel's protocol is not the one compiled into the running verifier. */
|
|
66
69
|
BundleMismatch: 'bundle-mismatch',
|
|
70
|
+
/** No commit on the kernel checkout's first-parent history declares a protocol this library supports. */
|
|
71
|
+
NoSupportedCommit: 'no-supported-commit',
|
|
72
|
+
/** A kernel commit's protocol declaration source exists but does not parse; refused, never skipped. */
|
|
73
|
+
Unparseable: 'unparseable',
|
|
74
|
+
/** An explicit kernel commit is not on the first-parent history of the checkout's develop head. */
|
|
75
|
+
NotOnDevelop: 'not-on-develop',
|
|
67
76
|
});
|
|
68
77
|
|
|
69
78
|
export class EditionProtocolMismatchError extends Error {
|
|
@@ -76,7 +85,8 @@ export class EditionProtocolMismatchError extends Error {
|
|
|
76
85
|
|
|
77
86
|
const describe = ({ version, contentDigest }) => `edition protocol v${version} (${contentDigest})`;
|
|
78
87
|
|
|
79
|
-
|
|
88
|
+
/** `v<N>, v<M>`: the versions this library supports, as every refusal names them. */
|
|
89
|
+
export const supportedList = () =>
|
|
80
90
|
SUPPORTED_EDITION_PROTOCOLS.map((protocol) => `v${protocol.version}`).join(', ');
|
|
81
91
|
|
|
82
92
|
/** Refuse an identity that states `provisional: true`; nothing provisional is ever read as a protocol. */
|
|
@@ -147,10 +157,11 @@ function contractIdentity(contractBytes) {
|
|
|
147
157
|
}
|
|
148
158
|
|
|
149
159
|
/**
|
|
150
|
-
* Refuse an identity this library does not support
|
|
151
|
-
*
|
|
160
|
+
* Refuse an identity this library does not support: a provisional one, an
|
|
161
|
+
* unsupported version, or a supported version at another digest. Returns it.
|
|
152
162
|
*/
|
|
153
|
-
export function
|
|
163
|
+
export function assertSupportedEditionIdentity(identity, source) {
|
|
164
|
+
assertFinalIdentity(identity, source);
|
|
154
165
|
const supported = SUPPORTED_EDITION_PROTOCOLS.find(
|
|
155
166
|
(protocol) => protocol.version === identity.version,
|
|
156
167
|
);
|
|
@@ -169,6 +180,22 @@ export function assertSupportedEditionProtocol({ identity, contractBytes, source
|
|
|
169
180
|
'contract without a new protocol version.',
|
|
170
181
|
);
|
|
171
182
|
}
|
|
183
|
+
return identity;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** True when `identity` is final and is a row of the supported table (version and digest). */
|
|
187
|
+
export const isSupportedEditionIdentity = (identity) =>
|
|
188
|
+
identity?.provisional === false &&
|
|
189
|
+
SUPPORTED_EDITION_PROTOCOLS.some(
|
|
190
|
+
(protocol) => protocol.version === identity.version && protocol.contentDigest === identity.contentDigest,
|
|
191
|
+
);
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Refuse an identity this library does not support, and a contract file that
|
|
195
|
+
* does not state that identity. Returns the identity.
|
|
196
|
+
*/
|
|
197
|
+
export function assertSupportedEditionProtocol({ identity, contractBytes, source }) {
|
|
198
|
+
assertSupportedEditionIdentity(identity, source);
|
|
172
199
|
const stated = contractIdentity(contractBytes);
|
|
173
200
|
if (stated?.version !== identity.version || stated?.contentDigest !== identity.contentDigest) {
|
|
174
201
|
throw new EditionProtocolMismatchError(
|
|
@@ -38,6 +38,8 @@ const ROOT_MEANING = Object.freeze({
|
|
|
38
38
|
});
|
|
39
39
|
|
|
40
40
|
const KERNEL_PACKAGE_SUBPATH = ['packages', 'kernel', 'kernel-contracts'];
|
|
41
|
+
/** The kernel package's path inside the kernel-contracts repository, `/`-separated (a git tree path). */
|
|
42
|
+
export const KERNEL_PACKAGE_REPO_PATH = KERNEL_PACKAGE_SUBPATH.join('/');
|
|
41
43
|
|
|
42
44
|
let configured = Object.freeze({});
|
|
43
45
|
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
// kernel-protocol-choice — which kernel commit a develop edition runs.
|
|
2
|
+
//
|
|
3
|
+
// The CLI runs the kernel's code and refuses a protocol it does not support
|
|
4
|
+
// (edition-protocol.mjs), so a develop edition that always took the kernel's
|
|
5
|
+
// develop HEAD would break on every kernel protocol bump until a CLI release
|
|
6
|
+
// caught up. Commits that declare the same protocol identity (version AND
|
|
7
|
+
// digest) are interchangeable by the digest contract, so the develop kernel is
|
|
8
|
+
// the NEWEST commit on the checkout's first-parent history whose declared
|
|
9
|
+
// identity is in this CLI's supported set: a deterministic choice, never a
|
|
10
|
+
// fallback.
|
|
11
|
+
//
|
|
12
|
+
// - head supported -> the head (reason head-supported)
|
|
13
|
+
// - head ahead of the CLI -> the newest supported (reason head-ahead, held)
|
|
14
|
+
// - an explicit 40-hex pin -> the pin, still checked (reason explicit-pin); it must be on the first-parent
|
|
15
|
+
// history of the develop head (refs/remotes/origin/develop), else not-on-develop
|
|
16
|
+
// - nothing supported -> refused (EditionProtocolMismatchError, no-supported-commit); never the head.
|
|
17
|
+
// The walk ends at the first commit with no declaration file (it predates the protocol).
|
|
18
|
+
// - a declaration that exists but does not parse, at the head or any walked commit
|
|
19
|
+
// -> refused (EditionProtocolMismatchError, unparseable) naming the commit; never skipped
|
|
20
|
+
//
|
|
21
|
+
// The declared identity is read from each commit's SOURCE
|
|
22
|
+
// (`<kernel package>/src/edition-protocol/lib/edition-protocol.ts`, the literal
|
|
23
|
+
// `EDITION_PROTOCOL = Object.freeze({ version, provisional, contentDigest })`),
|
|
24
|
+
// because the history is not built; the built kernel is checked again from its
|
|
25
|
+
// dist files by every loader. Reads git objects only; writes nothing.
|
|
26
|
+
|
|
27
|
+
import { execFileSync } from 'node:child_process';
|
|
28
|
+
|
|
29
|
+
import {
|
|
30
|
+
EditionProtocolMismatchError,
|
|
31
|
+
EditionProtocolMismatchReason,
|
|
32
|
+
assertSupportedEditionIdentity,
|
|
33
|
+
isSupportedEditionIdentity,
|
|
34
|
+
supportedList,
|
|
35
|
+
} from './edition-protocol.mjs';
|
|
36
|
+
import { KERNEL_PACKAGE_REPO_PATH } from './edition-workspace.mjs';
|
|
37
|
+
|
|
38
|
+
/** The kernel source file that declares the edition protocol identity, repository-relative. */
|
|
39
|
+
export const KERNEL_EDITION_PROTOCOL_SOURCE = `${KERNEL_PACKAGE_REPO_PATH}/src/edition-protocol/lib/edition-protocol.ts`;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The kernel checkout's develop head: the remote-tracking ref a full-history checkout (`fetch-depth: 0`)
|
|
43
|
+
* always has. Every choice and every explicit commit is judged against its first-parent history.
|
|
44
|
+
*/
|
|
45
|
+
export const KERNEL_DEVELOP_REF = 'refs/remotes/origin/develop';
|
|
46
|
+
|
|
47
|
+
/** Why a kernel commit was chosen. A closed set; `held` is exactly `head-ahead`. */
|
|
48
|
+
export const KernelChoiceReason = Object.freeze({
|
|
49
|
+
HeadSupported: 'head-supported',
|
|
50
|
+
HeadAhead: 'head-ahead',
|
|
51
|
+
ExplicitPin: 'explicit-pin',
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const SHA_PATTERN = /^[0-9a-f]{40}$/;
|
|
55
|
+
const MAX_GIT_OUTPUT = 256 * 1024 * 1024;
|
|
56
|
+
const DECLARATION = /export const EDITION_PROTOCOL\b[^=]*=\s*Object\.freeze\(\{([^}]*)\}\)/g;
|
|
57
|
+
const FIELDS = Object.freeze([
|
|
58
|
+
['version', /\bversion:\s*([1-9][0-9]*)\s*(?:,|$)/],
|
|
59
|
+
['provisional', /\bprovisional:\s*(true|false)\s*(?:,|$)/],
|
|
60
|
+
['contentDigest', /\bcontentDigest:\s*'([a-f0-9]{64})'\s*(?:,|$)/],
|
|
61
|
+
]);
|
|
62
|
+
|
|
63
|
+
/** A declaration source that exists but whose `EDITION_PROTOCOL` literal is absent or not exactly the three fields. */
|
|
64
|
+
const malformed = (label) =>
|
|
65
|
+
new EditionProtocolMismatchError(
|
|
66
|
+
EditionProtocolMismatchReason.Unparseable,
|
|
67
|
+
`${label} does not parse: it must declare exactly one EDITION_PROTOCOL = Object.freeze({ version: <positive integer>, ` +
|
|
68
|
+
"provisional: <boolean>, contentDigest: '<64 lowercase hex>' })",
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The identity `{contentDigest, provisional, version}` the source text declares. Strict: exactly one
|
|
73
|
+
* declaration, each field exactly once, nothing else in the literal. Throws Unparseable otherwise.
|
|
74
|
+
*/
|
|
75
|
+
export function parseDeclaredEditionProtocol(text, label) {
|
|
76
|
+
const declarations = [...text.matchAll(DECLARATION)];
|
|
77
|
+
if (declarations.length !== 1) {throw malformed(label);}
|
|
78
|
+
let rest = declarations[0][1];
|
|
79
|
+
const fields = {};
|
|
80
|
+
for (const [name, pattern] of FIELDS) {
|
|
81
|
+
const all = rest.match(new RegExp(pattern.source, 'g')) ?? [];
|
|
82
|
+
if (all.length !== 1) {throw malformed(label);}
|
|
83
|
+
fields[name] = all[0].match(pattern)[1];
|
|
84
|
+
rest = rest.replace(all[0], '');
|
|
85
|
+
}
|
|
86
|
+
if (rest.replace(/\/\/[^\n]*/g, '').trim() !== '') {throw malformed(label);}
|
|
87
|
+
return Object.freeze({
|
|
88
|
+
contentDigest: fields.contentDigest,
|
|
89
|
+
provisional: fields.provisional === 'true',
|
|
90
|
+
version: Number(fields.version),
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const describeShort = (identity) =>
|
|
95
|
+
identity === undefined ? 'no declared protocol' : `v${identity.version}${identity.provisional ? '-provisional' : ''}`;
|
|
96
|
+
|
|
97
|
+
/** Git against one checkout; `runGit(args, input?)` returns stdout as a string. */
|
|
98
|
+
function gitIn(repoDir, runGit) {
|
|
99
|
+
return (args, input) =>
|
|
100
|
+
runGit('git', ['-C', repoDir, ...args], {
|
|
101
|
+
encoding: 'utf8',
|
|
102
|
+
maxBuffer: MAX_GIT_OUTPUT,
|
|
103
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
104
|
+
...(input === undefined ? {} : { input }),
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function assertCommit(git, sha, label) {
|
|
109
|
+
if (typeof sha !== 'string' || !SHA_PATTERN.test(sha)) {
|
|
110
|
+
throw new Error(`${label} is not a 40-hex commit SHA: ${JSON.stringify(sha)}`);
|
|
111
|
+
}
|
|
112
|
+
let resolved;
|
|
113
|
+
try {
|
|
114
|
+
resolved = git(['rev-parse', '--verify', '--quiet', `${sha}^{commit}`]).trim();
|
|
115
|
+
} catch {
|
|
116
|
+
resolved = '';
|
|
117
|
+
}
|
|
118
|
+
if (resolved !== sha) {throw new Error(`${label} is not a commit in the kernel checkout`);}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** The commit {@link KERNEL_DEVELOP_REF} names; refused when the checkout has no such ref. */
|
|
122
|
+
function readDevelopHead(git) {
|
|
123
|
+
let head;
|
|
124
|
+
try {
|
|
125
|
+
head = git(['rev-parse', '--verify', '--quiet', `${KERNEL_DEVELOP_REF}^{commit}`]).trim();
|
|
126
|
+
} catch {
|
|
127
|
+
head = '';
|
|
128
|
+
}
|
|
129
|
+
if (!SHA_PATTERN.test(head)) {
|
|
130
|
+
throw new Error(`the kernel checkout has no ${KERNEL_DEVELOP_REF}; check it out with full history (fetch-depth: 0)`);
|
|
131
|
+
}
|
|
132
|
+
return head;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** The first-parent history of `head`, newest first. */
|
|
136
|
+
const firstParentHistory = (git, head) =>
|
|
137
|
+
git(['rev-list', '--first-parent', head]).split('\n').filter((line) => line.length > 0);
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The declaring blob of every commit, by one `git cat-file --batch-check`: a Map commit -> blob sha, or
|
|
141
|
+
* undefined when the commit has no such file.
|
|
142
|
+
*/
|
|
143
|
+
function declaringBlobs(git, commits) {
|
|
144
|
+
const input = commits.map((commit) => `${commit}:${KERNEL_EDITION_PROTOCOL_SOURCE}\n`).join('');
|
|
145
|
+
const lines = git(['cat-file', '--batch-check'], input).split('\n').filter((line) => line.length > 0);
|
|
146
|
+
if (lines.length !== commits.length) {
|
|
147
|
+
throw new Error(`git cat-file answered ${lines.length} lines for ${commits.length} kernel commits`);
|
|
148
|
+
}
|
|
149
|
+
return new Map(
|
|
150
|
+
commits.map((commit, index) => {
|
|
151
|
+
const [sha, type] = lines[index].split(' ');
|
|
152
|
+
return [commit, type === 'blob' && SHA_PATTERN.test(sha) ? sha : undefined];
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** A reader of the declared identity per blob (memoized); `undefined` for an absent file. Throws Unparseable. */
|
|
158
|
+
function identityReader(git) {
|
|
159
|
+
const byBlob = new Map();
|
|
160
|
+
return (commit, blob) => {
|
|
161
|
+
if (blob === undefined) {return undefined;}
|
|
162
|
+
if (!byBlob.has(blob)) {
|
|
163
|
+
const label = `the kernel commit ${commit}: ${KERNEL_EDITION_PROTOCOL_SOURCE}`;
|
|
164
|
+
byBlob.set(blob, parseDeclaredEditionProtocol(git(['cat-file', 'blob', blob]), label));
|
|
165
|
+
}
|
|
166
|
+
return byBlob.get(blob);
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Refuse a kernel commit whose declared identity this CLI does not support (absent, provisional, another
|
|
172
|
+
* version, or a supported version at another digest), naming `label`. The one check behind an explicit pin
|
|
173
|
+
* and `freeze-graph --kernel-sha`. Returns the identity.
|
|
174
|
+
*/
|
|
175
|
+
export function assertKernelCommitSupported({ repoDir, commit, label = `the kernel commit ${commit}`, runGit = execFileSync }) {
|
|
176
|
+
const git = gitIn(repoDir, runGit);
|
|
177
|
+
assertCommit(git, commit, label);
|
|
178
|
+
const developHead = readDevelopHead(git);
|
|
179
|
+
if (!firstParentHistory(git, developHead).includes(commit)) {
|
|
180
|
+
throw new EditionProtocolMismatchError(
|
|
181
|
+
EditionProtocolMismatchReason.NotOnDevelop,
|
|
182
|
+
`${label} is not on the first-parent history of the kernel develop head ${developHead} (${KERNEL_DEVELOP_REF}); ` +
|
|
183
|
+
'an explicit kernel commit must be one that kernel develop actually passed through.',
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
const identity = identityReader(git)(commit, declaringBlobs(git, [commit]).get(commit));
|
|
187
|
+
if (identity === undefined) {
|
|
188
|
+
throw new EditionProtocolMismatchError(
|
|
189
|
+
EditionProtocolMismatchReason.Undeclared,
|
|
190
|
+
`${label} declares no edition protocol (${KERNEL_EDITION_PROTOCOL_SOURCE} is absent); this CLI supports ${supportedList()}.`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
return assertSupportedEditionIdentity(identity, `${label} (${KERNEL_EDITION_PROTOCOL_SOURCE})`);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Choose the develop kernel commit in the checkout at `repoDir` (fetched with full history).
|
|
198
|
+
*
|
|
199
|
+
* @param {object} request
|
|
200
|
+
* @param {string} request.repoDir the kernel-contracts checkout
|
|
201
|
+
* @param {string} [request.pin] an explicit 40-hex commit; it wins, and is still checked
|
|
202
|
+
* @returns {{ choice: {chosenSha: string, headSha: string, held: boolean, reason: string}, notice?: string }}
|
|
203
|
+
* `choice` is what is recorded; `notice` (only when held) is the run-summary line, never persisted.
|
|
204
|
+
*/
|
|
205
|
+
export function chooseKernelCommit({ repoDir, pin, runGit = execFileSync }) {
|
|
206
|
+
const git = gitIn(repoDir, runGit);
|
|
207
|
+
const headSha = readDevelopHead(git);
|
|
208
|
+
if (pin !== undefined) {
|
|
209
|
+
assertKernelCommitSupported({ repoDir, commit: pin, label: `the kernel pin ${pin}`, runGit });
|
|
210
|
+
return { choice: { chosenSha: pin, headSha, held: false, reason: KernelChoiceReason.ExplicitPin } };
|
|
211
|
+
}
|
|
212
|
+
const commits = firstParentHistory(git, headSha);
|
|
213
|
+
const blobs = declaringBlobs(git, commits);
|
|
214
|
+
const identityAt = identityReader(git);
|
|
215
|
+
// Fail closed: an unparseable declaration (head or walked) is refused by name, never skipped; a commit
|
|
216
|
+
// with no declaration file predates the protocol, so the walk ends there.
|
|
217
|
+
const headIdentity = identityAt(headSha, blobs.get(headSha));
|
|
218
|
+
if (isSupportedEditionIdentity(headIdentity)) {
|
|
219
|
+
return { choice: { chosenSha: headSha, headSha, held: false, reason: KernelChoiceReason.HeadSupported } };
|
|
220
|
+
}
|
|
221
|
+
for (const commit of headIdentity === undefined ? [] : commits.slice(1)) {
|
|
222
|
+
const identity = identityAt(commit, blobs.get(commit));
|
|
223
|
+
if (identity === undefined) {break;}
|
|
224
|
+
if (isSupportedEditionIdentity(identity)) {
|
|
225
|
+
return {
|
|
226
|
+
choice: { chosenSha: commit, headSha, held: true, reason: KernelChoiceReason.HeadAhead },
|
|
227
|
+
notice:
|
|
228
|
+
`kernel held at ${describeShort(identity)} (${commit.slice(0, 12)}), ` +
|
|
229
|
+
`head at ${describeShort(headIdentity)} (${headSha.slice(0, 12)}): raise the CLI`,
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
throw new EditionProtocolMismatchError(
|
|
234
|
+
EditionProtocolMismatchReason.NoSupportedCommit,
|
|
235
|
+
`no commit on the first-parent history of the kernel head ${headSha} declares an edition protocol this CLI ` +
|
|
236
|
+
`supports (this CLI supports ${supportedList()}; the head declares ${describeShort(headIdentity)}` +
|
|
237
|
+
`${headIdentity === undefined ? '' : ` at ${headIdentity.contentDigest}`}). Raise the CLI; the head is never taken instead.`,
|
|
238
|
+
);
|
|
239
|
+
}
|