@xemahq/xema 0.27.0 → 0.29.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.
Files changed (63) hide show
  1. package/dist/commands/edition.d.ts.map +1 -1
  2. package/dist/commands/edition.js +8 -7
  3. package/dist/commands/edition.js.map +1 -1
  4. package/dist/dev/env-defaults.d.ts.map +1 -1
  5. package/dist/dev/env-defaults.js +0 -1
  6. package/dist/dev/env-defaults.js.map +1 -1
  7. package/dist/edition/verify-image-evidence.bundle.mjs +231 -50
  8. package/dist/edition/verify-image-evidence.bundle.provenance.json +55 -43
  9. package/dist/runtime/edition/verb-map.d.ts +2 -1
  10. package/dist/runtime/edition/verb-map.d.ts.map +1 -1
  11. package/dist/runtime/edition/verb-map.js +2 -0
  12. package/dist/runtime/edition/verb-map.js.map +1 -1
  13. package/dist/runtime/runner/chart-set-lock.d.ts +4 -1
  14. package/dist/runtime/runner/chart-set-lock.d.ts.map +1 -1
  15. package/dist/runtime/runner/chart-set-lock.js +39 -4
  16. package/dist/runtime/runner/chart-set-lock.js.map +1 -1
  17. package/edition/README.md +76 -41
  18. package/edition/build-biome-index.mjs +1 -1
  19. package/edition/build-platform-service-index.mjs +1 -1
  20. package/edition/build-runtime-image-source-index.mjs +64 -20
  21. package/edition/catalogue-fetch.mjs +36 -9
  22. package/edition/catalogue-pin.mjs +71 -0
  23. package/edition/catalogue.mjs +11 -0
  24. package/edition/chart-set-source-commit.mjs +17 -7
  25. package/edition/checkout-indexed-source-repositories.mjs +17 -17
  26. package/edition/customer-flags.mjs +0 -1
  27. package/edition/develop-customer-edition.mjs +23 -5
  28. package/edition/develop-edition.mjs +147 -48
  29. package/edition/develop-freeze.mjs +69 -25
  30. package/edition/develop-seal.mjs +39 -25
  31. package/edition/dist-check.mjs +1 -1
  32. package/edition/edition-artifact.mjs +79 -18
  33. package/edition/edition-biome-manifests.mjs +2 -3
  34. package/edition/edition-chart-set.mjs +59 -6
  35. package/edition/edition-config.mjs +45 -21
  36. package/edition/edition-constants.mjs +10 -4
  37. package/edition/edition-contract-parse.mjs +52 -0
  38. package/edition/edition-contract.json +21 -5
  39. package/edition/edition-workspace.mjs +23 -9
  40. package/edition/evidence-checkout.mjs +22 -1
  41. package/edition/evidence-trust-policy.mjs +142 -78
  42. package/edition/external-source-walk.mjs +48 -61
  43. package/edition/git-source-auth.mjs +104 -25
  44. package/edition/image-delivery-entitlement-descriptor.mjs +106 -0
  45. package/edition/image-evidence.mjs +21 -3
  46. package/edition/kernel-contract-authority.mjs +23 -1
  47. package/edition/lib.mjs +16 -24
  48. package/edition/oras.mjs +1 -1
  49. package/edition/pin-source.mjs +6 -1
  50. package/edition/produce-image-delivery-entitlement-evidence.mjs +12 -12
  51. package/edition/registry-credentials.mjs +11 -1
  52. package/edition/release-channel-guard.mjs +11 -1
  53. package/edition/release-edition-artifact.mjs +5 -6
  54. package/edition/resolve-chart-set.mjs +50 -30
  55. package/edition/resolve-customer-edition.mjs +63 -7
  56. package/edition/resolve-distribution.mjs +1 -1
  57. package/edition/resolve-edition.mjs +12 -11
  58. package/edition/resolve-images.mjs +36 -15
  59. package/edition/seal-image-digests.mjs +31 -7
  60. package/edition/source-closure.mjs +1 -2
  61. package/edition/trust-policy-arming-verb.mjs +68 -0
  62. package/edition/verify-image-evidence.mjs +40 -12
  63. package/package.json +3 -3
@@ -1,40 +1,36 @@
1
- // The evidence-trust part of `xema edition check`.
1
+ // The evidence-trust part of `xema edition check`, and the state an edition
2
+ // records in its descriptor.
2
3
  //
