@xemahq/repo-build-tooling 0.27.2 → 0.28.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
@@ -30,7 +30,7 @@ pnpm exec xema-scrub-swagger-paths dist
30
30
  | `xema-service-bootstrap [--check] [--filter <key>] [--root <dir>]` | Generates `src/generated/*.generated.ts` service and worker descriptors from the repository's `xema-biome.json` manifests; `--check` fails on drift |
31
31
  | `xema-scrub-swagger-paths <dist-dir>...` | Rewrites `@nestjs/swagger` plugin output that names a private install path, then fails the build if any leak remains (`--self-test` available) |
32
32
  | `xema-composed-image <command>` | Build-time half of a composed-process image: `members`, `filters`, `dirs`, `pins`, `index`, `load`, `stage`, `stage-tree`, `pnpm-context`, `sync-injected`, `prisma-check`, `migrate`, `coordinate`, `load-image`, `smoke-refusal`, `dockerfile` |
33
- | `xema-service-image stage --service <pkg> --path <dir> --platform linux/<amd64\|arm64> [--stage-dir <dir>]` | Runner half of a per-service image: `pnpm deploy --prod --offline --ignore-scripts` of a service already built on the runner, selected for the TARGET platform (`--os=linux --cpu=<arch> --libc=glibc`), into `<stage>/service/app`, with its Prisma client and the target's schema engine. Refuses a package whose native code holds no ELF for the target (a multi-platform package that also ships the target's binary is accepted) and production dependencies whose build script would have to run, uncommitted changes to tracked files, an untracked root build input, and running as any tool but the one the root installs. Fetches the schema engine from Prisma for every stage, checks it against both published checksums, and caches nothing. Writes `<stage>/service/xema-service-image.json` (commit, sha256 of each root build input: lockfile, root `package.json`, `pnpm-workspace.yaml`, `.npmrc`, `.pnpmfile.cjs`, `turbo.json`, `tsconfig*.json`; tool version; natives per package; engine sha256) and `<stage>/tool`. The Dockerfile COPYs those root inputs into `/proof/context/`, which puts them in the content tag, and its proof stage runs `src/service-image-in-image.mjs prove <app> <proof dir> --service <svc> --commit <BUILD_SHA>` on the target, offline: it checks the commit, the root inputs, the tool version, the platform and Node ABI, that each package's addons `dlopen`, and the engine's sha256 and version. The final stage only COPYs |
33
+ | `xema-service-image stage --service <pkg> --path <dir> --platform linux/<amd64\|arm64> [--stage-dir <dir>]` | Runner half of a per-service image: `pnpm deploy --prod --offline --ignore-scripts` of a service already built on the runner, selected for the TARGET platform (`--os=linux --cpu=<arch> --libc=glibc`), into `<stage>/service/app`, with its Prisma client and the target's schema engine. Refuses a package whose native code holds no ELF for the target (a multi-platform package that also ships the target's binary is accepted) and production dependencies whose build script would have to run, uncommitted changes to tracked files, an untracked root build input, and running as any tool but the one the root installs. Fetches the schema engine from Prisma for every stage, checks it against both published checksums, and caches nothing. Writes `<stage>/service/xema-service-image.json` (commit, sha256 of each root build input: lockfile, root `package.json`, `pnpm-workspace.yaml`, `.npmrc`, `.pnpmfile.cjs`, `turbo.json`, `tsconfig*.json`; tool version; natives per package; engine sha256) and `<stage>/tool`. The Dockerfile COPYs those root inputs into `/proof/context/`, which puts them in the content tag, and its proof stage runs `src/service-image-in-image.mjs prove <app> <proof dir> --service <svc> --commit <BUILD_SHA>` on the target, offline: it checks the commit, the root inputs, the tool version, the platform and Node ABI, that each package's addons `dlopen`, and the engine's sha256 and version. The stage reaches the build as the named context `xema-service` (`--build-context xema-service=.xema-stage`); the authoritative name is `STAGED_SERVICE_CONTEXT` in `@xemahq/distribution-source-hash`, whose image hash frames such a service from its tracked tree, closure, turbo globalDependencies and tsconfig chain, so the stage must be built from no other repository input. The final stage only COPYs |
34
34
  | `xema-arm64-supplement-needed` | Decides whether an arm64 supplement is still owed at `REGISTRY/IMAGE:CONTENT_TAG` (env: `REGISTRY`, `IMAGE`, `CONTENT_TAG`, `REGISTRY_USER`, `REGISTRY_TOKEN`). `needed=false` only when the coordinate serves an index whose linux/arm64 child is proven by its own image config; any other resolvable state is `needed=true`; a 401/403/5xx or an unresolvable child is an error. Writes `needed=true\|false` to `$GITHUB_OUTPUT`. Replaces the generated `arm64-supplement-needed.mjs` copy. |
35
35
  | `xema-promote-image --service <name> --dev-ref <repo> --release-ref <repo> --tag <tag> --commit <sha> --main-ref <ref> --signer-repository <owner/repo> --develop-workflow <file> --release-workflow <file>... [--release-index-workflow <file>] --promotion-predicate-out <path> -- <tag command>` | Publishes the develop image's digest under the release tag instead of rebuilding: `reused` (the release digest carries an `image-promotion/v1` attestation signed by a `--release-workflow` identity on main, with exactly one verified statement about the digest and a `ghcr.io/<owner>/dev/<name>` source; no dev-ref probe), `copied` (the proven develop digest, digest-preserving; fails closed if the release digest differs) or `miss` (build it; a reuse whose release digest has no `image-promotion/v1` attestation, a legacy image, is a miss with `reason=reuse-no-promotion` on the stdout line; with `--release-index-workflow`, a release tag that names an image index signed by that workflow on main, with exactly one `linux/amd64` child whose own digest passes that same reuse check, is instead `reused` with `evidence=release-index`, `digest` = the amd64 child A, `index_digest` = the index D and no predicate, but only when A's selected promotion statement promotes `--commit` itself (a rerun; a later commit with the same content takes the existing miss and attests its own promotion). On `evidence=release-index` the caller skips sign/attest, tags `:<sha>` and `main-<sha>` to the amd64 subject `digest` (release-arm64 resolves `:<sha>` to it and verifies its promotion), and points only the content tag (and `latest`, if it moves it) at the index `index_digest`. Any failed step keeps the miss, and two amd64 children are an error; a signature or identity failure, or a source outside `dev/`, is an error; a copy whose dev digest carries no SLSA v1 provenance at all, an image reused on develop from before provenance, is a miss with `reason=copy-no-develop-provenance`, while provenance that is present but fails a leg is an error). A develop digest is proven only if its SLSA v1 provenance is signed by the develop workflow at `refs/heads/develop`, its revision is an ancestor of the main ref, and the tag command prints the same tag at that revision and at `--commit`; any failed leg is an error, never a miss. On every non-miss result it writes the `https://xema.dev/attestations/image-promotion/v1` predicate for the caller to attest: a copy states the facts just proved; a reuse states this run's `release` and `promotedCommit` and carries `source` and `developProvenance` from the earlier promotion. Writes `result`, `digest`, `source`, `revision`, `evidence`, `provenance_digest`, `promotion_predicate`, `promotion_predicate_type` to `$GITHUB_OUTPUT`. Needs `docker`, `cosign` (major v2, the same as the develop attest step and xema-deploy's verify; it runs `cosign version` first and refuses any other major or an unparsable version), `git` and full history. It does not sign |
