@kungfu-tech/buildchain 4.1.3-alpha.5 → 4.1.3-alpha.7

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 (89) hide show
  1. package/AGENTS.md +41 -75
  2. package/CONTRIBUTING.md +19 -26
  3. package/architecture/agent-change-map.md +7 -12
  4. package/architecture/capability-state-machine-manifest.json +1 -4
  5. package/architecture/ci-lane-change-budget.json +35 -1026
  6. package/architecture/core-mechanisms.json +3 -6
  7. package/architecture/decisions/0008-pipeline-product-publication.md +29 -8
  8. package/architecture/internal-capabilities.json +1 -5
  9. package/architecture/maintainability-debt.json +43 -36
  10. package/architecture/maintainability-policy.json +8 -8
  11. package/architecture/minimal-consumer-migration.json +80 -1977
  12. package/architecture/platform-stage-checkpoints.json +31 -7
  13. package/architecture/release-topology.json +64 -84
  14. package/architecture/stage-capsule-qualification.json +3 -1
  15. package/architecture/universal-workflow-bootstrap.json +3 -1
  16. package/architecture/universal-workflow-capability-policy.json +4 -4
  17. package/architecture/universal-workflow-fault-campaign.json +3 -1
  18. package/architecture/workflow-taxonomy.json +24 -341
  19. package/dist/site/buildchain-contract.json +13 -67
  20. package/dist/site/buildchain-site.json +28 -23
  21. package/dist/site/capability-registry.json +2 -2
  22. package/dist/site/controller-registry.json +1 -47
  23. package/dist/site/kfd-claims.json +78 -448
  24. package/dist/site/kfd-upstream-aggregate.json +1 -1
  25. package/dist/site/manual-registry.json +3 -3
  26. package/dist/site/node-api-registry.json +24 -24
  27. package/dist/site/page-registry.json +21 -16
  28. package/dist/site/public-surface-audit.json +31 -347
  29. package/dist/site/publication-authority-registry.json +51 -511
  30. package/dist/site/publication-registry.json +4 -4
  31. package/dist/site/site-manifest.json +7 -7
  32. package/dist/site/workflow-registry.json +259 -1126
  33. package/docs/code-organization.md +9 -6
  34. package/docs/node-api-reference.md +57 -57
  35. package/docs/release-governance.md +10 -12
  36. package/docs/runtime-entry.md +10 -3
  37. package/docs/runtime-train-validation.md +39 -11
  38. package/docs/stable-candidate-patrol.md +6 -5
  39. package/docs/workflow-catalog.md +3 -32
  40. package/package.json +1 -1
  41. package/packages/core/adoption/buildchain-kfd-claims.js +7 -7
  42. package/packages/core/dev-delivery/dev-delivery-provider-heartbeat.js +18 -15
  43. package/packages/core/dev-delivery/native/actions.js +2 -0
  44. package/packages/core/dev-delivery/native/process-entry.js +19 -0
  45. package/packages/core/dev-delivery/queue/landing-action.js +49 -1
  46. package/packages/core/dev-delivery/warrant/failure-settlement.js +8 -6
  47. package/packages/core/dev-delivery/warrant/service.js +7 -0
  48. package/packages/core/governance/buildchain-publication-authority.js +2 -23
  49. package/packages/core/governance/github-governance-authority.js +1 -0
  50. package/packages/core/observability/controller-evidence.js +0 -8
  51. package/packages/core/providers/github/pipeline-entry-runs.js +8 -1
  52. package/packages/core/providers/github/pipeline-events.js +79 -0
  53. package/packages/core/providers/github/pipeline-product-release.js +10 -3
  54. package/packages/core/publication/npm/pipeline-channel.js +0 -51
  55. package/packages/core/publication/npm/pipeline-provider.js +29 -1
  56. package/packages/core/publication/pipeline/apply.js +2 -1
  57. package/packages/core/publication/pipeline/capsules.js +4 -1
  58. package/packages/core/publication/pipeline/distribution.js +29 -23
  59. package/packages/core/publication/pipeline/effects.js +34 -4
  60. package/packages/core/publication/pipeline/pack.js +31 -8
  61. package/packages/core/publication/pipeline/settle.js +0 -1
  62. package/packages/core/workflow/attempt/journal.js +11 -9
  63. package/packages/core/workflow/pipeline/actions.js +0 -23
  64. package/packages/core/workflow/pipeline/build-evidence.js +123 -5
  65. package/packages/core/workflow/pipeline/build-result.js +1 -0
  66. package/packages/core/workflow/pipeline/channel-control.js +25 -4
  67. package/packages/core/workflow/pipeline/controller.js +9 -1
  68. package/packages/core/workflow/pipeline/delivery-control.js +54 -9
  69. package/packages/core/workflow/pipeline/delivery-observation.js +16 -10
  70. package/packages/core/workflow/pipeline/group-control.js +1 -0
  71. package/packages/core/workflow/pipeline/guard-build.js +37 -0
  72. package/packages/core/workflow/pipeline/guard.js +4 -2
  73. package/packages/core/workflow/pipeline/host.js +23 -1
  74. package/packages/core/workflow/pipeline/integration-status.js +113 -0
  75. package/packages/core/workflow/pipeline/record-action.js +27 -0
  76. package/packages/core/workflow/pipeline/recorder-status.js +69 -0
  77. package/packages/core/workflow/pipeline/recovery-integration.js +14 -1
  78. package/packages/core/workflow/workflow-taxonomy.mjs +67 -47
  79. package/scripts/check-inventory.mjs +7 -70
  80. package/scripts/check-pipeline-publication-topology.mjs +2 -0
  81. package/scripts/check-public-dogfood-contract.mjs +38 -51
  82. package/scripts/check-release-topology.mjs +8 -7
  83. package/scripts/check-self-consumer-contract.mjs +133 -0
  84. package/scripts/check-universal-workflow-bootstrap.mjs +4 -6
  85. package/scripts/generate-minimal-consumer-contract.mjs +1 -1
  86. package/scripts/inventory/binary.mjs +9 -19
  87. package/scripts/inventory/promotion.mjs +2 -13
  88. package/scripts/prepare-product-toolchain.mjs +10 -0
  89. package/scripts/verify-product-platform.mjs +65 -0
