@xemahq/xema 0.26.0 → 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 +70 -37
- package/dist/assets/distribution-assets.manifest.json +1 -2
- package/dist/cli.js +2 -2
- package/dist/cli.js.map +1 -1
- package/dist/commands/dev.js +1 -0
- package/dist/commands/dev.js.map +1 -1
- package/dist/commands/down.d.ts.map +1 -1
- package/dist/commands/down.js +7 -1
- package/dist/commands/down.js.map +1 -1
- package/dist/commands/edition.d.ts +3 -0
- package/dist/commands/edition.d.ts.map +1 -0
- package/dist/commands/edition.js +46 -0
- package/dist/commands/edition.js.map +1 -0
- package/dist/commands/init.js +2 -2
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/serve.js +1 -0
- package/dist/commands/serve.js.map +1 -1
- package/dist/commands/up.js +1 -0
- package/dist/commands/up.js.map +1 -1
- package/dist/dev/env-defaults.d.ts.map +1 -1
- package/dist/dev/env-defaults.js +5 -2
- package/dist/dev/env-defaults.js.map +1 -1
- package/dist/dev/infra-preflight.d.ts +3 -2
- package/dist/dev/infra-preflight.d.ts.map +1 -1
- package/dist/dev/infra-preflight.js +18 -8
- package/dist/dev/infra-preflight.js.map +1 -1
- package/dist/edition/verify-image-evidence.bundle.mjs +54283 -0
- package/dist/edition/verify-image-evidence.bundle.provenance.json +2877 -0
- package/dist/runtime/config/schema.d.ts +1 -0
- package/dist/runtime/config/schema.d.ts.map +1 -1
- package/dist/runtime/custody/local-custody.d.ts +12 -0
- package/dist/runtime/custody/local-custody.d.ts.map +1 -0
- package/dist/runtime/custody/local-custody.js +22 -0
- package/dist/runtime/custody/local-custody.js.map +1 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.d.ts +18 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.js +50 -0
- package/dist/runtime/custody/openbao-custody-bootstrap.js.map +1 -0
- package/dist/runtime/custody/openbao-custody-store.d.ts +18 -0
- package/dist/runtime/custody/openbao-custody-store.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-custody-store.js +55 -0
- package/dist/runtime/custody/openbao-custody-store.js.map +1 -0
- package/dist/runtime/custody/openbao-http-client.d.ts +34 -0
- package/dist/runtime/custody/openbao-http-client.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-http-client.js +154 -0
- package/dist/runtime/custody/openbao-http-client.js.map +1 -0
- package/dist/runtime/custody/openbao-local-constants.d.ts +22 -0
- package/dist/runtime/custody/openbao-local-constants.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-local-constants.js +48 -0
- package/dist/runtime/custody/openbao-local-constants.js.map +1 -0
- package/dist/runtime/custody/openbao-local-runner.d.ts +16 -0
- package/dist/runtime/custody/openbao-local-runner.d.ts.map +1 -0
- package/dist/runtime/custody/openbao-local-runner.js +86 -0
- package/dist/runtime/custody/openbao-local-runner.js.map +1 -0
- package/dist/runtime/edition/run-entry.d.ts +9 -0
- package/dist/runtime/edition/run-entry.d.ts.map +1 -0
- package/dist/runtime/edition/run-entry.js +36 -0
- package/dist/runtime/edition/run-entry.js.map +1 -0
- package/dist/runtime/edition/verb-map.d.ts +60 -0
- package/dist/runtime/edition/verb-map.d.ts.map +1 -0
- package/dist/runtime/edition/verb-map.js +146 -0
- package/dist/runtime/edition/verb-map.js.map +1 -0
- package/dist/runtime/env-seeder.d.ts +2 -0
- package/dist/runtime/env-seeder.d.ts.map +1 -1
- package/dist/runtime/env-seeder.js +18 -3
- package/dist/runtime/env-seeder.js.map +1 -1
- package/dist/runtime/launch.d.ts +2 -1
- package/dist/runtime/launch.d.ts.map +1 -1
- package/dist/runtime/launch.js +31 -7
- package/dist/runtime/launch.js.map +1 -1
- package/dist/runtime/paths.d.ts +1 -2
- package/dist/runtime/paths.d.ts.map +1 -1
- package/dist/runtime/paths.js +0 -10
- package/dist/runtime/paths.js.map +1 -1
- package/dist/runtime/runner/chart-local-provenance.d.ts +20 -0
- package/dist/runtime/runner/chart-local-provenance.d.ts.map +1 -0
- package/dist/runtime/runner/chart-local-provenance.js +114 -0
- package/dist/runtime/runner/chart-local-provenance.js.map +1 -0
- package/dist/runtime/runner/chart-preflight.d.ts +8 -8
- package/dist/runtime/runner/chart-preflight.d.ts.map +1 -1
- package/dist/runtime/runner/chart-preflight.js +30 -69
- package/dist/runtime/runner/chart-preflight.js.map +1 -1
- package/dist/runtime/runner/chart-pull.d.ts +14 -0
- package/dist/runtime/runner/chart-pull.d.ts.map +1 -0
- package/dist/runtime/runner/chart-pull.js +96 -0
- package/dist/runtime/runner/chart-pull.js.map +1 -0
- package/dist/runtime/runner/chart-resolver.d.ts +4 -12
- package/dist/runtime/runner/chart-resolver.d.ts.map +1 -1
- package/dist/runtime/runner/chart-resolver.js +16 -62
- package/dist/runtime/runner/chart-resolver.js.map +1 -1
- package/dist/runtime/runner/chart-set-lock.d.ts +6 -20
- package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
- package/dist/runtime/runner/chart-set-lock.js +55 -99
- package/dist/runtime/runner/chart-set-lock.js.map +1 -1
- package/dist/runtime/runner/helm-cli.d.ts +3 -1
- package/dist/runtime/runner/helm-cli.d.ts.map +1 -1
- package/dist/runtime/runner/helm-cli.js +9 -7
- package/dist/runtime/runner/helm-cli.js.map +1 -1
- package/dist/runtime/runner/helm-runner.d.ts +4 -4
- package/dist/runtime/runner/helm-runner.d.ts.map +1 -1
- package/dist/runtime/runner/helm-runner.js +18 -16
- package/dist/runtime/runner/helm-runner.js.map +1 -1
- package/dist/runtime/sources/kinds/git.d.ts +1 -2
- package/dist/runtime/sources/kinds/git.d.ts.map +1 -1
- package/dist/runtime/sources/kinds/git.js +8 -5
- package/dist/runtime/sources/kinds/git.js.map +1 -1
- package/dist/runtime/sources/manifest-walk.d.ts +3 -1
- package/dist/runtime/sources/manifest-walk.d.ts.map +1 -1
- package/dist/runtime/sources/manifest-walk.js +8 -3
- package/dist/runtime/sources/manifest-walk.js.map +1 -1
- package/dist/runtime/sources/schema.d.ts +3 -0
- package/dist/runtime/sources/schema.d.ts.map +1 -1
- package/dist/runtime/sources/schema.js +6 -1
- package/dist/runtime/sources/schema.js.map +1 -1
- package/dist/runtime/supervisor.d.ts +2 -0
- package/dist/runtime/supervisor.d.ts.map +1 -1
- package/dist/runtime/supervisor.js +1 -0
- package/dist/runtime/supervisor.js.map +1 -1
- package/edition/README.md +313 -0
- package/edition/assemble-producer-input.mjs +1251 -0
- package/edition/authorization-base.mjs +347 -0
- package/edition/biome-content.mjs +268 -0
- package/edition/build-biome-index.mjs +175 -0
- package/edition/build-platform-service-index.mjs +288 -0
- package/edition/build-runtime-image-source-index.mjs +821 -0
- package/edition/built-verifier.mjs +55 -0
- package/edition/catalogue-edition.mjs +49 -0
- package/edition/catalogue-fetch.mjs +64 -0
- package/edition/catalogue.mjs +135 -0
- package/edition/chart-set-source-commit.mjs +92 -0
- package/edition/check-committed-image-evidence.mjs +383 -0
- package/edition/checkout-indexed-source-repositories.mjs +105 -0
- package/edition/comment-blanker.mjs +75 -0
- package/edition/connector-source-authority.mjs +60 -0
- package/edition/customer-definition.mjs +183 -0
- package/edition/customer-flags.mjs +7 -0
- package/edition/develop-customer-edition.mjs +99 -0
- package/edition/develop-edition-resolve.mjs +65 -0
- package/edition/develop-edition.mjs +274 -0
- package/edition/develop-freeze.mjs +173 -0
- package/edition/develop-image-coordinate.mjs +54 -0
- package/edition/develop-seal.mjs +77 -0
- package/edition/develop-snapshot-inputs.mjs +104 -0
- package/edition/dist-check.mjs +986 -0
- package/edition/distribution-evidence-set.mjs +987 -0
- package/edition/distribution-paths.mjs +191 -0
- package/edition/edition-artifact.mjs +284 -0
- package/edition/edition-biome-manifests.mjs +85 -0
- package/edition/edition-channel.mjs +6 -0
- package/edition/edition-chart-set.mjs +93 -0
- package/edition/edition-config.mjs +205 -0
- package/edition/edition-constants.mjs +57 -0
- package/edition/edition-contract.json +30 -0
- package/edition/edition-list.mjs +35 -0
- package/edition/edition-locks.mjs +69 -0
- package/edition/edition-protocol.mjs +228 -0
- package/edition/edition-workspace.mjs +187 -0
- package/edition/evidence-checkout.mjs +439 -0
- package/edition/evidence-grammars.mjs +16 -0
- package/edition/evidence-trust-pin.mjs +304 -0
- package/edition/evidence-trust-policy.mjs +294 -0
- package/edition/external-source-walk.mjs +463 -0
- package/edition/first-party-source-ids.mjs +59 -0
- package/edition/fleet-coordinate-finality.mjs +350 -0
- package/edition/git-source-auth.mjs +52 -0
- package/edition/image-delivery-entitlement-descriptor.mjs +106 -0
- package/edition/image-evidence-replay-state.mjs +242 -0
- package/edition/image-evidence.mjs +1233 -0
- package/edition/kernel-contract-authority.mjs +443 -0
- package/edition/lib.mjs +3021 -0
- package/edition/no-follow-file.mjs +94 -0
- package/edition/oci-evidence-verbs.mjs +392 -0
- package/edition/oci-image-source-evidence.mjs +239 -0
- package/edition/oras.mjs +648 -0
- package/edition/pin-source.mjs +227 -0
- package/edition/plan-image-evidence-refresh.mjs +408 -0
- package/edition/platform-lock-readability.mjs +892 -0
- package/edition/platform-service-class.mjs +18 -0
- package/edition/platform-service-source-authority.mjs +105 -0
- package/edition/positive-closure.mjs +162 -0
- package/edition/produce-distribution-completeness-evidence.mjs +311 -0
- package/edition/produce-image-delivery-entitlement-evidence.mjs +294 -0
- package/edition/produce-image-source-evidence.mjs +765 -0
- package/edition/producer-evidence-bundle.mjs +157 -0
- package/edition/registry-credentials.mjs +193 -0
- package/edition/release-catalogue.mjs +39 -0
- package/edition/release-channel-guard.mjs +109 -0
- package/edition/release-edition-artifact.mjs +157 -0
- package/edition/remedy-command.mjs +89 -0
- package/edition/repin-evidence-trust.mjs +101 -0
- package/edition/required-resource-provider.mjs +58 -0
- package/edition/resolve-chart-set.mjs +415 -0
- package/edition/resolve-customer-edition.mjs +288 -0
- package/edition/resolve-distribution.mjs +2318 -0
- package/edition/resolve-edition.mjs +186 -0
- package/edition/resolve-images.mjs +1606 -0
- package/edition/seal-image-digests.mjs +855 -0
- package/edition/sealed-evidence-accept.mjs +82 -0
- package/edition/source-authority.mjs +725 -0
- package/edition/source-closure.mjs +116 -0
- package/edition/strict-json.mjs +121 -0
- package/edition/string-order.mjs +5 -0
- package/edition/trust-policy-arming.mjs +120 -0
- package/edition/trusted-executable.mjs +166 -0
- package/edition/verifier-binding.mjs +45 -0
- package/edition/verifier-files.mjs +8 -0
- package/edition/verifier-kernel-schemas.mjs +22 -0
- package/edition/verifier-provenance.mjs +64 -0
- package/edition/verify-image-evidence.mjs +458 -0
- package/edition/verify-image-platforms.mjs +495 -0
- package/edition/why.mjs +56 -0
- package/edition/workspace-sections.mjs +195 -0
- package/examples/06-biome-sources.yaml +1 -0
- package/package.json +14 -6
- package/dist/commands/dist.d.ts +0 -16
- package/dist/commands/dist.d.ts.map +0 -1
- package/dist/commands/dist.js +0 -182
- package/dist/commands/dist.js.map +0 -1
- package/dist/runtime/distribution/available-biome.d.ts +0 -10
- package/dist/runtime/distribution/available-biome.d.ts.map +0 -1
- package/dist/runtime/distribution/available-biome.js +0 -156
- package/dist/runtime/distribution/available-biome.js.map +0 -1
- package/dist/runtime/distribution/compose.d.ts +0 -23
- package/dist/runtime/distribution/compose.d.ts.map +0 -1
- package/dist/runtime/distribution/compose.js +0 -48
- package/dist/runtime/distribution/compose.js.map +0 -1
- package/dist/runtime/runner/chart-trust.d.ts +0 -57
- package/dist/runtime/runner/chart-trust.d.ts.map +0 -1
- package/dist/runtime/runner/chart-trust.js +0 -270
- package/dist/runtime/runner/chart-trust.js.map +0 -1
|
@@ -0,0 +1,1606 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Resolve a distribution into schema-v3 actual runtime image subjects.
|
|
3
|
+
// Services, the host shell, and independently sourced platform images are
|
|
4
|
+
// subjects. Biomes remain selection/ownership units and never become fictitious
|
|
5
|
+
// one-container-per-Biome build outputs.
|
|
6
|
+
//
|
|
7
|
+
// ── "REGENERATE AND DIFF" IS NOT A VALID CHECK ON THIS ARTIFACT ─────────────
|
|
8
|
+
//
|
|
9
|
+
// This resolver emits NO `imageDigest`. The digests in a committed
|
|
10
|
+
// `images.lock.json` are written afterwards, by `seal-image-digests.mjs`, and
|
|
11
|
+
// nothing here reproduces them. So the obvious verification — regenerate the
|
|
12
|
+
// lock and diff it against the committed one — reports drift that is purely the
|
|
13
|
+
// absence of a seal, on every subject, every time.
|
|
14
|
+
//
|
|
15
|
+
// Measured 2026-09-23 on `origin/main`, running this script unmodified against a
|
|
16
|
+
// pristine checkout with two editions left alone as controls:
|
|
17
|
+
//
|
|
18
|
+
// core 54 of 54 subjects carried a digest -> 0 after; `grep -c imageDigest` = 0
|
|
19
|
+
// cloud 66 of 66 -> 0 after
|
|
20
|
+
// minimal / oss (not re-resolved) -> 45 and 63, untouched
|
|
21
|
+
//
|
|
22
|
+
// The field is ABSENT rather than emptied, on `subjects`, `platformServiceBindings`
|
|
23
|
+
// and `webBiomes` alike, and no merge path carries a subset forward. Two
|
|
24
|
+
// consequences, and the second is the one that costs a night:
|
|
25
|
+
//
|
|
26
|
+
// - to verify a lock, compare a fresh resolve against ANOTHER fresh resolve
|
|
27
|
+
// (which cancels both the seal and the wall-clock `resolvedAt`), never
|
|
28
|
+
// against the committed file;
|
|
29
|
+
// - a re-resolve DISARMS the sealer's moved-tag refusal, which fires only when
|
|
30
|
+
// the lock being sealed already carries a digest for that subject. That is
|
|
31
|
+
// not a bypass to reach for — it is why "re-resolve then seal" and "re-seal
|
|
32
|
+
// the committed lock" are different operations with different verdicts, and
|
|
33
|
+
// anyone comparing their outcomes needs to know which one they ran.
|
|
34
|
+
|
|
35
|
+
import { execFileSync } from 'node:child_process';
|
|
36
|
+
import { writeFileSync } from 'node:fs';
|
|
37
|
+
import { join, relative, sep } from 'node:path';
|
|
38
|
+
|
|
39
|
+
import {
|
|
40
|
+
assertCanonicalDistributionId,
|
|
41
|
+
distributionOutputPath,
|
|
42
|
+
readBoundDistributionFile,
|
|
43
|
+
} from './distribution-paths.mjs';
|
|
44
|
+
import { editionWorkspaceConfig } from './edition-config.mjs';
|
|
45
|
+
import { biomeIndexPath, editionsRoot, runtimeImageSourceIndexPath, WorkspaceRoot, configureEditionWorkspaceFromProcess, isEntryModule, sourcePath, sourcesRoot } from './edition-workspace.mjs';
|
|
46
|
+
import {
|
|
47
|
+
externalSourceCacheDir,
|
|
48
|
+
materializeGitSource,
|
|
49
|
+
} from './external-source-walk.mjs';
|
|
50
|
+
import { assertDeclaredSourceId, firstPartySourceId } from './first-party-source-ids.mjs';
|
|
51
|
+
import {
|
|
52
|
+
CROSS_REPO_BUILD_INPUTS_PATH,
|
|
53
|
+
compareStrings,
|
|
54
|
+
crossRepoInputKey,
|
|
55
|
+
hashImageBuildInputs,
|
|
56
|
+
imageInputSourceHashAlgorithm,
|
|
57
|
+
loadKernelDistributionSchemas,
|
|
58
|
+
parseResolvedDistributionLock,
|
|
59
|
+
readGitJsonAtCommit,
|
|
60
|
+
resolveCrossRepoVendoredInputs,
|
|
61
|
+
serializeJson,
|
|
62
|
+
sha256Hex,
|
|
63
|
+
validateImmutableDistributionLock,
|
|
64
|
+
validateImmutableImageLock,
|
|
65
|
+
} from './lib.mjs';
|
|
66
|
+
import { assertReleaseContent } from './release-channel-guard.mjs';
|
|
67
|
+
import { composeEdition } from './resolve-distribution.mjs';
|
|
68
|
+
import { readStrictJsonFile } from './strict-json.mjs';
|
|
69
|
+
|
|
70
|
+
if (isEntryModule(import.meta.url)) {
|
|
71
|
+
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache, WorkspaceRoot.SourceHash]);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const firstPartyImageRegistry = () => editionWorkspaceConfig().imageRegistry;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* WHERE A LOCKED BIOME'S SOURCE TREE IS ON DISK — the one answer this plane
|
|
78
|
+
* needs, and the one thing every read in this file used to assume.
|
|
79
|
+
*
|
|
80
|
+
* Every Git read here was `sourcePath(<repo>)`, which encodes an
|
|
81
|
+
* assumption that held while every biome came from a sibling repository in the
|
|
82
|
+
* aggregator's layout: that a `repo` HANDLE IS A PATH under `sourcesRoot()`. A
|
|
83
|
+
* biome drawn from an EXTERNAL source breaks it — its `repo` is that source's
|
|
84
|
+
* own LOGICAL name (`sources[].name`), deliberately not a `repos/<x>` path,
|
|
85
|
+
* because a hosting repository's name is source-management detail and must not
|
|
86
|
+
* become a biome's provenance. `<sourcesRoot()>/<logical name>` is a directory that
|
|
87
|
+
* does not exist, so the content hash could not be computed at all.
|
|
88
|
+
*
|
|
89
|
+
* The fix is ONE locator rather than a special case per read site: a `repo`
|
|
90
|
+
* handle resolves to a checkout DIRECTORY plus the BASE its `path` is relative
|
|
91
|
+
* to. Both kinds answer the same shape — a first-party repo's paths are
|
|
92
|
+
* `sourcesRoot()`-relative, an external source's are relative to the cache the walk
|
|
93
|
+
* materialised it into — so every call site reads the same two fields and none
|
|
94
|
+
* of them knows which kind it has.
|
|
95
|
+
*
|
|
96
|
+
* WHAT THE HASH THEN COVERS IS UNCHANGED, and that is the point of routing the
|
|
97
|
+
* DIRECTORY rather than teaching the hash about sources. `hashImageBuildInputs`
|
|
98
|
+
* reads the service's own source tree, the transitive workspace-package
|
|
99
|
+
* closure, the exact Dockerfile blob the build resolves to, every file the
|
|
100
|
+
* Dockerfile copies out of the context, the filtered pnpm lock graph, the
|
|
101
|
+
* context-directory residue and any vendored cross-repo tree — all out of the
|
|
102
|
+
* Git objects of whatever repository it is handed, at an exact commit. Handing
|
|
103
|
+
* it an external checkout therefore covers that repository's FULL build input
|
|
104
|
+
* by the same construction it already covers a first-party one, with no second
|
|
105
|
+
* definition of what an image is built from.
|
|
106
|
+
*
|
|
107
|
+
* MATERIALISATION IS AT THE LOCKED COMMIT, never at the declared ref. The lock
|
|
108
|
+
* already recorded the exact commit its manifests were read at; re-resolving a
|
|
109
|
+
* ref here would let a hash be computed over a different revision than the one
|
|
110
|
+
* the edition locked, silently and in the direction nothing detects.
|
|
111
|
+
* `materializeGitSource` short-circuits an exact 40-hex ref instead of sending
|
|
112
|
+
* it to `ls-remote`, so passing the commit AS the ref is the pin rather than a
|
|
113
|
+
* re-resolution.
|
|
114
|
+
*/
|
|
115
|
+
export function externalSourceTrees(
|
|
116
|
+
lock,
|
|
117
|
+
{ cacheRoot = externalSourceCacheDir(), materialize = materializeGitSource } = {},
|
|
118
|
+
) {
|
|
119
|
+
const byRepo = new Map();
|
|
120
|
+
for (const biome of lock.biomes ?? []) {
|
|
121
|
+
if (biome.source === undefined) {continue;}
|
|
122
|
+
const name = biome.source.name;
|
|
123
|
+
const incumbent = byRepo.get(name);
|
|
124
|
+
if (incumbent) {
|
|
125
|
+
if (incumbent.commit !== biome.source.commit) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
`distribution "${lock.distributionId}" pins two commits for external source ` +
|
|
128
|
+
`"${name}" (${incumbent.commit} and ${biome.source.commit}) — one source is one ` +
|
|
129
|
+
'revision, so an image built from it cannot be addressed',
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
byRepo.set(name, { source: biome.source, commit: biome.source.commit });
|
|
135
|
+
}
|
|
136
|
+
const trees = new Map();
|
|
137
|
+
for (const [name, { source, commit }] of byRepo) {
|
|
138
|
+
const { directory } = materialize({ source: { ...source, ref: commit }, cacheRoot });
|
|
139
|
+
trees.set(name, { dir: directory, base: cacheRoot });
|
|
140
|
+
}
|
|
141
|
+
return trees;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* `repo` handle -> `{ dir, base }`.
|
|
146
|
+
*
|
|
147
|
+
* An unknown handle is FIRST-PARTY, which is the only default that can be
|
|
148
|
+
* correct: the external set is CLOSED by the lock's own `sources[]`, so
|
|
149
|
+
* anything outside it came from this fleet's layout. A handle that is neither
|
|
150
|
+
* resolves to a directory that does not exist and fails at the first Git read,
|
|
151
|
+
* naming the path — a loud failure rather than a silent fall back to an empty
|
|
152
|
+
* tree, which would hash to a coordinate nothing ever published.
|
|
153
|
+
*/
|
|
154
|
+
export function sourceTreeLocator(externalTrees) {
|
|
155
|
+
return (repo) =>
|
|
156
|
+
externalTrees.get(repo) ?? { dir: sourcePath(repo), base: sourcesRoot() };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The first-party-only locator — for callers that hold no lock to read. */
|
|
160
|
+
const FIRST_PARTY_TREES = (repo) => ({ dir: sourcePath(repo), base: sourcesRoot() });
|
|
161
|
+
const imageBuildInputHashByIdentity = new Map();
|
|
162
|
+
const sourceManifestHashByIdentity = new Map();
|
|
163
|
+
|
|
164
|
+
function readJson(path) {
|
|
165
|
+
return readStrictJsonFile(path);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Memoized image build-input closure hash.
|
|
170
|
+
*
|
|
171
|
+
* Every distribution that ships a service resolves the SAME closure for it, and
|
|
172
|
+
* the closure reads immutable Git objects at an exact commit, so one digest per
|
|
173
|
+
* (service, service path, commit, vendored inputs) is exact — not a cache with
|
|
174
|
+
* an invalidation story.
|
|
175
|
+
*/
|
|
176
|
+
function exactImageBuildInputHash(request, algorithm) {
|
|
177
|
+
const identity = serializeJson({ request, algorithm });
|
|
178
|
+
const cached = imageBuildInputHashByIdentity.get(identity);
|
|
179
|
+
if (cached) {return cached;}
|
|
180
|
+
const hash = hashImageBuildInputs(request, algorithm);
|
|
181
|
+
imageBuildInputHashByIdentity.set(identity, hash);
|
|
182
|
+
return hash;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function exactSourceManifestHash(
|
|
186
|
+
repoDirectory,
|
|
187
|
+
repoRelativePath,
|
|
188
|
+
sourceCommit,
|
|
189
|
+
) {
|
|
190
|
+
const identity = serializeJson({
|
|
191
|
+
repoDirectory,
|
|
192
|
+
repoRelativePath,
|
|
193
|
+
sourceCommit,
|
|
194
|
+
});
|
|
195
|
+
const cached = sourceManifestHashByIdentity.get(identity);
|
|
196
|
+
if (cached) {return cached;}
|
|
197
|
+
const hash = sha256Hex(
|
|
198
|
+
serializeJson(
|
|
199
|
+
readGitJsonAtCommit(repoDirectory, repoRelativePath, sourceCommit),
|
|
200
|
+
),
|
|
201
|
+
);
|
|
202
|
+
sourceManifestHashByIdentity.set(identity, hash);
|
|
203
|
+
return hash;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* Key of one vendored input WITHIN one consumer repository.
|
|
208
|
+
*
|
|
209
|
+
* Both the store and the lookup go through this, so the two sides cannot spell
|
|
210
|
+
* the separator differently — which they did, and every lookup missed.
|
|
211
|
+
*/
|
|
212
|
+
function consumerInputKey(consumerRepo, input) {
|
|
213
|
+
return `${consumerRepo}\u0000${crossRepoInputKey(input)}`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Compose one image ref: WHERE the artifact lives, and WHAT it is called.
|
|
218
|
+
*
|
|
219
|
+
* `registry` defaults to the vendor namespace because that is where everything
|
|
220
|
+
* this organisation BUILDS is published, and every caller but one wants it. The
|
|
221
|
+
* exception is a biome drawn from an external source, whose coordinate is a
|
|
222
|
+
* DECLARED fact carried on the lock (`biome.source.imageRegistry`) rather than a
|
|
223
|
+
* constant — see the biomes loop in `resolveImageStructure`.
|
|
224
|
+
*
|
|
225
|
+
* `namePrefix` is the second half of that same declared coordinate, and it is
|
|
226
|
+
* what keeps a subject's LOGICAL id separate from its PUBLISHED name. Those two
|
|
227
|
+
* are the same string for every first-party subject — measured 2026-09-15, 67
|
|
228
|
+
* of 67 deploy-manifest entries and 60 of 60 `oci-image` component keys — but
|
|
229
|
+
* that is a property of owning the whole of the first-party registry namespace, not a law. A
|
|
230
|
+
* customer organisation's namespace is flat and carries every product that
|
|
231
|
+
* customer ships, so a bare component name there identifies nothing in
|
|
232
|
+
* particular and the first collision silently overwrites somebody's tags.
|
|
233
|
+
*
|
|
234
|
+
* DEFAULTING TO THE EMPTY STRING IS THE IDENTITY ELEMENT OF CONCATENATION, not
|
|
235
|
+
* a fallback: an unqualified name is what every existing subject already gets,
|
|
236
|
+
* so the absent case reproduces today's behaviour exactly rather than
|
|
237
|
+
* substituting something. Contrast `registry`, whose absence on an external
|
|
238
|
+
* source is REFUSED in `resolveImageStructure` — there the only available
|
|
239
|
+
* default names the wrong ORG, which is a misdirection rather than a preference.
|
|
240
|
+
*
|
|
241
|
+
* Both parameters are deliberately not module-level mutables: two editions are
|
|
242
|
+
* resolved in one process by `dist:check`, and a coordinate held in module scope
|
|
243
|
+
* would leak one edition's customer namespace into the next one's subjects.
|
|
244
|
+
*/
|
|
245
|
+
export function imageRefForSubject(
|
|
246
|
+
subjectId,
|
|
247
|
+
registry = firstPartyImageRegistry(),
|
|
248
|
+
namePrefix = '',
|
|
249
|
+
) {
|
|
250
|
+
return `${registry}/${namePrefix}${subjectId}`;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* The OCI platforms one distribution requires every runtime image to ship.
|
|
255
|
+
*
|
|
256
|
+
* Read from that edition's OWN `xema-distribution.json`
|
|
257
|
+
* and projected onto image-lock platform
|
|
258
|
+
* coordinates by the kernel. This deliberately replaced a global frozen
|
|
259
|
+
* `[{linux, amd64}]` constant: with one constant, every subject of every
|
|
260
|
+
* edition declared amd64 — including `appliance-lean`/`appliance-base`, whose
|
|
261
|
+
* installer (`xema-appliance/os/dgx/install-k3s.sh`, `ansible/site.yml`) refuses
|
|
262
|
+
* any host that is not ARM64. The image plane therefore declared the one
|
|
263
|
+
* architecture the appliance could not run, and nothing in the chain could say
|
|
264
|
+
* so.
|
|
265
|
+
*
|
|
266
|
+
* The kernel owns the vocabulary and the fail-fast: an absent, empty, unknown,
|
|
267
|
+
* duplicated or misordered declaration throws here rather than resolving to a
|
|
268
|
+
* default. There is no default.
|
|
269
|
+
*/
|
|
270
|
+
export function resolveDistributionRequiredPlatforms(schemas, id) {
|
|
271
|
+
assertCanonicalDistributionId(id);
|
|
272
|
+
const { requiredPlatforms } = composeEdition(schemas.parseDistribution, id);
|
|
273
|
+
return schemas.resolveRuntimeImagePlatforms(requiredPlatforms);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The edition's DECLARED `sources[]`,
|
|
278
|
+
*
|
|
279
|
+
* WHY THIS IS A PARAMETER AND NOT A LOCK FIELD. `imageNamePrefix` is producer
|
|
280
|
+
* INPUT: it composes the image NAME and is then spent, because the lock carries
|
|
281
|
+
* the fully-qualified `imageRef`. It cannot travel in the lock to reach the
|
|
282
|
+
* resolver — `DistributionLockSchema` is `.strict()` and learned that key only
|
|
283
|
+
* in `@xemahq/contracts` 0.10.0, so a pinned image resolving `^0.9.x` refuses
|
|
284
|
+
* the whole lock at boot. Persisting it took presenca-dev's three lock
|
|
285
|
+
* consumers into CrashLoopBackOff on 2026-09-17.
|
|
286
|
+
*
|
|
287
|
+
* So the producer reads its OWN declaration, and `requiredPlatforms` one
|
|
288
|
+
* function up is the precedent: a declared fact the pure structure resolver
|
|
289
|
+
* needs arrives as an argument the impure caller composes. That keeps
|
|
290
|
+
* `resolveImageStructure` pure over the committed lock and runtime index, which
|
|
291
|
+
* is what lets `dist-check` grade an images lock offline.
|
|
292
|
+
*/
|
|
293
|
+
export function resolveDistributionDeclaredSources(schemas, id) {
|
|
294
|
+
assertCanonicalDistributionId(id);
|
|
295
|
+
const { declaredSources } = composeEdition(schemas.parseDistribution, id);
|
|
296
|
+
return declaredSources;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
function subjectKey(subject) {
|
|
300
|
+
return `${subject.kind}\0${subject.id}`;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function bindingKey(binding) {
|
|
304
|
+
return `${binding.subjectKind}\0${binding.subjectId}`;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function compareSubject(left, right) {
|
|
308
|
+
return (
|
|
309
|
+
compareStrings(left.kind, right.kind) || compareStrings(left.id, right.id)
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function compareOwner(left, right) {
|
|
314
|
+
return compareStrings(serializeJson(left), serializeJson(right));
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function addSubject(subjects, candidate) {
|
|
318
|
+
const key = subjectKey(candidate);
|
|
319
|
+
const existing = subjects.get(key);
|
|
320
|
+
if (!existing) {
|
|
321
|
+
subjects.set(key, {
|
|
322
|
+
...candidate,
|
|
323
|
+
owners: [...candidate.owners],
|
|
324
|
+
});
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
const withoutOwners = ({ owners: _owners, ...value }) => value;
|
|
328
|
+
if (
|
|
329
|
+
serializeJson(withoutOwners(existing)) !==
|
|
330
|
+
serializeJson(withoutOwners(candidate))
|
|
331
|
+
) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`runtime image subject "${candidate.kind}:${candidate.id}" has conflicting source identities`,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
const ownerKeys = new Set(existing.owners.map(serializeJson));
|
|
337
|
+
for (const owner of candidate.owners) {
|
|
338
|
+
if (!ownerKeys.has(serializeJson(owner))) {existing.owners.push(owner);}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* The refusal for a SELECTED platform service whose declared artifact
|
|
344
|
+
* dependency no selected biome produces.
|
|
345
|
+
*
|
|
346
|
+
* The edge is DECLARED, never inferred from a name: a platform service's
|
|
347
|
+
* `runtimeBinding` (platform-service-source-authority.mjs, projected into
|
|
348
|
+
* `runtime-image-source-index.json#platformServiceBindings`) says which image
|
|
349
|
+
* subject carries it — `opencode-image-prepuller` → `runtime-component:
|
|
350
|
+
* opencode-worker`. When that subject is not supplemental it is produced by a
|
|
351
|
+
* biome, and the biome that produces it is the one whose OWN manifest declares
|
|
352
|
+
* it (`services` / `runtimeComponents` in the biome index). So excluding the
|
|
353
|
+
* provider biome while keeping the dependent service is refused here, naming
|
|
354
|
+
* both ends; excluding both leaves no edge and resolves.
|
|
355
|
+
*/
|
|
356
|
+
function missingArtifactDependencyMessage(lock, serviceName, binding, biomeIndex) {
|
|
357
|
+
const field =
|
|
358
|
+
binding.subjectKind === 'runtime-component' ? 'runtimeComponents' : 'services';
|
|
359
|
+
const providers = Array.isArray(biomeIndex)
|
|
360
|
+
? [
|
|
361
|
+
...new Set(
|
|
362
|
+
biomeIndex
|
|
363
|
+
.filter((entry) => (entry[field] ?? []).includes(binding.subjectId))
|
|
364
|
+
.map((entry) => entry.id),
|
|
365
|
+
),
|
|
366
|
+
].sort(compareStrings)
|
|
367
|
+
: [];
|
|
368
|
+
const providerText =
|
|
369
|
+
providers.length > 0
|
|
370
|
+
? `, which is declared by biome ${providers.map((id) => `"${id}"`).join(' / ')}` +
|
|
371
|
+
' — and this edition does not select it'
|
|
372
|
+
: ', and no biome this edition selects declares it';
|
|
373
|
+
return (
|
|
374
|
+
`distribution "${lock.distributionId}" ships platform service "${serviceName}", ` +
|
|
375
|
+
`whose declared runtime binding requires image subject ` +
|
|
376
|
+
`"${binding.subjectKind}:${binding.subjectId}"${providerText}. ` +
|
|
377
|
+
`Either keep the providing biome, or exclude "${serviceName}" as well — ` +
|
|
378
|
+
'refusing rather than resolving an edition whose service has no image.'
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Offline roster derivation. It reads only committed locks/indexes and performs
|
|
384
|
+
* no registry, GitHub, DigitalOcean, or source-worktree access.
|
|
385
|
+
*
|
|
386
|
+
* `requiredPlatforms` is supplied by the caller (see
|
|
387
|
+
* {@link resolveDistributionRequiredPlatforms}) rather than read here, so this
|
|
388
|
+
* function stays a pure projection of its inputs — and so a caller that forgets
|
|
389
|
+
* the distribution's declaration gets a hard error instead of a default.
|
|
390
|
+
*
|
|
391
|
+
* THE ROSTER IS THE ARTIFACT CLOSURE OF THE LOGICAL CLOSURE, and nothing else.
|
|
392
|
+
* `runtime-image-source-index.json` is the FLEET's catalogue — every platform
|
|
393
|
+
* service binding, every supplemental subject — and an edition selects from it.
|
|
394
|
+
* A binding for a platform service this edition does not ship is simply not
|
|
395
|
+
* part of this edition's artifact graph and is dropped; it is never a reason to
|
|
396
|
+
* refuse. What DOES refuse is a SELECTED service whose declared artifact
|
|
397
|
+
* dependency is absent from the closure (see `missingArtifactDependencyMessage`).
|
|
398
|
+
*
|
|
399
|
+
* `options.biomeIndex` is optional and used ONLY to name, in a refusal, which
|
|
400
|
+
* biome declares the missing artifact. It never changes a resolved roster.
|
|
401
|
+
*/
|
|
402
|
+
export function resolveImageStructure(
|
|
403
|
+
lock,
|
|
404
|
+
runtimeSourceIndex,
|
|
405
|
+
enums,
|
|
406
|
+
requiredPlatforms,
|
|
407
|
+
declaredSources,
|
|
408
|
+
{ biomeIndex } = {},
|
|
409
|
+
) {
|
|
410
|
+
if (!Array.isArray(requiredPlatforms) || requiredPlatforms.length === 0) {
|
|
411
|
+
throw new Error(
|
|
412
|
+
`distribution "${lock.distributionId}" resolved no required image platforms — ` +
|
|
413
|
+
`every distribution declares requiredPlatforms in its xema-distribution.json ` +
|
|
414
|
+
`and there is no default; a silent default is the bug this replaced`,
|
|
415
|
+
);
|
|
416
|
+
}
|
|
417
|
+
const subjects = new Map();
|
|
418
|
+
const webBiomes = [];
|
|
419
|
+
const platformServiceBindings = [];
|
|
420
|
+
const repositoryPins = new Map(
|
|
421
|
+
runtimeSourceIndex.repositoryPins.map((repository) => [
|
|
422
|
+
repository.repo,
|
|
423
|
+
repository,
|
|
424
|
+
]),
|
|
425
|
+
);
|
|
426
|
+
const requireRepositoryPin = (repo) => {
|
|
427
|
+
const pin = repositoryPins.get(repo);
|
|
428
|
+
if (!pin) {
|
|
429
|
+
throw new Error(`runtime image source "${repo}" has no repository pin`);
|
|
430
|
+
}
|
|
431
|
+
return pin;
|
|
432
|
+
};
|
|
433
|
+
|
|
434
|
+
for (const biome of lock.biomes) {
|
|
435
|
+
// WHERE THIS BIOME'S IMAGES LIVE, and WHICH COMMIT THEY ARE BUILT FROM.
|
|
436
|
+
// Both answers move together when a biome is drawn from an external source,
|
|
437
|
+
// because both are facts about that source rather than about this fleet.
|
|
438
|
+
//
|
|
439
|
+
// ── THE REGISTRY ──
|
|
440
|
+
//
|
|
441
|
+
// A first-party biome is published to the vendor namespace. A biome drawn
|
|
442
|
+
// from an EXTERNAL source is not, and its coordinate is DECLARED — on the
|
|
443
|
+
// `sources[]` entry as `imageRegistry`, carried into the lock verbatim by
|
|
444
|
+
// `external-source-walk.mjs` and read HERE. This used to refuse
|
|
445
|
+
// unconditionally, including when the coordinate WAS declared, because
|
|
446
|
+
// reading it was not implemented; that was an honest partial and it is now
|
|
447
|
+
// implemented.
|
|
448
|
+
//
|
|
449
|
+
// THE REFUSAL SURVIVES, NARROWED TO THE CASE IT WAS ALWAYS ABOUT: an
|
|
450
|
+
// external source that declares NO registry. The kernel legislates that on
|
|
451
|
+
// the field itself — *"a plane that needs one and finds it absent must
|
|
452
|
+
// REFUSE rather than substitute the first-party registry, which would
|
|
453
|
+
// publish a customer's build into the vendor org."* Absent must never
|
|
454
|
+
// become a fallback to the first-party registry. That substitution is the entire
|
|
455
|
+
// defect; narrowing the refusal must not soften it into a default.
|
|
456
|
+
//
|
|
457
|
+
// ── THE COMMIT ──
|
|
458
|
+
//
|
|
459
|
+
// A first-party biome's commit comes from `runtime-image-source-index.json`
|
|
460
|
+
// via `requireRepositoryPin`. An external biome has no entry there and
|
|
461
|
+
// never will: its `repo` is the source's own LOGICAL name as declared in
|
|
462
|
+
// `sources[]`, not a `repos/<x>` path, so the pin lookup would fail with "has no
|
|
463
|
+
// repository pin" — a true sentence about the wrong plane. Its pin is the
|
|
464
|
+
// commit the WALK resolved and the lock recorded, which is the same fact
|
|
465
|
+
// held by the authority that actually resolved it.
|
|
466
|
+
//
|
|
467
|
+
// ── PLACEMENT ──
|
|
468
|
+
//
|
|
469
|
+
// Both are resolved BEFORE `requireRepositoryPin` and before the
|
|
470
|
+
// `target === 'web'` branch, so services, runtime components and web biomes
|
|
471
|
+
// are covered by one statement rather than leaving the web branch's
|
|
472
|
+
// `continue` as a hole.
|
|
473
|
+
//
|
|
474
|
+
// KNOWN NARROWING, stated rather than discovered: a web biome composes no
|
|
475
|
+
// image ref at all, so an external source shipping ONLY web biomes needs no
|
|
476
|
+
// registry and is nonetheless refused here. That is deliberately left
|
|
477
|
+
// fail-closed — it has no subject today (both externally-sourced biomes in
|
|
478
|
+
// this plane declare `target: "server"`), and loosening a fence for a case
|
|
479
|
+
// that does not exist is how a fence stops meaning anything.
|
|
480
|
+
let imageRegistry = firstPartyImageRegistry();
|
|
481
|
+
// The identity element, not a fallback — see `imageRefForSubject`. A
|
|
482
|
+
// first-party subject is published under its own id, which is exactly what
|
|
483
|
+
// an empty prefix composes.
|
|
484
|
+
let imageNamePrefix = '';
|
|
485
|
+
let sourceCommit;
|
|
486
|
+
if (biome.source === undefined) {
|
|
487
|
+
sourceCommit = requireRepositoryPin(biome.repo).sourceCommit;
|
|
488
|
+
} else {
|
|
489
|
+
if (biome.source.imageRegistry === undefined) {
|
|
490
|
+
throw new Error(
|
|
491
|
+
`distribution "${lock.distributionId}" biome "${biome.id}" was drawn from external ` +
|
|
492
|
+
`source "${biome.source.name}" (${biome.source.url}@${biome.source.commit}), which ` +
|
|
493
|
+
'declares no imageRegistry, so this plane cannot address its images. Composing ' +
|
|
494
|
+
`them under "${firstPartyImageRegistry()}" would address a build this organisation does not ` +
|
|
495
|
+
'produce, inside the vendor namespace. Declare `imageRegistry` on that sources[] ' +
|
|
496
|
+
'entry — refusing rather than substituting the first-party registry.',
|
|
497
|
+
);
|
|
498
|
+
}
|
|
499
|
+
imageRegistry = biome.source.imageRegistry;
|
|
500
|
+
// THE PREFIX COMES FROM THE DECLARATION, NEVER FROM THE LOCK.
|
|
501
|
+
//
|
|
502
|
+
// This read was `biome.source.imageNamePrefix`, which required the lock to
|
|
503
|
+
// carry the key — and a lock carrying it is refused outright by any
|
|
504
|
+
// consumer on `@xemahq/contracts` `^0.9.x`, whose DistributionLockSchema
|
|
505
|
+
// is `.strict()` and learned the key only in 0.10.0.
|
|
506
|
+
//
|
|
507
|
+
// Removing it from the lock WITHOUT moving this read is what composed
|
|
508
|
+
// `<customer-registry>/pai-core-api` instead of `<customer-registry>/presenca-pai-core-api`:
|
|
509
|
+
// the `?? ''` swallowed the absence, so an unqualified name in a shared
|
|
510
|
+
// organisation namespace resolved silently. That is worse than a 404 —
|
|
511
|
+
// the first collision overwrites another product's tags.
|
|
512
|
+
//
|
|
513
|
+
// A MISSING DECLARED ENTRY IS THEREFORE REFUSED, never defaulted. An entry
|
|
514
|
+
// that declares NO prefix is a different fact and is honoured: the empty
|
|
515
|
+
// prefix is the identity element, composing a subject under its bare id.
|
|
516
|
+
if (!Array.isArray(declaredSources)) {
|
|
517
|
+
throw new Error(
|
|
518
|
+
`distribution "${lock.distributionId}" biome "${biome.id}" was drawn from ` +
|
|
519
|
+
`external source "${biome.source.name}", but this resolve was given no ` +
|
|
520
|
+
'declared sources[] to read its imageNamePrefix from. That prefix is ' +
|
|
521
|
+
'producer input and is deliberately not carried in the lock; pass ' +
|
|
522
|
+
'`resolveDistributionDeclaredSources(schemas, id)` — refusing rather ' +
|
|
523
|
+
'than composing an unprefixed name.',
|
|
524
|
+
);
|
|
525
|
+
}
|
|
526
|
+
const declaredSource = declaredSources.find(
|
|
527
|
+
(candidate) => candidate.name === biome.source.name,
|
|
528
|
+
);
|
|
529
|
+
if (declaredSource === undefined) {
|
|
530
|
+
throw new Error(
|
|
531
|
+
`distribution "${lock.distributionId}" biome "${biome.id}" was drawn from ` +
|
|
532
|
+
`external source "${biome.source.name}", which this distribution's ` +
|
|
533
|
+
`composed sources[] does not declare (declared: ${
|
|
534
|
+
declaredSources.map((candidate) => candidate.name).join(', ') ||
|
|
535
|
+
'none'
|
|
536
|
+
}). The image name cannot be composed without its declaration — ` +
|
|
537
|
+
'refusing rather than publishing under an unprefixed name in a shared ' +
|
|
538
|
+
"organisation namespace, where the first collision overwrites another " +
|
|
539
|
+
"product's tags.",
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
imageNamePrefix = declaredSource.imageNamePrefix ?? '';
|
|
543
|
+
sourceCommit = biome.source.commit;
|
|
544
|
+
}
|
|
545
|
+
if (biome.target === 'web') {
|
|
546
|
+
webBiomes.push({
|
|
547
|
+
biomeId: biome.id,
|
|
548
|
+
repo: biome.repo,
|
|
549
|
+
path: biome.path,
|
|
550
|
+
sourceCommit,
|
|
551
|
+
sourceManifestHash: biome.sourceManifestHash,
|
|
552
|
+
});
|
|
553
|
+
continue;
|
|
554
|
+
}
|
|
555
|
+
// A worker that runs a sibling service's image builds none of its own: the
|
|
556
|
+
// service's subject is the one sealed coordinate, and the deploy plane
|
|
557
|
+
// reaches it through `workerImages[worker].imageOf`. Minting a subject for
|
|
558
|
+
// the worker would name an image nothing builds.
|
|
559
|
+
const sharedImageWorkers = new Set(Object.keys(biome.workerImages ?? {}));
|
|
560
|
+
for (const service of biome.services ?? []) {
|
|
561
|
+
if (sharedImageWorkers.has(service)) {
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
addSubject(subjects, {
|
|
565
|
+
kind: enums.RuntimeImageSubjectKind.Service,
|
|
566
|
+
id: service,
|
|
567
|
+
imageRef: imageRefForSubject(service, imageRegistry, imageNamePrefix),
|
|
568
|
+
repo: biome.repo,
|
|
569
|
+
path: biome.path,
|
|
570
|
+
sourceCommit,
|
|
571
|
+
sourceManifestHash: biome.sourceManifestHash,
|
|
572
|
+
requiredPlatforms,
|
|
573
|
+
owners: [
|
|
574
|
+
{
|
|
575
|
+
kind: enums.RuntimeImageOwnerKind.Biome,
|
|
576
|
+
biomeId: biome.id,
|
|
577
|
+
},
|
|
578
|
+
],
|
|
579
|
+
});
|
|
580
|
+
}
|
|
581
|
+
// Runtime components are FULL delivery subjects — built, content-addressed,
|
|
582
|
+
// signed, mirrored and air-gapped exactly like a service image. They differ
|
|
583
|
+
// only in that nothing schedules them, which the deploy plane reads off the
|
|
584
|
+
// subject KIND. Their source path resolves through the same
|
|
585
|
+
// `ships.apis[].path` SSOT, because a runtime component IS a `ships.apis[]`
|
|
586
|
+
// entry — it just carries `workload: "runtime-component"`.
|
|
587
|
+
for (const component of biome.runtimeComponents ?? []) {
|
|
588
|
+
addSubject(subjects, {
|
|
589
|
+
kind: enums.RuntimeImageSubjectKind.RuntimeComponent,
|
|
590
|
+
id: component,
|
|
591
|
+
imageRef: imageRefForSubject(component, imageRegistry, imageNamePrefix),
|
|
592
|
+
repo: biome.repo,
|
|
593
|
+
path: biome.path,
|
|
594
|
+
sourceCommit,
|
|
595
|
+
sourceManifestHash: biome.sourceManifestHash,
|
|
596
|
+
requiredPlatforms,
|
|
597
|
+
owners: [
|
|
598
|
+
{
|
|
599
|
+
kind: enums.RuntimeImageOwnerKind.Biome,
|
|
600
|
+
biomeId: biome.id,
|
|
601
|
+
},
|
|
602
|
+
],
|
|
603
|
+
});
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const runtimeBindings = new Map(
|
|
608
|
+
runtimeSourceIndex.platformServiceBindings.map((binding) => [
|
|
609
|
+
binding.platformService,
|
|
610
|
+
binding,
|
|
611
|
+
]),
|
|
612
|
+
);
|
|
613
|
+
const supplementalSubjects = new Map(
|
|
614
|
+
runtimeSourceIndex.supplementalSubjects.map((subject) => [
|
|
615
|
+
subjectKey(subject),
|
|
616
|
+
subject,
|
|
617
|
+
]),
|
|
618
|
+
);
|
|
619
|
+
|
|
620
|
+
for (const service of lock.platformServices ?? []) {
|
|
621
|
+
let binding;
|
|
622
|
+
// The discriminator is the DECLARED runtime binding, never "does the lock
|
|
623
|
+
// carry a source". Every platform service carries an exact source identity
|
|
624
|
+
// now (`LockedPlatformService.source` is required at lock schemaVersion 2),
|
|
625
|
+
// so `if (service.source)` became unconditionally true — and it would have
|
|
626
|
+
// minted a repository-ROOT `platform` subject for `xema-kernel-server`,
|
|
627
|
+
// `docs-api`, `xema-host-web` and `opencode-image-prepuller`, hashing the
|
|
628
|
+
// wrong closure for three of them and inventing an image that has never
|
|
629
|
+
// existed for the fourth. A binding names the subject that really carries
|
|
630
|
+
// the service; its absence means the service IS its own repository image.
|
|
631
|
+
const runtimeBinding = runtimeBindings.get(service.name);
|
|
632
|
+
if (!runtimeBinding) {
|
|
633
|
+
if (!service.source) {
|
|
634
|
+
throw new Error(
|
|
635
|
+
`platform service "${service.name}" has no declared runtime image binding ` +
|
|
636
|
+
'and no exact source identity, so no image subject can be resolved for it',
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
const repository = requireRepositoryPin(service.source.repo);
|
|
640
|
+
if (service.source.sourceCommit !== repository.sourceCommit) {
|
|
641
|
+
throw new Error(
|
|
642
|
+
`platform service "${service.name}" source commit does not match its repository pin`,
|
|
643
|
+
);
|
|
644
|
+
}
|
|
645
|
+
binding = {
|
|
646
|
+
platformService: service.name,
|
|
647
|
+
subjectKind: enums.RuntimeImageSubjectKind.Platform,
|
|
648
|
+
subjectId: service.source.imageKey,
|
|
649
|
+
};
|
|
650
|
+
addSubject(subjects, {
|
|
651
|
+
kind: binding.subjectKind,
|
|
652
|
+
id: binding.subjectId,
|
|
653
|
+
imageRef: imageRefForSubject(binding.subjectId),
|
|
654
|
+
repo: service.source.repo,
|
|
655
|
+
path: service.source.repo,
|
|
656
|
+
sourceCommit: repository.sourceCommit,
|
|
657
|
+
sourceManifestHash: service.source.sourceManifestHash,
|
|
658
|
+
sourceManifestPath: service.source.path,
|
|
659
|
+
requiredPlatforms,
|
|
660
|
+
owners: [
|
|
661
|
+
{
|
|
662
|
+
kind: enums.RuntimeImageOwnerKind.PlatformService,
|
|
663
|
+
platformService: service.name,
|
|
664
|
+
},
|
|
665
|
+
],
|
|
666
|
+
});
|
|
667
|
+
} else {
|
|
668
|
+
binding = runtimeBinding;
|
|
669
|
+
const key = bindingKey(binding);
|
|
670
|
+
const supplemental = supplementalSubjects.get(key);
|
|
671
|
+
if (supplemental) {
|
|
672
|
+
const repository = requireRepositoryPin(supplemental.repo);
|
|
673
|
+
if (supplemental.sourceCommit !== repository.sourceCommit) {
|
|
674
|
+
throw new Error(
|
|
675
|
+
`runtime subject "${supplemental.kind}:${supplemental.id}" source commit does not match its repository pin`,
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
addSubject(subjects, {
|
|
679
|
+
kind: supplemental.kind,
|
|
680
|
+
id: supplemental.id,
|
|
681
|
+
imageRef: supplemental.imageRef,
|
|
682
|
+
repo: supplemental.repo,
|
|
683
|
+
path: supplemental.path,
|
|
684
|
+
sourceCommit: repository.sourceCommit,
|
|
685
|
+
sourceManifestHash: supplemental.sourceManifestHash,
|
|
686
|
+
sourceManifestPath: supplemental.sourceManifestPath,
|
|
687
|
+
requiredPlatforms,
|
|
688
|
+
owners: [
|
|
689
|
+
{
|
|
690
|
+
kind: enums.RuntimeImageOwnerKind.PlatformService,
|
|
691
|
+
platformService: service.name,
|
|
692
|
+
},
|
|
693
|
+
],
|
|
694
|
+
});
|
|
695
|
+
} else {
|
|
696
|
+
const target = subjects.get(key);
|
|
697
|
+
if (!target) {
|
|
698
|
+
throw new Error(
|
|
699
|
+
missingArtifactDependencyMessage(lock, service.name, binding, biomeIndex),
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
addSubject(subjects, {
|
|
703
|
+
...target,
|
|
704
|
+
owners: [
|
|
705
|
+
{
|
|
706
|
+
kind: enums.RuntimeImageOwnerKind.PlatformService,
|
|
707
|
+
platformService: service.name,
|
|
708
|
+
},
|
|
709
|
+
],
|
|
710
|
+
});
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
platformServiceBindings.push(binding);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// A binding for a service this edition does not ship is outside its artifact
|
|
717
|
+
// graph. It was REFUSED here ("runtime image source index contains unselected
|
|
718
|
+
// platform service"), which made every image-bearing platform service
|
|
719
|
+
// unexcludable: `billing-api` could be dropped only because it happens to be
|
|
720
|
+
// independently sourced and carries no binding, while `docs-api`,
|
|
721
|
+
// `xema-host-web` and `opencode-image-prepuller` could not. The index is the
|
|
722
|
+
// fleet catalogue, not this edition's roster; the roster is derived from the
|
|
723
|
+
// lock's `platformServices`, above. A binding naming a service the COMPOSED
|
|
724
|
+
// catalogue does not know at all is still a declaration defect, and is
|
|
725
|
+
// refused by `build-runtime-image-source-index.mjs --check`, which owns the
|
|
726
|
+
// index — not by every edition that happens to omit the service.
|
|
727
|
+
|
|
728
|
+
const orderedSubjects = [...subjects.values()]
|
|
729
|
+
.map((subject) => ({
|
|
730
|
+
...subject,
|
|
731
|
+
owners: subject.owners.sort(compareOwner),
|
|
732
|
+
}))
|
|
733
|
+
.sort(compareSubject);
|
|
734
|
+
webBiomes.sort((left, right) => compareStrings(left.biomeId, right.biomeId));
|
|
735
|
+
platformServiceBindings.sort((left, right) =>
|
|
736
|
+
compareStrings(left.platformService, right.platformService),
|
|
737
|
+
);
|
|
738
|
+
return {
|
|
739
|
+
subjects: orderedSubjects,
|
|
740
|
+
webBiomes,
|
|
741
|
+
platformServiceBindings,
|
|
742
|
+
};
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* THE COMPOSED-PROCESS IMAGE OF ONE BUILD — where a producing repository
|
|
747
|
+
* declares it, and how an edition addresses it.
|
|
748
|
+
*
|
|
749
|
+
* The kernel (`RuntimeImageSubjectKind.ComposedProcess`) fixes WHAT the subject
|
|
750
|
+
* is: one repository at one commit, carrying every platform-scheduled service
|
|
751
|
+
* of that build and `@xemahq/xema-service-nest`'s `xema-composed-process`
|
|
752
|
+
* entrypoint, owned by the biomes whose services it carries. Neither the kernel
|
|
753
|
+
* nor the SDK fixes WHERE a repository builds it, so this plane does, once:
|
|
754
|
+
*
|
|
755
|
+
* - the image directory is `infra/composed-process/` at the repository root,
|
|
756
|
+
* holding its own `Dockerfile` and an `image-build-inputs.json` whose
|
|
757
|
+
* `workspacePackages` lists every member service's workspace package
|
|
758
|
+
* directory — the Dockerfile-only image shape `hashImageBuildInputs`
|
|
759
|
+
* already reads (`opencode-worker` is the other one). Its tag is therefore
|
|
760
|
+
* an ordinary `xema-image-input-sha256-v2` key, computed by the one
|
|
761
|
+
* implementation every producer uses, with `SERVICE` = the subject id and
|
|
762
|
+
* `SERVICE_PATH` = this directory;
|
|
763
|
+
* - the DECLARATION is that `image-build-inputs.json` existing at the pinned
|
|
764
|
+
* commit. A repository that declares none has no composed image, and an
|
|
765
|
+
* edition carries no composed subject for it — which the deploy plane
|
|
766
|
+
* reports as "the producer builds no composed-process image at that
|
|
767
|
+
* commit". That is an honest absence, not a skipped subject: a DECLARED
|
|
768
|
+
* image that has not been built is still emitted, carries no
|
|
769
|
+
* `imageDigest`, and is undeliverable until it is sealed;
|
|
770
|
+
* - the subject id is `<declared source id>-composed-process`. The source
|
|
771
|
+
* id is DECLARED, never derived from where the repository is hosted: an
|
|
772
|
+
* external source's is the edition's `sources[].name` (which the walk
|
|
773
|
+
* also stamps as the biome's `repo`), a first-party repository's is
|
|
774
|
+
* declared in `first-party-source-ids.mjs`. A rename or host move changes
|
|
775
|
+
* `repositoryUrl` — provenance — and no subject id, `imageRef` or
|
|
776
|
+
* `contentHash` (the id is the `SERVICE` input of the tag). It cannot be
|
|
777
|
+
* a constant: two first-party repositories publish into one registry
|
|
778
|
+
* namespace, and the image lock refuses two subjects sharing an
|
|
779
|
+
* `imageRef`. The producing build publishes under this same name;
|
|
780
|
+
* - `imageRef` is composed exactly as the repository's service subjects are
|
|
781
|
+
* (`imageRefForSubject`, with the same registry and declared prefix).
|
|
782
|
+
*/
|
|
783
|
+
export const COMPOSED_PROCESS_IMAGE_PATH = 'infra/composed-process';
|
|
784
|
+
export const COMPOSED_PROCESS_DECLARATION_FILE = 'image-build-inputs.json';
|
|
785
|
+
const COMPOSED_PROCESS_ID_SUFFIX = '-composed-process';
|
|
786
|
+
|
|
787
|
+
export function composedProcessSubjectId(sourceId) {
|
|
788
|
+
return `${assertDeclaredSourceId(sourceId, 'composed-process subject')}${COMPOSED_PROCESS_ID_SUFFIX}`;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* The DECLARED source id of every repository this edition builds a service
|
|
793
|
+
* image from, keyed by the subject's `repo` handle — the edition's own
|
|
794
|
+
* `sources[].name` for a source-walked biome, the first-party declaration
|
|
795
|
+
* otherwise. Never read off a URL.
|
|
796
|
+
*/
|
|
797
|
+
export function editionSourceIds(lock) {
|
|
798
|
+
const ids = new Map();
|
|
799
|
+
for (const biome of lock.biomes ?? []) {
|
|
800
|
+
if (biome.source === undefined) {continue;}
|
|
801
|
+
if (biome.repo !== biome.source.name) {
|
|
802
|
+
throw new Error(
|
|
803
|
+
`lock biome "${biome.id}" is drawn from source "${biome.source.name}" but its repo ` +
|
|
804
|
+
`handle is "${biome.repo}" — a source-walked biome's repo IS its declared source id`,
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
ids.set(biome.repo, assertDeclaredSourceId(biome.source.name, `lock biome "${biome.id}" source`));
|
|
808
|
+
}
|
|
809
|
+
return { get: (repo) => ids.get(repo) ?? firstPartySourceId(repo) };
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Read each repository's composed-process DECLARATION at the commit the
|
|
814
|
+
* edition builds its services from. Only repositories that contribute a
|
|
815
|
+
* service subject are asked: a composed image carries services, so a
|
|
816
|
+
* repository shipping none has nothing to compose.
|
|
817
|
+
*/
|
|
818
|
+
export function readComposedProcessDeclarations(
|
|
819
|
+
structure,
|
|
820
|
+
enums,
|
|
821
|
+
sourceIds,
|
|
822
|
+
locate = FIRST_PARTY_TREES,
|
|
823
|
+
) {
|
|
824
|
+
const commits = new Map();
|
|
825
|
+
for (const subject of structure.subjects) {
|
|
826
|
+
if (subject.kind !== enums.RuntimeImageSubjectKind.Service) {continue;}
|
|
827
|
+
if (!subject.owners.some((owner) => owner.kind === enums.RuntimeImageOwnerKind.Biome)) {continue;}
|
|
828
|
+
commits.set(subject.repo, subject.sourceCommit);
|
|
829
|
+
}
|
|
830
|
+
const declarations = [];
|
|
831
|
+
for (const [repo, sourceCommit] of [...commits].sort(([a], [b]) => compareStrings(a, b))) {
|
|
832
|
+
const { dir, base } = locate(repo);
|
|
833
|
+
const declarationPath = `${COMPOSED_PROCESS_IMAGE_PATH}/${COMPOSED_PROCESS_DECLARATION_FILE}`;
|
|
834
|
+
// ABSENCE IS ONLY AN ANSWER ABOUT A COMMIT THAT WAS READ. A repository
|
|
835
|
+
// missing from this layout, or one lacking the pinned commit, would
|
|
836
|
+
// otherwise read as "declares nothing", and `dist-check` would grade a lock
|
|
837
|
+
// that dropped the composed subject as up to date.
|
|
838
|
+
try {
|
|
839
|
+
execFileSync('git', ['-C', dir, 'cat-file', '-e', `${sourceCommit}^{commit}`], {
|
|
840
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
841
|
+
});
|
|
842
|
+
} catch {
|
|
843
|
+
throw new Error(
|
|
844
|
+
`runtime image source "${repo}" pins commit "${sourceCommit}", which is absent from ` +
|
|
845
|
+
`${dir} — its composed-process declaration cannot be read; fetch the repository ` +
|
|
846
|
+
'before resolving',
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
try {
|
|
850
|
+
execFileSync('git', ['-C', dir, 'cat-file', '-e', `${sourceCommit}:${declarationPath}`], {
|
|
851
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
852
|
+
});
|
|
853
|
+
} catch {
|
|
854
|
+
continue; // declares no composed-process image at this commit
|
|
855
|
+
}
|
|
856
|
+
const sourceId = sourceIds.get(repo);
|
|
857
|
+
const repoRelative = relative(base, dir).split(sep).join('/');
|
|
858
|
+
declarations.push({
|
|
859
|
+
repo,
|
|
860
|
+
sourceId,
|
|
861
|
+
sourceCommit,
|
|
862
|
+
path: `${repoRelative}/${COMPOSED_PROCESS_IMAGE_PATH}`,
|
|
863
|
+
sourceManifestPath: `${repoRelative}/${declarationPath}`,
|
|
864
|
+
sourceManifestHash: exactSourceManifestHash(dir, declarationPath, sourceCommit),
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
return declarations;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
/**
|
|
871
|
+
* Add ONE `composed-process` subject per declaring repository to an edition's
|
|
872
|
+
* structure. Pure over its inputs, so the structural check and the source
|
|
873
|
+
* resolve derive the same subject from the same declarations.
|
|
874
|
+
*
|
|
875
|
+
* Owners are the biomes whose service subjects come from that repository in
|
|
876
|
+
* this edition — the members the deploy plane may place in a process with it.
|
|
877
|
+
*/
|
|
878
|
+
export function withComposedProcessSubjects(structure, declarations, enums, requiredPlatforms) {
|
|
879
|
+
if (declarations.length === 0) {return structure;}
|
|
880
|
+
const subjects = new Map(structure.subjects.map((subject) => [subjectKey(subject), subject]));
|
|
881
|
+
for (const declaration of declarations) {
|
|
882
|
+
const services = structure.subjects.filter(
|
|
883
|
+
(subject) =>
|
|
884
|
+
subject.kind === enums.RuntimeImageSubjectKind.Service && subject.repo === declaration.repo,
|
|
885
|
+
);
|
|
886
|
+
// One owner per biome: a biome shipping several services owns the
|
|
887
|
+
// composed image once (the kernel refuses a repeated owner identity).
|
|
888
|
+
const biomeOwners = [
|
|
889
|
+
...new Map(
|
|
890
|
+
services
|
|
891
|
+
.flatMap((subject) => subject.owners)
|
|
892
|
+
.filter((owner) => owner.kind === enums.RuntimeImageOwnerKind.Biome)
|
|
893
|
+
.map((owner) => [owner.biomeId, owner]),
|
|
894
|
+
).values(),
|
|
895
|
+
];
|
|
896
|
+
if (biomeOwners.length === 0) {
|
|
897
|
+
throw new Error(
|
|
898
|
+
`composed-process declaration for "${declaration.repo}" names no biome service ` +
|
|
899
|
+
'subject in this edition — a composed image carries services, so it has no owner',
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
for (const subject of services) {
|
|
903
|
+
if (subject.sourceCommit !== declaration.sourceCommit) {
|
|
904
|
+
throw new Error(
|
|
905
|
+
`runtime subject "${subject.kind}:${subject.id}" is resolved at ${subject.sourceCommit}, ` +
|
|
906
|
+
`but "${declaration.repo}"'s composed-process image is declared at ` +
|
|
907
|
+
`${declaration.sourceCommit} — one composed image is ONE build`,
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
// The registry and declared prefix the repository's services are published
|
|
912
|
+
// under: `imageRefForSubject` is `<registry>/<prefix><id>`, so stripping
|
|
913
|
+
// the service id recovers it exactly. Two services disagreeing would mean
|
|
914
|
+
// one build published under two coordinates, which nothing can address.
|
|
915
|
+
const namespaces = new Set(
|
|
916
|
+
services.map((subject) => {
|
|
917
|
+
if (!subject.imageRef.endsWith(subject.id)) {
|
|
918
|
+
throw new Error(
|
|
919
|
+
`runtime subject "${subject.kind}:${subject.id}" imageRef "${subject.imageRef}" ` +
|
|
920
|
+
'does not end with its id, so its namespace cannot be recovered',
|
|
921
|
+
);
|
|
922
|
+
}
|
|
923
|
+
return subject.imageRef.slice(0, -subject.id.length);
|
|
924
|
+
}),
|
|
925
|
+
);
|
|
926
|
+
if (namespaces.size !== 1) {
|
|
927
|
+
throw new Error(
|
|
928
|
+
`"${declaration.repo}"'s services publish under ${namespaces.size} namespaces ` +
|
|
929
|
+
`(${[...namespaces].join(', ')}) — a composed image of one build has one`,
|
|
930
|
+
);
|
|
931
|
+
}
|
|
932
|
+
const id = composedProcessSubjectId(declaration.sourceId);
|
|
933
|
+
addSubject(subjects, {
|
|
934
|
+
kind: enums.RuntimeImageSubjectKind.ComposedProcess,
|
|
935
|
+
id,
|
|
936
|
+
imageRef: `${[...namespaces][0]}${id}`,
|
|
937
|
+
repo: declaration.repo,
|
|
938
|
+
path: declaration.path,
|
|
939
|
+
sourceCommit: declaration.sourceCommit,
|
|
940
|
+
sourceManifestHash: declaration.sourceManifestHash,
|
|
941
|
+
sourceManifestPath: declaration.sourceManifestPath,
|
|
942
|
+
requiredPlatforms,
|
|
943
|
+
owners: biomeOwners.map((owner) => ({ kind: owner.kind, biomeId: owner.biomeId })),
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
return {
|
|
947
|
+
...structure,
|
|
948
|
+
subjects: [...subjects.values()]
|
|
949
|
+
.map((subject) => ({ ...subject, owners: [...subject.owners].sort(compareOwner) }))
|
|
950
|
+
.sort(compareSubject),
|
|
951
|
+
};
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
/**
|
|
955
|
+
* The edition structure INCLUDING its composed-process subjects — the one
|
|
956
|
+
* entry point the source resolve and the structural check share, so neither
|
|
957
|
+
* can carry a subject the other does not. A repository that declares no
|
|
958
|
+
* composed image at its pinned commit contributes no subject.
|
|
959
|
+
*/
|
|
960
|
+
export function resolveComposedProcessStructure(
|
|
961
|
+
lock,
|
|
962
|
+
structure,
|
|
963
|
+
enums,
|
|
964
|
+
requiredPlatforms,
|
|
965
|
+
{ locate } = {},
|
|
966
|
+
) {
|
|
967
|
+
return withComposedProcessSubjects(
|
|
968
|
+
structure,
|
|
969
|
+
readComposedProcessDeclarations(
|
|
970
|
+
structure,
|
|
971
|
+
enums,
|
|
972
|
+
editionSourceIds(lock),
|
|
973
|
+
// Materialised only when something will be read through it.
|
|
974
|
+
locate ?? sourceTreeLocator(externalSourceTrees(lock)),
|
|
975
|
+
),
|
|
976
|
+
enums,
|
|
977
|
+
requiredPlatforms,
|
|
978
|
+
);
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/**
|
|
982
|
+
* The exact commit every image source repository is resolved at.
|
|
983
|
+
*
|
|
984
|
+
* The PIN is the authority, not the local checkout. Requiring each submodule's
|
|
985
|
+
* `HEAD` to equal its pin made regeneration impossible in any working tree that
|
|
986
|
+
* had moved on — which is every working tree — and bought nothing: the hashes
|
|
987
|
+
* read immutable Git objects at the pinned commit, so what is checked out
|
|
988
|
+
* cannot change a digest. What DOES have to hold is that the pinned commit is
|
|
989
|
+
* present in the local object database, which is asserted here.
|
|
990
|
+
*/
|
|
991
|
+
function sourceCommitMap(structure, locate = FIRST_PARTY_TREES) {
|
|
992
|
+
const commitByRepo = new Map();
|
|
993
|
+
for (const source of [...structure.subjects, ...structure.webBiomes]) {
|
|
994
|
+
if (!source.sourceCommit) {continue;}
|
|
995
|
+
const incumbent = commitByRepo.get(source.repo);
|
|
996
|
+
if (incumbent && incumbent !== source.sourceCommit) {
|
|
997
|
+
throw new Error(
|
|
998
|
+
`runtime image roster pins multiple commits for "${source.repo}"`,
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
commitByRepo.set(source.repo, source.sourceCommit);
|
|
1002
|
+
}
|
|
1003
|
+
const repositories = [
|
|
1004
|
+
...new Set([
|
|
1005
|
+
...structure.subjects.map((subject) => subject.repo),
|
|
1006
|
+
...structure.webBiomes.map((biome) => biome.repo),
|
|
1007
|
+
]),
|
|
1008
|
+
].sort(compareStrings);
|
|
1009
|
+
for (const repo of repositories) {
|
|
1010
|
+
const commit = commitByRepo.get(repo);
|
|
1011
|
+
if (!commit) {
|
|
1012
|
+
throw new Error(`runtime image source "${repo}" has no pinned commit`);
|
|
1013
|
+
}
|
|
1014
|
+
try {
|
|
1015
|
+
execFileSync(
|
|
1016
|
+
'git',
|
|
1017
|
+
['-C', locate(repo).dir, 'cat-file', '-e', `${commit}^{commit}`],
|
|
1018
|
+
{ stdio: ['ignore', 'ignore', 'pipe'] },
|
|
1019
|
+
);
|
|
1020
|
+
} catch {
|
|
1021
|
+
throw new Error(
|
|
1022
|
+
`runtime image source "${repo}" pins commit "${commit}", which is absent ` +
|
|
1023
|
+
`from the local object database — fetch the repository before resolving`,
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
return commitByRepo;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
/**
|
|
1031
|
+
* The SERVICE directory an image is built from, repository-relative.
|
|
1032
|
+
*
|
|
1033
|
+
* A biome subject's `path` is the BIOME directory, and a biome can ship several
|
|
1034
|
+
* services — `agent-runtime` ships four. Hashing the biome directory would give
|
|
1035
|
+
* all four the same closure and would move all four when any one of them
|
|
1036
|
+
* changes, so the service directory is read from the manifest SSOT
|
|
1037
|
+
* (`xema.ships.apis[].path`) rather than synthesized. Non-biome subjects
|
|
1038
|
+
* (`sourceManifestPath` present) already carry their own service directory.
|
|
1039
|
+
*
|
|
1040
|
+
* Exported for `assemble-producer-input.mjs`, which must derive the SAME
|
|
1041
|
+
* service directory to hash the SAME build-input closure the lock names.
|
|
1042
|
+
*/
|
|
1043
|
+
export function subjectServicePath(subject, sourceCommit, locate = FIRST_PARTY_TREES) {
|
|
1044
|
+
const { dir: repoDirectory, base } = locate(subject.repo);
|
|
1045
|
+
const toRepoRelative = (absolute) =>
|
|
1046
|
+
relative(repoDirectory, absolute).split(sep).join('/') || '.';
|
|
1047
|
+
if (subject.sourceManifestPath) {
|
|
1048
|
+
return toRepoRelative(join(base, subject.path));
|
|
1049
|
+
}
|
|
1050
|
+
const biomePath = toRepoRelative(join(base, subject.path));
|
|
1051
|
+
const manifestPath =
|
|
1052
|
+
biomePath === '.' ? 'xema-biome.json' : `${biomePath}/xema-biome.json`;
|
|
1053
|
+
const manifest = readGitJsonAtCommit(
|
|
1054
|
+
repoDirectory,
|
|
1055
|
+
manifestPath,
|
|
1056
|
+
sourceCommit,
|
|
1057
|
+
);
|
|
1058
|
+
const xema = manifest?.xema;
|
|
1059
|
+
const apis = xema?.ships?.apis;
|
|
1060
|
+
const legacyApis = Array.isArray(apis) ? apis : [];
|
|
1061
|
+
// component-contract-v5: a component's source directory is declared by its
|
|
1062
|
+
// own `artifact.path`.
|
|
1063
|
+
//
|
|
1064
|
+
// Both `service` AND `worker` kinds qualify. Restricting this to `service`
|
|
1065
|
+
// was correct only while a runtime component was spelled in the legacy
|
|
1066
|
+
// `ships.apis[]` block; v5 dropped that field, so `opencode-worker` —
|
|
1067
|
+
// `kind: "worker"`, `scheduler: "runtime"` — fell through to the legacy
|
|
1068
|
+
// branch and failed with "ships no apis" even though its manifest declares
|
|
1069
|
+
// everything needed. What identifies the subject is
|
|
1070
|
+
// `protocol.serviceName`, not the component kind.
|
|
1071
|
+
const components = Array.isArray(xema?.components) ? xema.components : [];
|
|
1072
|
+
const v4ServiceComponents = components.filter(
|
|
1073
|
+
(component) =>
|
|
1074
|
+
component !== null &&
|
|
1075
|
+
typeof component === 'object' &&
|
|
1076
|
+
(component.kind === 'service' || component.kind === 'worker'),
|
|
1077
|
+
);
|
|
1078
|
+
if (legacyApis.length > 0 && v4ServiceComponents.length > 0) {
|
|
1079
|
+
throw new Error(
|
|
1080
|
+
`${subject.repo}@${sourceCommit}:${manifestPath} declares services in BOTH ` +
|
|
1081
|
+
`xema.ships.apis[] (legacy) and xema.components[] kind "service" ` +
|
|
1082
|
+
`(component-contract-v4) — a biome must use exactly one shape`,
|
|
1083
|
+
);
|
|
1084
|
+
}
|
|
1085
|
+
let servicePath;
|
|
1086
|
+
if (v4ServiceComponents.length > 0) {
|
|
1087
|
+
// A SERVICE is identified by `protocol.serviceName`; a WORKER has no
|
|
1088
|
+
// service protocol and is identified by its component `key`. Matching only
|
|
1089
|
+
// on `serviceName` silently found nothing for every worker subject and
|
|
1090
|
+
// reported it as an undeclared service directory.
|
|
1091
|
+
const component = v4ServiceComponents.find(
|
|
1092
|
+
(entry) =>
|
|
1093
|
+
entry?.protocol?.serviceName === subject.id || entry?.key === subject.id,
|
|
1094
|
+
);
|
|
1095
|
+
if (
|
|
1096
|
+
!component ||
|
|
1097
|
+
typeof component?.artifact?.path !== 'string' ||
|
|
1098
|
+
component.artifact.path.length === 0
|
|
1099
|
+
) {
|
|
1100
|
+
throw new Error(
|
|
1101
|
+
`${subject.repo}@${sourceCommit}:${manifestPath} declares no xema.components[] ` +
|
|
1102
|
+
`kind "service"/"worker" entry identified as "${subject.id}" (by ` +
|
|
1103
|
+
`protocol.serviceName or component key) with an ` +
|
|
1104
|
+
`artifact.path — the service source directory is the manifest's to declare ` +
|
|
1105
|
+
`and must never be synthesized`,
|
|
1106
|
+
);
|
|
1107
|
+
}
|
|
1108
|
+
servicePath = component.artifact.path;
|
|
1109
|
+
} else {
|
|
1110
|
+
if (legacyApis.length === 0) {
|
|
1111
|
+
throw new Error(
|
|
1112
|
+
`${subject.repo}@${sourceCommit}:${manifestPath} ships no apis, but runtime ` +
|
|
1113
|
+
`subject "${subject.kind}:${subject.id}" claims to be one of them`,
|
|
1114
|
+
);
|
|
1115
|
+
}
|
|
1116
|
+
const api = legacyApis.find((entry) => entry?.name === subject.id);
|
|
1117
|
+
if (!api || typeof api.path !== 'string' || api.path.length === 0) {
|
|
1118
|
+
throw new Error(
|
|
1119
|
+
`${subject.repo}@${sourceCommit}:${manifestPath} declares no ships.apis[] entry ` +
|
|
1120
|
+
`named "${subject.id}" with a "path" — the service source directory is the ` +
|
|
1121
|
+
`manifest's to declare and must never be synthesized`,
|
|
1122
|
+
);
|
|
1123
|
+
}
|
|
1124
|
+
servicePath = api.path;
|
|
1125
|
+
}
|
|
1126
|
+
return toRepoRelative(join(base, subject.path, servicePath));
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function assertSourceManifestParity(structure, commitByRepo, locate = FIRST_PARTY_TREES) {
|
|
1130
|
+
for (const source of structure.subjects) {
|
|
1131
|
+
const { dir: repoDirectory, base } = locate(source.repo);
|
|
1132
|
+
const sourceCommit = commitByRepo.get(source.repo);
|
|
1133
|
+
const manifestPath = source.sourceManifestPath
|
|
1134
|
+
? relative(repoDirectory, join(base, source.sourceManifestPath))
|
|
1135
|
+
.split(sep)
|
|
1136
|
+
.join('/')
|
|
1137
|
+
: relative(repoDirectory, join(base, source.path, 'xema-biome.json'))
|
|
1138
|
+
.split(sep)
|
|
1139
|
+
.join('/');
|
|
1140
|
+
const actual = exactSourceManifestHash(
|
|
1141
|
+
repoDirectory,
|
|
1142
|
+
manifestPath,
|
|
1143
|
+
sourceCommit,
|
|
1144
|
+
);
|
|
1145
|
+
if (actual !== source.sourceManifestHash) {
|
|
1146
|
+
throw new Error(
|
|
1147
|
+
`runtime subject "${source.kind}:${source.id}" sourceManifestHash does not match ${source.repo}@${sourceCommit}:${manifestPath}`,
|
|
1148
|
+
);
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
for (const biome of structure.webBiomes) {
|
|
1152
|
+
const { dir: repoDirectory, base } = locate(biome.repo);
|
|
1153
|
+
const sourceCommit = commitByRepo.get(biome.repo);
|
|
1154
|
+
const manifestPath = relative(
|
|
1155
|
+
repoDirectory,
|
|
1156
|
+
join(base, biome.path, 'xema-biome.json'),
|
|
1157
|
+
)
|
|
1158
|
+
.split(sep)
|
|
1159
|
+
.join('/');
|
|
1160
|
+
const actual = exactSourceManifestHash(
|
|
1161
|
+
repoDirectory,
|
|
1162
|
+
manifestPath,
|
|
1163
|
+
sourceCommit,
|
|
1164
|
+
);
|
|
1165
|
+
if (actual !== biome.sourceManifestHash) {
|
|
1166
|
+
throw new Error(
|
|
1167
|
+
`web biome "${biome.biomeId}" sourceManifestHash does not match ${biome.repo}@${sourceCommit}:${manifestPath}`,
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/**
|
|
1174
|
+
* AN EXTERNAL SOURCE THAT VENDORS CROSS-REPOSITORY CONTENT IS REFUSED, LOUDLY.
|
|
1175
|
+
*
|
|
1176
|
+
* `runtime-image-source-index.json` declares the vendoring this fleet's own
|
|
1177
|
+
* repositories do, and an external source has no entry there — correctly, since
|
|
1178
|
+
* the index is the vendor's view of the vendor's repositories. Reading that
|
|
1179
|
+
* absence as "this repository vendors nothing" is the silent half: a Dockerfile
|
|
1180
|
+
* that copies a tree in from elsewhere would have those bytes drop out of the
|
|
1181
|
+
* closure, and the hash would name an image whose content it does not describe.
|
|
1182
|
+
*
|
|
1183
|
+
* So the ABSENCE is checked rather than assumed. The producing repository's own
|
|
1184
|
+
* committed record — the same file `vendor-cross-repo-inputs.mjs` reads to emit
|
|
1185
|
+
* the `--vendored` arguments CI hands the tag computation — is read at the
|
|
1186
|
+
* locked commit. Absent means genuinely nothing to vendor, which is the honest
|
|
1187
|
+
* empty. PRESENT means this plane cannot pin the other side (it has no checkout
|
|
1188
|
+
* of a repository the customer vendors from, and no authority to resolve one),
|
|
1189
|
+
* and an undeclared input must fail rather than silently drop out.
|
|
1190
|
+
*/
|
|
1191
|
+
function assertExternalSourcesDeclareNoVendoring(lock, locate, commitByRepo) {
|
|
1192
|
+
const external = new Set(
|
|
1193
|
+
(lock.biomes ?? [])
|
|
1194
|
+
.filter((biome) => biome.source !== undefined)
|
|
1195
|
+
.map((biome) => biome.repo),
|
|
1196
|
+
);
|
|
1197
|
+
for (const repo of [...external].sort(compareStrings)) {
|
|
1198
|
+
const { dir } = locate(repo);
|
|
1199
|
+
const commit = commitByRepo.get(repo);
|
|
1200
|
+
if (!commit) {continue;}
|
|
1201
|
+
// Existence is probed with `cat-file -e` rather than by catching the read:
|
|
1202
|
+
// a failed read writes `fatal: path ... does not exist` to stderr, and a
|
|
1203
|
+
// `fatal:` printed during a successful run is a false signal a reader has
|
|
1204
|
+
// to spend time discounting.
|
|
1205
|
+
let present;
|
|
1206
|
+
try {
|
|
1207
|
+
execFileSync('git', ['-C', dir, 'cat-file', '-e', `${commit}:${CROSS_REPO_BUILD_INPUTS_PATH}`], {
|
|
1208
|
+
stdio: ['ignore', 'ignore', 'ignore'],
|
|
1209
|
+
});
|
|
1210
|
+
present = true;
|
|
1211
|
+
} catch {
|
|
1212
|
+
present = false;
|
|
1213
|
+
}
|
|
1214
|
+
if (!present) {continue;} // absent — nothing vendored, which is the honest empty
|
|
1215
|
+
const record = readGitJsonAtCommit(dir, CROSS_REPO_BUILD_INPUTS_PATH, commit);
|
|
1216
|
+
const inputs = Array.isArray(record?.inputs) ? record.inputs : [];
|
|
1217
|
+
if (inputs.length === 0) {continue;}
|
|
1218
|
+
throw new Error(
|
|
1219
|
+
`external source "${repo}" declares ${inputs.length} cross-repository build ` +
|
|
1220
|
+
`input(s) in ${CROSS_REPO_BUILD_INPUTS_PATH} at ${commit}. This plane cannot pin ` +
|
|
1221
|
+
'the other side of them — it holds no checkout of the repositories they vendor ' +
|
|
1222
|
+
'from — so the image content hash would omit bytes the build bakes in and name a ' +
|
|
1223
|
+
'coordinate it does not describe. Refusing rather than hashing an incomplete ' +
|
|
1224
|
+
'closure.',
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* The vendored cross-repository build inputs of THIS roster's images, keyed
|
|
1231
|
+
* `${consumerRepo}\0${consumerService ?? ''}`.
|
|
1232
|
+
*
|
|
1233
|
+
* EVERY COMMIT HERE COMES FROM A REPOSITORY PIN, NEVER FROM ROSTER MEMBERSHIP.
|
|
1234
|
+
*
|
|
1235
|
+
* The consumer commit — the commit whose committed
|
|
1236
|
+
* `tooling/release/cross-repo-build-inputs.json` names the vendored revisions —
|
|
1237
|
+
* is the consumer repository's `repositoryPins` entry. It used to be read out
|
|
1238
|
+
* of the edition's resolved commit map, which knows a repository only if some
|
|
1239
|
+
* SELECTED biome happens to come from it. That made an image's identity depend
|
|
1240
|
+
* on unrelated membership, and made a trimmed edition unaddressable: an edition
|
|
1241
|
+
* shipping no `repos/xema-community` biome refused with "cross-repo build inputs
|
|
1242
|
+
* are declared for repos/xema-community, but this distribution resolves no
|
|
1243
|
+
* commit for it" — although it shipped no xema-community image at all. For a
|
|
1244
|
+
* first-party subject the two are the same commit by construction (a subject's
|
|
1245
|
+
* `sourceCommit` IS its repository pin), so every edition that resolved before
|
|
1246
|
+
* resolves to byte-identical hashes now.
|
|
1247
|
+
*
|
|
1248
|
+
* Only consumers the roster actually BUILDS FROM are resolved: a consumer with
|
|
1249
|
+
* no selected subject contributes no input to any hash here, so reading its
|
|
1250
|
+
* record would be work that grades nothing. The vendored revision itself is the
|
|
1251
|
+
* consumer's own record at its pin (see the reasoning above the call site): the
|
|
1252
|
+
* pin fixes the record, and the record fixes the bytes. A consumer, or a
|
|
1253
|
+
* repository it vendors from, with NO repository pin is refused by name — the
|
|
1254
|
+
* image cannot be addressed without one.
|
|
1255
|
+
*/
|
|
1256
|
+
export function resolveCrossRepoInputsForRoster({
|
|
1257
|
+
runtimeSourceIndex,
|
|
1258
|
+
subjects,
|
|
1259
|
+
resolveVendored = resolveCrossRepoVendoredInputs,
|
|
1260
|
+
repoRoot = sourcesRoot(),
|
|
1261
|
+
// The subject kind that builds EVERY input of its repository (the composed
|
|
1262
|
+
// process image carries every service of its build). Undefined: none does.
|
|
1263
|
+
composedProcessKind = undefined,
|
|
1264
|
+
}) {
|
|
1265
|
+
const pins = new Map(
|
|
1266
|
+
runtimeSourceIndex.repositoryPins.map((pin) => [pin.repo, pin]),
|
|
1267
|
+
);
|
|
1268
|
+
const requirePin = (repo, role) => {
|
|
1269
|
+
const pin = pins.get(repo);
|
|
1270
|
+
if (!pin) {
|
|
1271
|
+
throw new Error(
|
|
1272
|
+
`cross-repo build inputs need a repository pin for "${repo}" (${role}), ` +
|
|
1273
|
+
'and runtime-image-source-index.json declares none — the vendored content ' +
|
|
1274
|
+
'cannot be pinned, so the image cannot be addressed',
|
|
1275
|
+
);
|
|
1276
|
+
}
|
|
1277
|
+
return pin;
|
|
1278
|
+
};
|
|
1279
|
+
const builds = (input) =>
|
|
1280
|
+
subjects.some(
|
|
1281
|
+
(subject) =>
|
|
1282
|
+
subject.repo === input.consumerRepo &&
|
|
1283
|
+
(input.consumerService === undefined || input.consumerService === null ||
|
|
1284
|
+
input.consumerService === subject.id ||
|
|
1285
|
+
(composedProcessKind !== undefined && subject.kind === composedProcessKind)),
|
|
1286
|
+
);
|
|
1287
|
+
const needed = runtimeSourceIndex.crossRepoBuildInputs.filter(builds);
|
|
1288
|
+
const vendoredCommitByInputKey = new Map();
|
|
1289
|
+
for (const consumerRepo of [
|
|
1290
|
+
...new Set(needed.map((input) => input.consumerRepo)),
|
|
1291
|
+
].sort(compareStrings)) {
|
|
1292
|
+
const consumerCommit = requirePin(consumerRepo, 'the consuming repository').sourceCommit;
|
|
1293
|
+
for (const subject of subjects) {
|
|
1294
|
+
if (subject.repo === consumerRepo && subject.sourceCommit !== undefined &&
|
|
1295
|
+
subject.sourceCommit !== consumerCommit) {
|
|
1296
|
+
throw new Error(
|
|
1297
|
+
`runtime subject "${subject.kind}:${subject.id}" is resolved at ` +
|
|
1298
|
+
`${subject.sourceCommit}, but "${consumerRepo}" is pinned at ${consumerCommit}`,
|
|
1299
|
+
);
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
// The record is read against EVERY input the index declares for this
|
|
1303
|
+
// consumer, not the roster-filtered subset: set equality with the
|
|
1304
|
+
// consumer's record is the fence against a drifted declaration, and it must
|
|
1305
|
+
// not weaken because an edition ships fewer services.
|
|
1306
|
+
for (const resolved of resolveVendored({
|
|
1307
|
+
indexInputs: runtimeSourceIndex.crossRepoBuildInputs,
|
|
1308
|
+
consumerRepo,
|
|
1309
|
+
consumerRepoDir: join(repoRoot, consumerRepo),
|
|
1310
|
+
consumerCommit,
|
|
1311
|
+
})) {
|
|
1312
|
+
vendoredCommitByInputKey.set(
|
|
1313
|
+
consumerInputKey(consumerRepo, {
|
|
1314
|
+
sourceRepo: relative(repoRoot, resolved.sourceRepoDir),
|
|
1315
|
+
sourcePath: resolved.sourcePath,
|
|
1316
|
+
destinationPath: resolved.destinationPath,
|
|
1317
|
+
}),
|
|
1318
|
+
resolved.sourceCommit,
|
|
1319
|
+
);
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
const crossRepoInputsByConsumer = new Map();
|
|
1323
|
+
for (const input of needed) {
|
|
1324
|
+
requirePin(input.sourceRepo, `vendored into "${input.consumerRepo}"`);
|
|
1325
|
+
// A null `consumerService` means the vendoring step runs for EVERY service
|
|
1326
|
+
// built from that repository; it is keyed on the repository alone.
|
|
1327
|
+
const key = `${input.consumerRepo}\u0000${input.consumerService ?? ''}`;
|
|
1328
|
+
const sourceCommit = vendoredCommitByInputKey.get(
|
|
1329
|
+
consumerInputKey(input.consumerRepo, input),
|
|
1330
|
+
);
|
|
1331
|
+
if (!sourceCommit) {
|
|
1332
|
+
throw new Error(
|
|
1333
|
+
`cross-repo build input for "${input.consumerService}" vendoring ` +
|
|
1334
|
+
`"${input.sourceRepo}:${input.sourcePath}" has no commit in ` +
|
|
1335
|
+
`"${input.consumerRepo}"'s own cross-repo build-input record — its ` +
|
|
1336
|
+
`vendored content cannot be pinned, so the image cannot be addressed`,
|
|
1337
|
+
);
|
|
1338
|
+
}
|
|
1339
|
+
const entries = crossRepoInputsByConsumer.get(key) ?? [];
|
|
1340
|
+
entries.push({
|
|
1341
|
+
destinationPath: input.destinationPath,
|
|
1342
|
+
sourceRepoDir: join(repoRoot, input.sourceRepo),
|
|
1343
|
+
sourcePath: input.sourcePath,
|
|
1344
|
+
sourceCommit,
|
|
1345
|
+
});
|
|
1346
|
+
crossRepoInputsByConsumer.set(key, entries);
|
|
1347
|
+
}
|
|
1348
|
+
return crossRepoInputsByConsumer;
|
|
1349
|
+
}
|
|
1350
|
+
|
|
1351
|
+
/** The release's inputs: the COMMITTED locks and indexes, read from disk. */
|
|
1352
|
+
function releaseImageInputs(schemas, id) {
|
|
1353
|
+
return {
|
|
1354
|
+
lock: validateImmutableDistributionLock(
|
|
1355
|
+
readBoundDistributionFile({
|
|
1356
|
+
distributionsRoot: editionsRoot(),
|
|
1357
|
+
distributionId: id,
|
|
1358
|
+
fileName: 'distribution.lock.json',
|
|
1359
|
+
// A pure READ of the committed lock: the tolerant reader, never the writer.
|
|
1360
|
+
parse: (raw) => parseResolvedDistributionLock(schemas, raw),
|
|
1361
|
+
}).value,
|
|
1362
|
+
),
|
|
1363
|
+
runtimeSourceIndex: schemas.parseRuntimeImageSourceIndex(readJson(runtimeImageSourceIndexPath())),
|
|
1364
|
+
biomeIndex: readJson(biomeIndexPath()),
|
|
1365
|
+
requiredPlatforms: resolveDistributionRequiredPlatforms(schemas, id),
|
|
1366
|
+
declaredSources: resolveDistributionDeclaredSources(schemas, id),
|
|
1367
|
+
};
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* A develop snapshot's inputs: the SNAPSHOT distribution lock and the
|
|
1372
|
+
* snapshot indexes and chain (`develop-snapshot-inputs.mjs`), all already
|
|
1373
|
+
* resolved at the snapshot's pins. The runtime index carries the snapshot pins
|
|
1374
|
+
* already and is not re-pinned here.
|
|
1375
|
+
*/
|
|
1376
|
+
function snapshotImageInputs(schemas, { inputs, lock }) {
|
|
1377
|
+
return {
|
|
1378
|
+
lock: validateImmutableDistributionLock(lock),
|
|
1379
|
+
runtimeSourceIndex: inputs.runtimeImageSourceIndex,
|
|
1380
|
+
biomeIndex: inputs.biomeIndex,
|
|
1381
|
+
requiredPlatforms: schemas.resolveRuntimeImagePlatforms(inputs.composed.requiredPlatforms),
|
|
1382
|
+
declaredSources: inputs.composed.declaredSources,
|
|
1383
|
+
};
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
export async function resolveImages(id, { inputs, lock } = {}) {
|
|
1387
|
+
assertCanonicalDistributionId(id);
|
|
1388
|
+
if ((inputs === undefined) !== (lock === undefined)) {
|
|
1389
|
+
throw new Error('resolveImages takes the snapshot `inputs` and its distribution `lock` together, or neither');
|
|
1390
|
+
}
|
|
1391
|
+
const schemas = await loadKernelDistributionSchemas();
|
|
1392
|
+
const {
|
|
1393
|
+
lock: distributionLock,
|
|
1394
|
+
runtimeSourceIndex,
|
|
1395
|
+
biomeIndex,
|
|
1396
|
+
requiredPlatforms,
|
|
1397
|
+
declaredSources,
|
|
1398
|
+
} = inputs === undefined ? releaseImageInputs(schemas, id) : snapshotImageInputs(schemas, { inputs, lock });
|
|
1399
|
+
// Materialise every EXTERNAL source at the commit the lock pins, and resolve
|
|
1400
|
+
// every `repo` handle through one locator from here down.
|
|
1401
|
+
const locate = sourceTreeLocator(externalSourceTrees(distributionLock));
|
|
1402
|
+
const structure = resolveComposedProcessStructure(
|
|
1403
|
+
distributionLock,
|
|
1404
|
+
resolveImageStructure(
|
|
1405
|
+
distributionLock,
|
|
1406
|
+
runtimeSourceIndex,
|
|
1407
|
+
schemas,
|
|
1408
|
+
requiredPlatforms,
|
|
1409
|
+
declaredSources,
|
|
1410
|
+
{ biomeIndex },
|
|
1411
|
+
),
|
|
1412
|
+
schemas,
|
|
1413
|
+
requiredPlatforms,
|
|
1414
|
+
{ locate },
|
|
1415
|
+
);
|
|
1416
|
+
const commitsBefore = sourceCommitMap(structure, locate);
|
|
1417
|
+
assertSourceManifestParity(structure, commitsBefore, locate);
|
|
1418
|
+
assertExternalSourcesDeclareNoVendoring(distributionLock, locate, commitsBefore);
|
|
1419
|
+
|
|
1420
|
+
const buildStepDirectoriesByRepo = new Map();
|
|
1421
|
+
for (const directory of runtimeSourceIndex.buildStepContextDirectories) {
|
|
1422
|
+
const entries = buildStepDirectoriesByRepo.get(directory.repo) ?? [];
|
|
1423
|
+
entries.push(directory.path);
|
|
1424
|
+
buildStepDirectoriesByRepo.set(directory.repo, entries);
|
|
1425
|
+
}
|
|
1426
|
+
// Vendored content is pinned from the CONSUMER REPOSITORY'S OWN committed
|
|
1427
|
+
// record of what it vendored (`resolveCrossRepoVendoredInputs`) — never from
|
|
1428
|
+
// the index's `repositoryPins` mirror of the vendored axis, and never from this
|
|
1429
|
+
// edition's resolved commit map. The record is read at the CONSUMER's
|
|
1430
|
+
// repository pin, which is what makes the result roster-independent (see
|
|
1431
|
+
// `resolveCrossRepoInputsForRoster`).
|
|
1432
|
+
//
|
|
1433
|
+
// An image's identity is edition-INDEPENDENT, and that still holds: the record
|
|
1434
|
+
// is a property of the consumer's COMMIT, not of any edition, so every edition
|
|
1435
|
+
// shipping a subject derives the same `contentHash` for it.
|
|
1436
|
+
// The first-party `biome-host-api` image is built once, by the producing
|
|
1437
|
+
// repository's CI, which has no notion of an edition and vendors every
|
|
1438
|
+
// declared cross-repo input. Reading the vendored commit from `commitsBefore`
|
|
1439
|
+
// made that edition-independent quantity depend on the edition, which failed
|
|
1440
|
+
// in both directions at once:
|
|
1441
|
+
// • it is UNCOMPUTABLE for an edition that does not itself resolve the
|
|
1442
|
+
// source repo — `appliance-lean` and `minimal` do not ship the xema-store
|
|
1443
|
+
// biome, so re-resolving either one threw here and their locks could not
|
|
1444
|
+
// be regenerated at all, even though the image they name is the very one
|
|
1445
|
+
// the other six editions resolve fine;
|
|
1446
|
+
// • had it not thrown it would have been WORSE, silently minting a second
|
|
1447
|
+
// content hash for one image — a coordinate no CI ever pushes, which is
|
|
1448
|
+
// the exact "the tag the lock names does not exist" failure the delivery
|
|
1449
|
+
// chain exists to prevent (.claude/rules/image-release-coordinate.md).
|
|
1450
|
+
//
|
|
1451
|
+
// Reading `index.repositoryPins` instead was the SAME failure by a different
|
|
1452
|
+
// route, and it shipped. The mirror is generated from the index and committed
|
|
1453
|
+
// separately, so lagging it by a pin is its normal resting state — and the
|
|
1454
|
+
// vendored commit is framed into the image-input hash's component KEY, so a
|
|
1455
|
+
// one-commit lag mints a `contentHash` for byte-identical bytes that no CI run
|
|
1456
|
+
// has ever pushed. Measured 2026-09-07 on `origin/main`: five of sixty-eight
|
|
1457
|
+
// coordinates 404'd for exactly this, while the tags CI DID push were served
|
|
1458
|
+
// on both platforms. The gate that was supposed to catch it,
|
|
1459
|
+
// `tooling/release/check-image-tag-parity.mjs` in the AGGREGATOR (a path that
|
|
1460
|
+
// resolves in no producing repository, though two of them cite it as if it
|
|
1461
|
+
// did), read `index.repositoryPins` too — so both of its halves consumed the
|
|
1462
|
+
// one input that had diverged, and it reported 474/474 verified while the
|
|
1463
|
+
// delivery plane was undeliverable. It now reads the consumer's record, as
|
|
1464
|
+
// this does.
|
|
1465
|
+
const crossRepoInputsByConsumer = resolveCrossRepoInputsForRoster({
|
|
1466
|
+
runtimeSourceIndex,
|
|
1467
|
+
subjects: structure.subjects,
|
|
1468
|
+
composedProcessKind: schemas.RuntimeImageSubjectKind.ComposedProcess,
|
|
1469
|
+
});
|
|
1470
|
+
// A composed image carries EVERY service of its build, so it vendors every
|
|
1471
|
+
// cross-repository input its repository declares — repository-wide and
|
|
1472
|
+
// per-service alike. The set is a property of the repository at its commit,
|
|
1473
|
+
// never of the edition, so every edition derives the same tag for it.
|
|
1474
|
+
const crossRepoInputsFor = (subject) =>
|
|
1475
|
+
subject.kind === schemas.RuntimeImageSubjectKind.ComposedProcess
|
|
1476
|
+
? [...crossRepoInputsByConsumer]
|
|
1477
|
+
.filter(([key]) => key.startsWith(`${subject.repo}\u0000`))
|
|
1478
|
+
.sort(([a], [b]) => compareStrings(a, b))
|
|
1479
|
+
.flatMap(([, entries]) => entries)
|
|
1480
|
+
: [
|
|
1481
|
+
...(crossRepoInputsByConsumer.get(`${subject.repo}\u0000`) ?? []),
|
|
1482
|
+
...(crossRepoInputsByConsumer.get(`${subject.repo}\u0000${subject.id}`) ?? []),
|
|
1483
|
+
];
|
|
1484
|
+
const subjects = structure.subjects.map((subject) => {
|
|
1485
|
+
const sourceCommit = commitsBefore.get(subject.repo);
|
|
1486
|
+
const repoDirectory = locate(subject.repo).dir;
|
|
1487
|
+
const servicePath = subjectServicePath(subject, sourceCommit, locate);
|
|
1488
|
+
const { sourceManifestPath: ignoredSourceManifestPath, ...lockedSubject } =
|
|
1489
|
+
subject;
|
|
1490
|
+
void ignoredSourceManifestPath;
|
|
1491
|
+
return {
|
|
1492
|
+
...lockedSubject,
|
|
1493
|
+
sourceCommit,
|
|
1494
|
+
contentHash: exactImageBuildInputHash(
|
|
1495
|
+
{
|
|
1496
|
+
repoDir: repoDirectory,
|
|
1497
|
+
service: subject.id,
|
|
1498
|
+
servicePath,
|
|
1499
|
+
commit: sourceCommit,
|
|
1500
|
+
crossRepoInputs: crossRepoInputsFor(subject),
|
|
1501
|
+
buildStepDirectories:
|
|
1502
|
+
buildStepDirectoriesByRepo.get(subject.repo) ?? [],
|
|
1503
|
+
},
|
|
1504
|
+
imageInputSourceHashAlgorithm(),
|
|
1505
|
+
),
|
|
1506
|
+
};
|
|
1507
|
+
});
|
|
1508
|
+
const webBiomes = structure.webBiomes.map((biome) => ({
|
|
1509
|
+
...biome,
|
|
1510
|
+
sourceCommit: commitsBefore.get(biome.repo),
|
|
1511
|
+
}));
|
|
1512
|
+
const imageLock = validateImmutableImageLock(
|
|
1513
|
+
schemas.parseImageLock({
|
|
1514
|
+
schemaVersion: 3,
|
|
1515
|
+
sourceHashAlgorithm: schemas.ImageSourceHashAlgorithm.ImageInputV2,
|
|
1516
|
+
distributionId: id,
|
|
1517
|
+
contractAuthority: runtimeSourceIndex.contractAuthority,
|
|
1518
|
+
subjects,
|
|
1519
|
+
platformServiceBindings: structure.platformServiceBindings,
|
|
1520
|
+
webBiomes,
|
|
1521
|
+
}),
|
|
1522
|
+
schemas,
|
|
1523
|
+
);
|
|
1524
|
+
// The SAME locator the hash used. The default locator resolves every repo
|
|
1525
|
+
// handle to a first-party tree under the aggregator root, so for an edition
|
|
1526
|
+
// with an external source it looked for `<sourcesRoot()>/<source name>`, found no
|
|
1527
|
+
// repository there, and refused with "absent from the local object database"
|
|
1528
|
+
// — after every hash had already been computed correctly through `locate`.
|
|
1529
|
+
// Measured 2026-09-16 resolving a customer edition against a customer root.
|
|
1530
|
+
const commitsAfter = sourceCommitMap(structure, locate);
|
|
1531
|
+
if (serializeJson([...commitsBefore]) !== serializeJson([...commitsAfter])) {
|
|
1532
|
+
throw new Error(
|
|
1533
|
+
'refusing image-lock write: a source repository commit changed while hashing',
|
|
1534
|
+
);
|
|
1535
|
+
}
|
|
1536
|
+
return { imageLock, commits: [...commitsBefore] };
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* Resolve and persist one schema-v3 image lock. Keeping the explicit trusted
|
|
1541
|
+
* write acknowledgement on this exported boundary means the in-process plane
|
|
1542
|
+
* refresh has the same safety contract as the standalone CLI.
|
|
1543
|
+
*/
|
|
1544
|
+
export async function writeResolvedImages(
|
|
1545
|
+
id,
|
|
1546
|
+
{ trustedWriteV3 = false, log = true } = {},
|
|
1547
|
+
) {
|
|
1548
|
+
assertCanonicalDistributionId(id);
|
|
1549
|
+
if (!trustedWriteV3) {
|
|
1550
|
+
throw new Error(
|
|
1551
|
+
'refusing to write a schema-v3 runtime image roster without --trusted-write-v3',
|
|
1552
|
+
);
|
|
1553
|
+
}
|
|
1554
|
+
// The RELEASE lock is published-only: a develop edition resolves through
|
|
1555
|
+
// `develop-edition.mjs` and never reaches this writer.
|
|
1556
|
+
const { imageLock, commits } = await resolveImages(id);
|
|
1557
|
+
const outPath = distributionOutputPath(
|
|
1558
|
+
editionsRoot(),
|
|
1559
|
+
id,
|
|
1560
|
+
'images.lock.json',
|
|
1561
|
+
);
|
|
1562
|
+
assertReleaseContent(imageLock, `the images lock for "${id}"`);
|
|
1563
|
+
writeFileSync(outPath, serializeJson(imageLock));
|
|
1564
|
+
if (log) {
|
|
1565
|
+
console.log(
|
|
1566
|
+
`[dist:images] wrote ${outPath} — ${imageLock.subjects.length} actual runtime image subjects, ${imageLock.webBiomes.length} bundled web biomes, ${imageLock.platformServiceBindings.length} platform bindings.`,
|
|
1567
|
+
);
|
|
1568
|
+
for (const [repo, commit] of commits) {
|
|
1569
|
+
console.log(` source ${repo}@${commit}`);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
return { imageLock, commits };
|
|
1573
|
+
}
|
|
1574
|
+
|
|
1575
|
+
async function main() {
|
|
1576
|
+
const id = process.argv[2];
|
|
1577
|
+
const trustedWriteV3 = process.argv.includes('--trusted-write-v3');
|
|
1578
|
+
if (!id) {
|
|
1579
|
+
throw new Error(
|
|
1580
|
+
'usage: resolve-images.mjs <distribution-id> --trusted-write-v3',
|
|
1581
|
+
);
|
|
1582
|
+
}
|
|
1583
|
+
// `--editions-root` (and its `=` spelling, plus the value that follows
|
|
1584
|
+
// the bare form) is consumed by `editionsRoot()` in lib.mjs, off
|
|
1585
|
+
// `process.argv`, before this parser ever runs. It has to be accepted here or
|
|
1586
|
+
// the flag that decides WHICH editions root is read is rejected by the one
|
|
1587
|
+
// command that reads it.
|
|
1588
|
+
const rest = process.argv.slice(3);
|
|
1589
|
+
const unknownArgs = [];
|
|
1590
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
1591
|
+
const argument = rest[i];
|
|
1592
|
+
if (argument === '--trusted-write-v3') {continue;}
|
|
1593
|
+
unknownArgs.push(argument);
|
|
1594
|
+
}
|
|
1595
|
+
if (unknownArgs.length > 0) {
|
|
1596
|
+
throw new Error(`unknown argument "${unknownArgs[0]}"`);
|
|
1597
|
+
}
|
|
1598
|
+
await writeResolvedImages(id, { trustedWriteV3 });
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
if (isEntryModule(import.meta.url)) {
|
|
1602
|
+
main().catch((error) => {
|
|
1603
|
+
console.error(`[dist:images] ${error.message}`);
|
|
1604
|
+
process.exit(1);
|
|
1605
|
+
});
|
|
1606
|
+
}
|