@kungfu-tech/buildchain 2.3.2-alpha.0 → 2.4.0-alpha.1

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.
@@ -0,0 +1,345 @@
1
+ #!/usr/bin/env node
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { pathToFileURL } from "node:url";
5
+ import {
6
+ applyInfraContract,
7
+ applyInfraContractPropagation,
8
+ createInfraContractArtifact,
9
+ createInfraContractEvidenceBundle,
10
+ createInfraContractPlan,
11
+ createInfraContractPropagationPlan,
12
+ validateInfraContractProject,
13
+ verifyInfraContractEvidenceBundle,
14
+ } from "./infra-contract-core.mjs";
15
+ import { writeGitHubOutputs } from "./build-contract-core.mjs";
16
+
17
+ const INFRA_CONTRACT_CI_CONTRACT = "kungfu-buildchain-infra-contract-ci";
18
+
19
+ function readArg(name, fallback = "") {
20
+ const index = process.argv.indexOf(`--${name}`);
21
+ if (index === -1) {
22
+ return fallback;
23
+ }
24
+ return process.argv[index + 1] || "";
25
+ }
26
+
27
+ function readRepeatedArg(name) {
28
+ const values = [];
29
+ for (let index = 0; index < process.argv.length; index += 1) {
30
+ if (process.argv[index] === `--${name}` && process.argv[index + 1]) {
31
+ values.push(process.argv[index + 1]);
32
+ index += 1;
33
+ }
34
+ }
35
+ return values;
36
+ }
37
+
38
+ function readBooleanArg(name, fallback = true) {
39
+ const value = readArg(name, "");
40
+ if (!value) {
41
+ return fallback;
42
+ }
43
+ return value === "true" || value === "1";
44
+ }
45
+
46
+ function readNumberArg(name, fallback) {
47
+ const value = readArg(name, "");
48
+ if (!value) {
49
+ return fallback;
50
+ }
51
+ const parsed = Number(value);
52
+ if (!Number.isFinite(parsed)) {
53
+ throw new Error(`--${name} must be a number`);
54
+ }
55
+ return parsed;
56
+ }
57
+
58
+ function readNumberEnv(name, fallback) {
59
+ const value = process.env[name] || "";
60
+ if (!value) {
61
+ return fallback;
62
+ }
63
+ const parsed = Number(value);
64
+ if (!Number.isFinite(parsed)) {
65
+ throw new Error(`${name} must be a number`);
66
+ }
67
+ return parsed;
68
+ }
69
+
70
+ function writeJson(result, outputPath) {
71
+ const json = `${JSON.stringify(result, null, 2)}\n`;
72
+ if (outputPath) {
73
+ fs.mkdirSync(path.dirname(path.resolve(outputPath)), { recursive: true });
74
+ fs.writeFileSync(outputPath, json);
75
+ } else {
76
+ process.stdout.write(json);
77
+ }
78
+ }
79
+
80
+ function writeJsonFile(result, filePath) {
81
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
82
+ fs.writeFileSync(filePath, `${JSON.stringify(result, null, 2)}\n`);
83
+ return filePath;
84
+ }
85
+
86
+ function posixRelative(cwd, filePath) {
87
+ return path.relative(cwd, filePath).split(path.sep).join("/");
88
+ }
89
+
90
+ function readJsonFileArg(name) {
91
+ const value = readArg(name, "");
92
+ if (!value) {
93
+ return null;
94
+ }
95
+ return JSON.parse(fs.readFileSync(path.resolve(value), "utf8"));
96
+ }
97
+
98
+ function readKeyValueMapArg(name) {
99
+ const entries = {};
100
+ for (const value of readRepeatedArg(name)) {
101
+ const separator = value.indexOf("=");
102
+ if (separator <= 0) {
103
+ throw new Error(`--${name} must use key=value`);
104
+ }
105
+ entries[value.slice(0, separator)] = value.slice(separator + 1);
106
+ }
107
+ return entries;
108
+ }
109
+
110
+ export function infraContractCli() {
111
+ const mode = readArg("mode", process.env.BUILDCHAIN_INFRA_CONTRACT_MODE || "validate");
112
+ const cwd = readArg("cwd", process.env.BUILDCHAIN_WORKDIR || process.cwd());
113
+ const sourceSha = readArg("source-sha", process.env.BUILDCHAIN_SOURCE_SHA || process.env.GITHUB_SHA || "");
114
+ const output = readArg("output", process.env.BUILDCHAIN_INFRA_CONTRACT_OUTPUT || "");
115
+
116
+ if (mode === "validate") {
117
+ const result = validateInfraContractProject(cwd);
118
+ writeJson(result, output);
119
+ writeGitHubOutputs({
120
+ "project-type": result.project?.type || "",
121
+ "infra-adapter": result.infra?.adapter || "",
122
+ "infra-adoption-mode": result.infra?.adoptionMode || "",
123
+ "infra-apply-mode": result.infra?.applyMode || "",
124
+ });
125
+ return result;
126
+ }
127
+
128
+ if (mode === "ci") {
129
+ const outputDir = path.resolve(cwd, readArg("output-dir", process.env.BUILDCHAIN_INFRA_CONTRACT_OUTPUT_DIR || ".buildchain"));
130
+ const files = {
131
+ validation: path.join(outputDir, "infra-contract-validate.json"),
132
+ plan: path.join(outputDir, "infra-contract-plan.json"),
133
+ artifact: path.join(outputDir, "buildchain.infra-contract.json"),
134
+ propagationPlan: path.join(outputDir, "infra-contract-propagation.json"),
135
+ propagationResult: path.join(outputDir, "infra-contract-propagation-apply.json"),
136
+ applyResult: path.join(outputDir, "infra-contract-apply.json"),
137
+ evidenceBundle: path.join(outputDir, "infra-contract-evidence-bundle.json"),
138
+ verification: path.join(outputDir, "infra-contract-evidence-verification.json"),
139
+ };
140
+ const validation = validateInfraContractProject(cwd);
141
+ writeJsonFile(validation, files.validation);
142
+ const executeAdapterCommands = readBooleanArg("execute-adapter-commands", false);
143
+ const plan = createInfraContractPlan({
144
+ cwd,
145
+ sourceSha,
146
+ executeAdapterCommands,
147
+ });
148
+ writeJsonFile(plan, files.plan);
149
+ const artifact = createInfraContractArtifact({
150
+ cwd,
151
+ sourceSha,
152
+ plan,
153
+ approvedBy: readArg("approved-by", process.env.GITHUB_ACTOR || ""),
154
+ approvalId: readArg("approval-id", ""),
155
+ applyRunId: readArg("apply-run-id", process.env.GITHUB_RUN_ID || ""),
156
+ rollbackPointer: readArg("rollback-pointer", process.env.BUILDCHAIN_ROLLBACK_REF || ""),
157
+ executeAdapterCommands,
158
+ });
159
+ writeJsonFile(artifact, files.artifact);
160
+ const propagationPlan = createInfraContractPropagationPlan({
161
+ cwd,
162
+ artifact,
163
+ branchPrefix: readArg("branch-prefix", "buildchain/infra-contract"),
164
+ });
165
+ writeJsonFile(propagationPlan, files.propagationPlan);
166
+ const propagationResult = (artifact.consumers || []).length > 0
167
+ ? applyInfraContractPropagation({
168
+ cwd,
169
+ artifact,
170
+ propagationPlan,
171
+ dryRun: true,
172
+ })
173
+ : null;
174
+ if (propagationResult) {
175
+ writeJsonFile(propagationResult, files.propagationResult);
176
+ }
177
+ let applyResult = null;
178
+ if (artifact.apply?.enabled) {
179
+ const approvalId = readArg("approval-id", "");
180
+ if (!approvalId) {
181
+ throw new Error("infra-contract ci requires --approval-id for apply-enabled contracts");
182
+ }
183
+ applyResult = applyInfraContract({
184
+ cwd,
185
+ sourceSha,
186
+ approvalId,
187
+ dryRun: true,
188
+ plan,
189
+ planMaxAgeMinutes: readNumberArg(
190
+ "plan-max-age-minutes",
191
+ readNumberEnv("BUILDCHAIN_INFRA_CONTRACT_PLAN_MAX_AGE_MINUTES", 60),
192
+ ),
193
+ });
194
+ writeJsonFile(applyResult, files.applyResult);
195
+ }
196
+ const evidenceBundle = createInfraContractEvidenceBundle({
197
+ artifact,
198
+ applyResult,
199
+ propagationResult,
200
+ });
201
+ writeJsonFile(evidenceBundle, files.evidenceBundle);
202
+ const verification = verifyInfraContractEvidenceBundle(evidenceBundle);
203
+ writeJsonFile(verification, files.verification);
204
+ if (!verification.ok) {
205
+ throw new Error(`infra-contract ci evidence verification failed: ${verification.issues.map((issue) => issue.code).join(", ")}`);
206
+ }
207
+ const result = {
208
+ schemaVersion: 1,
209
+ contract: INFRA_CONTRACT_CI_CONTRACT,
210
+ cwd,
211
+ sourceSha,
212
+ artifactHash: artifact.artifactHash,
213
+ evidenceBundleHash: evidenceBundle.bundleHash,
214
+ verificationOk: verification.ok,
215
+ mutationAllowed: false,
216
+ mutationExecuted: false,
217
+ propagationPlanned: Boolean(propagationResult),
218
+ files: Object.fromEntries(
219
+ Object.entries(files)
220
+ .filter(([, filePath]) => fs.existsSync(filePath))
221
+ .map(([key, filePath]) => [key, posixRelative(cwd, filePath)]),
222
+ ),
223
+ };
224
+ writeJson(result, output);
225
+ writeGitHubOutputs({
226
+ "infra-contract-hash": artifact.artifactHash,
227
+ "infra-evidence-bundle-hash": evidenceBundle.bundleHash,
228
+ "infra-evidence-verification-ok": String(verification.ok),
229
+ "infra-ci-json": JSON.stringify(result),
230
+ });
231
+ return result;
232
+ }
233
+
234
+ if (mode === "plan") {
235
+ const result = createInfraContractPlan({
236
+ cwd,
237
+ sourceSha,
238
+ executeAdapterCommands: readBooleanArg("execute-adapter-commands", false),
239
+ });
240
+ writeJson(result, output);
241
+ writeGitHubOutputs({
242
+ "infra-plan-hash": result.planHash,
243
+ "infra-plan-json": JSON.stringify(result),
244
+ });
245
+ return result;
246
+ }
247
+
248
+ if (mode === "contract") {
249
+ const plan = readJsonFileArg("plan");
250
+ const result = createInfraContractArtifact({
251
+ cwd,
252
+ sourceSha,
253
+ plan,
254
+ approvedBy: readArg("approved-by", process.env.GITHUB_ACTOR || ""),
255
+ approvalId: readArg("approval-id", ""),
256
+ applyRunId: readArg("apply-run-id", process.env.GITHUB_RUN_ID || ""),
257
+ rollbackPointer: readArg("rollback-pointer", process.env.BUILDCHAIN_ROLLBACK_REF || ""),
258
+ executeAdapterCommands: readBooleanArg("execute-adapter-commands", false),
259
+ });
260
+ writeJson(result, output);
261
+ writeGitHubOutputs({
262
+ "infra-contract-hash": result.artifactHash,
263
+ "infra-contract-json": JSON.stringify(result),
264
+ });
265
+ return result;
266
+ }
267
+
268
+ if (mode === "propagation-plan") {
269
+ const artifact = readJsonFileArg("artifact");
270
+ const result = createInfraContractPropagationPlan({
271
+ cwd,
272
+ artifact,
273
+ branchPrefix: readArg("branch-prefix", "buildchain/infra-contract"),
274
+ });
275
+ writeJson(result, output);
276
+ writeGitHubOutputs({
277
+ "infra-propagation-count": String(result.pullRequests.length),
278
+ "infra-propagation-plan-json": JSON.stringify(result),
279
+ });
280
+ return result;
281
+ }
282
+
283
+ if (mode === "propagation-apply") {
284
+ const result = applyInfraContractPropagation({
285
+ cwd,
286
+ artifact: readJsonFileArg("artifact"),
287
+ propagationPlan: readJsonFileArg("propagation-plan"),
288
+ dryRun: readBooleanArg("dry-run", true),
289
+ approvalId: readArg("approval-id", ""),
290
+ consumerWorkspaces: readKeyValueMapArg("consumer-workspace"),
291
+ });
292
+ writeJson(result, output);
293
+ writeGitHubOutputs({
294
+ "infra-propagation-status": result.status,
295
+ "infra-propagation-result-json": JSON.stringify(result),
296
+ });
297
+ return result;
298
+ }
299
+
300
+ if (mode === "evidence-bundle") {
301
+ const result = createInfraContractEvidenceBundle({
302
+ artifact: readJsonFileArg("artifact"),
303
+ applyResult: readJsonFileArg("apply-result"),
304
+ propagationResult: readJsonFileArg("propagation-result"),
305
+ });
306
+ writeJson(result, output);
307
+ writeGitHubOutputs({
308
+ "infra-evidence-bundle-hash": result.bundleHash,
309
+ "infra-evidence-bundle-json": JSON.stringify(result),
310
+ });
311
+ return result;
312
+ }
313
+
314
+ if (mode === "apply") {
315
+ const result = applyInfraContract({
316
+ cwd,
317
+ sourceSha,
318
+ approvalId: readArg("approval-id", ""),
319
+ dryRun: readBooleanArg("dry-run", true),
320
+ plan: readJsonFileArg("plan"),
321
+ planMaxAgeMinutes: readNumberArg(
322
+ "plan-max-age-minutes",
323
+ readNumberEnv("BUILDCHAIN_INFRA_CONTRACT_PLAN_MAX_AGE_MINUTES", 60),
324
+ ),
325
+ executeAdapterCommands: readBooleanArg("execute-adapter-commands", false),
326
+ });
327
+ writeJson(result, output);
328
+ writeGitHubOutputs({
329
+ "infra-apply-status": result.status,
330
+ "infra-apply-result-json": JSON.stringify(result),
331
+ });
332
+ return result;
333
+ }
334
+
335
+ throw new Error(`unsupported infra-contract mode: ${mode}`);
336
+ }
337
+
338
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
339
+ try {
340
+ infraContractCli();
341
+ } catch (error) {
342
+ console.error(`::error::${String(error.message || error).replace(/\r?\n/g, "%0A")}`);
343
+ process.exitCode = 1;
344
+ }
345
+ }
@@ -212,7 +212,78 @@ command = "corepack pnpm run check"
212
212
  `;
213
213
  }
214
214
 
215
- function workflowYaml({ runnerPreset, artifactName }) {
215
+ function infraContractToml(cwd) {
216
+ const name = repoName(cwd);
217
+ return `schema = 1
218
+
219
+ [project]
220
+ type = "infra-contract"
221
+ name = "${name}"
222
+
223
+ [infra]
224
+ adapter = "manual-observed"
225
+ adoption_mode = "manual-observed"
226
+ apply = "disabled"
227
+ environment = "staging"
228
+ desired = ["infra/desired.json"]
229
+ contract = ["infra/outputs.json"]
230
+
231
+ [[consumers]]
232
+ repo = "kungfu-systems/site-example"
233
+ path = "infra/outputs.json"
234
+ source = "infra/outputs.json"
235
+
236
+ [lifecycle.verify]
237
+ command = "buildchain infra-contract --mode ci"
238
+ `;
239
+ }
240
+
241
+ function infraContractDesiredJson(cwd) {
242
+ return `${JSON.stringify({
243
+ service: repoName(cwd),
244
+ environment: "staging",
245
+ resources: [
246
+ {
247
+ kind: "static-site",
248
+ name: repoName(cwd),
249
+ desiredState: {
250
+ hostname: "staging.example.com",
251
+ accessControl: "managed-network",
252
+ owner: "platform",
253
+ },
254
+ },
255
+ ],
256
+ }, null, 2)}\n`;
257
+ }
258
+
259
+ function infraContractOutputsJson(cwd) {
260
+ return `${JSON.stringify({
261
+ service: repoName(cwd),
262
+ environment: "staging",
263
+ observedMode: "manual-observed",
264
+ outputs: {
265
+ hostname: "staging.example.com",
266
+ distributionId: "observed-distribution-id",
267
+ bucket: "observed-bucket-name",
268
+ },
269
+ }, null, 2)}\n`;
270
+ }
271
+
272
+ function workflowArtifactPaths(type) {
273
+ if (type === "infra-contract") {
274
+ return `.buildchain/infra-contract-validate.json
275
+ .buildchain/infra-contract-plan.json
276
+ .buildchain/buildchain.infra-contract.json
277
+ .buildchain/infra-contract-propagation.json
278
+ .buildchain/infra-contract-propagation-apply.json
279
+ .buildchain/infra-contract-evidence-bundle.json
280
+ .buildchain/infra-contract-evidence-verification.json`;
281
+ }
282
+ return `dist
283
+ build/stage`;
284
+ }
285
+
286
+ function workflowYaml({ type, runnerPreset, artifactName }) {
216
287
  return `name: Build
