@xemahq/repo-build-tooling 0.25.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-in-image-closure.test.mjs +1 -1
- package/src/composed-image-paths.mjs +3 -0
- package/src/composed-image-pnpm-context.test.mjs +2 -2
- package/src/composed-image-prebuilt-dockerfile.mjs +8 -3
- package/src/composed-image-prebuilt-dockerfile.test.mjs +9 -1
- package/src/composed-image-stage.mjs +5 -5
- package/src/composed-image-stage.test.mjs +4 -4
- package/src/composed-image.mjs +4 -3
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.25.
|
|
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
|
}
|
|
@@ -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';
|
|
@@ -13,7 +13,7 @@ function setup({ store = true, cache = true } = {}) {
|
|
|
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', base: BASE, 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', base: BASE, 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,7 +7,7 @@ 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
12
|
import { npmrcUsesToken } from './composed-image-dockerfile.mjs';
|
|
13
13
|
import { IGNORED_DIRECTORIES, IGNORED_FILE_PATTERNS, packageSources } from './composed-image-package-files.mjs';
|
|
@@ -20,7 +20,10 @@ const CACHE_ID = /^[a-z0-9][a-z0-9-]*$/;
|
|
|
20
20
|
const REPOSITORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
|
|
21
21
|
const DIGEST_REFERENCE = /^[a-z0-9][a-z0-9._/-]*(:[A-Za-z0-9._-]+)?@sha256:[0-9a-f]{64}$/;
|
|
22
22
|
|
|
23
|
-
|
|
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}"`);
|
|
24
27
|
if (!DIGEST_REFERENCE.test(base ?? '')) throw new Error(`--base must be an image reference pinned by digest (<ref>@sha256:<64 hex>), got "${base}"`);
|
|
25
28
|
for (const directory of [...(copy ?? []), stageDir]) {
|
|
26
29
|
if (!DIRECTORY.test(directory) || directory.startsWith('/') || directory.split('/').includes('..')) {
|
|
@@ -175,11 +178,12 @@ export function renderPrebuiltDockerfile({
|
|
|
175
178
|
usesToken,
|
|
176
179
|
stageDir = DEFAULT_STAGE_DIR,
|
|
177
180
|
base,
|
|
181
|
+
nodeMajor,
|
|
178
182
|
exec,
|
|
179
183
|
prodPackages = [],
|
|
180
184
|
}) {
|
|
181
185
|
if (typeof usesToken !== 'boolean') throw new Error('usesToken must be a boolean derived from .npmrc');
|
|
182
|
-
validate({ copy, cacheId, buildSource, stageDir, base });
|
|
186
|
+
validate({ copy, cacheId, buildSource, stageDir, base, nodeMajor });
|
|
183
187
|
const toolingVersion = toolVersion();
|
|
184
188
|
const workspaceFile = join(root, 'pnpm-workspace.yaml');
|
|
185
189
|
const built = existsSync(workspaceFile) ? onlyBuiltDependencies(readFileSync(workspaceFile, 'utf8')) : [];
|
|
@@ -309,6 +313,7 @@ RUN ${toolMount} \\
|
|
|
309
313
|
# keeps their paths under /.
|
|
310
314
|
FROM base
|
|
311
315
|
WORKDIR /monorepo
|
|
316
|
+
LABEL ${NODE_MAJOR_LABEL}="${nodeMajor}"
|
|
312
317
|
COPY --from=deps /monorepo /monorepo
|
|
313
318
|
COPY --from=assemble --exclude=node_modules/.pnpm /monorepo /monorepo
|
|
314
319
|
COPY --from=assemble --parents /monorepo/node_modules/.pnpm/*@file+* /
|
|
@@ -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', base: BASE, 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', () => {
|
|
@@ -233,3 +233,11 @@ test('--check fails when the rendered base or a files entry changed', () => {
|
|
|
233
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
234
|
assert.throws(() => syncPrebuiltDockerfile({ mode: 'check', ...options(root) }), /Dockerfile is stale/);
|
|
235
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> --base <ref@sha256:…> [--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> --base <ref@sha256:…> [--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
335
|
const VALUED = new Set(['root', 'image-dir', 'manifests', 'canonical-dockerfile', 'cross-repo-manifest', 'build-source', 'base',
|
|
336
|
-
'image-name', 'service', 'cache-id', 'copy', 'member', 'stage-dir', 'output', 'also', 'subject',
|
|
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) {
|
|
@@ -454,6 +454,7 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
|
|
|
454
454
|
buildSource: options['build-source'],
|
|
455
455
|
stageDir: options['stage-dir'],
|
|
456
456
|
base: options.base,
|
|
457
|
+
nodeMajor: options['node-major'],
|
|
457
458
|
});
|
|
458
459
|
log(`composed prebuilt Dockerfile ${targets.join(', ')} ${options.mode === 'write' ? 'written' : 'current'}`);
|
|
459
460
|
return;
|