@xemahq/edition-tooling 0.0.0-stage → 0.1.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 (219) hide show
  1. package/LICENSE +175 -0
  2. package/dist/cli.d.ts +7 -0
  3. package/dist/cli.d.ts.map +1 -0
  4. package/dist/cli.js +47 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/commands/edition.d.ts +4 -0
  7. package/dist/commands/edition.d.ts.map +1 -0
  8. package/dist/commands/edition.js +58 -0
  9. package/dist/commands/edition.js.map +1 -0
  10. package/dist/edition/verify-image-evidence.bundle.mjs +72104 -0
  11. package/dist/edition/verify-image-evidence.bundle.provenance.json +4119 -0
  12. package/dist/lib/exit-codes.d.ts +6 -0
  13. package/dist/lib/exit-codes.d.ts.map +1 -0
  14. package/dist/lib/exit-codes.js +21 -0
  15. package/dist/lib/exit-codes.js.map +1 -0
  16. package/dist/runtime/edition/run-entry.d.ts +10 -0
  17. package/dist/runtime/edition/run-entry.d.ts.map +1 -0
  18. package/dist/runtime/edition/run-entry.js +49 -0
  19. package/dist/runtime/edition/run-entry.js.map +1 -0
  20. package/dist/runtime/edition/verb-map.d.ts +70 -0
  21. package/dist/runtime/edition/verb-map.d.ts.map +1 -0
  22. package/dist/runtime/edition/verb-map.js +166 -0
  23. package/dist/runtime/edition/verb-map.js.map +1 -0
  24. package/edition/README.md +291 -0
  25. package/edition/adapter-bundled-versions.mjs +128 -0
  26. package/edition/adapter-host-versions.mjs +76 -0
  27. package/edition/assemble-producer-input.mjs +1423 -0
  28. package/edition/assemble-third-party-input.mjs +348 -0
  29. package/edition/authored-source-check.mjs +141 -0
  30. package/edition/authority-binding.mjs +23 -0
  31. package/edition/authorization-base.mjs +357 -0
  32. package/edition/biome-content.mjs +327 -0
  33. package/edition/build-biome-index.mjs +180 -0
  34. package/edition/build-ledger-completeness.mjs +67 -0
  35. package/edition/build-ledger.mjs +107 -0
  36. package/edition/build-platform-service-index.mjs +292 -0
  37. package/edition/build-runtime-image-source-index.mjs +1197 -0
  38. package/edition/built-verifier.mjs +55 -0
  39. package/edition/candidate-edition.mjs +735 -0
  40. package/edition/canonical-evidence-json.mjs +82 -0
  41. package/edition/canonical-repository-url.mjs +61 -0
  42. package/edition/catalogue-edition.mjs +49 -0
  43. package/edition/catalogue-evidence.mjs +228 -0
  44. package/edition/catalogue-fetch.mjs +124 -0
  45. package/edition/catalogue-lock-field.mjs +41 -0
  46. package/edition/catalogue-pin.mjs +71 -0
  47. package/edition/catalogue.mjs +237 -0
  48. package/edition/chart-set-source-commit.mjs +97 -0
  49. package/edition/check-committed-image-evidence.mjs +412 -0
  50. package/edition/checkout-indexed-source-repositories.mjs +283 -0
  51. package/edition/collected-refusals.mjs +59 -0
  52. package/edition/comment-blanker.mjs +75 -0
  53. package/edition/connector-source-authority.mjs +60 -0
  54. package/edition/contract-authority.mjs +122 -0
  55. package/edition/cosign-certificate-constraints.mjs +50 -0
  56. package/edition/cross-repo-build-inputs.mjs +158 -0
  57. package/edition/customer-definition.mjs +72 -0
  58. package/edition/customer-edition-verbs.mjs +610 -0
  59. package/edition/customer-flags.mjs +15 -0
  60. package/edition/definition-checkout.mjs +96 -0
  61. package/edition/develop-build-record.mjs +122 -0
  62. package/edition/develop-catalogue-free-resolve.mjs +282 -0
  63. package/edition/develop-catalogue-free-vendor.mjs +118 -0
  64. package/edition/develop-channel-evidence.mjs +23 -0
  65. package/edition/develop-channel-record.mjs +49 -0
  66. package/edition/develop-channel-satellites.mjs +65 -0
  67. package/edition/develop-chart-roster.mjs +96 -0
  68. package/edition/develop-chart-set.mjs +147 -0
  69. package/edition/develop-current-resolution.mjs +29 -0
  70. package/edition/develop-edition-invariants.mjs +142 -0
  71. package/edition/develop-edition-resolve.mjs +250 -0
  72. package/edition/develop-edition.mjs +332 -0
  73. package/edition/develop-engine-image.mjs +97 -0
  74. package/edition/develop-freeze.mjs +304 -0
  75. package/edition/develop-git-bundles.mjs +296 -0
  76. package/edition/develop-graph.mjs +34 -0
  77. package/edition/develop-image-coordinate.mjs +85 -0
  78. package/edition/develop-image-probe.mjs +122 -0
  79. package/edition/develop-image-record.mjs +260 -0
  80. package/edition/develop-resolve-report.mjs +159 -0
  81. package/edition/develop-select-records.mjs +90 -0
  82. package/edition/develop-selection-line.mjs +29 -0
  83. package/edition/develop-selection-pins.mjs +36 -0
  84. package/edition/develop-snapshot-inputs.mjs +112 -0
  85. package/edition/develop-store-root.mjs +20 -0
  86. package/edition/dispatched-runtime-components.mjs +172 -0
  87. package/edition/dist-check.mjs +1174 -0
  88. package/edition/distribution-evidence-set.mjs +1064 -0
  89. package/edition/distribution-paths.mjs +191 -0
  90. package/edition/edition-artifact.mjs +529 -0
  91. package/edition/edition-biome-manifests.mjs +85 -0
  92. package/edition/edition-ceiling.mjs +182 -0
  93. package/edition/edition-channel.mjs +27 -0
  94. package/edition/edition-chart-set.mjs +104 -0
  95. package/edition/edition-config.mjs +374 -0
  96. package/edition/edition-constants.mjs +110 -0
  97. package/edition/edition-contract-parse.mjs +179 -0
  98. package/edition/edition-contract.json +100 -0
  99. package/edition/edition-image-seal.mjs +115 -0
  100. package/edition/edition-kind.mjs +10 -0
  101. package/edition/edition-list.mjs +35 -0
  102. package/edition/edition-locks.mjs +78 -0
  103. package/edition/edition-pin-verbs.mjs +183 -0
  104. package/edition/edition-pin.mjs +17 -0
  105. package/edition/edition-pinned-inputs.mjs +116 -0
  106. package/edition/edition-protocol.mjs +285 -0
  107. package/edition/edition-publisher.mjs +21 -0
  108. package/edition/edition-seal.mjs +257 -0
  109. package/edition/edition-source-pins.mjs +211 -0
  110. package/edition/edition-unpin.mjs +16 -0
  111. package/edition/edition-workspace.mjs +205 -0
  112. package/edition/evidence-checkout.mjs +439 -0
  113. package/edition/evidence-grammars.mjs +18 -0
  114. package/edition/evidence-input-mode.mjs +136 -0
  115. package/edition/evidence-trust-pin.mjs +392 -0
  116. package/edition/evidence-trust-policy.mjs +374 -0
  117. package/edition/external-source-walk.mjs +486 -0
  118. package/edition/first-party-source-ids.mjs +62 -0
  119. package/edition/fleet-coordinate-finality.mjs +363 -0
  120. package/edition/fulcio-certificate-extensions.mjs +278 -0
  121. package/edition/git-json.mjs +54 -0
  122. package/edition/git-source-auth.mjs +131 -0
  123. package/edition/image-delivery-entitlement-descriptor.mjs +106 -0
  124. package/edition/image-evidence-replay-state.mjs +242 -0
  125. package/edition/image-evidence.mjs +1328 -0
  126. package/edition/image-lock-service-rows.mjs +26 -0
  127. package/edition/image-subject-identity.mjs +19 -0
  128. package/edition/images-from-record.mjs +216 -0
  129. package/edition/indexes-at-pins.mjs +13 -0
  130. package/edition/infra-images-lock.mjs +105 -0
  131. package/edition/kernel-capability-domain.mjs +30 -0
  132. package/edition/kernel-contract-authority.mjs +526 -0
  133. package/edition/kernel-contract-source-path-set.mjs +96 -0
  134. package/edition/kernel-package.mjs +237 -0
  135. package/edition/kernel-pin-source.mjs +63 -0
  136. package/edition/kernel-protocol-choice.mjs +239 -0
  137. package/edition/kernel-signer-kind.mjs +23 -0
  138. package/edition/lib.mjs +3322 -0
  139. package/edition/locked-bundle-signer.mjs +297 -0
  140. package/edition/map-with-concurrency.mjs +19 -0
  141. package/edition/no-follow-file.mjs +94 -0
  142. package/edition/oci-evidence-verbs.mjs +421 -0
  143. package/edition/oci-image-source-evidence.mjs +377 -0
  144. package/edition/operation-grant-admission.mjs +285 -0
  145. package/edition/operation-requirements.mjs +282 -0
  146. package/edition/oras-process.mjs +35 -0
  147. package/edition/oras.mjs +664 -0
  148. package/edition/pin-source.mjs +242 -0
  149. package/edition/pinned-cosign.mjs +86 -0
  150. package/edition/plan-image-evidence-refresh.mjs +428 -0
  151. package/edition/platform-lock-readability.mjs +903 -0
  152. package/edition/platform-service-class.mjs +85 -0
  153. package/edition/platform-service-ownership.mjs +91 -0
  154. package/edition/platform-service-source-authority.mjs +105 -0
  155. package/edition/positive-closure.mjs +162 -0
  156. package/edition/previous-image-lock.mjs +25 -0
  157. package/edition/produce-distribution-completeness-evidence.mjs +317 -0
  158. package/edition/produce-edition-catalogue-evidence.mjs +252 -0
  159. package/edition/produce-image-delivery-entitlement-evidence.mjs +302 -0
  160. package/edition/produce-image-source-evidence.mjs +788 -0
  161. package/edition/producer-evidence-bundle.mjs +201 -0
  162. package/edition/producer-evidence-index-file.mjs +4 -0
  163. package/edition/record-refused.mjs +11 -0
  164. package/edition/registry-client-factory.mjs +21 -0
  165. package/edition/registry-credentials.mjs +225 -0
  166. package/edition/release-catalogue.mjs +48 -0
  167. package/edition/release-channel-guard.mjs +108 -0
  168. package/edition/release-edition-artifact.mjs +152 -0
  169. package/edition/release-edition.mjs +54 -0
  170. package/edition/remedy-command.mjs +89 -0
  171. package/edition/repin-evidence-trust.mjs +109 -0
  172. package/edition/required-resource-provider.mjs +58 -0
  173. package/edition/resolve-chart-set.mjs +376 -0
  174. package/edition/resolve-customer-edition.mjs +374 -0
  175. package/edition/resolve-distribution.mjs +2642 -0
  176. package/edition/resolve-edition.mjs +192 -0
  177. package/edition/resolve-images.mjs +2344 -0
  178. package/edition/resolve-infra-images.mjs +88 -0
  179. package/edition/seal-image-digests.mjs +954 -0
  180. package/edition/sealed-distribution-lock.mjs +22 -0
  181. package/edition/sealed-evidence-accept.mjs +82 -0
  182. package/edition/shallow-checkout.mjs +18 -0
  183. package/edition/sigstore-bundle-media-type.mjs +4 -0
  184. package/edition/source-authority.mjs +713 -0
  185. package/edition/source-closure.mjs +115 -0
  186. package/edition/store-publisher-trust.mjs +40 -0
  187. package/edition/strict-json.mjs +121 -0
  188. package/edition/string-order.mjs +5 -0
  189. package/edition/third-party-admission.mjs +118 -0
  190. package/edition/third-party-bundle-registry.mjs +349 -0
  191. package/edition/third-party-certificate-extensions.mjs +58 -0
  192. package/edition/third-party-image-pins.mjs +41 -0
  193. package/edition/third-party-pins-trusted.mjs +24 -0
  194. package/edition/third-party-producer-evidence.mjs +76 -0
  195. package/edition/third-party-source-check.mjs +28 -0
  196. package/edition/third-party-source-context.mjs +41 -0
  197. package/edition/third-party-source-evidence.mjs +326 -0
  198. package/edition/third-party-source-flags.mjs +148 -0
  199. package/edition/third-party-source-pins.mjs +270 -0
  200. package/edition/third-party-source-uniqueness.mjs +54 -0
  201. package/edition/third-party-source-walk.mjs +118 -0
  202. package/edition/third-party-source-writer.mjs +228 -0
  203. package/edition/third-party-subjects.mjs +128 -0
  204. package/edition/trust-policy-arming-verb.mjs +60 -0
  205. package/edition/trust-policy-arming.mjs +144 -0
  206. package/edition/trust-policy-image-ownership.mjs +85 -0
  207. package/edition/trusted-executable.mjs +166 -0
  208. package/edition/verifier-binding.mjs +45 -0
  209. package/edition/verifier-digest-pin.mjs +17 -0
  210. package/edition/verifier-files.mjs +8 -0
  211. package/edition/verifier-kernel-schemas.mjs +22 -0
  212. package/edition/verifier-provenance.mjs +64 -0
  213. package/edition/verify-catalogue-evidence.mjs +247 -0
  214. package/edition/verify-image-evidence.mjs +493 -0
  215. package/edition/verify-image-platforms.mjs +523 -0
  216. package/edition/why.mjs +56 -0
  217. package/edition/workspace-sections.mjs +287 -0
  218. package/package.json +83 -4
  219. package/README.md +0 -3