@@ -3,17 +3,38 @@ import { pipelineBuildEvidence } from "./build-evidence.js";
3
3
 
4
4
  async function record(session, phase, state, receipt, host, reason = "") {
5
5
  const observed = await session.journal.read();
6
+ if (observed.attempt !== session.observed.attempt)
7
+ throw new Error("Channel receipt belongs to a superseded attempt");
6
8
  const root = recordDigest(receipt);
7
- const reference = await host
8
- .materialStore(session)
9
- .retain(`${phase}/${root.slice(7)}`, receipt, "provider-readback");
9
+ const eventKey = `${phase}:${state}:${root}`;
10
+ const store = host.materialStore(session);
11
+ const prior = observed.history
12
+ .at(-1)
13
+ .events.find((event) => event.payload.eventKey === eventKey);
14
+ if (prior) {
15
+ if (
16
+ prior.node !== phase ||
17
+ prior.payload.state !== state ||
18
+ prior.payload.reason !== reason ||
19
+ prior.payload.materials.length !== 1 ||
20
+ recordDigest(await store.read(prior.payload.materials[0])) !== root
21
+ )
22
+ throw new Error("Channel retry changed its immutable receipt");
23
+ return observed;
24
+ }
25
+ const reference = await store.retain(
26
+ `${phase}/${root.slice(7)}`,
27
+ receipt,
28
+ "provider-readback",
29
+ );
10
30
  return session.progress.progress({
11
31
  attempt: observed.attempt,
12
32
  phase,
13
33
  state,
14
- eventKey: `${phase}:${state}:${root}`,
34
+ eventKey,
15
35
  reason,
16
36
  materials: [reference],
37
+ expectedHead: observed.head,
17
38
  });
18
39
  }
19
40
 
@@ -2,7 +2,10 @@ import { pipelineEvent } from "./events.js";
2
2
  import { selectPipelineSession } from "./selection.js";
3
3
  import { beginPipelineBuild } from "./build-control.js";
4
4
  import { controlPipelineDelivery } from "./delivery-control.js";
5
- import { githubPipelineEvents } from "../../providers/github/pipeline-events.js";
5
+ import {
6
+ githubPipelineEvents,
7
+ githubPipelineQueueExit,
8
+ } from "../../providers/github/pipeline-events.js";
6
9
  import { pipelinePlatforms } from "./platforms.js";
7
10
  import { recordDigest } from "../../release/discussion/envelope.js";
8
11
 
@@ -95,6 +98,11 @@ export async function controlPipeline(name, payload, inputs, host) {
95
98
  attempt: session.observed.attempt,
96
99
  };
