@xemahq/repo-build-tooling 0.12.0 → 0.13.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,7 +1,7 @@
1
1
  {
2
2
  "name": "@xemahq/repo-build-tooling",
3
- "version": "0.12.0",
4
- "description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM with zero dependencies so the published artifact is the reviewed source.",
3
+ "version": "0.13.0",
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)",
7
7
  "homepage": "https://xema.dev",
@@ -32,16 +32,18 @@
32
32
  "./check-no-vendored-deps": "./src/check-no-vendored-deps.mjs",
33
33
  "./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs",
34
34
  "./composed-image": "./src/composed-image.mjs",
35
- "./ghcr-manifest": "./src/ghcr-manifest.mjs",
36
35
  "./package.json": "./package.json",
37
36
  "./readiness": "./src/readiness.mjs",
38
37
  "./scrub-swagger-plugin-paths": "./src/scrub-swagger-plugin-paths.mjs",
39
38
  "./service-bootstrap": "./src/service-bootstrap.mjs"
40
39
  },
40
+ "dependencies": {
41
+ "@xemahq/release-policy": "^14.0.0"
42
+ },
41
43
  "scripts": {
42
- "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/ghcr-manifest.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/registry-fetch.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-stage.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-migrate.test.mjs"
44
+ "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-stage.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-migrate.test.mjs"
43
45
  },
44
46
  "xemaRelease": {
45
- "sourceDigest": "sha256:65f155b64c9951c7c47a7d2a2b9cf65e08615471f0d5c49097a18460cbed260d"
47
+ "sourceDigest": "sha256:a3994566dfeac8ede246a07315647fa7f10828b510cf5835a0640a8a97650f5c"
46
48
  }
47
49
  }
@@ -0,0 +1,60 @@
1
+ import assert from 'node:assert/strict';
2
+ import { spawnSync } from 'node:child_process';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import test from 'node:test';
7
+ import { fileURLToPath } from 'node:url';
8
+
9
+ const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), 'composed-image.mjs');
10
+ const HASH = 'a'.repeat(64);
11
+
12
+ function consumer() {
13
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coordinate-cli-'));
14
+ const hashDir = path.join(root, 'node_modules', '@xemahq', 'distribution-source-hash');
15
+ fs.mkdirSync(hashDir, { recursive: true });
16
+ fs.writeFileSync(
17
+ path.join(hashDir, 'package.json'),
18
+ JSON.stringify({ name: '@xemahq/distribution-source-hash', bin: { 'xema-compute-image-tag': 'bin.js' } }),
19
+ );
20
+ fs.writeFileSync(path.join(hashDir, 'bin.js'), `console.log('${HASH}');\n`);
21
+ fs.writeFileSync(path.join(root, 'package.json'), '{}');
22
+ return root;
23
+ }
24
+
25
+ // The release-policy develop-images CLI runs the coordinate under an allow-listed
26
+ // environment with no registry credential and parses these two stdout lines.
27
+ test('the CLI prints content_tag and cross_repo_pins under an environment with no registry credential', () => {
28
+ const root = consumer();
29
+ try {
30
+ const tagArgs = path.join(root, 'tag-args.txt');
31
+ fs.writeFileSync(tagArgs, `--vendored\nd=r@${'b'.repeat(40)}:s\n`);
32
+ const result = spawnSync(process.execPath, [BIN, 'coordinate', '--image-name', 'img'], {
33
+ cwd: root,
34
+ encoding: 'utf8',
35
+ env: { PATH: process.env.PATH, SHA: 'b'.repeat(40), REGISTRY: 'ghcr.io', REGISTRY_ORG: 'org', TAG_ARGS_FILE: tagArgs },
36
+ });
37
+ assert.equal(result.status, 0, result.stderr);
38
+ assert.match(result.stdout, new RegExp(`^content_tag=${HASH}$`, 'm'));
39
+ assert.match(result.stdout, /^cross_repo_pins=1$/m);
40
+ assert.doesNotMatch(result.stdout, /exists=/);
41
+ } finally {
42
+ fs.rmSync(root, { recursive: true, force: true });
43
+ }
44
+ });
45
+
46
+ test('the CLI fails with the real message when the hash package is not installed', () => {
47
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'coordinate-cli-'));
48
+ try {
49
+ fs.writeFileSync(path.join(root, 'package.json'), '{}');
50
+ const result = spawnSync(process.execPath, [BIN, 'coordinate', '--image-name', 'img'], {
51
+ cwd: root,
52
+ encoding: 'utf8',
53
+ env: { PATH: process.env.PATH, SHA: 'b'.repeat(40), REGISTRY: 'ghcr.io', REGISTRY_ORG: 'org' },
54
+ });
55
+ assert.equal(result.status, 1);
56
+ assert.match(result.stderr, /distribution-source-hash is not installed/);
57
+ } finally {
58
+ fs.rmSync(root, { recursive: true, force: true });
59
+ }
60
+ });
@@ -1,5 +1,5 @@
1
1
  // `xema-composed-image coordinate --image-name <name>`: the release coordinate of a
2
- // repository's composed-process image, and whether the registry already serves it.
2
+ // repository's composed-process image and how many cross-repository inputs it hashes.
3
3
  //
4
4
  // The tag is the ORDINARY build-input key, `xema-compute-image-tag` over the
5
5
  // image directory, exactly as for any other image subject, so the distribution
@@ -8,23 +8,34 @@
8
8
  // bin is resolved from the CONSUMER's own `@xemahq/distribution-source-hash`
9
9
  // install, and the coordinate refuses when the repository does not carry it.
