@kungfu-tech/buildchain 3.0.2 → 3.0.3-alpha.1

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 (127) hide show
  1. package/AGENTS.md +1 -1
  2. package/CONTRIBUTING.md +1 -1
  3. package/README.md +27 -1
  4. package/actions/promote-buildchain-ref/README.md +90 -19
  5. package/actions/report-buildchain-issue/README.md +20 -2
  6. package/actions/run-lifecycle/README.md +5 -0
  7. package/actions/validate-config/README.md +19 -1
  8. package/bin/buildchain.mjs +56 -2
  9. package/bin/internal/command-registry.mjs +82 -0
  10. package/bin/internal/trust-release-cli.mjs +1 -0
  11. package/contracts/auditable-demo-media-profiles-v1.json +116 -1
  12. package/contracts/buildchain-v2-residuals-v1.json +146 -0
  13. package/contracts/evidence/auditable-demo-responsive-web-delivery-v1.json +143 -0
  14. package/contracts/evidence/auditable-demo-web-delivery-v1.json +7 -3
  15. package/contracts/fixtures/auditable-demo-responsive-web-delivery-v1/complete-transcript.txt +2 -0
  16. package/contracts/fixtures/auditable-demo-responsive-web-delivery-v1/public-projection.json +16 -0
  17. package/contracts/fixtures/auditable-demo-responsive-web-delivery-v1/scene.json +12 -0
  18. package/dist/site/buildchain-contract.json +188 -46
  19. package/dist/site/buildchain-site.json +181 -90
  20. package/dist/site/capability-registry.json +5 -5
  21. package/dist/site/cli-registry.json +860 -98
  22. package/dist/site/controller-registry.json +118 -5
  23. package/dist/site/kfd-claims.json +233 -19
  24. package/dist/site/kfd-upstream-aggregate.json +1 -1
  25. package/dist/site/manual-registry.json +20 -20
  26. package/dist/site/node-api-registry.json +374 -59
  27. package/dist/site/page-registry.json +156 -65
  28. package/dist/site/public-surface-audit.json +231 -22
  29. package/dist/site/publication-registry.json +4 -4
  30. package/dist/site/release-provenance.json +3 -0
  31. package/dist/site/site-manifest.json +24 -24
  32. package/dist/site/workflow-registry.json +575 -79
  33. package/docs/MAP.md +22 -4
  34. package/docs/auditable-demo.md +54 -13
  35. package/docs/aws-us-elastic-runner-burst-plane.md +381 -0
  36. package/docs/cli.md +119 -12
  37. package/docs/consumer-issue-reporting.md +20 -2
  38. package/docs/dev-alpha-candidate-patrol.md +24 -10
  39. package/docs/github-governance-authority.md +24 -31
  40. package/docs/lifecycle-protocol.md +20 -2
  41. package/docs/migration-inventory.md +24 -5
  42. package/docs/observed-evidence-patrol.md +28 -12
  43. package/docs/ownership.md +19 -1
  44. package/docs/publication-artifacts.md +158 -18
  45. package/docs/publication-authority.md +4 -4
  46. package/docs/publish-transaction.md +78 -31
  47. package/docs/release-candidate.md +24 -0
  48. package/docs/release-flow.md +50 -32
  49. package/docs/release-governance.md +39 -32
  50. package/docs/release-passport.md +4 -0
  51. package/docs/release-propagation.md +57 -12
  52. package/docs/reusable-build-surface.md +218 -90
  53. package/docs/runtime-train-validation.md +20 -2
  54. package/docs/shifu-gate-profiles.md +7 -1
  55. package/docs/stable-candidate-patrol.md +6 -6
  56. package/docs/toolkit-observability.md +22 -0
  57. package/docs/versioning.md +46 -24
  58. package/docs/web-surface-deployments.md +82 -1
  59. package/package.json +7 -5
  60. package/packages/core/README.md +60 -4
  61. package/packages/core/artifact-signing.js +1 -0
  62. package/packages/core/buildchain-contract.js +9 -4
  63. package/packages/core/cache-evidence.js +1 -0
  64. package/packages/core/controller-evidence.js +2 -0
  65. package/packages/core/diagnostics.js +105 -30
  66. package/packages/core/github-governance-authority.js +25 -17
  67. package/packages/core/index.js +40 -0
  68. package/packages/core/paper.js +3226 -0
  69. package/packages/core/public-surface-audit.js +3 -0
  70. package/packages/core/publication-artifact.js +1 -6
  71. package/packages/core/publication-authority.js +1 -0
  72. package/packages/core/publication-control-plane-audit.js +28 -0
  73. package/packages/core/publication-reproducibility.js +947 -0
  74. package/packages/core/publication-sealed-bundle.js +190 -0
  75. package/packages/core/publish-transaction.js +147 -17
  76. package/packages/core/release-candidate.js +234 -0
  77. package/packages/core/release-passport.js +519 -228
  78. package/packages/core/release-propagation.js +175 -1
  79. package/packages/core/stable-release-gate.js +4 -1
  80. package/scripts/artifact-signing-delegation.mjs +268 -0
  81. package/scripts/audit-github-governance.mjs +32 -13
  82. package/scripts/audit-publication-control-plane.mjs +17 -8
  83. package/scripts/auditable-demo.mjs +266 -7
  84. package/scripts/aws-codebuild-toolchain.mjs +285 -0
  85. package/scripts/aws-macos-jit-core.mjs +378 -0
  86. package/scripts/aws-macos-jit.mjs +123 -0
  87. package/scripts/aws-runner-burst-core.mjs +248 -0
  88. package/scripts/aws-runner-burst.mjs +79 -0
  89. package/scripts/aws-windows-jit-core.mjs +374 -0
  90. package/scripts/aws-windows-jit.mjs +121 -0
  91. package/scripts/build-contract-core.mjs +69 -6
  92. package/scripts/build-standalone-binary.mjs +23 -0
  93. package/scripts/buildchain-contract-lock.mjs +1 -1
  94. package/scripts/buildchain-patrol.mjs +1 -1
  95. package/scripts/check-internal-architecture.mjs +69 -2
  96. package/scripts/check-inventory.mjs +70 -2
  97. package/scripts/check-javascript-syntax.mjs +31 -0
  98. package/scripts/check-maintainability.mjs +371 -0
  99. package/scripts/compiler-cache-evidence.mjs +221 -0
  100. package/scripts/dev-alpha-candidate-patrol.mjs +288 -43
  101. package/scripts/dispatch-artifact-signing-authority.mjs +7 -1
  102. package/scripts/finalize-native-artifact-signing-result.mjs +185 -31
  103. package/scripts/gate-profile-core.mjs +6 -1
  104. package/scripts/generate-channel-promotion-workflow.mjs +3 -12
  105. package/scripts/generate-release-candidate-passport.mjs +4 -0
  106. package/scripts/generate-site-bundle.mjs +84 -18
  107. package/scripts/import-artifact-signing-results.mjs +64 -5
  108. package/scripts/inspect-artifact-signing-requests.mjs +52 -14
  109. package/scripts/maintainability-metrics.mjs +339 -0
  110. package/scripts/npm-publish-transaction.mjs +57 -6
  111. package/scripts/observed-evidence.mjs +151 -33
  112. package/scripts/paper.mjs +645 -0
  113. package/scripts/promotion-channel-router.mjs +16 -4
  114. package/scripts/publication-reproducibility.mjs +62 -0
  115. package/scripts/reconcile-github-governance.mjs +8 -1
  116. package/scripts/release-propagation.mjs +39 -0
  117. package/scripts/resolve-artifact-signing-upload-route.mjs +55 -0
  118. package/scripts/resolve-build-contract.mjs +6 -0
  119. package/scripts/route-offline-runners.mjs +236 -0
  120. package/scripts/run-candidate-body-prefix-renderer.mjs +187 -0
  121. package/scripts/run-lifecycle-core.mjs +62 -1
  122. package/scripts/runtime-ref-core.mjs +36 -4
  123. package/scripts/seal-artifact-signing-requests.mjs +64 -12
  124. package/scripts/stable-candidate-qualification.mjs +50 -0
  125. package/scripts/verify-artifact-signing-results.mjs +26 -1
  126. package/scripts/web-surface-core.mjs +272 -86
  127. package/scripts/web-surface-production-decision.mjs +19 -3
