@xemahq/xema 0.33.2 → 0.33.4
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/edition/verify-image-evidence.bundle.mjs +12 -17
- package/dist/edition/verify-image-evidence.bundle.provenance.json +47 -41
- package/dist/runtime/runner/chart-set-lock.d.ts +0 -3
- package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
- package/dist/runtime/runner/chart-set-lock.js +3 -25
- package/dist/runtime/runner/chart-set-lock.js.map +1 -1
- package/edition/chart-set-source-commit.mjs +2 -7
- package/edition/checkout-indexed-source-repositories.mjs +67 -0
- package/edition/cross-repo-build-inputs.mjs +158 -0
- package/edition/develop-chart-set.mjs +61 -0
- package/edition/develop-edition-resolve.mjs +6 -7
- package/edition/dist-check.mjs +1 -2
- package/edition/edition-artifact.mjs +1 -1
- package/edition/edition-chart-set.mjs +11 -53
- package/edition/git-json.mjs +54 -0
- package/edition/lib.mjs +3 -165
- package/edition/release-channel-guard.mjs +3 -4
- package/edition/release-edition-artifact.mjs +1 -1
- package/edition/resolve-chart-set.mjs +7 -69
- package/edition/resolve-customer-edition.mjs +1 -1
- package/edition/resolve-images.mjs +1 -3
- package/package.json +2 -2
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The ONE reader of a consumer repository's cross-repo build-input record, and
|
|
3
|
+
* the resolver built on it. The source checkout (which commits to fetch) and the
|
|
4
|
+
* image resolve (which bytes to hash) both read the record here, at the
|
|
5
|
+
* consumer's pin, so the plan and the resolve cannot diverge.
|
|
6
|
+
*/
|
|
7
|
+
import { sourcePath } from './edition-workspace.mjs';
|
|
8
|
+
import { readGitJsonAtCommit } from './git-json.mjs';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The producing repository's OWN committed record of what it vendored.
|
|
12
|
+
*
|
|
13
|
+
* `vendor-cross-repo-inputs.mjs` reads exactly this file, at the commit being
|
|
14
|
+
* built, to emit the `--vendored <dest>=<repoDir>@<commit>:<src>` arguments it
|
|
15
|
+
* hands `compute-image-tag.mjs`. It is therefore the ONLY source of the vendored
|
|
16
|
+
* commit that can agree with the tag CI pushes.
|
|
17
|
+
*/
|
|
18
|
+
export const CROSS_REPO_BUILD_INPUTS_PATH =
|
|
19
|
+
'tooling/release/cross-repo-build-inputs.json';
|
|
20
|
+
|
|
21
|
+
/** Identity of one vendored input — the tuple that survives a pin bump. */
|
|
22
|
+
export function crossRepoInputKey(input) {
|
|
23
|
+
return [input.sourceRepo, input.sourcePath, input.destinationPath].join('\u0000');
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The `[sourceRepo, sourcePath, destinationPath]` a `crossRepoInputKey` frames. */
|
|
27
|
+
export function splitCrossRepoInputKey(key) {
|
|
28
|
+
return key.split('\u0000');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The consumer's record of what it vendored, read at ONE commit of the consumer
|
|
33
|
+
* (never the work tree): a map from `crossRepoInputKey` to the vendored
|
|
34
|
+
* `sourceCommit`. Unreadable is an error, not an empty record.
|
|
35
|
+
*/
|
|
36
|
+
export function readCrossRepoBuildInputsRecord({
|
|
37
|
+
consumerRepo,
|
|
38
|
+
consumerRepoDir,
|
|
39
|
+
consumerCommit,
|
|
40
|
+
}) {
|
|
41
|
+
let record;
|
|
42
|
+
try {
|
|
43
|
+
record = readGitJsonAtCommit(
|
|
44
|
+
consumerRepoDir,
|
|
45
|
+
CROSS_REPO_BUILD_INPUTS_PATH,
|
|
46
|
+
consumerCommit,
|
|
47
|
+
);
|
|
48
|
+
} catch (cause) {
|
|
49
|
+
throw new Error(
|
|
50
|
+
`"${consumerRepo}" declares cross-repo build inputs but ` +
|
|
51
|
+
`${CROSS_REPO_BUILD_INPUTS_PATH} is unreadable at ${consumerCommit}. ` +
|
|
52
|
+
`That file is what its CI feeds to compute-image-tag.mjs; without it ` +
|
|
53
|
+
`no coordinate this tooling derives can match the tag CI pushes.`,
|
|
54
|
+
{ cause },
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
const mirrored = new Map();
|
|
58
|
+
for (const input of record?.inputs ?? []) {
|
|
59
|
+
mirrored.set(crossRepoInputKey(input), input.sourceCommit);
|
|
60
|
+
}
|
|
61
|
+
return mirrored;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Resolve the vendored inputs of ONE consumer repository at ONE commit.
|
|
66
|
+
*
|
|
67
|
+
* WHY THE COMMIT COMES FROM THE CONSUMER, NOT FROM THE INDEX
|
|
68
|
+
*
|
|
69
|
+
* The index's `crossRepoBuildInputs` carry no `sourceCommit`; they name only
|
|
70
|
+
* WHICH content is vendored. Both this tooling and the parity gate used to
|
|
71
|
+
* supply the missing commit from `runtimeSourceIndex.repositoryPins`, and CI
|
|
72
|
+
* supplies it from the file above — so the two disagreed the moment a consumer
|
|
73
|
+
* repository's mirror lagged the index by one pin, which is its NORMAL resting
|
|
74
|
+
* state: the mirror is generated FROM the index and committed separately, so it
|
|
75
|
+
* is stale by construction between the index moving and the consumer
|
|
76
|
+
* regenerating.
|
|
77
|
+
*
|
|
78
|
+
* That is not a cosmetic disagreement. `crossRepo.sourceCommit` is framed into
|
|
79
|
+
* the component KEY of the image-input hash, so a pin that differs by one
|
|
80
|
+
* commit yields a different `contentHash` FOR BYTE-IDENTICAL VENDORED CONTENT —
|
|
81
|
+
* a coordinate no CI run has ever pushed or ever will. Measured 2026-09-07 on
|
|
82
|
+
* `origin/main`: five of the sixty-eight coordinates in the eight editions
|
|
83
|
+
* (`backlog-api`, `channel-gateway-api`, `control-plane-api`,
|
|
84
|
+
* `document-render-api` from repos/xema-community, and `biome-host-api` from
|
|
85
|
+
* repos/xema-base) 404'd in the registry for exactly this reason, while the tags
|
|
86
|
+
* their producing CI DID push were present and served on both platforms.
|
|
87
|
+
*
|
|
88
|
+
* Reading the index pin also imposed a requirement that cannot be satisfied.
|
|
89
|
+
* `repos/xema-base` vendors from `repos/xema-community` and vice versa, so
|
|
90
|
+
* "every mirror agrees with the index" is a mutual fixed point over a 2-cycle,
|
|
91
|
+
* and bumping either side invalidates the other. No such pair exists: scanning
|
|
92
|
+
* eighty commits of each on `origin/main` found ZERO mutually coherent pairs.
|
|
93
|
+
* Reading the consumer's own record removes the fixed point entirely — an
|
|
94
|
+
* image's identity depends only on the repository that builds it.
|
|
95
|
+
*
|
|
96
|
+
* It stays edition-INDEPENDENT, which is the property the previous design was
|
|
97
|
+
* protecting: the record is a property of the consumer's COMMIT, not of any
|
|
98
|
+
* edition's resolved commit map, so every edition shipping a subject still
|
|
99
|
+
* derives the same `contentHash` for it.
|
|
100
|
+
*/
|
|
101
|
+
export function resolveCrossRepoVendoredInputs({
|
|
102
|
+
indexInputs,
|
|
103
|
+
consumerRepo,
|
|
104
|
+
consumerRepoDir,
|
|
105
|
+
consumerCommit,
|
|
106
|
+
}) {
|
|
107
|
+
const declared = indexInputs.filter(
|
|
108
|
+
(input) => input.consumerRepo === consumerRepo,
|
|
109
|
+
);
|
|
110
|
+
if (declared.length === 0) {return [];}
|
|
111
|
+
if (!consumerCommit) {
|
|
112
|
+
throw new Error(
|
|
113
|
+
`cross-repo build inputs are declared for "${consumerRepo}", but this ` +
|
|
114
|
+
`distribution resolves no commit for it — the vendored content cannot ` +
|
|
115
|
+
`be pinned, so the image cannot be addressed`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
const mirrored = readCrossRepoBuildInputsRecord({
|
|
119
|
+
consumerRepo,
|
|
120
|
+
consumerRepoDir,
|
|
121
|
+
consumerCommit,
|
|
122
|
+
});
|
|
123
|
+
// Set equality in BOTH directions. A commit that LAGS is normal and is now
|
|
124
|
+
// honoured; a differing input SET is a genuine declaration disagreement, and
|
|
125
|
+
// it moves the hash just as surely — so it must fail rather than resolve to a
|
|
126
|
+
// coordinate built from a different closure.
|
|
127
|
+
const declaredKeys = new Set(declared.map((input) => crossRepoInputKey(input)));
|
|
128
|
+
const resolved = declared.map((input) => {
|
|
129
|
+
const sourceCommit = mirrored.get(crossRepoInputKey(input));
|
|
130
|
+
if (!sourceCommit) {
|
|
131
|
+
throw new Error(
|
|
132
|
+
`the runtime image source index declares a cross-repo build input for ` +
|
|
133
|
+
`"${consumerRepo}" vendoring "${input.sourceRepo}:${input.sourcePath}" ` +
|
|
134
|
+
`to "${input.destinationPath}", but ${CROSS_REPO_BUILD_INPUTS_PATH} at ` +
|
|
135
|
+
`${consumerCommit} declares no such input — its CI does not vendor it, ` +
|
|
136
|
+
`so the coordinate this tooling would derive is one nothing builds`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return {
|
|
140
|
+
consumerService: input.consumerService ?? null,
|
|
141
|
+
destinationPath: input.destinationPath,
|
|
142
|
+
sourceRepoDir: sourcePath(input.sourceRepo),
|
|
143
|
+
sourcePath: input.sourcePath,
|
|
144
|
+
sourceCommit,
|
|
145
|
+
};
|
|
146
|
+
});
|
|
147
|
+
for (const key of mirrored.keys()) {
|
|
148
|
+
if (declaredKeys.has(key)) {continue;}
|
|
149
|
+
const [sourceRepo, sourcePath, destinationPath] = splitCrossRepoInputKey(key);
|
|
150
|
+
throw new Error(
|
|
151
|
+
`${CROSS_REPO_BUILD_INPUTS_PATH} at ${consumerCommit} vendors ` +
|
|
152
|
+
`"${sourceRepo}:${sourcePath}" to "${destinationPath}" for ` +
|
|
153
|
+
`"${consumerRepo}", which the runtime image source index does not ` +
|
|
154
|
+
`declare — its CI bakes content this tooling does not hash`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return resolved;
|
|
158
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// develop-chart-set — the chart-set lock of a develop edition.
|
|
2
|
+
//
|
|
3
|
+
// A develop edition renders with the charts the publisher actually published:
|
|
4
|
+
// the committed release chart-set lock's entries (names, content versions and
|
|
5
|
+
// digests, and each index digest), unchanged. xema-deploy publishes charts per
|
|
6
|
+
// content version (`<n>-h<16 hex>`) for the commit its release set was closed
|
|
7
|
+
// at, so that commit is the only deploy commit whose charts exist in the
|
|
8
|
+
// registry. A develop edition frozen at that commit takes the release lock
|
|
9
|
+
// as it is, and asks the registry nothing about a chart's tag; a develop edition
|
|
10
|
+
// frozen at any other commit is refused, because no chart was published for it
|
|
11
|
+
// (a develop chart lane in xema-deploy is the owner's decision, not built).
|
|
12
|
+
|
|
13
|
+
import { basename } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { chartSetSourceCommits } from './chart-set-source-commit.mjs';
|
|
16
|
+
import { assertRosteredChartSetLock } from './edition-chart-set.mjs';
|
|
17
|
+
import { COMMIT_PATTERN } from './evidence-grammars.mjs';
|
|
18
|
+
import { deployPinKey } from './pin-source.mjs';
|
|
19
|
+
import { CHART_SET_LOCK_FILE, chartRegistryClient, chartSetLockPath, readLock } from './resolve-chart-set.mjs';
|
|
20
|
+
|
|
21
|
+
/** The source commits of `lock`'s publishers, read from their index charts at the digests the lock pins. */
|
|
22
|
+
const registrySourceCommits = (lock) => chartSetSourceCommits({ client: chartRegistryClient(), lock });
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The develop chart-set lock for the deploy commit `xemaDeploySha`: the committed
|
|
26
|
+
* release lock, when that lock's deploy publisher closed its set at this commit.
|
|
27
|
+
* `readSourceCommits(lock)` defaults to the chart registry's index charts and
|
|
28
|
+
* returns `{publishers: [{repo, sourceCommit}]}`.
|
|
29
|
+
*
|
|
30
|
+
* TEMPORARY BRIDGE (owner: edition, lead D41 10-13): only the release set's
|
|
31
|
+
* source commit is resolvable here. Exit: develop charts resolved by content
|
|
32
|
+
* version (edition T37: the shared developChartVersionFromDigest, published by
|
|
33
|
+
* xema-deploy publish-charts on develop pushes), which replaces this function
|
|
34
|
+
* before t348-i2 lands.
|
|
35
|
+
*/
|
|
36
|
+
export async function resolveDevelopChartSet({ xemaDeploySha, releaseLock = readLock(), readSourceCommits = registrySourceCommits }) {
|
|
37
|
+
if (!COMMIT_PATTERN.test(xemaDeploySha ?? '')) {
|
|
38
|
+
throw new Error(`the deploy checkout SHA is not a 40-hex commit: ${JSON.stringify(xemaDeploySha)}`);
|
|
39
|
+
}
|
|
40
|
+
if (releaseLock === null) {
|
|
41
|
+
throw new Error(`${chartSetLockPath()} is missing or is not JSON: run 'xema edition chart-set resolve' first`);
|
|
42
|
+
}
|
|
43
|
+
assertRosteredChartSetLock(releaseLock, CHART_SET_LOCK_FILE);
|
|
44
|
+
const deployRepo = basename(deployPinKey());
|
|
45
|
+
const deployPublishers = releaseLock.publishers.filter((publisher) => basename(publisher.repo) === deployRepo);
|
|
46
|
+
if (deployPublishers.length !== 1) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`${deployPublishers.length} chart publishers of ${CHART_SET_LOCK_FILE} have repository ${deployRepo} (the deploy pin role); exactly one must, ` +
|
|
49
|
+
'since its source commit is the one commit whose charts were published',
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const { sourceCommit } = (await readSourceCommits(releaseLock)).publishers.find((publisher) => publisher.index === deployPublishers[0].index) ?? {};
|
|
53
|
+
if (sourceCommit !== xemaDeploySha) {
|
|
54
|
+
throw new Error(
|
|
55
|
+
`the develop deploy commit ${xemaDeploySha} is not the commit ${CHART_SET_LOCK_FILE} was published from (${sourceCommit}): ` +
|
|
56
|
+
'develop charts are only published for the release set\'s source commit, so no chart exists for this commit. ' +
|
|
57
|
+
'A develop chart lane in the deploy repository is required to develop at another commit (owner decision, not built)',
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
return releaseLock;
|
|
61
|
+
}
|
|
@@ -2,26 +2,26 @@
|
|
|
2
2
|
//
|
|
3
3
|
// The whole pipeline, in the order the release runs it, over the snapshot's
|
|
4
4
|
// injected inputs: distribution lock -> authorization base (composed, never
|
|
5
|
-
// written) -> images lock -> develop seal, plus the chart-set lock: the
|
|
6
|
-
//
|
|
5
|
+
// written) -> images lock -> develop seal, plus the chart-set lock: the release
|
|
6
|
+
// lock's published charts (develop-chart-set.mjs). Nothing here writes to the editions
|
|
7
7
|
// trees; the caller writes the artifact (edition-artifact.mjs).
|
|
8
8
|
|
|
9
9
|
import { adapterHostRow, assertAdapterBundlesMatchHost, fetchRegistryManifest } from './adapter-bundled-versions.mjs';
|
|
10
10
|
import { biomeSourceTreeIds, sealLock } from './biome-content.mjs';
|
|
11
11
|
import { buildCatalogue } from './catalogue.mjs';
|
|
12
|
+
import { resolveDevelopChartSet } from './develop-chart-set.mjs';
|
|
12
13
|
import { sealDevelopImages } from './develop-seal.mjs';
|
|
13
14
|
import { assertFrozenCheckouts, buildSnapshotInputs } from './develop-snapshot-inputs.mjs';
|
|
14
15
|
import { EditionChannel } from './edition-constants.mjs';
|
|
15
|
-
import {
|
|
16
|
+
import { kernelPackageDir } from './edition-workspace.mjs';
|
|
16
17
|
import { kernelPackageOfAuthority } from './kernel-package.mjs';
|
|
17
18
|
import { loadKernelDistributionSchemas } from './lib.mjs';
|
|
18
19
|
import { deployPinKey } from './pin-source.mjs';
|
|
19
|
-
import { resolveDevelopChartSet } from './resolve-chart-set.mjs';
|
|
20
20
|
import { composeAuthorizationBase, resolveDistributionWithIndex } from './resolve-distribution.mjs';
|
|
21
21
|
import { externalSourceTrees, resolveImages, sourceTreeLocator } from './resolve-images.mjs';
|
|
22
22
|
import { createPinnedSourceAuthority } from './source-authority.mjs';
|
|
23
23
|
|
|
24
|
-
export async function resolveDevelopEdition({ id, pins, env = process.env, sealOptions = {},
|
|
24
|
+
export async function resolveDevelopEdition({ id, pins, env = process.env, sealOptions = {}, readSourceCommits, fetchKernelTarball, fetchManifest = fetchRegistryManifest }) {
|
|
25
25
|
const schemas = await loadKernelDistributionSchemas();
|
|
26
26
|
assertFrozenCheckouts(pins);
|
|
27
27
|
const inputs = await buildSnapshotInputs({ id, pins, schemas });
|
|
@@ -41,9 +41,8 @@ export async function resolveDevelopEdition({ id, pins, env = process.env, sealO
|
|
|
41
41
|
vendoredCommit: (repo) => authority.vendoredSourceCommitForRepository(repo),
|
|
42
42
|
});
|
|
43
43
|
const chartSetLock = await resolveDevelopChartSet({
|
|
44
|
-
xemaDeployDir: deployRoot(),
|
|
45
44
|
xemaDeploySha: pins[deployPinKey()],
|
|
46
|
-
|
|
45
|
+
...(readSourceCommits === undefined ? {} : { readSourceCommits }),
|
|
47
46
|
});
|
|
48
47
|
const sourceTreeIds = await biomeSourceTreeIds({
|
|
49
48
|
schemas,
|
package/edition/dist-check.mjs
CHANGED
|
@@ -37,7 +37,6 @@ import {
|
|
|
37
37
|
loadKernelAuthorizationComposer,
|
|
38
38
|
} from './authorization-base.mjs';
|
|
39
39
|
import { verifyBiomeContentHashes } from './biome-content.mjs';
|
|
40
|
-
import { EditionChannel } from './edition-channel.mjs';
|
|
41
40
|
import {
|
|
42
41
|
EDITION_CHART_SET_FILE,
|
|
43
42
|
EditionChartSetCheck,
|
|
@@ -951,7 +950,7 @@ async function main() {
|
|
|
951
950
|
let chartSetPinText = null;
|
|
952
951
|
try {
|
|
953
952
|
chartSetPinText = readFileSync(fleetChartSetLockPath(), 'utf8');
|
|
954
|
-
assertRosteredChartSetLock(JSON.parse(chartSetPinText), EDITION_CHART_SET_FILE
|
|
953
|
+
assertRosteredChartSetLock(JSON.parse(chartSetPinText), EDITION_CHART_SET_FILE);
|
|
955
954
|
} catch (err) {
|
|
956
955
|
console.error(`[dist:check] chart-set pin: ${err.message}. Run \`${commandFor('resolve-chart-set.mjs')}\`.`);
|
|
957
956
|
failed = true;
|
|
@@ -276,7 +276,7 @@ export function writeEditionArtifact({
|
|
|
276
276
|
throw new Error(`edition "${distributionId}" has no ${file}: an edition carries all four locks`);
|
|
277
277
|
}
|
|
278
278
|
}
|
|
279
|
-
assertRosteredChartSetLock(chartSetLock, EDITION_CHART_SET_FILE
|
|
279
|
+
assertRosteredChartSetLock(chartSetLock, EDITION_CHART_SET_FILE);
|
|
280
280
|
const descriptor = editionDescriptor({ distributionId, channel, pins, workflow, evidenceTrustPolicy, engineImage });
|
|
281
281
|
mkdirSync(outDir, { recursive: true });
|
|
282
282
|
for (const [file, value] of [[EDITION_DESCRIPTOR_FILE, descriptor], ...byFile]) {
|
|
@@ -26,7 +26,6 @@
|
|
|
26
26
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
|
27
27
|
import { join } from 'node:path';
|
|
28
28
|
|
|
29
|
-
import { EditionChannel } from './edition-channel.mjs';
|
|
30
29
|
import { dataRoot } from './edition-workspace.mjs';
|
|
31
30
|
|
|
32
31
|
export const EDITION_CHART_SET_FILE = 'chart-set.lock.json';
|
|
@@ -39,38 +38,12 @@ export const EditionChartSetCheck = Object.freeze({
|
|
|
39
38
|
STALE: 'stale',
|
|
40
39
|
});
|
|
41
40
|
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
* hash) in a release chart set, `-dev.<12 hex>` (the frozen xema-deploy SHA) in a
|
|
45
|
-
* develop one. One lock SHAPE serves both; the coordinate is what the producer
|
|
46
|
-
* of each channel writes, so each producer validates its own channel's (this is
|
|
47
|
-
* a check of what is being written, not a branch of any reader).
|
|
48
|
-
*/
|
|
49
|
-
const CHART_VERSION_COORDINATE = Object.freeze({
|
|
50
|
-
[EditionChannel.Release]: /-h[0-9a-f]{16}$/,
|
|
51
|
-
[EditionChannel.Develop]: /-dev\.[0-9a-f]{12}$/,
|
|
52
|
-
});
|
|
41
|
+
/** A published chart's version coordinate: `-h<16 hex>`, the content hash its publisher mints. A develop chart set takes the release set's charts, so it carries the same coordinate. */
|
|
42
|
+
const CHART_VERSION_COORDINATE = /-h[0-9a-f]{16}$/;
|
|
53
43
|
|
|
54
44
|
/** The chart-set lock schema every reader accepts; a lock of any other schema is re-resolved, never migrated. */
|
|
55
45
|
export const CHART_SET_LOCK_SCHEMA_VERSION = 4;
|
|
56
46
|
|
|
57
|
-
/** The version prefix of a develop snapshot chart set (`0.0.0-dev.<sha12>`): xema-deploy's frozen-SHA charts, which have no index chart. */
|
|
58
|
-
export const DEVELOP_CHART_VERSION_PREFIX = '0.0.0-dev.';
|
|
59
|
-
|
|
60
|
-
/** The length of the lowercase-hex xema-deploy SHA a develop snapshot version carries. */
|
|
61
|
-
export const DEVELOP_CHART_SHA_LENGTH = 12;
|
|
62
|
-
|
|
63
|
-
/** The full develop snapshot version grammar: `0.0.0-dev.<12 lowercase hex>`, anchored. */
|
|
64
|
-
export const DEVELOP_CHART_VERSION_PATTERN = new RegExp(
|
|
65
|
-
`^${DEVELOP_CHART_VERSION_PREFIX.replaceAll('.', '\\.')}[0-9a-f]{${DEVELOP_CHART_SHA_LENGTH}}$`,
|
|
66
|
-
);
|
|
67
|
-
|
|
68
|
-
/** Does `version` start with the develop prefix, whether or not it is the full develop grammar? */
|
|
69
|
-
export const hasDevelopChartVersionPrefix = (version) => typeof version === 'string' && version.startsWith(DEVELOP_CHART_VERSION_PREFIX);
|
|
70
|
-
|
|
71
|
-
/** Is `version` a develop snapshot version? The one predicate for "this publisher has no index chart". */
|
|
72
|
-
export const isDevelopChartVersion = (version) => typeof version === 'string' && DEVELOP_CHART_VERSION_PATTERN.test(version);
|
|
73
|
-
|
|
74
47
|
/** An OCI manifest digest: `sha256:` and 64 lowercase hex. */
|
|
75
48
|
export const OCI_DIGEST_PATTERN = /^sha256:[a-f0-9]{64}$/;
|
|
76
49
|
|
|
@@ -79,15 +52,10 @@ export const RESOLVE_CHART_SET_INSTRUCTION = 're-resolve the chart set with `xem
|
|
|
79
52
|
|
|
80
53
|
/**
|
|
81
54
|
* A chart-set lock (schemaVersion 4) that pins every member by version and OCI
|
|
82
|
-
* manifest digest, with
|
|
83
|
-
* index chart by `indexDigest` (
|
|
84
|
-
* which has no index chart; the release channel refuses null).
|
|
55
|
+
* manifest digest, with the `-h<16 hex>` content coordinate, and pins each
|
|
56
|
+
* publisher's index chart by `indexDigest` (always a sha256 digest).
|
|
85
57
|
*/
|
|
86
|
-
export function assertRosteredChartSetLock(lock, label
|
|
87
|
-
const coordinate = CHART_VERSION_COORDINATE[channel];
|
|
88
|
-
if (coordinate === undefined) {
|
|
89
|
-
throw new Error(`${label}: cannot validate a chart-set lock for channel ${JSON.stringify(channel)}; expected ${Object.values(EditionChannel).join(' | ')}`);
|
|
90
|
-
}
|
|
58
|
+
export function assertRosteredChartSetLock(lock, label) {
|
|
91
59
|
const fail = (why) => {
|
|
92
60
|
throw new Error(`${label} is not a rostered chart-set lock: ${why}`);
|
|
93
61
|
};
|
|
@@ -101,30 +69,20 @@ export function assertRosteredChartSetLock(lock, label, channel) {
|
|
|
101
69
|
}
|
|
102
70
|
for (const [name, member] of Object.entries(pin.charts)) {
|
|
103
71
|
if (!member || typeof member !== 'object' || Array.isArray(member)) {fail(`${pin.index}: ${name} must be {version, digest}`);}
|
|
104
|
-
if (!
|
|
72
|
+
if (!CHART_VERSION_COORDINATE.test(String(member.version))) {fail(`${pin.index}: ${name} has no content coordinate (${member.version}); a chart set carries ${CHART_VERSION_COORDINATE}`);}
|
|
105
73
|
if (!OCI_DIGEST_PATTERN.test(String(member.digest))) {fail(`${pin.index}: ${name} has no sha256 manifest digest`);}
|
|
106
74
|
}
|
|
107
|
-
assertIndexDigest(pin, position,
|
|
75
|
+
assertIndexDigest(pin, position, fail);
|
|
108
76
|
}
|
|
109
77
|
return lock;
|
|
110
78
|
}
|
|
111
79
|
|
|
112
|
-
/** The `indexDigest` rule:
|
|
113
|
-
function assertIndexDigest(pin, position,
|
|
80
|
+
/** The `indexDigest` rule: the key is present and is a sha256 digest. */
|
|
81
|
+
function assertIndexDigest(pin, position, fail) {
|
|
114
82
|
const where = `publisher ${position} (${pin?.index ?? '?'})`;
|
|
115
83
|
if (!pin || typeof pin !== 'object' || !Object.hasOwn(pin, 'indexDigest')) {
|
|
116
|
-
fail(`${where} carries no indexDigest key (required: "sha256:<64 hex>"
|
|
117
|
-
}
|
|
118
|
-
if (hasDevelopChartVersionPrefix(pin.version) && !isDevelopChartVersion(pin.version)) {
|
|
119
|
-
fail(`${where} has malformed develop chart version ${JSON.stringify(pin.version)}; a develop version is exactly ${DEVELOP_CHART_VERSION_PREFIX}<${DEVELOP_CHART_SHA_LENGTH} lowercase hex>`);
|
|
120
|
-
}
|
|
121
|
-
const develop = isDevelopChartVersion(pin.version);
|
|
122
|
-
if (pin.indexDigest === null) {
|
|
123
|
-
if (!develop) {fail(`${where} has indexDigest null with version ${JSON.stringify(pin.version)}; null is only for a develop snapshot version (${DEVELOP_CHART_VERSION_PREFIX}<sha12>)`);}
|
|
124
|
-
if (channel === EditionChannel.Release) {fail(`${where} has indexDigest null, which a release chart set refuses; a release index chart is pinned by digest`);}
|
|
125
|
-
return;
|
|
84
|
+
fail(`${where} carries no indexDigest key (required: "sha256:<64 hex>")`);
|
|
126
85
|
}
|
|
127
|
-
if (develop) {fail(`${where} has an indexDigest with develop snapshot version ${pin.version}; a develop snapshot has no index chart, so indexDigest must be null`);}
|
|
128
86
|
if (!OCI_DIGEST_PATTERN.test(String(pin.indexDigest))) {fail(`${where} has a malformed indexDigest (${JSON.stringify(pin.indexDigest)}); expected "sha256:<64 lowercase hex>"`);}
|
|
129
87
|
}
|
|
130
88
|
|
|
@@ -134,7 +92,7 @@ function assertIndexDigest(pin, position, channel, fail) {
|
|
|
134
92
|
* coordinates is written.
|
|
135
93
|
*/
|
|
136
94
|
export function writeEditionChartSet(editionDir, pinText) {
|
|
137
|
-
assertRosteredChartSetLock(JSON.parse(pinText), EDITION_CHART_SET_FILE
|
|
95
|
+
assertRosteredChartSetLock(JSON.parse(pinText), EDITION_CHART_SET_FILE);
|
|
138
96
|
writeFileSync(join(editionDir, EDITION_CHART_SET_FILE), pinText);
|
|
139
97
|
}
|
|
140
98
|
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
import { createRequire } from 'node:module';
|
|
3
|
+
|
|
4
|
+
import { parseStrictJson } from './strict-json.mjs';
|
|
5
|
+
|
|
6
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
7
|
+
|
|
8
|
+
let loadedSourceHash;
|
|
9
|
+
|
|
10
|
+
/** Git-object primitives of `@xemahq/distribution-source-hash`; no kernel is consulted. */
|
|
11
|
+
function gitObjectReader() {
|
|
12
|
+
loadedSourceHash ??= requireFromHere('@xemahq/distribution-source-hash');
|
|
13
|
+
return loadedSourceHash;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Read + parse a JSON file out of an exact Git commit (never the work tree).
|
|
18
|
+
*
|
|
19
|
+
* This is the seam every authored source file crosses on its way into a lock
|
|
20
|
+
* or an index — `xema-biome.json`, a platform service's `package.json`,
|
|
21
|
+
* `cross-repo-build-inputs.json`, the committed generated biome index — so it
|
|
22
|
+
* parses STRICTLY: a duplicated object key is refused, naming the commit, the
|
|
23
|
+
* path and the JSON pointer of the object. `JSON.parse` would keep the last
|
|
24
|
+
* occurrence, which is how a `resources` declaration was dropped from a sealed
|
|
25
|
+
* lock with every reader agreeing (see `strict-json.mjs`).
|
|
26
|
+
*
|
|
27
|
+
* The commit check, the buffer ceiling and the exact-UTF-8 decode are the
|
|
28
|
+
* source-hash package's own (loaded WITHOUT the kernel check hashing needs, so
|
|
29
|
+
* the source checkout, which runs before any kernel is built, can read a record
|
|
30
|
+
* too); only the parse differs from its
|
|
31
|
+
* `readGitJsonAtCommit`, which is lenient and is therefore not delegated to.
|
|
32
|
+
*/
|
|
33
|
+
export function readGitJsonAtCommit(repoDir, repoRelativePath, commit) {
|
|
34
|
+
const sourceHash = gitObjectReader();
|
|
35
|
+
sourceHash.assertGitCommitExists(repoDir, commit);
|
|
36
|
+
let raw;
|
|
37
|
+
try {
|
|
38
|
+
raw = execFileSync(
|
|
39
|
+
'git',
|
|
40
|
+
['-C', repoDir, 'show', `${commit}:${repoRelativePath}`],
|
|
41
|
+
{
|
|
42
|
+
encoding: 'buffer',
|
|
43
|
+
maxBuffer: sourceHash.GIT_OBJECT_MAX_BUFFER_BYTES,
|
|
44
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
} catch {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`cannot read "${repoRelativePath}" from Git commit "${commit}"`,
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
const context = `Git object "${commit}:${repoRelativePath}"`;
|
|
53
|
+
return parseStrictJson(sourceHash.decodeExactUtf8(raw, context), context);
|
|
54
|
+
}
|
package/edition/lib.mjs
CHANGED
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
sourcePath,
|
|
31
31
|
} from './edition-workspace.mjs';
|
|
32
32
|
import { SHA256_HEX_PATTERN } from './evidence-grammars.mjs';
|
|
33
|
+
import { readGitJsonAtCommit } from './git-json.mjs';
|
|
33
34
|
import { isKernelCapabilityDomain } from './kernel-capability-domain.mjs';
|
|
34
35
|
import {
|
|
35
36
|
assertPrivilegesHavePrincipals,
|
|
@@ -42,7 +43,7 @@ import {
|
|
|
42
43
|
PUBLISHED_SOURCE_REF,
|
|
43
44
|
publishedSourceCommit,
|
|
44
45
|
} from './source-authority.mjs';
|
|
45
|
-
import {
|
|
46
|
+
import { readStrictJsonFile } from './strict-json.mjs';
|
|
46
47
|
import { compareStrings } from './string-order.mjs';
|
|
47
48
|
|
|
48
49
|
export {
|
|
@@ -51,6 +52,7 @@ export {
|
|
|
51
52
|
DistributionSourceMode,
|
|
52
53
|
PUBLISHED_SOURCE_REF,
|
|
53
54
|
publishedSourceCommit,
|
|
55
|
+
readGitJsonAtCommit,
|
|
54
56
|
};
|
|
55
57
|
|
|
56
58
|
|
|
@@ -3063,167 +3065,3 @@ export function hashImageBuildInputs(request, algorithm) {
|
|
|
3063
3065
|
return distributionSourceHash().hashImageBuildInputs(request, algorithm);
|
|
3064
3066
|
}
|
|
3065
3067
|
|
|
3066
|
-
/**
|
|
3067
|
-
* Read + parse a JSON file out of an exact Git commit (never the work tree).
|
|
3068
|
-
*
|
|
3069
|
-
* This is the seam every authored source file crosses on its way into a lock
|
|
3070
|
-
* or an index — `xema-biome.json`, a platform service's `package.json`,
|
|
3071
|
-
* `cross-repo-build-inputs.json`, the committed generated biome index — so it
|
|
3072
|
-
* parses STRICTLY: a duplicated object key is refused, naming the commit, the
|
|
3073
|
-
* path and the JSON pointer of the object. `JSON.parse` would keep the last
|
|
3074
|
-
* occurrence, which is how a `resources` declaration was dropped from a sealed
|
|
3075
|
-
* lock with every reader agreeing (see `strict-json.mjs`).
|
|
3076
|
-
*
|
|
3077
|
-
* The commit check, the buffer ceiling and the exact-UTF-8 decode are the
|
|
3078
|
-
* source-hash package's own; only the parse differs from its
|
|
3079
|
-
* `readGitJsonAtCommit`, which is lenient and is therefore not delegated to.
|
|
3080
|
-
*/
|
|
3081
|
-
export function readGitJsonAtCommit(repoDir, repoRelativePath, commit) {
|
|
3082
|
-
const sourceHash = distributionSourceHash();
|
|
3083
|
-
sourceHash.assertGitCommitExists(repoDir, commit);
|
|
3084
|
-
let raw;
|
|
3085
|
-
try {
|
|
3086
|
-
raw = execFileSync(
|
|
3087
|
-
'git',
|
|
3088
|
-
['-C', repoDir, 'show', `${commit}:${repoRelativePath}`],
|
|
3089
|
-
{
|
|
3090
|
-
encoding: 'buffer',
|
|
3091
|
-
maxBuffer: sourceHash.GIT_OBJECT_MAX_BUFFER_BYTES,
|
|
3092
|
-
stdio: ['ignore', 'pipe', 'pipe'],
|
|
3093
|
-
},
|
|
3094
|
-
);
|
|
3095
|
-
} catch {
|
|
3096
|
-
throw new Error(
|
|
3097
|
-
`cannot read "${repoRelativePath}" from Git commit "${commit}"`,
|
|
3098
|
-
);
|
|
3099
|
-
}
|
|
3100
|
-
const context = `Git object "${commit}:${repoRelativePath}"`;
|
|
3101
|
-
return parseStrictJson(sourceHash.decodeExactUtf8(raw, context), context);
|
|
3102
|
-
}
|
|
3103
|
-
|
|
3104
|
-
/**
|
|
3105
|
-
* The producing repository's OWN committed record of what it vendored.
|
|
3106
|
-
*
|
|
3107
|
-
* `vendor-cross-repo-inputs.mjs` reads exactly this file, at the commit being
|
|
3108
|
-
* built, to emit the `--vendored <dest>=<repoDir>@<commit>:<src>` arguments it
|
|
3109
|
-
* hands `compute-image-tag.mjs`. It is therefore the ONLY source of the vendored
|
|
3110
|
-
* commit that can agree with the tag CI pushes.
|
|
3111
|
-
*/
|
|
3112
|
-
export const CROSS_REPO_BUILD_INPUTS_PATH =
|
|
3113
|
-
'tooling/release/cross-repo-build-inputs.json';
|
|
3114
|
-
|
|
3115
|
-
/** Identity of one vendored input — the tuple that survives a pin bump. */
|
|
3116
|
-
export function crossRepoInputKey(input) {
|
|
3117
|
-
return [input.sourceRepo, input.sourcePath, input.destinationPath].join('\u0000');
|
|
3118
|
-
}
|
|
3119
|
-
|
|
3120
|
-
/**
|
|
3121
|
-
* Resolve the vendored inputs of ONE consumer repository at ONE commit.
|
|
3122
|
-
*
|
|
3123
|
-
* WHY THE COMMIT COMES FROM THE CONSUMER, NOT FROM THE INDEX
|
|
3124
|
-
*
|
|
3125
|
-
* The index's `crossRepoBuildInputs` carry no `sourceCommit`; they name only
|
|
3126
|
-
* WHICH content is vendored. Both this tooling and the parity gate used to
|
|
3127
|
-
* supply the missing commit from `runtimeSourceIndex.repositoryPins`, and CI
|
|
3128
|
-
* supplies it from the file above — so the two disagreed the moment a consumer
|
|
3129
|
-
* repository's mirror lagged the index by one pin, which is its NORMAL resting
|
|
3130
|
-
* state: the mirror is generated FROM the index and committed separately, so it
|
|
3131
|
-
* is stale by construction between the index moving and the consumer
|
|
3132
|
-
* regenerating.
|
|
3133
|
-
*
|
|
3134
|
-
* That is not a cosmetic disagreement. `crossRepo.sourceCommit` is framed into
|
|
3135
|
-
* the component KEY of the image-input hash, so a pin that differs by one
|
|
3136
|
-
* commit yields a different `contentHash` FOR BYTE-IDENTICAL VENDORED CONTENT —
|
|
3137
|
-
* a coordinate no CI run has ever pushed or ever will. Measured 2026-09-07 on
|
|
3138
|
-
* `origin/main`: five of the sixty-eight coordinates in the eight editions
|
|
3139
|
-
* (`backlog-api`, `channel-gateway-api`, `control-plane-api`,
|
|
3140
|
-
* `document-render-api` from repos/xema-community, and `biome-host-api` from
|
|
3141
|
-
* repos/xema-base) 404'd in the registry for exactly this reason, while the tags
|
|
3142
|
-
* their producing CI DID push were present and served on both platforms.
|
|
3143
|
-
*
|
|
3144
|
-
* Reading the index pin also imposed a requirement that cannot be satisfied.
|
|
3145
|
-
* `repos/xema-base` vendors from `repos/xema-community` and vice versa, so
|
|
3146
|
-
* "every mirror agrees with the index" is a mutual fixed point over a 2-cycle,
|
|
3147
|
-
* and bumping either side invalidates the other. No such pair exists: scanning
|
|
3148
|
-
* eighty commits of each on `origin/main` found ZERO mutually coherent pairs.
|
|
3149
|
-
* Reading the consumer's own record removes the fixed point entirely — an
|
|
3150
|
-
* image's identity depends only on the repository that builds it.
|
|
3151
|
-
*
|
|
3152
|
-
* It stays edition-INDEPENDENT, which is the property the previous design was
|
|
3153
|
-
* protecting: the record is a property of the consumer's COMMIT, not of any
|
|
3154
|
-
* edition's resolved commit map, so every edition shipping a subject still
|
|
3155
|
-
* derives the same `contentHash` for it.
|
|
3156
|
-
*/
|
|
3157
|
-
export function resolveCrossRepoVendoredInputs({
|
|
3158
|
-
indexInputs,
|
|
3159
|
-
consumerRepo,
|
|
3160
|
-
consumerRepoDir,
|
|
3161
|
-
consumerCommit,
|
|
3162
|
-
}) {
|
|
3163
|
-
const declared = indexInputs.filter(
|
|
3164
|
-
(input) => input.consumerRepo === consumerRepo,
|
|
3165
|
-
);
|
|
3166
|
-
if (declared.length === 0) {return [];}
|
|
3167
|
-
if (!consumerCommit) {
|
|
3168
|
-
throw new Error(
|
|
3169
|
-
`cross-repo build inputs are declared for "${consumerRepo}", but this ` +
|
|
3170
|
-
`distribution resolves no commit for it — the vendored content cannot ` +
|
|
3171
|
-
`be pinned, so the image cannot be addressed`,
|
|
3172
|
-
);
|
|
3173
|
-
}
|
|
3174
|
-
let record;
|
|
3175
|
-
try {
|
|
3176
|
-
record = readGitJsonAtCommit(
|
|
3177
|
-
consumerRepoDir,
|
|
3178
|
-
CROSS_REPO_BUILD_INPUTS_PATH,
|
|
3179
|
-
consumerCommit,
|
|
3180
|
-
);
|
|
3181
|
-
} catch (cause) {
|
|
3182
|
-
throw new Error(
|
|
3183
|
-
`"${consumerRepo}" declares cross-repo build inputs but ` +
|
|
3184
|
-
`${CROSS_REPO_BUILD_INPUTS_PATH} is unreadable at ${consumerCommit}. ` +
|
|
3185
|
-
`That file is what its CI feeds to compute-image-tag.mjs; without it ` +
|
|
3186
|
-
`no coordinate this tooling derives can match the tag CI pushes.`,
|
|
3187
|
-
{ cause },
|
|
3188
|
-
);
|
|
3189
|
-
}
|
|
3190
|
-
const mirrored = new Map();
|
|
3191
|
-
for (const input of record?.inputs ?? []) {
|
|
3192
|
-
mirrored.set(crossRepoInputKey(input), input.sourceCommit);
|
|
3193
|
-
}
|
|
3194
|
-
// Set equality in BOTH directions. A commit that LAGS is normal and is now
|
|
3195
|
-
// honoured; a differing input SET is a genuine declaration disagreement, and
|
|
3196
|
-
// it moves the hash just as surely — so it must fail rather than resolve to a
|
|
3197
|
-
// coordinate built from a different closure.
|
|
3198
|
-
const declaredKeys = new Set(declared.map((input) => crossRepoInputKey(input)));
|
|
3199
|
-
const resolved = declared.map((input) => {
|
|
3200
|
-
const sourceCommit = mirrored.get(crossRepoInputKey(input));
|
|
3201
|
-
if (!sourceCommit) {
|
|
3202
|
-
throw new Error(
|
|
3203
|
-
`the runtime image source index declares a cross-repo build input for ` +
|
|
3204
|
-
`"${consumerRepo}" vendoring "${input.sourceRepo}:${input.sourcePath}" ` +
|
|
3205
|
-
`to "${input.destinationPath}", but ${CROSS_REPO_BUILD_INPUTS_PATH} at ` +
|
|
3206
|
-
`${consumerCommit} declares no such input — its CI does not vendor it, ` +
|
|
3207
|
-
`so the coordinate this tooling would derive is one nothing builds`,
|
|
3208
|
-
);
|
|
3209
|
-
}
|
|
3210
|
-
return {
|
|
3211
|
-
consumerService: input.consumerService ?? null,
|
|
3212
|
-
destinationPath: input.destinationPath,
|
|
3213
|
-
sourceRepoDir: sourcePath(input.sourceRepo),
|
|
3214
|
-
sourcePath: input.sourcePath,
|
|
3215
|
-
sourceCommit,
|
|
3216
|
-
};
|
|
3217
|
-
});
|
|
3218
|
-
for (const key of mirrored.keys()) {
|
|
3219
|
-
if (declaredKeys.has(key)) {continue;}
|
|
3220
|
-
const [sourceRepo, sourcePath, destinationPath] = key.split('\0');
|
|
3221
|
-
throw new Error(
|
|
3222
|
-
`${CROSS_REPO_BUILD_INPUTS_PATH} at ${consumerCommit} vendors ` +
|
|
3223
|
-
`"${sourceRepo}:${sourcePath}" to "${destinationPath}" for ` +
|
|
3224
|
-
`"${consumerRepo}", which the runtime image source index does not ` +
|
|
3225
|
-
`declare — its CI bakes content this tooling does not hash`,
|
|
3226
|
-
);
|
|
3227
|
-
}
|
|
3228
|
-
return resolved;
|
|
3229
|
-
}
|