@xemahq/xema 0.25.1 → 0.27.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/appliance-base/images.lock.json +126 -126
- package/dist/assets/appliance-lean/images.lock.json +123 -123
- package/dist/assets/cloud/images.lock.json +126 -126
- package/dist/assets/core/images.lock.json +120 -120
- package/dist/assets/distribution-assets.manifest.json +15 -16
- package/dist/assets/minimal/images.lock.json +108 -108
- package/dist/assets/oss/images.lock.json +126 -126
- package/dist/assets/starter/images.lock.json +123 -123
- 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 +6 -1
- 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 +54179 -0
- package/dist/edition/verify-image-evidence.bundle.provenance.json +2871 -0
- package/dist/runtime/backing-registration.d.ts.map +1 -1
- package/dist/runtime/backing-registration.js +7 -2
- package/dist/runtime/backing-registration.js.map +1 -1
- 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 +3 -0
- package/dist/runtime/env-seeder.d.ts.map +1 -1
- package/dist/runtime/env-seeder.js +20 -3
- package/dist/runtime/env-seeder.js.map +1 -1
- package/dist/runtime/launch.d.ts +3 -1
- package/dist/runtime/launch.d.ts.map +1 -1
- package/dist/runtime/launch.js +37 -8
- 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 +3 -0
- package/dist/runtime/supervisor.d.ts.map +1 -1
- package/dist/runtime/supervisor.js +2 -0
- package/dist/runtime/supervisor.js.map +1 -1
- package/edition/README.md +315 -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 +270 -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 +207 -0
- package/edition/edition-constants.mjs +57 -0
- package/edition/edition-contract.json +23 -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 +176 -0
- package/edition/evidence-checkout.mjs +418 -0
- package/edition/evidence-grammars.mjs +16 -0
- package/edition/evidence-trust-pin.mjs +304 -0
- package/edition/evidence-trust-policy.mjs +256 -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-evidence-replay-state.mjs +242 -0
- package/edition/image-evidence.mjs +1225 -0
- package/edition/kernel-contract-authority.mjs +443 -0
- package/edition/lib.mjs +3018 -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 +99 -0
- package/edition/release-edition-artifact.mjs +158 -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 +430 -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 +18 -7
- 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 -154
- 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,855 @@
|
|
|
1
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
2
|
+
// seal-image-digests — write the PUBLISHED ARTIFACT's digest into the lock.
|
|
3
|
+
//
|
|
4
|
+
// WHY THIS EXISTS
|
|
5
|
+
// ---------------
|
|
6
|
+
// An `images.lock.json` subject records `contentHash`, `sourceCommit` and
|
|
7
|
+
// `sourceManifestHash`. Every one of those is a BUILD INPUT. None of them names
|
|
8
|
+
// the artifact that was actually produced, so the lock said what was INTENDED
|
|
9
|
+
// and never what SHIPPED.
|
|
10
|
+
//
|
|
11
|
+
// The kernel has carried the field for this the whole time —
|
|
12
|
+
// `RuntimeImageSubjectSchema.imageDigest`, optional, documented down to "this is
|
|
13
|
+
// the INDEX digest, not a per-platform child" and "it belongs to the DELIVERY
|
|
14
|
+
// step". Measured 2026-09-14: `imageDigest` occurs in NONE of the nine committed
|
|
15
|
+
// locks and in NO .mjs in this repository. The contract was declared and the
|
|
16
|
+
// producer was never built. This is the producer.
|
|
17
|
+
//
|
|
18
|
+
// WHAT IT COST TO BE MISSING. `chartlib.imageRef` refuses a 64-hex tag, and a
|
|
19
|
+
// contentHash IS 64-hex — so with no digest to install from, NO Distribution
|
|
20
|
+
// lock could render through the canonical chart library at all. Every fresh
|
|
21
|
+
// customer install, every appliance and every air-gap bundle was blocked on a
|
|
22
|
+
// field nothing wrote.
|
|
23
|
+
//
|
|
24
|
+
// IT RESOLVES NOTHING ITSELF, and that is deliberate
|
|
25
|
+
// --------------------------------------------------
|
|
26
|
+
// `verifyDistributionPlatforms` already fetches the index, hash-checks the bytes
|
|
27
|
+
// against `Docker-Content-Digest` rather than trusting the header, walks every
|
|
28
|
+
// child manifest BY DIGEST, reads each image CONFIG for its real platform, and
|
|
29
|
+
// fails closed when a required platform is absent. It already returns
|
|
30
|
+
// `manifestDigest` per subject and then throws it away. Re-implementing any of
|
|
31
|
+
// that here would be a second resolver that can disagree with the verifier about
|
|
32
|
+
// what was published — so this script performs the verification and records its
|
|
33
|
+
// answer, and there is no path through it that writes a digest the verifier did
|
|
34
|
+
// not prove.
|
|
35
|
+
//
|
|
36
|
+
// FOUR REFUSALS, none of which may be softened:
|
|
37
|
+
//
|
|
38
|
+
// * A subject the verifier cannot resolve aborts the WHOLE run before
|
|
39
|
+
// anything is written. A partially sealed lock is worse than an unsealed
|
|
40
|
+
// one: it installs some services by digest and silently leaves the rest
|
|
41
|
+
// un-pinned.
|
|
42
|
+
// * A subject whose lock ALREADY carries a different `imageDigest` is a hard
|
|
43
|
+
// error, never an update. The tag is immutable by convention in this fleet;
|
|
44
|
+
// if the same contentHash now resolves to different bytes, either the tag
|
|
45
|
+
// moved or the registry is lying, and both are supply-chain incidents rather
|
|
46
|
+
// than drift to reconcile.
|
|
47
|
+
// * Nothing is invented. There is no fallback that derives a digest from the
|
|
48
|
+
// contentHash — they are different hashes of different things, and the whole
|
|
49
|
+
// defect this closes came from treating one as the other.
|
|
50
|
+
// * A run over zero subjects fails. An empty seal that reports success is the
|
|
51
|
+
// "reports green while executing nothing" shape.
|
|
52
|
+
//
|
|
53
|
+
// It is IDEMPOTENT: re-running against an already-sealed lock re-verifies every
|
|
54
|
+
// subject, finds the same digests, and writes nothing.
|
|
55
|
+
//
|
|
56
|
+
// node repos-infra/xema-distributions/seal-image-digests.mjs <distributionId>
|
|
57
|
+
// node repos-infra/xema-distributions/seal-image-digests.mjs <distributionId> --check
|
|
58
|
+
// node repos-infra/xema-distributions/seal-image-digests.mjs <distributionId> --if-published
|
|
59
|
+
// node repos-infra/xema-distributions/seal-image-digests.mjs <distributionId> --publication-verdict
|
|
60
|
+
//
|
|
61
|
+
// `--check` verifies and reports without writing — the CI shape, so a lock that
|
|
62
|
+
// has drifted from the registry fails a pull request instead of a deploy.
|
|
63
|
+
//
|
|
64
|
+
// FOUR MODES, BECAUSE THEY ASK QUESTIONS AT DIFFERENT MOMENTS
|
|
65
|
+
// -----------------------------------------------------------
|
|
66
|
+
// The two added below exist because a lock is WRITTEN before the images it
|
|
67
|
+
// names are built. `resolve-images.mjs` computes `contentHash` from git build
|
|
68
|
+
// inputs, so it runs — correctly — against a registry that does not yet serve
|
|
69
|
+
// the coordinate it just predicted. Sealing therefore cannot happen at lock
|
|
70
|
+
// production, and a single mode cannot serve both the pass that re-pins and the
|
|
71
|
+
// pass that publishes.
|
|
72
|
+
//
|
|
73
|
+
// `--if-published` RECONCILE. Seal every subject, or, if the coordinate is
|
|
74
|
+
// not published YET, skip THE WHOLE EDITION and exit 0.
|
|
75
|
+
//
|
|
76
|
+
// This does NOT soften the partial-seal refusal above, and
|
|
77
|
+
// the distinction is the reason it is safe: that refusal is
|
|
78
|
+
// about writing SOME subjects of one lock. Skipping leaves
|
|
79
|
+
// the lock ENTIRELY unsealed, which is the state the
|
|
80
|
+
// publication gate then refuses. An edition is still all or
|
|
81
|
+
// nothing; what changed is that "nothing" became reachable
|
|
82
|
+
// without failing a scheduled job that re-pins every 20
|
|
83
|
+
// minutes. Without it, every pin move reddens reconcile
|
|
84
|
+
// fleet-wide for the interval between the pin landing and
|
|
85
|
+
// its producers building — which is the normal case, not an
|
|
86
|
+
// exceptional one.
|
|
87
|
+
//
|
|
88
|
+
// ONLY a 404 skips. The discriminator is `error.status`,
|
|
89
|
+
// set by `manifestHttpError` and measured to survive
|
|
90
|
+
// `verifyDistributionPlatforms` intact — never a match on
|
|
91
|
+
// message text, which would silently widen the day a
|
|
92
|
+
// registry reworded its error body. A 401, a 403, a
|
|
93
|
+
// malformed index or an absent required platform all still
|
|
94
|
+
// abort.
|
|
95
|
+
//
|
|
96
|
+
// `--publication-verdict` PUBLICATION. Classify the lock PUBLISHABLE
|
|
97
|
+
// (fully sealed) or WITHHELD (wholly unsealed — the resting
|
|
98
|
+
// state a pin advance leaves, owner ruling 2026-09-29), as
|
|
99
|
+
// ONE line on stdout, exit 0. A malformed, empty or
|
|
100
|
+
// PARTIALLY sealed lock exits 1: a broken artifact.
|
|
101
|
+
// OFFLINE and structural: it reads the lock and nothing
|
|
102
|
+
// else. That is deliberate rather than a shortcut — a
|
|
103
|
+
// publication gate that needs a registry credential can
|
|
104
|
+
// fail for reasons that have nothing to do with the artifact
|
|
105
|
+
// being published, and a gate people learn to re-run is a
|
|
106
|
+
// gate that stops being read. `--check` answers the STRONGER
|
|
107
|
+
// question (does the lock still AGREE with the registry) and
|
|
108
|
+
// needs the network to do it; this one answers the question
|
|
109
|
+
// publication actually turns on.
|
|
110
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
import { readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
|
113
|
+
import { join } from 'node:path';
|
|
114
|
+
|
|
115
|
+
import { sealLock, verifyBiomeContentHashes } from './biome-content.mjs';
|
|
116
|
+
import { fetchCatalogue } from './catalogue-fetch.mjs';
|
|
117
|
+
import { CATALOGUE_FLAG, CATALOGUE_TOKEN_ENV_FLAG, CATALOGUE_USERNAME_FLAG } from './customer-flags.mjs';
|
|
118
|
+
import { WorkspaceRoot, configureEditionWorkspaceFromProcess, editionsRoot, isEntryModule, runtimeImageSourceIndexPath } from './edition-workspace.mjs';
|
|
119
|
+
import {
|
|
120
|
+
assertCoordinatesFinal,
|
|
121
|
+
collectFleetPlatformRequirements,
|
|
122
|
+
isNotFinalYet,
|
|
123
|
+
readFleetImageLocks,
|
|
124
|
+
} from './fleet-coordinate-finality.mjs';
|
|
125
|
+
import { loadKernelDistributionSchemas, serializeJson } from './lib.mjs';
|
|
126
|
+
import { assertPlatformCanParseLock } from './platform-lock-readability.mjs';
|
|
127
|
+
import { assertReleaseEditionTree } from './release-channel-guard.mjs';
|
|
128
|
+
import { commandForSelf } from './remedy-command.mjs';
|
|
129
|
+
import { externalSourceTrees, sourceTreeLocator } from './resolve-images.mjs';
|
|
130
|
+
import { committedRepositoryPins, createPinnedSourceAuthority } from './source-authority.mjs';
|
|
131
|
+
import {
|
|
132
|
+
loadImageLockForVerification,
|
|
133
|
+
verifyDistributionPlatforms,
|
|
134
|
+
} from './verify-image-platforms.mjs';
|
|
135
|
+
|
|
136
|
+
if (isEntryModule(import.meta.url)) {
|
|
137
|
+
configureEditionWorkspaceFromProcess([WorkspaceRoot.Data, WorkspaceRoot.Editions, WorkspaceRoot.Sources, WorkspaceRoot.Kernel, WorkspaceRoot.Cache, WorkspaceRoot.SourceHash]);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const SHA256_DIGEST = /^sha256:[a-f0-9]{64}$/u;
|
|
141
|
+
|
|
142
|
+
// This file's own directory is the distributions root in BOTH layouts the fleet
|
|
143
|
+
// runs — the aggregator's `repos-infra/xema-distributions/` and a FLAT checkout
|
|
144
|
+
// of this repository alone. It is the FALLBACK only: `--editions-root` and
|
|
145
|
+
// `XEMA_DISTRIBUTIONS_ROOT` still win, through the plane's own resolver, so an
|
|
146
|
+
// external editions root is honoured here exactly as it is everywhere else.
|
|
147
|
+
//
|
|
148
|
+
// The module-level `editionsRoot()` in `lib.mjs` is deliberately NOT used: it
|
|
149
|
+
// applies the resolver with a fallback that assumes the aggregator layout, and
|
|
150
|
+
// measured from a flat checkout it answers a path that does not exist.
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The remedy, as a command the reader can actually run FROM WHERE THEY ARE.
|
|
154
|
+
*
|
|
155
|
+
* Built from this file's real path rather than printed as `pnpm dist:seal-digests`,
|
|
156
|
+
* because that script is declared in the AGGREGATOR's `package.json` and this
|
|
157
|
+
* repository has no `scripts` block at all — so the pnpm spelling is unrunnable
|
|
158
|
+
* in a flat checkout, which is exactly where the publication gate fails. A
|
|
159
|
+
* remedy that cannot be run where it is printed is a dead end.
|
|
160
|
+
*
|
|
161
|
+
* This was the only site in the repository that got it right, and the reasoning
|
|
162
|
+
* above is why. It now DELEGATES to `remedy-command.mjs`, which generalises it
|
|
163
|
+
* to the other ten diagnostics rather than leaving each to re-derive it.
|
|
164
|
+
*/
|
|
165
|
+
export function sealCommandFor(distributionId) {
|
|
166
|
+
return commandForSelf(import.meta.url, distributionId);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The lock as the RESOLVER sees it: every subject with its sealed `imageDigest`
|
|
171
|
+
* removed, everything else byte-for-byte as committed.
|
|
172
|
+
*
|
|
173
|
+
* `resolve-images.mjs` computes a lock from git build inputs and can never know
|
|
174
|
+
* the digest — the digest is what this module writes AFTER the registry has
|
|
175
|
+
* served the coordinate. So a comparison of a committed lock against a fresh
|
|
176
|
+
* resolve has to subtract exactly what the seal added, or a sealed lock reads
|
|
177
|
+
* as drifted from a resolver that is in fact in full agreement with it.
|
|
178
|
+
*
|
|
179
|
+
* Measured 2026-09-16: `dist-check.mjs` stripped `contentHash` and
|
|
180
|
+
* `sourceManifestPath` from its structural view and NOT `imageDigest`, so every
|
|
181
|
+
* lock this module had sealed (a customer edition, 63/63) was reported
|
|
182
|
+
* "structurally out of date" and the printed remedy — re-resolve — would have
|
|
183
|
+
* produced an UNSEALED lock that the publication gate then refuses. Sealing
|
|
184
|
+
* must never make the drift check red: the digest is a fact about what
|
|
185
|
+
* SHIPPED, layered over a lock whose structure and hashes it does not touch.
|
|
186
|
+
*
|
|
187
|
+
* Pure, and owned HERE rather than in the checker because this is the module
|
|
188
|
+
* that decides what a seal writes; a second list of "fields the seal adds" in
|
|
189
|
+
* `dist-check.mjs` is the pair that can only agree by hand.
|
|
190
|
+
*/
|
|
191
|
+
export function withoutSealedDigests(lock) {
|
|
192
|
+
return {
|
|
193
|
+
...lock,
|
|
194
|
+
subjects: (lock.subjects ?? []).map(
|
|
195
|
+
({ imageDigest: _imageDigest, ...subject }) => subject,
|
|
196
|
+
),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Pure: decide what each subject's `imageDigest` should become.
|
|
202
|
+
*
|
|
203
|
+
* Separated from the I/O so the decision table is testable without a registry,
|
|
204
|
+
* and so the refusals are assertable rather than reachable only through a
|
|
205
|
+
* network round trip.
|
|
206
|
+
*/
|
|
207
|
+
export function planDigestSeal({ subjects, verified }) {
|
|
208
|
+
if (!Array.isArray(subjects) || subjects.length === 0) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
'seal-image-digests: the lock declares no subjects; sealing nothing and ' +
|
|
211
|
+
'reporting success would be a verification that executed nothing',
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const byId = new Map(
|
|
215
|
+
verified.map((entry) => [`${entry.subjectKind}:${entry.subjectId}`, entry]),
|
|
216
|
+
);
|
|
217
|
+
const writes = [];
|
|
218
|
+
const unchanged = [];
|
|
219
|
+
for (const subject of subjects) {
|
|
220
|
+
const key = `${subject.kind}:${subject.id}`;
|
|
221
|
+
const entry = byId.get(key);
|
|
222
|
+
if (!entry) {
|
|
223
|
+
throw new Error(
|
|
224
|
+
`seal-image-digests: ${key} has no verification result; refusing to seal a ` +
|
|
225
|
+
'lock the verifier did not fully cover',
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
const digest = entry.manifestDigest;
|
|
229
|
+
if (typeof digest !== 'string' || !SHA256_DIGEST.test(digest)) {
|
|
230
|
+
throw new Error(
|
|
231
|
+
`seal-image-digests: ${key} resolved to "${digest}", which is not a canonical ` +
|
|
232
|
+
'sha256 digest',
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
if (subject.imageDigest === digest) {
|
|
236
|
+
unchanged.push({ key, digest });
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
if (typeof subject.imageDigest === 'string' && subject.imageDigest.length > 0) {
|
|
240
|
+
throw new Error(
|
|
241
|
+
`seal-image-digests: ${key} is already sealed at ${subject.imageDigest} but ` +
|
|
242
|
+
`${entry.coordinate} now resolves to ${digest}. The tag MOVED, or the registry ` +
|
|
243
|
+
'is serving different bytes for the same coordinate. This is not drift to ' +
|
|
244
|
+
'reconcile — nothing is rewritten, and the cause must be established first.',
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
writes.push({ key, digest, coordinate: entry.coordinate });
|
|
248
|
+
}
|
|
249
|
+
return { writes, unchanged };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** The two RESTING STATES a lock may be published from — or withheld in. */
|
|
253
|
+
export const PublicationVerdict = Object.freeze({
|
|
254
|
+
Publishable: 'PUBLISHABLE',
|
|
255
|
+
Withheld: 'WITHHELD',
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Pure: is this lock PUBLISHABLE, WITHHELD, or BROKEN?
|
|
260
|
+
*
|
|
261
|
+
* The publication gate. OFFLINE — it reads the parsed lock and nothing else — so
|
|
262
|
+
* it cannot fail for a reason unrelated to the artifact being published, and
|
|
263
|
+
* cannot be re-run into passing.
|
|
264
|
+
*
|
|
265
|
+
* THREE ANSWERS, NOT TWO. Owner ruling 2026-09-29: an unsealed lock blocks
|
|
266
|
+
* PUBLICATION and nothing else. A pin advance is committed UNSEALED — the
|
|
267
|
+
* resolver strips every `imageDigest` when it re-pins — and only the edition
|
|
268
|
+
* that must publish is sealed. So a wholly unsealed lock is a RESTING STATE of
|
|
269
|
+
* `main`, not a defect, and it gets the npm release lane's shape: the edition is
|
|
270
|
+
* WITHHELD, said out loud, and nothing is uploaded for it. It is never
|
|
271
|
+
* published, because an unsealed lock addresses its images by a MUTABLE tag and
|
|
272
|
+
* `chartlib.imageRef` refuses a bare 64-hex contentHash in the tag slot, so it
|
|
273
|
+
* could not be installed by the canonical chart library at all.
|
|
274
|
+
*
|
|
275
|
+
* What still THROWS is a lock no resting state produces — a BROKEN artifact:
|
|
276
|
+
*
|
|
277
|
+
* * NO SUBJECTS — a gate passing over an empty roster has verified nothing;
|
|
278
|
+
* * a NON-CANONICAL digest — a bare 64-hex contentHash laundered into the
|
|
279
|
+
* digest slot, an uppercased or truncated one;
|
|
280
|
+
* * a PARTIAL seal — some subjects carry a digest and others do not. The
|
|
281
|
+
* sealer refuses to write one (it seals all or nothing) and the resolver
|
|
282
|
+
* strips all or nothing, so no correct path produces it. It would install
|
|
283
|
+
* some services by digest and silently leave the rest un-pinned.
|
|
284
|
+
*
|
|
285
|
+
* IT CHECKS SHAPE AS WELL AS PRESENCE. This gate reads the lock with
|
|
286
|
+
* `JSON.parse` because the publication workflow checks out this repository
|
|
287
|
+
* ALONE, with no built kernel to parse through, so the kernel's digest regex is
|
|
288
|
+
* not upstream of it and this file's own `SHA256_DIGEST` is the only rule on
|
|
289
|
+
* this path.
|
|
290
|
+
*/
|
|
291
|
+
export function classifyPublicationState({
|
|
292
|
+
distributionId,
|
|
293
|
+
subjects,
|
|
294
|
+
remedy = sealCommandFor(distributionId),
|
|
295
|
+
}) {
|
|
296
|
+
if (!Array.isArray(subjects) || subjects.length === 0) {
|
|
297
|
+
throw new Error(
|
|
298
|
+
`seal-image-digests: "${distributionId}" declares no subjects; a publication gate ` +
|
|
299
|
+
'that passes over an empty roster has verified nothing',
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
// MALFORMED is reported before anything else: one verdict, not a cascade.
|
|
303
|
+
const malformed = subjects
|
|
304
|
+
.filter(
|
|
305
|
+
(subject) =>
|
|
306
|
+
typeof subject.imageDigest === 'string' &&
|
|
307
|
+
subject.imageDigest.length > 0 &&
|
|
308
|
+
!SHA256_DIGEST.test(subject.imageDigest),
|
|
309
|
+
)
|
|
310
|
+
.map((subject) => `${subject.kind}:${subject.id}=${subject.imageDigest}`);
|
|
311
|
+
if (malformed.length > 0) {
|
|
312
|
+
throw new Error(
|
|
313
|
+
`seal-image-digests: "${distributionId}" carries ${malformed.length} NON-CANONICAL ` +
|
|
314
|
+
`imageDigest value(s): ${malformed.slice(0, 4).join(', ')}. A digest is ` +
|
|
315
|
+
'`sha256:` followed by 64 lowercase hex characters. A BARE 64-hex value is a ' +
|
|
316
|
+
"contentHash — a hash of BUILD INPUTS, not of the artifact — and `chartlib.imageRef` " +
|
|
317
|
+
'refuses it in the tag slot for exactly this reason. Refusing to publish.',
|
|
318
|
+
);
|
|
319
|
+
}
|
|
320
|
+
const unsealed = subjects
|
|
321
|
+
.filter((subject) => typeof subject.imageDigest !== 'string' || subject.imageDigest.length === 0)
|
|
322
|
+
.map((subject) => `${subject.kind}:${subject.id}`);
|
|
323
|
+
if (unsealed.length === 0) {
|
|
324
|
+
return {
|
|
325
|
+
verdict: PublicationVerdict.Publishable,
|
|
326
|
+
distributionId,
|
|
327
|
+
subjectCount: subjects.length,
|
|
328
|
+
unsealedCount: 0,
|
|
329
|
+
};
|
|
330
|
+
}
|
|
331
|
+
if (unsealed.length < subjects.length) {
|
|
332
|
+
throw new Error(
|
|
333
|
+
`seal-image-digests: "${distributionId}" is PARTIALLY sealed — ${unsealed.length} of ` +
|
|
334
|
+
`${subjects.length} subject(s) carry no imageDigest: ${unsealed.slice(0, 8).join(', ')}` +
|
|
335
|
+
`${unsealed.length > 8 ? `, and ${unsealed.length - 8} more` : ''}. ` +
|
|
336
|
+
'No correct path produces this: the sealer writes all or nothing and the resolver ' +
|
|
337
|
+
'strips all or nothing. It would install some services by digest and silently ' +
|
|
338
|
+
'leave the rest un-pinned. This is a broken artifact, not a resting state; ' +
|
|
339
|
+
`refusing to publish. Re-seal the whole edition:\n ${remedy}`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
return {
|
|
343
|
+
verdict: PublicationVerdict.Withheld,
|
|
344
|
+
distributionId,
|
|
345
|
+
subjectCount: subjects.length,
|
|
346
|
+
unsealedCount: unsealed.length,
|
|
347
|
+
remedy,
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* Is this failure "the coordinate is not published YET"?
|
|
353
|
+
*
|
|
354
|
+
* `manifestHttpError` stamps `error.status` from the HTTP response, and that
|
|
355
|
+
* property was measured surviving `verifyDistributionPlatforms` intact against
|
|
356
|
+
* the live registry (a fabricated tag on a real repository returns
|
|
357
|
+
* `status === 404`, body `MANIFEST_UNKNOWN`). Matching the MESSAGE instead would
|
|
358
|
+
* widen silently the day ghcr rewords its error body — and widening this
|
|
359
|
+
* predicate turns a hard failure into a skip, which is the one direction that
|
|
360
|
+
* must never happen by accident.
|
|
361
|
+
*/
|
|
362
|
+
export function isNotPublishedYet(error) {
|
|
363
|
+
return Boolean(error) && error.status === 404;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Pure: split argv into the mode flags and the ONE positional distribution id.
|
|
368
|
+
*
|
|
369
|
+
* WHY THIS EXISTS AS A FUNCTION
|
|
370
|
+
* -----------------------------
|
|
371
|
+
* The previous form was `args.filter((a) => !FLAGS.has(a))` over a set holding
|
|
372
|
+
* only the three MODE flags, and everything surviving that filter was treated as
|
|
373
|
+
* positional. `--editions-root <path>` therefore contributed TWO unexpected
|
|
374
|
+
* positionals and the usage error fired — so the editions-root flag was
|
|
375
|
+
* REJECTED by the very script that threads argv into the resolver
|
|
376
|
+
* (`editionsRoot()` below passes `args` to `resolveEditionsRootWithSource`,
|
|
377
|
+
* which reads exactly this flag).
|
|
378
|
+
*
|
|
379
|
+
* That is the "written and never wired" shape pointed at a CLI: the capability
|
|
380
|
+
* was reachable in code and unreachable from the command line, while
|
|
381
|
+
* `XEMA_DISTRIBUTIONS_ROOT` — the env half of the same precedence chain — worked.
|
|
382
|
+
* A customer edition resolved from a root this repository does not own is the
|
|
383
|
+
* case the flag exists for, and it is exactly the case that could not be spelled.
|
|
384
|
+
*
|
|
385
|
+
* The flag NAME is imported from `lib.mjs` rather than restated here, so this
|
|
386
|
+
* parser cannot drift from the resolver that consumes it.
|
|
387
|
+
*
|
|
388
|
+
* VALUE HANDLING IS DELIBERATELY NOT DUPLICATED. The space form's value is
|
|
389
|
+
* skipped without being validated, because `resolveEditionsRootWithSource`
|
|
390
|
+
* already refuses a missing value, an empty one, and one that swallows the next
|
|
391
|
+
* flag. Re-checking here would be a second implementation of a rule that owns
|
|
392
|
+
* itself — and the two could disagree about which is authoritative.
|
|
393
|
+
*/
|
|
394
|
+
export function parseSealArgs(args) {
|
|
395
|
+
const MODES = new Set(['--check', '--if-published', '--publication-verdict']);
|
|
396
|
+
const CATALOGUE_VALUE_FLAGS = new Set([CATALOGUE_FLAG, CATALOGUE_USERNAME_FLAG, CATALOGUE_TOKEN_ENV_FLAG]);
|
|
397
|
+
const positional = [];
|
|
398
|
+
const catalogueFlags = new Map();
|
|
399
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
400
|
+
const arg = args[i];
|
|
401
|
+
if (MODES.has(arg)) {continue;}
|
|
402
|
+
if (CATALOGUE_VALUE_FLAGS.has(arg)) {
|
|
403
|
+
if (args[i + 1] === undefined || args[i + 1].startsWith('--')) {throw new Error(`${arg} takes a value`);}
|
|
404
|
+
catalogueFlags.set(arg, args[i + 1]);
|
|
405
|
+
i += 1;
|
|
406
|
+
continue;
|
|
407
|
+
}
|
|
408
|
+
positional.push(arg);
|
|
409
|
+
}
|
|
410
|
+
const [distributionId, ...rest] = positional;
|
|
411
|
+
return {
|
|
412
|
+
catalogueFlags,
|
|
413
|
+
distributionId,
|
|
414
|
+
rest,
|
|
415
|
+
check: args.includes('--check'),
|
|
416
|
+
ifPublished: args.includes('--if-published'),
|
|
417
|
+
publicationVerdict: args.includes('--publication-verdict'),
|
|
418
|
+
};
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/** Where a lock is staged before it replaces `path`: beside it, so the rename never crosses a filesystem. */
|
|
422
|
+
export const sealStagingPath = (path) => `${path}.sealing`;
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Replace the locks of one seal together. Every document is first written to its
|
|
426
|
+
* staging file; only when all of them are on disk is each renamed over its lock,
|
|
427
|
+
* in the order given. A failure while staging removes what was staged and leaves
|
|
428
|
+
* every committed lock byte-identical.
|
|
429
|
+
*/
|
|
430
|
+
function replaceLocksTogether(documents) {
|
|
431
|
+
const staged = [];
|
|
432
|
+
try {
|
|
433
|
+
for (const { path, text } of documents) {
|
|
434
|
+
writeFileSync(sealStagingPath(path), text);
|
|
435
|
+
staged.push(path);
|
|
436
|
+
}
|
|
437
|
+
} catch (error) {
|
|
438
|
+
for (const path of staged) {rmSync(sealStagingPath(path), { force: true });}
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
441
|
+
for (const { path } of documents) {renameSync(sealStagingPath(path), path);}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* The seal of one edition's two locks, as ONE write.
|
|
446
|
+
*
|
|
447
|
+
* `digests` (`kind:id` -> registry digest) are the image digests this run seals
|
|
448
|
+
* into the images lock. Each biome's `biomeContentHash` is then RECOMPUTED from
|
|
449
|
+
* its sources over that sealed images lock, and the unsealed distribution lock
|
|
450
|
+
* the resolver wrote is turned into the v5 lock by the kernel's
|
|
451
|
+
* `sealDistributionLock`. A distribution lock that is already sealed is VERIFIED
|
|
452
|
+
* instead: every hash is recomputed and a mismatch is refused, naming the biome.
|
|
453
|
+
*
|
|
454
|
+
* Nothing is written until both documents exist in memory, and the distribution
|
|
455
|
+
* lock is renamed LAST: a seal that fails for any reason before that leaves the
|
|
456
|
+
* committed pair as it was, and the one state a crash between the two renames
|
|
457
|
+
* can leave (images sealed, distribution unsealed) is refused by every reader of
|
|
458
|
+
* a sealed edition.
|
|
459
|
+
*/
|
|
460
|
+
export async function sealEditionLocks({ distributionId, root, digests = new Map(), check = false, label = 'dist:seal-digests', vendoredCommit, catalogue, catalogueDigest }) {
|
|
461
|
+
const schemas = await loadKernelDistributionSchemas();
|
|
462
|
+
const lockPath = join(root, distributionId, 'distribution.lock.json');
|
|
463
|
+
const imagesPath = join(root, distributionId, 'images.lock.json');
|
|
464
|
+
// The file is re-read rather than the PARSED lock re-serialised: the parse is a
|
|
465
|
+
// validation, not a round-trip, and writing back a schema-normalised document
|
|
466
|
+
// would silently reformat or drop anything the parser does not model.
|
|
467
|
+
const rawImages = JSON.parse(readFileSync(imagesPath, 'utf8'));
|
|
468
|
+
let applied = 0;
|
|
469
|
+
for (const subject of rawImages.subjects) {
|
|
470
|
+
const digest = digests.get(`${subject.kind}:${subject.id}`);
|
|
471
|
+
if (digest) {
|
|
472
|
+
subject.imageDigest = digest;
|
|
473
|
+
applied += 1;
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
if (applied !== digests.size) {
|
|
477
|
+
throw new Error(
|
|
478
|
+
`seal-image-digests: planned ${digests.size} writes but matched ${applied} subjects ` +
|
|
479
|
+
'in the file on disk; nothing written',
|
|
480
|
+
);
|
|
481
|
+
}
|
|
482
|
+
const raw = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
483
|
+
const imageLock = schemas.parseImageLock(rawImages);
|
|
484
|
+
const pinned = vendoredCommit ?? ((repo) => createPinnedSourceAuthority({ pins: committedRepositoryPins() }).vendoredSourceCommitForRepository(repo));
|
|
485
|
+
const lockDocument = raw !== null && typeof raw === 'object' && 'unsealedSchema' in raw ? schemas.parseUnsealedDistributionLock(raw) : schemas.parseDistributionLock(raw);
|
|
486
|
+
const result = await sealOrVerifyLock({
|
|
487
|
+
schemas,
|
|
488
|
+
lock: lockDocument,
|
|
489
|
+
imageLock,
|
|
490
|
+
locate: sourceTreeLocator(externalSourceTrees(lockDocument)),
|
|
491
|
+
vendoredCommit: pinned,
|
|
492
|
+
catalogue,
|
|
493
|
+
catalogueDigest,
|
|
494
|
+
check,
|
|
495
|
+
lockPath,
|
|
496
|
+
});
|
|
497
|
+
replaceLocksTogether([
|
|
498
|
+
...(applied > 0 ? [{ path: imagesPath, text: `${JSON.stringify(rawImages, null, 2)}\n` }] : []),
|
|
499
|
+
...(result.sealed ? [{ path: lockPath, text: serializeJson(result.lock) }] : []),
|
|
500
|
+
]);
|
|
501
|
+
if (applied > 0) {console.log(`[${label}] sealed ${applied} subject(s) into ${imagesPath}.`);}
|
|
502
|
+
if (result.sealed) {
|
|
503
|
+
console.log(`[${label}] sealed ${result.lock.biomes.length} biomeContentHash(es) into ${lockPath}.`);
|
|
504
|
+
} else {
|
|
505
|
+
console.log(`[${label}] ${result.lock.biomes.length} biomeContentHash(es) recomputed from their inputs and agree with ${lockPath}.`);
|
|
506
|
+
}
|
|
507
|
+
return result.lock;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* The decision, with no files: an UNSEALED lock is sealed (refused under `check`); a SEALED lock is VERIFIED -
|
|
512
|
+
* every hash recomputed, a mismatch refused naming the biome.
|
|
513
|
+
*/
|
|
514
|
+
export async function sealOrVerifyLock({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest, check = false, lockPath = 'distribution.lock.json' }) {
|
|
515
|
+
if ('unsealedSchema' in lock) {
|
|
516
|
+
if (check) {
|
|
517
|
+
throw new Error(`${lockPath} is unsealed (no biomeContentHash); run \`xema edition seal ${lock.distributionId}\``);
|
|
518
|
+
}
|
|
519
|
+
return { sealed: true, lock: await sealLock({ schemas, unsealed: lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest }) };
|
|
520
|
+
}
|
|
521
|
+
await verifyBiomeContentHashes({ schemas, lock, imageLock, locate, vendoredCommit, catalogue, catalogueDigest });
|
|
522
|
+
return { sealed: false, lock };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
async function main() {
|
|
526
|
+
const args = process.argv.slice(2);
|
|
527
|
+
const { distributionId, rest, check, ifPublished, publicationVerdict, catalogueFlags } =
|
|
528
|
+
parseSealArgs(args);
|
|
529
|
+
if (!distributionId || rest.length > 0) {
|
|
530
|
+
throw new Error(
|
|
531
|
+
'usage: xema edition seal <distributionId> ' +
|
|
532
|
+
`[--check | --if-published | --publication-verdict] ` +
|
|
533
|
+
`[--catalogue oci://<repository>@sha256:<digest> [--catalogue-username <name> --catalogue-token-env <VAR>]]`,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
// The modes answer different questions and combining them would make the
|
|
537
|
+
// verdict depend on precedence nobody declared.
|
|
538
|
+
const selected = [check, ifPublished, publicationVerdict].filter(Boolean).length;
|
|
539
|
+
if (selected > 1) {
|
|
540
|
+
throw new Error(
|
|
541
|
+
'seal-image-digests: --check, --if-published and --publication-verdict are mutually exclusive',
|
|
542
|
+
);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
// PUBLICATION GATE — offline and structural. It returns BEFORE any call that
|
|
546
|
+
// would load the built kernel contracts, so it needs no sibling repository, no
|
|
547
|
+
// credential and no `node_modules`.
|
|
548
|
+
//
|
|
549
|
+
// The distinction is the IMPORT versus the CALL, and getting it wrong once
|
|
550
|
+
// cost a needless refactor: importing `verify-image-platforms.mjs`,
|
|
551
|
+
// `oras.mjs` and `lib.mjs` is all FINE without a kernel —
|
|
552
|
+
// measured, all three load in a flat checkout — because
|
|
553
|
+
// `loadKernelDistributionSchemas()` is invoked at USE time, not at module
|
|
554
|
+
// load. What fails in the publication checkout is CALLING
|
|
555
|
+
// `loadImageLockForVerification`. So the fix is to not call it, which is what
|
|
556
|
+
// this early return does; making the imports dynamic bought nothing and was
|
|
557
|
+
// reverted.
|
|
558
|
+
// EVERY mode, the offline publication verdict included: a release edition
|
|
559
|
+
// never carries develop content, whatever the sealer is asked to do with it.
|
|
560
|
+
assertReleaseEditionTree(join(editionsRoot(), distributionId));
|
|
561
|
+
|
|
562
|
+
if (publicationVerdict) {
|
|
563
|
+
const lockPath = join(editionsRoot(), distributionId, 'images.lock.json');
|
|
564
|
+
const raw = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
565
|
+
const state = classifyPublicationState({
|
|
566
|
+
distributionId,
|
|
567
|
+
subjects: raw.subjects,
|
|
568
|
+
});
|
|
569
|
+
// STDOUT CARRIES EXACTLY ONE MACHINE LINE, and nothing else. The publish
|
|
570
|
+
// loop branches on it, and accepts only these two shapes: anything else —
|
|
571
|
+
// including an empty stdout from a crash — is a failure there, never a
|
|
572
|
+
// withhold. The human explanation goes to stderr.
|
|
573
|
+
if (state.verdict === PublicationVerdict.Publishable) {
|
|
574
|
+
console.error(
|
|
575
|
+
`[dist:seal-digests --publication-verdict] "${distributionId}": all ` +
|
|
576
|
+
`${state.subjectCount} subject(s) carry a canonical imageDigest; publishable. ` +
|
|
577
|
+
`(${lockPath})`,
|
|
578
|
+
);
|
|
579
|
+
console.log(`${state.verdict} ${distributionId} ${state.subjectCount}`);
|
|
580
|
+
return;
|
|
581
|
+
}
|
|
582
|
+
console.error(
|
|
583
|
+
`[dist:seal-digests --publication-verdict] "${distributionId}": WITHHELD — all ` +
|
|
584
|
+
`${state.unsealedCount} subject(s) are unsealed. This is a resting state (owner ` +
|
|
585
|
+
'ruling 2026-09-29: an unsealed lock blocks PUBLICATION and nothing else), so ' +
|
|
586
|
+
'nothing is uploaded for it. To publish it, seal it once its producers have ' +
|
|
587
|
+
`built:\n ${state.remedy}`,
|
|
588
|
+
);
|
|
589
|
+
console.log(
|
|
590
|
+
`${state.verdict} ${distributionId} ${state.unsealedCount} ${state.subjectCount}`,
|
|
591
|
+
);
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// THE ROOT MUST REACH THE READ, NOT ONLY THE WRITE.
|
|
596
|
+
//
|
|
597
|
+
// `loadImageLockForVerification` takes `(distributionId, distributionsRoot =
|
|
598
|
+
// dataRoot())`. Called with one argument it silently read the DEFAULT
|
|
599
|
+
// root while the write below used `editionsRoot()` — so with an external
|
|
600
|
+
// root configured, this script verified ONE lock and would have sealed the
|
|
601
|
+
// digests it proved into a DIFFERENT one. It failed safe only when the id was
|
|
602
|
+
// absent from the default root (ENOENT); an edition present in BOTH roots
|
|
603
|
+
// would have been sealed from the wrong verification, silently.
|
|
604
|
+
//
|
|
605
|
+
// That fix reached the LOCK read and NOT the sibling seven lines below it,
|
|
606
|
+
// and the sentence that used to stand here — "one root, resolved once, used
|
|
607
|
+
// by every path in this function" — was the reason: a reader confirms it
|
|
608
|
+
// against the call it is standing next to and stops. `verifyDistributionPlatforms`
|
|
609
|
+
// defaults `distributionsRoot` to `dataRoot()`, so the REGISTRY
|
|
610
|
+
// CREDENTIALS were still read out of this repository while the lock came from
|
|
611
|
+
// the configured root.
|
|
612
|
+
//
|
|
613
|
+
// The two halves fail with OPPOSITE loudness, which is why only the quiet one
|
|
614
|
+
// survived: a lock read against the wrong root is an ENOENT, and a credential
|
|
615
|
+
// read against the wrong root simply authenticates against a different
|
|
616
|
+
// declaration file and proceeds. A customer root declaring its own registry
|
|
617
|
+
// authority would have been verified with the vendor's declarations.
|
|
618
|
+
//
|
|
619
|
+
// Both reads now take `root`. Asserted BEHAVIOURALLY in
|
|
620
|
+
// `seal-image-digests.test.mjs` — the external root declares a sentinel
|
|
621
|
+
// `authTokenEnv` and the test reads which variable the refusal names, because
|
|
622
|
+
// no structural check can tell a threaded argument from an omitted one.
|
|
623
|
+
const root = editionsRoot();
|
|
624
|
+
// CUSTOMER MODE: the digest-pinned catalogue the edition was resolved against.
|
|
625
|
+
// Its vendor biomes' tree ids are inputs on ITS authority (biome-content.mjs).
|
|
626
|
+
const fetched =
|
|
627
|
+
catalogueFlags.has(CATALOGUE_FLAG)
|
|
628
|
+
? await fetchCatalogue({
|
|
629
|
+
ref: catalogueFlags.get(CATALOGUE_FLAG),
|
|
630
|
+
username: catalogueFlags.get(CATALOGUE_USERNAME_FLAG),
|
|
631
|
+
tokenEnv: catalogueFlags.get(CATALOGUE_TOKEN_ENV_FLAG),
|
|
632
|
+
})
|
|
633
|
+
: undefined;
|
|
634
|
+
const catalogue = fetched?.catalogue;
|
|
635
|
+
const catalogueDigest = fetched?.digest;
|
|
636
|
+
const imageLock = await loadImageLockForVerification(distributionId, root);
|
|
637
|
+
|
|
638
|
+
// Throws on ANY unresolvable subject or missing required platform, before a
|
|
639
|
+
// single byte of the lock is touched.
|
|
640
|
+
let report;
|
|
641
|
+
try {
|
|
642
|
+
report = await verifyDistributionPlatforms({ imageLock, distributionsRoot: root });
|
|
643
|
+
} catch (error) {
|
|
644
|
+
// RECONCILE: a coordinate this pass just re-pinned has not been built yet.
|
|
645
|
+
// Skip the EDITION — never some of its subjects — and leave the lock
|
|
646
|
+
// entirely unsealed for the publication gate to refuse.
|
|
647
|
+
if (ifPublished && isNotPublishedYet(error)) {
|
|
648
|
+
console.log(
|
|
649
|
+
`[dist:seal-digests --if-published] "${distributionId}": not published yet — ` +
|
|
650
|
+
`${error.message.split('\n')[0]}`,
|
|
651
|
+
);
|
|
652
|
+
console.log(
|
|
653
|
+
`[dist:seal-digests --if-published] skipping this edition ENTIRELY and writing ` +
|
|
654
|
+
'nothing. It stays unsealed, and publication refuses an unsealed lock, so ' +
|
|
655
|
+
'nothing installable is produced from it until its producers have built.',
|
|
656
|
+
);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
throw error;
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
// ── BEFORE ANY DIGEST IS WRITTEN ───────────────────────────────────────────
|
|
663
|
+
//
|
|
664
|
+
// Can the images of this edition READ the lock it is about to be sealed
|
|
665
|
+
// against? The plane resolves with the contracts build in
|
|
666
|
+
// `repos/xema-kernel-contracts`, which runs ahead of what ships, and
|
|
667
|
+
// `DistributionLockSchema` is `.strict()` — so a key the plane knows and a
|
|
668
|
+
// pinned repository does not makes the whole artifact unreadable AT BOOT.
|
|
669
|
+
// That is not hypothetical: on 2026-09-17 `imageNamePrefix` did exactly this
|
|
670
|
+
// to three presenca-dev services at once, and the edition had sealed,
|
|
671
|
+
// published and installed perfectly first.
|
|
672
|
+
//
|
|
673
|
+
// It is asked of every pinned repository that SHIPS AN IMAGE INTO THIS
|
|
674
|
+
// EDITION and PARSES A LOCK — never of `repos/xema-base` alone, which
|
|
675
|
+
// declares the NEWEST range in the fleet and is therefore the weakest
|
|
676
|
+
// possible subject, and never of every pin, which grades repositories the
|
|
677
|
+
// edition's lock cannot reach. Both narrowings are DERIVED (from this
|
|
678
|
+
// edition's `images.lock.json` and from the pinned commit's own source);
|
|
679
|
+
// see `platform-lock-readability.mjs` for the measurement behind each.
|
|
680
|
+
//
|
|
681
|
+
// WHY HERE AND NOT BEFORE `verifyDistributionPlatforms`, which is where the
|
|
682
|
+
// original of this call sat. Three placements were available and only this
|
|
683
|
+
// one is correct today:
|
|
684
|
+
//
|
|
685
|
+
// • before the lock read — `--publication-verdict` returns above it, and that
|
|
686
|
+
// gate must stay offline and structural. It would also need a sibling
|
|
687
|
+
// repository and the registry to answer a question about publication.
|
|
688
|
+
// • before the verify — an edition whose coordinates are NOT PUBLISHED YET
|
|
689
|
+
// is the ORDINARY daily outcome, and `--if-published` skips it in the
|
|
690
|
+
// catch below. Asking first would make an edition nothing is about to
|
|
691
|
+
// seal fail HARD on a readability question, and would pay the npm cost
|
|
692
|
+
// for every skipped edition.
|
|
693
|
+
// • HERE — after the skip, before `planDigestSeal` and therefore before any
|
|
694
|
+
// write, so it runs exactly for the editions a digest is about to be
|
|
695
|
+
// written into, and for `--check`, which is a verification mode and
|
|
696
|
+
// should answer it.
|
|
697
|
+
//
|
|
698
|
+
// `runtimeImageSourceIndexPath()` is THIS repository's file rather than the
|
|
699
|
+
// editions root's, and deliberately: the pins are a fact about the vendor
|
|
700
|
+
// plane that BUILT the images, not about whose editions are being sealed.
|
|
701
|
+
// Every other consumer of the index (`dist-check.mjs`, `resolve-images.mjs`)
|
|
702
|
+
// reads it the same way.
|
|
703
|
+
//
|
|
704
|
+
// `imageLock` is THE EDITION'S OWN, already loaded above from `root`, and it
|
|
705
|
+
// is what narrows the question from the fleet to this edition: the pin index
|
|
706
|
+
// is fleet-wide, so grading every pin refuses a seal over a repository that
|
|
707
|
+
// ships nothing here. Measured 2026-09-27, that is not hypothetical — it
|
|
708
|
+
// blocked a customer-edition seal on `repos/xema-operator`, which has
|
|
709
|
+
// ZERO subjects in that edition.
|
|
710
|
+
// ── THE TWO LOCKS READ HERE ARE DIFFERENT CLASSES ────────────────────────
|
|
711
|
+
//
|
|
712
|
+
// `distribution.lock.json` is REBUILDABLE — a fresh resolve reproduces it,
|
|
713
|
+
// and zero of the 9 committed ones carries a digest. `imageLock` is SEALED:
|
|
714
|
+
// 523 of 523 subjects across those 9 editions carry an `imageDigest` that
|
|
715
|
+
// THIS script wrote, and `resolve-images.mjs` emits none, so re-resolving it
|
|
716
|
+
// DISCARDS them rather than recomputing them. They appear as two arguments
|
|
717
|
+
// to one call, which is exactly the shape that invites treating them alike;
|
|
718
|
+
// `dist-check.mjs`'s `compareCommittedLock` carries the full statement of
|
|
719
|
+
// the split, at its own read.
|
|
720
|
+
//
|
|
721
|
+
// The practical rule for anything added below: the distribution lock may be
|
|
722
|
+
// regenerated to answer a question; the image lock may not.
|
|
723
|
+
await assertPlatformCanParseLock({
|
|
724
|
+
lock: JSON.parse(
|
|
725
|
+
readFileSync(join(root, distributionId, 'distribution.lock.json'), 'utf8'),
|
|
726
|
+
),
|
|
727
|
+
imageLock,
|
|
728
|
+
distributionId,
|
|
729
|
+
runtimeSourceIndex: JSON.parse(
|
|
730
|
+
readFileSync(runtimeImageSourceIndexPath(), 'utf8'),
|
|
731
|
+
),
|
|
732
|
+
});
|
|
733
|
+
|
|
734
|
+
const { writes, unchanged } = planDigestSeal({
|
|
735
|
+
subjects: imageLock.subjects,
|
|
736
|
+
verified: report.subjects,
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
const label = check ? 'dist:seal-digests --check' : 'dist:seal-digests';
|
|
740
|
+
console.log(
|
|
741
|
+
`[${label}] "${distributionId}": ${report.subjectCount} subjects verified against ` +
|
|
742
|
+
`${report.registries.join(', ')}; ${unchanged.length} already sealed, ${writes.length} to seal.`,
|
|
743
|
+
);
|
|
744
|
+
for (const w of writes) {console.log(` seal ${w.key} ${w.coordinate} -> ${w.digest}`);}
|
|
745
|
+
|
|
746
|
+
if (writes.length === 0) {
|
|
747
|
+
console.log(`[${label}] lock is fully sealed and agrees with the registry.`);
|
|
748
|
+
await sealEditionLocks({ distributionId, root, check, label, catalogue, catalogueDigest });
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
// ── FLEET-SCOPED FINALITY ──────────────────────────────────────────────────
|
|
753
|
+
//
|
|
754
|
+
// Everything above this point is PER-EDITION and passed. That is exactly the
|
|
755
|
+
// state `xema-internal` was in at 09:25 on 2026-09-18, when it was sealed
|
|
756
|
+
// against 40 coordinates that were still amd64-only and whose arm64
|
|
757
|
+
// supplement then moved 63 of its 66 digests.
|
|
758
|
+
//
|
|
759
|
+
// A seal pins what a MUTABLE tag resolves to, so it is stable only once the
|
|
760
|
+
// coordinate is FINAL — and finality is a property of the FLEET, because the
|
|
761
|
+
// edition that can still supplement the coordinate is a DIFFERENT one. Asked
|
|
762
|
+
// here, over `writes`, because this is the moment the regression is
|
|
763
|
+
// introduced and the repository holding every edition's lock is where the
|
|
764
|
+
// question is answerable at all.
|
|
765
|
+
const { editions, withoutLock } = readFleetImageLocks(root);
|
|
766
|
+
const fleetRequirements = collectFleetPlatformRequirements({ editions });
|
|
767
|
+
try {
|
|
768
|
+
const finality = assertCoordinatesFinal({
|
|
769
|
+
distributionId,
|
|
770
|
+
writes,
|
|
771
|
+
verified: report.subjects,
|
|
772
|
+
fleetRequirements,
|
|
773
|
+
});
|
|
774
|
+
// The CORPUS, not a confirmation: how many coordinates were graded against
|
|
775
|
+
// how many editions, and which editions made no claim. Without the
|
|
776
|
+
// denominators a pass cannot be told from a pass over nothing, and the
|
|
777
|
+
// editions root is printed because it BOUNDS the fleet this answer is about.
|
|
778
|
+
console.log(
|
|
779
|
+
`[${label}] finality: ${finality.coordinateCount} coordinate(s) graded against ` +
|
|
780
|
+
`${editions.length} edition(s) under ${root}` +
|
|
781
|
+
(withoutLock.length > 0
|
|
782
|
+
? ` (${withoutLock.length} declared no image lock: ${withoutLock.join(', ')})`
|
|
783
|
+
: '') +
|
|
784
|
+
`; ${finality.coordinateCount - finality.pendingElsewhere.length} final for every ` +
|
|
785
|
+
`edition, ${finality.pendingElsewhere.length} final for this edition's own platforms only.`,
|
|
786
|
+
);
|
|
787
|
+
// THE ACCEPTED RESIDUAL, recorded one line per coordinate (owner ruling
|
|
788
|
+
// 2026-09-29): another edition still awaits a platform THIS edition does not
|
|
789
|
+
// declare, so a later supplement may move the tag. This seal keeps pinning
|
|
790
|
+
// the manifest it verified, which serves every platform this edition needs.
|
|
791
|
+
for (const pending of finality.pendingElsewhere) {
|
|
792
|
+
console.log(
|
|
793
|
+
`[${label}] RECORDED: ${pending.key} ${pending.coordinate} — ` +
|
|
794
|
+
pending.unmet
|
|
795
|
+
.map((u) => `${u.platform} pending for ${u.requiredBy.join(', ')}`)
|
|
796
|
+
.join('; ') +
|
|
797
|
+
`; not required by "${distributionId}" (serves: ${pending.served.join(', ') || 'nothing'}).`,
|
|
798
|
+
);
|
|
799
|
+
}
|
|
800
|
+
} catch (error) {
|
|
801
|
+
// RECONCILE: another edition is still awaiting a platform here. That is the
|
|
802
|
+
// ORDINARY transient state while a supplement builds — the same shape as
|
|
803
|
+
// "not published yet", and treated the same way for the same reason: a
|
|
804
|
+
// daily job that reddens for a state nobody can act on today is one people
|
|
805
|
+
// learn to bypass. The edition is skipped WHOLE and stays unsealed, so the
|
|
806
|
+
// publication gate still refuses to ship it.
|
|
807
|
+
if (ifPublished && isNotFinalYet(error)) {
|
|
808
|
+
console.log(`[dist:seal-digests --if-published] ${error.message}`);
|
|
809
|
+
console.log(
|
|
810
|
+
`[dist:seal-digests --if-published] skipping this edition ENTIRELY and writing ` +
|
|
811
|
+
'nothing. It stays unsealed, and publication refuses an unsealed lock.',
|
|
812
|
+
);
|
|
813
|
+
return;
|
|
814
|
+
}
|
|
815
|
+
throw error;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
if (check) {
|
|
819
|
+
throw new Error(
|
|
820
|
+
`${writes.length} subject(s) carry no imageDigest. Run without --check to seal them. ` +
|
|
821
|
+
'An unsealed lock cannot be installed by digest, and a tag-based install is ' +
|
|
822
|
+
'mutable lookup in the production path.',
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
await sealEditionLocks({
|
|
827
|
+
distributionId,
|
|
828
|
+
root,
|
|
829
|
+
digests: new Map(writes.map((w) => [w.key, w.digest])),
|
|
830
|
+
check,
|
|
831
|
+
label,
|
|
832
|
+
catalogue,
|
|
833
|
+
catalogueDigest,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
if (isEntryModule(import.meta.url)) {
|
|
838
|
+
main().catch((error) => {
|
|
839
|
+
console.error(`[dist:seal-digests] ${error.message}`);
|
|
840
|
+
// The footer is MODE-AWARE. Pointing a reader at a registry credential when
|
|
841
|
+
// the gate that just failed never opens a socket is a remedy for the wrong
|
|
842
|
+
// problem, and a wrong remedy costs more than no remedy.
|
|
843
|
+
const offline = process.argv.includes('--publication-verdict');
|
|
844
|
+
console.error(
|
|
845
|
+
offline
|
|
846
|
+
? '[dist:seal-digests] fail-closed: this gate is OFFLINE — it read the lock and ' +
|
|
847
|
+
'nothing else, so no credential, network or registry state is involved in ' +
|
|
848
|
+
'the verdict above.'
|
|
849
|
+
: '[dist:seal-digests] fail-closed: no digest above was assumed, and a partial ' +
|
|
850
|
+
'seal is never written. Registry credentials are declared in ' +
|
|
851
|
+
'image-registry-credentials.json.',
|
|
852
|
+
);
|
|
853
|
+
process.exit(1);
|
|
854
|
+
});
|
|
855
|
+
}
|