@@ -0,0 +1,428 @@
1
+ #!/usr/bin/env node
2
+ // ═══════════════════════════════════════════════════════════════════════════
3
+ // WHICH PRODUCING REPOSITORIES MUST RE-SIGN BEFORE THE NEXT RELEASE?
4
+ //
5
+ // xema-edition evidence plan-refresh \
6
+ // --data-root <dir> --editions-root <dir> --kernel <dir> \
7
+ // [--kernel-checkout exact|preview] \
8
+ // --distribution <id> [repeatable] \
9
+ // [--horizon-seconds <n>] [--now <epoch-millis>] [--github-output <path>]
10
+ //
11
+ // Producer evidence is read from ONE place: the OCI referrers of each locked
12
+ // image, in the registry the lock names.
13
+ //
14
+ // `--kernel-checkout exact` (the default) loads the kernel schemas from a clean
15
+ // kernel checkout at exactly the commit the data root's evidence-trust pin
16
+ // records. `preview` loads whatever kernel is built at `--kernel`: that build
17
+ // is held to no commit, so its plan is a LOCAL PREVIEW. It says so on every
18
+ // run and cannot write `--github-output`, so no workflow can act on it.
19
+ //
20
+ // WHY THIS EXISTS
21
+ //
22
+ // Producer evidence carries a hard `expiresAt` that the offline verifier
23
+ // enforces. Nothing was watching it. Evidence was produced only as a
24
+ // side-effect of an image BUILD (or a manual dispatch), so a subject whose
25
+ // source has not changed in a week has evidence that quietly ages out — and
26
+ // the failure surfaces at the worst possible moment: at a customer install, or
27
+ // as a release that cannot be delivered, with a green CI behind it.
28
+ //
29
+ // This is the standing question asked on a schedule instead: for every subject
30
+ // of every named edition, is there a published producer bundle, is it usable,
31
+ // and does it still have enough life to survive the release that will consume
32
+ // it? Anything else is a refresh that is DUE, reported per producing
33
+ // repository so the owner of that repository can act on one line.
34
+ //
35
+ // EXIT CODES — the distinction is deliberate
36
+ //
37
+ // 0 every subject is CURRENT.
38
+ // 2 a verdict was reached and a refresh IS due. This is the loud failure the
39
+ // scheduled workflow exists to produce.
40
+ // 1 NO verdict could be reached (bad arguments, unreadable locks, a lock and
41
+ // the runtime index disagreeing about a repository pin). A planner that
42
+ // cannot decide must never report "nothing to do".
43
+ //
44
+ // THE HORIZON IS THE REUSE FLOOR
45
+ //
46
+ // The default horizon is `MIN_REMAINING_TTL_SECONDS`, the same floor
47
+ // `check-committed-image-evidence.mjs` uses to decide a committed bundle is
48
+ // too close to expiry to hand forward. One number, one meaning: below it, the
49
+ // bundle is not something a release should be built on.
50
+ // ═══════════════════════════════════════════════════════════════════════════
51
+
52
+ import { appendFileSync } from 'node:fs';
53
+ import { isAbsolute } from 'node:path';
54
+
55
+ import { MIN_REMAINING_TTL_SECONDS } from './check-committed-image-evidence.mjs';
56
+ import {
57
+ expectedProducerSource,
58
+ parseEvidenceCliFlags,
59
+ } from './distribution-evidence-set.mjs';
60
+ import {
61
+ WorkspaceRoot,
62
+ configureEditionWorkspaceFromArgv,
63
+ dataRoot,
64
+ editionsRoot,
65
+ isEntryModule,
66
+ } from './edition-workspace.mjs';
67
+ import {
68
+ canonicalJson,
69
+ loadValidatedImageLocks,
70
+ } from './image-evidence.mjs';
71
+ import { assertKernelContractAuthorityEqual } from './kernel-contract-authority.mjs';
72
+ import { compareStrings, loadKernelDistributionSchemas } from './lib.mjs';
73
+ import {
74
+ createRegistryClientResolver,
75
+ describeInvalidReferrer,
76
+ resolveImageSourceEvidenceReferrer,
77
+ writeInvalidReferrerReport,
78
+ } from './oci-image-source-evidence.mjs';
79
+ import {
80
+ loadProducerKernelSchemas,
81
+ readPinnedContractAuthority,
82
+ } from './produce-image-source-evidence.mjs';
83
+ import { splitImageRef } from './registry-credentials.mjs';
84
+
85
+ /** Closed set of per-subject refresh verdicts. */
86
+ export const EvidenceRefreshStatus = Object.freeze({
87
+ Current: 'CURRENT',
88
+ Absent: 'ABSENT',
89
+ Unusable: 'UNUSABLE',
90
+ Expired: 'EXPIRED',
91
+ Expiring: 'EXPIRING',
92
+ });
93
+
94
+ /** Where the kernel schemas of a planning run come from. */
95
+ export const KernelCheckout = Object.freeze({
96
+ /** A clean kernel checkout at exactly the pinned authority commit. */
97
+ Exact: 'exact',
98
+ /** The kernel built at `--kernel`, held to no commit: a local preview only. */
99
+ Preview: 'preview',
100
+ });
101
+
102
+ const DUE_STATUSES = Object.freeze(
103
+ new Set([
104
+ EvidenceRefreshStatus.Absent,
105
+ EvidenceRefreshStatus.Unusable,
106
+ EvidenceRefreshStatus.Expired,
107
+ EvidenceRefreshStatus.Expiring,
108
+ ]),
109
+ );
110
+
111
+ /**
112
+ * @returns {Promise<{horizonSeconds: number, refreshDue: boolean,
113
+ * subjects: Array<object>, groups: Array<object>}>}
114
+ */
115
+ export async function planImageEvidenceRefresh({
116
+ distributionIds,
117
+ dataRoot: dataRootDirectory,
118
+ editionsRoot: editionsRootDirectory,
119
+ schemas,
120
+ expectedContractAuthority,
121
+ registryClientFactory,
122
+ horizonSeconds = MIN_REMAINING_TTL_SECONDS,
123
+ now = Date.now(),
124
+ }) {
125
+ if (!Array.isArray(distributionIds) || distributionIds.length === 0) {
126
+ throw new Error('at least one distribution id is required');
127
+ }
128
+ if (!expectedContractAuthority) {
129
+ throw new Error('the expected kernel contract authority is required');
130
+ }
131
+ if (!isAbsolute(dataRootDirectory) || !isAbsolute(editionsRootDirectory)) {
132
+ throw new Error('the data root and the editions root must be absolute paths');
133
+ }
134
+ if (!Number.isSafeInteger(horizonSeconds) || horizonSeconds <= 0) {
135
+ throw new Error('the refresh horizon must be a positive whole number of seconds');
136
+ }
137
+ if (!Number.isSafeInteger(now) || now <= 0) {
138
+ throw new Error('the evaluation instant must be positive epoch milliseconds');
139
+ }
140
+ const clientFor = createRegistryClientResolver({
141
+ dataRoot: dataRootDirectory,
142
+ factory: registryClientFactory,
143
+ });
144
+
145
+ const subjects = [];
146
+ for (const distributionId of [...distributionIds].sort(compareStrings)) {
147
+ const { imageLock, repositoryPins, runtimeSourceIndex, thirdPartySources } = await loadValidatedImageLocks(
148
+ distributionId,
149
+ dataRootDirectory,
150
+ schemas,
151
+ { editionsRoot: editionsRootDirectory },
152
+ );
153
+ assertKernelContractAuthorityEqual(
154
+ runtimeSourceIndex.contractAuthority,
155
+ expectedContractAuthority,
156
+ 'runtime-image-source-index.json',
157
+ );
158
+ for (const subject of imageLock.subjects) {
159
+ // A third-party subject's contentHash is publisher-attested (no tag, no registry referrer here); its evidence is the committed bundle.
160
+ if (thirdPartySources.has(subject.repo)) {continue;}
161
+ const repositoryPin = repositoryPins.get(subject.repo);
162
+ const expectedSource = expectedProducerSource({
163
+ subject,
164
+ repositoryPin,
165
+ sourceHashAlgorithm: imageLock.sourceHashAlgorithm,
166
+ });
167
+ let matches = [];
168
+ // Set when the registry ANSWERS that the locked coordinate holds no
169
+ // image. That is a decided verdict for this subject, not an undecidable
170
+ // one, so it is reported rather than thrown — see the exit-code contract
171
+ // in this file's header. Until 2026-09-02 the throw escaped the whole
172
+ // planner: measured that day, ALL 68 locked coordinates were absent from
173
+ // ghcr, and the run died on the first of them and said nothing about the
174
+ // other 67.
175
+ let unusableReason = null;
176
+ // Every referrer of this subject that is not valid evidence, as reported
177
+ // by the resolver. All of them invalid is a decided UNUSABLE verdict.
178
+ const invalidReferrers = [];
179
+ {
180
+ const { authority, repository } = splitImageRef(subject.imageRef);
181
+ const client = clientFor(authority);
182
+ let image = null;
183
+ try {
184
+ image = await client.getManifestByTag(repository, subject.contentHash);
185
+ } catch (error) {
186
+ // A registry that says "no such manifest" has decided this subject.
187
+ // Any other failure means the question went UNANSWERED, and a planner
188
+ // that cannot decide must never report "nothing to do" — so it still
189
+ // escapes and the run still exits 1.
190
+ if (error?.status !== 404) {throw error;}
191
+ unusableReason =
192
+ `the registry has no image at the locked coordinate ` +
193
+ `${subject.imageRef}:${subject.contentHash} — nothing was ever ` +
194
+ `published there, so no producer evidence can attest it and the ` +
195
+ `lock must be re-resolved against a commit that was actually built`;
196
+ }
197
+ if (image !== null) {
198
+ const artifact = await resolveImageSourceEvidenceReferrer({
199
+ client,
200
+ imageRepository: subject.imageRef,
201
+ subjectDigest: image.digest,
202
+ schemas,
203
+ expectedContractAuthority,
204
+ authorityBound: true, // first-party subject: bound to the contract authority on every channel (authority-binding.mjs)
205
+ onInvalidReferrer: (skipped) => {
206
+ invalidReferrers.push(skipped);
207
+ writeInvalidReferrerReport(skipped);
208
+ },
209
+ });
210
+ if (artifact === null && invalidReferrers.length > 0) {
211
+ unusableReason =
212
+ `every evidence referrer of ${subject.imageRef}@${image.digest} ` +
213
+ `(repo ${subject.repo}) is invalid under the required kernel contract ` +
214
+ `authority ${expectedContractAuthority.sourceCommit}: ` +
215
+ invalidReferrers.map(describeInvalidReferrer).join('; ');
216
+ }
217
+ if (artifact !== null) {
218
+ matches = artifact.records.filter(
219
+ (record) =>
220
+ canonicalJson(record.statement.predicate.source) ===
221
+ canonicalJson(expectedSource),
222
+ );
223
+ }
224
+ }
225
+ }
226
+ const verdict =
227
+ unusableReason !== null
228
+ ? {
229
+ status: EvidenceRefreshStatus.Unusable,
230
+ remainingSeconds: null,
231
+ reason: unusableReason,
232
+ }
233
+ : classifySubject({ matches, horizonSeconds, now });
234
+ subjects.push({
235
+ distributionId,
236
+ subjectKind: subject.kind,
237
+ subjectId: subject.id,
238
+ repo: subject.repo,
239
+ repositoryUrl: repositoryPin.repositoryUrl,
240
+ sourceCommit: subject.sourceCommit,
241
+ ...verdict,
242
+ });
243
+ }
244
+ }
245
+
246
+ subjects.sort(
247
+ (left, right) =>
248
+ compareStrings(left.repo, right.repo) ||
249
+ compareStrings(left.distributionId, right.distributionId) ||
250
+ compareStrings(left.subjectKind, right.subjectKind) ||
251
+ compareStrings(left.subjectId, right.subjectId),
252
+ );
253
+ const groups = [];
254
+ for (const subject of subjects) {
255
+ const incumbent = groups.at(-1);
256
+ if (incumbent?.repo === subject.repo) {
257
+ incumbent.subjects.push(subject);
258
+ incumbent.due ||= DUE_STATUSES.has(subject.status);
259
+ continue;
260
+ }
261
+ groups.push({
262
+ repo: subject.repo,
263
+ repositoryUrl: subject.repositoryUrl,
264
+ subjects: [subject],
265
+ due: DUE_STATUSES.has(subject.status),
266
+ });
267
+ }
268
+ return {
269
+ horizonSeconds,
270
+ refreshDue: subjects.some((subject) => DUE_STATUSES.has(subject.status)),
271
+ subjects,
272
+ groups,
273
+ };
274
+ }
275
+
276
+ function classifySubject({ matches, horizonSeconds, now }) {
277
+ if (matches.length === 0) {
278
+ return {
279
+ status: EvidenceRefreshStatus.Absent,
280
+ remainingSeconds: null,
281
+ reason: 'no published producer evidence attests this source coordinate',
282
+ };
283
+ }
284
+ const [match] = matches;
285
+ const expiresAt = match.statement.predicate.producerRun.expiresAt;
286
+ const remainingSeconds = Math.floor((Date.parse(expiresAt) - now) / 1000);
287
+ if (!Number.isFinite(remainingSeconds)) {
288
+ return {
289
+ status: EvidenceRefreshStatus.Unusable,
290
+ remainingSeconds: null,
291
+ reason: `published statement has an unreadable expiresAt "${String(expiresAt)}"`,
292
+ };
293
+ }
294
+ if (remainingSeconds <= 0) {
295
+ return {
296
+ status: EvidenceRefreshStatus.Expired,
297
+ remainingSeconds,
298
+ reason: `evidence EXPIRED at ${expiresAt}; it can never verify again`,
299
+ };
300
+ }
301
+ if (remainingSeconds < horizonSeconds) {
302
+ return {
303
+ status: EvidenceRefreshStatus.Expiring,
304
+ remainingSeconds,
305
+ reason: `evidence expires in ${remainingSeconds}s, inside the ${horizonSeconds}s refresh horizon`,
306
+ };
307
+ }
308
+ return {
309
+ status: EvidenceRefreshStatus.Current,
310
+ remainingSeconds,
311
+ reason: `evidence is usable for another ${remainingSeconds}s`,
312
+ };
313
+ }
314
+
315
+ /** One line per subject, plus GitHub annotations grouped by producing repo. */
316
+ export function renderRefreshPlan(plan) {
317
+ const lines = [];
318
+ for (const group of plan.groups) {
319
+ const due = group.subjects.filter((subject) =>
320
+ DUE_STATUSES.has(subject.status),
321
+ );
322
+ if (due.length === 0) {
323
+ lines.push(
324
+ `[image-evidence-refresh] ${group.repo}: all ${group.subjects.length} subject(s) CURRENT`,
325
+ );
326
+ continue;
327
+ }
328
+ lines.push(
329
+ `::error::[image-evidence-refresh] ${group.repo} (${group.repositoryUrl}) must re-run its ` +
330
+ `image-source evidence producer for ${due.length} of ${group.subjects.length} subject(s): ` +
331
+ due
332
+ .map(
333
+ (subject) =>
334
+ `${subject.subjectKind}:${subject.subjectId} [${subject.distributionId}] ${subject.status} — ${subject.reason}`,
335
+ )
336
+ .join(' | '),
337
+ );
338
+ }
339
+ lines.push(
340
+ plan.refreshDue
341
+ ? `[image-evidence-refresh] REFRESH DUE for ${
342
+ plan.subjects.filter((subject) => DUE_STATUSES.has(subject.status))
343
+ .length
344
+ } of ${plan.subjects.length} subject(s) within a ${plan.horizonSeconds}s horizon`
345
+ : `[image-evidence-refresh] all ${plan.subjects.length} subject(s) hold evidence beyond the ${plan.horizonSeconds}s horizon`,
346
+ );
347
+ return lines;
348
+ }
349
+
350
+ /** The step outputs a workflow reads: whether a refresh is due, how many subjects, and which producing repositories. */
351
+ export function renderGithubOutputs(plan) {
352
+ const dueCount = plan.subjects.filter((subject) => DUE_STATUSES.has(subject.status)).length;
353
+ return (
354
+ `refresh-due=${plan.refreshDue}\n` +
355
+ `due-subjects=${dueCount}\n` +
356
+ `total-subjects=${plan.subjects.length}\n` +
357
+ `due-repositories=${plan.groups
358
+ .filter((group) => group.due)
359
+ .map((group) => group.repo)
360
+ .join(',')}\n`
361
+ );
362
+ }
363
+
364
+ async function main() {
365
+ const argv = configureEditionWorkspaceFromArgv(process.argv.slice(2), [
366
+ WorkspaceRoot.Data,
367
+ WorkspaceRoot.Editions,
368
+ WorkspaceRoot.Kernel,
369
+ ]);
370
+ const flags = parseEvidenceCliFlags(argv, {
371
+ single: [
372
+ 'horizon-seconds',
373
+ 'now',
374
+ 'github-output',
375
+ 'kernel-checkout',
376
+ ],
377
+ repeated: ['distribution'],
378
+ });
379
+ const horizonRaw = flags.optional('horizon-seconds');
380
+ const nowRaw = flags.optional('now');
381
+ const kernelCheckout = flags.optional('kernel-checkout') ?? KernelCheckout.Exact;
382
+ if (!Object.values(KernelCheckout).includes(kernelCheckout)) {
383
+ throw new Error(`--kernel-checkout must be one of ${Object.values(KernelCheckout).join(', ')}`);
384
+ }
385
+ const githubOutput = flags.optional('github-output');
386
+ const preview = kernelCheckout === KernelCheckout.Preview;
387
+ if (preview && githubOutput !== undefined) {
388
+ throw new Error(
389
+ '--kernel-checkout preview plans with a kernel build that is held to no commit; ' +
390
+ 'its plan is a local preview and cannot be written to --github-output',
391
+ );
392
+ }
393
+ const schemas = preview
394
+ ? await loadKernelDistributionSchemas()
395
+ : (await loadProducerKernelSchemas({ dataRoot: dataRoot() })).schemas;
396
+ const plan = await planImageEvidenceRefresh({
397
+ distributionIds: flags.requireMany('distribution'),
398
+ dataRoot: dataRoot(),
399
+ editionsRoot: editionsRoot(),
400
+ schemas,
401
+ expectedContractAuthority: readPinnedContractAuthority({ dataRoot: dataRoot(), schemas }),
402
+ horizonSeconds:
403
+ horizonRaw === undefined ? MIN_REMAINING_TTL_SECONDS : Number(horizonRaw),
404
+ now: nowRaw === undefined ? Date.now() : Number(nowRaw),
405
+ });
406
+ if (preview) {
407
+ console.log(
408
+ '[image-evidence-refresh] LOCAL PREVIEW: planned with the kernel built at --kernel, which is held to no commit. ' +
409
+ 'Nothing may be sealed or scheduled from this plan; run without --kernel-checkout preview for a verdict.',
410
+ );
411
+ }
412
+ for (const line of renderRefreshPlan(plan)) {
413
+ console.log(line);
414
+ }
415
+ if (githubOutput !== undefined) {
416
+ appendFileSync(githubOutput, renderGithubOutputs(plan), 'utf8');
417
+ }
418
+ if (plan.refreshDue) {
419
+ process.exitCode = 2;
420
+ }
421
+ }
422
+
423
+ if (isEntryModule(import.meta.url)) {
424
+ main().catch((error) => {
425
+ console.error(`[image-evidence-refresh] ${error.message}`);
426
+ process.exitCode = 1;
427
+ });
428
+ }