@xemahq/repo-build-tooling 0.11.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/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>]…` output (requires `--tooling-version`, `--cache-id`, `--build-source`; `--copy` and `--stage-dir` optional); `load --each` loads every member in its own process. 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 `--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.
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.11.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:8a0729af873e6dee7bc38a38408c3f0f57447884e6461ad36e06e8165cf28f4b"
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', () => {
@@ -43,10 +43,15 @@ function packageName(root, directory) {
43
43
  return manifest.name;
44
44
  }
45
45
 
46
- /** The pnpm `--filter <package>...` arguments covering every member's closure. */
46
+ /**
47
+ * The pnpm `--filter <package>...` arguments covering every member's closure, then
48
+ * each `also` package's (a workspace package name); a name a member already
49
+ * selected is listed once.
50
+ */
47
51
  export function memberFilters(options = {}) {
48
52
  const root = options.root ?? process.cwd();
49
- return readMembers(options).map(([, directory]) => `--filter ${packageName(root, directory)}...`);
53
+ const names = readMembers(options).map(([, directory]) => packageName(root, directory));
54
+ return [...new Set([...names, ...(options.also ?? [])])].map((name) => `--filter ${name}...`);
50
55
  }
51
56
 
52
57
 
@@ -66,21 +71,30 @@ export function memberManifests({ root = process.cwd(), imageDir = COMPOSED_IMAG
66
71
 
67
72
  const defaultExec = (file, args, { cwd }) => execFileSync(file, args, { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
68
73
 
69
- /**
70
- * The workspace packages the members' filters select (the members and everything
71
- * they depend on inside the workspace): repository-relative directories, sorted.
72
- * `exec(file, args, { cwd })` returns stdout; injectable for tests.
73
- */
74
- export function memberClosure({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, exec = defaultExec } = {}) {
75
- const words = memberFilters({ root, imageDir }).flatMap((filter) => filter.split(' '));
76
- const listing = exec('pnpm', ['ls', '-r', '--depth', '-1', '--json', ...words], { cwd: root });
74
+ function parseListing(listing) {
77
75
  let packages;
78
76
  try {
79
77
  packages = JSON.parse(listing);
80
78
  } catch (error) {
81
79
  throw new Error(`pnpm ls did not print JSON: ${error.message}`);
82
80
  }
83
- if (!Array.isArray(packages) || packages.length === 0) throw new Error('pnpm ls selected no workspace package');
81
+ if (!Array.isArray(packages)) throw new Error('pnpm ls did not print a JSON array');
82
+ return packages;
83
+ }
84
+
85
+ /**
86
+ * The workspace packages the members' filters select (the members and everything
87
+ * they depend on inside the workspace): repository-relative directories, sorted.
88
+ * `exec(file, args, { cwd })` returns stdout; injectable for tests.
89
+ */
90
+ export function memberClosure({ root = process.cwd(), imageDir = COMPOSED_IMAGE_DIR, exec = defaultExec, also = [] } = {}) {
91
+ for (const name of also) {
92
+ const found = parseListing(exec('pnpm', ['ls', '-r', '--depth', '-1', '--json', '--filter', name], { cwd: root }));
93
+ if (!found.some((pkg) => pkg?.name === name)) throw new Error(`--also ${name}: no workspace package has that name`);
94
+ }
95
+ const words = memberFilters({ root, imageDir, also }).flatMap((filter) => filter.split(' '));
96
+ const packages = parseListing(exec('pnpm', ['ls', '-r', '--depth', '-1', '--json', ...words], { cwd: root }));
97
+ if (packages.length === 0) throw new Error('pnpm ls selected no workspace package');
84
98
  const real = realpathSync(root);
85
99
  const directories = new Set();
86
100
  for (const pkg of packages) {
@@ -123,7 +123,9 @@ export function renderPrebuiltDockerfile({
123
123
  # assemble source of the closure + the staged outputs, the injected copies synced to
124
124
  # their \`files\`, then the proofs on the target arch: Prisma clients run, the
125
125
  # member index is written, every member loads ALONE as \`node\`, production.
126
- # (final) \`/monorepo\` from assemble. Nothing of the tool ships.
126
+ # (final) \`/monorepo\` of assemble, shipped as THREE layers so a commit that changes only
127
+ # members' code re-pushes only the layers that changed (see the final stage).
128
+ # Nothing of the tool ships.
127
129
  #
128
130
  # The \`--filter <package>...\` words below are LITERAL: the content hash of this image reads
129
131
  # them to narrow the lockfile to the members' closures.
@@ -168,9 +170,22 @@ USER node
168
170
  RUN ${toolMount} \\
169
171
  NODE_ENV=production node ${node} load --each${dirFlag}
170
172
 
173
+ # The final stage is layered by how often each part changes, so a registry and a node
174
+ # already holding the stable layers fetch only the rest. The three copies overlay to
175
+ # exactly assemble's /monorepo (proven by a tree diff of both images).
176
+ # 1 deps root manifests, the external .pnpm store, and the injected stubs that hold
177
+ # only package.json. Stable while the lockfile and the closure's package.json
178
+ # files are unchanged.
179
+ # 2 assemble members, dist, Prisma clients, member index, migrate script: everything
180
+ # but the .pnpm store, whose packages layer 1 already carries.
181
+ # 3 injected the workspace copies \`node_modules/.pnpm/*@file+*\`: \`sync-injected\` fills
182
+ # them from the members' files, so they change with every commit. \`--parents\`
183
+ # keeps their paths under /.
171
184
  FROM base
172
185
  WORKDIR /monorepo
173
- COPY --from=assemble /monorepo /monorepo
186
+ COPY --from=deps /monorepo /monorepo
187
+ COPY --from=assemble --exclude=node_modules/.pnpm /monorepo /monorepo
188
+ COPY --from=assemble --parents /monorepo/node_modules/.pnpm/*@file+* /
174
189
  ENV NODE_ENV=production
175
190
  # Set by the IMAGE, never by a deployment (kernel \`COMPOSED_PROCESS_ENV.MemberIndex\`).
176
191
  ENV XEMA_COMPOSED_MEMBER_INDEX=/monorepo/xema-composed-member-index.json
@@ -39,7 +39,7 @@ test('the tool is only bind-mounted, and the final stage carries nothing of it',
39
39
  assert.doesNotMatch(dockerfile, /\bnpm install|COPY \.xema-stage\/tool/);
40
40
  const final = dockerfile.split('\nFROM base\n')[1];
41
41
  assert.doesNotMatch(final, /xema-composed-image|repo-build-tooling|npm install|\/opt\//);
42
- assert.match(final, /COPY --from=assemble \/monorepo \/monorepo/);
42
+ assert.doesNotMatch(final, /COPY --from=assemble \/monorepo \/monorepo/);
43
43
  assert.match(dockerfile, /USER node\nRUN --mount=\S+ \\\n {2}NODE_ENV=production node \/opt\/xema-composed-image\/src\/composed-image\.mjs load --each\n/);
44
44
  assert.match(dockerfile, /cp \/opt\/xema-composed-image\/src\/composed-migrate\.mjs \/monorepo\/xema-composed-migrate\.mjs/);
45
45
  });
@@ -101,3 +101,16 @@ test('a token-using repository gets single-backslash line continuations (a doubl
101
101
  assert.match(dockerfile, /--mount=type=secret,id=npm_token,required=true \\\n/);
102
102
  for (const line of dockerfile.split('\n')) assert.doesNotMatch(line, /\\\\\s*$/, `line ends with a doubled backslash: ${line}`);
103
103
  });
104
+
105
+ test('the final stage is layered: deps, then assemble without the .pnpm store, then the injected copies', () => {
106
+ const { dockerfile } = renderPrebuiltDockerfile(options(workspace()));
107
+ const final = dockerfile.split('\nFROM base\n')[1];
108
+ const copies = final.split('\n').filter((line) => line.startsWith('COPY '));
109
+ assert.deepEqual(copies, [
110
+ 'COPY --from=deps /monorepo /monorepo',
111
+ 'COPY --from=assemble --exclude=node_modules/.pnpm /monorepo /monorepo',
112
+ 'COPY --from=assemble --parents /monorepo/node_modules/.pnpm/*@file+* /',
113
+ ]);
114
+ assert.ok(final.indexOf('ENV NODE_ENV=production') > final.lastIndexOf('COPY --from=assemble'));
115
+ assert.match(final, /USER node\nCMD \["node", "\/monorepo\/xema-composed-process"\]\n$/);
116
+ });
@@ -3,7 +3,8 @@
3
3
  // directory the `--prebuilt` Dockerfile copies from. Docker compiles nothing; this
4
4
  // is the only place outputs enter an image, so it is also where they are refused.
5
5
  //
6
- // <dir>/out/<package dir>/<output>/ every closure package's outputs (default: dist)
6
+ // <dir>/out/<package dir>/<output>/ every closure package's outputs (default: dist);
7
+ // `--also <pkg>` adds that package's closure (no member checks)
7
8
  // <dir>/out/<member>/node_modules/.prisma/ every member's generated Prisma clients
8
9
  // <dir>/tool/ this package (package.json + src, no tests):
9
10
  // bind-mounted into the build, never copied into a layer
@@ -65,6 +66,7 @@ export async function stageBuildOutputs({
65
66
  imageDir = COMPOSED_IMAGE_DIR,
66
67
  dir,
67
68
  outputs = DEFAULT_OUTPUTS,
69
+ also = [],
68
70
  exec,
69
71
  nodeMajor = process.versions.node.split('.')[0],
70
72
  formatSpawn,
@@ -90,7 +92,7 @@ export async function stageBuildOutputs({
90
92
 
91
93
  const members = readMembers({ root, imageDir }).map(([key, directory]) => ({ key, directory }));
92
94
  const memberDirectories = new Set(members.map((m) => m.directory));
93
- const closure = memberClosure({ root, imageDir, exec });
95
+ const closure = memberClosure({ root, imageDir, exec, also });
94
96
  const schemaJobs = [];
95
97
  for (const { key, directory } of members) {
96
98
  if (!closure.includes(directory)) problems.push(`${key}: ${directory} is not in the closure pnpm selected`);
@@ -100,3 +100,54 @@ test('refuses a client generated from another schema than the member carries; th
100
100
  await assert.rejects(stage(stale), /alpha-db-api: .*differs from the member's prisma\/schema\.prisma .*stale/);
101
101
  assert.equal((await stage(ready())).packages, 3);
102
102
  });
103
+
104
+ /** An `exec` that answers per-name probes (`--filter <name>`) and the closure listing (`--filter <name>...`). */
105
+ function alsoPnpm(root, known) {
106
+ return (_file, args) => {
107
+ const filters = args.flatMap((a, i) => (args[i - 1] === '--filter' ? [a] : []));
108
+ const names = filters.filter((f) => !f.endsWith('...'));
109
+ if (names.length > 0) return JSON.stringify(known.filter((p) => p.name === names[0]).map((p) => ({ name: p.name, path: path.join(root, p.dir) })));
110
+ const wanted = new Set(filters.map((f) => f.slice(0, -3)));
111
+ const dirs = new Set(['biomes/alpha/api/alpha-api', 'biomes/alpha/api/alpha-db-api', 'packages/sdks/lib-a']);
112
+ for (const p of known) if (wanted.has(p.name)) for (const d of p.closure) dirs.add(d);
113
+ return JSON.stringify([...dirs].map((dir) => ({ name: dir, path: path.join(root, dir) })));
114
+ };
115
+ }
116
+ const EXTRA = [{ name: '@t/extra', dir: 'packages/sdks/extra', closure: ['packages/sdks/extra', 'packages/sdks/extra-dep'] },
117
+ { name: '@t/lib-a', dir: 'packages/sdks/lib-a', closure: ['packages/sdks/lib-a'] }];
118
+
119
+ function withExtra() {
120
+ const root = ready();
121
+ write(root, 'packages/sdks/extra/package.json', { name: '@t/extra' });
122
+ write(root, 'packages/sdks/extra/dist/index.js', '// extra\n');
123
+ write(root, 'packages/sdks/extra-dep/dist/index.js', '// dep\n');
124
+ return root;
125
+ }
126
+
127
+ test('--also stages the named package closure without member checks, and a member named again is idempotent', async () => {
128
+ const root = withExtra();
129
+ const result = await stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra', '@t/lib-a'] });
130
+ assert.equal(result.packages, 5);
131
+ const staged = files(path.join(root, '.xema-stage/out'));
132
+ for (const expected of ['packages/sdks/extra/dist/index.js', 'packages/sdks/extra-dep/dist/index.js', 'packages/sdks/lib-a/dist/index.js']) {
133
+ assert.ok(staged.includes(expected), expected);
134
+ }
135
+ assert.ok(!staged.some((f) => f.startsWith('packages/sdks/extra/node_modules')));
136
+ });
137
+
138
+ test('--also refuses an unknown package and a native addon in the extra closure', async () => {
139
+ const root = withExtra();
140
+ await assert.rejects(stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/nope'] }), /--also @t\/nope: no workspace package/);
141
+ assert.equal(fs.existsSync(path.join(root, '.xema-stage')), false);
142
+ write(root, 'packages/sdks/extra-dep/dist/a.node', 'x');
143
+ await assert.rejects(stage(root, { exec: alsoPnpm(root, EXTRA), also: ['@t/extra'] }), /extra-dep\/dist\/a\.node/);
144
+ });
145
+
146
+ test('filters and the argument parser carry --also', async () => {
147
+ const root = ready();
148
+ const { memberFilters } = await import('./composed-image-members.mjs');
149
+ const { parseArguments } = await import('./composed-image.mjs');
150
+ assert.deepEqual(memberFilters({ root, also: ['@t/extra', '@t/lib-a', '@t/extra'] }).slice(-2), ['--filter @t/extra...', '--filter @t/lib-a...']);
151
+ assert.equal(memberFilters({ root, also: ['@t/alpha-api'] }).filter((f) => f === '--filter @t/alpha-api...').length, 1);
152
+ assert.deepEqual(parseArguments(['stage', 'd', '--also', 'a', '--also', 'b']).options.also, ['a', 'b']);
153
+ });
@@ -24,7 +24,8 @@
24
24
  // derive the member set and write / verify
25
25
  // `<image-dir>/image-build-inputs.json` (its existence at a commit IS
26
26
  // the repository's composed-process declaration).
27
- // filters print `--filter <package>...` for every member, so install and
27
+ // filters [--also <pkg>]…
28
+ // print `--filter <package>...` for every member (and each --also package), so install and
28
29
  // build cover exactly the members' closures.
29
30
  // dirs print every member's directory, one per line.
30
31
  // pins <committed-lockfile>
@@ -38,10 +39,11 @@
38
39
  // load --each
39
40
  // the same check, one `node` process PER member, so a member that only
40
41
  // loads because another loaded first is caught (the prebuilt image's gate).
41
- // stage <dir> [--output <rel>]…
42
+ // stage <dir> [--output <rel>]… [--also <pkg>]…
42
43
  // gather the runner's build outputs (default `dist`) and the members'
43
44
  // generated Prisma clients into a build-step directory, with this tool,
44
- // for the `--prebuilt` Dockerfile (`composed-image-stage.mjs`).
45
+ // for the `--prebuilt` Dockerfile (`composed-image-stage.mjs`). Each `--also`
46
+ // workspace package adds its closure's built outputs (no Prisma checks).
45
47
  // sync-injected | prisma-check
46
48
  // IN-IMAGE proofs of the prebuilt build: the injected workspace copies hold
47
49
  // their packages' `files`; every Prisma client runs on the target arch.
@@ -53,8 +55,9 @@
53
55
  // user and NODE_ENV: `load` runs in the builder, which is not what ships.
54
56
  // coordinate --image-name <name>
55
57
  // the image's content tag (computed ONLY by the repository's own
56
- // `xema-compute-image-tag`) and whether GHCR already serves it
57
- // (`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).
58
61
  // smoke-refusal <image> [--member <biomeId>/<componentKey>]x2
59
62
  // boot the pushed image with a declaration the host must refuse
60
63
  // (`smoke-refusal.mjs`).
@@ -93,7 +96,7 @@ import { pathToFileURL } from 'node:url';
93
96
 
94
97
  import { COMPOSED_IMAGE_DIR } from './composed-image-paths.mjs';
95
98
  import { composedCoordinate, emitCoordinate } from './composed-image-coordinate.mjs';
96
- import { describeErrorChain } from './registry-fetch.mjs';
99
+ import { describeErrorChain } from '@xemahq/release-policy/registry';
97
100
  import { syncComposedDockerfile } from './composed-image-dockerfile.mjs';
98
101
  import { IMAGE_BUILD_INPUTS_FILE, memberClosure, memberFilters, readMembers } from './composed-image-members.mjs';
99
102
  import { syncPrebuiltDockerfile } from './composed-image-prebuilt-dockerfile.mjs';
@@ -454,18 +457,18 @@ export function loadMembersEach({ root = process.cwd(), imageDir = COMPOSED_IMAG
454
457
  const USAGE =
455
458
  'usage: xema-composed-image <command> [--root <dir>] [--image-dir <dir>]\n' +
456
459
  ' members --write|--check [--manifests <glob>]… [--canonical-dockerfile <path>] [--cross-repo-manifest <path>]\n' +
457
- ' filters | dirs | pins <committed-lockfile> | index <commit> --build-source <repo> | load [--each]\n' +
458
- ' stage <dir> [--output <rel>]… | sync-injected | prisma-check | migrate <member> [--root <dir>]\n' +
459
- ' coordinate --image-name <name> (env: SHA REGISTRY REGISTRY_ORG REGISTRY_USER REGISTRY_TOKEN [TAG_ARGS_FILE])\n' +
460
+ ' filters [--also <pkg>]… | dirs | pins <committed-lockfile> | index <commit> --build-source <repo> | load [--each]\n' +
461
+ ' stage <dir> [--output <rel>]… [--also <pkg>]… | sync-injected | prisma-check | migrate <member> [--root <dir>]\n' +
462
+ ' coordinate --image-name <name> (env: SHA REGISTRY REGISTRY_ORG [TAG_ARGS_FILE])\n' +
460
463
  ' load-image <image>\n' +
461
464
  ' smoke-refusal <image> [--member <biomeId>/<componentKey>]x2\n' +
462
465
  ' dockerfile --write|--check --tooling-version <x.y.z> --copy <dir>… --cache-id <id> --build-source <repo>\n' +
463
466
  ' dockerfile --prebuilt --write|--check --tooling-version <x.y.z> --cache-id <id> --build-source <repo> [--copy <dir>]… [--stage-dir <dir>]';
464
467
 
465
- const REPEATABLE = new Set(['manifests', 'copy', 'member', 'output']);
468
+ const REPEATABLE = new Set(['manifests', 'copy', 'member', 'output', 'also']);
466
469
  const BOOLEAN = new Set(['prebuilt', 'each']);
467
470
  const VALUED = new Set(['root', 'image-dir', 'manifests', 'canonical-dockerfile', 'cross-repo-manifest', 'build-source',
468
- 'image-name', 'tooling-version', 'cache-id', 'copy', 'member', 'stage-dir', 'output',
471
+ 'image-name', 'tooling-version', 'cache-id', 'copy', 'member', 'stage-dir', 'output', 'also',
469
472
  ]);
470
473
 
471
474
  export function parseArguments(argv) {
@@ -518,7 +521,7 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
518
521
  return;
519
522
  }
520
523
  case 'filters':
521
- write(`${memberFilters({ root, imageDir }).join(' ')}\n`);
524
+ write(`${memberFilters({ root, imageDir, also: options.also }).join(' ')}\n`);
522
525
  return;
523
526
  case 'dirs':
524
527
  write(`${readMembers({ root, imageDir }).map(([, directory]) => directory).join('\n')}\n`);
@@ -541,7 +544,7 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
541
544
  log(`composed members: all ${loadMembers({ root, imageDir })} module graph(s) load`);
542
545
  return;
543
546
  case 'stage': {
544
- return stageBuildOutputs({ root, imageDir, dir: argument, outputs: options.output }).then((staged) => {
547
+ return stageBuildOutputs({ root, imageDir, dir: argument, outputs: options.output, also: options.also }).then((staged) => {
545
548
  log(`composed stage ${argument}: ${staged.packages} package(s), ${staged.outputs} output dir(s), ${staged.files} file(s), tool ${staged.toolFiles} file(s)`);
546
549
  });
547
550
  }
@@ -556,9 +559,8 @@ export function main(argv, { log = console.log, write = (text) => process.stdout
556
559
 
557
560
  }
558
561
  case 'coordinate':
559
- return composedCoordinate({ root, imageDir, imageName: options['image-name'] }).then((outputs) =>
560
- emitCoordinate(outputs, { log }),
561
- );
562
+ emitCoordinate(composedCoordinate({ root, imageDir, imageName: options['image-name'] }), { log });
563
+ return;
562
564
  case 'load-image':
563
565
  runImageLoad({ image: argument, log });
564
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
- });