@xemahq/repo-build-tooling 0.21.2 → 0.22.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
@@ -30,9 +30,9 @@ pnpm exec xema-scrub-swagger-paths dist
30
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
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
32
  | `xema-composed-image <command>` | Build-time half of a composed-process image: `members`, `filters`, `dirs`, `pins`, `index`, `load`, `stage`, `pnpm-context`, `sync-injected`, `prisma-check`, `migrate`, `coordinate`, `load-image`, `smoke-refusal`, `dockerfile` |
33
+ | `xema-service-image stage --service <pkg> --path <dir> --platform linux/<amd64\|arm64> [--stage-dir <dir>]` | Runner half of a per-service image: `pnpm deploy --prod --offline --ignore-scripts` of a service already built on the runner, selected for the TARGET platform (`--os=linux --cpu=<arch> --libc=glibc`), into `<stage>/service/app`, with its Prisma client and the target's schema engine. Refuses a package whose native code holds no ELF for the target (a multi-platform package that also ships the target's binary is accepted) and production dependencies whose build script would have to run, uncommitted changes to tracked files, an untracked root build input, and running as any tool but the one the root installs. Fetches the schema engine from Prisma for every stage, checks it against both published checksums, and caches nothing. Writes `<stage>/service/xema-service-image.json` (commit, sha256 of each root build input: lockfile, root `package.json`, `pnpm-workspace.yaml`, `.npmrc`, `.pnpmfile.cjs`, `turbo.json`, `tsconfig*.json`; tool version; natives per package; engine sha256) and `<stage>/tool`. The Dockerfile COPYs those root inputs into `/proof/context/`, which puts them in the content tag, and its proof stage runs `src/service-image-in-image.mjs prove <app> <proof dir> --service <svc> --commit <BUILD_SHA>` on the target, offline: it checks the commit, the root inputs, the tool version, the platform and Node ABI, that each package's addons `dlopen`, and the engine's sha256 and version. The final stage only COPYs |
33
34
  | `xema-arm64-supplement-needed` | Decides whether an arm64 supplement is still owed at `REGISTRY/IMAGE:CONTENT_TAG` (env: `REGISTRY`, `IMAGE`, `CONTENT_TAG`, `REGISTRY_USER`, `REGISTRY_TOKEN`). `needed=false` only when the coordinate serves an index whose linux/arm64 child is proven by its own image config; any other resolvable state is `needed=true`; a 401/403/5xx or an unresolvable child is an error. Writes `needed=true\|false` to `$GITHUB_OUTPUT`. Replaces the generated `arm64-supplement-needed.mjs` copy. |
34
35
  | `xema-promote-image --service <name> --dev-ref <repo> --release-ref <repo> --tag <tag> --commit <sha> --main-ref <ref> --signer-repository <owner/repo> --develop-workflow <file> --release-workflow <file>... [--release-index-workflow <file>] --promotion-predicate-out <path> -- <tag command>` | Publishes the develop image's digest under the release tag instead of rebuilding: `reused` (the release digest carries an `image-promotion/v1` attestation signed by a `--release-workflow` identity on main, with exactly one verified statement about the digest and a `ghcr.io/<owner>/dev/<name>` source; no dev-ref probe), `copied` (the proven develop digest, digest-preserving; fails closed if the release digest differs) or `miss` (build it; a reuse whose release digest has no `image-promotion/v1` attestation, a legacy image, is a miss with `reason=reuse-no-promotion` on the stdout line; with `--release-index-workflow`, a release tag that names an image index signed by that workflow on main, with exactly one `linux/amd64` child whose own digest passes that same reuse check, is instead `reused` with `evidence=release-index`, `digest` = the amd64 child A, `index_digest` = the index D and no predicate, but only when A's selected promotion statement promotes `--commit` itself (a rerun; a later commit with the same content takes the existing miss and attests its own promotion). On `evidence=release-index` the caller skips sign/attest, tags `:<sha>` and `main-<sha>` to the amd64 subject `digest` (release-arm64 resolves `:<sha>` to it and verifies its promotion), and points only the content tag (and `latest`, if it moves it) at the index `index_digest`. Any failed step keeps the miss, and two amd64 children are an error; a signature or identity failure, or a source outside `dev/`, is an error; a copy whose dev digest carries no SLSA v1 provenance at all, an image reused on develop from before provenance, is a miss with `reason=copy-no-develop-provenance`, while provenance that is present but fails a leg is an error). A develop digest is proven only if its SLSA v1 provenance is signed by the develop workflow at `refs/heads/develop`, its revision is an ancestor of the main ref, and the tag command prints the same tag at that revision and at `--commit`; any failed leg is an error, never a miss. On every non-miss result it writes the `https://xema.dev/attestations/image-promotion/v1` predicate for the caller to attest: a copy states the facts just proved; a reuse states this run's `release` and `promotedCommit` and carries `source` and `developProvenance` from the earlier promotion. Writes `result`, `digest`, `source`, `revision`, `evidence`, `provenance_digest`, `promotion_predicate`, `promotion_predicate_type` to `$GITHUB_OUTPUT`. Needs `docker`, `cosign` (major v2, the same as the develop attest step and xema-deploy's verify; it runs `cosign version` first and refuses any other major or an unparsable version), `git` and full history. It does not sign |
