@xemahq/repo-build-tooling 0.12.0 → 0.14.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/README.md CHANGED
@@ -33,7 +33,7 @@ pnpm exec xema-scrub-swagger-paths dist
33
33
  | `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
34
34
  | `xema-check-workspace-ranges` | Fails on a `workspace:` range the local package does not satisfy |
35
35
 
36
- `xema-composed-image members --write|--check` accepts `--manifests <glob>` (repeatable, default `**/xema-biome.json`), `--canonical-dockerfile <path>` and `--cross-repo-manifest <path>`. `xema-composed-image dockerfile` requires `--tooling-version`, `--copy`, `--cache-id` and `--build-source`. `dockerfile --prebuilt` renders the Dockerfile and `Dockerfile.dockerignore` of an image assembled from `stage <dir> [--output <rel>]… [--also <pkg>]…` output (requires `--tooling-version`, `--cache-id`, `--build-source`; `--copy` and `--stage-dir` optional); `load --each` loads every member in its own process. `stage` and `filters` accept `--also <workspace package name>` (repeatable): the package's closure is built and staged (built outputs only, no Prisma checks) alongside the members; an unknown name is refused. Run `xema-composed-image` with no arguments for the full usage.
36
+ `xema-composed-image members --write|--check` accepts `--manifests <glob>` (repeatable, default `**/xema-biome.json`), `--canonical-dockerfile <path>` and `--cross-repo-manifest <path>`. `xema-composed-image dockerfile` requires `--copy`, `--cache-id` and `--build-source`; it pins the version of this tool itself, and there is no flag for it (`--tooling-version` is refused as an unknown option). `dockerfile --prebuilt` renders the Dockerfile and `Dockerfile.dockerignore` of an image assembled from `stage <dir> [--output <rel>]… [--also <pkg>]…` output (requires `--cache-id`, `--build-source`; `--copy` and `--stage-dir` optional); `load --each` loads every member in its own process. `stage` and `filters` accept `--also <workspace package name>` (repeatable): the package's closure is built and staged (built outputs only, no Prisma checks) alongside the members; an unknown name is refused. Run `xema-composed-image` with no arguments for the full usage.
37
37
 
38
38
  Every step refuses rather than skips. The package has no dependencies.
39
39
 
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.14.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:003d770229d6ab378573068cfaf5b02e89b6bbd632dcfce3ccb17125d94322e1"
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', () => {
@@ -5,7 +5,7 @@
5
5
  // build context carries (`--copy`, repeatable), its pnpm cache id (`--cache-id`),
6
6
  // the repository its member subjects name in an edition's image lock
7
7
  // (`--build-source`) and the EXACT version of this tool the builder installs
8
- // (`--tooling-version`). The tool is pinned exactly so the image directory, which
8
+ // (its own package version, never a flag). The tool is pinned exactly so the image directory, which
9
9
  // the content tag hashes, changes whenever the tool does.
10
10
  //
11
11
  // NOTHING OF THE TOOL SHIPS: it is installed globally in the builder stage only,
@@ -17,19 +17,16 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
17
17
  import { dirname, join, posix } from 'node:path';
18
18
 
19
19
  import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
20
+ import { toolVersion } from './composed-image-stage.mjs';
20
21
 
21
22
  const TOKEN_REFERENCE = /_authToken\b|\$\{(?:NPM_TOKEN|GH_PACKAGES_TOKEN)\}/;
22
- const VERSION = /^\d+\.\d+\.\d+$/;
23
23
  const DIRECTORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*\/?$/;
24
24
  const CACHE_ID = /^[a-z0-9][a-z0-9-]*$/;
25
25
  const REPOSITORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
26
26
  export const NODE_BASE =
27
27
  'mirror.gcr.io/library/node:22-bookworm-slim@sha256:d649c27dae7ba0137b3cef5dd75baa422c08dc3d9e3fc0c23dfb172dc3cc6436';
28
28
 
29
- function validate({ toolingVersion, copy, cacheId, buildSource }) {
30
- if (!VERSION.test(toolingVersion ?? '')) {
31
- throw new Error(`--tooling-version must be an exact x.y.z version, got "${toolingVersion}"`);
32
- }
29
+ function validate({ copy, cacheId, buildSource }) {
33
30
  if (!Array.isArray(copy) || copy.length === 0) throw new Error('--copy needs at least one directory');
34
31
  for (const directory of copy) {
35
32
  if (!DIRECTORY.test(directory) || directory.startsWith('/') || directory.split('/').includes('..')) {
@@ -46,9 +43,10 @@ export function npmrcUsesToken(npmrcText) {
46
43
  }
47
44
 
48
45
  /** The Dockerfile text. `usesToken` mounts the `npm_token` build secret; derive it with `npmrcUsesToken`. `imageDir` is where the repository keeps the image (and so where this is written). */
49
- export function renderComposedDockerfile({ toolingVersion, copy, cacheId, buildSource, usesToken, imageDir = COMPOSED_IMAGE_DIR }) {
46
+ export function renderComposedDockerfile({ copy, cacheId, buildSource, usesToken, imageDir = COMPOSED_IMAGE_DIR }) {
50
47
  if (typeof usesToken !== 'boolean') throw new Error('usesToken must be a boolean derived from .npmrc');
51
- validate({ toolingVersion, copy, cacheId, buildSource });
48
+ validate({ copy, cacheId, buildSource });
49
+ const toolingVersion = toolVersion();
52
50
  const dirFlag = imageDir === COMPOSED_IMAGE_DIR ? '' : ` --image-dir ${imageDir}`;
53
51
  const tool = (command) => `xema-composed-image ${command}${dirFlag}`;
54
52
  const copies = [...new Set([...copy.map((d) => posix.normalize(`${d}/`)), posix.normalize(`${imageDir}/`)])]
@@ -4,10 +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 { toolVersion } from './composed-image-stage.mjs';
8
+
7
9
  import { npmrcUsesToken, renderComposedDockerfile, syncComposedDockerfile } from './composed-image-dockerfile.mjs';
8
10
 
9
11
  const OPTIONS = {
10
- toolingVersion: '0.9.0',
11
12
  copy: ['packages', 'tooling/dev/', 'biomes'],
12
13
  cacheId: 'alpha-composed-process',
13
14
  buildSource: 'alpha',
@@ -16,7 +17,7 @@ const OPTIONS = {
16
17
 
17
18
  test('the template carries every parameter and no per-repository literal', () => {
18
19
  const text = renderComposedDockerfile(OPTIONS);
19
- assert.match(text, /npm install --global --ignore-scripts @xemahq\/repo-build-tooling@0\.9\.0\n/);
20
+ assert.ok(text.includes(`npm install --global --ignore-scripts @xemahq/repo-build-tooling@${toolVersion()}\n`));
20
21
  assert.match(text, /^COPY packages\/ \.\/packages\/\nCOPY tooling\/dev\/ \.\/tooling\/dev\/\nCOPY biomes\/ \.\/biomes\/\nCOPY infra\/composed-process\/ \.\/infra\/composed-process\/$/m);
21
22
  assert.match(text, /id=pnpm-store-alpha-composed-process,/);
22
23
  assert.match(text, /id=pnpm-metadata-alpha-composed-process,/);
@@ -39,9 +40,7 @@ test('nothing of the tool reaches the final stage; load runs in the builder as n
39
40
  assert.match(final, /CMD \["node", "\/monorepo\/xema-composed-process"\]/);
40
41
  });
41
42
 
42
- test('the tool version, the directories and the ids are validated', () => {
43
- assert.throws(() => renderComposedDockerfile({ ...OPTIONS, toolingVersion: '^0.9.0' }), /exact x\.y\.z/);
44
- assert.throws(() => renderComposedDockerfile({ ...OPTIONS, toolingVersion: undefined }), /exact x\.y\.z/);
43
+ test('the directories and the ids are validated', () => {
45
44
  assert.throws(() => renderComposedDockerfile({ ...OPTIONS, copy: [] }), /at least one/);
46
45
  for (const bad of ['../x', '/abs', 'a b', 'a;b']) assert.throws(() => renderComposedDockerfile({ ...OPTIONS, copy: [bad] }), /repository-relative/);
47
46
  assert.throws(() => renderComposedDockerfile({ ...OPTIONS, cacheId: 'Bad_Id' }), /--cache-id/);
@@ -56,7 +55,7 @@ test('--write renders and --check refuses a stale or missing file', () => {
56
55
  assert.throws(() => syncComposedDockerfile({ mode: 'check', root, ...OPTIONS }), /is stale/);
57
56
  assert.equal(syncComposedDockerfile({ mode: 'write', root, ...OPTIONS }), 'infra/composed-process/Dockerfile');
58
57
  syncComposedDockerfile({ mode: 'check', root, ...OPTIONS });
59
- assert.throws(() => syncComposedDockerfile({ mode: 'check', root, ...OPTIONS, toolingVersion: '0.9.1' }), /is stale/);
58
+ assert.throws(() => syncComposedDockerfile({ mode: 'check', root, ...OPTIONS, cacheId: 'beta-composed-process' }), /is stale/);
60
59
  assert.throws(() => syncComposedDockerfile({ mode: 'sideways', root, ...OPTIONS }), /write or check/);
61
60
  });
62
61
 
@@ -7,17 +7,16 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
7
7
  import { dirname, join, posix } from 'node:path';
8
8
 
9
9
  import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
10
+ import { toolVersion } from './composed-image-stage.mjs';
10
11
  import { NODE_BASE, npmrcUsesToken } from './composed-image-dockerfile.mjs';
11
12
  import { memberClosure, memberFilters, memberManifests } from './composed-image-members.mjs';
12
13
 
13
14
  export const DEFAULT_STAGE_DIR = '.xema-stage';
14
- const VERSION = /^\d+\.\d+\.\d+$/;
15
15
  const DIRECTORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*\/?$/;
16
16
  const CACHE_ID = /^[a-z0-9][a-z0-9-]*$/;
17
17
  const REPOSITORY = /^[A-Za-z0-9._-]+(\/[A-Za-z0-9._-]+)*$/;
18
18
 
19
- function validate({ toolingVersion, copy, cacheId, buildSource, stageDir }) {
20
- if (!VERSION.test(toolingVersion ?? '')) throw new Error(`--tooling-version must be an exact x.y.z version, got "${toolingVersion}"`);
19
+ function validate({ copy, cacheId, buildSource, stageDir }) {
21
20
  for (const directory of [...(copy ?? []), stageDir]) {
22
21
  if (!DIRECTORY.test(directory) || directory.startsWith('/') || directory.split('/').includes('..')) {
23
22
  throw new Error(`"${directory}" is not a repository-relative directory`);
@@ -69,7 +68,6 @@ const parts = (sources) => sources.map((source) => ` ${source} \\`).join('\n'
69
68
  export function renderPrebuiltDockerfile({
70
69
  root = process.cwd(),
71
70
  imageDir = COMPOSED_IMAGE_DIR,
72
- toolingVersion,
73
71
  copy = [],
74
72
  cacheId,
75
73
  buildSource,
@@ -78,7 +76,8 @@ export function renderPrebuiltDockerfile({
78
76
  exec,
79
77
  }) {
80
78
  if (typeof usesToken !== 'boolean') throw new Error('usesToken must be a boolean derived from .npmrc');
81
- validate({ toolingVersion, copy, cacheId, buildSource, stageDir });
79
+ validate({ copy, cacheId, buildSource, stageDir });
80
+ const toolingVersion = toolVersion();
82
81
  const workspaceFile = join(root, 'pnpm-workspace.yaml');
83
82
  const built = existsSync(workspaceFile) ? onlyBuiltDependencies(readFileSync(workspaceFile, 'utf8')) : [];
84
83
  if (built.length === 0) {
@@ -8,7 +8,7 @@ import { parseArguments } from './composed-image.mjs';
8
8
  import { CLOSURE, fakePnpm, workspace } from './composed-image-fixture.test.mjs';
9
9
 
10
10
  function options(root, extra = {}) {
11
- return { root, toolingVersion: '0.11.0', copy: ['tooling/dev'], cacheId: 'alpha-composed-process', buildSource: 'alpha', usesToken: true, exec: fakePnpm(root), ...extra };
11
+ return { root, copy: ['tooling/dev'], cacheId: 'alpha-composed-process', buildSource: 'alpha', usesToken: true, exec: fakePnpm(root), ...extra };
12
12
  }
13
13
 
14
14
  test('the closure comes from pnpm ls with the members filters, and the filters are literal per member', () => {
@@ -55,15 +55,16 @@
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`).
63
- // dockerfile --write|--check --tooling-version <x.y.z> --copy <dir>… --cache-id <id> --build-source <repo>
64
+ // dockerfile --write|--check --copy <dir>… --cache-id <id> --build-source <repo>
64
65
  // render `<image-dir>/Dockerfile` from the package's template
