@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.
package/README.md CHANGED
@@ -64,6 +64,49 @@ shape and no clean literal, and that a residual leak fails the run. This step's
64
64
  failure mode is silence, so "0 leaks" and "0 files read" must never be
65
65
  indistinguishable.
66
66
 
67
+ ### `xema-composed-image <command>`
68
+
69
+ The build-time half of a repository's composed-process image (the runtime half,
70
+ the `xema-composed-process` host, is `@xemahq/xema-service-nest`'s). One image per
71
+ build carries every composable platform service of the repository at one commit.
72
+
73
+ - `members --write|--check` derives that set from the tracked biome manifests and
74
+ writes or verifies `infra/composed-process/image-build-inputs.json`. A member is a
75
+ `kind: service` with `scheduler: platform`; workers, jobs and runtime-scheduled
76
+ services never are. A service is left out, with its reason printed, when its
77
+ `src/main.ts` is not one top-level `void bootstrapXemaService(…)`, it has its own
78
+ `Dockerfile`, it bakes cross-repository inputs, or the canonical Dockerfile
79
+ installs a toolchain for it. Options say where the repository keeps those facts:
80
+ `--manifests <glob>` (repeatable, default `**/xema-biome.json`),
81
+ `--canonical-dockerfile <path>` and `--cross-repo-manifest <path>` (both optional).
82
+ - Inside the image build: `filters` and `dirs` name the members' closures,
83
+ `pins <committed-lockfile>` refuses a re-resolution that moves a registry pin,
84
+ `index <commit> --build-source <repo>` writes the member index the host reads
85
+ (graded by `@xemahq/contracts/process-topology`, resolved from the members) and
86
+ links the host bin, and `load` evaluates every member's entry through the host's
87
+ `readServiceEntry`. `--build-source` is the `repo` the repository's subjects carry
88
+ in an edition's image lock.
89
+
90
+ - `coordinate --image-name <name>` is the image's release coordinate: the content tag
91
+ and whether GHCR already serves it (only 200 and 404 are answers). The tag is computed
92
+ ONLY by the repository's own `@xemahq/distribution-source-hash` (`xema-compute-image-tag`,
93
+ resolved from the repository root); the command refuses when it is not installed, and never
94
+ hashes anything itself. Environment: `SHA`, `REGISTRY`, `REGISTRY_ORG`, `REGISTRY_USER`,
95
+ `REGISTRY_TOKEN`, optional `TAG_ARGS_FILE`, `GITHUB_OUTPUT`. Outputs `image`, `content_tag`,
96
+ `exists`, `entry`. The registry check is also exported as `./ghcr-manifest`
97
+ (`ghcrManifestExists`, `acquireGhcrPullToken`).
98
+ - `smoke-refusal <image> [--member <biomeId>/<componentKey>]x2` boots the pushed image
99
+ with two members on one port and fails unless the host refuses at the declaration, names
100
+ the collision, and constructs no member. Needs only `docker`.
101
+ - `dockerfile --write|--check --tooling-version <x.y.z> --copy <dir>… --cache-id <id> --build-source <repo>`
102
+ renders `infra/composed-process/Dockerfile` from one template. The builder stage installs
103
+ this tool at the EXACT version (so the hashed image directory moves when the tool does),
104
+ runs every step above, and runs `load` last as `node` with `NODE_ENV=production`. Nothing here
105
+ ships in an image: the final stage copies `/monorepo` only.
106
+
107
+ Every step refuses rather than skips. The tool has no dependencies: the host and
108
+ the kernel schema are resolved from the members being built.
109
+
67
110
  ## Scope boundary
68
111
 
69
112
  Dev-time tooling that **more than one repository** runs. Two rules keep it from
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/repo-build-tooling",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
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)",
@@ -23,17 +23,23 @@
23
23
  "bin": {
24
24
  "xema-check-no-vendored-deps": "src/check-no-vendored-deps.mjs",
25
25
  "xema-check-workspace-ranges": "src/check-workspace-range-matches-local.mjs",
26
+ "xema-composed-image": "src/composed-image.mjs",
26
27
  "xema-readiness": "src/readiness.mjs",
27
28
  "xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs"
28
29
  },
