@xemahq/repo-build-tooling 0.21.2 → 0.22.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 +1 -10
- package/package.json +7 -7
- package/src/composed-image-in-image.mjs +11 -6
- package/src/composed-image-prebuilt-dockerfile.mjs +27 -1
- package/src/composed-image-prebuilt-dockerfile.test.mjs +24 -0
- package/src/composed-image-prisma-engine.mjs +97 -0
- package/src/composed-image-prisma-engine.test.mjs +73 -0
- package/src/prepush-sanity.mjs +38 -8
- package/src/prepush-sanity.test.mjs +42 -2
- package/src/service-image-in-image.mjs +194 -0
- package/src/service-image-platform.mjs +128 -0
- package/src/service-image-stage.mjs +253 -0
- package/src/service-image.mjs +60 -0
- package/src/service-image.test.mjs +404 -0
- package/src/buildx-builder-host.mjs +0 -61
- package/src/buildx-builder-limits.mjs +0 -40
- package/src/buildx-builder.mjs +0 -175
- package/src/buildx-builder.test.mjs +0 -232
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// `prove`: the IN-IMAGE half of a per-service image (`xema-service-image`). The Dockerfile's
|
|
3
|
+
// proof stage runs it on the TARGET platform, `--network=none`, with the staged app and this
|
|
4
|
+
// tool bind-mounted, the stage manifest COPYed into <proof dir>, and the repository's root build
|
|
5
|
+
// inputs COPYed from the build context into <proof dir>/context:
|
|
6
|
+
//
|
|
7
|
+
// node <tool>/src/service-image-in-image.mjs prove <app dir> <proof dir> --service <service> --commit <sha>
|
|
8
|
+
//
|
|
9
|
+
// The image's content tag is computed from the build context, not from the stage, so the stage
|
|
10
|
+
// must be proved to be the build context's. `prove` refuses the image unless: the manifest names
|
|
11
|
+
// <service>; it was staged at <sha> (the BUILD_SHA the image is built for, so a stale stage
|
|
12
|
+
// cannot ship); the root build inputs in the build context (`ROOT_BUILD_INPUT`: the lockfile,
|
|
13
|
+
// the root manifest, the workspace, .npmrc, .pnpmfile.cjs, turbo.json, tsconfig*.json) are
|
|
14
|
+
// exactly the ones the stage hashed, which also proves the Dockerfile COPYs every one of them
|
|
15
|
+
// (and so the content tag covers them); this tool is the version the stage recorded; this
|
|
16
|
+
// process runs on the manifest's platform; the Node ABI is the stager's when the app carries
|
|
17
|
+
// Node addons; every package's addons load here (`process.dlopen`: an ELF for the right
|
|
18
|
+
// machine can still need a newer glibc than the image has), where a package that ships several
|
|
19
|
+
// builds needs one that loads; Prisma computes, HERE, the binaryTarget whose schema engine was
|
|
20
|
+
// staged, and that engine has the staged sha256 and reports the pinned version; dist/main.js
|
|
21
|
+
// exists. It then writes <proof dir>/verified.json, which the final stage ships, so the image
|
|
22
|
+
// cannot be built without the proof.
|
|
23
|
+
//
|
|
24
|
+
// Loads only `node:` builtins and relative modules that do too (Prisma's own modules are
|
|
25
|
+
// resolved from the app).
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
import { copyFileSync, existsSync, readFileSync, readdirSync, realpathSync } from 'node:fs';
|
|
28
|
+
import { createRequire } from 'node:module';
|
|
29
|
+
import { dirname, join, resolve } from 'node:path';
|
|
30
|
+
import { fileURLToPath } from 'node:url';
|
|
31
|
+
|
|
32
|
+
import { prismaEnginesDirectories, verifySchemaEngine } from './composed-image-prisma-engine.mjs';
|
|
33
|
+
import { currentPlatform } from './service-image-platform.mjs';
|
|
34
|
+
|
|
35
|
+
export const MANIFEST_FILE = 'xema-service-image.json';
|
|
36
|
+
export const MANIFEST_SCHEMA = 2;
|
|
37
|
+
export const VERIFIED_FILE = 'verified.json';
|
|
38
|
+
export const LOCKFILE = 'pnpm-lock.yaml';
|
|
39
|
+
/** The proof dir's subdirectory the Dockerfile COPYs the root build inputs into. */
|
|
40
|
+
export const CONTEXT_DIRECTORY = 'context';
|
|
41
|
+
/**
|
|
42
|
+
* Repository-root files that steer the runner's install and build, so they decide the staged
|
|
43
|
+
* bytes. The stage hashes every one present; the Dockerfile must COPY each into the proof
|
|
44
|
+
* context, which puts it in the content tag.
|
|
45
|
+
*/
|
|
46
|
+
export const ROOT_BUILD_INPUT = /^(pnpm-lock\.yaml|package\.json|pnpm-workspace\.yaml|\.npmrc|\.pnpmfile\.cjs|turbo\.json|tsconfig[^/]*\.json)$/;
|
|
47
|
+
export const TOOL_NAME = '@xemahq/repo-build-tooling';
|
|
48
|
+
const TOOL_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
49
|
+
|
|
50
|
+
export const sha256File = (file) => createHash('sha256').update(readFileSync(file)).digest('hex');
|
|
51
|
+
|
|
52
|
+
/** The version of the tool running this code (the stage's copy, in the image). */
|
|
53
|
+
export const ownToolVersion = () => JSON.parse(readFileSync(join(TOOL_DIRECTORY, 'package.json'), 'utf8')).version;
|
|
54
|
+
|
|
55
|
+
/** Load one Node addon the way `require` does. */
|
|
56
|
+
const dlopen = (file) => process.dlopen({ exports: {} }, file);
|
|
57
|
+
|
|
58
|
+
const getBinaryTarget = (enginesDir) =>
|
|
59
|
+
createRequire(join(enginesDir, 'package.json'))('@prisma/get-platform').getBinaryTargetForCurrentPlatform();
|
|
60
|
+
|
|
61
|
+
/** The problems that keep the root build inputs in `contextDir` from being the ones `inputs` recorded. */
|
|
62
|
+
function contextProblems(contextDir, inputs) {
|
|
63
|
+
const problems = [];
|
|
64
|
+
const carried = existsSync(contextDir) ? readdirSync(contextDir).sort() : [];
|
|
65
|
+
for (const name of Object.keys(inputs).sort()) {
|
|
66
|
+
if (!carried.includes(name)) {
|
|
67
|
+
problems.push(`${name} is a root build input of the stage, and the build context does not carry it: ` +
|
|
68
|
+
`COPY it into /proof/${CONTEXT_DIRECTORY}/ in the Dockerfile and admit it in its .dockerignore, so the content tag covers it`);
|
|
69
|
+
} else if (sha256File(join(contextDir, name)) !== inputs[name]) {
|
|
70
|
+
problems.push(`the build context's ${name} is not the one the stage was built from: re-stage`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
for (const name of carried) {
|
|
74
|
+
if (!Object.hasOwn(inputs, name)) problems.push(`the build context carries ${name}, which the stage did not hash as a root build input`);
|
|
75
|
+
}
|
|
76
|
+
return problems;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Each package's addons (`natives`: package dir -> its target ELF files) must load: one per package. */
|
|
80
|
+
function loadProblems(appDir, natives, load) {
|
|
81
|
+
const problems = [];
|
|
82
|
+
for (const [owner, files] of Object.entries(natives)) {
|
|
83
|
+
const addons = files.filter((file) => file.endsWith('.node'));
|
|
84
|
+
const failures = [];
|
|
85
|
+
for (const addon of addons) {
|
|
86
|
+
try {
|
|
87
|
+
load(join(appDir, addon));
|
|
88
|
+
break;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
failures.push(`${addon}: ${String(error.message).split('\n')[0]}`);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (addons.length > 0 && failures.length === addons.length) {
|
|
94
|
+
problems.push(`no Node addon of ${owner} loads on this image:\n${failures.map((f) => ` ${f}`).join('\n')}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return problems;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check a staged app against its manifest and the build context, on this platform. Throws once,
|
|
102
|
+
* naming every failed check. `platform`, `nodeAbi`, `binaryTarget`, `version`, `load` and
|
|
103
|
+
* `toolVersion` are injectable for tests.
|
|
104
|
+
*/
|
|
105
|
+
export async function prove({
|
|
106
|
+
appDir,
|
|
107
|
+
proofDir,
|
|
108
|
+
service,
|
|
109
|
+
commit,
|
|
110
|
+
platform = currentPlatform(),
|
|
111
|
+
nodeAbi = process.versions.modules,
|
|
112
|
+
binaryTarget = getBinaryTarget,
|
|
113
|
+
version,
|
|
114
|
+
load = dlopen,
|
|
115
|
+
toolVersion = ownToolVersion(),
|
|
116
|
+
log = console.log,
|
|
117
|
+
}) {
|
|
118
|
+
const manifest = JSON.parse(readFileSync(join(proofDir, MANIFEST_FILE), 'utf8'));
|
|
119
|
+
if (manifest.schema !== MANIFEST_SCHEMA) {
|
|
120
|
+
throw new Error(`the stage manifest is schema ${manifest.schema}, this proof reads schema ${MANIFEST_SCHEMA}: re-stage with this tool`);
|
|
121
|
+
}
|
|
122
|
+
const problems = [];
|
|
123
|
+
if (manifest.service !== service) problems.push(`the stage is for '${manifest.service}', this build is for '${service}'`);
|
|
124
|
+
if (manifest.commit !== commit) problems.push(`the stage was built at ${manifest.commit}, this image is for ${commit}: re-stage`);
|
|
125
|
+
problems.push(...contextProblems(join(proofDir, CONTEXT_DIRECTORY), manifest.inputs));
|
|
126
|
+
if (manifest.tool?.name !== TOOL_NAME || manifest.tool?.version !== toolVersion) {
|
|
127
|
+
problems.push(`the stage was made by ${manifest.tool?.name}@${manifest.tool?.version}, this proof is ${TOOL_NAME}@${toolVersion}`);
|
|
128
|
+
}
|
|
129
|
+
const onPlatform = manifest.platform === platform;
|
|
130
|
+
if (!onPlatform) problems.push(`the stage is for ${manifest.platform}, this build runs on ${platform}`);
|
|
131
|
+
const addons = Object.values(manifest.natives).flat().filter((file) => file.endsWith('.node'));
|
|
132
|
+
const sameAbi = addons.length === 0 || manifest.nodeAbi === nodeAbi;
|
|
133
|
+
if (!sameAbi) {
|
|
134
|
+
problems.push(`the app carries Node addons (${addons.join(', ')}) built for Node ABI ${manifest.nodeAbi}, the image's Node is ABI ${nodeAbi}`);
|
|
135
|
+
}
|
|
136
|
+
if (onPlatform && sameAbi) problems.push(...loadProblems(appDir, manifest.natives, load));
|
|
137
|
+
if (!existsSync(join(appDir, 'dist', 'main.js'))) problems.push('dist/main.js does not exist');
|
|
138
|
+
|
|
139
|
+
const enginesDirs = existsSync(join(appDir, 'node_modules', '.pnpm')) ? prismaEnginesDirectories(appDir) : [];
|
|
140
|
+
if (manifest.prisma === null && enginesDirs.length > 0) problems.push('the app carries a prisma CLI, but the stage staged no schema engine');
|
|
141
|
+
if (manifest.prisma !== null && enginesDirs.length === 0) problems.push('the stage staged a schema engine, but the app carries no prisma CLI');
|
|
142
|
+
for (const enginesDir of manifest.prisma === null ? [] : enginesDirs) {
|
|
143
|
+
const target = await binaryTarget(enginesDir);
|
|
144
|
+
if (target !== manifest.prisma.binaryTarget) {
|
|
145
|
+
problems.push(`Prisma computes binaryTarget ${target} here, the stage staged ${manifest.prisma.binaryTarget}`);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
const engine = join(enginesDir, `schema-engine-${target}`);
|
|
149
|
+
if (!existsSync(engine)) {
|
|
150
|
+
problems.push(`${engine} does not exist`);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
if (sha256File(engine) !== manifest.prisma.sha256) {
|
|
154
|
+
problems.push(`${engine} is not the schema engine the stage fetched and verified (sha256 ${manifest.prisma.sha256})`);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
try {
|
|
158
|
+
log(`prisma schema engine: ${engine} (${verifySchemaEngine(engine, manifest.prisma.enginesVersion, version)})`);
|
|
159
|
+
} catch (error) {
|
|
160
|
+
problems.push(error.message);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
if (problems.length > 0) throw new Error(`the staged ${manifest.service} is not this image's:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
164
|
+
copyFileSync(join(proofDir, MANIFEST_FILE), join(proofDir, VERIFIED_FILE));
|
|
165
|
+
log(`proved ${service} at ${commit} on ${platform}`);
|
|
166
|
+
return manifest;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const USAGE = 'usage: service-image-in-image.mjs prove <app dir> <proof dir> --service <service> --commit <40-hex sha>';
|
|
170
|
+
|
|
171
|
+
export function parseProveArgs(argv) {
|
|
172
|
+
const [verb, appDir, proofDir, ...rest] = argv;
|
|
173
|
+
if (verb !== 'prove' || appDir === undefined || proofDir === undefined || rest.length !== 4) throw new Error(USAGE);
|
|
174
|
+
const options = {};
|
|
175
|
+
for (let i = 0; i < rest.length; i += 2) {
|
|
176
|
+
const name = { '--service': 'service', '--commit': 'commit' }[rest[i]];
|
|
177
|
+
if (name === undefined || Object.hasOwn(options, name)) throw new Error(USAGE);
|
|
178
|
+
options[name] = rest[i + 1];
|
|
179
|
+
}
|
|
180
|
+
if (!/^[a-z0-9@][a-z0-9@/._-]*$/.test(options.service ?? '')) throw new Error(`--service must name a workspace package\n${USAGE}`);
|
|
181
|
+
if (!/^[0-9a-f]{40}$/.test(options.commit ?? '')) throw new Error(`--commit must be the 40-hex commit the image is built for (BUILD_SHA)\n${USAGE}`);
|
|
182
|
+
return { appDir: resolve(appDir), proofDir: resolve(proofDir), service: options.service, commit: options.commit };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// realpath both sides: Node resolves the entry module's symlinks, `process.argv[1]` keeps them.
|
|
186
|
+
const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : undefined;
|
|
187
|
+
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
188
|
+
Promise.resolve()
|
|
189
|
+
.then(() => prove(parseProveArgs(process.argv.slice(2))))
|
|
190
|
+
.catch((error) => {
|
|
191
|
+
console.error(error.message);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
});
|
|
194
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// The target platform of a per-service image (`xema-service-image`), and the proof that a
|
|
2
|
+
// staged tree holds native code for that platform only. Loads only `node:` builtins: the
|
|
3
|
+
// in-image `prove` step imports it from the bind-mounted tool directory.
|
|
4
|
+
import { closeSync, existsSync, openSync, readSync, readdirSync } from 'node:fs';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The image platforms this recipe builds, keyed by Docker's `TARGETARCH`. `node` is Node's
|
|
9
|
+
* `process.arch`, `elfMachine` the ELF `e_machine` of native code for it, `prismaBinaryTarget`
|
|
10
|
+
* the binaryTarget Prisma's get-platform computes inside the runtime base image
|
|
11
|
+
* (node:22-bookworm-slim + openssl 3.0). The in-image `prove` step re-computes the last one on
|
|
12
|
+
* the target and refuses the image when it differs, so a base image change cannot ship a
|
|
13
|
+
* schema engine Prisma would not use.
|
|
14
|
+
*/
|
|
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' }),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** `linux/<arch>` → `<arch>`; refuses any platform this recipe does not build. */
|
|
21
|
+
export function platformArch(platform) {
|
|
22
|
+
const match = /^linux\/([a-z0-9]+)$/.exec(String(platform ?? ''));
|
|
23
|
+
if (match === null || !Object.hasOwn(IMAGE_PLATFORMS, match[1])) {
|
|
24
|
+
throw new Error(`--platform must be one of ${Object.keys(IMAGE_PLATFORMS).map((a) => `linux/${a}`).join(', ')}, got '${platform}'`);
|
|
25
|
+
}
|
|
26
|
+
return match[1];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The `linux/<arch>` this Node process runs on (the in-image check of the target). */
|
|
30
|
+
export function currentPlatform(nodeArch = process.arch) {
|
|
31
|
+
const arch = Object.keys(IMAGE_PLATFORMS).find((key) => IMAGE_PLATFORMS[key].node === nodeArch);
|
|
32
|
+
if (arch === undefined) throw new Error(`Node arch '${nodeArch}' is not an image platform this recipe builds`);
|
|
33
|
+
return `linux/${arch}`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const MACH_O = new Set([0xfeedface, 0xfeedfacf, 0xcefaedfe, 0xcffaedfe, 0xcafebabe]);
|
|
37
|
+
|
|
38
|
+
/** What the first bytes of a file say it is: `{ format: 'elf', machine }`, `{ format: 'mach-o' }`, `{ format: 'pe' }` or `undefined`. */
|
|
39
|
+
export function nativeFormat(header) {
|
|
40
|
+
if (header.length >= 20 && header[0] === 0x7f && header[1] === 0x45 && header[2] === 0x4c && header[3] === 0x46) {
|
|
41
|
+
const littleEndian = header[5] === 1;
|
|
42
|
+
return { format: 'elf', machine: littleEndian ? header.readUInt16LE(18) : header.readUInt16BE(18) };
|
|
43
|
+
}
|
|
44
|
+
if (header.length >= 4 && MACH_O.has(header.readUInt32BE(0))) return { format: 'mach-o' };
|
|
45
|
+
if (header.length >= 2 && header[0] === 0x4d && header[1] === 0x5a) return { format: 'pe' };
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readHeader(file) {
|
|
50
|
+
const fd = openSync(file, 'r');
|
|
51
|
+
try {
|
|
52
|
+
const buffer = Buffer.alloc(20);
|
|
53
|
+
const read = readSync(fd, buffer, 0, 20, 0);
|
|
54
|
+
return buffer.subarray(0, read);
|
|
55
|
+
} finally {
|
|
56
|
+
closeSync(fd);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The directory of the nearest `package.json` at or above `dir`, stopping at `root` (`root` itself when none). */
|
|
61
|
+
function owningPackage(root, rel) {
|
|
62
|
+
const parts = rel.split('/').slice(0, -1);
|
|
63
|
+
for (let n = parts.length; n > 0; n -= 1) {
|
|
64
|
+
const candidate = parts.slice(0, n).join('/');
|
|
65
|
+
if (existsSync(join(root, candidate, 'package.json'))) return candidate;
|
|
66
|
+
}
|
|
67
|
+
return '.';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Every native file under `root` for `arch` (relative, sorted), and the problems that keep the tree out
|
|
72
|
+
* of a linux/`arch` image. Native code is judged PER PACKAGE (the nearest `package.json`): a package may
|
|
73
|
+
* ship prebuilt binaries for several platforms and load the one for the platform it runs on
|
|
74
|
+
* (`@temporalio/core-bridge` carries `releases/<triple>/index.node`). A package is refused when it
|
|
75
|
+
* carries native code (an ELF object, a Mach-O, a `.node` addon; a PE header counts only on
|
|
76
|
+
* `.node`/`.exe`/`.dll`, as `MZ` also opens ordinary data) and NONE of it is an ELF for `arch`: then the
|
|
77
|
+
* binary it would load was built for another machine (a host build) and cannot run here.
|
|
78
|
+
* `natives` lists the ELF objects for `arch`; `byPackage` groups them by owning package (the in-image
|
|
79
|
+
* proof loads each package's addons on the target); `foreign` the other platforms' files a refused
|
|
80
|
+
* package did not cause. Symlinks are not followed.
|
|
81
|
+
*/
|
|
82
|
+
export function scanNatives(root, arch) {
|
|
83
|
+
const { elfMachine } = IMAGE_PLATFORMS[arch];
|
|
84
|
+
const packages = new Map(); // package dir -> { target: [], foreign: [] }
|
|
85
|
+
const record = (rel, isTarget, why) => {
|
|
86
|
+
const owner = owningPackage(root, rel);
|
|
87
|
+
if (!packages.has(owner)) packages.set(owner, { target: [], foreign: [] });
|
|
88
|
+
const entry = packages.get(owner);
|
|
89
|
+
if (isTarget) entry.target.push(rel);
|
|
90
|
+
else entry.foreign.push(`${rel}: ${why}`);
|
|
91
|
+
};
|
|
92
|
+
const walk = (dir, prefix) => {
|
|
93
|
+
for (const entry of readdirSync(dir, { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : 1))) {
|
|
94
|
+
const rel = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
|
|
95
|
+
if (entry.isDirectory()) {
|
|
96
|
+
walk(join(dir, entry.name), rel);
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (!entry.isFile()) continue;
|
|
100
|
+
const kind = nativeFormat(readHeader(join(dir, entry.name)));
|
|
101
|
+
if (kind?.format === 'elf') {
|
|
102
|
+
if (kind.machine === elfMachine) record(rel, true);
|
|
103
|
+
else record(rel, false, `ELF e_machine ${kind.machine}, the linux/${arch} image needs ${elfMachine}`);
|
|
104
|
+
} else if (kind?.format === 'mach-o') {
|
|
105
|
+
record(rel, false, 'a Mach-O binary');
|
|
106
|
+
} else if (kind?.format === 'pe' && /\.(node|exe|dll)$/.test(entry.name)) {
|
|
107
|
+
record(rel, false, 'a Windows binary');
|
|
108
|
+
} else if (entry.name.endsWith('.node')) {
|
|
109
|
+
record(rel, false, 'a native addon that is not an ELF object');
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
};
|
|
113
|
+
walk(root, '');
|
|
114
|
+
const natives = [];
|
|
115
|
+
const byPackage = {};
|
|
116
|
+
const foreign = [];
|
|
117
|
+
const problems = [];
|
|
118
|
+
for (const [owner, { target, foreign: others }] of [...packages].sort(([a], [b]) => (a < b ? -1 : 1))) {
|
|
119
|
+
natives.push(...target);
|
|
120
|
+
if (target.length > 0) byPackage[owner] = [...target].sort();
|
|
121
|
+
if (target.length === 0) {
|
|
122
|
+
problems.push(`${owner} carries native code and none of it is for linux/${arch}:\n${others.map((o) => ` ${o}`).join('\n')}`);
|
|
123
|
+
} else {
|
|
124
|
+
foreign.push(...others.map((o) => o.slice(0, o.indexOf(': '))));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return { natives: natives.sort(), byPackage, foreign: foreign.sort(), problems };
|
|
128
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
// `xema-service-image stage`: the CI runner's half of a per-service image. The service is
|
|
2
|
+
// built on the runner (`turbo run build prisma:generate --filter <service>...`, the lane's
|
|
3
|
+
// step) and DEPLOYED here, for the image's target platform, into the build-step directory:
|
|
4
|
+
//
|
|
5
|
+
// <stage>/service/app/ `pnpm deploy --prod` of the service: dist, the
|
|
6
|
+
// production node_modules for linux/<arch>, the Prisma
|
|
7
|
+
// client and the target's Prisma schema engine
|
|
8
|
+
// <stage>/service/xema-service-image.json the manifest the in-image `prove` step checks
|
|
9
|
+
// <stage>/tool/ this package (bind-mounted for `prove`, never shipped)
|
|
10
|
+
//
|
|
11
|
+
// The Dockerfile COPYs `app/` and runs nothing but the proof: no install, no build, no network.
|
|
12
|
+
// The image's content tag is computed from the commit's build context, never from the stage, so
|
|
13
|
+
// the manifest binds the stage to it: the commit (with no uncommitted change to a tracked file),
|
|
14
|
+
// the sha256 of every root build input (`ROOT_BUILD_INPUT`), which the Dockerfile COPYs into the
|
|
15
|
+
// proof context and so into the tag, and the tool's version, which the lockfile pins (the stage
|
|
16
|
+
// refuses to run as any tool but the one the repository root installs).
|
|
17
|
+
// The per-repo, single-service counterpart of `xema-composed-image stage`; it shares its tool
|
|
18
|
+
// copy, its build-script rule (`rebuildList`) and its Prisma engine proof. The one difference is
|
|
19
|
+
// where production dependencies are installed: here, on the runner, selected for the TARGET
|
|
20
|
+
// platform with `--os/--cpu/--libc`, never for the host, and then scanned (`scanNatives`).
|
|
21
|
+
import { execFileSync } from 'node:child_process';
|
|
22
|
+
import { createHash } from 'node:crypto';
|
|
23
|
+
import { chmodSync, cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, writeFileSync } from 'node:fs';
|
|
24
|
+
import { createRequire } from 'node:module';
|
|
25
|
+
import { dirname, join, posix, resolve } from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
import { gunzipSync } from 'node:zlib';
|
|
28
|
+
|
|
29
|
+
import { REBUILD_EXCLUDED, onlyBuiltDependencies, rebuildList } from './composed-image-prebuilt-dockerfile.mjs';
|
|
30
|
+
import { prismaEnginesDirectories } from './composed-image-prisma-engine.mjs';
|
|
31
|
+
import { copyTool, toolVersion } from './composed-image-stage.mjs';
|
|
32
|
+
import { LOCKFILE, MANIFEST_FILE, MANIFEST_SCHEMA, ROOT_BUILD_INPUT, TOOL_NAME, sha256File } from './service-image-in-image.mjs';
|
|
33
|
+
import { IMAGE_PLATFORMS, platformArch, scanNatives } from './service-image-platform.mjs';
|
|
34
|
+
|
|
35
|
+
export { LOCKFILE, MANIFEST_FILE, MANIFEST_SCHEMA, ROOT_BUILD_INPUT };
|
|
36
|
+
|
|
37
|
+
export const DEFAULT_STAGE_DIR = '.xema-stage';
|
|
38
|
+
export const SERVICE_STAGE_SUBDIRECTORY = 'service';
|
|
39
|
+
export const APP_DIRECTORY = 'app';
|
|
40
|
+
/** Where Prisma publishes its engines, by engines commit and binaryTarget. */
|
|
41
|
+
export const PRISMA_ENGINES_URL = 'https://binaries.prisma.sh/all_commits';
|
|
42
|
+
const TOOL_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
43
|
+
|
|
44
|
+
/** The deploy command: xema-base `service-prebuilt`'s deploy flags, plus the target selection. */
|
|
45
|
+
export const deployArgs = ({ service, arch, destination }) => [
|
|
46
|
+
'deploy',
|
|
47
|
+
'--prod',
|
|
48
|
+
'--offline',
|
|
49
|
+
'--ignore-scripts',
|
|
50
|
+
'--os=linux',
|
|
51
|
+
`--cpu=${arch}`,
|
|
52
|
+
'--libc=glibc',
|
|
53
|
+
'--config.force-legacy-deploy=false',
|
|
54
|
+
'--config.inject-workspace-packages=true',
|
|
55
|
+
'--config.dedupe-injected-deps=true',
|
|
56
|
+
'--filter',
|
|
57
|
+
service,
|
|
58
|
+
destination,
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
const runPnpm = (args, cwd) => execFileSync('pnpm', args, { cwd, stdio: 'inherit' });
|
|
62
|
+
const runGit = (args, cwd) => execFileSync('git', args, { cwd, encoding: 'utf8' });
|
|
63
|
+
|
|
64
|
+
/** `<name>` of a `node_modules/.pnpm` entry (`@scope+name@1.0.0_peer@2…` → `@scope/name`). */
|
|
65
|
+
export function pnpmEntryName(entry) {
|
|
66
|
+
const at = entry.indexOf('@', entry.startsWith('@') ? 1 : 0);
|
|
67
|
+
if (at <= 0) return undefined;
|
|
68
|
+
const flat = entry.slice(0, at);
|
|
69
|
+
return flat.startsWith('@') ? flat.replace('+', '/') : flat;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The production closure of a deploy: `[{ name, dir }]`, one per `node_modules/.pnpm` entry. */
|
|
73
|
+
export function deployedPackages(appDir) {
|
|
74
|
+
const store = join(appDir, 'node_modules', '.pnpm');
|
|
75
|
+
if (!existsSync(store)) return [];
|
|
76
|
+
const packages = [];
|
|
77
|
+
for (const entry of readdirSync(store).sort()) {
|
|
78
|
+
const name = pnpmEntryName(entry);
|
|
79
|
+
if (name === undefined) continue;
|
|
80
|
+
const dir = join(store, entry, 'node_modules', name);
|
|
81
|
+
if (existsSync(join(dir, 'package.json'))) packages.push({ name, dir });
|
|
82
|
+
}
|
|
83
|
+
return packages;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
87
|
+
|
|
88
|
+
async function get(url) {
|
|
89
|
+
const response = await fetch(url);
|
|
90
|
+
if (!response.ok) throw new Error(`GET ${url}: ${response.status} ${response.statusText}`);
|
|
91
|
+
return Buffer.from(await response.arrayBuffer());
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** The sha256 Prisma publishes next to `url` (`<url>.sha256`: `<hex> <name>`). */
|
|
95
|
+
async function publishedSha256(url) {
|
|
96
|
+
const [hex] = (await get(`${url}.sha256`)).toString('utf8').trim().split(/\s+/);
|
|
97
|
+
if (!/^[a-f0-9]{64}$/.test(hex)) throw new Error(`${url}.sha256 is not a sha256`);
|
|
98
|
+
return hex;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The schema engine at `enginesVersion` (the engines commit the lockfile pins, through the
|
|
103
|
+
* deploy's @prisma/engines-version) for `binaryTarget`, fetched from Prisma for every stage and
|
|
104
|
+
* written to `enginesDir`. Both the download and the engine must match the checksums Prisma
|
|
105
|
+
* publishes. There is no cache: Prisma's own downloader keeps one across runs in the home
|
|
106
|
+
* directory, honours mirror variables and retries, so it is not used.
|
|
107
|
+
*/
|
|
108
|
+
export async function downloadSchemaEngine({ enginesDir, binaryTarget, enginesVersion }) {
|
|
109
|
+
if (!/^[a-f0-9]{40}$/.test(enginesVersion)) throw new Error(`@prisma/engines-version ${enginesVersion} is not an engines commit`);
|
|
110
|
+
const url = `${PRISMA_ENGINES_URL}/${enginesVersion}/${binaryTarget}/schema-engine`;
|
|
111
|
+
const [zippedSha256, engineSha256, zipped] = await Promise.all([publishedSha256(`${url}.gz`), publishedSha256(url), get(`${url}.gz`)]);
|
|
112
|
+
if (sha256(zipped) !== zippedSha256) throw new Error(`${url}.gz has sha256 ${sha256(zipped)}, Prisma publishes ${zippedSha256}`);
|
|
113
|
+
const engine = gunzipSync(zipped);
|
|
114
|
+
if (sha256(engine) !== engineSha256) throw new Error(`${url} has sha256 ${sha256(engine)}, Prisma publishes ${engineSha256}`);
|
|
115
|
+
const file = join(enginesDir, `schema-engine-${binaryTarget}`);
|
|
116
|
+
writeFileSync(file, engine);
|
|
117
|
+
chmodSync(file, 0o755);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The commit, the root build inputs (name -> sha256) and the problems that keep them from
|
|
122
|
+
* naming the stage: an uncommitted change to a tracked file, or a root build input git does
|
|
123
|
+
* not track (the content tag is computed from the commit, so it could not see either).
|
|
124
|
+
*/
|
|
125
|
+
function buildInputs(root, git) {
|
|
126
|
+
const problems = [];
|
|
127
|
+
const commit = git(['rev-parse', 'HEAD'], root).trim();
|
|
128
|
+
const changed = git(['status', '--porcelain', '--untracked-files=no'], root).trim();
|
|
129
|
+
if (changed !== '') problems.push(`tracked files differ from ${commit}, which the image's tag is computed from:\n${changed}`);
|
|
130
|
+
const present = readdirSync(root, { withFileTypes: true })
|
|
131
|
+
.filter((entry) => entry.isFile() && ROOT_BUILD_INPUT.test(entry.name))
|
|
132
|
+
.map((entry) => entry.name)
|
|
133
|
+
.sort();
|
|
134
|
+
if (!present.includes(LOCKFILE)) problems.push(`${LOCKFILE} does not exist at ${root}`);
|
|
135
|
+
const tracked = present.length === 0 ? new Set() : new Set(git(['ls-files', '-z', '--', ...present], root).split('\0'));
|
|
136
|
+
const inputs = {};
|
|
137
|
+
for (const name of present) {
|
|
138
|
+
if (tracked.has(name)) inputs[name] = sha256File(join(root, name));
|
|
139
|
+
else problems.push(`${name} steers the build and git does not track it, so no image tag can cover it`);
|
|
140
|
+
}
|
|
141
|
+
return { commit, inputs, problems };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** The problem when this code is not the @xemahq/repo-build-tooling the repository root installs. */
|
|
145
|
+
function toolProblem(root) {
|
|
146
|
+
const installed = join(root, 'node_modules', ...TOOL_NAME.split('/'));
|
|
147
|
+
const here = realpathSync(TOOL_DIRECTORY);
|
|
148
|
+
if (existsSync(installed) && realpathSync(installed) === here) return undefined;
|
|
149
|
+
return `this xema-service-image (${here}) is not the ${TOOL_NAME} the repository root installs: run \`pnpm exec xema-service-image\` from the root, so the lockfile pins the tool that stages`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Deploy, complete and check one service for one platform. Throws once, naming every offender.
|
|
154
|
+
* `deploy` and `fetchEngine` are injectable for tests.
|
|
155
|
+
*/
|
|
156
|
+
export async function stageService({
|
|
157
|
+
root = process.cwd(),
|
|
158
|
+
service,
|
|
159
|
+
path,
|
|
160
|
+
platform,
|
|
161
|
+
stageDir = DEFAULT_STAGE_DIR,
|
|
162
|
+
deploy = runPnpm,
|
|
163
|
+
fetchEngine = downloadSchemaEngine,
|
|
164
|
+
git = runGit,
|
|
165
|
+
log = console.log,
|
|
166
|
+
}) {
|
|
167
|
+
if (typeof service !== 'string' || service === '') throw new Error('stage needs --service <workspace package>');
|
|
168
|
+
if (typeof path !== 'string' || path === '') throw new Error('stage needs --path <service directory>');
|
|
169
|
+
const arch = platformArch(platform);
|
|
170
|
+
const servicePath = posix.normalize(path);
|
|
171
|
+
const serviceDir = join(root, servicePath);
|
|
172
|
+
|
|
173
|
+
const problems = [];
|
|
174
|
+
const manifestPath = join(serviceDir, 'package.json');
|
|
175
|
+
if (!existsSync(manifestPath)) throw new Error(`${servicePath}/package.json does not exist`);
|
|
176
|
+
const name = JSON.parse(readFileSync(manifestPath, 'utf8')).name;
|
|
177
|
+
if (name !== service) problems.push(`${servicePath}/package.json is '${name}', not '${service}'`);
|
|
178
|
+
if (!existsSync(join(serviceDir, 'dist', 'main.js'))) problems.push(`${servicePath}/dist/main.js does not exist: build the service first`);
|
|
179
|
+
const schema = existsSync(join(serviceDir, 'prisma', 'schema.prisma'));
|
|
180
|
+
const clientDir = join(serviceDir, 'node_modules', '.prisma');
|
|
181
|
+
const client = existsSync(clientDir) && readdirSync(clientDir, { withFileTypes: true }).some((e) => e.isDirectory());
|
|
182
|
+
if (schema && !client) problems.push(`${servicePath} has prisma/schema.prisma but no node_modules/.prisma/<client>/: run prisma generate`);
|
|
183
|
+
if (!schema && client) problems.push(`${servicePath} has node_modules/.prisma/<client>/ but no prisma/schema.prisma`);
|
|
184
|
+
const { commit, inputs, problems: inputProblems } = buildInputs(root, git);
|
|
185
|
+
problems.push(...inputProblems);
|
|
186
|
+
const wrongTool = toolProblem(root);
|
|
187
|
+
if (wrongTool !== undefined) problems.push(wrongTool);
|
|
188
|
+
const stageRoot = join(root, stageDir);
|
|
189
|
+
if (existsSync(stageRoot) && readdirSync(stageRoot).length > 0) problems.push(`${stageDir} already exists and is not empty`);
|
|
190
|
+
if (problems.length > 0) throw new Error(`cannot stage ${service}:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
191
|
+
|
|
192
|
+
const serviceStage = join(stageRoot, SERVICE_STAGE_SUBDIRECTORY);
|
|
193
|
+
const appDir = join(serviceStage, APP_DIRECTORY);
|
|
194
|
+
mkdirSync(serviceStage, { recursive: true });
|
|
195
|
+
deploy(deployArgs({ service, arch, destination: resolve(appDir) }), root);
|
|
196
|
+
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?`);
|
|
197
|
+
|
|
198
|
+
// A dependency whose build script matters would have to run on the target; this recipe runs none.
|
|
199
|
+
const workspaceFile = join(root, 'pnpm-workspace.yaml');
|
|
200
|
+
const built = existsSync(workspaceFile) ? onlyBuiltDependencies(readFileSync(workspaceFile, 'utf8')) : [];
|
|
201
|
+
const prodPackages = deployedPackages(appDir);
|
|
202
|
+
try {
|
|
203
|
+
for (const needsBuild of rebuildList({ built, prodPackages })) {
|
|
204
|
+
problems.push(`${needsBuild} is in the production closure and needs its build script on linux/${arch}; ` +
|
|
205
|
+
`this recipe runs no scripts (only ${REBUILD_EXCLUDED.join(', ')} are handled)`);
|
|
206
|
+
}
|
|
207
|
+
} catch (error) {
|
|
208
|
+
problems.push(error.message);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (client) cpSync(clientDir, join(appDir, 'node_modules', '.prisma'), { recursive: true, verbatimSymlinks: true });
|
|
212
|
+
|
|
213
|
+
let prisma = null;
|
|
214
|
+
const enginesDirs = prodPackages.some((p) => p.name === 'prisma') ? prismaEnginesDirectories(appDir) : [];
|
|
215
|
+
const binaryTarget = IMAGE_PLATFORMS[arch].prismaBinaryTarget;
|
|
216
|
+
for (const enginesDir of enginesDirs) {
|
|
217
|
+
const { enginesVersion } = createRequire(join(enginesDir, 'package.json'))('@prisma/engines-version');
|
|
218
|
+
if (prisma !== null && prisma.enginesVersion !== enginesVersion) {
|
|
219
|
+
problems.push(`the deploy carries two Prisma engines versions (${prisma.enginesVersion}, ${enginesVersion})`);
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
await fetchEngine({ enginesDir, binaryTarget, enginesVersion });
|
|
223
|
+
if (!existsSync(join(enginesDir, `schema-engine-${binaryTarget}`))) {
|
|
224
|
+
problems.push(`the download did not produce schema-engine-${binaryTarget} in ${enginesDir}`);
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
prisma = { enginesVersion, binaryTarget, sha256: sha256File(join(enginesDir, `schema-engine-${binaryTarget}`)) };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const { natives, byPackage, foreign, problems: nativeProblems } = scanNatives(appDir, arch);
|
|
231
|
+
problems.push(...nativeProblems);
|
|
232
|
+
if (problems.length > 0) throw new Error(`cannot stage ${service} for linux/${arch}:\n${problems.map((p) => ` - ${p}`).join('\n')}`);
|
|
233
|
+
|
|
234
|
+
const manifest = {
|
|
235
|
+
schema: MANIFEST_SCHEMA,
|
|
236
|
+
service,
|
|
237
|
+
path: servicePath,
|
|
238
|
+
platform: `linux/${arch}`,
|
|
239
|
+
commit,
|
|
240
|
+
inputs,
|
|
241
|
+
nodeAbi: process.versions.modules,
|
|
242
|
+
natives: byPackage,
|
|
243
|
+
foreignNatives: foreign.length,
|
|
244
|
+
prisma,
|
|
245
|
+
tool: { name: TOOL_NAME, version: toolVersion() },
|
|
246
|
+
};
|
|
247
|
+
writeFileSync(join(serviceStage, MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
248
|
+
const toolFiles = copyTool(join(stageRoot, 'tool'));
|
|
249
|
+
log(`staged ${service} for linux/${arch}: ${prodPackages.length} packages, ${natives.length} native files ` +
|
|
250
|
+
`(${foreign.length} other-platform files in multi-platform packages), ` +
|
|
251
|
+
`prisma ${prisma === null ? 'none' : `${prisma.enginesVersion} ${prisma.binaryTarget}`}, tool ${toolFiles} files`);
|
|
252
|
+
return manifest;
|
|
253
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
3
|
+
// `xema-service-image`: ONE service's image, built on the CI runner and COPYed into Docker.
|
|
4
|
+
//
|
|
5
|
+
// The per-repository, single-service counterpart of `xema-composed-image`. The lane builds
|
|
6
|
+
// the service on the runner (`turbo run build prisma:generate --filter <service>...`), then:
|
|
7
|
+
//
|
|
8
|
+
// xema-service-image stage --service <pkg> --path <dir> --platform linux/<amd64|arm64> [--stage-dir <dir>]
|
|
9
|
+
// `pnpm deploy --prod --offline --ignore-scripts` of the service for the TARGET
|
|
10
|
+
// platform into `<stage>/service/app`, with its Prisma client and the target's schema
|
|
11
|
+
// engine (fetched and checksum-verified, never cached); refuses a package with no
|
|
12
|
+
// native binary for the target, dependencies whose build script would have to run,
|
|
13
|
+
// uncommitted tracked changes and any tool but the one the root installs; writes the
|
|
14
|
+
// manifest (commit, root build inputs, tool version) and the tool the Dockerfile's
|
|
15
|
+
// proof stage runs (`service-image-stage.mjs`).
|
|
16
|
+
//
|
|
17
|
+
// The Dockerfile then runs only `prove` (`service-image-in-image.mjs`, on the target, offline)
|
|
18
|
+
// and COPYs `app/`: no install, no build, no registry token in the image build.
|
|
19
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
20
|
+
import { realpathSync } from 'node:fs';
|
|
21
|
+
import { fileURLToPath } from 'node:url';
|
|
22
|
+
|
|
23
|
+
import { DEFAULT_STAGE_DIR, stageService } from './service-image-stage.mjs';
|
|
24
|
+
|
|
25
|
+
const USAGE = 'usage: xema-service-image stage --service <pkg> --path <dir> --platform linux/<amd64|arm64> [--stage-dir <dir>]';
|
|
26
|
+
const OPTIONS = new Set(['service', 'path', 'platform', 'stage-dir']);
|
|
27
|
+
|
|
28
|
+
export function parseStageArgs(argv) {
|
|
29
|
+
const options = {};
|
|
30
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
31
|
+
const flag = argv[i];
|
|
32
|
+
const value = argv[i + 1];
|
|
33
|
+
const name = flag?.startsWith('--') ? flag.slice(2) : undefined;
|
|
34
|
+
if (name === undefined || !OPTIONS.has(name)) throw new Error(`unknown argument '${flag}'\n${USAGE}`);
|
|
35
|
+
if (value === undefined || value.startsWith('--')) throw new Error(`${flag} needs a value\n${USAGE}`);
|
|
36
|
+
if (Object.hasOwn(options, name)) throw new Error(`${flag} is given twice`);
|
|
37
|
+
options[name] = value;
|
|
38
|
+
}
|
|
39
|
+
for (const required of ['service', 'path', 'platform']) {
|
|
40
|
+
if (!Object.hasOwn(options, required)) throw new Error(`--${required} is required\n${USAGE}`);
|
|
41
|
+
}
|
|
42
|
+
return { service: options.service, path: options.path, platform: options.platform, stageDir: options['stage-dir'] ?? DEFAULT_STAGE_DIR };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function main(argv) {
|
|
46
|
+
const [verb, ...rest] = argv;
|
|
47
|
+
if (verb !== 'stage') throw new Error(USAGE);
|
|
48
|
+
await stageService(parseStageArgs(rest));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// realpath both sides: Node resolves the entry module's symlinks (a pnpm bin shim runs this file
|
|
52
|
+
// through node_modules), `process.argv[1]` keeps them.
|
|
53
|
+
const invokedPath = process.argv[1] ? realpathSync(process.argv[1]) : undefined;
|
|
54
|
+
if (invokedPath === fileURLToPath(import.meta.url)) {
|
|
55
|
+
main(process.argv.slice(2)).catch((error) => {
|
|
56
|
+
console.error(`::error::${error.message.split('\n')[0]}`);
|
|
57
|
+
console.error(error.message);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
});
|
|
60
|
+
}
|