@kungfu-tech/buildchain 3.0.6-alpha.1 → 3.0.6-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.
Files changed (68) hide show
  1. package/README.md +4 -4
  2. package/actions/promote-buildchain-ref/README.md +8 -0
  3. package/bin/buildchain.mjs +13 -1
  4. package/contracts/auditable-demo-scenario-v1.schema.json +52 -0
  5. package/dist/site/buildchain-contract.json +47 -27
  6. package/dist/site/buildchain-site.json +158 -42
  7. package/dist/site/capability-registry.json +3 -3
  8. package/dist/site/cli-registry.json +40 -4
  9. package/dist/site/controller-registry.json +20 -4
  10. package/dist/site/kfd-claims.json +139 -19
  11. package/dist/site/kfd-upstream-aggregate.json +1 -1
  12. package/dist/site/manual-registry.json +9 -9
  13. package/dist/site/node-api-registry.json +1161 -180
  14. package/dist/site/page-registry.json +145 -29
  15. package/dist/site/public-surface-audit.json +385 -19
  16. package/dist/site/publication-authority-registry.json +61 -1
  17. package/dist/site/publication-registry.json +4 -4
  18. package/dist/site/release-provenance.json +1 -0
  19. package/dist/site/site-manifest.json +13 -13
  20. package/dist/site/workflow-registry.json +150 -13
  21. package/docs/MAP.md +1 -0
  22. package/docs/auditable-demo.md +58 -11
  23. package/docs/aws-us-elastic-runner-burst-plane.md +23 -19
  24. package/docs/cli-reference.md +154 -0
  25. package/docs/dev-alpha-candidate-patrol.md +13 -5
  26. package/docs/dev-delivery-warrant.md +158 -0
  27. package/docs/node-api-reference.md +54 -15
  28. package/docs/publication-authority.md +11 -0
  29. package/docs/release-candidate.md +19 -2
  30. package/docs/release-governance.md +52 -0
  31. package/docs/reusable-build-surface.md +11 -1
  32. package/docs/shifu-gate-profiles.md +12 -1
  33. package/docs/versioning.md +2 -0
  34. package/package.json +2 -1
  35. package/packages/core/buildchain-publication-authority.js +3 -1
  36. package/packages/core/channel-candidate.js +2 -21
  37. package/packages/core/channel-promotion-baseline.js +199 -0
  38. package/packages/core/dev-delivery-candidate-identity.js +94 -0
  39. package/packages/core/dev-delivery-common.js +73 -0
  40. package/packages/core/dev-delivery-proof.js +252 -0
  41. package/packages/core/dev-delivery-warrant-cancellation.js +94 -0
  42. package/packages/core/dev-delivery-warrant-settlement.js +73 -0
  43. package/packages/core/dev-delivery-warrant.js +591 -0
  44. package/scripts/auditable-demo-bundle-verification.mjs +148 -0
  45. package/scripts/auditable-demo-platform.mjs +86 -50
  46. package/scripts/auditable-demo-presentation.mjs +83 -0
  47. package/scripts/auditable-demo-renditions.mjs +264 -0
  48. package/scripts/auditable-demo.mjs +24 -30
  49. package/scripts/build-contract-core.mjs +58 -3
  50. package/scripts/buildchain-cli-help.mjs +8 -0
  51. package/scripts/buildchain-patrol.mjs +9 -0
  52. package/scripts/check-inventory.mjs +1 -0
  53. package/scripts/dev-alpha-candidate-patrol.mjs +45 -48
  54. package/scripts/dev-delivery-proof.mjs +193 -0
  55. package/scripts/dev-delivery-warrant.mjs +426 -0
  56. package/scripts/dev-pr-auto-merge.mjs +488 -46
  57. package/scripts/dev-pr-delivery-warrant.mjs +209 -0
  58. package/scripts/gate-profile-core.mjs +24 -0
  59. package/scripts/generate-site-bundle.mjs +2 -2
  60. package/scripts/git-fetch-process-tree.mjs +142 -0
  61. package/scripts/lifecycle-substage-evidence.mjs +274 -0
  62. package/scripts/locked-source-checkout.mjs +6 -3
  63. package/scripts/resolve-artifact-transfer-mode.mjs +9 -0
  64. package/scripts/resolve-build-contract.mjs +7 -0
  65. package/scripts/route-offline-runners.mjs +1 -0
  66. package/scripts/run-lifecycle-core.mjs +9 -9
  67. package/scripts/shifu-gate-profile.mjs +10 -16
  68. package/scripts/site-capability-metadata.mjs +14 -0
@@ -1,15 +1,23 @@
1
1
  #!/usr/bin/env node
2
+ import crypto from "node:crypto";
3
+ import { spawnSync } from "node:child_process";
2
4
  import fs from "node:fs";
3
5
  import path from "node:path";
4
-
6
+ import { verifyProjectCutReplayProof } from "../packages/core/dev-delivery-warrant.js";
7
+ import { admitExistingQueueEntry, createDevPrAdmissionReceipt, readDeliveryWarrantResult, runSourceQualification, runTargetedQueueAdmission } from "./dev-pr-delivery-warrant.mjs";
5
8
  const DEFAULT_BLOCK_LABELS = ["blocked", "do-not-merge", "work-in-progress"];
6
9
  const DEFAULT_ALLOWED_HEAD_PREFIXES = ["feature/", "fix/", "chore/", "docs/", "ci/", "refactor/"];
7
10
  const DEFAULT_REQUIRED_CHECKS = ["check"];
11
+ const DEFAULT_READY_LABEL = "ready";
8
12
  const SUCCESS_STATES = new Set(["success"]);
9
13
  const SUCCESS_CONCLUSIONS = new Set(["success", "neutral", "skipped"]);
10
14
  const VALID_LANDING_MODES = new Set(["auto", "direct", "queue"]);