@@ -0,0 +1,221 @@
1
+ import crypto from "node:crypto";
2
+ import { spawnSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ export const BUILDCHAIN_COMPILER_CACHE_PREPARATION_CONTRACT =
8
+ "kungfu-buildchain-compiler-cache-preparation";
9
+
10
+ const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
11
+
12
+ function stableValue(value) {
13
+ if (Array.isArray(value)) return value.map(stableValue);
14
+ if (value && typeof value === "object") {
15
+ return Object.fromEntries(
16
+ Object.keys(value)
17
+ .sort()
18
+ .map((key) => [key, stableValue(value[key])]),
19
+ );
20
+ }
21
+ return value;
22
+ }
23
+
24
+ function digest(value) {
25
+ return `sha256:${crypto
26
+ .createHash("sha256")
27
+ .update(JSON.stringify(stableValue(value)))
28
+ .digest("hex")}`;
29
+ }
30
+
31
+ function cleanText(value) {
32
+ return String(value || "").trim();
33
+ }
34
+
35
+ function optionalDigest(value, label) {
36
+ const candidate = cleanText(value);
37
+ if (!candidate) return "";
38
+ if (!DIGEST_RE.test(candidate)) {
39
+ throw new Error(`${label} must be a sha256 digest`);
40
+ }
41
+ return candidate;
42
+ }
43
+
44
+ function defaultRunCommand(command, args, options = {}) {
45
+ return spawnSync(command, args, {
46
+ cwd: options.cwd,
47
+ env: options.env,
48
+ encoding: "utf8",
49
+ stdio: ["ignore", "pipe", "pipe"],
50
+ windowsHide: true,
51
+ });
52
+ }
53
+
54
+ function assertCommandSucceeded(result, label) {
55
+ if (result?.error) {
56
+ throw new Error(`${label} failed: ${result.error.code || result.error.message}`);
57
+ }
58
+ if (result?.status !== 0) {
59
+ const detail = cleanText(result?.stderr || result?.stdout);
60
+ throw new Error(`${label} exited ${result?.status ?? "without status"}${detail ? `: ${detail}` : ""}`);
61
+ }
62
+ }
63
+
64
+ function appendGithubEnv(filePath, values) {
65
+ if (!filePath) return;
66
+ for (const [name, value] of Object.entries(values)) {
67
+ if (/[\r\n\0]/.test(String(value))) {
68
+ throw new Error(`${name} contains control characters`);
69
+ }
70
+ }
71
+ fs.appendFileSync(
72
+ filePath,
73
+ `${Object.entries(values)
74
+ .map(([name, value]) => `${name}=${value}`)
75
+ .join("\n")}\n`,
76
+ );
77
+ }
78
+
79
+ function readToolEvidence({ cwd, env, expectedRoot }) {
80
+ const configured = cleanText(
81
+ env.BUILDCHAIN_COMPILER_CACHE_TOOL_EVIDENCE_PATH,
82
+ );
83
+ if (!configured) return undefined;
84
+ const filePath = path.resolve(cwd, configured);
85
+ const relative = path.relative(path.resolve(cwd), filePath);
86
+ if (relative.startsWith("..") || path.isAbsolute(relative)) {
87
+ throw new Error(
88
+ "BUILDCHAIN_COMPILER_CACHE_TOOL_EVIDENCE_PATH must remain inside the workflow workspace",
89
+ );
90
+ }
91
+ const evidence = JSON.parse(fs.readFileSync(filePath, "utf8"));
92
+ if (!DIGEST_RE.test(evidence?.root || "")) {
93
+ throw new Error("compiler-cache tool evidence root is invalid");
94
+ }
95
+ if (expectedRoot && evidence.root !== expectedRoot) {
96
+ throw new Error(
97
+ "compiler-cache tool evidence does not match BUILDCHAIN_COMPILER_CACHE_TOOL_ROOT",
98
+ );
99
+ }
100
+ return evidence;
101
+ }
102
+
103
+ export function prepareCompilerCacheEvidence({
104
+ cwd = process.cwd(),
105
+ env = process.env,
106
+ runCommand = defaultRunCommand,
107
+ now = () => new Date(),
108
+ } = {}) {
109
+ const provider = cleanText(env.BUILDCHAIN_COMPILER_CACHE_PROVIDER || "none").toLowerCase();
110
+ const required = cleanText(env.BUILDCHAIN_COMPILER_CACHE_REQUIRED).toLowerCase() === "true";
111
+ if (provider !== "sccache") {
112
+ if (required) {
113
+ throw new Error(`required compiler cache provider must be sccache, received ${provider || "empty"}`);
114
+ }
115
+ return undefined;
116
+ }
117
+
118
+ const outputPath = path.resolve(
119
+ cwd,
120
+ cleanText(env.BUILDCHAIN_COMPILER_CACHE_PREPARATION_PATH) ||
121
+ ".buildchain/diagnostics/compiler-cache-preparation.json",
122
+ );
123
+ const versionResult = runCommand("sccache", ["--version"], { cwd, env });
124
+ assertCommandSucceeded(versionResult, "sccache version probe");
125
+ const version = cleanText(versionResult.stdout).split(/\r?\n/, 1)[0];
126
+ if (!version) {
127
+ throw new Error("sccache version probe returned empty output");
128
+ }
129
+
130
+ const resetResult = runCommand("sccache", ["--zero-stats"], { cwd, env });
131
+ assertCommandSucceeded(resetResult, "sccache current-run stats reset");
132
+
133
+ const compilerCacheToolRoot = optionalDigest(
134
+ env.BUILDCHAIN_COMPILER_CACHE_TOOL_ROOT,
135
+ "BUILDCHAIN_COMPILER_CACHE_TOOL_ROOT",
136
+ );
137
+ const toolEvidence = readToolEvidence({
138
+ cwd,
139
+ env,
140
+ expectedRoot: compilerCacheToolRoot,
141
+ });
142
+ const bindings = Object.fromEntries(
143
+ [
144
+ ["sourceCommit", cleanText(env.BUILDCHAIN_SOURCE_SHA || env.GITHUB_SHA)],
145
+ ["sourceTree", cleanText(env.BUILDCHAIN_SOURCE_TREE_SHA)],
146
+ ["runtimeCommit", cleanText(env.BUILDCHAIN_RUNTIME_SHA)],
147
+ ["dependencyLockRoot", cleanText(env.BUILDCHAIN_DEPENDENCY_LOCK_ROOT)],
148
+ ["toolchainRoot", cleanText(env.BUILDCHAIN_TOOLCHAIN_ROOT)],
149
+ ["policyRoot", cleanText(env.BUILDCHAIN_CACHE_POLICY_ROOT)],
150
+ ["cacheProfileRoot", optionalDigest(env.SHIFU_CACHE_PROFILE_DIGEST, "SHIFU_CACHE_PROFILE_DIGEST")],
151
+ ["compilerCacheToolRoot", compilerCacheToolRoot],
152
+ ].filter(([, value]) => value),
153
+ );
154
+ if (!bindings.sourceCommit) {
155
+ throw new Error("BUILDCHAIN_SOURCE_SHA is required for compiler-cache preparation evidence");
156
+ }
157
+
158
+ const body = {
159
+ schemaVersion: 1,
160
+ contract: BUILDCHAIN_COMPILER_CACHE_PREPARATION_CONTRACT,
161
+ generatedAt: now().toISOString(),
162
+ provider,
163
+ required,
164
+ status: "prepared",
165
+ platform: {
166
+ id: cleanText(env.BUILDCHAIN_PLATFORM_ID) || "unknown",
167
+ os: cleanText(env.RUNNER_OS) || process.platform,
168
+ arch: cleanText(env.RUNNER_ARCH) || process.arch,
169
+ },
170
+ tool: {
171
+ command: "sccache",
172
+ version,
173
+ ...(bindings.compilerCacheToolRoot
174
+ ? { evidenceRoot: bindings.compilerCacheToolRoot }
175
+ : {}),
176
+ ...(toolEvidence ? { evidence: toolEvidence } : {}),
177
+ },
178
+ action: {
179
+ statsReset: true,
180
+ command: ["sccache", "--zero-stats"],
181
+ },
182
+ bindings,
183
+ };
184
+ const receipt = {
185
+ ...body,
186
+ root: digest(body),
187
+ };
188
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
189
+ fs.writeFileSync(outputPath, `${JSON.stringify(receipt, null, 2)}\n`);
190
+ appendGithubEnv(env.GITHUB_ENV, {
191
+ BUILDCHAIN_COMPILER_CACHE_ACTIVE_PROVIDER: provider,
192
+ BUILDCHAIN_COMPILER_CACHE_PREPARATION_PATH: outputPath,
193
+ BUILDCHAIN_COMPILER_CACHE_PREPARATION_ROOT: receipt.root,
194
+ });
195
+ return receipt;
196
+ }
197
+
198
+ function main() {
199
+ const command = process.argv[2] || "";
200
+ if (command !== "prepare") {
201
+ throw new Error("usage: node scripts/compiler-cache-evidence.mjs prepare");
202
+ }
203
+ const receipt = prepareCompilerCacheEvidence();
204
+ if (receipt) {
205
+ console.log(`compiler_cache_provider=${receipt.provider}`);
206
+ console.log(`compiler_cache_preparation_root=${receipt.root}`);
207
+ } else {
208
+ console.log("compiler_cache_provider=none");
209
+ }
210
+ }
211
+
212
+ const isMain = process.argv[1] &&
213
+ path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
214
+ if (isMain) {
215
+ try {
216
+ main();
217
+ } catch (error) {
218
+ console.error(error.message);
219
+ process.exitCode = 1;
220
+ }
221
+ }
@@ -12,6 +12,8 @@ const STATE_MARKER_START = "<!-- buildchain-dev-alpha-candidate-state";
12
12
  const STATE_MARKER_END = "-->";
13
13
  const LEGACY_BODY_MARKER = "Buildchain exact-source channel candidate.";
14
14
  const EXACT_SHA = /^[0-9a-f]{40}$/u;
15
+ const EVIDENCE_ROOT = /^sha256:[0-9a-f]{64}$/u;
16
+ const ABSENT_STATE_ROOT = "absent";
15
17
 
16
18
  function text(value = "") {
17
19
  return String(value ?? "").trim();
@@ -65,6 +67,25 @@ function pullRequestBodyPrefix(value) {
65
67
  return normalized;
66
68
  }
67
69
 
70
+ function optionalExactSha(value, name) {
71
+ const normalized = text(value);
72
+ if (normalized && !EXACT_SHA.test(normalized))
73
+ throw new Error(`${name} must be an exact 40-character commit SHA`);
74
+ return normalized;
75
+ }
76
+
77
+ function optionalStateRoot(value, name) {
78
+ const normalized = text(value);
79
+ if (
80
+ normalized &&
81
+ normalized !== ABSENT_STATE_ROOT &&
82
+ !EVIDENCE_ROOT.test(normalized)
83
+ ) {
84
+ throw new Error(`${name} must be absent or an exact sha256 root`);
85
+ }
86
+ return normalized;
87
+ }
88
+
68
89
  function canonical(value) {
69
90
  if (Array.isArray(value)) return value.map(canonical);
70
91
  if (value && typeof value === "object") {
@@ -225,6 +246,36 @@ export function normalizeDevAlphaPatrolOptions(options = {}) {
225
246
  options.pullRequestBodyPrefix ??
226
247
  process.env.BUILDCHAIN_CHANNEL_PATROL_PR_BODY_PREFIX,
227
248
  ),
249
+ expectedSelectedSha: optionalExactSha(
250
+ options.expectedSelectedSha ??
251
+ process.env.BUILDCHAIN_CHANNEL_PATROL_EXPECTED_SELECTED_SHA,
252
+ "expectedSelectedSha",
253
+ ),
254
+ expectedPriorStateRoot: optionalStateRoot(
255
+ options.expectedPriorStateRoot ??
256
+ process.env.BUILDCHAIN_CHANNEL_PATROL_EXPECTED_PRIOR_STATE_ROOT,
257
+ "expectedPriorStateRoot",
258
+ ),
259
+ reactivationAuthorized: bool(
260
+ options.reactivationAuthorized ??
261
+ process.env.BUILDCHAIN_CHANNEL_PATROL_REACTIVATION_AUTHORIZED,
262
+ false,
263
+ ),
264
+ transitionAuthority: {
265
+ actor: text(
266
+ options.transitionAuthority?.actor ?? process.env.GITHUB_ACTOR,
267
+ ),
268
+ workflow: text(
269
+ options.transitionAuthority?.workflow ?? process.env.GITHUB_WORKFLOW,
270
+ ),
271
+ runId: text(
272
+ options.transitionAuthority?.runId ?? process.env.GITHUB_RUN_ID,
273
+ ),
274
+ runAttempt: text(
275
+ options.transitionAuthority?.runAttempt ??
276
+ process.env.GITHUB_RUN_ATTEMPT,
277
+ ),
278
+ },
228
279
  createPullRequest,
229
280
  settlementAuthorized: bool(
230
281
  options.settlementAuthorized ??
@@ -384,6 +435,10 @@ function candidateFromDecision(decision) {
384
435
  sourceLockRef: decision.sourceLockRef,
385
436
  decisionRoot: decision.decisionRoot,
386
437
  workflowEvidence: decision.workflowEvidence,
438
+ qualificationRoot: evidenceRoot({
439
+ sourceSha: decision.source.sha,
440
+ workflowEvidence: decision.workflowEvidence,
441
+ }),
387
442
  };
388
443
  }
389
444
 
@@ -394,6 +449,9 @@ function candidateStateBody({
394
449
  activeCandidate,
395
450
  nextCandidate,
396
451
  supersededCandidate,
452
+ priorStateRoot = ABSENT_STATE_ROOT,
453
+ generation = 1,
454
+ tombstones = [],
397
455
  }) {
398
456
  const body = {
399
457
  schema: DEV_ALPHA_CANDIDATE_STATE_SCHEMA,
@@ -401,8 +459,11 @@ function candidateStateBody({
401
459
  sourceBranch: options.sourceBranch,
402
460
  targetBranch: options.targetBranch,
403
461
  targetSha,
462
+ generation,
463
+ priorStateRoot,
404
464
  activeCandidate,
405
465
  nextCandidate,
466
+ tombstones,
406
467
  observationDecisionRoot: decision.decisionRoot || null,
407
468
  observedAt: options.now,
408
469
  ...(supersededCandidate ? { supersededCandidate } : {}),
@@ -410,6 +471,50 @@ function candidateStateBody({
410
471
  return { ...body, stateRoot: evidenceRoot(body) };
411
472
  }
412
473
 
474
+ function persistedStateRoot(candidate) {
475
+ return text(candidate?.state?.stateRoot) || ABSENT_STATE_ROOT;
476
+ }
477
+
478
+ function candidateTombstone({
479
+ options,
480
+ candidate,
481
+ generation,
482
+ priorStateRoot,
483
+ reason,
484
+ pullRequest,
485
+ }) {
486
+ const body = {
487
+ schema: "kungfu-buildchain-dev-alpha-candidate-tombstone/v1",
488
+ repository: options.repository,
489
+ targetBranch: options.targetBranch,
490
+ candidateSha: candidate.sourceSha,
491
+ qualificationRoot:
492
+ candidate.qualificationRoot ||
493
+ evidenceRoot({
494
+ sourceSha: candidate.sourceSha,
495
+ workflowEvidence: candidate.workflowEvidence || [],
496
+ }),
497
+ generation,
498
+ priorStateRoot,
499
+ reason,
500
+ transitionAuthority: options.transitionAuthority,
501
+ pullRequest: pullRequest
502
+ ? {
503
+ number: Number(pullRequest.number),
504
+ url: text(pullRequest.url || pullRequest.html_url),
505
+ }
506
+ : null,
507
+ workflowRuns: (candidate.workflowEvidence || []).map((row) => ({
508
+ workflowPath: row.workflowPath,
509
+ runId: row.runId,
510
+ runAttempt: row.runAttempt,
511
+ url: row.url,
512
+ })),
513
+ recordedAt: options.now,
514
+ };
515
+ return { ...body, tombstoneRoot: evidenceRoot(body) };
516
+ }
517
+
413
518
  function pullRequestBody({
414
519
  options,
415
520
  observedSourceHeadSha,
@@ -526,6 +631,14 @@ export async function runDevAlphaCandidatePatrol(
526
631
  maxAgeSeconds: options.maxAgeSeconds,
527
632
  now: options.now,
528
633
  });
634
+ if (
635
+ options.expectedSelectedSha &&
636
+ decision.source.sha !== options.expectedSelectedSha
637
+ ) {
638
+ throw new Error(
639
+ `selected source changed between observation and settlement: expected ${options.expectedSelectedSha}, observed ${decision.source.sha}`,
640
+ );
641
+ }
529
642
  const openPullRequests = await client.listOpenPullRequests(
530
643
  options.targetBranch,
531
644
  );
@@ -553,9 +666,24 @@ export async function runDevAlphaCandidatePatrol(
553
666
  );
554
667
  }
555
668
  let activeCandidate = managedCandidates[0] || null;
669
+ const priorStateRoot = persistedStateRoot(activeCandidate);
670
+ if (
671
+ options.expectedPriorStateRoot &&
672
+ options.expectedPriorStateRoot !== priorStateRoot
673
+ ) {
674
+ throw new Error(
675
+ `candidate controller compare-and-swap failed: expected prior state root ${options.expectedPriorStateRoot}, observed ${priorStateRoot}`,
676
+ );
677
+ }
678
+ const priorGeneration = Number(activeCandidate?.state?.generation || 0);
679
+ let generation = Math.max(priorGeneration, 1);
680
+ const priorTombstones = Array.isArray(activeCandidate?.state?.tombstones)
681
+ ? activeCandidate.state.tombstones
682
+ : [];
556
683
  const observedCandidate = candidateFromDecision(decision);
557
684
  let nextCandidate = null;
558
685
  let supersededCandidate = null;
686
+ let tombstones = [...priorTombstones];
559
687
  let controllerState = decision.eligible
560
688
  ? "eligible-for-settlement"
561
689
  : qualificationError
@@ -565,43 +693,100 @@ export async function runDevAlphaCandidatePatrol(
565
693
  : "observed";
566
694
  if (activeCandidate) {
567
695
  controllerState = "active";
696
+ nextCandidate = activeCandidate.nextCandidate || null;
568
697
  if (
569
698
  observedCandidate &&
570
699
  observedCandidate.sourceSha !== activeCandidate.sourceSha
571
700
  ) {
572
- nextCandidate = observedCandidate;
573
- controllerState = "retained-next";
574
- }
575
- if (
576
- activeCandidate.nextCandidate &&
577
- activeCandidate.nextCandidate.sourceSha !== nextCandidate?.sourceSha
701
+ const matchingTombstone = priorTombstones.find(
702
+ (row) => row.candidateSha === observedCandidate.sourceSha,
703
+ );
704
+ if (matchingTombstone && !options.reactivationAuthorized) {
705
+ controllerState = "tombstone-blocked";
706
+ } else {
707
+ if (
708
+ nextCandidate &&
709
+ nextCandidate.sourceSha !== observedCandidate.sourceSha
710
+ ) {
711
+ supersededCandidate = nextCandidate;
712
+ generation = priorGeneration + 1;
713
+ tombstones.push(
714
+ candidateTombstone({
715
+ options,
716
+ candidate: supersededCandidate,
717
+ generation,
718
+ priorStateRoot,
719
+ reason: "newer-qualified-next-candidate",
720
+ pullRequest: activeCandidate,
721
+ }),
722
+ );
723
+ }
724
+ nextCandidate = observedCandidate;
725
+ controllerState = "retained-next";
726
+ }
727
+ } else if (
728
+ !decision.eligible &&
729
+ nextCandidate &&
730
+ decision.source.sha === nextCandidate.sourceSha
578
731
  ) {
579
- supersededCandidate = activeCandidate.nextCandidate;
732
+ supersededCandidate = nextCandidate;
733
+ nextCandidate = null;
734
+ generation = priorGeneration + 1;
735
+ tombstones.push(
736
+ candidateTombstone({
737
+ options,
738
+ candidate: supersededCandidate,
739
+ generation,
740
+ priorStateRoot,
741
+ reason: "qualification-evidence-rejected",
742
+ pullRequest: activeCandidate,
743
+ }),
744
+ );
745
+ controllerState = "rejected-next";
580
746
  }
581
747
  }
582
- let state = candidateStateBody({
583
- options,
584
- targetSha,
585
- decision,
586
- activeCandidate: activeCandidate
587
- ? {
588
- sourceSha: activeCandidate.sourceSha,
589
- sourceLockRef: activeCandidate.sourceLockRef,
590
- decisionRoot: activeCandidate.decisionRoot || null,
591
- pullRequestNumber: activeCandidate.number,
592
- pullRequestUrl: activeCandidate.url,
593
- }
594
- : null,
595
- nextCandidate,
596
- supersededCandidate,
597
- });
748
+ const persistedNextSha = text(activeCandidate?.nextCandidate?.sourceSha);
749
+ const desiredNextSha = text(nextCandidate?.sourceSha);
750
+ const transitionNeeded =
751
+ !activeCandidate?.state ||
752
+ persistedNextSha !== desiredNextSha ||
753
+ tombstones.length !== priorTombstones.length;
754
+ if (transitionNeeded && activeCandidate && generation === priorGeneration) {
755
+ generation = priorGeneration + 1;
756
+ }
757
+ let state =
758
+ activeCandidate?.state && !transitionNeeded
759
+ ? activeCandidate.state
760
+ : candidateStateBody({
761
+ options,
762
+ targetSha,
763
+ decision,
764
+ activeCandidate: activeCandidate
765
+ ? {
766
+ sourceSha: activeCandidate.sourceSha,
767
+ sourceLockRef: activeCandidate.sourceLockRef,
768
+ decisionRoot: activeCandidate.decisionRoot || null,
769
+ pullRequestNumber: activeCandidate.number,
770
+ pullRequestUrl: activeCandidate.url,
771
+ }
772
+ : null,
773
+ nextCandidate,
774
+ supersededCandidate,
775
+ priorStateRoot,
776
+ generation,
777
+ tombstones,
778
+ });
598
779
  let pullRequest;
599
780
  let settlementAction = "none";
600
781
  if (options.settlementAuthorized && !options.dryRun) {
601
782
  if (activeCandidate) {
602
783
  const nextBody = replaceCandidateStateMarker(activeCandidate.body, state);
603
784
  if (nextBody !== activeCandidate.body) {
604
- await client.updatePullRequestBody(activeCandidate.number, nextBody);
785
+ await client.updatePullRequestBody(
786
+ activeCandidate.number,
787
+ nextBody,
788
+ priorStateRoot,
789
+ );
605
790
  settlementAction = nextCandidate
606
791
  ? supersededCandidate
607
792
  ? "supersede-next-candidate"
@@ -636,15 +821,53 @@ export async function runDevAlphaCandidatePatrol(
636
821
  state,
637
822
  }),
638
823
  });
639
- settlementAction = "create-active-candidate";
640
- controllerState = "active";
641
- activeCandidate = {
642
- number: Number(pullRequest.number || 0),
643
- url: text(pullRequest.html_url),
644
- sourceSha,
645
- sourceLockRef: decision.sourceLockRef,
646
- decisionRoot: decision.decisionRoot,
647
- };
824
+ if (pullRequest.state && pullRequest.state !== "open") {
825
+ const tombstone = candidateTombstone({
826
+ options,
827
+ candidate: observedCandidate,
828
+ generation,
829
+ priorStateRoot,
830
+ reason: pullRequest.merged_at
831
+ ? "candidate-pr-merged"
832
+ : "candidate-pr-closed",
833
+ pullRequest,
834
+ });
835
+ tombstones = [...priorTombstones, tombstone];
836
+ state = candidateStateBody({
837
+ options,
838
+ targetSha,
839
+ decision,
840
+ activeCandidate: null,
841
+ nextCandidate: null,
842
+ supersededCandidate: null,
843
+ priorStateRoot,
844
+ generation,
845
+ tombstones,
846
+ });
847
+ const nextBody = replaceCandidateStateMarker(pullRequest.body, state);
848
+ await client.updatePullRequestBody(
849
+ pullRequest.number,
850
+ nextBody,
851
+ persistedStateRoot({
852
+ state: parseCandidateStateMarker(pullRequest.body),
853
+ }),
854
+ );
855
+ settlementAction = "reuse-known-candidate-tombstone";
856
+ controllerState = "tombstoned";
857
+ activeCandidate = null;
858
+ } else {
859
+ settlementAction = pullRequest.reused
860
+ ? "reuse-active-candidate"
861
+ : "create-active-candidate";
862
+ controllerState = "active";
863
+ activeCandidate = {
864
+ number: Number(pullRequest.number || 0),
865
+ url: text(pullRequest.html_url),
866
+ sourceSha,
867
+ sourceLockRef: decision.sourceLockRef,
868
+ decisionRoot: decision.decisionRoot,
869
+ };
870
+ }
648
871
  }
649
872
  }
650
873
  return {
@@ -661,6 +884,9 @@ export async function runDevAlphaCandidatePatrol(
661
884
  supersededCandidate,
662
885
  settlementAction,
663
886
  stateRoot: state.stateRoot,
887
+ priorStateRoot,
888
+ generation: state.generation,
889
+ tombstones: state.tombstones || [],
664
890
  },
665
891
  pullRequest: pullRequest || null,
666
892
  };
@@ -789,18 +1015,36 @@ export function createGitHubChannelCandidateClient({
789
1015
  });
790
1016
  },
791
1017
  async ensurePullRequest({ head, base, title, body }) {
792
- const open = await api(
793
- `/repos/${owner}/${repo}/pulls?state=open&head=${encodeURIComponent(`${owner}:${head}`)}&base=${encodeURIComponent(base)}&per_page=20`,
794
- );
795
- return (
796
- open[0] ||
797
- api(`/repos/${owner}/${repo}/pulls`, {
798
- method: "POST",
799
- body: { head, base, title, body },
800
- })
1018
+ const known = await api(
1019
+ `/repos/${owner}/${repo}/pulls?state=all&head=${encodeURIComponent(`${owner}:${head}`)}&base=${encodeURIComponent(base)}&per_page=20`,
801
1020
  );
1021
+ if (known.length > 1) {
1022
+ throw new Error(
1023
+ `multiple known candidate pull requests bind ${head}: ${known
1024
+ .map((row) => `#${row.number}`)
1025
+ .join(", ")}`,
1026
+ );
1027
+ }
1028
+ if (known[0]) return { ...known[0], reused: true };
1029
+ return api(`/repos/${owner}/${repo}/pulls`, {
1030
+ method: "POST",
1031
+ body: { head, base, title, body },
1032
+ });
802
1033
  },
803
- async updatePullRequestBody(number, body) {
1034
+ async updatePullRequestBody(
1035
+ number,
1036
+ body,
1037
+ expectedPriorStateRoot = ABSENT_STATE_ROOT,
1038
+ ) {
1039
+ const current = await api(`/repos/${owner}/${repo}/pulls/${number}`);
1040
+ const observedPriorStateRoot = persistedStateRoot({
1041
+ state: parseCandidateStateMarker(current.body),
1042
+ });
1043
+ if (observedPriorStateRoot !== expectedPriorStateRoot) {
1044
+ throw new Error(
1045
+ `candidate controller compare-and-swap failed: expected prior state root ${expectedPriorStateRoot}, observed ${observedPriorStateRoot}`,
1046
+ );
1047
+ }
804
1048
  return api(`/repos/${owner}/${repo}/pulls/${number}`, {
805
1049
  method: "PATCH",
806
1050
  body: { body },
@@ -846,6 +1090,7 @@ async function main() {
846
1090
  "active-candidate-pr": result.controller.activeCandidate?.url || "",
847
1091
  "next-candidate-sha": result.controller.nextCandidate?.sourceSha || "",
848
1092
  "settlement-action": result.controller.settlementAction,
1093
+ "prior-state-root": result.controller.priorStateRoot,
849
1094
  };
850
1095
  fs.appendFileSync(
851
1096
  process.env.GITHUB_OUTPUT,
@@ -17,6 +17,12 @@ function repository(value, label) {
17
17
  }
18
18
 
19
19
  const TRANSIENT_GITHUB_STATUSES = new Set([408, 429, 500, 502, 503, 504]);
20
+ const DEFAULT_ARTIFACT_SIGNING_AUTHORITY_REF = "authority/v3/v3.0/artifact-signing";
21
+
22
+ export function resolveAuthorityDispatchRef(value) {
23
+ const ref = required(value, "authority ref");
24
+ return /^[0-9a-f]{40}$/u.test(ref) ? DEFAULT_ARTIFACT_SIGNING_AUTHORITY_REF : ref;
25
+ }
20
26
 
21
27
  function retryDelayMs(attempt) {
22
28
  return Math.min(1_000 * (2 ** (attempt - 1)), 10_000);
@@ -98,7 +104,7 @@ export async function dispatchArtifactSigningAuthority({
98
104
  const authToken = required(token, "Buildchain authority dispatch token");
99
105
  const authorityRepo = repository(authorityRepository, "authority repository");
100
106
  const sourceRepo = repository(sourceRepository, "source repository");
101
- const ref = required(authorityRef, "authority ref");
107
+ const ref = resolveAuthorityDispatchRef(authorityRef);
102
108
  const runtime = required(runtimeSha, "Buildchain runtime SHA");
103
109
  if (!/^[0-9a-f]{40}$/u.test(runtime)) throw new Error("Buildchain runtime SHA must be exact");
104
110
  const correlationId = `${required(sourceRunId, "source run ID")}-${required(sourceRunAttempt, "source run attempt")}-${runtime.slice(0, 12)}-${required(requestArtifact, "request artifact").replace(/[^A-Za-z0-9._-]+/gu, "-")}`;