65
66
  // (`composed-image-dockerfile.mjs`).
66
- // dockerfile --prebuilt --write|--check --tooling-version <x.y.z> --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]
67
+ // dockerfile --prebuilt --write|--check --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]
67
68
  // render the Dockerfile that ASSEMBLES the image from `stage` output, and its
68
69
  // `Dockerfile.dockerignore` (`composed-image-prebuilt-dockerfile.mjs`).
69
70
  //
@@ -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,16 +459,16 @@ 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
- ' dockerfile --write|--check --tooling-version <x.y.z> --copy <dir>… --cache-id <id> --build-source <repo>\n' +
465
- ' dockerfile --prebuilt --write|--check --tooling-version <x.y.z> --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]';
465
+ ' dockerfile --write|--check --copy <dir>… --cache-id <id> --build-source <repo>\n' +
466
+ ' dockerfile --prebuilt --write|--check --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]';
466
467
 
467
468
  const REPEATABLE = new Set(['manifests', 'copy', 'member', 'output', 'also']);
468
469
  const BOOLEAN = new Set(['prebuilt', 'each']);
469
470
  const VALUED = new Set(['root', 'image-dir', 'manifests', 'canonical-dockerfile', 'cross-repo-manifest', 'build-source',
470
- 'image-name', 'tooling-version', 'cache-id', 'copy', 'member', 'stage-dir', 'output', 'also',
471
+ 'image-name', 'cache-id', 'copy', 'member', 'stage-dir', 'output', 'also',
471
472
  ]);
