@kungfu-tech/buildchain 2.14.0 → 2.14.1-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,288 @@
1
+ import crypto from "node:crypto";
2
+
3
+ const DIGEST_RE = /^sha256:[0-9a-f]{64}$/;
4
+ const SOURCE_RE = /^[0-9a-f]{40,64}$/;
5
+ const SAFE_PATH_RE = /^(?:~\/|[A-Za-z0-9._-]+\/)[A-Za-z0-9._/+-]+$/;
6
+ const LAYERS = new Set(["dependency", "compiler"]);
7
+
8
+ function assert(condition, message) {
9
+ if (!condition) throw new Error(message);
10
+ }
11
+
12
+ function ordered(value) {
13
+ if (Array.isArray(value)) return value.map(ordered);
14
+ if (value && typeof value === "object") {
15
+ return Object.fromEntries(
16
+ Object.keys(value)
17
+ .sort()
18
+ .map((key) => [key, ordered(value[key])]),
19
+ );
20
+ }
21
+ return value;
22
+ }
23
+
24
+ function stableJson(value) {
25
+ return JSON.stringify(ordered(value));
26
+ }
27
+
28
+ function digest(value) {
29
+ return `sha256:${crypto
30
+ .createHash("sha256")
31
+ .update(typeof value === "string" ? value : stableJson(value))
32
+ .digest("hex")}`;
33
+ }
34
+
35
+ function shortDigest(value) {
36
+ return value.replace(/^sha256:/, "").slice(0, 24);
37
+ }
38
+
39
+ function exactKeys(value, allowed, label) {
40
+ assert(
41
+ value && typeof value === "object" && !Array.isArray(value),
42
+ `${label} must be an object`,
43
+ );
44
+ for (const key of Object.keys(value))
45
+ assert(allowed.has(key), `${label}.${key} is not allowed`);
46
+ }
47
+
48
+ function checkedText(value, label) {
49
+ assert(
50
+ typeof value === "string" && value.trim() === value && value.length > 0,
51
+ `${label} is required`,
52
+ );
53
+ assert(!/[\r\n\0]/.test(value), `${label} contains control characters`);
54
+ return value;
55
+ }
56
+
57
+ function checkedDigest(value, label) {
58
+ assert(DIGEST_RE.test(value), `${label} must be a sha256 digest`);
59
+ return value;
60
+ }
61
+
62
+ function normalizeRoots(roots) {
63
+ assert(
64
+ Array.isArray(roots) && roots.length > 0 && roots.length <= 8,
65
+ "roots must contain 1-8 entries",
66
+ );
67
+ const ids = new Set();
68
+ const paths = new Set();
69
+ return roots
70
+ .map((root, index) => {
71
+ exactKeys(root, new Set(["id", "path"]), `roots[${index}]`);
72
+ const id = checkedText(root.id, `roots[${index}].id`);
73
+ assert(
74
+ /^[a-z0-9][a-z0-9-]{0,31}$/.test(id),
75
+ `roots[${index}].id is invalid`,
76
+ );
77
+ const normalizedPath = checkedText(
78
+ root.path,
79
+ `roots[${index}].path`,
80
+ ).replaceAll("\\", "/");
81
+ assert(
82
+ SAFE_PATH_RE.test(normalizedPath),
83
+ `roots[${index}].path must be workspace-relative or start with ~/`,
84
+ );
85
+ assert(
86
+ !normalizedPath.split("/").includes(".."),
87
+ `roots[${index}].path cannot escape its root`,
88
+ );
89
+ assert(!ids.has(id), `duplicate root id: ${id}`);
90
+ assert(
91
+ !paths.has(normalizedPath),
92
+ `duplicate cache root: ${normalizedPath}`,
93
+ );
94
+ ids.add(id);
95
+ paths.add(normalizedPath);
96
+ return { id, path: normalizedPath };
97
+ })
98
+ .sort((left, right) => left.id.localeCompare(right.id));
99
+ }
100
+
101
+ function normalizeManifest(manifest) {
102
+ exactKeys(
103
+ manifest,
104
+ new Set(["schema", "layer", "roots", "identity"]),
105
+ "manifest",
106
+ );
107
+ assert(
108
+ manifest.schema === "buildchain.portable-dev-cache-manifest/v1",
109
+ "unsupported portable dev cache manifest schema",
110
+ );
111
+ assert(LAYERS.has(manifest.layer), "layer must be dependency or compiler");
112
+ exactKeys(
113
+ manifest.identity,
114
+ new Set([
115
+ "platform",
116
+ "arch",
117
+ "runnerImage",
118
+ "toolchainDigest",
119
+ "dependencyLockDigest",
120
+ "profileDigest",
121
+ "sourceSha",
122
+ "planDigest",
123
+ ]),
124
+ "manifest.identity",
125
+ );
126
+ const identity = {
127
+ platform: checkedText(
128
+ manifest.identity.platform,
129
+ "manifest.identity.platform",
130
+ ).toLowerCase(),
131
+ arch: checkedText(
132
+ manifest.identity.arch,
133
+ "manifest.identity.arch",
134
+ ).toLowerCase(),
135
+ runnerImage: checkedText(
136
+ manifest.identity.runnerImage,
137
+ "manifest.identity.runnerImage",
138
+ ),
139
+ toolchainDigest: checkedDigest(
140
+ manifest.identity.toolchainDigest,
141
+ "manifest.identity.toolchainDigest",
142
+ ),
143
+ dependencyLockDigest: checkedDigest(
144
+ manifest.identity.dependencyLockDigest,
145
+ "manifest.identity.dependencyLockDigest",
146
+ ),
147
+ profileDigest: checkedDigest(
148
+ manifest.identity.profileDigest,
149
+ "manifest.identity.profileDigest",
150
+ ),
151
+ sourceSha: checkedText(
152
+ manifest.identity.sourceSha,
153
+ "manifest.identity.sourceSha",
154
+ ).toLowerCase(),
155
+ planDigest: checkedDigest(
156
+ manifest.identity.planDigest,
157
+ "manifest.identity.planDigest",
158
+ ),
159
+ };
160
+ assert(
161
+ SOURCE_RE.test(identity.sourceSha),
162
+ "manifest.identity.sourceSha must be a 40-64 character Git SHA",
163
+ );
164
+ return {
165
+ schema: manifest.schema,
166
+ layer: manifest.layer,
167
+ roots: normalizeRoots(manifest.roots),
168
+ identity,
169
+ };
170
+ }
171
+
172
+ export function createPortableDevCachePlan(manifest) {
173
+ const normalized = normalizeManifest(manifest);
174
+ const compatibility = {
175
+ schema: normalized.schema,
176
+ layer: normalized.layer,
177
+ roots: normalized.roots,
178
+ platform: normalized.identity.platform,
179
+ arch: normalized.identity.arch,
180
+ runnerImage: normalized.identity.runnerImage,
181
+ toolchainDigest: normalized.identity.toolchainDigest,
182
+ dependencyLockDigest: normalized.identity.dependencyLockDigest,
183
+ profileDigest: normalized.identity.profileDigest,
184
+ };
185
+ const compatibilityDigest = digest(compatibility);
186
+ const exactRootDigest = digest({
187
+ compatibilityDigest,
188
+ sourceSha: normalized.identity.sourceSha,
189
+ planDigest: normalized.identity.planDigest,
190
+ });
191
+ const prefix = [
192
+ "buildchain-pdc-v1",
193
+ normalized.layer,
194
+ normalized.identity.platform.replace(/[^a-z0-9_-]+/g, "-"),
195
+ normalized.identity.arch.replace(/[^a-z0-9_-]+/g, "-"),
196
+ shortDigest(compatibilityDigest),
197
+ ].join("-");
198
+ const plan = {
199
+ schema: "buildchain.portable-dev-cache-plan/v1",
200
+ provider: "github-actions-cache",
201
+ manifest: normalized,
202
+ compatibilityDigest,
203
+ exactRootDigest,
204
+ key: `${prefix}-${shortDigest(exactRootDigest)}`,
205
+ restoreKeys: [`${prefix}-`],
206
+ paths: normalized.roots.map(({ path }) => path),
207
+ };
208
+ return { ...plan, planDigest: digest(plan) };
209
+ }
210
+
211
+ export function verifyPortableDevCachePlan(plan) {
212
+ assert(
213
+ plan?.schema === "buildchain.portable-dev-cache-plan/v1",
214
+ "unsupported portable dev cache plan schema",
215
+ );
216
+ const { planDigest, ...body } = plan;
217
+ assert(planDigest === digest(body), "portable dev cache plan digest drift");
218
+ const rebuilt = createPortableDevCachePlan(plan.manifest);
219
+ assert(
220
+ stableJson(rebuilt) === stableJson(plan),
221
+ "portable dev cache plan does not match its manifest",
222
+ );
223
+ return true;
224
+ }
225
+
226
+ export function createPortableDevCacheReceipt({
227
+ plan,
228
+ matchedKey = "",
229
+ cacheHit = "",
230
+ validationStatus = "pass",
231
+ validationReason = "",
232
+ coldFallbackStatus = "not-run",
233
+ }) {
234
+ verifyPortableDevCachePlan(plan);
235
+ assert(
236
+ ["", "true", "false"].includes(String(cacheHit)),
237
+ "cacheHit must be empty, true, or false",
238
+ );
239
+ assert(
240
+ ["pass", "fail"].includes(validationStatus),
241
+ "validationStatus must be pass or fail",
242
+ );
243
+ assert(
244
+ ["not-run", "passed", "failed"].includes(coldFallbackStatus),
245
+ "coldFallbackStatus must be not-run, passed, or failed",
246
+ );
247
+ let outcome = "miss";
248
+ if (matchedKey === plan.key && String(cacheHit) === "true") outcome = "exact";
249
+ else if (
250
+ matchedKey &&
251
+ plan.restoreKeys.some((prefix) => matchedKey.startsWith(prefix)) &&
252
+ String(cacheHit) !== "true"
253
+ )
254
+ outcome = "compatible";
255
+ else if (matchedKey)
256
+ throw new Error("matched cache key is outside the portable plan authority");
257
+ else if (String(cacheHit) === "true")
258
+ throw new Error("cacheHit=true requires the exact planned key");
259
+ if (validationStatus === "fail") outcome = "corrupt";
260
+ const cacheUsable =
261
+ validationStatus === "pass" && ["exact", "compatible"].includes(outcome);
262
+ const coldFallbackRequired = outcome === "miss" || outcome === "corrupt";
263
+ if (!coldFallbackRequired && coldFallbackStatus !== "not-run") {
264
+ throw new Error(
265
+ "cold fallback evidence is only valid for miss or corrupt outcomes",
266
+ );
267
+ }
268
+ const receipt = {
269
+ schema: "buildchain.portable-dev-cache-receipt/v1",
270
+ provider: plan.provider,
271
+ planDigest: plan.planDigest,
272
+ exactRootDigest: plan.exactRootDigest,
273
+ compatibilityDigest: plan.compatibilityDigest,
274
+ sourceSha: plan.manifest.identity.sourceSha,
275
+ planRootDigest: plan.manifest.identity.planDigest,
276
+ layer: plan.manifest.layer,
277
+ outcome,
278
+ usable: cacheUsable,
279
+ coldFallbackRequired,
280
+ coldFallbackStatus,
281
+ qualified:
282
+ cacheUsable ||
283
+ (coldFallbackRequired && coldFallbackStatus === "passed"),
284
+ matchedKey: matchedKey || null,
285
+ validation: { status: validationStatus, reason: validationReason || null },
286
+ };
287
+ return { ...receipt, receiptDigest: digest(receipt) };
288
+ }
@@ -91,6 +91,7 @@ export function normalizePatrolOptions(options = {}) {
91
91
  sameRepositoryOnly: options.sameRepositoryOnly ?? process.env.BUILDCHAIN_PATROL_SAME_REPOSITORY_ONLY,
92
92
  maxActions: intOption(options.maxActions ?? process.env.BUILDCHAIN_PATROL_MAX_ACTIONS, 1),
93
93
  mergeMethod: String(options.mergeMethod || process.env.BUILDCHAIN_PATROL_MERGE_METHOD || "merge").trim(),
94
+ landingMode: String(options.landingMode || process.env.BUILDCHAIN_PATROL_LANDING_MODE || "auto").trim(),
94
95
  dryRun: boolOption(options.dryRun ?? process.env.BUILDCHAIN_PATROL_DRY_RUN, true),
95
96
  outputPath: String(options.outputPath || process.env.BUILDCHAIN_PATROL_OUTPUT_PATH || DEFAULT_OUTPUT_PATH),
96
97
  };
@@ -167,6 +168,7 @@ export async function runBuildchainPatrol(optionsInput = {}, clientInput) {
167
168
  sameRepositoryOnly: options.sameRepositoryOnly,
168
169
  maxMerges: options.maxActions,
169
170
  mergeMethod: options.mergeMethod,
171
+ landingMode: options.landingMode,
170
172
  dryRun: options.dryRun,
171
173
  outputPath: path.join(path.dirname(options.outputPath), "dev-pr-auto-merge.json"),
172
174
  },
@@ -178,7 +180,7 @@ export async function runBuildchainPatrol(optionsInput = {}, clientInput) {
178
180
  result: mergeResult,
179
181
  });
180
182
  result.summary.evaluatedCount += mergeResult.evaluated.length;
181
- result.summary.actionCount += mergeResult.merged.length;
183
+ result.summary.actionCount += mergeResult.actions.length;
182
184
  result.summary.skippedCount += mergeResult.skipped.length;
183
185
  }
184
186