@xemahq/repo-build-tooling 0.27.3 → 0.28.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 +401 -0
- package/src/effective-input.test.mjs +259 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/repo-build-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.28.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:741aec4b08022b9aa6702a86627e72163defa62b568c2dbacc2f9e29f14d85bf"
|
|
54
56
|
}
|
|
55
57
|
}
|
|
@@ -0,0 +1,401 @@
|
|
|
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>]… [--lane candidate|develop] --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 { rootBuildInputNames } from './staged-image-proof.mjs';
|
|
29
|
+
|
|
30
|
+
export const KEY_SCHEMA = 1;
|
|
31
|
+
/** Build args that are a root-relative path or the commit, never part of the key. */
|
|
32
|
+
export const NON_CONTENT_BUILD_ARGS = Object.freeze(['REPO_DIR', 'SERVICE_PATH', 'BUILD_SHA']);
|
|
33
|
+
const DIGEST_PINNED = /^[^\s@]+@sha256:[0-9a-f]{64}$/;
|
|
34
|
+
const COMPILE_ROOT_FILE = /^(\.npmrc|tsconfig[^/]*\.json)$/;
|
|
35
|
+
const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies'];
|
|
36
|
+
const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
|
|
37
|
+
|
|
38
|
+
/** Canonical JSON: keys sorted recursively, no whitespace. */
|
|
39
|
+
export function canonicalJson(value) {
|
|
40
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
41
|
+
if (value !== null && typeof value === 'object') {
|
|
42
|
+
return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
|
|
43
|
+
}
|
|
44
|
+
return JSON.stringify(value);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const unquote = (key) => (key.startsWith("'") && key.endsWith("'") ? key.slice(1, -1) : key);
|
|
48
|
+
|
|
49
|
+
/** The top-level sections of a pnpm v9 lockfile, each as `{ entryKey: [body lines] }` (entries are the 2-space keys). */
|
|
50
|
+
export function lockSections(text) {
|
|
51
|
+
const sections = {};
|
|
52
|
+
let section;
|
|
53
|
+
let entry;
|
|
54
|
+
for (const line of text.split('\n')) {
|
|
55
|
+
if (line.trim() === '') continue;
|
|
56
|
+
if (!line.startsWith(' ')) {
|
|
57
|
+
section = line.endsWith(':') ? line.slice(0, -1) : undefined;
|
|
58
|
+
if (section !== undefined) sections[section] = {};
|
|
59
|
+
entry = undefined;
|
|
60
|
+
} else if (section !== undefined && /^ {2}\S/.test(line)) {
|
|
61
|
+
entry = unquote(line.trim().replace(/:( \{\})?$/, ''));
|
|
62
|
+
sections[section][entry] = [];
|
|
63
|
+
} else if (entry !== undefined) {
|
|
64
|
+
sections[section][entry].push(line);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return sections;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** `{ dependencyName: { specifier, version } }` per section of an importer body (a pnpm importer's 4/6/8-space layout). */
|
|
71
|
+
function importerDependencies(lines) {
|
|
72
|
+
const sections = {};
|
|
73
|
+
let section;
|
|
74
|
+
let name;
|
|
75
|
+
for (const line of lines) {
|
|
76
|
+
const indent = line.length - line.trimStart().length;
|
|
77
|
+
const text = line.trim();
|
|
78
|
+
if (indent === 4) {
|
|
79
|
+
section = text.replace(/:$/, '');
|
|
80
|
+
sections[section] = {};
|
|
81
|
+
} else if (indent === 6 && section !== undefined) {
|
|
82
|
+
name = unquote(text.replace(/:$/, ''));
|
|
83
|
+
sections[section][name] = {};
|
|
84
|
+
} else if (indent === 8 && name !== undefined) {
|
|
85
|
+
const [field, ...rest] = text.split(': ');
|
|
86
|
+
sections[section][name][field] = unquote(rest.join(': '));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return sections;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** `{ name: version }` of a snapshot body's dependency sections. */
|
|
93
|
+
function snapshotDependencies(lines) {
|
|
94
|
+
const deps = {};
|
|
95
|
+
let inside = false;
|
|
96
|
+
for (const line of lines) {
|
|
97
|
+
const indent = line.length - line.trimStart().length;
|
|
98
|
+
const text = line.trim();
|
|
99
|
+
if (indent === 4) inside = DEPENDENCY_SECTIONS.includes(text.replace(/:$/, ''));
|
|
100
|
+
else if (indent === 6 && inside) {
|
|
101
|
+
const at = text.indexOf(': ');
|
|
102
|
+
deps[unquote(text.slice(0, at))] = unquote(text.slice(at + 2));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return deps;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** 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. */
|
|
109
|
+
const ALIAS_TARGET = /^(?:npm:)?(@?[^@(]+@.*)$/;
|
|
110
|
+
const snapshotKey = (name, version) => ALIAS_TARGET.exec(version)?.[1] ?? `${name}@${version}`;
|
|
111
|
+
const packageKey = (key) => key.replace(/\(.*$/, '');
|
|
112
|
+
|
|
113
|
+
export const LOCKFILE_VERSION = '9.0';
|
|
114
|
+
|
|
115
|
+
/** The lockfile's declared version, refused unless it is the one this parser reads. */
|
|
116
|
+
export function assertLockfileVersion(lockText) {
|
|
117
|
+
const declared = /^lockfileVersion:\s*'?([^'\s]+)'?\s*$/m.exec(lockText)?.[1];
|
|
118
|
+
if (declared !== LOCKFILE_VERSION) {
|
|
119
|
+
throw new Error(`lockfileVersion '${declared}' is not '${LOCKFILE_VERSION}', the only pnpm lockfile format effective-input reads`);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** `pnpm-workspace.yaml` minus its `packages` block, comments and blank lines: the install settings it carries. */
|
|
124
|
+
export function workspaceSettings(yamlText) {
|
|
125
|
+
const kept = [];
|
|
126
|
+
let inPackages = false;
|
|
127
|
+
for (const line of yamlText.split('\n')) {
|
|
128
|
+
if (/^\S/.test(line)) inPackages = /^packages\s*:/.test(line);
|
|
129
|
+
if (inPackages) continue;
|
|
130
|
+
const text = line.replace(/\s+$/, '');
|
|
131
|
+
if (text.trim() === '' || text.trim().startsWith('#')) continue;
|
|
132
|
+
kept.push(text);
|
|
133
|
+
}
|
|
134
|
+
return kept.join('\n');
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* What steers the install and so the bytes, beyond the closure's own entries: the lockfile's `settings`, `overrides`
|
|
139
|
+
* and `patchedDependencies`, the root `package.json`'s `packageManager` and `pnpm` block, and the workspace file's
|
|
140
|
+
* settings. Each is canonical data; none depends on the root's path.
|
|
141
|
+
*/
|
|
142
|
+
export function installInputs({ lockText, packageJson, workspaceYaml }) {
|
|
143
|
+
assertLockfileVersion(lockText);
|
|
144
|
+
const { settings = {}, overrides = {}, patchedDependencies = {} } = lockSections(lockText);
|
|
145
|
+
const manifest = JSON.parse(packageJson);
|
|
146
|
+
return {
|
|
147
|
+
lock: { settings, overrides, patchedDependencies },
|
|
148
|
+
packageManager: manifest.packageManager ?? null,
|
|
149
|
+
pnpm: manifest.pnpm ?? null,
|
|
150
|
+
workspace: sha256(workspaceSettings(workspaceYaml)),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The lockfile slice of a closure: its importers (keyed by package name, a `link:<path>` version rewritten to
|
|
156
|
+
* `link:<package name>`), plus the snapshots reachable from them and each one's `packages` entry. `packages` is
|
|
157
|
+
* `[{ name, dir }]`, `dir` relative to the lockfile's root.
|
|
158
|
+
*/
|
|
159
|
+
export function lockfileSlice(lockText, packages) {
|
|
160
|
+
assertLockfileVersion(lockText);
|
|
161
|
+
const { importers = {}, packages: registry = {}, snapshots = {} } = lockSections(lockText);
|
|
162
|
+
const nameByDir = new Map(packages.map((p) => [posix.normalize(p.dir), p.name]));
|
|
163
|
+
const slice = { importers: {}, snapshots: {}, packages: {} };
|
|
164
|
+
const pending = [];
|
|
165
|
+
for (const { name, dir } of packages) {
|
|
166
|
+
const body = importers[posix.normalize(dir)];
|
|
167
|
+
if (body === undefined) throw new Error(`the lockfile has no importer for ${name} (${dir})`);
|
|
168
|
+
const sections = importerDependencies(body);
|
|
169
|
+
for (const deps of Object.values(sections)) {
|
|
170
|
+
for (const [dep, entry] of Object.entries(deps)) {
|
|
171
|
+
if (entry.version?.startsWith('link:')) {
|
|
172
|
+
const target = nameByDir.get(posix.join(dir, entry.version.slice(5)));
|
|
173
|
+
if (target === undefined) throw new Error(`${name} links ${entry.version}, which is not in the closure`);
|
|
174
|
+
entry.version = `link:${target}`;
|
|
175
|
+
} else if (entry.version !== undefined) {
|
|
176
|
+
pending.push(snapshotKey(dep, entry.version));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
slice.importers[name] = sections;
|
|
181
|
+
}
|
|
182
|
+
while (pending.length > 0) {
|
|
183
|
+
const key = pending.pop();
|
|
184
|
+
if (Object.hasOwn(slice.snapshots, key)) continue;
|
|
185
|
+
const body = snapshots[key];
|
|
186
|
+
if (body === undefined) throw new Error(`the lockfile has no snapshot ${key}`);
|
|
187
|
+
slice.snapshots[key] = body.map((l) => l.trim());
|
|
188
|
+
const entry = registry[packageKey(key)];
|
|
189
|
+
if (entry === undefined) throw new Error(`the lockfile has no packages entry ${packageKey(key)}`);
|
|
190
|
+
slice.packages[packageKey(key)] = entry.map((l) => l.trim());
|
|
191
|
+
for (const [dep, version] of Object.entries(snapshotDependencies(body))) pending.push(snapshotKey(dep, version));
|
|
192
|
+
}
|
|
193
|
+
return slice;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** sha256 over the sorted `<path>\0<git mode>\0<sha256>\n` lines of `files` (`{ relativePath: { bytes, mode } }`). */
|
|
197
|
+
export function packageDigest(files) {
|
|
198
|
+
const hash = createHash('sha256');
|
|
199
|
+
for (const path of Object.keys(files).sort()) hash.update(`${path}\0${files[path].mode}\0${sha256(files[path].bytes)}\n`);
|
|
200
|
+
return hash.digest('hex');
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The key and its parts. `packages` is `[{ name, files }]` (`files`: package-relative path -> `{ bytes, mode }`), `lockSlice` the
|
|
205
|
+
* result of `lockfileSlice`, `tooling` `{ dockerfile, scripts: { name: bytes }, buildArgs: { NAME: value }, stageTool: { name, version } }`,
|
|
206
|
+
* `rootConfig` `{ fileName: bytes }`, `install` the result of `installInputs`.
|
|
207
|
+
*/
|
|
208
|
+
export function effectiveInput({ service, platform, baseImage, packages, lockSlice, install, tooling, rootConfig }) {
|
|
209
|
+
if (!/^linux\/(amd64|arm64)$/.test(platform)) throw new Error(`platform must be linux/amd64 or linux/arm64, got '${platform}'`);
|
|
210
|
+
if (typeof baseImage !== 'string' || !DIGEST_PINNED.test(baseImage)) {
|
|
211
|
+
throw new Error(`the base image must be digest-pinned (<name>@sha256:<64 hex>), got '${baseImage}': a tag names mutable bytes`);
|
|
212
|
+
}
|
|
213
|
+
const excluded = Object.keys(tooling.buildArgs ?? {}).filter((n) => NON_CONTENT_BUILD_ARGS.includes(n));
|
|
214
|
+
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`);
|
|
215
|
+
const parts = {
|
|
216
|
+
schema: KEY_SCHEMA,
|
|
217
|
+
service,
|
|
218
|
+
platform,
|
|
219
|
+
baseImage,
|
|
220
|
+
tooling: {
|
|
221
|
+
buildArgs: tooling.buildArgs ?? {},
|
|
222
|
+
dockerfile: sha256(tooling.dockerfile),
|
|
223
|
+
scripts: Object.fromEntries(Object.entries(tooling.scripts).map(([name, bytes]) => [name, sha256(bytes)])),
|
|
224
|
+
stageTool: tooling.stageTool,
|
|
225
|
+
},
|
|
226
|
+
rootConfig: Object.fromEntries(Object.entries(rootConfig).map(([name, bytes]) => [name, sha256(bytes)])),
|
|
227
|
+
workspace: Object.fromEntries(packages.map((p) => [p.name, packageDigest(p.files)])),
|
|
228
|
+
lock: sha256(canonicalJson(lockSlice)),
|
|
229
|
+
install,
|
|
230
|
+
};
|
|
231
|
+
return { key: sha256(canonicalJson(parts)), parts };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Gathering from a workspace root ─────────────────────────────────────────────────────────────────────────────────
|
|
235
|
+
|
|
236
|
+
const run = (file, args, cwd) => execFileSync(file, args, { cwd, encoding: 'utf8', maxBuffer: 1 << 28 });
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* The workspace package graph, from ONE `turbo query` (the whole graph, so a package with no build task is not dropped):
|
|
240
|
+
* `Map<name, { name, dir, dependencies: string[] }>`, `dir` relative to `root`.
|
|
241
|
+
*/
|
|
242
|
+
export function packageGraph(root) {
|
|
243
|
+
const query = 'query { packages { items { name path directDependencies { items { name } } } } }';
|
|
244
|
+
const items = JSON.parse(run('pnpm', ['exec', 'turbo', 'query', query], root)).data.packages.items;
|
|
245
|
+
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) }]));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/** `[{ name, dir }]` of `service` and every workspace package it depends on, sorted by name. */
|
|
249
|
+
export function closureIn(graph, service) {
|
|
250
|
+
const seen = new Map();
|
|
251
|
+
const pending = [service];
|
|
252
|
+
while (pending.length > 0) {
|
|
253
|
+
const name = pending.pop();
|
|
254
|
+
if (seen.has(name)) continue;
|
|
255
|
+
const entry = graph.get(name);
|
|
256
|
+
if (entry === undefined) throw new Error(`'${name}' is not a package of this workspace`);
|
|
257
|
+
seen.set(name, { name, dir: entry.dir });
|
|
258
|
+
pending.push(...entry.dependencies.filter((d) => graph.has(d)));
|
|
259
|
+
}
|
|
260
|
+
return [...seen.values()].sort((a, b) => (a.name < b.name ? -1 : 1));
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const GIT_MODES = new Set(['100644', '100755', '120000']);
|
|
264
|
+
|
|
265
|
+
/** The package-relative tracked files of `dir` (git, run inside it: a submodule or the repository), each with its git mode. */
|
|
266
|
+
export function trackedFiles(root, dir) {
|
|
267
|
+
const cwd = join(root, dir);
|
|
268
|
+
const out = run('git', ['ls-files', '-s', '-z', '--', '.'], cwd);
|
|
269
|
+
const files = {};
|
|
270
|
+
for (const record of out.split('\0').filter(Boolean)) {
|
|
271
|
+
const tab = record.indexOf('\t');
|
|
272
|
+
const [mode] = record.slice(0, tab).split(' ');
|
|
273
|
+
const path = record.slice(tab + 1);
|
|
274
|
+
if (!GIT_MODES.has(mode)) throw new Error(`${dir}/${path} has git mode ${mode}, which effective-input does not key (a nested repository?)`);
|
|
275
|
+
files[path] = { mode, bytes: mode === '120000' ? Buffer.from(readlinkSync(join(cwd, path))) : readFileSync(join(cwd, path)) };
|
|
276
|
+
}
|
|
277
|
+
return files;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/** The key and parts of each of `services`, from one package-graph query and one read of the lockfile and tooling. */
|
|
281
|
+
export function effectiveInputKeys({ root, services, platform, baseImage, dockerfile, scripts, buildArgs = {} }) {
|
|
282
|
+
if (!Array.isArray(services) || services.length === 0) throw new Error('at least one service is required');
|
|
283
|
+
const names = scripts.map((x) => posix.basename(x));
|
|
284
|
+
const duplicate = names.find((n, i) => names.indexOf(n) !== i);
|
|
285
|
+
if (duplicate !== undefined) throw new Error(`two --script files are named '${duplicate}': the key names scripts by file name`);
|
|
286
|
+
const graph = packageGraph(root);
|
|
287
|
+
const toolManifest = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8'));
|
|
288
|
+
const rootConfig = Object.fromEntries(rootBuildInputNames(root).filter((n) => COMPILE_ROOT_FILE.test(n)).map((n) => [n, readFileSync(join(root, n))]));
|
|
289
|
+
const lockText = readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8');
|
|
290
|
+
const install = installInputs({ lockText, packageJson: readFileSync(join(root, 'package.json'), 'utf8'), workspaceYaml: readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8') });
|
|
291
|
+
const tooling = {
|
|
292
|
+
dockerfile: readFileSync(dockerfile),
|
|
293
|
+
scripts: Object.fromEntries(scripts.map((s) => [posix.basename(s), readFileSync(s)])),
|
|
294
|
+
buildArgs,
|
|
295
|
+
stageTool: { name: toolManifest.name, version: toolManifest.version },
|
|
296
|
+
};
|
|
297
|
+
const filesByPackage = new Map();
|
|
298
|
+
const filesOf = ({ name, dir }) => {
|
|
299
|
+
if (!filesByPackage.has(name)) filesByPackage.set(name, trackedFiles(root, dir));
|
|
300
|
+
return filesByPackage.get(name);
|
|
301
|
+
};
|
|
302
|
+
return Object.fromEntries(services.map((service) => {
|
|
303
|
+
const closure = closureIn(graph, service);
|
|
304
|
+
return [service, effectiveInput({
|
|
305
|
+
service,
|
|
306
|
+
platform,
|
|
307
|
+
baseImage,
|
|
308
|
+
packages: closure.map((p) => ({ name: p.name, files: filesOf(p) })),
|
|
309
|
+
lockSlice: lockfileSlice(lockText, closure),
|
|
310
|
+
install,
|
|
311
|
+
tooling,
|
|
312
|
+
rootConfig,
|
|
313
|
+
})];
|
|
314
|
+
}));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The key and parts of one service (`effectiveInputKeys` of one). */
|
|
318
|
+
export function effectiveInputKey({ service, ...options }) {
|
|
319
|
+
return effectiveInputKeys({ ...options, services: [service] })[service];
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The registries' tag lanes. `Candidate` images are built by the owl candidate run, `Develop` ones by develop CI. */
|
|
323
|
+
export const EffectiveInputLane = Object.freeze({ Candidate: 'candidate', Develop: 'develop' });
|
|
324
|
+
const LANE_PREFIX = Object.freeze({ [EffectiveInputLane.Candidate]: 'cand', [EffectiveInputLane.Develop]: 'ei' });
|
|
325
|
+
|
|
326
|
+
/** The one place a key becomes a registry tag: `cand-<key>` or `ei-<key>`. */
|
|
327
|
+
export function effectiveInputTag(key, lane) {
|
|
328
|
+
if (!/^[0-9a-f]{64}$/.test(key)) throw new Error(`'${key}' is not an effective-input key (64 lowercase hex)`);
|
|
329
|
+
if (!Object.hasOwn(LANE_PREFIX, lane)) throw new Error(`lane must be one of ${Object.values(EffectiveInputLane).join(', ')}, got '${lane}'`);
|
|
330
|
+
return `${LANE_PREFIX[lane]}-${key}`;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* The `--out` map: per service `{ schema, platform, key, parts, tags: { candidate, develop } }`. The tags are built only by
|
|
335
|
+
* `effectiveInputTag`, so a consumer (release-policy's `prepare --effective-inputs`) reads them here and declares no prefix.
|
|
336
|
+
*/
|
|
337
|
+
export function effectiveInputRecord(keys, platform) {
|
|
338
|
+
return Object.fromEntries(
|
|
339
|
+
Object.entries(keys).map(([service, { key, parts }]) => [
|
|
340
|
+
service,
|
|
341
|
+
{ schema: KEY_SCHEMA, platform, key, parts, tags: Object.fromEntries(Object.values(EffectiveInputLane).map((lane) => [lane, effectiveInputTag(key, lane)])) },
|
|
342
|
+
]),
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Only what the key can see is keyed: the closure's tracked files, the lockfile, the install settings, the Dockerfile, the
|
|
347
|
+
// `--script` files and the build args. A file the Dockerfile COPYs from OUTSIDE the closure (an entrypoint, infra/docker/*)
|
|
348
|
+
// is keyed only if it is passed as --script: the COPY sources are not derived (ARG-expanded paths such as REPO_DIR make that
|
|
349
|
+
// root-dependent), so the caller must list them.
|
|
350
|
+
const USAGE = 'usage: xema-effective-input --service <pkg> [--service <pkg>]... [--lane candidate|develop] --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)';
|
|
351
|
+
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
|
352
|
+
const SINGLE = ['platform', 'base-image', 'dockerfile', 'out', 'lane'];
|
|
353
|
+
|
|
354
|
+
export function parseArgs(argv) {
|
|
355
|
+
const options = { service: [], script: [], 'build-arg': [] };
|
|
356
|
+
for (let i = 0; i < argv.length; i += 2) {
|
|
357
|
+
const name = argv[i]?.startsWith('--') ? argv[i].slice(2) : undefined;
|
|
358
|
+
const value = argv[i + 1];
|
|
359
|
+
if (name === undefined || value === undefined || ![...SINGLE, 'service', 'script', 'build-arg'].includes(name)) throw new Error(USAGE);
|
|
360
|
+
if (Array.isArray(options[name])) options[name].push(value);
|
|
361
|
+
else if (Object.hasOwn(options, name)) throw new Error(`--${name} is given twice`);
|
|
362
|
+
else options[name] = value;
|
|
363
|
+
}
|
|
364
|
+
for (const required of ['platform', 'base-image', 'dockerfile']) if (!Object.hasOwn(options, required)) throw new Error(USAGE);
|
|
365
|
+
if (options.service.length === 0) throw new Error(USAGE);
|
|
366
|
+
if (options.lane !== undefined && !Object.values(EffectiveInputLane).includes(options.lane)) throw new Error(USAGE);
|
|
367
|
+
const buildArgs = {};
|
|
368
|
+
for (const pair of options['build-arg']) {
|
|
369
|
+
const eq = pair.indexOf('=');
|
|
370
|
+
if (eq <= 0) throw new Error(`--build-arg wants NAME=VALUE, got '${pair}'`);
|
|
371
|
+
const name = pair.slice(0, eq);
|
|
372
|
+
if (Object.hasOwn(buildArgs, name)) throw new Error(`build arg ${name} is given twice`);
|
|
373
|
+
buildArgs[name] = pair.slice(eq + 1);
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
services: options.service,
|
|
377
|
+
lane: options.lane,
|
|
378
|
+
platform: options.platform,
|
|
379
|
+
baseImage: options['base-image'],
|
|
380
|
+
dockerfile: resolve(options.dockerfile),
|
|
381
|
+
scripts: options.script.map((s) => resolve(s)),
|
|
382
|
+
buildArgs,
|
|
383
|
+
out: options.out,
|
|
384
|
+
};
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
388
|
+
try {
|
|
389
|
+
const { out, lane, ...options } = parseArgs(process.argv.slice(2));
|
|
390
|
+
const keys = effectiveInputKeys({ root: process.cwd(), ...options });
|
|
391
|
+
if (out !== undefined) {
|
|
392
|
+
writeFileSync(out, `${JSON.stringify(effectiveInputRecord(keys, options.platform), null, 2)}\n`);
|
|
393
|
+
}
|
|
394
|
+
const shown = Object.fromEntries(Object.entries(keys).map(([service, { key }]) => [service, lane === undefined ? key : effectiveInputTag(key, lane)]));
|
|
395
|
+
process.stdout.write(options.services.length === 1 ? `${shown[options.services[0]]}\n` : `${JSON.stringify(shown)}\n`);
|
|
396
|
+
} catch (error) {
|
|
397
|
+
console.error(`::error::${error.message.split('\n')[0]}`);
|
|
398
|
+
console.error(error.message);
|
|
399
|
+
process.exit(1);
|
|
400
|
+
}
|
|
401
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { execFileSync } from 'node:child_process';
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { dirname, join } from 'node:path';
|
|
6
|
+
import { test } from 'node:test';
|
|
7
|
+
|
|
8
|
+
import { EffectiveInputLane, canonicalJson, closureIn, effectiveInput, effectiveInputKeys, effectiveInputRecord, effectiveInputTag, installInputs, lockfileSlice, parseArgs, trackedFiles } from './effective-input.mjs';
|
|
9
|
+
|
|
10
|
+
const DIGEST = `ghcr.io/x/node-runtime@sha256:${'a'.repeat(64)}`;
|
|
11
|
+
const lock = (prefix, settings = 'autoInstallPeers: true') => `lockfileVersion: '9.0'
|
|
12
|
+
|
|
13
|
+
settings:
|
|
14
|
+
${settings}
|
|
15
|
+
|
|
16
|
+
overrides:
|
|
17
|
+
left-pad: 1.3.0
|
|
18
|
+
|
|
19
|
+
patchedDependencies:
|
|
20
|
+
tiny@1.0.0:
|
|
21
|
+
hash: aaa
|
|
22
|
+
path: patches/tiny.patch
|
|
23
|
+
|
|
24
|
+
importers:
|
|
25
|
+
|
|
26
|
+
.:
|
|
27
|
+
devDependencies:
|
|
28
|
+
turbo:
|
|
29
|
+
specifier: ^2.0.0
|
|
30
|
+
version: 2.0.1
|
|
31
|
+
|
|
32
|
+
${prefix}apps/svc:
|
|
33
|
+
dependencies:
|
|
34
|
+
'@x/lib':
|
|
35
|
+
specifier: workspace:*
|
|
36
|
+
version: link:../../packages/lib
|
|
37
|
+
left-pad:
|
|
38
|
+
specifier: ^1.0.0
|
|
39
|
+
version: 1.3.0
|
|
40
|
+
|
|
41
|
+
${prefix}packages/lib:
|
|
42
|
+
dependencies:
|
|
43
|
+
tiny:
|
|
44
|
+
specifier: ^1.0.0
|
|
45
|
+
version: 1.0.0(peer@1.0.0)
|
|
46
|
+
|
|
47
|
+
packages:
|
|
48
|
+
|
|
49
|
+
left-pad@1.3.0:
|
|
50
|
+
resolution: {integrity: sha512-LEFT}
|
|
51
|
+
|
|
52
|
+
tiny@1.0.0:
|
|
53
|
+
resolution: {integrity: sha512-TINY}
|
|
54
|
+
|
|
55
|
+
peer@1.0.0:
|
|
56
|
+
resolution: {integrity: sha512-PEER}
|
|
57
|
+
|
|
58
|
+
unused@9.9.9:
|
|
59
|
+
resolution: {integrity: sha512-UNUSED}
|
|
60
|
+
|
|
61
|
+
snapshots:
|
|
62
|
+
|
|
63
|
+
left-pad@1.3.0: {}
|
|
64
|
+
|
|
65
|
+
tiny@1.0.0(peer@1.0.0):
|
|
66
|
+
dependencies:
|
|
67
|
+
peer: 1.0.0
|
|
68
|
+
|
|
69
|
+
peer@1.0.0: {}
|
|
70
|
+
`;
|
|
71
|
+
|
|
72
|
+
/** A workspace root at `prefix` ('' = a repository, 'repos/r/' = an aggregator), each package its own git repo. */
|
|
73
|
+
function workspace(prefix, { svcSource = 'export const a = 1;\n', libSource = 'export const b = 1;\n' } = {}) {
|
|
74
|
+
const root = mkdtempSync(join(tmpdir(), 'effective-input-'));
|
|
75
|
+
const files = { [`${prefix}apps/svc/src/main.ts`]: svcSource, [`${prefix}packages/lib/src/index.ts`]: libSource };
|
|
76
|
+
for (const [path, content] of Object.entries(files)) {
|
|
77
|
+
mkdirSync(join(root, dirname(path)), { recursive: true });
|
|
78
|
+
writeFileSync(join(root, path), content);
|
|
79
|
+
}
|
|
80
|
+
for (const dir of [`${prefix}apps/svc`, `${prefix}packages/lib`]) {
|
|
81
|
+
execFileSync('git', ['init', '-q'], { cwd: join(root, dir) });
|
|
82
|
+
execFileSync('git', ['add', '.'], { cwd: join(root, dir) });
|
|
83
|
+
}
|
|
84
|
+
const dirs = [{ name: '@x/lib', dir: `${prefix}packages/lib` }, { name: 'svc', dir: `${prefix}apps/svc` }];
|
|
85
|
+
return { root, dirs, prefix };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const PACKAGE_JSON = JSON.stringify({ packageManager: 'pnpm@10.0.0', pnpm: { onlyBuiltDependencies: ['esbuild'], overrides: { a: '1' }, patchedDependencies: { b: 'p' } } });
|
|
89
|
+
const WORKSPACE_YAML = '# header\npackages:\n - "apps/*"\n - "packages/*"\n\nonlyBuiltDependencies:\n - esbuild\ninjectWorkspacePackages: true\n';
|
|
90
|
+
const install = (over = {}) => installInputs({ lockText: lock(''), packageJson: PACKAGE_JSON, workspaceYaml: WORKSPACE_YAML, ...over });
|
|
91
|
+
|
|
92
|
+
const base = {
|
|
93
|
+
service: 'svc',
|
|
94
|
+
platform: 'linux/arm64',
|
|
95
|
+
baseImage: DIGEST,
|
|
96
|
+
tooling: { dockerfile: 'FROM x', scripts: { 'split.mjs': 's' }, buildArgs: { INSTALL_COSIGN: 'no' }, stageTool: { name: 't', version: '1.0.0' } },
|
|
97
|
+
rootConfig: { '.npmrc': 'a=b', 'tsconfig.base.json': '{}' },
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function keyOf(ws, overrides = {}) {
|
|
101
|
+
const packages = ws.dirs.map(({ name, dir }) => ({ name, files: trackedFiles(ws.root, dir) }));
|
|
102
|
+
return effectiveInput({ ...base, packages, lockSlice: lockfileSlice(lock(ws.prefix), ws.dirs), install: install(), ...overrides }).key;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
test('the same inputs give the same key from a repository root and from an aggregator root', () => {
|
|
106
|
+
const repo = workspace('');
|
|
107
|
+
const aggregator = workspace('repos/r/');
|
|
108
|
+
try {
|
|
109
|
+
assert.equal(keyOf(repo), keyOf(aggregator));
|
|
110
|
+
assert.equal(keyOf(repo), keyOf(repo));
|
|
111
|
+
assert.match(keyOf(repo), /^[0-9a-f]{64}$/);
|
|
112
|
+
} finally {
|
|
113
|
+
rmSync(repo.root, { recursive: true });
|
|
114
|
+
rmSync(aggregator.root, { recursive: true });
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test('each input class changes the key', () => {
|
|
119
|
+
const ws = workspace('');
|
|
120
|
+
const changed = workspace('', { libSource: 'export const b = 2;\n' });
|
|
121
|
+
try {
|
|
122
|
+
const original = keyOf(ws);
|
|
123
|
+
const variants = {
|
|
124
|
+
'workspace source (transitive package)': keyOf(changed),
|
|
125
|
+
'lockfile slice': effectiveInput({
|
|
126
|
+
...base,
|
|
127
|
+
packages: ws.dirs.map(({ name, dir }) => ({ name, files: trackedFiles(ws.root, dir) })),
|
|
128
|
+
install: install(),
|
|
129
|
+
lockSlice: lockfileSlice(lock('').replace('sha512-TINY', 'sha512-OTHER'), ws.dirs),
|
|
130
|
+
}).key,
|
|
131
|
+
'file mode (executable bit)': (() => {
|
|
132
|
+
const files = Object.fromEntries(Object.entries(trackedFiles(ws.root, 'packages/lib')).map(([k, v]) => [k, { ...v, mode: '100755' }]));
|
|
133
|
+
return effectiveInput({ ...base, install: install(), lockSlice: lockfileSlice(lock(''), ws.dirs), packages: [{ name: '@x/lib', files }, { name: 'svc', files: trackedFiles(ws.root, 'apps/svc') }] }).key;
|
|
134
|
+
})(),
|
|
135
|
+
'lock settings': keyOf(ws, { install: install({ lockText: lock('', 'autoInstallPeers: false') }) }),
|
|
136
|
+
'lock overrides': keyOf(ws, { install: install({ lockText: lock('').replace('left-pad: 1.3.0', 'left-pad: 1.2.0') }) }),
|
|
137
|
+
'lock patchedDependencies': keyOf(ws, { install: install({ lockText: lock('').replace('hash: aaa', 'hash: bbb') }) }),
|
|
138
|
+
'package.json packageManager': keyOf(ws, { install: install({ packageJson: PACKAGE_JSON.replace('10.0.0', '10.1.0') }) }),
|
|
139
|
+
'package.json pnpm block': keyOf(ws, { install: install({ packageJson: PACKAGE_JSON.replace('esbuild', 'sharp') }) }),
|
|
140
|
+
'pnpm-workspace.yaml settings': keyOf(ws, { install: install({ workspaceYaml: WORKSPACE_YAML.replace('true', 'false') }) }),
|
|
141
|
+
dockerfile: keyOf(ws, { tooling: { ...base.tooling, dockerfile: 'FROM y' } }),
|
|
142
|
+
'stage script': keyOf(ws, { tooling: { ...base.tooling, scripts: { 'split.mjs': 't' } } }),
|
|
143
|
+
'stage tool version': keyOf(ws, { tooling: { ...base.tooling, stageTool: { name: 't', version: '1.0.1' } } }),
|
|
144
|
+
'build arg': keyOf(ws, { tooling: { ...base.tooling, buildArgs: { INSTALL_COSIGN: 'yes' } } }),
|
|
145
|
+
'base image digest': keyOf(ws, { baseImage: `ghcr.io/x/node-runtime@sha256:${'b'.repeat(64)}` }),
|
|
146
|
+
arch: keyOf(ws, { platform: 'linux/amd64' }),
|
|
147
|
+
'root compile config': keyOf(ws, { rootConfig: { ...base.rootConfig, '.npmrc': 'a=c' } }),
|
|
148
|
+
};
|
|
149
|
+
for (const [name, key] of Object.entries(variants)) assert.notEqual(key, original, `${name} must change the key`);
|
|
150
|
+
assert.equal(new Set(Object.values(variants)).size, Object.keys(variants).length, 'the classes are distinct');
|
|
151
|
+
} finally {
|
|
152
|
+
rmSync(ws.root, { recursive: true });
|
|
153
|
+
rmSync(changed.root, { recursive: true });
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('a lockfile entry outside the closure does not change the key', () => {
|
|
158
|
+
const ws = workspace('');
|
|
159
|
+
try {
|
|
160
|
+
const slice = (text) => canonicalJson(lockfileSlice(text, ws.dirs));
|
|
161
|
+
assert.equal(slice(lock('')), slice(lock('').replace('sha512-UNUSED', 'sha512-ELSE')));
|
|
162
|
+
} finally {
|
|
163
|
+
rmSync(ws.root, { recursive: true });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test('a tag-only base image is refused, with the reason', () => {
|
|
168
|
+
const ws = workspace('');
|
|
169
|
+
try {
|
|
170
|
+
assert.throws(() => keyOf(ws, { baseImage: 'ghcr.io/x/node-runtime:22' }), /digest-pinned/);
|
|
171
|
+
assert.throws(() => keyOf(ws, { baseImage: 'ghcr.io/x/node-runtime@sha256:abc' }), /digest-pinned/);
|
|
172
|
+
} finally {
|
|
173
|
+
rmSync(ws.root, { recursive: true });
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test('a per-root path or the commit is refused as a build arg', () => {
|
|
178
|
+
const ws = workspace('');
|
|
179
|
+
try {
|
|
180
|
+
for (const name of ['REPO_DIR', 'SERVICE_PATH', 'BUILD_SHA']) {
|
|
181
|
+
assert.throws(() => keyOf(ws, { tooling: { ...base.tooling, buildArgs: { [name]: 'x' } } }), new RegExp(name));
|
|
182
|
+
}
|
|
183
|
+
} finally {
|
|
184
|
+
rmSync(ws.root, { recursive: true });
|
|
185
|
+
}
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
test('a workspace link outside the closure and a missing snapshot fail fast', () => {
|
|
189
|
+
const ws = workspace('');
|
|
190
|
+
try {
|
|
191
|
+
assert.throws(() => lockfileSlice(lock(''), ws.dirs.filter((d) => d.name === 'svc')), /not in the closure/);
|
|
192
|
+
assert.throws(() => lockfileSlice(lock('').replace(' peer@1.0.0: {}\n', ''), ws.dirs), /no snapshot/);
|
|
193
|
+
} finally {
|
|
194
|
+
rmSync(ws.root, { recursive: true });
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test('the command line takes repeated build args and refuses a duplicate', () => {
|
|
199
|
+
const argv = ['--service', 's', '--service', 't', '--platform', 'linux/arm64', '--base-image', DIGEST, '--dockerfile', 'D', '--build-arg', 'A=1', '--build-arg', 'B=2=3'];
|
|
200
|
+
assert.deepEqual(parseArgs(argv).buildArgs, { A: '1', B: '2=3' });
|
|
201
|
+
assert.deepEqual(parseArgs(argv).services, ['s', 't']);
|
|
202
|
+
assert.throws(() => parseArgs([...argv, '--lane', 'prod']), /usage/);
|
|
203
|
+
assert.throws(() => parseArgs([...argv, '--build-arg', 'A=2']), /given twice/);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('tags name both lanes from one declaration and refuse a bad key or lane', () => {
|
|
207
|
+
const key = 'a'.repeat(64);
|
|
208
|
+
assert.equal(effectiveInputTag(key, EffectiveInputLane.Candidate), `cand-${key}`);
|
|
209
|
+
assert.equal(effectiveInputTag(key, EffectiveInputLane.Develop), `ei-${key}`);
|
|
210
|
+
assert.notEqual(effectiveInputTag(key, EffectiveInputLane.Candidate), effectiveInputTag(key, EffectiveInputLane.Develop));
|
|
211
|
+
assert.throws(() => effectiveInputTag(key, 'prod'), /lane must be one of/);
|
|
212
|
+
assert.throws(() => effectiveInputTag('abc', EffectiveInputLane.Develop), /not an effective-input key/);
|
|
213
|
+
assert.throws(() => effectiveInputTag('cand-' + key, EffectiveInputLane.Develop), /not an effective-input key/);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('the closure is walked transitively from the package graph, including a package with no build task, and refuses an unknown service', () => {
|
|
217
|
+
const graph = new Map([
|
|
218
|
+
['svc', { name: 'svc', dir: 'apps/svc', dependencies: ['mid', 'external'] }],
|
|
219
|
+
['mid', { name: 'mid', dir: 'packages/mid', dependencies: ['leaf', 'svc-cycle-free'] }],
|
|
220
|
+
['leaf', { name: 'leaf', dir: 'packages/leaf', dependencies: [] }],
|
|
221
|
+
['svc-cycle-free', { name: 'svc-cycle-free', dir: 'packages/c', dependencies: ['mid'] }],
|
|
222
|
+
['other', { name: 'other', dir: 'apps/other', dependencies: [] }],
|
|
223
|
+
]);
|
|
224
|
+
assert.deepEqual(closureIn(graph, 'svc').map((p) => p.name), ['leaf', 'mid', 'svc', 'svc-cycle-free']);
|
|
225
|
+
assert.deepEqual(closureIn(graph, 'other').map((p) => p.name), ['other']);
|
|
226
|
+
assert.throws(() => closureIn(graph, 'nope'), /not a package/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
test('the workspace file keys its settings, not its package globs, comments or blank lines', () => {
|
|
230
|
+
const ws = workspace('');
|
|
231
|
+
try {
|
|
232
|
+
const original = keyOf(ws);
|
|
233
|
+
assert.equal(keyOf(ws, { install: install({ workspaceYaml: WORKSPACE_YAML.replace('"apps/*"', '"repos/*/apps/*"').replace('# header', '# other') + '\n\n' }) }), original);
|
|
234
|
+
} finally {
|
|
235
|
+
rmSync(ws.root, { recursive: true });
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test('a lockfileVersion other than 9.0 is refused by name', () => {
|
|
240
|
+
const ws = workspace('');
|
|
241
|
+
try {
|
|
242
|
+
assert.throws(() => lockfileSlice(lock('').replace("'9.0'", "'6.0'"), ws.dirs), /lockfileVersion '6.0' is not '9.0'/);
|
|
243
|
+
assert.throws(() => install({ lockText: 'settings:\n' }), /lockfileVersion 'undefined'/);
|
|
244
|
+
} finally {
|
|
245
|
+
rmSync(ws.root, { recursive: true });
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test('two scripts with the same file name are refused', () => {
|
|
250
|
+
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'/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('the --out map carries both lane tags per service, from effectiveInputTag', () => {
|
|
254
|
+
const key = 'c'.repeat(64);
|
|
255
|
+
const record = effectiveInputRecord({ 'a-api': { key, parts: { x: 1 } } }, 'linux/amd64');
|
|
256
|
+
assert.deepEqual(record['a-api'].tags, { candidate: effectiveInputTag(key, EffectiveInputLane.Candidate), develop: effectiveInputTag(key, EffectiveInputLane.Develop) });
|
|
257
|
+
assert.deepEqual(record['a-api'].tags, { candidate: `cand-${key}`, develop: `ei-${key}` });
|
|
258
|
+
assert.equal(record['a-api'].key, key);
|
|
259
|
+
});
|