36
36
  | `xema-check-no-vendored-deps` | Fails on a `workspace:` range for a package the repository does not own |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xemahq/repo-build-tooling",
3
- "version": "0.27.2",
3
+ "version": "0.28.0",
4
4
  "description": "Dev-time build tooling shared by every Xema repository. Ships as plain ESM, depending only on @xemahq/release-policy for registry access, so the published artifact is the reviewed source.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Neuralchowder Inc. <developer@xema.dev> (https://xema.dev)",
@@ -25,6 +25,7 @@
25
25
  "xema-check-no-vendored-deps": "src/check-no-vendored-deps.mjs",
26
26
  "xema-check-workspace-ranges": "src/check-workspace-range-matches-local.mjs",
27
27
  "xema-composed-image": "src/composed-image.mjs",
28
+ "xema-effective-input": "src/effective-input.mjs",
28
29
  "xema-promote-image": "src/promote-image.mjs",
29
30
  "xema-readiness": "src/readiness.mjs",
30
31
  "xema-scrub-swagger-paths": "src/scrub-swagger-plugin-paths.mjs",
@@ -36,6 +37,7 @@
36
37
  "./check-no-vendored-deps": "./src/check-no-vendored-deps.mjs",
37
38
  "./check-workspace-range-matches-local": "./src/check-workspace-range-matches-local.mjs",
38
39
  "./composed-image": "./src/composed-image.mjs",
40
+ "./effective-input": "./src/effective-input.mjs",
39
41
  "./package.json": "./package.json",
40
42
  "./promote-image": "./src/promote-image.mjs",
41
43
  "./readiness": "./src/readiness.mjs",
@@ -47,9 +49,9 @@
47
49
  "@xemahq/release-policy": "^16.0.0"
48
50
  },
49
51
  "scripts": {
50
- "test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-stage-tree.test.mjs && node --test src/composed-image-install.test.mjs && node --test src/composed-image-proof.test.mjs && node --test src/composed-image-pnpm-context.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-image-prisma-engine.test.mjs && node --test src/composed-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs && node --test src/service-image.test.mjs && node --test src/image-cpu-arch.test.mjs"
52
+ "test": "node src/scrub-swagger-plugin-paths.mjs --self-test && node --test src/check-workspace-range-matches-local.test.mjs && node --test src/check-no-vendored-deps.test.mjs && node --test src/readiness.test.mjs && node --test src/composed-image.test.mjs && node --test src/composed-image-coordinate-cli.test.mjs && node --test src/declaration-refusal.test.mjs && node --test src/smoke-refusal.test.mjs && node --test src/load-image.test.mjs && node --test src/composed-image-coordinate.test.mjs && node --test src/composed-image-dockerfile.test.mjs && node --test src/service-bootstrap.test.mjs && node --test src/service-bootstrap-protocols.test.mjs && node --test src/composed-image-prebuilt-dockerfile.test.mjs && node --test src/composed-image-in-image-closure.test.mjs && node --test src/composed-image-stage.test.mjs && node --test src/composed-image-stage-tree.test.mjs && node --test src/composed-image-install.test.mjs && node --test src/composed-image-proof.test.mjs && node --test src/composed-image-pnpm-context.test.mjs && node --test src/composed-image-sync-injected.test.mjs && node --test src/composed-image-prisma-check.test.mjs && node --test src/composed-image-prisma-engine.test.mjs && node --test src/composed-migrate.test.mjs && node --test src/promote-image.test.mjs && node --test src/arm64-supplement-needed.test.mjs && node --test src/service-image.test.mjs && node --test src/image-cpu-arch.test.mjs && node --test src/staged-image-proof.test.mjs && node --test src/effective-input.test.mjs"
51
53
  },
52
54
  "xemaRelease": {
53
- "sourceDigest": "sha256:3a4f2d10146fe209afa48b5f783954e50bd239602444a7a59920cc7196169c6b"
55
+ "sourceDigest": "sha256:741aec4b08022b9aa6702a86627e72163defa62b568c2dbacc2f9e29f14d85bf"
54
56
  }
55
57
  }
