@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/repo-build-tooling",
3
- "version": "0.28.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:741aec4b08022b9aa6702a86627e72163defa62b568c2dbacc2f9e29f14d85bf"
55
+ "sourceDigest": "sha256:a5327bf366fb31aeef8adb584888e52a5ae4da7aa5602eb672eb2876ed912a8b"
56
56
  }
57
57
  }
@@ -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>]… [--lane candidate|develop] --platform linux/<amd64|arm64> --base-image <name@sha256:digest>
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
- /** 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) {
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
- 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}`;
329
+ return `ei-${key}`;
331
330
  }
332
331
 
333
332
  /**
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.
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>]... [--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)';
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', 'lane'];
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 (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
381
+ if (invokedDirectly(process.argv[1], import.meta.url)) {
388
382
  try {
389
- const { out, lane, ...options } = parseArgs(process.argv.slice(2));
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, lane === undefined ? key : effectiveInputTag(key, lane)]));
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 { EffectiveInputLane, canonicalJson, closureIn, effectiveInput, effectiveInputKeys, effectiveInputRecord, effectiveInputTag, installInputs, lockfileSlice, parseArgs, trackedFiles } from './effective-input.mjs';
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', 'prod']), /usage/);
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('tags name both lanes from one declaration and refuse a bad key or lane', () => {
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, 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/);
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 both lane tags per service, from effectiveInputTag', () => {
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.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}` });
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, realpathSync, rmSync } from 'node:fs';
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
- * Was this module RUN, or merely imported (by its own test)?
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