@xemahq/repo-build-tooling 0.10.1 → 0.11.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
@@ -1,138 +1,46 @@
1
- # `@xemahq/repo-build-tooling`
2
-
3
- This package belongs to Layer 1 — dev-time build tooling shared by every Xema
4
- repository. Nothing here ever ships inside a service image; every consumer
5
- declares it as a `devDependency`.
6
-
7
- ## Why it is a package and not a file
8
-
9
- It used to be a file — five byte-identical copies of one 700-line script, one
10
- per repository that needed it, kept honest by a boundary check that ran each
11
- copy's `--self-test`.
12
-
13
- That arrangement could not hold the invariant it existed for. A defect found in
14
- one copy is fixed in one copy; the other four keep shipping it until someone
15
- remembers. Exactly that happened: three of the five copies deliberately carried
16
- *less* behaviour than the other two (they detected a leak shape without
17
- rewriting it), which was a reasonable decision at the time and then became a
18
- permanent, undocumented divergence nobody re-examined. A parity check on N
19
- copies is a workaround for not having one artifact.
20
-
21
- ## How the two resolution modes work
22
-
23
- This is the standard shape in this fleet, and getting it wrong is the single
24
- most common source of "green locally, red in CI":
25
-
26
- - **Inside the `xema-monorepo` aggregator**, the root `package.json`'s
27
- `pnpm.overrides` maps `@xemahq/repo-build-tooling` to `workspace:*`, so it
28
- resolves to this directory. Editing the tool and rebuilding a service in the
29
- same checkout needs no publish, no version bump and no registry round-trip.
30
- - **Inside each independent repository's own CI**, there is no aggregator and no
31
- sibling checkout. The declared semver range is what resolves, from npm.
32
-
33
- So the range in a consumer's `package.json` is the real contract, and the local
34
- link is a development convenience layered on top. A symbol added here is not
35
- available to a consumer until this package is published AND that consumer's
36
- range admits the new version — the aggregator will happily compile against the
37
- local source and tell you nothing.
38
-
39
- ## Contents
40
-
41
- ### `xema-scrub-swagger-paths <dist-dir> [<dist-dir>…]`
42
-
43
- Post-build sanitizer for `@nestjs/swagger` plugin output, and the verification
44
- step for the same property. The plugin writes `require()` literals into
45
- `_OPENAPI_METADATA_FACTORY()` at `nest build` time — they exist in no source
46
- file — so this is the only thing in the fleet that can see them, and it runs
47
- immediately after `nest build` in all 54 services that use the plugin.
48
-
49
- It rewrites specifiers that name the builder's private install layout into the
50
- target package's public specifier, then re-reads the output from disk and fails
51
- the build if any leak survives.
52
-
53
- The rewrite is **derived and proved**, never tabled: the replacement comes from
54
- the target package's own `exports` map, and is accepted only if the emitted
55
- re-export chain (read statically — package code is never executed) reaches the
56
- same module. It refuses rather than guesses. That matters because the two
57
- packages that leak most are opposites — `@xemahq/kernel-contracts` exports only
58
- `./*` and has no root export, so rewriting to the bare name would swap one crash
59
- for another, while `@xemahq/platform-common` exports both `.` and `./dist/*`.
60
-
61
- `--self-test` proves, on a scratch tree with no build and no fixtures, that a
62
- missing/empty/no-`.js` directory each FAIL, that the detector matches every leak
63
- shape and no clean literal, and that a residual leak fails the run. This step's
64
- failure mode is silence, so "0 leaks" and "0 files read" must never be
65
- indistinguishable.
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
- - `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.
99
- - `smoke-refusal <image> [--member <biomeId>/<componentKey>]x2` boots the pushed image
100
- with two members on one port and fails unless the host refuses at the declaration, names
101
- the collision, and constructs no member. Needs only `docker`.
102
- - `dockerfile --write|--check --tooling-version <x.y.z> --copy <dir>… --cache-id <id> --build-source <repo>`
103
- renders `infra/composed-process/Dockerfile` from one template. The builder stage installs
104
- this tool at the EXACT version (so the hashed image directory moves when the tool does),
105
- runs every step above, and runs `load` last as `node` with `NODE_ENV=production`. Nothing here
106
- ships in an image: the final stage copies `/monorepo` only.
107
-
108
- Every step refuses rather than skips. The tool has no dependencies: the host and
109
- the kernel schema are resolved from the members being built.
110
-
111
- ### `xema-service-bootstrap [--check] [--filter <key>] [--root <dir>]`
112
-
113
- Generates the descriptors `@xemahq/xema-service-nest` boots from, out of the
114
- repository's own `xema-biome.json` manifests. For every `kind: service` component
115
- it writes `src/generated/<name>.bootstrap.generated.ts` (a `BiomeServiceDescriptor`),
116
- and for every `kind: worker` component `src/generated/<key>.worker-descriptor.generated.ts`
117
- (a `XemaWorkerDescriptor`; a `shared-oci-image` worker lands in the package of the
118
- component whose image it shares). Only packages that declare
119
- `@xemahq/xema-service-nest` are written to.
120
-
121
- `requirements.scaling.concurrency.maximumPerInstance` becomes
122
- `maximumConcurrentRequests` on a service and `maximumConcurrentActivities` on a
123
- worker. A component without a positive integer there is refused, naming the
124
- manifest and the component.
125
-
126
- `--check` writes nothing and fails on any drift. A run that finds no manifest, or no
127
- service or worker to generate, fails rather than reporting success. Run it from the
128
- repository root, or pass `--root`.
129
-
130
- ## Scope boundary
131
-
132
- Dev-time tooling that **more than one repository** runs. Two rules keep it from
133
- becoming a junk drawer:
134
-
135
- - Nothing with a runtime consumer. If a service imports it at run time it is not
136
- build tooling and belongs in an SDK package.
137
- - Nothing with a single consumer. A tool used by one repository stays in that
138
- repository until a second one needs it.
1
+ # @xemahq/repo-build-tooling
2
+
3
+ > Dev-time build tooling shared by every Xema repository
4
+
5
+ ## Overview
6
+
7
+ Command-line tools that more than one Xema repository runs at build or check time. Nothing here ships in a service image; consumers declare it as a `devDependency`.
8
+
9
+ ## When to use it
10
+
11
+ - To run the shared build and readiness checks in a repository, locally or in CI.
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ pnpm add -D @xemahq/repo-build-tooling
17
+ ```
18
+
19
+ ## Usage
20
+
21
+ ```bash
22
+ pnpm exec xema-readiness
23
+ pnpm exec xema-service-bootstrap --check
24
+ pnpm exec xema-scrub-swagger-paths dist
25
+ ```
26
+
27
+ | Bin | What it does |
28
+ | --- | --- |
29
+ | `xema-readiness` | Runs the ordered checks between a source change and a push |
30
+ | `xema-service-bootstrap [--check] [--filter <key>] [--root <dir>]` | Generates `src/generated/*.generated.ts` service and worker descriptors from the repository's `xema-biome.json` manifests; `--check` fails on drift |
31
+ | `xema-scrub-swagger-paths <dist-dir>...` | Rewrites `@nestjs/swagger` plugin output that names a private install path, then fails the build if any leak remains (`--self-test` available) |
32
+ | `xema-composed-image <command>` | Build-time half of a composed-process image: `members`, `filters`, `dirs`, `pins`, `index`, `load`, `stage`, `sync-injected`, `prisma-check`, `migrate`, `coordinate`, `load-image`, `smoke-refusal`, `dockerfile` |
33
+ | `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
34
+ | `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
35
+
36
+ `xema-composed-image members --write|--check` accepts `--manifests <glob>` (repeatable, default `**/xema-biome.json`), `--canonical-dockerfile <path>` and `--cross-repo-manifest <path>`. `xema-composed-image dockerfile` requires `--tooling-version`, `--copy`, `--cache-id` and `--build-source`. `dockerfile --prebuilt` renders the Dockerfile and `Dockerfile.dockerignore` of an image assembled from `stage <dir> [--output <rel>]…` output (requires `--tooling-version`, `--cache-id`, `--build-source`; `--copy` and `--stage-dir` optional); `load --each` loads every member in its own process. Run `xema-composed-image` with no arguments for the full usage.
37
+
38
+ Every step refuses rather than skips. The package has no dependencies.
39
+
40
+ ## Resolution
41
+
42
+ Inside the `xema-monorepo` aggregator, `@xemahq/repo-build-tooling` resolves to this directory through a workspace override. In an independent repository's CI it resolves from npm by the declared range, so a change here reaches a consumer only after it is published and the consumer's range admits it.
43
+
44
+ ## License
45
+
46
+ Apache-2.0 &copy; Xema — [xema.dev](https://xema.dev)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/repo-build-tooling",
3
- "version": "0.10.1",
3
+ "version": "0.11.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)",
@@ -39,9 +39,9 @@
39
39
  "./service-bootstrap": "./src/service-bootstrap.mjs"
40
40
  },
