@xemahq/repo-build-tooling 0.11.0 → 0.12.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
@@ -33,7 +33,7 @@ pnpm exec xema-scrub-swagger-paths dist
33
33
  | `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
34
34
  | `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
35
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.
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>]… [--also <pkg>]…` output (requires `--tooling-version`, `--cache-id`, `--build-source`; `--copy` and `--stage-dir` optional); `load --each` loads every member in its own process. `stage` and `filters` accept `--also <workspace package name>` (repeatable): the package's closure is built and staged (built outputs only, no Prisma checks) alongside the members; an unknown name is refused. Run `xema-composed-image` with no arguments for the full usage.
37
37
 
38
38
  Every step refuses rather than skips. The package has no dependencies.
39
39
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/repo-build-tooling",
3
- "version": "0.11.0",
3
+ "version": "0.12.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)",
@@ -42,6 +42,6 @@
42
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:8a0729af873e6dee7bc38a38408c3f0f57447884e6461ad36e06e8165cf28f4b"
45
+ "sourceDigest": "sha256:65f155b64c9951c7c47a7d2a2b9cf65e08615471f0d5c49097a18460cbed260d"
46
46
  }
47
47
  }
@@ -43,10 +43,15 @@ function packageName(root, directory) {
43
43
  return manifest.name;
44
44
  }
45
45
 
46
- /** The pnpm `--filter <package>...` arguments covering every member's closure. */
46
+ /**
47
+ * The pnpm `--filter <package>...` arguments covering every member's closure, then
48
+ * each `also` package's (a workspace package name); a name a member already
49
+ * selected is listed once.
50
+ */
47
51
  export function memberFilters(options = {}) {
48
52
  const root = options.root ?? process.cwd();
49
- return readMembers(options).map(([, directory]) => `--filter ${packageName(root, directory)}...`);
53
+ const names = readMembers(options).map(([, directory]) => packageName(root, directory));
54
+ return [...new Set([...names, ...(options.also ?? [])])].map((name) => `--filter ${name}...`);
50
55
  }
51
56
 
52
57
 
@@ -66,21 +71,30 @@ export function memberManifests({ root = process.cwd(), imageDir = COMPOSED_IMAG
66
71
 
67
72
  const defaultExec = (file, args, { cwd }) => execFileSync(file, args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
68
73
 
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 });
74
+ function parseListing(listing) {
77
75
  let packages;
78
76
  try {
79
77
  packages = JSON.parse(listing);
80
78
  } catch (error) {
81
79
  throw new Error(`pnpm ls did not print JSON: ${error.message}`);
82
80
  }
83
- if (!Array.isArray(packages) || packages.length === 0) throw new Error('pnpm ls selected no workspace package');
81
+ if (!Array.isArray(packages)) throw new Error('pnpm ls did not print a JSON array');
82
+ return packages;
83
+ }
84
+
85
+ /**
86
+ * The workspace packages the members' filters select (the members and everything
87
+ * they depend on inside the workspace): repository-relative directories, sorted.
88
+ * `exec(file, args, { cwd })` returns stdout; injectable for tests.
89
+ */
90
+ export function memberClosure({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, exec = defaultExec, also = [] } = {}) {
91
+ for (const name of also) {
92
+ const found = parseListing(exec('pnpm', ['ls', '-r', '--depth', '-1', '--json', '--filter', name], { cwd: root }));
93
+ if (!found.some((pkg) => pkg?.name === name)) throw new Error(`--also ${name}: no workspace package has that name`);
94
+ }
95
+ const words = memberFilters({ root, imageDir, also }).flatMap((filter) => filter.split(' '));
96
+ const packages = parseListing(exec('pnpm', ['ls', '-r', '--depth', '-1', '--json', ...words], { cwd: root }));
97
+ if (packages.length === 0) throw new Error('pnpm ls selected no workspace package');
84
98
  const real = realpathSync(root);
85
99
  const directories = new Set();
86
100
  for (const pkg of packages) {
@@ -123,7 +123,9 @@ export function renderPrebuiltDockerfile({
123
123
  # assemble source of the closure + the staged outputs, the injected copies synced to
124
124
  # their \`files\`, then the proofs on the target arch: Prisma clients run, the
125
125
  # member index is written, every member loads ALONE as \`node\`, production.
126
- # (final) \`/monorepo\` from assemble. Nothing of the tool ships.
126
+ # (final) \`/monorepo\` of assemble, shipped as THREE layers so a commit that changes only
127
+ # members' code re-pushes only the layers that changed (see the final stage).
128
+ # Nothing of the tool ships.
127
129
  #
128
130
  # The \`--filter <package>...\` words below are LITERAL: the content hash of this image reads
129
131
  # them to narrow the lockfile to the members' closures.
@@ -168,9 +170,22 @@ USER node
168
170
  RUN ${toolMount} \\
169
171
  NODE_ENV=production node ${node} load --each${dirFlag}
170
172
 
173
+ # The final stage is layered by how often each part changes, so a registry and a node
174
+ # already holding the stable layers fetch only the rest. The three copies overlay to
175
+ # exactly assemble's /monorepo (proven by a tree diff of both images).
176
+ # 1 deps root manifests, the external .pnpm store, and the injected stubs that hold
177
+ # only package.json. Stable while the lockfile and the closure's package.json
178
+ # files are unchanged.
179
+ # 2 assemble members, dist, Prisma clients, member index, migrate script: everything
180
+ # but the .pnpm store, whose packages layer 1 already carries.
181
+ # 3 injected the workspace copies \`node_modules/.pnpm/*@file+*\`: \`sync-injected\` fills
182
+ # them from the members' files, so they change with every commit. \`--parents\`
183
+ # keeps their paths under /.
171
184
  FROM base
172
185
  WORKDIR /monorepo
173
- COPY --from=assemble /monorepo /monorepo
186
+ COPY --from=deps /monorepo /monorepo
187
+ COPY --from=assemble --exclude=node_modules/.pnpm /monorepo /monorepo
188
+ COPY --from=assemble --parents /monorepo/node_modules/.pnpm/*@file+* /
174
189
  ENV NODE_ENV=production
175
190
  # Set by the IMAGE, never by a deployment (kernel \`COMPOSED_PROCESS_ENV.MemberIndex\`).
176
191
  ENV XEMA_COMPOSED_MEMBER_INDEX=/monorepo/xema-composed-member-index.json
@@ -39,7 +39,7 @@ test('the tool is only bind-mounted, and the final stage carries nothing of it',
39
39
  assert.doesNotMatch(dockerfile, /\bnpm install|COPY \.xema-stage\/tool/);
40
40
  const final = dockerfile.split('\nFROM base\n')[1];
41
41
  assert.doesNotMatch(final, /xema-composed-image|repo-build-tooling|npm install|\/opt\//);
42
- assert.match(final, /COPY --from=assemble \/monorepo \/monorepo/);
42
+ assert.doesNotMatch(final, /COPY --from=assemble \/monorepo \/monorepo/);
43
43
  assert.match(dockerfile, /USER node\nRUN --mount=\S+ \\\n {2}NODE_ENV=production node \/opt\/xema-composed-image\/src\/composed-image\.mjs load --each\n/);
44
44
  assert.match(dockerfile, /cp \/opt\/xema-composed-image\/src\/composed-migrate\.mjs \/monorepo\/xema-composed-migrate\.mjs/);
45
45
  });
@@ -101,3 +101,16 @@ test('a token-using repository gets single-backslash line continuations (a doubl
101
101
  assert.match(dockerfile, /--mount=type=secret,id=npm_token,required=true \\\n/);
102
102
  for (const line of dockerfile.split('\n')) assert.doesNotMatch(line, /\\\\\s*$/, `line ends with a doubled backslash: ${line}`);
103
103
  });
104
+
105
+ test('the final stage is layered: deps, then assemble without the .pnpm store, then the injected copies', () => {
106
+ const { dockerfile } = renderPrebuiltDockerfile(options(workspace()));
107
+ const final = dockerfile.split('\nFROM base\n')[1];
108
+ const copies = final.split('\n').filter((line) => line.startsWith('COPY '));
109
+ assert.deepEqual(copies, [
110
+ 'COPY --from=deps /monorepo /monorepo',
111
+ 'COPY --from=assemble --exclude=node_modules/.pnpm /monorepo /monorepo',
112
+ 'COPY --from=assemble --parents /monorepo/node_modules/.pnpm/*@file+* /',
113
+ ]);
114
+ assert.ok(final.indexOf('ENV NODE_ENV=production') > final.lastIndexOf('COPY --from=assemble'));
115
+ assert.match(final, /USER node\nCMD \["node", "\/monorepo\/xema-composed-process"\]\n$/);
116
+ });
@@ -3,7 +3,8 @@
3
3
  // directory the `--prebuilt` Dockerfile copies from. Docker compiles nothing; this
4
4
  // is the only place outputs enter an image, so it is also where they are refused.
5
5
  //
6
- // <dir>/out/<package dir>/<output>/ every closure package's outputs (default: dist)
6
+ // <dir>/out/<package dir>/<output>/ every closure package's outputs (default: dist);
7
+ // `--also <pkg>` adds that package's closure (no member checks)
7
8
  // <dir>/out/<member>/node_modules/.prisma/ every member's generated Prisma clients
8
9
  // <dir>/tool/ this package (package.json + src, no tests):
9
10
  // bind-mounted into the build, never copied into a layer
@@ -65,6 +66,7 @@ export async function stageBuildOutputs({
65
66
  imageDir = COMPOSED_IMAGE_DIR,
66
67
  dir,
67
68
  outputs = DEFAULT_OUTPUTS,
69
+ also = [],
68
70
  exec,
69
71
  nodeMajor = process.versions.node.split('.')[0],
70
72
  formatSpawn,
@@ -90,7 +92,7 @@ export async function stageBuildOutputs({
90
92
 
91
93
  const members = readMembers({ root, imageDir }).map(([key, directory]) => ({ key, directory }));
92
94
  const memberDirectories = new Set(members.map((m) => m.directory));
93
- const closure = memberClosure({ root, imageDir, exec });
95
+ const closure = memberClosure({ root, imageDir, exec, also });
94
96
  const schemaJobs = [];
95
97
  for (const { key, directory } of members) {
96
98
  if (!closure.includes(directory)) problems.push(`${key}: ${directory} is not in the closure pnpm selected`);
@@ -100,3 +100,54 @@ test('refuses a client generated from another schema than the member carries; th
100
100
  await assert.rejects(stage(stale), /alpha-db-api: .*differs from the member's prisma\/schema\.prisma .*stale/);
101
101
  assert.equal((await stage(ready())).packages, 3);
102
102
  });
103
+
104
+ /** An `exec` that answers per-name probes (`--filter <name>`) and the closure listing (`--filter <name>...`). */
105
+ function alsoPnpm(root, known) {
106
+ return (_file, args) => {
107
+ const filters = args.flatMap((a, i) => (args[i - 1] === '--filter' ? [a] : []));
108
+ const names = filters.filter((f) => !f.endsWith('...'));
109
+ if (names.length > 0) return JSON.stringify(known.filter((p) => p.name === names[0]).map((p) => ({ name: p.name, path: path.join(root, p.dir) })));
110
+ const wanted = new Set(filters.map((f) => f.slice(0, -3)));
111
+ const dirs = new Set(['biomes/alpha/api/alpha-api', 'biomes/alpha/api/alpha-db-api', 'packages/sdks/lib-a']);
112
+ for (const p of known) if (wanted.has(p.name)) for (const d of p.closure) dirs.add(d);
113
+ return JSON.stringify([...dirs].map((dir) => ({ name: dir, path: path.join(root, dir) })));
114
+ };
115
+ }
116
+ const EXTRA = [{ name: '@t/extra', dir: 'packages/sdks/extra', closure: ['packages/sdks/extra', 'packages/sdks/extra-dep'] },
117
+ { name: '@t/lib-a', dir: 'packages/sdks/lib-a', closure: ['packages/sdks/lib-a'] }];
118
+
119
+ function withExtra() {
120
+ const root = ready();
121
+ write(root, 'packages/sdks/extra/package.json', { name: '@t/extra' });
122
+ write(root, 'packages/sdks/extra/dist/index.js', '// extra\n');
123
+ write(root, 'packages/sdks/extra-dep/dist/index.js', '// dep\n');
124
+ return root;
125
+ }
126
+
127
+ test('--also stages the named package closure without member checks, and a member named again is idempotent', async () => {
128
+ const root = withExtra();
129
+ const result = await stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra', '@t/lib-a'] });
130
+ assert.equal(result.packages, 5);
131
+ const staged = files(path.join(root, '.xema-stage/out'));
132
+ for (const expected of ['packages/sdks/extra/dist/index.js', 'packages/sdks/extra-dep/dist/index.js', 'packages/sdks/lib-a/dist/index.js']) {
133
+ assert.ok(staged.includes(expected), expected);
134
+ }
135
+ assert.ok(!staged.some((f) => f.startsWith('packages/sdks/extra/node_modules')));
136
+ });
137
+
138
+ test('--also refuses an unknown package and a native addon in the extra closure', async () => {
139
+ const root = withExtra();
140
+ await assert.rejects(stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/nope'] }), /--also @t\/nope: no workspace package/);
141
+ assert.equal(fs.existsSync(path.join(root, '.xema-stage')), false);
142
+ write(root, 'packages/sdks/extra-dep/dist/a.node', 'x');
143
+ await assert.rejects(stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra'] }), /extra-dep\/dist\/a\.node/);
144
+ });
145
+
146
+ test('filters and the argument parser carry --also', async () => {
147
+ const root = ready();
148
+ const { memberFilters } = await import('./composed-image-members.mjs');
149
+ const { parseArguments } = await import('./composed-image.mjs');
150
+ assert.deepEqual(memberFilters({ root, also: ['@t/extra', '@t/lib-a', '@t/extra'] }).slice(-2), ['--filter @t/extra...', '--filter @t/lib-a...']);
151
+ assert.equal(memberFilters({ root, also: ['@t/alpha-api'] }).filter((f) => f === '--filter @t/alpha-api...').length, 1);
152
+ assert.deepEqual(parseArguments(['stage', 'd', '--also', 'a', '--also', 'b']).options.also, ['a', 'b']);
153
+ });
@@ -24,7 +24,8 @@
24
24
  // derive the member set and write / verify
25
25
  // `<image-dir>/image-build-inputs.json` (its existence at a commit IS
26
26
  // the repository's composed-process declaration).
27
- // filters print `--filter <package>...` for every member, so install and
27
+ // filters [--also <pkg>]…
28
+ // print `--filter <package>...` for every member (and each --also package), so install and
28
29
  // build cover exactly the members' closures.
29
30
  // dirs print every member's directory, one per line.
30
31
  // pins <committed-lockfile>
@@ -38,10 +39,11 @@
38
39
  // load --each
39
40
  // the same check, one `node` process PER member, so a member that only
40
41
  // loads because another loaded first is caught (the prebuilt image's gate).
41
- // stage <dir> [--output <rel>]…
42
+ // stage <dir> [--output <rel>]… [--also <pkg>]…
42
43
  // gather the runner's build outputs (default `dist`) and the members'
43
44
  // generated Prisma clients into a build-step directory, with this tool,
44
- // for the `--prebuilt` Dockerfile (`composed-image-stage.mjs`).
45
+ // for the `--prebuilt` Dockerfile (`composed-image-stage.mjs`). Each `--also`
46
+ // workspace package adds its closure's built outputs (no Prisma checks).
45
47
  // sync-injected | prisma-check
46
48
  // IN-IMAGE proofs of the prebuilt build: the injected workspace copies hold
47
49
  // their packages' `files`; every Prisma client runs on the target arch.
@@ -454,18 +456,18 @@ export function loadMembersEach({ root = process.cwd(), imageDir = COMPOSED_IMAG
454
456
  const USAGE =
455
457
  'usage: xema-composed-image <command> [--root <dir>] [--image-dir <dir>]\n' +
456
458
  ' members --write|--check [--manifests <glob>]… [--canonical-dockerfile <path>] [--cross-repo-manifest <path>]\n' +
457
- ' filters | dirs | pins <committed-lockfile> | index <commit> --build-source <repo> | load [--each]\n' +
458
- ' stage <dir> [--output <rel>]… | sync-injected | prisma-check | migrate <member> [--root <dir>]\n' +
459
+ ' filters [--also <pkg>]… | dirs | pins <committed-lockfile> | index <commit> --build-source <repo> | load [--each]\n' +
460
+ ' stage <dir> [--output <rel>]… [--also <pkg>]… | sync-injected | prisma-check | migrate <member> [--root <dir>]\n' +
459
461
  ' coordinate --image-name <name> (env: SHA REGISTRY REGISTRY_ORG REGISTRY_USER REGISTRY_TOKEN [TAG_ARGS_FILE])\n' +
460
462
  ' load-image <image>\n' +
461
463
  ' smoke-refusal <image> [--member <biomeId>/<componentKey>]x2\n' +
462
464
  ' dockerfile --write|--check --tooling-version <x.y.z> --copy <dir>… --cache-id <id> --build-source <repo>\n' +
463
465
  ' dockerfile --prebuilt --write|--check --tooling-version <x.y.z> --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]';
464
466
 
465
- const REPEATABLE = new Set(['manifests', 'copy', 'member', 'output']);
467
+ const REPEATABLE = new Set(['manifests', 'copy', 'member', 'output', 'also']);
466
468
  const BOOLEAN = new Set(['prebuilt', 'each']);
467
469
  const VALUED = new Set(['root', 'image-dir', 'manifests', 'canonical-dockerfile', 'cross-repo-manifest', 'build-source',
468
- 'image-name', 'tooling-version', 'cache-id', 'copy', 'member', 'stage-dir', 'output',
470
+ 'image-name', 'tooling-version', 'cache-id', 'copy', 'member', 'stage-dir', 'output', 'also',
469
471
  ]);
470
472
 
471
473
  export function parseArguments(argv) {
@@ -518,7 +520,7 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
518
520
  return;
519
521
  }
520
522
  case 'filters':
521
- write(`${memberFilters({ root, imageDir }).join(' ')}\n`);
523
+ write(`${memberFilters({ root, imageDir, also: options.also }).join(' ')}\n`);
522
524
  return;
523
525
  case 'dirs':
524
526
  write(`${readMembers({ root, imageDir }).map(([, directory]) => directory).join('\n')}\n`);
@@ -541,7 +543,7 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
541
543
  log(`composed members: all ${loadMembers({ root, imageDir })} module graph(s) load`);
542
544
  return;
543
545
  case 'stage': {
544
- return stageBuildOutputs({ root, imageDir, dir: argument, outputs: options.output }).then((staged) => {
546
+ return stageBuildOutputs({ root, imageDir, dir: argument, outputs: options.output, also: options.also }).then((staged) => {
545
547
  log(`composed stage ${argument}: ${staged.packages} package(s), ${staged.outputs} output dir(s), ${staged.files} file(s), tool ${staged.toolFiles} file(s)`);
546
548
  });
547
549
  }