472
473
 
473
474
  export function parseArguments(argv) {
@@ -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;
@@ -573,7 +573,6 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
573
573
  mode: options.mode,
574
574
  root,
575
575
  imageDir,
576
- toolingVersion: options['tooling-version'],
577
576
  copy: options.copy,
578
577
  cacheId: options['cache-id'],
579
578
  buildSource: options['build-source'],
@@ -586,7 +585,6 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
586
585
  mode: options.mode,
587
586
  root,
588
587
  imageDir,
589
- toolingVersion: options['tooling-version'],
590
588
  copy: options.copy,
591
589
  cacheId: options['cache-id'],
592
590
  buildSource: options['build-source'],
@@ -238,13 +238,14 @@ 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 () => {
242
- const parsed = parseArguments(['dockerfile', '--write', '--copy', 'a', '--copy', 'b', '--cache-id', 'c', '--tooling-version', '1.2.3']);
241
+ test('the new commands parse their options; coordinate refuses without an image name', async () => {
242
+ const parsed = parseArguments(['dockerfile', '--write', '--copy', 'a', '--copy', 'b', '--cache-id', 'c']);
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/);
247
- assert.throws(() => main(['dockerfile', '--write'], { log() {} }), /exact x\.y\.z/);
246
+ assert.throws(() => main(['coordinate'], { log() {} }), /--image-name/);
247
+ assert.throws(() => main(['dockerfile', '--write'], { log() {} }), /at least one/);
248
+ assert.throws(() => parseArguments(['dockerfile', '--write', '--tooling-version', '1.2.3']), /unknown option --tooling-version/);
248
249
  assert.throws(() => main(['smoke-refusal'], { log() {} }), /usage/);
249
250
  });
250
251
 
@@ -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
- });