@xemahq/repo-build-tooling 0.15.0 → 0.16.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 +1 -1
- package/package.json +2 -2
- package/src/composed-image-pnpm-context.mjs +30 -27
- package/src/composed-image-pnpm-context.test.mjs +25 -14
- package/src/composed-image-prebuilt-dockerfile.mjs +110 -19
- package/src/composed-image-prebuilt-dockerfile.test.mjs +65 -4
- package/src/composed-image.mjs +2 -3
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 `--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.
|
|
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`, and, with `--image-dir` only, a warmed `cache/` of registry metadata; `--subject` writes no `cache/` and `staged.json` says `cache: false`); it prints `XEMA_PNPM_CONTEXT=<dir>` and `XEMA_PNPM_STORE=<store root>` on stdout (everything else on stderr) and refuses a non-empty `<dir>`. `dockerfile --prebuilt` renders an OFFLINE image: every install RUN is `--network=none` and reads the runner's pnpm store and the `pnpm-context` output as the named build contexts `xema-pnpm-store` and `xema-pnpm`; without them the build fails with a message (no network fallback). `pnpm rebuild` runs only for `onlyBuiltDependencies` in the members' prod closure (less prisma, @prisma/client, @prisma/engines, @nestjs/core, esbuild, @swc/core) and the render needs `pnpm install` first; a node-gyp module with no optional prebuilt packages is refused. `pnpm-context --image-dir` reads the deps stage's COPY directories from the committed Dockerfile. 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.16.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)",
|
|
@@ -44,6 +44,6 @@
|
|
|
44
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:679fdd3bb57a17ae5498cff954e255f49308d3babaed4c89fbb37a0ecc126db2"
|
|
48
48
|
}
|
|
49
49
|
}
|
|
@@ -3,26 +3,36 @@
|
|
|
3
3
|
// offline prebuilt image build consumes as the named BuildKit context `xema-pnpm`. The runner's
|
|
4
4
|
// own pnpm store is the second context (`xema-pnpm-store`); it is never copied.
|
|
5
5
|
//
|
|
6
|
-
// <dir>/staged.json { tool, pnpm, storeDir, subjects[], imageDir? } — the sentinel the image checks
|
|
6
|
+
// <dir>/staged.json { tool, pnpm, storeDir, subjects[], cache, imageDir? } — the sentinel the image checks
|
|
7
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
|
|
8
|
+
// <dir>/cache/ --image-dir only: pnpm `cache-dir` (registry metadata), seeded from the runner's and warmed
|
|
9
|
+
// (--subject writes no cache/: the member deploy is lockfile-driven and offline)
|
|
9
10
|
//
|
|
10
11
|
// stdout is exactly two lines for `$GITHUB_ENV`: `XEMA_PNPM_CONTEXT=<dir>` and
|
|
11
12
|
// `XEMA_PNPM_STORE=<store root>`. Everything else goes to stderr. pnpm's own request-level
|
|
12
13
|
// fetch retries apply; nothing here retries a command.
|
|
13
14
|
import { execFileSync } from 'node:child_process';
|
|
14
15
|
import { cpSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
15
|
-
import { tmpdir } from 'node:os';
|
|
16
|
+
import { homedir, tmpdir } from 'node:os';
|
|
16
17
|
import { dirname, join, resolve } from 'node:path';
|
|
17
18
|
|
|
18
19
|
import { memberClosure, memberFilters } from './composed-image-members.mjs';
|
|
19
|
-
import { lockfileOnlyCommand } from './composed-image-prebuilt-dockerfile.mjs';
|
|
20
|
+
import { depsCopyDirs, lockfileOnlyCommand } from './composed-image-prebuilt-dockerfile.mjs';
|
|
20
21
|
import { toolVersion } from './composed-image-stage.mjs';
|
|
21
22
|
|
|
22
23
|
/** Runs a command, returns stdout; stderr is the caller's. Injectable for tests. */
|
|
23
24
|
const defaultExec = (file, args, { cwd } = {}) =>
|
|
24
25
|
execFileSync(file, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], maxBuffer: 64 * 1024 * 1024 });
|
|
25
26
|
|
|
27
|
+
/** `pnpm config get cache-dir` prints `undefined` when unset; then pnpm's own default applies. */
|
|
28
|
+
export function resolveCacheDir(printed, { platform = process.platform, env = process.env, home = homedir() } = {}) {
|
|
29
|
+
const value = printed.trim();
|
|
30
|
+
if (value !== '' && value !== 'undefined') return value;
|
|
31
|
+
if (platform === 'darwin') return join(home, 'Library', 'Caches', 'pnpm');
|
|
32
|
+
if (platform === 'win32') return join(env.LOCALAPPDATA ?? join(home, 'AppData', 'Local'), 'pnpm-cache');
|
|
33
|
+
return join(env.XDG_CACHE_HOME ?? join(home, '.cache'), 'pnpm');
|
|
34
|
+
}
|
|
35
|
+
|
|
26
36
|
/** `pnpm@10.33.0+sha512.…` → `10.33.0`. */
|
|
27
37
|
function pnpmVersion(root) {
|
|
28
38
|
const manager = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).packageManager;
|
|
@@ -49,7 +59,11 @@ function seedCache(exec, source, target, log) {
|
|
|
49
59
|
}
|
|
50
60
|
|
|
51
61
|
/** A scratch tree holding exactly the deps stage's inputs, so `--lockfile-only` never touches the repository. */
|
|
52
|
-
function scratchLockfileInputs({ root, imageDir, exec
|
|
62
|
+
function scratchLockfileInputs({ root, imageDir, exec }) {
|
|
63
|
+
// The directories the deps stage COPYs are read back from the committed Dockerfile (`depsCopyDirs`), so there is one definition.
|
|
64
|
+
const dockerfile = join(root, imageDir, 'Dockerfile');
|
|
65
|
+
if (!existsSync(dockerfile)) throw new Error(`${imageDir}/Dockerfile is absent — render it first: xema-composed-image dockerfile --prebuilt --write`);
|
|
66
|
+
const copy = depsCopyDirs(readFileSync(dockerfile, 'utf8'));
|
|
53
67
|
const scratch = mkdtempSync(join(tmpdir(), 'xema-pnpm-lock-'));
|
|
54
68
|
const put = (rel) => {
|
|
55
69
|
if (!existsSync(join(root, rel))) return;
|
|
@@ -67,7 +81,6 @@ export function buildPnpmContext({
|
|
|
67
81
|
dir,
|
|
68
82
|
subjects = [],
|
|
69
83
|
imageDir,
|
|
70
|
-
copy = [],
|
|
71
84
|
exec = defaultExec,
|
|
72
85
|
log = () => {},
|
|
73
86
|
}) {
|
|
@@ -83,37 +96,27 @@ export function buildPnpmContext({
|
|
|
83
96
|
if (storePath === '' || !existsSync(storePath)) {
|
|
84
97
|
throw new Error(`the runner's pnpm store ${storePath || '(unknown)'} does not exist — run pnpm install --frozen-lockfile before pnpm-context`);
|
|
85
98
|
}
|
|
86
|
-
const cacheSource = exec('pnpm', ['config', 'get', 'cache-dir'], { cwd: root }).trim();
|
|
87
|
-
|
|
88
99
|
mkdirSync(join(target, 'corepack'), { recursive: true });
|
|
89
100
|
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
101
|
|
|
93
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
}
|
|
102
|
-
} finally {
|
|
103
|
-
rmSync(tmp, { recursive: true, force: true });
|
|
104
|
-
}
|
|
105
|
-
} else {
|
|
106
|
-
const scratch = scratchLockfileInputs({ root, imageDir, exec, copy });
|
|
102
|
+
// --subject: the member image runs the lockfile-driven `pnpm deploy --offline` from the store alone, so no
|
|
103
|
+
// metadata cache is seeded or warmed. --image-dir: its `--lockfile-only` resolve needs registry metadata, warmed here.
|
|
104
|
+
const withCache = subjects.length === 0;
|
|
105
|
+
if (withCache) {
|
|
106
|
+
const cacheSource = resolveCacheDir(exec('pnpm', ['config', 'get', 'cache-dir'], { cwd: root }));
|
|
107
|
+
const cache = join(target, 'cache');
|
|
108
|
+
log(`pnpm-context: cache ${seedCache(exec, cacheSource, cache, log)} from ${cacheSource}`);
|
|
109
|
+
const scratch = scratchLockfileInputs({ root, imageDir, exec });
|
|
107
110
|
try {
|
|
108
|
-
const [file, ...args] = lockfileOnlyCommand(memberFilters({ root, imageDir }).flatMap((f) => f.split(' ')));
|
|
109
|
-
exec(file, [...args,
|
|
111
|
+
const [file, ...args] = lockfileOnlyCommand(memberFilters({ root, imageDir }).flatMap((f) => f.split(' ')), { offline: false });
|
|
112
|
+
exec(file, [...args, `--config.cache-dir=${cache}`], { cwd: scratch });
|
|
110
113
|
log(`pnpm-context: warmed the ${imageDir} lockfile-only resolve`);
|
|
111
114
|
} finally {
|
|
112
115
|
rmSync(scratch, { recursive: true, force: true });
|
|
113
116
|
}
|
|
114
117
|
}
|
|
115
118
|
|
|
116
|
-
const sentinel = { tool: toolVersion(), pnpm, storeDir: storeRoot, subjects: [...subjects], ...(imageDir === undefined ? {} : { imageDir }) };
|
|
119
|
+
const sentinel = { tool: toolVersion(), pnpm, storeDir: storeRoot, subjects: [...subjects], cache: withCache, ...(imageDir === undefined ? {} : { imageDir }) };
|
|
117
120
|
writeFileSync(join(target, 'staged.json'), `${JSON.stringify(sentinel, null, 2)}\n`);
|
|
118
121
|
return { context: target, store: storeRoot };
|
|
119
122
|
}
|
|
@@ -4,7 +4,7 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
|
|
7
|
-
import { buildPnpmContext } from './composed-image-pnpm-context.mjs';
|
|
7
|
+
import { buildPnpmContext, resolveCacheDir } from './composed-image-pnpm-context.mjs';
|
|
8
8
|
import { lockfileOnlyCommand, renderPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
|
|
9
9
|
import { CLOSURE, fakePnpm, workspace, write } from './composed-image-fixture.test.mjs';
|
|
10
10
|
|
|
@@ -12,6 +12,8 @@ function setup({ store = true, cache = true } = {}) {
|
|
|
12
12
|
const root = workspace();
|
|
13
13
|
write(root, 'package.json', { name: 'r', packageManager: 'pnpm@10.33.0+sha512.abc' });
|
|
14
14
|
write(root, 'pnpm-lock.yaml', 'lockfileVersion: 9\n');
|
|
15
|
+
write(root, 'tooling/dev/x.txt', 'x');
|
|
16
|
+
write(root, 'infra/composed-process/Dockerfile', renderPrebuiltDockerfile({ root, copy: ['tooling/dev'], cacheId: 'x', buildSource: 'repo', usesToken: false, exec: fakePnpm(root) }).dockerfile);
|
|
15
17
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-pnpm-home-'));
|
|
16
18
|
const storePath = path.join(home, '.pnpm-store', 'v10');
|
|
17
19
|
const cacheDir = path.join(home, 'cache');
|
|
@@ -38,23 +40,20 @@ function setup({ store = true, cache = true } = {}) {
|
|
|
38
40
|
return { root, out, exec, calls, storePath, home };
|
|
39
41
|
}
|
|
40
42
|
|
|
41
|
-
test('--subject: writes corepack tgz
|
|
43
|
+
test('--subject: writes the corepack tgz and the sentinel, no cache dir and no deploy warm-up; returns the two env values', () => {
|
|
42
44
|
const { root, out, exec, calls, home } = setup();
|
|
43
45
|
const result = buildPnpmContext({ root, dir: out, subjects: ['svc-a', 'svc-b'], exec });
|
|
44
46
|
assert.deepEqual(result, { context: out, store: path.join(home, '.pnpm-store') });
|
|
45
47
|
assert.equal(fs.readFileSync(path.join(out, 'corepack/pnpm.tgz'), 'utf8'), 'tgz');
|
|
46
|
-
assert.
|
|
48
|
+
assert.equal(fs.existsSync(path.join(out, 'cache')), false);
|
|
47
49
|
const staged = JSON.parse(fs.readFileSync(path.join(out, 'staged.json'), 'utf8'));
|
|
48
|
-
assert.deepEqual(Object.keys(staged), ['tool', 'pnpm', 'storeDir', 'subjects']);
|
|
50
|
+
assert.deepEqual(Object.keys(staged), ['tool', 'pnpm', 'storeDir', 'subjects', 'cache']);
|
|
49
51
|
assert.equal(staged.pnpm, '10.33.0');
|
|
52
|
+
assert.equal(staged.cache, false);
|
|
50
53
|
assert.deepEqual(staged.subjects, ['svc-a', 'svc-b']);
|
|
51
54
|
assert.deepEqual(calls.find((c) => c.file === 'corepack').args.slice(0, 2), ['pack', 'pnpm@10.33.0']);
|
|
52
|
-
|
|
53
|
-
assert.
|
|
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
|
-
}
|
|
55
|
+
assert.equal(calls.filter((c) => c.args[0] === 'deploy' || c.args.includes('--lockfile-only')).length, 0, 'no warm-up');
|
|
56
|
+
assert.ok(!calls.some((c) => c.args[0] === 'config'));
|
|
58
57
|
});
|
|
59
58
|
|
|
60
59
|
test('refuses a non-empty directory, a missing store, and a bad invocation, writing nothing', () => {
|
|
@@ -74,19 +73,24 @@ test('--image-dir: runs the generator\'s lockfile-only command on a scratch copy
|
|
|
74
73
|
const resolve = calls.find((c) => c.args.includes('--lockfile-only'));
|
|
75
74
|
assert.notEqual(resolve.cwd, root);
|
|
76
75
|
assert.ok(resolve.scratch.includes('pnpm-lock.yaml') && resolve.scratch.includes('package.json'));
|
|
76
|
+
assert.ok(resolve.scratch.includes('tooling'), 'the deps stage COPY directories come from the committed Dockerfile');
|
|
77
|
+
assert.ok(resolve.args.includes('--prefer-offline') && !resolve.args.includes('--offline'), 'the warm-up fetches; the image resolves --offline');
|
|
77
78
|
assert.ok(resolve.args.includes(`--config.cache-dir=${path.join(out, 'cache')}`));
|
|
78
79
|
assert.equal(fs.existsSync(resolve.cwd), false, 'the scratch tree is deleted');
|
|
79
80
|
assert.equal(fs.readFileSync(path.join(root, 'pnpm-lock.yaml'), 'utf8'), 'lockfileVersion: 9\n');
|
|
80
|
-
|
|
81
|
+
const staged = JSON.parse(fs.readFileSync(path.join(out, 'staged.json'), 'utf8'));
|
|
82
|
+
assert.equal(staged.imageDir, 'infra/composed-process');
|
|
83
|
+
assert.equal(staged.cache, true);
|
|
81
84
|
});
|
|
82
85
|
|
|
83
86
|
test('the lockfile-only command is the one the generated Dockerfile runs (no drift)', () => {
|
|
84
87
|
const root = workspace();
|
|
85
88
|
const words = lockfileOnlyCommand(['--filter', '@t/alpha-api...']);
|
|
86
|
-
assert.deepEqual(words.slice(0, 4), ['pnpm', 'install', '--lockfile-only', '--
|
|
89
|
+
assert.deepEqual(words.slice(0, 4), ['pnpm', 'install', '--lockfile-only', '--offline']);
|
|
90
|
+
assert.equal(lockfileOnlyCommand(['--filter', 'x'], { offline: false })[3], '--prefer-offline');
|
|
87
91
|
assert.ok(words.includes('--config.inject-workspace-packages=true') && words.includes('--config.dedupe-injected-deps=false'));
|
|
88
92
|
const { dockerfile: text } = renderPrebuiltDockerfile({ root, cacheId: 'x', buildSource: 'repo', usesToken: false, exec: fakePnpm(root) });
|
|
89
|
-
assert.ok(text.includes('pnpm install --lockfile-only --
|
|
93
|
+
assert.ok(text.includes('pnpm install --lockfile-only --offline ${INJECT} --filter @t/alpha-api... --filter @t/alpha-db-api... &&'));
|
|
90
94
|
assert.ok(text.includes('INJECT="--config.inject-workspace-packages=true --config.dedupe-injected-deps=false"'));
|
|
91
95
|
});
|
|
92
96
|
|
|
@@ -97,8 +101,15 @@ test('a failed cp -al falls back to a copy and logs why', () => {
|
|
|
97
101
|
if (file === 'cp') throw new Error('cross-device link');
|
|
98
102
|
return exec(file, args, options);
|
|
99
103
|
};
|
|
100
|
-
buildPnpmContext({ root, dir: out,
|
|
104
|
+
buildPnpmContext({ root, dir: out, imageDir: 'infra/composed-process', exec: failing, log: (m) => logs.push(m) });
|
|
101
105
|
assert.ok(logs.some((m) => /cp -al failed \(cross-device link\)/.test(m)));
|
|
102
106
|
assert.ok(fs.existsSync(path.join(out, 'cache/metadata-v1.3/registry.npmjs.org/x.json')));
|
|
103
107
|
assert.ok(home);
|
|
104
108
|
});
|
|
109
|
+
|
|
110
|
+
test('an unset cache-dir ("undefined") resolves to the pnpm default per platform', () => {
|
|
111
|
+
assert.equal(resolveCacheDir('/x/c\n'), '/x/c');
|
|
112
|
+
assert.equal(resolveCacheDir('undefined\n', { platform: 'darwin', home: '/h' }), '/h/Library/Caches/pnpm');
|
|
113
|
+
assert.equal(resolveCacheDir('undefined', { platform: 'linux', env: {}, home: '/h' }), '/h/.cache/pnpm');
|
|
114
|
+
assert.equal(resolveCacheDir('', { platform: 'linux', env: { XDG_CACHE_HOME: '/xdg' }, home: '/h' }), '/xdg/pnpm');
|
|
115
|
+
});
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
// is ASSEMBLED from build outputs made on the CI runner (`xema-composed-image stage`).
|
|
4
4
|
// Docker compiles nothing. The full-build template (`composed-image-dockerfile.mjs`)
|
|
5
5
|
// stays available for repositories that still build inside Docker.
|
|
6
|
-
import {
|
|
6
|
+
import { execFileSync } from 'node:child_process';
|
|
7
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
7
8
|
import { dirname, join, posix } from 'node:path';
|
|
8
9
|
|
|
9
10
|
import { COMPOSED_IMAGE_DIR, IN_IMAGE_ENTRY } from './composed-image-paths.mjs';
|
|
@@ -68,12 +69,75 @@ export const injectFlags = () => ['--config.inject-workspace-packages=true', '--
|
|
|
68
69
|
|
|
69
70
|
/**
|
|
70
71
|
* The deps stage's `--lockfile-only` resolve, as words (`INJECT_TOKEN` stands for `injectFlags()`).
|
|
71
|
-
* The
|
|
72
|
-
*
|
|
72
|
+
* The image runs it `--offline` against the metadata cache `pnpm-context --image-dir` warmed; the warm-up
|
|
73
|
+
* runs the SAME words with `--prefer-offline` (`offline: false`). Both read THIS function, so the metadata
|
|
74
|
+
* the context holds is exactly what the image resolves.
|
|
73
75
|
*/
|
|
74
|
-
export const lockfileOnlyWords = (filters
|
|
75
|
-
|
|
76
|
-
|
|
76
|
+
export const lockfileOnlyWords = (filters, { offline = true } = {}) =>
|
|
77
|
+
['pnpm', 'install', '--lockfile-only', offline ? '--offline' : '--prefer-offline', INJECT_TOKEN, ...filters];
|
|
78
|
+
/** The same command with the inject words expanded: `[file, ...args]`. `offline: false` is the cache warm-up form. */
|
|
79
|
+
export const lockfileOnlyCommand = (filters, options) => lockfileOnlyWords(filters, options).flatMap((w) => (w === INJECT_TOKEN ? injectFlags() : [w]));
|
|
80
|
+
|
|
81
|
+
/** The line that brings one `--copy` directory into the deps stage; `depsCopyDirs` is its inverse (pnpm-context reads the directories back from the committed Dockerfile). */
|
|
82
|
+
const copyLine = (directory) => `COPY ${directory} ./${directory}`;
|
|
83
|
+
export function depsCopyDirs(dockerfileText) {
|
|
84
|
+
const deps = /^FROM base AS deps\n([\s\S]*?)^COPY --parents/m.exec(dockerfileText);
|
|
85
|
+
if (deps === null) throw new Error('the Dockerfile has no deps stage with COPY --parents — render it: xema-composed-image dockerfile --prebuilt --write');
|
|
86
|
+
return [...deps[1].matchAll(/^COPY (\S+\/) \.\/\1$/gm)].map((m) => m[1]);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Build-script dependencies the image never runs: Prisma 7 with the pg adapter ships no engine (migrations run in-app), the rest are JS or fetch nothing the image needs. */
|
|
90
|
+
export const REBUILD_EXCLUDED = Object.freeze(['prisma', '@prisma/client', '@prisma/engines', '@nestjs/core', 'esbuild', '@swc/core']);
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The `onlyBuiltDependencies` the image must still `pnpm rebuild`: those in the members' PROD closure
|
|
94
|
+
* (`prodPackages`: `[{ name, dir }]`) less `REBUILD_EXCLUDED`. A module that needs `node-gyp` (a `binding.gyp`
|
|
95
|
+
* and no optional prebuilt packages) cannot be built here (the install is offline, `--ignore-scripts`, and the base
|
|
96
|
+
* has no toolchain), so it is refused: build it on a runner of the target arch and stage the `.node` files.
|
|
97
|
+
*/
|
|
98
|
+
export function rebuildList({ built, prodPackages }) {
|
|
99
|
+
const present = new Map(prodPackages.map((p) => [p.name, p.dir]));
|
|
100
|
+
const names = built.filter((name) => !REBUILD_EXCLUDED.includes(name) && present.has(name));
|
|
101
|
+
for (const name of names) {
|
|
102
|
+
const dir = present.get(name);
|
|
103
|
+
let optional = 0;
|
|
104
|
+
try {
|
|
105
|
+
optional = Object.keys(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).optionalDependencies ?? {}).length;
|
|
106
|
+
} catch {
|
|
107
|
+
/* unreadable manifest: judged by binding.gyp alone */
|
|
108
|
+
}
|
|
109
|
+
if (existsSync(join(dir, 'binding.gyp')) && optional === 0) {
|
|
110
|
+
throw new Error(
|
|
111
|
+
`${name} needs node-gyp (binding.gyp, no optional prebuilt packages) and no native artifact is staged for it: ` +
|
|
112
|
+
'the prebuilt composed image installs offline with --ignore-scripts and has no toolchain. ' +
|
|
113
|
+
'Build it on a runner of each target arch (the arm runner for arm64) and stage the .node files; see the arm64 native-artifact note in composed-image-prebuilt-dockerfile.mjs. Refusing to render.',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return names;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const defaultExec = (file, args, { cwd }) => execFileSync(file, args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
121
|
+
|
|
122
|
+
/** `[{ name, dir }]` for every installed package in the members' prod closure (`pnpm ls --prod --parseable`); needs `pnpm install` to have run. */
|
|
123
|
+
export function memberProdPackages({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, exec = defaultExec } = {}) {
|
|
124
|
+
const words = memberFilters({ root, imageDir }).flatMap((f) => f.split(' '));
|
|
125
|
+
const listing = exec('pnpm', ['ls', '-r', '--prod', '--depth', 'Infinity', '--parseable', ...words], { cwd: root });
|
|
126
|
+
const seen = new Map();
|
|
127
|
+
for (const line of listing.split('\n').map((l) => l.trim()).filter(Boolean)) {
|
|
128
|
+
if (!line.includes('node_modules')) continue; // workspace packages themselves
|
|
129
|
+
let dir;
|
|
130
|
+
try {
|
|
131
|
+
dir = realpathSync(line);
|
|
132
|
+
const name = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name;
|
|
133
|
+
if (typeof name === 'string') seen.set(name, dir);
|
|
134
|
+
} catch {
|
|
135
|
+
/* not an installed package directory */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
if (seen.size === 0) throw new Error('pnpm ls --prod listed no installed package: run pnpm install --frozen-lockfile before rendering the prebuilt Dockerfile');
|
|
139
|
+
return [...seen].map(([name, dir]) => ({ name, dir }));
|
|
140
|
+
}
|
|
77
141
|
|
|
78
142
|
const parts = (sources) => sources.map((source) => ` ${source} \\`).join('\n');
|
|
79
143
|
|
|
@@ -87,6 +151,7 @@ export function renderPrebuiltDockerfile({
|
|
|
87
151
|
usesToken,
|
|
88
152
|
stageDir = DEFAULT_STAGE_DIR,
|
|
89
153
|
exec,
|
|
154
|
+
prodPackages = [],
|
|
90
155
|
}) {
|
|
91
156
|
if (typeof usesToken !== 'boolean') throw new Error('usesToken must be a boolean derived from .npmrc');
|
|
92
157
|
validate({ copy, cacheId, buildSource, stageDir });
|
|
@@ -97,19 +162,20 @@ export function renderPrebuiltDockerfile({
|
|
|
97
162
|
throw new Error('pnpm-workspace.yaml has no onlyBuiltDependencies: — `pnpm rebuild` would have nothing to build; refusing to render');
|
|
98
163
|
}
|
|
99
164
|
const closure = memberClosure({ root, imageDir, exec });
|
|
165
|
+
const rebuild = rebuildList({ built, prodPackages });
|
|
100
166
|
const filters = memberFilters({ root, imageDir }).join(' ');
|
|
101
167
|
const manifests = memberManifests({ root, imageDir });
|
|
102
168
|
const dirFlag = imageDir === COMPOSED_IMAGE_DIR ? '' : ` --image-dir ${imageDir}`;
|
|
103
169
|
const toolDir = '/opt/xema-composed-image';
|
|
104
170
|
const toolMount = `--mount=type=bind,source=${stageDir}/tool,target=${toolDir}`;
|
|
105
171
|
const node = `${toolDir}/${IN_IMAGE_ENTRY}`;
|
|
106
|
-
const store =
|
|
107
|
-
const metadata =
|
|
172
|
+
const store = '--mount=type=bind,from=xema-pnpm-store,target=/root/.pnpm-store,rw';
|
|
173
|
+
const metadata = '--mount=type=bind,from=xema-pnpm,source=cache,target=/root/.cache/pnpm,rw';
|
|
108
174
|
const secret = usesToken ? ' --mount=type=secret,id=npm_token,required=true \\\n' : '';
|
|
109
175
|
const tokenExport = usesToken
|
|
110
176
|
? 'export NPM_TOKEN="$(cat /run/secrets/npm_token)" && export GH_PACKAGES_TOKEN="$NPM_TOKEN" && \\\n '
|
|
111
177
|
: '';
|
|
112
|
-
const copies = [...new Set(copy.map((d) => posix.normalize(`${d}/`)))].map(
|
|
178
|
+
const copies = [...new Set(copy.map((d) => posix.normalize(`${d}/`)))].map(copyLine).join('\n');
|
|
113
179
|
const manifestSources = closure.map((directory) => `${directory}/package.json`);
|
|
114
180
|
const assembleSources = [...closure, ...manifests];
|
|
115
181
|
const imageCopy = posix.normalize(`${imageDir}/`);
|
|
@@ -126,12 +192,16 @@ export function renderPrebuiltDockerfile({
|
|
|
126
192
|
#
|
|
127
193
|
# Stages:
|
|
128
194
|
# deps arch-native. Inputs are the lockfile and the closure's manifests ONLY, so the
|
|
129
|
-
# layer is cached until one of them changes.
|
|
130
|
-
#
|
|
131
|
-
#
|
|
132
|
-
#
|
|
133
|
-
#
|
|
134
|
-
#
|
|
195
|
+
# layer is cached until one of them changes. OFFLINE: every install RUN is
|
|
196
|
+
# \`--network=none\` and reads only the CI runner's pnpm store (\`xema-pnpm-store\`
|
|
197
|
+
# context), the packed pnpm and the warmed registry-metadata cache (\`xema-pnpm\`
|
|
198
|
+
# context, built by \`xema-composed-image pnpm-context --image-dir\`). corepack
|
|
199
|
+
# installs pnpm from that tarball, never from the network. Production install with
|
|
200
|
+
# the workspace packages INJECTED and \`--ignore-scripts\`. \`pnpm rebuild <names>\`
|
|
201
|
+
# runs only for \`onlyBuiltDependencies\` present in the members' PROD closure, less
|
|
202
|
+
# prisma, @prisma/client, @prisma/engines (Prisma 7 + pg adapter ships no engine and
|
|
203
|
+
# migrations run in-app), @nestjs/core, esbuild and @swc/core; when none is left the
|
|
204
|
+
# step is not rendered. The list is rendered, so a change makes \`--check\` stale.
|
|
135
205
|
# assemble source of the closure + the staged outputs, the injected copies synced to
|
|
136
206
|
# their \`files\`, then the proofs on the target arch: Prisma clients run, the
|
|
137
207
|
# member index is written, every member loads ALONE as \`node\`, production.
|
|
@@ -139,8 +209,18 @@ export function renderPrebuiltDockerfile({
|
|
|
139
209
|
# members' code re-pushes only the layers that changed (see the final stage).
|
|
140
210
|
# Nothing of the tool ships.
|
|
141
211
|
#
|
|
212
|
+
# NATIVE MODULES. No prod-closure module needs node-gyp today. The renderer REFUSES to render when
|
|
213
|
+
# one appears (a \`binding.gyp\` and no optional prebuilt packages) because this image installs with
|
|
214
|
+
# --ignore-scripts and carries no compiler. The path then: build the module on a runner of EACH target
|
|
215
|
+
# arch natively (the \`arm\` runner for arm64; no QEMU), in the digest-pinned bookworm node image with
|
|
216
|
+
# --network=none and the image's node headers, stage \`native/<arch>/<pkg>/*.node\`, and COPY them from
|
|
217
|
+
# \`native/\${TARGETARCH}/\` in the deps stage.
|
|
218
|
+
#
|
|
142
219
|
# The \`--filter <package>...\` words below are LITERAL: the content hash of this image reads
|
|
143
220
|
# them to narrow the lockfile to the members' closures.
|
|
221
|
+
FROM scratch AS xema-pnpm
|
|
222
|
+
FROM scratch AS xema-pnpm-store
|
|
223
|
+
|
|
144
224
|
FROM ${NODE_BASE} AS base
|
|
145
225
|
RUN corepack enable && \\
|
|
146
226
|
apt-get update && apt-get install -y --no-install-recommends openssl wget && rm -rf /var/lib/apt/lists/* && \\
|
|
@@ -149,19 +229,29 @@ RUN corepack enable && \\
|
|
|
149
229
|
FROM base AS deps
|
|
150
230
|
WORKDIR /monorepo
|
|
151
231
|
ENV CI=true
|
|
232
|
+
# corepack must not download pnpm: the packed tarball comes from the runner.
|
|
233
|
+
ENV COREPACK_ENABLE_NETWORK=0
|
|
234
|
+
# The runner's store is bind-mounted here. Set it for pnpm explicitly: the base layer's \`pnpm config set\` was
|
|
235
|
+
# written by whatever pnpm corepack fetched then, and an offline resolve falls back to the store index.
|
|
236
|
+
ENV npm_config_store_dir=/root/.pnpm-store
|
|
152
237
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
|
153
238
|
${copies === '' ? '' : `${copies}\n`}COPY --parents \\
|
|
154
239
|
${parts(manifestSources)}
|
|
155
240
|
./
|
|
156
|
-
RUN
|
|
241
|
+
RUN --network=none --mount=type=bind,from=xema-pnpm,target=/xema-pnpm \\
|
|
242
|
+
test -f /xema-pnpm/staged.json || { \\
|
|
243
|
+
echo "the prebuilt composed image needs --build-context xema-pnpm and xema-pnpm-store (CI only); render the full image (no --prebuilt) to build elsewhere" >&2; \\
|
|
244
|
+
exit 1; \\
|
|
245
|
+
} && corepack install -g /xema-pnpm/corepack/pnpm.tgz
|
|
246
|
+
RUN --network=none ${store} \\
|
|
157
247
|
${metadata} \\
|
|
158
248
|
${secret} ${toolMount} \\
|
|
159
249
|
${tokenExport}INJECT="${injectFlags().join(' ')}" && \\
|
|
160
250
|
cp pnpm-lock.yaml /tmp/pnpm-lock.committed.yaml && \\
|
|
161
251
|
${lockfileOnlyWords(filters.split(' ')).join(' ')} && \\
|
|
162
252
|
node ${node} pins${dirFlag} /tmp/pnpm-lock.committed.yaml && \\
|
|
163
|
-
pnpm install --prod --
|
|
164
|
-
pnpm rebuild ${
|
|
253
|
+
pnpm install --prod --offline --ignore-scripts \${INJECT} ${filters} --frozen-lockfile${rebuild.length === 0 ? '' : ` && \\
|
|
254
|
+
pnpm rebuild ${rebuild.join(' ')} ${filters}`}
|
|
165
255
|
|
|
166
256
|
FROM deps AS assemble
|
|
167
257
|
COPY --parents \\
|
|
@@ -212,7 +302,8 @@ export function syncPrebuiltDockerfile({ mode, root = process.cwd(), imageDir =
|
|
|
212
302
|
if (mode !== 'write' && mode !== 'check') throw new Error(`mode must be write or check, got "${mode}"`);
|
|
213
303
|
const npmrc = join(root, '.npmrc');
|
|
214
304
|
if (!existsSync(npmrc)) throw new Error(`${npmrc} is absent — the Dockerfile copies it and its token use decides the build secret`);
|
|
215
|
-
const
|
|
305
|
+
const prodPackages = options.prodPackages ?? memberProdPackages({ root, imageDir, exec });
|
|
306
|
+
const { dockerfile, dockerignore } = renderPrebuiltDockerfile({ ...options, root, imageDir, exec, prodPackages, usesToken: npmrcUsesToken(readFileSync(npmrc, 'utf8')) });
|
|
216
307
|
const files = [
|
|
217
308
|
[posix.join(imageDir, 'Dockerfile'), dockerfile],
|
|
218
309
|
[posix.join(imageDir, 'Dockerfile.dockerignore'), dockerignore],
|
|
@@ -3,12 +3,21 @@ import fs from 'node:fs';
|
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import test from 'node:test';
|
|
5
5
|
|
|
6
|
-
import { onlyBuiltDependencies, renderPrebuiltDockerfile, renderPrebuiltDockerignore, syncPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
|
|
6
|
+
import { depsCopyDirs, memberProdPackages, onlyBuiltDependencies, renderPrebuiltDockerfile, renderPrebuiltDockerignore, syncPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
|
|
7
7
|
import { parseArguments } from './composed-image.mjs';
|
|
8
8
|
import { CLOSURE, fakePnpm, workspace } from './composed-image-fixture.test.mjs';
|
|
9
9
|
|
|
10
|
+
/** An installed package directory for the prod-closure listing: `{ name, dir }`. */
|
|
11
|
+
function installed(root, name, { gyp = false, optional = {} } = {}) {
|
|
12
|
+
const dir = path.join(root, 'node_modules/.pnpm', name.replace('/', '+'), 'node_modules', name);
|
|
13
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
14
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name, optionalDependencies: optional }));
|
|
15
|
+
if (gyp) fs.writeFileSync(path.join(dir, 'binding.gyp'), '{}');
|
|
16
|
+
return { name, dir };
|
|
17
|
+
}
|
|
18
|
+
|
|
10
19
|
function options(root, extra = {}) {
|
|
11
|
-
return { root, copy: ['tooling/dev'], cacheId: 'alpha-composed-process', buildSource: 'alpha', usesToken: true, exec: fakePnpm(root), ...extra };
|
|
20
|
+
return { root, copy: ['tooling/dev'], cacheId: 'alpha-composed-process', buildSource: 'alpha', usesToken: true, exec: fakePnpm(root), prodPackages: [], ...extra };
|
|
12
21
|
}
|
|
13
22
|
|
|
14
23
|
test('the closure comes from pnpm ls with the members filters, and the filters are literal per member', () => {
|
|
@@ -18,9 +27,10 @@ test('the closure comes from pnpm ls with the members filters, and the filters a
|
|
|
18
27
|
assert.deepEqual(closure, CLOSURE);
|
|
19
28
|
assert.deepEqual(exec.calls[0].args, ['ls', '-r', '--depth', '-1', '--json', '--filter', '@t/alpha-api...', '--filter', '@t/alpha-db-api...']);
|
|
20
29
|
const filters = '--filter @t/alpha-api... --filter @t/alpha-db-api...';
|
|
21
|
-
for (const verb of ['pnpm install --lockfile-only --
|
|
30
|
+
for (const verb of ['pnpm install --lockfile-only --offline ${INJECT}', 'pnpm install --prod --offline --ignore-scripts ${INJECT}']) {
|
|
22
31
|
assert.ok(dockerfile.includes(`${verb} ${filters}`), verb);
|
|
23
32
|
}
|
|
33
|
+
assert.doesNotMatch(dockerfile, /^ {2}pnpm rebuild/m, 'every built dependency is excluded or absent from the prod closure');
|
|
24
34
|
assert.doesNotMatch(renderPrebuiltDockerfile(options(root, { usesToken: false })).dockerfile, /\$\(cat/);
|
|
25
35
|
});
|
|
26
36
|
|
|
@@ -91,7 +101,7 @@ test('the rebuild names come from onlyBuiltDependencies: comments skipped, empty
|
|
|
91
101
|
syncPrebuiltDockerfile({ mode: 'write', ...options(root) });
|
|
92
102
|
const file = path.join(root, 'pnpm-workspace.yaml');
|
|
93
103
|
fs.writeFileSync(file, fs.readFileSync(file, 'utf8').replace(" - 'esbuild'\n", " - 'esbuild'\n - sharp\n"));
|
|
94
|
-
assert.throws(() => syncPrebuiltDockerfile({ mode: 'check', ...options(root) }), /Dockerfile is stale/);
|
|
104
|
+
assert.throws(() => syncPrebuiltDockerfile({ mode: 'check', ...options(root, { prodPackages: [installed(root, 'sharp')] }) }), /Dockerfile is stale/);
|
|
95
105
|
fs.writeFileSync(file, 'packages: []\n');
|
|
96
106
|
assert.throws(() => renderPrebuiltDockerfile(options(root)), /no onlyBuiltDependencies/);
|
|
97
107
|
});
|
|
@@ -114,3 +124,54 @@ test('the final stage is layered: deps, then assemble without the .pnpm store, t
|
|
|
114
124
|
assert.ok(final.indexOf('ENV NODE_ENV=production') > final.lastIndexOf('COPY --from=assemble'));
|
|
115
125
|
assert.match(final, /USER node\nCMD \["node", "\/monorepo\/xema-composed-process"\]\n$/);
|
|
116
126
|
});
|
|
127
|
+
|
|
128
|
+
test('rebuild lists only built dependencies present in the prod closure, less the excluded; none left means no step', () => {
|
|
129
|
+
const root = workspace();
|
|
130
|
+
fs.appendFileSync(path.join(root, 'pnpm-workspace.yaml'), '');
|
|
131
|
+
const yaml = fs.readFileSync(path.join(root, 'pnpm-workspace.yaml'), 'utf8').replace(" - 'esbuild'\n", " - 'esbuild'\n - msgpackr-extract\n - not-in-closure\n - '@swc/core'\n");
|
|
132
|
+
fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'), yaml);
|
|
133
|
+
const prodPackages = ['prisma', '@prisma/engines', 'esbuild', '@swc/core', 'msgpackr-extract'].map((n) => installed(root, n, n === 'msgpackr-extract' ? { gyp: true, optional: { '@msgpackr-extract/linux-x64': '1' } } : {}));
|
|
134
|
+
const { dockerfile } = renderPrebuiltDockerfile(options(root, { prodPackages }));
|
|
135
|
+
assert.match(dockerfile, /--frozen-lockfile && \\\n {2}pnpm rebuild msgpackr-extract --filter @t\/alpha-api\.\.\. --filter @t\/alpha-db-api\.\.\.\n/);
|
|
136
|
+
assert.doesNotMatch(dockerfile, /^ {2}pnpm rebuild[^\n]*(prisma|esbuild|swc|engines|not-in-closure)/);
|
|
137
|
+
assert.doesNotMatch(renderPrebuiltDockerfile(options(root, { prodPackages: prodPackages.slice(0, 4) })).dockerfile, /^ {2}pnpm rebuild/m);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('the renderer refuses a node-gyp module with no optional prebuilt packages, naming it and the arm-runner path', () => {
|
|
141
|
+
const root = workspace();
|
|
142
|
+
fs.writeFileSync(path.join(root, 'pnpm-workspace.yaml'), "onlyBuiltDependencies:\n - node-pty\n - better-sqlite3\n");
|
|
143
|
+
const bare = [installed(root, 'node-pty', { gyp: true })];
|
|
144
|
+
assert.throws(() => renderPrebuiltDockerfile(options(root, { prodPackages: bare })), (e) => /node-pty needs node-gyp/.test(e.message) && /arm runner/.test(e.message) && /Refusing to render/.test(e.message));
|
|
145
|
+
assert.throws(() => syncPrebuiltDockerfile({ mode: 'write', ...options(root, { prodPackages: bare }) }), /node-pty needs node-gyp/);
|
|
146
|
+
assert.equal(fs.existsSync(path.join(root, 'infra/composed-process/Dockerfile')), false);
|
|
147
|
+
// The same package outside the prod closure, or with optional prebuilt packages, is fine.
|
|
148
|
+
assert.doesNotThrow(() => renderPrebuiltDockerfile(options(root, { prodPackages: [] })));
|
|
149
|
+
assert.doesNotThrow(() => renderPrebuiltDockerfile(options(root, { prodPackages: [installed(root, 'better-sqlite3', { gyp: true, optional: { prebuilt: '1' } })] })));
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test('the deps stage is offline: --network=none on every pnpm and corepack RUN, no cache mounts, packed pnpm, scratch contexts', () => {
|
|
153
|
+
const { dockerfile } = renderPrebuiltDockerfile(options(workspace()));
|
|
154
|
+
assert.match(dockerfile, /^FROM scratch AS xema-pnpm\nFROM scratch AS xema-pnpm-store\n/m);
|
|
155
|
+
const deps = dockerfile.split('FROM base AS deps')[1].split('FROM deps AS assemble')[0];
|
|
156
|
+
const runs = deps.split(/^RUN /m).slice(1);
|
|
157
|
+
assert.equal(runs.length, 2);
|
|
158
|
+
for (const run of runs) assert.match(run, /^--network=none /);
|
|
159
|
+
assert.doesNotMatch(dockerfile, /type=cache|--prefer-offline|npx /);
|
|
160
|
+
assert.match(deps, /ENV COREPACK_ENABLE_NETWORK=0/);
|
|
161
|
+
assert.match(deps, /^ENV npm_config_store_dir=\/root\/\.pnpm-store$/m);
|
|
162
|
+
assert.match(deps, /corepack install -g \/xema-pnpm\/corepack\/pnpm\.tgz/);
|
|
163
|
+
assert.match(deps, /from=xema-pnpm-store,target=\/root\/\.pnpm-store,rw/);
|
|
164
|
+
assert.match(deps, /from=xema-pnpm,source=cache,target=\/root\/\.cache\/pnpm,rw/);
|
|
165
|
+
assert.match(deps, /needs --build-context xema-pnpm and xema-pnpm-store \(CI only\)/);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('memberProdPackages reads the installed prod closure and refuses an empty one; depsCopyDirs inverts the rendered COPY lines', () => {
|
|
169
|
+
const root = workspace();
|
|
170
|
+
const a = installed(root, '@s/a');
|
|
171
|
+
const exec = () => `${root}/biomes/x\n${a.dir}\n${a.dir}\n/not/installed/node_modules/zzz\n`;
|
|
172
|
+
assert.deepEqual(memberProdPackages({ root, exec }), [{ name: '@s/a', dir: fs.realpathSync(a.dir) }]);
|
|
173
|
+
assert.throws(() => memberProdPackages({ root, exec: () => '' }), /run pnpm install/);
|
|
174
|
+
const { dockerfile } = renderPrebuiltDockerfile(options(root, { copy: ['tooling/dev', 'tooling/ci/'] }));
|
|
175
|
+
assert.deepEqual(depsCopyDirs(dockerfile), ['tooling/dev/', 'tooling/ci/']);
|
|
176
|
+
assert.deepEqual(depsCopyDirs(renderPrebuiltDockerfile(options(root, { copy: [] })).dockerfile), []);
|
|
177
|
+
});
|
package/src/composed-image.mjs
CHANGED
|
@@ -44,7 +44,7 @@
|
|
|
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>)
|
|
47
|
+
// pnpm-context <dir> (--subject <svc>… | --image-dir <composed>)
|
|
48
48
|
// CI-runner only, after `pnpm install --frozen-lockfile`: build the offline `xema-pnpm`
|
|
49
49
|
// build context (corepack pnpm.tgz, warmed metadata cache, staged.json) for the
|
|
50
50
|
// prebuilt Dockerfiles (`composed-image-pnpm-context.mjs`). stdout is
|
|
@@ -319,7 +319,7 @@ const USAGE =
|
|
|
319
319
|
'usage: xema-composed-image <command> [--root <dir>] [--image-dir <dir>]\n' +
|
|
320
320
|
' members --write|--check [--manifests <glob>]… [--canonical-dockerfile <path>] [--cross-repo-manifest <path>]\n' +
|
|
321
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>)
|
|
322
|
+
' pnpm-context <dir> (--subject <svc>… | --image-dir <composed>)\n' +
|
|
323
323
|
' stage <dir> [--output <rel>]… [--also <pkg>]… | sync-injected | prisma-check | migrate <member> [--root <dir>]\n' +
|
|
324
324
|
' coordinate --image-name <name> (env: SHA REGISTRY REGISTRY_ORG [TAG_ARGS_FILE])\n' +
|
|
325
325
|
' load-image <image>\n' +
|
|
@@ -416,7 +416,6 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
|
|
|
416
416
|
dir: argument,
|
|
417
417
|
subjects: options.subject,
|
|
418
418
|
imageDir: options.subject === undefined ? options['image-dir'] : undefined,
|
|
419
|
-
copy: options.copy,
|
|
420
419
|
log: (message) => process.stderr.write(`${message}\n`),
|
|
421
420
|
});
|
|
422
421
|
write(`XEMA_PNPM_CONTEXT=${result.context}\nXEMA_PNPM_STORE=${result.store}\n`);
|