@kungfu-tech/buildchain 4.1.3-alpha.4 → 4.1.3-alpha.6
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 +8 -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/internal-capabilities.json +1 -5
- package/architecture/maintainability-debt.json +44 -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 +33 -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 +26 -16
- package/dist/site/public-surface-audit.json +35 -349
- 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 +263 -1128
- 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 +31 -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-source.js +24 -7
- package/packages/core/publication/pipeline/pack.js +31 -8
- 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/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
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
import { readBusinessAttempt } from "../../workflow/attempt/reader.js";
|
|
2
2
|
|
|
3
|
+
// The current provider timeline survives webhook/runtime handoff. Compare it
|
|
4
|
+
// with this candidate's lifetime so an earlier removal cannot cancel recovery.
|
|
5
|
+
export function githubPipelineQueueExit(graphql, repository) {
|
|
6
|
+
if (!/^[\w.-]+\/[\w.-]+$/u.test(repository || ""))
|
|
7
|
+
throw new Error("Invalid pipeline queue repository");
|
|
8
|
+
const [owner, repo] = repository.split("/");
|
|
9
|
+
return async (current, candidate) => {
|
|
10
|
+
if (!candidate || candidate.terminal) return null;
|
|
11
|
+
const started = Date.parse(candidate.enqueuedAt);
|
|
12
|
+
if (!Number.isFinite(started))
|
|
13
|
+
throw new Error("Queue candidate timestamp is missing");
|
|
14
|
+
const number = current.intent.source.pullRequest;
|
|
15
|
+
const result = await graphql(
|
|
16
|
+
`
|
|
17
|
+
query ($owner: String!, $repo: String!, $number: Int!) {
|
|
18
|
+
repository(owner: $owner, name: $repo) {
|
|
19
|
+
pullRequest(number: $number) {
|
|
20
|
+
number
|
|
21
|
+
headRefOid
|
|
22
|
+
baseRefName
|
|
23
|
+
state
|
|
24
|
+
mergeQueueEntry {
|
|
25
|
+
id
|
|
26
|
+
}
|
|
27
|
+
timelineItems(
|
|
28
|
+
last: 2
|
|
29
|
+
itemTypes: [
|
|
30
|
+
ADDED_TO_MERGE_QUEUE_EVENT
|
|
31
|
+
REMOVED_FROM_MERGE_QUEUE_EVENT
|
|
32
|
+
]
|
|
33
|
+
) {
|
|
34
|
+
nodes {
|
|
35
|
+
__typename
|
|
36
|
+
... on AddedToMergeQueueEvent {
|
|
37
|
+
id
|
|
38
|
+
createdAt
|
|
39
|
+
}
|
|
40
|
+
... on RemovedFromMergeQueueEvent {
|
|
41
|
+
id
|
|
42
|
+
createdAt
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
`,
|
|
50
|
+
{ owner, repo, number },
|
|
51
|
+
);
|
|
52
|
+
const pr = result.repository?.pullRequest;
|
|
53
|
+
if (
|
|
54
|
+
pr?.number !== number ||
|
|
55
|
+
pr.headRefOid !== current.generation.source.commit ||
|
|
56
|
+
pr.baseRefName !== current.intent.source.targetBranch
|
|
57
|
+
)
|
|
58
|
+
throw new Error("Queue exit source identity changed; reobserve");
|
|
59
|
+
if (pr.state !== "OPEN" || pr.mergeQueueEntry) return null;
|
|
60
|
+
const nodes = pr.timelineItems?.nodes;
|
|
61
|
+
if (!Array.isArray(nodes) || nodes.length > 2)
|
|
62
|
+
throw new Error("Queue exit timeline readback is missing or ambiguous");
|
|
63
|
+
const event = nodes.at(-1);
|
|
64
|
+
if (event?.__typename !== "RemovedFromMergeQueueEvent") return null;
|
|
65
|
+
const queued = nodes.at(-2);
|
|
66
|
+
if (queued?.__typename !== "AddedToMergeQueueEvent")
|
|
67
|
+
throw new Error("Queue removal has no matching admission readback");
|
|
68
|
+
const removed = Date.parse(event.createdAt);
|
|
69
|
+
const entered = Date.parse(queued.createdAt);
|
|
70
|
+
if (
|
|
71
|
+
!event.id ||
|
|
72
|
+
!queued.id ||
|
|
73
|
+
!Number.isFinite(removed) ||
|
|
74
|
+
!Number.isFinite(entered) ||
|
|
75
|
+
entered > removed
|
|
76
|
+
)
|
|
77
|
+
throw new Error("Queue removal identity or timestamp is missing");
|
|
78
|
+
return entered > started ? event : null;
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
3
82
|
// Branch/terminal notifications wake existing intents only. They cannot create
|
|
4
83
|
// historical releases or infer an execution request from an untracked old PR.
|
|
5
84
|
export function githubPipelineEvents(request, journal, repository) {
|
|
@@ -10,17 +10,34 @@ function exactSha(value) {
|
|
|
10
10
|
return value;
|
|
11
11
|
}
|
|
12
12
|
|
|
13
|
+
function sourceRefBinding(reference) {
|
|
14
|
+
return {
|
|
15
|
+
ref: reference?.ref ?? null,
|
|
16
|
+
sha: reference?.sha ?? null,
|
|
17
|
+
repository: {
|
|
18
|
+
id: reference?.repo?.id ?? null,
|
|
19
|
+
nodeId: reference?.repo?.node_id ?? null,
|
|
20
|
+
fullName: reference?.repo?.full_name ?? null,
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function mergedCommit(pr) {
|
|
26
|
+
return pr.merged === true ? pr.merge_commit_sha : null;
|
|
27
|
+
}
|
|
28
|
+
|
|
13
29
|
function pullRequestBinding(pr) {
|
|
30
|
+
// Provider timestamps and repository metadata can change during our own
|
|
31
|
+
// evidence writes. Bind source and delivery state, not those unrelated fields.
|
|
14
32
|
return {
|
|
15
33
|
number: pr.number,
|
|
16
34
|
state: pr.state,
|
|
17
35
|
merged: pr.merged,
|
|
18
|
-
mergeCommit: pr
|
|
19
|
-
head: pr.head,
|
|
20
|
-
base: pr.base,
|
|
36
|
+
mergeCommit: mergedCommit(pr),
|
|
37
|
+
head: sourceRefBinding(pr.head),
|
|
38
|
+
base: sourceRefBinding(pr.base),
|
|
21
39
|
draft: pr.draft,
|
|
22
|
-
|
|
23
|
-
updatedAt: pr.updated_at,
|
|
40
|
+
ready: pr.labels.some((label) => label.name === "ready"),
|
|
24
41
|
};
|
|
25
42
|
}
|
|
26
43
|
|
|
@@ -113,7 +130,7 @@ export function githubPipelineSource(request, repository) {
|
|
|
113
130
|
baseCommit,
|
|
114
131
|
state: pr.state,
|
|
115
132
|
merged: pr.merged === true,
|
|
116
|
-
mergeCommit: pr
|
|
133
|
+
mergeCommit: mergedCommit(pr),
|
|
117
134
|
draft: pr.draft === true,
|
|
118
135
|
ready: pr.labels.some((label) => label.name === "ready"),
|
|
119
136
|
},
|
|
@@ -141,7 +158,7 @@ export function githubPipelineSource(request, repository) {
|
|
|
141
158
|
targetBranch: pr.base.ref,
|
|
142
159
|
state: pr.state,
|
|
143
160
|
merged: pr.merged === true,
|
|
144
|
-
mergeCommit: pr
|
|
161
|
+
mergeCommit: mergedCommit(pr),
|
|
145
162
|
source: sameHead ? generation.source : null,
|
|
146
163
|
baseCommit: null,
|
|
147
164
|
ready: pr.labels.some((label) => label.name === "ready"),
|
|
@@ -44,6 +44,33 @@ function packageArtifact(directory, output, expected, version, environment) {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
function archiveNames(file, suffix) {
|
|
48
|
+
const options = {
|
|
49
|
+
cwd: path.dirname(file),
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
52
|
+
timeout: 30000,
|
|
53
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
54
|
+
};
|
|
55
|
+
if (suffix === ".zip") {
|
|
56
|
+
const script = [
|
|
57
|
+
"import json,sys,zipfile",
|
|
58
|
+
"with zipfile.ZipFile(sys.argv[1]) as archive:",
|
|
59
|
+
" print(json.dumps(archive.namelist()))",
|
|
60
|
+
].join("\n");
|
|
61
|
+
return JSON.parse(
|
|
62
|
+
execFileSync(
|
|
63
|
+
process.platform === "win32" ? "python" : "python3",
|
|
64
|
+
["-c", script, path.basename(file)],
|
|
65
|
+
options,
|
|
66
|
+
),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
return execFileSync("tar", ["-tf", path.basename(file)], options)
|
|
70
|
+
.trim()
|
|
71
|
+
.split(/\r?\n/u);
|
|
72
|
+
}
|
|
73
|
+
|
|
47
74
|
export function inspectPipelineFileArtifact(directory, expected) {
|
|
48
75
|
const file = publicationPath(directory, expected.path);
|
|
49
76
|
if (expected.kind === "pdf") {
|
|
@@ -60,18 +87,14 @@ export function inspectPipelineFileArtifact(directory, expected) {
|
|
|
60
87
|
);
|
|
61
88
|
if (!suffix)
|
|
62
89
|
throw new Error("Declared binary output must be a standard archive");
|
|
63
|
-
const names =
|
|
64
|
-
cwd: path.dirname(file),
|
|
65
|
-
encoding: "utf8",
|
|
66
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
67
|
-
})
|
|
68
|
-
.trim()
|
|
69
|
-
.split(/\r?\n/u);
|
|
90
|
+
const names = archiveNames(file, suffix);
|
|
70
91
|
if (
|
|
71
92
|
!names[0] ||
|
|
72
93
|
names.some(
|
|
73
94
|
(name) =>
|
|
74
|
-
name.
|
|
95
|
+
!name.trim() ||
|
|
96
|
+
/[\u0000-\u001f\u007f]/u.test(name) ||
|
|
97
|
+
name.replaceAll("\\", "/").startsWith("/") ||
|
|
75
98
|
/^[A-Za-z]:/u.test(name) ||
|
|
76
99
|
name.replaceAll("\\", "/").split("/").includes(".."),
|
|
77
100
|
)
|
|
@@ -5,12 +5,7 @@ import { normalInputs } from "../../consumer/contract/entries.js";
|
|
|
5
5
|
import { pipelineHost } from "./host.js";
|
|
6
6
|
import { controlPipeline } from "./controller.js";
|
|
7
7
|
import { buildPipelineSource } from "./build.js";
|
|
8
|
-
import { recordPipelineBuild } from "./build-control.js";
|
|
9
8
|
import { wakePipelineStableWait } from "../../publication/pipeline/stable-wait.js";
|
|
10
|
-
import {
|
|
11
|
-
RECOVERY_BUILD_CONTEXT,
|
|
12
|
-
recordRecoveryBuild,
|
|
13
|
-
} from "./recovery-build-control.js";
|
|
14
9
|
|
|
15
10
|
export async function controlPipelineAction(core, env) {
|
|
16
11
|
const { configPath } = normalInputs({
|
|
@@ -39,24 +34,6 @@ export async function buildPipelineAction(core, env) {
|
|
|
39
34
|
);
|
|
40
35
|
}
|
|
41
36
|
|
|
42
|
-
export async function recordPipelineBuildAction(core, env) {
|
|
43
|
-
const host = await pipelineHost(core, env, "Record product build");
|
|
44
|
-
const context = JSON.parse(core.getInput("context", { required: true }));
|
|
45
|
-
if (context.schema === "buildchain.pipeline-group-build-context/v1")
|
|
46
|
-
return host.groupRecord(context);
|
|
47
|
-
const result =
|
|
48
|
-
context.schema === RECOVERY_BUILD_CONTEXT
|
|
49
|
-
? await recordRecoveryBuild(context, host)
|
|
50
|
-
: await recordPipelineBuild(context, host);
|
|
51
|
-
if (result.outcome !== "success")
|
|
52
|
-
throw new Error("Product build did not succeed");
|
|
53
|
-
core.info(
|
|
54
|
-
result.wakePending
|
|
55
|
-
? "Build retained; next event will retry delivery wake"
|
|
56
|
-
: "Build retained and attempt woken",
|
|
57
|
-
);
|
|
58
|
-
}
|
|
59
|
-
|
|
60
37
|
export async function wakePipelineAction(core, env) {
|
|
61
38
|
const waiting = core.getInput("stable-wait");
|
|
62
39
|
const host = await pipelineHost(
|
|
@@ -5,6 +5,83 @@ import {
|
|
|
5
5
|
} from "./build-qualification.js";
|
|
6
6
|
import { pipelinePlatforms } from "./platforms.js";
|
|
7
7
|
|
|
8
|
+
const RECOVERY_CHECK = "Buildchain recovery verification";
|
|
9
|
+
|
|
10
|
+
// Dispatch-created Actions checks do not satisfy protected PR checks. Preserve
|
|
11
|
+
// their evidence under a diagnostic name instead of shadowing normal PR checks.
|
|
12
|
+
export async function observePipelineBuildChecks(source, request, repository) {
|
|
13
|
+
if (
|
|
14
|
+
source.repository !== repository ||
|
|
15
|
+
!/^[0-9a-f]{40}$/u.test(source.commit)
|
|
16
|
+
)
|
|
17
|
+
throw new Error("Recovery check repository or source identity drift");
|
|
18
|
+
const base = `/repos/${repository}`,
|
|
19
|
+
renames = [],
|
|
20
|
+
eligible = [];
|
|
21
|
+
for (let page = 1; page <= 20; page++) {
|
|
22
|
+
const result = await request(
|
|
23
|
+
`${base}/commits/${source.commit}/check-runs?filter=all&per_page=100&page=${page}`,
|
|
24
|
+
);
|
|
25
|
+
if (!Array.isArray(result.check_runs) || result.check_runs.length > 100)
|
|
26
|
+
throw new Error("Recovery check inventory is incomplete");
|
|
27
|
+
for (const check of result.check_runs) {
|
|
28
|
+
const match = /^buildchain:(attempt-[0-9a-f]{64}):(\d+):(\d+)$/u.exec(
|
|
29
|
+
check.external_id || "",
|
|
30
|
+
);
|
|
31
|
+
if (
|
|
32
|
+
check.name !== "check" ||
|
|
33
|
+
check.app?.slug !== "github-actions" ||
|
|
34
|
+
!match
|
|
35
|
+
)
|
|
36
|
+
continue;
|
|
37
|
+
const runId = Number(match[2]),
|
|
38
|
+
runAttempt = Number(match[3]);
|
|
39
|
+
if (
|
|
40
|
+
check.head_sha !== source.commit ||
|
|
41
|
+
![check.id, runId, runAttempt].every(
|
|
42
|
+
(value) => Number.isSafeInteger(value) && value > 0,
|
|
43
|
+
)
|
|
44
|
+
)
|
|
45
|
+
throw new Error("Recovery check provider identity drift");
|
|
46
|
+
const run = await request(
|
|
47
|
+
`${base}/actions/runs/${runId}/attempts/${runAttempt}`,
|
|
48
|
+
);
|
|
49
|
+
if (
|
|
50
|
+
run.id !== runId ||
|
|
51
|
+
run.run_attempt !== runAttempt ||
|
|
52
|
+
run.repository?.full_name !== repository
|
|
53
|
+
)
|
|
54
|
+
throw new Error("Recovery check execution identity drift");
|
|
55
|
+
if (["workflow_dispatch", "repository_dispatch"].includes(run.event))
|
|
56
|
+
renames.push({
|
|
57
|
+
id: check.id,
|
|
58
|
+
externalId: check.external_id,
|
|
59
|
+
sourceHead: source.commit,
|
|
60
|
+
runId,
|
|
61
|
+
runAttempt,
|
|
62
|
+
name: RECOVERY_CHECK,
|
|
63
|
+
});
|
|
64
|
+
else if (
|
|
65
|
+
[
|
|
66
|
+
"push",
|
|
67
|
+
"pull_request",
|
|
68
|
+
"pull_request_review",
|
|
69
|
+
"pull_request_target",
|
|
70
|
+
].includes(run.event) &&
|
|
71
|
+
run.head_sha === source.commit
|
|
72
|
+
)
|
|
73
|
+
eligible.push({
|
|
74
|
+
id: check.id,
|
|
75
|
+
runId,
|
|
76
|
+
runAttempt,
|
|
77
|
+
conclusion: check.conclusion,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
if (result.check_runs.length < 100) return { renames, eligible };
|
|
81
|
+
}
|
|
82
|
+
throw new Error("Recovery check inventory exceeds its complete-read bound");
|
|
83
|
+
}
|
|
84
|
+
|
|
8
85
|
// The archive reference is already validated against the current attempt. A
|
|
9
86
|
// build result is still re-read from the provider before it can admit delivery.
|
|
10
87
|
export async function pipelineBuildEvidence(session, host) {
|
|
@@ -74,18 +151,59 @@ export async function publishPipelineBuildCheck(
|
|
|
74
151
|
"Product check source differs from independently verified build",
|
|
75
152
|
);
|
|
76
153
|
const externalId = `buildchain:${context.attempt}:${context.runId}:${context.runAttempt}`;
|
|
154
|
+
const recovered = readback.schema === PIPELINE_BUILD_QUALIFICATION;
|
|
155
|
+
let checkName = recovered ? RECOVERY_CHECK : "check";
|
|
156
|
+
const output = {
|
|
157
|
+
title: recovered
|
|
158
|
+
? "Buildchain recovered product verification"
|
|
159
|
+
: "Buildchain product verification",
|
|
160
|
+
summary: `Exact product jobs: ${readback.root}\nAttempt: ${context.attempt}`,
|
|
161
|
+
};
|
|
162
|
+
if (recovered) {
|
|
163
|
+
const checks = await observePipelineBuildChecks(
|
|
164
|
+
context.source,
|
|
165
|
+
request,
|
|
166
|
+
repository,
|
|
167
|
+
);
|
|
168
|
+
for (const change of checks.renames)
|
|
169
|
+
await request(`/repos/${repository}/check-runs/${change.id}`, {
|
|
170
|
+
method: "PATCH",
|
|
171
|
+
body: { name: change.name },
|
|
172
|
+
});
|
|
173
|
+
const original = checks.eligible.find((check) =>
|
|
174
|
+
readback.segments.some(
|
|
175
|
+
(segment) =>
|
|
176
|
+
segment.readback.runId === check.runId &&
|
|
177
|
+
segment.readback.runAttempt === check.runAttempt,
|
|
178
|
+
),
|
|
179
|
+
);
|
|
180
|
+
// A complete replacement has no reusable original check to project onto.
|
|
181
|
+
// Retain that failed execution and publish this independently qualified
|
|
182
|
+
// aggregate as the required check. Protected landing still requalifies it
|
|
183
|
+
// and publishes the App-bound commit status before queue admission.
|
|
184
|
+
if (!original) checkName = "check";
|
|
185
|
+
// Keep the eligible normal-event check identity. Only a fully requalified
|
|
186
|
+
// source aggregate can repair its failed projection; original runs and
|
|
187
|
+
// immutable build receipts still retain their original failure outcomes.
|
|
188
|
+
if (
|
|
189
|
+
original &&
|
|
190
|
+
original.conclusion !== "success" &&
|
|
191
|
+
readback.outcome === "success"
|
|
192
|
+
)
|
|
193
|
+
await request(`/repos/${repository}/check-runs/${original.id}`, {
|
|
194
|
+
method: "PATCH",
|
|
195
|
+
body: { status: "completed", conclusion: "success", output },
|
|
196
|
+
});
|
|
197
|
+
}
|
|
77
198
|
return request(`/repos/${repository}/check-runs`, {
|
|
78
199
|
method: "POST",
|
|
79
200
|
body: {
|
|
80
|
-
name:
|
|
201
|
+
name: checkName,
|
|
81
202
|
head_sha: context.source.commit,
|
|
82
203
|
external_id: externalId,
|
|
83
204
|
status: "completed",
|
|
84
205
|
conclusion: readback.outcome === "success" ? "success" : "failure",
|
|
85
|
-
output
|
|
86
|
-
title: "Buildchain product verification",
|
|
87
|
-
summary: `Exact product jobs: ${readback.root}\nAttempt: ${context.attempt}`,
|
|
88
|
-
},
|
|
206
|
+
output,
|
|
89
207
|
details_url: `https://github.com/${repository}/actions/runs/${context.runId}/attempts/${context.runAttempt}`,
|
|
90
208
|
},
|
|
91
209
|
});
|
|
@@ -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) {
|