@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
package/README.md
CHANGED
|
@@ -29,7 +29,7 @@ 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`, `stage`, `pnpm-context`, `sync-injected`, `prisma-check`, `migrate`, `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`, `stage-tree`, `pnpm-context`, `sync-injected`, `prisma-check`, `migrate`, `coordinate`, `load-image`, `smoke-refusal`, `dockerfile` |
|
|
33
33
|
| `xema-service-image stage --service <pkg> --path <dir> --platform linux/<amd64\|arm64> [--stage-dir <dir>]` | Runner half of a per-service image: `pnpm deploy --prod --offline --ignore-scripts` of a service already built on the runner, selected for the TARGET platform (`--os=linux --cpu=<arch> --libc=glibc`), into `<stage>/service/app`, with its Prisma client and the target's schema engine. Refuses a package whose native code holds no ELF for the target (a multi-platform package that also ships the target's binary is accepted) and production dependencies whose build script would have to run, uncommitted changes to tracked files, an untracked root build input, and running as any tool but the one the root installs. Fetches the schema engine from Prisma for every stage, checks it against both published checksums, and caches nothing. Writes `<stage>/service/xema-service-image.json` (commit, sha256 of each root build input: lockfile, root `package.json`, `pnpm-workspace.yaml`, `.npmrc`, `.pnpmfile.cjs`, `turbo.json`, `tsconfig*.json`; tool version; natives per package; engine sha256) and `<stage>/tool`. The Dockerfile COPYs those root inputs into `/proof/context/`, which puts them in the content tag, and its proof stage runs `src/service-image-in-image.mjs prove <app> <proof dir> --service <svc> --commit <BUILD_SHA>` on the target, offline: it checks the commit, the root inputs, the tool version, the platform and Node ABI, that each package's addons `dlopen`, and the engine's sha256 and version. The final stage only COPYs |
|
|
34
34
|
| `xema-arm64-supplement-needed` | Decides whether an arm64 supplement is still owed at `REGISTRY/IMAGE:CONTENT_TAG` (env: `REGISTRY`, `IMAGE`, `CONTENT_TAG`, `REGISTRY_USER`, `REGISTRY_TOKEN`). `needed=false` only when the coordinate serves an index whose linux/arm64 child is proven by its own image config; any other resolvable state is `needed=true`; a 401/403/5xx or an unresolvable child is an error. Writes `needed=true\|false` to `$GITHUB_OUTPUT`. Replaces the generated `arm64-supplement-needed.mjs` copy. |
|
|
35
35
|
| `xema-promote-image --service <name> --dev-ref <repo> --release-ref <repo> --tag <tag> --commit <sha> --main-ref <ref> --signer-repository <owner/repo> --develop-workflow <file> --release-workflow <file>... [--release-index-workflow <file>] --promotion-predicate-out <path> -- <tag command>` | Publishes the develop image's digest under the release tag instead of rebuilding: `reused` (the release digest carries an `image-promotion/v1` attestation signed by a `--release-workflow` identity on main, with exactly one verified statement about the digest and a `ghcr.io/<owner>/dev/<name>` source; no dev-ref probe), `copied` (the proven develop digest, digest-preserving; fails closed if the release digest differs) or `miss` (build it; a reuse whose release digest has no `image-promotion/v1` attestation, a legacy image, is a miss with `reason=reuse-no-promotion` on the stdout line; with `--release-index-workflow`, a release tag that names an image index signed by that workflow on main, with exactly one `linux/amd64` child whose own digest passes that same reuse check, is instead `reused` with `evidence=release-index`, `digest` = the amd64 child A, `index_digest` = the index D and no predicate, but only when A's selected promotion statement promotes `--commit` itself (a rerun; a later commit with the same content takes the existing miss and attests its own promotion). On `evidence=release-index` the caller skips sign/attest, tags `:<sha>` and `main-<sha>` to the amd64 subject `digest` (release-arm64 resolves `:<sha>` to it and verifies its promotion), and points only the content tag (and `latest`, if it moves it) at the index `index_digest`. Any failed step keeps the miss, and two amd64 children are an error; a signature or identity failure, or a source outside `dev/`, is an error; a copy whose dev digest carries no SLSA v1 provenance at all, an image reused on develop from before provenance, is a miss with `reason=copy-no-develop-provenance`, while provenance that is present but fails a leg is an error). A develop digest is proven only if its SLSA v1 provenance is signed by the develop workflow at `refs/heads/develop`, its revision is an ancestor of the main ref, and the tag command prints the same tag at that revision and at `--commit`; any failed leg is an error, never a miss. On every non-miss result it writes the `https://xema.dev/attestations/image-promotion/v1` predicate for the caller to attest: a copy states the facts just proved; a reuse states this run's `release` and `promotedCommit` and carries `source` and `developProvenance` from the earlier promotion. Writes `result`, `digest`, `source`, `revision`, `evidence`, `provenance_digest`, `promotion_predicate`, `promotion_predicate_type` to `$GITHUB_OUTPUT`. Needs `docker`, `cosign` (major v2, the same as the develop attest step and xema-deploy's verify; it runs `cosign version` first and refuses any other major or an unparsable version), `git` and full history. It does not sign |
|
|
@@ -37,7 +37,11 @@ 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).
|
|
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).
|
|
41
|
+
|
|
42
|
+
`dockerfile --prebuilt` renders the Dockerfile and `Dockerfile.dockerignore` of an image that only COPIES a tree the CI runner staged: no `RUN pnpm install`, no corepack, no secret, no network (every RUN is `--network=none`). It requires `--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 the stage compares with the runner's Node); `--copy` and `--stage-dir` are optional and `--cache-id` is refused (there is no cache). The proof stage COPIES the root build inputs (`pnpm-lock.yaml`, root `package.json`, `pnpm-workspace.yaml`, `.npmrc`, `.pnpmfile.cjs`, `turbo.json`, `tsconfig*.json`, whichever exist) into `/proof/context/` and the `--copy` directories into `/proof/copied/`, which puts exactly the bytes that decided the staged tree in the image's content tag, and runs `prove`, `prisma-check` and `load --each`; the final stage COPIES the two staged layers and the proof's verdict. The `--filter <package>...` words in the `prove` line are literal: the content hash reads them to narrow the lockfile.
|
|
43
|
+
|
|
44
|
+
`stage-tree <dir> --platform linux/<amd64|arm64> [--output <rel>]…` is the runner half, run after the build and the job's own `pnpm install --frozen-lockfile`. It checks the members' build outputs (as `stage` does), then runs an OFFLINE, frozen, `--ignore-scripts` production install of the members' closures into a staging tree from the job's pnpm store, selected for the target platform (`--os=linux --cpu=<arch> --libc=glibc`, workspace packages injected, the re-resolved lockfile checked to pin nothing new); copies in only each package's `package.json` and declared `files` (plus the built outputs among them), the `--copy` directories, the biome manifests and `image-build-inputs.json`; writes the member index and host link; fetches the platform's Prisma schema engine (checksum-verified, never cached) when the closure carries the prisma CLI; and refuses a dependency that needs its build script on the target, a package whose native code holds no ELF for the platform, an uncommitted change to a tracked file and an untracked root build input. Output: `<dir>/layers/store` (the registry packages, stable until the lockfile changes), `<dir>/layers/app` (everything else), `<dir>/proof/xema-composed-image.json` (commit, sha256 of each root build input and `--copy` directory, the literal filters, members, natives, engine, tool version, platform) and `<dir>/tool`. `--also` is not accepted. A refused stage leaves nothing behind. The in-image `prove` refuses the build unless the stage was made at `BUILD_SHA`, from the context's bytes, with this tool version, for the platform the build runs on, with the Dockerfile's filters, and every addon loads and the engine runs there. `stage <dir> [--output <rel>]… [--also <pkg>]…` remains as the outputs-only stage (`<dir>/out`, `<dir>/tool`) for per-service prebuilt images that still read it. `pnpm-context <dir> --subject <svc>…` builds the offline `xema-pnpm` context for those images (`staged.json`, `corepack/pnpm.tgz`); it prints `XEMA_PNPM_CONTEXT=<dir>` and `XEMA_PNPM_STORE=<store root>` and refuses a non-empty `<dir>`. `load --each` loads every member in its own process. Run `xema-composed-image` with no arguments for the full usage.
|
|
41
45
|
|
|
42
46
|
Every step refuses rather than skips. The package has no dependencies.
|
|
43
47
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM, depending only on @xemahq/release-policy for registry access, so the published artifact is the reviewed source.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
|
|
@@ -51,9 +51,9 @@
|
|
|
51
51
|
"@xemahq/release-policy": "^16.0.0"
|
|
52
52
|
},
|
|
53
53
|
"scripts": {
|
|
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"
|
|
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-stage-tree.test.mjs && node --test src/composed-image-install.test.mjs && node --test src/composed-image-proof.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:eb28744794a3da50dbb000a38b05bdd4da61176ff6495a664e1d58d22d1979ad"
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -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 { BASE,
|
|
12
|
+
import { BASE, workspace } from './composed-image-fixture.test.mjs';
|
|
13
13
|
|
|
14
14
|
const temps = [];
|
|
15
15
|
after(() => {
|
|
@@ -27,29 +27,21 @@ function renderedEntries() {
|
|
|
27
27
|
const { dockerfile } = renderPrebuiltDockerfile({
|
|
28
28
|
root,
|
|
29
29
|
copy: ['tooling/dev'],
|
|
30
|
-
|
|
31
|
-
buildSource: 'alpha', base: BASE,
|
|
32
|
-
usesToken: true,
|
|
33
|
-
exec: fakePnpm(root),
|
|
30
|
+
buildSource: 'alpha', base: BASE, nodeMajor: '22',
|
|
34
31
|
});
|
|
35
32
|
const entries = [...new Set([...dockerfile.matchAll(/node \/opt\/xema-composed-image\/(\S+\.mjs)/g)].map((m) => m[1]))];
|
|
36
33
|
assert.ok(entries.length > 0, 'the rendered Dockerfile runs at least one tool entry');
|
|
37
34
|
return entries;
|
|
38
35
|
}
|
|
39
36
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
test('every in-image entry runs from the copied tool directory with no node_modules', () => {
|
|
37
|
+
test('every in-image entry runs from the copied tool directory with no node_modules (it reaches its own usage error, never a missing module)', () => {
|
|
43
38
|
const tool = path.join(temp('xema-tool-'), 'tool');
|
|
44
39
|
copyTool(tool);
|
|
45
|
-
const root = temp('xema-root-');
|
|
46
|
-
fs.writeFileSync(path.join(root, 'pnpm-lock.yaml'), LOCKFILE);
|
|
47
|
-
const committed = path.join(root, 'committed.yaml');
|
|
48
|
-
fs.copyFileSync(path.join(root, 'pnpm-lock.yaml'), committed);
|
|
49
40
|
for (const entry of renderedEntries()) {
|
|
50
|
-
const result = spawnSync(process.execPath, [path.join(tool, entry), '
|
|
41
|
+
const result = spawnSync(process.execPath, [path.join(tool, entry), 'nonsense'], { encoding: 'utf8' });
|
|
51
42
|
assert.doesNotMatch(result.stderr, /ERR_MODULE_NOT_FOUND/, `${entry}: ${result.stderr}`);
|
|
52
|
-
assert.equal(result.status,
|
|
43
|
+
assert.equal(result.status, 1, `${entry} exited ${result.status}`);
|
|
44
|
+
assert.match(result.stderr, /usage: composed-image-in-image/);
|
|
53
45
|
}
|
|
54
46
|
});
|
|
55
47
|
|
|
@@ -3,13 +3,12 @@
|
|
|
3
3
|
// THE IN-IMAGE HALF OF THE COMPOSED-PROCESS BUILD.
|
|
4
4
|
//
|
|
5
5
|
// The prebuilt Dockerfile bind-mounts a copy of this package's `package.json` + `src/`
|
|
6
|
-
// (NO node_modules) and runs THIS file for `
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
// so runs only in the image.
|
|
6
|
+
// (NO node_modules) and runs THIS file for `prove`, `prisma-check` and `load --each`. So this
|
|
7
|
+
// module and everything it imports load only `node:` builtins and relative modules; a bare
|
|
8
|
+
// package import would die with ERR_MODULE_NOT_FOUND in the image
|
|
9
|
+
// (`composed-image-in-image-closure.test.mjs` proves it). `composed-image.mjs` re-exports these
|
|
10
|
+
// functions and dispatches the same verbs outside Docker; the runner's staging (`index`, `pins`,
|
|
11
|
+
// `sync-injected`) uses them directly.
|
|
13
12
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
14
13
|
|
|
15
14
|
import { spawnSync } from 'node:child_process';
|
|
@@ -20,9 +19,8 @@ import { pathToFileURL } from 'node:url';
|
|
|
20
19
|
|
|
21
20
|
import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
|
|
22
21
|
import { readMembers } from './composed-image-members.mjs';
|
|
23
|
-
import { syncInjected } from './composed-image-sync-injected.mjs';
|
|
24
22
|
import { prismaCheck } from './composed-image-prisma-check.mjs';
|
|
25
|
-
import {
|
|
23
|
+
import { proveComposed } from './composed-image-proof.mjs';
|
|
26
24
|
|
|
27
25
|
export const MEMBER_INDEX_FILE = 'xema-composed-member-index.json';
|
|
28
26
|
export const HOST_LINK = 'xema-composed-process';
|
|
@@ -185,7 +183,7 @@ export function loadMembersEach({ root = process.cwd(), imageDir = COMPOSED_IMAG
|
|
|
185
183
|
|
|
186
184
|
const USAGE =
|
|
187
185
|
'usage: composed-image-in-image <command> [--root <dir>] [--image-dir <dir>]\n' +
|
|
188
|
-
'
|
|
186
|
+
' prove <proof dir> --commit <sha> --filter <word>… | index <commit> --build-source <repo> | load [--each] | prisma-check';
|
|
189
187
|
|
|
190
188
|
function parseInImageArguments(argv) {
|
|
191
189
|
const positional = [];
|
|
@@ -201,11 +199,12 @@ function parseInImageArguments(argv) {
|
|
|
201
199
|
options.each = true;
|
|
202
200
|
continue;
|
|
203
201
|
}
|
|
204
|
-
if (name !== 'root' && name !== 'image-dir' && name !== 'build-source') throw new Error(`unknown option ${arg}\n${USAGE}`);
|
|
202
|
+
if (name !== 'root' && name !== 'image-dir' && name !== 'build-source' && name !== 'commit' && name !== 'filter') throw new Error(`unknown option ${arg}\n${USAGE}`);
|
|
205
203
|
const value = argv[i + 1];
|
|
206
204
|
if (value === undefined || value.startsWith('--')) throw new Error(`${arg} needs a value`);
|
|
207
205
|
i += 1;
|
|
208
|
-
options[
|
|
206
|
+
if (name === 'filter') (options.filter ??= []).push(value);
|
|
207
|
+
else options[name] = value;
|
|
209
208
|
}
|
|
210
209
|
return { command: positional[0], argument: positional[1], options };
|
|
211
210
|
}
|
|
@@ -215,11 +214,6 @@ export function main(argv, { log = console.log } = {}) {
|
|
|
215
214
|
const root = resolve(options.root ?? process.cwd());
|
|
216
215
|
const imageDir = options['image-dir'] ?? COMPOSED_IMAGE_DIR;
|
|
217
216
|
switch (command) {
|
|
218
|
-
case 'pins': {
|
|
219
|
-
const count = assertPinsUnchanged({ root, committedLockfile: argument });
|
|
220
|
-
log(`composed install: every registry pin is the committed lockfile's (${count})`);
|
|
221
|
-
return;
|
|
222
|
-
}
|
|
223
217
|
case 'index': {
|
|
224
218
|
const result = writeMemberIndex({ root, imageDir, commit: argument, buildSourceRepository: options['build-source'] });
|
|
225
219
|
log(`composed member index: ${result.members} member(s) at ${argument}; host ${result.host}`);
|
|
@@ -232,13 +226,14 @@ export function main(argv, { log = console.log } = {}) {
|
|
|
232
226
|
}
|
|
233
227
|
log(`composed members: all ${loadMembers({ root, imageDir })} module graph(s) load`);
|
|
234
228
|
return;
|
|
235
|
-
case 'sync-injected':
|
|
236
|
-
syncInjected({ root, imageDir, log });
|
|
237
|
-
return;
|
|
238
229
|
case 'prisma-check':
|
|
239
230
|
return prismaCheck({ root, imageDir, log }).then(() => undefined);
|
|
240
|
-
case '
|
|
241
|
-
|
|
231
|
+
case 'prove': {
|
|
232
|
+
if (argument === undefined) throw new Error(USAGE);
|
|
233
|
+
if (!COMMIT.test(options.commit ?? '')) throw new Error(`--commit must be the 40-hex commit the image is built for (BUILD_SHA)\n${USAGE}`);
|
|
234
|
+
const filters = (options.filter ?? []).map((word) => `--filter ${word}`);
|
|
235
|
+
return proveComposed({ proofDir: resolve(argument), root, commit: options.commit, filters, imageDir, log }).then(() => undefined);
|
|
236
|
+
}
|
|
242
237
|
default:
|
|
243
238
|
throw new Error(USAGE);
|
|
244
239
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The composed image's production install, run on the CI RUNNER inside the staged tree
|
|
2
|
+
// (`composed-image-stage-tree.mjs`), never in Docker. It is the install the image used to run
|
|
3
|
+
// itself, with the same words: the workspace packages are INJECTED, the lockfile is re-resolved
|
|
4
|
+
// for the members' closures only and checked to pin nothing new, then production dependencies are
|
|
5
|
+
// installed from the runner's pnpm store alone (`--offline`, `--ignore-scripts`), selected for the
|
|
6
|
+
// TARGET platform (`--os/--cpu/--libc`) so no foreign-platform binary is staged.
|
|
7
|
+
import { execFileSync } from 'node:child_process';
|
|
8
|
+
import { copyFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
9
|
+
import { tmpdir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
|
|
12
|
+
import { assertPinsUnchanged } from './composed-image-in-image.mjs';
|
|
13
|
+
|
|
14
|
+
/** The install-flag words that make pnpm copy workspace packages into the store layout. */
|
|
15
|
+
export const injectFlags = () => ['--config.inject-workspace-packages=true', '--config.dedupe-injected-deps=false'];
|
|
16
|
+
|
|
17
|
+
/** The `--lockfile-only` re-resolve for `filters` (`['--filter', '<pkg>...', ...]`) as `[file, ...args]`. */
|
|
18
|
+
export const lockfileOnlyCommand = (filters) => ['pnpm', 'install', '--lockfile-only', '--prefer-offline', ...injectFlags(), ...filters];
|
|
19
|
+
|
|
20
|
+
/** The production install for `filters` on `linux/<arch>` as `[file, ...args]`. */
|
|
21
|
+
export const productionInstallCommand = (filters, arch) => [
|
|
22
|
+
'pnpm', 'install', '--prod', '--offline', '--ignore-scripts', ...injectFlags(), ...filters, '--frozen-lockfile',
|
|
23
|
+
'--os=linux', `--cpu=${arch}`, '--libc=glibc',
|
|
24
|
+
];
|
|
25
|
+
|
|
26
|
+
const defaultExec = (file, args, { cwd }) => execFileSync(file, args, { cwd, stdio: 'inherit' });
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Install the members' production closure into `treeRoot` (which holds the root manifests, the lockfile and the
|
|
30
|
+
* closure's package files). `filters` are the members' literal `--filter <package>...` words. Throws when the
|
|
31
|
+
* re-resolve pinned a registry package the committed lockfile does not.
|
|
32
|
+
*/
|
|
33
|
+
export function installClosure({ treeRoot, filters, arch, exec = defaultExec }) {
|
|
34
|
+
const scratch = mkdtempSync(join(tmpdir(), 'xema-composed-lock-'));
|
|
35
|
+
try {
|
|
36
|
+
const committed = join(scratch, 'pnpm-lock.committed.yaml');
|
|
37
|
+
copyFileSync(join(treeRoot, 'pnpm-lock.yaml'), committed);
|
|
38
|
+
const [resolveFile, ...resolveArgs] = lockfileOnlyCommand(filters);
|
|
39
|
+
exec(resolveFile, resolveArgs, { cwd: treeRoot });
|
|
40
|
+
assertPinsUnchanged({ root: treeRoot, committedLockfile: committed });
|
|
41
|
+
const [installFile, ...installArgs] = productionInstallCommand(filters, arch);
|
|
42
|
+
exec(installFile, installArgs, { cwd: treeRoot });
|
|
43
|
+
} finally {
|
|
44
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import test from 'node:test';
|
|
6
|
+
|
|
7
|
+
import { installClosure, lockfileOnlyCommand, productionInstallCommand } from './composed-image-install.mjs';
|
|
8
|
+
|
|
9
|
+
const lock = (...pins) => `lockfileVersion: '9.0'\n\npackages:\n\n${pins.map((p) => ` ${p}:\n resolution: {integrity: sha512-x}\n`).join('\n')}`;
|
|
10
|
+
const FILTERS = ['--filter', '@t/a...', '--filter', '@t/b...'];
|
|
11
|
+
|
|
12
|
+
function tree(committed) {
|
|
13
|
+
const treeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-install-'));
|
|
14
|
+
fs.writeFileSync(path.join(treeRoot, 'pnpm-lock.yaml'), committed);
|
|
15
|
+
return treeRoot;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
test('the re-resolve then the production install run in the tree, offline and frozen, for the target platform, with the literal filters', () => {
|
|
19
|
+
const treeRoot = tree(lock('left-pad@1.3.0'));
|
|
20
|
+
const calls = [];
|
|
21
|
+
installClosure({ treeRoot, filters: FILTERS, arch: 'arm64', exec: (file, args, options) => calls.push({ line: [file, ...args], cwd: options.cwd }) });
|
|
22
|
+
assert.deepEqual(calls.map((c) => c.cwd), [treeRoot, treeRoot]);
|
|
23
|
+
assert.deepEqual(calls[0].line, lockfileOnlyCommand(FILTERS));
|
|
24
|
+
assert.deepEqual(calls[1].line, productionInstallCommand(FILTERS, 'arm64'));
|
|
25
|
+
const install = calls[1].line.join(' ');
|
|
26
|
+
for (const word of ['install --prod --offline --ignore-scripts', '--frozen-lockfile', '--os=linux --cpu=arm64 --libc=glibc', '--config.inject-workspace-packages=true', '--filter @t/a... --filter @t/b...']) assert.ok(install.includes(word), word);
|
|
27
|
+
assert.doesNotMatch(install, /--prefer-offline/);
|
|
28
|
+
assert.ok(calls[0].line.includes('--lockfile-only') && !calls[0].line.includes('--frozen-lockfile'));
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test('a re-resolution that pins a registry package the committed lockfile does not stops before the install', () => {
|
|
32
|
+
const treeRoot = tree(lock('left-pad@1.3.0'));
|
|
33
|
+
const calls = [];
|
|
34
|
+
const exec = (file, args) => {
|
|
35
|
+
calls.push(args);
|
|
36
|
+
if (args.includes('--lockfile-only')) fs.writeFileSync(path.join(treeRoot, 'pnpm-lock.yaml'), lock('left-pad@1.3.0', 'evil@9.9.9'));
|
|
37
|
+
};
|
|
38
|
+
assert.throws(() => installClosure({ treeRoot, filters: FILTERS, arch: 'amd64', exec }), /pinned registry package\(s\) the committed lockfile does not:\n {2}- evil@9\.9\.9/);
|
|
39
|
+
assert.equal(calls.length, 1, 'the production install never ran');
|
|
40
|
+
});
|
|
@@ -3,3 +3,23 @@ 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';
|
|
9
|
+
|
|
10
|
+
// The layout of the stage directory the prebuilt Dockerfile COPYs from (relative to the stage dir).
|
|
11
|
+
/** Production packages (`node_modules/.pnpm`): stable until the lockfile changes. Copied to `/`. */
|
|
12
|
+
export const STAGE_STORE_LAYER = 'layers/store';
|
|
13
|
+
/** Everything else of `/monorepo`. Copied to `/`. */
|
|
14
|
+
export const STAGE_APP_LAYER = 'layers/app';
|
|
15
|
+
/** The stage manifest the proof reads. */
|
|
16
|
+
export const STAGE_PROOF_DIRECTORY = 'proof';
|
|
17
|
+
/** The image's root directory, under each layer. */
|
|
18
|
+
export const IMAGE_ROOT_DIRECTORY = 'monorepo';
|
|
19
|
+
/** Where the proof stage keeps its inputs, in the image build. */
|
|
20
|
+
export const PROOF_DIRECTORY = 'proof';
|
|
21
|
+
/** The proof's verdict (a copy of the manifest it accepted), which the final image carries. */
|
|
22
|
+
export const VERIFIED_IMAGE_FILE = 'xema-composed-verified.json';
|
|
23
|
+
/** The stage manifest's file name and schema version. */
|
|
24
|
+
export const COMPOSED_MANIFEST_FILE = 'xema-composed-image.json';
|
|
25
|
+
export const COMPOSED_MANIFEST_SCHEMA = 1;
|
|
@@ -1,38 +1,23 @@
|
|
|
1
|
-
// `xema-composed-image pnpm-context <dir>
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
1
|
+
// `xema-composed-image pnpm-context <dir> --subject <svc>...`: builds, on the CI runner and AFTER its
|
|
2
|
+
// `pnpm install --frozen-lockfile`, the pnpm context an offline per-service prebuilt image build consumes as the
|
|
3
|
+
// named BuildKit context `xema-pnpm`. The runner's own pnpm store is the second context (`xema-pnpm-store`);
|
|
4
|
+
// it is never copied. (The composed image no longer uses it: it is staged whole by `stage-tree`.)
|
|
5
5
|
//
|
|
6
|
-
// <dir>/staged.json { tool, pnpm, storeDir, subjects[]
|
|
6
|
+
// <dir>/staged.json { tool, pnpm, storeDir, subjects[] } — the sentinel the image checks
|
|
7
7
|
// <dir>/corepack/pnpm.tgz `corepack pack pnpm@<root packageManager>`
|
|
8
|
-
// <dir>/cache/ --image-dir only: pnpm `cache-dir` (registry metadata), seeded from the runner's and warmed
|
|
9
|
-
// (--subject writes no cache/: the member deploy is lockfile-driven and offline)
|
|
10
8
|
//
|
|
11
9
|
// stdout is exactly two lines for `$GITHUB_ENV`: `XEMA_PNPM_CONTEXT=<dir>` and
|
|
12
|
-
// `XEMA_PNPM_STORE=<store root>`. Everything else goes to stderr.
|
|
13
|
-
// fetch retries apply; nothing here retries a command.
|
|
10
|
+
// `XEMA_PNPM_STORE=<store root>`. Everything else goes to stderr.
|
|
14
11
|
import { execFileSync } from 'node:child_process';
|
|
15
|
-
import {
|
|
16
|
-
import { homedir, tmpdir } from 'node:os';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
|
|
17
13
|
import { dirname, join, resolve } from 'node:path';
|
|
18
14
|
|
|
19
|
-
import { memberClosure, memberFilters } from './composed-image-members.mjs';
|
|
20
|
-
import { depsCopyDirs, lockfileOnlyCommand } from './composed-image-prebuilt-dockerfile.mjs';
|
|
21
15
|
import { toolVersion } from './composed-image-stage.mjs';
|
|
22
16
|
|
|
23
17
|
/** Runs a command, returns stdout; stderr is the caller's. Injectable for tests. */
|
|
24
18
|
const defaultExec = (file, args, { cwd } = {}) =>
|
|
25
19
|
execFileSync(file, args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'inherit'], maxBuffer: 64 * 1024 * 1024 });
|
|
26
20
|
|
|
27
|
-
/** `pnpm config get cache-dir` prints `undefined` when unset; then pnpm's own default applies. */
|
|
28
|
-
export function resolveCacheDir(printed, { platform = process.platform, env = process.env, home = homedir() } = {}) {
|
|
29
|
-
const value = printed.trim();
|
|
30
|
-
if (value !== '' && value !== 'undefined') return value;
|
|
31
|
-
if (platform === 'darwin') return join(home, 'Library', 'Caches', 'pnpm');
|
|
32
|
-
if (platform === 'win32') return join(env.LOCALAPPDATA ?? join(home, 'AppData', 'Local'), 'pnpm-cache');
|
|
33
|
-
return join(env.XDG_CACHE_HOME ?? join(home, '.cache'), 'pnpm');
|
|
34
|
-
}
|
|
35
|
-
|
|
36
21
|
/** `pnpm@10.33.0+sha512.…` → `10.33.0`. */
|
|
37
22
|
function pnpmVersion(root) {
|
|
38
23
|
const manager = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')).packageManager;
|
|
@@ -41,52 +26,9 @@ function pnpmVersion(root) {
|
|
|
41
26
|
return found[1];
|
|
42
27
|
}
|
|
43
28
|
|
|
44
|
-
function
|
|
45
|
-
mkdirSync(dirname(target), { recursive: true });
|
|
46
|
-
if (!existsSync(source)) {
|
|
47
|
-
mkdirSync(target);
|
|
48
|
-
return 'empty';
|
|
49
|
-
}
|
|
50
|
-
try {
|
|
51
|
-
exec('cp', ['-al', source, target], {});
|
|
52
|
-
return 'hardlinked';
|
|
53
|
-
} catch (error) {
|
|
54
|
-
log(`pnpm-context: cp -al failed (${String(error?.message ?? error).split('\n')[0]}); copying the cache instead`);
|
|
55
|
-
rmSync(target, { recursive: true, force: true });
|
|
56
|
-
cpSync(source, target, { recursive: true });
|
|
57
|
-
return 'copied';
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** A scratch tree holding exactly the deps stage's inputs, so `--lockfile-only` never touches the repository. */
|
|
62
|
-
function scratchLockfileInputs({ root, imageDir, exec }) {
|
|
63
|
-
// The directories the deps stage COPYs are read back from the committed Dockerfile (`depsCopyDirs`), so there is one definition.
|
|
64
|
-
const dockerfile = join(root, imageDir, 'Dockerfile');
|
|
65
|
-
if (!existsSync(dockerfile)) throw new Error(`${imageDir}/Dockerfile is absent — render it first: xema-composed-image dockerfile --prebuilt --write`);
|
|
66
|
-
const copy = depsCopyDirs(readFileSync(dockerfile, 'utf8'));
|
|
67
|
-
const scratch = mkdtempSync(join(tmpdir(), 'xema-pnpm-lock-'));
|
|
68
|
-
const put = (rel) => {
|
|
69
|
-
if (!existsSync(join(root, rel))) return;
|
|
70
|
-
mkdirSync(dirname(join(scratch, rel)), { recursive: true });
|
|
71
|
-
copyFileSync(join(root, rel), join(scratch, rel));
|
|
72
|
-
};
|
|
73
|
-
for (const rel of ['package.json', 'pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc']) put(rel);
|
|
74
|
-
for (const directory of memberClosure({ root, imageDir, exec })) put(`${directory}/package.json`);
|
|
75
|
-
for (const directory of copy) cpSync(join(root, directory), join(scratch, directory), { recursive: true });
|
|
76
|
-
return scratch;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function buildPnpmContext({
|
|
80
|
-
root = process.cwd(),
|
|
81
|
-
dir,
|
|
82
|
-
subjects = [],
|
|
83
|
-
imageDir,
|
|
84
|
-
exec = defaultExec,
|
|
85
|
-
log = () => {},
|
|
86
|
-
}) {
|
|
29
|
+
export function buildPnpmContext({ root = process.cwd(), dir, subjects = [], exec = defaultExec }) {
|
|
87
30
|
if (typeof dir !== 'string' || dir.length === 0) throw new Error('pnpm-context needs the output directory: pnpm-context <dir>');
|
|
88
|
-
if (subjects.length === 0
|
|
89
|
-
if (subjects.length > 0 && imageDir !== undefined) throw new Error('pnpm-context takes --subject or --image-dir, not both');
|
|
31
|
+
if (subjects.length === 0) throw new Error('pnpm-context needs --subject <svc>…');
|
|
90
32
|
const target = resolve(dir);
|
|
91
33
|
if (existsSync(target) && readdirSync(target).length > 0) throw new Error(`${dir} already exists and is not empty`);
|
|
92
34
|
|
|
@@ -98,25 +40,7 @@ export function buildPnpmContext({
|
|
|
98
40
|
}
|
|
99
41
|
mkdirSync(join(target, 'corepack'), { recursive: true });
|
|
100
42
|
exec('corepack', ['pack', `pnpm@${pnpm}`, '-o', join(target, 'corepack', 'pnpm.tgz')], { cwd: root });
|
|
101
|
-
|
|
102
|
-
// --subject: the member image runs the lockfile-driven `pnpm deploy --offline` from the store alone, so no
|
|
103
|
-
// metadata cache is seeded or warmed. --image-dir: its `--lockfile-only` resolve needs registry metadata, warmed here.
|
|
104
|
-
const withCache = subjects.length === 0;
|
|
105
|
-
if (withCache) {
|
|
106
|
-
const cacheSource = resolveCacheDir(exec('pnpm', ['config', 'get', 'cache-dir'], { cwd: root }));
|
|
107
|
-
const cache = join(target, 'cache');
|
|
108
|
-
log(`pnpm-context: cache ${seedCache(exec, cacheSource, cache, log)} from ${cacheSource}`);
|
|
109
|
-
const scratch = scratchLockfileInputs({ root, imageDir, exec });
|
|
110
|
-
try {
|
|
111
|
-
const [file, ...args] = lockfileOnlyCommand(memberFilters({ root, imageDir }).flatMap((f) => f.split(' ')), { offline: false });
|
|
112
|
-
exec(file, [...args, `--config.cache-dir=${cache}`], { cwd: scratch });
|
|
113
|
-
log(`pnpm-context: warmed the ${imageDir} lockfile-only resolve`);
|
|
114
|
-
} finally {
|
|
115
|
-
rmSync(scratch, { recursive: true, force: true });
|
|
116
|
-
}
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const sentinel = { tool: toolVersion(), pnpm, storeDir: storeRoot, subjects: [...subjects], cache: withCache, ...(imageDir === undefined ? {} : { imageDir }) };
|
|
43
|
+
const sentinel = { tool: toolVersion(), pnpm, storeDir: storeRoot, subjects: [...subjects] };
|
|
120
44
|
writeFileSync(join(target, 'staged.json'), `${JSON.stringify(sentinel, null, 2)}\n`);
|
|
121
45
|
return { context: target, store: storeRoot };
|
|
122
46
|
}
|
|
@@ -4,59 +4,40 @@ import os from 'node:os';
|
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import test from 'node:test';
|
|
6
6
|
|
|
7
|
-
import { buildPnpmContext
|
|
8
|
-
import {
|
|
9
|
-
import { BASE, CLOSURE, fakePnpm, workspace, write } from './composed-image-fixture.test.mjs';
|
|
7
|
+
import { buildPnpmContext } from './composed-image-pnpm-context.mjs';
|
|
8
|
+
import { workspace, write } from './composed-image-fixture.test.mjs';
|
|
10
9
|
|
|
11
|
-
function setup({ store = true
|
|
10
|
+
function setup({ store = true } = {}) {
|
|
12
11
|
const root = workspace();
|
|
13
12
|
write(root, 'package.json', { name: 'r', packageManager: 'pnpm@10.33.0+sha512.abc' });
|
|
14
|
-
write(root, 'pnpm-lock.yaml', 'lockfileVersion: 9\n');
|
|
15
|
-
write(root, 'tooling/dev/x.txt', 'x');
|
|
16
|
-
write(root, 'infra/composed-process/Dockerfile', renderPrebuiltDockerfile({ root, copy: ['tooling/dev'], cacheId: 'x', buildSource: 'repo', base: BASE, usesToken: false, exec: fakePnpm(root) }).dockerfile);
|
|
17
13
|
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'xema-pnpm-home-'));
|
|
18
14
|
const storePath = path.join(home, '.pnpm-store', 'v10');
|
|
19
|
-
const cacheDir = path.join(home, 'cache');
|
|
20
15
|
if (store) fs.mkdirSync(storePath, { recursive: true });
|
|
21
|
-
if (cache) write(home, 'cache/metadata-v1.3/registry.npmjs.org/x.json', '{}');
|
|
22
16
|
const out = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'xema-pnpm-out-')), 'ctx');
|
|
23
|
-
const pnpmLs = fakePnpm(root, CLOSURE);
|
|
24
17
|
const calls = [];
|
|
25
|
-
const exec = (file, args
|
|
26
|
-
calls.push({ file, args
|
|
27
|
-
if (file === 'pnpm' && args[0] === 'ls') return pnpmLs(file, args, options);
|
|
18
|
+
const exec = (file, args) => {
|
|
19
|
+
calls.push({ file, args });
|
|
28
20
|
if (file === 'pnpm' && args[0] === 'store') return `${storePath}\n`;
|
|
29
|
-
if (file === '
|
|
30
|
-
if (file === 'corepack') {
|
|
31
|
-
fs.writeFileSync(args[args.indexOf('-o') + 1], 'tgz');
|
|
32
|
-
return '';
|
|
33
|
-
}
|
|
34
|
-
if (file === 'cp') {
|
|
35
|
-
fs.cpSync(args[1], args[2], { recursive: true });
|
|
36
|
-
return '';
|
|
37
|
-
}
|
|
21
|
+
if (file === 'corepack') fs.writeFileSync(args[args.indexOf('-o') + 1], 'tgz');
|
|
38
22
|
return '';
|
|
39
23
|
};
|
|
40
|
-
return { root, out, exec, calls,
|
|
24
|
+
return { root, out, exec, calls, home };
|
|
41
25
|
}
|
|
42
26
|
|
|
43
|
-
test('
|
|
27
|
+
test('writes the corepack tgz and the sentinel; returns the two env values', () => {
|
|
44
28
|
const { root, out, exec, calls, home } = setup();
|
|
45
29
|
const result = buildPnpmContext({ root, dir: out, subjects: ['svc-a', 'svc-b'], exec });
|
|
46
30
|
assert.deepEqual(result, { context: out, store: path.join(home, '.pnpm-store') });
|
|
47
31
|
assert.equal(fs.readFileSync(path.join(out, 'corepack/pnpm.tgz'), 'utf8'), 'tgz');
|
|
48
32
|
assert.equal(fs.existsSync(path.join(out, 'cache')), false);
|
|
49
33
|
const staged = JSON.parse(fs.readFileSync(path.join(out, 'staged.json'), 'utf8'));
|
|
50
|
-
assert.deepEqual(Object.keys(staged), ['tool', 'pnpm', 'storeDir', 'subjects'
|
|
34
|
+
assert.deepEqual(Object.keys(staged), ['tool', 'pnpm', 'storeDir', 'subjects']);
|
|
51
35
|
assert.equal(staged.pnpm, '10.33.0');
|
|
52
|
-
assert.equal(staged.cache, false);
|
|
53
36
|
assert.deepEqual(staged.subjects, ['svc-a', 'svc-b']);
|
|
54
37
|
assert.deepEqual(calls.find((c) => c.file === 'corepack').args.slice(0, 2), ['pack', 'pnpm@10.33.0']);
|
|
55
|
-
assert.equal(calls.filter((c) => c.args[0] === 'deploy' || c.args.includes('--lockfile-only')).length, 0, 'no warm-up');
|
|
56
|
-
assert.ok(!calls.some((c) => c.args[0] === 'config'));
|
|
57
38
|
});
|
|
58
39
|
|
|
59
|
-
test('refuses a non-empty directory, a missing store, and a
|
|
40
|
+
test('refuses a non-empty directory, a missing store, and a missing subject, writing nothing', () => {
|
|
60
41
|
const full = setup();
|
|
61
42
|
write(full.out, 'old', 'x');
|
|
62
43
|
assert.throws(() => buildPnpmContext({ root: full.root, dir: full.out, subjects: ['s'], exec: full.exec }), /not empty/);
|
|
@@ -64,52 +45,4 @@ test('refuses a non-empty directory, a missing store, and a bad invocation, writ
|
|
|
64
45
|
assert.throws(() => buildPnpmContext({ root: none.root, dir: none.out, subjects: ['s'], exec: none.exec }), /pnpm store .* does not exist/);
|
|
65
46
|
assert.equal(fs.existsSync(none.out), false);
|
|
66
47
|
assert.throws(() => buildPnpmContext({ root: none.root, dir: none.out, exec: none.exec }), /--subject/);
|
|
67
|
-
assert.throws(() => buildPnpmContext({ root: none.root, dir: none.out, subjects: ['s'], imageDir: 'x', exec: none.exec }), /not both/);
|
|
68
|
-
});
|
|
69
|
-
|
|
70
|
-
test('--image-dir: runs the generator\'s lockfile-only command on a scratch copy and leaves the repository lockfile alone', () => {
|
|
71
|
-
const { root, out, exec, calls } = setup({ cache: false });
|
|
72
|
-
buildPnpmContext({ root, dir: out, imageDir: 'infra/composed-process', exec });
|
|
73
|
-
const resolve = calls.find((c) => c.args.includes('--lockfile-only'));
|
|
74
|
-
assert.notEqual(resolve.cwd, root);
|
|
75
|
-
assert.ok(resolve.scratch.includes('pnpm-lock.yaml') && resolve.scratch.includes('package.json'));
|
|
76
|
-
assert.ok(resolve.scratch.includes('tooling'), 'the deps stage COPY directories come from the committed Dockerfile');
|
|
77
|
-
assert.ok(resolve.args.includes('--prefer-offline') && !resolve.args.includes('--offline'), 'the warm-up fetches; the image resolves --offline');
|
|
78
|
-
assert.ok(resolve.args.includes(`--config.cache-dir=${path.join(out, 'cache')}`));
|
|
79
|
-
assert.equal(fs.existsSync(resolve.cwd), false, 'the scratch tree is deleted');
|
|
80
|
-
assert.equal(fs.readFileSync(path.join(root, 'pnpm-lock.yaml'), 'utf8'), 'lockfileVersion: 9\n');
|
|
81
|
-
const staged = JSON.parse(fs.readFileSync(path.join(out, 'staged.json'), 'utf8'));
|
|
82
|
-
assert.equal(staged.imageDir, 'infra/composed-process');
|
|
83
|
-
assert.equal(staged.cache, true);
|
|
84
|
-
});
|
|
85
|
-
|
|
86
|
-
test('the lockfile-only command is the one the generated Dockerfile runs (no drift)', () => {
|
|
87
|
-
const root = workspace();
|
|
88
|
-
const words = lockfileOnlyCommand(['--filter', '@t/alpha-api...']);
|
|
89
|
-
assert.deepEqual(words.slice(0, 4), ['pnpm', 'install', '--lockfile-only', '--offline']);
|
|
90
|
-
assert.equal(lockfileOnlyCommand(['--filter', 'x'], { offline: false })[3], '--prefer-offline');
|
|
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) });
|
|
93
|
-
assert.ok(text.includes('pnpm install --lockfile-only --offline ${INJECT} --filter @t/alpha-api... --filter @t/alpha-db-api... &&'));
|
|
94
|
-
assert.ok(text.includes('INJECT="--config.inject-workspace-packages=true --config.dedupe-injected-deps=false"'));
|
|
95
|
-
});
|
|
96
|
-
|
|
97
|
-
test('a failed cp -al falls back to a copy and logs why', () => {
|
|
98
|
-
const { root, out, exec, home } = setup();
|
|
99
|
-
const logs = [];
|
|
100
|
-
const failing = (file, args, options) => {
|
|
101
|
-
if (file === 'cp') throw new Error('cross-device link');
|
|
102
|
-
return exec(file, args, options);
|
|
103
|
-
};
|
|
104
|
-
buildPnpmContext({ root, dir: out, imageDir: 'infra/composed-process', exec: failing, log: (m) => logs.push(m) });
|
|
105
|
-
assert.ok(logs.some((m) => /cp -al failed \(cross-device link\)/.test(m)));
|
|
106
|
-
assert.ok(fs.existsSync(path.join(out, 'cache/metadata-v1.3/registry.npmjs.org/x.json')));
|
|
107
|
-
assert.ok(home);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test('an unset cache-dir ("undefined") resolves to the pnpm default per platform', () => {
|
|
111
|
-
assert.equal(resolveCacheDir('/x/c\n'), '/x/c');
|
|
112
|
-
assert.equal(resolveCacheDir('undefined\n', { platform: 'darwin', home: '/h' }), '/h/Library/Caches/pnpm');
|
|
113
|
-
assert.equal(resolveCacheDir('undefined', { platform: 'linux', env: {}, home: '/h' }), '/h/.cache/pnpm');
|
|
114
|
-
assert.equal(resolveCacheDir('', { platform: 'linux', env: { XDG_CACHE_HOME: '/xdg' }, home: '/h' }), '/xdg/pnpm');
|
|
115
48
|
});
|