10
10
  //
11
- // Presence is the same question a service matrix asks (a HEAD on the content tag),
12
- // with the same posture: only 200 and 404 are answers.
11
+ // The coordinate is PURE: it computes and prints, and never probes the registry, so
12
+ // it needs no registry credential and runs under an environment that carries none.
13
+ // Whether the registry serves the tag is the caller's own question.
13
14
  //
14
- // Environment: SHA (40-hex commit), REGISTRY, REGISTRY_ORG, REGISTRY_USER,
15
- // REGISTRY_TOKEN, optional TAG_ARGS_FILE (one tag argument per line, e.g. the
16
- // repository's vendored cross-repository inputs; when set it must exist),
17
- // optional GITHUB_OUTPUT.
15
+ // Environment: SHA (40-hex commit), REGISTRY, REGISTRY_ORG, optional TAG_ARGS_FILE
16
+ // (one tag argument per line, e.g. the repository's vendored cross-repository
17
+ // inputs; when set it must exist), optional GITHUB_OUTPUT.
18
18
 
19
19
  import { execFileSync } from 'node:child_process';
20
20
  import { appendFileSync, existsSync, readFileSync } from 'node:fs';
21
21
  import { createRequire } from 'node:module';
22
22
  import { dirname, join } from 'node:path';
23
23
 
24
- import { ghcrManifestExists } from './ghcr-manifest.mjs';
25
-
26
24
  export const HASH_PACKAGE = '@xemahq/distribution-source-hash';
27
25
  export const HASH_BIN = 'xema-compute-image-tag';
26
+ const VENDORED_FLAG = '--vendored';
27
+
28
+ /**
29
+ * The number of cross-repository pinned inputs in the hash input set: each
30
+ * `--vendored <dest>=<repo>@<commit>:<src>` pair of the tag arguments. Every tag
31
+ * argument flag takes exactly one value, so the arguments are read as flag/value pairs.
32
+ */
33
+ export function countCrossRepoPins(tagArgs) {
34
+ if (tagArgs.length % 2 !== 0) throw new Error('TAG_ARGS_FILE must hold flag/value pairs, one argument per line');
35
+ let pins = 0;
36
+ for (let i = 0; i < tagArgs.length; i += 2) if (tagArgs[i] === VENDORED_FLAG) pins += 1;
37
+ return pins;
38
+ }
28
39
 