3
- // A data root states whether its editions MUST be evidence-gated
4
- // (`evidenceTrustPolicy.required` in `edition-workspace.json`, no default) and
5
- // may DECLARE a policy (`policy` + `pin`). This gate says which, in words, and
6
- // refuses every state in which the answer would be a silent one:
4
+ // A data root DECLARES an evidence trust policy (`evidenceTrustPolicy: {policy,
5
+ // pin}` in `edition-workspace.json`) or states that it declares none (`null`).
6
+ // No switch admits an ungated release:
7
7
  //
8
- // * required but no policy -> refused (EvidenceTrustRequiredError, code
9
- // EVIDENCE_TRUST_REQUIRED_UNARMED): a data root that
10
- // insists on gating cannot run ungated. Sealing an
11
- // edition (descriptorEvidenceTrust) refuses the same
12
- // way for a RELEASE edition of a required data
13
- // root whose declared policy is UNARMED or PARTIAL
14
- // on the release channel;
15
- // * not required, no policy and clean -> passes, printing that the editions are NOT
16
- // evidence-gated (an explicit absence, not a skip);
8
+ // * a RELEASE edition is written only against a policy ARMED on the release
9
+ // channel. No policy, UNARMED or PARTIALLY ARMED there -> refused
10
+ // (EvidenceTrustRequiredError, code EVIDENCE_TRUST_REQUIRED_UNARMED);
11
+ // * a DEVELOP edition admits not-armed and says so in one line; a policy
12
+ // PARTIALLY ARMED on the develop channel is refused;
13
+ // * `xema edition check` FAILS when the editions tree holds a release
14
+ // edition (an edition directory holding a release lock) and the policy is
15
+ // not ARMED on the release channel (none declared, UNARMED or PARTIAL):
16
+ // that edition cannot be published as an artifact. A tree holding no
17
+ // release edition passes for develop-only use, with a notice saying so;
17
18
  // * no policy declared but a policy file is present at the top level or under
18
- // `trust/` -> refused: a policy nothing reads is a policy
19
- // someone believes is enforced;
20
- // * declared -> the policy parses with the kernel schema, its
21
- // pinned Sigstore root matches, the data-root pin
22
- // records the policy's digest AND this CLI's built
23
- // verifier (a missing bundle is refused, never
24
- // skipped), and the authority configuration is
25
- // judged PER CHANNEL (trust-policy-arming.mjs): on
26
- // the release channel it is unarmed (passes,
27
- // delivery fail-closed) or armed; the develop
28
- // channel admits not-armed and says so in one
29
- // line; a channel that is partially armed is refused.
19
+ // `trust/` -> refused: a policy nothing reads is a policy someone believes
20
+ // is enforced;
21
+ // * declared -> the policy parses with the kernel schema, its pinned Sigstore
22
+ // root matches, the data-root pin records the policy's digest AND this
23
+ // CLI's built verifier (a missing bundle is refused, never skipped), and
24
+ // the authority configuration is judged PER CHANNEL
25
+ // (trust-policy-arming.mjs); a channel that is partially armed is refused.
30
26
 
31
- import { readFileSync, readdirSync } from 'node:fs';
27
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
32
28
  import { join } from 'node:path';
33
29
 
34
30
  import { locateBuiltVerifier, readBuiltVerifierDigests } from './built-verifier.mjs';
35
31
  import { EDITION_WORKSPACE_CONFIG_FILE, editionWorkspaceConfig } from './edition-config.mjs';
36
32
  import { EDITION_CONTRACT, EditionChannel, EvidenceTrustState } from './edition-constants.mjs';
