@xemahq/repo-build-tooling 0.9.0 → 0.9.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/README.md CHANGED
@@ -95,6 +95,7 @@ build carries every composable platform service of the repository at one commit.
95
95
  `REGISTRY_TOKEN`, optional `TAG_ARGS_FILE`, `GITHUB_OUTPUT`. Outputs `image`, `content_tag`,
96
96
  `exists`, `entry`. The registry check is also exported as `./ghcr-manifest`
97
97
  (`ghcrManifestExists`, `acquireGhcrPullToken`).
98
+ - `load-image <image>` runs the member-load check inside the shipped image (`docker run`), as its own user and `NODE_ENV`; `load` runs in the builder and cannot prove that.
98
99
  - `smoke-refusal <image> [--member <biomeId>/<componentKey>]x2` boots the pushed image
99
100
  with two members on one port and fails unless the host refuses at the declaration, names
100
101
  the collision, and constructs no member. Needs only `docker`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/repo-build-tooling",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM with zero dependencies so the published artifact is the reviewed source.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
@@ -37,9 +37,9 @@
37
37
  "./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs"
38
38
  },
39
39
  "scripts": {
40
- "test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/ghcr-manifest.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs"
40
+ "test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/ghcr-manifest.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs"
41
41
  },
42
42
  "xemaRelease": {
43
- "sourceDigest": "sha256:89bea8bf6a2415cef4ea2dd5f199135d3ee71cba50d1c9eb93cc92f1b9eb1984"
43
+ "sourceDigest": "sha256:b9de4f5bf70260506dc8fc6c2e2bad30aba714198303f5440888071880721d98"
44
44
  }
45
45
  }
@@ -35,6 +35,9 @@
35
35
  // and the `xema-composed-process` link the image runs.
36
36
  // load evaluate every member's entry exactly as the host does
37
37
  // (`readServiceEntry`) and refuse the image if any graph does not load.
38
+ // load-image <image>
39
+ // the same check against the SHIPPED image (`docker run`), as its own
40
+ // user and NODE_ENV: `load` runs in the builder, which is not what ships.
38
41
  // coordinate --image-name <name>
39
42
  // the image's content tag (computed ONLY by the repository's own
40
43
  // `xema-compute-image-tag`) and whether GHCR already serves it
@@ -75,6 +78,7 @@ import { pathToFileURL } from 'node:url';
75
78
  import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
76
79
  import { composedCoordinate, emitCoordinate } from './composed-image-coordinate.mjs';
77
80
  import { syncComposedDockerfile } from './composed-image-dockerfile.mjs';
81
+ import { runImageLoad } from './load-image.mjs';
78
82
  import { runRefusalSmoke } from './smoke-refusal.mjs';
79
83
 
80
84
  export { COMPOSED_IMAGE_DIR };
@@ -438,6 +442,7 @@ const USAGE =
438
442
  ' members --write|--check [--manifests <glob>]… [--canonical-dockerfile <path>] [--cross-repo-manifest <path>]\n' +
439
443
  ' filters | dirs | pins <committed-lockfile> | index <commit> --build-source <repo> | load\n' +
440
444
  ' coordinate --image-name <name> (env: SHA REGISTRY REGISTRY_ORG REGISTRY_USER REGISTRY_TOKEN [TAG_ARGS_FILE])\n' +
445
+ ' load-image <image>\n' +
441
446
  ' smoke-refusal <image> [--member <biomeId>/<componentKey>]x2\n' +
442
447
  ' dockerfile --write|--check --tooling-version <x.y.z> --copy <dir>… --cache-id <id> --build-source <repo>';
443
448
 
@@ -514,6 +519,9 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
514
519
  return composedCoordinate({ root, imageDir, imageName: options['image-name'] }).then((outputs) =>
515
520
  emitCoordinate(outputs, { log }),
516
521
  );
522
+ case 'load-image':
523
+ runImageLoad({ image: argument, log });
524
+ return;
517
525
  case 'smoke-refusal':
518
526
  runRefusalSmoke({ image: argument, members: options.member, log, write });
519
527
  return;
