@xemahq/repo-build-tooling 0.14.1 → 0.15.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 +2 -2
- package/package.json +3 -3
- package/src/composed-image-pnpm-context.mjs +119 -0
- package/src/composed-image-pnpm-context.test.mjs +104 -0
- package/src/composed-image-prebuilt-dockerfile.mjs +15 -2
- package/src/composed-image-stage.mjs +17 -3
- package/src/composed-image-stage.test.mjs +22 -0
- package/src/composed-image.mjs +21 -2
package/README.md
CHANGED
|
@@ -29,11 +29,11 @@ pnpm exec xema-scrub-swagger-paths dist
|
|
|
29
29
|
| `xema-readiness` | Runs the ordered checks between a source change and a push |
|
|
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
|
-
| `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` |
|
|
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
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 `--copy`, `--cache-id` and `--build-source`; it pins the version of this tool itself, and there is no flag for it (`--tooling-version` is refused as an unknown option). `dockerfile --prebuilt` renders the Dockerfile and `Dockerfile.dockerignore` of an image assembled from `stage <dir> [--output <rel>]… [--also <pkg>]…` output (requires `--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.
|
|
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 `--copy`, `--cache-id` and `--build-source`; it pins the version of this tool itself, and there is no flag for it (`--tooling-version` is refused as an unknown option). `dockerfile --prebuilt` renders the Dockerfile and `Dockerfile.dockerignore` of an image assembled from `stage <dir> [--output <rel>]… [--also <pkg>]…` output (requires `--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. `pnpm-context <dir> (--subject <svc>… | --image-dir <composed>)` runs on the CI runner after `pnpm install --frozen-lockfile` and builds the offline `xema-pnpm` build context (`staged.json`, `corepack/pnpm.tgz`, `cache/`); it prints `XEMA_PNPM_CONTEXT=<dir>` and `XEMA_PNPM_STORE=<store root>` on stdout (everything else on stderr) and refuses a non-empty `<dir>`. 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.
|
|
3
|
+
"version": "0.15.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)",
|
|
@@ -41,9 +41,9 @@
|
|
|
41
41
|
"@xemahq/release-policy": "^14.0.0"
|
|
42
42
|
},
|
|
43
43
|
"scripts": {
|
|
44
|
-
"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-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-migrate.test.mjs"
|
|
44
|
+
"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-migrate.test.mjs"
|
|
45
45
|
},
|
|
46
46
|
"xemaRelease": {
|
|
47
|
-
"sourceDigest": "sha256:
|
|
47
|
+
"sourceDigest": "sha256:d3f54741da7e081ed21ee59ca2b27600880be97f359fde67028b756528c66153"
|
|
48
48
|
}
|
|
49
49
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// `xema-composed-image pnpm-context <dir> (--subject <svc>... | --image-dir <composed>)`:
|
|
2
|
+
// builds, on the CI runner and AFTER its `pnpm install --frozen-lockfile`, the pnpm context an
|
|
3
|
+
// offline prebuilt image build consumes as the named BuildKit context `xema-pnpm`. The runner's
|
|
4
|
+
// own pnpm store is the second context (`xema-pnpm-store`); it is never copied.
|
|
5
|
+
//
|
|
6
|
+
// <dir>/staged.json { tool, pnpm, storeDir, subjects[], imageDir? } — the sentinel the image checks
|
|
7
|
+
// <dir>/corepack/pnpm.tgz `corepack pack pnpm@<root packageManager>`
|
|
8
|
+
// <dir>/cache/ pnpm `cache-dir` (registry metadata), seeded from the runner's and warmed
|
|
9
|
+
//
|
|
10
|
+
// stdout is exactly two lines for `$GITHUB_ENV`: `XEMA_PNPM_CONTEXT=<dir>` and
|
|
11
|
+
// `XEMA_PNPM_STORE=<store root>`. Everything else goes to stderr. pnpm's own request-level
|
|
12
|
+
// fetch retries apply; nothing here retries a command.
|
|
13
|
+
import { execFileSync } from 'node:child_process';
|
|
14
|
+
import { cpSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
+
import { tmpdir } from 'node:os';
|
|
16
|
+
import { dirname, join, resolve } from 'node:path';
|
|
17
|
+
|
|
18
|
+
import { memberClosure, memberFilters } from './composed-image-members.mjs';
|
|
19
|
+
import { lockfileOnlyCommand } from './composed-image-prebuilt-dockerfile.mjs';
|
|
20
|
+
import { toolVersion } from './composed-image-stage.mjs';
|
|
21
|
+
|
|
22
|
+
/** Runs a command, returns stdout; stderr is the caller's. Injectable for tests. */
|
|
23
|
+
const defaultExec = (file, args, { cwd } = {}) =>
|
|
24
|
+
execFileSync(file, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], maxBuffer: 64 * 1024 * 1024 });
|
|
25
|
+
|
|
26
|
+
/** `pnpm@10.33.0+sha512.…` → `10.33.0`. */
|
|
27
|
+
function pnpmVersion(root) {
|
|
28
|
+
const manager = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).packageManager;
|
|
29
|
+
const found = typeof manager === 'string' ? /^pnpm@(\d+\.\d+\.\d+)(?:\+.*)?$/.exec(manager) : null;
|
|
30
|
+
if (found === null) throw new Error(`package.json packageManager must be pnpm@<x.y.z>, got ${JSON.stringify(manager)}`);
|
|
31
|
+
return found[1];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function seedCache(exec, source, target, log) {
|
|
35
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
36
|
+
if (!existsSync(source)) {
|
|
37
|
+
mkdirSync(target);
|
|
38
|
+
return 'empty';
|
|
39
|
+
}
|
|
40
|
+
try {
|
|
41
|
+
exec('cp', ['-al', source, target], {});
|
|
42
|
+
return 'hardlinked';
|
|
43
|
+
} catch (error) {
|
|
44
|
+
log(`pnpm-context: cp -al failed (${String(error?.message ?? error).split('\n')[0]}); copying the cache instead`);
|
|
45
|
+
rmSync(target, { recursive: true, force: true });
|
|
46
|
+
cpSync(source, target, { recursive: true });
|
|
47
|
+
return 'copied';
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A scratch tree holding exactly the deps stage's inputs, so `--lockfile-only` never touches the repository. */
|
|
52
|
+
function scratchLockfileInputs({ root, imageDir, exec, copy }) {
|
|
53
|
+
const scratch = mkdtempSync(join(tmpdir(), 'xema-pnpm-lock-'));
|
|
54
|
+
const put = (rel) => {
|
|
55
|
+
if (!existsSync(join(root, rel))) return;
|
|
56
|
+
mkdirSync(dirname(join(scratch, rel)), { recursive: true });
|
|
57
|
+
copyFileSync(join(root, rel), join(scratch, rel));
|
|
58
|
+
};
|
|
59
|
+
for (const rel of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc']) put(rel);
|
|
60
|
+
for (const directory of memberClosure({ root, imageDir, exec })) put(`${directory}/package.json`);
|
|
61
|
+
for (const directory of copy) cpSync(join(root, directory), join(scratch, directory), { recursive: true });
|
|
62
|
+
return scratch;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function buildPnpmContext({
|
|
66
|
+
root = process.cwd(),
|
|
67
|
+
dir,
|
|
68
|
+
subjects = [],
|
|
69
|
+
imageDir,
|
|
70
|
+
copy = [],
|
|
71
|
+
exec = defaultExec,
|
|
72
|
+
log = () => {},
|
|
73
|
+
}) {
|
|
74
|
+
if (typeof dir !== 'string' || dir.length === 0) throw new Error('pnpm-context needs the output directory: pnpm-context <dir>');
|
|
75
|
+
if (subjects.length === 0 && imageDir === undefined) throw new Error('pnpm-context needs --subject <svc>… or --image-dir <composed>');
|
|
76
|
+
if (subjects.length > 0 && imageDir !== undefined) throw new Error('pnpm-context takes --subject or --image-dir, not both');
|
|
77
|
+
const target = resolve(dir);
|
|
78
|
+
if (existsSync(target) && readdirSync(target).length > 0) throw new Error(`${dir} already exists and is not empty`);
|
|
79
|
+
|
|
80
|
+
const pnpm = pnpmVersion(root);
|
|
81
|
+
const storePath = exec('pnpm', ['store', 'path'], { cwd: root }).trim();
|
|
82
|
+
const storeRoot = dirname(storePath);
|
|
83
|
+
if (storePath === '' || !existsSync(storePath)) {
|
|
84
|
+
throw new Error(`the runner's pnpm store ${storePath || '(unknown)'} does not exist — run pnpm install --frozen-lockfile before pnpm-context`);
|
|
85
|
+
}
|
|
86
|
+
const cacheSource = exec('pnpm', ['config', 'get', 'cache-dir'], { cwd: root }).trim();
|
|
87
|
+
|
|
88
|
+
mkdirSync(join(target, 'corepack'), { recursive: true });
|
|
89
|
+
exec('corepack', ['pack', `pnpm@${pnpm}`, '-o', join(target, 'corepack', 'pnpm.tgz')], { cwd: root });
|
|
90
|
+
const cache = join(target, 'cache');
|
|
91
|
+
log(`pnpm-context: cache ${seedCache(exec, cacheSource, cache, log)} from ${cacheSource}`);
|
|
92
|
+
|
|
93
|
+
// Warm: the exact metadata requests the offline image build will make, answered from the registry once, here.
|
|
94
|
+
const base = [`--config.cache-dir=${cache}`];
|
|
95
|
+
if (subjects.length > 0) {
|
|
96
|
+
const tmp = mkdtempSync(join(tmpdir(), 'xema-pnpm-deploy-'));
|
|
97
|
+
try {
|
|
98
|
+
for (const subject of subjects) {
|
|
99
|
+
exec('pnpm', ['deploy', '--prod', '--ignore-scripts', '--prefer-offline', ...base, '--filter', subject, join(tmp, subject.replace(/[^A-Za-z0-9._-]/g, '_'))], { cwd: root });
|
|
100
|
+
log(`pnpm-context: warmed ${subject}`);
|
|
101
|
+
}
|
|
102
|
+
} finally {
|
|
103
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
104
|
+
}
|
|
105
|
+
} else {
|
|
106
|
+
const scratch = scratchLockfileInputs({ root, imageDir, exec, copy });
|
|
107
|
+
try {
|
|
108
|
+
const [file, ...args] = lockfileOnlyCommand(memberFilters({ root, imageDir }).flatMap((f) => f.split(' ')));
|
|
109
|
+
exec(file, [...args, ...base], { cwd: scratch });
|
|
110
|
+
log(`pnpm-context: warmed the ${imageDir} lockfile-only resolve`);
|
|
111
|
+
} finally {
|
|
112
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const sentinel = { tool: toolVersion(), pnpm, storeDir: storeRoot, subjects: [...subjects], ...(imageDir === undefined ? {} : { imageDir }) };
|
|
117
|
+
writeFileSync(join(target, 'staged.json'), `${JSON.stringify(sentinel, null, 2)}\n`);
|
|
118
|
+
return { context: target, store: storeRoot };
|
|
119
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { buildPnpmContext } from './composed-image-pnpm-context.mjs';
|
|
8
|
+
import { lockfileOnlyCommand, renderPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
|
|
9
|
+
import { CLOSURE, fakePnpm, workspace, write } from './composed-image-fixture.test.mjs';
|
|
10
|
+
|
|
11
|
+
function setup({ store = true, cache = true } = {}) {
|
|
12
|
+
const root = workspace();
|
|
13
|
+
write(root, 'package.json', { name: 'r', packageManager: 'pnpm@10.33.0+sha512.abc' });
|
|
14
|
+
write(root, 'pnpm-lock.yaml', 'lockfileVersion: 9\n');
|
|
15
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-pnpm-home-'));
|
|
16
|
+
const storePath = path.join(home, '.pnpm-store', 'v10');
|
|
17
|
+
const cacheDir = path.join(home, 'cache');
|
|
18
|
+
if (store) fs.mkdirSync(storePath, { recursive: true });
|
|
19
|
+
if (cache) write(home, 'cache/metadata-v1.3/registry.npmjs.org/x.json', '{}');
|
|
20
|
+
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'xema-pnpm-out-')), 'ctx');
|
|
21
|
+
const pnpmLs = fakePnpm(root, CLOSURE);
|
|
22
|
+
const calls = [];
|
|
23
|
+
const exec = (file, args, options = {}) => {
|
|
24
|
+
calls.push({ file, args, cwd: options.cwd, scratch: options.cwd !== root && options.cwd !== undefined ? fs.readdirSync(options.cwd) : undefined });
|
|
25
|
+
if (file === 'pnpm' && args[0] === 'ls') return pnpmLs(file, args, options);
|
|
26
|
+
if (file === 'pnpm' && args[0] === 'store') return `${storePath}\n`;
|
|
27
|
+
if (file === 'pnpm' && args[0] === 'config') return `${cacheDir}\n`;
|
|
28
|
+
if (file === 'corepack') {
|
|
29
|
+
fs.writeFileSync(args[args.indexOf('-o') + 1], 'tgz');
|
|
30
|
+
return '';
|
|
31
|
+
}
|
|
32
|
+
if (file === 'cp') {
|
|
33
|
+
fs.cpSync(args[1], args[2], { recursive: true });
|
|
34
|
+
return '';
|
|
35
|
+
}
|
|
36
|
+
return '';
|
|
37
|
+
};
|
|
38
|
+
return { root, out, exec, calls, storePath, home };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
test('--subject: writes corepack tgz, a seeded and warmed cache dir, and the sentinel; returns the two env values', () => {
|
|
42
|
+
const { root, out, exec, calls, home } = setup();
|
|
43
|
+
const result = buildPnpmContext({ root, dir: out, subjects: ['svc-a', 'svc-b'], exec });
|
|
44
|
+
assert.deepEqual(result, { context: out, store: path.join(home, '.pnpm-store') });
|
|
45
|
+
assert.equal(fs.readFileSync(path.join(out, 'corepack/pnpm.tgz'), 'utf8'), 'tgz');
|
|
46
|
+
assert.ok(fs.existsSync(path.join(out, 'cache/metadata-v1.3/registry.npmjs.org/x.json')));
|
|
47
|
+
const staged = JSON.parse(fs.readFileSync(path.join(out, 'staged.json'), 'utf8'));
|
|
48
|
+
assert.deepEqual(Object.keys(staged), ['tool', 'pnpm', 'storeDir', 'subjects']);
|
|
49
|
+
assert.equal(staged.pnpm, '10.33.0');
|
|
50
|
+
assert.deepEqual(staged.subjects, ['svc-a', 'svc-b']);
|
|
51
|
+
assert.deepEqual(calls.find((c) => c.file === 'corepack').args.slice(0, 2), ['pack', 'pnpm@10.33.0']);
|
|
52
|
+
const deploys = calls.filter((c) => c.args[0] === 'deploy');
|
|
53
|
+
assert.equal(deploys.length, 2);
|
|
54
|
+
for (const d of deploys) {
|
|
55
|
+
for (const flag of ['--prod', '--ignore-scripts', '--prefer-offline', `--config.cache-dir=${path.join(out, 'cache')}`]) assert.ok(d.args.includes(flag), flag);
|
|
56
|
+
assert.ok(!fs.existsSync(d.args.at(-1)), 'the deploy target is deleted');
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('refuses a non-empty directory, a missing store, and a bad invocation, writing nothing', () => {
|
|
61
|
+
const full = setup();
|
|
62
|
+
write(full.out, 'old', 'x');
|
|
63
|
+
assert.throws(() => buildPnpmContext({ root: full.root, dir: full.out, subjects: ['s'], exec: full.exec }), /not empty/);
|
|
64
|
+
const none = setup({ store: false });
|
|
65
|
+
assert.throws(() => buildPnpmContext({ root: none.root, dir: none.out, subjects: ['s'], exec: none.exec }), /pnpm store .* does not exist/);
|
|
66
|
+
assert.equal(fs.existsSync(none.out), false);
|
|
67
|
+
assert.throws(() => buildPnpmContext({ root: none.root, dir: none.out, exec: none.exec }), /--subject/);
|
|
68
|
+
assert.throws(() => buildPnpmContext({ root: none.root, dir: none.out, subjects: ['s'], imageDir: 'x', exec: none.exec }), /not both/);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('--image-dir: runs the generator\'s lockfile-only command on a scratch copy and leaves the repository lockfile alone', () => {
|
|
72
|
+
const { root, out, exec, calls } = setup({ cache: false });
|
|
73
|
+
buildPnpmContext({ root, dir: out, imageDir: 'infra/composed-process', exec });
|
|
74
|
+
const resolve = calls.find((c) => c.args.includes('--lockfile-only'));
|
|
75
|
+
assert.notEqual(resolve.cwd, root);
|
|
76
|
+
assert.ok(resolve.scratch.includes('pnpm-lock.yaml') && resolve.scratch.includes('package.json'));
|
|
77
|
+
assert.ok(resolve.args.includes(`--config.cache-dir=${path.join(out, 'cache')}`));
|
|
78
|
+
assert.equal(fs.existsSync(resolve.cwd), false, 'the scratch tree is deleted');
|
|
79
|
+
assert.equal(fs.readFileSync(path.join(root, 'pnpm-lock.yaml'), 'utf8'), 'lockfileVersion: 9\n');
|
|
80
|
+
assert.equal(JSON.parse(fs.readFileSync(path.join(out, 'staged.json'), 'utf8')).imageDir, 'infra/composed-process');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('the lockfile-only command is the one the generated Dockerfile runs (no drift)', () => {
|
|
84
|
+
const root = workspace();
|
|
85
|
+
const words = lockfileOnlyCommand(['--filter', '@t/alpha-api...']);
|
|
86
|
+
assert.deepEqual(words.slice(0, 4), ['pnpm', 'install', '--lockfile-only', '--prefer-offline']);
|
|
87
|
+
assert.ok(words.includes('--config.inject-workspace-packages=true') && words.includes('--config.dedupe-injected-deps=false'));
|
|
88
|
+
const { dockerfile: text } = renderPrebuiltDockerfile({ root, cacheId: 'x', buildSource: 'repo', usesToken: false, exec: fakePnpm(root) });
|
|
89
|
+
assert.ok(text.includes('pnpm install --lockfile-only --prefer-offline ${INJECT} --filter @t/alpha-api... --filter @t/alpha-db-api... &&'));
|
|
90
|
+
assert.ok(text.includes('INJECT="--config.inject-workspace-packages=true --config.dedupe-injected-deps=false"'));
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('a failed cp -al falls back to a copy and logs why', () => {
|
|
94
|
+
const { root, out, exec, home } = setup();
|
|
95
|
+
const logs = [];
|
|
96
|
+
const failing = (file, args, options) => {
|
|
97
|
+
if (file === 'cp') throw new Error('cross-device link');
|
|
98
|
+
return exec(file, args, options);
|
|
99
|
+
};
|
|
100
|
+
buildPnpmContext({ root, dir: out, subjects: ['s'], exec: failing, log: (m) => logs.push(m) });
|
|
101
|
+
assert.ok(logs.some((m) => /cp -al failed \(cross-device link\)/.test(m)));
|
|
102
|
+
assert.ok(fs.existsSync(path.join(out, 'cache/metadata-v1.3/registry.npmjs.org/x.json')));
|
|
103
|
+
assert.ok(home);
|
|
104
|
+
});
|
|
@@ -62,6 +62,19 @@ export function onlyBuiltDependencies(workspaceYaml) {
|
|
|
62
62
|
return names;
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/** The install-flag word the rendered RUN keeps as a shell variable; `injectFlags()` is what it expands to. */
|
|
66
|
+
export const INJECT_TOKEN = '${INJECT}';
|
|
67
|
+
export const injectFlags = () => ['--config.inject-workspace-packages=true', '--config.dedupe-injected-deps=false'];
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The deps stage's `--lockfile-only` resolve, as words (`INJECT_TOKEN` stands for `injectFlags()`).
|
|
71
|
+
* The Dockerfile renderer and `xema-composed-image pnpm-context` both read THIS function, so the
|
|
72
|
+
* metadata the context warms is exactly what the image resolves.
|
|
73
|
+
*/
|
|
74
|
+
export const lockfileOnlyWords = (filters) => ['pnpm', 'install', '--lockfile-only', '--prefer-offline', INJECT_TOKEN, ...filters];
|
|
75
|
+
/** The same command with the inject words expanded: `[file, ...args]`. */
|
|
76
|
+
export const lockfileOnlyCommand = (filters) => lockfileOnlyWords(filters).flatMap((w) => (w === INJECT_TOKEN ? injectFlags() : [w]));
|
|
77
|
+
|
|
65
78
|
const parts = (sources) => sources.map((source) => ` ${source} \\`).join('\n');
|
|
66
79
|
|
|
67
80
|
/** The prebuilt Dockerfile text. The closure and the members' literal filters are derived from `root` (`memberClosure`); `exec` is injectable for tests. */
|
|
@@ -143,9 +156,9 @@ ${parts(manifestSources)}
|
|
|
143
156
|
RUN ${store} \\
|
|
144
157
|
${metadata} \\
|
|
145
158
|
${secret} ${toolMount} \\
|
|
146
|
-
${tokenExport}INJECT="
|
|
159
|
+
${tokenExport}INJECT="${injectFlags().join(' ')}" && \\
|
|
147
160
|
cp pnpm-lock.yaml /tmp/pnpm-lock.committed.yaml && \\
|
|
148
|
-
|
|
161
|
+
${lockfileOnlyWords(filters.split(' ')).join(' ')} && \\
|
|
149
162
|
node ${node} pins${dirFlag} /tmp/pnpm-lock.committed.yaml && \\
|
|
150
163
|
pnpm install --prod --prefer-offline --ignore-scripts \${INJECT} ${filters} --frozen-lockfile && \\
|
|
151
164
|
pnpm rebuild ${built.join(' ')} ${filters}
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
//
|
|
6
6
|
// <dir>/out/<package dir>/<output>/ every closure package's outputs (default: dist);
|
|
7
7
|
// `--also <pkg>` adds that package's closure (no member checks)
|
|
8
|
-
// <dir>/out/<
|
|
8
|
+
// <dir>/out/<pkg dir>/node_modules/.prisma/ the generated Prisma clients of every closure root that has
|
|
9
|
+
// `prisma/schema.prisma` (members and `--also` roots alike)
|
|
9
10
|
// <dir>/tool/ this package (package.json + src, no tests):
|
|
10
11
|
// bind-mounted into the build, never copied into a layer
|
|
11
12
|
//
|
|
@@ -124,12 +125,25 @@ export async function stageBuildOutputs({
|
|
|
124
125
|
const plan = []; // { source, destination, files }
|
|
125
126
|
for (const directory of closure) {
|
|
126
127
|
const sources = outputs.map((output) => posix.join(directory, output));
|
|
127
|
-
|
|
128
|
+
// Members always (their checks above name a missing client); any other closure root when it carries a schema.
|
|
129
|
+
if (memberDirectories.has(directory) || existsSync(join(root, directory, 'prisma', 'schema.prisma'))) {
|
|
130
|
+
sources.push(posix.join(directory, 'node_modules', '.prisma'));
|
|
131
|
+
}
|
|
128
132
|
for (const rel of sources) {
|
|
129
133
|
const source = join(root, rel);
|
|
130
|
-
if (!existsSync(source))
|
|
134
|
+
if (!existsSync(source)) {
|
|
135
|
+
if (rel.endsWith('/node_modules/.prisma') && !memberDirectories.has(directory)) {
|
|
136
|
+
problems.push(`${directory} has prisma/schema.prisma but no generated client: run prisma generate`);
|
|
137
|
+
}
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
131
140
|
const files = listFiles(source);
|
|
132
141
|
for (const file of files) if (file.endsWith('.node')) problems.push(`${rel}/${file}: a native addon cannot be staged (the image installs native code itself, per arch)`);
|
|
142
|
+
if (rel.endsWith('/node_modules/.prisma')) {
|
|
143
|
+
for (const file of files) {
|
|
144
|
+
if (/^libquery_engine/.test(posix.basename(file))) problems.push(`${rel}/${file}: a query engine cannot be staged (the Prisma client must be pure JS/wasm)`);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
133
147
|
plan.push({ source, destination: join(target, 'out', rel), files });
|
|
134
148
|
}
|
|
135
149
|
}
|
|
@@ -151,3 +151,25 @@ test('filters and the argument parser carry --also', async () => {
|
|
|
151
151
|
assert.equal(memberFilters({ root, also: ['@t/alpha-api'] }).filter((f) => f === '--filter @t/alpha-api...').length, 1);
|
|
152
152
|
assert.deepEqual(parseArguments(['stage', 'd', '--also', 'a', '--also', 'b']).options.also, ['a', 'b']);
|
|
153
153
|
});
|
|
154
|
+
|
|
155
|
+
test('--also stages node_modules/.prisma of an extra closure root that has a schema, and refuses a query engine in it', async () => {
|
|
156
|
+
const root = withExtra();
|
|
157
|
+
write(root, 'packages/sdks/extra/prisma/schema.prisma', 'datasource db {}\n');
|
|
158
|
+
write(root, 'packages/sdks/extra/node_modules/.prisma/client/index.js', '// client\n');
|
|
159
|
+
const result = await stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra'] });
|
|
160
|
+
assert.ok(result.outputs >= 5);
|
|
161
|
+
assert.ok(files(path.join(root, '.xema-stage/out')).includes('packages/sdks/extra/node_modules/.prisma/client/index.js'));
|
|
162
|
+
fs.rmSync(path.join(root, '.xema-stage'), { recursive: true });
|
|
163
|
+
write(root, 'packages/sdks/extra/node_modules/.prisma/client/libquery_engine-linux-arm64-openssl-3.0.x.so', 'x');
|
|
164
|
+
await assert.rejects(stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra'] }), /extra\/node_modules\/\.prisma\/client\/libquery_engine.*pure JS\/wasm/);
|
|
165
|
+
fs.rmSync(path.join(root, 'packages/sdks/extra/node_modules/.prisma/client/libquery_engine-linux-arm64-openssl-3.0.x.so'));
|
|
166
|
+
write(root, 'packages/sdks/extra/node_modules/.prisma/client/query.node', 'x');
|
|
167
|
+
await assert.rejects(stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra'] }), /extra\/node_modules\/\.prisma\/client\/query\.node/);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('--also refuses a non-member closure root with prisma/schema.prisma but no generated client', async () => {
|
|
171
|
+
const root = withExtra();
|
|
172
|
+
write(root, 'packages/sdks/extra/prisma/schema.prisma', 'datasource db {}\n');
|
|
173
|
+
await assert.rejects(stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra'] }), /packages\/sdks\/extra has prisma\/schema\.prisma but no generated client: run prisma generate/);
|
|
174
|
+
assert.equal(fs.existsSync(path.join(root, '.xema-stage')), false);
|
|
175
|
+
});
|
package/src/composed-image.mjs
CHANGED
|
@@ -44,6 +44,11 @@
|
|
|
44
44
|
// generated Prisma clients into a build-step directory, with this tool,
|
|
45
45
|
// for the `--prebuilt` Dockerfile (`composed-image-stage.mjs`). Each `--also`
|
|
46
46
|
// workspace package adds its closure's built outputs (no Prisma checks).
|
|
47
|
+
// pnpm-context <dir> (--subject <svc>… | --image-dir <composed>) [--copy <dir>]…
|
|
48
|
+
// CI-runner only, after `pnpm install --frozen-lockfile`: build the offline `xema-pnpm`
|
|
49
|
+
// build context (corepack pnpm.tgz, warmed metadata cache, staged.json) for the
|
|
50
|
+
// prebuilt Dockerfiles (`composed-image-pnpm-context.mjs`). stdout is
|
|
51
|
+
// `XEMA_PNPM_CONTEXT=<dir>` and `XEMA_PNPM_STORE=<store root>` for `$GITHUB_ENV`.
|
|
47
52
|
// sync-injected | prisma-check
|
|
48
53
|
// IN-IMAGE proofs of the prebuilt build: the injected workspace copies hold
|
|
49
54
|
// their packages' `files`; every Prisma client runs on the target arch.
|
|
@@ -101,6 +106,7 @@ import { syncComposedDockerfile } from './composed-image-dockerfile.mjs';
|
|
|
101
106
|
import { IMAGE_BUILD_INPUTS_FILE, memberClosure, memberFilters, readMembers } from './composed-image-members.mjs';
|
|
102
107
|
import { syncPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
|
|
103
108
|
import { stageBuildOutputs } from './composed-image-stage.mjs';
|
|
109
|
+
import { buildPnpmContext } from './composed-image-pnpm-context.mjs';
|
|
104
110
|
import { syncInjected } from './composed-image-sync-injected.mjs';
|
|
105
111
|
import { prismaCheck } from './composed-image-prisma-check.mjs';
|
|
106
112
|
import { runMigrate } from './composed-migrate.mjs';
|
|
@@ -313,6 +319,7 @@ const USAGE =
|
|
|
313
319
|
'usage: xema-composed-image <command> [--root <dir>] [--image-dir <dir>]\n' +
|
|
314
320
|
' members --write|--check [--manifests <glob>]… [--canonical-dockerfile <path>] [--cross-repo-manifest <path>]\n' +
|
|
315
321
|
' filters [--also <pkg>]… | dirs | pins <committed-lockfile> | index <commit> --build-source <repo> | load [--each]\n' +
|
|
322
|
+
' pnpm-context <dir> (--subject <svc>… | --image-dir <composed>) [--copy <dir>]…\n' +
|
|
316
323
|
' stage <dir> [--output <rel>]… [--also <pkg>]… | sync-injected | prisma-check | migrate <member> [--root <dir>]\n' +
|
|
317
324
|
' coordinate --image-name <name> (env: SHA REGISTRY REGISTRY_ORG [TAG_ARGS_FILE])\n' +
|
|
318
325
|
' load-image <image>\n' +
|
|
@@ -320,10 +327,10 @@ const USAGE =
|
|
|
320
327
|
' dockerfile --write|--check --copy <dir>… --cache-id <id> --build-source <repo>\n' +
|
|
321
328
|
' dockerfile --prebuilt --write|--check --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]';
|
|
322
329
|
|
|
323
|
-
const REPEATABLE = new Set(['manifests', 'copy', 'member', 'output', 'also']);
|
|
330
|
+
const REPEATABLE = new Set(['manifests', 'copy', 'member', 'output', 'also', 'subject']);
|
|
324
331
|
const BOOLEAN = new Set(['prebuilt', 'each']);
|
|
325
332
|
const VALUED = new Set(['root', 'image-dir', 'manifests', 'canonical-dockerfile', 'cross-repo-manifest', 'build-source',
|
|
326
|
-
'image-name', 'cache-id', 'copy', 'member', 'stage-dir', 'output', 'also',
|
|
333
|
+
'image-name', 'cache-id', 'copy', 'member', 'stage-dir', 'output', 'also', 'subject',
|
|
327
334
|
]);
|
|
328
335
|
|
|
329
336
|
export function parseArguments(argv) {
|
|
@@ -403,6 +410,18 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
|
|
|
403
410
|
log(`composed stage ${argument}: ${staged.packages} package(s), ${staged.outputs} output dir(s), ${staged.files} file(s), tool ${staged.toolFiles} file(s)`);
|
|
404
411
|
});
|
|
405
412
|
}
|
|
413
|
+
case 'pnpm-context': {
|
|
414
|
+
const result = buildPnpmContext({
|
|
415
|
+
root,
|
|
416
|
+
dir: argument,
|
|
417
|
+
subjects: options.subject,
|
|
418
|
+
imageDir: options.subject === undefined ? options['image-dir'] : undefined,
|
|
419
|
+
copy: options.copy,
|
|
420
|
+
log: (message) => process.stderr.write(`${message}\n`),
|
|
421
|
+
});
|
|
422
|
+
write(`XEMA_PNPM_CONTEXT=${result.context}\nXEMA_PNPM_STORE=${result.store}\n`);
|
|
423
|
+
return;
|
|
424
|
+
}
|
|
406
425
|
case 'sync-injected':
|
|
407
426
|
syncInjected({ root, imageDir, log });
|
|
408
427
|
return;
|