35
- | `xema-buildx-builder ensure` | Ensures the runner's persistent, capped buildkitd container (`xema-buildkit-signed-<runner>`, state volume `-state`) is up and is the current buildx builder. Env: `RUNNER_NAME`, `BUILDKIT_IMAGE`, `BUILDKIT_LIMITS` (JSON `cpus`, `memory`, `maxParallelism`, `gcMaxUsedSpace`, all required and positive), optional `BUILDKIT_READY_DEADLINE_SECONDS` (positive integer, default 180). The slot is `RUNNER_NAME` with a trailing `-<hostname>` stripped only when it equals this container's own hostname, so a recreated runner container reuses its builder and cache. Pinned to the runner's cgroup cpuset; caps are clamped so every `xema-buildkit-signed-*` builder on the host fits (memory at most half of `docker info` MemTotal divided by N, CPUs at most NCPU divided by N). On each run it removes this verb's labelled builders, and their `-state` volumes, whose recorded runner container is no longer running; unlabelled containers are never touched. Recreated once when the image, effective caps, config or runner container change. Prints `buildx-boot path= secs= builder=`. |
36
36
  | `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
37
37
  | `xema-prepush-sanity` | Lefthook `pre-push`: turbo `lint` and `typecheck` on what the push changes plus dependents (root `lint` only without turbo; typecheck is CI-only there); CI is the proof |
38
38
  | `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
@@ -41,15 +41,6 @@ pnpm exec xema-scrub-swagger-paths dist
41
41
 
42
42
  Every step refuses rather than skips. The package has no dependencies.
43
43
 
44
- ## Security model of `xema-buildx-builder`
45
-
46
- The persistent builder and its cache mounts are for signed develop and main builds only.
47
-
48
- - The verb refuses any `GITHUB_EVENT_NAME` other than `push` and `workflow_dispatch`, and any `GITHUB_REF` other than `refs/heads/main` and `refs/heads/develop`, before any docker call. A pull request never touches the persistent builder or its cache.
49
- - Callers also gate the step on the opt-in org variable: the develop-images lane runs the verb only `if: vars.XEMA_BUILDKIT_MODE == 'persistent'` and uses the stock `setup-buildx-action` otherwise.
50
- - The builder, its container and its state volume are named `xema-buildkit-signed-<runner>`; the `xema-buildkit-signed-*` prefix is reserved for signed lanes, so no other lane shares those cache mounts.
51
- - Owner decision D97 (2026-10-14): pull request jobs run on the same runners. The in-process guards above are the accepted mitigation, because only we push to our repositories.
52
-
53
44
  ## Resolution
54
45
 
55
46
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/repo-build-tooling",
3
- "version": "0.21.2",
3
+ "version": "0.22.0",
4
4
  "description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM, depending only on @xemahq/release-policy for registry access, 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)",
@@ -22,7 +22,6 @@
22
22
  ],
23
23
  "bin": {
24
24
  "xema-arm64-supplement-needed": "src/arm64-supplement-needed.mjs",
25
- "xema-buildx-builder": "src/buildx-builder.mjs",
26
25
  "xema-check-no-vendored-deps": "src/check-no-vendored-deps.mjs",
27
26
  "xema-check-workspace-ranges": "src/check-workspace-range-matches-local.mjs",
28
27
  "xema-composed-image": "src/composed-image.mjs",
@@ -30,11 +29,11 @@
30
29
  "xema-promote-image": "src/promote-image.mjs",
31
30
  "xema-readiness": "src/readiness.mjs",
32
31
  "xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs",
33
- "xema-service-bootstrap": "src/service-bootstrap.mjs"
32
+ "xema-service-bootstrap": "src/service-bootstrap.mjs",
33
+ "xema-service-image": "src/service-image.mjs"
34
34
  },
35
35
  "exports": {
36
36
  "./arm64-supplement-needed": "./src/arm64-supplement-needed.mjs",
37
- "./buildx-builder": "./src/buildx-builder.mjs",
38
37
  "./check-no-vendored-deps": "./src/check-no-vendored-deps.mjs",
39
38
  "./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs",
40
39
  "./composed-image": "./src/composed-image.mjs",
@@ -43,15 +42,16 @@
43
42
  "./promote-image": "./src/promote-image.mjs",
44
43
  "./readiness": "./src/readiness.mjs",
45
44
  "./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs",
46
- "./service-bootstrap": "./src/service-bootstrap.mjs"
45
+ "./service-bootstrap": "./src/service-bootstrap.mjs",
46
+ "./service-image": "./src/service-image.mjs"
47
47
  },
48
48
  "dependencies": {
49
49
  "@xemahq/release-policy": "^14.0.0"
50
50
  },
51
51
  "scripts": {
52
- "test": "node --test src/buildx-builder.test.mjs && 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/composed-image-coordinate-cli.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/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-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-pnpm-context.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 && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs && node --test src/prepush-sanity.test.mjs"
52
+ "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/composed-image-coordinate-cli.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/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-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-pnpm-context.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-image-prisma-engine.test.mjs && node --test src/composed-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs && node --test src/prepush-sanity.test.mjs && node --test src/service-image.test.mjs"
53
53
  },
54
54
  "xemaRelease": {
55
- "sourceDigest": "sha256:417658f5270347a7cfc99992c7004050eb9f0603498c75094f9610da66f6ccb2"
55
+ "sourceDigest": "sha256:0b63df80f6c68f70cb652dfc51f08288aeac11bbee2445cfa6fc440242a7bcf9"
56
56
  }
