@kungfu-tech/buildchain 2.13.0 → 2.14.0-alpha.0
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/bin/buildchain.mjs +13 -0
- package/dist/site/buildchain-contract.json +23 -18
- package/dist/site/buildchain-site.json +33 -28
- package/dist/site/capability-registry.json +1 -1
- package/dist/site/cli-registry.json +12 -0
- package/dist/site/controller-registry.json +6 -2
- package/dist/site/kfd-claims.json +21 -8
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +8 -8
- package/dist/site/node-api-registry.json +5 -5
- package/dist/site/page-registry.json +21 -16
- package/dist/site/public-surface-audit.json +27 -6
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +12 -12
- package/dist/site/workflow-registry.json +2 -1
- package/docs/MAP.md +2 -1
- package/docs/cli.md +19 -0
- package/docs/publication-authority.md +24 -0
- package/docs/release-flow.md +2 -1
- package/docs/release-governance.md +49 -2
- package/docs/release-propagation.md +9 -0
- package/docs/versioning.md +1 -0
- package/docs/web-surface-deployments.md +18 -4
- package/package.json +2 -2
- package/packages/core/index.js +8 -0
- package/packages/core/publication-authority.js +52 -2
- package/packages/core/release-line-bootstrap.js +5 -0
- package/packages/core/web-surface-publication-candidate.js +162 -0
- package/scripts/assemble-web-surface-publication-admission.mjs +204 -0
- package/scripts/check-action-bundles.mjs +50 -0
- package/scripts/dev-merge-queue.mjs +257 -0
- package/scripts/generate-site-bundle.mjs +2 -0
- package/scripts/verify-web-surface-publication-capability.mjs +78 -0
- package/scripts/web-surface-production-decision.mjs +138 -0
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { validateControllerReceipt } from "./controller-evidence.js";
|
|
4
|
+
|
|
5
|
+
export const WEB_SURFACE_PUBLICATION_CANDIDATE_CONTRACT =
|
|
6
|
+
"kungfu-buildchain-web-surface-publication-candidate";
|
|
7
|
+
export const WEB_SURFACE_PRODUCTION_DECISION_CONTRACT =
|
|
8
|
+
"kungfu-buildchain-web-surface-production-decision";
|
|
9
|
+
|
|
10
|
+
function stableJson(value) {
|
|
11
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
12
|
+
if (value && typeof value === "object") {
|
|
13
|
+
return `{${Object.keys(value)
|
|
14
|
+
.sort()
|
|
15
|
+
.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
|
|
16
|
+
.join(",")}}`;
|
|
17
|
+
}
|
|
18
|
+
return JSON.stringify(value);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function webSurfacePublicationDigest(value) {
|
|
22
|
+
return crypto.createHash("sha256").update(stableJson(value)).digest("hex");
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function requiredString(value, label) {
|
|
26
|
+
const normalized = String(value || "").trim();
|
|
27
|
+
if (!normalized) throw new Error(`${label} must be a non-empty string`);
|
|
28
|
+
return normalized;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function normalizeDigest(value, label) {
|
|
32
|
+
const normalized = requiredString(value, label).replace(/^sha256:/, "").toLowerCase();
|
|
33
|
+
if (!/^[0-9a-f]{64}$/.test(normalized)) throw new Error(`${label} must be a sha256 digest`);
|
|
34
|
+
return normalized;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeGitSha(value, label) {
|
|
38
|
+
const normalized = requiredString(value, label).toLowerCase();
|
|
39
|
+
if (!/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(normalized)) {
|
|
40
|
+
throw new Error(`${label} must be a 40- or 64-character Git SHA`);
|
|
41
|
+
}
|
|
42
|
+
return normalized;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function createWebSurfaceProductionDecision({
|
|
46
|
+
approved,
|
|
47
|
+
kind,
|
|
48
|
+
repository,
|
|
49
|
+
sourceSha,
|
|
50
|
+
actor,
|
|
51
|
+
actorPermission = "",
|
|
52
|
+
releasePr = 0,
|
|
53
|
+
releaseSource = "",
|
|
54
|
+
reason,
|
|
55
|
+
} = {}) {
|
|
56
|
+
const normalizedKind = requiredString(kind, "kind");
|
|
57
|
+
if (!["manual-dispatch", "release-pr", "none"].includes(normalizedKind)) {
|
|
58
|
+
throw new Error(`unsupported web-surface production decision kind: ${normalizedKind}`);
|
|
59
|
+
}
|
|
60
|
+
const payload = {
|
|
61
|
+
schemaVersion: 1,
|
|
62
|
+
contract: WEB_SURFACE_PRODUCTION_DECISION_CONTRACT,
|
|
63
|
+
approved: approved === true,
|
|
64
|
+
kind: normalizedKind,
|
|
65
|
+
repository: requiredString(repository, "repository"),
|
|
66
|
+
sourceSha: normalizeGitSha(sourceSha, "sourceSha"),
|
|
67
|
+
actor: requiredString(actor, "actor"),
|
|
68
|
+
actorPermission: String(actorPermission || ""),
|
|
69
|
+
releasePr: Number(releasePr || 0),
|
|
70
|
+
releaseSource: String(releaseSource || ""),
|
|
71
|
+
reason: requiredString(reason, "reason"),
|
|
72
|
+
};
|
|
73
|
+
if (!Number.isSafeInteger(payload.releasePr) || payload.releasePr < 0) {
|
|
74
|
+
throw new Error("releasePr must be a non-negative safe integer");
|
|
75
|
+
}
|
|
76
|
+
return { ...payload, decisionDigest: webSurfacePublicationDigest(payload) };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function createWebSurfacePublicationCandidate({
|
|
80
|
+
repository,
|
|
81
|
+
sourceSha,
|
|
82
|
+
sourceTreeSha,
|
|
83
|
+
runtimeSha,
|
|
84
|
+
plan,
|
|
85
|
+
planFileDigest,
|
|
86
|
+
controllerReceipt,
|
|
87
|
+
decision,
|
|
88
|
+
} = {}) {
|
|
89
|
+
const normalizedRepository = requiredString(repository, "repository");
|
|
90
|
+
const normalizedSourceSha = normalizeGitSha(sourceSha, "sourceSha");
|
|
91
|
+
const normalizedSourceTreeSha = normalizeGitSha(sourceTreeSha, "sourceTreeSha");
|
|
92
|
+
const normalizedRuntimeSha = normalizeGitSha(runtimeSha, "runtimeSha");
|
|
93
|
+
if (plan?.contract !== "kungfu-buildchain-web-surface-deploy-plan") {
|
|
94
|
+
throw new Error("web-surface publication plan contract mismatch");
|
|
95
|
+
}
|
|
96
|
+
if (plan.channel !== "production" || plan.dryRun !== true) {
|
|
97
|
+
throw new Error("web-surface publication requires a production dry-run plan");
|
|
98
|
+
}
|
|
99
|
+
if (plan.manifest?.sourceSha !== normalizedSourceSha) {
|
|
100
|
+
throw new Error("web-surface publication plan source SHA mismatch");
|
|
101
|
+
}
|
|
102
|
+
const artifactHash = normalizeDigest(plan.artifact?.hash, "plan.artifact.hash");
|
|
103
|
+
if (normalizeDigest(plan.manifest?.artifactHash, "plan.manifest.artifactHash") !== artifactHash) {
|
|
104
|
+
throw new Error("web-surface publication plan artifact hash mismatch");
|
|
105
|
+
}
|
|
106
|
+
if (plan.manifest?.runtimeId && String(plan.manifest.runtimeId).toLowerCase() !== normalizedRuntimeSha) {
|
|
107
|
+
throw new Error("web-surface publication plan runtime SHA mismatch");
|
|
108
|
+
}
|
|
109
|
+
const canonicalPlanDigest = crypto
|
|
110
|
+
.createHash("sha256")
|
|
111
|
+
.update(`${JSON.stringify(plan, null, 2)}\n`)
|
|
112
|
+
.digest("hex");
|
|
113
|
+
if (normalizeDigest(planFileDigest, "planFileDigest") !== canonicalPlanDigest) {
|
|
114
|
+
throw new Error("web-surface publication plan file digest mismatch");
|
|
115
|
+
}
|
|
116
|
+
const controllerValidation = validateControllerReceipt(controllerReceipt, {
|
|
117
|
+
expectedSourceSha: normalizedSourceSha,
|
|
118
|
+
expectedRuntimeSha: normalizedRuntimeSha,
|
|
119
|
+
});
|
|
120
|
+
if (!controllerValidation.ok || !controllerValidation.qualifying) {
|
|
121
|
+
throw new Error(`web-surface publication controller receipt did not qualify: ${controllerValidation.issues.join("; ")}`);
|
|
122
|
+
}
|
|
123
|
+
const planEvidence = (controllerReceipt.evidence || []).filter(
|
|
124
|
+
(entry) => entry.kind === "web-surface-plan",
|
|
125
|
+
);
|
|
126
|
+
if (
|
|
127
|
+
planEvidence.length !== 1 ||
|
|
128
|
+
normalizeDigest(planEvidence[0].digest, "controllerReceipt.web-surface-plan.digest") !== canonicalPlanDigest
|
|
129
|
+
) {
|
|
130
|
+
throw new Error("web-surface publication controller receipt plan evidence mismatch");
|
|
131
|
+
}
|
|
132
|
+
if (decision?.contract !== WEB_SURFACE_PRODUCTION_DECISION_CONTRACT || decision.approved !== true) {
|
|
133
|
+
throw new Error("web-surface production decision is not approved");
|
|
134
|
+
}
|
|
135
|
+
const { decisionDigest: suppliedDecisionDigest, ...decisionPayload } = decision;
|
|
136
|
+
const decisionDigest = webSurfacePublicationDigest(decisionPayload);
|
|
137
|
+
if (normalizeDigest(suppliedDecisionDigest, "decision.decisionDigest") !== decisionDigest) {
|
|
138
|
+
throw new Error("web-surface production decision digest mismatch");
|
|
139
|
+
}
|
|
140
|
+
if (decision.repository !== normalizedRepository || decision.sourceSha !== normalizedSourceSha) {
|
|
141
|
+
throw new Error("web-surface production decision source binding mismatch");
|
|
142
|
+
}
|
|
143
|
+
if (!["manual-dispatch", "release-pr"].includes(decision.kind)) {
|
|
144
|
+
throw new Error("web-surface production decision kind is not authorizing");
|
|
145
|
+
}
|
|
146
|
+
const payload = {
|
|
147
|
+
schemaVersion: 1,
|
|
148
|
+
contract: WEB_SURFACE_PUBLICATION_CANDIDATE_CONTRACT,
|
|
149
|
+
repository: normalizedRepository,
|
|
150
|
+
sourceSha: normalizedSourceSha,
|
|
151
|
+
sourceTreeSha: normalizedSourceTreeSha,
|
|
152
|
+
runtimeSha: normalizedRuntimeSha,
|
|
153
|
+
site: requiredString(plan.manifest?.site, "plan.manifest.site"),
|
|
154
|
+
environment: "production",
|
|
155
|
+
deployTarget: requiredString(plan.manifest?.deployTarget, "plan.manifest.deployTarget"),
|
|
156
|
+
artifactHash,
|
|
157
|
+
planDigest: canonicalPlanDigest,
|
|
158
|
+
controllerReceiptDigest: normalizeDigest(controllerReceipt.digest, "controllerReceipt.digest"),
|
|
159
|
+
decisionDigest,
|
|
160
|
+
};
|
|
161
|
+
return { ...payload, candidateDigest: webSurfacePublicationDigest(payload) };
|
|
162
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import crypto from "node:crypto";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import { execFileSync } from "node:child_process";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
createPublicationAdmission,
|
|
9
|
+
createPublicationControlPlaneAudit,
|
|
10
|
+
createPublicationGateDecision,
|
|
11
|
+
createRunnerProvenance,
|
|
12
|
+
publicationGateAggregateBindings,
|
|
13
|
+
verifyPublicationAdmission,
|
|
14
|
+
} from "../packages/core/publication-authority.js";
|
|
15
|
+
import { createWebSurfacePublicationCandidate } from "../packages/core/web-surface-publication-candidate.js";
|
|
16
|
+
|
|
17
|
+
function required(name) {
|
|
18
|
+
const value = String(process.env[name] || "").trim();
|
|
19
|
+
if (!value) throw new Error(`${name} is required`);
|
|
20
|
+
return value;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function sha256(value) {
|
|
24
|
+
return crypto.createHash("sha256").update(String(value)).digest("hex");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function readJson(file) {
|
|
28
|
+
return JSON.parse(fs.readFileSync(file, "utf8"));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
async function sourceTree(repository, sourceSha, token) {
|
|
32
|
+
const response = await fetch(
|
|
33
|
+
`${required("GITHUB_API_URL")}/repos/${repository}/git/commits/${sourceSha}`,
|
|
34
|
+
{
|
|
35
|
+
headers: {
|
|
36
|
+
accept: "application/vnd.github+json",
|
|
37
|
+
authorization: `Bearer ${token}`,
|
|
38
|
+
"x-github-api-version": "2022-11-28",
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
);
|
|
42
|
+
if (!response.ok) throw new Error(`could not resolve admitted source tree: GitHub API ${response.status}`);
|
|
43
|
+
return String((await response.json()).tree?.sha || "").toLowerCase();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function auditFact(id, value) {
|
|
47
|
+
return { id, status: "pass", digest: sha256(JSON.stringify(value)) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function writeBundle(outputDir, values) {
|
|
51
|
+
fs.mkdirSync(outputDir, { recursive: true });
|
|
52
|
+
for (const [name, value] of Object.entries(values)) {
|
|
53
|
+
fs.writeFileSync(path.join(outputDir, `${name}.json`), `${JSON.stringify(value, null, 2)}\n`);
|
|
54
|
+
}
|
|
55
|
+
if (process.env.GITHUB_OUTPUT) {
|
|
56
|
+
const output = fs.createWriteStream(process.env.GITHUB_OUTPUT, { flags: "a" });
|
|
57
|
+
for (const [name, value] of Object.entries(values)) {
|
|
58
|
+
output.write(`${name.replaceAll("_", "-")}-json=${JSON.stringify(value)}\n`);
|
|
59
|
+
}
|
|
60
|
+
output.write(`capability-digest=${values.capability.capabilityDigest}\n`);
|
|
61
|
+
output.end();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function main() {
|
|
66
|
+
const repository = required("BUILDCHAIN_REPOSITORY");
|
|
67
|
+
const sourceSha = required("BUILDCHAIN_SOURCE_SHA").toLowerCase();
|
|
68
|
+
const runtimeRoot = process.env.BUILDCHAIN_RUNTIME_ROOT || ".buildchain/runtime";
|
|
69
|
+
const runtimeSha = execFileSync("git", ["-C", runtimeRoot, "rev-parse", "HEAD"], {
|
|
70
|
+
encoding: "utf8",
|
|
71
|
+
}).trim().toLowerCase();
|
|
72
|
+
const planPath = required("BUILDCHAIN_WEB_SURFACE_PLAN_PATH");
|
|
73
|
+
const plan = readJson(planPath);
|
|
74
|
+
const controllerReceipt = readJson(required("BUILDCHAIN_CONTROLLER_RECEIPT_PATH"));
|
|
75
|
+
const decision = JSON.parse(required("BUILDCHAIN_PRODUCTION_DECISION_JSON"));
|
|
76
|
+
const token = required("GITHUB_TOKEN");
|
|
77
|
+
const sourceTreeSha = await sourceTree(repository, sourceSha, token);
|
|
78
|
+
const planFileDigest = sha256(fs.readFileSync(planPath));
|
|
79
|
+
const candidate = createWebSurfacePublicationCandidate({
|
|
80
|
+
repository,
|
|
81
|
+
sourceSha,
|
|
82
|
+
sourceTreeSha,
|
|
83
|
+
runtimeSha,
|
|
84
|
+
plan,
|
|
85
|
+
planFileDigest,
|
|
86
|
+
controllerReceipt,
|
|
87
|
+
decision,
|
|
88
|
+
});
|
|
89
|
+
const environment = required("BUILDCHAIN_PUBLICATION_ENVIRONMENT");
|
|
90
|
+
const roleArn = required("BUILDCHAIN_PRODUCTION_ROLE_ARN");
|
|
91
|
+
if (!/^arn:[^:]+:iam::[0-9]{12}:role\/.+/.test(roleArn)) {
|
|
92
|
+
throw new Error("BUILDCHAIN_PRODUCTION_ROLE_ARN must be an AWS IAM role ARN");
|
|
93
|
+
}
|
|
94
|
+
if (process.env.GITHUB_ACTIONS !== "true" || process.env.RUNNER_ENVIRONMENT !== "github-hosted") {
|
|
95
|
+
throw new Error("managed web-surface publication requires an ephemeral GitHub-hosted Actions runner");
|
|
96
|
+
}
|
|
97
|
+
const workflowPath = ".github/workflows/.web-surface.yml";
|
|
98
|
+
const publisherWorkflowPath = workflowPath;
|
|
99
|
+
const issuedAt = new Date();
|
|
100
|
+
const controlPlaneAudit = createPublicationControlPlaneAudit({
|
|
101
|
+
repository,
|
|
102
|
+
workflowPath,
|
|
103
|
+
publisherWorkflowPath,
|
|
104
|
+
environment,
|
|
105
|
+
observedAt: issuedAt.toISOString(),
|
|
106
|
+
expiresAt: new Date(issuedAt.getTime() + 10 * 60 * 1000).toISOString(),
|
|
107
|
+
facts: [
|
|
108
|
+
auditFact("actions-policy", { githubActions: true, sourceSha }),
|
|
109
|
+
auditFact("branch-policy", { decisionKind: decision.kind, decisionDigest: decision.decisionDigest }),
|
|
110
|
+
auditFact("environment-policy", { environment }),
|
|
111
|
+
auditFact("oidc-policy", { roleArnDigest: sha256(roleArn), authorization: "provider-at-transaction" }),
|
|
112
|
+
auditFact("publisher-policy", { workflowPath, publisherWorkflowPath }),
|
|
113
|
+
auditFact("runner-policy", { runnerEnvironment: process.env.RUNNER_ENVIRONMENT, runnerOs: process.env.RUNNER_OS }),
|
|
114
|
+
],
|
|
115
|
+
});
|
|
116
|
+
const runnerProvenance = createRunnerProvenance({
|
|
117
|
+
runnerClass: "ephemeral",
|
|
118
|
+
os: required("RUNNER_OS"),
|
|
119
|
+
architecture: required("RUNNER_ARCH"),
|
|
120
|
+
imageDigest: sha256(`${process.env.ImageOS || "unknown"}|${process.env.ImageVersion || "unknown"}`),
|
|
121
|
+
measurementDigest: sha256([
|
|
122
|
+
process.env.GITHUB_WORKFLOW,
|
|
123
|
+
process.env.GITHUB_JOB,
|
|
124
|
+
process.env.GITHUB_RUN_ID,
|
|
125
|
+
process.env.GITHUB_RUN_ATTEMPT,
|
|
126
|
+
process.env.RUNNER_ENVIRONMENT,
|
|
127
|
+
].join("|")),
|
|
128
|
+
isolation: "github-hosted-single-job",
|
|
129
|
+
});
|
|
130
|
+
const gateAggregate = createPublicationGateDecision({
|
|
131
|
+
sourceSha,
|
|
132
|
+
profile: "managed-web-surface-production",
|
|
133
|
+
required: false,
|
|
134
|
+
rationale: "The managed web-surface production lane has no project-specific Shifu Gate registry.",
|
|
135
|
+
policy: { scope: "managed-web-surface-production", repository },
|
|
136
|
+
});
|
|
137
|
+
const gateBindings = publicationGateAggregateBindings(gateAggregate);
|
|
138
|
+
const registry = readJson(path.join(runtimeRoot, "dist/site/publication-authority-registry.json"));
|
|
139
|
+
const target = `aws-role:${roleArn}#deploy:${candidate.deployTarget}`;
|
|
140
|
+
const admission = createPublicationAdmission({
|
|
141
|
+
registryDigest: registry.registryDigest,
|
|
142
|
+
workflowPath,
|
|
143
|
+
publisherWorkflowPath,
|
|
144
|
+
repository,
|
|
145
|
+
sourceSha,
|
|
146
|
+
runtimeSha,
|
|
147
|
+
contractDigest: controllerReceipt.runtime.contractDigest,
|
|
148
|
+
policyDigest: gateBindings.policyDigest,
|
|
149
|
+
gateRegistryDigest: gateBindings.registryDigest,
|
|
150
|
+
controllerReceiptDigest: controllerReceipt.digest,
|
|
151
|
+
runnerProvenanceDigest: runnerProvenance.receiptDigest,
|
|
152
|
+
controlPlaneAuditDigest: controlPlaneAudit.receiptDigest,
|
|
153
|
+
gateAggregateDigest: gateBindings.gateAggregateDigest,
|
|
154
|
+
environment,
|
|
155
|
+
product: candidate.site,
|
|
156
|
+
target,
|
|
157
|
+
version: sourceSha,
|
|
158
|
+
channel: "production",
|
|
159
|
+
artifactDigest: candidate.candidateDigest,
|
|
160
|
+
nonce: `${required("GITHUB_RUN_ID")}:${required("GITHUB_RUN_ATTEMPT")}:${sourceSha}:web-production`,
|
|
161
|
+
issuedAt: issuedAt.toISOString(),
|
|
162
|
+
expiresAt: new Date(issuedAt.getTime() + 10 * 60 * 1000).toISOString(),
|
|
163
|
+
});
|
|
164
|
+
const bindingNames = [
|
|
165
|
+
"repository", "publisherWorkflowPath", "sourceSha", "runtimeSha", "contractDigest", "policyDigest",
|
|
166
|
+
"gateRegistryDigest", "controllerReceiptDigest", "gateAggregateDigest", "environment", "product",
|
|
167
|
+
"target", "version", "channel", "artifactDigest",
|
|
168
|
+
];
|
|
169
|
+
const expected = Object.fromEntries(bindingNames.map((name) => [name, admission[name]]));
|
|
170
|
+
const capability = verifyPublicationAdmission({
|
|
171
|
+
admission,
|
|
172
|
+
registry,
|
|
173
|
+
runnerProvenance,
|
|
174
|
+
controlPlaneAudit,
|
|
175
|
+
publicationEvidence: {
|
|
176
|
+
webSurfaceCandidate: {
|
|
177
|
+
repository,
|
|
178
|
+
sourceSha,
|
|
179
|
+
sourceTreeSha,
|
|
180
|
+
runtimeSha,
|
|
181
|
+
plan,
|
|
182
|
+
planFileDigest,
|
|
183
|
+
controllerReceipt,
|
|
184
|
+
decision,
|
|
185
|
+
},
|
|
186
|
+
gateAggregate,
|
|
187
|
+
},
|
|
188
|
+
expected,
|
|
189
|
+
});
|
|
190
|
+
writeBundle(process.env.BUILDCHAIN_OUTPUT_DIR || ".buildchain/web-publication-authority", {
|
|
191
|
+
admission,
|
|
192
|
+
runner_provenance: runnerProvenance,
|
|
193
|
+
control_plane_audit: controlPlaneAudit,
|
|
194
|
+
gate_aggregate: gateAggregate,
|
|
195
|
+
expected,
|
|
196
|
+
candidate,
|
|
197
|
+
capability,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
main().catch((error) => {
|
|
202
|
+
console.error(`assemble web-surface publication admission: ${error.message}`);
|
|
203
|
+
process.exitCode = 1;
|
|
204
|
+
});
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
|
7
|
+
const actionsRoot = path.join(root, "actions");
|
|
8
|
+
const bundlePaths = readdirSync(actionsRoot, { withFileTypes: true })
|
|
9
|
+
.filter((entry) => entry.isDirectory())
|
|
10
|
+
.map((entry) => path.join(actionsRoot, entry.name, "dist", "index.js"))
|
|
11
|
+
.filter((bundlePath) => {
|
|
12
|
+
try {
|
|
13
|
+
readFileSync(bundlePath);
|
|
14
|
+
return true;
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
})
|
|
19
|
+
.sort();
|
|
20
|
+
|
|
21
|
+
const before = new Map(
|
|
22
|
+
bundlePaths.map((bundlePath) => [bundlePath, readFileSync(bundlePath)]),
|
|
23
|
+
);
|
|
24
|
+
const pnpm = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
|
25
|
+
const build = spawnSync(
|
|
26
|
+
pnpm,
|
|
27
|
+
["-r", "--filter", "./actions/**", "build"],
|
|
28
|
+
{ cwd: root, stdio: "inherit" },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
if (build.error) {
|
|
32
|
+
throw build.error;
|
|
33
|
+
}
|
|
34
|
+
if (build.status !== 0) {
|
|
35
|
+
process.exit(build.status ?? 1);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const changed = bundlePaths.filter(
|
|
39
|
+
(bundlePath) => !before.get(bundlePath).equals(readFileSync(bundlePath)),
|
|
40
|
+
);
|
|
41
|
+
if (changed.length > 0) {
|
|
42
|
+
console.error("Generated action bundles were stale before the build:");
|
|
43
|
+
for (const bundlePath of changed) {
|
|
44
|
+
console.error(`- ${path.relative(root, bundlePath)}`);
|
|
45
|
+
}
|
|
46
|
+
console.error("Commit the regenerated bundles and rerun pnpm run check.");
|
|
47
|
+
process.exit(1);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
console.log(`action bundle integrity check passed (${bundlePaths.length} bundles)`);
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
|
|
6
|
+
const RULESET_PREFIX = "Buildchain dev merge queue";
|
|
7
|
+
|
|
8
|
+
function positiveInteger(value, label, fallback) {
|
|
9
|
+
const parsed = Number(value || fallback);
|
|
10
|
+
if (!Number.isInteger(parsed) || parsed < 1) {
|
|
11
|
+
throw new Error(`${label} must be a positive integer`);
|
|
12
|
+
}
|
|
13
|
+
return parsed;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function requiredString(value, label) {
|
|
17
|
+
const normalized = String(value || "").trim();
|
|
18
|
+
if (!normalized) throw new Error(`${label} is required`);
|
|
19
|
+
return normalized;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function selectMergeQueueMethod(repositorySettings = {}) {
|
|
23
|
+
const candidates = [
|
|
24
|
+
["MERGE", repositorySettings.allow_merge_commit],
|
|
25
|
+
["SQUASH", repositorySettings.allow_squash_merge],
|
|
26
|
+
["REBASE", repositorySettings.allow_rebase_merge],
|
|
27
|
+
];
|
|
28
|
+
const selected = candidates.find(([, allowed]) => allowed === true)?.[0];
|
|
29
|
+
if (!selected) {
|
|
30
|
+
throw new Error("repository must allow at least one merge method before merge queue enablement");
|
|
31
|
+
}
|
|
32
|
+
return selected;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validateMergeGroupWorkflows(workflows = []) {
|
|
36
|
+
if (workflows.length === 0) {
|
|
37
|
+
throw new Error("at least one required workflow must be declared with --workflow");
|
|
38
|
+
}
|
|
39
|
+
const results = workflows.map(({ path: workflowPath, source }) => {
|
|
40
|
+
const hasPullRequest = /^\s{0,4}pull_request\s*:/m.test(source);
|
|
41
|
+
const hasMergeGroup = /^\s{0,4}merge_group\s*:/m.test(source);
|
|
42
|
+
const usesPullRequestPayload = /github\.event\.pull_request/.test(source);
|
|
43
|
+
return { path: workflowPath, hasPullRequest, hasMergeGroup, usesPullRequestPayload };
|
|
44
|
+
});
|
|
45
|
+
const invalid = results.filter((workflow) => !workflow.hasPullRequest || !workflow.hasMergeGroup);
|
|
46
|
+
if (invalid.length > 0) {
|
|
47
|
+
throw new Error(
|
|
48
|
+
`merge queue requires pull_request and merge_group triggers in every required workflow: ${invalid.map((entry) => entry.path).join(", ")}`,
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
const payloadBound = results.filter((workflow) => workflow.usesPullRequestPayload);
|
|
52
|
+
if (payloadBound.length > 0) {
|
|
53
|
+
throw new Error(
|
|
54
|
+
`merge queue workflows must not depend directly on github.event.pull_request: ${payloadBound.map((entry) => entry.path).join(", ")}`,
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
return results;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function createDevMergeQueuePlan({
|
|
61
|
+
repository,
|
|
62
|
+
branch,
|
|
63
|
+
workflows,
|
|
64
|
+
protection,
|
|
65
|
+
repositorySettings,
|
|
66
|
+
rulesets = [],
|
|
67
|
+
checkResponseTimeoutMinutes = 120,
|
|
68
|
+
maxEntriesToBuild = 1,
|
|
69
|
+
} = {}) {
|
|
70
|
+
const normalizedRepository = requiredString(repository, "repository");
|
|
71
|
+
const normalizedBranch = requiredString(branch, "branch");
|
|
72
|
+
if (!/^dev\/v\d+\/v\d+\.\d+$/.test(normalizedBranch)) {
|
|
73
|
+
throw new Error(`branch must be a Buildchain dev channel, got '${normalizedBranch}'`);
|
|
74
|
+
}
|
|
75
|
+
const workflowChecks = validateMergeGroupWorkflows(workflows);
|
|
76
|
+
const mergeMethod = selectMergeQueueMethod(repositorySettings);
|
|
77
|
+
const requiredChecks = protection?.required_status_checks?.checks || [];
|
|
78
|
+
if (requiredChecks.length === 0) {
|
|
79
|
+
throw new Error(`protected branch ${normalizedBranch} must declare required status checks before merge queue enablement`);
|
|
80
|
+
}
|
|
81
|
+
const rulesetName = `${RULESET_PREFIX}: ${normalizedBranch}`;
|
|
82
|
+
const existingRuleset = rulesets.find((entry) => entry?.name === rulesetName);
|
|
83
|
+
const ruleset = {
|
|
84
|
+
name: rulesetName,
|
|
85
|
+
target: "branch",
|
|
86
|
+
enforcement: "active",
|
|
87
|
+
conditions: {
|
|
88
|
+
ref_name: {
|
|
89
|
+
include: [`refs/heads/${normalizedBranch}`],
|
|
90
|
+
exclude: [],
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
rules: [{
|
|
94
|
+
type: "merge_queue",
|
|
95
|
+
parameters: {
|
|
96
|
+
check_response_timeout_minutes: positiveInteger(checkResponseTimeoutMinutes, "check response timeout", 120),
|
|
97
|
+
grouping_strategy: "ALLGREEN",
|
|
98
|
+
max_entries_to_build: positiveInteger(maxEntriesToBuild, "max entries to build", 1),
|
|
99
|
+
max_entries_to_merge: 1,
|
|
100
|
+
merge_method: mergeMethod,
|
|
101
|
+
min_entries_to_merge: 1,
|
|
102
|
+
min_entries_to_merge_wait_minutes: 0,
|
|
103
|
+
},
|
|
104
|
+
}],
|
|
105
|
+
};
|
|
106
|
+
return {
|
|
107
|
+
schemaVersion: 1,
|
|
108
|
+
contract: "kungfu-buildchain-dev-merge-queue-policy",
|
|
109
|
+
repository: normalizedRepository,
|
|
110
|
+
branch: normalizedBranch,
|
|
111
|
+
ok: true,
|
|
112
|
+
workflowChecks,
|
|
113
|
+
before: {
|
|
114
|
+
strict: protection.required_status_checks?.strict === true,
|
|
115
|
+
requiredStatusChecks: requiredChecks.map((check) => check.context),
|
|
116
|
+
rulesetId: existingRuleset?.id || null,
|
|
117
|
+
},
|
|
118
|
+
desired: {
|
|
119
|
+
strict: false,
|
|
120
|
+
requiredStatusChecks: requiredChecks.map((check) => check.context),
|
|
121
|
+
mergeMethod,
|
|
122
|
+
ruleset,
|
|
123
|
+
},
|
|
124
|
+
operations: [
|
|
125
|
+
{
|
|
126
|
+
method: existingRuleset ? "PUT" : "POST",
|
|
127
|
+
endpoint: existingRuleset
|
|
128
|
+
? `repos/${normalizedRepository}/rulesets/${existingRuleset.id}`
|
|
129
|
+
: `repos/${normalizedRepository}/rulesets`,
|
|
130
|
+
body: ruleset,
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
method: "PATCH",
|
|
134
|
+
endpoint: `repos/${normalizedRepository}/branches/${encodeURIComponent(normalizedBranch)}/protection/required_status_checks`,
|
|
135
|
+
body: { strict: false, checks: requiredChecks },
|
|
136
|
+
},
|
|
137
|
+
],
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function reconcileDevMergeQueue({
|
|
142
|
+
api,
|
|
143
|
+
repository,
|
|
144
|
+
branch,
|
|
145
|
+
workflows,
|
|
146
|
+
apply = false,
|
|
147
|
+
checkResponseTimeoutMinutes = 120,
|
|
148
|
+
maxEntriesToBuild = 1,
|
|
149
|
+
} = {}) {
|
|
150
|
+
const encodedBranch = encodeURIComponent(requiredString(branch, "branch"));
|
|
151
|
+
const repositorySettings = await api.request("GET", `repos/${repository}`);
|
|
152
|
+
const protection = await api.request("GET", `repos/${repository}/branches/${encodedBranch}/protection`);
|
|
153
|
+
const rulesets = await api.request("GET", `repos/${repository}/rulesets?includes_parents=false&per_page=100`);
|
|
154
|
+
const plan = createDevMergeQueuePlan({
|
|
155
|
+
repository,
|
|
156
|
+
branch,
|
|
157
|
+
workflows,
|
|
158
|
+
protection,
|
|
159
|
+
repositorySettings,
|
|
160
|
+
rulesets,
|
|
161
|
+
checkResponseTimeoutMinutes,
|
|
162
|
+
maxEntriesToBuild,
|
|
163
|
+
});
|
|
164
|
+
if (!apply) return { ...plan, action: "planned", applied: false };
|
|
165
|
+
const results = [];
|
|
166
|
+
for (const operation of plan.operations) {
|
|
167
|
+
results.push(await api.request(operation.method, operation.endpoint, operation.body));
|
|
168
|
+
}
|
|
169
|
+
return {
|
|
170
|
+
...plan,
|
|
171
|
+
action: plan.before.rulesetId ? "updated" : "created",
|
|
172
|
+
applied: true,
|
|
173
|
+
after: {
|
|
174
|
+
strict: false,
|
|
175
|
+
rulesetId: results[0]?.id || plan.before.rulesetId || null,
|
|
176
|
+
},
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function createGhApi() {
|
|
181
|
+
return {
|
|
182
|
+
request(method, endpoint, body) {
|
|
183
|
+
const args = [
|
|
184
|
+
"api",
|
|
185
|
+
"--method",
|
|
186
|
+
method,
|
|
187
|
+
endpoint,
|
|
188
|
+
"-H",
|
|
189
|
+
"Accept: application/vnd.github+json",
|
|
190
|
+
"-H",
|
|
191
|
+
"X-GitHub-Api-Version: 2026-03-10",
|
|
192
|
+
];
|
|
193
|
+
if (body !== undefined) args.push("--input", "-");
|
|
194
|
+
const result = spawnSync("gh", args, {
|
|
195
|
+
encoding: "utf8",
|
|
196
|
+
input: body === undefined ? undefined : `${JSON.stringify(body)}\n`,
|
|
197
|
+
env: process.env,
|
|
198
|
+
});
|
|
199
|
+
if (result.error) throw result.error;
|
|
200
|
+
if (result.status !== 0) {
|
|
201
|
+
throw new Error(`GitHub API ${method} ${endpoint} failed: ${String(result.stderr || "").trim()}`);
|
|
202
|
+
}
|
|
203
|
+
const output = String(result.stdout || "").trim();
|
|
204
|
+
return output ? JSON.parse(output) : {};
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function readFlag(args, name, fallback = "") {
|
|
210
|
+
const index = args.indexOf(`--${name}`);
|
|
211
|
+
return index === -1 ? fallback : args[index + 1] || "";
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function readRepeatedFlag(args, name) {
|
|
215
|
+
const values = [];
|
|
216
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
217
|
+
if (args[index] === `--${name}` && args[index + 1]) {
|
|
218
|
+
values.push(args[index + 1]);
|
|
219
|
+
index += 1;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return values;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function loadWorkflowSources(cwd, workflowPaths) {
|
|
226
|
+
return workflowPaths.map((workflowPath) => {
|
|
227
|
+
const absolutePath = path.resolve(cwd, workflowPath);
|
|
228
|
+
if (!fs.existsSync(absolutePath) || !fs.statSync(absolutePath).isFile()) {
|
|
229
|
+
throw new Error(`required workflow not found: ${workflowPath}`);
|
|
230
|
+
}
|
|
231
|
+
return { path: workflowPath, source: fs.readFileSync(absolutePath, "utf8") };
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function main(args = process.argv.slice(2)) {
|
|
236
|
+
const cwd = path.resolve(readFlag(args, "cwd", process.cwd()));
|
|
237
|
+
const repository = readFlag(args, "repository", process.env.GITHUB_REPOSITORY || "");
|
|
238
|
+
const branch = readFlag(args, "branch", "");
|
|
239
|
+
const workflows = loadWorkflowSources(cwd, readRepeatedFlag(args, "workflow"));
|
|
240
|
+
const result = await reconcileDevMergeQueue({
|
|
241
|
+
api: createGhApi(),
|
|
242
|
+
repository,
|
|
243
|
+
branch,
|
|
244
|
+
workflows,
|
|
245
|
+
apply: args.includes("--apply"),
|
|
246
|
+
checkResponseTimeoutMinutes: readFlag(args, "check-response-timeout-minutes", "120"),
|
|
247
|
+
maxEntriesToBuild: readFlag(args, "max-entries-to-build", "1"),
|
|
248
|
+
});
|
|
249
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
|
253
|
+
main().catch((error) => {
|
|
254
|
+
console.error(`buildchain dev merge-queue: ${error.message}`);
|
|
255
|
+
process.exitCode = 1;
|
|
256
|
+
});
|
|
257
|
+
}
|
|
@@ -435,6 +435,8 @@ function cliCommandMeta(id) {
|
|
|
435
435
|
"kfd-upstream-collect": { group: "kfd-trust", purpose: "Collect declared KFD-aware upstream package evidence and hashes from Buildchain config." },
|
|
436
436
|
"kfd-upstream-roles": { group: "kfd-trust", purpose: "List Buildchain-managed KFD upstream role values and inference policy." },
|
|
437
437
|
lifecycle: { group: "reusable-build", purpose: "Run configured lifecycle commands and write deterministic artifact manifests." },
|
|
438
|
+
dev: { group: "governance-versioning", purpose: "Inspect protected development-channel governance command families." },
|
|
439
|
+
"dev-merge-queue": { group: "governance-versioning", purpose: "Plan or apply an exact-branch GitHub merge queue after required workflow event compatibility is verified." },
|
|
438
440
|
log: { group: "observability-diagnostics", purpose: "Inspect Buildchain logging command families." },
|
|
439
441
|
logging: { group: "observability-diagnostics", purpose: "Emit timestamped build events, summarize logs, and enforce required phases." },
|
|
440
442
|
mark: { group: "observability-diagnostics", purpose: "Emit a single Buildchain log event." },
|