29
40
  function required(env, name) {
30
41
  const value = env[name];
@@ -65,8 +76,8 @@ export function resolveHashBin(root) {
65
76
  return join(dirname(manifestPath), bin);
66
77
  }
67
78
 
68
- /** `{ image, content_tag, exists, entry }` for the composed image of the repository at `root`. */
69
- export async function composedCoordinate({ root, imageName, imageDir, env = process.env, fetchImpl = fetch, hash }) {
79
+ /** `{ image, content_tag, cross_repo_pins, entry }` for the composed image of the repository at `root`. */
80
+ export function composedCoordinate({ root, imageName, imageDir, env = process.env, hash }) {
70
81
  if (typeof imageName !== 'string' || imageName.length === 0) throw new Error('coordinate needs --image-name');
71
82
  const sha = required(env, 'SHA');
72
83
  if (!/^[a-f0-9]{40}$/.test(sha)) throw new Error(`SHA '${sha}' is not an exact 40-hex commit`);
@@ -93,16 +104,7 @@ export async function composedCoordinate({ root, imageName, imageDir, env = proc
93
104
  throw new Error(`${HASH_BIN} emitted '${contentHash}', not a sha256 hex digest`);
94
105
  }
95
106
 
96
- const repository = `${org}/${imageName}`;
97
- const exists = await ghcrManifestExists({
98
- registry,
99
- repository,
100
- contentHash,
101
- user: required(env, 'REGISTRY_USER'),
102
- token: required(env, 'REGISTRY_TOKEN'),
103
- fetchImpl,
104
- });
105
- const image = `${registry}/${repository}`;
107
+ const image = `${registry}/${org}/${imageName}`;
106
108
  // The same entry shape a service matrix entry has, so the arm64 supplement
107
109
  // lane reads this image's receipt exactly as it reads a service's.
108
110
  const entry = {
@@ -113,7 +115,7 @@ export async function composedCoordinate({ root, imageName, imageDir, env = proc
113
115
  imageRepository: image,
114
116
  contentHash,
115
117
  };
116
- return { image, content_tag: contentHash, exists: String(exists), entry: JSON.stringify(entry) };
118
+ return { image, content_tag: contentHash, cross_repo_pins: String(countCrossRepoPins(tagArgs)), entry: JSON.stringify(entry) };
117
119
  }
118
120
 
119
121
  /** Print the outputs, append them to GITHUB_OUTPUT when set. */
@@ -122,5 +124,5 @@ export function emitCoordinate(outputs, { env = process.env, log = console.log }
122
124
  if (env.GITHUB_OUTPUT) appendFileSync(env.GITHUB_OUTPUT, `${key}=${value}\n`);
123
125
  log(`${key}=${value}`);
124
126
  }
125
- log(`${outputs.image}:${outputs.content_tag} ${outputs.exists === 'true' ? 'exists — skip' : 'is missing — build'}`);
127
+ log(`${outputs.image}:${outputs.content_tag} (${outputs.cross_repo_pins} cross-repo pins)`);
126
128
  }
@@ -4,26 +4,11 @@ import os from 'node:os';
4
4
  import path from 'node:path';
5
5
  import test from 'node:test';
6
6
 
7
- import { composedCoordinate, emitCoordinate, resolveHashBin } from './composed-image-coordinate.mjs';
7
+ import { composedCoordinate, countCrossRepoPins, emitCoordinate, resolveHashBin } from './composed-image-coordinate.mjs';
8
8
 
9
9
  const SHA = 'c'.repeat(40);
10
10
  const HASH = 'd'.repeat(64);
11
- const ENV = { SHA, REGISTRY: 'ghcr.io', REGISTRY_ORG: 'xema-dev', REGISTRY_USER: 'runner', REGISTRY_TOKEN: 'secret' };
12
-
13
- function response(status, body) {
14
- return { status, async json() { return body; } };
15
- }
16
-
17
- function registry(manifestStatus) {
18
- const urls = [];
19
- return {
20
- urls,
21
- fetchImpl: async (url) => {
22
- urls.push(String(url));
23
- return String(url).includes('/token') ? response(200, { token: 't' }) : response(manifestStatus, {});
24
- },
25
- };
26
- }
11
+ const ENV = { SHA, REGISTRY: 'ghcr.io', REGISTRY_ORG: 'xema-dev' };
27
12
 
28
13
  /** A consumer root whose own install carries a stand-in `xema-compute-image-tag` that echoes its arguments into a file. */
29
14
  function consumer({ withHash = true, exportsMap } = {}) {
@@ -44,67 +29,77 @@ function consumer({ withHash = true, exportsMap } = {}) {
44
29
  return root;
45
30
  }
46
31
 
47
- test('the hash comes from the consumer\'s own bin, called with the service, directory, commit and tag args', async () => {
32
+ test('the hash comes from the consumer\'s own bin, called with the service, directory, commit and tag args', () => {
48
33
  const root = consumer();
49
34
  const tagArgs = path.join(root, 'tag-args.txt');
50
35
  fs.writeFileSync(tagArgs, '--vendored\nx=y@z:w\n');
51
- const { fetchImpl, urls } = registry(404);
52
- const out = await composedCoordinate({
36
+ const out = composedCoordinate({
53
37
  root,
54
38
  imageName: 'alpha-composed-process',
55
39
  imageDir: 'infra/composed-process',
56
40
  env: { ...ENV, TAG_ARGS_FILE: tagArgs },
57
- fetchImpl,
58
41
  });
59
42
  assert.deepEqual(JSON.parse(fs.readFileSync(path.join(root, 'args.json'), 'utf8')), [
60
43
  '--service', 'alpha-composed-process', '--path', 'infra/composed-process', '--commit', SHA, '--repo-dir', root, '--vendored', 'x=y@z:w',
61
44
  ]);
62
45
  assert.equal(out.image, 'ghcr.io/xema-dev/alpha-composed-process');
63
46
  assert.equal(out.content_tag, HASH);
64
- assert.equal(out.exists, 'false');
65
- assert.ok(urls[1].endsWith(`/v2/xema-dev/alpha-composed-process/manifests/${HASH}`));
47
+ assert.equal(out.cross_repo_pins, '1');
48
+ assert.equal('exists' in out, false);
66
49
  assert.deepEqual(JSON.parse(out.entry), {
67
50
  service: 'alpha-composed-process', path: 'infra/composed-process', workspace: false, runtimeImage: false,
68
51
  imageRepository: 'ghcr.io/xema-dev/alpha-composed-process', contentHash: HASH,
69
52
  });
70
53
  });
71
54
 
72
- test('a present manifest reports exists', async () => {
73
- const out = await composedCoordinate({ root: consumer(), imageName: 'a', imageDir: 'd', env: ENV, fetchImpl: registry(200).fetchImpl });
74
- assert.equal(out.exists, 'true');
55
+ test('a composed subject with no tag arguments has zero cross-repo pins', () => {
56
+ const out = composedCoordinate({ root: consumer(), imageName: 'a', imageDir: 'd', env: ENV });
57
+ assert.equal(out.cross_repo_pins, '0');
58
+ });
59
+
60
+ test('every --vendored input counts, other tag arguments do not', () => {
61
+ assert.equal(countCrossRepoPins([]), 0);
62
+ assert.equal(countCrossRepoPins(['--vendored', 'a=r@' + SHA + ':p', '--build-step-dir', 'x', '--vendored', 'b=r@' + SHA + ':q']), 2);
63
+ assert.throws(() => countCrossRepoPins(['--vendored']), /flag\/value pairs/);
75
64
  });
76
65
 
77
- test('a repository without the hash package is refused, never hashed another way', async () => {
78
- await assert.rejects(
79
- composedCoordinate({ root: consumer({ withHash: false }), imageName: 'a', imageDir: 'd', env: ENV, fetchImpl: registry(404).fetchImpl }),
66
+ test('the coordinate is pure: no registry credential is needed and no fetch is made', () => {
67
+ const realFetch = globalThis.fetch;
68
+ globalThis.fetch = () => {
69
+ throw new Error('coordinate must not touch the network');
70
+ };
71
+ try {
72
+ const out = composedCoordinate({ root: consumer(), imageName: 'a', imageDir: 'd', env: { ...ENV } });
73
+ assert.equal(out.content_tag, HASH);
74
+ } finally {
75
+ globalThis.fetch = realFetch;
76
+ }
77
+ });
78
+
79
+ test('a repository without the hash package is refused, never hashed another way', () => {
80
+ assert.throws(
81
+ () => composedCoordinate({ root: consumer({ withHash: false }), imageName: 'a', imageDir: 'd', env: ENV }),
80
82
  /distribution-source-hash is not installed/,
81
83
  );
82
84
  assert.throws(() => resolveHashBin(consumer({ withHash: false })), /not installed/);
83
85
  });
84
86
 
85
- test('the inputs are checked: commit, registry settings, tag args file, digest shape', async () => {
87
+ test('the inputs are checked: commit, registry settings, tag args file, digest shape', () => {
86
88
  const root = consumer();
87
- const base = { root, imageName: 'a', imageDir: 'd', fetchImpl: registry(404).fetchImpl };
88
- await assert.rejects(composedCoordinate({ ...base, env: { ...ENV, SHA: 'abc' } }), /40-hex/);
89
- await assert.rejects(composedCoordinate({ ...base, env: { ...ENV, REGISTRY_ORG: '' } }), /REGISTRY_ORG is required/);
90
- await assert.rejects(composedCoordinate({ ...base, env: { ...ENV, TAG_ARGS_FILE: path.join(root, 'nope') } }), /TAG_ARGS_FILE .* absent/);
91
- await assert.rejects(composedCoordinate({ ...base, imageName: '', env: ENV }), /--image-name/);
92
- await assert.rejects(composedCoordinate({ ...base, env: ENV, hash: () => 'not-a-digest' }), /not a sha256/);
93
- });
94
-
95
- test('an unusable registry answer fails the coordinate rather than reading as missing', async () => {
96
- await assert.rejects(
97
- composedCoordinate({ root: consumer(), imageName: 'a', imageDir: 'd', env: ENV, fetchImpl: registry(500).fetchImpl }),
98
- /HTTP 500/,
99
- );
89
+ const base = { root, imageName: 'a', imageDir: 'd' };
90
+ assert.throws(() => composedCoordinate({ ...base, env: { ...ENV, SHA: 'abc' } }), /40-hex/);
91
+ assert.throws(() => composedCoordinate({ ...base, env: { ...ENV, REGISTRY_ORG: '' } }), /REGISTRY_ORG is required/);
92
+ assert.throws(() => composedCoordinate({ ...base, env: { ...ENV, TAG_ARGS_FILE: path.join(root, 'nope') } }), /TAG_ARGS_FILE .* absent/);
93
+ assert.throws(() => composedCoordinate({ ...base, imageName: '', env: ENV }), /--image-name/);
94
+ assert.throws(() => composedCoordinate({ ...base, env: ENV, hash: () => 'not-a-digest' }), /not a sha256/);
100
95
  });
101
96
 
102
97
  test('emitCoordinate prints every output and appends them to GITHUB_OUTPUT', () => {
103
98
  const file = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'xema-out-')), 'out');
104
99
  const lines = [];
105
- emitCoordinate({ image: 'i', content_tag: HASH, exists: 'false', entry: '{}' }, { env: { GITHUB_OUTPUT: file }, log: (l) => lines.push(l) });
106
- assert.equal(fs.readFileSync(file, 'utf8'), `image=i\ncontent_tag=${HASH}\nexists=false\nentry={}\n`);
107
- assert.match(lines.at(-1), /is missing — build/);
100
+ emitCoordinate({ image: 'i', content_tag: HASH, cross_repo_pins: '0', entry: '{}' }, { env: { GITHUB_OUTPUT: file }, log: (l) => lines.push(l) });
101
+ assert.equal(fs.readFileSync(file, 'utf8'), `image=i\ncontent_tag=${HASH}\ncross_repo_pins=0\nentry={}\n`);
102
+ assert.match(lines.at(-1), /0 cross-repo pins/);
108
103
  });
109
104
 
110
105
  test('a package whose exports map lacks ./package.json still resolves, as published 0.2.2 does', () => {
@@ -55,8 +55,9 @@
55
55
  // user and NODE_ENV: `load` runs in the builder, which is not what ships.
56
56
  // coordinate --image-name <name>
57
57
  // the image's content tag (computed ONLY by the repository's own
58
- // `xema-compute-image-tag`) and whether GHCR already serves it
59
- // (`composed-image-coordinate.mjs`; environment-driven, GITHUB_OUTPUT).
58
+ // `xema-compute-image-tag`) and its cross-repository pin count; pure, no
59
+ // registry probe or credential (`composed-image-coordinate.mjs`;
60
+ // environment-driven, GITHUB_OUTPUT).
60
61
  // smoke-refusal <image> [--member <biomeId>/<componentKey>]x2
61
62
  // boot the pushed image with a declaration the host must refuse
62
63
  // (`smoke-refusal.mjs`).
@@ -95,7 +96,7 @@ import { pathToFileURL } from 'node:url';
95
96
 
96
97
  import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
97
98
  import { composedCoordinate, emitCoordinate } from './composed-image-coordinate.mjs';
98
- import { describeErrorChain } from './registry-fetch.mjs';
99
+ import { describeErrorChain } from '@xemahq/release-policy/registry';
99
100
  import { syncComposedDockerfile } from './composed-image-dockerfile.mjs';
100
101
  import { IMAGE_BUILD_INPUTS_FILE, memberClosure, memberFilters, readMembers } from './composed-image-members.mjs';
101
102
  import { syncPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
@@ -458,7 +459,7 @@ const USAGE =
458
459
  ' members --write|--check [--manifests <glob>]… [--canonical-dockerfile <path>] [--cross-repo-manifest <path>]\n' +
459
460
  ' filters [--also <pkg>]… | dirs | pins <committed-lockfile> | index <commit> --build-source <repo> | load [--each]\n' +
460
461
  ' stage <dir> [--output <rel>]… [--also <pkg>]… | sync-injected | prisma-check | migrate <member> [--root <dir>]\n' +
461
- ' coordinate --image-name <name> (env: SHA REGISTRY REGISTRY_ORG REGISTRY_USER REGISTRY_TOKEN [TAG_ARGS_FILE])\n' +
462
+ ' coordinate --image-name <name> (env: SHA REGISTRY REGISTRY_ORG [TAG_ARGS_FILE])\n' +
462
463
  ' load-image <image>\n' +
463
464
  ' smoke-refusal <image> [--member <biomeId>/<componentKey>]x2\n' +
464
465
  ' dockerfile --write|--check --tooling-version <x.y.z> --copy <dir>… --cache-id <id> --build-source <repo>\n' +
@@ -558,9 +559,8 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
558
559
 
559
560
  }
560
561
  case 'coordinate':
561
- return composedCoordinate({ root, imageDir, imageName: options['image-name'] }).then((outputs) =>
562
- emitCoordinate(outputs, { log }),
563
- );
562
+ emitCoordinate(composedCoordinate({ root, imageDir, imageName: options['image-name'] }), { log });
563
+ return;
564
564
  case 'load-image':
565
565
  runImageLoad({ image: argument, log });
566
566
  return;
@@ -238,12 +238,12 @@ test('the bin runs through a symlink, as npm installs it, and exits non-zero on
238
238
  );
239
239
  });
240
240
 
241
- test('the new commands parse their options; coordinate is asynchronous and refuses without an image name', async () => {
241
+ test('the new commands parse their options; coordinate refuses without an image name', async () => {
242
242
  const parsed = parseArguments(['dockerfile', '--write', '--copy', 'a', '--copy', 'b', '--cache-id', 'c', '--tooling-version', '1.2.3']);
243
243
  assert.deepEqual(parsed.options.copy, ['a', 'b']);
244
244
  assert.equal(parsed.options['cache-id'], 'c');
245
245
  assert.deepEqual(parseArguments(['smoke-refusal', 'img', '--member', 'x/y', '--member', 'x/z']).options.member, ['x/y', 'x/z']);
246
- await assert.rejects(main(['coordinate'], { log() {} }), /--image-name/);
246
+ assert.throws(() => main(['coordinate'], { log() {} }), /--image-name/);
247
247
  assert.throws(() => main(['dockerfile', '--write'], { log() {} }), /exact x\.y\.z/);
248
248
  assert.throws(() => main(['smoke-refusal'], { log() {} }), /usage/);
249
249
  });
@@ -1,72 +0,0 @@
1
- // Is a content-addressed image coordinate already in GHCR? Shared by every
2
- // repository that plans a build from a content tag (service matrix, composed image).
3
- //
4
- // Only 200 and 404 are answers; anything else throws, so an unreachable or
5
- // unauthorised registry can never read as "missing" and trigger a rebuild.
6
-
7
- import { registryFetch } from './registry-fetch.mjs';
8
-
9
- const MANIFEST_ACCEPT = [
10
- 'application/vnd.oci.image.index.v1+json',
11
- 'application/vnd.oci.image.manifest.v1+json',
12
- 'application/vnd.docker.distribution.manifest.list.v2+json',
13
- 'application/vnd.docker.distribution.manifest.v2+json',
14
- ].join(', ');
15
-
16
- function assertNonEmpty(value, label) {
17
- if (typeof value !== 'string' || value.trim().length === 0) {
18
- throw new Error(`${label} is missing or empty`);
19
- }
20
- return value;
21
- }
22
-
23
- function basicAuthorization(user, token) {
24
- return `Basic ${Buffer.from(`${user}:${token}`, 'utf8').toString('base64')}`;
25
- }
26
-
27
- /**
28
- * Acquire a pull-only GHCR bearer token for one repository.
29
- *
30
- * The endpoint is deliberately fixed to the configured GHCR host. Following a
31
- * challenge-provided realm while carrying the long-lived credential would let
32
- * an untrusted/misconfigured registry redirect that credential elsewhere.
33
- */
34
- export async function acquireGhcrPullToken({ registry, repository, user, token, fetchImpl = fetch }) {
35
- if (registry !== 'ghcr.io') {
36
- throw new Error(`GHCR manifest check cannot send credentials to registry '${registry}'`);
37
- }
38
- assertNonEmpty(user, 'REGISTRY_USER');
39
- assertNonEmpty(token, 'REGISTRY_TOKEN');
40
- const url = new URL(`https://${registry}/token`);
41
- url.searchParams.set('service', registry);
42
- url.searchParams.set('scope', `repository:${repository}:pull`);
43
- const response = await registryFetch(fetchImpl, url, {
44
- method: 'GET',
45
- headers: { Authorization: basicAuthorization(user, token), Accept: 'application/json' },
46
- });
47
- if (response.status !== 200) {
48
- throw new Error(`${registry}: pull-token request for ${repository} returned HTTP ${response.status}`);
49
- }
50
- let body;
51
- try {
52
- body = await response.json();
53
- } catch {
54
- throw new Error(`${registry}: pull-token response for ${repository} was not JSON`);
55
- }
56
- if (typeof body?.token !== 'string' || body.token.length === 0) {
57
- throw new Error(`${registry}: pull-token response for ${repository} contained no token`);
58
- }
59
- return body.token;
60
- }
61
-
62
- /** Returns `true` only for a proven existing manifest and `false` only for 404. */
63
- export async function ghcrManifestExists({ registry, repository, contentHash, user, token, fetchImpl = fetch }) {
64
- const bearer = await acquireGhcrPullToken({ registry, repository, user, token, fetchImpl });
65
- const response = await registryFetch(fetchImpl, `https://${registry}/v2/${repository}/manifests/${contentHash}`, {
66
- method: 'HEAD',
67
- headers: { Authorization: `Bearer ${bearer}`, Accept: MANIFEST_ACCEPT },
68
- });
69
- if (response.status === 200) return true;
70
- if (response.status === 404) return false;
71
- throw new Error(`${registry}: manifest lookup for ${repository}:${contentHash} returned HTTP ${response.status}`);
72
- }
@@ -1,96 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import test from 'node:test';
3
-
4
- import { acquireGhcrPullToken, ghcrManifestExists } from './ghcr-manifest.mjs';
5
-
6
- const HASH = 'a'.repeat(64);
7
- const COMMON = { registry: 'ghcr.io', repository: 'xema-dev/alpha-api', user: 'runner', token: 'secret' };
8
-
9
- function response(status, body) {
10
- return {
11
- status,
12
- async json() {
13
- if (body instanceof Error) throw body;
14
- return body;
15
- },
16
- };
17
- }
18
-
19
- test('a pull token is requested with the exact repository scope', async () => {
20
- let captured;
21
- const token = await acquireGhcrPullToken({
22
- ...COMMON,
23
- fetchImpl: async (url, init) => {
24
- captured = { url: String(url), init };
25
- return response(200, { token: 'bearer-token' });
26
- },
27
- });
28
- assert.equal(token, 'bearer-token');
29
- const url = new URL(captured.url);
30
- assert.equal(url.origin + url.pathname, 'https://ghcr.io/token');
31
- assert.equal(url.searchParams.get('service'), 'ghcr.io');
32
- assert.equal(url.searchParams.get('scope'), 'repository:xema-dev/alpha-api:pull');
33
- assert.equal(captured.init.method, 'GET');
34
- assert.match(captured.init.headers.Authorization, /^Basic /);
35
- });
36
-
37
- test('the long-lived credential can only be sent to GHCR', async () => {
38
- let called = false;
39
- await assert.rejects(
40
- acquireGhcrPullToken({
41
- ...COMMON,
42
- registry: 'registry.example.com',
43
- fetchImpl: async () => {
44
- called = true;
45
- return response(200, { token: 'bearer-token' });
46
- },
47
- }),
48
- /cannot send credentials/,
49
- );
50
- assert.equal(called, false);
51
- });
52
-
53
- test('a missing credential is refused before any request', async () => {
54
- await assert.rejects(acquireGhcrPullToken({ ...COMMON, token: '', fetchImpl: async () => assert.fail('requested') }), /REGISTRY_TOKEN/);
55
- });
56
-
57
- test('token acquisition fails closed on every uncertain response', async () => {
58
- for (const status of [401, 403, 404, 429, 500]) {
59
- await assert.rejects(acquireGhcrPullToken({ ...COMMON, fetchImpl: async () => response(status, {}) }), new RegExp(`HTTP ${status}`));
60
- }
61
- for (const body of [{}, { token: '' }, new Error('invalid json')]) {
62
- await assert.rejects(acquireGhcrPullToken({ ...COMMON, fetchImpl: async () => response(200, body) }), /token response/);
63
- }
64
- });
65
-
66
- test('only authenticated 200 and 404 are classified', async () => {
67
- for (const [status, expected] of [[200, true], [404, false]]) {
68
- const calls = [];
69
- const exists = await ghcrManifestExists({
70
- ...COMMON,
71
- contentHash: HASH,
72
- fetchImpl: async (url, init) => {
73
- calls.push({ url: String(url), init });
74
- return calls.length === 1 ? response(200, { token: 'bearer-token' }) : response(status, {});
75
- },
76
- });
77
- assert.equal(exists, expected);
78
- assert.equal(calls[1].url, `https://ghcr.io/v2/xema-dev/alpha-api/manifests/${HASH}`);
79
- assert.equal(calls[1].init.method, 'HEAD');
80
- assert.equal(calls[1].init.headers.Authorization, 'Bearer bearer-token');
81
- }
82
- for (const status of [401, 403, 429, 500]) {
83
- let count = 0;
84
- await assert.rejects(
85
- ghcrManifestExists({
86
- ...COMMON,
87
- contentHash: HASH,
88
- fetchImpl: async () => {
89
- count += 1;
90
- return count === 1 ? response(200, { token: 'bearer-token' }) : response(status, {});
91
- },
92
- }),
93
- new RegExp(`HTTP ${status}`),
94
- );
95
- }
96
- });
@@ -1,82 +0,0 @@
1
- // The one place a registry request is sent and a network failure is named.
2
- //
3
- // Node's fetch rejects with `TypeError: fetch failed` and keeps the real reason
4
- // (ENOTFOUND, ECONNREFUSED, a TLS error, a timeout) in `error.cause`. Letting that
5
- // escape as a bare message hides which request failed and why.
6
-
7
- /** The URL with credentials and every query value removed; only the query keys remain. */
8
- export function redactUrl(input) {
9
- let url;
10
- try {
11
- url = new URL(String(input));
12
- } catch {
13
- return '<unparseable url>';
14
- }
15
- url.username = '';
16
- url.password = '';
17
- const keys = [...new Set(url.searchParams.keys())];
18
- url.search = '';
19
- url.hash = '';
20
- return keys.length > 0 ? `${url.href}?${keys.map((key) => `${key}=<redacted>`).join('&')}` : url.href;
21
- }
22
-
23
- function describeOne(error) {
24
- if (!(error instanceof Error)) return String(error);
25
- const details = ['code', 'errno', 'syscall', 'hostname', 'address', 'port']
26
- .filter((key) => error[key] !== undefined)
27
- .map((key) => `${key}=${error[key]}`);
28
- const head = `${error.name}: ${error.message}`;
29
- return details.length > 0 ? `${head} (${details.join(', ')})` : head;
30
- }
31
-
32
- /** One line per link of the `cause` chain, outermost first. Bounded against cycles. */
33
- export function describeErrorChain(error) {
34
- const lines = [];
35
- const seen = new Set();
36
- for (let current = error; current !== undefined && !seen.has(current) && lines.length < 10; ) {
37
- lines.push(describeOne(current));
38
- if (!(current instanceof Error)) break;
39
- seen.add(current);
40
- current = current.cause;
41
- }
42
- return lines;
43
- }
44
-
45
- /** Total attempts per request, the first included. */
46
- export const MAX_ATTEMPTS = 3;
47
- /** Fixed wait in milliseconds after failed attempt n (1-based); deterministic, no jitter. */
48
- export const RETRY_BACKOFF_MS = [1000, 3000];
49
-
50
- const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
51
-
52
- function causeCode(error) {
53
- for (let current = error, depth = 0; current instanceof Error && depth < 10; current = current.cause, depth += 1) {
54
- if (current.code !== undefined) return String(current.code);
55
- }
56
- return 'unknown';
57
- }
58
-
59
- /**
60
- * Send one registry request. Only a transport failure (the fetch promise rejects) is
61
- * retried (an abort, `AbortError` or an aborted `init.signal`, is rethrown at once), up to MAX_ATTEMPTS with RETRY_BACKOFF_MS between attempts; any HTTP response,
62
- * whatever its status, is returned as is. The final failure is thrown naming the method,
63
- * the redacted URL and the attempt count, with the last error as `cause`. Headers are
64
- * never reported. `options.sleep` replaces the real wait (for tests).
65
- */
66
- export async function registryFetch(fetchImpl, url, init, { sleep = realSleep } = {}) {
67
- const method = init?.method ?? 'GET';
68
- for (let attempt = 1; ; attempt += 1) {
69
- try {
70
- return await fetchImpl(url, init);
71
- } catch (error) {
72
- if (error?.name === 'AbortError' || init?.signal?.aborted === true) throw error;
73
- if (attempt >= MAX_ATTEMPTS) {
74
- throw new Error(`${method} ${redactUrl(url)} failed after ${attempt} attempts`, { cause: error });
75
- }
76
- process.stderr.write(
77
- `registry request failed (attempt ${attempt} of ${MAX_ATTEMPTS}): ${method} ${redactUrl(url)} cause=${causeCode(error)}; retrying\n`,
78
- );
79
- await sleep(RETRY_BACKOFF_MS[attempt - 1]);
80
- }
81
- }
82
- }
@@ -1,151 +0,0 @@
1
- import assert from 'node:assert/strict';
2
- import { spawnSync } from 'node:child_process';
3
- import fs from 'node:fs';
4
- import net from 'node:net';
5
- import os from 'node:os';
6
- import path from 'node:path';
7
- import test from 'node:test';
8
- import { fileURLToPath } from 'node:url';
9
-
10
- import { MAX_ATTEMPTS, RETRY_BACKOFF_MS, describeErrorChain, redactUrl, registryFetch } from './registry-fetch.mjs';
11
-
12
- const BIN = path.join(path.dirname(fileURLToPath(import.meta.url)), 'composed-image.mjs');
13
-
14
- async function closedPort() {
15
- const server = net.createServer();
16
- await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
17
- const { port } = server.address();
18
- await new Promise((resolve) => server.close(resolve));
19
- return port;
20
- }
21
-
22
- test('redactUrl drops credentials, query values and the fragment', () => {
23
- const out = redactUrl('https://user:pw@ghcr.io/token?service=ghcr.io&scope=secret-scope#frag');
24
- assert.equal(out, 'https://ghcr.io/token?service=<redacted>&scope=<redacted>');
25
- assert.equal(redactUrl('not a url'), '<unparseable url>');
26
- });
27
-
28
- test('a refused connection names the method, the URL and the cause code, never the headers', async () => {
29
- const port = await closedPort();
30
- const url = `http://127.0.0.1:${port}/v2/org/img/manifests/abc?access_token=SECRETQ`;
31
- const error = await registryFetch(fetch, url, { method: 'HEAD', headers: { Authorization: 'Bearer SECRETH' } }, { sleep: async () => {} }).then(
32
- () => assert.fail('expected the request to fail'),
33
- (caught) => caught,
34
- );
35
- const text = [error.message, ...describeErrorChain(error)].join('\n');
36
- assert.match(error.message, new RegExp(`^HEAD http://127\\.0\\.0\\.1:${port}/v2/org/img/manifests/abc\\?access_token=<redacted> failed after 3 attempts$`));
37
- assert.match(text, /ECONNREFUSED/);
38
- assert.doesNotMatch(text, /SECRETQ|SECRETH/);
39
- });
40
-
41
- test('describeErrorChain walks the cause chain and survives a cycle', () => {
42
- const inner = Object.assign(new Error('getaddrinfo ENOTFOUND ghcr.io'), { code: 'ENOTFOUND', hostname: 'ghcr.io' });
43
- const outer = new TypeError('fetch failed', { cause: inner });
44
- inner.cause = outer;
45
- const lines = describeErrorChain(outer);
46
- assert.equal(lines.length, 2);
47
- assert.match(lines[1], /code=ENOTFOUND, hostname=ghcr\.io/);
48
- });
49
-
50
- test('the CLI prints the failing request and the cause chain to stderr and exits 1', () => {
51
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'fetch-cause-'));
52
- try {
53
- const hashDir = path.join(root, 'node_modules', '@xemahq', 'distribution-source-hash');
54
- fs.mkdirSync(hashDir, { recursive: true });
55
- fs.writeFileSync(
56
- path.join(hashDir, 'package.json'),
57
- JSON.stringify({ name: '@xemahq/distribution-source-hash', bin: { 'xema-compute-image-tag': 'bin.js' } }),
58
- );
59
- fs.writeFileSync(path.join(hashDir, 'bin.js'), `console.log('${'a'.repeat(64)}');\n`);
60
- fs.writeFileSync(path.join(root, 'package.json'), '{}');
61
- const preload = path.join(root, 'preload.mjs');
62
- fs.writeFileSync(
63
- preload,
64
- `globalThis.fetch = async () => { throw new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo ENOTFOUND ghcr.io'), { code: 'ENOTFOUND', hostname: 'ghcr.io' }) }); };\n`,
65
- );
66
- const result = spawnSync(process.execPath, ['--import', preload, BIN, 'coordinate', '--image-name', 'img'], {
67
- cwd: root,
68
- encoding: 'utf8',
69
- env: {
70
- PATH: process.env.PATH,
71
- SHA: 'b'.repeat(40),
72
- REGISTRY: 'ghcr.io',
73
- REGISTRY_ORG: 'org',
74
- REGISTRY_USER: 'u',
75
- REGISTRY_TOKEN: 'TOPSECRET',
76
- },
77
- });
78
- assert.equal(result.status, 1);
79
- assert.match(result.stderr, /GET https:\/\/ghcr\.io\/token\?service=<redacted>&scope=<redacted> failed after 3 attempts/);
80
- assert.match(result.stderr, /caused by TypeError: fetch failed/);
81
- assert.match(result.stderr, /caused by Error: getaddrinfo ENOTFOUND ghcr\.io \(code=ENOTFOUND, hostname=ghcr\.io\)/);
82
- assert.doesNotMatch(result.stderr, /TOPSECRET/);
83
- } finally {
84
- fs.rmSync(root, { recursive: true, force: true });
85
- }
86
- });
87
-
88
- const transportError = () =>
89
- new TypeError('fetch failed', { cause: Object.assign(new Error('getaddrinfo EAI_AGAIN ghcr.io'), { code: 'EAI_AGAIN' }) });
90
-
91
- test('a transient transport failure is retried once and then returns the response', async () => {
92
- let calls = 0;
93
- const waits = [];
94
- const response = new Response('ok', { status: 200 });
95
- const fetchImpl = async () => {
96
- calls += 1;
97
- if (calls === 1) throw transportError();
98
- return response;
99
- };
100
- const result = await registryFetch(fetchImpl, 'https://ghcr.io/token?scope=x', { method: 'GET' }, { sleep: async (ms) => waits.push(ms) });
101
- assert.equal(calls, 2);
102
- assert.equal(result, response);
103
- assert.deepEqual(waits, [RETRY_BACKOFF_MS[0]]);
104
- });
105
-
106
- test('three transport failures throw naming 3 attempts and keep the cause chain', async () => {
107
- let calls = 0;
108
- const waits = [];
109
- const fetchImpl = async () => {
110
- calls += 1;
111
- throw transportError();
112
- };
113
- const error = await registryFetch(fetchImpl, 'https://ghcr.io/v2/o/i/manifests/a', { method: 'HEAD' }, { sleep: async (ms) => waits.push(ms) }).then(
114
- () => assert.fail('expected failure'),
115
- (caught) => caught,
116
- );
117
- assert.equal(calls, MAX_ATTEMPTS);
118
- assert.deepEqual(waits, [1000, 3000]);
119
- assert.equal(error.message, 'HEAD https://ghcr.io/v2/o/i/manifests/a failed after 3 attempts');
120
- assert.match(describeErrorChain(error).join('\n'), /fetch failed[\s\S]*code=EAI_AGAIN/);
121
- });
122
-
123
- test('an HTTP error status is returned after one call, never retried', async () => {
124
- let calls = 0;
125
- const fetchImpl = async () => {
126
- calls += 1;
127
- return new Response('down', { status: 503 });
128
- };
129
- const result = await registryFetch(fetchImpl, 'https://ghcr.io/token', { method: 'GET' }, { sleep: async () => assert.fail('must not sleep') });
130
- assert.equal(calls, 1);
131
- assert.equal(result.status, 503);
132
- });
133
-
134
- test('an abort is rethrown at once, never retried', async () => {
135
- let calls = 0;
136
- const controller = new AbortController();
137
- controller.abort();
138
- const fetchImpl = async () => {
139
- calls += 1;
140
- throw new Error('request failed');
141
- };
142
- const error = await registryFetch(fetchImpl, 'https://ghcr.io/token', { method: 'GET', signal: controller.signal }, { sleep: async () => assert.fail('must not sleep') }).then(
143
- () => assert.fail('expected failure'),
144
- (caught) => caught,
145
- );
146
- assert.equal(calls, 1);
147
- assert.equal(error.message, 'request failed');
148
- let named = 0;
149
- await registryFetch(async () => { named += 1; throw new DOMException('aborted', 'AbortError'); }, 'https://ghcr.io/token', {}, { sleep: async () => assert.fail('must not sleep') }).catch(() => {});
150
- assert.equal(named, 1);
151
- });