97
100
  }
101
+ if (session.intent.expectedNodes.includes("warrant"))
102
+ host.queueExit ||= githubPipelineQueueExit(
103
+ host.github.graphql,
104
+ host.repository,
105
+ );
98
106
  const operation = session.intent.expectedNodes.includes("warrant")
99
107
  ? await controlPipelineDelivery(session, inputs, host)
100
108
  : await host.channel(session, admission, inputs);
@@ -3,6 +3,7 @@ import { preparePipelineDelivery } from "./delivery-request.js";
3
3
  import { pipelineBuildEvidence } from "./build-evidence.js";
4
4
  import {
5
5
  observePipelineWorker,
6
+ pipelineDeliveryExecution,
6
7
  pipelineDeliveryStore,
7
8
  } from "./delivery-observation.js";
8
9
  import { reconcilePipeline, pipelineCandidate } from "./reconcile.js";
@@ -21,8 +22,14 @@ export async function observePipelineDelivery(session, inputs, host, delivery) {
21
22
  inputs,
22
23
  );
23
24
  admission.live.terminalOnly = host.terminalOnly === true;
24
- admission.live.dequeued = host.eventAction === "dequeued";
25
25
  const queue = await delivery.read();
26
+ admission.live.queueExit =
27
+ admission.live.state === "open" &&
28
+ admission.live.source?.commit === current.generation.source.commit &&
29
+ admission.live.targetBranch === session.intent.source.targetBranch
30
+ ? await host.queueExit(current, pipelineCandidate(queue, current))
31
+ : null;
32
+ admission.live.dequeued = Boolean(admission.live.queueExit);
26
33
  const worker = await observePipelineWorker(session, current, queue, host);
27
34
  const input = { current, live: admission.live, queue, worker, evidence: {} };
28
35
  return {
@@ -68,6 +75,30 @@ async function retainDeliveryRequest(fresh, session, build, host) {
68
75
  ...scheduling.history.at(-1),
69
76
  intent: session.intent,
70
77
  };
78
+ // The provider execution owns the delivery slot before it acquires a
79
+ // Warrant. A later wake must not replace its retained coordinates while its
80
+ // queued or running job is still entitled to perform final admission.
81
+ const executionOwner = await pipelineDeliveryExecution(
82
+ session,
83
+ schedulingCurrent,
84
+ host,
85
+ );
86
+ if (executionOwner) {
87
+ const { run } = await host.runs.read(
88
+ executionOwner.runId,
89
+ executionOwner.runAttempt,
90
+ );
91
+ if (
92
+ run.id !== executionOwner.runId ||
93
+ run.run_attempt !== executionOwner.runAttempt
94
+ )
95
+ throw new Error("Retained delivery execution provider identity drift");
96
+ if (run.status !== "completed")
97
+ return {
98
+ operation: "wait",
99
+ reason: "admitted-delivery-execution-running",
100
+ };
101
+ }
71
102
  const active = await observePipelineWorker(
72
103
  session,
73
104
  schedulingCurrent,
@@ -145,14 +176,28 @@ export async function controlPipelineDelivery(session, inputs, host) {
145
176
  !["cancelled", "failure", "superseded", "complete"].includes(
146
177
  fresh.observed.status,
147
178
  )
148
- )
149
- await session.progress.progress({
150
- attempt: fresh.attempt,
151
- phase: fresh.phase,
152
- state: "superseded",
153
- eventKey: `supersede:${recordDigest(fresh.live)}`,
154
- reason: fresh.decision.reason,
155
- });
179
+ ) {
180
+ try {
181
+ await session.progress.progress({
182
+ attempt: fresh.attempt,
183
+ phase: fresh.phase,
184
+ state: "superseded",
185
+ eventKey: `supersede:${recordDigest(fresh.live)}`,
186
+ reason: fresh.decision.reason,
187
+ });
188
+ } catch (error) {
189
+ // Concurrent notifications can finish the same attempt after readback.
190
+ // Its immutable terminal result is sufficient; never rewrite that phase.
191
+ const latest = await session.journal.read();
192
+ if (
193
+ latest.attempt !== fresh.attempt ||
194
+ !["cancelled", "failure", "superseded", "complete"].includes(
195
+ latest.status,
196
+ )
197
+ )
198
+ throw error;
199
+ }
200
+ }
156
201
  return { operation: "successor", reason: fresh.decision.reason };
157
202
  }
