@xemahq/repo-build-tooling 0.26.4 → 0.27.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 +0 -1
- package/package.json +3 -7
- package/src/composed-image-install.mjs +5 -2
- package/src/image-cpu-arch.test.mjs +65 -7
- package/src/prune-foreign-libc.mjs +96 -0
- package/src/scrub-swagger-plugin-paths.mjs +66 -8
- package/src/service-image-platform.mjs +10 -2
- package/src/service-image-stage.mjs +5 -2
- package/src/install-git-hooks.mjs +0 -323
- package/src/install-git-hooks.test.mjs +0 -85
- package/src/prepush-sanity.mjs +0 -473
- package/src/prepush-sanity.test.mjs +0 -205
package/README.md
CHANGED
|
@@ -34,7 +34,6 @@ pnpm exec xema-scrub-swagger-paths dist
|
|
|
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 |
|
|
36
36
|
| `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
|
|
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
37
|
| `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
|
|
39
38
|
|
|
40
39
|
`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).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.27.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)",
|
|
@@ -25,8 +25,6 @@
|
|
|
25
25
|
"xema-check-no-vendored-deps": "src/check-no-vendored-deps.mjs",
|
|
26
26
|
"xema-check-workspace-ranges": "src/check-workspace-range-matches-local.mjs",
|
|
27
27
|
"xema-composed-image": "src/composed-image.mjs",
|
|
28
|
-
"xema-install-git-hooks": "src/install-git-hooks.mjs",
|
|
29
|
-
"xema-prepush-sanity": "src/prepush-sanity.mjs",
|
|
30
28
|
"xema-promote-image": "src/promote-image.mjs",
|
|
31
29
|
"xema-readiness": "src/readiness.mjs",
|
|
32
30
|
"xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs",
|
|
@@ -38,9 +36,7 @@
|
|
|
38
36
|
"./check-no-vendored-deps": "./src/check-no-vendored-deps.mjs",
|
|
39
37
|
"./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs",
|
|
40
38
|
"./composed-image": "./src/composed-image.mjs",
|
|
41
|
-
"./install-git-hooks": "./src/install-git-hooks.mjs",
|
|
42
39
|
"./package.json": "./package.json",
|
|
43
|
-
"./prepush-sanity": "./src/prepush-sanity.mjs",
|
|
44
40
|
"./promote-image": "./src/promote-image.mjs",
|
|
45
41
|
"./readiness": "./src/readiness.mjs",
|
|
46
42
|
"./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs",
|
|
@@ -51,9 +47,9 @@
|
|
|
51
47
|
"@xemahq/release-policy": "^16.0.0"
|
|
52
48
|
},
|
|
53
49
|
"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-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/
|
|
50
|
+
"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/service-image.test.mjs && node --test src/image-cpu-arch.test.mjs"
|
|
55
51
|
},
|
|
56
52
|
"xemaRelease": {
|
|
57
|
-
"sourceDigest": "sha256:
|
|
53
|
+
"sourceDigest": "sha256:4a2cf6b565691b2837d7562f58925a40e3ad2284eded9395b72a74312788d417"
|
|
58
54
|
}
|
|
59
55
|
}
|
|
@@ -10,7 +10,8 @@ import { tmpdir } from 'node:os';
|
|
|
10
10
|
import { join } from 'node:path';
|
|
11
11
|
|
|
12
12
|
import { assertPinsUnchanged } from './composed-image-in-image.mjs';
|
|
13
|
-
import {
|
|
13
|
+
import { pruneForeignLibcPackages } from './prune-foreign-libc.mjs';
|
|
14
|
+
import { libcOf, nodeArch } from './service-image-platform.mjs';
|
|
14
15
|
|
|
15
16
|
/** The install-flag words that make pnpm copy workspace packages into the store layout. */
|
|
16
17
|
export const injectFlags = () => ['--config.inject-workspace-packages=true', '--config.dedupe-injected-deps=false'];
|
|
@@ -27,7 +28,7 @@ export const lockfileOnlyCommand = (filters) => ['pnpm', 'install', '--lockfile-
|
|
|
27
28
|
/** The production install for `filters` on `linux/<arch>` as `[file, ...args]`. */
|
|
28
29
|
export const productionInstallCommand = (filters, arch) => [
|
|
29
30
|
'pnpm', 'install', '--prod', '--offline', '--ignore-scripts', ...injectFlags(), ...filters, '--frozen-lockfile',
|
|
30
|
-
'--os=linux', `--cpu=${nodeArch(arch)}`,
|
|
31
|
+
'--os=linux', `--cpu=${nodeArch(arch)}`, `--libc=${libcOf(arch)}`,
|
|
31
32
|
];
|
|
32
33
|
|
|
33
34
|
const defaultExec = (file, args, { cwd }) => execFileSync(file, args, { cwd, stdio: 'inherit' });
|
|
@@ -47,6 +48,8 @@ export function installClosure({ treeRoot, filters, arch, exec = defaultExec })
|
|
|
47
48
|
assertPinsUnchanged({ root: treeRoot, committedLockfile: committed });
|
|
48
49
|
const [installFile, ...installArgs] = productionInstallCommand(filters, arch);
|
|
49
50
|
exec(installFile, installArgs, { cwd: treeRoot });
|
|
51
|
+
// pnpm 10.33 frozen offline install does not filter by libc; cpu/os do.
|
|
52
|
+
pruneForeignLibcPackages(treeRoot, libcOf(arch));
|
|
50
53
|
} finally {
|
|
51
54
|
rmSync(scratch, { recursive: true, force: true });
|
|
52
55
|
}
|
|
@@ -2,13 +2,14 @@
|
|
|
2
2
|
// optional x64 binary, so an amd64 image lost its @swc/core binding.
|
|
3
3
|
import assert from 'node:assert/strict';
|
|
4
4
|
import { execFileSync } from 'node:child_process';
|
|
5
|
-
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
5
|
+
import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
6
6
|
import { tmpdir } from 'node:os';
|
|
7
7
|
import { join } from 'node:path';
|
|
8
8
|
import test from 'node:test';
|
|
9
9
|
import { injectFlags, productionInstallCommand } from './composed-image-install.mjs';
|
|
10
10
|
import { deployArgs } from './service-image-stage.mjs';
|
|
11
|
-
import {
|
|
11
|
+
import { pruneForeignLibcPackages } from './prune-foreign-libc.mjs';
|
|
12
|
+
import { libcOf, nodeArch } from './service-image-platform.mjs';
|
|
12
13
|
|
|
13
14
|
test('nodeArch maps amd64 to x64 and arm64 to arm64, and refuses an arch with no row', () => {
|
|
14
15
|
assert.equal(nodeArch('amd64'), 'x64');
|
|
@@ -38,8 +39,9 @@ test('an amd64 stage installs @swc/core-linux-x64-gnu and no arm64 binding; arm6
|
|
|
38
39
|
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
|
39
40
|
const x64 = fixturePackage(dir, '@swc/core-linux-x64-gnu', { os: ['linux'], cpu: ['x64'], libc: ['glibc'] });
|
|
40
41
|
const arm64 = fixturePackage(dir, '@swc/core-linux-arm64-gnu', { os: ['linux'], cpu: ['arm64'], libc: ['glibc'] });
|
|
42
|
+
const x64musl = fixturePackage(dir, '@swc/core-linux-x64-musl', { os: ['linux'], cpu: ['x64'], libc: ['musl'] });
|
|
41
43
|
const core = fixturePackage(dir, '@swc/core', {
|
|
42
|
-
optionalDependencies: { '@swc/core-linux-x64-gnu': `file:${x64}`, '@swc/core-linux-arm64-gnu': `file:${arm64}` },
|
|
44
|
+
optionalDependencies: { '@swc/core-linux-x64-gnu': `file:${x64}`, '@swc/core-linux-x64-musl': `file:${x64musl}`, '@swc/core-linux-arm64-gnu': `file:${arm64}` },
|
|
43
45
|
});
|
|
44
46
|
const store = join(dir, 'store');
|
|
45
47
|
const env = { ...process.env, npm_config_store_dir: store };
|
|
@@ -51,17 +53,73 @@ test('an amd64 stage installs @swc/core-linux-x64-gnu and no arm64 binding; arm6
|
|
|
51
53
|
}
|
|
52
54
|
};
|
|
53
55
|
for (const arch of ['amd64', 'arm64']) {
|
|
56
|
+
const manifest = JSON.stringify({ name: 'app', version: '1.0.0', dependencies: { '@swc/core': `file:${core}` } });
|
|
57
|
+
// Prime from a SEPARATE directory so the project under test carries no install state recording libc=*.
|
|
58
|
+
const prime = join(dir, `prime-${arch}`);
|
|
59
|
+
mkdirSync(prime);
|
|
60
|
+
writeFileSync(join(prime, 'package.json'), manifest);
|
|
61
|
+
// Resolve and fetch every platform's tarball into the store, as the real stage's committed lockfile and primed store do.
|
|
62
|
+
run(prime, ['install', '--ignore-workspace', '--ignore-scripts', ...injectFlags(), '--force', '--config.supported-architectures.cpu=*', '--config.supported-architectures.os=*', '--config.supported-architectures.libc=*']);
|
|
54
63
|
const app = join(dir, `app-${arch}`);
|
|
55
64
|
mkdirSync(app);
|
|
56
|
-
writeFileSync(join(app, 'package.json'),
|
|
57
|
-
|
|
58
|
-
run(app, ['install', '--ignore-workspace', '--ignore-scripts', ...injectFlags(), '--force', '--config.supported-architectures.cpu=*', '--config.supported-architectures.os=*', '--config.supported-architectures.libc=*']);
|
|
59
|
-
rmSync(join(app, 'node_modules'), { recursive: true, force: true });
|
|
65
|
+
writeFileSync(join(app, 'package.json'), manifest);
|
|
66
|
+
copyFileSync(join(prime, 'pnpm-lock.yaml'), join(app, 'pnpm-lock.yaml'));
|
|
60
67
|
const [file, ...args] = productionInstallCommand(['--ignore-workspace'], arch);
|
|
61
68
|
run(app, args);
|
|
62
69
|
assert.equal(file, 'pnpm');
|
|
70
|
+
// pnpm 10.33 frozen offline install does not filter by libc; cpu/os do. The prune is what keeps musl out.
|
|
71
|
+
pruneForeignLibcPackages(app, libcOf(arch), () => {});
|
|
63
72
|
const present = (binding) => existsSync(join(app, 'node_modules', '.pnpm', 'node_modules', '@swc', binding));
|
|
64
73
|
assert.equal(present('core-linux-x64-gnu'), arch === 'amd64', `${arch}: x64 binding`);
|
|
74
|
+
assert.equal(present('core-linux-x64-musl'), false, `${arch}: a glibc image stages no musl binding`);
|
|
65
75
|
assert.equal(present('core-linux-arm64-gnu'), arch === 'arm64', `${arch}: arm64 binding`);
|
|
66
76
|
}
|
|
67
77
|
});
|
|
78
|
+
|
|
79
|
+
test('libcOf reads the IMAGE_PLATFORMS row and refuses an unknown arch', () => {
|
|
80
|
+
assert.equal(libcOf('amd64'), 'glibc');
|
|
81
|
+
assert.throws(() => libcOf('riscv64'), /no libc for image arch 'riscv64'/);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// A hand-built pnpm layout: .pnpm/<entry>/node_modules/<name> is the package, siblings are links to dependencies.
|
|
85
|
+
function layout(root, packages, links) {
|
|
86
|
+
for (const [entry, name, manifest] of packages) {
|
|
87
|
+
const dir = join(root, 'node_modules', '.pnpm', entry, 'node_modules', name);
|
|
88
|
+
mkdirSync(dir, { recursive: true });
|
|
89
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name, version: '1.0.0', ...manifest }));
|
|
90
|
+
}
|
|
91
|
+
for (const [from, name, toEntry, toName] of links) {
|
|
92
|
+
const linkDir = join(root, 'node_modules', '.pnpm', from, 'node_modules');
|
|
93
|
+
symlinkSync(join(root, 'node_modules', '.pnpm', toEntry, 'node_modules', toName), join(linkDir, name));
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
test('prune removes only packages declaring a foreign libc, with their links; a package without libc is untouched', (t) => {
|
|
98
|
+
const root = mkdtempSync(join(tmpdir(), 'xema-prune-'));
|
|
99
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
100
|
+
layout(root, [
|
|
101
|
+
['core@1', 'core', { optionalDependencies: { gnu: '1', musl: '1' } }],
|
|
102
|
+
['gnu@1', 'gnu', { libc: ['glibc'] }],
|
|
103
|
+
['musl@1', 'musl', { libc: ['musl'] }],
|
|
104
|
+
['plain@1', 'plain', {}],
|
|
105
|
+
], [['core@1', 'gnu', 'gnu@1', 'gnu'], ['core@1', 'musl', 'musl@1', 'musl']]);
|
|
106
|
+
const lines = [];
|
|
107
|
+
assert.deepEqual(pruneForeignLibcPackages(root, 'glibc', (line) => lines.push(line)), ['musl@1']);
|
|
108
|
+
assert.equal(lines.length, 1);
|
|
109
|
+
const store = join(root, 'node_modules', '.pnpm');
|
|
110
|
+
assert.equal(existsSync(join(store, 'musl@1')), false);
|
|
111
|
+
assert.equal(lstatSync(join(store, 'core@1', 'node_modules', 'musl'), { throwIfNoEntry: false }), undefined);
|
|
112
|
+
for (const kept of ['gnu@1', 'plain@1', 'core@1']) assert.ok(existsSync(join(store, kept)), kept);
|
|
113
|
+
assert.ok(existsSync(join(store, 'core@1', 'node_modules', 'gnu', 'package.json')));
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
test('prune throws, naming both, when a kept package hard-depends on a pruned one', (t) => {
|
|
117
|
+
const root = mkdtempSync(join(tmpdir(), 'xema-prune-'));
|
|
118
|
+
t.after(() => rmSync(root, { recursive: true, force: true }));
|
|
119
|
+
layout(root, [
|
|
120
|
+
['app@1', 'app', { dependencies: { musl: '1' } }],
|
|
121
|
+
['musl@1', 'musl', { libc: ['musl'] }],
|
|
122
|
+
], [['app@1', 'musl', 'musl@1', 'musl']]);
|
|
123
|
+
assert.throws(() => pruneForeignLibcPackages(root, 'glibc', () => {}), /app depends on musl/);
|
|
124
|
+
assert.ok(existsSync(join(root, 'node_modules', '.pnpm', 'musl@1')));
|
|
125
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// pnpm 10.33's frozen offline install does not filter packages by `libc` (cpu and os it does), so a glibc image's
|
|
2
|
+
// tree also holds the musl bindings (`@swc/core-linux-x64-musl`). This removes every package that DECLARES a `libc`
|
|
3
|
+
// list without the target's, and the symlinks to it. A package with no `libc` field is never touched.
|
|
4
|
+
// Upstream: no pnpm issue found 2026-10-17 that tracks libc being ignored by supportedArchitectures/--libc in frozen
|
|
5
|
+
// offline installs (nearest, not the same: pnpm/pnpm#11702, `pnpm add` on 11.1.2). Not fixed as of pnpm 10.33.
|
|
6
|
+
// Delete this module at the first pnpm raise that filters libc (fixed in pnpm <version>).
|
|
7
|
+
import { existsSync, lstatSync, readdirSync, readFileSync, readlinkSync, rmSync, unlinkSync } from 'node:fs';
|
|
8
|
+
import { dirname, join, resolve, sep } from 'node:path';
|
|
9
|
+
|
|
10
|
+
const readJson = (file) => JSON.parse(readFileSync(file, 'utf8'));
|
|
11
|
+
const isDirectory = (path) => {
|
|
12
|
+
try {
|
|
13
|
+
return lstatSync(path).isDirectory();
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error.code === 'ENOENT' || error.code === 'ENOTDIR') return false;
|
|
16
|
+
throw error;
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The real (non-symlink) package directories under a `node_modules`, scoped ones included: `[{ name, dir }]`. */
|
|
21
|
+
function realPackages(nodeModules) {
|
|
22
|
+
const found = [];
|
|
23
|
+
if (!isDirectory(nodeModules)) return found;
|
|
24
|
+
for (const entry of readdirSync(nodeModules)) {
|
|
25
|
+
const path = join(nodeModules, entry);
|
|
26
|
+
if (!isDirectory(path)) continue;
|
|
27
|
+
if (entry.startsWith('@')) {
|
|
28
|
+
for (const child of readdirSync(path)) if (isDirectory(join(path, child))) found.push({ name: `${entry}/${child}`, dir: join(path, child) });
|
|
29
|
+
} else {
|
|
30
|
+
found.push({ name: entry, dir: path });
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return found;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Every symlink directly under `nodeModules` (scoped children included): absolute paths. */
|
|
37
|
+
function symlinksIn(nodeModules) {
|
|
38
|
+
const links = [];
|
|
39
|
+
if (!isDirectory(nodeModules)) return links;
|
|
40
|
+
for (const entry of readdirSync(nodeModules)) {
|
|
41
|
+
const path = join(nodeModules, entry);
|
|
42
|
+
const stat = lstatSync(path);
|
|
43
|
+
if (stat.isSymbolicLink()) links.push(path);
|
|
44
|
+
else if (entry.startsWith('@') && stat.isDirectory()) {
|
|
45
|
+
for (const child of readdirSync(path)) if (lstatSync(join(path, child)).isSymbolicLink()) links.push(join(path, child));
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return links;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Remove, under `<stagedRoot>/node_modules/.pnpm`, every package whose `libc` array lacks `libc`, with the symlinks
|
|
53
|
+
* to it. Throws, naming both, when a package that stays depends on a removed one non-optionally. Returns the removed
|
|
54
|
+
* `.pnpm` entry names, one log line each.
|
|
55
|
+
*/
|
|
56
|
+
export function pruneForeignLibcPackages(stagedRoot, libc, log = (line) => console.log(line)) {
|
|
57
|
+
const store = join(stagedRoot, 'node_modules', '.pnpm');
|
|
58
|
+
if (!isDirectory(store)) return [];
|
|
59
|
+
const removed = new Map(); // entry -> package name
|
|
60
|
+
const kept = [];
|
|
61
|
+
for (const entry of readdirSync(store)) {
|
|
62
|
+
const entryDir = join(store, entry);
|
|
63
|
+
if (entry === 'node_modules' || entry === 'lock.yaml' || !isDirectory(entryDir)) continue;
|
|
64
|
+
for (const { name, dir } of realPackages(join(entryDir, 'node_modules'))) {
|
|
65
|
+
const manifestFile = join(dir, 'package.json');
|
|
66
|
+
if (!existsSync(manifestFile)) continue;
|
|
67
|
+
const manifest = readJson(manifestFile);
|
|
68
|
+
// The entry's own package is the one whose directory is not a dependency link; a dependency's real dir lives in its own entry.
|
|
69
|
+
if (Array.isArray(manifest.libc) && !manifest.libc.includes(libc)) removed.set(entry, name);
|
|
70
|
+
else kept.push({ entry, name, manifest });
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (removed.size === 0) return [];
|
|
74
|
+
const removedNames = new Set(removed.values());
|
|
75
|
+
for (const { name, manifest } of kept) {
|
|
76
|
+
for (const dependency of Object.keys(manifest.dependencies ?? {})) {
|
|
77
|
+
if (removedNames.has(dependency)) {
|
|
78
|
+
throw new Error(`${name} depends on ${dependency} (not optionally), which declares a libc without '${libc}' and would be removed; refusing to break the image`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
const doomed = [...removed.keys()].map((entry) => join(store, entry) + sep);
|
|
83
|
+
const nodeModulesDirs = [join(stagedRoot, 'node_modules'), join(store, 'node_modules')];
|
|
84
|
+
for (const entry of readdirSync(store)) nodeModulesDirs.push(join(store, entry, 'node_modules'));
|
|
85
|
+
for (const directory of nodeModulesDirs) {
|
|
86
|
+
for (const link of symlinksIn(directory)) {
|
|
87
|
+
const target = resolve(dirname(link), readlinkSync(link)) + sep;
|
|
88
|
+
if (doomed.some((prefix) => target.startsWith(prefix))) unlinkSync(link);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const [entry, name] of removed) {
|
|
92
|
+
rmSync(join(store, entry), { recursive: true, force: true });
|
|
93
|
+
log(`pruned ${name} (.pnpm/${entry}): declares a libc without '${libc}'`);
|
|
94
|
+
}
|
|
95
|
+
return [...removed.keys()];
|
|
96
|
+
}
|
|
@@ -129,7 +129,7 @@
|
|
|
129
129
|
|
|
130
130
|
import { createRequire } from 'node:module';
|
|
131
131
|
import { readFileSync, realpathSync } from 'node:fs';
|
|
132
|
-
import { mkdir, mkdtemp, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises';
|
|
132
|
+
import { mkdir, mkdtemp, readdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises';
|
|
133
133
|
import { tmpdir } from 'node:os';
|
|
134
134
|
import { dirname, join, resolve } from 'node:path';
|
|
135
135
|
import process from 'node:process';
|
|
@@ -168,14 +168,14 @@ const FLAT_LEAK_REGEX = new RegExp(
|
|
|
168
168
|
// and down into the contracts package. There is no `node_modules/` segment
|
|
169
169
|
// so shapes 1 and 2 above never fire.
|
|
170
170
|
// Capture group 1: relative path from the scrubbed file to the package directory.
|
|
171
|
-
// Capture group 2:
|
|
171
|
+
// Capture group 2: everything after dist/ (the physical subpath inside the package).
|
|
172
172
|
// The public package name is NOT derived from the directory name or a fixed
|
|
173
173
|
// scope: it is read from the target package's own package.json `name`, so
|
|
174
174
|
// unscoped and differently-named workspace packages resolve correctly.
|
|
175
175
|
// Scoped to paths that pass through `packages/`, `biomes/`, or `kernel/` to
|
|
176
176
|
// avoid false-positive matches on intra-service relative requires.
|
|
177
177
|
const RELATIVE_WORKSPACE_REGEX = new RegExp(
|
|
178
|
-
`require\\("((?:\\.\\./)+(?:packages|biomes|kernel)/(?:[a-z0-9][a-z0-9.-]*/)*[a-z0-9][a-z0-9.-]*)/dist/([a-z0-9][a-z0-9-]*
|
|
178
|
+
`require\\("((?:\\.\\./)+(?:packages|biomes|kernel)/(?:[a-z0-9][a-z0-9.-]*/)*[a-z0-9][a-z0-9.-]*)/dist/([a-z0-9][a-z0-9-]*(?:/[^"]*)?)"\\)`,
|
|
179
179
|
'g',
|
|
180
180
|
);
|
|
181
181
|
|
|
@@ -323,6 +323,7 @@ function reExportsReach(entry, target, depth = 6, seen = new Set()) {
|
|
|
323
323
|
* @param {string} pkg e.g. `@xemahq/kernel-contracts`
|
|
324
324
|
* @param {string} subpath physical path inside the package, e.g. `dist/agent-composition/lib/x`
|
|
325
325
|
* @param {string} original the full leaked specifier, for the proof and the error
|
|
326
|
+
* @param {string} [importer] the file holding the require, named in the refusal
|
|
326
327
|
*/
|
|
327
328
|
// NUL is the separator in the cache key because it cannot occur in a package
|
|
328
329
|
// name or a subpath, so no two distinct specifiers can collide on a
|
|
@@ -332,7 +333,7 @@ function reExportsReach(entry, target, depth = 6, seen = new Set()) {
|
|
|
332
333
|
// silently, and a file no search can read is a file no audit, no review and no
|
|
333
334
|
// boundary check covers. Enforced by the aggregator boundary gate
|
|
334
335
|
// `grep-readable-sources`.
|
|
335
|
-
function publicSpecifierFor(resolveFrom, pkg, subpath, original) {
|
|
336
|
+
function publicSpecifierFor(resolveFrom, pkg, subpath, original, importer) {
|
|
336
337
|
const cacheKey = `${pkg}\u0000${subpath}`;
|
|
337
338
|
if (specifierCache.has(cacheKey)) return specifierCache.get(cacheKey);
|
|
338
339
|
|
|
@@ -412,6 +413,7 @@ function publicSpecifierFor(resolveFrom, pkg, subpath, original) {
|
|
|
412
413
|
throw new ScrubFailure(
|
|
413
414
|
`no public specifier of ${pkg} resolves to the same file as\n` +
|
|
414
415
|
` ${original}\n` +
|
|
416
|
+
(importer ? ` required by ${importer}\n` : '') +
|
|
415
417
|
` tried: ${candidates.length > 0 ? candidates.join(', ') : '(none — the exports map admits no matching form)'}\n` +
|
|
416
418
|
` Add the needed entry to that package's "exports", or export the symbol from a\n` +
|
|
417
419
|
` path that is public. Rewriting to a specifier that resolves elsewhere would\n` +
|
|
@@ -586,10 +588,16 @@ async function scrubFile(path) {
|
|
|
586
588
|
` rather than reaching through dist/.`,
|
|
587
589
|
);
|
|
588
590
|
}
|
|
589
|
-
out = out.replace(RELATIVE_WORKSPACE_REGEX, (match, relPackageDir,
|
|
591
|
+
out = out.replace(RELATIVE_WORKSPACE_REGEX, (match, relPackageDir, rest) => {
|
|
590
592
|
count += 1;
|
|
591
593
|
const name = packageNameAt(resolve(dirname(path), relPackageDir), match);
|
|
592
|
-
|
|
594
|
+
// Through the target's own `exports` map, never a hardcoded `<name>/<seg>`:
|
|
595
|
+
// a package that exports only "." has no such subpath, and the old form
|
|
596
|
+
// crashed at runtime with ERR_PACKAGE_PATH_NOT_EXPORTED. When the root
|
|
597
|
+
// barrel re-exports the file, the root is the proved public form; when no
|
|
598
|
+
// exported subpath reaches it, the build refuses.
|
|
599
|
+
const original = match.slice('require("'.length, -2);
|
|
600
|
+
return `require("${publicSpecifierFor(resolveFrom, name, `dist/${rest}`, original, path)}")`;
|
|
593
601
|
});
|
|
594
602
|
if (count > 0) {
|
|
595
603
|
await writeFile(path, out, 'utf8');
|
|
@@ -712,6 +720,13 @@ async function expectFailure(label, fn) {
|
|
|
712
720
|
return threw;
|
|
713
721
|
}
|
|
714
722
|
|
|
723
|
+
/** Make `name` resolvable from `consumerDir` the way a workspace install does. */
|
|
724
|
+
async function linkWorkspacePackage(consumerDir, name, packageDir) {
|
|
725
|
+
const link = join(consumerDir, 'node_modules', name);
|
|
726
|
+
await mkdir(dirname(link), { recursive: true });
|
|
727
|
+
await symlink(packageDir, link, 'dir');
|
|
728
|
+
}
|
|
729
|
+
|
|
715
730
|
async function selfTest() {
|
|
716
731
|
const root = await mkdtemp(join(tmpdir(), 'scrub-swagger-selftest-'));
|
|
717
732
|
try {
|
|
@@ -830,11 +845,18 @@ async function selfTest() {
|
|
|
830
845
|
['packages/contracts/plain-dir', 'plain-contract'],
|
|
831
846
|
['packages/contracts/other-dir', '@xemahq/renamed'],
|
|
832
847
|
];
|
|
848
|
+
await mkdir(join(ws, 'services/svc/dist'), { recursive: true });
|
|
833
849
|
for (const [dir, name] of pkgs) {
|
|
834
850
|
await mkdir(join(ws, dir, 'dist/lib'), { recursive: true });
|
|
835
|
-
await writeFile(
|
|
851
|
+
await writeFile(
|
|
852
|
+
join(ws, dir, 'package.json'),
|
|
853
|
+
JSON.stringify({ name, exports: { './*': './dist/*/index.js' } }),
|
|
854
|
+
'utf8',
|
|
855
|
+
);
|
|
856
|
+
await writeFile(join(ws, dir, 'dist/lib/index.js'), 'module.exports = require("./x");\n', 'utf8');
|
|
857
|
+
await writeFile(join(ws, dir, 'dist/lib/x.js'), 'module.exports = {};\n', 'utf8');
|
|
858
|
+
await linkWorkspacePackage(join(ws, 'services/svc'), name, join(ws, dir));
|
|
836
859
|
}
|
|
837
|
-
await mkdir(join(ws, 'services/svc/dist'), { recursive: true });
|
|
838
860
|
const wsFile = join(ws, 'services/svc/dist/dto.js');
|
|
839
861
|
const relOf = (dir) => `../../../${dir}/dist/lib/x`;
|
|
840
862
|
await writeFile(
|
|
@@ -851,6 +873,42 @@ async function selfTest() {
|
|
|
851
873
|
throw new Error(`self-test: relative-workspace rewrite missing ${expected}: ${wsAfter}`);
|
|
852
874
|
}
|
|
853
875
|
}
|
|
876
|
+
|
|
877
|
+
// 4c. A package that exports ONLY "." (xema-base authorization-nest). The
|
|
878
|
+
// swagger plugin requires the enum's declaring file; `<name>/<file>` is not
|
|
879
|
+
// exported and crashed at runtime with ERR_PACKAGE_PATH_NOT_EXPORTED.
|
|
880
|
+
// - root barrel re-exports the file -> the root is the proved form;
|
|
881
|
+
// - no exported subpath reaches the file -> the build refuses, naming the
|
|
882
|
+
// package, the file and the importing module.
|
|
883
|
+
const dot = join(root, 'dot');
|
|
884
|
+
const dotPkg = join(dot, 'packages/sdks/dot-only');
|
|
885
|
+
await mkdir(join(dotPkg, 'dist'), { recursive: true });
|
|
886
|
+
await mkdir(join(dot, 'svc/dist'), { recursive: true });
|
|
887
|
+
await writeFile(
|
|
888
|
+
join(dotPkg, 'package.json'),
|
|
889
|
+
JSON.stringify({ name: '@xemahq/dot-only', exports: { '.': './dist/index.js' } }),
|
|
890
|
+
'utf8',
|
|
891
|
+
);
|
|
892
|
+
await writeFile(join(dotPkg, 'dist/index.js'), 'module.exports = require("./grant-wire");\n', 'utf8');
|
|
893
|
+
await writeFile(join(dotPkg, 'dist/grant-wire.js'), 'module.exports = { Kind: {} };\n', 'utf8');
|
|
894
|
+
await writeFile(join(dotPkg, 'dist/internal-only.js'), 'module.exports = { Hidden: {} };\n', 'utf8');
|
|
895
|
+
await linkWorkspacePackage(join(dot, 'svc'), '@xemahq/dot-only', dotPkg);
|
|
896
|
+
const dotDto = (file) =>
|
|
897
|
+
`class Dto {\n static _OPENAPI_METADATA_FACTORY() {\n return { kind: { type: () => require("../../packages/sdks/dot-only/dist/${file}").Kind } };\n }\n}\n`;
|
|
898
|
+
const dotFile = join(dot, 'svc/dist/dto.js');
|
|
899
|
+
await writeFile(dotFile, dotDto('grant-wire'), 'utf8');
|
|
900
|
+
await run([join(dot, 'svc/dist')]);
|
|
901
|
+
const dotAfter = await readFile(dotFile, 'utf8');
|
|
902
|
+
if (!dotAfter.includes('require("@xemahq/dot-only").Kind') || dotAfter.includes('dot-only/')) {
|
|
903
|
+
throw new Error(`self-test: a "."-only package must map to its root, got: ${dotAfter}`);
|
|
904
|
+
}
|
|
905
|
+
await writeFile(dotFile, dotDto('internal-only'), 'utf8');
|
|
906
|
+
const dotRefusal = await expectFailure('unexported file no exported subpath reaches', () => run([join(dot, 'svc/dist')]));
|
|
907
|
+
for (const named of ['@xemahq/dot-only', 'internal-only', dotFile]) {
|
|
908
|
+
if (!dotRefusal.message.includes(named)) {
|
|
909
|
+
throw new Error(`self-test: the refusal must name ${named}; got: ${dotRefusal.message}`);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
854
912
|
// A target directory without its own package.json is a hard failure.
|
|
855
913
|
const orphan = join(root, 'orphan');
|
|
856
914
|
await mkdir(join(orphan, 'svc/dist'), { recursive: true });
|
|
@@ -13,8 +13,8 @@ import { join } from 'node:path';
|
|
|
13
13
|
* schema engine Prisma would not use.
|
|
14
14
|
*/
|
|
15
15
|
export const IMAGE_PLATFORMS = Object.freeze({
|
|
16
|
-
amd64: Object.freeze({ node: 'x64', elfMachine: 62, prismaBinaryTarget: 'debian-openssl-3.0.x' }),
|
|
17
|
-
arm64: Object.freeze({ node: 'arm64', elfMachine: 183, prismaBinaryTarget: 'linux-arm64-openssl-3.0.x' }),
|
|
16
|
+
amd64: Object.freeze({ node: 'x64', libc: 'glibc', elfMachine: 62, prismaBinaryTarget: 'debian-openssl-3.0.x' }),
|
|
17
|
+
arm64: Object.freeze({ node: 'arm64', libc: 'glibc', elfMachine: 183, prismaBinaryTarget: 'linux-arm64-openssl-3.0.x' }),
|
|
18
18
|
});
|
|
19
19
|
|
|
20
20
|
/** The Node `process.arch` name of an image arch (`amd64` → `x64`): what pnpm's `--cpu` takes. Refuses an arch with no row. */
|
|
@@ -25,6 +25,14 @@ export function nodeArch(arch) {
|
|
|
25
25
|
return IMAGE_PLATFORMS[arch].node;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/** The libc of an image arch's runtime base (glibc: node:22-bookworm-slim), from the same row as `nodeArch`. Refuses an arch with no row. */
|
|
29
|
+
export function libcOf(arch) {
|
|
30
|
+
if (!Object.hasOwn(IMAGE_PLATFORMS, arch)) {
|
|
31
|
+
throw new Error(`no libc for image arch '${arch}'; known: ${Object.keys(IMAGE_PLATFORMS).join(', ')}`);
|
|
32
|
+
}
|
|
33
|
+
return IMAGE_PLATFORMS[arch].libc;
|
|
34
|
+
}
|
|
35
|
+
|
|
28
36
|
/** `linux/<arch>` → `<arch>`; refuses any platform this recipe does not build. */
|
|
29
37
|
export function platformArch(platform) {
|
|
30
38
|
const match = /^linux\/([a-z0-9]+)$/.exec(String(platform ?? ''));
|
|
@@ -31,7 +31,8 @@ import { prismaEnginesDirectories } from './composed-image-prisma-engine.mjs';
|
|
|
31
31
|
import { copyTool, toolVersion } from './composed-image-stage.mjs';
|
|
32
32
|
import { MANIFEST_FILE, MANIFEST_SCHEMA } from './service-image-in-image.mjs';
|
|
33
33
|
import { LOCKFILE, TOOL_NAME, rootBuildInputNames, sha256File } from './staged-image-proof.mjs';
|
|
34
|
-
import {
|
|
34
|
+
import { pruneForeignLibcPackages } from './prune-foreign-libc.mjs';
|
|
35
|
+
import { IMAGE_PLATFORMS, libcOf, nodeArch, platformArch, scanNatives } from './service-image-platform.mjs';
|
|
35
36
|
|
|
36
37
|
|
|
37
38
|
export const DEFAULT_STAGE_DIR = '.xema-stage';
|
|
@@ -49,7 +50,7 @@ export const deployArgs = ({ service, arch, destination }) => [
|
|
|
49
50
|
'--ignore-scripts',
|
|
50
51
|
'--os=linux',
|
|
51
52
|
`--cpu=${nodeArch(arch)}`,
|
|
52
|
-
|
|
53
|
+
`--libc=${libcOf(arch)}`,
|
|
53
54
|
'--config.force-legacy-deploy=false',
|
|
54
55
|
'--config.inject-workspace-packages=true',
|
|
55
56
|
'--config.dedupe-injected-deps=true',
|
|
@@ -249,6 +250,8 @@ export async function stageService({
|
|
|
249
250
|
const appDir = join(serviceStage, APP_DIRECTORY);
|
|
250
251
|
mkdirSync(serviceStage, { recursive: true });
|
|
251
252
|
deploy(deployArgs({ service, arch, destination: resolve(appDir) }), root);
|
|
253
|
+
// pnpm 10.33 frozen offline install does not filter by libc; cpu/os do.
|
|
254
|
+
pruneForeignLibcPackages(appDir, libcOf(arch));
|
|
252
255
|
if (!existsSync(join(appDir, 'dist', 'main.js'))) problems.push(`the deploy of ${service} has no dist/main.js: is dist excluded from the package's files?`);
|
|
253
256
|
|
|
254
257
|
// A dependency whose build script matters would have to run on the target; this recipe runs none.
|