@kungfu-tech/buildchain 3.0.3-alpha.0 → 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 (107) 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 +19 -1
  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 +0 -45
  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 +161 -42
  19. package/dist/site/buildchain-site.json +175 -84
  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 +97 -5
  23. package/dist/site/kfd-claims.json +228 -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 +370 -55
  27. package/dist/site/page-registry.json +150 -59
  28. package/dist/site/public-surface-audit.json +226 -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 +570 -79
  33. package/docs/MAP.md +22 -4
  34. package/docs/auditable-demo.md +35 -12
  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 +19 -1
  38. package/docs/github-governance-authority.md +2 -2
  39. package/docs/lifecycle-protocol.md +20 -2
  40. package/docs/migration-inventory.md +24 -5
  41. package/docs/ownership.md +19 -1
  42. package/docs/publication-artifacts.md +158 -18
  43. package/docs/publication-authority.md +4 -4
  44. package/docs/publish-transaction.md +78 -31
  45. package/docs/release-candidate.md +24 -0
  46. package/docs/release-flow.md +50 -32
  47. package/docs/release-governance.md +31 -31
  48. package/docs/release-passport.md +4 -0
  49. package/docs/release-propagation.md +57 -12
  50. package/docs/reusable-build-surface.md +95 -27
  51. package/docs/runtime-train-validation.md +20 -2
  52. package/docs/stable-candidate-patrol.md +6 -6
  53. package/docs/toolkit-observability.md +22 -0
  54. package/docs/versioning.md +6 -4
  55. package/docs/web-surface-deployments.md +62 -0
  56. package/package.json +7 -5
  57. package/packages/core/README.md +60 -4
  58. package/packages/core/buildchain-contract.js +8 -4
  59. package/packages/core/cache-evidence.js +1 -0
  60. package/packages/core/diagnostics.js +105 -30
  61. package/packages/core/index.js +40 -0
  62. package/packages/core/paper.js +3226 -0
  63. package/packages/core/public-surface-audit.js +3 -0
  64. package/packages/core/publication-artifact.js +1 -6
  65. package/packages/core/publication-authority.js +1 -0
  66. package/packages/core/publication-reproducibility.js +947 -0
  67. package/packages/core/publication-sealed-bundle.js +190 -0
  68. package/packages/core/publish-transaction.js +147 -17
  69. package/packages/core/release-candidate.js +234 -0
  70. package/packages/core/release-passport.js +483 -201
  71. package/packages/core/release-propagation.js +175 -1
  72. package/scripts/auditable-demo.mjs +119 -5
  73. package/scripts/aws-codebuild-toolchain.mjs +285 -0
  74. package/scripts/aws-macos-jit-core.mjs +378 -0
  75. package/scripts/aws-macos-jit.mjs +123 -0
  76. package/scripts/aws-runner-burst-core.mjs +248 -0
  77. package/scripts/aws-runner-burst.mjs +79 -0
  78. package/scripts/aws-windows-jit-core.mjs +374 -0
  79. package/scripts/aws-windows-jit.mjs +121 -0
  80. package/scripts/build-contract-core.mjs +69 -6
  81. package/scripts/build-standalone-binary.mjs +23 -0
  82. package/scripts/buildchain-contract-lock.mjs +1 -1
  83. package/scripts/check-internal-architecture.mjs +69 -2
  84. package/scripts/check-inventory.mjs +69 -2
  85. package/scripts/check-javascript-syntax.mjs +31 -0
  86. package/scripts/check-maintainability.mjs +371 -0
  87. package/scripts/compiler-cache-evidence.mjs +221 -0
  88. package/scripts/dev-alpha-candidate-patrol.mjs +268 -43
  89. package/scripts/finalize-native-artifact-signing-result.mjs +75 -4
  90. package/scripts/generate-channel-promotion-workflow.mjs +3 -12
  91. package/scripts/generate-release-candidate-passport.mjs +4 -0
  92. package/scripts/generate-site-bundle.mjs +84 -18
  93. package/scripts/import-artifact-signing-results.mjs +64 -5
  94. package/scripts/inspect-artifact-signing-requests.mjs +7 -0
  95. package/scripts/maintainability-metrics.mjs +339 -0
  96. package/scripts/npm-publish-transaction.mjs +57 -6
  97. package/scripts/paper.mjs +645 -0
  98. package/scripts/promotion-channel-router.mjs +16 -4
  99. package/scripts/publication-reproducibility.mjs +62 -0
  100. package/scripts/release-propagation.mjs +39 -0
  101. package/scripts/resolve-build-contract.mjs +6 -0
  102. package/scripts/route-offline-runners.mjs +236 -0
  103. package/scripts/run-lifecycle-core.mjs +62 -1
  104. package/scripts/runtime-ref-core.mjs +23 -2
  105. package/scripts/seal-artifact-signing-requests.mjs +13 -6
  106. package/scripts/stable-candidate-qualification.mjs +42 -0
  107. package/scripts/web-surface-core.mjs +272 -86