29
30
  "exports": {
30
31
  "./check-no-vendored-deps": "./src/check-no-vendored-deps.mjs",
31
32
  "./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs",
33
+ "./composed-image": "./src/composed-image.mjs",
34
+ "./ghcr-manifest": "./src/ghcr-manifest.mjs",
32
35
  "./package.json": "./package.json",
33
36
  "./readiness": "./src/readiness.mjs",
34
37
  "./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs"
35
38
  },
36
39
  "scripts": {
37
- "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"
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"
41
+ },
42
+ "xemaRelease": {
43
+ "sourceDigest": "sha256:89bea8bf6a2415cef4ea2dd5f199135d3ee71cba50d1c9eb93cc92f1b9eb1984"
38
44
  }
39
- }
45
+ }
@@ -0,0 +1,112 @@
1
+ // `xema-composed-image coordinate --image-name <name>`: the release coordinate of a
2
+ // repository's composed-process image, and whether the registry already serves it.
3
+ //
4
+ // The tag is the ORDINARY build-input key, `xema-compute-image-tag` over the
5
+ // image directory, exactly as for any other image subject, so the distribution
6
+ // resolver (which hashes the `composed-process` subject at the image directory with
7
+ // the same package) computes the same value. The hash is NEVER computed here: the
8
+ // bin is resolved from the CONSUMER's own `@xemahq/distribution-source-hash`
9
+ // install, and the coordinate refuses when the repository does not carry it.
10
+ //
11
+ // Presence is the same question a service matrix asks (a HEAD on the content tag),
12
+ // with the same posture: only 200 and 404 are answers.
13
+ //
14
+ // Environment: SHA (40-hex commit), REGISTRY, REGISTRY_ORG, REGISTRY_USER,
15
+ // REGISTRY_TOKEN, optional TAG_ARGS_FILE (one tag argument per line, e.g. the
16
+ // repository's vendored cross-repository inputs; when set it must exist),
17
+ // optional GITHUB_OUTPUT.
18
+
19
+ import { execFileSync } from 'node:child_process';
20
+ import { appendFileSync, existsSync, readFileSync } from 'node:fs';
21
+ import { createRequire } from 'node:module';
22
+ import { dirname, join } from 'node:path';
23
+
24
+ import { ghcrManifestExists } from './ghcr-manifest.mjs';
25
+
26
+ export const HASH_PACKAGE = '@xemahq/distribution-source-hash';
27
+ export const HASH_BIN = 'xema-compute-image-tag';
28
+
29
+ function required(env, name) {
30
+ const value = env[name];
31
+ if (typeof value !== 'string' || value.length === 0) throw new Error(`${name} is required`);
32
+ return value;
33
+ }
34
+
35
+ /** The consumer's own `xema-compute-image-tag` bin path; refuses when it is not installed there. */
36
+ export function resolveHashBin(root) {
37
+ let manifestPath;
38
+ try {
39
+ manifestPath = createRequire(join(root, 'package.json')).resolve(`${HASH_PACKAGE}/package.json`);
40
+ } catch (error) {
41
+ if (error?.code === 'MODULE_NOT_FOUND' || error?.code === 'ERR_PACKAGE_PATH_NOT_EXPORTED') {
42
+ throw new Error(
43
+ `${HASH_PACKAGE} is not installed at ${root} — the content tag is computed only by its ${HASH_BIN} bin; ` +
44
+ 'add it as a devDependency of this repository and install',
45
+ );
46
+ }
47
+ throw error;
48
+ }
49
+ const bin = JSON.parse(readFileSync(manifestPath, 'utf8')).bin?.[HASH_BIN];
50
+ if (typeof bin !== 'string') throw new Error(`${HASH_PACKAGE} at ${manifestPath} ships no "${HASH_BIN}" bin`);
51
+ return join(dirname(manifestPath), bin);
52
+ }
53
+
54
+ /** `{ image, content_tag, exists, entry }` for the composed image of the repository at `root`. */
55
+ export async function composedCoordinate({ root, imageName, imageDir, env = process.env, fetchImpl = fetch, hash }) {
56
+ if (typeof imageName !== 'string' || imageName.length === 0) throw new Error('coordinate needs --image-name');
57
+ const sha = required(env, 'SHA');
58
+ if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error(`SHA '${sha}' is not an exact 40-hex commit`);
59
+ const registry = required(env, 'REGISTRY');
60
+ const org = required(env, 'REGISTRY_ORG');
61
+ const tagArgsFile = env.TAG_ARGS_FILE;
62
+ let tagArgs = [];
63
+ if (tagArgsFile !== undefined && tagArgsFile.length > 0) {
64
+ if (!existsSync(tagArgsFile)) throw new Error(`TAG_ARGS_FILE ${tagArgsFile} is absent`);
65
+ tagArgs = readFileSync(tagArgsFile, 'utf8').split('\n').filter(Boolean);
66
+ }
67
+
68
+ const hashArguments = ['--service', imageName, '--path', imageDir, '--commit', sha, '--repo-dir', root, ...tagArgs];
69
+ const contentHash = (
70
+ hash ??
71
+ ((args) =>
72
+ execFileSync(process.execPath, [resolveHashBin(root), ...args], {
73
+ cwd: root,
74
+ encoding: 'utf8',
75
+ maxBuffer: 32 * 1024 * 1024,
76
+ }))
77
+ )(hashArguments).trim();
78
+ if (!/^[a-f0-9]{64}$/.test(contentHash)) {
79
+ throw new Error(`${HASH_BIN} emitted '${contentHash}', not a sha256 hex digest`);
80
+ }
81
+
82
+ const repository = `${org}/${imageName}`;
83
+ const exists = await ghcrManifestExists({
84
+ registry,
85
+ repository,
86
+ contentHash,
87
+ user: required(env, 'REGISTRY_USER'),
88
+ token: required(env, 'REGISTRY_TOKEN'),
89
+ fetchImpl,
90
+ });
91
+ const image = `${registry}/${repository}`;
92
+ // The same entry shape a service matrix entry has, so the arm64 supplement
93
+ // lane reads this image's receipt exactly as it reads a service's.
94
+ const entry = {
95
+ service: imageName,
96
+ path: imageDir,
97
+ workspace: false,
98
+ runtimeImage: false,
99
+ imageRepository: image,
100
+ contentHash,
101
+ };
102
+ return { image, content_tag: contentHash, exists: String(exists), entry: JSON.stringify(entry) };
103
+ }
104
+
105
+ /** Print the outputs, append them to GITHUB_OUTPUT when set. */
106
+ export function emitCoordinate(outputs, { env = process.env, log = console.log } = {}) {
107
+ for (const [key, value] of Object.entries(outputs)) {
108
+ if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `${key}=${value}\n`);
109
+ log(`${key}=${value}`);
110
+ }
111
+ log(`${outputs.image}:${outputs.content_tag} ${outputs.exists === 'true' ? 'exists — skip' : 'is missing — build'}`);
112
+ }
@@ -0,0 +1,108 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import { composedCoordinate, emitCoordinate, resolveHashBin } from './composed-image-coordinate.mjs';
8
+
9
+ const SHA = 'c'.repeat(40);
10
+ const HASH = 'd'.repeat(64);
11
+ const ENV = { SHA, REGISTRY: 'ghcr.io', REGISTRY_ORG: 'xema-dev', REGISTRY_USER: 'runner', REGISTRY_TOKEN: 'secret' };
12
+
13
+ function response(status, body) {
14
+ return { status, async json() { return body; } };
15
+ }
16
+
17
+ function registry(manifestStatus) {
18
+ const urls = [];
19
+ return {
20
+ urls,
21
+ fetchImpl: async (url) => {
22
+ urls.push(String(url));
23
+ return String(url).includes('/token') ? response(200, { token: 't' }) : response(manifestStatus, {});
24
+ },
25
+ };
26
+ }
27
+
28
+ /** A consumer root whose own install carries a stand-in `xema-compute-image-tag` that echoes its arguments into a file. */
29
+ function consumer({ withHash = true } = {}) {
30
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-coordinate-'));
31
+ fs.writeFileSync(path.join(root, 'package.json'), '{"name":"consumer"}');
32
+ if (withHash) {
33
+ const pkg = path.join(root, 'node_modules/@xemahq/distribution-source-hash');
34
+ fs.mkdirSync(path.join(pkg, 'dist/bin'), { recursive: true });
35
+ fs.writeFileSync(
36
+ path.join(pkg, 'package.json'),
37
+ JSON.stringify({ name: '@xemahq/distribution-source-hash', version: '1.0.0', bin: { 'xema-compute-image-tag': 'dist/bin/compute-image-tag.js' } }),
38
+ );
39
+ fs.writeFileSync(
40
+ path.join(pkg, 'dist/bin/compute-image-tag.js'),
41
+ `require('node:fs').writeFileSync(${JSON.stringify(path.join(root, 'args.json'))}, JSON.stringify(process.argv.slice(2)));\nconsole.log(${JSON.stringify(HASH)});\n`,
42
+ );
43
+ }
44
+ return root;
45
+ }
46
+
47
+ test('the hash comes from the consumer\'s own bin, called with the service, directory, commit and tag args', async () => {
48
+ const root = consumer();
49
+ const tagArgs = path.join(root, 'tag-args.txt');
50
+ fs.writeFileSync(tagArgs, '--vendored\nx=y@z:w\n');
51
+ const { fetchImpl, urls } = registry(404);
52
+ const out = await composedCoordinate({
53
+ root,
54
+ imageName: 'alpha-composed-process',
55
+ imageDir: 'infra/composed-process',
56
+ env: { ...ENV, TAG_ARGS_FILE: tagArgs },
57
+ fetchImpl,
58
+ });
59
+ assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, 'args.json'), 'utf8')), [
60
+ '--service', 'alpha-composed-process', '--path', 'infra/composed-process', '--commit', SHA, '--repo-dir', root, '--vendored', 'x=y@z:w',
61
+ ]);
62
+ assert.equal(out.image, 'ghcr.io/xema-dev/alpha-composed-process');
63
+ assert.equal(out.content_tag, HASH);
64
+ assert.equal(out.exists, 'false');
65
+ assert.ok(urls[1].endsWith(`/v2/xema-dev/alpha-composed-process/manifests/${HASH}`));
66
+ assert.deepEqual(JSON.parse(out.entry), {
67
+ service: 'alpha-composed-process', path: 'infra/composed-process', workspace: false, runtimeImage: false,
68
+ imageRepository: 'ghcr.io/xema-dev/alpha-composed-process', contentHash: HASH,
69
+ });
70
+ });
71
+
72
+ test('a present manifest reports exists', async () => {
73
+ const out = await composedCoordinate({ root: consumer(), imageName: 'a', imageDir: 'd', env: ENV, fetchImpl: registry(200).fetchImpl });
74
+ assert.equal(out.exists, 'true');
75
+ });
76
+
77
+ test('a repository without the hash package is refused, never hashed another way', async () => {
78
+ await assert.rejects(
79
+ composedCoordinate({ root: consumer({ withHash: false }), imageName: 'a', imageDir: 'd', env: ENV, fetchImpl: registry(404).fetchImpl }),
80
+ /distribution-source-hash is not installed/,
81
+ );
82
+ assert.throws(() => resolveHashBin(consumer({ withHash: false })), /not installed/);
83
+ });
84
+
85
+ test('the inputs are checked: commit, registry settings, tag args file, digest shape', async () => {
86
+ const root = consumer();
87
+ const base = { root, imageName: 'a', imageDir: 'd', fetchImpl: registry(404).fetchImpl };
88
+ await assert.rejects(composedCoordinate({ ...base, env: { ...ENV, SHA: 'abc' } }), /40-hex/);
89
+ await assert.rejects(composedCoordinate({ ...base, env: { ...ENV, REGISTRY_ORG: '' } }), /REGISTRY_ORG is required/);
90
+ await assert.rejects(composedCoordinate({ ...base, env: { ...ENV, TAG_ARGS_FILE: path.join(root, 'nope') } }), /TAG_ARGS_FILE .* absent/);
91
+ await assert.rejects(composedCoordinate({ ...base, imageName: '', env: ENV }), /--image-name/);
92
+ await assert.rejects(composedCoordinate({ ...base, env: ENV, hash: () => 'not-a-digest' }), /not a sha256/);
93
+ });
94
+
95
+ test('an unusable registry answer fails the coordinate rather than reading as missing', async () => {
96
+ await assert.rejects(
97
+ composedCoordinate({ root: consumer(), imageName: 'a', imageDir: 'd', env: ENV, fetchImpl: registry(500).fetchImpl }),
98
+ /HTTP 500/,
99
+ );
100
+ });
101
+
102
+ test('emitCoordinate prints every output and appends them to GITHUB_OUTPUT', () => {
103
+ const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'xema-out-')), 'out');
104
+ const lines = [];
105
+ emitCoordinate({ image: 'i', content_tag: HASH, exists: 'false', entry: '{}' }, { env: { GITHUB_OUTPUT: file }, log: (l) => lines.push(l) });
106
+ assert.equal(fs.readFileSync(file, 'utf8'), `image=i\ncontent_tag=${HASH}\nexists=false\nentry={}\n`);
107
+ assert.match(lines.at(-1), /is missing — build/);
108
+ });
@@ -0,0 +1,181 @@
1
+ // `xema-composed-image dockerfile --write|--check`: renders `<image-dir>/Dockerfile`,
2
+ // the composed-process image of ANY producing repository, from one template.
3
+ //
4
+ // What differs per repository is a parameter, never a branch: the directories its
5
+ // build context carries (`--copy`, repeatable), its pnpm cache id (`--cache-id`),
6
+ // the repository its member subjects name in an edition's image lock
7
+ // (`--build-source`) and the EXACT version of this tool the builder installs
8
+ // (`--tooling-version`). The tool is pinned exactly so the image directory, which
9
+ // the content tag hashes, changes whenever the tool does.
10
+ //
11
+ // NOTHING OF THE TOOL SHIPS: it is installed globally in the builder stage only,
12
+ // and the final stage copies `/monorepo` alone. `load` runs at the end of the
13
+ // builder as the `node` user with NODE_ENV=production, after the production
14
+ // install, which is the user and environment the image runs with.
15
+
16
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
17
+ import { join, posix } from 'node:path';
18
+
19
+ import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
20
+
21
+ const VERSION = /^\d+\.\d+\.\d+$/;
22
+ const DIRECTORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*\/?$/;
23
+ const CACHE_ID = /^[a-z0-9][a-z0-9-]*$/;
24
+ const REPOSITORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
25
+ const NODE_BASE =
26
+ 'mirror.gcr.io/library/node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436';
27
+
28
+ function validate({ toolingVersion, copy, cacheId, buildSource }) {
29
+ if (!VERSION.test(toolingVersion ?? '')) {
30
+ throw new Error(`--tooling-version must be an exact x.y.z version, got "${toolingVersion}"`);
31
+ }
32
+ if (!Array.isArray(copy) || copy.length === 0) throw new Error('--copy needs at least one directory');
33
+ for (const directory of copy) {
34
+ if (!DIRECTORY.test(directory) || directory.startsWith('/') || directory.split('/').includes('..')) {
35
+ throw new Error(`--copy "${directory}" is not a repository-relative directory`);
36
+ }
37
+ }
38
+ if (!CACHE_ID.test(cacheId ?? '')) throw new Error(`--cache-id must be lowercase [a-z0-9-], got "${cacheId}"`);
39
+ if (!REPOSITORY.test(buildSource ?? '')) throw new Error(`--build-source must name a repository, got "${buildSource}"`);
40
+ }
41
+
42
+ /** The Dockerfile text. `imageDir` is where the repository keeps the image (and so where this is written). */
43
+ export function renderComposedDockerfile({ toolingVersion, copy, cacheId, buildSource, imageDir = COMPOSED_IMAGE_DIR }) {
44
+ validate({ toolingVersion, copy, cacheId, buildSource });
45
+ const dirFlag = imageDir === COMPOSED_IMAGE_DIR ? '' : ` --image-dir ${imageDir}`;
46
+ const tool = (command) => `xema-composed-image ${command}${dirFlag}`;
47
+ const copies = [...new Set([...copy.map((d) => posix.normalize(`${d}/`)), posix.normalize(`${imageDir}/`)])]
48
+ .map((d) => `COPY ${d} ./${d}`)
49
+ .join('\n');
50
+ const store = `--mount=type=cache,id=pnpm-store-${cacheId},target=/root/.pnpm-store`;
51
+ const metadata = `--mount=type=cache,id=pnpm-metadata-${cacheId},target=/root/.cache/pnpm`;
52
+ return `# @generated by \`xema-composed-image dockerfile --write\` (@xemahq/repo-build-tooling@${toolingVersion}) — DO NOT EDIT.
53
+ # Re-render with the same options; \`--check\` refuses a stale file.
54
+ #
55
+ # The COMPOSED-PROCESS image of this repository: ONE image per build (repository at
56
+ # commit), carrying every member listed in \`image-build-inputs.json\` in ONE
57
+ # module graph, behind the SDK's \`xema-composed-process\` host.
58
+ #
59
+ # It is never per grouping. Which members share a process is the DEPLOYMENT's
60
+ # declaration (\`XEMA_PROCESS_*\`), read at boot; the image owns only what it
61
+ # contains — the baked member index — so regrouping never builds an image.
62
+ #
63
+ # Built at the ordinary build-input key \`xema-compute-image-tag\` computes for this
64
+ # directory, so the tool pinned below is part of the key.
65
+ #
66
+ # Layout: the workspace stays a workspace. Every member keeps its own
67
+ # directory, its own \`node_modules\` and its own Prisma client, all resolving
68
+ # through ONE \`.pnpm\` store, so an identity package (\`@nestjs/core\`, …) has one
69
+ # physical copy — the host refuses at boot, and \`xema-composed-image index\`
70
+ # refuses at build, any member that resolves a second one.
71
+ FROM ${NODE_BASE} AS base
72
+ RUN corepack enable && \\
73
+ apt-get update && apt-get install -y --no-install-recommends openssl wget && rm -rf /var/lib/apt/lists/* && \\
74
+ pnpm config set store-dir /root/.pnpm-store
75
+
76
+ FROM base AS builder
77
+ WORKDIR /monorepo
78
+ RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
79
+ ENV CI=true
80
+ ARG BUILD_SHA
81
+
82
+ # The build tool, at an EXACT version, in the builder only: the final stage
83
+ # copies /monorepo, so nothing of it ships.
84
+ RUN npm install --global --ignore-scripts @xemahq/repo-build-tooling@${toolingVersion}
85
+
86
+ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc tsconfig.base.json ./
87
+ ${copies}
88
+
89
+ RUN ${tool('filters')} > /tmp/member-filters && \\
90
+ test -s /tmp/member-filters
91
+
92
+ RUN ${store} \\
93
+ --mount=type=secret,id=npm_token,required=true \\
94
+ NPM_TOKEN="$(cat /run/secrets/npm_token)" GH_PACKAGES_TOKEN="$NPM_TOKEN" \\
95
+ pnpm fetch $(cat /tmp/member-filters) --frozen-lockfile
96
+ RUN ${store} \\
97
+ --mount=type=secret,id=npm_token,required=true \\
98
+ NPM_TOKEN="$(cat /run/secrets/npm_token)" GH_PACKAGES_TOKEN="$NPM_TOKEN" \\
99
+ pnpm install --prefer-offline $(cat /tmp/member-filters) --frozen-lockfile
100
+
101
+ # Every member that owns a Prisma schema generates its OWN client into its own
102
+ # \`node_modules/.prisma/<service>\` (the schema's \`output\`), before it builds.
103
+ RUN for schema in $(${tool('dirs')}); do \\
104
+ if [ -f "\${schema}/prisma/schema.prisma" ]; then \\
105
+ echo "prisma generate: \${schema}" && pnpm --dir "\${schema}" exec prisma generate || exit 1; \\
106
+ fi; \\
107
+ done
108
+
109
+ RUN --mount=type=secret,id=npm_token,required=true \\
110
+ NPM_TOKEN="$(cat /run/secrets/npm_token)" GH_PACKAGES_TOKEN="$NPM_TOKEN" \\
111
+ pnpm $(cat /tmp/member-filters) run build
112
+
113
+ # Production install, with the members' WORKSPACE packages INJECTED — the same
114
+ # resolution the per-service image gets from \`pnpm deploy\` (legacy deploy
115
+ # injects every workspace dependency). A workspace SDK that lists a package
116
+ # only as a PEER (\`@nestjs/common\`, \`ioredis\`, …) has it in the linked
117
+ # workspace solely as its own devDependency, which \`--prod\` removes: the members
118
+ # that import it then fail \`Cannot find module\`. Injected, each workspace
119
+ # package is a copy (its built \`files\`) inside the store whose peers are the
120
+ # DEPENDENT member's own copies — one module graph, peers resolved.
121
+ #
122
+ # The committed lockfile links workspace packages, so the injected one is
123
+ # re-resolved here from it (\`--lockfile-only\`; \`dedupe-injected-deps=false\`, or
124
+ # pnpm folds an injected package back to the link that loses its peers). The
125
+ # re-resolution may only add the injected workspace entries: \`pins\` refuses it
126
+ # if any registry package's version moved off the committed lockfile. Then the
127
+ # frozen install of exactly that lockfile, and the Prisma clients the prune may
128
+ # have removed (\`prisma\` is a production dependency of every member with a schema).
129
+ #
130
+ # \`--ignore-scripts\`: an injected workspace package runs its own lifecycle
131
+ # (\`prepublishOnly: build\` on the clients), which cannot run in a production
132
+ # install and has nothing to do — its \`dist\` was built above and is what is
133
+ # injected. Every registry package keeps the virtual-store directory the first
134
+ # install built, and the members' \`postinstall\` is the Prisma loop below.
135
+ RUN ${store} \\
136
+ ${metadata} \\
137
+ --mount=type=secret,id=npm_token,required=true \\
138
+ export NPM_TOKEN="$(cat /run/secrets/npm_token)" && export GH_PACKAGES_TOKEN="$NPM_TOKEN" && \\
139
+ INJECT="--config.inject-workspace-packages=true --config.dedupe-injected-deps=false" && \\
140
+ cp pnpm-lock.yaml /tmp/pnpm-lock.committed.yaml && \\
141
+ pnpm install --lockfile-only --prefer-offline \${INJECT} $(cat /tmp/member-filters) && \\
142
+ ${tool('pins')} /tmp/pnpm-lock.committed.yaml && \\
143
+ pnpm install --prod --prefer-offline --ignore-scripts \${INJECT} $(cat /tmp/member-filters) --frozen-lockfile
144
+ RUN for schema in $(${tool('dirs')}); do \\
145
+ if [ -f "\${schema}/prisma/schema.prisma" ]; then \\
146
+ pnpm --dir "\${schema}" exec prisma generate || exit 1; \\
147
+ fi; \\
148
+ done
149
+
150
+ RUN : "\${BUILD_SHA:?BUILD_SHA build-arg is required}" && \\
151
+ ${tool(`index "\${BUILD_SHA}" --build-source ${buildSource}`)}
152
+
153
+ # Every member's module graph loads here, through the host's own entry reader, as
154
+ # the user and NODE_ENV the image runs with, or the image is never pushed. It runs
155
+ # in the builder so the tool above never reaches the final stage; it only reads.
156
+ USER node
157
+ RUN NODE_ENV=production ${tool('load')}
158
+
159
+ FROM base
160
+ WORKDIR /monorepo
161
+ COPY --from=builder /monorepo /monorepo
162
+ ENV NODE_ENV=production
163
+ # Set by the IMAGE, never by a deployment (kernel \`COMPOSED_PROCESS_ENV.MemberIndex\`).
164
+ ENV XEMA_COMPOSED_MEMBER_INDEX=/monorepo/xema-composed-member-index.json
165
+ USER node
166
+ CMD ["node", "/monorepo/xema-composed-process"]
167
+ `;
168
+ }
169
+
170
+ /** `--write` writes the rendered Dockerfile; `--check` throws when the committed one differs. */
171
+ export function syncComposedDockerfile({ mode, root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, ...options }) {
172
+ if (mode !== 'write' && mode !== 'check') throw new Error(`mode must be write or check, got "${mode}"`);
173
+ const rendered = renderComposedDockerfile({ ...options, imageDir });
174
+ const relativeTarget = posix.join(imageDir, 'Dockerfile');
175
+ const target = join(root, relativeTarget);
176
+ if (mode === 'write') writeFileSync(target, rendered);
177
+ else if ((existsSync(target) ? readFileSync(target, 'utf8') : '') !== rendered) {
178
+ throw new Error(`${relativeTarget} is stale. Run: xema-composed-image dockerfile --write (with the same options)`);
179
+ }
180
+ return relativeTarget;
181
+ }
@@ -0,0 +1,59 @@
1
+ import assert from 'node:assert/strict';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import test from 'node:test';
6
+
7
+ import { renderComposedDockerfile, syncComposedDockerfile } from './composed-image-dockerfile.mjs';
8
+
9
+ const OPTIONS = {
10
+ toolingVersion: '0.9.0',
11
+ copy: ['packages', 'tooling/dev/', 'biomes'],
12
+ cacheId: 'alpha-composed-process',
13
+ buildSource: 'alpha',
14
+ };
15
+
16
+ test('the template carries every parameter and no per-repository literal', () => {
17
+ const text = renderComposedDockerfile(OPTIONS);
18
+ assert.match(text, /npm install --global --ignore-scripts @xemahq\/repo-build-tooling@0\.9\.0\n/);
19
+ assert.match(text, /^COPY packages\/ \.\/packages\/\nCOPY tooling\/dev\/ \.\/tooling\/dev\/\nCOPY biomes\/ \.\/biomes\/\nCOPY infra\/composed-process\/ \.\/infra\/composed-process\/$/m);
20
+ assert.match(text, /id=pnpm-store-alpha-composed-process,/);
21
+ assert.match(text, /id=pnpm-metadata-alpha-composed-process,/);
22
+ assert.match(text, /xema-composed-image index "\$\{BUILD_SHA\}" --build-source alpha/);
23
+ assert.doesNotMatch(text, /xema-base|composed-image\.mjs/);
24
+ });
25
+
26
+ test('every in-image step uses the pinned bin, and a custom image dir is threaded through', () => {
27
+ const text = renderComposedDockerfile({ ...OPTIONS, imageDir: 'infra/other' });
28
+ for (const command of ['filters', 'dirs', 'pins', 'index', 'load']) assert.match(text, new RegExp(`xema-composed-image ${command}[^\\n]* --image-dir infra/other|xema-composed-image ${command} --image-dir infra/other`));
29
+ assert.match(text, /COPY infra\/other\/ \.\/infra\/other\//);
30
+ });
31
+
32
+ test('nothing of the tool reaches the final stage; load runs in the builder as node, production', () => {
33
+ const text = renderComposedDockerfile(OPTIONS);
34
+ const [builder, final] = text.split('\nFROM base\n');
35
+ assert.match(builder, /USER node\nRUN NODE_ENV=production xema-composed-image load/);
36
+ assert.doesNotMatch(final, /xema-composed-image|repo-build-tooling|npm install/);
37
+ assert.match(final, /COPY --from=builder \/monorepo \/monorepo/);
38
+ assert.match(final, /CMD \["node", "\/monorepo\/xema-composed-process"\]/);
39
+ });
40
+
41
+ test('the tool version, the directories and the ids are validated', () => {
42
+ assert.throws(() => renderComposedDockerfile({ ...OPTIONS, toolingVersion: '^0.9.0' }), /exact x\.y\.z/);
43
+ assert.throws(() => renderComposedDockerfile({ ...OPTIONS, toolingVersion: undefined }), /exact x\.y\.z/);
44
+ assert.throws(() => renderComposedDockerfile({ ...OPTIONS, copy: [] }), /at least one/);
45
+ for (const bad of ['../x', '/abs', 'a b', 'a;b']) assert.throws(() => renderComposedDockerfile({ ...OPTIONS, copy: [bad] }), /repository-relative/);
46
+ assert.throws(() => renderComposedDockerfile({ ...OPTIONS, cacheId: 'Bad_Id' }), /--cache-id/);
47
+ assert.throws(() => renderComposedDockerfile({ ...OPTIONS, buildSource: 'a//b' }), /--build-source/);
48
+ assert.match(renderComposedDockerfile({ ...OPTIONS, buildSource: 'repos/alpha' }), /--build-source repos\/alpha/);
49
+ });
50
+
51
+ test('--write renders and --check refuses a stale or missing file', () => {
52
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-dockerfile-'));
53
+ fs.mkdirSync(path.join(root, 'infra/composed-process'), { recursive: true });
54
+ assert.throws(() => syncComposedDockerfile({ mode: 'check', root, ...OPTIONS }), /is stale/);
55
+ assert.equal(syncComposedDockerfile({ mode: 'write', root, ...OPTIONS }), 'infra/composed-process/Dockerfile');
56
+ syncComposedDockerfile({ mode: 'check', root, ...OPTIONS });
57
+ assert.throws(() => syncComposedDockerfile({ mode: 'check', root, ...OPTIONS, toolingVersion: '0.9.1' }), /is stale/);
58
+ assert.throws(() => syncComposedDockerfile({ mode: 'sideways', root, ...OPTIONS }), /write or check/);
59
+ });
@@ -0,0 +1,2 @@
1
+ // Where a repository keeps its composed-process image: the one place this is spelled.
2
+ export const COMPOSED_IMAGE_DIR = 'infra/composed-process';