@kungfu-tech/buildchain 2.12.0 → 2.12.1-alpha.2

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.
@@ -0,0 +1,276 @@
1
+ export const STABLE_CANDIDATE_LEDGER_CONTRACT = "kungfu-buildchain-stable-candidate-ledger";
2
+ export const STABLE_CANDIDATE_STATES = Object.freeze([
3
+ "soaking",
4
+ "qualified",
5
+ "revoked",
6
+ "promoted",
7
+ ]);
8
+
9
+ function text(value = "") {
10
+ return String(value ?? "").trim();
11
+ }
12
+
13
+ function iso(value, label) {
14
+ const normalized = text(value);
15
+ const milliseconds = Date.parse(normalized);
16
+ if (!normalized || !Number.isFinite(milliseconds)) {
17
+ throw new Error(`${label} must be an ISO-8601 timestamp`);
18
+ }
19
+ return new Date(milliseconds).toISOString();
20
+ }
21
+
22
+ function exactAlpha(version) {
23
+ const normalized = text(version).replace(/^v/, "");
24
+ const match = normalized.match(/^(\d+)\.(\d+)\.(\d+)-alpha\.(\d+)$/);
25
+ if (!match) {
26
+ throw new Error(`candidate version must be an exact alpha, got ${version || "<empty>"}`);
27
+ }
28
+ return {
29
+ version: normalized,
30
+ stableVersion: `${match[1]}.${match[2]}.${match[3]}`,
31
+ order: match.slice(1).map(Number),
32
+ };
33
+ }
34
+
35
+ function sha(value, label = "candidate sha") {
36
+ const normalized = text(value).toLowerCase();
37
+ if (!/^[0-9a-f]{40}$/.test(normalized)) {
38
+ throw new Error(`${label} must be a 40-character commit SHA`);
39
+ }
40
+ return normalized;
41
+ }
42
+
43
+ function clone(value) {
44
+ return JSON.parse(JSON.stringify(value));
45
+ }
46
+
47
+ function compareCandidates(left, right) {
48
+ const a = exactAlpha(left.version).order;
49
+ const b = exactAlpha(right.version).order;
50
+ for (let index = 0; index < a.length; index += 1) {
51
+ if (a[index] !== b[index]) return a[index] - b[index];
52
+ }
53
+ return left.publishedAt.localeCompare(right.publishedAt);
54
+ }
55
+
56
+ export function createStableCandidateLedger({ repository, targetBranch, now = new Date().toISOString() } = {}) {
57
+ const normalizedRepository = text(repository);
58
+ const normalizedTargetBranch = text(targetBranch).replace(/^refs\/heads\//, "");
59
+ if (!/^[^/\s]+\/[^/\s]+$/.test(normalizedRepository)) {
60
+ throw new Error(`repository must be owner/repo, got ${repository || "<empty>"}`);
61
+ }
62
+ if (!/^release\/v\d+\/v\d+\.\d+$/.test(normalizedTargetBranch)) {
63
+ throw new Error(`targetBranch must be release/vN/vN.M, got ${targetBranch || "<empty>"}`);
64
+ }
65
+ return {
66
+ schemaVersion: 1,
67
+ contract: STABLE_CANDIDATE_LEDGER_CONTRACT,
68
+ repository: normalizedRepository,
69
+ targetBranch: normalizedTargetBranch,
70
+ hold: { enabled: false, reason: "", updatedAt: iso(now, "now") },
71
+ candidates: [],
72
+ updatedAt: iso(now, "now"),
73
+ };
74
+ }
75
+
76
+ export function normalizeStableCandidateLedger(input, expected = {}) {
77
+ const ledger = clone(input || {});
78
+ if (ledger.contract !== STABLE_CANDIDATE_LEDGER_CONTRACT || Number(ledger.schemaVersion) !== 1) {
79
+ throw new Error(`stable candidate ledger must use ${STABLE_CANDIDATE_LEDGER_CONTRACT} schemaVersion 1`);
80
+ }
81
+ if (expected.repository && ledger.repository !== expected.repository) {
82
+ throw new Error(`stable candidate ledger repository mismatch: ${ledger.repository} != ${expected.repository}`);
83
+ }
84
+ if (expected.targetBranch && ledger.targetBranch !== expected.targetBranch) {
85
+ throw new Error(`stable candidate ledger targetBranch mismatch: ${ledger.targetBranch} != ${expected.targetBranch}`);
86
+ }
87
+ ledger.hold ||= { enabled: false, reason: "", updatedAt: ledger.updatedAt };
88
+ ledger.candidates = (ledger.candidates || []).map((candidate) => {
89
+ const parsed = exactAlpha(candidate.version);
90
+ const state = text(candidate.state);
91
+ if (!STABLE_CANDIDATE_STATES.includes(state)) {
92
+ throw new Error(`unsupported candidate state ${state || "<empty>"}`);
93
+ }
94
+ return {
95
+ ...candidate,
96
+ version: parsed.version,
97
+ stableVersion: parsed.stableVersion,
98
+ sha: sha(candidate.sha),
99
+ publishedAt: iso(candidate.publishedAt, `candidate ${parsed.version} publishedAt`),
100
+ state,
101
+ };
102
+ });
103
+ return ledger;
104
+ }
105
+
106
+ export function registerStableCandidate(ledgerInput, candidateInput, { now = new Date().toISOString() } = {}) {
107
+ const ledger = normalizeStableCandidateLedger(ledgerInput);
108
+ const parsed = exactAlpha(candidateInput.version);
109
+ const candidateSha = sha(candidateInput.sha);
110
+ const existing = ledger.candidates.find((candidate) => candidate.version === parsed.version);
111
+ if (existing) {
112
+ if (existing.sha !== candidateSha) {
113
+ throw new Error(`candidate ${parsed.version} is already bound to ${existing.sha}, not ${candidateSha}`);
114
+ }
115
+ return ledger;
116
+ }
117
+ ledger.candidates.push({
118
+ version: parsed.version,
119
+ stableVersion: parsed.stableVersion,
120
+ sha: candidateSha,
121
+ tag: `v${parsed.version}`,
122
+ publishedAt: iso(candidateInput.publishedAt, `candidate ${parsed.version} publishedAt`),
123
+ state: "soaking",
124
+ qualification: {
125
+ ok: false,
126
+ observedAt: "",
127
+ soakStartedAt: "",
128
+ soakElapsedSeconds: 0,
129
+ requiredSeconds: 0,
130
+ checks: [],
131
+ },
132
+ decision: { reason: "registered", updatedAt: iso(now, "now"), actor: text(candidateInput.actor) },
133
+ });
134
+ ledger.candidates.sort(compareCandidates);
135
+ ledger.updatedAt = iso(now, "now");
136
+ return ledger;
137
+ }
138
+
139
+ export function qualifyStableCandidate(
140
+ ledgerInput,
141
+ observation,
142
+ { minimumSoakSeconds = 3600, now = new Date().toISOString() } = {},
143
+ ) {
144
+ const ledger = normalizeStableCandidateLedger(ledgerInput);
145
+ const version = exactAlpha(observation.version).version;
146
+ const candidate = ledger.candidates.find((entry) => entry.version === version);
147
+ if (!candidate) throw new Error(`candidate ${version} is not registered`);
148
+ if (candidate.sha !== sha(observation.sha)) {
149
+ throw new Error(`candidate ${version} observation SHA does not match ledger`);
150
+ }
151
+ if (["revoked", "promoted"].includes(candidate.state)) return ledger;
152
+
153
+ const checks = Array.isArray(observation.checks) ? observation.checks.map((check) => ({
154
+ id: text(check.id),
155
+ status: text(check.status),
156
+ completedAt: check.completedAt ? iso(check.completedAt, `check ${check.id} completedAt`) : "",
157
+ evidenceUrl: text(check.evidenceUrl),
158
+ })) : [];
159
+ const passed = checks.length > 0 && checks.every((check) => check.id && check.status === "pass" && check.completedAt);
160
+ const latestCheck = passed ? Math.max(...checks.map((check) => Date.parse(check.completedAt))) : NaN;
161
+ const soakStartedAt = passed
162
+ ? new Date(Math.max(Date.parse(candidate.publishedAt), latestCheck)).toISOString()
163
+ : "";
164
+ const elapsedSeconds = passed
165
+ ? Math.max(0, Math.floor((Date.parse(iso(now, "now")) - Date.parse(soakStartedAt)) / 1000))
166
+ : 0;
167
+ candidate.qualification = {
168
+ ok: passed && elapsedSeconds >= Number(minimumSoakSeconds),
169
+ observedAt: iso(now, "now"),
170
+ soakStartedAt,
171
+ soakElapsedSeconds: elapsedSeconds,
172
+ requiredSeconds: Number(minimumSoakSeconds),
173
+ checks,
174
+ };
175
+ candidate.state = candidate.qualification.ok ? "qualified" : "soaking";
176
+ candidate.decision = {
177
+ reason: candidate.qualification.ok ? "qualification-satisfied" : passed ? "soaking" : "checks-incomplete",
178
+ updatedAt: iso(now, "now"),
179
+ actor: text(observation.actor),
180
+ };
181
+ ledger.updatedAt = iso(now, "now");
182
+ return ledger;
183
+ }
184
+
185
+ export function revokeStableCandidate(ledgerInput, versionInput, { reason, actor = "", now = new Date().toISOString() } = {}) {
186
+ const ledger = normalizeStableCandidateLedger(ledgerInput);
187
+ const version = exactAlpha(versionInput).version;
188
+ const candidate = ledger.candidates.find((entry) => entry.version === version);
189
+ if (!candidate) throw new Error(`candidate ${version} is not registered`);
190
+ if (candidate.state === "promoted") throw new Error(`promoted candidate ${version} cannot be revoked`);
191
+ if (!text(reason)) throw new Error("candidate revocation requires a reason");
192
+ candidate.state = "revoked";
193
+ candidate.decision = { reason: text(reason), actor: text(actor), updatedAt: iso(now, "now") };
194
+ ledger.updatedAt = iso(now, "now");
195
+ return ledger;
196
+ }
197
+
198
+ export function setStableCandidateHold(ledgerInput, enabled, { reason = "", now = new Date().toISOString() } = {}) {
199
+ const ledger = normalizeStableCandidateLedger(ledgerInput);
200
+ if (enabled && !text(reason)) throw new Error("enabling stable candidate hold requires a reason");
201
+ ledger.hold = { enabled: Boolean(enabled), reason: text(reason), updatedAt: iso(now, "now") };
202
+ ledger.updatedAt = iso(now, "now");
203
+ return ledger;
204
+ }
205
+
206
+ export function selectStableCandidate(ledgerInput, { releaseNow = "", now = new Date().toISOString() } = {}) {
207
+ const ledger = normalizeStableCandidateLedger(ledgerInput);
208
+ if (ledger.hold.enabled && !releaseNow) {
209
+ return { selected: false, reason: "repository-held", hold: ledger.hold };
210
+ }
211
+ if (releaseNow) {
212
+ const version = exactAlpha(releaseNow).version;
213
+ const candidate = ledger.candidates.find((entry) => entry.version === version);
214
+ if (!candidate) return { selected: false, reason: "release-now-candidate-missing", version };
215
+ if (["revoked", "promoted"].includes(candidate.state)) {
216
+ return { selected: false, reason: `release-now-candidate-${candidate.state}`, candidate };
217
+ }
218
+ return { selected: true, reason: "human-release-now", authority: "human", candidate };
219
+ }
220
+ const candidates = ledger.candidates
221
+ .filter((candidate) => candidate.state === "qualified")
222
+ .sort(compareCandidates);
223
+ const candidate = candidates.at(-1);
224
+ return candidate
225
+ ? { selected: true, reason: "latest-qualified", authority: "policy", candidate }
226
+ : { selected: false, reason: "no-qualified-candidate" };
227
+ }
228
+
229
+ export function markStableCandidatePromoted(
230
+ ledgerInput,
231
+ versionInput,
232
+ { stableTag = "", stableSha = "", now = new Date().toISOString() } = {},
233
+ ) {
234
+ const ledger = normalizeStableCandidateLedger(ledgerInput);
235
+ const version = exactAlpha(versionInput).version;
236
+ const candidate = ledger.candidates.find((entry) => entry.version === version);
237
+ if (!candidate) throw new Error(`candidate ${version} is not registered`);
238
+ if (!candidate.qualification?.ok && candidate.decision?.reason !== "human-release-now") {
239
+ throw new Error(`candidate ${version} is not qualified for promotion`);
240
+ }
241
+ candidate.state = "promoted";
242
+ candidate.promotion = {
243
+ stableTag: text(stableTag) || `v${candidate.stableVersion}`,
244
+ stableSha: stableSha ? sha(stableSha, "stable sha") : "",
245
+ promotedAt: iso(now, "now"),
246
+ };
247
+ candidate.decision = { reason: "promoted", actor: "", updatedAt: iso(now, "now") };
248
+ for (const entry of ledger.candidates) {
249
+ if (entry.version !== version && entry.stableVersion === candidate.stableVersion && entry.state !== "promoted") {
250
+ entry.state = "revoked";
251
+ entry.decision = {
252
+ reason: `stable-version-promoted-by:${version}`,
253
+ actor: "buildchain",
254
+ updatedAt: iso(now, "now"),
255
+ };
256
+ }
257
+ }
258
+ ledger.updatedAt = iso(now, "now");
259
+ return ledger;
260
+ }
261
+
262
+ export function stableCandidatePromotionRefs(candidateInput, targetBranch) {
263
+ const candidate = { ...candidateInput, ...exactAlpha(candidateInput.version) };
264
+ const normalizedTarget = text(targetBranch).replace(/^refs\/heads\//, "");
265
+ const match = normalizedTarget.match(/^release\/(v\d+)\/(v\d+\.\d+)$/);
266
+ if (!match) throw new Error(`targetBranch must be release/vN/vN.M, got ${targetBranch || "<empty>"}`);
267
+ if (`v${candidate.stableVersion.split(".").slice(0, 2).join(".")}` !== match[2]) {
268
+ throw new Error(`candidate ${candidate.version} does not belong to ${normalizedTarget}`);
269
+ }
270
+ return {
271
+ sourceRef: `publish-gate/release/${match[1]}/${match[2]}/${candidate.version}`,
272
+ targetRef: normalizedTarget,
273
+ exactAlphaTag: `v${candidate.version}`,
274
+ stableTag: `v${candidate.stableVersion}`,
275
+ };
276
+ }
@@ -319,6 +319,7 @@ const manualMetaById = new Map(Object.entries({
319
319
  "binary-distribution": { capabilityGroup: "release-passport-trust", audience: ["release-operator", "agent"], maturity: "stable", order: 110 },
320
320
  "publish-transaction": { capabilityGroup: "release-passport-trust", audience: ["release-operator"], maturity: "stable", order: 120 },
321
321
  "release-candidate": { capabilityGroup: "reusable-build", audience: ["release-operator", "consumer"], maturity: "stable", order: 130 },
322
+ "stable-candidate-patrol": { capabilityGroup: "governance-versioning", audience: ["release-operator", "consumer"], maturity: "preview", order: 135 },
322
323
  "reusable-build-surface": { capabilityGroup: "reusable-build", audience: ["consumer", "release-operator"], maturity: "stable", order: 200 },
323
324
  "lifecycle-protocol": { capabilityGroup: "reusable-build", audience: ["consumer", "developer"], maturity: "stable", order: 210 },
324
325
  "runtime-train-validation": { capabilityGroup: "governance-versioning", audience: ["maintainer", "consumer"], maturity: "stable", order: 220 },
@@ -472,6 +473,7 @@ function nodeApiMeta(exportName) {
472
473
  "./artifact-passport": { group: "release-passport-trust", summary: "Artifact passport digest and evidence helper APIs." },
473
474
  "./release-passport": { group: "release-passport-trust", summary: "Release passport collection, verification, explanation, and evidence APIs." },
474
475
  "./release-candidate": { group: "reusable-build", summary: "PR-stage release-candidate artifact, passport, and promote-only resolver APIs." },
476
+ "./stable-candidate-ledger": { group: "governance-versioning", summary: "Immutable alpha candidate ledger, qualification, revocation, selection, and exact stable source-lock APIs." },
475
477
  "./release-propagation": { group: "site-and-propagation", summary: "Release propagation graph, plan, and exact upstream lock APIs." },
476
478
  "./release-line-bootstrap": { group: "governance-versioning", summary: "Semver release-line bootstrap planning and version-state APIs." },
477
479
  "./buildchain-contract": { group: "governance-versioning", summary: "Runtime contract world and compatibility digest APIs for floating-ref drift checks." },
@@ -747,6 +749,7 @@ function buildSiteBundle() {
747
749
  "docs/kfd-support.md",
748
750
  "docs/reusable-build-surface.md",
749
751
  "docs/release-candidate.md",
752
+ "docs/stable-candidate-patrol.md",
750
753
  "docs/release-governance.md",
751
754
  "docs/release-passport.md",
752
755
  "docs/publish-transaction.md",
@@ -827,6 +830,8 @@ function buildSiteBundle() {
827
830
  ["buildchain-patrol-daily", "repository-patrol"],
828
831
  ["buildchain-patrol-weekly", "repository-patrol"],
829
832
  ["buildchain-patrol-monthly", "repository-patrol"],
833
+ ["stable-candidate-patrol", "repository-patrol"],
834
+ ["buildchain-stable-candidate-patrol", "repository-patrol"],
830
835
  ["patrol-daily", "repository-patrol"],
831
836
  ["patrol-weekly", "repository-patrol"],
832
837
  ["patrol-monthly", "repository-patrol"],