@xemahq/repo-build-tooling 0.24.0 → 0.25.1
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-fixture.test.mjs +7 -3
- package/src/composed-image-in-image-closure.test.mjs +2 -2
- package/src/composed-image-package-files.mjs +33 -0
- package/src/composed-image-paths.mjs +3 -0
- package/src/composed-image-pnpm-context.test.mjs +3 -3
- package/src/composed-image-prebuilt-dockerfile.mjs +16 -13
- package/src/composed-image-prebuilt-dockerfile.test.mjs +45 -3
- package/src/composed-image-stage.mjs +5 -5
- package/src/composed-image-stage.test.mjs +4 -4
- package/src/composed-image.mjs +6 -4
package/README.md
CHANGED
|
@@ -37,7 +37,7 @@ pnpm exec xema-scrub-swagger-paths dist
|
|
|
37
37
|
| `xema-prepush-sanity` | Lefthook `pre-push`: turbo `lint` and `typecheck` on what the push changes plus dependents, and `test` and `openapi:current` (where defined) only on the packages the push changed; a fleet-wide change runs only `lint` and `typecheck` on the full graph; root `lint` only without turbo; a park-only push (`refs/xema-session/*`) skips |
|
|
38
38
|
| `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
|
|
39
39
|
|
|
40
|
-
`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
|
|
40
|
+
`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`, `--base <ref@sha256:…>`, the digest-pinned image the stages start FROM (a reference without a digest is refused), and `--node-major <n>`, that base's Node major, rendered as the `dev.xema.composed-image.node-major` label, which `stage` compares with the runner's Node; `--copy` and `--stage-dir` optional). The assemble stage copies per package only `package.json` and its plain `files` entries (what `pnpm pack` ships), so a README or test edit changes no image; a package without a `files` array or with a glob entry is refused; `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.
|
|
41
41
|
|
|
42
42
|
Every step refuses rather than skips. The package has no dependencies.
|
|
43
43
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.1",
|
|
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)",
|
|
@@ -54,6 +54,6 @@
|
|
|
54
54
|
"test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-pnpm-context.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-image-prisma-engine.test.mjs && node --test src/composed-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs && node --test src/prepush-sanity.test.mjs && node --test src/service-image.test.mjs && node --test src/install-git-hooks.test.mjs"
|
|
55
55
|
},
|
|
56
56
|
"xemaRelease": {
|
|
57
|
-
"sourceDigest": "sha256:
|
|
57
|
+
"sourceDigest": "sha256:d6fa7e246feba0804afe8de72c9ef8286cfd3a9517d5e7dca81a9fec1b3c127a"
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -9,6 +9,8 @@ export function write(root, rel, content) {
|
|
|
9
9
|
fs.writeFileSync(path.join(root, rel), typeof content === 'string' ? content : JSON.stringify(content, null, 2));
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
+
export const BASE = 'ghcr.io/example/base/node-runtime@sha256:' + 'a'.repeat(64);
|
|
13
|
+
|
|
12
14
|
export const MEMBERS = Object.freeze({ 'alpha/alpha-api': 'biomes/alpha/api/alpha-api', 'alpha/alpha-db-api': 'biomes/alpha/api/alpha-db-api' });
|
|
13
15
|
export const CLOSURE = Object.freeze(['biomes/alpha/api/alpha-api', 'biomes/alpha/api/alpha-db-api', 'packages/sdks/lib-a']);
|
|
14
16
|
|
|
@@ -26,10 +28,12 @@ export function workspace({ built = true } = {}) {
|
|
|
26
28
|
write(root, 'infra/composed-process/image-build-inputs.json', { workspacePackages: Object.values(MEMBERS) });
|
|
27
29
|
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
30
|
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-api/package.json', { name: '@t/alpha-api', files: ['dist'] });
|
|
32
|
+
write(root, 'biomes/alpha/api/alpha-db-api/package.json', { name: '@t/alpha-db-api', files: ['dist', 'prisma'] });
|
|
31
33
|
write(root, 'biomes/alpha/api/alpha-db-api/prisma/schema.prisma', 'datasource db {}\n');
|
|
32
|
-
write(root, '
|
|
34
|
+
write(root, 'biomes/alpha/api/alpha-api/README.md', '# readme\n');
|
|
35
|
+
write(root, 'biomes/alpha/api/alpha-api/src/main.test.ts', '// test\n');
|
|
36
|
+
write(root, 'packages/sdks/lib-a/package.json', { name: '@t/lib-a', files: ['dist', 'assets/data.json'] });
|
|
33
37
|
if (built) {
|
|
34
38
|
write(root, 'biomes/alpha/api/alpha-api/dist/main.js', '// main\n');
|
|
35
39
|
write(root, 'biomes/alpha/api/alpha-db-api/dist/main.js', '// main\n');
|
|
@@ -9,7 +9,7 @@ import test, { after } from 'node:test';
|
|
|
9
9
|
|
|
10
10
|
import { renderPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
|
|
11
11
|
import { copyTool } from './composed-image-stage.mjs';
|
|
12
|
-
import { fakePnpm, workspace } from './composed-image-fixture.test.mjs';
|
|
12
|
+
import { BASE, fakePnpm, workspace } from './composed-image-fixture.test.mjs';
|
|
13
13
|
|
|
14
14
|
const temps = [];
|
|
15
15
|
after(() => {
|
|
@@ -28,7 +28,7 @@ function renderedEntries() {
|
|
|
28
28
|
root,
|
|
29
29
|
copy: ['tooling/dev'],
|
|
30
30
|
cacheId: 'alpha-composed-process',
|
|
31
|
-
buildSource: 'alpha',
|
|
31
|
+
buildSource: 'alpha', base: BASE, nodeMajor: '22',
|
|
32
32
|
usesToken: true,
|
|
33
33
|
exec: fakePnpm(root),
|
|
34
34
|
});
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// What one workspace package contributes to the assemble stage's build context: its
|
|
2
|
+
// `package.json` plus the `files` entries `pnpm pack` ships. Nothing else of the directory
|
|
3
|
+
// (README, tests, configs) enters the context, so editing it changes no image.
|
|
4
|
+
import { readFileSync } from 'node:fs';
|
|
5
|
+
import { join, posix } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/** Directories and files the prebuilt `.dockerignore` keeps out of the context (the staged outputs bring them back). */
|
|
8
|
+
export const IGNORED_DIRECTORIES = Object.freeze(['node_modules', 'dist', '.turbo', 'coverage']);
|
|
9
|
+
export const IGNORED_FILE_PATTERNS = Object.freeze(['*.tsbuildinfo', '*.log']);
|
|
10
|
+
|
|
11
|
+
const GLOB = /[*?[\]{}!]/;
|
|
12
|
+
const ignoredFile = (name) => name.endsWith('.tsbuildinfo') || name.endsWith('.log');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Repository-relative COPY sources for `directory`: `package.json` and each plain `files` entry. An entry the
|
|
16
|
+
* `.dockerignore` excludes (e.g. `dist`) is built output, staged separately, and is not a source here.
|
|
17
|
+
* A package with no `files` array, or a glob entry, is refused: `files` is what says what ships.
|
|
18
|
+
*/
|
|
19
|
+
export function packageSources({ root, directory }) {
|
|
20
|
+
const files = JSON.parse(readFileSync(join(root, directory, 'package.json'), 'utf8')).files;
|
|
21
|
+
if (!Array.isArray(files)) throw new Error(`${directory}/package.json has no "files" field — nothing says which of its files ship in the image`);
|
|
22
|
+
const globs = files.filter((entry) => typeof entry !== 'string' || GLOB.test(entry));
|
|
23
|
+
if (globs.length > 0) throw new Error(`${directory}/package.json "files" has glob or non-string entries (${globs.join(', ')}); list plain paths`);
|
|
24
|
+
const entries = files.map((entry) => posix.normalize(entry).replace(/\/$/, ''));
|
|
25
|
+
for (const entry of entries) {
|
|
26
|
+
if (entry === '.' || entry.startsWith('/') || entry.split('/').includes('..')) throw new Error(`${directory}/package.json "files" entry "${entry}" is not inside the package`);
|
|
27
|
+
}
|
|
28
|
+
const shipped = entries.filter((entry) => {
|
|
29
|
+
const parts = entry.split('/');
|
|
30
|
+
return !parts.some((part) => IGNORED_DIRECTORIES.includes(part)) && !ignoredFile(parts.at(-1));
|
|
31
|
+
});
|
|
32
|
+
return [...new Set(['package.json', ...shipped])].map((entry) => posix.join(directory, entry));
|
|
33
|
+
}
|
|
@@ -3,3 +3,6 @@ export const COMPOSED_IMAGE_DIR = 'infra/composed-process';
|
|
|
3
3
|
|
|
4
4
|
// The one entry the prebuilt image runs from the bind-mounted tool directory: loads only `node:` and relative modules.
|
|
5
5
|
export const IN_IMAGE_ENTRY = 'src/composed-image-in-image.mjs';
|
|
6
|
+
|
|
7
|
+
/** The image label that carries the Node major of the pinned base: a digest reference does not name it, and the stage compares the runner's Node with it. */
|
|
8
|
+
export const NODE_MAJOR_LABEL = 'dev.xema.composed-image.node-major';
|
|
@@ -6,14 +6,14 @@ import test from 'node:test';
|
|
|
6
6
|
|
|
7
7
|
import { buildPnpmContext, resolveCacheDir } from './composed-image-pnpm-context.mjs';
|
|
8
8
|
import { lockfileOnlyCommand, renderPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
|
|
9
|
-
import { CLOSURE, fakePnpm, workspace, write } from './composed-image-fixture.test.mjs';
|
|
9
|
+
import { BASE, CLOSURE, fakePnpm, workspace, write } from './composed-image-fixture.test.mjs';
|
|
10
10
|
|
|
11
11
|
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
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);
|
|
16
|
+
write(root, 'infra/composed-process/Dockerfile', renderPrebuiltDockerfile({ root, copy: ['tooling/dev'], cacheId: 'x', buildSource: 'repo', base: BASE, nodeMajor: '22', usesToken: false, exec: fakePnpm(root) }).dockerfile);
|
|
17
17
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-pnpm-home-'));
|
|
18
18
|
const storePath = path.join(home, '.pnpm-store', 'v10');
|
|
19
19
|
const cacheDir = path.join(home, 'cache');
|
|
@@ -89,7 +89,7 @@ test('the lockfile-only command is the one the generated Dockerfile runs (no dri
|
|
|
89
89
|
assert.deepEqual(words.slice(0, 4), ['pnpm', 'install', '--lockfile-only', '--offline']);
|
|
90
90
|
assert.equal(lockfileOnlyCommand(['--filter', 'x'], { offline: false })[3], '--prefer-offline');
|
|
91
91
|
assert.ok(words.includes('--config.inject-workspace-packages=true') && words.includes('--config.dedupe-injected-deps=false'));
|
|
92
|
-
const { dockerfile: text } = renderPrebuiltDockerfile({ root, cacheId: 'x', buildSource: 'repo', usesToken: false, exec: fakePnpm(root) });
|
|
92
|
+
const { dockerfile: text } = renderPrebuiltDockerfile({ root, cacheId: 'x', buildSource: 'repo', base: BASE, nodeMajor: '22', usesToken: false, exec: fakePnpm(root) });
|
|
93
93
|
assert.ok(text.includes('pnpm install --lockfile-only --offline ${INJECT} --filter @t/alpha-api... --filter @t/alpha-db-api... &&'));
|
|
94
94
|
assert.ok(text.includes('INJECT="--config.inject-workspace-packages=true --config.dedupe-injected-deps=false"'));
|
|
95
95
|
});
|
|
@@ -7,9 +7,10 @@ import { execFileSync } from 'node:child_process';
|
|
|
7
7
|
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
|
|
8
8
|
import { dirname, join, posix } from 'node:path';
|
|
9
9
|
|
|
10
|
-
import { COMPOSED_IMAGE_DIR, IN_IMAGE_ENTRY } from './composed-image-paths.mjs';
|
|
10
|
+
import { COMPOSED_IMAGE_DIR, IN_IMAGE_ENTRY, NODE_MAJOR_LABEL } from './composed-image-paths.mjs';
|
|
11
11
|
import { toolVersion } from './composed-image-stage.mjs';
|
|
12
|
-
import {
|
|
12
|
+
import { npmrcUsesToken } from './composed-image-dockerfile.mjs';
|
|
13
|
+
import { IGNORED_DIRECTORIES, IGNORED_FILE_PATTERNS, packageSources } from './composed-image-package-files.mjs';
|
|
13
14
|
import { memberClosure, memberFilters, memberManifests } from './composed-image-members.mjs';
|
|
14
15
|
import { needsPrismaSchemaEngine } from './composed-image-prisma-engine.mjs';
|
|
15
16
|
|
|
@@ -17,8 +18,13 @@ export const DEFAULT_STAGE_DIR = '.xema-stage';
|
|
|
17
18
|
const DIRECTORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*\/?$/;
|
|
18
19
|
const CACHE_ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
19
20
|
const REPOSITORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
|
21
|
+
const DIGEST_REFERENCE = /^[a-z0-9][a-z0-9._/-]*(:[A-Za-z0-9._-]+)?@sha256:[0-9a-f]{64}$/;
|
|
20
22
|
|
|
21
|
-
|
|
23
|
+
const NODE_MAJOR = /^[1-9][0-9]*$/;
|
|
24
|
+
|
|
25
|
+
function validate({ copy, cacheId, buildSource, stageDir, base, nodeMajor }) {
|
|
26
|
+
if (!NODE_MAJOR.test(nodeMajor ?? '')) throw new Error(`--node-major must be the Node major of the --base image (e.g. 22), got "${nodeMajor}"`);
|
|
27
|
+
if (!DIGEST_REFERENCE.test(base ?? '')) throw new Error(`--base must be an image reference pinned by digest (<ref>@sha256:<64 hex>), got "${base}"`);
|
|
22
28
|
for (const directory of [...(copy ?? []), stageDir]) {
|
|
23
29
|
if (!DIRECTORY.test(directory) || directory.startsWith('/') || directory.split('/').includes('..')) {
|
|
24
30
|
throw new Error(`"${directory}" is not a repository-relative directory`);
|
|
@@ -33,12 +39,7 @@ export function renderPrebuiltDockerignore({ stageDir = DEFAULT_STAGE_DIR } = {}
|
|
|
33
39
|
return `# @generated by \`xema-composed-image dockerfile --prebuilt --write\` — DO NOT EDIT.
|
|
34
40
|
# Local build output stays out of the context; the staged outputs carry dist and node_modules/.prisma paths, so they are re-included LAST.
|
|
35
41
|
.git
|
|
36
|
-
|
|
37
|
-
**/dist
|
|
38
|
-
**/.turbo
|
|
39
|
-
**/coverage
|
|
40
|
-
**/*.tsbuildinfo
|
|
41
|
-
**/*.log
|
|
42
|
+
${[...IGNORED_DIRECTORIES.map((d) => `**/${d}`), ...IGNORED_FILE_PATTERNS.map((p) => `**/${p}`)].join('\n')}
|
|
42
43
|
!${stageDir}/**
|
|
43
44
|
`;
|
|
44
45
|
}
|
|
@@ -176,11 +177,13 @@ export function renderPrebuiltDockerfile({
|
|
|
176
177
|
buildSource,
|
|
177
178
|
usesToken,
|
|
178
179
|
stageDir = DEFAULT_STAGE_DIR,
|
|
180
|
+
base,
|
|
181
|
+
nodeMajor,
|
|
179
182
|
exec,
|
|
180
183
|
prodPackages = [],
|
|
181
184
|
}) {
|
|
182
185
|
if (typeof usesToken !== 'boolean') throw new Error('usesToken must be a boolean derived from .npmrc');
|
|
183
|
-
validate({ copy, cacheId, buildSource, stageDir });
|
|
186
|
+
validate({ copy, cacheId, buildSource, stageDir, base, nodeMajor });
|
|
184
187
|
const toolingVersion = toolVersion();
|
|
185
188
|
const workspaceFile = join(root, 'pnpm-workspace.yaml');
|
|
186
189
|
const built = existsSync(workspaceFile) ? onlyBuiltDependencies(readFileSync(workspaceFile, 'utf8')) : [];
|
|
@@ -203,7 +206,7 @@ export function renderPrebuiltDockerfile({
|
|
|
203
206
|
: '';
|
|
204
207
|
const copies = [...new Set(copy.map((d) => posix.normalize(`${d}/`)))].map(copyLine).join('\n');
|
|
205
208
|
const manifestSources = closure.map((directory) => `${directory}/package.json`);
|
|
206
|
-
const assembleSources = [...closure, ...manifests];
|
|
209
|
+
const assembleSources = [...closure.flatMap((directory) => packageSources({ root, directory })), ...manifests];
|
|
207
210
|
const imageCopy = posix.normalize(`${imageDir}/`);
|
|
208
211
|
const text = `# @generated by \`xema-composed-image dockerfile --prebuilt --write\` (@xemahq/repo-build-tooling@${toolingVersion}) — DO NOT EDIT.
|
|
209
212
|
# Re-render with the same options; \`--check\` refuses a stale file (and a stale Dockerfile.dockerignore).
|
|
@@ -247,9 +250,8 @@ export function renderPrebuiltDockerfile({
|
|
|
247
250
|
FROM scratch AS xema-pnpm
|
|
248
251
|
FROM scratch AS xema-pnpm-store
|
|
249
252
|
|
|
250
|
-
FROM ${
|
|
253
|
+
FROM ${base} AS base
|
|
251
254
|
RUN corepack enable && \\
|
|
252
|
-
apt-get update && apt-get install -y --no-install-recommends openssl wget && rm -rf /var/lib/apt/lists/* && \\
|
|
253
255
|
pnpm config set store-dir /root/.pnpm-store
|
|
254
256
|
|
|
255
257
|
FROM base AS deps
|
|
@@ -311,6 +313,7 @@ RUN ${toolMount} \\
|
|
|
311
313
|
# keeps their paths under /.
|
|
312
314
|
FROM base
|
|
313
315
|
WORKDIR /monorepo
|
|
316
|
+
LABEL ${NODE_MAJOR_LABEL}="${nodeMajor}"
|
|
314
317
|
COPY --from=deps /monorepo /monorepo
|
|
315
318
|
COPY --from=assemble --exclude=node_modules/.pnpm /monorepo /monorepo
|
|
316
319
|
COPY --from=assemble --parents /monorepo/node_modules/.pnpm/*@file+* /
|
|
@@ -5,7 +5,7 @@ import test from 'node:test';
|
|
|
5
5
|
|
|
6
6
|
import { depsCopyDirs, memberProdPackages, onlyBuiltDependencies, renderPrebuiltDockerfile, renderPrebuiltDockerignore, syncPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
|
|
7
7
|
import { parseArguments } from './composed-image.mjs';
|
|
8
|
-
import { CLOSURE, fakePnpm, workspace } from './composed-image-fixture.test.mjs';
|
|
8
|
+
import { BASE, CLOSURE, fakePnpm, workspace } from './composed-image-fixture.test.mjs';
|
|
9
9
|
|
|
10
10
|
/** An installed package directory for the prod-closure listing: `{ name, dir }`. */
|
|
11
11
|
function installed(root, name, { gyp = false, optional = {} } = {}) {
|
|
@@ -17,7 +17,7 @@ function installed(root, name, { gyp = false, optional = {} } = {}) {
|
|
|
17
17
|
}
|
|
18
18
|
|
|
19
19
|
function options(root, extra = {}) {
|
|
20
|
-
return { root, copy: ['tooling/dev'], cacheId: 'alpha-composed-process', buildSource: 'alpha', usesToken: true, exec: fakePnpm(root), prodPackages: [], ...extra };
|
|
20
|
+
return { root, copy: ['tooling/dev'], cacheId: 'alpha-composed-process', buildSource: 'alpha', base: BASE, nodeMajor: '22', usesToken: true, exec: fakePnpm(root), prodPackages: [], ...extra };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
test('the closure comes from pnpm ls with the members filters, and the filters are literal per member', () => {
|
|
@@ -39,7 +39,7 @@ test('one COPY --parents per kind: manifests in deps, package dirs (and biome ma
|
|
|
39
39
|
assert.equal(dockerfile.match(/^COPY --parents/gm).length, 2);
|
|
40
40
|
const [deps, assemble] = dockerfile.split('FROM deps AS assemble');
|
|
41
41
|
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/);
|
|
42
|
-
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/);
|
|
42
|
+
assert.match(assemble, /COPY --parents \\\n {4}biomes\/alpha\/api\/alpha-api\/package\.json \\\n {4}biomes\/alpha\/api\/alpha-db-api\/package\.json \\\n {4}biomes\/alpha\/api\/alpha-db-api\/prisma \\\n {4}packages\/sdks\/lib-a\/package\.json \\\n {4}packages\/sdks\/lib-a\/assets\/data\.json \\\n {4}biomes\/alpha\/xema-biome\.json \\\n {4}\.\/\n/);
|
|
43
43
|
assert.match(assemble, /COPY infra\/composed-process\/ \.\/infra\/composed-process\/\nCOPY \.xema-stage\/out\/ \.\/\n/);
|
|
44
44
|
});
|
|
45
45
|
|
|
@@ -77,6 +77,8 @@ test('--write writes both files; --check refuses either stale, and a closure cha
|
|
|
77
77
|
const targets = syncPrebuiltDockerfile({ mode: 'write', ...options(root) });
|
|
78
78
|
assert.deepEqual(targets, ['infra/composed-process/Dockerfile', 'infra/composed-process/Dockerfile.dockerignore']);
|
|
79
79
|
syncPrebuiltDockerfile({ mode: 'check', ...options(root) });
|
|
80
|
+
fs.mkdirSync(path.join(root, 'packages/sdks/lib-b'), { recursive: true });
|
|
81
|
+
fs.writeFileSync(path.join(root, 'packages/sdks/lib-b/package.json'), JSON.stringify({ name: '@t/lib-b', files: ['dist'] }));
|
|
80
82
|
assert.throws(() => syncPrebuiltDockerfile({ mode: 'check', ...options(root, { exec: fakePnpm(root, [...CLOSURE, 'packages/sdks/lib-b']) }) }), /Dockerfile is stale/);
|
|
81
83
|
fs.appendFileSync(path.join(root, 'infra/composed-process/Dockerfile.dockerignore'), 'x\n');
|
|
82
84
|
assert.throws(() => syncPrebuiltDockerfile({ mode: 'check', ...options(root) }), /Dockerfile\.dockerignore is stale/);
|
|
@@ -199,3 +201,43 @@ test('the Prisma schema engine step renders in deps only when the prod closure c
|
|
|
199
201
|
const custom = renderPrebuiltDockerfile(options(root, { stageDir: 'build/stage', prodPackages: [installed(root, 'prisma')] })).dockerfile;
|
|
200
202
|
assert.match(custom, /--mount=type=bind,source=build\/stage\/prisma-engines,target=\/xema-prisma-engines/);
|
|
201
203
|
});
|
|
204
|
+
|
|
205
|
+
test('FROM is the digest-pinned base, and the base RUN installs no OS package', () => {
|
|
206
|
+
const { dockerfile } = renderPrebuiltDockerfile(options(workspace()));
|
|
207
|
+
assert.match(dockerfile, new RegExp(`^FROM ${BASE} AS base\\nRUN corepack enable && \\\\\\n {4}pnpm config set store-dir /root/\\.pnpm-store\\n`, 'm'));
|
|
208
|
+
assert.doesNotMatch(dockerfile, /apt-get|node:22-bookworm/);
|
|
209
|
+
for (const base of [undefined, 'ghcr.io/example/base/node-runtime', 'ghcr.io/example/base/node-runtime:1', `x@sha256:${'a'.repeat(63)}`]) {
|
|
210
|
+
assert.throws(() => renderPrebuiltDockerfile(options(workspace(), { base })), /--base must be an image reference pinned by digest/);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('the assemble COPY lists per package only package.json and the files entries, never README or tests', () => {
|
|
215
|
+
const assemble = renderPrebuiltDockerfile(options(workspace())).dockerfile.split('FROM deps AS assemble')[1].split('COPY infra/')[0];
|
|
216
|
+
assert.doesNotMatch(assemble, /README|\.test\.|\/src\b/);
|
|
217
|
+
assert.doesNotMatch(assemble, /alpha-api \\\n/, 'a package directory is never copied whole');
|
|
218
|
+
assert.doesNotMatch(assemble, /\/dist\b/, 'built output arrives from the stage dir');
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
test('a package with no files field or a glob entry is refused', () => {
|
|
222
|
+
const root = workspace();
|
|
223
|
+
fs.writeFileSync(path.join(root, 'packages/sdks/lib-a/package.json'), JSON.stringify({ name: '@t/lib-a' }));
|
|
224
|
+
assert.throws(() => renderPrebuiltDockerfile(options(root)), /packages\/sdks\/lib-a\/package\.json has no "files" field/);
|
|
225
|
+
fs.writeFileSync(path.join(root, 'packages/sdks/lib-a/package.json'), JSON.stringify({ name: '@t/lib-a', files: ['lib/**'] }));
|
|
226
|
+
assert.throws(() => renderPrebuiltDockerfile(options(root)), /glob/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('--check fails when the rendered base or a files entry changed', () => {
|
|
230
|
+
const root = workspace();
|
|
231
|
+
syncPrebuiltDockerfile({ mode: 'write', ...options(root) });
|
|
232
|
+
assert.throws(() => syncPrebuiltDockerfile({ mode: 'check', ...options(root, { base: `other/base@sha256:${'b'.repeat(64)}` }) }), /Dockerfile is stale/);
|
|
233
|
+
fs.writeFileSync(path.join(root, 'packages/sdks/lib-a/package.json'), JSON.stringify({ name: '@t/lib-a', files: ['dist', 'assets/data.json', 'extra'] }));
|
|
234
|
+
assert.throws(() => syncPrebuiltDockerfile({ mode: 'check', ...options(root) }), /Dockerfile is stale/);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('the Node major is a required, rendered fact of the digest-pinned base, and the render carries it as a label', () => {
|
|
238
|
+
const root = workspace();
|
|
239
|
+
assert.throws(() => renderPrebuiltDockerfile(options(root, { nodeMajor: undefined })), /--node-major/);
|
|
240
|
+
assert.throws(() => renderPrebuiltDockerfile(options(root, { nodeMajor: '22.1' })), /--node-major/);
|
|
241
|
+
const { dockerfile } = renderPrebuiltDockerfile(options(root));
|
|
242
|
+
assert.match(dockerfile, /^LABEL dev\.xema\.composed-image\.node-major="22"$/m);
|
|
243
|
+
});
|
|
@@ -16,7 +16,7 @@ import { copyFileSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSy
|
|
|
16
16
|
import { dirname, join, posix } from 'node:path';
|
|
17
17
|
import { fileURLToPath } from 'node:url';
|
|
18
18
|
|
|
19
|
-
import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
|
|
19
|
+
import { COMPOSED_IMAGE_DIR, NODE_MAJOR_LABEL } from './composed-image-paths.mjs';
|
|
20
20
|
import { memberClosure, readMembers } from './composed-image-members.mjs';
|
|
21
21
|
import { clientSchemaProblems, memberPrismaSchema } from './composed-migrate.mjs';
|
|
22
22
|
|
|
@@ -80,10 +80,10 @@ export async function stageBuildOutputs({
|
|
|
80
80
|
|
|
81
81
|
const problems = [];
|
|
82
82
|
const dockerfile = renderedDockerfile(root, imageDir);
|
|
83
|
-
const
|
|
84
|
-
if (
|
|
85
|
-
else if (
|
|
86
|
-
problems.push(`the runner's Node ${nodeMajor} differs from the image's Node ${
|
|
83
|
+
const declared = new RegExp(`^LABEL ${NODE_MAJOR_LABEL.replaceAll('.', '\\.')}="(\\d+)"$`, 'm').exec(dockerfile);
|
|
84
|
+
if (declared === null) problems.push(`${imageDir}/Dockerfile declares no base Node major (LABEL ${NODE_MAJOR_LABEL}): render it with --node-major`);
|
|
85
|
+
else if (declared[1] !== String(nodeMajor)) {
|
|
86
|
+
problems.push(`the runner's Node ${nodeMajor} differs from the image's Node ${declared[1]} (${imageDir}/Dockerfile)`);
|
|
87
87
|
}
|
|
88
88
|
const pinned = /@xemahq\/repo-build-tooling@(\d+\.\d+\.\d+)/.exec(dockerfile);
|
|
89
89
|
if (pinned === null) problems.push(`${imageDir}/Dockerfile header pins no @xemahq/repo-build-tooling@<x.y.z>`);
|
|
@@ -8,7 +8,7 @@ import { fakePnpm, formatNoop, workspace, write } from './composed-image-fixture
|
|
|
8
8
|
|
|
9
9
|
function ready(extra = {}) {
|
|
10
10
|
const root = workspace(extra);
|
|
11
|
-
write(root, 'infra/composed-process/Dockerfile', `# @generated (@xemahq/repo-build-tooling@${toolVersion()})\nFROM
|
|
11
|
+
write(root, 'infra/composed-process/Dockerfile', `# @generated (@xemahq/repo-build-tooling@${toolVersion()})\nFROM ghcr.io/example/base/node-runtime@sha256:${'a'.repeat(64)} AS base\nLABEL dev.xema.composed-image.node-major="22"\n`);
|
|
12
12
|
return root;
|
|
13
13
|
}
|
|
14
14
|
const stage = (root, extra = {}) => stageBuildOutputs({ root, dir: '.xema-stage', exec: fakePnpm(root), nodeMajor: '22', formatSpawn: formatNoop, ...extra });
|
|
@@ -55,18 +55,18 @@ test('refuses a member without dist/main.js and a schema without a generated cli
|
|
|
55
55
|
await assert.rejects(stage(root), (e) => /alpha\/alpha-api: .*dist\/main\.js does not exist/.test(e.message) && /alpha-db-api: .*no node_modules\/\.prisma/.test(e.message));
|
|
56
56
|
});
|
|
57
57
|
|
|
58
|
-
test('refuses a runner Node major that differs from the image base, and an absent Dockerfile or
|
|
58
|
+
test('refuses a runner Node major that differs from the image base, and an absent Dockerfile or Node-major label', async () => {
|
|
59
59
|
const root = ready();
|
|
60
60
|
await assert.rejects(stage(root, { nodeMajor: '24' }), /Node 24 differs from the image's Node 22/);
|
|
61
61
|
fs.writeFileSync(path.join(root, 'infra/composed-process/Dockerfile'), `# (@xemahq/repo-build-tooling@${toolVersion()})\nFROM scratch\n`);
|
|
62
|
-
await assert.rejects(stage(root), /no base
|
|
62
|
+
await assert.rejects(stage(root), /declares no base Node major/);
|
|
63
63
|
fs.rmSync(path.join(root, 'infra/composed-process/Dockerfile'));
|
|
64
64
|
await assert.rejects(stage(root), /Dockerfile is absent/);
|
|
65
65
|
});
|
|
66
66
|
|
|
67
67
|
test('refuses a tool version that differs from the rendered Dockerfile pin', async () => {
|
|
68
68
|
const root = ready();
|
|
69
|
-
write(root, 'infra/composed-process/Dockerfile', '# (@xemahq/repo-build-tooling@0.0.1)\nFROM node:
|
|
69
|
+
write(root, 'infra/composed-process/Dockerfile', '# (@xemahq/repo-build-tooling@0.0.1)\nFROM ghcr.io/example/base/node-runtime@sha256:abc AS base\n');
|
|
70
70
|
await assert.rejects(stage(root), /pins 0\.0\.1/);
|
|
71
71
|
});
|
|
72
72
|
|
package/src/composed-image.mjs
CHANGED
|
@@ -72,7 +72,7 @@
|
|
|
72
72
|
// dockerfile --write|--check --copy <dir>… --cache-id <id> --build-source <repo>
|
|
73
73
|
// render `<image-dir>/Dockerfile` from the package's template
|
|
74
74
|
// (`composed-image-dockerfile.mjs`).
|
|
75
|
-
// dockerfile --prebuilt --write|--check --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]
|
|
75
|
+
// dockerfile --prebuilt --write|--check --cache-id <id> --build-source <repo> --base <ref@sha256:…> --node-major <n> [--copy <dir>]… [--stage-dir <dir>]
|
|
76
76
|
// render the Dockerfile that ASSEMBLES the image from `stage` output, and its
|
|
77
77
|
// `Dockerfile.dockerignore` (`composed-image-prebuilt-dockerfile.mjs`).
|
|
78
78
|
//
|
|
@@ -328,12 +328,12 @@ const USAGE =
|
|
|
328
328
|
' load-image <image>\n' +
|
|
329
329
|
' smoke-refusal <image> [--member <biomeId>/<componentKey>]x2\n' +
|
|
330
330
|
' dockerfile --write|--check --copy <dir>… --cache-id <id> --build-source <repo>\n' +
|
|
331
|
-
' dockerfile --prebuilt --write|--check --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]';
|
|
331
|
+
' dockerfile --prebuilt --write|--check --cache-id <id> --build-source <repo> --base <ref@sha256:…> --node-major <n> [--copy <dir>]… [--stage-dir <dir>]';
|
|
332
332
|
|
|
333
333
|
const REPEATABLE = new Set(['manifests', 'copy', 'member', 'output', 'also', 'subject']);
|
|
334
334
|
const BOOLEAN = new Set(['prebuilt', 'each']);
|
|
335
|
-
const VALUED = new Set(['root', 'image-dir', 'manifests', 'canonical-dockerfile', 'cross-repo-manifest', 'build-source',
|
|
336
|
-
'image-name', 'service', 'cache-id', 'copy', 'member', 'stage-dir', 'output', 'also', 'subject',
|
|
335
|
+
const VALUED = new Set(['root', 'image-dir', 'manifests', 'canonical-dockerfile', 'cross-repo-manifest', 'build-source', 'base',
|
|
336
|
+
'image-name', 'service', 'cache-id', 'node-major', 'copy', 'member', 'stage-dir', 'output', 'also', 'subject',
|
|
337
337
|
]);
|
|
338
338
|
|
|
339
339
|
export function parseArguments(argv) {
|
|
@@ -453,6 +453,8 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
|
|
|
453
453
|
cacheId: options['cache-id'],
|
|
454
454
|
buildSource: options['build-source'],
|
|
455
455
|
stageDir: options['stage-dir'],
|
|
456
|
+
base: options.base,
|
|
457
|
+
nodeMajor: options['node-major'],
|
|
456
458
|
});
|
|
457
459
|
log(`composed prebuilt Dockerfile ${targets.join(', ')} ${options.mode === 'write' ? 'written' : 'current'}`);
|
|
458
460
|
return;
|