@xemahq/xema 0.32.0 → 0.33.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.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 +45 -39
- 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/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
|
+
}
|
|
@@ -1,21 +1,22 @@
|
|
|
1
1
|
// release-catalogue — the catalogue of a RELEASE offering, from its committed, sealed locks.
|
|
2
2
|
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
4
|
-
|
|
5
3
|
import { biomeSourceTreeIds } from './biome-content.mjs';
|
|
6
4
|
import { buildCatalogue } from './catalogue.mjs';
|
|
7
5
|
import { EditionChannel } from './edition-constants.mjs';
|
|
8
|
-
import {
|
|
6
|
+
import { editionPinnedInputs } from './edition-pinned-inputs.mjs';
|
|
7
|
+
import { kernelPackageDir } from './edition-workspace.mjs';
|
|
9
8
|
import { kernelPackageOfAuthority } from './kernel-package.mjs';
|
|
10
9
|
import { loadKernelDistributionSchemas } from './lib.mjs';
|
|
11
10
|
import { loadEditionManifest } from './resolve-distribution.mjs';
|
|
12
11
|
import { externalSourceTrees, sourceTreeLocator } from './resolve-images.mjs';
|
|
13
|
-
import {
|
|
12
|
+
import { createPinnedSourceAuthority } from './source-authority.mjs';
|
|
14
13
|
|
|
15
14
|
/** The catalogue of release offering `id`, from the committed locks (`locks` as `readCommittedEdition` returns them). */
|
|
16
15
|
export async function releaseCatalogue(id, locks, { fetchKernelTarball } = {}) {
|
|
17
16
|
const schemas = await loadKernelDistributionSchemas();
|
|
18
|
-
|
|
17
|
+
// The EDITION's indexes and pins: a held repository is catalogued at the held commit.
|
|
18
|
+
const pinned = await editionPinnedInputs(id, { schemas });
|
|
19
|
+
const authority = createPinnedSourceAuthority({ pins: pinned.repositoryPins });
|
|
19
20
|
// The WRITER of the catalogue's tree ids: the real `git rev-parse <commit>:<path>` of every biome.
|
|
20
21
|
const sourceTreeIds = await biomeSourceTreeIds({
|
|
21
22
|
schemas,
|
|
@@ -24,7 +25,7 @@ export async function releaseCatalogue(id, locks, { fetchKernelTarball } = {}) {
|
|
|
24
25
|
locate: sourceTreeLocator(externalSourceTrees(locks.distributionLock)),
|
|
25
26
|
vendoredCommit: (repo) => authority.vendoredSourceCommitForRepository(repo),
|
|
26
27
|
});
|
|
27
|
-
const contractAuthority =
|
|
28
|
+
const { contractAuthority } = pinned.runtimeImageSourceIndex;
|
|
28
29
|
return buildCatalogue({
|
|
29
30
|
sourceTreeIds,
|
|
30
31
|
offeringId: id,
|
|
@@ -32,8 +33,8 @@ export async function releaseCatalogue(id, locks, { fetchKernelTarball } = {}) {
|
|
|
32
33
|
distributionLock: locks.distributionLock,
|
|
33
34
|
imageLock: locks.imageLock,
|
|
34
35
|
chartSetLock: locks.chartSetLock,
|
|
35
|
-
biomeIndex:
|
|
36
|
-
platformServiceIndex:
|
|
36
|
+
biomeIndex: pinned.biomeIndex,
|
|
37
|
+
platformServiceIndex: pinned.platformServiceIndex,
|
|
37
38
|
contractAuthority,
|
|
38
39
|
kernel: await kernelPackageOfAuthority({
|
|
39
40
|
kernelPackageDir: kernelPackageDir(),
|