57
57
  }
@@ -3,11 +3,13 @@
3
3
  // THE IN-IMAGE HALF OF THE COMPOSED-PROCESS BUILD.
4
4
  //
5
5
  // The prebuilt Dockerfile bind-mounts a copy of this package's `package.json` + `src/`
6
- // (NO node_modules) and runs THIS file for `pins`, `index`, `load`, `sync-injected` and
7
- // `prisma-check`. So this module and everything it imports load only `node:` builtins and
8
- // relative modules; a bare package import would die with ERR_MODULE_NOT_FOUND in the image
9
- // (`composed-image-in-image-closure.test.mjs` proves it). `composed-image.mjs` re-exports
10
- // these functions and dispatches the same verbs outside Docker.
6
+ // (NO node_modules) and runs THIS file for `pins`, `index`, `load`, `sync-injected`,
7
+ // `prisma-check` and `prisma-schema-engine`. So this module and everything it imports
8
+ // load only `node:` builtins and relative modules; a bare package import would die with
9
+ // ERR_MODULE_NOT_FOUND in the image (`composed-image-in-image-closure.test.mjs` proves
10
+ // it). `composed-image.mjs` re-exports these functions and dispatches the same verbs
11
+ // outside Docker, except `prisma-schema-engine`, which writes into the installed tree and
12
+ // so runs only in the image.
11
13
  // ═══════════════════════════════════════════════════════════════════════════
12
14
 
13
15
  import { spawnSync } from 'node:child_process';
@@ -20,6 +22,7 @@ import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
20
22
  import { readMembers } from './composed-image-members.mjs';
21
23
  import { syncInjected } from './composed-image-sync-injected.mjs';
22
24
  import { prismaCheck } from './composed-image-prisma-check.mjs';
25
+ import { installPrismaSchemaEngine } from './composed-image-prisma-engine.mjs';
23
26
 
24
27
  export const MEMBER_INDEX_FILE = 'xema-composed-member-index.json';
25
28
  export const HOST_LINK = 'xema-composed-process';
