@xemahq/repo-build-tooling 0.10.2 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/package.json +3 -3
- package/src/composed-image-dockerfile.mjs +4 -4
- package/src/composed-image-dockerfile.test.mjs +6 -0
- package/src/composed-image-fixture.test.mjs +55 -0
- package/src/composed-image-members.mjs +108 -0
- package/src/composed-image-prebuilt-dockerfile.mjs +220 -0
- package/src/composed-image-prebuilt-dockerfile.test.mjs +116 -0
- package/src/composed-image-prisma-check.mjs +127 -0
- package/src/composed-image-prisma-check.test.mjs +40 -0
- package/src/composed-image-stage.mjs +148 -0
- package/src/composed-image-stage.test.mjs +153 -0
- package/src/composed-image-sync-injected.mjs +91 -0
- package/src/composed-image-sync-injected.test.mjs +43 -0
- package/src/composed-image.mjs +104 -51
- package/src/composed-migrate.mjs +147 -0
- package/src/composed-migrate.test.mjs +102 -0
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`, `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`, `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 `--tooling-version`, `--copy`, `--cache-id` and `--build-source`. Run `xema-composed-image` with no arguments for the full usage.
|
|
36
|
+
`xema-composed-image members --write|--check` accepts `--manifests <glob>` (repeatable, default `**/xema-biome.json`), `--canonical-dockerfile <path>` and `--cross-repo-manifest <path>`. `xema-composed-image dockerfile` requires `--tooling-version`, `--copy`, `--cache-id` and `--build-source`. `dockerfile --prebuilt` renders the Dockerfile and `Dockerfile.dockerignore` of an image assembled from `stage <dir> [--output <rel>]… [--also <pkg>]…` output (requires `--tooling-version`, `--cache-id`, `--build-source`; `--copy` and `--stage-dir` optional); `load --each` loads every member in its own process. `stage` and `filters` accept `--also <workspace package name>` (repeatable): the package's closure is built and staged (built outputs only, no Prisma checks) alongside the members; an unknown name is refused. Run `xema-composed-image` with no arguments for the full usage.
|
|
37
37
|
|
|
38
38
|
Every step refuses rather than skips. The package has no dependencies.
|
|
39
39
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM with zero dependencies so the published artifact is the reviewed source.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
|
|
@@ -39,9 +39,9 @@
|
|
|
39
39
|
"./service-bootstrap": "./src/service-bootstrap.mjs"
|
|
40
40
|
},
|
|
41
41
|
"scripts": {
|
|
42
|
-
"test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/ghcr-manifest.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/registry-fetch.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs"
|
|
42
|
+
"test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/ghcr-manifest.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/registry-fetch.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-migrate.test.mjs"
|
|
43
43
|
},
|
|
44
44
|
"xemaRelease": {
|
|
45
|
-
"sourceDigest": "sha256:
|
|
45
|
+
"sourceDigest": "sha256:65f155b64c9951c7c47a7d2a2b9cf65e08615471f0d5c49097a18460cbed260d"
|
|
46
46
|
}
|
|
47
47
|
}
|
|
@@ -23,7 +23,7 @@ const VERSION = /^\d+\.\d+\.\d+$/;
|
|
|
23
23
|
const DIRECTORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*\/?$/;
|
|
24
24
|
const CACHE_ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
25
25
|
const REPOSITORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
|
26
|
-
const NODE_BASE =
|
|
26
|
+
export const NODE_BASE =
|
|
27
27
|
'mirror.gcr.io/library/node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436';
|
|
28
28
|
|
|
29
29
|
function validate({ toolingVersion, copy, cacheId, buildSource }) {
|
|
@@ -55,10 +55,10 @@ export function renderComposedDockerfile({ toolingVersion, copy, cacheId, buildS
|
|
|
55
55
|
.map((d) => `COPY ${d} ./${d}`)
|
|
56
56
|
.join('\n');
|
|
57
57
|
const store = `--mount=type=cache,id=pnpm-store-${cacheId},target=/root/.pnpm-store`;
|
|
58
|
-
const mount = usesToken ? ' --mount=type=secret,id=npm_token,required=true
|
|
59
|
-
const tokenEnv = usesToken ? 'NPM_TOKEN="$(cat /run/secrets/npm_token)" GH_PACKAGES_TOKEN="$NPM_TOKEN"
|
|
58
|
+
const mount = usesToken ? ' --mount=type=secret,id=npm_token,required=true \\\n' : '';
|
|
59
|
+
const tokenEnv = usesToken ? 'NPM_TOKEN="$(cat /run/secrets/npm_token)" GH_PACKAGES_TOKEN="$NPM_TOKEN" \\\n ' : '';
|
|
60
60
|
const tokenExport = usesToken
|
|
61
|
-
? 'export NPM_TOKEN="$(cat /run/secrets/npm_token)" && export GH_PACKAGES_TOKEN="$NPM_TOKEN" &&
|
|
61
|
+
? 'export NPM_TOKEN="$(cat /run/secrets/npm_token)" && export GH_PACKAGES_TOKEN="$NPM_TOKEN" && \\\n '
|
|
62
62
|
: '';
|
|
63
63
|
const metadata = `--mount=type=cache,id=pnpm-metadata-${cacheId},target=/root/.cache/pnpm`;
|
|
64
64
|
return `# @generated by \`xema-composed-image dockerfile --write\` (@xemahq/repo-build-tooling@${toolingVersion}) — DO NOT EDIT.
|
|
@@ -84,3 +84,9 @@ test('the token use is derived from .npmrc, deterministically', () => {
|
|
|
84
84
|
fs.writeFileSync(path.join(root, '.npmrc'), '//registry.npmjs.org/:_authToken=${NPM_TOKEN}\n');
|
|
85
85
|
assert.throws(() => syncComposedDockerfile({ mode: 'check', root, ...OPTIONS }), /is stale/);
|
|
86
86
|
});
|
|
87
|
+
|
|
88
|
+
test('a token-using repository gets single-backslash line continuations (a doubled one is a parse error)', () => {
|
|
89
|
+
const text = renderComposedDockerfile({ ...OPTIONS, usesToken: true });
|
|
90
|
+
assert.match(text, /--mount=type=secret,id=npm_token,required=true \\\n/);
|
|
91
|
+
for (const line of text.split('\n')) assert.doesNotMatch(line, /\\\\\s*$/, `line ends with a doubled backslash: ${line}`);
|
|
92
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// Shared fixture for the prebuilt-image tests: a tiny workspace with two members
|
|
2
|
+
// (one with a Prisma schema) and one non-member dependency.
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import os from 'node:os';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
export function write(root, rel, content) {
|
|
8
|
+
fs.mkdirSync(path.join(root, path.dirname(rel)), { recursive: true });
|
|
9
|
+
fs.writeFileSync(path.join(root, rel), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const MEMBERS = Object.freeze({ 'alpha/alpha-api': 'biomes/alpha/api/alpha-api', 'alpha/alpha-db-api': 'biomes/alpha/api/alpha-db-api' });
|
|
13
|
+
export const CLOSURE = Object.freeze(['biomes/alpha/api/alpha-api', 'biomes/alpha/api/alpha-db-api', 'packages/sdks/lib-a']);
|
|
14
|
+
|
|
15
|
+
export function workspace({ built = true } = {}) {
|
|
16
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-prebuilt-'));
|
|
17
|
+
write(root, 'biomes/alpha/xema-biome.json', {
|
|
18
|
+
xema: {
|
|
19
|
+
id: 'alpha',
|
|
20
|
+
components: [
|
|
21
|
+
{ key: 'alpha-api', kind: 'service', scheduler: 'platform', artifact: { path: 'api/alpha-api' } },
|
|
22
|
+
{ key: 'alpha-db-api', kind: 'service', scheduler: 'platform', artifact: { path: 'api/alpha-db-api' } },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
write(root, 'infra/composed-process/image-build-inputs.json', { workspacePackages: Object.values(MEMBERS) });
|
|
27
|
+
write(root, 'pnpm-workspace.yaml', "packages:\n - 'biomes/*/api/*'\n\nonlyBuiltDependencies:\n - 'esbuild'\n # a note\n - prisma\n\n - \"@prisma/engines\" # trailing\nother: 1\n");
|
|
28
|
+
write(root, '.npmrc', 'registry=https://registry.npmjs.org/\n');
|
|
29
|
+
write(root, 'biomes/alpha/api/alpha-api/package.json', { name: '@t/alpha-api' });
|
|
30
|
+
write(root, 'biomes/alpha/api/alpha-db-api/package.json', { name: '@t/alpha-db-api' });
|
|
31
|
+
write(root, 'biomes/alpha/api/alpha-db-api/prisma/schema.prisma', 'datasource db {}\n');
|
|
32
|
+
write(root, 'packages/sdks/lib-a/package.json', { name: '@t/lib-a', files: ['dist'] });
|
|
33
|
+
if (built) {
|
|
34
|
+
write(root, 'biomes/alpha/api/alpha-api/dist/main.js', '// main\n');
|
|
35
|
+
write(root, 'biomes/alpha/api/alpha-db-api/dist/main.js', '// main\n');
|
|
36
|
+
write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/index.js', '// client\n');
|
|
37
|
+
write(root, 'biomes/alpha/api/alpha-db-api/node_modules/.prisma/alpha-db/schema.prisma', 'datasource db {}\n');
|
|
38
|
+
write(root, 'packages/sdks/lib-a/dist/index.js', '// lib\n');
|
|
39
|
+
}
|
|
40
|
+
return root;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** An `exec` standing in for `pnpm ls -r --depth -1 --json …`. */
|
|
44
|
+
export function fakePnpm(root, closure = CLOSURE) {
|
|
45
|
+
const calls = [];
|
|
46
|
+
const exec = (file, args, options) => {
|
|
47
|
+
calls.push({ file, args, options });
|
|
48
|
+
return JSON.stringify(closure.map((dir) => ({ name: dir, path: path.join(root, dir) })));
|
|
49
|
+
};
|
|
50
|
+
exec.calls = calls;
|
|
51
|
+
return exec;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** A `prisma format` stand-in that leaves the schema as it is (already formatted). */
|
|
55
|
+
export const formatNoop = () => ({ status: 0, stdout: '', stderr: '' });
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// The member set a repository's committed declaration names, and the workspace closure
|
|
2
|
+
// those members select. Shared by the build-time tool, the stage command and the
|
|
3
|
+
// prebuilt Dockerfile renderer, so all of them see one answer.
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
|
+
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
6
|
+
import { dirname, join, posix, relative } from 'node:path';
|
|
7
|
+
|
|
8
|
+
import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
|
|
9
|
+
|
|
10
|
+
export const IMAGE_BUILD_INPUTS_FILE = 'image-build-inputs.json';
|
|
11
|
+
|
|
12
|
+
/** The `<biomeId>/<componentKey>` of the one service whose artifact is `directory`. */
|
|
13
|
+
function memberKey(root, directory) {
|
|
14
|
+
for (let dir = dirname(directory); dir !== '.' && dir !== dirname(dir); dir = dirname(dir)) {
|
|
15
|
+
const manifestPath = join(root, dir, 'xema-biome.json');
|
|
16
|
+
if (!existsSync(manifestPath)) continue;
|
|
17
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
18
|
+
const matches = (manifest.xema?.components ?? []).filter(
|
|
19
|
+
(component) =>
|
|
20
|
+
component.kind === 'service' &&
|
|
21
|
+
typeof component.artifact?.path === 'string' &&
|
|
22
|
+
posix.normalize(posix.join(dir, component.artifact.path)) === directory,
|
|
23
|
+
);
|
|
24
|
+
if (matches.length !== 1) {
|
|
25
|
+
throw new Error(`${dir}/xema-biome.json declares ${matches.length} service(s) at ${directory}, not exactly one`);
|
|
26
|
+
}
|
|
27
|
+
return `${manifest.xema.id}/${matches[0].key}`;
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`no biome manifest above ${directory}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** `[key, directory]` for every member the committed declaration names. */
|
|
33
|
+
export function readMembers({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR } = {}) {
|
|
34
|
+
const inputs = join(root, imageDir, IMAGE_BUILD_INPUTS_FILE);
|
|
35
|
+
const directories = JSON.parse(readFileSync(inputs, 'utf8')).workspacePackages ?? [];
|
|
36
|
+
if (directories.length === 0) throw new Error(`${inputs} declares no workspacePackages`);
|
|
37
|
+
return directories.map((directory) => [memberKey(root, directory), directory]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function packageName(root, directory) {
|
|
41
|
+
const manifest = JSON.parse(readFileSync(join(root, directory, 'package.json'), 'utf8'));
|
|
42
|
+
if (typeof manifest.name !== 'string') throw new Error(`${directory}/package.json has no name`);
|
|
43
|
+
return manifest.name;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The pnpm `--filter <package>...` arguments covering every member's closure, then
|
|
48
|
+
* each `also` package's (a workspace package name); a name a member already
|
|
49
|
+
* selected is listed once.
|
|
50
|
+
*/
|
|
51
|
+
export function memberFilters(options = {}) {
|
|
52
|
+
const root = options.root ?? process.cwd();
|
|
53
|
+
const names = readMembers(options).map(([, directory]) => packageName(root, directory));
|
|
54
|
+
return [...new Set([...names, ...(options.also ?? [])])].map((name) => `--filter ${name}...`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
/** The biome manifest (`xema-biome.json`) above every member, repository-relative and sorted: what `index` reads to name a member. */
|
|
59
|
+
export function memberManifests({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR } = {}) {
|
|
60
|
+
const manifests = new Set();
|
|
61
|
+
for (const [, directory] of readMembers({ root, imageDir })) {
|
|
62
|
+
for (let dir = dirname(directory); dir !== '.' && dir !== dirname(dir); dir = dirname(dir)) {
|
|
63
|
+
if (existsSync(join(root, dir, 'xema-biome.json'))) {
|
|
64
|
+
manifests.add(posix.join(dir, 'xema-biome.json'));
|
|
65
|
+
break;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return [...manifests].sort();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const defaultExec = (file, args, { cwd }) => execFileSync(file, args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
73
|
+
|
|
74
|
+
function parseListing(listing) {
|
|
75
|
+
let packages;
|
|
76
|
+
try {
|
|
77
|
+
packages = JSON.parse(listing);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
throw new Error(`pnpm ls did not print JSON: ${error.message}`);
|
|
80
|
+
}
|
|
81
|
+
if (!Array.isArray(packages)) throw new Error('pnpm ls did not print a JSON array');
|
|
82
|
+
return packages;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* The workspace packages the members' filters select (the members and everything
|
|
87
|
+
* they depend on inside the workspace): repository-relative directories, sorted.
|
|
88
|
+
* `exec(file, args, { cwd })` returns stdout; injectable for tests.
|
|
89
|
+
*/
|
|
90
|
+
export function memberClosure({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, exec = defaultExec, also = [] } = {}) {
|
|
91
|
+
for (const name of also) {
|
|
92
|
+
const found = parseListing(exec('pnpm', ['ls', '-r', '--depth', '-1', '--json', '--filter', name], { cwd: root }));
|
|
93
|
+
if (!found.some((pkg) => pkg?.name === name)) throw new Error(`--also ${name}: no workspace package has that name`);
|
|
94
|
+
}
|
|
95
|
+
const words = memberFilters({ root, imageDir, also }).flatMap((filter) => filter.split(' '));
|
|
96
|
+
const packages = parseListing(exec('pnpm', ['ls', '-r', '--depth', '-1', '--json', ...words], { cwd: root }));
|
|
97
|
+
if (packages.length === 0) throw new Error('pnpm ls selected no workspace package');
|
|
98
|
+
const real = realpathSync(root);
|
|
99
|
+
const directories = new Set();
|
|
100
|
+
for (const pkg of packages) {
|
|
101
|
+
if (typeof pkg?.path !== 'string') throw new Error('pnpm ls listed a package with no path');
|
|
102
|
+
let rel = relative(root, pkg.path);
|
|
103
|
+
if (rel.startsWith('..')) rel = relative(real, pkg.path);
|
|
104
|
+
if (rel.startsWith('..')) throw new Error(`pnpm ls listed ${pkg.path}, outside ${root}`);
|
|
105
|
+
if (rel !== '') directories.add(rel.split('\\').join('/'));
|
|
106
|
+
}
|
|
107
|
+
return [...directories].sort();
|
|
108
|
+
}
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
// `xema-composed-image dockerfile --prebuilt --write|--check`: renders
|
|
2
|
+
// `<image-dir>/Dockerfile` AND `<image-dir>/Dockerfile.dockerignore` for the image that
|
|
3
|
+
// is ASSEMBLED from build outputs made on the CI runner (`xema-composed-image stage`).
|
|
4
|
+
// Docker compiles nothing. The full-build template (`composed-image-dockerfile.mjs`)
|
|
5
|
+
// stays available for repositories that still build inside Docker.
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
7
|
+
import { dirname, join, posix } from 'node:path';
|
|
8
|
+
|
|
9
|
+
import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
|
|
10
|
+
import { NODE_BASE, npmrcUsesToken } from './composed-image-dockerfile.mjs';
|
|
11
|
+
import { memberClosure, memberFilters, memberManifests } from './composed-image-members.mjs';
|
|
12
|
+
|
|
13
|
+
export const DEFAULT_STAGE_DIR = '.xema-stage';
|
|
14
|
+
const VERSION = /^\d+\.\d+\.\d+$/;
|
|
15
|
+
const DIRECTORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*\/?$/;
|
|
16
|
+
const CACHE_ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
17
|
+
const REPOSITORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
|
18
|
+
|
|
19
|
+
function validate({ toolingVersion, copy, cacheId, buildSource, stageDir }) {
|
|
20
|
+
if (!VERSION.test(toolingVersion ?? '')) throw new Error(`--tooling-version must be an exact x.y.z version, got "${toolingVersion}"`);
|
|
21
|
+
for (const directory of [...(copy ?? []), stageDir]) {
|
|
22
|
+
if (!DIRECTORY.test(directory) || directory.startsWith('/') || directory.split('/').includes('..')) {
|
|
23
|
+
throw new Error(`"${directory}" is not a repository-relative directory`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (!CACHE_ID.test(cacheId ?? '')) throw new Error(`--cache-id must be lowercase [a-z0-9-], got "${cacheId}"`);
|
|
27
|
+
if (!REPOSITORY.test(buildSource ?? '')) throw new Error(`--build-source must name a repository, got "${buildSource}"`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** The ignore file: the build context carries sources, never local build output, except the staged outputs. */
|
|
31
|
+
export function renderPrebuiltDockerignore({ stageDir = DEFAULT_STAGE_DIR } = {}) {
|
|
32
|
+
return `# @generated by \`xema-composed-image dockerfile --prebuilt --write\` — DO NOT EDIT.
|
|
33
|
+
# Local build output stays out of the context; the staged outputs carry dist and node_modules/.prisma paths, so they are re-included LAST.
|
|
34
|
+
.git
|
|
35
|
+
**/node_modules
|
|
36
|
+
**/dist
|
|
37
|
+
**/.turbo
|
|
38
|
+
**/coverage
|
|
39
|
+
**/*.tsbuildinfo
|
|
40
|
+
**/*.log
|
|
41
|
+
!${stageDir}/**
|
|
42
|
+
`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The `onlyBuiltDependencies:` items of a `pnpm-workspace.yaml` text (block list; comments skipped; ends at the next top-level key). */
|
|
46
|
+
export function onlyBuiltDependencies(workspaceYaml) {
|
|
47
|
+
const names = [];
|
|
48
|
+
let inList = false;
|
|
49
|
+
for (const line of workspaceYaml.split('\n')) {
|
|
50
|
+
if (!inList) {
|
|
51
|
+
inList = /^onlyBuiltDependencies:\s*(#.*)?$/.test(line);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (/^\s*(#.*)?$/.test(line)) continue;
|
|
55
|
+
if (/^\S/.test(line)) break;
|
|
56
|
+
const item = /^\s+-\s+(?:'([^']*)'|"([^"]*)"|([^\s#]+))\s*(?:#.*)?$/.exec(line);
|
|
57
|
+
if (item === null) throw new Error(`pnpm-workspace.yaml: cannot read onlyBuiltDependencies item "${line.trim()}"`);
|
|
58
|
+
names.push(item[1] ?? item[2] ?? item[3]);
|
|
59
|
+
}
|
|
60
|
+
for (const name of names) {
|
|
61
|
+
if (!/^[@A-Za-z0-9._/-]+$/.test(name)) throw new Error(`pnpm-workspace.yaml: onlyBuiltDependencies "${name}" is not a package name`);
|
|
62
|
+
}
|
|
63
|
+
return names;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const parts = (sources) => sources.map((source) => ` ${source} \\`).join('\n');
|
|
67
|
+
|
|
68
|
+
/** The prebuilt Dockerfile text. The closure and the members' literal filters are derived from `root` (`memberClosure`); `exec` is injectable for tests. */
|
|
69
|
+
export function renderPrebuiltDockerfile({
|
|
70
|
+
root = process.cwd(),
|
|
71
|
+
imageDir = COMPOSED_IMAGE_DIR,
|
|
72
|
+
toolingVersion,
|
|
73
|
+
copy = [],
|
|
74
|
+
cacheId,
|
|
75
|
+
buildSource,
|
|
76
|
+
usesToken,
|
|
77
|
+
stageDir = DEFAULT_STAGE_DIR,
|
|
78
|
+
exec,
|
|
79
|
+
}) {
|
|
80
|
+
if (typeof usesToken !== 'boolean') throw new Error('usesToken must be a boolean derived from .npmrc');
|
|
81
|
+
validate({ toolingVersion, copy, cacheId, buildSource, stageDir });
|
|
82
|
+
const workspaceFile = join(root, 'pnpm-workspace.yaml');
|
|
83
|
+
const built = existsSync(workspaceFile) ? onlyBuiltDependencies(readFileSync(workspaceFile, 'utf8')) : [];
|
|
84
|
+
if (built.length === 0) {
|
|
85
|
+
throw new Error('pnpm-workspace.yaml has no onlyBuiltDependencies: — `pnpm rebuild` would have nothing to build; refusing to render');
|
|
86
|
+
}
|
|
87
|
+
const closure = memberClosure({ root, imageDir, exec });
|
|
88
|
+
const filters = memberFilters({ root, imageDir }).join(' ');
|
|
89
|
+
const manifests = memberManifests({ root, imageDir });
|
|
90
|
+
const dirFlag = imageDir === COMPOSED_IMAGE_DIR ? '' : ` --image-dir ${imageDir}`;
|
|
91
|
+
const toolDir = '/opt/xema-composed-image';
|
|
92
|
+
const toolMount = `--mount=type=bind,source=${stageDir}/tool,target=${toolDir}`;
|
|
93
|
+
const node = `${toolDir}/src/composed-image.mjs`;
|
|
94
|
+
const store = `--mount=type=cache,id=pnpm-store-${cacheId},target=/root/.pnpm-store`;
|
|
95
|
+
const metadata = `--mount=type=cache,id=pnpm-metadata-${cacheId},target=/root/.cache/pnpm`;
|
|
96
|
+
const secret = usesToken ? ' --mount=type=secret,id=npm_token,required=true \\\n' : '';
|
|
97
|
+
const tokenExport = usesToken
|
|
98
|
+
? 'export NPM_TOKEN="$(cat /run/secrets/npm_token)" && export GH_PACKAGES_TOKEN="$NPM_TOKEN" && \\\n '
|
|
99
|
+
: '';
|
|
100
|
+
const copies = [...new Set(copy.map((d) => posix.normalize(`${d}/`)))].map((d) => `COPY ${d} ./${d}`).join('\n');
|
|
101
|
+
const manifestSources = closure.map((directory) => `${directory}/package.json`);
|
|
102
|
+
const assembleSources = [...closure, ...manifests];
|
|
103
|
+
const imageCopy = posix.normalize(`${imageDir}/`);
|
|
104
|
+
const text = `# @generated by \`xema-composed-image dockerfile --prebuilt --write\` (@xemahq/repo-build-tooling@${toolingVersion}) — DO NOT EDIT.
|
|
105
|
+
# Re-render with the same options; \`--check\` refuses a stale file (and a stale Dockerfile.dockerignore).
|
|
106
|
+
#
|
|
107
|
+
# The COMPOSED-PROCESS image of this repository, ASSEMBLED from build outputs: ONE image
|
|
108
|
+
# per build (repository at commit), every member of \`image-build-inputs.json\` in ONE module
|
|
109
|
+
# graph behind the SDK's \`xema-composed-process\` host. Docker compiles nothing. The CI
|
|
110
|
+
# runner ran \`turbo run build prisma:generate\` for the members' closures and
|
|
111
|
+
# \`xema-composed-image stage ${stageDir}\` gathered the outputs into the build-step
|
|
112
|
+
# directory \`${stageDir}/\` (\`out/\` = dist and generated Prisma clients, pure JS/wasm;
|
|
113
|
+
# \`tool/\` = this tool, bind-mounted into the steps below and never copied into a layer).
|
|
114
|
+
#
|
|
115
|
+
# Stages:
|
|
116
|
+
# deps arch-native. Inputs are the lockfile and the closure's manifests ONLY, so the
|
|
117
|
+
# layer is cached until one of them changes. Production install with the
|
|
118
|
+
# workspace packages INJECTED, then \`pnpm rebuild <names>\` runs the build scripts
|
|
119
|
+
# of the root \`onlyBuiltDependencies\` (Prisma engines, native addons) natively.
|
|
120
|
+
# The names are listed because a bare filtered \`pnpm rebuild\` would also run
|
|
121
|
+
# the members' own postinstall (no schema here); a name absent from the graph
|
|
122
|
+
# is tolerated. The list is rendered, so a change to it makes \`--check\` stale.
|
|
123
|
+
# assemble source of the closure + the staged outputs, the injected copies synced to
|
|
124
|
+
# their \`files\`, then the proofs on the target arch: Prisma clients run, the
|
|
125
|
+
# member index is written, every member loads ALONE as \`node\`, production.
|
|
126
|
+
# (final) \`/monorepo\` of assemble, shipped as THREE layers so a commit that changes only
|
|
127
|
+
# members' code re-pushes only the layers that changed (see the final stage).
|
|
128
|
+
# Nothing of the tool ships.
|
|
129
|
+
#
|
|
130
|
+
# The \`--filter <package>...\` words below are LITERAL: the content hash of this image reads
|
|
131
|
+
# them to narrow the lockfile to the members' closures.
|
|
132
|
+
FROM ${NODE_BASE} AS base
|
|
133
|
+
RUN corepack enable && \\
|
|
134
|
+
apt-get update && apt-get install -y --no-install-recommends openssl wget && rm -rf /var/lib/apt/lists/* && \\
|
|
135
|
+
pnpm config set store-dir /root/.pnpm-store
|
|
136
|
+
|
|
137
|
+
FROM base AS deps
|
|
138
|
+
WORKDIR /monorepo
|
|
139
|
+
ENV CI=true
|
|
140
|
+
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
|
141
|
+
${copies === '' ? '' : `${copies}\n`}COPY --parents \\
|
|
142
|
+
${parts(manifestSources)}
|
|
143
|
+
./
|
|
144
|
+
RUN ${store} \\
|
|
145
|
+
${metadata} \\
|
|
146
|
+
${secret} ${toolMount} \\
|
|
147
|
+
${tokenExport}INJECT="--config.inject-workspace-packages=true --config.dedupe-injected-deps=false" && \\
|
|
148
|
+
cp pnpm-lock.yaml /tmp/pnpm-lock.committed.yaml && \\
|
|
149
|
+
pnpm install --lockfile-only --prefer-offline \${INJECT} ${filters} && \\
|
|
150
|
+
node ${node} pins${dirFlag} /tmp/pnpm-lock.committed.yaml && \\
|
|
151
|
+
pnpm install --prod --prefer-offline --ignore-scripts \${INJECT} ${filters} --frozen-lockfile && \\
|
|
152
|
+
pnpm rebuild ${built.join(' ')} ${filters}
|
|
153
|
+
|
|
154
|
+
FROM deps AS assemble
|
|
155
|
+
COPY --parents \\
|
|
156
|
+
${parts(assembleSources)}
|
|
157
|
+
./
|
|
158
|
+
COPY ${imageCopy} ./${imageCopy}
|
|
159
|
+
COPY ${stageDir}/out/ ./
|
|
160
|
+
ARG BUILD_SHA
|
|
161
|
+
RUN ${toolMount} \\
|
|
162
|
+
T="node ${node}" && \\
|
|
163
|
+
$T sync-injected${dirFlag} && $T prisma-check${dirFlag} && \\
|
|
164
|
+
: "\${BUILD_SHA:?BUILD_SHA build-arg is required}" && $T index "\${BUILD_SHA}" --build-source ${buildSource}${dirFlag} && \\
|
|
165
|
+
cp ${toolDir}/src/composed-migrate.mjs /monorepo/xema-composed-migrate.mjs
|
|
166
|
+
|
|
167
|
+
# Every member's module graph loads here, ALONE (one process each), through the host's own
|
|
168
|
+
# entry reader, as the user and NODE_ENV the image runs with, or the image is never pushed.
|
|
169
|
+
USER node
|
|
170
|
+
RUN ${toolMount} \\
|
|
171
|
+
NODE_ENV=production node ${node} load --each${dirFlag}
|
|
172
|
+
|
|
173
|
+
# The final stage is layered by how often each part changes, so a registry and a node
|
|
174
|
+
# already holding the stable layers fetch only the rest. The three copies overlay to
|
|
175
|
+
# exactly assemble's /monorepo (proven by a tree diff of both images).
|
|
176
|
+
# 1 deps root manifests, the external .pnpm store, and the injected stubs that hold
|
|
177
|
+
# only package.json. Stable while the lockfile and the closure's package.json
|
|
178
|
+
# files are unchanged.
|
|
179
|
+
# 2 assemble members, dist, Prisma clients, member index, migrate script: everything
|
|
180
|
+
# but the .pnpm store, whose packages layer 1 already carries.
|
|
181
|
+
# 3 injected the workspace copies \`node_modules/.pnpm/*@file+*\`: \`sync-injected\` fills
|
|
182
|
+
# them from the members' files, so they change with every commit. \`--parents\`
|
|
183
|
+
# keeps their paths under /.
|
|
184
|
+
FROM base
|
|
185
|
+
WORKDIR /monorepo
|
|
186
|
+
COPY --from=deps /monorepo /monorepo
|
|
187
|
+
COPY --from=assemble --exclude=node_modules/.pnpm /monorepo /monorepo
|
|
188
|
+
COPY --from=assemble --parents /monorepo/node_modules/.pnpm/*@file+* /
|
|
189
|
+
ENV NODE_ENV=production
|
|
190
|
+
# Set by the IMAGE, never by a deployment (kernel \`COMPOSED_PROCESS_ENV.MemberIndex\`).
|
|
191
|
+
ENV XEMA_COMPOSED_MEMBER_INDEX=/monorepo/xema-composed-member-index.json
|
|
192
|
+
USER node
|
|
193
|
+
CMD ["node", "/monorepo/xema-composed-process"]
|
|
194
|
+
`;
|
|
195
|
+
return { dockerfile: text, dockerignore: renderPrebuiltDockerignore({ stageDir }), closure };
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** `--write` writes both files; `--check` throws when either differs. */
|
|
199
|
+
export function syncPrebuiltDockerfile({ mode, root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, exec, ...options }) {
|
|
200
|
+
if (mode !== 'write' && mode !== 'check') throw new Error(`mode must be write or check, got "${mode}"`);
|
|
201
|
+
const npmrc = join(root, '.npmrc');
|
|
202
|
+
if (!existsSync(npmrc)) throw new Error(`${npmrc} is absent — the Dockerfile copies it and its token use decides the build secret`);
|
|
203
|
+
const { dockerfile, dockerignore } = renderPrebuiltDockerfile({ ...options, root, imageDir, exec, usesToken: npmrcUsesToken(readFileSync(npmrc, 'utf8')) });
|
|
204
|
+
const files = [
|
|
205
|
+
[posix.join(imageDir, 'Dockerfile'), dockerfile],
|
|
206
|
+
[posix.join(imageDir, 'Dockerfile.dockerignore'), dockerignore],
|
|
207
|
+
];
|
|
208
|
+
const stale = [];
|
|
209
|
+
for (const [relativeTarget, rendered] of files) {
|
|
210
|
+
const target = join(root, relativeTarget);
|
|
211
|
+
if (mode === 'write') {
|
|
212
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
213
|
+
writeFileSync(target, rendered);
|
|
214
|
+
} else if ((existsSync(target) ? readFileSync(target, 'utf8') : '') !== rendered) stale.push(relativeTarget);
|
|
215
|
+
}
|
|
216
|
+
if (stale.length > 0) {
|
|
217
|
+
throw new Error(`${stale.join(' and ')} ${stale.length > 1 ? 'are' : 'is'} stale. Run: xema-composed-image dockerfile --prebuilt --write (with the same options)`);
|
|
218
|
+
}
|
|
219
|
+
return files.map(([relativeTarget]) => relativeTarget);
|
|
220
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import test from 'node:test';
|
|
5
|
+
|
|
6
|
+
import { onlyBuiltDependencies, renderPrebuiltDockerfile, renderPrebuiltDockerignore, syncPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
|
|
7
|
+
import { parseArguments } from './composed-image.mjs';
|
|
8
|
+
import { CLOSURE, fakePnpm, workspace } from './composed-image-fixture.test.mjs';
|
|
9
|
+
|
|
10
|
+
function options(root, extra = {}) {
|
|
11
|
+
return { root, toolingVersion: '0.11.0', copy: ['tooling/dev'], cacheId: 'alpha-composed-process', buildSource: 'alpha', usesToken: true, exec: fakePnpm(root), ...extra };
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
test('the closure comes from pnpm ls with the members filters, and the filters are literal per member', () => {
|
|
15
|
+
const root = workspace();
|
|
16
|
+
const exec = fakePnpm(root);
|
|
17
|
+
const { dockerfile, closure } = renderPrebuiltDockerfile(options(root, { exec }));
|
|
18
|
+
assert.deepEqual(closure, CLOSURE);
|
|
19
|
+
assert.deepEqual(exec.calls[0].args, ['ls', '-r', '--depth', '-1', '--json', '--filter', '@t/alpha-api...', '--filter', '@t/alpha-db-api...']);
|
|
20
|
+
const filters = '--filter @t/alpha-api... --filter @t/alpha-db-api...';
|
|
21
|
+
for (const verb of ['pnpm install --lockfile-only --prefer-offline ${INJECT}', 'pnpm install --prod --prefer-offline --ignore-scripts ${INJECT}', 'pnpm rebuild esbuild prisma @prisma/engines']) {
|
|
22
|
+
assert.ok(dockerfile.includes(`${verb} ${filters}`), verb);
|
|
23
|
+
}
|
|
24
|
+
assert.doesNotMatch(renderPrebuiltDockerfile(options(root, { usesToken: false })).dockerfile, /\$\(cat/);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('one COPY --parents per kind: manifests in deps, package dirs (and biome manifests) in assemble', () => {
|
|
28
|
+
const { dockerfile } = renderPrebuiltDockerfile(options(workspace()));
|
|
29
|
+
assert.equal(dockerfile.match(/^COPY --parents/gm).length, 2);
|
|
30
|
+
const [deps, assemble] = dockerfile.split('FROM deps AS assemble');
|
|
31
|
+
assert.match(deps, /COPY tooling\/dev\/ \.\/tooling\/dev\/\nCOPY --parents \\\n {4}biomes\/alpha\/api\/alpha-api\/package\.json \\\n {4}biomes\/alpha\/api\/alpha-db-api\/package\.json \\\n {4}packages\/sdks\/lib-a\/package\.json \\\n {4}\.\/\n/);
|
|
32
|
+
assert.match(assemble, /COPY --parents \\\n {4}biomes\/alpha\/api\/alpha-api \\\n {4}biomes\/alpha\/api\/alpha-db-api \\\n {4}packages\/sdks\/lib-a \\\n {4}biomes\/alpha\/xema-biome\.json \\\n {4}\.\/\n/);
|
|
33
|
+
assert.match(assemble, /COPY infra\/composed-process\/ \.\/infra\/composed-process\/\nCOPY \.xema-stage\/out\/ \.\/\n/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test('the tool is only bind-mounted, and the final stage carries nothing of it', () => {
|
|
37
|
+
const { dockerfile } = renderPrebuiltDockerfile(options(workspace()));
|
|
38
|
+
assert.equal(dockerfile.match(/--mount=type=bind,source=\.xema-stage\/tool,target=\/opt\/xema-composed-image/g).length, 3);
|
|
39
|
+
assert.doesNotMatch(dockerfile, /\bnpm install|COPY \.xema-stage\/tool/);
|
|
40
|
+
const final = dockerfile.split('\nFROM base\n')[1];
|
|
41
|
+
assert.doesNotMatch(final, /xema-composed-image|repo-build-tooling|npm install|\/opt\//);
|
|
42
|
+
assert.doesNotMatch(final, /COPY --from=assemble \/monorepo \/monorepo/);
|
|
43
|
+
assert.match(dockerfile, /USER node\nRUN --mount=\S+ \\\n {2}NODE_ENV=production node \/opt\/xema-composed-image\/src\/composed-image\.mjs load --each\n/);
|
|
44
|
+
assert.match(dockerfile, /cp \/opt\/xema-composed-image\/src\/composed-migrate\.mjs \/monorepo\/xema-composed-migrate\.mjs/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('the secret is mounted only for a tokened .npmrc; a custom image dir and stage dir are threaded', () => {
|
|
48
|
+
const root = workspace();
|
|
49
|
+
assert.match(renderPrebuiltDockerfile(options(root)).dockerfile, /type=secret,id=npm_token,required=true \\\n {2}--mount=type=bind/);
|
|
50
|
+
const without = renderPrebuiltDockerfile(options(root, { usesToken: false })).dockerfile;
|
|
51
|
+
assert.doesNotMatch(without, /npm_token|NPM_TOKEN|type=secret/);
|
|
52
|
+
const custom = renderPrebuiltDockerfile(options(root, { stageDir: 'build/stage' })).dockerfile;
|
|
53
|
+
assert.match(custom, /source=build\/stage\/tool/);
|
|
54
|
+
assert.match(custom, /COPY build\/stage\/out\/ \.\/\n/);
|
|
55
|
+
assert.throws(() => renderPrebuiltDockerfile(options(root, { stageDir: '../x' })), /repository-relative/);
|
|
56
|
+
assert.throws(() => renderPrebuiltDockerfile(options(root, { usesToken: undefined })), /usesToken/);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('the dockerignore re-includes the stage dir LAST', () => {
|
|
60
|
+
const lines = renderPrebuiltDockerignore({ stageDir: '.xema-stage' }).trim().split('\n');
|
|
61
|
+
assert.equal(lines.at(-1), '!.xema-stage/**');
|
|
62
|
+
for (const pattern of ['.git', '**/node_modules', '**/dist', '**/.turbo', '**/coverage', '**/*.tsbuildinfo', '**/*.log']) assert.ok(lines.includes(pattern), pattern);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test('--write writes both files; --check refuses either stale, and a closure change', () => {
|
|
66
|
+
const root = workspace();
|
|
67
|
+
const targets = syncPrebuiltDockerfile({ mode: 'write', ...options(root) });
|
|
68
|
+
assert.deepEqual(targets, ['infra/composed-process/Dockerfile', 'infra/composed-process/Dockerfile.dockerignore']);
|
|
69
|
+
syncPrebuiltDockerfile({ mode: 'check', ...options(root) });
|
|
70
|
+
assert.throws(() => syncPrebuiltDockerfile({ mode: 'check', ...options(root, { exec: fakePnpm(root, [...CLOSURE, 'packages/sdks/lib-b']) }) }), /Dockerfile is stale/);
|
|
71
|
+
fs.appendFileSync(path.join(root, 'infra/composed-process/Dockerfile.dockerignore'), 'x\n');
|
|
72
|
+
assert.throws(() => syncPrebuiltDockerfile({ mode: 'check', ...options(root) }), /Dockerfile\.dockerignore is stale/);
|
|
73
|
+
assert.throws(() => syncPrebuiltDockerfile({ mode: 'sideways', ...options(root) }), /write or check/);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test('the flags parse: --prebuilt and --each are booleans, --output repeats', () => {
|
|
77
|
+
const { options: parsed } = parseArguments(['dockerfile', '--prebuilt', '--write', '--stage-dir', 's']);
|
|
78
|
+
assert.equal(parsed.prebuilt, true);
|
|
79
|
+
assert.equal(parsed['stage-dir'], 's');
|
|
80
|
+
const stage = parseArguments(['stage', 'd', '--output', 'dist', '--output', 'build', '--each']);
|
|
81
|
+
assert.deepEqual(stage.options.output, ['dist', 'build']);
|
|
82
|
+
assert.equal(stage.options.each, true);
|
|
83
|
+
assert.equal(stage.argument, 'd');
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('the rebuild names come from onlyBuiltDependencies: comments skipped, empty refused, a change goes stale', () => {
|
|
87
|
+
assert.deepEqual(onlyBuiltDependencies("a: 1\nonlyBuiltDependencies:\n # c\n - '@x/y'\n - z # t\n\n # more\n - \"w\"\nnext:\n - nope\n"), ['@x/y', 'z', 'w']);
|
|
88
|
+
assert.deepEqual(onlyBuiltDependencies('packages: []\n'), []);
|
|
89
|
+
const root = workspace();
|
|
90
|
+
assert.throws(() => onlyBuiltDependencies('onlyBuiltDependencies:\n - a b c d\n - "bad name"\n'), /cannot read|not a package name/);
|
|
91
|
+
syncPrebuiltDockerfile({ mode: 'write', ...options(root) });
|
|
92
|
+
const file = path.join(root, 'pnpm-workspace.yaml');
|
|
93
|
+
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/);
|
|
95
|
+
fs.writeFileSync(file, 'packages: []\n');
|
|
96
|
+
assert.throws(() => renderPrebuiltDockerfile(options(root)), /no onlyBuiltDependencies/);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test('a token-using repository gets single-backslash line continuations (a doubled one is a parse error)', () => {
|
|
100
|
+
const { dockerfile } = renderPrebuiltDockerfile(options(workspace(), { usesToken: true }));
|
|
101
|
+
assert.match(dockerfile, /--mount=type=secret,id=npm_token,required=true \\\n/);
|
|
102
|
+
for (const line of dockerfile.split('\n')) assert.doesNotMatch(line, /\\\\\s*$/, `line ends with a doubled backslash: ${line}`);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('the final stage is layered: deps, then assemble without the .pnpm store, then the injected copies', () => {
|
|
106
|
+
const { dockerfile } = renderPrebuiltDockerfile(options(workspace()));
|
|
107
|
+
const final = dockerfile.split('\nFROM base\n')[1];
|
|
108
|
+
const copies = final.split('\n').filter((line) => line.startsWith('COPY '));
|
|
109
|
+
assert.deepEqual(copies, [
|
|
110
|
+
'COPY --from=deps /monorepo /monorepo',
|
|
111
|
+
'COPY --from=assemble --exclude=node_modules/.pnpm /monorepo /monorepo',
|
|
112
|
+
'COPY --from=assemble --parents /monorepo/node_modules/.pnpm/*@file+* /',
|
|
113
|
+
]);
|
|
114
|
+
assert.ok(final.indexOf('ENV NODE_ENV=production') > final.lastIndexOf('COPY --from=assemble'));
|
|
115
|
+
assert.match(final, /USER node\nCMD \["node", "\/monorepo\/xema-composed-process"\]\n$/);
|
|
116
|
+
});
|