@xemahq/repo-build-tooling 0.28.0 → 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 +2 -2
- package/src/effective-input.mjs +16 -22
- package/src/effective-input.test.mjs +45 -14
- 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)",
|
|
@@ -52,6 +52,6 @@
|
|
|
52
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"
|
|
53
53
|
},
|
|
54
54
|
"xemaRelease": {
|
|
55
|
-
"sourceDigest": "sha256:
|
|
55
|
+
"sourceDigest": "sha256:a5327bf366fb31aeef8adb584888e52a5ae4da7aa5602eb672eb2876ed912a8b"
|
|
56
56
|
}
|
|
57
57
|
}
|
package/src/effective-input.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// The EFFECTIVE-INPUT key of one image: a content hash of every input that decides its bytes, computed from
|
|
3
3
|
// SOURCES, before any build, so a lane can skip build, stage and push when the key is already in the registry.
|
|
4
4
|
//
|
|
5
|
-
// xema-effective-input --service <pkg> [--service <pkg>]…
|
|
5
|
+
// xema-effective-input --service <pkg> [--service <pkg>]… --platform linux/<amd64|arm64> --base-image <name@sha256:digest>
|
|
6
6
|
// --dockerfile <file> [--script <file>]… [--build-arg NAME=VALUE]… [--out <file>]
|
|
7
7
|
//
|
|
8
8
|
// Run from the workspace root (the repository, or an aggregator workspace root: the staging actions' `context-root`).
|
|
@@ -25,6 +25,7 @@ import { readFileSync, readlinkSync, writeFileSync } from 'node:fs';
|
|
|
25
25
|
import { join, posix, resolve } from 'node:path';
|
|
26
26
|
import { fileURLToPath } from 'node:url';
|
|
27
27
|
|
|
28
|
+
import { invokedDirectly } from './invoked-directly.mjs';
|
|
28
29
|
import { rootBuildInputNames } from './staged-image-proof.mjs';
|
|
29
30
|
|
|
30
31
|
export const KEY_SCHEMA = 1;
|
|
@@ -319,27 +320,22 @@ export function effectiveInputKey({ service, ...options }) {
|
|
|
319
320
|
return effectiveInputKeys({ ...options, services: [service] })[service];
|
|
320
321
|
}
|
|
321
322
|
|
|
322
|
-
/**
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
export function effectiveInputTag(key, lane) {
|
|
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
328
|
if (!/^[0-9a-f]{64}$/.test(key)) throw new Error(`'${key}' is not an effective-input key (64 lowercase hex)`);
|
|
329
|
-
|
|
330
|
-
return `${LANE_PREFIX[lane]}-${key}`;
|
|
329
|
+
return `ei-${key}`;
|
|
331
330
|
}
|
|
332
331
|
|
|
333
332
|
/**
|
|
334
|
-
* The `--out` map: per service `{ schema, platform, key, parts,
|
|
335
|
-
*
|
|
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.
|
|
336
335
|
*/
|
|
337
336
|
export function effectiveInputRecord(keys, platform) {
|
|
338
337
|
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
|
-
]),
|
|
338
|
+
Object.entries(keys).map(([service, { key, parts }]) => [service, { schema: KEY_SCHEMA, platform, key, parts, tag: effectiveInputTag(key) }]),
|
|
343
339
|
);
|
|
344
340
|
}
|
|
345
341
|
|
|
@@ -347,9 +343,9 @@ export function effectiveInputRecord(keys, platform) {
|
|
|
347
343
|
// `--script` files and the build args. A file the Dockerfile COPYs from OUTSIDE the closure (an entrypoint, infra/docker/*)
|
|
348
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
|
|
349
345
|
// root-dependent), so the caller must list them.
|
|
350
|
-
const USAGE = 'usage: xema-effective-input --service <pkg> [--service <pkg>]...
|
|
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)';
|
|
351
347
|
const HERE = fileURLToPath(new URL('.', import.meta.url));
|
|
352
|
-
const SINGLE = ['platform', 'base-image', 'dockerfile', 'out'
|
|
348
|
+
const SINGLE = ['platform', 'base-image', 'dockerfile', 'out'];
|
|
353
349
|
|
|
354
350
|
export function parseArgs(argv) {
|
|
355
351
|
const options = { service: [], script: [], 'build-arg': [] };
|
|
@@ -363,7 +359,6 @@ export function parseArgs(argv) {
|
|
|
363
359
|
}
|
|
364
360
|
for (const required of ['platform', 'base-image', 'dockerfile']) if (!Object.hasOwn(options, required)) throw new Error(USAGE);
|
|
365
361
|
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
362
|
const buildArgs = {};
|
|
368
363
|
for (const pair of options['build-arg']) {
|
|
369
364
|
const eq = pair.indexOf('=');
|
|
@@ -374,7 +369,6 @@ export function parseArgs(argv) {
|
|
|
374
369
|
}
|
|
375
370
|
return {
|
|
376
371
|
services: options.service,
|
|
377
|
-
lane: options.lane,
|
|
378
372
|
platform: options.platform,
|
|
379
373
|
baseImage: options['base-image'],
|
|
380
374
|
dockerfile: resolve(options.dockerfile),
|
|
@@ -384,14 +378,14 @@ export function parseArgs(argv) {
|
|
|
384
378
|
};
|
|
385
379
|
}
|
|
386
380
|
|
|
387
|
-
if (
|
|
381
|
+
if (invokedDirectly(process.argv[1], import.meta.url)) {
|
|
388
382
|
try {
|
|
389
|
-
const { out,
|
|
383
|
+
const { out, ...options } = parseArgs(process.argv.slice(2));
|
|
390
384
|
const keys = effectiveInputKeys({ root: process.cwd(), ...options });
|
|
391
385
|
if (out !== undefined) {
|
|
392
386
|
writeFileSync(out, `${JSON.stringify(effectiveInputRecord(keys, options.platform), null, 2)}\n`);
|
|
393
387
|
}
|
|
394
|
-
const shown = Object.fromEntries(Object.entries(keys).map(([service, { key }]) => [service,
|
|
388
|
+
const shown = Object.fromEntries(Object.entries(keys).map(([service, { key }]) => [service, key]));
|
|
395
389
|
process.stdout.write(options.services.length === 1 ? `${shown[options.services[0]]}\n` : `${JSON.stringify(shown)}\n`);
|
|
396
390
|
} catch (error) {
|
|
397
391
|
console.error(`::error::${error.message.split('\n')[0]}`);
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import assert from 'node:assert/strict';
|
|
2
|
-
import { execFileSync } from 'node:child_process';
|
|
3
|
-
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
3
|
+
import { mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { dirname, join } from 'node:path';
|
|
6
|
+
import { fileURLToPath } from 'node:url';
|
|
6
7
|
import { test } from 'node:test';
|
|
7
8
|
|
|
8
|
-
import {
|
|
9
|
+
import { canonicalJson, closureIn, effectiveInput, effectiveInputKeys, effectiveInputRecord, effectiveInputTag, installInputs, lockfileSlice, parseArgs, trackedFiles } from './effective-input.mjs';
|
|
9
10
|
|
|
10
11
|
const DIGEST = `ghcr.io/x/node-runtime@sha256:${'a'.repeat(64)}`;
|
|
11
12
|
const lock = (prefix, settings = 'autoInstallPeers: true') => `lockfileVersion: '9.0'
|
|
@@ -199,18 +200,15 @@ test('the command line takes repeated build args and refuses a duplicate', () =>
|
|
|
199
200
|
const argv = ['--service', 's', '--service', 't', '--platform', 'linux/arm64', '--base-image', DIGEST, '--dockerfile', 'D', '--build-arg', 'A=1', '--build-arg', 'B=2=3'];
|
|
200
201
|
assert.deepEqual(parseArgs(argv).buildArgs, { A: '1', B: '2=3' });
|
|
201
202
|
assert.deepEqual(parseArgs(argv).services, ['s', 't']);
|
|
202
|
-
assert.throws(() => parseArgs([...argv, '--lane', '
|
|
203
|
+
assert.throws(() => parseArgs([...argv, '--lane', 'develop']), /usage/);
|
|
203
204
|
assert.throws(() => parseArgs([...argv, '--build-arg', 'A=2']), /given twice/);
|
|
204
205
|
});
|
|
205
206
|
|
|
206
|
-
test('
|
|
207
|
+
test('the tag is ei-<key>, and a bad key is refused', () => {
|
|
207
208
|
const key = 'a'.repeat(64);
|
|
208
|
-
assert.equal(effectiveInputTag(key
|
|
209
|
-
assert.
|
|
210
|
-
assert.
|
|
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/);
|
|
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/);
|
|
214
212
|
});
|
|
215
213
|
|
|
216
214
|
test('the closure is walked transitively from the package graph, including a package with no build task, and refuses an unknown service', () => {
|
|
@@ -250,10 +248,43 @@ test('two scripts with the same file name are refused', () => {
|
|
|
250
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'/);
|
|
251
249
|
});
|
|
252
250
|
|
|
253
|
-
test('the --out map carries
|
|
251
|
+
test('the --out map carries the tag per service, from effectiveInputTag', () => {
|
|
254
252
|
const key = 'c'.repeat(64);
|
|
255
253
|
const record = effectiveInputRecord({ 'a-api': { key, parts: { x: 1 } } }, 'linux/amd64');
|
|
256
|
-
assert.
|
|
257
|
-
assert.
|
|
254
|
+
assert.equal(record['a-api'].tag, effectiveInputTag(key));
|
|
255
|
+
assert.equal(record['a-api'].tag, `ei-${key}`);
|
|
258
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
|
+
}
|
|
259
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
|
|