@xemahq/xema 0.32.0 → 0.33.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/edition.d.ts.map +1 -1
- package/dist/commands/edition.js +3 -1
- package/dist/commands/edition.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +243 -160
- package/dist/edition/verify-image-evidence.bundle.provenance.json +46 -40
- package/dist/runtime/edition/verb-map.d.ts +3 -1
- package/dist/runtime/edition/verb-map.d.ts.map +1 -1
- package/dist/runtime/edition/verb-map.js +4 -0
- package/dist/runtime/edition/verb-map.js.map +1 -1
- package/edition/README.md +19 -0
- package/edition/checkout-indexed-source-repositories.mjs +57 -2
- package/edition/customer-definition.mjs +8 -0
- package/edition/develop-snapshot-inputs.mjs +4 -15
- package/edition/dist-check.mjs +11 -7
- package/edition/edition-biome-manifests.mjs +3 -2
- package/edition/edition-pin-verbs.mjs +183 -0
- package/edition/edition-pin.mjs +17 -0
- package/edition/edition-pinned-inputs.mjs +116 -0
- package/edition/edition-source-pins.mjs +209 -0
- package/edition/edition-unpin.mjs +16 -0
- package/edition/image-evidence.mjs +13 -7
- package/edition/indexes-at-pins.mjs +29 -0
- package/edition/lib.mjs +30 -9
- package/edition/release-catalogue.mjs +9 -8
- package/edition/release-edition-artifact.mjs +3 -2
- package/edition/resolve-distribution.mjs +13 -25
- package/edition/resolve-images.mjs +12 -11
- package/edition/seal-image-digests.mjs +12 -5
- package/edition/source-authority.mjs +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
// edition-pin-verbs — `xema edition pin` and `xema edition unpin`: the only
|
|
2
|
+
// supported way to change a vendor edition's `sourcePins` (edition-source-pins.mjs).
|
|
3
|
+
//
|
|
4
|
+
// `pin` validates the hold with the SAME checks a resolve applies
|
|
5
|
+
// (`effectiveRepositoryPins`: graph repository, not the kernel, not equal to the
|
|
6
|
+
// shared pin; `assertEditionPinsAuthorized`: commit present, contained in
|
|
7
|
+
// origin/main, built), writes the entry into the edition's definition keeping
|
|
8
|
+
// its formatting and key order, and re-resolves the edition. `unpin` removes one
|
|
9
|
+
// entry (or all), drops the key when empty, and re-resolves. Both are all or
|
|
10
|
+
// nothing: a failed re-resolve restores the edition directory byte for byte.
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { assertCanonicalDistributionId } from './distribution-paths.mjs';
|
|
16
|
+
import {
|
|
17
|
+
assertEditionPinsAuthorized,
|
|
18
|
+
effectiveRepositoryPins,
|
|
19
|
+
parseEditionSourcePins,
|
|
20
|
+
SOURCE_PINS_KEY,
|
|
21
|
+
} from './edition-source-pins.mjs';
|
|
22
|
+
import { editionsRoot, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
|
|
23
|
+
import { loadKernelDistributionSchemas } from './lib.mjs';
|
|
24
|
+
import { writeResolvedLockSet } from './resolve-edition.mjs';
|
|
25
|
+
import { loadBuildLedger } from './source-authority.mjs';
|
|
26
|
+
import { readStrictJsonFile } from './strict-json.mjs';
|
|
27
|
+
|
|
28
|
+
export const PIN_USAGE =
|
|
29
|
+
'usage: xema edition pin <edition> <repo> <ref> --until "<exit condition>" --data-root <dir> --editions-root <dir> --sources-root <dir> --kernel <dir>';
|
|
30
|
+
export const UNPIN_USAGE =
|
|
31
|
+
'usage: xema edition unpin <edition> [<repo>] --data-root <dir> --editions-root <dir> --sources-root <dir> --kernel <dir>';
|
|
32
|
+
|
|
33
|
+
const UNTIL_FLAG = '--until';
|
|
34
|
+
|
|
35
|
+
/** `{ positional, until }` from the arguments after the root flags. */
|
|
36
|
+
export function parsePinArgs(args) {
|
|
37
|
+
const positional = [];
|
|
38
|
+
let until;
|
|
39
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
40
|
+
const argument = args[i];
|
|
41
|
+
if (argument === UNTIL_FLAG || argument.startsWith(`${UNTIL_FLAG}=`)) {
|
|
42
|
+
if (until !== undefined) {throw new Error(`${UNTIL_FLAG} given twice\n${PIN_USAGE}`);}
|
|
43
|
+
until = argument === UNTIL_FLAG ? args[(i += 1)] : argument.slice(UNTIL_FLAG.length + 1);
|
|
44
|
+
} else if (argument.startsWith('--')) {
|
|
45
|
+
throw new Error(`unexpected argument ${JSON.stringify(argument)}\n${PIN_USAGE}`);
|
|
46
|
+
} else {
|
|
47
|
+
positional.push(argument);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return { positional, until };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const definitionPath = (id) => join(editionsRoot(), id, 'xema-distribution.json');
|
|
54
|
+
|
|
55
|
+
/** The file text and its parsed document; the text is what a rewrite preserves. */
|
|
56
|
+
function readDefinition(id) {
|
|
57
|
+
assertCanonicalDistributionId(id);
|
|
58
|
+
const path = definitionPath(id);
|
|
59
|
+
let text;
|
|
60
|
+
try {
|
|
61
|
+
text = readFileSync(path, 'utf8');
|
|
62
|
+
} catch {
|
|
63
|
+
throw new Error(`edition "${id}" not found: ${path} does not exist`);
|
|
64
|
+
}
|
|
65
|
+
return { path, text, document: readStrictJsonFile(path) };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The indent unit (a tab or the spaces of the first indented line) and the trailing newline of `text`. */
|
|
69
|
+
function formatOf(text) {
|
|
70
|
+
const indent = /^([ \t]+)\S/mu.exec(text)?.[1] ?? ' ';
|
|
71
|
+
return { indent, trailingNewline: text.endsWith('\n') };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** `document` serialized in the format of the file it was read from. */
|
|
75
|
+
function render(document, text) {
|
|
76
|
+
const { indent, trailingNewline } = formatOf(text);
|
|
77
|
+
return `${JSON.stringify(document, null, indent)}${trailingNewline ? '\n' : ''}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** The document with `sourcePins` set (appended last when new, in place when present) or, for none, removed. */
|
|
81
|
+
function withSourcePins(document, sourcePins) {
|
|
82
|
+
if (sourcePins.length === 0) {
|
|
83
|
+
const { [SOURCE_PINS_KEY]: _removed, ...rest } = document;
|
|
84
|
+
return rest;
|
|
85
|
+
}
|
|
86
|
+
return { ...document, [SOURCE_PINS_KEY]: sourcePins };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Every file under `directory` (relative path to bytes), the pre-command state a failed command returns to. */
|
|
90
|
+
function snapshotDirectory(directory) {
|
|
91
|
+
const files = new Map();
|
|
92
|
+
const walk = (current) => {
|
|
93
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
94
|
+
const path = join(current, entry.name);
|
|
95
|
+
if (entry.isDirectory()) {walk(path);} else {files.set(path, readFileSync(path));}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
walk(directory);
|
|
99
|
+
return files;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Return `directory` to `snapshot` byte for byte: rewrite what changed, delete what the command added. */
|
|
103
|
+
function restoreDirectory(directory, snapshot) {
|
|
104
|
+
const walk = (current) => {
|
|
105
|
+
for (const entry of readdirSync(current, { withFileTypes: true })) {
|
|
106
|
+
const path = join(current, entry.name);
|
|
107
|
+
if (entry.isDirectory()) {walk(path);} else if (!snapshot.has(path)) {rmSync(path);}
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
walk(directory);
|
|
111
|
+
for (const [path, bytes] of snapshot) {
|
|
112
|
+
if (!existsSync(path) || !readFileSync(path).equals(bytes)) {
|
|
113
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
114
|
+
writeFileSync(path, bytes);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Write the definition and re-resolve, all or nothing: on any failure the
|
|
121
|
+
* edition directory (definition and every lock the resolve wrote) is restored
|
|
122
|
+
* to its exact prior bytes and the original error is rethrown.
|
|
123
|
+
*/
|
|
124
|
+
async function writeAndReresolve({ id, path, rendered }) {
|
|
125
|
+
const directory = dirname(path);
|
|
126
|
+
const snapshot = snapshotDirectory(directory);
|
|
127
|
+
try {
|
|
128
|
+
writeFileSync(path, rendered);
|
|
129
|
+
await writeResolvedLockSet(id);
|
|
130
|
+
} catch (error) {
|
|
131
|
+
restoreDirectory(directory, snapshot);
|
|
132
|
+
throw error;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Hold `repo` of edition `id` at `ref` until `until`, then re-resolve it. An
|
|
138
|
+
* existing hold of the same repository is replaced.
|
|
139
|
+
*/
|
|
140
|
+
export async function pinEdition({ id, repo, ref, until, schemas, log = console.log }) {
|
|
141
|
+
const { path, text, document } = readDefinition(id);
|
|
142
|
+
const current = parseEditionSourcePins(document[SOURCE_PINS_KEY], id);
|
|
143
|
+
const entry = parseEditionSourcePins([{ repo, ref, until }], id)[0];
|
|
144
|
+
const sourcePins = current.some((pin) => pin.repo === repo)
|
|
145
|
+
? current.map((pin) => (pin.repo === repo ? entry : pin))
|
|
146
|
+
: [...current, entry];
|
|
147
|
+
const committed = schemas.parseRuntimeImageSourceIndex(readStrictJsonFile(runtimeImageSourceIndexPath()));
|
|
148
|
+
effectiveRepositoryPins(committed.repositoryPins, sourcePins, { id, contractAuthority: committed.contractAuthority });
|
|
149
|
+
assertEditionPinsAuthorized({ id, sourcePins: [entry], ledger: loadBuildLedger() });
|
|
150
|
+
await writeAndReresolve({ id, path, rendered: render(withSourcePins(document, sourcePins), text) });
|
|
151
|
+
log(`[edition:pin] ${id}: holding ${repo} @${ref.slice(0, 12)} until: ${until}`);
|
|
152
|
+
return sourcePins;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Remove the hold on `repo` (or every hold) of edition `id`, then re-resolve it. */
|
|
156
|
+
export async function unpinEdition({ id, repo, log = console.log }) {
|
|
157
|
+
const { path, text, document } = readDefinition(id);
|
|
158
|
+
const current = parseEditionSourcePins(document[SOURCE_PINS_KEY], id);
|
|
159
|
+
if (current.length === 0) {
|
|
160
|
+
log(`[edition:unpin] ${id}: holds nothing`);
|
|
161
|
+
return current;
|
|
162
|
+
}
|
|
163
|
+
if (repo !== undefined && !current.some((pin) => pin.repo === repo)) {
|
|
164
|
+
throw new Error(`edition "${id}" does not hold ${repo} (it holds: ${current.map((pin) => pin.repo).join(', ')})`);
|
|
165
|
+
}
|
|
166
|
+
const sourcePins = repo === undefined ? [] : current.filter((pin) => pin.repo !== repo);
|
|
167
|
+
await writeAndReresolve({ id, path, rendered: render(withSourcePins(document, sourcePins), text) });
|
|
168
|
+
log(`[edition:unpin] ${id}: released ${repo ?? current.map((pin) => pin.repo).join(', ')}`);
|
|
169
|
+
return sourcePins;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function runPin(args) {
|
|
173
|
+
const { positional, until } = parsePinArgs(args);
|
|
174
|
+
if (positional.length !== 3 || until === undefined) {throw new Error(PIN_USAGE);}
|
|
175
|
+
const [id, repo, ref] = positional;
|
|
176
|
+
await pinEdition({ id, repo, ref, until, schemas: await loadKernelDistributionSchemas() });
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export async function runUnpin(args) {
|
|
180
|
+
const { positional, until } = parsePinArgs(args);
|
|
181
|
+
if (positional.length < 1 || positional.length > 2 || until !== undefined) {throw new Error(UNPIN_USAGE);}
|
|
182
|
+
await unpinEdition({ id: positional[0], repo: positional[1] });
|
|
183
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `xema edition pin <edition> <repo> <ref> --until "<exit condition>"`: hold one
|
|
3
|
+
// repository of a vendor edition at an exact commit, then re-resolve the edition.
|
|
4
|
+
// The logic is edition-pin-verbs.mjs; see edition-source-pins.mjs for the trust it applies.
|
|
5
|
+
//
|
|
6
|
+
// usage: xema edition pin <edition> <repo> <ref> --until "<exit condition>" --data-root <dir> --editions-root <dir> --sources-root <dir> --kernel <dir>
|
|
7
|
+
|
|
8
|
+
import { runPin } from './edition-pin-verbs.mjs';
|
|
9
|
+
import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule } from './edition-workspace.mjs';
|
|
10
|
+
|
|
11
|
+
if (isEntryModule(import.meta.url)) {
|
|
12
|
+
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel]);
|
|
13
|
+
runPin(process.argv.slice(2)).catch((error) => {
|
|
14
|
+
console.error(`[edition:pin] ${error.message}`);
|
|
15
|
+
process.exit(1);
|
|
16
|
+
});
|
|
17
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// edition-pinned-inputs — the ONE per-edition source of the indexes an edition
|
|
2
|
+
// resolves from, and of the repository pins it resolves at.
|
|
3
|
+
//
|
|
4
|
+
// An edition that holds no repository (no `sourcePins`, edition-source-pins.mjs)
|
|
5
|
+
// reads the committed data-root files exactly as before: nothing is rebuilt.
|
|
6
|
+
// An edition that holds repositories has its three indexes rebuilt at ITS
|
|
7
|
+
// effective pins by the release builders (indexes-at-pins.mjs), after every
|
|
8
|
+
// held commit is authorized. Every per-edition reader (resolve, images, check,
|
|
9
|
+
// seal, catalogue, biome manifests) takes its indexes and pins from here, so a
|
|
10
|
+
// held edition carries the held commit everywhere and none can read the shared
|
|
11
|
+
// pin by the back door.
|
|
12
|
+
//
|
|
13
|
+
// A develop snapshot replaces ALL pins and does not come through here:
|
|
14
|
+
// `sourcePins` do not apply on the develop channel.
|
|
15
|
+
|
|
16
|
+
import { createHash } from 'node:crypto';
|
|
17
|
+
import { existsSync, readFileSync } from 'node:fs';
|
|
18
|
+
|
|
19
|
+
import { assertEditionPinsAuthorized, effectiveRepositoryPins, EditionSourcePinsError, readEditionSourcePins } from './edition-source-pins.mjs';
|
|
20
|
+
import { biomeIndexPath, dataRoot, editionsRoot, platformServiceIndexPath, runtimeImageSourceIndexPath, sourcesRoot } from './edition-workspace.mjs';
|
|
21
|
+
import { buildIndexesAtPins } from './indexes-at-pins.mjs';
|
|
22
|
+
import { commandFor } from './remedy-command.mjs';
|
|
23
|
+
import { committedRepositoryPins, loadBuildLedger } from './source-authority.mjs';
|
|
24
|
+
import { readStrictJsonFile } from './strict-json.mjs';
|
|
25
|
+
|
|
26
|
+
function readCommitted(path, remedy) {
|
|
27
|
+
if (!existsSync(path)) {
|
|
28
|
+
throw new Error(`${path} not found — ${remedy}`);
|
|
29
|
+
}
|
|
30
|
+
return readStrictJsonFile(path);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The bypass guard: the rebuilt runtime index must carry exactly the effective
|
|
35
|
+
* pins on both axes. A builder that re-decided a held repository would
|
|
36
|
+
* otherwise resolve it at a commit nobody authorized.
|
|
37
|
+
*/
|
|
38
|
+
function assertRebuiltPinsAreEffective({ id, runtimeImageSourceIndex, repositoryPins }) {
|
|
39
|
+
const rebuilt = new Map(runtimeImageSourceIndex.repositoryPins.map((pin) => [pin.repo, pin]));
|
|
40
|
+
const mismatches = repositoryPins.filter((pin) => {
|
|
41
|
+
const actual = rebuilt.get(pin.repo);
|
|
42
|
+
return actual === undefined || actual.sourceCommit !== pin.sourceCommit || actual.vendoredSourceCommit !== pin.vendoredSourceCommit;
|
|
43
|
+
});
|
|
44
|
+
if (mismatches.length > 0 || rebuilt.size !== repositoryPins.length) {
|
|
45
|
+
throw new EditionSourcePinsError(
|
|
46
|
+
`edition "${id}": the indexes rebuilt at its held pins do not carry the effective repository pins ` +
|
|
47
|
+
`(${mismatches.map((pin) => pin.repo).join(', ') || 'repository set differs'}); refusing to resolve`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const memo = new Map();
|
|
53
|
+
|
|
54
|
+
async function heldInputs({ id, sourcePins, committed, schemas }) {
|
|
55
|
+
const repositoryPins = effectiveRepositoryPins(committed.repositoryPins, sourcePins, { id, contractAuthority: committed.contractAuthority });
|
|
56
|
+
assertEditionPinsAuthorized({ id, sourcePins, ledger: loadBuildLedger() });
|
|
57
|
+
const built = await buildIndexesAtPins({ schemas, repositoryPins });
|
|
58
|
+
assertRebuiltPinsAreEffective({ id, runtimeImageSourceIndex: built.runtimeImageSourceIndex, repositoryPins });
|
|
59
|
+
return { ...built, repositoryPins, held: true, sourcePins };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* An edition that holds nothing reads the committed data-root files, each ONLY
|
|
64
|
+
* when a reader asks for it, as each reader read its own file before: a
|
|
65
|
+
* resolve needs no runtime image source index, a seal needs no platform-service
|
|
66
|
+
* index. A missing file refuses at the point of use, naming itself.
|
|
67
|
+
*/
|
|
68
|
+
function committedInputs({ schemas, sourcePins }) {
|
|
69
|
+
const cache = new Map();
|
|
70
|
+
const once = (name, read) => {
|
|
71
|
+
if (!cache.has(name)) {cache.set(name, read());}
|
|
72
|
+
return cache.get(name);
|
|
73
|
+
};
|
|
74
|
+
return {
|
|
75
|
+
get runtimeImageSourceIndex() {
|
|
76
|
+
return once('runtime', () => schemas.parseRuntimeImageSourceIndex(readStrictJsonFile(runtimeImageSourceIndexPath())));
|
|
77
|
+
},
|
|
78
|
+
get biomeIndex() {
|
|
79
|
+
return once('biome', () => readCommitted(biomeIndexPath(), `run \`${commandFor('build-biome-index.mjs')}\` first`));
|
|
80
|
+
},
|
|
81
|
+
get platformServiceIndex() {
|
|
82
|
+
return once('platform', () =>
|
|
83
|
+
schemas.parsePlatformServiceSourceIndex(
|
|
84
|
+
readCommitted(platformServiceIndexPath(), 'run the trusted platform-service index generator first'),
|
|
85
|
+
),
|
|
86
|
+
);
|
|
87
|
+
},
|
|
88
|
+
get repositoryPins() {
|
|
89
|
+
return effectiveRepositoryPins(committedRepositoryPins(), sourcePins);
|
|
90
|
+
},
|
|
91
|
+
held: false,
|
|
92
|
+
sourcePins,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* @param {string} id the edition
|
|
98
|
+
* @param {{ schemas: object }} kernel the loaded kernel distribution schemas
|
|
99
|
+
* @returns {Promise<{ runtimeImageSourceIndex, biomeIndex, platformServiceIndex, repositoryPins, held: boolean, sourcePins }>}
|
|
100
|
+
*
|
|
101
|
+
* The held (rebuilt) result is memoized per configured roots, edition, declared
|
|
102
|
+
* pins and committed runtime index, so one process pays the rebuild once.
|
|
103
|
+
*/
|
|
104
|
+
export async function editionPinnedInputs(id, { schemas }) {
|
|
105
|
+
const sourcePins = readEditionSourcePins(id);
|
|
106
|
+
if (sourcePins.length === 0) {return committedInputs({ schemas, sourcePins });}
|
|
107
|
+
const indexText = readFileSync(runtimeImageSourceIndexPath(), 'utf8');
|
|
108
|
+
const committed = schemas.parseRuntimeImageSourceIndex(readStrictJsonFile(runtimeImageSourceIndexPath()));
|
|
109
|
+
const key = JSON.stringify([dataRoot(), editionsRoot(), sourcesRoot(), id, sourcePins, createHash('sha256').update(indexText).digest('hex')]);
|
|
110
|
+
if (!memo.has(key)) {
|
|
111
|
+
const pending = heldInputs({ id, sourcePins, committed, schemas });
|
|
112
|
+
memo.set(key, pending);
|
|
113
|
+
pending.catch(() => memo.delete(key));
|
|
114
|
+
}
|
|
115
|
+
return memo.get(key);
|
|
116
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
// edition-source-pins — a vendor edition's per-edition source pins: the one
|
|
2
|
+
// place that parses the declaration, computes the effective repository pins and
|
|
3
|
+
// authorizes a held commit.
|
|
4
|
+
//
|
|
5
|
+
// A vendor edition's `xema-distribution.json` MAY carry the CLI-owned top-level
|
|
6
|
+
// key `sourcePins`, `[{ repo, ref, until }]`: this edition resolves `repo` at the
|
|
7
|
+
// exact commit `ref` instead of at the data root's shared `repositoryPins`, and
|
|
8
|
+
// `until` states the condition under which the hold ends. Entries are sparse:
|
|
9
|
+
// one exists only for a repository the edition holds back, so an entry equal to
|
|
10
|
+
// the shared pin is refused (it is not a hold). `xema edition pin|unpin` are the
|
|
11
|
+
// only supported way to change them. It is a
|
|
12
|
+
// CLI key, not a kernel one: the kernel's `parseDistribution` is strict, so the
|
|
13
|
+
// key is REMOVED before the kernel parse (the precedent is a customer
|
|
14
|
+
// definition's `imageRegistryMirror`, customer-definition.mjs), by the one
|
|
15
|
+
// splitter below. Every reader of an edition definition goes through it.
|
|
16
|
+
//
|
|
17
|
+
// `effectiveRepositoryPins` is the ONLY computation of an edition's pins: the
|
|
18
|
+
// committed pins untouched (the same array) when the edition holds nothing,
|
|
19
|
+
// else a copy whose held repositories carry the ref on BOTH axes (`sourceCommit`
|
|
20
|
+
// and `vendoredSourceCommit`), as `createExactCommitSourceAuthority` does.
|
|
21
|
+
//
|
|
22
|
+
// A held commit is authorized, never trusted (`assertEditionPinsAuthorized`):
|
|
23
|
+
// the commit object exists, it is contained in the published ref, and it is a
|
|
24
|
+
// BUILT commit (the same predicate `publishedSourceCommit` uses). No flag or
|
|
25
|
+
// path overrides any of the three.
|
|
26
|
+
//
|
|
27
|
+
// This module is pure of the index builders on purpose: the evidence verifier
|
|
28
|
+
// reads the declaration through it and must not bundle the release builders
|
|
29
|
+
// (edition-pinned-inputs.mjs owns those).
|
|
30
|
+
|
|
31
|
+
import { execFileSync } from 'node:child_process';
|
|
32
|
+
import { existsSync } from 'node:fs';
|
|
33
|
+
import { join, resolve } from 'node:path';
|
|
34
|
+
|
|
35
|
+
import { editionsRoot, sourcesRoot } from './edition-workspace.mjs';
|
|
36
|
+
import { COMMIT_PATTERN } from './evidence-grammars.mjs';
|
|
37
|
+
import { builtCommits, PUBLISHED_SOURCE_REF } from './source-authority.mjs';
|
|
38
|
+
import { readStrictJsonFile } from './strict-json.mjs';
|
|
39
|
+
|
|
40
|
+
export const SOURCE_PINS_KEY = 'sourcePins';
|
|
41
|
+
|
|
42
|
+
const SOURCE_PIN_KEYS = Object.freeze(['ref', 'repo', 'until']);
|
|
43
|
+
const GRAPH_REPO = /^(repos|submodules)\/[a-z0-9][a-z0-9-]*$/;
|
|
44
|
+
|
|
45
|
+
/** A refusal of an edition's `sourcePins` declaration or of a held commit. */
|
|
46
|
+
export class EditionSourcePinsError extends Error {
|
|
47
|
+
constructor(message, options) {
|
|
48
|
+
super(message, options);
|
|
49
|
+
this.name = 'EditionSourcePinsError';
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const label = (id) => (id === undefined ? '' : `edition "${id}": `);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Strictly parse the value of `sourcePins`. Absent (`undefined`) is no pins.
|
|
57
|
+
* Present, it is a non-empty array of exactly `{repo, ref, until}` objects,
|
|
58
|
+
* `repo` a graph repository path, `ref` an exact lowercase 40-hex commit (never
|
|
59
|
+
* a branch, which would move under a lock), `until` the non-empty exit
|
|
60
|
+
* condition of the hold, no repository twice.
|
|
61
|
+
*/
|
|
62
|
+
export function parseEditionSourcePins(raw, id) {
|
|
63
|
+
if (raw === undefined) {return [];}
|
|
64
|
+
if (!Array.isArray(raw) || raw.length === 0) {
|
|
65
|
+
throw new EditionSourcePinsError(`${label(id)}"${SOURCE_PINS_KEY}" must be a non-empty array of {repo, ref, until}; got ${JSON.stringify(raw)}`);
|
|
66
|
+
}
|
|
67
|
+
const seen = new Set();
|
|
68
|
+
return raw.map((entry, index) => {
|
|
69
|
+
const where = `${label(id)}${SOURCE_PINS_KEY}[${index}] ${JSON.stringify(entry)}`;
|
|
70
|
+
if (entry === null || typeof entry !== 'object' || Array.isArray(entry) || Object.keys(entry).sort().join() !== SOURCE_PIN_KEYS.join()) {
|
|
71
|
+
throw new EditionSourcePinsError(`${where} must be exactly {repo, ref, until}; \`until\` (the exit condition of the hold) is required`);
|
|
72
|
+
}
|
|
73
|
+
if (typeof entry.repo !== 'string' || !GRAPH_REPO.test(entry.repo)) {
|
|
74
|
+
throw new EditionSourcePinsError(`${where}: repo must be a repository path (repos/<name> or submodules/<name>)`);
|
|
75
|
+
}
|
|
76
|
+
if (typeof entry.ref !== 'string' || !COMMIT_PATTERN.test(entry.ref)) {
|
|
77
|
+
throw new EditionSourcePinsError(`${where}: ref must be an exact lowercase 40-hex commit, not a branch, tag or abbreviation`);
|
|
78
|
+
}
|
|
79
|
+
if (typeof entry.until !== 'string' || entry.until.trim().length === 0) {
|
|
80
|
+
throw new EditionSourcePinsError(`${where}: until must be non-empty text stating when the hold ends`);
|
|
81
|
+
}
|
|
82
|
+
if (seen.has(entry.repo)) {
|
|
83
|
+
throw new EditionSourcePinsError(`${where}: repo ${entry.repo} is pinned twice`);
|
|
84
|
+
}
|
|
85
|
+
seen.add(entry.repo);
|
|
86
|
+
return Object.freeze({ repo: entry.repo, ref: entry.ref, until: entry.until });
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Split a raw edition definition into the document the kernel parses (without
|
|
92
|
+
* the CLI key) and the parsed `sourcePins`. A value that is not an object is
|
|
93
|
+
* returned as it is for the kernel to refuse.
|
|
94
|
+
*/
|
|
95
|
+
export function splitEditionDefinition(raw, id) {
|
|
96
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw) || !Object.hasOwn(raw, SOURCE_PINS_KEY)) {
|
|
97
|
+
return { document: raw, sourcePins: [] };
|
|
98
|
+
}
|
|
99
|
+
const { [SOURCE_PINS_KEY]: declared, ...document } = raw;
|
|
100
|
+
return { document, sourcePins: parseEditionSourcePins(declared, id) };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The `sourcePins` of edition `id`, read from the configured editions root. */
|
|
104
|
+
export function readEditionSourcePins(id) {
|
|
105
|
+
const path = join(editionsRoot(), id, 'xema-distribution.json');
|
|
106
|
+
if (!existsSync(path)) {
|
|
107
|
+
throw new Error(`distribution "${id}" not found: ${path} does not exist`);
|
|
108
|
+
}
|
|
109
|
+
return splitEditionDefinition(readStrictJsonFile(path), id).sourcePins;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A repository URL without a trailing `.git`, so the two forms a pin may carry compare equal. */
|
|
113
|
+
const canonicalRepositoryUrl = (url) => String(url).replace(/\.git$/u, '');
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The repository pins edition `id` resolves at. With nothing held, the
|
|
117
|
+
* committed array itself. A held repository must be in the graph (committed
|
|
118
|
+
* pins) and must not be the kernel contract repository, read from the index's
|
|
119
|
+
* own `contractAuthority.repositoryUrl` (data, so the evidence verifier applies
|
|
120
|
+
* the same rule): the kernel contract authority stays data-root-wide.
|
|
121
|
+
*/
|
|
122
|
+
export function effectiveRepositoryPins(committedRepositoryPins, sourcePins, { id, contractAuthority } = {}) {
|
|
123
|
+
if (sourcePins === undefined || sourcePins.length === 0) {return committedRepositoryPins;}
|
|
124
|
+
if (typeof contractAuthority?.repositoryUrl !== 'string') {
|
|
125
|
+
throw new EditionSourcePinsError(`${label(id)}held pins are computed against the index's contractAuthority, and none was given`);
|
|
126
|
+
}
|
|
127
|
+
const kernelUrl = canonicalRepositoryUrl(contractAuthority.repositoryUrl);
|
|
128
|
+
const held = new Map(sourcePins.map((pin) => [pin.repo, pin.ref]));
|
|
129
|
+
for (const repo of held.keys()) {
|
|
130
|
+
const committed = committedRepositoryPins.find((pin) => pin.repo === repo);
|
|
131
|
+
if (committed === undefined) {
|
|
132
|
+
throw new EditionSourcePinsError(
|
|
133
|
+
`${label(id)}${SOURCE_PINS_KEY} holds ${repo}, which has no repository pin in runtime-image-source-index.json (not an image source repository)`,
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
const ref = held.get(repo);
|
|
137
|
+
if (committed.sourceCommit === ref && committed.vendoredSourceCommit === ref) {
|
|
138
|
+
throw new EditionSourcePinsError(
|
|
139
|
+
`${label(id)}${SOURCE_PINS_KEY} entry for ${repo} is not a hold: ${ref} is the shared repository pin; remove it (xema edition unpin)`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
if (canonicalRepositoryUrl(committed.repositoryUrl) === kernelUrl) {
|
|
143
|
+
throw new EditionSourcePinsError(
|
|
144
|
+
`${label(id)}${SOURCE_PINS_KEY} may not hold ${repo}: it is the kernel contract repository, whose authority is the data root's alone`,
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return committedRepositoryPins.map((pin) =>
|
|
149
|
+
held.has(pin.repo) ? { ...pin, sourceCommit: held.get(pin.repo), vendoredSourceCommit: held.get(pin.repo) } : pin,
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function git(directory, args) {
|
|
154
|
+
return execFileSync('git', ['-C', directory, ...args], {
|
|
155
|
+
encoding: 'utf8',
|
|
156
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
157
|
+
}).trim();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function refusalsOf({ pin, directory, ledger }) {
|
|
161
|
+
const { repo, ref } = pin;
|
|
162
|
+
let resolved;
|
|
163
|
+
try {
|
|
164
|
+
resolved = git(directory, ['rev-parse', '--verify', `${ref}^{commit}`]);
|
|
165
|
+
} catch {
|
|
166
|
+
return [`${repo}@${ref}: no such commit in ${directory}; fetch it before holding it`];
|
|
167
|
+
}
|
|
168
|
+
if (resolved !== ref) {return [`${repo}@${ref}: resolves to ${resolved}; name the commit itself`];}
|
|
169
|
+
try {
|
|
170
|
+
git(directory, ['rev-parse', '--verify', `${PUBLISHED_SOURCE_REF}^{commit}`]);
|
|
171
|
+
} catch {
|
|
172
|
+
return [`${repo}@${ref}: ${repo} has no ${PUBLISHED_SOURCE_REF}, so containment cannot be shown; a held commit must be published`];
|
|
173
|
+
}
|
|
174
|
+
try {
|
|
175
|
+
git(directory, ['merge-base', '--is-ancestor', ref, PUBLISHED_SOURCE_REF]);
|
|
176
|
+
} catch {
|
|
177
|
+
return [`${repo}@${ref}: not contained in ${PUBLISHED_SOURCE_REF}; a held commit must be published, or a consumer cannot fetch it`];
|
|
178
|
+
}
|
|
179
|
+
const built = builtCommits(directory, PUBLISHED_SOURCE_REF, repo, ledger);
|
|
180
|
+
// A held repository always has a runtime-image-source pin, so `null` (neither record knows it) means the build evidence is missing.
|
|
181
|
+
if (built === null) {return [`${repo}@${ref}: no build record for ${repo}; a held commit must be one an image was built from`];}
|
|
182
|
+
if (built.has(ref)) {return [];}
|
|
183
|
+
return [`${repo}@${ref}: not a built commit (in neither the build ledger nor the evidence tree); a held commit must be one an image was built from`];
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Authorize every held commit of edition `id` against its source checkout
|
|
188
|
+
* under `repoRoot`: present, contained in the published ref (a missing ref
|
|
189
|
+
* refuses, never skips) and built. Throws once, listing every refusal.
|
|
190
|
+
*/
|
|
191
|
+
export function assertEditionPinsAuthorized({ id, sourcePins, repoRoot = sourcesRoot(), ledger }) {
|
|
192
|
+
const refusals = sourcePins.flatMap((pin) => refusalsOf({ pin, directory: resolve(repoRoot, pin.repo), ledger }));
|
|
193
|
+
if (refusals.length > 0) {
|
|
194
|
+
throw new EditionSourcePinsError(
|
|
195
|
+
`${label(id)}${refusals.length} held source pin(s) refused:\n ${refusals.join('\n ')}`,
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The report `xema edition check` prints: one line per active hold of the given
|
|
202
|
+
* editions, then a count line. Informational, never a failure.
|
|
203
|
+
*/
|
|
204
|
+
export function describeEditionHolds(ids, read = readEditionSourcePins) {
|
|
205
|
+
const lines = ids.flatMap((id) =>
|
|
206
|
+
read(id).map((pin) => `[dist:check] hold: ${id} ${pin.repo} @${pin.ref.slice(0, 12)} until: ${pin.until}`),
|
|
207
|
+
);
|
|
208
|
+
return [...lines, lines.length === 0 ? '[dist:check] no holds' : `[dist:check] ${lines.length} hold(s) active`];
|
|
209
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `xema edition unpin <edition> [<repo>]`: release one hold (or all) of a vendor
|
|
3
|
+
// edition, then re-resolve the edition. The logic is edition-pin-verbs.mjs.
|
|
4
|
+
//
|
|
5
|
+
// usage: xema edition unpin <edition> [<repo>] --data-root <dir> --editions-root <dir> --sources-root <dir> --kernel <dir>
|
|
6
|
+
|
|
7
|
+
import { runUnpin } from './edition-pin-verbs.mjs';
|
|
8
|
+
import { WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule } from './edition-workspace.mjs';
|
|
9
|
+
|
|
10
|
+
if (isEntryModule(import.meta.url)) {
|
|
11
|
+
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel]);
|
|
12
|
+
runUnpin(process.argv.slice(2)).catch((error) => {
|
|
13
|
+
console.error(`[edition:unpin] ${error.message}`);
|
|
14
|
+
process.exit(1);
|
|
15
|
+
});
|
|
16
|
+
}
|
|
@@ -18,6 +18,7 @@ import {
|
|
|
18
18
|
import { TextDecoder } from 'node:util';
|
|
19
19
|
|
|
20
20
|
import { assertCanonicalDistributionId } from './distribution-paths.mjs';
|
|
21
|
+
import { effectiveRepositoryPins, splitEditionDefinition } from './edition-source-pins.mjs';
|
|
21
22
|
import { SHA256_DIGEST_PATTERN } from './evidence-grammars.mjs';
|
|
22
23
|
import { assertKernelContractAuthorityEqual } from './kernel-contract-authority.mjs';
|
|
23
24
|
import {
|
|
@@ -1316,19 +1317,24 @@ export async function loadValidatedImageLocks(
|
|
|
1316
1317
|
'images.lock.json kernel contract authority does not exactly match runtime-image-source-index.json',
|
|
1317
1318
|
);
|
|
1318
1319
|
}
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
]),
|
|
1324
|
-
);
|
|
1325
|
-
const distribution = schemas.parseDistribution(
|
|
1320
|
+
// The definition carries the CLI-owned `sourcePins` (the repositories this edition holds at an exact
|
|
1321
|
+
// commit); the kernel parses the document without it. Each subject is graded against the EFFECTIVE pin
|
|
1322
|
+
// of THIS edition. Pure: no git, no sources root (this runs inside the built verifier).
|
|
1323
|
+
const { document, sourcePins } = splitEditionDefinition(
|
|
1326
1324
|
JSON.parse(
|
|
1327
1325
|
readFileSync(
|
|
1328
1326
|
join(editionsRoot, distributionId, 'xema-distribution.json'),
|
|
1329
1327
|
'utf8',
|
|
1330
1328
|
),
|
|
1331
1329
|
),
|
|
1330
|
+
distributionId,
|
|
1331
|
+
);
|
|
1332
|
+
const distribution = schemas.parseDistribution(document);
|
|
1333
|
+
const repositoryPins = new Map(
|
|
1334
|
+
effectiveRepositoryPins(runtimeSourceIndex.repositoryPins, sourcePins, { id: distributionId, contractAuthority: runtimeSourceIndex.contractAuthority }).map((repository) => [
|
|
1335
|
+
repository.repo,
|
|
1336
|
+
repository,
|
|
1337
|
+
]),
|
|
1332
1338
|
);
|
|
1333
1339
|
if (distribution.id !== distributionId) {
|
|
1334
1340
|
throw new Error(
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// indexes-at-pins — the platform-service, biome and runtime-image-source
|
|
2
|
+
// indexes of a source universe pinned at ARBITRARY repository pins.
|
|
3
|
+
//
|
|
4
|
+
// The release builders regenerate the committed indexes over a pinned source
|
|
5
|
+
// authority; this runs the same builders, in memory, over any pin set. A develop
|
|
6
|
+
// snapshot (develop-snapshot-inputs.mjs) and a vendor edition that holds
|
|
7
|
+
// repositories (edition-pinned-inputs.mjs) both derive their indexes here, so
|
|
8
|
+
// there is no second builder. No fallback: the authority has no delegate, so a
|
|
9
|
+
// repository the pins do not name refuses by name instead of resolving to a tip.
|
|
10
|
+
|
|
11
|
+
import { buildValidatedPlatformServiceIndex } from './build-platform-service-index.mjs';
|
|
12
|
+
import { buildTrustedIndex, validateRuntimeImageSourceIndex } from './build-runtime-image-source-index.mjs';
|
|
13
|
+
import { buildBiomeIndex, distributionBiomeSourceRepositories } from './lib.mjs';
|
|
14
|
+
import { createPinnedSourceAuthority } from './source-authority.mjs';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {{ schemas: object, repositoryPins: object[] }} request
|
|
18
|
+
* @returns {{ platformServiceIndex, biomeIndex, runtimeImageSourceIndex }}
|
|
19
|
+
*/
|
|
20
|
+
export async function buildIndexesAtPins({ schemas, repositoryPins }) {
|
|
21
|
+
const sourceAuthority = createPinnedSourceAuthority({ pins: repositoryPins });
|
|
22
|
+
const platformServiceIndex = buildValidatedPlatformServiceIndex(schemas, sourceAuthority);
|
|
23
|
+
const biomeIndex = await buildBiomeIndex(platformServiceIndex, distributionBiomeSourceRepositories(), sourceAuthority);
|
|
24
|
+
const runtimeImageSourceIndex = schemas.parseRuntimeImageSourceIndex(
|
|
25
|
+
await buildTrustedIndex(schemas, sourceAuthority, undefined, undefined, { biomeIndex, platformServiceIndex }),
|
|
26
|
+
);
|
|
27
|
+
await validateRuntimeImageSourceIndex(runtimeImageSourceIndex, schemas, { biomeIndex, platformServiceIndex });
|
|
28
|
+
return { platformServiceIndex, biomeIndex, runtimeImageSourceIndex };
|
|
29
|
+
}
|
package/edition/lib.mjs
CHANGED
|
@@ -1375,6 +1375,28 @@ function componentProcessFacts(component, where) {
|
|
|
1375
1375
|
return facts;
|
|
1376
1376
|
}
|
|
1377
1377
|
|
|
1378
|
+
/**
|
|
1379
|
+
* The capabilities a signed biome declares: the sorted unique union of the refs
|
|
1380
|
+
* of the `capability` contributions it ships (read from its committed generated
|
|
1381
|
+
* index, which lists descriptor files and inline entries alike) and its
|
|
1382
|
+
* manifest's `requiresCapabilitiesForService`. Counterpart of biome-host's
|
|
1383
|
+
* `signedBiomeFacts` in xema-base, which must produce the same set.
|
|
1384
|
+
*/
|
|
1385
|
+
export function signedBiomeDeclaredCapabilities(indexBiome, xema) {
|
|
1386
|
+
const shipped = indexBiome.contributions
|
|
1387
|
+
.filter((fact) => fact.kind === 'capability')
|
|
1388
|
+
.map((fact) => {
|
|
1389
|
+
if (typeof fact.capabilityRef !== 'string' || fact.capabilityRef.length === 0) {
|
|
1390
|
+
throw new Error(
|
|
1391
|
+
`biome "${indexBiome.biomeId}": capability contribution "${fact.id}" carries no capabilityRef in its generated index`,
|
|
1392
|
+
);
|
|
1393
|
+
}
|
|
1394
|
+
return fact.capabilityRef;
|
|
1395
|
+
});
|
|
1396
|
+
const requiredForService = Array.isArray(xema.requiresCapabilitiesForService) ? xema.requiresCapabilitiesForService : [];
|
|
1397
|
+
return [...new Set([...shipped, ...requiredForService])].sort(compareStrings);
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1378
1400
|
export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, path, authorizationModule }) {
|
|
1379
1401
|
const xema = raw?.xema;
|
|
1380
1402
|
if (!xema || typeof xema !== 'object') {
|
|
@@ -2173,15 +2195,14 @@ export async function manifestToIndexEntry({ raw, generatedIndex, rel, repo, pat
|
|
|
2173
2195
|
),
|
|
2174
2196
|
].sort(compareStrings),
|
|
2175
2197
|
// The capabilities the signed biome declares (a delegation
|
|
2176
|
-
// origination may lend only these)
|
|
2177
|
-
//
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
].sort(compareStrings),
|
|
2198
|
+
// origination may lend only these) — the runtime's rule, which
|
|
2199
|
+
// biome-host's `signedBiomeFacts` (xema-base) computes and this
|
|
2200
|
+
// MUST agree with byte for byte: the sorted unique union of the
|
|
2201
|
+
// refs of the `capability` contributions the biome ITSELF ships
|
|
2202
|
+
// (descriptor files and inline entries) and the manifest's
|
|
2203
|
+
// `requiresCapabilitiesForService`. `exposesCapabilities` is not
|
|
2204
|
+
// part of it.
|
|
2205
|
+
declaredCapabilities: signedBiomeDeclaredCapabilities(parsedIndex.biome, xema),
|
|
2185
2206
|
},
|
|
2186
2207
|
},
|
|
2187
2208
|
}
|