@xemahq/repo-build-tooling 0.8.2 → 0.9.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.
@@ -0,0 +1,249 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import test from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ import {
10
+ ExclusionReason,
11
+ assertPinsUnchanged,
12
+ codeWithoutComments,
13
+ deriveComposedMembers,
14
+ isComposableEntry,
15
+ loadMembers,
16
+ main,
17
+ memberFilters,
18
+ parseArguments,
19
+ readMembers,
20
+ registryPins,
21
+ syncImageBuildInputs,
22
+ writeMemberIndex,
23
+ } from './composed-image.mjs';
24
+
25
+ const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), 'composed-image.mjs');
26
+ const COMMIT = 'a'.repeat(40);
27
+ const COMPOSABLE = "import { bootstrapXemaService } from '@xemahq/xema-service-nest';\n\n// bootstrapXemaService(once more, in prose)\nvoid bootstrapXemaService({ appModule: AppModule });\n";
28
+
29
+ function write(root, rel, content) {
30
+ fs.mkdirSync(path.join(root, path.dirname(rel)), { recursive: true });
31
+ fs.writeFileSync(path.join(root, rel), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
32
+ }
33
+
34
+ function service(key, artifactPath, extra = {}) {
35
+ return { key, kind: 'service', scheduler: 'platform', artifact: { kind: 'oci-image', path: artifactPath }, ...extra };
36
+ }
37
+
38
+ /**
39
+ * Two repository layouts in one tree, so the same tool is exercised the way its two
40
+ * consumers are shaped: a `biomes/**` platform repository and a `products/**` product one.
41
+ */
42
+ function fixture() {
43
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-composed-image-'));
44
+ write(root, 'biomes/alpha/xema-biome.json', {
45
+ xema: {
46
+ id: 'alpha',
47
+ components: [
48
+ service('alpha-api', 'api/alpha-api'),
49
+ service('alpha-own-api', 'api/alpha-own-api'),
50
+ service('alpha-tool-api', 'api/alpha-tool-api'),
51
+ service('alpha-vendor-api', 'api/alpha-vendor-api'),
52
+ service('alpha-shape-api', 'api/alpha-shape-api'),
53
+ service('alpha-runtime', 'api/alpha-runtime', { scheduler: 'runtime' }),
54
+ { key: 'alpha-worker', kind: 'worker', scheduler: 'platform', artifact: { kind: 'shared-oci-image', component: 'alpha-api' } },
55
+ ],
56
+ },
57
+ });
58
+ for (const name of ['alpha-api', 'alpha-own-api', 'alpha-tool-api', 'alpha-vendor-api']) {
59
+ write(root, `biomes/alpha/api/${name}/src/main.ts`, COMPOSABLE);
60
+ write(root, `biomes/alpha/api/${name}/package.json`, { name: `@t/${name}` });
61
+ }
62
+ write(root, 'biomes/alpha/api/alpha-own-api/Dockerfile', 'FROM scratch\n');
63
+ write(root, 'biomes/alpha/api/alpha-shape-api/src/main.ts', 'async function main() { await bootstrapXemaService({}); }\nvoid main();\n');
64
+ write(root, 'products/pai/core/pai-core/xema-biome.json', {
65
+ xema: { id: 'pai-core', components: [service('pai-core-api', 'api/pai-core-api'), { key: 'pai-core-worker', kind: 'worker', scheduler: 'platform', artifact: { kind: 'shared-oci-image', component: 'pai-core-api' } }] },
66
+ });
67
+ write(root, 'products/pai/core/pai-core/api/pai-core-api/src/main.ts', COMPOSABLE);
68
+ write(root, 'products/pai/core/pai-core/api/pai-core-api/package.json', { name: '@p/pai-core-api' });
69
+ write(root, 'infra/docker/nestjs-service.dockerfile', 'FROM scratch\nARG SUPPLY_CHAIN_SIGSTORE_SERVICES="alpha-tool-api"\n');
70
+ write(root, 'infra/docker/plain.dockerfile', 'FROM scratch\n');
71
+ write(root, 'tooling/release/cross-repo-build-inputs.json', { inputs: [{ consumerService: 'alpha-vendor-api' }] });
72
+ execFileSync('git', ['init', '-q'], { cwd: root });
73
+ execFileSync('git', ['add', '-A'], { cwd: root });
74
+ return root;
75
+ }
76
+
77
+ test('codeWithoutComments blanks comments and keeps strings and line positions', () => {
78
+ const source = "a // x\n/* b\n c */ d 'e // f' \"/* g */\" `h // i`\n";
79
+ const code = codeWithoutComments(source);
80
+ assert.equal(code.split('\n').length, source.split('\n').length);
81
+ assert.ok(!code.includes('x') && !code.includes(' b'));
82
+ assert.ok(code.includes("'e // f'") && code.includes('"/* g */"') && code.includes('`h // i`'));
83
+ });
84
+
85
+ test('isComposableEntry: one top-level void call, comments ignored', () => {
86
+ assert.equal(isComposableEntry(COMPOSABLE), true);
87
+ assert.equal(isComposableEntry('/*\nvoid bootstrapXemaService({});\n*/\n'), false);
88
+ assert.equal(isComposableEntry('void bootstrapXemaService({});\nvoid bootstrapXemaService({});\n'), false);
89
+ assert.equal(isComposableEntry('async function m() { await bootstrapXemaService({}); }\nvoid m();\n'), false);
90
+ });
91
+
92
+ test('deriveComposedMembers derives each exclusion from a repository fact; workers and runtime services are never members', () => {
93
+ const root = fixture();
94
+ const derived = deriveComposedMembers({
95
+ root,
96
+ manifestGlobs: ['biomes/**/xema-biome.json'],
97
+ canonicalDockerfile: 'infra/docker/nestjs-service.dockerfile',
98
+ crossRepoManifest: 'tooling/release/cross-repo-build-inputs.json',
99
+ });
100
+ assert.deepEqual(derived.members, { 'alpha/alpha-api': 'biomes/alpha/api/alpha-api' });
101
+ assert.deepEqual(derived.excluded, {
102
+ 'alpha/alpha-own-api': ExclusionReason.OwnDockerfile,
103
+ 'alpha/alpha-shape-api': ExclusionReason.EntryShape,
104
+ 'alpha/alpha-tool-api': ExclusionReason.ImageToolchain,
105
+ 'alpha/alpha-vendor-api': ExclusionReason.VendoredInputs,
106
+ });
107
+ });
108
+
109
+ test('deriveComposedMembers works for a product layout with no canonical or cross-repo inputs', () => {
110
+ const root = fixture();
111
+ const derived = deriveComposedMembers({ root, manifestGlobs: ['products/**/xema-biome.json'] });
112
+ assert.deepEqual(derived.members, { 'pai-core/pai-core-api': 'products/pai/core/pai-core/api/pai-core-api' });
113
+ assert.deepEqual(derived.excluded, {});
114
+ });
115
+
116
+ test('deriveComposedMembers refuses rather than derives a wrong set', () => {
117
+ const root = fixture();
118
+ assert.throws(
119
+ () => deriveComposedMembers({ root, manifestGlobs: ['biomes/**/xema-biome.json'], canonicalDockerfile: 'infra/docker/plain.dockerfile' }),
120
+ /declares no ARG SUPPLY_CHAIN/,
121
+ );
122
+ assert.throws(() => deriveComposedMembers({ root, manifestGlobs: ['nowhere/**/xema-biome.json'] }), /no tracked biome manifest/);
123
+ assert.throws(() => deriveComposedMembers({ root, manifestGlobs: [] }), /at least one glob/);
124
+ });
125
+
126
+ test('members --write then --check is current; a changed set is stale', () => {
127
+ const root = fixture();
128
+ const argv = ['members', '--root', root, '--manifests', 'products/**/xema-biome.json'];
129
+ fs.mkdirSync(path.join(root, 'infra/composed-process'), { recursive: true });
130
+ assert.throws(() => main([...argv, '--check'], { log() {} }), /is stale/);
131
+ main([...argv, '--write'], { log() {} });
132
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, 'infra/composed-process/image-build-inputs.json'), 'utf8')), {
133
+ workspacePackages: ['products/pai/core/pai-core/api/pai-core-api'],
134
+ });
135
+ main([...argv, '--check'], { log() {} });
136
+ assert.throws(
137
+ () => syncImageBuildInputs({ mode: 'check', root, manifestGlobs: ['products/**/xema-biome.json', 'biomes/**/xema-biome.json'] }),
138
+ /is stale/,
139
+ );
140
+ });
141
+
142
+ test('parseArguments refuses unknown options and valueless flags', () => {
143
+ assert.deepEqual(parseArguments(['members', '--write', '--manifests', 'a', '--manifests', 'b']).options, { mode: 'write', manifests: ['a', 'b'] });
144
+ assert.throws(() => parseArguments(['index', '--bogus', 'x']), /unknown option --bogus/);
145
+ assert.throws(() => parseArguments(['index', '--build-source']), /needs a value/);
146
+ assert.throws(() => main(['nope'], { log() {} }), /usage:/);
147
+ });
148
+
149
+ /** A built image tree: two members, a host SDK with its bin, and the kernel schema. */
150
+ function builtTree({ secondCopyOf } = {}) {
151
+ const root = fixture();
152
+ write(root, 'infra/composed-process/image-build-inputs.json', {
153
+ workspacePackages: ['biomes/alpha/api/alpha-api', 'products/pai/core/pai-core/api/pai-core-api'],
154
+ });
155
+ const sdk = (base) => {
156
+ write(root, `${base}/node_modules/@xemahq/xema-service-nest/package.json`, {
157
+ name: '@xemahq/xema-service-nest',
158
+ main: 'index.js',
159
+ bin: { 'xema-composed-process': 'dist/bin/xema-composed-process.js' },
160
+ });
161
+ write(root, `${base}/node_modules/@xemahq/xema-service-nest/dist/bin/xema-composed-process.js`, '');
162
+ write(
163
+ root,
164
+ `${base}/node_modules/@xemahq/xema-service-nest/index.js`,
165
+ "exports.readServiceEntry = (entry) => { if (entry.includes('pai-core')) throw new Error('graph does not load'); return {}; };\n",
166
+ );
167
+ };
168
+ sdk('.');
169
+ if (secondCopyOf) sdk(secondCopyOf);
170
+ write(root, 'node_modules/@xemahq/contracts/package.json', { name: '@xemahq/contracts', exports: { './*': './dist/*/index.js' } });
171
+ write(
172
+ root,
173
+ 'node_modules/@xemahq/contracts/dist/process-topology/index.js',
174
+ "exports.ComposedProcessMemberIndexSchema = { parse(d) { if (d.schemaVersion !== 1 || !d.buildSource.repository) throw new Error('invalid index'); return d; } };\n",
175
+ );
176
+ for (const dir of ['biomes/alpha/api/alpha-api', 'products/pai/core/pai-core/api/pai-core-api']) write(root, `${dir}/dist/main.js`, '');
177
+ return root;
178
+ }
179
+
180
+ test('filters and dirs name every declared member', () => {
181
+ const root = builtTree();
182
+ assert.deepEqual(memberFilters({ root }), ['--filter @t/alpha-api...', '--filter @p/pai-core-api...']);
183
+ assert.deepEqual(readMembers({ root }).map(([key]) => key), ['alpha/alpha-api', 'pai-core/pai-core-api']);
184
+ write(root, 'infra/composed-process/image-build-inputs.json', { workspacePackages: ['biomes/alpha/api/missing-api'] });
185
+ assert.throws(() => readMembers({ root }), /declares 0 service\(s\)/);
186
+ });
187
+
188
+ test('index writes the member index under the declared build source and links the host', () => {
189
+ const root = builtTree();
190
+ assert.throws(() => writeMemberIndex({ root, commit: 'abc', buildSourceRepository: 'r' }), /40-hex/);
191
+ assert.throws(() => writeMemberIndex({ root, commit: COMMIT }), /--build-source/);
192
+ const result = writeMemberIndex({ root, commit: COMMIT, buildSourceRepository: 'presenca-biomes' });
193
+ assert.equal(result.members, 2);
194
+ const index = JSON.parse(fs.readFileSync(path.join(root, 'xema-composed-member-index.json'), 'utf8'));
195
+ assert.deepEqual(index.buildSource, { repository: 'presenca-biomes', commit: COMMIT });
196
+ assert.deepEqual(index.members['pai-core/pai-core-api'], { entry: 'products/pai/core/pai-core/api/pai-core-api/dist/main.js' });
197
+ assert.equal(
198
+ fs.readlinkSync(path.join(root, 'xema-composed-process')),
199
+ 'node_modules/@xemahq/xema-service-nest/dist/bin/xema-composed-process.js',
200
+ );
201
+ });
202
+
203
+ test('index refuses two copies of the host and a missing built entry', () => {
204
+ const twoCopies = builtTree({ secondCopyOf: 'products/pai/core/pai-core/api/pai-core-api' });
205
+ assert.throws(() => writeMemberIndex({ root: twoCopies, commit: COMMIT, buildSourceRepository: 'r' }), /resolves @xemahq\/xema-service-nest at/);
206
+ const unbuilt = builtTree();
207
+ fs.rmSync(path.join(unbuilt, 'biomes/alpha/api/alpha-api/dist/main.js'));
208
+ assert.throws(() => writeMemberIndex({ root: unbuilt, commit: COMMIT, buildSourceRepository: 'r' }), /built entry .* does not exist/);
209
+ });
210
+
211
+ test('load reports every member whose graph does not load', () => {
212
+ const root = builtTree();
213
+ assert.throws(() => loadMembers({ root }), /1 of 2 member\(s\) do not load:\n {2}- pai-core\/pai-core-api: graph does not load/);
214
+ });
215
+
216
+ test('pins refuses a registry pin the committed lockfile lacks and ignores injected file: entries', () => {
217
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-composed-pins-'));
218
+ const lock = (...keys) => `lockfileVersion: '9.0'\n\npackages:\n\n${keys.map((k) => ` ${k}:\n resolution: {}\n`).join('\n')}\nsnapshots: {}\n`;
219
+ write(root, 'committed.yaml', lock('a@1.0.0', "'@s/b@2.0.0'"));
220
+ write(root, 'pnpm-lock.yaml', lock('a@1.0.0', "'@s/b@2.0.0'", '@t/member@file:biomes/x'));
221
+ assert.deepEqual([...registryPins(path.join(root, 'committed.yaml'))], ['a@1.0.0', '@s/b@2.0.0']);
222
+ assert.equal(assertPinsUnchanged({ root, committedLockfile: path.join(root, 'committed.yaml') }), 2);
223
+ write(root, 'pnpm-lock.yaml', lock('a@1.0.1', "'@s/b@2.0.0'"));
224
+ assert.throws(() => assertPinsUnchanged({ root, committedLockfile: path.join(root, 'committed.yaml') }), /- a@1\.0\.1/);
225
+ write(root, 'v6.yaml', "lockfileVersion: '6.0'\n");
226
+ assert.throws(() => registryPins(path.join(root, 'v6.yaml')), /not a v9 lockfile/);
227
+ });
228
+
229
+ test('the bin runs through a symlink, as npm installs it, and exits non-zero on refusal', () => {
230
+ const root = builtTree();
231
+ const link = path.join(root, 'xema-composed-image');
232
+ fs.symlinkSync(BIN, link);
233
+ const out = execFileSync(process.execPath, [link, 'dirs', '--root', root], { encoding: 'utf8' });
234
+ assert.equal(out, 'biomes/alpha/api/alpha-api\nproducts/pai/core/pai-core/api/pai-core-api\n');
235
+ assert.throws(
236
+ () => execFileSync(process.execPath, [link, 'load', '--root', root], { encoding: 'utf8', stdio: 'pipe' }),
237
+ (error) => error.status === 1 && /xema-composed-image: 1 of 2 member/.test(error.stderr),
238
+ );
239
+ });
240
+
241
+ test('the new commands parse their options; coordinate is asynchronous and refuses without an image name', async () => {
242
+ const parsed = parseArguments(['dockerfile', '--write', '--copy', 'a', '--copy', 'b', '--cache-id', 'c', '--tooling-version', '1.2.3']);
243
+ assert.deepEqual(parsed.options.copy, ['a', 'b']);
244
+ assert.equal(parsed.options['cache-id'], 'c');
245
+ assert.deepEqual(parseArguments(['smoke-refusal', 'img', '--member', 'x/y', '--member', 'x/z']).options.member, ['x/y', 'x/z']);
246
+ await assert.rejects(main(['coordinate'], { log() {} }), /--image-name/);
247
+ assert.throws(() => main(['dockerfile', '--write'], { log() {} }), /exact x\.y\.z/);
248
+ assert.throws(() => main(['smoke-refusal'], { log() {} }), /usage/);
249
+ });
@@ -0,0 +1,56 @@
1
+ // The declaration-level refusal of the composed-process host, as data: the
2
+ // scenario the smoke boots and the grade of what the host did with it.
3
+ //
4
+ // Scenario: two members of the image on ONE port. The host must refuse it while
5
+ // grading the declaration — before any member is constructed — exit non-zero,
6
+ // and name the collision. A host that constructs a member first, starts anyway,
7
+ // or refuses for some other reason fails the grade.
8
+
9
+ export const REFUSAL_PROCESS_NAME = 'declaration-refusal-smoke';
10
+ export const PROCESS_PORT = 8080;
11
+ export const SHARED_MEMBER_PORT = 8081;
12
+
13
+ const HOST_REFUSAL = '[xema-composed-process] refused to start';
14
+ /** Logged by the host for each member it previews or creates — after the declaration passed. */
15
+ const MEMBER_CONSTRUCTION_MARKERS = ['previewing its module graph', '" created ('];
16
+
17
+ function memberOf(key) {
18
+ const [biomeId, componentKey] = key.split('/');
19
+ return { biomeId, componentKey };
20
+ }
21
+
22
+ export function portCollisionMessage(keys) {
23
+ return `${keys.length} members listen on port ${SHARED_MEMBER_PORT} (${keys.join(', ')})`;
24
+ }
25
+
26
+ /** The host environment for `keys` (two member keys) sharing one port, with `memberFacts` derived from the image. */
27
+ export function refusalEnvironment(keys, memberFacts) {
28
+ if (keys.length !== 2) throw new Error(`the refusal scenario takes exactly two members, got ${keys.length}`);
29
+ const prefixes = Object.fromEntries(keys.map((key, index) => [key, `M${index}_`]));
30
+ return {
31
+ XEMA_PROCESS_DEFINITION: JSON.stringify({
32
+ name: REFUSAL_PROCESS_NAME,
33
+ members: keys.map(memberOf),
34
+ replicas: { min: 1, max: 1 },
35
+ listen: { port: PROCESS_PORT },
36
+ }),
37
+ XEMA_PROCESS_MEMBER_FACTS: JSON.stringify(memberFacts),
38
+ XEMA_PROCESS_MEMBER_ENV_PREFIXES: JSON.stringify(prefixes),
39
+ ...Object.fromEntries(Object.values(prefixes).map((prefix) => [`${prefix}PORT`, String(SHARED_MEMBER_PORT)])),
40
+ };
41
+ }
42
+
43
+ /** Every way the host's run of the scenario is not the declaration-level refusal. Empty means it is. */
44
+ export function gradeRefusal({ status, output }, keys) {
45
+ const problems = [];
46
+ if (status === 0) problems.push('the host exited 0 — it did not refuse');
47
+ if (status === null) problems.push('the host did not exit on its own');
48
+ if (!output.includes(HOST_REFUSAL)) problems.push(`the host never printed "${HOST_REFUSAL}"`);
49
+ const named = portCollisionMessage(keys);
50
+ if (!output.includes(named)) problems.push(`the refusal does not name the collision ("${named}")`);
51
+ for (const marker of MEMBER_CONSTRUCTION_MARKERS) {
52
+ const line = output.split('\n').find((candidate) => candidate.includes(marker));
53
+ if (line !== undefined) problems.push(`a member was constructed before the refusal: ${line.trim()}`);
54
+ }
55
+ return problems;
56
+ }
@@ -0,0 +1,59 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+
4
+ import {
5
+ gradeRefusal,
6
+ portCollisionMessage,
7
+ refusalEnvironment,
8
+ SHARED_MEMBER_PORT,
9
+ } from './declaration-refusal.mjs';
10
+
11
+ const KEYS = ['activity-feed/activity-feed-api', 'agent-runtime/agent-session-api'];
12
+ const REFUSED =
13
+ '[xema-composed-process] refused to start — Error: composeXemaProcess: process "declaration-refusal-smoke" ' +
14
+ `was handed mounts that do not match its declared members (1 problem):\n - ${portCollisionMessage(KEYS)} — give each member its own PORT\n`;
15
+
16
+ test('the scenario puts both members on one port, apart from the process port', () => {
17
+ const env = refusalEnvironment(KEYS, []);
18
+ const definition = JSON.parse(env.XEMA_PROCESS_DEFINITION);
19
+ assert.deepEqual(definition.members, [
20
+ { biomeId: 'activity-feed', componentKey: 'activity-feed-api' },
21
+ { biomeId: 'agent-runtime', componentKey: 'agent-session-api' },
22
+ ]);
23
+ assert.notEqual(definition.listen.port, SHARED_MEMBER_PORT);
24
+ const prefixes = JSON.parse(env.XEMA_PROCESS_MEMBER_ENV_PREFIXES);
25
+ for (const key of KEYS) assert.equal(env[`${prefixes[key]}PORT`], String(SHARED_MEMBER_PORT));
26
+ });
27
+
28
+ test('a declaration-level refusal grades clean', () => {
29
+ assert.deepEqual(gradeRefusal({ status: 1, output: REFUSED }, KEYS), []);
30
+ });
31
+
32
+ test('a host that starts is not a refusal', () => {
33
+ const problems = gradeRefusal({ status: 0, output: 'process "x" probes on 8080' }, KEYS);
34
+ assert.ok(problems.some((problem) => problem.includes('exited 0')));
35
+ assert.ok(problems.some((problem) => problem.includes('never printed')));
36
+ });
37
+
38
+ test('a host that never exits is not a refusal', () => {
39
+ assert.ok(gradeRefusal({ status: null, output: REFUSED }, KEYS).some((p) => p.includes('did not exit')));
40
+ });
41
+
42
+ test('a refusal for another reason does not name the collision', () => {
43
+ const output = '[xema-composed-process] refused to start — Error: members are missing required environment\n';
44
+ assert.ok(gradeRefusal({ status: 1, output }, KEYS).some((p) => p.includes('does not name the collision')));
45
+ });
46
+
47
+ test('a refusal after a member was previewed or created is not declaration-level', () => {
48
+ for (const line of [
49
+ `[Nest] LOG [composeXemaProcess] member "${KEYS[0]}": previewing its module graph (nothing is constructed)`,
50
+ `[Nest] LOG [composeXemaProcess] member "${KEYS[0]}" created (0 config service(s) bound)`,
51
+ ]) {
52
+ const problems = gradeRefusal({ status: 1, output: `${line}\n${REFUSED}` }, KEYS);
53
+ assert.ok(problems.some((p) => p.includes('constructed before the refusal')), line);
54
+ }
55
+ });
56
+
57
+ test('the scenario takes exactly two members', () => {
58
+ assert.throws(() => refusalEnvironment(KEYS.slice(0, 1), []), /exactly two/);
59
+ });
@@ -0,0 +1,56 @@
1
+ #!/usr/bin/env node
2
+ // INVOKER: declaration-refusal-smoke.mjs, INSIDE the composed-process image
3
+ // (piped to the image's own node on stdin). Self-contained on purpose:
4
+ // it resolves nothing from this repository's checkout, only from the image.
5
+ //
6
+ // Prints `ProcessMemberFacts[]` (JSON) for the members named on argv, derived
7
+ // by the KERNEL's own `deriveProcessMemberFacts` from the biome manifests the
8
+ // image carries and the build its member index names — the same projection the
9
+ // distribution lock's `serviceProcessFacts` is made of. Nothing is restated:
10
+ // kind, scheduler, scaling and intrinsic facts come from the parsed manifest.
11
+ //
12
+ // Usage: docker run -i --entrypoint node <image> --input-type=module - <biomeId>/<componentKey>... < derive-member-facts.mjs
13
+ import { existsSync, readFileSync } from 'node:fs';
14
+ import { createRequire } from 'node:module';
15
+ import { dirname, join } from 'node:path';
16
+
17
+ const indexPath = process.env.XEMA_COMPOSED_MEMBER_INDEX;
18
+ if (!indexPath) throw new Error('XEMA_COMPOSED_MEMBER_INDEX is not set — not a composed-process image');
19
+ const root = dirname(indexPath);
20
+ const index = JSON.parse(readFileSync(indexPath, 'utf8'));
21
+
22
+ function manifestAbove(entry) {
23
+ for (let dir = dirname(join(root, entry)); dir !== root && dir !== dirname(dir); dir = dirname(dir)) {
24
+ const candidate = join(dir, 'xema-biome.json');
25
+ if (existsSync(candidate)) return candidate;
26
+ }
27
+ throw new Error(`no xema-biome.json above ${entry}`);
28
+ }
29
+
30
+ const keys = process.argv.slice(2);
31
+ if (keys.length === 0) throw new Error('usage: derive-member-facts.mjs <biomeId>/<componentKey>...');
32
+
33
+ const facts = keys.map((key) => {
34
+ const carried = index.members[key];
35
+ if (carried === undefined) throw new Error(`${key} is not in the image's member index`);
36
+ const requireFromMember = createRequire(join(root, carried.entry));
37
+ const { parseBiomeManifest, BiomeOrigin } = requireFromMember('@xemahq/contracts/biome');
38
+ const { deriveProcessMemberFacts, ProcessMemberFactsSchema } = requireFromMember(
39
+ '@xemahq/contracts/process-topology',
40
+ );
41
+ const manifest = parseBiomeManifest(JSON.parse(readFileSync(manifestAbove(carried.entry), 'utf8')));
42
+ const [biomeId, componentKey] = key.split('/');
43
+ if (manifest.xema.id !== biomeId) throw new Error(`${key}: manifest declares biome ${manifest.xema.id}`);
44
+ const component = manifest.xema.components.find((candidate) => candidate.key === componentKey);
45
+ if (component === undefined) throw new Error(`${key}: manifest declares no component ${componentKey}`);
46
+ return ProcessMemberFactsSchema.parse(
47
+ deriveProcessMemberFacts(component, {
48
+ biomeId,
49
+ // Every member of this image is built from this first-party repository.
50
+ origin: BiomeOrigin.FirstParty,
51
+ version: manifest.version,
52
+ buildSource: index.buildSource,
53
+ }),
54
+ );
55
+ });
56
+ process.stdout.write(`${JSON.stringify(facts)}\n`);
@@ -0,0 +1,70 @@
1
+ // Is a content-addressed image coordinate already in GHCR? Shared by every
2
+ // repository that plans a build from a content tag (service matrix, composed image).
3
+ //
4
+ // Only 200 and 404 are answers; anything else throws, so an unreachable or
5
+ // unauthorised registry can never read as "missing" and trigger a rebuild.
6
+
7
+ const MANIFEST_ACCEPT = [
8
+ 'application/vnd.oci.image.index.v1+json',
9
+ 'application/vnd.oci.image.manifest.v1+json',
10
+ 'application/vnd.docker.distribution.manifest.list.v2+json',
11
+ 'application/vnd.docker.distribution.manifest.v2+json',
12
+ ].join(', ');
13
+
14
+ function assertNonEmpty(value, label) {
15
+ if (typeof value !== 'string' || value.trim().length === 0) {
16
+ throw new Error(`${label} is missing or empty`);
17
+ }
18
+ return value;
19
+ }
20
+
21
+ function basicAuthorization(user, token) {
22
+ return `Basic ${Buffer.from(`${user}:${token}`, 'utf8').toString('base64')}`;
23
+ }
24
+
25
+ /**
26
+ * Acquire a pull-only GHCR bearer token for one repository.
27
+ *
28
+ * The endpoint is deliberately fixed to the configured GHCR host. Following a
29
+ * challenge-provided realm while carrying the long-lived credential would let
30
+ * an untrusted/misconfigured registry redirect that credential elsewhere.
31
+ */
32
+ export async function acquireGhcrPullToken({ registry, repository, user, token, fetchImpl = fetch }) {
33
+ if (registry !== 'ghcr.io') {
34
+ throw new Error(`GHCR manifest check cannot send credentials to registry '${registry}'`);
35
+ }
36
+ assertNonEmpty(user, 'REGISTRY_USER');
37
+ assertNonEmpty(token, 'REGISTRY_TOKEN');
38
+ const url = new URL(`https://${registry}/token`);
39
+ url.searchParams.set('service', registry);
40
+ url.searchParams.set('scope', `repository:${repository}:pull`);
41
+ const response = await fetchImpl(url, {
42
+ method: 'GET',
43
+ headers: { Authorization: basicAuthorization(user, token), Accept: 'application/json' },
44
+ });
45
+ if (response.status !== 200) {
46
+ throw new Error(`${registry}: pull-token request for ${repository} returned HTTP ${response.status}`);
47
+ }
48
+ let body;
49
+ try {
50
+ body = await response.json();
51
+ } catch {
52
+ throw new Error(`${registry}: pull-token response for ${repository} was not JSON`);
53
+ }
54
+ if (typeof body?.token !== 'string' || body.token.length === 0) {
55
+ throw new Error(`${registry}: pull-token response for ${repository} contained no token`);
56
+ }
57
+ return body.token;
58
+ }
59
+
60
+ /** Returns `true` only for a proven existing manifest and `false` only for 404. */
61
+ export async function ghcrManifestExists({ registry, repository, contentHash, user, token, fetchImpl = fetch }) {
62
+ const bearer = await acquireGhcrPullToken({ registry, repository, user, token, fetchImpl });
63
+ const response = await fetchImpl(`https://${registry}/v2/${repository}/manifests/${contentHash}`, {
64
+ method: 'HEAD',
65
+ headers: { Authorization: `Bearer ${bearer}`, Accept: MANIFEST_ACCEPT },
66
+ });
67
+ if (response.status === 200) return true;
68
+ if (response.status === 404) return false;
69
+ throw new Error(`${registry}: manifest lookup for ${repository}:${contentHash} returned HTTP ${response.status}`);
70
+ }
@@ -0,0 +1,96 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { acquireGhcrPullToken, ghcrManifestExists } from './ghcr-manifest.mjs';
5
+
6
+ const HASH = 'a'.repeat(64);
7
+ const COMMON = { registry: 'ghcr.io', repository: 'xema-dev/alpha-api', user: 'runner', token: 'secret' };
8
+
9
+ function response(status, body) {
10
+ return {
11
+ status,
12
+ async json() {
13
+ if (body instanceof Error) throw body;
14
+ return body;
15
+ },
16
+ };
17
+ }
18
+
19
+ test('a pull token is requested with the exact repository scope', async () => {
20
+ let captured;
21
+ const token = await acquireGhcrPullToken({
22
+ ...COMMON,
23
+ fetchImpl: async (url, init) => {
24
+ captured = { url: String(url), init };
25
+ return response(200, { token: 'bearer-token' });
26
+ },
27
+ });
28
+ assert.equal(token, 'bearer-token');
29
+ const url = new URL(captured.url);
30
+ assert.equal(url.origin + url.pathname, 'https://ghcr.io/token');
31
+ assert.equal(url.searchParams.get('service'), 'ghcr.io');
32
+ assert.equal(url.searchParams.get('scope'), 'repository:xema-dev/alpha-api:pull');
33
+ assert.equal(captured.init.method, 'GET');
34
+ assert.match(captured.init.headers.Authorization, /^Basic /);
35
+ });
36
+
37
+ test('the long-lived credential can only be sent to GHCR', async () => {
38
+ let called = false;
39
+ await assert.rejects(
40
+ acquireGhcrPullToken({
41
+ ...COMMON,
42
+ registry: 'registry.example.com',
43
+ fetchImpl: async () => {
44
+ called = true;
45
+ return response(200, { token: 'bearer-token' });
46
+ },
47
+ }),
48
+ /cannot send credentials/,
49
+ );
50
+ assert.equal(called, false);
51
+ });
52
+
53
+ test('a missing credential is refused before any request', async () => {
54
+ await assert.rejects(acquireGhcrPullToken({ ...COMMON, token: '', fetchImpl: async () => assert.fail('requested') }), /REGISTRY_TOKEN/);
55
+ });
56
+
57
+ test('token acquisition fails closed on every uncertain response', async () => {
58
+ for (const status of [401, 403, 404, 429, 500]) {
59
+ await assert.rejects(acquireGhcrPullToken({ ...COMMON, fetchImpl: async () => response(status, {}) }), new RegExp(`HTTP ${status}`));
60
+ }
61
+ for (const body of [{}, { token: '' }, new Error('invalid json')]) {
62
+ await assert.rejects(acquireGhcrPullToken({ ...COMMON, fetchImpl: async () => response(200, body) }), /token response/);
63
+ }
64
+ });
65
+
66
+ test('only authenticated 200 and 404 are classified', async () => {
67
+ for (const [status, expected] of [[200, true], [404, false]]) {
68
+ const calls = [];
69
+ const exists = await ghcrManifestExists({
70
+ ...COMMON,
71
+ contentHash: HASH,
72
+ fetchImpl: async (url, init) => {
73
+ calls.push({ url: String(url), init });
74
+ return calls.length === 1 ? response(200, { token: 'bearer-token' }) : response(status, {});
75
+ },
76
+ });
77
+ assert.equal(exists, expected);
78
+ assert.equal(calls[1].url, `https://ghcr.io/v2/xema-dev/alpha-api/manifests/${HASH}`);
79
+ assert.equal(calls[1].init.method, 'HEAD');
80
+ assert.equal(calls[1].init.headers.Authorization, 'Bearer bearer-token');
81
+ }
82
+ for (const status of [401, 403, 429, 500]) {
83
+ let count = 0;
84
+ await assert.rejects(
85
+ ghcrManifestExists({
86
+ ...COMMON,
87
+ contentHash: HASH,
88
+ fetchImpl: async () => {
89
+ count += 1;
90
+ return count === 1 ? response(200, { token: 'bearer-token' }) : response(status, {});
91
+ },
92
+ }),
93
+ new RegExp(`HTTP ${status}`),
94
+ );
95
+ }
96
+ });