15
+ const VALID_WARRANT_MODES = new Set(["off", "required"]);
11
16
  const STATIC_SKIP_REASONS = new Set(["draft", "fork-or-cross-repository-head", "head-prefix-not-allowed", "missing-ready-label", "blocked-label"]);
12
17
  const ADMISSION_CONTRACT = "kungfu-buildchain-dev-merge-queue-admission";
18
+ const AGENT_ADMISSION_RESULT_SCHEMA = "kungfu.buildchain.dev-pr-admission-result/v1";
19
+ const AGENT_ADMISSION_MARKER = "buildchain-dev-pr-admission:v1";
20
+ const SHA_PATTERN = /^[0-9a-f]{40}$/;
13
21
 
14
22
  function splitList(value, fallback = []) {
15
23
  if (Array.isArray(value)) return value.map((entry) => String(entry).trim()).filter(Boolean);
@@ -33,6 +41,11 @@ function intOption(value, fallback) {
33
41
  return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : fallback;
34
42
  }
35
43
 
44
+ function positiveIntOption(value, fallback = 0) {
45
+ const parsed = Number(value);
46
+ return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
47
+ }
48
+
36
49
  function choiceOption(value, valid, fallback, field) {
37
50
  const normalized = String(value || fallback).trim().toLowerCase();
38
51
  if (!valid.has(normalized)) {
@@ -52,7 +65,7 @@ function normalizeOptions(options = {}) {
52
65
  return {
53
66
  repository: normalizeRepo(options.repository || process.env.GITHUB_REPOSITORY),
54
67
  targetBranch: String(options.targetBranch || "").replace(/^refs\/heads\//, ""),
55
- readyLabel: String(options.readyLabel ?? "ready").trim(),
68
+ readyLabel: String(options.readyLabel || DEFAULT_READY_LABEL).trim(),
56
69
  blockLabels: splitList(options.blockLabels, DEFAULT_BLOCK_LABELS).map((label) => label.toLowerCase()),
57
70
  allowedHeadPrefixes: splitList(options.allowedHeadPrefixes, DEFAULT_ALLOWED_HEAD_PREFIXES),
58
71
  requiredChecks: splitList(options.requiredChecks, DEFAULT_REQUIRED_CHECKS),
@@ -66,9 +79,34 @@ function normalizeOptions(options = {}) {
66
79
  pollMergeableAttempts: intOption(options.pollMergeableAttempts, 3),
67
80
  pollMergeableDelayMs: intOption(options.pollMergeableDelayMs, 1000),
68
81
  outputPath: String(options.outputPath || ".buildchain/dev-pr-auto-merge/result.json"),
82
+ targetPullRequestNumber: positiveIntOption(options.targetPullRequestNumber, 0),
83
+ expectedHeadSha: String(options.expectedHeadSha || "").trim().toLowerCase(),
84
+ diagnosticContext: String(options.diagnosticContext || "Buildchain delivery intent").trim(),
85
+ warrantMode: choiceOption(options.warrantMode, VALID_WARRANT_MODES, "off", "delivery Warrant mode"),
86
+ warrantResultPath: String(options.warrantResultPath || "").trim(),
87
+ projectCutProofPath: String(options.projectCutProofPath || "").trim(),
88
+ sourcePatchRoot: String(options.sourcePatchRoot || "").trim().toLowerCase(),
89
+ qualificationOnly: boolOption(options.qualificationOnly, false),
69
90
  };
70
91
  }
71
92
 
93
+ function stableValue(value) {
94
+ if (Array.isArray(value)) return value.map(stableValue);
95
+ if (value && typeof value === "object") {
96
+ return Object.fromEntries(
97
+ Object.entries(value)
98
+ .filter(([, entry]) => entry !== undefined)
99
+ .sort(([left], [right]) => left.localeCompare(right))
100
+ .map(([key, entry]) => [key, stableValue(entry)]),
101
+ );
102
+ }
103
+ return value;
104
+ }
105
+
106
+ function contentRoot(value) {
107
+ return `sha256:${crypto.createHash("sha256").update(`${JSON.stringify(stableValue(value))}\n`).digest("hex")}`;
108
+ }
109
+
72
110
  function labelsOf(pr) {
73
111
  return (pr.labels || []).map((label) => String(label.name || label).toLowerCase());
74
112
  }
@@ -147,10 +185,32 @@ function summarizeChecks({ statuses = [], checkRuns = [] } = {}, requiredChecks
147
185
  };
148
186
  }
149
187
 
150
- function mergeableAccepted(pr, landingMode = "direct") {
188
+ function mergeableAccepted(pr, landingMode = "direct", projectCutQualified = false) {
151
189
  if (pr.mergeable === false) return false;
152
190
  const state = String(pr.mergeable_state || pr.mergeStateStatus || "").toLowerCase();
153
- return state ? ["clean", "has_hooks", "unstable", "unknown", ...(landingMode === "queue" && pr.mergeable === true ? ["blocked"] : [])].includes(state) : pr.mergeable === true;
191
+ return state ? ["clean", "has_hooks", "unstable", "unknown", ...(landingMode === "queue" && pr.mergeable === true ? ["blocked", ...(projectCutQualified ? ["behind"] : [])] : [])].includes(state) : pr.mergeable === true;
192
+ }
193
+
194
+ async function projectCutQualification(pr, options, client) {
195
+ if (!options.projectCutProofPath) return { ok: false, reason: "project-cut-proof-required" };
196
+ let proof;
197
+ try {
198
+ proof = JSON.parse(fs.readFileSync(options.projectCutProofPath, "utf8"));
199
+ } catch {
200
+ return { ok: false, reason: "project-cut-proof-invalid" };
201
+ }
202
+ const currentBase = await client.getBranchSha(options.targetBranch).catch(() => "");
203
+ const verification = verifyProjectCutReplayProof(proof, {
204
+ repository: options.repository.fullName,
205
+ protectedBase: options.targetBranch,
206
+ pullRequestNumber: Number(pr.number),
207
+ sourceHead: String(pr.head?.sha || "").toLowerCase(),
208
+ ...(options.sourcePatchRoot ? { sourcePatchRoot: options.sourcePatchRoot } : {}),
209
+ currentBase,
210
+ });
211
+ return verification.ok
212
+ ? { ok: true, reason: verification.reason, proofRoot: verification.proofRoot, currentBase }
213
+ : { ok: false, reason: `project-cut-${verification.reason}`, currentBase };
154
214
  }
155
215
  async function setQueueAdmissionStatus(client, repository, sha, context, state) {
156
216
  if (!context) return null;
@@ -188,7 +248,12 @@ export async function evaluatePullRequest(pr, options, client) {
188
248
  observedBaseRef: detailed.base.ref,
189
249
  });
190
250
  }
191
- if (!mergeableAccepted(detailed, options.landingMode)) {
251
+ const mergeableState = String(detailed.mergeable_state || detailed.mergeStateStatus || "").toLowerCase();
252
+ const projectCut = mergeableState === "behind" && options.landingMode === "queue"
253
+ ? await projectCutQualification(detailed, options, client)
254
+ : null;
255
+ if (projectCut && !projectCut.ok) return skip(projectCut.reason, { projectCut });
256
+ if (!mergeableAccepted(detailed, options.landingMode, projectCut?.ok === true)) {
192
257
  return skip("not-mergeable", {
193
258
  mergeable: detailed.mergeable,
194
259
  mergeableState: detailed.mergeable_state || detailed.mergeStateStatus || "",
@@ -212,6 +277,7 @@ export async function evaluatePullRequest(pr, options, client) {
212
277
  reason: options.dryRun ? "dry-run" : "eligible",
213
278
  checks: checkSummary,
214
279
  approval,
280
+ projectCut,
215
281
  pullRequestId: detailed.node_id || pr.node_id || "",
216
282
  observedHeadSha,
217
283
  };
@@ -418,6 +484,124 @@ export class GitHubClient {
418
484
  headSha: entry.headCommit?.oid || "",
419
485
  };
420
486
  }
487
+
488
+ async addLabels(number, labels) {
489
+ const { data } = await this.request(
490
+ "POST",
491
+ `/repos/${this.repository.owner}/${this.repository.repo}/issues/${number}/labels`,
492
+ { body: { labels } },
493
+ );
494
+ return data;
495
+ }
496
+
497
+ async listIssueComments(number) {
498
+ return this.paginate(`/repos/${this.repository.owner}/${this.repository.repo}/issues/${number}/comments?per_page=100`);
499
+ }
500
+
501
+ async createIssueComment(number, body) {
502
+ const { data } = await this.request(
503
+ "POST",
504
+ `/repos/${this.repository.owner}/${this.repository.repo}/issues/${number}/comments`,
505
+ { body: { body } },
506
+ );
507
+ return data;
508
+ }
509
+
510
+ async updateIssueComment(commentId, body) {
511
+ const { data } = await this.request(
512
+ "PATCH",
513
+ `/repos/${this.repository.owner}/${this.repository.repo}/issues/comments/${commentId}`,
514
+ { body: { body } },
515
+ );
516
+ return data;
517
+ }
518
+
519
+ async setCommitStatus(sha, { state, context, description, targetUrl = "" }) {
520
+ const { data } = await this.request(
521
+ "POST",
522
+ `/repos/${this.repository.owner}/${this.repository.repo}/statuses/${sha}`,
523
+ { body: { state, context, description, target_url: targetUrl } },
524
+ );
525
+ return data;
526
+ }
527
+ }
528
+
529
+ function ghJson(args, { input } = {}) {
530
+ const ghEnvironment = { ...process.env };
531
+ delete ghEnvironment.GITHUB_TOKEN;
532
+ delete ghEnvironment.GH_TOKEN;
533
+ const result = spawnSync("gh", args, { encoding: "utf8", input, env: ghEnvironment });
534
+ if (result.error) throw result.error;
535
+ if (result.status !== 0) {
536
+ const error = new Error((result.stderr || result.stdout || "gh command failed").trim());
537
+ error.status = result.status;
538
+ throw error;
539
+ }
540
+ return result.stdout.trim() ? JSON.parse(result.stdout) : null;
541
+ }
542
+
543
+ export class GhCliClient extends GitHubClient {
544
+ constructor({ repository } = {}) {
545
+ super({ token: "gh-cli", repository, fetchImpl: async () => { throw new Error("unexpected fetch"); } });
546
+ }
547
+
548
+ async request(method, requestPath, { body } = {}) {
549
+ const endpoint = requestPath.replace(/^https:\/\/api\.github\.com/, "");
550
+ const args = ["api", "--method", method, endpoint];
551
+ if (body !== undefined) args.push("--input", "-");
552
+ return {
553
+ data: ghJson(args, { input: body === undefined ? undefined : `${JSON.stringify(body)}\n` }),
554
+ response: { headers: { get: () => "" } },
555
+ };
556
+ }
557
+
558
+ async paginate(requestPath) {
559
+ return ghJson(["api", "--paginate", requestPath, "--slurp"]).flatMap((page) => page);
560
+ }
561
+
562
+ async getMergeQueueState(branch) {
563
+ const query = `query($owner:String!,$repo:String!,$branch:String!){repository(owner:$owner,name:$repo){mergeQueue(branch:$branch){id entries(first:100){nodes{id position state baseCommit{oid} headCommit{oid} pullRequest{number headRefOid}}}}}}`;
564
+ const data = ghJson([
565
+ "api", "graphql", "-f", `query=${query}`,
566
+ "-f", `owner=${this.repository.owner}`,
567
+ "-f", `repo=${this.repository.repo}`,
568
+ "-f", `branch=${branch}`,
569
+ ]).data;
570
+ const queue = data?.repository?.mergeQueue || null;
571
+ return {
572
+ enabled: Boolean(queue),
573
+ id: queue?.id || "",
574
+ entries: (queue?.entries?.nodes || []).map((entry) => ({
575
+ id: entry.id || "",
576
+ position: entry.position,
577
+ state: entry.state || "",
578
+ pullRequestNumber: entry.pullRequest?.number || null,
579
+ pullRequestHeadSha: entry.pullRequest?.headRefOid || "",
580
+ baseSha: entry.baseCommit?.oid || "",
581
+ headSha: entry.headCommit?.oid || "",
582
+ })),
583
+ };
584
+ }
585
+
586
+ async enqueuePullRequest({ pullRequestId, expectedHeadOid }) {
587
+ const query = `mutation($id:ID!,$head:GitObjectID!){enqueuePullRequest(input:{pullRequestId:$id,expectedHeadOid:$head}){mergeQueueEntry{id position state baseCommit{oid} headCommit{oid} pullRequest{number headRefOid}}}}`;
588
+ const data = ghJson([
589
+ "api", "graphql", "-f", `query=${query}`,
590
+ "-f", `id=${pullRequestId}`,
591
+ "-f", `head=${expectedHeadOid}`,
592
+ ]).data;
593
+ const entry = data?.enqueuePullRequest?.mergeQueueEntry;
594
+ if (!entry?.id) throw new Error("GitHub did not return a merge queue entry");
595
+ return {
596
+ id: entry.id,
597
+ position: entry.position,
598
+ state: entry.state || "",
599
+ pullRequestNumber: entry.pullRequest?.number || null,
600
+ pullRequestHeadSha: entry.pullRequest?.headRefOid || "",
601
+ baseSha: entry.baseCommit?.oid || "",
602
+ headSha: entry.headCommit?.oid || "",
603
+ };
604
+ }
421
605
  }
422
606
 
423
607
  function queuePredecessor(queueState, fallback = {}) {
@@ -450,6 +634,7 @@ function admissionReceipt({
450
634
  checks,
451
635
  approval,
452
636
  predecessor,
637
+ projectCut,
453
638
  } = {}) {
454
639
  return {
455
640
  schemaVersion: 1,
@@ -467,6 +652,7 @@ function admissionReceipt({
467
652
  decision,
468
653
  reason,
469
654
  predecessor: predecessor || null,
655
+ projectCut: projectCut || null,
470
656
  finalSafetyBoundary: "github-merge-group",
471
657
  };
472
658
  }
@@ -505,6 +691,228 @@ function blockRemainingPullRequests(result, pullRequests, startIndex, options, e
505
691
  }
506
692
  }
507
693
 
694
+ function admissionStateFor(entry = {}) {
695
+ if (["enqueued", "merged"].includes(entry.action)) return "queued";
696
+ if (["would-enqueue", "would-merge", "merge"].includes(entry.action)) return "ready";
697
+ if (entry.reason === "missing-approval") return "waiting-approval";
698
+ if (entry.reason === "required-checks-not-passing") return "waiting-checks";
699
+ if (entry.reason === "blocked-by-predecessor") return "waiting-queue";
700
+ if (["head-sha-drift", "base-branch-drift", "base-sha-drift"].includes(entry.reason)) return "stale";
701
+ if (["blocked-label", "fork-or-cross-repository-head"].includes(entry.reason)) return "blocked";
702
+ return "rejected";
703
+ }
704
+
705
+ function nextAdmissionAction({ options, state, reason, observedHeadSha = "" }) {
706
+ const command = [
707
+ "buildchain dev pr-admit",
708
+ `--repository ${options.repository.fullName}`,
709
+ `--branch ${options.targetBranch}`,
710
+ `--pull-request ${options.targetPullRequestNumber}`,
711
+ `--expected-head ${observedHeadSha || options.expectedHeadSha}`,
712
+ "--execute",
713
+ ].join(" ");
714
+ if (state === "queued") return `Monitor PR #${options.targetPullRequestNumber} and its native merge-group checks.`;
715
+ if (state === "waiting-approval") return `Obtain an independent approval, then rerun: ${command}`;
716
+ if (state === "waiting-checks") return `Wait for or repair required checks, then rerun: ${command}`;
717
+ if (state === "waiting-queue") return `Wait for the active queue predecessor to finish, then rerun: ${command}`;
718
+ if (state === "stale") return `Re-read the PR head and rerun with that exact SHA: ${command}`;
719
+ if (reason === "missing-ready-label") return `Declare the exact delivery intent by running: ${command}`;
720
+ if (state === "ready") return options.dryRun ? `Apply the reviewed plan: ${command}` : "Continue with native merge-group qualification.";
721
+ return `Inspect reason ${reason || "unknown"}, repair it, and rerun the exact-head command.`;
722
+ }
723
+
724
+ function diagnosticState(state) {
725
+ if (["ready", "queued"].includes(state)) return "success";
726
+ if (["waiting-approval", "waiting-checks", "waiting-queue"].includes(state)) return "pending";
727
+ return "failure";
728
+ }
729
+
730
+ function renderAdmissionComment(receipt, receiptRoot) {
731
+ const marker = `<!-- ${AGENT_ADMISSION_MARKER} pr=${receipt.pullRequestNumber} head=${receipt.expectedHeadSha} -->`;
732
+ return [
733
+ marker,
734
+ "## Buildchain dev PR admission",
735
+ "",
736
+ `- State: \`${receipt.state}\``,
737
+ `- Reason: \`${receipt.reason}\``,
738
+ `- Exact head: \`${receipt.expectedHeadSha}\``,
739
+ `- Readiness: \`${receipt.readiness.observed ? "present" : "missing"}\` (\`${receipt.readiness.label || "policy-equivalent"}\`)`,
740
+ `- Receipt root: \`${receiptRoot}\``,
741
+ `- Next action: ${receipt.nextAction}`,
742
+ "",
743
+ "GitHub auto-merge state is observed evidence only; it is not Buildchain admission authority.",
744
+ ].join("\n");
745
+ }
746
+
747
+ async function publishAdmissionDiagnostic(client, options, receipt, receiptRoot) {
748
+ const marker = `<!-- ${AGENT_ADMISSION_MARKER} pr=${receipt.pullRequestNumber} head=${receipt.expectedHeadSha} -->`;
749
+ const body = renderAdmissionComment(receipt, receiptRoot);
750
+ const comments = await client.listIssueComments(receipt.pullRequestNumber);
751
+ const existing = comments.find((comment) => String(comment.body || "").includes(marker));
752
+ const comment = existing
753
+ ? await client.updateIssueComment(existing.id, body)
754
+ : await client.createIssueComment(receipt.pullRequestNumber, body);
755
+ let status = null;
756
+ if (receipt.expectedHeadSha === receipt.observedHeadSha) {
757
+ status = await client.setCommitStatus(receipt.expectedHeadSha, {
758
+ state: diagnosticState(receipt.state),
759
+ context: options.diagnosticContext,
760
+ description: `${receipt.state}: ${receipt.reason}`.slice(0, 140),
761
+ targetUrl: receipt.pullRequestUrl,
762
+ });
763
+ }
764
+ return { commentId: comment?.id || null, commentUrl: comment?.html_url || "", statusPublished: Boolean(status) };
765
+ }
766
+
767
+ function createAdmissionReceipt({ options, pr = {}, state, reason, readiness, decision = {}, queue = null, warrant = null }) {
768
+ return createDevPrAdmissionReceipt({ options, pr, state, reason, readiness, decision, queue, warrant, labels: labelsOf(pr), nextAction: nextAdmissionAction });
769
+ }
770
+
771
+ function targetedFailure({ options, pr, state, reason, readiness, decision, queue }) {
772
+ const receipt = createAdmissionReceipt({ options, pr, state, reason, readiness, decision, queue });
773
+ return {
774
+ schema: AGENT_ADMISSION_RESULT_SCHEMA,
775
+ ok: false,
776
+ mode: options.dryRun ? "plan" : "execute",
777
+ outcome: "targeted-admission-failed",
778
+ receipt,
779
+ receiptRoot: contentRoot(receipt),
780
+ diagnostic: null,
781
+ };
782
+ }
783
+
784
+ export async function runDevPrAdmission(optionsInput = {}, clientInput) {
785
+ const options = normalizeOptions(optionsInput);
786
+ if (!options.targetBranch) throw new Error("target branch is required");
787
+ if (!options.targetPullRequestNumber) throw new Error("pull request number is required");
788
+ if (!SHA_PATTERN.test(options.expectedHeadSha)) throw new Error("expected head must be an exact 40-character lowercase Git SHA");
789
+ const client = clientInput || (optionsInput.useGhCli || !process.env.GITHUB_TOKEN
790
+ ? new GhCliClient({ repository: options.repository })
791
+ : new GitHubClient({
792
+ token: optionsInput.token || process.env.GITHUB_TOKEN,
793
+ repository: options.repository,
794
+ apiUrl: optionsInput.apiUrl || process.env.GITHUB_API_URL || "https://api.github.com",
795
+ }));
796
+
797
+ let pr;
798
+ try {
799
+ pr = await client.getPullRequest(options.targetPullRequestNumber, {
800
+ attempts: options.pollMergeableAttempts,
801
+ delayMs: options.pollMergeableDelayMs,
802
+ });
803
+ } catch (error) {
804
+ return targetedFailure({ options, pr: {}, state: "missing", reason: "pull-request-not-found" });
805
+ }
806
+
807
+ const reject = async (state, reason, readiness = { observed: hasReadyLabel(pr, options.readyLabel), established: false }) => {
808
+ const result = targetedFailure({ options, pr, state, reason, readiness });
809
+ if (!options.dryRun) result.diagnostic = await publishAdmissionDiagnostic(client, options, result.receipt, result.receiptRoot);
810
+ return result;
811
+ };
812
+ if (String(pr.state || "open").toLowerCase() !== "open") return reject("rejected", "pull-request-not-open");
813
+ if (pr.base?.ref !== options.targetBranch) return reject("stale", "base-branch-drift");
814
+ if (String(pr.head?.sha || "").toLowerCase() !== options.expectedHeadSha) return reject("stale", "head-sha-drift");
815
+ if (!sameRepositoryAllowed(pr, options.repository, options.sameRepositoryOnly)) return reject("blocked", "fork-or-cross-repository-head");
816
+ if (hasBlockedLabel(pr, options.blockLabels)) return reject("blocked", "blocked-label");
817
+ if (pr.draft) return reject("blocked", "draft");
818
+ if (!headPrefixAllowed(pr, options.allowedHeadPrefixes)) return reject("blocked", "head-prefix-not-allowed");
819
+
820
+ let readiness = { observed: hasReadyLabel(pr, options.readyLabel), established: false };
821
+ if (!readiness.observed) {
822
+ if (options.dryRun) return reject("rejected", "missing-ready-label", readiness);
823
+ await client.addLabels(pr.number, [options.readyLabel]);
824
+ const readback = await client.getPullRequest(pr.number, {
825
+ attempts: options.pollMergeableAttempts,
826
+ delayMs: options.pollMergeableDelayMs,
827
+ });
828
+ if (String(readback.head?.sha || "").toLowerCase() !== options.expectedHeadSha) {
829
+ pr = readback;
830
+ return reject("stale", "head-sha-drift-after-readiness-write", readiness);
831
+ }
832
+ if (readback.base?.ref !== options.targetBranch) {
833
+ pr = readback;
834
+ return reject("stale", "base-branch-drift-after-readiness-write", readiness);
835
+ }
836
+ pr = readback;
837
+ readiness = { observed: hasReadyLabel(pr, options.readyLabel), established: true };
838
+ if (!readiness.observed) return reject("rejected", "readiness-readback-failed", readiness);
839
+ }
840
+
841
+ if (options.qualificationOnly) {
842
+ return runSourceQualification({
843
+ options, pullRequest: pr, readiness, client, reject,
844
+ evaluate: evaluatePullRequest,
845
+ admissionState: admissionStateFor,
846
+ createReceipt: createAdmissionReceipt,
847
+ root: contentRoot,
848
+ publishDiagnostic: publishAdmissionDiagnostic,
849
+ });
850
+ }
851
+
852
+ const initialQueue = await client.getMergeQueueState(options.targetBranch);
853
+ let warrant = null;
854
+ try {
855
+ warrant = readDeliveryWarrantResult(options, pr);
856
+ } catch (error) {
857
+ return reject("blocked", error.code || "invalid-delivery-warrant");
858
+ }
859
+ const matchingEntry = initialQueue.entries.find((entry) =>
860
+ entry.pullRequestNumber === pr.number && entry.pullRequestHeadSha === options.expectedHeadSha);
861
+ const existing = await admitExistingQueueEntry({
862
+ options, pullRequest: pr, readiness, client, entry: matchingEntry, warrant,
863
+ createReceipt: createAdmissionReceipt,
864
+ root: contentRoot,
865
+ publishDiagnostic: publishAdmissionDiagnostic,
866
+ });
867
+ if (existing) return existing;
868
+
869
+ return runTargetedQueueAdmission({
870
+ options, pullRequest: pr, readiness, client, warrant,
871
+ runController: runDevPrAutoMerge,
872
+ admissionState: admissionStateFor,
873
+ createReceipt: createAdmissionReceipt,
874
+ root: contentRoot,
875
+ publishDiagnostic: publishAdmissionDiagnostic,
876
+ });
877
+ }
878
+
879
+ function finalizePatrolResult(result) {
880
+ result.runKind = "cadence-patrol";
881
+ result.outcome = result.evaluated.length === 0
882
+ ? "no-op-no-candidates"
883
+ : result.actions.length === 0
884
+ ? "no-op-all-skipped"
885
+ : "actions-present";
886
+ result.qualification = false;
887
+ result.noOp = result.actions.length === 0;
888
+ return result;
889
+ }
890
+
891
+ async function reconcileEnqueueError({ client, options, pr, expectedHeadSha, entry, result, error }) {
892
+ const queueReadback = await client.getMergeQueueState(options.targetBranch).catch(() => null);
893
+ const exactEntry = queueReadback?.entries?.find((candidate) =>
894
+ candidate.pullRequestNumber === pr.number && candidate.pullRequestHeadSha === expectedHeadSha);
895
+ if (exactEntry) {
896
+ entry.action = "enqueued";
897
+ entry.reason = "already-enqueued-exact-head";
898
+ entry.queueEntry = exactEntry;
899
+ entry.admissionReceipt.reason = entry.reason;
900
+ result.actions.push(entry);
901
+ result.enqueued.push(entry);
902
+ return;
903
+ }
904
+ entry.queueAdmissionStatus = await setQueueAdmissionStatus(client, options.repository, expectedHeadSha, options.queueAdmissionContext, "failure");
905
+ entry.action = "skip";
906
+ entry.reason = "enqueue-rejected";
907
+ entry.enqueueError = {
908
+ status: error.status || null,
909
+ message: error.message || "GitHub rejected merge queue admission",
910
+ };
911
+ entry.admissionReceipt.decision = "rejected";
912
+ entry.admissionReceipt.reason = entry.reason;
913
+ result.skipped.push(entry);
914
+ }
915
+
508
916
  export async function runDevPrAutoMerge(optionsInput = {}, clientInput) {
509
917
  const options = normalizeOptions(optionsInput);
510
918
  if (!options.targetBranch) throw new Error("target branch is required");
@@ -513,7 +921,6 @@ export async function runDevPrAutoMerge(optionsInput = {}, clientInput) {
513
921
  repository: options.repository,
514
922
  apiUrl: optionsInput.apiUrl || process.env.GITHUB_API_URL || "https://api.github.com",
515
923
  });
516
-
517
924
  const [pullRequests, initialBaseSha, initialQueueState] = await Promise.all([
518
925
  client.listPullRequests(options.targetBranch),
519
926
  client.getBranchSha(options.targetBranch).catch(() => ""),
@@ -539,7 +946,6 @@ export async function runDevPrAutoMerge(optionsInput = {}, clientInput) {
539
946
  finalBaseSha: initialBaseSha,
540
947
  mergeQueue: initialQueueState,
541
948
  };
542
-
543
949
  if (landingMode === "queue" && !initialQueueState.enabled) {
544
950
  blockRemainingPullRequests(result, orderedPullRequests, 0, options, initialBaseSha, null);
545
951
  for (const entry of result.evaluated) {
@@ -547,12 +953,12 @@ export async function runDevPrAutoMerge(optionsInput = {}, clientInput) {
547
953
  entry.admissionReceipt.reason = "merge-queue-not-enabled";
548
954
  entry.admissionReceipt.decision = "rejected";
549
955
  }
550
- return result;
956
+ return finalizePatrolResult(result);
551
957
  }
552
958
 
553
959
  if (landingMode === "queue" && initialQueueState.entries.length > 0) {
554
960
  blockRemainingPullRequests(result, orderedPullRequests, 0, options, initialBaseSha, queuePredecessor(initialQueueState));
555
- return result;
961
+ return finalizePatrolResult(result);
556
962
  }
557
963
 
558
964
  for (let index = 0; index < orderedPullRequests.length; index += 1) {
@@ -564,7 +970,7 @@ export async function runDevPrAutoMerge(optionsInput = {}, clientInput) {
564
970
  continue;
565
971
  }
566
972
 
567
- const decision = await evaluatePullRequest(pr, options, client);
973
+ const decision = await evaluatePullRequest(pr, { ...options, landingMode }, client);
568
974
  const entry = evaluatedEntry(pr, decision);
569
975
  result.evaluated.push(entry);
570
976
 
@@ -594,6 +1000,7 @@ export async function runDevPrAutoMerge(optionsInput = {}, clientInput) {
594
1000
  reason: decision.reason,
595
1001
  checks: decision.checks,
596
1002
  approval: decision.approval,
1003
+ projectCut: decision.projectCut,
597
1004
  });
598
1005
  result.skipped.push(entry);
599
1006
  blockRemainingPullRequests(result, orderedPullRequests, index + 1, options, initialBaseSha, queuePredecessor(null, {
@@ -626,7 +1033,7 @@ export async function runDevPrAutoMerge(optionsInput = {}, clientInput) {
626
1033
  } else if (observedHeadSha !== expectedHeadSha) {
627
1034
  admissionDecision = "rejected";
628
1035
  admissionReason = "head-sha-drift";
629
- } else if (!mergeableAccepted(observedPullRequest, landingMode)) {
1036
+ } else if (!mergeableAccepted(observedPullRequest, landingMode, decision.projectCut?.ok === true)) {
630
1037
  admissionDecision = "rejected";
631
1038
  admissionReason = "not-mergeable-on-admission-recheck";
632
1039
  }
@@ -646,6 +1053,7 @@ export async function runDevPrAutoMerge(optionsInput = {}, clientInput) {
646
1053
  checks: decision.checks,
647
1054
  approval: decision.approval,
648
1055
  predecessor,
1056
+ projectCut: decision.projectCut,
649
1057
  });
650
1058
 
651
1059
  if (admissionDecision === "planned") {
@@ -671,16 +1079,7 @@ export async function runDevPrAutoMerge(optionsInput = {}, clientInput) {
671
1079
  result.actions.push(entry);
672
1080
  result.enqueued.push(entry);
673
1081
  } catch (error) {
674
- entry.queueAdmissionStatus = await setQueueAdmissionStatus(client, options.repository, expectedHeadSha, options.queueAdmissionContext, "failure");
675
- entry.action = "skip";
676
- entry.reason = "enqueue-rejected";
677
- entry.enqueueError = {
678
- status: error.status || null,
679
- message: error.message || "GitHub rejected merge queue admission",
680
- };
681
- entry.admissionReceipt.decision = "rejected";
682
- entry.admissionReceipt.reason = entry.reason;
683
- result.skipped.push(entry);
1082
+ await reconcileEnqueueError({ client, options, pr, expectedHeadSha, entry, result, error });
684
1083
  }
685
1084
  }
686
1085
  } else {
@@ -699,7 +1098,7 @@ export async function runDevPrAutoMerge(optionsInput = {}, clientInput) {
699
1098
  }
700
1099
 
701
1100
  result.finalBaseSha = await client.getBranchSha(options.targetBranch).catch(() => "");
702
- return result;
1101
+ return finalizePatrolResult(result);
703
1102
  }
704
1103
 
705
1104
  export function renderMarkdownSummary(result) {
@@ -735,38 +1134,81 @@ export function writeGitHubOutputs(outputs, outputFile = process.env.GITHUB_OUTP
735
1134
  fs.appendFileSync(outputFile, `${lines.join("\n")}\n`);
736
1135
  }
737
1136
 
1137
+ function cliValue(args, name, fallback = "") {
1138
+ const index = args.indexOf(`--${name}`);
1139
+ return index === -1 ? fallback : args[index + 1] || "";
1140
+ }
1141
+
1142
+ function cliFlag(args, name) {
1143
+ return args.includes(`--${name}`);
1144
+ }
1145
+
1146
+ export function cliOptions(args = [], environment = process.env) {
1147
+ const targetPullRequestNumber = cliValue(args, "pull-request", environment.BUILDCHAIN_DEV_PR_EXPECTED_PR_NUMBER);
1148
+ return {
1149
+ repository: cliValue(args, "repository", environment.BUILDCHAIN_DEV_PR_REPOSITORY || environment.GITHUB_REPOSITORY),
1150
+ targetBranch: cliValue(args, "branch", environment.BUILDCHAIN_DEV_PR_TARGET_BRANCH || environment.GITHUB_REF_NAME),
1151
+ targetPullRequestNumber,
1152
+ expectedHeadSha: cliValue(args, "expected-head", environment.BUILDCHAIN_DEV_PR_EXPECTED_HEAD_SHA),
1153
+ readyLabel: cliValue(args, "ready-label", environment.BUILDCHAIN_DEV_PR_READY_LABEL || DEFAULT_READY_LABEL),
1154
+ blockLabels: cliValue(args, "block-labels", environment.BUILDCHAIN_DEV_PR_BLOCK_LABELS),
1155
+ allowedHeadPrefixes: cliValue(args, "allowed-head-prefixes", environment.BUILDCHAIN_DEV_PR_ALLOWED_HEAD_PREFIXES),
1156
+ requiredChecks: cliValue(args, "required-checks", environment.BUILDCHAIN_DEV_PR_REQUIRED_CHECKS),
1157
+ queueAdmissionContext: cliValue(args, "queue-admission-context", environment.BUILDCHAIN_DEV_PR_QUEUE_ADMISSION_CONTEXT),
1158
+ diagnosticContext: cliValue(args, "diagnostic-context", environment.BUILDCHAIN_DEV_PR_DIAGNOSTIC_CONTEXT),
1159
+ warrantMode: cliValue(args, "warrant-mode", environment.BUILDCHAIN_DEV_PR_WARRANT_MODE),
1160
+ warrantResultPath: cliValue(args, "warrant-result", environment.BUILDCHAIN_DEV_PR_WARRANT_RESULT_PATH),
1161
+ projectCutProofPath: cliValue(args, "project-cut-proof", environment.BUILDCHAIN_DEV_PR_PROJECT_CUT_PROOF_PATH),
1162
+ sourcePatchRoot: cliValue(args, "source-patch-root", environment.BUILDCHAIN_DEV_PR_SOURCE_PATCH_ROOT),
1163
+ qualificationOnly: cliFlag(args, "qualification-only"),
1164
+ requireApproval: environment.BUILDCHAIN_DEV_PR_REQUIRE_APPROVAL,
1165
+ sameRepositoryOnly: environment.BUILDCHAIN_DEV_PR_SAME_REPOSITORY_ONLY,
1166
+ maxMerges: environment.BUILDCHAIN_DEV_PR_MAX_MERGES,
1167
+ mergeMethod: environment.BUILDCHAIN_DEV_PR_MERGE_METHOD,
1168
+ landingMode: cliValue(args, "landing-mode", environment.BUILDCHAIN_DEV_PR_LANDING_MODE),
1169
+ dryRun: cliFlag(args, "execute") ? false : environment.BUILDCHAIN_DEV_PR_DRY_RUN,
1170
+ outputPath: cliValue(
1171
+ args,
1172
+ "output",
1173
+ environment.BUILDCHAIN_DEV_PR_OUTPUT_PATH || (targetPullRequestNumber
1174
+ ? ".buildchain/dev-pr-admission/result.json"
1175
+ : ".buildchain/dev-pr-auto-merge/result.json"),
1176
+ ),
1177
+ useGhCli: cliFlag(args, "gh-cli"),
1178
+ };
1179
+ }
1180
+
738
1181
  async function main() {
739
- const options = normalizeOptions({
740
- repository: process.env.BUILDCHAIN_DEV_PR_REPOSITORY || process.env.GITHUB_REPOSITORY,
741
- targetBranch: process.env.BUILDCHAIN_DEV_PR_TARGET_BRANCH || process.env.GITHUB_REF_NAME,
742
- readyLabel: process.env.BUILDCHAIN_DEV_PR_READY_LABEL,
743
- blockLabels: process.env.BUILDCHAIN_DEV_PR_BLOCK_LABELS,
744
- allowedHeadPrefixes: process.env.BUILDCHAIN_DEV_PR_ALLOWED_HEAD_PREFIXES,
745
- requiredChecks: process.env.BUILDCHAIN_DEV_PR_REQUIRED_CHECKS,
746
- queueAdmissionContext: process.env.BUILDCHAIN_DEV_PR_QUEUE_ADMISSION_CONTEXT,
747
- requireApproval: process.env.BUILDCHAIN_DEV_PR_REQUIRE_APPROVAL,
748
- sameRepositoryOnly: process.env.BUILDCHAIN_DEV_PR_SAME_REPOSITORY_ONLY,
749
- maxMerges: process.env.BUILDCHAIN_DEV_PR_MAX_MERGES,
750
- mergeMethod: process.env.BUILDCHAIN_DEV_PR_MERGE_METHOD,
751
- landingMode: process.env.BUILDCHAIN_DEV_PR_LANDING_MODE,
752
- dryRun: process.env.BUILDCHAIN_DEV_PR_DRY_RUN,
753
- outputPath: process.env.BUILDCHAIN_DEV_PR_OUTPUT_PATH,
754
- });
755
- const result = await runDevPrAutoMerge(options);
1182
+ const args = process.argv.slice(2);
1183
+ if (cliFlag(args, "help")) {
1184
+ process.stdout.write("Usage:\n buildchain dev pr-admit --repository owner/repo --branch dev/vN/vN.M --pull-request N --expected-head SHA [--qualification-only] [--landing-mode auto|direct|queue] [--warrant-mode off|required] [--warrant-result FILE] [--execute] [--output FILE] [--json]\n");
1185
+ return;
1186
+ }
1187
+ const options = normalizeOptions(cliOptions(args));
1188
+ const targeted = options.targetPullRequestNumber > 0 || Boolean(options.expectedHeadSha);
1189
+ const result = targeted
1190
+ ? await runDevPrAdmission({ ...options, useGhCli: cliFlag(args, "gh-cli") })
1191
+ : await runDevPrAutoMerge(options);
756
1192
  fs.mkdirSync(path.dirname(options.outputPath), { recursive: true });
757
1193
  fs.writeFileSync(options.outputPath, `${JSON.stringify(result, null, 2)}\n`);
758
- const summary = renderMarkdownSummary(result);
1194
+ const summary = targeted ? `${renderAdmissionComment(result.receipt, result.receiptRoot)}\n` : renderMarkdownSummary(result);
759
1195
  if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, summary);
1196
+ else if (cliFlag(args, "json")) process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
760
1197
  else process.stdout.write(summary);
761
1198
  writeGitHubOutputs({
762
- "evaluated-count": result.evaluated.length,
763
- "merged-count": result.merged.length,
764
- "enqueued-count": result.enqueued.length,
765
- "action-count": result.actions.length,
766
- "skipped-count": result.skipped.length,
767
- "final-base-sha": result.finalBaseSha,
1199
+ "evaluated-count": targeted ? 1 : result.evaluated.length,
1200
+ "merged-count": targeted ? Number(result.receipt.state === "merged") : result.merged.length,
1201
+ "enqueued-count": targeted ? Number(result.receipt.state === "queued") : result.enqueued.length,
1202
+ "action-count": targeted ? Number(result.ok) : result.actions.length,
1203
+ "skipped-count": targeted ? Number(!result.ok) : result.skipped.length,
1204
+ "final-base-sha": targeted ? "" : result.finalBaseSha,
1205
+ "targeted": targeted,
1206
+ "targeted-ok": targeted ? result.ok : "",
1207
+ "admission-state": targeted ? result.receipt.state : "",
1208
+ "receipt-root": targeted ? result.receiptRoot : "",
768
1209
  "result-path": options.outputPath,
769
1210
  });
1211
+ if (targeted && !result.ok) process.exitCode = 1;
770
1212
  }
771
1213
 
772
1214
  if (import.meta.url === `file://${process.argv[1]}`) {