@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.
- package/AGENTS.md +41 -75
- package/CONTRIBUTING.md +19 -26
- package/architecture/agent-change-map.md +7 -12
- package/architecture/capability-state-machine-manifest.json +1 -4
- package/architecture/ci-lane-change-budget.json +35 -1026
- package/architecture/core-mechanisms.json +3 -6
- package/architecture/decisions/0008-pipeline-product-publication.md +29 -8
- package/architecture/internal-capabilities.json +1 -5
- package/architecture/maintainability-debt.json +43 -36
- package/architecture/maintainability-policy.json +8 -8
- package/architecture/minimal-consumer-migration.json +80 -1977
- package/architecture/platform-stage-checkpoints.json +31 -7
- package/architecture/release-topology.json +64 -84
- package/architecture/stage-capsule-qualification.json +3 -1
- package/architecture/universal-workflow-bootstrap.json +3 -1
- package/architecture/universal-workflow-capability-policy.json +4 -4
- package/architecture/universal-workflow-fault-campaign.json +3 -1
- package/architecture/workflow-taxonomy.json +24 -341
- package/dist/site/buildchain-contract.json +13 -67
- package/dist/site/buildchain-site.json +28 -23
- package/dist/site/capability-registry.json +2 -2
- package/dist/site/controller-registry.json +1 -47
- package/dist/site/kfd-claims.json +78 -448
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +3 -3
- package/dist/site/node-api-registry.json +24 -24
- package/dist/site/page-registry.json +21 -16
- package/dist/site/public-surface-audit.json +31 -347
- package/dist/site/publication-authority-registry.json +51 -511
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +7 -7
- package/dist/site/workflow-registry.json +259 -1126
- package/docs/code-organization.md +9 -6
- package/docs/node-api-reference.md +57 -57
- package/docs/release-governance.md +10 -12
- package/docs/runtime-entry.md +10 -3
- package/docs/runtime-train-validation.md +39 -11
- package/docs/stable-candidate-patrol.md +6 -5
- package/docs/workflow-catalog.md +3 -32
- package/package.json +1 -1
- package/packages/core/adoption/buildchain-kfd-claims.js +7 -7
- package/packages/core/dev-delivery/dev-delivery-provider-heartbeat.js +18 -15
- package/packages/core/dev-delivery/native/actions.js +2 -0
- package/packages/core/dev-delivery/native/process-entry.js +19 -0
- package/packages/core/dev-delivery/queue/landing-action.js +49 -1
- package/packages/core/dev-delivery/warrant/failure-settlement.js +8 -6
- package/packages/core/dev-delivery/warrant/service.js +7 -0
- package/packages/core/governance/buildchain-publication-authority.js +2 -23
- package/packages/core/governance/github-governance-authority.js +1 -0
- package/packages/core/observability/controller-evidence.js +0 -8
- package/packages/core/providers/github/pipeline-entry-runs.js +8 -1
- package/packages/core/providers/github/pipeline-events.js +79 -0
- package/packages/core/providers/github/pipeline-product-release.js +10 -3
- package/packages/core/publication/npm/pipeline-channel.js +0 -51
- package/packages/core/publication/npm/pipeline-provider.js +29 -1
- package/packages/core/publication/pipeline/apply.js +2 -1
- package/packages/core/publication/pipeline/capsules.js +4 -1
- package/packages/core/publication/pipeline/distribution.js +29 -23
- package/packages/core/publication/pipeline/effects.js +34 -4
- package/packages/core/publication/pipeline/pack.js +31 -8
- package/packages/core/publication/pipeline/settle.js +0 -1
- package/packages/core/workflow/attempt/journal.js +11 -9
- package/packages/core/workflow/pipeline/actions.js +0 -23
- package/packages/core/workflow/pipeline/build-evidence.js +123 -5
- package/packages/core/workflow/pipeline/build-result.js +1 -0
- package/packages/core/workflow/pipeline/channel-control.js +25 -4
- package/packages/core/workflow/pipeline/controller.js +9 -1
- package/packages/core/workflow/pipeline/delivery-control.js +54 -9
- package/packages/core/workflow/pipeline/delivery-observation.js +16 -10
- package/packages/core/workflow/pipeline/group-control.js +1 -0
- package/packages/core/workflow/pipeline/guard-build.js +37 -0
- package/packages/core/workflow/pipeline/guard.js +4 -2
- package/packages/core/workflow/pipeline/host.js +23 -1
- package/packages/core/workflow/pipeline/integration-status.js +113 -0
- package/packages/core/workflow/pipeline/record-action.js +27 -0
- package/packages/core/workflow/pipeline/recorder-status.js +69 -0
- package/packages/core/workflow/pipeline/recovery-integration.js +14 -1
- package/packages/core/workflow/workflow-taxonomy.mjs +67 -47
- package/scripts/check-inventory.mjs +7 -70
- package/scripts/check-pipeline-publication-topology.mjs +2 -0
- package/scripts/check-public-dogfood-contract.mjs +38 -51
- package/scripts/check-release-topology.mjs +8 -7
- package/scripts/check-self-consumer-contract.mjs +133 -0
- package/scripts/check-universal-workflow-bootstrap.mjs +4 -6
- package/scripts/generate-minimal-consumer-contract.mjs +1 -1
- package/scripts/inventory/binary.mjs +9 -19
- package/scripts/inventory/promotion.mjs +2 -13
- package/scripts/prepare-product-toolchain.mjs +10 -0
- 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
|
|
8
|
-
|
|
9
|
-
|
|
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
|
|
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 {
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
26
|
+
policyAdmission.protectedPlan.review,
|
|
14
27
|
integration,
|
|
15
28
|
);
|
|
16
29
|
if (!review.review || !review.checksPassing)
|