41
41
  "scripts": {
42
- "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 && node --test src/registry-fetch.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs"
42
+ "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 && node --test src/registry-fetch.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-migrate.test.mjs"
43
43
  },
44
44
  "xemaRelease": {
45
- "sourceDigest": "sha256:35b4aafd2345b2d892fff707b22790985f6ae1e20f75d0f7467f3401f8b583d2"
45
+ "sourceDigest": "sha256:8a0729af873e6dee7bc38a38408c3f0f57447884e6461ad36e06e8165cf28f4b"
46
46
  }
47
47
  }
@@ -23,7 +23,7 @@ const VERSION = /^\d+\.\d+\.\d+$/;
23
23
  const DIRECTORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*\/?$/;
24
24
  const CACHE_ID = /^[a-z0-9][a-z0-9-]*$/;
25
25
  const REPOSITORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
26
- const NODE_BASE =
26
+ export const NODE_BASE =
27
27
  'mirror.gcr.io/library/node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436';
28
28
 
29
29
  function validate({ toolingVersion, copy, cacheId, buildSource }) {
@@ -55,10 +55,10 @@ export function renderComposedDockerfile({ toolingVersion, copy, cacheId, buildS
55
55
  .map((d) => `COPY ${d} ./${d}`)
56
56
  .join('\n');
57
57
  const store = `--mount=type=cache,id=pnpm-store-${cacheId},target=/root/.pnpm-store`;
58
- const mount = usesToken ? ' --mount=type=secret,id=npm_token,required=true \\\\\n' : '';
59
- const tokenEnv = usesToken ? 'NPM_TOKEN="$(cat /run/secrets/npm_token)" GH_PACKAGES_TOKEN="$NPM_TOKEN" \\\\\n ' : '';
58
+ const mount = usesToken ? ' --mount=type=secret,id=npm_token,required=true \\\n' : '';
59
+ const tokenEnv = usesToken ? 'NPM_TOKEN="$(cat /run/secrets/npm_token)" GH_PACKAGES_TOKEN="$NPM_TOKEN" \\\n ' : '';
60
60
  const tokenExport = usesToken
61
- ? 'export NPM_TOKEN="$(cat /run/secrets/npm_token)" && export GH_PACKAGES_TOKEN="$NPM_TOKEN" && \\\\\n '
61
+ ? 'export NPM_TOKEN="$(cat /run/secrets/npm_token)" && export GH_PACKAGES_TOKEN="$NPM_TOKEN" && \\\n '
62
62
  : '';
63
63
  const metadata = `--mount=type=cache,id=pnpm-metadata-${cacheId},target=/root/.cache/pnpm`;
64
64
  return `# @generated by \`xema-composed-image dockerfile --write\` (@xemahq/repo-build-tooling@${toolingVersion}) — DO NOT EDIT.
@@ -84,3 +84,9 @@ test('the token use is derived from .npmrc, deterministically', () => {
84
84
  fs.writeFileSync(path.join(root, '.npmrc'), '//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n');
85
85
  assert.throws(() => syncComposedDockerfile({ mode: 'check', root, ...OPTIONS }), /is stale/);
86
86
  });
87
+
88
+ test('a token-using repository gets single-backslash line continuations (a doubled one is a parse error)', () => {
89
+ const text = renderComposedDockerfile({ ...OPTIONS, usesToken: true });
90
+ assert.match(text, /--mount=type=secret,id=npm_token,required=true \\\n/);
91
+ for (const line of text.split('\n')) assert.doesNotMatch(line, /\\\\\s*$/, `line ends with a doubled backslash: ${line}`);
92
+ });
@@ -0,0 +1,55 @@
1
+ // Shared fixture for the prebuilt-image tests: a tiny workspace with two members
2
+ // (one with a Prisma schema) and one non-member dependency.
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ export function write(root, rel, content) {
8
+ fs.mkdirSync(path.join(root, path.dirname(rel)), { recursive: true });
9
+ fs.writeFileSync(path.join(root, rel), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
10
+ }
11
+
12
+ export const MEMBERS = Object.freeze({ 'alpha/alpha-api': 'biomes/alpha/api/alpha-api', 'alpha/alpha-db-api': 'biomes/alpha/api/alpha-db-api' });
13
+ export const CLOSURE = Object.freeze(['biomes/alpha/api/alpha-api', 'biomes/alpha/api/alpha-db-api', 'packages/sdks/lib-a']);
14
+
15
+ export function workspace({ built = true } = {}) {
16
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-prebuilt-'));
17
+ write(root, 'biomes/alpha/xema-biome.json', {
18
+ xema: {
19
+ id: 'alpha',
20
+ components: [
21
+ { key: 'alpha-api', kind: 'service', scheduler: 'platform', artifact: { path: 'api/alpha-api' } },
22
+ { key: 'alpha-db-api', kind: 'service', scheduler: 'platform', artifact: { path: 'api/alpha-db-api' } },
23
+ ],
24
+ },
25
+ });
26
+ write(root, 'infra/composed-process/image-build-inputs.json', { workspacePackages: Object.values(MEMBERS) });
27
+ write(root, 'pnpm-workspace.yaml', "packages:\n - 'biomes/*/api/*'\n\nonlyBuiltDependencies:\n - 'esbuild'\n # a note\n - prisma\n\n - \"@prisma/engines\" # trailing\nother: 1\n");
28
+ write(root, '.npmrc', 'registry=https://registry.npmjs.org/\n');
29
+ write(root, 'biomes/alpha/api/alpha-api/package.json', { name: '@t/alpha-api' });
30
+ write(root, 'biomes/alpha/api/alpha-db-api/package.json', { name: '@t/alpha-db-api' });
31
+ write(root, 'biomes/alpha/api/alpha-db-api/prisma/schema.prisma', 'datasource db {}\n');
32
+ write(root, 'packages/sdks/lib-a/package.json', { name: '@t/lib-a', files: ['dist'] });
33
+ if (built) {
34
+ write(root, 'biomes/alpha/api/alpha-api/dist/main.js', '// main\n');
35
+ write(root, 'biomes/alpha/api/alpha-db-api/dist/main.js', '// main\n');
36
+ write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/index.js', '// client\n');
37
+ write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/schema.prisma', 'datasource db {}\n');
38
+ write(root, 'packages/sdks/lib-a/dist/index.js', '// lib\n');
39
+ }
40
+ return root;
41
+ }
42
+
43
+ /** An `exec` standing in for `pnpm ls -r --depth -1 --json …`. */
44
+ export function fakePnpm(root, closure = CLOSURE) {
45
+ const calls = [];
46
+ const exec = (file, args, options) => {
47
+ calls.push({ file, args, options });
48
+ return JSON.stringify(closure.map((dir) => ({ name: dir, path: path.join(root, dir) })));
49
+ };
50
+ exec.calls = calls;
51
+ return exec;
52
+ }
53
+
54
+ /** A `prisma format` stand-in that leaves the schema as it is (already formatted). */
55
+ export const formatNoop = () => ({ status: 0, stdout: '', stderr: '' });
@@ -0,0 +1,94 @@
1
+ // The member set a repository's committed declaration names, and the workspace closure
2
+ // those members select. Shared by the build-time tool, the stage command and the
3
+ // prebuilt Dockerfile renderer, so all of them see one answer.
4
+ import { execFileSync } from 'node:child_process';
5
+ import { existsSync, readFileSync, realpathSync } from 'node:fs';
6
+ import { dirname, join, posix, relative } from 'node:path';
7
+
8
+ import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
9
+
10
+ export const IMAGE_BUILD_INPUTS_FILE = 'image-build-inputs.json';
11
+
12
+ /** The `<biomeId>/<componentKey>` of the one service whose artifact is `directory`. */
13
+ function memberKey(root, directory) {
14
+ for (let dir = dirname(directory); dir !== '.' && dir !== dirname(dir); dir = dirname(dir)) {
15
+ const manifestPath = join(root, dir, 'xema-biome.json');
16
+ if (!existsSync(manifestPath)) continue;
17
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
18
+ const matches = (manifest.xema?.components ?? []).filter(
19
+ (component) =>
20
+ component.kind === 'service' &&
21
+ typeof component.artifact?.path === 'string' &&
22
+ posix.normalize(posix.join(dir, component.artifact.path)) === directory,
23
+ );
24
+ if (matches.length !== 1) {
25
+ throw new Error(`${dir}/xema-biome.json declares ${matches.length} service(s) at ${directory}, not exactly one`);
26
+ }
27
+ return `${manifest.xema.id}/${matches[0].key}`;
28
+ }
29
+ throw new Error(`no biome manifest above ${directory}`);
30
+ }
31
+
32
+ /** `[key, directory]` for every member the committed declaration names. */
33
+ export function readMembers({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR } = {}) {
34
+ const inputs = join(root, imageDir, IMAGE_BUILD_INPUTS_FILE);
35
+ const directories = JSON.parse(readFileSync(inputs, 'utf8')).workspacePackages ?? [];
36
+ if (directories.length === 0) throw new Error(`${inputs} declares no workspacePackages`);
37
+ return directories.map((directory) => [memberKey(root, directory), directory]);
38
+ }
39
+
40
+ function packageName(root, directory) {
41
+ const manifest = JSON.parse(readFileSync(join(root, directory, 'package.json'), 'utf8'));
42
+ if (typeof manifest.name !== 'string') throw new Error(`${directory}/package.json has no name`);
43
+ return manifest.name;
44
+ }
45
+
46
+ /** The pnpm `--filter <package>...` arguments covering every member's closure. */
47
+ export function memberFilters(options = {}) {
48
+ const root = options.root ?? process.cwd();
49
+ return readMembers(options).map(([, directory]) => `--filter ${packageName(root, directory)}...`);
50
+ }
51
+
52
+
53
+ /** The biome manifest (`xema-biome.json`) above every member, repository-relative and sorted: what `index` reads to name a member. */
54
+ export function memberManifests({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR } = {}) {
55
+ const manifests = new Set();
56
+ for (const [, directory] of readMembers({ root, imageDir })) {
57
+ for (let dir = dirname(directory); dir !== '.' && dir !== dirname(dir); dir = dirname(dir)) {
58
+ if (existsSync(join(root, dir, 'xema-biome.json'))) {
59
+ manifests.add(posix.join(dir, 'xema-biome.json'));
60
+ break;
61
+ }
62
+ }
63
+ }
64
+ return [...manifests].sort();
65
+ }
66
+
67
+ const defaultExec = (file, args, { cwd }) => execFileSync(file, args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
68
+
69
+ /**
70
+ * The workspace packages the members' filters select (the members and everything
71
+ * they depend on inside the workspace): repository-relative directories, sorted.
72
+ * `exec(file, args, { cwd })` returns stdout; injectable for tests.
73
+ */
74
+ export function memberClosure({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, exec = defaultExec } = {}) {
75
+ const words = memberFilters({ root, imageDir }).flatMap((filter) => filter.split(' '));
76
+ const listing = exec('pnpm', ['ls', '-r', '--depth', '-1', '--json', ...words], { cwd: root });
77
+ let packages;
78
+ try {
79
+ packages = JSON.parse(listing);
80
+ } catch (error) {
81
+ throw new Error(`pnpm ls did not print JSON: ${error.message}`);
82
+ }
83
+ if (!Array.isArray(packages) || packages.length === 0) throw new Error('pnpm ls selected no workspace package');
84
+ const real = realpathSync(root);
85
+ const directories = new Set();
86
+ for (const pkg of packages) {
87
+ if (typeof pkg?.path !== 'string') throw new Error('pnpm ls listed a package with no path');
88
+ let rel = relative(root, pkg.path);
89
+ if (rel.startsWith('..')) rel = relative(real, pkg.path);
90
+ if (rel.startsWith('..')) throw new Error(`pnpm ls listed ${pkg.path}, outside ${root}`);
91
+ if (rel !== '') directories.add(rel.split('\\').join('/'));
92
+ }
93
+ return [...directories].sort();
94
+ }
@@ -0,0 +1,205 @@
1
+ // `xema-composed-image dockerfile --prebuilt --write|--check`: renders
2
+ // `<image-dir>/Dockerfile` AND `<image-dir>/Dockerfile.dockerignore` for the image that
3
+ // is ASSEMBLED from build outputs made on the CI runner (`xema-composed-image stage`).
4
+ // Docker compiles nothing. The full-build template (`composed-image-dockerfile.mjs`)
5
+ // stays available for repositories that still build inside Docker.
6
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
+ import { dirname, join, posix } from 'node:path';
8
+
9
+ import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
10
+ import { NODE_BASE, npmrcUsesToken } from './composed-image-dockerfile.mjs';
11
+ import { memberClosure, memberFilters, memberManifests } from './composed-image-members.mjs';
12
+
13
+ export const DEFAULT_STAGE_DIR = '.xema-stage';
14
+ const VERSION = /^\d+\.\d+\.\d+$/;
15
+ const DIRECTORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*\/?$/;
16
+ const CACHE_ID = /^[a-z0-9][a-z0-9-]*$/;
17
+ const REPOSITORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
18
+
19
+ function validate({ toolingVersion, copy, cacheId, buildSource, stageDir }) {
20
+ if (!VERSION.test(toolingVersion ?? '')) throw new Error(`--tooling-version must be an exact x.y.z version, got "${toolingVersion}"`);
21
+ for (const directory of [...(copy ?? []), stageDir]) {
22
+ if (!DIRECTORY.test(directory) || directory.startsWith('/') || directory.split('/').includes('..')) {
23
+ throw new Error(`"${directory}" is not a repository-relative directory`);
24
+ }
25
+ }
26
+ if (!CACHE_ID.test(cacheId ?? '')) throw new Error(`--cache-id must be lowercase [a-z0-9-], got "${cacheId}"`);
27
+ if (!REPOSITORY.test(buildSource ?? '')) throw new Error(`--build-source must name a repository, got "${buildSource}"`);
28
+ }
29
+
30
+ /** The ignore file: the build context carries sources, never local build output, except the staged outputs. */
31
+ export function renderPrebuiltDockerignore({ stageDir = DEFAULT_STAGE_DIR } = {}) {
32
+ return `# @generated by \`xema-composed-image dockerfile --prebuilt --write\` — DO NOT EDIT.
33
+ # Local build output stays out of the context; the staged outputs carry dist and node_modules/.prisma paths, so they are re-included LAST.
34
+ .git
35
+ **/node_modules
36
+ **/dist
37
+ **/.turbo
38
+ **/coverage
39
+ **/*.tsbuildinfo
40
+ **/*.log
41
+ !${stageDir}/**
42
+ `;
43
+ }
44
+
45
+ /** The `onlyBuiltDependencies:` items of a `pnpm-workspace.yaml` text (block list; comments skipped; ends at the next top-level key). */
46
+ export function onlyBuiltDependencies(workspaceYaml) {
47
+ const names = [];
48
+ let inList = false;
49
+ for (const line of workspaceYaml.split('\n')) {
50
+ if (!inList) {
51
+ inList = /^onlyBuiltDependencies:\s*(#.*)?$/.test(line);
52
+ continue;
53
+ }
54
+ if (/^\s*(#.*)?$/.test(line)) continue;
55
+ if (/^\S/.test(line)) break;
56
+ const item = /^\s+-\s+(?:'([^']*)'|"([^"]*)"|([^\s#]+))\s*(?:#.*)?$/.exec(line);
57
+ if (item === null) throw new Error(`pnpm-workspace.yaml: cannot read onlyBuiltDependencies item "${line.trim()}"`);
58
+ names.push(item[1] ?? item[2] ?? item[3]);
59
+ }
60
+ for (const name of names) {
61
+ if (!/^[@A-Za-z0-9._/-]+$/.test(name)) throw new Error(`pnpm-workspace.yaml: onlyBuiltDependencies "${name}" is not a package name`);
62
+ }
63
+ return names;
64
+ }
65
+
66
+ const parts = (sources) => sources.map((source) => ` ${source} \\`).join('\n');
67
+
68
+ /** The prebuilt Dockerfile text. The closure and the members' literal filters are derived from `root` (`memberClosure`); `exec` is injectable for tests. */
69
+ export function renderPrebuiltDockerfile({
70
+ root = process.cwd(),
71
+ imageDir = COMPOSED_IMAGE_DIR,
72
+ toolingVersion,
73
+ copy = [],
74
+ cacheId,
75
+ buildSource,
76
+ usesToken,
77
+ stageDir = DEFAULT_STAGE_DIR,
78
+ exec,
79
+ }) {
80
+ if (typeof usesToken !== 'boolean') throw new Error('usesToken must be a boolean derived from .npmrc');
81
+ validate({ toolingVersion, copy, cacheId, buildSource, stageDir });
82
+ const workspaceFile = join(root, 'pnpm-workspace.yaml');
83
+ const built = existsSync(workspaceFile) ? onlyBuiltDependencies(readFileSync(workspaceFile, 'utf8')) : [];
84
+ if (built.length === 0) {
85
+ throw new Error('pnpm-workspace.yaml has no onlyBuiltDependencies: — `pnpm rebuild` would have nothing to build; refusing to render');
86
+ }
87
+ const closure = memberClosure({ root, imageDir, exec });
88
+ const filters = memberFilters({ root, imageDir }).join(' ');
89
+ const manifests = memberManifests({ root, imageDir });
90
+ const dirFlag = imageDir === COMPOSED_IMAGE_DIR ? '' : ` --image-dir ${imageDir}`;
91
+ const toolDir = '/opt/xema-composed-image';
92
+ const toolMount = `--mount=type=bind,source=${stageDir}/tool,target=${toolDir}`;
93
+ const node = `${toolDir}/src/composed-image.mjs`;
94
+ const store = `--mount=type=cache,id=pnpm-store-${cacheId},target=/root/.pnpm-store`;
95
+ const metadata = `--mount=type=cache,id=pnpm-metadata-${cacheId},target=/root/.cache/pnpm`;
96
+ const secret = usesToken ? ' --mount=type=secret,id=npm_token,required=true \\\n' : '';
97
+ const tokenExport = usesToken
98
+ ? 'export NPM_TOKEN="$(cat /run/secrets/npm_token)" && export GH_PACKAGES_TOKEN="$NPM_TOKEN" && \\\n '
99
+ : '';
100
+ const copies = [...new Set(copy.map((d) => posix.normalize(`${d}/`)))].map((d) => `COPY ${d} ./${d}`).join('\n');
101
+ const manifestSources = closure.map((directory) => `${directory}/package.json`);
102
+ const assembleSources = [...closure, ...manifests];
103
+ const imageCopy = posix.normalize(`${imageDir}/`);
104
+ const text = `# @generated by \`xema-composed-image dockerfile --prebuilt --write\` (@xemahq/repo-build-tooling@${toolingVersion}) — DO NOT EDIT.
105
+ # Re-render with the same options; \`--check\` refuses a stale file (and a stale Dockerfile.dockerignore).
106
+ #
107
+ # The COMPOSED-PROCESS image of this repository, ASSEMBLED from build outputs: ONE image
108
+ # per build (repository at commit), every member of \`image-build-inputs.json\` in ONE module
109
+ # graph behind the SDK's \`xema-composed-process\` host. Docker compiles nothing. The CI
110
+ # runner ran \`turbo run build prisma:generate\` for the members' closures and
111
+ # \`xema-composed-image stage ${stageDir}\` gathered the outputs into the build-step
112
+ # directory \`${stageDir}/\` (\`out/\` = dist and generated Prisma clients, pure JS/wasm;
113
+ # \`tool/\` = this tool, bind-mounted into the steps below and never copied into a layer).
114
+ #
115
+ # Stages:
116
+ # deps arch-native. Inputs are the lockfile and the closure's manifests ONLY, so the
117
+ # layer is cached until one of them changes. Production install with the
118
+ # workspace packages INJECTED, then \`pnpm rebuild <names>\` runs the build scripts
119
+ # of the root \`onlyBuiltDependencies\` (Prisma engines, native addons) natively.
120
+ # The names are listed because a bare filtered \`pnpm rebuild\` would also run
121
+ # the members' own postinstall (no schema here); a name absent from the graph
122
+ # is tolerated. The list is rendered, so a change to it makes \`--check\` stale.
123
+ # assemble source of the closure + the staged outputs, the injected copies synced to
124
+ # their \`files\`, then the proofs on the target arch: Prisma clients run, the
125
+ # member index is written, every member loads ALONE as \`node\`, production.
126
+ # (final) \`/monorepo\` from assemble. Nothing of the tool ships.
127
+ #
128
+ # The \`--filter <package>...\` words below are LITERAL: the content hash of this image reads
129
+ # them to narrow the lockfile to the members' closures.
130
+ FROM ${NODE_BASE} AS base
131
+ RUN corepack enable && \\
132
+ apt-get update && apt-get install -y --no-install-recommends openssl wget && rm -rf /var/lib/apt/lists/* && \\
133
+ pnpm config set store-dir /root/.pnpm-store
134
+
135
+ FROM base AS deps
136
+ WORKDIR /monorepo
137
+ ENV CI=true
138
+ COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
139
+ ${copies === '' ? '' : `${copies}\n`}COPY --parents \\
140
+ ${parts(manifestSources)}
141
+ ./
142
+ RUN ${store} \\
143
+ ${metadata} \\
144
+ ${secret} ${toolMount} \\
145
+ ${tokenExport}INJECT="--config.inject-workspace-packages=true --config.dedupe-injected-deps=false" && \\
146
+ cp pnpm-lock.yaml /tmp/pnpm-lock.committed.yaml && \\
147
+ pnpm install --lockfile-only --prefer-offline \${INJECT} ${filters} && \\
148
+ node ${node} pins${dirFlag} /tmp/pnpm-lock.committed.yaml && \\
149
+ pnpm install --prod --prefer-offline --ignore-scripts \${INJECT} ${filters} --frozen-lockfile && \\
150
+ pnpm rebuild ${built.join(' ')} ${filters}
151
+
152
+ FROM deps AS assemble
153
+ COPY --parents \\
154
+ ${parts(assembleSources)}
155
+ ./
156
+ COPY ${imageCopy} ./${imageCopy}
157
+ COPY ${stageDir}/out/ ./
158
+ ARG BUILD_SHA
159
+ RUN ${toolMount} \\
160
+ T="node ${node}" && \\
161
+ $T sync-injected${dirFlag} && $T prisma-check${dirFlag} && \\
162
+ : "\${BUILD_SHA:?BUILD_SHA build-arg is required}" && $T index "\${BUILD_SHA}" --build-source ${buildSource}${dirFlag} && \\
163
+ cp ${toolDir}/src/composed-migrate.mjs /monorepo/xema-composed-migrate.mjs
164
+
165
+ # Every member's module graph loads here, ALONE (one process each), through the host's own
166
+ # entry reader, as the user and NODE_ENV the image runs with, or the image is never pushed.
167
+ USER node
168
+ RUN ${toolMount} \\
169
+ NODE_ENV=production node ${node} load --each${dirFlag}
170
+
171
+ FROM base
172
+ WORKDIR /monorepo
173
+ COPY --from=assemble /monorepo /monorepo
174
+ ENV NODE_ENV=production
175
+ # Set by the IMAGE, never by a deployment (kernel \`COMPOSED_PROCESS_ENV.MemberIndex\`).
176
+ ENV XEMA_COMPOSED_MEMBER_INDEX=/monorepo/xema-composed-member-index.json
177
+ USER node
178
+ CMD ["node", "/monorepo/xema-composed-process"]
179
+ `;
180
+ return { dockerfile: text, dockerignore: renderPrebuiltDockerignore({ stageDir }), closure };
181
+ }
182
+
183
+ /** `--write` writes both files; `--check` throws when either differs. */
184
+ export function syncPrebuiltDockerfile({ mode, root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, exec, ...options }) {
185
+ if (mode !== 'write' && mode !== 'check') throw new Error(`mode must be write or check, got "${mode}"`);
186
+ const npmrc = join(root, '.npmrc');
187
+ if (!existsSync(npmrc)) throw new Error(`${npmrc} is absent — the Dockerfile copies it and its token use decides the build secret`);
188
+ const { dockerfile, dockerignore } = renderPrebuiltDockerfile({ ...options, root, imageDir, exec, usesToken: npmrcUsesToken(readFileSync(npmrc, 'utf8')) });
189
+ const files = [
190
+ [posix.join(imageDir, 'Dockerfile'), dockerfile],
191
+ [posix.join(imageDir, 'Dockerfile.dockerignore'), dockerignore],
192
+ ];
193
+ const stale = [];
194
+ for (const [relativeTarget, rendered] of files) {
195
+ const target = join(root, relativeTarget);
196
+ if (mode === 'write') {
197
+ mkdirSync(dirname(target), { recursive: true });
198
+ writeFileSync(target, rendered);
199
+ } else if ((existsSync(target) ? readFileSync(target, 'utf8') : '') !== rendered) stale.push(relativeTarget);
200
+ }
201
+ if (stale.length > 0) {
202
+ throw new Error(`${stale.join(' and ')} ${stale.length > 1 ? 'are' : 'is'} stale. Run: xema-composed-image dockerfile --prebuilt --write (with the same options)`);
203
+ }
204
+ return files.map(([relativeTarget]) => relativeTarget);
205
+ }