158
203
  if (fresh.live.merged) return { operation: "settle", fresh, delivery };
@@ -13,16 +13,7 @@ function workerJob(jobs, name) {
13
13
  return selected[0];
14
14
  }
15
15
 
16
- export async function observePipelineWorker(session, current, queue, host) {
17
- const warrant = queue.activeWarrant;
18
- const candidate = pipelineCandidate(queue, current);
19
- if (
20
- !warrant ||
21
- candidate?.candidateId !== warrant.candidateId ||
22
- warrant.sourceHead !== current.generation.source.commit ||
23
- warrant.pullRequestNumber !== current.intent.source.pullRequest
24
- )
25
- return null;
16
+ export async function pipelineDeliveryExecution(session, current, host) {
26
17
  const phase = [...current.events]
27
18
  .reverse()
28
19
  .find((event) =>
@@ -43,6 +34,21 @@ export async function observePipelineWorker(session, current, queue, host) {
43
34
  execution.generation !== current.generation.id
44
35
  )
45
36
  throw new Error("Native worker execution belongs to another attempt");
37
+ return execution;
38
+ }
39
+
40
+ export async function observePipelineWorker(session, current, queue, host) {
41
+ const warrant = queue.activeWarrant;
42
+ const candidate = pipelineCandidate(queue, current);
43
+ if (
44
+ !warrant ||
45
+ candidate?.candidateId !== warrant.candidateId ||
46
+ warrant.sourceHead !== current.generation.source.commit ||
47
+ warrant.pullRequestNumber !== current.intent.source.pullRequest
48
+ )
49
+ return null;
50
+ const execution = await pipelineDeliveryExecution(session, current, host);
51
+ if (!execution) return null;
46
52
  const { run, jobs } = await host.runs.read(
47
53
  execution.runId,
48
54
  execution.runAttempt,
@@ -38,6 +38,7 @@ export async function recordPipelineGroup(context, host) {
38
38
  );
39
39
  if (readback.outcome === "success")
40
40
  await host.qualifyChannel(context.source, context.branch);
41
+ await host.publishBuildStatus?.(context, readback);
41
42
  await host.request(`/repos/${host.repository}/check-runs`, {
42
43
  method: "POST",
43
44
  body: {
@@ -35,3 +35,40 @@ export async function guardPipelineBuild(
35
35
  );
36
36
  return build;
37
37
  }
38
+
39
+ // A commit status is source-bound and survives later workflow suites. Publish
40
+ // only the independently reread build at the protected landing boundary, whose
41
+ // existing Actions credential owns statuses:write. Retained build receipts
42
+ // preserve the original executions and their outcomes.
43
+ export async function publishPipelineBuildStatus(
44
+ input,
45
+ build,
46
+ request,
47
+ repository,
48
+ ) {
49
+ if (
50
+ build.readback.source.repository !== repository ||
51
+ build.readback.source.commit !== input["expected-head-sha"] ||
52
+ build.sourceHead !== input["expected-head-sha"] ||
53
+ build.run.id !== Number(input["source-workflow-run-id"]) ||
54
+ build.readback.runId !== build.run.id ||
55
+ build.readback.runAttempt !== build.run.run_attempt ||
56
+ build.outcome !== "success" ||
57
+ build.readback.outcome !== "success"
58
+ )
59
+ throw new Error(
60
+ "Landing status differs from its qualified exact-source build",
61
+ );
62
+ return request(`/repos/${repository}/statuses/${build.sourceHead}`, {
63
+ method: "POST",
64
+ body: {
65
+ context: "check",
66
+ state: "success",
67
+ description: `Qualified product build: ${build.readback.root}`.slice(
68
+ 0,
69
+ 140,
70
+ ),
71
+ target_url: `https://github.com/${repository}/actions/runs/${build.run.id}/attempts/${build.run.run_attempt}`,
72
+ },
73
+ });
74
+ }
@@ -105,13 +105,15 @@ export async function guardPipelineAdmission(input, connection) {
105
105
  throw new Error(
106
106
  "Pipeline protected review or required checks changed before delivery effect",
107
107
  );
108
- await guardPipelineBuild(input, observed, {
108
+ const build = await guardPipelineBuild(input, observed, {
109
109
  material,
110
110
  source,
111
111
  request,
112
112
  repository: connection.repository,
113
113
  });
114
- return guardPipelineDelivery(input, connection);
114
+ const fresh = await guardPipelineDelivery(input, connection);
115
+ await connection.publishBuildStatus?.(build);
116
+ return fresh;
115
117
  }
116
118
 
117
119
  export async function guardPipelineDelivery(input, connection, lookup) {
@@ -24,6 +24,24 @@ import { resumePipelineNotifications } from "./notifications.js";
24
24
  import { projectPipelineStatus } from "./web-status.js";
25
25
  import { inspectRecoveryPublication } from "../../publication/pipeline/recovery-inspection.js";
26
26
 
27
+ export function pipelineQueue({ queueToken, ...options }) {
28
+ const reader = new GitHubClient(options);
29
+ return {
30
+ getMergeQueueState: (branch) => reader.getMergeQueueState(branch),
31
+ enqueuePullRequest: async (input) => {
32
+ // GITHUB_TOKEN enqueue suppresses the merge_group workflow it requires.
33
+ if (!queueToken || queueToken === options.token)
34
+ throw new Error(
35
+ "Channel enqueue requires a distinct App or automation credential",
36
+ );
37
+ return new GitHubClient({
38
+ ...options,
39
+ token: queueToken,
40
+ }).enqueuePullRequest(input);
41
+ },
42
+ };
43
+ }
44
+
27
45
  export async function pipelineHost(core, env, jobName) {
28
46
  const token = core.getInput("token", { required: true });
29
47
  const repository = env.GITHUB_REPOSITORY;
@@ -84,7 +102,11 @@ export async function pipelineHost(core, env, jobName) {
84
102
  source,
85
103
  workers: githubPipelineWorker(request),
86
104
  policy: githubPipelinePolicy(request, repository),
87
- queue: new GitHubClient({ repository: { owner, repo }, token }),
105
+ queue: pipelineQueue({
106
+ repository: { owner, repo },
107
+ token,
108
+ queueToken: core.getInput("queue-token"),
109
+ }),
88
110
  integration: githubPipelineIntegration(request, repository, source, runs),
89
111
  groupRecord: (context) => recordPipelineGroup(context, host),
90
112
  channel: (session, admission, inputs) =>
@@ -0,0 +1,113 @@
1
+ import { recordDigest } from "../../release/discussion/envelope.js";
2
+
3
+ // The landing job already owns statuses:write. Keep its exact admission alive
4
+ // through the actual queue result so later channel PRs need not borrow the
5
+ // temporary queue branch's Actions check suite.
6
+ async function observeProtectedQueue(current, ports) {
7
+ const { repository, request, queue } = ports;
8
+ const now = ports.now || Date.now;
9
+ const pause =
10
+ ports.pause || ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
11
+ const deadline = now() + 120 * 60 * 1000;
12
+ const number = current.intent.source.pullRequest;
13
+ const branch = current.intent.source.targetBranch;
14
+ let queueExitAt;
15
+ while (true) {
16
+ const pr = await request(`/repos/${repository}/pulls/${number}`);
17
+ if (
18
+ pr.head?.sha !== current.generation.source.commit ||
19
+ pr.head?.repo?.full_name !== repository ||
20
+ pr.base?.repo?.full_name !== repository ||
21
+ pr.base?.ref !== branch
22
+ )
23
+ throw new Error("Integrated status lost its exact admitted PR source");
24
+ if (pr.merged) return { pr, deadline, now, pause };
25
+ if (now() >= deadline)
26
+ throw new Error(
27
+ "Protected queue did not complete the admitted integration",
28
+ );
29
+ const pending = await queue(branch);
30
+ if (
31
+ pr.state !== "open" ||
32
+ !pending.enabled ||
33
+ !pending.entries.some(
34
+ (entry) =>
35
+ entry.pullRequestNumber === number &&
36
+ entry.pullRequestHeadSha === current.generation.source.commit,
37
+ )
38
+ ) {
39
+ // Queue removal can precede the PR endpoint's merged readback. Bound
40
+ // convergence without treating absence or a closed PR as merge evidence.
41
+ queueExitAt ??= now();
42
+ if (now() - queueExitAt >= 30_000)
43
+ throw new Error("Admitted PR left its protected merge queue");
44
+ } else queueExitAt = undefined;
45
+ await pause(10000);
46
+ }
47
+ }
48
+
49
+ async function observeIntegrationWorkflow(merged, ports) {
50
+ const { pr, deadline, now, pause } = merged;
51
+ if (!/^[0-9a-f]{40}$/u.test(pr.merge_commit_sha || ""))
52
+ throw new Error("Merged PR has no exact integration commit");
53
+ while (true) {
54
+ const result = await ports.request(
55
+ `/repos/${ports.repository}/actions/runs?event=merge_group&head_sha=${pr.merge_commit_sha}&per_page=100`,
56
+ );
57
+ if (!Array.isArray(result.workflow_runs) || result.total_count > 100)
58
+ throw new Error("Integration workflow inventory exceeds its bound");
59
+ const runs = result.workflow_runs.filter(
60
+ (run) =>
61
+ run.path?.split("@")[0] === ".github/workflows/buildchain.yml" &&
62
+ run.event === "merge_group" &&
63
+ run.head_sha === pr.merge_commit_sha,
64
+ );
65
+ if (
66
+ runs.some(
67
+ (run) => run.status === "completed" && run.conclusion === "success",
68
+ )
69
+ )
70
+ return;
71
+ if (!runs.some((run) => run.status !== "completed") || now() >= deadline)
72
+ throw new Error("Merged integration has no successful queue workflow");
73
+ await pause(10000);
74
+ }
75
+ }
76
+
77
+ function validateIntegratedStatus(proof, current, repository) {
78
+ const { root, ...body } = proof;
79
+ if (
80
+ recordDigest(body) !== root ||
81
+ proof.repository !== repository ||
82
+ proof.pullRequest !== current.intent.source.pullRequest ||
83
+ proof.sourceHead !== current.generation.source.commit ||
84
+ proof.branch !== current.intent.source.targetBranch ||
85
+ proof.build.source.commit !== proof.mergeCommit ||
86
+ proof.build.source.repository !== repository ||
87
+ proof.build.outcome !== "success" ||
88
+ proof.run.conclusion !== "success" ||
89
+ proof.run.status !== "completed" ||
90
+ proof.run.event !== "merge_group" ||
91
+ proof.run.head_sha !== proof.mergeCommit
92
+ )
93
+ throw new Error("Integrated status differs from its verified queue result");
94
+ }
95
+
96
+ export async function publishIntegratedPipelineStatus(current, ports) {
97
+ const merged = await observeProtectedQueue(current, ports);
98
+ await observeIntegrationWorkflow(merged, ports);
99
+ const { repository, integration, status } = ports;
100
+ const proof = await integration.observe(current);
101
+ validateIntegratedStatus(proof, current, repository);
102
+ const { root } = proof;
103
+ await status(`/repos/${repository}/statuses/${proof.mergeCommit}`, {
104
+ method: "POST",
105
+ body: {
106
+ context: "check",
107
+ state: "success",
108
+ description: `Qualified protected integration: ${root}`.slice(0, 140),
109
+ target_url: `https://github.com/${repository}/actions/runs/${proof.run.id}/attempts/${proof.run.run_attempt}`,
110
+ },
111
+ });
112
+ return proof;
113
+ }
@@ -0,0 +1,27 @@
1
+ import { pipelineHost } from "./host.js";
2
+ import { recordPipelineBuild } from "./build-control.js";
3
+ import { publishRecorderStatus } from "./recorder-status.js";
4
+ import {
5
+ RECOVERY_BUILD_CONTEXT,
6
+ recordRecoveryBuild,
7
+ } from "./recovery-build-control.js";
8
+
9
+ export async function recordPipelineBuildAction(core, env) {
10
+ const host = await pipelineHost(core, env, "Record product build");
11
+ host.publishBuildStatus = (context, readback) =>
12
+ publishRecorderStatus(context, readback, host);
13
+ const context = JSON.parse(core.getInput("context", { required: true }));
14
+ if (context.schema === "buildchain.pipeline-group-build-context/v1")
15
+ return host.groupRecord(context);
16
+ const result =
17
+ context.schema === RECOVERY_BUILD_CONTEXT
18
+ ? await recordRecoveryBuild(context, host)
19
+ : await recordPipelineBuild(context, host);
20
+ if (result.outcome !== "success")
21
+ throw new Error("Product build did not succeed");
22
+ core.info(
23
+ result.wakePending
24
+ ? "Build retained; next event will retry delivery wake"
25
+ : "Build retained and attempt woken",
26
+ );
27
+ }
@@ -0,0 +1,69 @@
1
+ import { createHash } from "node:crypto";
2
+ import { parse } from "yaml";
3
+ import { publishPipelineBuildStatus } from "./guard-build.js";
4
+ import { recordDigest } from "../../release/discussion/envelope.js";
5
+
6
+ // A repaired runtime may execute through an older immutable published entry.
7
+ // Read the real recorder job's permission, rather than assuming the runtime's
8
+ // current workflow is the one GitHub scheduled. API failures are never ignored.
9
+ export async function publishRecorderStatus(context, readback, host) {
10
+ const { root, ...body } = readback;
11
+ if (
12
+ context.runId !== host.runId ||
13
+ context.runAttempt !== host.runAttempt ||
14
+ readback.runId !== host.runId ||
15
+ readback.runAttempt !== host.runAttempt ||
16
+ recordDigest(context.source) !== recordDigest(readback.source) ||
17
+ root !== recordDigest(body)
18
+ )
19
+ throw new Error("Recorder status changed its current source or execution");
20
+ const { run } = await host.runs.read(host.runId, host.runAttempt);
21
+ const path = ".github/workflows/.ops-pipeline-execute.yml";
22
+ const entries = (run.referenced_workflows || []).filter((entry) =>
23
+ entry.path?.startsWith(`kungfu-systems/buildchain/${path}@`),
24
+ );
25
+ if (entries.length !== 1 || !/^[0-9a-f]{40}$/u.test(entries[0].sha || ""))
26
+ throw new Error("Recorder has no exact published execution workflow");
27
+ const file = await host.request(
28
+ `/repos/kungfu-systems/buildchain/contents/${path}?ref=${entries[0].sha}`,
29
+ );
30
+ if (
31
+ file.type !== "file" ||
32
+ file.encoding !== "base64" ||
33
+ !Number.isSafeInteger(file.size) ||
34
+ file.size < 1 ||
35
+ file.size > 64000 ||
36
+ typeof file.content !== "string" ||
37
+ file.content.length > 128000
38
+ )
39
+ throw new Error(
40
+ "Recorder execution workflow is unavailable or exceeds its bound",
41
+ );
42
+ const bytes = Buffer.from(file.content, "base64");
43
+ const blob = createHash("sha1")
44
+ .update(`blob ${bytes.length}\0`)
45
+ .update(bytes)
46
+ .digest("hex");
47
+ if (bytes.length !== file.size || blob !== file.sha)
48
+ throw new Error("Recorder execution workflow bytes changed");
49
+ const job = parse(bytes.toString("utf8")).jobs?.["record-build"];
50
+ if (job?.name !== "Record product build")
51
+ throw new Error("Recorder execution workflow changed its job identity");
52
+ if (job.permissions?.statuses !== "write") return { published: false };
53
+ if (readback.outcome !== "success") return { published: false };
54
+ await publishPipelineBuildStatus(
55
+ {
56
+ "expected-head-sha": context.source.commit,
57
+ "source-workflow-run-id": String(readback.runId),
58
+ },
59
+ {
60
+ sourceHead: context.source.commit,
61
+ outcome: readback.outcome,
62
+ run: { id: readback.runId, run_attempt: readback.runAttempt },
63
+ readback,
64
+ },
65
+ host.request,
66
+ host.repository,
67
+ );
68
+ return { published: true };
69
+ }
@@ -7,10 +7,23 @@ export async function qualifyRecoveryIntegration(session, admission, host) {
7
7
  ...session.observed.history.at(-1),
8
8
  intent: session.intent,
9
9
  };
10
+ const policyAdmission = await host.source.observe(
11
+ session.intent.source.pullRequest,
12
+ { "config-path": current.generation.source.configPath },
13
+ );
14
+ if (
15
+ !policyAdmission.eligible ||
16
+ !policyAdmission.live.merged ||
17
+ policyAdmission.live.targetBranch !== session.intent.source.targetBranch ||
18
+ policyAdmission.live.mergeCommit !== admission.live.mergeCommit ||
19
+ recordDigest(policyAdmission.live.source) !==
20
+ recordDigest(current.generation.source)
21
+ )
22
+ throw new Error("Recovery observed changed protected integration");
10
23
  const integration = await host.integration.observe(current);
11
24
  const review = await host.policy.observeMerged(
12
25
  current,
13
- admission.protectedPlan.review,
26
+ policyAdmission.protectedPlan.review,
14
27
  integration,
15
28
  );
16
29
  if (!review.review || !review.checksPassing)