@kungfu-tech/buildchain 0.0.0-bootstrap.0 → 2.0.13-alpha.10
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 +262 -0
- package/bin/buildchain.mjs +222 -0
- package/docs/cli.md +124 -0
- package/docs/lifecycle-protocol.md +422 -0
- package/docs/publish-transaction.md +285 -0
- package/docs/reusable-build-surface.md +350 -0
- package/docs/web-surface-deployments.md +211 -0
- package/package.json +52 -1
- package/packages/core/README.md +15 -0
- package/packages/core/buildchain-config.js +721 -0
- package/packages/core/index.js +40 -0
- package/packages/core/package-manager.js +291 -0
- package/packages/core/publish-transaction.js +418 -0
- package/packages/core/release-line-dry-run.js +296 -0
- package/scripts/aggregate-build-summary.mjs +88 -0
- package/scripts/build-contract-core.mjs +731 -0
- package/scripts/check-inventory.mjs +325 -0
- package/scripts/init-repo.mjs +316 -0
- package/scripts/npm-publish-dry-run.mjs +176 -0
- package/scripts/npm-publish-transaction.mjs +268 -0
- package/scripts/publish-source-ref-resolver.mjs +113 -0
- package/scripts/release-line-dry-run.mjs +53 -0
- package/scripts/release-line-policy.mjs +141 -0
- package/scripts/release-transaction.mjs +212 -0
- package/scripts/resolve-build-contract.mjs +63 -0
- package/scripts/resolve-publish-gate.mjs +33 -0
- package/scripts/resolve-publish-source.mjs +99 -0
- package/scripts/run-lifecycle-core.mjs +162 -0
- package/scripts/run-lifecycle.mjs +40 -0
- package/scripts/strip-trailing-whitespace.mjs +14 -0
- package/scripts/tsup-action.config.mjs +19 -0
- package/scripts/verify-publish-source-lock.mjs +37 -0
- package/scripts/verify-release-pr.mjs +34 -0
- package/scripts/web-surface-core.mjs +382 -0
- package/scripts/web-surface.mjs +112 -0
|
@@ -0,0 +1,731 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export const RUNNER_PRESETS = Object.freeze({
|
|
6
|
+
"github-hosted": [
|
|
7
|
+
{ id: "linux-x64", name: "Linux x64", runner: "[\"ubuntu-24.04\"]" },
|
|
8
|
+
{ id: "macos", name: "macOS", runner: "[\"macos-latest\"]" },
|
|
9
|
+
{ id: "windows-x64", name: "Windows x64", runner: "[\"windows-2022\"]" },
|
|
10
|
+
],
|
|
11
|
+
"kungfu-v4-self-hosted": [
|
|
12
|
+
{
|
|
13
|
+
id: "linux-x64",
|
|
14
|
+
name: "Linux x64",
|
|
15
|
+
runner: "[\"self-hosted\",\"Linux\",\"X64\",\"kungfu-build-v4-linux-x64\"]",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
id: "macos-arm64",
|
|
19
|
+
name: "macOS ARM64",
|
|
20
|
+
runner: "[\"self-hosted\",\"macOS\",\"ARM64\",\"kungfu-build-v4-macos-arm64\"]",
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: "windows-x64",
|
|
24
|
+
name: "Windows x64",
|
|
25
|
+
runner: "[\"self-hosted\",\"Windows\",\"X64\",\"kungfu-build-v4-windows-x64\"]",
|
|
26
|
+
},
|
|
27
|
+
],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const RUNNER_PRESET_ALIASES = Object.freeze({
|
|
31
|
+
github: "github-hosted",
|
|
32
|
+
"github-hosted-default": "github-hosted",
|
|
33
|
+
kungfu: "kungfu-v4-self-hosted",
|
|
34
|
+
"kungfu-self-hosted": "kungfu-v4-self-hosted",
|
|
35
|
+
"kungfu-v4": "kungfu-v4-self-hosted",
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
export const DEFAULT_ARTIFACT_NAME_TEMPLATE = "{artifact}-{platform}-{sha}";
|
|
39
|
+
export const DEFAULT_PUBLISH_REFS = Object.freeze({
|
|
40
|
+
alpha: [
|
|
41
|
+
"^refs/heads/alpha/v\\d+/v\\d+\\.\\d+$",
|
|
42
|
+
"^refs/tags/v\\d+\\.\\d+\\.\\d+-alpha\\.\\d+$",
|
|
43
|
+
"^refs/heads/publish-gate/alpha/.+/.+$",
|
|
44
|
+
],
|
|
45
|
+
release: [
|
|
46
|
+
"^refs/heads/release/v\\d+/v\\d+\\.\\d+$",
|
|
47
|
+
"^refs/tags/v\\d+\\.\\d+\\.\\d+$",
|
|
48
|
+
"^refs/tags/v\\d+\\.\\d+$",
|
|
49
|
+
"^refs/tags/v\\d+$",
|
|
50
|
+
"^refs/heads/publish-gate/release/.+/.+$",
|
|
51
|
+
],
|
|
52
|
+
anchor: ["^refs/heads/publish-gate/anchor$"],
|
|
53
|
+
major: [
|
|
54
|
+
"^refs/heads/publish-gate/major$",
|
|
55
|
+
"^refs/heads/major-gate$",
|
|
56
|
+
"^refs/tags/v\\d+\\.0\\.0$",
|
|
57
|
+
"^refs/tags/v\\d+\\.0$",
|
|
58
|
+
"^refs/tags/v\\d+$",
|
|
59
|
+
],
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function parseJsonObject(value, label) {
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(value);
|
|
65
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
66
|
+
throw new Error(`${label} must be a JSON object`);
|
|
67
|
+
}
|
|
68
|
+
return parsed;
|
|
69
|
+
} catch (error) {
|
|
70
|
+
if (error.message.includes(label)) {
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
throw new Error(`${label} must be valid JSON: ${error.message}`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function getByDottedKey(target, key) {
|
|
78
|
+
return String(key)
|
|
79
|
+
.split(".")
|
|
80
|
+
.reduce((current, segment) => current?.[segment], target);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function assertSha(sha, label = "sourceSha") {
|
|
84
|
+
const value = String(sha || "").trim();
|
|
85
|
+
if (!/^[0-9a-f]{40}$/i.test(value)) {
|
|
86
|
+
throw new Error(`${label} must be a 40-character Git SHA`);
|
|
87
|
+
}
|
|
88
|
+
return value;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeGitRefName(value = "") {
|
|
92
|
+
return String(value || "")
|
|
93
|
+
.trim()
|
|
94
|
+
.replace(/^refs\/heads\//, "")
|
|
95
|
+
.replace(/^refs\/tags\//, "");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function normalizeGitFullRef(value = "") {
|
|
99
|
+
const ref = String(value || "").trim();
|
|
100
|
+
if (!ref) {
|
|
101
|
+
return "";
|
|
102
|
+
}
|
|
103
|
+
if (ref.startsWith("refs/")) {
|
|
104
|
+
return ref;
|
|
105
|
+
}
|
|
106
|
+
if (/^v\d/.test(ref)) {
|
|
107
|
+
return `refs/tags/${ref}`;
|
|
108
|
+
}
|
|
109
|
+
return `refs/heads/${ref}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function parsePublishSourceRef(value = "") {
|
|
113
|
+
const sourceRef = normalizeGitRefName(value);
|
|
114
|
+
if (!sourceRef) {
|
|
115
|
+
return {
|
|
116
|
+
sourceRef: "",
|
|
117
|
+
fullRef: "",
|
|
118
|
+
enabled: false,
|
|
119
|
+
channel: "none",
|
|
120
|
+
line: "",
|
|
121
|
+
consumerVersion: "",
|
|
122
|
+
anchor: false,
|
|
123
|
+
legacyAlias: false,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
if (sourceRef === "publish-gate/anchor") {
|
|
127
|
+
return {
|
|
128
|
+
sourceRef,
|
|
129
|
+
fullRef: "refs/heads/publish-gate/anchor",
|
|
130
|
+
enabled: true,
|
|
131
|
+
channel: "anchor",
|
|
132
|
+
line: "",
|
|
133
|
+
consumerVersion: "",
|
|
134
|
+
anchor: true,
|
|
135
|
+
legacyAlias: false,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (sourceRef === "publish-gate/major" || sourceRef === "major-gate") {
|
|
139
|
+
return {
|
|
140
|
+
sourceRef,
|
|
141
|
+
fullRef: sourceRef === "major-gate" ? "refs/heads/major-gate" : "refs/heads/publish-gate/major",
|
|
142
|
+
enabled: true,
|
|
143
|
+
channel: "major",
|
|
144
|
+
line: "",
|
|
145
|
+
consumerVersion: "",
|
|
146
|
+
anchor: false,
|
|
147
|
+
legacyAlias: sourceRef === "major-gate",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
const match = sourceRef.match(/^publish-gate\/(alpha|release)\/(.+)\/([^/]+)$/);
|
|
151
|
+
if (!match) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`unsupported publish source ref: ${sourceRef}; expected publish-gate/alpha/<line>/<version>, publish-gate/release/<line>/<version>, publish-gate/anchor, publish-gate/major, or major-gate`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const [, channel, line, consumerVersion] = match;
|
|
157
|
+
if (!line.includes("/")) {
|
|
158
|
+
throw new Error(`publish source line must include a major/minor path: ${sourceRef}`);
|
|
159
|
+
}
|
|
160
|
+
if (!/^[A-Za-z0-9._+~-]+$/.test(consumerVersion)) {
|
|
161
|
+
throw new Error(`publish source consumer version contains unsupported characters: ${consumerVersion}`);
|
|
162
|
+
}
|
|
163
|
+
return {
|
|
164
|
+
sourceRef,
|
|
165
|
+
fullRef: `refs/heads/${sourceRef}`,
|
|
166
|
+
enabled: true,
|
|
167
|
+
channel,
|
|
168
|
+
line,
|
|
169
|
+
consumerVersion,
|
|
170
|
+
anchor: false,
|
|
171
|
+
legacyAlias: false,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function resolvePublishSourceLock({
|
|
176
|
+
publishSourceRef = "",
|
|
177
|
+
publishSourceSha = "",
|
|
178
|
+
fallbackRef = "",
|
|
179
|
+
fallbackSha = "",
|
|
180
|
+
} = {}) {
|
|
181
|
+
const parsed = parsePublishSourceRef(publishSourceRef);
|
|
182
|
+
if (!parsed.enabled) {
|
|
183
|
+
return {
|
|
184
|
+
...parsed,
|
|
185
|
+
sourceRef: "",
|
|
186
|
+
fullRef: "",
|
|
187
|
+
fallbackRef: normalizeGitRefName(fallbackRef),
|
|
188
|
+
fallbackFullRef: normalizeGitFullRef(fallbackRef),
|
|
189
|
+
sourceSha: fallbackSha ? assertSha(fallbackSha, "fallbackSha") : "",
|
|
190
|
+
sourceLocked: false,
|
|
191
|
+
sourceReason: "publish source ref is not configured",
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
return {
|
|
195
|
+
...parsed,
|
|
196
|
+
sourceSha: assertSha(publishSourceSha),
|
|
197
|
+
sourceLocked: true,
|
|
198
|
+
sourceReason: `locked ${parsed.sourceRef} at ${publishSourceSha}`,
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function versionValue(file) {
|
|
203
|
+
if (file.type === "json" || file.type === "toml") {
|
|
204
|
+
return getByDottedKey(file.content, file.key);
|
|
205
|
+
}
|
|
206
|
+
const match = file.source.match(file.pattern);
|
|
207
|
+
return match?.groups?.version;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export async function createResolvedReleaseManifest({
|
|
211
|
+
cwd = process.cwd(),
|
|
212
|
+
repository = "",
|
|
213
|
+
sourceRef = "",
|
|
214
|
+
sourceSha = "",
|
|
215
|
+
anchorRequestJson = "",
|
|
216
|
+
publishRegistry = "https://registry.npmjs.org/",
|
|
217
|
+
distTag = "",
|
|
218
|
+
visibilityGate = "main-package-last",
|
|
219
|
+
} = {}) {
|
|
220
|
+
const lock = resolvePublishSourceLock({
|
|
221
|
+
publishSourceRef: sourceRef,
|
|
222
|
+
publishSourceSha: sourceSha,
|
|
223
|
+
fallbackRef: sourceRef,
|
|
224
|
+
fallbackSha: sourceSha,
|
|
225
|
+
});
|
|
226
|
+
const {
|
|
227
|
+
discoverConfiguredVersionStateFiles,
|
|
228
|
+
getVersionStrategy,
|
|
229
|
+
loadBuildchainConfig,
|
|
230
|
+
loadConfiguredAnchorManifest,
|
|
231
|
+
} = await import("../packages/core/buildchain-config.js");
|
|
232
|
+
const loadedConfig = loadBuildchainConfig(cwd);
|
|
233
|
+
const versionStrategy = getVersionStrategy(loadedConfig);
|
|
234
|
+
const versionFiles = loadedConfig?.config?.version
|
|
235
|
+
? discoverConfiguredVersionStateFiles(cwd, loadedConfig)
|
|
236
|
+
: [];
|
|
237
|
+
const resolvedVersionFiles = versionFiles.map((file) => ({
|
|
238
|
+
path: file.path,
|
|
239
|
+
type: file.type,
|
|
240
|
+
key: file.key,
|
|
241
|
+
version: versionValue(file),
|
|
242
|
+
}));
|
|
243
|
+
if (lock.consumerVersion) {
|
|
244
|
+
if (resolvedVersionFiles.length === 0) {
|
|
245
|
+
throw new Error("publish source consumer version requires configured version.files");
|
|
246
|
+
}
|
|
247
|
+
for (const file of resolvedVersionFiles) {
|
|
248
|
+
if (file.version !== lock.consumerVersion) {
|
|
249
|
+
throw new Error(
|
|
250
|
+
`publish source version mismatch: ${file.path} has ${file.version}, expected ${lock.consumerVersion}`,
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const anchorManifest = loadConfiguredAnchorManifest(cwd, loadedConfig);
|
|
257
|
+
if (lock.consumerVersion && anchorManifest?.fields?.npmVersion && anchorManifest.fields.npmVersion !== lock.consumerVersion) {
|
|
258
|
+
throw new Error(
|
|
259
|
+
`anchor manifest npmVersion ${anchorManifest.fields.npmVersion} does not match ${lock.consumerVersion}`,
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
const anchorRequest = anchorRequestJson.trim()
|
|
263
|
+
? parseJsonObject(anchorRequestJson, "publish-anchor-request-json")
|
|
264
|
+
: undefined;
|
|
265
|
+
if (lock.channel === "anchor" && !anchorRequest) {
|
|
266
|
+
throw new Error("publish-gate/anchor requires publish-anchor-request-json");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return {
|
|
270
|
+
schema: 1,
|
|
271
|
+
sourceRef: lock.sourceRef,
|
|
272
|
+
sourceSha: lock.sourceSha,
|
|
273
|
+
sourceLocked: lock.sourceLocked,
|
|
274
|
+
channel: lock.channel,
|
|
275
|
+
line: lock.line,
|
|
276
|
+
consumerVersion: lock.consumerVersion,
|
|
277
|
+
repository,
|
|
278
|
+
versionStrategy: versionStrategy.strategy,
|
|
279
|
+
versionNext: versionStrategy.next,
|
|
280
|
+
versionFiles: resolvedVersionFiles,
|
|
281
|
+
anchorManifest: anchorManifest
|
|
282
|
+
? {
|
|
283
|
+
path: anchorManifest.path,
|
|
284
|
+
summary: anchorManifest.fields,
|
|
285
|
+
}
|
|
286
|
+
: undefined,
|
|
287
|
+
anchorRequest,
|
|
288
|
+
publish: {
|
|
289
|
+
registry: publishRegistry,
|
|
290
|
+
distTag: distTag || (lock.channel === "release" ? "latest" : lock.channel === "alpha" ? "alpha" : ""),
|
|
291
|
+
visibilityGate,
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export function verifyPublishSourceLock({ sourceRef = "", expectedSha = "", currentSha = "" } = {}) {
|
|
297
|
+
const expected = assertSha(expectedSha, "expectedSha");
|
|
298
|
+
const current = assertSha(currentSha, "currentSha");
|
|
299
|
+
if (expected !== current) {
|
|
300
|
+
throw new Error(`publish source ref moved: ${sourceRef || "<unknown>"} expected ${expected}, got ${current}`);
|
|
301
|
+
}
|
|
302
|
+
return {
|
|
303
|
+
ok: true,
|
|
304
|
+
sourceRef,
|
|
305
|
+
sourceSha: expected,
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function normalizePackageSet(packages) {
|
|
310
|
+
if (!Array.isArray(packages) || packages.length === 0) {
|
|
311
|
+
throw new Error("package set must include at least one package");
|
|
312
|
+
}
|
|
313
|
+
return packages.map((pkg, index) => {
|
|
314
|
+
const name = String(pkg?.name || "").trim();
|
|
315
|
+
const version = String(pkg?.version || "").trim();
|
|
316
|
+
if (!name) {
|
|
317
|
+
throw new Error(`packages[${index}].name is required`);
|
|
318
|
+
}
|
|
319
|
+
if (!version) {
|
|
320
|
+
throw new Error(`packages[${index}].version is required`);
|
|
321
|
+
}
|
|
322
|
+
return {
|
|
323
|
+
name,
|
|
324
|
+
version,
|
|
325
|
+
role: pkg.role === "main" ? "main" : "platform",
|
|
326
|
+
integrity: pkg.integrity ? String(pkg.integrity) : "",
|
|
327
|
+
};
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function existingPackageKey(entry) {
|
|
332
|
+
return `${entry.name}@${entry.version}`;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function planPackageSetPublish({
|
|
336
|
+
packages = [],
|
|
337
|
+
existingPackages = [],
|
|
338
|
+
mainPackage = "",
|
|
339
|
+
distTag = "alpha",
|
|
340
|
+
} = {}) {
|
|
341
|
+
const normalized = normalizePackageSet(packages).map((pkg) => ({
|
|
342
|
+
...pkg,
|
|
343
|
+
role: pkg.name === mainPackage ? "main" : pkg.role,
|
|
344
|
+
}));
|
|
345
|
+
const mainPackages = normalized.filter((pkg) => pkg.role === "main");
|
|
346
|
+
if (mainPackages.length !== 1) {
|
|
347
|
+
throw new Error("package set must contain exactly one main package");
|
|
348
|
+
}
|
|
349
|
+
const existing = new Map(
|
|
350
|
+
existingPackages.map((pkg) => {
|
|
351
|
+
const normalizedExisting = normalizePackageSet([pkg])[0];
|
|
352
|
+
return [existingPackageKey(normalizedExisting), normalizedExisting];
|
|
353
|
+
}),
|
|
354
|
+
);
|
|
355
|
+
const steps = [];
|
|
356
|
+
for (const pkg of normalized) {
|
|
357
|
+
const already = existing.get(existingPackageKey(pkg));
|
|
358
|
+
if (already) {
|
|
359
|
+
if (pkg.integrity && already.integrity && pkg.integrity !== already.integrity) {
|
|
360
|
+
throw new Error(`existing package integrity mismatch: ${pkg.name}@${pkg.version}`);
|
|
361
|
+
}
|
|
362
|
+
steps.push({ action: "accept-existing", package: pkg });
|
|
363
|
+
} else {
|
|
364
|
+
steps.push({ action: "publish", package: pkg });
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const orderedSteps = [
|
|
368
|
+
...steps.filter((step) => step.package.role !== "main"),
|
|
369
|
+
...steps.filter((step) => step.package.role === "main"),
|
|
370
|
+
];
|
|
371
|
+
const main = mainPackages[0];
|
|
372
|
+
return {
|
|
373
|
+
completeAfterPlan: true,
|
|
374
|
+
visibilityGate: "main-package-last",
|
|
375
|
+
distTag,
|
|
376
|
+
steps: orderedSteps,
|
|
377
|
+
distTagMove: {
|
|
378
|
+
action: "move-dist-tag",
|
|
379
|
+
package: main,
|
|
380
|
+
distTag,
|
|
381
|
+
after: "package-set-complete",
|
|
382
|
+
},
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function normalizePublishRefs(value = "") {
|
|
387
|
+
const raw = String(value || "").trim();
|
|
388
|
+
if (!raw) {
|
|
389
|
+
return DEFAULT_PUBLISH_REFS;
|
|
390
|
+
}
|
|
391
|
+
const parsed = parseJsonObject(raw, "publish-refs-json");
|
|
392
|
+
const normalized = {};
|
|
393
|
+
for (const [channel, patterns] of Object.entries(parsed)) {
|
|
394
|
+
const key = String(channel || "").trim();
|
|
395
|
+
if (!key) {
|
|
396
|
+
throw new Error("publish-refs-json channel names must be non-empty");
|
|
397
|
+
}
|
|
398
|
+
if (!Array.isArray(patterns) || patterns.length === 0) {
|
|
399
|
+
throw new Error(`publish-refs-json.${key} must be a non-empty array`);
|
|
400
|
+
}
|
|
401
|
+
normalized[key] = patterns.map((pattern, index) => {
|
|
402
|
+
const value = String(pattern || "").trim();
|
|
403
|
+
if (!value) {
|
|
404
|
+
throw new Error(`publish-refs-json.${key}[${index}] must be non-empty`);
|
|
405
|
+
}
|
|
406
|
+
try {
|
|
407
|
+
new RegExp(value);
|
|
408
|
+
} catch (error) {
|
|
409
|
+
throw new Error(`publish-refs-json.${key}[${index}] is invalid: ${error.message}`);
|
|
410
|
+
}
|
|
411
|
+
return value;
|
|
412
|
+
});
|
|
413
|
+
}
|
|
414
|
+
return normalized;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function parseJsonArray(value, label) {
|
|
418
|
+
try {
|
|
419
|
+
const parsed = JSON.parse(value);
|
|
420
|
+
if (!Array.isArray(parsed)) {
|
|
421
|
+
throw new Error(`${label} must be a JSON array`);
|
|
422
|
+
}
|
|
423
|
+
return parsed;
|
|
424
|
+
} catch (error) {
|
|
425
|
+
if (error.message.includes(label)) {
|
|
426
|
+
throw error;
|
|
427
|
+
}
|
|
428
|
+
throw new Error(`${label} must be valid JSON: ${error.message}`);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function normalizeRunnerPreset(value) {
|
|
433
|
+
const preset = String(value || "github-hosted").trim() || "github-hosted";
|
|
434
|
+
return RUNNER_PRESET_ALIASES[preset] || preset;
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function normalizePlatform(platform, index) {
|
|
438
|
+
const id = String(platform?.id || "").trim();
|
|
439
|
+
const name = String(platform?.name || id).trim();
|
|
440
|
+
const runner = String(platform?.runner || "").trim();
|
|
441
|
+
if (!id) {
|
|
442
|
+
throw new Error(`platforms-json[${index}].id is required`);
|
|
443
|
+
}
|
|
444
|
+
if (!name) {
|
|
445
|
+
throw new Error(`platforms-json[${index}].name is required`);
|
|
446
|
+
}
|
|
447
|
+
if (!runner) {
|
|
448
|
+
throw new Error(`platforms-json[${index}].runner is required`);
|
|
449
|
+
}
|
|
450
|
+
parseJsonArray(runner, `platforms-json[${index}].runner`);
|
|
451
|
+
return { id, name, runner };
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
export function resolveRunnerMatrix({ runnerPreset = "github-hosted", platformsJson = "" } = {}) {
|
|
455
|
+
const customPlatformsJson = String(platformsJson || "").trim();
|
|
456
|
+
if (customPlatformsJson) {
|
|
457
|
+
const platforms = parseJsonArray(customPlatformsJson, "platforms-json").map(normalizePlatform);
|
|
458
|
+
if (platforms.length === 0) {
|
|
459
|
+
throw new Error("platforms-json must include at least one platform");
|
|
460
|
+
}
|
|
461
|
+
return {
|
|
462
|
+
source: "platforms-json",
|
|
463
|
+
runnerPreset: "custom",
|
|
464
|
+
platforms,
|
|
465
|
+
platformsJson: JSON.stringify(platforms),
|
|
466
|
+
platformCount: platforms.length,
|
|
467
|
+
};
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
const preset = normalizeRunnerPreset(runnerPreset);
|
|
471
|
+
if (preset === "custom") {
|
|
472
|
+
throw new Error("runner-preset=custom requires platforms-json");
|
|
473
|
+
}
|
|
474
|
+
const platforms = RUNNER_PRESETS[preset];
|
|
475
|
+
if (!platforms) {
|
|
476
|
+
throw new Error(`unsupported runner-preset: ${preset}`);
|
|
477
|
+
}
|
|
478
|
+
return {
|
|
479
|
+
source: "runner-preset",
|
|
480
|
+
runnerPreset: preset,
|
|
481
|
+
platforms,
|
|
482
|
+
platformsJson: JSON.stringify(platforms),
|
|
483
|
+
platformCount: platforms.length,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function resolvePublishGate({
|
|
488
|
+
trusted = true,
|
|
489
|
+
publishChannel = "none",
|
|
490
|
+
eventName = "",
|
|
491
|
+
ref = "",
|
|
492
|
+
publishRefsJson = "",
|
|
493
|
+
} = {}) {
|
|
494
|
+
const channel = String(publishChannel || "none").trim() || "none";
|
|
495
|
+
const isTrusted = trusted === true || String(trusted) === "true";
|
|
496
|
+
if (channel === "none") {
|
|
497
|
+
return {
|
|
498
|
+
trusted: isTrusted,
|
|
499
|
+
publishChannel: channel,
|
|
500
|
+
publishAllowed: false,
|
|
501
|
+
publishReason: "publish channel is none",
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
if (channel === "anchor") {
|
|
505
|
+
return {
|
|
506
|
+
trusted: isTrusted,
|
|
507
|
+
publishChannel: channel,
|
|
508
|
+
publishAllowed: false,
|
|
509
|
+
publishReason: "anchor gates resolve source state but do not publish artifacts",
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
if (!isTrusted) {
|
|
513
|
+
return {
|
|
514
|
+
trusted: false,
|
|
515
|
+
publishChannel: channel,
|
|
516
|
+
publishAllowed: false,
|
|
517
|
+
publishReason: "event is not trusted",
|
|
518
|
+
};
|
|
519
|
+
}
|
|
520
|
+
if (String(eventName || "") === "pull_request") {
|
|
521
|
+
return {
|
|
522
|
+
trusted: true,
|
|
523
|
+
publishChannel: channel,
|
|
524
|
+
publishAllowed: false,
|
|
525
|
+
publishReason: "pull_request events may verify but may not publish",
|
|
526
|
+
};
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
const publishRefs = normalizePublishRefs(publishRefsJson);
|
|
530
|
+
const patterns = publishRefs[channel];
|
|
531
|
+
if (!patterns) {
|
|
532
|
+
return {
|
|
533
|
+
trusted: true,
|
|
534
|
+
publishChannel: channel,
|
|
535
|
+
publishAllowed: false,
|
|
536
|
+
publishReason: `unknown publish channel: ${channel}`,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
const refValue = String(ref || "");
|
|
540
|
+
const matchedPattern = patterns.find((pattern) => new RegExp(pattern).test(refValue));
|
|
541
|
+
if (!matchedPattern) {
|
|
542
|
+
return {
|
|
543
|
+
trusted: true,
|
|
544
|
+
publishChannel: channel,
|
|
545
|
+
publishAllowed: false,
|
|
546
|
+
publishReason: `ref ${refValue || "<empty>"} is not allowed for publish channel ${channel}`,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
return {
|
|
550
|
+
trusted: true,
|
|
551
|
+
publishChannel: channel,
|
|
552
|
+
publishAllowed: true,
|
|
553
|
+
publishReason: `ref matched ${matchedPattern}`,
|
|
554
|
+
};
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function sanitizeArtifactName(value) {
|
|
558
|
+
return String(value || "")
|
|
559
|
+
.trim()
|
|
560
|
+
.replace(/[\\/:*?"<>|\r\n]+/g, "-")
|
|
561
|
+
.replace(/\s+/g, "-")
|
|
562
|
+
.replace(/-+/g, "-")
|
|
563
|
+
.replace(/^-|-$/g, "");
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
export function resolveArtifactContract({
|
|
567
|
+
artifactName = "buildchain-artifact",
|
|
568
|
+
artifactNameTemplate = DEFAULT_ARTIFACT_NAME_TEMPLATE,
|
|
569
|
+
platformId = "",
|
|
570
|
+
platformName = "",
|
|
571
|
+
sha = "",
|
|
572
|
+
ref = "",
|
|
573
|
+
runId = "",
|
|
574
|
+
runAttempt = "",
|
|
575
|
+
} = {}) {
|
|
576
|
+
const baseName = String(artifactName || "buildchain-artifact").trim() || "buildchain-artifact";
|
|
577
|
+
const template =
|
|
578
|
+
String(artifactNameTemplate || "").trim() || DEFAULT_ARTIFACT_NAME_TEMPLATE;
|
|
579
|
+
const replacements = {
|
|
580
|
+
artifact: baseName,
|
|
581
|
+
artifactName: baseName,
|
|
582
|
+
platform: platformId,
|
|
583
|
+
platformId,
|
|
584
|
+
platformName,
|
|
585
|
+
sha,
|
|
586
|
+
shortSha: sha ? sha.slice(0, 12) : "",
|
|
587
|
+
ref,
|
|
588
|
+
runId,
|
|
589
|
+
runAttempt,
|
|
590
|
+
};
|
|
591
|
+
const resolved = template.replace(/\{([A-Za-z][A-Za-z0-9]*)\}/g, (match, key) => {
|
|
592
|
+
if (!Object.hasOwn(replacements, key)) {
|
|
593
|
+
throw new Error(`unsupported artifact-name-template placeholder: ${match}`);
|
|
594
|
+
}
|
|
595
|
+
return replacements[key] || "";
|
|
596
|
+
});
|
|
597
|
+
const safeName = sanitizeArtifactName(resolved);
|
|
598
|
+
if (!safeName) {
|
|
599
|
+
throw new Error("artifact-name-template resolved to an empty artifact name");
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
artifactName: safeName,
|
|
603
|
+
artifactBaseName: baseName,
|
|
604
|
+
artifactNameTemplate: template,
|
|
605
|
+
platform: {
|
|
606
|
+
id: platformId,
|
|
607
|
+
name: platformName || platformId,
|
|
608
|
+
},
|
|
609
|
+
};
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
export function parseExpectedArtifactsJson(value = "") {
|
|
613
|
+
const raw = String(value || "").trim();
|
|
614
|
+
if (!raw) {
|
|
615
|
+
return null;
|
|
616
|
+
}
|
|
617
|
+
const expected = parseJsonObject(raw, "expected-artifacts-json");
|
|
618
|
+
const normalized = {};
|
|
619
|
+
if (expected.minFiles !== undefined) {
|
|
620
|
+
normalized.minFiles = Number(expected.minFiles);
|
|
621
|
+
if (!Number.isInteger(normalized.minFiles) || normalized.minFiles < 0) {
|
|
622
|
+
throw new Error("expected-artifacts-json.minFiles must be a non-negative integer");
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
if (expected.maxFiles !== undefined) {
|
|
626
|
+
normalized.maxFiles = Number(expected.maxFiles);
|
|
627
|
+
if (!Number.isInteger(normalized.maxFiles) || normalized.maxFiles < 0) {
|
|
628
|
+
throw new Error("expected-artifacts-json.maxFiles must be a non-negative integer");
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
if (expected.minTotalBytes !== undefined) {
|
|
632
|
+
normalized.minTotalBytes = Number(expected.minTotalBytes);
|
|
633
|
+
if (!Number.isInteger(normalized.minTotalBytes) || normalized.minTotalBytes < 0) {
|
|
634
|
+
throw new Error("expected-artifacts-json.minTotalBytes must be a non-negative integer");
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (expected.requiredPaths !== undefined) {
|
|
638
|
+
if (!Array.isArray(expected.requiredPaths)) {
|
|
639
|
+
throw new Error("expected-artifacts-json.requiredPaths must be an array");
|
|
640
|
+
}
|
|
641
|
+
normalized.requiredPaths = expected.requiredPaths.map((entry, index) => {
|
|
642
|
+
const pathValue = String(entry || "").replace(/\\/g, "/").trim();
|
|
643
|
+
if (!pathValue) {
|
|
644
|
+
throw new Error(`expected-artifacts-json.requiredPaths[${index}] must be non-empty`);
|
|
645
|
+
}
|
|
646
|
+
return pathValue;
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
return normalized;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
export function createArtifactSummary({ artifactName, platform, files }) {
|
|
653
|
+
const totalBytes = files.reduce((sum, file) => sum + Number(file.size || 0), 0);
|
|
654
|
+
const digest = crypto.createHash("sha256");
|
|
655
|
+
for (const file of files) {
|
|
656
|
+
digest.update(`${file.path}\0${file.size}\0${file.sha256}\n`);
|
|
657
|
+
}
|
|
658
|
+
return {
|
|
659
|
+
contract: "kungfu-buildchain-artifact-summary",
|
|
660
|
+
artifactName,
|
|
661
|
+
platform,
|
|
662
|
+
fileCount: files.length,
|
|
663
|
+
totalBytes,
|
|
664
|
+
digest: digest.digest("hex"),
|
|
665
|
+
};
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
export function validateExpectedArtifacts({ expected, files, summary }) {
|
|
669
|
+
if (!expected) {
|
|
670
|
+
return { ok: true, source: "none", checks: [] };
|
|
671
|
+
}
|
|
672
|
+
const checks = [];
|
|
673
|
+
const paths = new Set(files.map((file) => file.path));
|
|
674
|
+
function addCheck(name, ok, detail) {
|
|
675
|
+
checks.push({ name, ok, detail });
|
|
676
|
+
if (!ok) {
|
|
677
|
+
throw new Error(`expected artifact check failed: ${name}: ${detail}`);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
if (expected.minFiles !== undefined) {
|
|
682
|
+
addCheck(
|
|
683
|
+
"minFiles",
|
|
684
|
+
summary.fileCount >= expected.minFiles,
|
|
685
|
+
`${summary.fileCount} >= ${expected.minFiles}`,
|
|
686
|
+
);
|
|
687
|
+
}
|
|
688
|
+
if (expected.maxFiles !== undefined) {
|
|
689
|
+
addCheck(
|
|
690
|
+
"maxFiles",
|
|
691
|
+
summary.fileCount <= expected.maxFiles,
|
|
692
|
+
`${summary.fileCount} <= ${expected.maxFiles}`,
|
|
693
|
+
);
|
|
694
|
+
}
|
|
695
|
+
if (expected.minTotalBytes !== undefined) {
|
|
696
|
+
addCheck(
|
|
697
|
+
"minTotalBytes",
|
|
698
|
+
summary.totalBytes >= expected.minTotalBytes,
|
|
699
|
+
`${summary.totalBytes} >= ${expected.minTotalBytes}`,
|
|
700
|
+
);
|
|
701
|
+
}
|
|
702
|
+
for (const requiredPath of expected.requiredPaths || []) {
|
|
703
|
+
addCheck("requiredPath", paths.has(requiredPath), requiredPath);
|
|
704
|
+
}
|
|
705
|
+
return { ok: true, source: "expected-artifacts-json", checks };
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export function writeGitHubOutputs(outputs) {
|
|
709
|
+
const outputPath = process.env.GITHUB_OUTPUT;
|
|
710
|
+
if (!outputPath) {
|
|
711
|
+
for (const [key, value] of Object.entries(outputs)) {
|
|
712
|
+
console.log(`${key}=${value}`);
|
|
713
|
+
}
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const lines = Object.entries(outputs).map(([key, value]) => `${key}=${value}`);
|
|
717
|
+
fs.appendFileSync(outputPath, `${lines.join("\n")}\n`);
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export function findJsonFiles(root) {
|
|
721
|
+
if (!fs.existsSync(root)) {
|
|
722
|
+
return [];
|
|
723
|
+
}
|
|
724
|
+
const stat = fs.statSync(root);
|
|
725
|
+
if (stat.isFile()) {
|
|
726
|
+
return root.endsWith(".json") ? [root] : [];
|
|
727
|
+
}
|
|
728
|
+
return fs
|
|
729
|
+
.readdirSync(root, { withFileTypes: true })
|
|
730
|
+
.flatMap((entry) => findJsonFiles(path.join(root, entry.name)));
|
|
731
|
+
}
|