@@ -0,0 +1,401 @@
1
+ #!/usr/bin/env node
2
+ // The EFFECTIVE-INPUT key of one image: a content hash of every input that decides its bytes, computed from
3
+ // SOURCES, before any build, so a lane can skip build, stage and push when the key is already in the registry.
4
+ //
5
+ // xema-effective-input --service <pkg> [--service <pkg>]… [--lane candidate|develop] --platform linux/<amd64|arm64> --base-image <name@sha256:digest>
6
+ // --dockerfile <file> [--script <file>]… [--build-arg NAME=VALUE]… [--out <file>]
7
+ //
8
+ // Run from the workspace root (the repository, or an aggregator workspace root: the staging actions' `context-root`).
9
+ // It prints the key. The key is the same from either root for the same inputs, because nothing in it is a root-relative
10
+ // path: workspace packages are keyed by NAME and hashed over their package-relative tracked files, the lockfile is
11
+ // reduced to a slice (the closure's importers, rewritten to package names, and the entries reachable from them), and of
12
+ // the root files only the compile-steering ones (`.npmrc`, `tsconfig*.json`) enter, by name. The whole root
13
+ // `package.json`, lockfile and workspace file differ by root and are the PROVE step's business (xema-service-image).
14
+ //
15
+ // Build args the Dockerfile receives from CI are part of `tooling.buildArgs` (name -> value, sorted), except
16
+ // the ones that are only a per-root or per-commit fact: `REPO_DIR` and `SERVICE_PATH` are paths that differ by root
17
+ // (the service is keyed by name and its files by package-relative path), `BUILD_SHA` is the commit (the key is a
18
+ // CONTENT key, and a commit tag would make every commit a new image). They are refused as inputs, not hashed.
19
+ //
20
+ // One enumeration: the closure is the one the stage builds (`turbo run build --filter "<service>..." --dry=json`), and
21
+ // the root files come from `rootBuildInputNames` of @xemahq/repo-build-tooling (the prove step's own list).
22
+ import { execFileSync } from 'node:child_process';
23
+ import { createHash } from 'node:crypto';
24
+ import { readFileSync, readlinkSync, writeFileSync } from 'node:fs';
25
+ import { join, posix, resolve } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ import { rootBuildInputNames } from './staged-image-proof.mjs';
29
+
30
+ export const KEY_SCHEMA = 1;
31
+ /** Build args that are a root-relative path or the commit, never part of the key. */
32
+ export const NON_CONTENT_BUILD_ARGS = Object.freeze(['REPO_DIR', 'SERVICE_PATH', 'BUILD_SHA']);
33
+ const DIGEST_PINNED = /^[^\s@]+@sha256:[0-9a-f]{64}$/;
34
+ const COMPILE_ROOT_FILE = /^(\.npmrc|tsconfig[^/]*\.json)$/;
35
+ const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'optionalDependencies'];
36
+ const sha256 = (bytes) => createHash('sha256').update(bytes).digest('hex');
37
+
38
+ /** Canonical JSON: keys sorted recursively, no whitespace. */
39
+ export function canonicalJson(value) {
40
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
41
+ if (value !== null && typeof value === 'object') {
42
+ return `{${Object.keys(value).sort().map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
43
+ }
44
+ return JSON.stringify(value);
45
+ }
46
+
47
+ const unquote = (key) => (key.startsWith("'") && key.endsWith("'") ? key.slice(1, -1) : key);
48
+
49
+ /** The top-level sections of a pnpm v9 lockfile, each as `{ entryKey: [body lines] }` (entries are the 2-space keys). */
50
+ export function lockSections(text) {
51
+ const sections = {};
52
+ let section;
53
+ let entry;
54
+ for (const line of text.split('\n')) {
55
+ if (line.trim() === '') continue;
56
+ if (!line.startsWith(' ')) {
57
+ section = line.endsWith(':') ? line.slice(0, -1) : undefined;
58
+ if (section !== undefined) sections[section] = {};
59
+ entry = undefined;
60
+ } else if (section !== undefined && /^ {2}\S/.test(line)) {
61
+ entry = unquote(line.trim().replace(/:( \{\})?$/, ''));
62
+ sections[section][entry] = [];
63
+ } else if (entry !== undefined) {
64
+ sections[section][entry].push(line);
65
+ }
66
+ }
67
+ return sections;
68
+ }
69
+
70
+ /** `{ dependencyName: { specifier, version } }` per section of an importer body (a pnpm importer's 4/6/8-space layout). */
71
+ function importerDependencies(lines) {
72
+ const sections = {};
73
+ let section;
74
+ let name;
75
+ for (const line of lines) {
76
+ const indent = line.length - line.trimStart().length;
77
+ const text = line.trim();
78
+ if (indent === 4) {
79
+ section = text.replace(/:$/, '');
80
+ sections[section] = {};
81
+ } else if (indent === 6 && section !== undefined) {
82
+ name = unquote(text.replace(/:$/, ''));
83
+ sections[section][name] = {};
84
+ } else if (indent === 8 && name !== undefined) {
85
+ const [field, ...rest] = text.split(': ');
86
+ sections[section][name][field] = unquote(rest.join(': '));
87
+ }
88
+ }
89
+ return sections;
90
+ }
91
+
92
+ /** `{ name: version }` of a snapshot body's dependency sections. */
93
+ function snapshotDependencies(lines) {
94
+ const deps = {};
95
+ let inside = false;
96
+ for (const line of lines) {
97
+ const indent = line.length - line.trimStart().length;
98
+ const text = line.trim();
99
+ if (indent === 4) inside = DEPENDENCY_SECTIONS.includes(text.replace(/:$/, ''));
100
+ else if (indent === 6 && inside) {
101
+ const at = text.indexOf(': ');
102
+ deps[unquote(text.slice(0, at))] = unquote(text.slice(at + 2));
103
+ }
104
+ }
105
+ return deps;
106
+ }
107
+
108
+ /** An alias (`wrap-ansi-cjs: wrap-ansi@7.0.0`) names its target's snapshot; a plain version belongs to the dependency's own name. */
109
+ const ALIAS_TARGET = /^(?:npm:)?(@?[^@(]+@.*)$/;
110
+ const snapshotKey = (name, version) => ALIAS_TARGET.exec(version)?.[1] ?? `${name}@${version}`;
111
+ const packageKey = (key) => key.replace(/\(.*$/, '');
112
+
113
+ export const LOCKFILE_VERSION = '9.0';
114
+
115
+ /** The lockfile's declared version, refused unless it is the one this parser reads. */
116
+ export function assertLockfileVersion(lockText) {
117
+ const declared = /^lockfileVersion:\s*'?([^'\s]+)'?\s*$/m.exec(lockText)?.[1];
118
+ if (declared !== LOCKFILE_VERSION) {
119
+ throw new Error(`lockfileVersion '${declared}' is not '${LOCKFILE_VERSION}', the only pnpm lockfile format effective-input reads`);
120
+ }
121
+ }
122
+
123
+ /** `pnpm-workspace.yaml` minus its `packages` block, comments and blank lines: the install settings it carries. */
124
+ export function workspaceSettings(yamlText) {
125
+ const kept = [];
126
+ let inPackages = false;
127
+ for (const line of yamlText.split('\n')) {
128
+ if (/^\S/.test(line)) inPackages = /^packages\s*:/.test(line);
129
+ if (inPackages) continue;
130
+ const text = line.replace(/\s+$/, '');
131
+ if (text.trim() === '' || text.trim().startsWith('#')) continue;
132
+ kept.push(text);
133
+ }
134
+ return kept.join('\n');
135
+ }
136
+
137
+ /**
138
+ * What steers the install and so the bytes, beyond the closure's own entries: the lockfile's `settings`, `overrides`
139
+ * and `patchedDependencies`, the root `package.json`'s `packageManager` and `pnpm` block, and the workspace file's
140
+ * settings. Each is canonical data; none depends on the root's path.
141
+ */
142
+ export function installInputs({ lockText, packageJson, workspaceYaml }) {
143
+ assertLockfileVersion(lockText);
144
+ const { settings = {}, overrides = {}, patchedDependencies = {} } = lockSections(lockText);
145
+ const manifest = JSON.parse(packageJson);
146
+ return {
147
+ lock: { settings, overrides, patchedDependencies },
148
+ packageManager: manifest.packageManager ?? null,
149
+ pnpm: manifest.pnpm ?? null,
150
+ workspace: sha256(workspaceSettings(workspaceYaml)),
151
+ };
152
+ }
153
+
154
+ /**
155
+ * The lockfile slice of a closure: its importers (keyed by package name, a `link:<path>` version rewritten to
156
+ * `link:<package name>`), plus the snapshots reachable from them and each one's `packages` entry. `packages` is
157
+ * `[{ name, dir }]`, `dir` relative to the lockfile's root.
158
+ */
159
+ export function lockfileSlice(lockText, packages) {
160
+ assertLockfileVersion(lockText);
161
+ const { importers = {}, packages: registry = {}, snapshots = {} } = lockSections(lockText);
162
+ const nameByDir = new Map(packages.map((p) => [posix.normalize(p.dir), p.name]));
163
+ const slice = { importers: {}, snapshots: {}, packages: {} };
164
+ const pending = [];
165
+ for (const { name, dir } of packages) {
166
+ const body = importers[posix.normalize(dir)];
167
+ if (body === undefined) throw new Error(`the lockfile has no importer for ${name} (${dir})`);
168
+ const sections = importerDependencies(body);
169
+ for (const deps of Object.values(sections)) {
170
+ for (const [dep, entry] of Object.entries(deps)) {
171
+ if (entry.version?.startsWith('link:')) {
172
+ const target = nameByDir.get(posix.join(dir, entry.version.slice(5)));
173
+ if (target === undefined) throw new Error(`${name} links ${entry.version}, which is not in the closure`);
174
+ entry.version = `link:${target}`;
175
+ } else if (entry.version !== undefined) {
176
+ pending.push(snapshotKey(dep, entry.version));
177
+ }
178
+ }
179
+ }
180
+ slice.importers[name] = sections;
181
+ }
182
+ while (pending.length > 0) {
183
+ const key = pending.pop();
184
+ if (Object.hasOwn(slice.snapshots, key)) continue;
185
+ const body = snapshots[key];
186
+ if (body === undefined) throw new Error(`the lockfile has no snapshot ${key}`);
187
+ slice.snapshots[key] = body.map((l) => l.trim());
188
+ const entry = registry[packageKey(key)];
189
+ if (entry === undefined) throw new Error(`the lockfile has no packages entry ${packageKey(key)}`);
190
+ slice.packages[packageKey(key)] = entry.map((l) => l.trim());
191
+ for (const [dep, version] of Object.entries(snapshotDependencies(body))) pending.push(snapshotKey(dep, version));
192
+ }
193
+ return slice;
194
+ }
195
+
196
+ /** sha256 over the sorted `<path>\0<git mode>\0<sha256>\n` lines of `files` (`{ relativePath: { bytes, mode } }`). */
197
+ export function packageDigest(files) {
198
+ const hash = createHash('sha256');
199
+ for (const path of Object.keys(files).sort()) hash.update(`${path}\0${files[path].mode}\0${sha256(files[path].bytes)}\n`);
200
+ return hash.digest('hex');
201
+ }
202
+
203
+ /**
204
+ * The key and its parts. `packages` is `[{ name, files }]` (`files`: package-relative path -> `{ bytes, mode }`), `lockSlice` the
205
+ * result of `lockfileSlice`, `tooling` `{ dockerfile, scripts: { name: bytes }, buildArgs: { NAME: value }, stageTool: { name, version } }`,
206
+ * `rootConfig` `{ fileName: bytes }`, `install` the result of `installInputs`.
207
+ */
208
+ export function effectiveInput({ service, platform, baseImage, packages, lockSlice, install, tooling, rootConfig }) {
209
+ if (!/^linux\/(amd64|arm64)$/.test(platform)) throw new Error(`platform must be linux/amd64 or linux/arm64, got '${platform}'`);
210
+ if (typeof baseImage !== 'string' || !DIGEST_PINNED.test(baseImage)) {
211
+ throw new Error(`the base image must be digest-pinned (<name>@sha256:<64 hex>), got '${baseImage}': a tag names mutable bytes`);
212
+ }
213
+ const excluded = Object.keys(tooling.buildArgs ?? {}).filter((n) => NON_CONTENT_BUILD_ARGS.includes(n));
214
+ if (excluded.length > 0) throw new Error(`build arg(s) ${excluded.join(', ')} are paths or the commit and are not key inputs: leave them out`);
215
+ const parts = {
216
+ schema: KEY_SCHEMA,
217
+ service,
218
+ platform,
219
+ baseImage,
220
+ tooling: {
221
+ buildArgs: tooling.buildArgs ?? {},
222
+ dockerfile: sha256(tooling.dockerfile),
223
+ scripts: Object.fromEntries(Object.entries(tooling.scripts).map(([name, bytes]) => [name, sha256(bytes)])),
224
+ stageTool: tooling.stageTool,
225
+ },
226
+ rootConfig: Object.fromEntries(Object.entries(rootConfig).map(([name, bytes]) => [name, sha256(bytes)])),
227
+ workspace: Object.fromEntries(packages.map((p) => [p.name, packageDigest(p.files)])),
228
+ lock: sha256(canonicalJson(lockSlice)),
229
+ install,
230
+ };
231
+ return { key: sha256(canonicalJson(parts)), parts };
232
+ }
233
+
234
+ // ── Gathering from a workspace root ─────────────────────────────────────────────────────────────────────────────────
235
+
236
+ const run = (file, args, cwd) => execFileSync(file, args, { cwd, encoding: 'utf8', maxBuffer: 1 << 28 });
237
+
238
+ /**
239
+ * The workspace package graph, from ONE `turbo query` (the whole graph, so a package with no build task is not dropped):
240
+ * `Map<name, { name, dir, dependencies: string[] }>`, `dir` relative to `root`.
241
+ */
242
+ export function packageGraph(root) {
243
+ const query = 'query { packages { items { name path directDependencies { items { name } } } } }';
244
+ const items = JSON.parse(run('pnpm', ['exec', 'turbo', 'query', query], root)).data.packages.items;
245
+ return new Map(items.filter((p) => p.name !== '//').map((p) => [p.name, { name: p.name, dir: p.path, dependencies: p.directDependencies.items.map((d) => d.name) }]));
246
+ }
247
+
248
+ /** `[{ name, dir }]` of `service` and every workspace package it depends on, sorted by name. */
249
+ export function closureIn(graph, service) {
250
+ const seen = new Map();
251
+ const pending = [service];
252
+ while (pending.length > 0) {
253
+ const name = pending.pop();
254
+ if (seen.has(name)) continue;
255
+ const entry = graph.get(name);
256
+ if (entry === undefined) throw new Error(`'${name}' is not a package of this workspace`);
257
+ seen.set(name, { name, dir: entry.dir });
258
+ pending.push(...entry.dependencies.filter((d) => graph.has(d)));
259
+ }
260
+ return [...seen.values()].sort((a, b) => (a.name < b.name ? -1 : 1));
261
+ }
262
+
263
+ const GIT_MODES = new Set(['100644', '100755', '120000']);
264
+
265
+ /** The package-relative tracked files of `dir` (git, run inside it: a submodule or the repository), each with its git mode. */
266
+ export function trackedFiles(root, dir) {
267
+ const cwd = join(root, dir);
268
+ const out = run('git', ['ls-files', '-s', '-z', '--', '.'], cwd);
269
+ const files = {};
270
+ for (const record of out.split('\0').filter(Boolean)) {
271
+ const tab = record.indexOf('\t');
272
+ const [mode] = record.slice(0, tab).split(' ');
273
+ const path = record.slice(tab + 1);
274
+ if (!GIT_MODES.has(mode)) throw new Error(`${dir}/${path} has git mode ${mode}, which effective-input does not key (a nested repository?)`);
275
+ files[path] = { mode, bytes: mode === '120000' ? Buffer.from(readlinkSync(join(cwd, path))) : readFileSync(join(cwd, path)) };
276
+ }
277
+ return files;
278
+ }
279
+
280
+ /** The key and parts of each of `services`, from one package-graph query and one read of the lockfile and tooling. */
281
+ export function effectiveInputKeys({ root, services, platform, baseImage, dockerfile, scripts, buildArgs = {} }) {
282
+ if (!Array.isArray(services) || services.length === 0) throw new Error('at least one service is required');
283
+ const names = scripts.map((x) => posix.basename(x));
284
+ const duplicate = names.find((n, i) => names.indexOf(n) !== i);
285
+ if (duplicate !== undefined) throw new Error(`two --script files are named '${duplicate}': the key names scripts by file name`);
286
+ const graph = packageGraph(root);
287
+ const toolManifest = JSON.parse(readFileSync(join(HERE, '..', 'package.json'), 'utf8'));
288
+ const rootConfig = Object.fromEntries(rootBuildInputNames(root).filter((n) => COMPILE_ROOT_FILE.test(n)).map((n) => [n, readFileSync(join(root, n))]));
289
+ const lockText = readFileSync(join(root, 'pnpm-lock.yaml'), 'utf8');
290
+ const install = installInputs({ lockText, packageJson: readFileSync(join(root, 'package.json'), 'utf8'), workspaceYaml: readFileSync(join(root, 'pnpm-workspace.yaml'), 'utf8') });
291
+ const tooling = {
292
+ dockerfile: readFileSync(dockerfile),
293
+ scripts: Object.fromEntries(scripts.map((s) => [posix.basename(s), readFileSync(s)])),
294
+ buildArgs,
295
+ stageTool: { name: toolManifest.name, version: toolManifest.version },
296
+ };
297
+ const filesByPackage = new Map();
298
+ const filesOf = ({ name, dir }) => {
299
+ if (!filesByPackage.has(name)) filesByPackage.set(name, trackedFiles(root, dir));
300
+ return filesByPackage.get(name);
301
+ };
302
+ return Object.fromEntries(services.map((service) => {
303
+ const closure = closureIn(graph, service);
304
+ return [service, effectiveInput({
305
+ service,
306
+ platform,
307
+ baseImage,
308
+ packages: closure.map((p) => ({ name: p.name, files: filesOf(p) })),
309
+ lockSlice: lockfileSlice(lockText, closure),
310
+ install,
311
+ tooling,
312
+ rootConfig,
313
+ })];
314
+ }));
315
+ }
316
+
317
+ /** The key and parts of one service (`effectiveInputKeys` of one). */
318
+ export function effectiveInputKey({ service, ...options }) {
319
+ return effectiveInputKeys({ ...options, services: [service] })[service];
320
+ }
321
+
322
+ /** The registries' tag lanes. `Candidate` images are built by the owl candidate run, `Develop` ones by develop CI. */
323
+ export const EffectiveInputLane = Object.freeze({ Candidate: 'candidate', Develop: 'develop' });
324
+ const LANE_PREFIX = Object.freeze({ [EffectiveInputLane.Candidate]: 'cand', [EffectiveInputLane.Develop]: 'ei' });
325
+
326
+ /** The one place a key becomes a registry tag: `cand-<key>` or `ei-<key>`. */
327
+ export function effectiveInputTag(key, lane) {
328
+ if (!/^[0-9a-f]{64}$/.test(key)) throw new Error(`'${key}' is not an effective-input key (64 lowercase hex)`);
329
+ if (!Object.hasOwn(LANE_PREFIX, lane)) throw new Error(`lane must be one of ${Object.values(EffectiveInputLane).join(', ')}, got '${lane}'`);
330
+ return `${LANE_PREFIX[lane]}-${key}`;
331
+ }
332
+
333
+ /**
334
+ * The `--out` map: per service `{ schema, platform, key, parts, tags: { candidate, develop } }`. The tags are built only by
335
+ * `effectiveInputTag`, so a consumer (release-policy's `prepare --effective-inputs`) reads them here and declares no prefix.
336
+ */
337
+ export function effectiveInputRecord(keys, platform) {
338
+ return Object.fromEntries(
339
+ Object.entries(keys).map(([service, { key, parts }]) => [
340
+ service,
341
+ { schema: KEY_SCHEMA, platform, key, parts, tags: Object.fromEntries(Object.values(EffectiveInputLane).map((lane) => [lane, effectiveInputTag(key, lane)])) },
342
+ ]),
343
+ );
344
+ }
345
+
346
+ // Only what the key can see is keyed: the closure's tracked files, the lockfile, the install settings, the Dockerfile, the
347
+ // `--script` files and the build args. A file the Dockerfile COPYs from OUTSIDE the closure (an entrypoint, infra/docker/*)
348
+ // is keyed only if it is passed as --script: the COPY sources are not derived (ARG-expanded paths such as REPO_DIR make that
349
+ // root-dependent), so the caller must list them.
350
+ const USAGE = 'usage: xema-effective-input --service <pkg> [--service <pkg>]... [--lane candidate|develop] --platform linux/<amd64|arm64> --base-image <name@sha256:digest> --dockerfile <file> [--script <file>]... [--build-arg NAME=VALUE]... [--out <file>] (--script: every file the Dockerfile COPYs from outside the closure)';
351
+ const HERE = fileURLToPath(new URL('.', import.meta.url));
352
+ const SINGLE = ['platform', 'base-image', 'dockerfile', 'out', 'lane'];
353
+
354
+ export function parseArgs(argv) {
355
+ const options = { service: [], script: [], 'build-arg': [] };
356
+ for (let i = 0; i < argv.length; i += 2) {
357
+ const name = argv[i]?.startsWith('--') ? argv[i].slice(2) : undefined;
358
+ const value = argv[i + 1];
359
+ if (name === undefined || value === undefined || ![...SINGLE, 'service', 'script', 'build-arg'].includes(name)) throw new Error(USAGE);
360
+ if (Array.isArray(options[name])) options[name].push(value);
361
+ else if (Object.hasOwn(options, name)) throw new Error(`--${name} is given twice`);
362
+ else options[name] = value;
363
+ }
364
+ for (const required of ['platform', 'base-image', 'dockerfile']) if (!Object.hasOwn(options, required)) throw new Error(USAGE);
365
+ if (options.service.length === 0) throw new Error(USAGE);
366
+ if (options.lane !== undefined && !Object.values(EffectiveInputLane).includes(options.lane)) throw new Error(USAGE);
367
+ const buildArgs = {};
368
+ for (const pair of options['build-arg']) {
369
+ const eq = pair.indexOf('=');
370
+ if (eq <= 0) throw new Error(`--build-arg wants NAME=VALUE, got '${pair}'`);
371
+ const name = pair.slice(0, eq);
372
+ if (Object.hasOwn(buildArgs, name)) throw new Error(`build arg ${name} is given twice`);
373
+ buildArgs[name] = pair.slice(eq + 1);
374
+ }
375
+ return {
376
+ services: options.service,
377
+ lane: options.lane,
378
+ platform: options.platform,
379
+ baseImage: options['base-image'],
380
+ dockerfile: resolve(options.dockerfile),
381
+ scripts: options.script.map((s) => resolve(s)),
382
+ buildArgs,
383
+ out: options.out,
384
+ };
385
+ }
386
+
387
+ if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
388
+ try {
389
+ const { out, lane, ...options } = parseArgs(process.argv.slice(2));
390
+ const keys = effectiveInputKeys({ root: process.cwd(), ...options });
391
+ if (out !== undefined) {
392
+ writeFileSync(out, `${JSON.stringify(effectiveInputRecord(keys, options.platform), null, 2)}\n`);
393
+ }
394
+ const shown = Object.fromEntries(Object.entries(keys).map(([service, { key }]) => [service, lane === undefined ? key : effectiveInputTag(key, lane)]));
395
+ process.stdout.write(options.services.length === 1 ? `${shown[options.services[0]]}\n` : `${JSON.stringify(shown)}\n`);
396
+ } catch (error) {
397
+ console.error(`::error::${error.message.split('\n')[0]}`);
398
+ console.error(error.message);
399
+ process.exit(1);
400
+ }
401
+ }
@@ -0,0 +1,259 @@
1
+ import assert from 'node:assert/strict';
2
+ import { execFileSync } from 'node:child_process';
3
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
4
+ import { tmpdir } from 'node:os';
5
+ import { dirname, join } from 'node:path';
6
+ import { test } from 'node:test';
7
+
8
+ import { EffectiveInputLane, canonicalJson, closureIn, effectiveInput, effectiveInputKeys, effectiveInputRecord, effectiveInputTag, installInputs, lockfileSlice, parseArgs, trackedFiles } from './effective-input.mjs';
9
+
10
+ const DIGEST = `ghcr.io/x/node-runtime@sha256:${'a'.repeat(64)}`;
11
+ const lock = (prefix, settings = 'autoInstallPeers: true') => `lockfileVersion: '9.0'
12
+
13
+ settings:
14
+ ${settings}
15
+
16
+ overrides:
17
+ left-pad: 1.3.0
18
+
19
+ patchedDependencies:
20
+ tiny@1.0.0:
21
+ hash: aaa
22
+ path: patches/tiny.patch
23
+
24
+ importers:
25
+
26
+ .:
27
+ devDependencies:
28
+ turbo:
29
+ specifier: ^2.0.0
30
+ version: 2.0.1
31
+
32
+ ${prefix}apps/svc:
33
+ dependencies:
34
+ '@x/lib':
35
+ specifier: workspace:*
36
+ version: link:../../packages/lib
37
+ left-pad:
38
+ specifier: ^1.0.0
39
+ version: 1.3.0
40
+
41
+ ${prefix}packages/lib:
42
+ dependencies:
43
+ tiny:
44
+ specifier: ^1.0.0
45
+ version: 1.0.0(peer@1.0.0)
46
+
47
+ packages:
48
+
49
+ left-pad@1.3.0:
50
+ resolution: {integrity: sha512-LEFT}
51
+
52
+ tiny@1.0.0:
53
+ resolution: {integrity: sha512-TINY}
54
+
55
+ peer@1.0.0:
56
+ resolution: {integrity: sha512-PEER}
57
+
58
+ unused@9.9.9:
59
+ resolution: {integrity: sha512-UNUSED}
60
+
61
+ snapshots:
62
+
63
+ left-pad@1.3.0: {}
64
+
65
+ tiny@1.0.0(peer@1.0.0):
66
+ dependencies:
67
+ peer: 1.0.0
68
+
69
+ peer@1.0.0: {}
70
+ `;
71
+
72
+ /** A workspace root at `prefix` ('' = a repository, 'repos/r/' = an aggregator), each package its own git repo. */
73
+ function workspace(prefix, { svcSource = 'export const a = 1;\n', libSource = 'export const b = 1;\n' } = {}) {
74
+ const root = mkdtempSync(join(tmpdir(), 'effective-input-'));
75
+ const files = { [`${prefix}apps/svc/src/main.ts`]: svcSource, [`${prefix}packages/lib/src/index.ts`]: libSource };
76
+ for (const [path, content] of Object.entries(files)) {
77
+ mkdirSync(join(root, dirname(path)), { recursive: true });
78
+ writeFileSync(join(root, path), content);
79
+ }
80
+ for (const dir of [`${prefix}apps/svc`, `${prefix}packages/lib`]) {
81
+ execFileSync('git', ['init', '-q'], { cwd: join(root, dir) });
82
+ execFileSync('git', ['add', '.'], { cwd: join(root, dir) });
83
+ }
84
+ const dirs = [{ name: '@x/lib', dir: `${prefix}packages/lib` }, { name: 'svc', dir: `${prefix}apps/svc` }];
85
+ return { root, dirs, prefix };
86
+ }
87
+
88
+ const PACKAGE_JSON = JSON.stringify({ packageManager: 'pnpm@10.0.0', pnpm: { onlyBuiltDependencies: ['esbuild'], overrides: { a: '1' }, patchedDependencies: { b: 'p' } } });
89
+ const WORKSPACE_YAML = '# header\npackages:\n - "apps/*"\n - "packages/*"\n\nonlyBuiltDependencies:\n - esbuild\ninjectWorkspacePackages: true\n';
90
+ const install = (over = {}) => installInputs({ lockText: lock(''), packageJson: PACKAGE_JSON, workspaceYaml: WORKSPACE_YAML, ...over });
91
+
92
+ const base = {
93
+ service: 'svc',
94
+ platform: 'linux/arm64',
95
+ baseImage: DIGEST,
96
+ tooling: { dockerfile: 'FROM x', scripts: { 'split.mjs': 's' }, buildArgs: { INSTALL_COSIGN: 'no' }, stageTool: { name: 't', version: '1.0.0' } },
97
+ rootConfig: { '.npmrc': 'a=b', 'tsconfig.base.json': '{}' },
98
+ };
99
+
100
+ function keyOf(ws, overrides = {}) {
101
+ const packages = ws.dirs.map(({ name, dir }) => ({ name, files: trackedFiles(ws.root, dir) }));
102
+ return effectiveInput({ ...base, packages, lockSlice: lockfileSlice(lock(ws.prefix), ws.dirs), install: install(), ...overrides }).key;
103
+ }
104
+
105
+ test('the same inputs give the same key from a repository root and from an aggregator root', () => {
106
+ const repo = workspace('');
107
+ const aggregator = workspace('repos/r/');
108
+ try {
109
+ assert.equal(keyOf(repo), keyOf(aggregator));
110
+ assert.equal(keyOf(repo), keyOf(repo));
111
+ assert.match(keyOf(repo), /^[0-9a-f]{64}$/);
112
+ } finally {
113
+ rmSync(repo.root, { recursive: true });
114
+ rmSync(aggregator.root, { recursive: true });
115
+ }
116
+ });
117
+
118
+ test('each input class changes the key', () => {
119
+ const ws = workspace('');
120
+ const changed = workspace('', { libSource: 'export const b = 2;\n' });
121
+ try {
122
+ const original = keyOf(ws);
123
+ const variants = {
124
+ 'workspace source (transitive package)': keyOf(changed),
125
+ 'lockfile slice': effectiveInput({
126
+ ...base,
127
+ packages: ws.dirs.map(({ name, dir }) => ({ name, files: trackedFiles(ws.root, dir) })),
128
+ install: install(),
129
+ lockSlice: lockfileSlice(lock('').replace('sha512-TINY', 'sha512-OTHER'), ws.dirs),
130
+ }).key,
131
+ 'file mode (executable bit)': (() => {
132
+ const files = Object.fromEntries(Object.entries(trackedFiles(ws.root, 'packages/lib')).map(([k, v]) => [k, { ...v, mode: '100755' }]));
133
+ return effectiveInput({ ...base, install: install(), lockSlice: lockfileSlice(lock(''), ws.dirs), packages: [{ name: '@x/lib', files }, { name: 'svc', files: trackedFiles(ws.root, 'apps/svc') }] }).key;
134
+ })(),
135
+ 'lock settings': keyOf(ws, { install: install({ lockText: lock('', 'autoInstallPeers: false') }) }),
136
+ 'lock overrides': keyOf(ws, { install: install({ lockText: lock('').replace('left-pad: 1.3.0', 'left-pad: 1.2.0') }) }),
137
+ 'lock patchedDependencies': keyOf(ws, { install: install({ lockText: lock('').replace('hash: aaa', 'hash: bbb') }) }),
138
+ 'package.json packageManager': keyOf(ws, { install: install({ packageJson: PACKAGE_JSON.replace('10.0.0', '10.1.0') }) }),
139
+ 'package.json pnpm block': keyOf(ws, { install: install({ packageJson: PACKAGE_JSON.replace('esbuild', 'sharp') }) }),
140
+ 'pnpm-workspace.yaml settings': keyOf(ws, { install: install({ workspaceYaml: WORKSPACE_YAML.replace('true', 'false') }) }),
141
+ dockerfile: keyOf(ws, { tooling: { ...base.tooling, dockerfile: 'FROM y' } }),
142
+ 'stage script': keyOf(ws, { tooling: { ...base.tooling, scripts: { 'split.mjs': 't' } } }),
143
+ 'stage tool version': keyOf(ws, { tooling: { ...base.tooling, stageTool: { name: 't', version: '1.0.1' } } }),
144
+ 'build arg': keyOf(ws, { tooling: { ...base.tooling, buildArgs: { INSTALL_COSIGN: 'yes' } } }),
145
+ 'base image digest': keyOf(ws, { baseImage: `ghcr.io/x/node-runtime@sha256:${'b'.repeat(64)}` }),
146
+ arch: keyOf(ws, { platform: 'linux/amd64' }),
147
+ 'root compile config': keyOf(ws, { rootConfig: { ...base.rootConfig, '.npmrc': 'a=c' } }),
148
+ };
149
+ for (const [name, key] of Object.entries(variants)) assert.notEqual(key, original, `${name} must change the key`);
150
+ assert.equal(new Set(Object.values(variants)).size, Object.keys(variants).length, 'the classes are distinct');
151
+ } finally {
152
+ rmSync(ws.root, { recursive: true });
153
+ rmSync(changed.root, { recursive: true });
154
+ }
155
+ });
156
+
157
+ test('a lockfile entry outside the closure does not change the key', () => {
158
+ const ws = workspace('');
159
+ try {
160
+ const slice = (text) => canonicalJson(lockfileSlice(text, ws.dirs));
161
+ assert.equal(slice(lock('')), slice(lock('').replace('sha512-UNUSED', 'sha512-ELSE')));
162
+ } finally {
163
+ rmSync(ws.root, { recursive: true });
164
+ }
165
+ });
166
+
167
+ test('a tag-only base image is refused, with the reason', () => {
168
+ const ws = workspace('');
169
+ try {
170
+ assert.throws(() => keyOf(ws, { baseImage: 'ghcr.io/x/node-runtime:22' }), /digest-pinned/);
171
+ assert.throws(() => keyOf(ws, { baseImage: 'ghcr.io/x/node-runtime@sha256:abc' }), /digest-pinned/);
172
+ } finally {
173
+ rmSync(ws.root, { recursive: true });
174
+ }
175
+ });
176
+
177
+ test('a per-root path or the commit is refused as a build arg', () => {
178
+ const ws = workspace('');
179
+ try {
180
+ for (const name of ['REPO_DIR', 'SERVICE_PATH', 'BUILD_SHA']) {
181
+ assert.throws(() => keyOf(ws, { tooling: { ...base.tooling, buildArgs: { [name]: 'x' } } }), new RegExp(name));
182
+ }
183
+ } finally {
184
+ rmSync(ws.root, { recursive: true });
185
+ }
186
+ });
187
+
188
+ test('a workspace link outside the closure and a missing snapshot fail fast', () => {
189
+ const ws = workspace('');
190
+ try {
191
+ assert.throws(() => lockfileSlice(lock(''), ws.dirs.filter((d) => d.name === 'svc')), /not in the closure/);
192
+ assert.throws(() => lockfileSlice(lock('').replace(' peer@1.0.0: {}\n', ''), ws.dirs), /no snapshot/);
193
+ } finally {
194
+ rmSync(ws.root, { recursive: true });
195
+ }
196
+ });
197
+
198
+ test('the command line takes repeated build args and refuses a duplicate', () => {
199
+ const argv = ['--service', 's', '--service', 't', '--platform', 'linux/arm64', '--base-image', DIGEST, '--dockerfile', 'D', '--build-arg', 'A=1', '--build-arg', 'B=2=3'];
200
+ assert.deepEqual(parseArgs(argv).buildArgs, { A: '1', B: '2=3' });
201
+ assert.deepEqual(parseArgs(argv).services, ['s', 't']);
202
+ assert.throws(() => parseArgs([...argv, '--lane', 'prod']), /usage/);
203
+ assert.throws(() => parseArgs([...argv, '--build-arg', 'A=2']), /given twice/);
204
+ });
205
+
206
+ test('tags name both lanes from one declaration and refuse a bad key or lane', () => {
207
+ const key = 'a'.repeat(64);
208
+ assert.equal(effectiveInputTag(key, EffectiveInputLane.Candidate), `cand-${key}`);
209
+ assert.equal(effectiveInputTag(key, EffectiveInputLane.Develop), `ei-${key}`);
210
+ assert.notEqual(effectiveInputTag(key, EffectiveInputLane.Candidate), effectiveInputTag(key, EffectiveInputLane.Develop));
211
+ assert.throws(() => effectiveInputTag(key, 'prod'), /lane must be one of/);
212
+ assert.throws(() => effectiveInputTag('abc', EffectiveInputLane.Develop), /not an effective-input key/);
213
+ assert.throws(() => effectiveInputTag('cand-' + key, EffectiveInputLane.Develop), /not an effective-input key/);
214
+ });
215
+
216
+ test('the closure is walked transitively from the package graph, including a package with no build task, and refuses an unknown service', () => {
217
+ const graph = new Map([
218
+ ['svc', { name: 'svc', dir: 'apps/svc', dependencies: ['mid', 'external'] }],
219
+ ['mid', { name: 'mid', dir: 'packages/mid', dependencies: ['leaf', 'svc-cycle-free'] }],
220
+ ['leaf', { name: 'leaf', dir: 'packages/leaf', dependencies: [] }],
221
+ ['svc-cycle-free', { name: 'svc-cycle-free', dir: 'packages/c', dependencies: ['mid'] }],
222
+ ['other', { name: 'other', dir: 'apps/other', dependencies: [] }],
223
+ ]);
224
+ assert.deepEqual(closureIn(graph, 'svc').map((p) => p.name), ['leaf', 'mid', 'svc', 'svc-cycle-free']);
225
+ assert.deepEqual(closureIn(graph, 'other').map((p) => p.name), ['other']);
226
+ assert.throws(() => closureIn(graph, 'nope'), /not a package/);
227
+ });
228
+
229
+ test('the workspace file keys its settings, not its package globs, comments or blank lines', () => {
230
+ const ws = workspace('');
231
+ try {
232
+ const original = keyOf(ws);
233
+ assert.equal(keyOf(ws, { install: install({ workspaceYaml: WORKSPACE_YAML.replace('"apps/*"', '"repos/*/apps/*"').replace('# header', '# other') + '\n\n' }) }), original);
234
+ } finally {
235
+ rmSync(ws.root, { recursive: true });
236
+ }
237
+ });
238
+
239
+ test('a lockfileVersion other than 9.0 is refused by name', () => {
240
+ const ws = workspace('');
241
+ try {
242
+ assert.throws(() => lockfileSlice(lock('').replace("'9.0'", "'6.0'"), ws.dirs), /lockfileVersion '6.0' is not '9.0'/);
243
+ assert.throws(() => install({ lockText: 'settings:\n' }), /lockfileVersion 'undefined'/);
244
+ } finally {
245
+ rmSync(ws.root, { recursive: true });
246
+ }
247
+ });
248
+
249
+ test('two scripts with the same file name are refused', () => {
250
+ assert.throws(() => effectiveInputKeys({ root: '.', services: ['s'], platform: 'linux/arm64', baseImage: DIGEST, dockerfile: 'D', scripts: ['a/x.mjs', 'b/x.mjs'] }), /two --script files are named 'x.mjs'/);
251
+ });
252
+
253
+ test('the --out map carries both lane tags per service, from effectiveInputTag', () => {
254
+ const key = 'c'.repeat(64);
255
+ const record = effectiveInputRecord({ 'a-api': { key, parts: { x: 1 } } }, 'linux/amd64');
256
+ assert.deepEqual(record['a-api'].tags, { candidate: effectiveInputTag(key, EffectiveInputLane.Candidate), develop: effectiveInputTag(key, EffectiveInputLane.Develop) });
257
+ assert.deepEqual(record['a-api'].tags, { candidate: `cand-${key}`, develop: `ei-${key}` });
258
+ assert.equal(record['a-api'].key, key);
259
+ });
@@ -24,12 +24,23 @@ export const CONTEXT_DIRECTORY = 'context';
24
24
  * context, which puts it in the content tag.
25
25
  */
26
26
  export const ROOT_BUILD_INPUT = /^(pnpm-lock\.yaml|package\.json|pnpm-workspace\.yaml|\.npmrc|\.pnpmfile\.cjs|turbo\.json|tsconfig[^/]*\.json)$/;
27
- /** The repository-root files that are build inputs (`ROOT_BUILD_INPUT`), sorted. */
28
- export const rootBuildInputNames = (root) =>
29
- readdirSync(root, { withFileTypes: true })
30
- .filter((entry) => entry.isFile() && ROOT_BUILD_INPUT.test(entry.name))
31
- .map((entry) => entry.name)
32
- .sort();
27
+ /**
28
+ * The repository-root files that are build inputs (`ROOT_BUILD_INPUT`), sorted. A root build input
29
+ * that is not a regular file (a symlink, a directory) is refused, naming it: the content tag frames
30
+ * a symlink's link text, not the file the build reads, so skipping or following it would let the
31
+ * staged bytes move under one tag.
32
+ */
33
+ export const rootBuildInputNames = (root) => {
34
+ const entries = readdirSync(root, { withFileTypes: true }).filter((entry) => ROOT_BUILD_INPUT.test(entry.name));
35
+ const irregular = entries.filter((entry) => !entry.isFile()).map((entry) => entry.name);
36
+ if (irregular.length > 0) {
37
+ throw new Error(
38
+ `root build input(s) ${irregular.sort().map((name) => `"${name}"`).join(', ')} in "${root}" are not regular files ` +
39
+ `(a symlink or a directory); the content tag cannot bind them, so commit each as a regular file`,
40
+ );
41
+ }
42
+ return entries.map((entry) => entry.name).sort();
43
+ };
33
44
  export const TOOL_NAME = '@xemahq/repo-build-tooling';
34
45
  const TOOL_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), '..');
35
46
 
@@ -0,0 +1,48 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { after, test } from 'node:test';
6
+
7
+ import { rootBuildInputNames } from './staged-image-proof.mjs';
8
+
9
+ const roots = [];
10
+ after(() => {
11
+ for (const root of roots) rmSync(root, { recursive: true, force: true });
12
+ });
13
+ const freshRoot = () => {
14
+ const root = realpathSync(mkdtempSync(join(realpathSync(tmpdir()), 'xema-staged-image-proof-')));
15
+ roots.push(root);
16
+ return root;
17
+ };
18
+
19
+ test('rootBuildInputNames lists the regular root build inputs, sorted, and nothing else', () => {
20
+ const root = freshRoot();
21
+ for (const name of ['turbo.json', 'package.json', 'tsconfig.base.json', 'README.md', '.node-version']) {
22
+ writeFileSync(join(root, name), '{}\n');
23
+ }
24
+ mkdirSync(join(root, 'services'));
25
+ assert.deepEqual(rootBuildInputNames(root), ['package.json', 'tsconfig.base.json', 'turbo.json']);
26
+ });
27
+
28
+ test('a symlinked root build input refuses, naming it', () => {
29
+ const root = freshRoot();
30
+ writeFileSync(join(root, 'package.json'), '{}\n');
31
+ mkdirSync(join(root, 'tooling'));
32
+ writeFileSync(join(root, 'tooling', 'turbo.json'), '{}\n');
33
+ symlinkSync('tooling/turbo.json', join(root, 'turbo.json'));
34
+ assert.throws(() => rootBuildInputNames(root), /root build input\(s\) "turbo\.json" .* are not regular files/);
35
+ });
36
+
37
+ test('a directory named like a root build input refuses', () => {
38
+ const root = freshRoot();
39
+ mkdirSync(join(root, 'tsconfig.json'));
40
+ assert.throws(() => rootBuildInputNames(root), /"tsconfig\.json" .* are not regular files/);
41
+ });
42
+
43
+ test('a symlink that is not a root build input is not this check\'s concern', () => {
44
+ const root = freshRoot();
45
+ writeFileSync(join(root, 'package.json'), '{}\n');
46
+ symlinkSync('package.json', join(root, 'README.md'));
47
+ assert.deepEqual(rootBuildInputNames(root), ['package.json']);
48
+ });