@@ -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();
@@ -72,6 +74,18 @@ function optionalExactSha(value, name) {
72
74
  return normalized;
73
75
  }
74
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
+
75
89
  function canonical(value) {
76
90
  if (Array.isArray(value)) return value.map(canonical);
77
91
  if (value && typeof value === "object") {
@@ -237,6 +251,31 @@ export function normalizeDevAlphaPatrolOptions(options = {}) {
237
251
  process.env.BUILDCHAIN_CHANNEL_PATROL_EXPECTED_SELECTED_SHA,
238
252
  "expectedSelectedSha",
239
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
+ },
240
279
  createPullRequest,
241
280
  settlementAuthorized: bool(
242
281
  options.settlementAuthorized ??
@@ -396,6 +435,10 @@ function candidateFromDecision(decision) {
396
435
  sourceLockRef: decision.sourceLockRef,
397
436
  decisionRoot: decision.decisionRoot,
398
437
  workflowEvidence: decision.workflowEvidence,
438
+ qualificationRoot: evidenceRoot({
439
+ sourceSha: decision.source.sha,
440
+ workflowEvidence: decision.workflowEvidence,
441
+ }),
399
442
  };
400
443
  }
401
444
 
@@ -406,6 +449,9 @@ function candidateStateBody({
406
449
  activeCandidate,
407
450
  nextCandidate,
408
451
  supersededCandidate,
452
+ priorStateRoot = ABSENT_STATE_ROOT,
453
+ generation = 1,
454
+ tombstones = [],
409
455
  }) {
410
456
  const body = {
411
457
  schema: DEV_ALPHA_CANDIDATE_STATE_SCHEMA,
@@ -413,8 +459,11 @@ function candidateStateBody({
413
459
  sourceBranch: options.sourceBranch,
414
460
  targetBranch: options.targetBranch,
415
461
  targetSha,
462
+ generation,
463
+ priorStateRoot,
416
464
  activeCandidate,
417
465
  nextCandidate,
466
+ tombstones,
418
467
  observationDecisionRoot: decision.decisionRoot || null,
419
468
  observedAt: options.now,
420
469
  ...(supersededCandidate ? { supersededCandidate } : {}),
@@ -422,6 +471,50 @@ function candidateStateBody({
422
471
  return { ...body, stateRoot: evidenceRoot(body) };
423
472
  }
424
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
+
425
518
  function pullRequestBody({
426
519
  options,
427
520
  observedSourceHeadSha,
@@ -573,9 +666,24 @@ export async function runDevAlphaCandidatePatrol(
573
666
  );
574
667
  }
575
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
+ : [];
576
683
  const observedCandidate = candidateFromDecision(decision);
577
684
  let nextCandidate = null;
578
685
  let supersededCandidate = null;
686
+ let tombstones = [...priorTombstones];
579
687
  let controllerState = decision.eligible
580
688
  ? "eligible-for-settlement"
581
689
  : qualificationError
@@ -585,43 +693,100 @@ export async function runDevAlphaCandidatePatrol(
585
693
  : "observed";
586
694
  if (activeCandidate) {
587
695
  controllerState = "active";
696
+ nextCandidate = activeCandidate.nextCandidate || null;
588
697
  if (
589
698
  observedCandidate &&
590
699
  observedCandidate.sourceSha !== activeCandidate.sourceSha
591
700
  ) {
592
- nextCandidate = observedCandidate;
593
- controllerState = "retained-next";
594
- }
595
- if (
596
- activeCandidate.nextCandidate &&
597
- 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
598
731
  ) {
599
- 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";
600
746
  }
601
747
  }
602
- let state = candidateStateBody({
603
- options,
604
- targetSha,
605
- decision,
606
- activeCandidate: activeCandidate
607
- ? {
608
- sourceSha: activeCandidate.sourceSha,
609
- sourceLockRef: activeCandidate.sourceLockRef,
610
- decisionRoot: activeCandidate.decisionRoot || null,
611
- pullRequestNumber: activeCandidate.number,
612
- pullRequestUrl: activeCandidate.url,
613
- }
614
- : null,
615
- nextCandidate,
616
- supersededCandidate,
617
- });
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
+ });
618
779
  let pullRequest;
619
780
  let settlementAction = "none";
620
781
  if (options.settlementAuthorized && !options.dryRun) {
621
782
  if (activeCandidate) {
622
783
  const nextBody = replaceCandidateStateMarker(activeCandidate.body, state);
623
784
  if (nextBody !== activeCandidate.body) {
624
- await client.updatePullRequestBody(activeCandidate.number, nextBody);
785
+ await client.updatePullRequestBody(
786
+ activeCandidate.number,
787
+ nextBody,
788
+ priorStateRoot,
789
+ );
625
790
  settlementAction = nextCandidate
626
791
  ? supersededCandidate
627
792
  ? "supersede-next-candidate"
@@ -656,15 +821,53 @@ export async function runDevAlphaCandidatePatrol(
656
821
  state,
657
822
  }),
658
823
  });
659
- settlementAction = "create-active-candidate";
660
- controllerState = "active";
661
- activeCandidate = {
662
- number: Number(pullRequest.number || 0),
663
- url: text(pullRequest.html_url),
664
- sourceSha,
665
- sourceLockRef: decision.sourceLockRef,
666
- decisionRoot: decision.decisionRoot,
667
- };
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
+ }
668
871
  }
669
872
  }
670
873
  return {
@@ -681,6 +884,9 @@ export async function runDevAlphaCandidatePatrol(
681
884
  supersededCandidate,
682
885
  settlementAction,
683
886
  stateRoot: state.stateRoot,
887
+ priorStateRoot,
888
+ generation: state.generation,
889
+ tombstones: state.tombstones || [],
684
890
  },
685
891
  pullRequest: pullRequest || null,
686
892
  };
@@ -809,18 +1015,36 @@ export function createGitHubChannelCandidateClient({
809
1015
  });
810
1016
  },
811
1017
  async ensurePullRequest({ head, base, title, body }) {
812
- const open = await api(
813
- `/repos/${owner}/${repo}/pulls?state=open&head=${encodeURIComponent(`${owner}:${head}`)}&base=${encodeURIComponent(base)}&per_page=20`,
814
- );
815
- return (
816
- open[0] ||
817
- api(`/repos/${owner}/${repo}/pulls`, {
818
- method: "POST",
819
- body: { head, base, title, body },
820
- })
1018
+ const known = await api(
1019
+ `/repos/${owner}/${repo}/pulls?state=all&head=${encodeURIComponent(`${owner}:${head}`)}&base=${encodeURIComponent(base)}&per_page=20`,
821
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
+ });
822
1033
  },
823
- 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
+ }
824
1048
  return api(`/repos/${owner}/${repo}/pulls/${number}`, {
825
1049
  method: "PATCH",
826
1050
  body: { body },
@@ -866,6 +1090,7 @@ async function main() {
866
1090
  "active-candidate-pr": result.controller.activeCandidate?.url || "",
867
1091
  "next-candidate-sha": result.controller.nextCandidate?.sourceSha || "",
868
1092
  "settlement-action": result.controller.settlementAction,
1093
+ "prior-state-root": result.controller.priorStateRoot,
869
1094
  };
870
1095
  fs.appendFileSync(
871
1096
  process.env.GITHUB_OUTPUT,