@@ -182,7 +185,7 @@ export function loadMembersEach({ root = process.cwd(), imageDir = COMPOSED_IMAG
182
185
 
183
186
  const USAGE =
184
187
  'usage: composed-image-in-image <command> [--root <dir>] [--image-dir <dir>]\n' +
185
- ' pins <committed-lockfile> | index <commit> --build-source <repo> | load [--each] | sync-injected | prisma-check';
188
+ ' pins <committed-lockfile> | index <commit> --build-source <repo> | load [--each] | sync-injected | prisma-check | prisma-schema-engine <staged dir>';
186
189
 
187
190
  function parseInImageArguments(argv) {
188
191
  const positional = [];
@@ -234,6 +237,8 @@ export function main(argv, { log = console.log } = {}) {
234
237
  return;
235
238
  case 'prisma-check':
236
239
  return prismaCheck({ root, imageDir, log }).then(() => undefined);
240
+ case 'prisma-schema-engine':
241
+ return installPrismaSchemaEngine({ root, stagedDir: argument === undefined ? undefined : resolve(argument), log }).then(() => undefined);
237
242
  default:
238
243
  throw new Error(USAGE);
239
244
  }
@@ -11,6 +11,7 @@ import { COMPOSED_IMAGE_DIR, IN_IMAGE_ENTRY } from './composed-image-paths.mjs';
11
11
  import { toolVersion } from './composed-image-stage.mjs';
12
12
  import { NODE_BASE, npmrcUsesToken } from './composed-image-dockerfile.mjs';
13
13
  import { memberClosure, memberFilters, memberManifests } from './composed-image-members.mjs';
14
+ import { needsPrismaSchemaEngine } from './composed-image-prisma-engine.mjs';
14
15
 
15
16
  export const DEFAULT_STAGE_DIR = '.xema-stage';
16
17
  const DIRECTORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*\/?$/;
@@ -139,6 +140,31 @@ export function memberProdPackages({ root = process.cwd(), imageDir = COMPOSED_I
139
140
  return [...seen].map(([name, dir]) => ({ name, dir }));
140
141
  }
141
142
 
143
+ /** Where the deps stage bind-mounts `<stage dir>/prisma-engines` (`<enginesVersion>/schema-engine-<binaryTarget>`). */
144
+ const PRISMA_ENGINES_MOUNT = '/xema-prisma-engines';
145
+
146
+ /**
147
+ * The deps-stage step that puts the Prisma schema engine where Prisma looks, rendered only when the
148
+ * prod closure carries the prisma CLI (the one thing that runs the engine). It runs in DEPS so the
149
+ * engine lands in `node_modules/.pnpm/@prisma+engines@<v>/node_modules/@prisma/engines/`, which the
150
+ * final stage takes from deps (layer 1); assemble's copy excludes the .pnpm store.
151
+ */
152
+ function prismaSchemaEngineStep({ toolMount, node, stageDir }) {
153
+ return `
154
+ # The Prisma SCHEMA ENGINE for this image's platform. \`prisma migrate deploy\` runs it (the
155
+ # Rust-free client does not replace it), and the prisma CLI downloads it into @prisma/engines
156
+ # at RUNTIME when it is missing, which the read-only, non-root container refuses. The install
157
+ # above is --ignore-scripts, so @prisma/engines' postinstall never fetched it. The runner
158
+ # staged it for every image platform at the lockfile's engines version (Prisma's downloader,
159
+ # sha256-checked) in \`${stageDir}/prisma-engines/\`; this copies the one for the platform
160
+ # Prisma computes HERE into each @prisma/engines a prisma CLI resolves, and fails the build
161
+ # unless it runs and reports that version. Here in deps, so the final stage carries it.
162
+ RUN --network=none ${toolMount} \\
163
+ --mount=type=bind,source=${stageDir}/prisma-engines,target=${PRISMA_ENGINES_MOUNT} \\
164
+ node ${node} prisma-schema-engine ${PRISMA_ENGINES_MOUNT}
165
+ `;
166
+ }
167
+
142
168
  const parts = (sources) => sources.map((source) => ` ${source} \\`).join('\n');
143
169
 
144
170
  /** The prebuilt Dockerfile text. The closure and the members' literal filters are derived from `root` (`memberClosure`); `exec` is injectable for tests. */
@@ -252,7 +278,7 @@ ${secret} ${toolMount} \\
252
278
  node ${node} pins${dirFlag} /tmp/pnpm-lock.committed.yaml && \\
253
279
  pnpm install --prod --offline --ignore-scripts \${INJECT} ${filters} --frozen-lockfile${rebuild.length === 0 ? '' : ` && \\
254
280
  pnpm rebuild ${rebuild.join(' ')} ${filters}`}
255
-
281
+ ${needsPrismaSchemaEngine(prodPackages) ? prismaSchemaEngineStep({ toolMount, node, stageDir }) : ''}
256
282
  FROM deps AS assemble
257
283
  COPY --parents \\
258
284
  ${parts(assembleSources)}
@@ -175,3 +175,27 @@ test('memberProdPackages reads the installed prod closure and refuses an empty o
175
175
  assert.deepEqual(depsCopyDirs(dockerfile), ['tooling/dev/', 'tooling/ci/']);
176
176
  assert.deepEqual(depsCopyDirs(renderPrebuiltDockerfile(options(root, { copy: [] })).dockerfile), []);
177
177
  });
178
+
179
+ test('the Prisma schema engine step renders in deps only when the prod closure carries the prisma CLI; otherwise the bytes are unchanged', () => {
180
+ const root = workspace();
181
+ const without = renderPrebuiltDockerfile(options(root, { prodPackages: [installed(root, 'left-pad'), installed(root, '@prisma/client')] })).dockerfile;
182
+ assert.equal(without, renderPrebuiltDockerfile(options(root)).dockerfile);
183
+ assert.doesNotMatch(without, /prisma-schema-engine|prisma-engines/);
184
+
185
+ const withCli = renderPrebuiltDockerfile(options(root, { prodPackages: [installed(root, 'prisma'), installed(root, '@prisma/engines')] })).dockerfile;
186
+ const step =
187
+ 'RUN --network=none --mount=type=bind,source=.xema-stage/tool,target=/opt/xema-composed-image \\\n' +
188
+ ' --mount=type=bind,source=.xema-stage/prisma-engines,target=/xema-prisma-engines \\\n' +
189
+ ' node /opt/xema-composed-image/src/composed-image-in-image.mjs prisma-schema-engine /xema-prisma-engines\n';
190
+ assert.equal(withCli.split(step).length, 2, 'exactly one engine step');
191
+ const [deps, rest] = withCli.split('FROM deps AS assemble');
192
+ assert.ok(deps.includes(step), 'the step is in deps, whose /monorepo the final stage copies whole (.pnpm included)');
193
+ assert.ok(deps.indexOf(step) > deps.indexOf('pnpm install --prod --offline --ignore-scripts'), 'after the install');
194
+ assert.doesNotMatch(rest, /prisma-schema-engine/);
195
+ // Only the commented step is added: removing it gives back the render without the CLI.
196
+ const added = /\n# The Prisma SCHEMA ENGINE[\s\S]*?prisma-schema-engine \/xema-prisma-engines\n/.exec(withCli)[0];
197
+ assert.equal(withCli.replace(added, ''), without);
198
+
199
+ const custom = renderPrebuiltDockerfile(options(root, { stageDir: 'build/stage', prodPackages: [installed(root, 'prisma')] })).dockerfile;
200
+ assert.match(custom, /--mount=type=bind,source=build\/stage\/prisma-engines,target=\/xema-prisma-engines/);
201
+ });
@@ -0,0 +1,97 @@
1
+ // `xema-composed-image prisma-schema-engine <staged dir>`, INSIDE the prebuilt image
2
+ // build, `--network=none`, on the TARGET arch, right after the deps stage's offline
3
+ // `pnpm install --prod --ignore-scripts`.
4
+ //
5
+ // `prisma migrate deploy` runs Prisma's NATIVE schema engine (the Rust-free client does
6
+ // not replace it). Before every migrate command the prisma CLI calls @prisma/engines'
7
+ // `ensureNeededBinariesExist`, which looks for `schema-engine-<binaryTarget>` in the
8
+ // @prisma/engines package directory and, when it is absent or reports another version,
9
+ // downloads it INTO that directory. A read-only, non-root container refuses the write
10
+ // ("Can't write to …/@prisma/engines"). The install runs `--ignore-scripts`, so the
11
+ // postinstall that would have fetched it never ran.
12
+ //
13
+ // The runner staged the engine for every image platform with Prisma's own downloader
14
+ // (which verifies each file against its published sha256), at the engines version the
15
+ // lockfile pins, as `<staged dir>/<enginesVersion>/schema-engine-<binaryTarget>`. This
16
+ // copies, for every prisma CLI the install carries, the engine for the platform Prisma
17
+ // computes HERE to the exact path Prisma looks at, then runs it and refuses the build
18
+ // unless it reports the pinned engines version: the same check Prisma makes before it
19
+ // decides to download. No environment variable points at it.
20
+ //
21
+ // Loads only `node:` builtins (see composed-image-in-image.mjs); Prisma's own modules are
22
+ // resolved from the installed tree.
23
+ import { spawnSync } from 'node:child_process';
24
+ import { chmodSync, copyFileSync, existsSync, readdirSync, realpathSync } from 'node:fs';
25
+ import { createRequire } from 'node:module';
26
+ import { dirname, join } from 'node:path';
27
+
28
+ /** The prod-closure package whose presence makes the image need the schema engine: the CLI that runs it. */
29
+ export const PRISMA_CLI = 'prisma';
30
+
31
+ /** True when the members' prod closure (`[{ name }]`) carries the prisma CLI. */
32
+ export const needsPrismaSchemaEngine = (prodPackages) => prodPackages.some((p) => p.name === PRISMA_CLI);
33
+
34
+ /** Every distinct @prisma/engines directory (realpath) a prisma CLI under `<root>/node_modules/.pnpm` resolves. */
35
+ export function prismaEnginesDirectories(root) {
36
+ const store = join(root, 'node_modules', '.pnpm');
37
+ if (!existsSync(store)) throw new Error(`${store} does not exist: run the install first`);
38
+ const directories = new Set();
39
+ for (const entry of readdirSync(store).filter((name) => name.startsWith(`${PRISMA_CLI}@`)).sort()) {
40
+ const manifest = join(store, entry, 'node_modules', PRISMA_CLI, 'package.json');
41
+ if (!existsSync(manifest)) continue;
42
+ let engines;
43
+ try {
44
+ engines = createRequire(manifest).resolve('@prisma/engines/package.json');
45
+ } catch (error) {
46
+ throw new Error(`${dirname(manifest)} does not resolve @prisma/engines: ${error?.message ?? String(error)}`);
47
+ }
48
+ directories.add(dirname(realpathSync(engines)));
49
+ }
50
+ return [...directories];
51
+ }
52
+
53
+ const runVersion = (file) => spawnSync(file, ['--version'], { encoding: 'utf8' });
54
+
55
+ /**
56
+ * Run a schema engine and refuse it unless it reports `enginesVersion`: the check Prisma makes before it
57
+ * decides to download one. Returns the engine's version line.
58
+ */
59
+ export function verifySchemaEngine(file, enginesVersion, version = runVersion) {
60
+ const result = version(file);
61
+ if (result.error || result.status !== 0 || !String(result.stdout ?? '').includes(enginesVersion)) {
62
+ const detail = String(result.stdout || result.stderr || result.error?.message || '').trim();
63
+ throw new Error(`${file} --version did not report ${enginesVersion}: status ${result.status}${detail === '' ? '' : `, ${detail}`}`);
64
+ }
65
+ return String(result.stdout).trim();
66
+ }
67
+
68
+ /**
69
+ * Install the staged schema engine into every @prisma/engines directory a prisma CLI resolves.
70
+ * `binaryTarget` and `version` are injectable for tests; by default they are Prisma's own
71
+ * (`@prisma/get-platform` and the engine's `--version`, resolved from each @prisma/engines).
72
+ */
73
+ export async function installPrismaSchemaEngine({ root = process.cwd(), stagedDir, log = console.log, binaryTarget, version = runVersion } = {}) {
74
+ if (typeof stagedDir !== 'string' || stagedDir.length === 0) throw new Error('prisma-schema-engine needs the staged engines directory');
75
+ if (!existsSync(stagedDir)) throw new Error(`${stagedDir} does not exist: stage the Prisma schema engines into it before the image build`);
76
+ const directories = prismaEnginesDirectories(root);
77
+ if (directories.length === 0) {
78
+ throw new Error(`no ${PRISMA_CLI} CLI is installed under ${join(root, 'node_modules', '.pnpm')}, yet the Dockerfile was rendered for one: re-render it`);
79
+ }
80
+ const installed = [];
81
+ for (const enginesDir of directories) {
82
+ const fromEngines = createRequire(join(enginesDir, 'package.json'));
83
+ const { enginesVersion } = fromEngines('@prisma/engines-version');
84
+ const target = binaryTarget ?? (await fromEngines('@prisma/get-platform').getBinaryTargetForCurrentPlatform());
85
+ const fileName = `schema-engine-${target}`;
86
+ const source = join(stagedDir, enginesVersion, fileName);
87
+ if (!existsSync(source)) {
88
+ throw new Error(`no staged schema engine ${source} (engines ${enginesVersion}, platform ${target}): stage it for every image platform at the lockfile's engines version`);
89
+ }
90
+ const destination = join(enginesDir, fileName);
91
+ copyFileSync(source, destination);
92
+ chmodSync(destination, 0o755);
93
+ log(`prisma schema engine: ${destination} (${verifySchemaEngine(destination, enginesVersion, version)})`);
94
+ installed.push(destination);
95
+ }
96
+ return installed;
97
+ }
@@ -0,0 +1,73 @@
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, { after } from 'node:test';
6
+
7
+ import { installPrismaSchemaEngine, needsPrismaSchemaEngine, prismaEnginesDirectories } from './composed-image-prisma-engine.mjs';
8
+
9
+ const VERSION = '7.8.0-6.abc';
10
+ const temps = [];
11
+ after(() => {
12
+ for (const dir of temps) fs.rmSync(dir, { recursive: true, force: true });
13
+ });
14
+
15
+ function write(file, text) {
16
+ fs.mkdirSync(path.dirname(file), { recursive: true });
17
+ fs.writeFileSync(file, text);
18
+ }
19
+
20
+ /** A pnpm-layout tree: prisma@7.8.0 -> @prisma/engines@7.8.0 -> @prisma/engines-version, and a staged engines dir. */
21
+ function tree({ stagedTargets = ['debian-openssl-3.0.x', 'linux-arm64-openssl-3.0.x'], cli = true } = {}) {
22
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-prisma-engine-'));
23
+ temps.push(root);
24
+ const store = path.join(root, 'node_modules', '.pnpm');
25
+ const engines = path.join(store, '@prisma+engines@7.8.0', 'node_modules', '@prisma', 'engines');
26
+ write(path.join(engines, 'package.json'), JSON.stringify({ name: '@prisma/engines', main: 'index.js' }));
27
+ write(path.join(engines, 'index.js'), 'module.exports = {};');
28
+ const version = path.join(store, '@prisma+engines@7.8.0', 'node_modules', '@prisma', 'engines-version');
29
+ write(path.join(version, 'package.json'), JSON.stringify({ name: '@prisma/engines-version', main: 'index.js' }));
30
+ write(path.join(version, 'index.js'), `module.exports = { enginesVersion: ${JSON.stringify(VERSION)} };`);
31
+ if (cli) {
32
+ const prisma = path.join(store, 'prisma@7.8.0_typescript@5.9.3', 'node_modules');
33
+ write(path.join(prisma, 'prisma', 'package.json'), JSON.stringify({ name: 'prisma' }));
34
+ fs.mkdirSync(path.join(prisma, '@prisma'), { recursive: true });
35
+ fs.symlinkSync(engines, path.join(prisma, '@prisma', 'engines'));
36
+ }
37
+ const staged = path.join(root, 'staged');
38
+ for (const target of stagedTargets) write(path.join(staged, VERSION, `schema-engine-${target}`), `engine ${target}`);
39
+ return { root, engines: fs.realpathSync(engines), staged };
40
+ }
41
+
42
+ const reports = (text) => () => ({ status: 0, stdout: `schema-engine-cli ${text}\n` });
43
+
44
+ test('needsPrismaSchemaEngine: only the prisma CLI in the prod closure asks for the engine', () => {
45
+ assert.equal(needsPrismaSchemaEngine([{ name: 'prisma' }, { name: 'x' }]), true);
46
+ assert.equal(needsPrismaSchemaEngine([{ name: '@prisma/client' }, { name: '@prisma/engines' }]), false);
47
+ assert.equal(needsPrismaSchemaEngine([]), false);
48
+ });
49
+
50
+ test('the engine for the computed platform lands in the @prisma/engines the CLI resolves (realpath), executable, version-checked', async () => {
51
+ const { root, engines, staged } = tree();
52
+ assert.deepEqual(prismaEnginesDirectories(root), [engines]);
53
+ const lines = [];
54
+ const installed = await installPrismaSchemaEngine({ root, stagedDir: staged, binaryTarget: 'linux-arm64-openssl-3.0.x', version: reports(VERSION), log: (l) => lines.push(l) });
55
+ const target = path.join(engines, 'schema-engine-linux-arm64-openssl-3.0.x');
56
+ assert.deepEqual(installed, [target]);
57
+ assert.equal(fs.readFileSync(target, 'utf8'), 'engine linux-arm64-openssl-3.0.x');
58
+ assert.equal(fs.statSync(target).mode & 0o777, 0o755);
59
+ assert.equal(fs.existsSync(path.join(engines, 'schema-engine-debian-openssl-3.0.x')), false, 'only the computed platform');
60
+ assert.match(lines[0], new RegExp(`${VERSION}`));
61
+ });
62
+
63
+ test('the build fails loudly: no staged file for the platform, a wrong version, a failing engine, no CLI, no staged dir', async () => {
64
+ const { root, staged } = tree({ stagedTargets: ['debian-openssl-3.0.x'] });
65
+ const run = (extra) => installPrismaSchemaEngine({ root, stagedDir: staged, binaryTarget: 'debian-openssl-3.0.x', version: reports(VERSION), log: () => {}, ...extra });
66
+ await assert.rejects(run({ binaryTarget: 'linux-arm64-openssl-3.0.x' }), /no staged schema engine .*schema-engine-linux-arm64-openssl-3\.0\.x \(engines 7\.8\.0-6\.abc, platform linux-arm64/);
67
+ await assert.rejects(run({ version: reports('7.7.0-1.other') }), /--version did not report 7\.8\.0-6\.abc/);
68
+ await assert.rejects(run({ version: () => ({ status: null, error: new Error('Exec format error') }) }), /did not report .*Exec format error/);
69
+ await assert.rejects(run({ stagedDir: path.join(root, 'absent') }), /does not exist: stage the Prisma schema engines/);
70
+ await assert.rejects(run({ stagedDir: undefined }), /needs the staged engines directory/);
71
+ const bare = tree({ cli: false });
72
+ await assert.rejects(installPrismaSchemaEngine({ root: bare.root, stagedDir: bare.staged, binaryTarget: 'debian-openssl-3.0.x', log: () => {} }), /no prisma CLI is installed/);
73
+ });
@@ -6,14 +6,16 @@
6
6
  //
7
7
  // With turbo (a `turbo.json` and a turbo dependency): turbo `lint` and `typecheck` run only on the packages
8
8
  // the pushed commits change plus their dependents. A change to the lockfile,
9
- // root package.json or turbo.json invalidates everything and runs the full
10
- // graph. A repo whose turbo carries no `lint` task also runs its root `lint`
9
+ // turbo.json, or anything in the root package.json other than its `scripts`
10
+ // table invalidates everything and runs the full graph. A repo whose turbo carries no `lint` task also runs its root `lint`
11
11
  // script. Without turbo: the root `lint` script only (typecheck needs a build, which the hook never runs; CI has it).
12
12
  //
13
13
  // The pushed SHA is what is graded. In place when the tree is clean and the SHA
14
14
  // is HEAD, or when no differing path lies in a package the run reads or at the
15
15
  // repository root; otherwise from a `git archive` export. A delete-only push
16
- // (or no ref on stdin) grades nothing.
16
+ // (or no ref on stdin) grades nothing. A push whose every remote ref is a
17
+ // session park (`refs/xema-session/*`) grades nothing either: a park is not a
18
+ // landing, and the work is graded when it lands on a branch.
17
19
  //
18
20
  // Never builds for its own sake, never runs tests, docker or `pnpm check`.
19
21
  //
@@ -80,9 +82,32 @@ export function parseRefs(stdin) {
80
82
  return refs;
81
83
  }
82
84
 
83
- /** True when a changed path invalidates every package. */
84
- export function isFleetWide(changedPaths) {
85
- return changedPaths.some((p) => p === 'pnpm-lock.yaml' || p === 'package.json' || p === 'turbo.json');
85
+ /** Remote refs under this prefix are session parks, not landings. */
86
+ export const PARK_REF_PREFIX = 'refs/xema-session/';
87
+
88
+ /** True when stdin names at least one ref and every remote ref is a session park. A branch anywhere in the push grades the push. */
89
+ export function parkOnly(stdin) {
90
+ const remoteRefs = stdin
91
+ .split('\n')
92
+ .map((line) => line.trim().split(/\s+/)[2])
93
+ .filter(Boolean);
94
+ return remoteRefs.length > 0 && remoteRefs.every((ref) => ref.startsWith(PARK_REF_PREFIX));
95
+ }
96
+
97
+ /** True when two root package.json texts differ only in the top-level `scripts` key; a missing side or unparseable JSON is false. */
98
+ export function scriptsOnlyChange(baseText, tipText) {
99
+ try {
100
+ const { scripts: _baseScripts, ...base } = JSON.parse(baseText);
101
+ const { scripts: _tipScripts, ...tip } = JSON.parse(tipText);
102
+ return JSON.stringify(base) === JSON.stringify(tip);
103
+ } catch {
104
+ return false;
105
+ }
106
+ }
107
+
108
+ /** True when a changed path invalidates every package. `rootScriptsOnly`: the root package.json change is the `scripts` table alone. */
109
+ export function isFleetWide(changedPaths, rootScriptsOnly = false) {
110
+ return changedPaths.some((p) => p === 'pnpm-lock.yaml' || p === 'turbo.json' || (p === 'package.json' && !rootScriptsOnly));
86
111
  }
87
112
 
88
113
  /**
@@ -148,8 +173,9 @@ function rangeFor(ctx, base, sha) {
148
173
  return '';
149
174
  }
150
175
  const changed = (ctx.gitOut(['diff', '--name-only', `${base}...${sha}`]) ?? '').split('\n').filter(Boolean);
151
- if (isFleetWide(changed)) {
152
- console.error('pre-push: lockfile, root package.json or turbo.json changed — fleet-wide, running the FULL graph.');
176
+ const rootScriptsOnly = changed.includes('package.json') && scriptsOnlyChange(ctx.gitOut(['show', `${base}:package.json`]) ?? '', ctx.gitOut(['show', `${sha}:package.json`]) ?? '');
177
+ if (isFleetWide(changed, rootScriptsOnly)) {
178
+ console.error('pre-push: lockfile, turbo.json or root package.json beyond its scripts changed — fleet-wide, running the FULL graph.');
153
179
  return '';
154
180
  }
155
181
  return `${base}...${sha}`;
@@ -306,6 +332,10 @@ export function main(stdin) {
306
332
  console.error('pre-push: not inside a git repository — FAILED');
307
333
  return 1;
308
334
  }
335
+ if (parkOnly(stdin)) {
336
+ console.log(`pre-push: skipped — every ref is a session park under ${PARK_REF_PREFIX}; parks are not landings, the gate runs when the work lands.`);
337
+ return 0;
338
+ }
309
339
  const ctx = createContext(top.stdout.trim());
310
340
  const refs = parseRefs(stdin);
311
341
  if (refs.length === 0) {
@@ -5,7 +5,7 @@ import { tmpdir } from 'node:os';
5
5
  import { join } from 'node:path';
6
6
  import { test } from 'node:test';
7
7
  import { fileURLToPath } from 'node:url';
8
- import { blockingPath, isFleetWide, parseRefs, stripJsonc, turboCacheDir, turboTasks } from './prepush-sanity.mjs';
8
+ import { blockingPath, isFleetWide, parkOnly, parseRefs, scriptsOnlyChange, stripJsonc, turboCacheDir, turboTasks } from './prepush-sanity.mjs';
9
9
 
10
10
  const VERB = fileURLToPath(new URL('./prepush-sanity.mjs', import.meta.url));
11
11
  const SHA_A = 'a'.repeat(40);
@@ -29,12 +29,26 @@ test('parseRefs dedupes shas and drops deletes and junk', () => {
29
29
  assert.deepEqual(parseRefs(''), []);
30
30
  });
31
31
 
32
- test('isFleetWide flags only the lockfile, root package.json and turbo.json', () => {
32
+ test('isFleetWide flags the lockfile, turbo.json and a root package.json change beyond scripts', () => {
33
33
  assert.equal(isFleetWide(['pnpm-lock.yaml']), true);
34
+ assert.equal(isFleetWide(['turbo.json']), true);
34
35
  assert.equal(isFleetWide(['package.json']), true);
36
+ assert.equal(isFleetWide(['package.json'], true), false);
37
+ assert.equal(isFleetWide(['package.json', 'pnpm-lock.yaml'], true), true);
35
38
  assert.equal(isFleetWide(['packages/a/package.json', 'src/x.ts']), false);
36
39
  });
37
40
 
41
+ test('scriptsOnlyChange: only the scripts key may differ; anything else or unreadable is fleet-wide', () => {
42
+ const base = { name: 't', scripts: { a: '1' }, dependencies: { x: '1' }, packageManager: 'pnpm@10.0.0' };
43
+ const text = (o) => JSON.stringify(o);
44
+ assert.equal(scriptsOnlyChange(text(base), text({ ...base, scripts: { a: '2', b: '3' } })), true);
45
+ for (const change of [{ dependencies: { x: '2' } }, { packageManager: 'pnpm@10.1.0' }, { pnpm: { overrides: {} } }, { engines: { node: '22' } }]) {
46
+ assert.equal(scriptsOnlyChange(text(base), text({ ...base, ...change })), false, Object.keys(change)[0]);
47
+ }
48
+ assert.equal(scriptsOnlyChange('', text(base)), false);
49
+ assert.equal(scriptsOnlyChange(text(base), '{ nope'), false);
50
+ });
51
+
38
52
  test('blockingPath: package reads and repo-root files block, root markdown does not', () => {
39
53
  const owned = (p) => p.startsWith('packages/');
40
54
  assert.equal(blockingPath(['packages/a/src/x.ts'], ['packages/a'], owned)?.why, 'is in a package this run reads');
@@ -122,3 +136,29 @@ test('the turbo invocation receives TURBO_CACHE_DIR', () => {
122
136
  rmSync(r.dir, { recursive: true, force: true });
123
137
  }
124
138
  });
139
+
140
+ test('parkOnly: only a push whose every remote ref is a session park', () => {
141
+ const park = `refs/heads/x ${SHA_A} refs/xema-session/lead/T1/x ${'0'.repeat(40)}`;
142
+ const dev = `refs/heads/x ${SHA_A} refs/heads/develop ${SHA_B}`;
143
+ assert.equal(parkOnly(`${park}\n`), true);
144
+ assert.equal(parkOnly(`(delete) ${'0'.repeat(40)} refs/xema-session/lead/T1/x ${SHA_A}\n`), true);
145
+ assert.equal(parkOnly(`${park}\n${dev}\n`), false);
146
+ assert.equal(parkOnly(`${dev}\n`), false);
147
+ assert.equal(parkOnly(`refs/heads/x ${SHA_A} refs/heads/main ${SHA_B}\n`), false);
148
+ assert.equal(parkOnly(`refs/heads/x ${SHA_A} refs/heads/xema-session/x ${SHA_B}\n`), false);
149
+ assert.equal(parkOnly(''), false);
150
+ });
151
+
152
+ test('a park-only push skips and says why; a park beside develop still grades', () => {
153
+ const r = repo({ typecheck: 'exit 1', lint: 'exit 1' });
154
+ try {
155
+ const parked = hook(r.dir, `refs/heads/x ${r.sha} refs/xema-session/lead/T1/x ${'0'.repeat(40)}\n`);
156
+ assert.equal(parked.status, 0, parked.stdout + parked.stderr);
157
+ assert.match(parked.stdout, /skipped — every ref is a session park/);
158
+ const mixed = hook(r.dir, `refs/heads/x ${r.sha} refs/xema-session/lead/T1/x ${'0'.repeat(40)}\nrefs/heads/x ${r.sha} refs/heads/develop ${'0'.repeat(40)}\n`);
159
+ assert.equal(mixed.status, 1, mixed.stdout + mixed.stderr);
160
+ assert.match(mixed.stdout, /pre-push: FAILED/);
161
+ } finally {
162
+ rmSync(r.dir, { recursive: true, force: true });
163
+ }
164
+ });