@@ -0,0 +1,27 @@
1
+ // `xema-composed-image load-image <image>`: proves every composed member module
2
+ // loads in the SHIPPED image, not in the builder stage that produced it. It pipes
3
+ // `load-members-in-image.mjs` into the image's own node, so the check sees the
4
+ // final stage's filesystem, `node_modules`, prune, user and NODE_ENV. Needs only
5
+ // `docker` and the image reference: no git history, no install.
6
+
7
+ import { spawnSync } from 'node:child_process';
8
+ import { readFileSync } from 'node:fs';
9
+ import { fileURLToPath } from 'node:url';
10
+
11
+ const LOAD_SCRIPT = fileURLToPath(new URL('./load-members-in-image.mjs', import.meta.url));
12
+
13
+ /** Returns the number of member module graphs that loaded; throws naming each that did not. */
14
+ export function runImageLoad({ image, spawn = spawnSync, log = console.log, script = () => readFileSync(LOAD_SCRIPT, 'utf8') }) {
15
+ if (!image) throw new Error('usage: xema-composed-image load-image <image>');
16
+ const result = spawn('docker', ['run', '--rm', '-i', '--entrypoint', 'node', image, '--input-type=module', '-'], {
17
+ encoding: 'utf8',
18
+ maxBuffer: 64 * 1024 * 1024,
19
+ input: script(),
20
+ });
21
+ if (result.error) throw result.error;
22
+ if (result.status !== 0) throw new Error(`the shipped image ${image} does not load:\n${result.stderr ?? ''}`);
23
+ const count = Number.parseInt(String(result.stdout).trim(), 10);
24
+ if (!Number.isInteger(count) || count < 1) throw new Error(`the image load check reported no members: ${result.stdout}`);
25
+ log(`composed image ${image}: all ${count} member module graph(s) load in the shipped image`);
26
+ return count;
27
+ }
@@ -0,0 +1,32 @@
1
+ import assert from 'node:assert/strict';
2
+ import test from 'node:test';
3
+
4
+ import { runImageLoad } from './load-image.mjs';
5
+
6
+ test('the check runs inside the image through its own node and passes on a clean load', () => {
7
+ const calls = [];
8
+ const lines = [];
9
+ const spawn = (command, args, options) => {
10
+ calls.push({ command, args, options });
11
+ return { status: 0, stdout: '3\n', stderr: '' };
12
+ };
13
+ assert.equal(runImageLoad({ image: 'img@sha256:x', spawn, log: (l) => lines.push(l), script: () => 'SCRIPT' }), 3);
14
+ assert.equal(calls[0].command, 'docker');
15
+ assert.deepEqual(calls[0].args, ['run', '--rm', '-i', '--entrypoint', 'node', 'img@sha256:x', '--input-type=module', '-']);
16
+ assert.equal(calls[0].options.input, 'SCRIPT');
17
+ assert.match(lines[0], /all 3 member module graph\(s\) load in the shipped image/);
18
+ });
19
+
20
+ test('a member that does not load fails the check with the image named', () => {
21
+ const spawn = () => ({ status: 1, stdout: '', stderr: '1 of 3 member(s) do not load:\n - a/b: boom\n' });
22
+ assert.throws(() => runImageLoad({ image: 'img', spawn, script: () => '' }), /shipped image img does not load:[\s\S]*a\/b: boom/);
23
+ });
24
+
25
+ test('an image that reports no members is refused', () => {
26
+ const spawn = () => ({ status: 0, stdout: '', stderr: '' });
27
+ assert.throws(() => runImageLoad({ image: 'img', spawn, script: () => '' }), /reported no members/);
28
+ });
29
+
30
+ test('an image reference is required', () => {
31
+ assert.throws(() => runImageLoad({ spawn: () => ({}) }), /usage/);
32
+ });
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ // INVOKER: load-image.mjs, INSIDE the composed-process image (piped to the image's
3
+ // own node on stdin). Self-contained on purpose: it resolves nothing from the
4
+ // repository's checkout, only from the image's own filesystem, as the image's own
5
+ // user and NODE_ENV.
6
+ //
7
+ // Evaluates every member's entry exactly as the host does (`readServiceEntry`),
8
+ // from the member's own directory, and exits non-zero naming each that does not load.
9
+ //
10
+ // Usage: docker run -i --entrypoint node <image> --input-type=module - < load-members-in-image.mjs
11
+ import { readFileSync } from 'node:fs';
12
+ import { createRequire } from 'node:module';
13
+ import { dirname, join } from 'node:path';
14
+
15
+ const indexPath = process.env.XEMA_COMPOSED_MEMBER_INDEX;
16
+ if (!indexPath) throw new Error('XEMA_COMPOSED_MEMBER_INDEX is not set — not a composed-process image');
17
+ const root = dirname(indexPath);
18
+ const members = Object.entries(JSON.parse(readFileSync(indexPath, 'utf8')).members);
19
+ if (members.length === 0) throw new Error('the image member index carries no members');
20
+
21
+ const problems = [];
22
+ for (const [key, { entry }] of members) {
23
+ try {
24
+ const memberDirectory = dirname(dirname(join(root, entry)));
25
+ createRequire(join(memberDirectory, 'package.json'))('@xemahq/xema-service-nest').readServiceEntry(join(root, entry), {});
26
+ } catch (error) {
27
+ problems.push(`${key}: ${error?.message ?? String(error)}`);
28
+ }
29
+ }
30
+ if (problems.length > 0) {
31
+ process.stderr.write(`${problems.length} of ${members.length} member(s) do not load:\n${problems.map((p) => ` - ${p}`).join('\n')}\n`);
32
+ process.exit(1);
33
+ }
34
+ process.stdout.write(`${members.length}\n`);