@xemahq/repo-build-tooling 0.27.3 → 0.29.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/package.json +5 -3
- package/src/effective-input.mjs +395 -0
- package/src/effective-input.test.mjs +290 -0
- package/src/invoked-directly.mjs +38 -0
- package/src/readiness.mjs +6 -35
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.29.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,6 +25,7 @@
|
|
|
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-effective-input": "src/effective-input.mjs",
|
|
28
29
|
"xema-promote-image": "src/promote-image.mjs",
|
|
29
30
|
"xema-readiness": "src/readiness.mjs",
|
|
30
31
|
"xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs",
|
|
@@ -36,6 +37,7 @@
|
|
|
36
37
|
"./check-no-vendored-deps": "./src/check-no-vendored-deps.mjs",
|
|
37
38
|
"./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs",
|
|
38
39
|
"./composed-image": "./src/composed-image.mjs",
|
|
40
|
+
"./effective-input": "./src/effective-input.mjs",
|
|
39
41
|
"./package.json": "./package.json",
|
|
40
42
|
"./promote-image": "./src/promote-image.mjs",
|
|
41
43
|
"./readiness": "./src/readiness.mjs",
|
|
@@ -47,9 +49,9 @@
|
|
|
47
49
|
"@xemahq/release-policy": "^16.0.0"
|
|
48
50
|
},
|
|
49
51
|
"scripts": {
|
|
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 && node --test src/staged-image-proof.test.mjs"
|
|
52
|
+
"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 && node --test src/staged-image-proof.test.mjs && node --test src/effective-input.test.mjs"
|
|
51
53
|
},
|
|
52
54
|
"xemaRelease": {
|
|
53
|
-
"sourceDigest": "sha256:
|
|
55
|
+
"sourceDigest": "sha256:a5327bf366fb31aeef8adb584888e52a5ae4da7aa5602eb672eb2876ed912a8b"
|
|
54
56
|
}
|
|
55
57
|
}
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The EFFECTIVE-INPUT key of one image: a content hash of every input that decides its bytes, computed from
|
|
3
|
+
// SOURCES, before any build, so a lane can skip build, stage and push when the key is already in the registry.
|
|
4
|
+
//
|
|
5
|
+
// xema-effective-input --service <pkg> [--service <pkg>]… --platform linux/<amd64|arm64> --base-image <name@sha256:digest>
|
|
6
|
+
// --dockerfile <file> [--script <file>]… [--build-arg NAME=VALUE]… [--out <file>]
|
|
7
|
+
//
|
|
8
|
+
// Run from the workspace root (the repository, or an aggregator workspace root: the staging actions' `context-root`).
|
|
9
|
+
// It prints the key. The key is the same from either root for the same inputs, because nothing in it is a root-relative
|
|
10
|
+
// path: workspace packages are keyed by NAME and hashed over their package-relative tracked files, the lockfile is
|
|
11
|
+
// reduced to a slice (the closure's importers, rewritten to package names, and the entries reachable from them), and of
|
|
12
|
+
// the root files only the compile-steering ones (`.npmrc`, `tsconfig*.json`) enter, by name. The whole root
|
|
13
|
+
// `package.json`, lockfile and workspace file differ by root and are the PROVE step's business (xema-service-image).
|
|
14
|
+
//
|
|
15
|
+
// Build args the Dockerfile receives from CI are part of `tooling.buildArgs` (name -> value, sorted), except
|
|
16
|
+
// the ones that are only a per-root or per-commit fact: `REPO_DIR` and `SERVICE_PATH` are paths that differ by root
|
|
17
|
+
// (the service is keyed by name and its files by package-relative path), `BUILD_SHA` is the commit (the key is a
|
|
18
|
+
// CONTENT key, and a commit tag would make every commit a new image). They are refused as inputs, not hashed.
|
|
19
|
+
//
|
|
20
|
+
// One enumeration: the closure is the one the stage builds (`turbo run build --filter "<service>..." --dry=json`), and
|
|
21
|
+
// the root files come from `rootBuildInputNames` of @xemahq/repo-build-tooling (the prove step's own list).
|
|
22
|
+
import { execFileSync } from 'node:child_process';
|
|
23
|
+
import { createHash } from 'node:crypto';
|
|
24
|
+
import { readFileSync, readlinkSync, writeFileSync } from 'node:fs';
|
|
25
|
+
import { join, posix, resolve } from 'node:path';
|
|
26
|
+
import { fileURLToPath } from 'node:url';
|
|
27
|
+
|
|
28
|
+
import { invokedDirectly } from './invoked-directly.mjs';
|
|
29
|
+
import { rootBuildInputNames } from './staged-image-proof.mjs';
|
|
30
|
+
|
|
31
|
+
export const KEY_SCHEMA = 1;
|
|
32
|
+
/** Build args that are a root-relative path or the commit, never part of the key. */
|
|
33
|
+
export const NON_CONTENT_BUILD_ARGS = Object.freeze(['REPO_DIR', 'SERVICE_PATH', 'BUILD_SHA']);
|
|
34
|
+
const DIGEST_PINNED = /^[^\s@]+@sha256:[0-9a-f]{64}$/;
|
|
35
|
+
const COMPILE_ROOT_FILE = /^(\.npmrc|tsconfig[^/]*\.json)$/;
|
|
36
|
+
const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies'];
|
|
37
|
+
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
38
|
+
|
|
39
|
+
/** Canonical JSON: keys sorted recursively, no whitespace. */
|
|
40
|
+
export function canonicalJson(value) {
|
|
41
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
42
|
+
if (value !== null && typeof value === 'object') {
|
|
43
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
|
|
44
|
+
}
|
|
45
|
+
return JSON.stringify(value);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const unquote = (key) => (key.startsWith("'") && key.endsWith("'") ? key.slice(1, -1) : key);
|
|
49
|
+
|
|
50
|
+
/** The top-level sections of a pnpm v9 lockfile, each as `{ entryKey: [body lines] }` (entries are the 2-space keys). */
|
|
51
|
+
export function lockSections(text) {
|
|
52
|
+
const sections = {};
|
|
53
|
+
let section;
|
|
54
|
+
let entry;
|
|
55
|
+
for (const line of text.split('\n')) {
|
|
56
|
+
if (line.trim() === '') continue;
|
|
57
|
+
if (!line.startsWith(' ')) {
|
|
58
|
+
section = line.endsWith(':') ? line.slice(0, -1) : undefined;
|
|
59
|
+
if (section !== undefined) sections[section] = {};
|
|
60
|
+
entry = undefined;
|
|
61
|
+
} else if (section !== undefined && /^ {2}\S/.test(line)) {
|
|
62
|
+
entry = unquote(line.trim().replace(/:( \{\})?$/, ''));
|
|
63
|
+
sections[section][entry] = [];
|
|
64
|
+
} else if (entry !== undefined) {
|
|
65
|
+
sections[section][entry].push(line);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return sections;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** `{ dependencyName: { specifier, version } }` per section of an importer body (a pnpm importer's 4/6/8-space layout). */
|
|
72
|
+
function importerDependencies(lines) {
|
|
73
|
+
const sections = {};
|
|
74
|
+
let section;
|
|
75
|
+
let name;
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
const indent = line.length - line.trimStart().length;
|
|
78
|
+
const text = line.trim();
|
|
79
|
+
if (indent === 4) {
|
|
80
|
+
section = text.replace(/:$/, '');
|
|
81
|
+
sections[section] = {};
|
|
82
|
+
} else if (indent === 6 && section !== undefined) {
|
|
83
|
+
name = unquote(text.replace(/:$/, ''));
|
|
84
|
+
sections[section][name] = {};
|
|
85
|
+
} else if (indent === 8 && name !== undefined) {
|
|
86
|
+
const [field, ...rest] = text.split(': ');
|
|
87
|
+
sections[section][name][field] = unquote(rest.join(': '));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return sections;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** `{ name: version }` of a snapshot body's dependency sections. */
|
|
94
|
+
function snapshotDependencies(lines) {
|
|
95
|
+
const deps = {};
|
|
96
|
+
let inside = false;
|
|
97
|
+
for (const line of lines) {
|
|
98
|
+
const indent = line.length - line.trimStart().length;
|
|
99
|
+
const text = line.trim();
|
|
100
|
+
if (indent === 4) inside = DEPENDENCY_SECTIONS.includes(text.replace(/:$/, ''));
|
|
101
|
+
else if (indent === 6 && inside) {
|
|
102
|
+
const at = text.indexOf(': ');
|
|
103
|
+
deps[unquote(text.slice(0, at))] = unquote(text.slice(at + 2));
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return deps;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** An alias (`wrap-ansi-cjs: wrap-ansi@7.0.0`) names its target's snapshot; a plain version belongs to the dependency's own name. */
|
|
110
|
+
const ALIAS_TARGET = /^(?:npm:)?(@?[^@(]+@.*)$/;
|
|
111
|
+
const snapshotKey = (name, version) => ALIAS_TARGET.exec(version)?.[1] ?? `${name}@${version}`;
|
|
112
|
+
const packageKey = (key) => key.replace(/\(.*$/, '');
|
|
113
|
+
|
|
114
|
+
export const LOCKFILE_VERSION = '9.0';
|
|
115
|
+
|
|
116
|
+
/** The lockfile's declared version, refused unless it is the one this parser reads. */
|
|
117
|
+
export function assertLockfileVersion(lockText) {
|
|
118
|
+
const declared = /^lockfileVersion:\s*'?([^'\s]+)'?\s*$/m.exec(lockText)?.[1];
|
|
119
|
+
if (declared !== LOCKFILE_VERSION) {
|
|
120
|
+
throw new Error(`lockfileVersion '${declared}' is not '${LOCKFILE_VERSION}', the only pnpm lockfile format effective-input reads`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** `pnpm-workspace.yaml` minus its `packages` block, comments and blank lines: the install settings it carries. */
|
|
125
|
+
export function workspaceSettings(yamlText) {
|
|
126
|
+
const kept = [];
|
|
127
|
+
let inPackages = false;
|
|
128
|
+
for (const line of yamlText.split('\n')) {
|
|
129
|
+
if (/^\S/.test(line)) inPackages = /^packages\s*:/.test(line);
|
|
130
|
+
if (inPackages) continue;
|
|
131
|
+
const text = line.replace(/\s+$/, '');
|
|
132
|
+
if (text.trim() === '' || text.trim().startsWith('#')) continue;
|
|
133
|
+
kept.push(text);
|
|
134
|
+
}
|
|
135
|
+
return kept.join('\n');
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* What steers the install and so the bytes, beyond the closure's own entries: the lockfile's `settings`, `overrides`
|
|
140
|
+
* and `patchedDependencies`, the root `package.json`'s `packageManager` and `pnpm` block, and the workspace file's
|
|
141
|
+
* settings. Each is canonical data; none depends on the root's path.
|
|
142
|
+
*/
|
|
143
|
+
export function installInputs({ lockText, packageJson, workspaceYaml }) {
|
|
144
|
+
assertLockfileVersion(lockText);
|
|
145
|
+
const { settings = {}, overrides = {}, patchedDependencies = {} } = lockSections(lockText);
|
|
146
|
+
const manifest = JSON.parse(packageJson);
|
|
147
|
+
return {
|
|
148
|
+
lock: { settings, overrides, patchedDependencies },
|
|
149
|
+
packageManager: manifest.packageManager ?? null,
|
|
150
|
+
pnpm: manifest.pnpm ?? null,
|
|
151
|
+
workspace: sha256(workspaceSettings(workspaceYaml)),
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* The lockfile slice of a closure: its importers (keyed by package name, a `link:<path>` version rewritten to
|
|
157
|
+
* `link:<package name>`), plus the snapshots reachable from them and each one's `packages` entry. `packages` is
|
|
158
|
+
* `[{ name, dir }]`, `dir` relative to the lockfile's root.
|
|
159
|
+
*/
|
|
160
|
+
export function lockfileSlice(lockText, packages) {
|
|
161
|
+
assertLockfileVersion(lockText);
|
|
162
|
+
const { importers = {}, packages: registry = {}, snapshots = {} } = lockSections(lockText);
|
|
163
|
+
const nameByDir = new Map(packages.map((p) => [posix.normalize(p.dir), p.name]));
|
|
164
|
+
const slice = { importers: {}, snapshots: {}, packages: {} };
|
|
165
|
+
const pending = [];
|
|
166
|
+
for (const { name, dir } of packages) {
|
|
167
|
+
const body = importers[posix.normalize(dir)];
|
|
168
|
+
if (body === undefined) throw new Error(`the lockfile has no importer for ${name} (${dir})`);
|
|
169
|
+
const sections = importerDependencies(body);
|
|
170
|
+
for (const deps of Object.values(sections)) {
|
|
171
|
+
for (const [dep, entry] of Object.entries(deps)) {
|
|
172
|
+
if (entry.version?.startsWith('link:')) {
|
|
173
|
+
const target = nameByDir.get(posix.join(dir, entry.version.slice(5)));
|
|
174
|
+
if (target === undefined) throw new Error(`${name} links ${entry.version}, which is not in the closure`);
|
|
175
|
+
entry.version = `link:${target}`;
|
|
176
|
+
} else if (entry.version !== undefined) {
|
|
177
|
+
pending.push(snapshotKey(dep, entry.version));
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
slice.importers[name] = sections;
|
|
182
|
+
}
|
|
183
|
+
while (pending.length > 0) {
|
|
184
|
+
const key = pending.pop();
|
|
185
|
+
if (Object.hasOwn(slice.snapshots, key)) continue;
|
|
186
|
+
const body = snapshots[key];
|
|
187
|
+
if (body === undefined) throw new Error(`the lockfile has no snapshot ${key}`);
|
|
188
|
+
slice.snapshots[key] = body.map((l) => l.trim());
|
|
189
|
+
const entry = registry[packageKey(key)];
|
|
190
|
+
if (entry === undefined) throw new Error(`the lockfile has no packages entry ${packageKey(key)}`);
|
|
191
|
+
slice.packages[packageKey(key)] = entry.map((l) => l.trim());
|
|
192
|
+
for (const [dep, version] of Object.entries(snapshotDependencies(body))) pending.push(snapshotKey(dep, version));
|
|
193
|
+
}
|
|
194
|
+
return slice;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/** sha256 over the sorted `<path>\0<git mode>\0<sha256>\n` lines of `files` (`{ relativePath: { bytes, mode } }`). */
|
|
198
|
+
export function packageDigest(files) {
|
|
199
|
+
const hash = createHash('sha256');
|
|
200
|
+
for (const path of Object.keys(files).sort()) hash.update(`${path}\0${files[path].mode}\0${sha256(files[path].bytes)}\n`);
|
|
201
|
+
return hash.digest('hex');
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* The key and its parts. `packages` is `[{ name, files }]` (`files`: package-relative path -> `{ bytes, mode }`), `lockSlice` the
|
|
206
|
+
* result of `lockfileSlice`, `tooling` `{ dockerfile, scripts: { name: bytes }, buildArgs: { NAME: value }, stageTool: { name, version } }`,
|
|
207
|
+
* `rootConfig` `{ fileName: bytes }`, `install` the result of `installInputs`.
|
|
208
|
+
*/
|
|
209
|
+
export function effectiveInput({ service, platform, baseImage, packages, lockSlice, install, tooling, rootConfig }) {
|
|
210
|
+
if (!/^linux\/(amd64|arm64)$/.test(platform)) throw new Error(`platform must be linux/amd64 or linux/arm64, got '${platform}'`);
|
|
211
|
+
if (typeof baseImage !== 'string' || !DIGEST_PINNED.test(baseImage)) {
|
|
212
|
+
throw new Error(`the base image must be digest-pinned (<name>@sha256:<64 hex>), got '${baseImage}': a tag names mutable bytes`);
|
|
213
|
+
}
|
|
214
|
+
const excluded = Object.keys(tooling.buildArgs ?? {}).filter((n) => NON_CONTENT_BUILD_ARGS.includes(n));
|
|
215
|
+
if (excluded.length > 0) throw new Error(`build arg(s) ${excluded.join(', ')} are paths or the commit and are not key inputs: leave them out`);
|
|
216
|
+
const parts = {
|
|
217
|
+
schema: KEY_SCHEMA,
|
|
218
|
+
service,
|
|
219
|
+
platform,
|
|
220
|
+
baseImage,
|
|
221
|
+
tooling: {
|
|
222
|
+
buildArgs: tooling.buildArgs ?? {},
|
|
223
|
+
dockerfile: sha256(tooling.dockerfile),
|
|
224
|
+
scripts: Object.fromEntries(Object.entries(tooling.scripts).map(([name, bytes]) => [name, sha256(bytes)])),
|
|
225
|
+
stageTool: tooling.stageTool,
|
|
226
|
+
},
|
|
227
|
+
rootConfig: Object.fromEntries(Object.entries(rootConfig).map(([name, bytes]) => [name, sha256(bytes)])),
|
|
228
|
+
workspace: Object.fromEntries(packages.map((p) => [p.name, packageDigest(p.files)])),
|
|
229
|
+
lock: sha256(canonicalJson(lockSlice)),
|
|
230
|
+
install,
|
|
231
|
+
};
|
|
232
|
+
return { key: sha256(canonicalJson(parts)), parts };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Gathering from a workspace root ─────────────────────────────────────────────────────────────────────────────────
|
|
236
|
+
|
|
237
|
+
const run = (file, args, cwd) => execFileSync(file, args, { cwd, encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* The workspace package graph, from ONE `turbo query` (the whole graph, so a package with no build task is not dropped):
|
|
241
|
+
* `Map<name, { name, dir, dependencies: string[] }>`, `dir` relative to `root`.
|
|
242
|
+
*/
|
|
243
|
+
export function packageGraph(root) {
|
|
244
|
+
const query = 'query { packages { items { name path directDependencies { items { name } } } } }';
|
|
245
|
+
const items = JSON.parse(run('pnpm', ['exec', 'turbo', 'query', query], root)).data.packages.items;
|
|
246
|
+
return new Map(items.filter((p) => p.name !== '//').map((p) => [p.name, { name: p.name, dir: p.path, dependencies: p.directDependencies.items.map((d) => d.name) }]));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** `[{ name, dir }]` of `service` and every workspace package it depends on, sorted by name. */
|
|
250
|
+
export function closureIn(graph, service) {
|
|
251
|
+
const seen = new Map();
|
|
252
|
+
const pending = [service];
|
|
253
|
+
while (pending.length > 0) {
|
|
254
|
+
const name = pending.pop();
|
|
255
|
+
if (seen.has(name)) continue;
|
|
256
|
+
const entry = graph.get(name);
|
|
257
|
+
if (entry === undefined) throw new Error(`'${name}' is not a package of this workspace`);
|
|
258
|
+
seen.set(name, { name, dir: entry.dir });
|
|
259
|
+
pending.push(...entry.dependencies.filter((d) => graph.has(d)));
|
|
260
|
+
}
|
|
261
|
+
return [...seen.values()].sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const GIT_MODES = new Set(['100644', '100755', '120000']);
|
|
265
|
+
|
|
266
|
+
/** The package-relative tracked files of `dir` (git, run inside it: a submodule or the repository), each with its git mode. */
|
|
267
|
+
export function trackedFiles(root, dir) {
|
|
268
|
+
const cwd = join(root, dir);
|
|
269
|
+
const out = run('git', ['ls-files', '-s', '-z', '--', '.'], cwd);
|
|
270
|
+
const files = {};
|
|
271
|
+
for (const record of out.split('\0').filter(Boolean)) {
|
|
272
|
+
const tab = record.indexOf('\t');
|
|
273
|
+
const [mode] = record.slice(0, tab).split(' ');
|
|
274
|
+
const path = record.slice(tab + 1);
|
|
275
|
+
if (!GIT_MODES.has(mode)) throw new Error(`${dir}/${path} has git mode ${mode}, which effective-input does not key (a nested repository?)`);
|
|
276
|
+
files[path] = { mode, bytes: mode === '120000' ? Buffer.from(readlinkSync(join(cwd, path))) : readFileSync(join(cwd, path)) };
|
|
277
|
+
}
|
|
278
|
+
return files;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The key and parts of each of `services`, from one package-graph query and one read of the lockfile and tooling. */
|
|
282
|
+
export function effectiveInputKeys({ root, services, platform, baseImage, dockerfile, scripts, buildArgs = {} }) {
|
|
283
|
+
if (!Array.isArray(services) || services.length === 0) throw new Error('at least one service is required');
|
|
284
|
+
const names = scripts.map((x) => posix.basename(x));
|
|
285
|
+
const duplicate = names.find((n, i) => names.indexOf(n) !== i);
|
|
286
|
+
if (duplicate !== undefined) throw new Error(`two --script files are named '${duplicate}': the key names scripts by file name`);
|
|
287
|
+
const graph = packageGraph(root);
|
|
288
|
+
const toolManifest = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8'));
|
|
289
|
+
const rootConfig = Object.fromEntries(rootBuildInputNames(root).filter((n) => COMPILE_ROOT_FILE.test(n)).map((n) => [n, readFileSync(join(root, n))]));
|
|
290
|
+
const lockText = readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8');
|
|
291
|
+
const install = installInputs({ lockText, packageJson: readFileSync(join(root, 'package.json'), 'utf8'), workspaceYaml: readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8') });
|
|
292
|
+
const tooling = {
|
|
293
|
+
dockerfile: readFileSync(dockerfile),
|
|
294
|
+
scripts: Object.fromEntries(scripts.map((s) => [posix.basename(s), readFileSync(s)])),
|
|
295
|
+
buildArgs,
|
|
296
|
+
stageTool: { name: toolManifest.name, version: toolManifest.version },
|
|
297
|
+
};
|
|
298
|
+
const filesByPackage = new Map();
|
|
299
|
+
const filesOf = ({ name, dir }) => {
|
|
300
|
+
if (!filesByPackage.has(name)) filesByPackage.set(name, trackedFiles(root, dir));
|
|
301
|
+
return filesByPackage.get(name);
|
|
302
|
+
};
|
|
303
|
+
return Object.fromEntries(services.map((service) => {
|
|
304
|
+
const closure = closureIn(graph, service);
|
|
305
|
+
return [service, effectiveInput({
|
|
306
|
+
service,
|
|
307
|
+
platform,
|
|
308
|
+
baseImage,
|
|
309
|
+
packages: closure.map((p) => ({ name: p.name, files: filesOf(p) })),
|
|
310
|
+
lockSlice: lockfileSlice(lockText, closure),
|
|
311
|
+
install,
|
|
312
|
+
tooling,
|
|
313
|
+
rootConfig,
|
|
314
|
+
})];
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** The key and parts of one service (`effectiveInputKeys` of one). */
|
|
319
|
+
export function effectiveInputKey({ service, ...options }) {
|
|
320
|
+
return effectiveInputKeys({ ...options, services: [service] })[service];
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* The one place a key becomes its registry tag: `ei-<key>`. INTERIM: this is to move to `@xemahq/contracts/distribution`
|
|
325
|
+
* (edition's ruling), the single home of the prefix; until it is published this is the one copy.
|
|
326
|
+
*/
|
|
327
|
+
export function effectiveInputTag(key) {
|
|
328
|
+
if (!/^[0-9a-f]{64}$/.test(key)) throw new Error(`'${key}' is not an effective-input key (64 lowercase hex)`);
|
|
329
|
+
return `ei-${key}`;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The `--out` map: per service `{ schema, platform, key, parts, tag }`. `tag` is built only by `effectiveInputTag`, so a consumer
|
|
334
|
+
* (release-policy's `prepare --effective-inputs`) reads it here and declares no prefix.
|
|
335
|
+
*/
|
|
336
|
+
export function effectiveInputRecord(keys, platform) {
|
|
337
|
+
return Object.fromEntries(
|
|
338
|
+
Object.entries(keys).map(([service, { key, parts }]) => [service, { schema: KEY_SCHEMA, platform, key, parts, tag: effectiveInputTag(key) }]),
|
|
339
|
+
);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Only what the key can see is keyed: the closure's tracked files, the lockfile, the install settings, the Dockerfile, the
|
|
343
|
+
// `--script` files and the build args. A file the Dockerfile COPYs from OUTSIDE the closure (an entrypoint, infra/docker/*)
|
|
344
|
+
// is keyed only if it is passed as --script: the COPY sources are not derived (ARG-expanded paths such as REPO_DIR make that
|
|
345
|
+
// root-dependent), so the caller must list them.
|
|
346
|
+
const USAGE = 'usage: xema-effective-input --service <pkg> [--service <pkg>]... --platform linux/<amd64|arm64> --base-image <name@sha256:digest> --dockerfile <file> [--script <file>]... [--build-arg NAME=VALUE]... [--out <file>] (--script: every file the Dockerfile COPYs from outside the closure)';
|
|
347
|
+
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
|
348
|
+
const SINGLE = ['platform', 'base-image', 'dockerfile', 'out'];
|
|
349
|
+
|
|
350
|
+
export function parseArgs(argv) {
|
|
351
|
+
const options = { service: [], script: [], 'build-arg': [] };
|
|
352
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
353
|
+
const name = argv[i]?.startsWith('--') ? argv[i].slice(2) : undefined;
|
|
354
|
+
const value = argv[i + 1];
|
|
355
|
+
if (name === undefined || value === undefined || ![...SINGLE, 'service', 'script', 'build-arg'].includes(name)) throw new Error(USAGE);
|
|
356
|
+
if (Array.isArray(options[name])) options[name].push(value);
|
|
357
|
+
else if (Object.hasOwn(options, name)) throw new Error(`--${name} is given twice`);
|
|
358
|
+
else options[name] = value;
|
|
359
|
+
}
|
|
360
|
+
for (const required of ['platform', 'base-image', 'dockerfile']) if (!Object.hasOwn(options, required)) throw new Error(USAGE);
|
|
361
|
+
if (options.service.length === 0) throw new Error(USAGE);
|
|
362
|
+
const buildArgs = {};
|
|
363
|
+
for (const pair of options['build-arg']) {
|
|
364
|
+
const eq = pair.indexOf('=');
|
|
365
|
+
if (eq <= 0) throw new Error(`--build-arg wants NAME=VALUE, got '${pair}'`);
|
|
366
|
+
const name = pair.slice(0, eq);
|
|
367
|
+
if (Object.hasOwn(buildArgs, name)) throw new Error(`build arg ${name} is given twice`);
|
|
368
|
+
buildArgs[name] = pair.slice(eq + 1);
|
|
369
|
+
}
|
|
370
|
+
return {
|
|
371
|
+
services: options.service,
|
|
372
|
+
platform: options.platform,
|
|
373
|
+
baseImage: options['base-image'],
|
|
374
|
+
dockerfile: resolve(options.dockerfile),
|
|
375
|
+
scripts: options.script.map((s) => resolve(s)),
|
|
376
|
+
buildArgs,
|
|
377
|
+
out: options.out,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
if (invokedDirectly(process.argv[1], import.meta.url)) {
|
|
382
|
+
try {
|
|
383
|
+
const { out, ...options } = parseArgs(process.argv.slice(2));
|
|
384
|
+
const keys = effectiveInputKeys({ root: process.cwd(), ...options });
|
|
385
|
+
if (out !== undefined) {
|
|
386
|
+
writeFileSync(out, `${JSON.stringify(effectiveInputRecord(keys, options.platform), null, 2)}\n`);
|
|
387
|
+
}
|
|
388
|
+
const shown = Object.fromEntries(Object.entries(keys).map(([service, { key }]) => [service, key]));
|
|
389
|
+
process.stdout.write(options.services.length === 1 ? `${shown[options.services[0]]}\n` : `${JSON.stringify(shown)}\n`);
|
|
390
|
+
} catch (error) {
|
|
391
|
+
console.error(`::error::${error.message.split('\n')[0]}`);
|
|
392
|
+
console.error(error.message);
|
|
393
|
+
process.exit(1);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
7
|
+
import { test } from 'node:test';
|
|
8
|
+
|
|
9
|
+
import { canonicalJson, closureIn, effectiveInput, effectiveInputKeys, effectiveInputRecord, effectiveInputTag, installInputs, lockfileSlice, parseArgs, trackedFiles } from './effective-input.mjs';
|
|
10
|
+
|
|
11
|
+
const DIGEST = `ghcr.io/x/node-runtime@sha256:${'a'.repeat(64)}`;
|
|
12
|
+
const lock = (prefix, settings = 'autoInstallPeers: true') => `lockfileVersion: '9.0'
|
|
13
|
+
|
|
14
|
+
settings:
|
|
15
|
+
${settings}
|
|
16
|
+
|
|
17
|
+
overrides:
|
|
18
|
+
left-pad: 1.3.0
|
|
19
|
+
|
|
20
|
+
patchedDependencies:
|
|
21
|
+
tiny@1.0.0:
|
|
22
|
+
hash: aaa
|
|
23
|
+
path: patches/tiny.patch
|
|
24
|
+
|
|
25
|
+
importers:
|
|
26
|
+
|
|
27
|
+
.:
|
|
28
|
+
devDependencies:
|
|
29
|
+
turbo:
|
|
30
|
+
specifier: ^2.0.0
|
|
31
|
+
version: 2.0.1
|
|
32
|
+
|
|
33
|
+
${prefix}apps/svc:
|
|
34
|
+
dependencies:
|
|
35
|
+
'@x/lib':
|
|
36
|
+
specifier: workspace:*
|
|
37
|
+
version: link:../../packages/lib
|
|
38
|
+
left-pad:
|
|
39
|
+
specifier: ^1.0.0
|
|
40
|
+
version: 1.3.0
|
|
41
|
+
|
|
42
|
+
${prefix}packages/lib:
|
|
43
|
+
dependencies:
|
|
44
|
+
tiny:
|
|
45
|
+
specifier: ^1.0.0
|
|
46
|
+
version: 1.0.0(peer@1.0.0)
|
|
47
|
+
|
|
48
|
+
packages:
|
|
49
|
+
|
|
50
|
+
left-pad@1.3.0:
|
|
51
|
+
resolution: {integrity: sha512-LEFT}
|
|
52
|
+
|
|
53
|
+
tiny@1.0.0:
|
|
54
|
+
resolution: {integrity: sha512-TINY}
|
|
55
|
+
|
|
56
|
+
peer@1.0.0:
|
|
57
|
+
resolution: {integrity: sha512-PEER}
|
|
58
|
+
|
|
59
|
+
unused@9.9.9:
|
|
60
|
+
resolution: {integrity: sha512-UNUSED}
|
|
61
|
+
|
|
62
|
+
snapshots:
|
|
63
|
+
|
|
64
|
+
left-pad@1.3.0: {}
|
|
65
|
+
|
|
66
|
+
tiny@1.0.0(peer@1.0.0):
|
|
67
|
+
dependencies:
|
|
68
|
+
peer: 1.0.0
|
|
69
|
+
|
|
70
|
+
peer@1.0.0: {}
|
|
71
|
+
`;
|
|
72
|
+
|
|
73
|
+
/** A workspace root at `prefix` ('' = a repository, 'repos/r/' = an aggregator), each package its own git repo. */
|
|
74
|
+
function workspace(prefix, { svcSource = 'export const a = 1;\n', libSource = 'export const b = 1;\n' } = {}) {
|
|
75
|
+
const root = mkdtempSync(join(tmpdir(), 'effective-input-'));
|
|
76
|
+
const files = { [`${prefix}apps/svc/src/main.ts`]: svcSource, [`${prefix}packages/lib/src/index.ts`]: libSource };
|
|
77
|
+
for (const [path, content] of Object.entries(files)) {
|
|
78
|
+
mkdirSync(join(root, dirname(path)), { recursive: true });
|
|
79
|
+
writeFileSync(join(root, path), content);
|
|
80
|
+
}
|
|
81
|
+
for (const dir of [`${prefix}apps/svc`, `${prefix}packages/lib`]) {
|
|
82
|
+
execFileSync('git', ['init', '-q'], { cwd: join(root, dir) });
|
|
83
|
+
execFileSync('git', ['add', '.'], { cwd: join(root, dir) });
|
|
84
|
+
}
|
|
85
|
+
const dirs = [{ name: '@x/lib', dir: `${prefix}packages/lib` }, { name: 'svc', dir: `${prefix}apps/svc` }];
|
|
86
|
+
return { root, dirs, prefix };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const PACKAGE_JSON = JSON.stringify({ packageManager: 'pnpm@10.0.0', pnpm: { onlyBuiltDependencies: ['esbuild'], overrides: { a: '1' }, patchedDependencies: { b: 'p' } } });
|
|
90
|
+
const WORKSPACE_YAML = '# header\npackages:\n - "apps/*"\n - "packages/*"\n\nonlyBuiltDependencies:\n - esbuild\ninjectWorkspacePackages: true\n';
|
|
91
|
+
const install = (over = {}) => installInputs({ lockText: lock(''), packageJson: PACKAGE_JSON, workspaceYaml: WORKSPACE_YAML, ...over });
|
|
92
|
+
|
|
93
|
+
const base = {
|
|
94
|
+
service: 'svc',
|
|
95
|
+
platform: 'linux/arm64',
|
|
96
|
+
baseImage: DIGEST,
|
|
97
|
+
tooling: { dockerfile: 'FROM x', scripts: { 'split.mjs': 's' }, buildArgs: { INSTALL_COSIGN: 'no' }, stageTool: { name: 't', version: '1.0.0' } },
|
|
98
|
+
rootConfig: { '.npmrc': 'a=b', 'tsconfig.base.json': '{}' },
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function keyOf(ws, overrides = {}) {
|
|
102
|
+
const packages = ws.dirs.map(({ name, dir }) => ({ name, files: trackedFiles(ws.root, dir) }));
|
|
103
|
+
return effectiveInput({ ...base, packages, lockSlice: lockfileSlice(lock(ws.prefix), ws.dirs), install: install(), ...overrides }).key;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
test('the same inputs give the same key from a repository root and from an aggregator root', () => {
|
|
107
|
+
const repo = workspace('');
|
|
108
|
+
const aggregator = workspace('repos/r/');
|
|
109
|
+
try {
|
|
110
|
+
assert.equal(keyOf(repo), keyOf(aggregator));
|
|
111
|
+
assert.equal(keyOf(repo), keyOf(repo));
|
|
112
|
+
assert.match(keyOf(repo), /^[0-9a-f]{64}$/);
|
|
113
|
+
} finally {
|
|
114
|
+
rmSync(repo.root, { recursive: true });
|
|
115
|
+
rmSync(aggregator.root, { recursive: true });
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test('each input class changes the key', () => {
|
|
120
|
+
const ws = workspace('');
|
|
121
|
+
const changed = workspace('', { libSource: 'export const b = 2;\n' });
|
|
122
|
+
try {
|
|
123
|
+
const original = keyOf(ws);
|
|
124
|
+
const variants = {
|
|
125
|
+
'workspace source (transitive package)': keyOf(changed),
|
|
126
|
+
'lockfile slice': effectiveInput({
|
|
127
|
+
...base,
|
|
128
|
+
packages: ws.dirs.map(({ name, dir }) => ({ name, files: trackedFiles(ws.root, dir) })),
|
|
129
|
+
install: install(),
|
|
130
|
+
lockSlice: lockfileSlice(lock('').replace('sha512-TINY', 'sha512-OTHER'), ws.dirs),
|
|
131
|
+
}).key,
|
|
132
|
+
'file mode (executable bit)': (() => {
|
|
133
|
+
const files = Object.fromEntries(Object.entries(trackedFiles(ws.root, 'packages/lib')).map(([k, v]) => [k, { ...v, mode: '100755' }]));
|
|
134
|
+
return effectiveInput({ ...base, install: install(), lockSlice: lockfileSlice(lock(''), ws.dirs), packages: [{ name: '@x/lib', files }, { name: 'svc', files: trackedFiles(ws.root, 'apps/svc') }] }).key;
|
|
135
|
+
})(),
|
|
136
|
+
'lock settings': keyOf(ws, { install: install({ lockText: lock('', 'autoInstallPeers: false') }) }),
|
|
137
|
+
'lock overrides': keyOf(ws, { install: install({ lockText: lock('').replace('left-pad: 1.3.0', 'left-pad: 1.2.0') }) }),
|
|
138
|
+
'lock patchedDependencies': keyOf(ws, { install: install({ lockText: lock('').replace('hash: aaa', 'hash: bbb') }) }),
|
|
139
|
+
'package.json packageManager': keyOf(ws, { install: install({ packageJson: PACKAGE_JSON.replace('10.0.0', '10.1.0') }) }),
|
|
140
|
+
'package.json pnpm block': keyOf(ws, { install: install({ packageJson: PACKAGE_JSON.replace('esbuild', 'sharp') }) }),
|
|
141
|
+
'pnpm-workspace.yaml settings': keyOf(ws, { install: install({ workspaceYaml: WORKSPACE_YAML.replace('true', 'false') }) }),
|
|
142
|
+
dockerfile: keyOf(ws, { tooling: { ...base.tooling, dockerfile: 'FROM y' } }),
|
|
143
|
+
'stage script': keyOf(ws, { tooling: { ...base.tooling, scripts: { 'split.mjs': 't' } } }),
|
|
144
|
+
'stage tool version': keyOf(ws, { tooling: { ...base.tooling, stageTool: { name: 't', version: '1.0.1' } } }),
|
|
145
|
+
'build arg': keyOf(ws, { tooling: { ...base.tooling, buildArgs: { INSTALL_COSIGN: 'yes' } } }),
|
|
146
|
+
'base image digest': keyOf(ws, { baseImage: `ghcr.io/x/node-runtime@sha256:${'b'.repeat(64)}` }),
|
|
147
|
+
arch: keyOf(ws, { platform: 'linux/amd64' }),
|
|
148
|
+
'root compile config': keyOf(ws, { rootConfig: { ...base.rootConfig, '.npmrc': 'a=c' } }),
|
|
149
|
+
};
|
|
150
|
+
for (const [name, key] of Object.entries(variants)) assert.notEqual(key, original, `${name} must change the key`);
|
|
151
|
+
assert.equal(new Set(Object.values(variants)).size, Object.keys(variants).length, 'the classes are distinct');
|
|
152
|
+
} finally {
|
|
153
|
+
rmSync(ws.root, { recursive: true });
|
|
154
|
+
rmSync(changed.root, { recursive: true });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test('a lockfile entry outside the closure does not change the key', () => {
|
|
159
|
+
const ws = workspace('');
|
|
160
|
+
try {
|
|
161
|
+
const slice = (text) => canonicalJson(lockfileSlice(text, ws.dirs));
|
|
162
|
+
assert.equal(slice(lock('')), slice(lock('').replace('sha512-UNUSED', 'sha512-ELSE')));
|
|
163
|
+
} finally {
|
|
164
|
+
rmSync(ws.root, { recursive: true });
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test('a tag-only base image is refused, with the reason', () => {
|
|
169
|
+
const ws = workspace('');
|
|
170
|
+
try {
|
|
171
|
+
assert.throws(() => keyOf(ws, { baseImage: 'ghcr.io/x/node-runtime:22' }), /digest-pinned/);
|
|
172
|
+
assert.throws(() => keyOf(ws, { baseImage: 'ghcr.io/x/node-runtime@sha256:abc' }), /digest-pinned/);
|
|
173
|
+
} finally {
|
|
174
|
+
rmSync(ws.root, { recursive: true });
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test('a per-root path or the commit is refused as a build arg', () => {
|
|
179
|
+
const ws = workspace('');
|
|
180
|
+
try {
|
|
181
|
+
for (const name of ['REPO_DIR', 'SERVICE_PATH', 'BUILD_SHA']) {
|
|
182
|
+
assert.throws(() => keyOf(ws, { tooling: { ...base.tooling, buildArgs: { [name]: 'x' } } }), new RegExp(name));
|
|
183
|
+
}
|
|
184
|
+
} finally {
|
|
185
|
+
rmSync(ws.root, { recursive: true });
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test('a workspace link outside the closure and a missing snapshot fail fast', () => {
|
|
190
|
+
const ws = workspace('');
|
|
191
|
+
try {
|
|
192
|
+
assert.throws(() => lockfileSlice(lock(''), ws.dirs.filter((d) => d.name === 'svc')), /not in the closure/);
|
|
193
|
+
assert.throws(() => lockfileSlice(lock('').replace(' peer@1.0.0: {}\n', ''), ws.dirs), /no snapshot/);
|
|
194
|
+
} finally {
|
|
195
|
+
rmSync(ws.root, { recursive: true });
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('the command line takes repeated build args and refuses a duplicate', () => {
|
|
200
|
+
const argv = ['--service', 's', '--service', 't', '--platform', 'linux/arm64', '--base-image', DIGEST, '--dockerfile', 'D', '--build-arg', 'A=1', '--build-arg', 'B=2=3'];
|
|
201
|
+
assert.deepEqual(parseArgs(argv).buildArgs, { A: '1', B: '2=3' });
|
|
202
|
+
assert.deepEqual(parseArgs(argv).services, ['s', 't']);
|
|
203
|
+
assert.throws(() => parseArgs([...argv, '--lane', 'develop']), /usage/);
|
|
204
|
+
assert.throws(() => parseArgs([...argv, '--build-arg', 'A=2']), /given twice/);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('the tag is ei-<key>, and a bad key is refused', () => {
|
|
208
|
+
const key = 'a'.repeat(64);
|
|
209
|
+
assert.equal(effectiveInputTag(key), `ei-${key}`);
|
|
210
|
+
assert.throws(() => effectiveInputTag('abc'), /not an effective-input key/);
|
|
211
|
+
assert.throws(() => effectiveInputTag('ei-' + key), /not an effective-input key/);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
test('the closure is walked transitively from the package graph, including a package with no build task, and refuses an unknown service', () => {
|
|
215
|
+
const graph = new Map([
|
|
216
|
+
['svc', { name: 'svc', dir: 'apps/svc', dependencies: ['mid', 'external'] }],
|
|
217
|
+
['mid', { name: 'mid', dir: 'packages/mid', dependencies: ['leaf', 'svc-cycle-free'] }],
|
|
218
|
+
['leaf', { name: 'leaf', dir: 'packages/leaf', dependencies: [] }],
|
|
219
|
+
['svc-cycle-free', { name: 'svc-cycle-free', dir: 'packages/c', dependencies: ['mid'] }],
|
|
220
|
+
['other', { name: 'other', dir: 'apps/other', dependencies: [] }],
|
|
221
|
+
]);
|
|
222
|
+
assert.deepEqual(closureIn(graph, 'svc').map((p) => p.name), ['leaf', 'mid', 'svc', 'svc-cycle-free']);
|
|
223
|
+
assert.deepEqual(closureIn(graph, 'other').map((p) => p.name), ['other']);
|
|
224
|
+
assert.throws(() => closureIn(graph, 'nope'), /not a package/);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test('the workspace file keys its settings, not its package globs, comments or blank lines', () => {
|
|
228
|
+
const ws = workspace('');
|
|
229
|
+
try {
|
|
230
|
+
const original = keyOf(ws);
|
|
231
|
+
assert.equal(keyOf(ws, { install: install({ workspaceYaml: WORKSPACE_YAML.replace('"apps/*"', '"repos/*/apps/*"').replace('# header', '# other') + '\n\n' }) }), original);
|
|
232
|
+
} finally {
|
|
233
|
+
rmSync(ws.root, { recursive: true });
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test('a lockfileVersion other than 9.0 is refused by name', () => {
|
|
238
|
+
const ws = workspace('');
|
|
239
|
+
try {
|
|
240
|
+
assert.throws(() => lockfileSlice(lock('').replace("'9.0'", "'6.0'"), ws.dirs), /lockfileVersion '6.0' is not '9.0'/);
|
|
241
|
+
assert.throws(() => install({ lockText: 'settings:\n' }), /lockfileVersion 'undefined'/);
|
|
242
|
+
} finally {
|
|
243
|
+
rmSync(ws.root, { recursive: true });
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test('two scripts with the same file name are refused', () => {
|
|
248
|
+
assert.throws(() => effectiveInputKeys({ root: '.', services: ['s'], platform: 'linux/arm64', baseImage: DIGEST, dockerfile: 'D', scripts: ['a/x.mjs', 'b/x.mjs'] }), /two --script files are named 'x.mjs'/);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
test('the --out map carries the tag per service, from effectiveInputTag', () => {
|
|
252
|
+
const key = 'c'.repeat(64);
|
|
253
|
+
const record = effectiveInputRecord({ 'a-api': { key, parts: { x: 1 } } }, 'linux/amd64');
|
|
254
|
+
assert.equal(record['a-api'].tag, effectiveInputTag(key));
|
|
255
|
+
assert.equal(record['a-api'].tag, `ei-${key}`);
|
|
256
|
+
assert.equal(record['a-api'].key, key);
|
|
257
|
+
assert.equal('tags' in record['a-api'], false);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// The failure this guards: through a pnpm bin shim `argv[1]` is a symlink, and a bin that compares it to its own real path
|
|
261
|
+
// runs nothing and exits 0. A run with no arguments must print the usage line and exit 1; silence is the failure.
|
|
262
|
+
const USAGE_RUN = (command, args, cwd) => spawnSync(command, args, { cwd, encoding: 'utf8' });
|
|
263
|
+
const assertUsage = (result) => {
|
|
264
|
+
assert.equal(result.status, 1, `exit ${result.status}, stdout '${result.stdout}', stderr '${result.stderr}'`);
|
|
265
|
+
assert.match(result.stderr, /usage: xema-effective-input/);
|
|
266
|
+
};
|
|
267
|
+
|
|
268
|
+
test('the bin runs through a symlink to it', () => {
|
|
269
|
+
const dir = mkdtempSync(join(tmpdir(), 'ei-bin-link-'));
|
|
270
|
+
try {
|
|
271
|
+
const link = join(dir, 'xema-effective-input');
|
|
272
|
+
symlinkSync(fileURLToPath(new URL('./effective-input.mjs', import.meta.url)), link);
|
|
273
|
+
assertUsage(USAGE_RUN(process.execPath, [link], dir));
|
|
274
|
+
} finally {
|
|
275
|
+
rmSync(dir, { recursive: true, force: true });
|
|
276
|
+
}
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test('the bin runs through `pnpm exec` from an installed copy', () => {
|
|
280
|
+
const dir = mkdtempSync(join(tmpdir(), 'ei-bin-pnpm-'));
|
|
281
|
+
try {
|
|
282
|
+
const packageDir = fileURLToPath(new URL('..', import.meta.url));
|
|
283
|
+
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'consumer', private: true, dependencies: { '@xemahq/repo-build-tooling': `link:${packageDir}` } }));
|
|
284
|
+
const install = USAGE_RUN('pnpm', ['install', '--offline', '--ignore-workspace'], dir);
|
|
285
|
+
assert.equal(install.status, 0, install.stderr);
|
|
286
|
+
assertUsage(USAGE_RUN('pnpm', ['exec', 'xema-effective-input'], dir));
|
|
287
|
+
} finally {
|
|
288
|
+
rmSync(dir, { recursive: true, force: true });
|
|
289
|
+
}
|
|
290
|
+
});
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
// Side-effect free ON PURPOSE: a CLI imports this to decide whether it was run, and must not execute anything (readiness.mjs, which
|
|
5
|
+
// used to hold it, refuses at import time when the cwd has no package.json).
|
|
6
|
+
/**
|
|
7
|
+
* Was this module RUN, or merely imported (by its own test)?
|
|
8
|
+
*
|
|
9
|
+
* BOTH SIDES ARE REALPATH'D, and that is the whole correctness of it. pnpm's
|
|
10
|
+
* isolated linker puts the package at `node_modules/.pnpm/<pkg>@<ver>/...` and
|
|
11
|
+
* symlinks `node_modules/@scope/<pkg>` to it, so `process.argv[1]` and
|
|
12
|
+
* `import.meta.url` can name the SAME FILE by two different paths. Comparing
|
|
13
|
+
* them unresolved gives an answer that depends on HOW THE PACKAGE WAS LINKED —
|
|
14
|
+
* true through pnpm's bin shim, which invokes the realpath, and false when the
|
|
15
|
+
* symlinked path is invoked. A predicate whose answer depends on the linker is
|
|
16
|
+
* not a predicate.
|
|
17
|
+
*
|
|
18
|
+
* The failure mode is the one this whole tool exists to prevent: a false answer
|
|
19
|
+
* takes the import branch, `run()` is never called, and the process exits 0 —
|
|
20
|
+
* a chain reporting success without running. Caught by a peer, not by me, and
|
|
21
|
+
* not by any test, which is why `invokedDirectly` is exported and asserted
|
|
22
|
+
* below against a REAL symlink.
|
|
23
|
+
*
|
|
24
|
+
* `fileURLToPath` rather than `new URL(...).pathname`: the latter is not the
|
|
25
|
+
* documented conversion and is wrong independently of the symlink — it breaks on
|
|
26
|
+
* a Windows drive letter and on any percent-encoded character, and a single
|
|
27
|
+
* SPACE in a checkout path is enough to produce one.
|
|
28
|
+
*/
|
|
29
|
+
export function invokedDirectly(argv1, metaUrl) {
|
|
30
|
+
if (argv1 === undefined) return false;
|
|
31
|
+
try {
|
|
32
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(metaUrl));
|
|
33
|
+
} catch {
|
|
34
|
+
// An unresolvable path cannot be this module; never guess YES, because a
|
|
35
|
+
// wrong YES runs the chain from a context that did not ask for it.
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
}
|
package/src/readiness.mjs
CHANGED
|
@@ -63,11 +63,12 @@
|
|
|
63
63
|
*/
|
|
64
64
|
import { execFileSync } from 'node:child_process';
|
|
65
65
|
import { createRequire } from 'node:module';
|
|
66
|
-
import { existsSync, readdirSync, readFileSync,
|
|
66
|
+
import { existsSync, readdirSync, readFileSync, rmSync } from 'node:fs';
|
|
67
67
|
import path from 'node:path';
|
|
68
|
-
import { fileURLToPath } from 'node:url';
|
|
69
68
|
import process from 'node:process';
|
|
70
69
|
|
|
70
|
+
import { invokedDirectly } from './invoked-directly.mjs';
|
|
71
|
+
|
|
71
72
|
// THE ROOT IS THE CONSUMER'S REPOSITORY, NEVER THIS PACKAGE'S LOCATION.
|
|
72
73
|
// This ships as a bin from `@xemahq/repo-build-tooling`, so `import.meta.url`
|
|
73
74
|
// resolves inside `node_modules` — deriving the root from it would grade the
|
|
@@ -744,39 +745,9 @@ function run() {
|
|
|
744
745
|
return 0;
|
|
745
746
|
}
|
|
746
747
|
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
* BOTH SIDES ARE REALPATH'D, and that is the whole correctness of it. pnpm's
|
|
751
|
-
* isolated linker puts the package at `node_modules/.pnpm/<pkg>@<ver>/...` and
|
|
752
|
-
* symlinks `node_modules/@scope/<pkg>` to it, so `process.argv[1]` and
|
|
753
|
-
* `import.meta.url` can name the SAME FILE by two different paths. Comparing
|
|
754
|
-
* them unresolved gives an answer that depends on HOW THE PACKAGE WAS LINKED —
|
|
755
|
-
* true through pnpm's bin shim, which invokes the realpath, and false when the
|
|
756
|
-
* symlinked path is invoked. A predicate whose answer depends on the linker is
|
|
757
|
-
* not a predicate.
|
|
758
|
-
*
|
|
759
|
-
* The failure mode is the one this whole tool exists to prevent: a false answer
|
|
760
|
-
* takes the import branch, `run()` is never called, and the process exits 0 —
|
|
761
|
-
* a chain reporting success without running. Caught by a peer, not by me, and
|
|
762
|
-
* not by any test, which is why `invokedDirectly` is exported and asserted
|
|
763
|
-
* below against a REAL symlink.
|
|
764
|
-
*
|
|
765
|
-
* `fileURLToPath` rather than `new URL(...).pathname`: the latter is not the
|
|
766
|
-
* documented conversion and is wrong independently of the symlink — it breaks on
|
|
767
|
-
* a Windows drive letter and on any percent-encoded character, and a single
|
|
768
|
-
* SPACE in a checkout path is enough to produce one.
|
|
769
|
-
*/
|
|
770
|
-
export function invokedDirectly(argv1, metaUrl) {
|
|
771
|
-
if (argv1 === undefined) return false;
|
|
772
|
-
try {
|
|
773
|
-
return realpathSync(argv1) === realpathSync(fileURLToPath(metaUrl));
|
|
774
|
-
} catch {
|
|
775
|
-
// An unresolvable path cannot be this module; never guess YES, because a
|
|
776
|
-
// wrong YES runs the chain from a context that did not ask for it.
|
|
777
|
-
return false;
|
|
778
|
-
}
|
|
779
|
-
}
|
|
748
|
+
// `invokedDirectly` lives in its own side-effect-free module (this one does work at import time); re-exported so every importer of it
|
|
749
|
+
// from here, and its test, keep working.
|
|
750
|
+
export { invokedDirectly };
|
|
780
751
|
|
|
781
752
|
const INVOKED_DIRECTLY = invokedDirectly(process.argv[1], import.meta.url);
|
|
782
753
|
|