@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.
- package/README.md +4 -4
- package/actions/promote-buildchain-ref/README.md +8 -0
- package/bin/buildchain.mjs +13 -1
- package/contracts/auditable-demo-scenario-v1.schema.json +52 -0
- package/dist/site/buildchain-contract.json +47 -27
- package/dist/site/buildchain-site.json +158 -42
- package/dist/site/capability-registry.json +3 -3
- package/dist/site/cli-registry.json +40 -4
- package/dist/site/controller-registry.json +20 -4
- package/dist/site/kfd-claims.json +139 -19
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +9 -9
- package/dist/site/node-api-registry.json +1161 -180
- package/dist/site/page-registry.json +145 -29
- package/dist/site/public-surface-audit.json +385 -19
- package/dist/site/publication-authority-registry.json +61 -1
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/release-provenance.json +1 -0
- package/dist/site/site-manifest.json +13 -13
- package/dist/site/workflow-registry.json +150 -13
- package/docs/MAP.md +1 -0
- package/docs/auditable-demo.md +58 -11
- package/docs/aws-us-elastic-runner-burst-plane.md +23 -19
- package/docs/cli-reference.md +154 -0
- package/docs/dev-alpha-candidate-patrol.md +13 -5
- package/docs/dev-delivery-warrant.md +158 -0
- package/docs/node-api-reference.md +54 -15
- package/docs/publication-authority.md +11 -0
- package/docs/release-candidate.md +19 -2
- package/docs/release-governance.md +52 -0
- package/docs/reusable-build-surface.md +11 -1
- package/docs/shifu-gate-profiles.md +12 -1
- package/docs/versioning.md +2 -0
- package/package.json +2 -1
- package/packages/core/buildchain-publication-authority.js +3 -1
- package/packages/core/channel-candidate.js +2 -21
- package/packages/core/channel-promotion-baseline.js +199 -0
- package/packages/core/dev-delivery-candidate-identity.js +94 -0
- package/packages/core/dev-delivery-common.js +73 -0
- package/packages/core/dev-delivery-proof.js +252 -0
- package/packages/core/dev-delivery-warrant-cancellation.js +94 -0
- package/packages/core/dev-delivery-warrant-settlement.js +73 -0
- package/packages/core/dev-delivery-warrant.js +591 -0
- package/scripts/auditable-demo-bundle-verification.mjs +148 -0
- package/scripts/auditable-demo-platform.mjs +86 -50
- package/scripts/auditable-demo-presentation.mjs +83 -0
- package/scripts/auditable-demo-renditions.mjs +264 -0
- package/scripts/auditable-demo.mjs +24 -30
- package/scripts/build-contract-core.mjs +58 -3
- package/scripts/buildchain-cli-help.mjs +8 -0
- package/scripts/buildchain-patrol.mjs +9 -0
- package/scripts/check-inventory.mjs +1 -0
- package/scripts/dev-alpha-candidate-patrol.mjs +45 -48
- package/scripts/dev-delivery-proof.mjs +193 -0
- package/scripts/dev-delivery-warrant.mjs +426 -0
- package/scripts/dev-pr-auto-merge.mjs +488 -46
- package/scripts/dev-pr-delivery-warrant.mjs +209 -0
- package/scripts/gate-profile-core.mjs +24 -0
- package/scripts/generate-site-bundle.mjs +2 -2
- package/scripts/git-fetch-process-tree.mjs +142 -0
- package/scripts/lifecycle-substage-evidence.mjs +274 -0
- package/scripts/locked-source-checkout.mjs +6 -3
- package/scripts/resolve-artifact-transfer-mode.mjs +9 -0
- package/scripts/resolve-build-contract.mjs +7 -0
- package/scripts/route-offline-runners.mjs +1 -0
- package/scripts/run-lifecycle-core.mjs +9 -9
- package/scripts/shifu-gate-profile.mjs +10 -16
- package/scripts/site-capability-metadata.mjs +14 -0
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
const ROOT_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
4
|
+
const SHA_PATTERN = /^[0-9a-f]{40}$/;
|
|
5
|
+
const ADMISSION_SCHEMA = "kungfu.buildchain.dev-pr-admission/v1";
|
|
6
|
+
|
|
7
|
+
function mismatch(code) {
|
|
8
|
+
const error = new Error(code);
|
|
9
|
+
error.code = code;
|
|
10
|
+
throw error;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function requireMatch(condition, code) {
|
|
14
|
+
if (!condition) mismatch(code);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readWarrantResult(file) {
|
|
18
|
+
try {
|
|
19
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
20
|
+
} catch (cause) {
|
|
21
|
+
const error = new Error(`delivery Warrant result is unreadable: ${cause.message}`);
|
|
22
|
+
error.code = "invalid-delivery-warrant-result";
|
|
23
|
+
throw error;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function exactActiveReadback(result) {
|
|
28
|
+
requireMatch(result.schema === "kungfu.buildchain.dev-delivery-command-result/v1", "unsupported-delivery-warrant-result");
|
|
29
|
+
requireMatch(result.mode === "execute", "delivery-warrant-not-executed");
|
|
30
|
+
requireMatch(SHA_PATTERN.test(String(result.after?.commitSha || "")), "delivery-warrant-commit-readback-missing");
|
|
31
|
+
requireMatch(ROOT_PATTERN.test(String(result.after?.stateRoot || "")), "delivery-warrant-state-readback-missing");
|
|
32
|
+
requireMatch(result.observation?.schema === "kungfu.buildchain.dev-delivery-queue-observation/v1", "delivery-warrant-observation-missing");
|
|
33
|
+
requireMatch(result.observation.stateRoot === result.after.stateRoot, "delivery-warrant-observation-root-mismatch");
|
|
34
|
+
const warrant = result.observation.activeWarrant;
|
|
35
|
+
const candidate = result.observation.activeCandidate;
|
|
36
|
+
requireMatch(warrant?.schema === "kungfu.buildchain.dev-delivery-warrant/v1", "delivery-warrant-missing");
|
|
37
|
+
requireMatch(candidate?.candidateId === warrant.candidateId, "delivery-warrant-candidate-readback-missing");
|
|
38
|
+
requireMatch(!result.warrant || JSON.stringify(result.warrant) === JSON.stringify(warrant), "delivery-warrant-readback-mismatch");
|
|
39
|
+
return { warrant, candidate };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function exactWarrantBinding({ result, warrant, candidate, options, pullRequest }) {
|
|
43
|
+
requireMatch(warrant.repository === options.repository.fullName, "delivery-warrant-repository-mismatch");
|
|
44
|
+
requireMatch(warrant.protectedBase === options.targetBranch, "delivery-warrant-base-mismatch");
|
|
45
|
+
requireMatch(Number(warrant.pullRequestNumber) === Number(pullRequest.number), "delivery-warrant-pr-mismatch");
|
|
46
|
+
requireMatch(String(warrant.sourceHead || "").toLowerCase() === options.expectedHeadSha, "delivery-warrant-head-mismatch");
|
|
47
|
+
requireMatch(Number(candidate.pullRequestNumber) === Number(pullRequest.number), "delivery-warrant-candidate-pr-mismatch");
|
|
48
|
+
requireMatch(String(candidate.sourceHead || "").toLowerCase() === options.expectedHeadSha, "delivery-warrant-candidate-head-mismatch");
|
|
49
|
+
requireMatch(ROOT_PATTERN.test(String(warrant.fencingToken || "")), "delivery-warrant-fencing-missing");
|
|
50
|
+
requireMatch(Number.isInteger(Number(warrant.generation)) && Number(warrant.generation) >= 1, "delivery-warrant-generation-invalid");
|
|
51
|
+
requireMatch(Number.isFinite(Date.parse(warrant.expiresAt)) && Date.parse(warrant.expiresAt) > Date.now(), "delivery-warrant-expired");
|
|
52
|
+
requireMatch(ROOT_PATTERN.test(String(result.receiptRoot || "")), "delivery-warrant-receipt-root-missing");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createDevPrAdmissionReceipt({ options, pr = {}, state, reason, readiness, decision = {}, queue = null, warrant = null, labels = [], nextAction }) {
|
|
56
|
+
const observedHeadSha = String(pr.head?.sha || "").toLowerCase();
|
|
57
|
+
return {
|
|
58
|
+
schema: ADMISSION_SCHEMA,
|
|
59
|
+
repository: options.repository.fullName,
|
|
60
|
+
targetBranch: options.targetBranch,
|
|
61
|
+
pullRequestNumber: options.targetPullRequestNumber,
|
|
62
|
+
pullRequestUrl: pr.html_url || `https://github.com/${options.repository.fullName}/pull/${options.targetPullRequestNumber}`,
|
|
63
|
+
expectedHeadSha: options.expectedHeadSha,
|
|
64
|
+
observedHeadSha,
|
|
65
|
+
observedBaseBranch: pr.base?.ref || "",
|
|
66
|
+
headRepository: pr.head?.repo?.full_name || "",
|
|
67
|
+
headRef: pr.head?.ref || "",
|
|
68
|
+
observedLabels: [...labels].sort(),
|
|
69
|
+
policy: {
|
|
70
|
+
readyLabel: options.readyLabel,
|
|
71
|
+
blockLabels: options.blockLabels,
|
|
72
|
+
allowedHeadPrefixes: options.allowedHeadPrefixes,
|
|
73
|
+
requiredChecks: options.requiredChecks,
|
|
74
|
+
requireApproval: options.requireApproval,
|
|
75
|
+
sameRepositoryOnly: options.sameRepositoryOnly,
|
|
76
|
+
landingMode: options.landingMode,
|
|
77
|
+
queueAdmissionContext: options.queueAdmissionContext,
|
|
78
|
+
diagnosticContext: options.diagnosticContext,
|
|
79
|
+
},
|
|
80
|
+
readiness: {
|
|
81
|
+
label: options.readyLabel,
|
|
82
|
+
observed: readiness?.observed === true,
|
|
83
|
+
established: readiness?.established === true,
|
|
84
|
+
mutationAuthorized: !options.dryRun,
|
|
85
|
+
},
|
|
86
|
+
approval: decision.approval || { required: options.requireApproval, passed: false },
|
|
87
|
+
checks: decision.checks || { required: options.requiredChecks, entries: [], passed: false },
|
|
88
|
+
projectCut: decision.projectCut || null,
|
|
89
|
+
queue,
|
|
90
|
+
deliveryWarrant: warrant,
|
|
91
|
+
autoMergeEnabled: Boolean(pr.auto_merge || pr.autoMergeRequest),
|
|
92
|
+
state,
|
|
93
|
+
reason,
|
|
94
|
+
decision: state,
|
|
95
|
+
qualification: ["ready", "queued"].includes(state),
|
|
96
|
+
nextAction: nextAction({ options, state, reason, observedHeadSha }),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function readDeliveryWarrantResult(options, pullRequest) {
|
|
101
|
+
if (options.warrantMode === "off") return null;
|
|
102
|
+
if (!options.warrantResultPath) mismatch("missing-delivery-warrant");
|
|
103
|
+
const result = readWarrantResult(options.warrantResultPath);
|
|
104
|
+
const { warrant, candidate } = exactActiveReadback(result);
|
|
105
|
+
exactWarrantBinding({ result, warrant, candidate, options, pullRequest });
|
|
106
|
+
return {
|
|
107
|
+
stateRef: result.stateRef || "",
|
|
108
|
+
stateCommit: result.after.commitSha,
|
|
109
|
+
stateRoot: result.after.stateRoot,
|
|
110
|
+
receiptRoot: result.receiptRoot,
|
|
111
|
+
candidateId: warrant.candidateId,
|
|
112
|
+
fencingToken: warrant.fencingToken,
|
|
113
|
+
generation: warrant.generation,
|
|
114
|
+
issuedAt: warrant.issuedAt,
|
|
115
|
+
expiresAt: warrant.expiresAt,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export async function runSourceQualification({ options, pullRequest, readiness, client, evaluate, admissionState, createReceipt, root, publishDiagnostic, reject }) {
|
|
120
|
+
const decision = await evaluate(pullRequest, { ...options, landingMode: options.landingMode, dryRun: true }, client);
|
|
121
|
+
if (decision.observedHeadSha && String(decision.observedHeadSha).toLowerCase() !== options.expectedHeadSha) {
|
|
122
|
+
return reject("stale", "head-sha-drift-during-source-qualification", readiness);
|
|
123
|
+
}
|
|
124
|
+
const state = decision.action === "would-merge" ? "ready" : admissionState(decision);
|
|
125
|
+
const receipt = createReceipt({
|
|
126
|
+
options,
|
|
127
|
+
pr: pullRequest,
|
|
128
|
+
state,
|
|
129
|
+
reason: state === "ready" ? "source-qualified-exact-head" : decision.reason,
|
|
130
|
+
readiness,
|
|
131
|
+
decision,
|
|
132
|
+
queue: null,
|
|
133
|
+
warrant: null,
|
|
134
|
+
});
|
|
135
|
+
const result = {
|
|
136
|
+
schema: "kungfu.buildchain.dev-pr-admission-result/v1",
|
|
137
|
+
ok: state === "ready",
|
|
138
|
+
mode: options.dryRun ? "plan" : "execute",
|
|
139
|
+
outcome: state === "ready" ? "source-qualified" : "targeted-admission-failed",
|
|
140
|
+
receipt,
|
|
141
|
+
receiptRoot: root(receipt),
|
|
142
|
+
diagnostic: null,
|
|
143
|
+
};
|
|
144
|
+
if (!options.dryRun) result.diagnostic = await publishDiagnostic(client, options, receipt, result.receiptRoot);
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export async function admitExistingQueueEntry({ options, pullRequest, readiness, client, entry, warrant, createReceipt, root, publishDiagnostic }) {
|
|
149
|
+
if (!entry) return null;
|
|
150
|
+
const receipt = createReceipt({
|
|
151
|
+
options,
|
|
152
|
+
pr: pullRequest,
|
|
153
|
+
state: "queued",
|
|
154
|
+
reason: "already-enqueued-exact-head",
|
|
155
|
+
readiness,
|
|
156
|
+
queue: { enabled: true, entry },
|
|
157
|
+
warrant,
|
|
158
|
+
});
|
|
159
|
+
const result = {
|
|
160
|
+
schema: "kungfu.buildchain.dev-pr-admission-result/v1",
|
|
161
|
+
ok: true,
|
|
162
|
+
mode: options.dryRun ? "plan" : "execute",
|
|
163
|
+
outcome: "admitted",
|
|
164
|
+
receipt,
|
|
165
|
+
receiptRoot: root(receipt),
|
|
166
|
+
diagnostic: null,
|
|
167
|
+
};
|
|
168
|
+
if (!options.dryRun) result.diagnostic = await publishDiagnostic(client, options, receipt, result.receiptRoot);
|
|
169
|
+
return result;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export async function runTargetedQueueAdmission({ options, pullRequest, readiness, client, warrant, runController, admissionState, createReceipt, root, publishDiagnostic }) {
|
|
173
|
+
const targetedClient = Object.create(client);
|
|
174
|
+
targetedClient.listPullRequests = async () => [pullRequest];
|
|
175
|
+
const controller = await runController({ ...options, targetPullRequestNumber: 0 }, targetedClient);
|
|
176
|
+
controller.runKind = "targeted-admission-evaluation";
|
|
177
|
+
controller.outcome = controller.actions.length === 0 ? "target-not-admitted" : "target-action-selected";
|
|
178
|
+
controller.qualification = false;
|
|
179
|
+
controller.noOp = controller.actions.length === 0;
|
|
180
|
+
const entry = controller.evaluated.find((value) => value.number === pullRequest.number) || { action: "skip", reason: "target-not-selected" };
|
|
181
|
+
const state = admissionState(entry);
|
|
182
|
+
const receipt = createReceipt({
|
|
183
|
+
options,
|
|
184
|
+
pr: pullRequest,
|
|
185
|
+
state,
|
|
186
|
+
reason: entry.reason,
|
|
187
|
+
readiness,
|
|
188
|
+
decision: entry,
|
|
189
|
+
queue: {
|
|
190
|
+
enabled: controller.mergeQueue?.enabled === true,
|
|
191
|
+
predecessor: entry.admissionReceipt?.predecessor || null,
|
|
192
|
+
entry: entry.queueEntry || null,
|
|
193
|
+
},
|
|
194
|
+
warrant,
|
|
195
|
+
});
|
|
196
|
+
const admitted = ["ready", "queued"].includes(state);
|
|
197
|
+
const result = {
|
|
198
|
+
schema: "kungfu.buildchain.dev-pr-admission-result/v1",
|
|
199
|
+
ok: admitted,
|
|
200
|
+
mode: options.dryRun ? "plan" : "execute",
|
|
201
|
+
outcome: admitted ? "admitted" : "targeted-admission-failed",
|
|
202
|
+
receipt,
|
|
203
|
+
receiptRoot: root(receipt),
|
|
204
|
+
diagnostic: null,
|
|
205
|
+
controller,
|
|
206
|
+
};
|
|
207
|
+
if (!options.dryRun) result.diagnostic = await publishDiagnostic(client, options, receipt, result.receiptRoot);
|
|
208
|
+
return result;
|
|
209
|
+
}
|
|
@@ -67,6 +67,25 @@ function uniqueStrings(value, label) {
|
|
|
67
67
|
return normalized;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
export function normalizeGateEnvironment(value = {}, label = "environment") {
|
|
71
|
+
const environment = assertObject(value, label);
|
|
72
|
+
return Object.fromEntries(
|
|
73
|
+
Object.entries(environment)
|
|
74
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
75
|
+
.map(([name, entry]) => {
|
|
76
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) {
|
|
77
|
+
throw new Error(`${label} has invalid environment name: ${name}`);
|
|
78
|
+
}
|
|
79
|
+
if (!["string", "number", "boolean"].includes(typeof entry)) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
`${label}.${name} must be a string, number, or boolean`,
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
return [name, String(entry)];
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
70
89
|
function inferShifuPlatform(platform) {
|
|
71
90
|
const explicit = String(platform.platform || "")
|
|
72
91
|
.trim()
|
|
@@ -121,6 +140,10 @@ export function normalizeGatePlatform(platform, index = 0) {
|
|
|
121
140
|
platform.capabilities || ["node"],
|
|
122
141
|
`platforms[${index}].capabilities`,
|
|
123
142
|
).sort(),
|
|
143
|
+
environment: normalizeGateEnvironment(
|
|
144
|
+
platform.environment || {},
|
|
145
|
+
`platforms[${index}].environment`,
|
|
146
|
+
),
|
|
124
147
|
required: platform.required !== false,
|
|
125
148
|
};
|
|
126
149
|
}
|
|
@@ -259,6 +282,7 @@ export function createGateExecutionMatrix({
|
|
|
259
282
|
platform: platform.platform,
|
|
260
283
|
runner: platform.runner,
|
|
261
284
|
capabilities: platform.capabilities,
|
|
285
|
+
environment: platform.environment,
|
|
262
286
|
required: platform.required,
|
|
263
287
|
profile,
|
|
264
288
|
includeAdvisory: Boolean(includeAdvisory),
|
|
@@ -56,7 +56,6 @@ const requireFromHere = createRequire(import.meta.url);
|
|
|
56
56
|
function readText(rel) {
|
|
57
57
|
return fs.readFileSync(path.join(root, rel), "utf8");
|
|
58
58
|
}
|
|
59
|
-
|
|
60
59
|
function readJson(rel) {
|
|
61
60
|
return JSON.parse(readText(rel));
|
|
62
61
|
}
|
|
@@ -518,7 +517,7 @@ function workflowCapabilityGroup(entry) {
|
|
|
518
517
|
if (["web-surface", "release-propagation"].includes(entry.id)) return capabilityGroup("site-and-propagation");
|
|
519
518
|
if (["build", "release-candidate-promote", "publication-artifact", "paper-release"].includes(entry.id)) return capabilityGroup("reusable-build");
|
|
520
519
|
if (["buildchain-ref-promotion", "release-line-bootstrap"].includes(entry.id)) return capabilityGroup("release-passport-trust");
|
|
521
|
-
if (entry.id.includes("patrol") || entry.id.includes("dev-pr-auto-merge")) return capabilityGroup("governance-versioning");
|
|
520
|
+
if (entry.id.includes("patrol") || entry.id.includes("dev-pr-auto-merge") || entry.id.includes("dev-delivery-warrant") || entry.id.includes("buildchain-dev-delivery")) return capabilityGroup("governance-versioning");
|
|
522
521
|
if (entry.status === "repository-internal" || entry.status === "compatibility-fixture") return capabilityGroup("api-cli-reference");
|
|
523
522
|
return capabilityGroup("api-cli-reference");
|
|
524
523
|
}
|
|
@@ -778,6 +777,7 @@ function buildSiteBundle() {
|
|
|
778
777
|
["paper-release", "reusable-build"],
|
|
779
778
|
["release-propagation", "release-propagation"],
|
|
780
779
|
["dev-pr-auto-merge", "dev-governance"],
|
|
780
|
+
["buildchain-dev-delivery", "dev-governance"],
|
|
781
781
|
["github-governance-audit", "dev-governance"],
|
|
782
782
|
["binary-distribution", "release-passport"],
|
|
783
783
|
["github-artifact-attestation", "release-passport"],
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
5
|
+
|
|
6
|
+
const INTERNAL_COMMAND = "--buildchain-internal-git-fetch";
|
|
7
|
+
const TIMEOUT_EXIT_CODE = 124;
|
|
8
|
+
const scriptPath = fileURLToPath(import.meta.url);
|
|
9
|
+
|
|
10
|
+
async function terminateProcessTree(child, graceMs) {
|
|
11
|
+
if (!child?.pid) return;
|
|
12
|
+
if (process.platform === "win32") {
|
|
13
|
+
await new Promise((resolve) => {
|
|
14
|
+
const killer = spawn(
|
|
15
|
+
"taskkill",
|
|
16
|
+
["/pid", String(child.pid), "/t", "/f"],
|
|
17
|
+
{
|
|
18
|
+
stdio: "ignore",
|
|
19
|
+
windowsHide: true,
|
|
20
|
+
},
|
|
21
|
+
);
|
|
22
|
+
const fallback = setTimeout(() => {
|
|
23
|
+
try {
|
|
24
|
+
child.kill("SIGKILL");
|
|
25
|
+
} catch {
|
|
26
|
+
// The process tree may already be gone.
|
|
27
|
+
}
|
|
28
|
+
resolve();
|
|
29
|
+
}, graceMs);
|
|
30
|
+
killer.once("error", () => {
|
|
31
|
+
clearTimeout(fallback);
|
|
32
|
+
try {
|
|
33
|
+
child.kill("SIGKILL");
|
|
34
|
+
} catch {
|
|
35
|
+
// The process tree may already be gone.
|
|
36
|
+
}
|
|
37
|
+
resolve();
|
|
38
|
+
});
|
|
39
|
+
killer.once("close", () => {
|
|
40
|
+
clearTimeout(fallback);
|
|
41
|
+
resolve();
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
process.kill(-child.pid, "SIGTERM");
|
|
48
|
+
} catch {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
await new Promise((resolve) => setTimeout(resolve, graceMs));
|
|
52
|
+
try {
|
|
53
|
+
process.kill(-child.pid, "SIGKILL");
|
|
54
|
+
} catch {
|
|
55
|
+
// The process group exited during the grace period.
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function runInternalGitFetch() {
|
|
60
|
+
const payload = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
61
|
+
const timeoutMs = Math.max(1, Number(payload.timeoutMs || 60000));
|
|
62
|
+
const graceMs = Math.max(
|
|
63
|
+
50,
|
|
64
|
+
Number(process.env.BUILDCHAIN_GIT_TIMEOUT_GRACE_MS || 2000),
|
|
65
|
+
);
|
|
66
|
+
const child = spawn("git", payload.args, {
|
|
67
|
+
cwd: payload.cwd,
|
|
68
|
+
env: process.env,
|
|
69
|
+
detached: process.platform !== "win32",
|
|
70
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
71
|
+
windowsHide: true,
|
|
72
|
+
});
|
|
73
|
+
const stdout = [];
|
|
74
|
+
const stderr = [];
|
|
75
|
+
child.stdout.on("data", (chunk) => stdout.push(chunk));
|
|
76
|
+
child.stderr.on("data", (chunk) => stderr.push(chunk));
|
|
77
|
+
let timedOut = false;
|
|
78
|
+
let spawnError;
|
|
79
|
+
const closed = new Promise((resolve) => {
|
|
80
|
+
child.once("error", (error) => {
|
|
81
|
+
spawnError = error;
|
|
82
|
+
resolve({ code: 1, signal: "" });
|
|
83
|
+
});
|
|
84
|
+
child.once("close", (code, signal) => resolve({ code, signal }));
|
|
85
|
+
});
|
|
86
|
+
const timer = setTimeout(() => {
|
|
87
|
+
timedOut = true;
|
|
88
|
+
void terminateProcessTree(child, graceMs);
|
|
89
|
+
}, timeoutMs);
|
|
90
|
+
const result = await closed;
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
if (stdout.length) process.stdout.write(Buffer.concat(stdout));
|
|
93
|
+
if (stderr.length) process.stderr.write(Buffer.concat(stderr));
|
|
94
|
+
if (spawnError)
|
|
95
|
+
console.error(
|
|
96
|
+
`buildchain: failed to start git fetch: ${spawnError.message}`,
|
|
97
|
+
);
|
|
98
|
+
if (timedOut)
|
|
99
|
+
console.error(
|
|
100
|
+
`buildchain: git fetch timed out after ${timeoutMs}ms; process tree terminated`,
|
|
101
|
+
);
|
|
102
|
+
if (result.signal && !timedOut)
|
|
103
|
+
console.error(`buildchain: git fetch terminated by ${result.signal}`);
|
|
104
|
+
process.exitCode = timedOut
|
|
105
|
+
? TIMEOUT_EXIT_CODE
|
|
106
|
+
: spawnError || result.signal
|
|
107
|
+
? 1
|
|
108
|
+
: (result.code ?? 1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function runGitFetchSync({ args, cwd, env, timeoutMs, stdio }) {
|
|
112
|
+
const commandStdio = Array.isArray(stdio)
|
|
113
|
+
? ["pipe", stdio[1] || "pipe", stdio[2] || "pipe"]
|
|
114
|
+
: ["pipe", stdio, stdio];
|
|
115
|
+
try {
|
|
116
|
+
const output = execFileSync(
|
|
117
|
+
process.execPath,
|
|
118
|
+
[scriptPath, INTERNAL_COMMAND],
|
|
119
|
+
{
|
|
120
|
+
cwd,
|
|
121
|
+
env,
|
|
122
|
+
encoding: "utf8",
|
|
123
|
+
stdio: commandStdio,
|
|
124
|
+
input: JSON.stringify({ args, cwd, timeoutMs }),
|
|
125
|
+
windowsHide: true,
|
|
126
|
+
},
|
|
127
|
+
);
|
|
128
|
+
return output ? String(output).trim() : "";
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (error?.status === TIMEOUT_EXIT_CODE) error.code = "ETIMEDOUT";
|
|
131
|
+
throw error;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (
|
|
136
|
+
process.argv[1] &&
|
|
137
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
138
|
+
) {
|
|
139
|
+
if (process.argv[2] !== INTERNAL_COMMAND)
|
|
140
|
+
throw new Error("internal git fetch command required");
|
|
141
|
+
await runInternalGitFetch();
|
|
142
|
+
}
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import crypto from "node:crypto";
|
|
4
|
+
import fs from "node:fs";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
|
|
8
|
+
const ROOT_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
|
9
|
+
const SHA_PATTERN = /^[0-9a-f]{40}$/u;
|
|
10
|
+
|
|
11
|
+
function canonical(value) {
|
|
12
|
+
if (Array.isArray(value)) return value.map(canonical);
|
|
13
|
+
if (value && typeof value === "object") {
|
|
14
|
+
return Object.fromEntries(
|
|
15
|
+
Object.entries(value)
|
|
16
|
+
.sort(([left], [right]) => left.localeCompare(right))
|
|
17
|
+
.map(([key, item]) => [key, canonical(item)]),
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function digest(value) {
|
|
24
|
+
return `sha256:${crypto
|
|
25
|
+
.createHash("sha256")
|
|
26
|
+
.update(JSON.stringify(canonical(value)))
|
|
27
|
+
.digest("hex")}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function withoutRoot(value, field = "evidenceRoot") {
|
|
31
|
+
const body = structuredClone(value);
|
|
32
|
+
Reflect.deleteProperty(body, field);
|
|
33
|
+
return body;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function requireIso(value, label) {
|
|
37
|
+
if (typeof value !== "string" || !Number.isFinite(Date.parse(value))) {
|
|
38
|
+
throw new Error(`${label} must be an ISO timestamp`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function verifyEvidenceHeader(
|
|
43
|
+
evidence,
|
|
44
|
+
{ lifecycleStage, sourceSha, sourceTree, platformId },
|
|
45
|
+
) {
|
|
46
|
+
if (evidence?.schema !== "kungfu.lifecycle-substage-evidence/v1") {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`unsupported lifecycle substage evidence schema: ${evidence?.schema || "missing"}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
if (!ROOT_PATTERN.test(evidence.evidenceRoot || "")) {
|
|
52
|
+
throw new Error("lifecycle substage evidence root is invalid");
|
|
53
|
+
}
|
|
54
|
+
if (evidence.evidenceRoot !== digest(withoutRoot(evidence))) {
|
|
55
|
+
throw new Error("lifecycle substage evidence root mismatch");
|
|
56
|
+
}
|
|
57
|
+
if (lifecycleStage && evidence.lifecycleStage !== lifecycleStage) {
|
|
58
|
+
throw new Error(
|
|
59
|
+
`lifecycle stage mismatch: expected ${lifecycleStage}, got ${evidence.lifecycleStage}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
!SHA_PATTERN.test(evidence.source?.sha || "") ||
|
|
64
|
+
!SHA_PATTERN.test(evidence.source?.tree || "")
|
|
65
|
+
) {
|
|
66
|
+
throw new Error("lifecycle substage source identity is invalid");
|
|
67
|
+
}
|
|
68
|
+
if (sourceSha && evidence.source.sha !== sourceSha) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`lifecycle substage source SHA mismatch: expected ${sourceSha}, got ${evidence.source.sha}`,
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
if (sourceTree && evidence.source.tree !== sourceTree) {
|
|
74
|
+
throw new Error(
|
|
75
|
+
`lifecycle substage source tree mismatch: expected ${sourceTree}, got ${evidence.source.tree}`,
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
if (platformId && evidence.platform?.id !== platformId) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`lifecycle substage platform mismatch: expected ${platformId}, got ${evidence.platform?.id}`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
if (!["passed", "failed"].includes(evidence.conclusion)) {
|
|
84
|
+
throw new Error("lifecycle substage conclusion must be passed or failed");
|
|
85
|
+
}
|
|
86
|
+
requireIso(evidence.startedAt, "lifecycle substage startedAt");
|
|
87
|
+
requireIso(evidence.completedAt, "lifecycle substage completedAt");
|
|
88
|
+
if (!Array.isArray(evidence.substages) || evidence.substages.length === 0) {
|
|
89
|
+
throw new Error("lifecycle substage evidence has no substages");
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function verifySubstage(substage, index, names) {
|
|
94
|
+
if (typeof substage.stage !== "string" || !substage.stage) {
|
|
95
|
+
throw new Error(`substage ${index} has no name`);
|
|
96
|
+
}
|
|
97
|
+
if (names.has(substage.stage)) {
|
|
98
|
+
throw new Error(`duplicate lifecycle substage: ${substage.stage}`);
|
|
99
|
+
}
|
|
100
|
+
names.add(substage.stage);
|
|
101
|
+
requireIso(substage.startedAt, `${substage.stage}.startedAt`);
|
|
102
|
+
requireIso(substage.completedAt, `${substage.stage}.completedAt`);
|
|
103
|
+
if (
|
|
104
|
+
!Number.isFinite(substage.durationSeconds) ||
|
|
105
|
+
substage.durationSeconds < 0
|
|
106
|
+
) {
|
|
107
|
+
throw new Error(`${substage.stage}.durationSeconds is invalid`);
|
|
108
|
+
}
|
|
109
|
+
if (
|
|
110
|
+
!Number.isInteger(substage.status) ||
|
|
111
|
+
!["passed", "failed"].includes(substage.conclusion)
|
|
112
|
+
) {
|
|
113
|
+
throw new Error(`${substage.stage} result is invalid`);
|
|
114
|
+
}
|
|
115
|
+
if ((substage.status === 0) !== (substage.conclusion === "passed")) {
|
|
116
|
+
throw new Error(`${substage.stage} status and conclusion disagree`);
|
|
117
|
+
}
|
|
118
|
+
if (
|
|
119
|
+
!["platform-native", "exact-source-reuse"].includes(substage.executionMode)
|
|
120
|
+
) {
|
|
121
|
+
throw new Error(`${substage.stage}.executionMode is invalid`);
|
|
122
|
+
}
|
|
123
|
+
if (
|
|
124
|
+
!ROOT_PATTERN.test(substage.evidenceRoot || "") ||
|
|
125
|
+
substage.evidenceRoot !== digest(withoutRoot(substage))
|
|
126
|
+
) {
|
|
127
|
+
throw new Error(`${substage.stage} evidence root mismatch`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function verifyAggregate(evidence) {
|
|
132
|
+
const failed = evidence.substages.some((substage) => substage.status !== 0);
|
|
133
|
+
const expectedFailureReason = failed
|
|
134
|
+
? "substage-failed"
|
|
135
|
+
: evidence.conclusion === "failed"
|
|
136
|
+
? "budget-exceeded"
|
|
137
|
+
: undefined;
|
|
138
|
+
if (
|
|
139
|
+
(failed && evidence.conclusion !== "failed") ||
|
|
140
|
+
(!failed && evidence.conclusion === "passed" && evidence.failureReason) ||
|
|
141
|
+
evidence.failureReason !== expectedFailureReason
|
|
142
|
+
) {
|
|
143
|
+
throw new Error("lifecycle substage aggregate conclusion is inconsistent");
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function verifyLifecycleSubstageEvidence(
|
|
148
|
+
value,
|
|
149
|
+
{
|
|
150
|
+
lifecycleStage = "",
|
|
151
|
+
sourceSha = "",
|
|
152
|
+
sourceTree = "",
|
|
153
|
+
platformId = "",
|
|
154
|
+
} = {},
|
|
155
|
+
) {
|
|
156
|
+
const evidence = value?.substageEvidence || value;
|
|
157
|
+
verifyEvidenceHeader(evidence, {
|
|
158
|
+
lifecycleStage,
|
|
159
|
+
sourceSha,
|
|
160
|
+
sourceTree,
|
|
161
|
+
platformId,
|
|
162
|
+
});
|
|
163
|
+
const names = new Set();
|
|
164
|
+
for (const [index, substage] of evidence.substages.entries()) {
|
|
165
|
+
verifySubstage(substage, index, names);
|
|
166
|
+
}
|
|
167
|
+
verifyAggregate(evidence);
|
|
168
|
+
return structuredClone(evidence);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function lifecycleSubstageEvidenceContext({
|
|
172
|
+
substageEvidencePath = "",
|
|
173
|
+
cwd,
|
|
174
|
+
workspace,
|
|
175
|
+
diagnosticsDir,
|
|
176
|
+
lifecycleStage,
|
|
177
|
+
sourceSha = process.env.BUILDCHAIN_SOURCE_SHA || "",
|
|
178
|
+
sourceTree = process.env.BUILDCHAIN_SOURCE_TREE_SHA || "",
|
|
179
|
+
platformId,
|
|
180
|
+
}) {
|
|
181
|
+
if (!substageEvidencePath) {
|
|
182
|
+
return {
|
|
183
|
+
evidence: undefined,
|
|
184
|
+
observability: {},
|
|
185
|
+
lifecycle: {},
|
|
186
|
+
links: {},
|
|
187
|
+
sourcePath: "",
|
|
188
|
+
targetPath: "",
|
|
189
|
+
sidecar: {},
|
|
190
|
+
};
|
|
191
|
+
}
|
|
192
|
+
const sourcePath = path.resolve(cwd, substageEvidencePath);
|
|
193
|
+
const targetPath = path.join(diagnosticsDir, "verify-substages.json");
|
|
194
|
+
const relativePath = path
|
|
195
|
+
.relative(workspace, targetPath)
|
|
196
|
+
.split(path.sep)
|
|
197
|
+
.join("/");
|
|
198
|
+
const evidence = readLifecycleSubstageEvidence(sourcePath, {
|
|
199
|
+
lifecycleStage,
|
|
200
|
+
sourceSha,
|
|
201
|
+
sourceTree,
|
|
202
|
+
platformId,
|
|
203
|
+
});
|
|
204
|
+
return {
|
|
205
|
+
evidence,
|
|
206
|
+
observability: {
|
|
207
|
+
substages: {
|
|
208
|
+
contract: evidence.schema,
|
|
209
|
+
evidenceRoot: evidence.evidenceRoot,
|
|
210
|
+
conclusion: evidence.conclusion,
|
|
211
|
+
path: relativePath,
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
lifecycle: { substageEvidence: evidence },
|
|
215
|
+
links: { lifecycleSubstages: relativePath },
|
|
216
|
+
sourcePath,
|
|
217
|
+
targetPath,
|
|
218
|
+
sidecar: {
|
|
219
|
+
kind: "lifecycle-substages",
|
|
220
|
+
filePath: targetPath,
|
|
221
|
+
required: true,
|
|
222
|
+
},
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function readLifecycleSubstageEvidence(file, options = {}) {
|
|
227
|
+
if (!file) return undefined;
|
|
228
|
+
const absolute = path.resolve(file);
|
|
229
|
+
if (!fs.existsSync(absolute))
|
|
230
|
+
throw new Error(`lifecycle substage evidence file not found: ${file}`);
|
|
231
|
+
return verifyLifecycleSubstageEvidence(
|
|
232
|
+
JSON.parse(fs.readFileSync(absolute, "utf8")),
|
|
233
|
+
options,
|
|
234
|
+
);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function parse(argv) {
|
|
238
|
+
const options = {};
|
|
239
|
+
for (let index = 0; index < argv.length; index += 2) {
|
|
240
|
+
const flag = argv[index];
|
|
241
|
+
if (!flag?.startsWith("--") || index + 1 >= argv.length)
|
|
242
|
+
throw new Error(`invalid option: ${flag || "missing"}`);
|
|
243
|
+
options[flag.slice(2)] = argv[index + 1];
|
|
244
|
+
}
|
|
245
|
+
return options;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function main(argv = process.argv.slice(2)) {
|
|
249
|
+
const options = parse(argv);
|
|
250
|
+
const evidence = readLifecycleSubstageEvidence(options.file, {
|
|
251
|
+
lifecycleStage: options.stage || "",
|
|
252
|
+
sourceSha: options["source-sha"] || "",
|
|
253
|
+
sourceTree: options["source-tree"] || "",
|
|
254
|
+
platformId: options["platform-id"] || "",
|
|
255
|
+
});
|
|
256
|
+
process.stdout.write(
|
|
257
|
+
`${JSON.stringify({ ok: true, evidenceRoot: evidence.evidenceRoot, conclusion: evidence.conclusion })}\n`,
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (
|
|
262
|
+
process.argv[1] &&
|
|
263
|
+
path.basename(process.argv[1]) === "lifecycle-substage-evidence.mjs" &&
|
|
264
|
+
fileURLToPath(import.meta.url) === path.resolve(process.argv[1])
|
|
265
|
+
) {
|
|
266
|
+
try {
|
|
267
|
+
main();
|
|
268
|
+
} catch (error) {
|
|
269
|
+
console.error(
|
|
270
|
+
`[lifecycle-substages] ${error instanceof Error ? error.message : String(error)}`,
|
|
271
|
+
);
|
|
272
|
+
process.exit(1);
|
|
273
|
+
}
|
|
274
|
+
}
|