217
288
 
218
289
  on:
@@ -242,8 +313,7 @@ jobs:
242
313
  runner-preset: "${runnerPreset}"
243
314
  artifact-name-template: "${artifactName}"
244
315
  artifact-paths: |
245
- dist
246
- build/stage
316
+ ${workflowArtifactPaths(type)}
247
317
  `;
248
318
  }
249
319
 
@@ -276,15 +346,19 @@ export function initBuildchainRepo({
276
346
  if (type === "web-surface") {
277
347
  return webSurfaceToml(resolvedCwd);
278
348
  }
349
+ if (type === "infra-contract") {
350
+ return infraContractToml(resolvedCwd);
351
+ }
279
352
  if (type === "anchored-package") {
280
353
  return anchoredPackageToml(resolvedCwd, manager);
281
354
  }
282
- throw new Error("init --type must be one of package, native, web-surface, or anchored-package");
355
+ throw new Error("init --type must be one of package, native, web-surface, infra-contract, or anchored-package");
283
356
  })();
284
357
 
285
358
  const written = [
286
359
  writeIfAllowed(path.join(resolvedCwd, "buildchain.toml"), toml, { force }),
287
360
  writeIfAllowed(path.join(resolvedCwd, ".github", "workflows", "build.yml"), workflowYaml({
361
+ type,
288
362
  runnerPreset,
289
363
  artifactName,
290
364
  }), { force }),
@@ -294,6 +368,13 @@ export function initBuildchainRepo({
294
368
  written.push(writeIfAllowed(path.join(resolvedCwd, "release.json"), "{\n \"upstream\": \"\",\n \"version\": \"0.0.0\"\n}\n", { force }));
295
369
  }
296
370
 
371
+ if (type === "infra-contract") {
372
+ written.push(
373
+ writeIfAllowed(path.join(resolvedCwd, "infra", "desired.json"), infraContractDesiredJson(resolvedCwd), { force }),
374
+ writeIfAllowed(path.join(resolvedCwd, "infra", "outputs.json"), infraContractOutputsJson(resolvedCwd), { force }),
375
+ );
376
+ }
377
+
297
378
  return {
298
379
  schemaVersion: 1,
299
380
  type,