@xemahq/repo-build-tooling 0.25.0 → 0.26.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 +6 -2
- package/package.json +3 -3
- package/src/composed-image-in-image-closure.test.mjs +6 -14
- package/src/composed-image-in-image.mjs +17 -22
- package/src/composed-image-install.mjs +46 -0
- package/src/composed-image-install.test.mjs +40 -0
- package/src/composed-image-paths.mjs +20 -0
- package/src/composed-image-pnpm-context.mjs +10 -86
- package/src/composed-image-pnpm-context.test.mjs +10 -77
- package/src/composed-image-prebuilt-dockerfile.mjs +68 -265
- package/src/composed-image-prebuilt-dockerfile.test.mjs +66 -194
- package/src/composed-image-prisma-engine.mjs +8 -51
- package/src/composed-image-prisma-engine.test.mjs +9 -22
- package/src/composed-image-proof.mjs +106 -0
- package/src/composed-image-proof.test.mjs +95 -0
- package/src/composed-image-rebuild.mjs +57 -0
- package/src/composed-image-stage-tree.mjs +201 -0
- package/src/composed-image-stage-tree.test.mjs +133 -0
- package/src/composed-image-stage.mjs +29 -12
- package/src/composed-image-stage.test.mjs +4 -4
- package/src/composed-image.mjs +22 -12
- package/src/service-image-in-image.mjs +5 -91
- package/src/service-image-stage.mjs +7 -10
- package/src/staged-image-proof.mjs +139 -0
|
@@ -1,333 +1,136 @@
|
|
|
1
1
|
// `xema-composed-image dockerfile --prebuilt --write|--check`: renders
|
|
2
|
-
// `<image-dir>/Dockerfile` AND `<image-dir>/Dockerfile.dockerignore` for the image that
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// `<image-dir>/Dockerfile` AND `<image-dir>/Dockerfile.dockerignore` for the image that is
|
|
3
|
+
// COPIED from a tree the CI runner staged (`xema-composed-image stage`). Docker installs nothing,
|
|
4
|
+
// compiles nothing and reaches no network. The full-build template (`composed-image-dockerfile.mjs`)
|
|
5
5
|
// stays available for repositories that still build inside Docker.
|
|
6
|
-
import {
|
|
7
|
-
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
6
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
8
7
|
import { dirname, join, posix } from 'node:path';
|
|
9
8
|
|
|
10
|
-
import { COMPOSED_IMAGE_DIR, IN_IMAGE_ENTRY } from './composed-image-paths.mjs';
|
|
9
|
+
import { COMPOSED_IMAGE_DIR, COMPOSED_MANIFEST_FILE, IN_IMAGE_ENTRY, NODE_MAJOR_LABEL, PROOF_DIRECTORY, STAGE_APP_LAYER, STAGE_PROOF_DIRECTORY, STAGE_STORE_LAYER, VERIFIED_IMAGE_FILE } from './composed-image-paths.mjs';
|
|
11
10
|
import { toolVersion } from './composed-image-stage.mjs';
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { needsPrismaSchemaEngine } from './composed-image-prisma-engine.mjs';
|
|
11
|
+
import { IGNORED_DIRECTORIES, IGNORED_FILE_PATTERNS } from './composed-image-package-files.mjs';
|
|
12
|
+
import { memberFilters } from './composed-image-members.mjs';
|
|
13
|
+
import { CONTEXT_DIRECTORY, rootBuildInputNames } from './staged-image-proof.mjs';
|
|
16
14
|
|
|
17
15
|
export const DEFAULT_STAGE_DIR = '.xema-stage';
|
|
18
16
|
const DIRECTORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*\/?$/;
|
|
19
|
-
const CACHE_ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
20
17
|
const REPOSITORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
|
21
18
|
const DIGEST_REFERENCE = /^[a-z0-9][a-z0-9._/-]*(:[A-Za-z0-9._-]+)?@sha256:[0-9a-f]{64}$/;
|
|
19
|
+
/** The image label that carries the repository its member index names; the stage reads it back from the committed Dockerfile. */
|
|
20
|
+
export const BUILD_SOURCE_LABEL = 'dev.xema.composed-image.build-source';
|
|
21
|
+
/** Where the proof stage puts the `--copy` directories, so the build context's bytes are what the stage hashed. */
|
|
22
|
+
const COPIED_DIRECTORY = `${PROOF_DIRECTORY}/copied`;
|
|
22
23
|
|
|
23
|
-
|
|
24
|
+
const NODE_MAJOR = /^[1-9][0-9]*$/;
|
|
25
|
+
|
|
26
|
+
function validate({ copy, buildSource, stageDir, base, nodeMajor }) {
|
|
27
|
+
if (!NODE_MAJOR.test(nodeMajor ?? '')) throw new Error(`--node-major must be the Node major of the --base image (e.g. 22), got "${nodeMajor}"`);
|
|
24
28
|
if (!DIGEST_REFERENCE.test(base ?? '')) throw new Error(`--base must be an image reference pinned by digest (<ref>@sha256:<64 hex>), got "${base}"`);
|
|
25
29
|
for (const directory of [...(copy ?? []), stageDir]) {
|
|
26
30
|
if (!DIRECTORY.test(directory) || directory.startsWith('/') || directory.split('/').includes('..')) {
|
|
27
31
|
throw new Error(`"${directory}" is not a repository-relative directory`);
|
|
28
32
|
}
|
|
29
33
|
}
|
|
30
|
-
if (!CACHE_ID.test(cacheId ?? '')) throw new Error(`--cache-id must be lowercase [a-z0-9-], got "${cacheId}"`);
|
|
31
34
|
if (!REPOSITORY.test(buildSource ?? '')) throw new Error(`--build-source must name a repository, got "${buildSource}"`);
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
/** The ignore file: the build context carries sources, never local build output, except the staged
|
|
37
|
+
/** The ignore file: the build context carries sources, never local build output, except the staged tree. */
|
|
35
38
|
export function renderPrebuiltDockerignore({ stageDir = DEFAULT_STAGE_DIR } = {}) {
|
|
36
39
|
return `# @generated by \`xema-composed-image dockerfile --prebuilt --write\` — DO NOT EDIT.
|
|
37
|
-
# Local build output stays out of the context; the staged
|
|
40
|
+
# Local build output stays out of the context; the staged tree carries dist and node_modules paths, so it is re-included LAST.
|
|
38
41
|
.git
|
|
39
42
|
${[...IGNORED_DIRECTORIES.map((d) => `**/${d}`), ...IGNORED_FILE_PATTERNS.map((p) => `**/${p}`)].join('\n')}
|
|
40
43
|
!${stageDir}/**
|
|
41
44
|
`;
|
|
42
45
|
}
|
|
43
46
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
const names = [];
|
|
47
|
-
let inList = false;
|
|
48
|
-
for (const line of workspaceYaml.split('\n')) {
|
|
49
|
-
if (!inList) {
|
|
50
|
-
inList = /^onlyBuiltDependencies:\s*(#.*)?$/.test(line);
|
|
51
|
-
continue;
|
|
52
|
-
}
|
|
53
|
-
if (/^\s*(#.*)?$/.test(line)) continue;
|
|
54
|
-
if (/^\S/.test(line)) break;
|
|
55
|
-
const item = /^\s+-\s+(?:'([^']*)'|"([^"]*)"|([^\s#]+))\s*(?:#.*)?$/.exec(line);
|
|
56
|
-
if (item === null) throw new Error(`pnpm-workspace.yaml: cannot read onlyBuiltDependencies item "${line.trim()}"`);
|
|
57
|
-
names.push(item[1] ?? item[2] ?? item[3]);
|
|
58
|
-
}
|
|
59
|
-
for (const name of names) {
|
|
60
|
-
if (!/^[@A-Za-z0-9._/-]+$/.test(name)) throw new Error(`pnpm-workspace.yaml: onlyBuiltDependencies "${name}" is not a package name`);
|
|
61
|
-
}
|
|
62
|
-
return names;
|
|
63
|
-
}
|
|
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 image runs it `--offline` against the metadata cache `pnpm-context --image-dir` warmed; the warm-up
|
|
72
|
-
* runs the SAME words with `--prefer-offline` (`offline: false`). Both read THIS function, so the metadata
|
|
73
|
-
* the context holds is exactly what the image resolves.
|
|
74
|
-
*/
|
|
75
|
-
export const lockfileOnlyWords = (filters, { offline = true } = {}) =>
|
|
76
|
-
['pnpm', 'install', '--lockfile-only', offline ? '--offline' : '--prefer-offline', INJECT_TOKEN, ...filters];
|
|
77
|
-
/** The same command with the inject words expanded: `[file, ...args]`. `offline: false` is the cache warm-up form. */
|
|
78
|
-
export const lockfileOnlyCommand = (filters, options) => lockfileOnlyWords(filters, options).flatMap((w) => (w === INJECT_TOKEN ? injectFlags() : [w]));
|
|
79
|
-
|
|
80
|
-
/** 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). */
|
|
81
|
-
const copyLine = (directory) => `COPY ${directory} ./${directory}`;
|
|
82
|
-
export function depsCopyDirs(dockerfileText) {
|
|
83
|
-
const deps = /^FROM base AS deps\n([\s\S]*?)^COPY --parents/m.exec(dockerfileText);
|
|
84
|
-
if (deps === null) throw new Error('the Dockerfile has no deps stage with COPY --parents — render it: xema-composed-image dockerfile --prebuilt --write');
|
|
85
|
-
return [...deps[1].matchAll(/^COPY (\S+\/) \.\/\1$/gm)].map((m) => m[1]);
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** 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. */
|
|
89
|
-
export const REBUILD_EXCLUDED = Object.freeze(['prisma', '@prisma/client', '@prisma/engines', '@nestjs/core', 'esbuild', '@swc/core']);
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* The `onlyBuiltDependencies` the image must still `pnpm rebuild`: those in the members' PROD closure
|
|
93
|
-
* (`prodPackages`: `[{ name, dir }]`) less `REBUILD_EXCLUDED`. A module that needs `node-gyp` (a `binding.gyp`
|
|
94
|
-
* and no optional prebuilt packages) cannot be built here (the install is offline, `--ignore-scripts`, and the base
|
|
95
|
-
* has no toolchain), so it is refused: build it on a runner of the target arch and stage the `.node` files.
|
|
96
|
-
*/
|
|
97
|
-
export function rebuildList({ built, prodPackages }) {
|
|
98
|
-
const present = new Map(prodPackages.map((p) => [p.name, p.dir]));
|
|
99
|
-
const names = built.filter((name) => !REBUILD_EXCLUDED.includes(name) && present.has(name));
|
|
100
|
-
for (const name of names) {
|
|
101
|
-
const dir = present.get(name);
|
|
102
|
-
let optional = 0;
|
|
103
|
-
try {
|
|
104
|
-
optional = Object.keys(JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).optionalDependencies ?? {}).length;
|
|
105
|
-
} catch {
|
|
106
|
-
/* unreadable manifest: judged by binding.gyp alone */
|
|
107
|
-
}
|
|
108
|
-
if (existsSync(join(dir, 'binding.gyp')) && optional === 0) {
|
|
109
|
-
throw new Error(
|
|
110
|
-
`${name} needs node-gyp (binding.gyp, no optional prebuilt packages) and no native artifact is staged for it: ` +
|
|
111
|
-
'the prebuilt composed image installs offline with --ignore-scripts and has no toolchain. ' +
|
|
112
|
-
'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.',
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
}
|
|
116
|
-
return names;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const defaultExec = (file, args, { cwd }) => execFileSync(file, args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
120
|
-
|
|
121
|
-
/** `[{ name, dir }]` for every installed package in the members' prod closure (`pnpm ls --prod --parseable`); needs `pnpm install` to have run. */
|
|
122
|
-
export function memberProdPackages({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, exec = defaultExec } = {}) {
|
|
123
|
-
const words = memberFilters({ root, imageDir }).flatMap((f) => f.split(' '));
|
|
124
|
-
const listing = exec('pnpm', ['ls', '-r', '--prod', '--depth', 'Infinity', '--parseable', ...words], { cwd: root });
|
|
125
|
-
const seen = new Map();
|
|
126
|
-
for (const line of listing.split('\n').map((l) => l.trim()).filter(Boolean)) {
|
|
127
|
-
if (!line.includes('node_modules')) continue; // workspace packages themselves
|
|
128
|
-
let dir;
|
|
129
|
-
try {
|
|
130
|
-
dir = realpathSync(line);
|
|
131
|
-
const name = JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')).name;
|
|
132
|
-
if (typeof name === 'string') seen.set(name, dir);
|
|
133
|
-
} catch {
|
|
134
|
-
/* not an installed package directory */
|
|
135
|
-
}
|
|
136
|
-
}
|
|
137
|
-
if (seen.size === 0) throw new Error('pnpm ls --prod listed no installed package: run pnpm install --frozen-lockfile before rendering the prebuilt Dockerfile');
|
|
138
|
-
return [...seen].map(([name, dir]) => ({ name, dir }));
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** Where the deps stage bind-mounts `<stage dir>/prisma-engines` (`<enginesVersion>/schema-engine-<binaryTarget>`). */
|
|
142
|
-
const PRISMA_ENGINES_MOUNT = '/xema-prisma-engines';
|
|
47
|
+
const normalizedCopy = (copy) => [...new Set(copy.map((d) => posix.normalize(`${d}/`)))];
|
|
48
|
+
const copyLine = (directory) => `COPY ${directory} /${COPIED_DIRECTORY}/${directory}`;
|
|
143
49
|
|
|
144
|
-
/**
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
function prismaSchemaEngineStep({ toolMount, node, stageDir }) {
|
|
151
|
-
return `
|
|
152
|
-
# The Prisma SCHEMA ENGINE for this image's platform. \`prisma migrate deploy\` runs it (the
|
|
153
|
-
# Rust-free client does not replace it), and the prisma CLI downloads it into @prisma/engines
|
|
154
|
-
# at RUNTIME when it is missing, which the read-only, non-root container refuses. The install
|
|
155
|
-
# above is --ignore-scripts, so @prisma/engines' postinstall never fetched it. The runner
|
|
156
|
-
# staged it for every image platform at the lockfile's engines version (Prisma's downloader,
|
|
157
|
-
# sha256-checked) in \`${stageDir}/prisma-engines/\`; this copies the one for the platform
|
|
158
|
-
# Prisma computes HERE into each @prisma/engines a prisma CLI resolves, and fails the build
|
|
159
|
-
# unless it runs and reports that version. Here in deps, so the final stage carries it.
|
|
160
|
-
RUN --network=none ${toolMount} \\
|
|
161
|
-
--mount=type=bind,source=${stageDir}/prisma-engines,target=${PRISMA_ENGINES_MOUNT} \\
|
|
162
|
-
node ${node} prisma-schema-engine ${PRISMA_ENGINES_MOUNT}
|
|
163
|
-
`;
|
|
50
|
+
/** What the stage reads back from the committed Dockerfile, so the stage and the render have one definition: the `--copy` directories and the build source. */
|
|
51
|
+
export function parseRenderedDockerfile(dockerfileText) {
|
|
52
|
+
const copy = [...dockerfileText.matchAll(new RegExp(`^COPY (\\S+/) /${COPIED_DIRECTORY}/\\1$`, 'gm'))].map((m) => m[1]);
|
|
53
|
+
const buildSource = new RegExp(`^LABEL ${BUILD_SOURCE_LABEL.replaceAll('.', '\\.')}="([^"]+)"$`, 'm').exec(dockerfileText)?.[1];
|
|
54
|
+
if (buildSource === undefined) throw new Error('the Dockerfile carries no build-source label — render it: xema-composed-image dockerfile --prebuilt --write');
|
|
55
|
+
return { copy, buildSource };
|
|
164
56
|
}
|
|
165
57
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
export function renderPrebuiltDockerfile({
|
|
170
|
-
root = process.cwd(),
|
|
171
|
-
imageDir = COMPOSED_IMAGE_DIR,
|
|
172
|
-
copy = [],
|
|
173
|
-
cacheId,
|
|
174
|
-
buildSource,
|
|
175
|
-
usesToken,
|
|
176
|
-
stageDir = DEFAULT_STAGE_DIR,
|
|
177
|
-
base,
|
|
178
|
-
exec,
|
|
179
|
-
prodPackages = [],
|
|
180
|
-
}) {
|
|
181
|
-
if (typeof usesToken !== 'boolean') throw new Error('usesToken must be a boolean derived from .npmrc');
|
|
182
|
-
validate({ copy, cacheId, buildSource, stageDir, base });
|
|
58
|
+
/** The prebuilt Dockerfile text. The members' literal filters and the root build inputs are derived from `root`. */
|
|
59
|
+
export function renderPrebuiltDockerfile({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, copy = [], buildSource, stageDir = DEFAULT_STAGE_DIR, base, nodeMajor }) {
|
|
60
|
+
validate({ copy, buildSource, stageDir, base, nodeMajor });
|
|
183
61
|
const toolingVersion = toolVersion();
|
|
184
|
-
const workspaceFile = join(root, 'pnpm-workspace.yaml');
|
|
185
|
-
const built = existsSync(workspaceFile) ? onlyBuiltDependencies(readFileSync(workspaceFile, 'utf8')) : [];
|
|
186
|
-
if (built.length === 0) {
|
|
187
|
-
throw new Error('pnpm-workspace.yaml has no onlyBuiltDependencies: — `pnpm rebuild` would have nothing to build; refusing to render');
|
|
188
|
-
}
|
|
189
|
-
const closure = memberClosure({ root, imageDir, exec });
|
|
190
|
-
const rebuild = rebuildList({ built, prodPackages });
|
|
191
62
|
const filters = memberFilters({ root, imageDir }).join(' ');
|
|
192
|
-
const
|
|
63
|
+
const rootInputs = rootBuildInputNames(root);
|
|
193
64
|
const dirFlag = imageDir === COMPOSED_IMAGE_DIR ? '' : ` --image-dir ${imageDir}`;
|
|
194
65
|
const toolDir = '/opt/xema-composed-image';
|
|
195
66
|
const toolMount = `--mount=type=bind,source=${stageDir}/tool,target=${toolDir}`;
|
|
196
67
|
const node = `${toolDir}/${IN_IMAGE_ENTRY}`;
|
|
197
|
-
const
|
|
198
|
-
const
|
|
199
|
-
const
|
|
200
|
-
const tokenExport = usesToken
|
|
201
|
-
? 'export NPM_TOKEN="$(cat /run/secrets/npm_token)" && export GH_PACKAGES_TOKEN="$NPM_TOKEN" && \\\n '
|
|
202
|
-
: '';
|
|
203
|
-
const copies = [...new Set(copy.map((d) => posix.normalize(`${d}/`)))].map(copyLine).join('\n');
|
|
204
|
-
const manifestSources = closure.map((directory) => `${directory}/package.json`);
|
|
205
|
-
const assembleSources = [...closure.flatMap((directory) => packageSources({ root, directory })), ...manifests];
|
|
206
|
-
const imageCopy = posix.normalize(`${imageDir}/`);
|
|
207
|
-
const text = `# @generated by \`xema-composed-image dockerfile --prebuilt --write\` (@xemahq/repo-build-tooling@${toolingVersion}) — DO NOT EDIT.
|
|
68
|
+
const copies = normalizedCopy(copy).map(copyLine).join('\n');
|
|
69
|
+
const layers = `COPY ${stageDir}/${STAGE_STORE_LAYER}/ /\nCOPY ${stageDir}/${STAGE_APP_LAYER}/ /`;
|
|
70
|
+
const dockerfile = `# @generated by \`xema-composed-image dockerfile --prebuilt --write\` (@xemahq/repo-build-tooling@${toolingVersion}) — DO NOT EDIT.
|
|
208
71
|
# Re-render with the same options; \`--check\` refuses a stale file (and a stale Dockerfile.dockerignore).
|
|
209
72
|
#
|
|
210
|
-
# The COMPOSED-PROCESS image of this repository,
|
|
211
|
-
# per build (repository at commit), every member of \`image-build-inputs.json\` in ONE module
|
|
212
|
-
# graph behind the SDK's \`xema-composed-process\` host. Docker
|
|
213
|
-
# runner
|
|
214
|
-
# \`xema-composed-image stage ${stageDir}
|
|
215
|
-
#
|
|
216
|
-
#
|
|
73
|
+
# The COMPOSED-PROCESS image of this repository, COPIED from a tree the CI runner staged: ONE
|
|
74
|
+
# image per build (repository at commit), every member of \`image-build-inputs.json\` in ONE module
|
|
75
|
+
# graph behind the SDK's \`xema-composed-process\` host. Docker installs nothing, compiles nothing
|
|
76
|
+
# and has no network. The runner built the members (\`turbo run build prisma:generate\`) and
|
|
77
|
+
# \`xema-composed-image stage ${stageDir} --platform linux/<arch>\` produced, from the job's own
|
|
78
|
+
# pnpm store, the production tree of the members' closures for that platform (the install, the
|
|
79
|
+
# injected workspace packages, the member index, the Prisma clients and the platform's schema
|
|
80
|
+
# engine) and wrote it to \`${stageDir}/${STAGE_STORE_LAYER}/\` (the \`.pnpm\` packages, stable until the lockfile changes) and
|
|
81
|
+
# \`${stageDir}/${STAGE_APP_LAYER}/\` (everything else). \`${stageDir}/tool/\` is this tool, bind-mounted into the steps
|
|
82
|
+
# below and never copied into a layer.
|
|
217
83
|
#
|
|
218
84
|
# Stages:
|
|
219
|
-
#
|
|
220
|
-
#
|
|
221
|
-
#
|
|
222
|
-
#
|
|
223
|
-
#
|
|
224
|
-
#
|
|
225
|
-
#
|
|
226
|
-
# runs only for \`onlyBuiltDependencies\` present in the members' PROD closure, less
|
|
227
|
-
# prisma, @prisma/client, @prisma/engines (Prisma 7 + pg adapter ships no engine and
|
|
228
|
-
# migrations run in-app), @nestjs/core, esbuild and @swc/core; when none is left the
|
|
229
|
-
# step is not rendered. The list is rendered, so a change makes \`--check\` stale.
|
|
230
|
-
# assemble source of the closure + the staged outputs, the injected copies synced to
|
|
231
|
-
# their \`files\`, then the proofs on the target arch: Prisma clients run, the
|
|
232
|
-
# member index is written, every member loads ALONE as \`node\`, production.
|
|
233
|
-
# (final) \`/monorepo\` of assemble, shipped as THREE layers so a commit that changes only
|
|
234
|
-
# members' code re-pushes only the layers that changed (see the final stage).
|
|
235
|
-
# Nothing of the tool ships.
|
|
236
|
-
#
|
|
237
|
-
# NATIVE MODULES. No prod-closure module needs node-gyp today. The renderer REFUSES to render when
|
|
238
|
-
# one appears (a \`binding.gyp\` and no optional prebuilt packages) because this image installs with
|
|
239
|
-
# --ignore-scripts and carries no compiler. The path then: build the module on a runner of EACH target
|
|
240
|
-
# arch natively (the \`arm\` runner for arm64; no QEMU), in the digest-pinned bookworm node image with
|
|
241
|
-
# --network=none and the image's node headers, stage \`native/<arch>/<pkg>/*.node\`, and COPY them from
|
|
242
|
-
# \`native/\${TARGETARCH}/\` in the deps stage.
|
|
85
|
+
# proof The staged tree and the repository's inputs meet. The root build inputs that decide the
|
|
86
|
+
# staged bytes are COPIED into \`/${PROOF_DIRECTORY}/${CONTEXT_DIRECTORY}/\` and the \`--copy\` directories into
|
|
87
|
+
# \`/${COPIED_DIRECTORY}/\`, which puts them in the image's content tag. \`prove\` refuses the build unless the
|
|
88
|
+
# stage was made at BUILD_SHA from exactly those bytes, with this tool, for this platform, and
|
|
89
|
+
# the literal \`--filter\` words below are the ones it staged. Then the Prisma clients run and every
|
|
90
|
+
# member loads ALONE as \`node\`, production, on the target arch.
|
|
91
|
+
# (final) the same two COPYs of the staged tree, plus the proof's verdict, so the image cannot exist without it.
|
|
243
92
|
#
|
|
244
93
|
# The \`--filter <package>...\` words below are LITERAL: the content hash of this image reads
|
|
245
94
|
# them to narrow the lockfile to the members' closures.
|
|
246
|
-
FROM scratch AS xema-pnpm
|
|
247
|
-
FROM scratch AS xema-pnpm-store
|
|
248
|
-
|
|
249
95
|
FROM ${base} AS base
|
|
250
|
-
RUN corepack enable && \\
|
|
251
|
-
pnpm config set store-dir /root/.pnpm-store
|
|
252
96
|
|
|
253
|
-
FROM base AS
|
|
254
|
-
WORKDIR
|
|
255
|
-
ENV CI=true
|
|
256
|
-
# corepack must not download pnpm: the packed tarball comes from the runner.
|
|
257
|
-
ENV COREPACK_ENABLE_NETWORK=0
|
|
258
|
-
# The runner's store is bind-mounted here. Set it for pnpm explicitly: the base layer's \`pnpm config set\` was
|
|
259
|
-
# written by whatever pnpm corepack fetched then, and an offline resolve falls back to the store index.
|
|
260
|
-
ENV npm_config_store_dir=/root/.pnpm-store
|
|
261
|
-
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml .npmrc ./
|
|
262
|
-
${copies === '' ? '' : `${copies}\n`}COPY --parents \\
|
|
263
|
-
${parts(manifestSources)}
|
|
264
|
-
./
|
|
265
|
-
RUN --network=none --mount=type=bind,from=xema-pnpm,target=/xema-pnpm \\
|
|
266
|
-
test -f /xema-pnpm/staged.json || { \\
|
|
267
|
-
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; \\
|
|
268
|
-
exit 1; \\
|
|
269
|
-
} && corepack install -g /xema-pnpm/corepack/pnpm.tgz
|
|
270
|
-
RUN --network=none ${store} \\
|
|
271
|
-
${metadata} \\
|
|
272
|
-
${secret} ${toolMount} \\
|
|
273
|
-
${tokenExport}INJECT="${injectFlags().join(' ')}" && \\
|
|
274
|
-
cp pnpm-lock.yaml /tmp/pnpm-lock.committed.yaml && \\
|
|
275
|
-
${lockfileOnlyWords(filters.split(' ')).join(' ')} && \\
|
|
276
|
-
node ${node} pins${dirFlag} /tmp/pnpm-lock.committed.yaml && \\
|
|
277
|
-
pnpm install --prod --offline --ignore-scripts \${INJECT} ${filters} --frozen-lockfile${rebuild.length === 0 ? '' : ` && \\
|
|
278
|
-
pnpm rebuild ${rebuild.join(' ')} ${filters}`}
|
|
279
|
-
${needsPrismaSchemaEngine(prodPackages) ? prismaSchemaEngineStep({ toolMount, node, stageDir }) : ''}
|
|
280
|
-
FROM deps AS assemble
|
|
281
|
-
COPY --parents \\
|
|
282
|
-
${parts(assembleSources)}
|
|
283
|
-
./
|
|
284
|
-
COPY ${imageCopy} ./${imageCopy}
|
|
285
|
-
COPY ${stageDir}/out/ ./
|
|
97
|
+
FROM base AS proof
|
|
98
|
+
WORKDIR /${PROOF_DIRECTORY}
|
|
286
99
|
ARG BUILD_SHA
|
|
287
|
-
|
|
100
|
+
COPY ${stageDir}/${STAGE_PROOF_DIRECTORY}/${COMPOSED_MANIFEST_FILE} /${PROOF_DIRECTORY}/
|
|
101
|
+
COPY ${rootInputs.join(' ')} /${PROOF_DIRECTORY}/${CONTEXT_DIRECTORY}/
|
|
102
|
+
${copies === '' ? '' : `${copies}\n`}${layers}
|
|
103
|
+
RUN --network=none ${toolMount} \\
|
|
104
|
+
: "\${BUILD_SHA:?BUILD_SHA build-arg is required}" && \\
|
|
288
105
|
T="node ${node}" && \\
|
|
289
|
-
$T
|
|
290
|
-
|
|
291
|
-
cp ${toolDir}/src/composed-migrate.mjs /monorepo/xema-composed-migrate.mjs
|
|
106
|
+
$T prove /${PROOF_DIRECTORY} --root /monorepo --commit "\${BUILD_SHA}" ${filters}${dirFlag} && \\
|
|
107
|
+
$T prisma-check --root /monorepo${dirFlag}
|
|
292
108
|
|
|
293
109
|
# Every member's module graph loads here, ALONE (one process each), through the host's own
|
|
294
110
|
# entry reader, as the user and NODE_ENV the image runs with, or the image is never pushed.
|
|
295
111
|
USER node
|
|
296
|
-
RUN ${toolMount} \\
|
|
297
|
-
NODE_ENV=production node ${node} load --each${dirFlag}
|
|
112
|
+
RUN --network=none ${toolMount} \\
|
|
113
|
+
NODE_ENV=production node ${node} load --each --root /monorepo${dirFlag}
|
|
298
114
|
|
|
299
|
-
# The final stage is layered by how often each part changes, so a registry and a node
|
|
300
|
-
# already holding the stable layers fetch only the rest. The three copies overlay to
|
|
301
|
-
# exactly assemble's /monorepo (proven by a tree diff of both images).
|
|
302
|
-
# 1 deps root manifests, the external .pnpm store, and the injected stubs that hold
|
|
303
|
-
# only package.json. Stable while the lockfile and the closure's package.json
|
|
304
|
-
# files are unchanged.
|
|
305
|
-
# 2 assemble members, dist, Prisma clients, member index, migrate script: everything
|
|
306
|
-
# but the .pnpm store, whose packages layer 1 already carries.
|
|
307
|
-
# 3 injected the workspace copies \`node_modules/.pnpm/*@file+*\`: \`sync-injected\` fills
|
|
308
|
-
# them from the members' files, so they change with every commit. \`--parents\`
|
|
309
|
-
# keeps their paths under /.
|
|
310
115
|
FROM base
|
|
311
116
|
WORKDIR /monorepo
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
COPY --from=
|
|
117
|
+
LABEL ${NODE_MAJOR_LABEL}="${nodeMajor}"
|
|
118
|
+
${layers}
|
|
119
|
+
COPY --from=proof /${PROOF_DIRECTORY}/${VERIFIED_IMAGE_FILE} /monorepo/${VERIFIED_IMAGE_FILE}
|
|
120
|
+
LABEL ${BUILD_SOURCE_LABEL}="${buildSource}"
|
|
315
121
|
ENV NODE_ENV=production
|
|
316
122
|
# Set by the IMAGE, never by a deployment (kernel \`COMPOSED_PROCESS_ENV.MemberIndex\`).
|
|
317
123
|
ENV XEMA_COMPOSED_MEMBER_INDEX=/monorepo/xema-composed-member-index.json
|
|
318
124
|
USER node
|
|
319
125
|
CMD ["node", "/monorepo/xema-composed-process"]
|
|
320
126
|
`;
|
|
321
|
-
return { dockerfile
|
|
127
|
+
return { dockerfile, dockerignore: renderPrebuiltDockerignore({ stageDir }) };
|
|
322
128
|
}
|
|
323
129
|
|
|
324
130
|
/** `--write` writes both files; `--check` throws when either differs. */
|
|
325
|
-
export function syncPrebuiltDockerfile({ mode, root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR,
|
|
131
|
+
export function syncPrebuiltDockerfile({ mode, root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, ...options }) {
|
|
326
132
|
if (mode !== 'write' && mode !== 'check') throw new Error(`mode must be write or check, got "${mode}"`);
|
|
327
|
-
const
|
|
328
|
-
if (!existsSync(npmrc)) throw new Error(`${npmrc} is absent — the Dockerfile copies it and its token use decides the build secret`);
|
|
329
|
-
const prodPackages = options.prodPackages ?? memberProdPackages({ root, imageDir, exec });
|
|
330
|
-
const { dockerfile, dockerignore } = renderPrebuiltDockerfile({ ...options, root, imageDir, exec, prodPackages, usesToken: npmrcUsesToken(readFileSync(npmrc, 'utf8')) });
|
|
133
|
+
const { dockerfile, dockerignore } = renderPrebuiltDockerfile({ ...options, root, imageDir });
|
|
331
134
|
const files = [
|
|
332
135
|
[posix.join(imageDir, 'Dockerfile'), dockerfile],
|
|
333
136
|
[posix.join(imageDir, 'Dockerfile.dockerignore'), dockerignore],
|