37
- import { dataRoot as configuredDataRoot } from './edition-workspace.mjs';
33
+ import { dataRoot as configuredDataRoot, editionsRoot as configuredEditionsRoot } from './edition-workspace.mjs';
38
34
  import {
39
35
  assertPinnedPolicy,
40
36
  assertPinnedVerifier,
@@ -42,6 +38,7 @@ import {
42
38
  readEvidenceTrustPin,
43
39
  } from './evidence-trust-pin.mjs';
44
40
  import { loadKernelDistributionSchemas } from './lib.mjs';
41
+ import { heldReleaseLocks } from './release-channel-guard.mjs';
45
42
  import {
46
43
  TrustPolicyArmingState,
47
44
  assertArmingChannel,
@@ -51,25 +48,72 @@ import {
51
48
  const TAG = '[dist:check]';
52
49
 
53
50
  /**
54
- * The stable code of the refusal to write a `not-armed` edition for a data root
55
- * that requires evidence gating. Consumers (xema-deploy, workflows) match on the
56
- * code, never on the message; it is also listed in edition-contract.json.
51
+ * The stable code of the refusal to write a RELEASE edition without an armed
52
+ * evidence trust policy. Consumers (xema-deploy, workflows) match on the code,
53
+ * never on the message; it is also listed in edition-contract.json.
57
54
  */
58
55
  export const EVIDENCE_TRUST_REQUIRED_UNARMED = EDITION_CONTRACT.evidenceTrust.refusal.code;
59
56
 
60
57
  /**
61
- * The data root requires evidence gating (`required: true`) but its evidence
62
- * trust is not armed: it declares no policy, or declares one that is UNARMED or
63
- * PARTIALLY ARMED. Sealing an edition or writing an artifact is refused with this
64
- * error; `not-armed` is never recorded for `required: true`.
58
+ * What `xema edition check` says of a data root whose policy is not armed on
59
+ * the release channel. Sealing is not gated (nothing sealed leaves without
60
+ * `xema edition artifact`); publishing is.
61
+ */
62
+ export const NO_RELEASE_ARTIFACT_NOTICE =
63
+ 'no release edition of this data root can be published as an artifact (written by `xema edition artifact`) until the policy is armed on the release channel';
64
+
65
+ /**
66
+ * The release editions of the configured editions tree: the edition directories
67
+ * (those holding `xema-distribution.json`) that hold a release lock. An edition
68
+ * that is only defined, with no lock resolved yet, is not one.
69
+ */
70
+ export function releaseEditionIds() {
71
+ const root = configuredEditionsRoot();
72
+ if (!existsSync(root)) {return [];}
73
+ return readdirSync(root, { withFileTypes: true })
74
+ .filter(
75
+ (entry) =>
76
+ entry.isDirectory() &&
77
+ existsSync(join(root, entry.name, 'xema-distribution.json')) &&
78
+ heldReleaseLocks(join(root, entry.name)).length > 0,
79
+ )
80
+ .map((entry) => entry.name)
81
+ .sort();
82
+ }
83
+
84
+ /**
85
+ * The verdict of the check for a policy that is not armed on the release
86
+ * channel: red when the tree holds a release edition (it names them and the
87
+ * repair), green with the notice when it holds none.
88
+ */
89
+ function notArmedOnReleaseVerdict(what, repair) {
90
+ const releaseEditions = releaseEditionIds();
91
+ if (releaseEditions.length === 0) {
92
+ console.warn(
93
+ `${TAG} image-evidence trust policy: NOT ARMED on the release channel — ${what}. ${configuredEditionsRoot()} holds no release edition, ` +
94
+ `so this passes for develop-only use: ${NO_RELEASE_ARTIFACT_NOTICE}.`,
95
+ );
96
+ return true;
97
+ }
98
+ console.error(
99
+ `${TAG} image-evidence trust policy FAILED: ${what}, and ${configuredEditionsRoot()} holds ${String(releaseEditions.length)} release edition(s) ` +
100
+ `(${releaseEditions.join(', ')}): ${NO_RELEASE_ARTIFACT_NOTICE}. ${repair}`,
101
+ );
102
+ return false;
103
+ }
104
+
105
+ /**
106
+ * A release edition requires an armed evidence trust policy and this data root
107
+ * has none: it declares no policy, or declares one that is UNARMED or PARTIALLY
108
+ * ARMED on the release channel. Writing the release descriptor or artifact is
109
+ * refused with this error; a release edition never records `not-armed`.
65
110
  */
66
111
  export class EvidenceTrustRequiredError extends Error {
67
112
  /** @param {string} dataRoot @param {string} reason what is not armed, in words */
68
113
  constructor(dataRoot, reason) {
69
114
  super(
70
- `${join(dataRoot, EDITION_WORKSPACE_CONFIG_FILE)} sets evidenceTrustPolicy.required to true but ${reason}: ` +
71
- 'arm the declared policy (`xema edition check` says what is unmet), declare {"required": true, "policy": ..., "pin": ...}, ' +
72
- 'or set required to false to run this data root NOT ARMED.',
115
+ `a release edition requires an armed evidence trust policy, but ${join(dataRoot, EDITION_WORKSPACE_CONFIG_FILE)} ${reason}: ` +
116
+ 'declare a policy ({"policy": ..., "pin": ...}) and arm it on the release channel (`xema edition check` says what is unmet).',
73
117
  );
74
118
  this.name = 'EvidenceTrustRequiredError';
75
119
  this.code = EVIDENCE_TRUST_REQUIRED_UNARMED;
@@ -89,27 +133,51 @@ function loadPinnedPolicy({ dataRoot, declaration, schemas, locateVerifier }) {
89
133
  return { policy, policyDigest };
90
134
  }
91
135
 
136
+ /**
137
+ * The arming of the data root's declared policy on `channel`, loaded and pinned
138
+ * exactly as `descriptorEvidenceTrust` and the check load it: `{ declared, state,
139
+ * unmet }`. A data root that declares no policy is `unarmed` with `declared:
140
+ * false` and one unmet entry; a declared policy that does not hold to its pin
141
+ * (digest, verifier) throws. Nothing is written.
142
+ *
143
+ * @param {{ channel: string, locateVerifier?: () => {bundlePath: string, provenancePath: string} }} options
144
+ */
145
+ export async function assessDeclaredTrustPolicyArming({ channel, locateVerifier = locateBuiltVerifier }) {
146
+ const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
147
+ const root = configuredDataRoot();
148
+ const schemas = await loadKernelDistributionSchemas();
149
+ assertArmingChannel(schemas, channel);
150
+ if (declaration.policy === null) {
151
+ return {
152
+ declared: false,
153
+ state: TrustPolicyArmingState.UNARMED,
154
+ unmet: [`${join(root, EDITION_WORKSPACE_CONFIG_FILE)} declares no evidenceTrustPolicy`],
155
+ };
156
+ }
157
+ const { policy } = loadPinnedPolicy({ dataRoot: root, declaration, schemas, locateVerifier });
158
+ const { state, unmet } = assessTrustPolicyArming(policy, schemas, channel);
159
+ return { declared: true, state, unmet };
160
+ }
161
+
92
162
  /** What a develop edition says, once, when it is written or checked without an armed develop channel. */
93
163
  export const DEVELOP_NOT_ARMED_NOTICE = 'develop channel: trust policy not armed, evidence is not signature-verified';
94
164
 
95
165
  /**
96
- * The evidence state an edition of `channel` records in its descriptor, two
97
- * states and fail closed. Arming is judged for that ONE channel
98
- * (`assessTrustPolicyArming`): the signers of the other channel count for nothing.
166
+ * The evidence state an edition of `channel` records in its descriptor. Arming
167
+ * is judged for that ONE channel (`assessTrustPolicyArming`): the signers of
168
+ * the other channel count for nothing.
99
169
  *
100
170
  * * `armed` - the data root declares a policy, the policy is pinned (the
101
171
  * policy and this CLI's verifier match the data-root pin) and
102
172
  * ARMED on `channel`;
103
- * * `not-armed` - a release edition: exactly `required: false` and no armed
104
- * policy. A develop edition: its channel is not armed, whatever
105
- * `required` says, and it says so in one stderr line.
173
+ * * `not-armed` - a DEVELOP edition only: its channel is not armed (no
174
+ * policy, or UNARMED there), and it says so in one stderr line.
106
175
  *
107
- * Every other state is refused: a release edition of a `required: true` data
108
- * root that is not ARMED on the release channel (no policy, or UNARMED or
109
- * PARTIAL there) throws EvidenceTrustRequiredError; a data root that requires
110
- * gating and declares no policy at all, a policy edited after pinning, a
111
- * drifted verifier, and a policy PARTIALLY ARMED on `channel` are refused on
112
- * either channel.
176
+ * Every other state is refused. A RELEASE edition that is not ARMED on the
177
+ * release channel (no policy, UNARMED or PARTIAL there) throws
178
+ * EvidenceTrustRequiredError: this function never returns `not-armed` for it.
179
+ * A policy edited after pinning, a drifted verifier, and a policy PARTIALLY
180
+ * ARMED on `channel` are refused on either channel.
113
181
  *
114
182
  * @param {{ channel: string, locateVerifier?: () => {bundlePath: string, provenancePath: string} }} options
115
183
  */
@@ -118,25 +186,24 @@ export async function descriptorEvidenceTrust({ channel, locateVerifier = locate
118
186
  const root = configuredDataRoot();
119
187
  const schemas = await loadKernelDistributionSchemas();
120
188
  assertArmingChannel(schemas, channel);
121
- const notArmed = () => {
122
- if (channel === EditionChannel.Develop) {console.error(DEVELOP_NOT_ARMED_NOTICE);}
189
+ const release = channel === EditionChannel.Release;
190
+ const developNotArmed = () => {
191
+ console.error(DEVELOP_NOT_ARMED_NOTICE);
123
192
  return EvidenceTrustState.NotArmed;
124
193
  };
125
194
  if (declaration.policy === null) {
126
- if (declaration.required) {throw new EvidenceTrustRequiredError(root, 'declares no policy');}
127
- return notArmed();
195
+ if (release) {throw new EvidenceTrustRequiredError(root, 'declares no policy');}
196
+ return developNotArmed();
128
197
  }
129
198
  const { policy } = loadPinnedPolicy({ dataRoot: root, declaration, schemas, locateVerifier });
130
199
  const { state, unmet } = assessTrustPolicyArming(policy, schemas, channel);
200
+ if (state === TrustPolicyArmingState.ARMED) {return EvidenceTrustState.Armed;}
131
201
  if (state === TrustPolicyArmingState.PARTIAL) {
132
- if (declaration.required) {throw new EvidenceTrustRequiredError(root, `its declared policy is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')})`);}
202
+ if (release) {throw new EvidenceTrustRequiredError(root, `declares a policy that is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')})`);}
133
203
  throw new Error(`the declared evidence trust policy is PARTIALLY ARMED on the ${channel} channel (${unmet.join('; ')}); an edition cannot be written against it`);
134
204
  }
135
- if (state === TrustPolicyArmingState.ARMED) {return EvidenceTrustState.Armed;}
136
- if (declaration.required && channel !== EditionChannel.Develop) {
137
- throw new EvidenceTrustRequiredError(root, `its declared policy is UNARMED on the ${channel} channel`);
138
- }
139
- return notArmed();
205
+ if (release) {throw new EvidenceTrustRequiredError(root, `declares a policy that is UNARMED on the ${channel} channel`);}
206
+ return developNotArmed();
140
207
  }
141
208
 
142
209
  export const UNDECLARED_TRUST_DIRECTORY = 'trust';
@@ -184,21 +251,19 @@ export async function checkEvidenceTrustPolicy({
184
251
  const declaration = editionWorkspaceConfig().evidenceTrustPolicy;
185
252
  const schemas = await loadKernelDistributionSchemas();
186
253
  if (declaration.policy === null) {
187
- if (declaration.required) {throw new EvidenceTrustRequiredError(dataRoot, 'declares no policy');}
188
254
  const undeclared = undeclaredPolicyArtifacts(dataRoot, schemas);
189
255
  if (undeclared.length > 0) {
190
256
  console.error(
191
257
  `${TAG} image-evidence trust policy FAILED: ${dataRoot} holds ${undeclared.join(', ')} ` +
192
- `but ${EDITION_WORKSPACE_CONFIG_FILE} declares evidenceTrustPolicy {"required": false} with no policy, so nothing reads it. ` +
193
- 'Declare it ({"required": ..., "policy": ..., "pin": ...}) or remove it.',
258
+ `but ${EDITION_WORKSPACE_CONFIG_FILE} declares no evidenceTrustPolicy, so nothing reads it. ` +
259
+ 'Declare it ({"policy": ..., "pin": ...}) or remove it.',
194
260
  );
195
261
  return false;
196
262
  }
197
- console.log(
198
- `${TAG} image-evidence trust policy: NOT ARMED — ${join(dataRoot, EDITION_WORKSPACE_CONFIG_FILE)} ` +
199
- 'declares evidenceTrustPolicy {"required": false} with no policy, so this data root\'s editions are not evidence-gated.',
263
+ return notArmedOnReleaseVerdict(
264
+ `${join(dataRoot, EDITION_WORKSPACE_CONFIG_FILE)} declares no evidenceTrustPolicy (its develop editions are not evidence-gated)`,
265
+ 'Declare a policy ({"policy": ..., "pin": ...}), authorize the release signers in it, pin it with `xema edition repin-evidence-trust`, and re-run `xema edition check` until it reports ARMED on the release channel.',
200
266
  );
201
- return true;
202
267
  }
203
268
 
204
269
  const { policy, policyDigest } = loadPinnedPolicy({ dataRoot, declaration, schemas, locateVerifier });
@@ -213,8 +278,9 @@ export async function checkEvidenceTrustPolicy({
213
278
  `${policy.trustedCosignBinaryDigests.length} cosign / ` +
214
279
  `${policy.trustedGitBinaryDigests.length} git binary pins.`,
215
280
  );
216
- // Each channel is judged on its own signers. The release channel is the one
217
- // `required` speaks for; the develop channel admits not-armed and says so.
281
+ // Each channel is judged on its own signers. The develop channel admits
282
+ // not-armed and says so; a release channel that is not armed fails the
283
+ // check as soon as the tree holds a release edition.
218
284
  const partial = (channel, unmet) => {
219
285
  console.error(
220
286
  `${TAG} image-evidence trust policy is PARTIALLY ARMED on the ${channel} channel — this mixed state is invalid ` +
@@ -232,20 +298,18 @@ export async function checkEvidenceTrustPolicy({
232
298
  const release = assessTrustPolicyArming(policy, schemas, EditionChannel.Release);
233
299
  if (release.state === TrustPolicyArmingState.PARTIAL) {
234
300
  partial(EditionChannel.Release, release.unmet);
235
- return false;
236
- }
237
- if (release.state === TrustPolicyArmingState.UNARMED && declaration.required) {
238
- const refusal = new EvidenceTrustRequiredError(dataRoot, 'its declared policy is UNARMED on the release channel');
239
- console.error(`${TAG} ${refusal.code}: ${refusal.message}`);
301
+ console.error(
302
+ `${TAG} ${NO_RELEASE_ARTIFACT_NOTICE}. Complete the release channel in ${declaration.policy} (every line above), ` +
303
+ 're-pin with `xema edition repin-evidence-trust`, and re-run `xema edition check`.',
304
+ );
240
305
  return false;
241
306
  }
242
307
  if (develop.state === TrustPolicyArmingState.UNARMED) {console.error(`${TAG} ${DEVELOP_NOT_ARMED_NOTICE}`);}
243
308
  if (release.state === TrustPolicyArmingState.UNARMED) {
244
- console.warn(
245
- `${TAG} image-evidence trust policy: declared but UNARMED on the release channel — delivery stays fail-closed ` +
246
- 'until `xema edition check` reports it armed.',
309
+ return notArmedOnReleaseVerdict(
310
+ `${declaration.policy} is declared but UNARMED on the release channel (it authorizes no release signer)`,
311
+ `Authorize the release signers and pin the cosign and git binaries in ${declaration.policy}, re-pin with \`xema edition repin-evidence-trust\`, and re-run \`xema edition check\` until it reports ARMED on the release channel.`,
247
312
  );
248
- return true;
249
313
  }
250
314
  console.log(`${TAG} image-evidence trust policy: ARMED on the release channel${develop.armed ? ' and on the develop channel' : ''}.`);
251
315
  return true;
@@ -82,8 +82,9 @@ import { existsSync, mkdirSync } from 'node:fs';
82
82
  import { join } from 'node:path';
83
83
 
84
84
  import { readBiomeAuthorizationModuleAt } from './authorization-base.mjs';
85
+ import { editionWorkspaceConfig } from './edition-config.mjs';
85
86
  import { cacheDir } from './edition-workspace.mjs';
86
- import { gitAuthEnv } from './git-source-auth.mjs';
87
+ import { holdSource, withSourceGitEnv } from './git-source-auth.mjs';
87
88
  import {
88
89
  BIOME_GENERATED_INDEX_FILENAME,
89
90
  loadKernelBiomeContracts,
@@ -114,48 +115,24 @@ const EXACT_COMMIT = /^[a-f0-9]{40}$/;
114
115
  */
115
116
  const EXTERNAL_SKIP_DIR_NAMES = new Set(['node_modules', '.git', 'dist']);
116
117
 
117
- function git(directory, args, env = process.env) {
118
- return execFileSync('git', ['-C', directory, ...args], {
118
+ function git(directory, args, env = process.env, exec = execFileSync) {
119
+ return exec('git', ['-C', directory, ...args], {
119
120
  encoding: 'utf8',
120
121
  env,
121
122
  stdio: ['ignore', 'pipe', 'pipe'],
122
123
  }).trim();
123
124
  }
124
125
 
125
- /**
126
- * Environment for a git call against one declared source.
127
- *
128
- * `authTokenEnv` NAMES an environment variable; the token is never inlined in
129
- * the manifest (`CredentialFreeGitUrlSchema` refuses a URL carrying userinfo).
130
- * A declared-but-unset variable is REFUSED rather than falling back to the
131
- * ambient credential helper: a source that states it needs a credential and
132
- * silently resolves without one has resolved as somebody else, and the commit
133
- * it pins would be whatever that other identity can see.
134
- */
135
- export function gitEnvForSource(source, env = process.env) {
136
- const base = { ...env, GIT_TERMINAL_PROMPT: '0' };
137
- if (source.authTokenEnv === undefined) {return base;}
138
- const token = env[source.authTokenEnv];
139
- if (typeof token !== 'string' || token.length === 0) {
140
- throw new Error(
141
- `source "${source.name}" declares authTokenEnv "${source.authTokenEnv}", but that ` +
142
- 'environment variable is unset or empty. Refusing to fall back to the ambient git ' +
143
- 'credential helper: a declared credential that silently resolves as another identity ' +
144
- 'would pin whatever commit that identity can see.',
145
- );
146
- }
147
- return gitAuthEnv(source.url, source.authUsername, token, base);
148
- }
149
-
150
126
  /**
151
127
  * Resolve a declared ref to an exact commit AT THE REMOTE.
152
128
  *
153
129
  * A moving branch may DISCOVER an upgrade candidate; it must never be the
154
130
  * IDENTITY of a released member. This is the only place a ref becomes a commit,
155
131
  * and everything downstream — the checkout, the manifest reads, the lock —
156
- * carries the commit.
132
+ * carries the commit. `gitEnv` is the environment `withSourceGitEnv` computed for
133
+ * this source; `exec` is the spawn seam.
157
134
  */
158
- export function resolveRemoteCommit(source, env = process.env) {
135
+ function resolveRemoteCommit(source, gitEnv, exec = execFileSync) {
159
136
  const ref = source.ref ?? 'HEAD';
160
137
  // AN EXACT COMMIT IS ALREADY THE ANSWER, and it must not be sent to
161
138
  // `ls-remote`, which matches REFS and would find nothing for a raw sha —
@@ -171,9 +148,9 @@ export function resolveRemoteCommit(source, env = process.env) {
171
148
  if (EXACT_COMMIT.test(ref)) {return ref;}
172
149
  let out;
173
150
  try {
174
- out = execFileSync('git', ['ls-remote', '--exit-code', source.url, ref], {
151
+ out = exec('git', ['ls-remote', '--exit-code', source.url, ref], {
175
152
  encoding: 'utf8',
176
- env: gitEnvForSource(source, env),
153
+ env: gitEnv,
177
154
  stdio: ['ignore', 'pipe', 'pipe'],
178
155
  }).trim();
179
156
  } catch (error) {
@@ -213,35 +190,38 @@ export function materializeGitSource({
213
190
  source,
214
191
  cacheRoot = externalSourceCacheDir(),
215
192
  env = process.env,
193
+ workspace = editionWorkspaceConfig(),
194
+ exec = execFileSync,
216
195
  }) {
217
- const gitEnv = gitEnvForSource(source, env);
218
- const commit = resolveRemoteCommit(source, env);
219
- const directory = join(cacheRoot, source.name);
220
- if (!existsSync(join(directory, '.git'))) {
221
- mkdirSync(directory, { recursive: true });
222
- execFileSync('git', ['init', '--quiet', directory], { env: gitEnv });
223
- git(directory, ['remote', 'add', 'origin', source.url], gitEnv);
224
- } else {
225
- // The cache is keyed by the DECLARED name, so a renamed or moved source
226
- // reuses it: fetch from where the declaration says the source lives NOW.
227
- git(directory, ['remote', 'set-url', 'origin', source.url], gitEnv);
228
- }
229
- const have = (() => {
230
- try {
231
- return git(directory, ['cat-file', '-e', `${commit}^{commit}`], gitEnv) === '';
232
- } catch {
233
- return false;
196
+ return withSourceGitEnv(source, { workspace, env }, (gitEnv) => {
197
+ const commit = resolveRemoteCommit(source, gitEnv, exec);
198
+ const directory = join(cacheRoot, source.name);
199
+ if (!existsSync(join(directory, '.git'))) {
200
+ mkdirSync(directory, { recursive: true });
201
+ exec('git', ['init', '--quiet', directory], { env: gitEnv });
202
+ git(directory, ['remote', 'add', 'origin', source.url], gitEnv, exec);
203
+ } else {
204
+ // The cache is keyed by the DECLARED name, so a renamed or moved source
205
+ // reuses it: fetch from where the declaration says the source lives NOW.
206
+ git(directory, ['remote', 'set-url', 'origin', source.url], gitEnv, exec);
234
207
  }
235
- })();
236
- if (!have) {
237
- execFileSync(
238
- 'git',
239
- ['-C', directory, 'fetch', '--quiet', '--depth=1', 'origin', commit],
240
- { env: gitEnv, stdio: ['ignore', 'pipe', 'pipe'] },
241
- );
242
- }
243
- git(directory, ['checkout', '--quiet', '--detach', commit], gitEnv);
244
- return { directory, commit: pinExternalSourceCommit({ name: source.name, directory, commit }) };
208
+ const have = (() => {
209
+ try {
210
+ return git(directory, ['cat-file', '-e', `${commit}^{commit}`], gitEnv, exec) === '';
211
+ } catch {
212
+ return false;
213
+ }
214
+ })();
215
+ if (!have) {
216
+ exec(
217
+ 'git',
218
+ ['-C', directory, 'fetch', '--quiet', '--depth=1', 'origin', commit],
219
+ { env: gitEnv, stdio: ['ignore', 'pipe', 'pipe'] },
220
+ );
221
+ }
222
+ git(directory, ['checkout', '--quiet', '--detach', commit], gitEnv, exec);
223
+ return { directory, commit: pinExternalSourceCommit({ name: source.name, directory, commit }) };
224
+ });
245
225
  }
246
226
 
247
227
  /**
@@ -407,7 +387,8 @@ function lockedSourceRecord(source, commit) {
407
387
  * Walk every declared source for one edition.
408
388
  *
409
389
  * Returns the stamped index entries and the `LockedBiomeSource` record per
410
- * source name. `materialize` is injectable so the contract suite can drive a
390
+ * source name. `workspace` (default: the configured data root) supplies the one source credential.
391
+ * `materialize` is injectable so the contract suite can drive a
411
392
  * real local git repository instead of reaching the network — the walk under
412
393
  * test is then the real one, not a fixture of it.
413
394
  */
@@ -415,9 +396,12 @@ export async function walkDeclaredSources({
415
396
  sources = [],
416
397
  materialize = materializeGitSource,
417
398
  env = process.env,
399
+ workspace = undefined,
418
400
  } = {}) {
419
401
  const entries = [];
420
402
  const sourceRecords = new Map();
403
+ // Every source is held (kind, then credential and URL) before the first is materialised.
404
+ const heldWorkspace = sources.length === 0 ? undefined : (workspace ?? editionWorkspaceConfig());
421
405
  for (const source of sources) {
422
406
  if (source.kind !== 'git') {
423
407
  throw new Error(
@@ -428,7 +412,10 @@ export async function walkDeclaredSources({
428
412
  'revision. Refusing rather than resolving.',
429
413
  );
430
414
  }
431
- const { directory, commit } = materialize({ source, env });
415
+ holdSource(source, heldWorkspace, env);
416
+ }
417
+ for (const source of sources) {
418
+ const { directory, commit } = materialize({ source, env, workspace: heldWorkspace });
432
419
  if (!EXACT_COMMIT.test(String(commit))) {
433
420
  throw new Error(
434
421
  `source "${source.name}": walk produced commit "${commit}", which is not exact 40-hex`,