@kungfu-tech/buildchain 2.11.14-alpha.0 → 2.11.14-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/dist/site/buildchain-contract.json +25 -17
- package/dist/site/buildchain-site.json +26 -21
- package/dist/site/kfd-claims.json +5 -4
- package/dist/site/kfd-upstream-aggregate.json +1 -1
- package/dist/site/manual-registry.json +5 -5
- package/dist/site/node-api-registry.json +3 -3
- package/dist/site/page-registry.json +17 -12
- package/dist/site/public-surface-audit.json +4 -3
- package/dist/site/publication-registry.json +4 -4
- package/dist/site/site-manifest.json +9 -9
- package/dist/site/workflow-registry.json +2 -1
- package/docs/lifecycle-protocol.md +4 -1
- package/docs/publication-artifacts.md +9 -0
- package/docs/release-flow.md +10 -1
- package/docs/release-governance.md +51 -3
- package/docs/reusable-build-surface.md +6 -2
- package/docs/web-surface-deployments.md +15 -1
- package/package.json +1 -1
- package/packages/core/buildchain-contract.js +9 -1
- package/packages/core/publication-artifact.js +56 -8
- package/packages/core/publication-package.js +5 -0
- package/packages/core/stable-release-gate.js +284 -0
- package/scripts/check-inventory.mjs +2 -0
- package/scripts/locked-source-checkout.mjs +43 -9
- package/scripts/publication-artifact.mjs +20 -2
- package/scripts/publication-registry-hydrate.mjs +216 -0
- package/scripts/run-lifecycle-core.mjs +7 -2
- package/scripts/stable-release-gate.mjs +295 -0
- package/scripts/web-surface-core.mjs +28 -0
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
export const STABLE_RELEASE_POLICY_CONTRACT = "kungfu-buildchain-stable-release-policy";
|
|
5
|
+
export const STABLE_RELEASE_GATE_CONTRACT = "kungfu-buildchain-stable-release-gate";
|
|
6
|
+
|
|
7
|
+
function string(value = "") {
|
|
8
|
+
return String(value ?? "").trim();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function positiveInteger(value, label) {
|
|
12
|
+
const number = Number(value);
|
|
13
|
+
if (!Number.isInteger(number) || number < 0) {
|
|
14
|
+
throw new Error(`${label} must be a non-negative integer`);
|
|
15
|
+
}
|
|
16
|
+
return number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function timestamp(value, label) {
|
|
20
|
+
const normalized = string(value);
|
|
21
|
+
const milliseconds = Date.parse(normalized);
|
|
22
|
+
if (!normalized || !Number.isFinite(milliseconds)) {
|
|
23
|
+
throw new Error(`${label} must be an ISO-8601 timestamp`);
|
|
24
|
+
}
|
|
25
|
+
return { iso: new Date(milliseconds).toISOString(), milliseconds };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function loadJsonInput({ cwd = process.cwd(), input = "", label }) {
|
|
29
|
+
const normalized = string(input);
|
|
30
|
+
if (!normalized) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
if (normalized.startsWith("{")) {
|
|
34
|
+
return JSON.parse(normalized);
|
|
35
|
+
}
|
|
36
|
+
const resolved = path.isAbsolute(normalized) ? normalized : path.resolve(cwd, normalized);
|
|
37
|
+
if (!fs.existsSync(resolved)) {
|
|
38
|
+
throw new Error(`${label} path does not exist: ${normalized}`);
|
|
39
|
+
}
|
|
40
|
+
return JSON.parse(fs.readFileSync(resolved, "utf8"));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function loadStableReleasePolicy({ cwd = process.cwd(), input = "" } = {}) {
|
|
44
|
+
const policy = loadJsonInput({ cwd, input, label: "stable release policy" });
|
|
45
|
+
if (!policy) {
|
|
46
|
+
return undefined;
|
|
47
|
+
}
|
|
48
|
+
if (policy.contract !== STABLE_RELEASE_POLICY_CONTRACT) {
|
|
49
|
+
throw new Error(`stable release policy contract must be ${STABLE_RELEASE_POLICY_CONTRACT}`);
|
|
50
|
+
}
|
|
51
|
+
if (Number(policy.schemaVersion) !== 1) {
|
|
52
|
+
throw new Error("stable release policy schemaVersion must be 1");
|
|
53
|
+
}
|
|
54
|
+
const minimumStableIntervalSeconds = positiveInteger(
|
|
55
|
+
policy.minimumStableIntervalSeconds,
|
|
56
|
+
"minimumStableIntervalSeconds",
|
|
57
|
+
);
|
|
58
|
+
const minimumCanarySoakSeconds = positiveInteger(
|
|
59
|
+
policy.minimumCanarySoakSeconds,
|
|
60
|
+
"minimumCanarySoakSeconds",
|
|
61
|
+
);
|
|
62
|
+
const productPathPrefixes = [...new Set(
|
|
63
|
+
(Array.isArray(policy.productPathPrefixes) ? policy.productPathPrefixes : [])
|
|
64
|
+
.map(string)
|
|
65
|
+
.filter(Boolean),
|
|
66
|
+
)];
|
|
67
|
+
if (productPathPrefixes.length === 0) {
|
|
68
|
+
throw new Error("stable release policy requires productPathPrefixes[]");
|
|
69
|
+
}
|
|
70
|
+
const requiredCanaries = (Array.isArray(policy.requiredCanaries) ? policy.requiredCanaries : [])
|
|
71
|
+
.map((canary, index) => {
|
|
72
|
+
const id = string(canary?.id);
|
|
73
|
+
const source = string(canary?.source);
|
|
74
|
+
if (!id) {
|
|
75
|
+
throw new Error(`requiredCanaries[${index}].id is required`);
|
|
76
|
+
}
|
|
77
|
+
if (!new Set(["release-candidate", "commit-status"]).has(source)) {
|
|
78
|
+
throw new Error(`requiredCanaries[${index}].source must be release-candidate or commit-status`);
|
|
79
|
+
}
|
|
80
|
+
if (source === "commit-status" && !string(canary.context)) {
|
|
81
|
+
throw new Error(`requiredCanaries[${index}].context is required for commit-status canaries`);
|
|
82
|
+
}
|
|
83
|
+
return {
|
|
84
|
+
id,
|
|
85
|
+
source,
|
|
86
|
+
repository: string(canary.repository),
|
|
87
|
+
workflow: string(canary.workflow),
|
|
88
|
+
context: string(canary.context),
|
|
89
|
+
allowedAttestors: [...new Set(
|
|
90
|
+
(Array.isArray(canary.allowedAttestors) ? canary.allowedAttestors : [])
|
|
91
|
+
.map(string)
|
|
92
|
+
.filter(Boolean),
|
|
93
|
+
)],
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
if (requiredCanaries.length === 0) {
|
|
97
|
+
throw new Error("stable release policy requires requiredCanaries[]");
|
|
98
|
+
}
|
|
99
|
+
if (new Set(requiredCanaries.map((canary) => canary.id)).size !== requiredCanaries.length) {
|
|
100
|
+
throw new Error("stable release policy canary ids must be unique");
|
|
101
|
+
}
|
|
102
|
+
return {
|
|
103
|
+
schemaVersion: 1,
|
|
104
|
+
contract: STABLE_RELEASE_POLICY_CONTRACT,
|
|
105
|
+
enabled: policy.enabled !== false,
|
|
106
|
+
minimumStableIntervalSeconds,
|
|
107
|
+
minimumCanarySoakSeconds,
|
|
108
|
+
productPathPrefixes,
|
|
109
|
+
requiredCanaries,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function check(ok, id, message, details = {}) {
|
|
114
|
+
return { id, status: ok ? "pass" : "fail", message, details };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function matchesProductPath(file, prefixes) {
|
|
118
|
+
return prefixes.some((prefix) => file === prefix || file.startsWith(prefix));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
export function evaluateStableReleaseGate({
|
|
122
|
+
policy,
|
|
123
|
+
channel = "",
|
|
124
|
+
candidate = {},
|
|
125
|
+
previousStable = undefined,
|
|
126
|
+
changedPaths = [],
|
|
127
|
+
impact = {},
|
|
128
|
+
canaries = [],
|
|
129
|
+
now = new Date().toISOString(),
|
|
130
|
+
} = {}) {
|
|
131
|
+
const normalizedChannel = string(channel);
|
|
132
|
+
if (!policy || policy.enabled === false || normalizedChannel !== "release") {
|
|
133
|
+
return {
|
|
134
|
+
schemaVersion: 1,
|
|
135
|
+
contract: STABLE_RELEASE_GATE_CONTRACT,
|
|
136
|
+
applies: false,
|
|
137
|
+
ok: true,
|
|
138
|
+
channel: normalizedChannel,
|
|
139
|
+
checks: [],
|
|
140
|
+
summary: { reason: !policy ? "policy-not-configured" : policy.enabled === false ? "policy-disabled" : "non-stable-channel" },
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const nowTime = timestamp(now, "now");
|
|
145
|
+
const candidatePublished = timestamp(candidate.publishedAt, "candidate.publishedAt");
|
|
146
|
+
const candidateSha = string(candidate.sha);
|
|
147
|
+
const candidateTag = string(candidate.tag);
|
|
148
|
+
const checks = [];
|
|
149
|
+
checks.push(check(/^[0-9a-f]{40}$/i.test(candidateSha), "candidate.sha", "candidate alpha SHA is exact", { sha: candidateSha }));
|
|
150
|
+
checks.push(check(/-alpha\.\d+$/.test(candidateTag), "candidate.tag", "candidate is an exact alpha tag", { tag: candidateTag }));
|
|
151
|
+
|
|
152
|
+
if (previousStable) {
|
|
153
|
+
const previousPublished = timestamp(previousStable.publishedAt, "previousStable.publishedAt");
|
|
154
|
+
const elapsedSeconds = Math.floor((nowTime.milliseconds - previousPublished.milliseconds) / 1000);
|
|
155
|
+
checks.push(check(
|
|
156
|
+
elapsedSeconds >= policy.minimumStableIntervalSeconds,
|
|
157
|
+
"stable.minimum_interval",
|
|
158
|
+
"minimum interval since the previous stable release is satisfied",
|
|
159
|
+
{
|
|
160
|
+
previousTag: string(previousStable.tag),
|
|
161
|
+
previousPublishedAt: previousPublished.iso,
|
|
162
|
+
elapsedSeconds,
|
|
163
|
+
requiredSeconds: policy.minimumStableIntervalSeconds,
|
|
164
|
+
},
|
|
165
|
+
));
|
|
166
|
+
} else {
|
|
167
|
+
checks.push(check(true, "stable.minimum_interval", "no previous stable release exists", { firstStable: true }));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const normalizedChangedPaths = [...new Set(changedPaths.map(string).filter(Boolean))].sort();
|
|
171
|
+
const productChangedPaths = normalizedChangedPaths.filter((file) =>
|
|
172
|
+
matchesProductPath(file, policy.productPathPrefixes));
|
|
173
|
+
checks.push(check(
|
|
174
|
+
productChangedPaths.length > 0,
|
|
175
|
+
"stable.product_diff",
|
|
176
|
+
"candidate contains a product or contract difference from the current stable release",
|
|
177
|
+
{ changedPaths: productChangedPaths, comparedPathCount: normalizedChangedPaths.length },
|
|
178
|
+
));
|
|
179
|
+
|
|
180
|
+
const surfaceImpacts = Array.isArray(impact?.surfaceImpacts) ? impact.surfaceImpacts : [];
|
|
181
|
+
checks.push(check(
|
|
182
|
+
surfaceImpacts.length > 0 && string(impact?.summary) !== "",
|
|
183
|
+
"stable.impact",
|
|
184
|
+
"version-bound impact declares a summary and at least one surface",
|
|
185
|
+
{ summary: string(impact?.summary), surfaceIds: surfaceImpacts.map((entry) => string(entry?.id)).filter(Boolean) },
|
|
186
|
+
));
|
|
187
|
+
|
|
188
|
+
const canaryById = new Map(canaries.map((canary) => [string(canary?.id), canary]));
|
|
189
|
+
const canaryCompletionTimes = [];
|
|
190
|
+
for (const required of policy.requiredCanaries) {
|
|
191
|
+
const evidence = canaryById.get(required.id);
|
|
192
|
+
const completedAt = evidence?.completedAt
|
|
193
|
+
? timestamp(evidence.completedAt, `canary ${required.id} completedAt`)
|
|
194
|
+
: undefined;
|
|
195
|
+
const attestorAllowed = required.allowedAttestors.length === 0 ||
|
|
196
|
+
required.allowedAttestors.includes(string(evidence?.attestor));
|
|
197
|
+
const valid = Boolean(
|
|
198
|
+
evidence &&
|
|
199
|
+
string(evidence.status) === "success" &&
|
|
200
|
+
string(evidence.candidateSha) === candidateSha &&
|
|
201
|
+
completedAt &&
|
|
202
|
+
completedAt.milliseconds >= candidatePublished.milliseconds &&
|
|
203
|
+
attestorAllowed,
|
|
204
|
+
);
|
|
205
|
+
if (completedAt) {
|
|
206
|
+
canaryCompletionTimes.push(completedAt.milliseconds);
|
|
207
|
+
}
|
|
208
|
+
checks.push(check(
|
|
209
|
+
valid,
|
|
210
|
+
`stable.canary.${required.id}`,
|
|
211
|
+
`required canary ${required.id} passed for the exact alpha candidate`,
|
|
212
|
+
{
|
|
213
|
+
source: required.source,
|
|
214
|
+
context: required.context,
|
|
215
|
+
repository: string(evidence?.repository || required.repository),
|
|
216
|
+
workflow: string(evidence?.workflow || required.workflow),
|
|
217
|
+
runtimeRef: string(evidence?.runtimeRef),
|
|
218
|
+
candidateSha: string(evidence?.candidateSha),
|
|
219
|
+
completedAt: completedAt?.iso || "",
|
|
220
|
+
evidenceUrl: string(evidence?.evidenceUrl),
|
|
221
|
+
attestor: string(evidence?.attestor),
|
|
222
|
+
attestorAllowed,
|
|
223
|
+
},
|
|
224
|
+
));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const soakStartMilliseconds = Math.max(candidatePublished.milliseconds, ...canaryCompletionTimes);
|
|
228
|
+
const soakElapsedSeconds = Math.floor((nowTime.milliseconds - soakStartMilliseconds) / 1000);
|
|
229
|
+
checks.push(check(
|
|
230
|
+
canaryCompletionTimes.length === policy.requiredCanaries.length &&
|
|
231
|
+
soakElapsedSeconds >= policy.minimumCanarySoakSeconds,
|
|
232
|
+
"stable.canary_soak",
|
|
233
|
+
"minimum soak interval after the final required canary is satisfied",
|
|
234
|
+
{
|
|
235
|
+
soakStartedAt: new Date(soakStartMilliseconds).toISOString(),
|
|
236
|
+
elapsedSeconds: soakElapsedSeconds,
|
|
237
|
+
requiredSeconds: policy.minimumCanarySoakSeconds,
|
|
238
|
+
},
|
|
239
|
+
));
|
|
240
|
+
|
|
241
|
+
const ok = checks.every((entry) => entry.status === "pass");
|
|
242
|
+
return {
|
|
243
|
+
schemaVersion: 1,
|
|
244
|
+
contract: STABLE_RELEASE_GATE_CONTRACT,
|
|
245
|
+
applies: true,
|
|
246
|
+
ok,
|
|
247
|
+
channel: normalizedChannel,
|
|
248
|
+
evaluatedAt: nowTime.iso,
|
|
249
|
+
candidate: {
|
|
250
|
+
tag: candidateTag,
|
|
251
|
+
sha: candidateSha,
|
|
252
|
+
publishedAt: candidatePublished.iso,
|
|
253
|
+
},
|
|
254
|
+
previousStable: previousStable
|
|
255
|
+
? {
|
|
256
|
+
tag: string(previousStable.tag),
|
|
257
|
+
sha: string(previousStable.sha),
|
|
258
|
+
publishedAt: timestamp(previousStable.publishedAt, "previousStable.publishedAt").iso,
|
|
259
|
+
}
|
|
260
|
+
: undefined,
|
|
261
|
+
policy: {
|
|
262
|
+
minimumStableIntervalSeconds: policy.minimumStableIntervalSeconds,
|
|
263
|
+
minimumCanarySoakSeconds: policy.minimumCanarySoakSeconds,
|
|
264
|
+
requiredCanaries: policy.requiredCanaries.map((canary) => canary.id),
|
|
265
|
+
},
|
|
266
|
+
checks,
|
|
267
|
+
summary: {
|
|
268
|
+
decision: ok ? "allow" : "block",
|
|
269
|
+
failedChecks: checks.filter((entry) => entry.status === "fail").map((entry) => entry.id),
|
|
270
|
+
productChangedPaths,
|
|
271
|
+
},
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export function assertStableReleaseGate(input = {}) {
|
|
276
|
+
const report = evaluateStableReleaseGate(input);
|
|
277
|
+
if (!report.ok) {
|
|
278
|
+
throw Object.assign(
|
|
279
|
+
new Error(`stable release gate blocked promotion: ${report.summary.failedChecks.join(", ")}`),
|
|
280
|
+
{ report },
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
return report;
|
|
284
|
+
}
|
|
@@ -725,6 +725,8 @@ for (const requiredSnippet of [
|
|
|
725
725
|
"publish-source-ref: ${{ steps.publish-gate.outputs.ref }}",
|
|
726
726
|
"publish-source-sha: ${{ steps.publish-gate.outputs.sha }}",
|
|
727
727
|
"publish-source-locked: ${{ steps.publish-gate.outputs.locked }}",
|
|
728
|
+
"Enforce Buildchain stable release canary gate",
|
|
729
|
+
"BUILDCHAIN_STABLE_RELEASE_POLICY: .buildchain/stable-release-policy.json",
|
|
728
730
|
]) {
|
|
729
731
|
if (!releaseCandidatePromoteWorkflow.includes(requiredSnippet)) {
|
|
730
732
|
throw new Error(`release candidate promote workflow missing KFD gate pass-through: ${requiredSnippet}`);
|
|
@@ -213,6 +213,31 @@ function fetchSha({ targetPath, remoteName, remoteUrl, sha, fetchRef, timeoutMs,
|
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
215
|
|
|
216
|
+
export function runBoundedFetch({ attempts = 1, fetch, onAttempt = () => {}, onRetry = () => {}, shouldRetry = () => true }) {
|
|
217
|
+
const limit = Math.max(1, Math.floor(Number(attempts) || 1));
|
|
218
|
+
let lastError;
|
|
219
|
+
for (let attempt = 1; attempt <= limit; attempt += 1) {
|
|
220
|
+
onAttempt({ attempt, limit });
|
|
221
|
+
try {
|
|
222
|
+
return { value: fetch({ attempt, limit }), attempts: attempt };
|
|
223
|
+
} catch (error) {
|
|
224
|
+
lastError = error;
|
|
225
|
+
if (attempt >= limit || !shouldRetry(error)) {
|
|
226
|
+
error.fetchAttempts = attempt;
|
|
227
|
+
throw error;
|
|
228
|
+
}
|
|
229
|
+
onRetry({ attempt, limit, error });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
throw lastError;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function retryableGitFetchError(error) {
|
|
236
|
+
const code = String(error?.code || "").toUpperCase();
|
|
237
|
+
if (["ETIMEDOUT", "ECONNRESET", "ECONNREFUSED", "EAI_AGAIN", "ENETUNREACH", "EPIPE"].includes(code)) return true;
|
|
238
|
+
return /timed?\s*out|timeout|connection (?:reset|refused)|remote end hung up|early eof|rpc failed|http (?:429|5\d\d)|temporary failure|network is unreachable/i.test(String(error?.message || error || ""));
|
|
239
|
+
}
|
|
240
|
+
|
|
216
241
|
function githubRemoteUrl({ repository, serverUrl = "https://github.com" }) {
|
|
217
242
|
const base = String(serverUrl || "https://github.com").replace(/\/+$/, "");
|
|
218
243
|
return `${base}/${repository}.git`;
|
|
@@ -252,6 +277,7 @@ export function lockedSourceCheckout({
|
|
|
252
277
|
referenceRepositoryTemplate = readEnv("BUILDCHAIN_CHECKOUT_CACHE_REFERENCE_REPOSITORY_TEMPLATE"),
|
|
253
278
|
fallback = readEnv("BUILDCHAIN_CHECKOUT_CACHE_FALLBACK", "github"),
|
|
254
279
|
timeoutSeconds = Number(readEnv("BUILDCHAIN_CHECKOUT_CACHE_TIMEOUT_SECONDS", "60") || 60),
|
|
280
|
+
fetchAttempts = Number(readEnv("BUILDCHAIN_CHECKOUT_CACHE_FETCH_ATTEMPTS", "3") || 3),
|
|
255
281
|
diagnosticsPath = readEnv("BUILDCHAIN_SOURCE_CHECKOUT_DIAGNOSTICS_PATH", ".buildchain/diagnostics/source-checkout.json"),
|
|
256
282
|
githubToken = readEnv("GITHUB_TOKEN"),
|
|
257
283
|
githubServerUrl = readEnv("GITHUB_SERVER_URL", "https://github.com"),
|
|
@@ -265,6 +291,7 @@ export function lockedSourceCheckout({
|
|
|
265
291
|
const treeSha = sourceTreeSha ? assertSha(sourceTreeSha, "sourceTreeSha") : "";
|
|
266
292
|
const repoParts = splitRepository(repository);
|
|
267
293
|
const timeoutMs = Math.max(1, Number(timeoutSeconds || 60)) * 1000;
|
|
294
|
+
const normalizedFetchAttempts = Math.max(1, Math.floor(Number(fetchAttempts) || 3));
|
|
268
295
|
const targetPath = ensureCheckoutTarget(checkoutPath, workspace);
|
|
269
296
|
git(["init"], { cwd: targetPath, timeoutMs });
|
|
270
297
|
const renderedMirrorUrl = renderTemplate(mirrorUrlTemplate, { ...repoParts, sha });
|
|
@@ -285,6 +312,7 @@ export function lockedSourceCheckout({
|
|
|
285
312
|
mode: normalizedMode,
|
|
286
313
|
fallback: normalizedFallback,
|
|
287
314
|
timeoutSeconds: Math.max(1, Number(timeoutSeconds || 60)),
|
|
315
|
+
fetchAttempts: normalizedFetchAttempts,
|
|
288
316
|
mirror: sanitizeIdentity(renderedMirrorUrl),
|
|
289
317
|
referenceRepository: sanitizeIdentity(renderedReferenceRepository),
|
|
290
318
|
},
|
|
@@ -294,6 +322,7 @@ export function lockedSourceCheckout({
|
|
|
294
322
|
transport: "github",
|
|
295
323
|
fallbackUsed: normalizedMode === "off",
|
|
296
324
|
fallbackReason: normalizedMode === "off" ? "cache disabled" : "",
|
|
325
|
+
githubFetchAttempts: 0,
|
|
297
326
|
},
|
|
298
327
|
verification: {
|
|
299
328
|
head: "",
|
|
@@ -352,15 +381,20 @@ export function lockedSourceCheckout({
|
|
|
352
381
|
if (!evidence.cache.fallbackReason && checkoutError) {
|
|
353
382
|
evidence.cache.fallbackReason = checkoutError.message;
|
|
354
383
|
}
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
384
|
+
try {
|
|
385
|
+
const fetchResult = runBoundedFetch({
|
|
386
|
+
attempts: normalizedFetchAttempts,
|
|
387
|
+
fetch: () => fetchSha({ targetPath, remoteName: "origin", remoteUrl, sha, fetchRef, timeoutMs, env: githubAuthEnv(githubToken) }),
|
|
388
|
+
onAttempt: ({ attempt }) => { evidence.cache.githubFetchAttempts = attempt; },
|
|
389
|
+
onRetry: ({ attempt, limit, error }) => console.log(`buildchain: GitHub source fetch failed, retry ${attempt + 1}/${limit}: ${error.message}`),
|
|
390
|
+
shouldRetry: retryableGitFetchError,
|
|
391
|
+
});
|
|
392
|
+
evidence.cache.githubFetchAttempts = fetchResult.attempts;
|
|
393
|
+
} catch (error) {
|
|
394
|
+
evidence.durationMs = Date.now() - startedAt;
|
|
395
|
+
writeEvidence(path.resolve(workspace, diagnosticsPath), evidence);
|
|
396
|
+
throw error;
|
|
397
|
+
}
|
|
364
398
|
checkoutFetchedCommit(targetPath, sha, timeoutMs);
|
|
365
399
|
}
|
|
366
400
|
evidence.verification = verifyCheckout({ targetPath, sourceSha: sha, sourceTreeSha: treeSha });
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { pathToFileURL } from "node:url";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
3
5
|
import { writePublicationArtifact } from "../packages/core/publication-artifact.js";
|
|
4
6
|
|
|
5
7
|
function readFlag(args, name, fallback = "") {
|
|
@@ -11,16 +13,32 @@ function readBooleanFlag(args, name) {
|
|
|
11
13
|
return args.includes(`--${name}`);
|
|
12
14
|
}
|
|
13
15
|
|
|
16
|
+
function readRegistryInputs(args, cwd) {
|
|
17
|
+
const inputs = [];
|
|
18
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
19
|
+
if (args[index] === "--registry-input" && args[index + 1]) inputs.push(args[index + 1]);
|
|
20
|
+
}
|
|
21
|
+
const inputDir = readFlag(args, "registry-input-dir", "");
|
|
22
|
+
if (inputDir) {
|
|
23
|
+
const resolved = path.resolve(cwd, inputDir);
|
|
24
|
+
if (!fs.existsSync(resolved) || !fs.statSync(resolved).isDirectory()) throw new Error(`publication registry input directory is missing: ${inputDir}`);
|
|
25
|
+
inputs.push(...fs.readdirSync(resolved).filter((entry) => entry.endsWith(".json")).sort().map((entry) => path.join(resolved, entry)));
|
|
26
|
+
}
|
|
27
|
+
return inputs;
|
|
28
|
+
}
|
|
29
|
+
|
|
14
30
|
export function runPublicationArtifactCli(args = process.argv.slice(2)) {
|
|
15
31
|
const [mode = "manifest"] = args;
|
|
16
32
|
if (!["manifest", "collect"].includes(mode)) {
|
|
17
|
-
throw new Error("usage: buildchain publication-artifact manifest [--cwd <dir>] [--source-sha <sha>] [--output <file>] [--passport-output <file>] [--registry-output <file>] [--source-bundle <file>] [--no-source-bundle] [--json]");
|
|
33
|
+
throw new Error("usage: buildchain publication-artifact manifest [--cwd <dir>] [--source-sha <sha>] [--output <file>] [--passport-output <file>] [--registry-output <file>] [--registry-input <file>] [--registry-input-dir <dir>] [--source-bundle <file>] [--no-source-bundle] [--json]");
|
|
18
34
|
}
|
|
35
|
+
const cwd = readFlag(args, "cwd", process.cwd());
|
|
19
36
|
const result = writePublicationArtifact({
|
|
20
|
-
cwd
|
|
37
|
+
cwd,
|
|
21
38
|
output: readFlag(args, "output", ""),
|
|
22
39
|
passportOutput: readFlag(args, "passport-output", ""),
|
|
23
40
|
registryOutput: readFlag(args, "registry-output", ""),
|
|
41
|
+
registryInputs: readRegistryInputs(args, cwd),
|
|
24
42
|
sourceSha: readFlag(args, "source-sha", ""),
|
|
25
43
|
sourceBundlePath: readFlag(args, "source-bundle", ""),
|
|
26
44
|
sourceBundle: !readBooleanFlag(args, "no-source-bundle"),
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { execFileSync } from "node:child_process";
|
|
3
|
+
import fs from "node:fs";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
import { pathToFileURL } from "node:url";
|
|
6
|
+
import { loadBuildchainConfig } from "../packages/core/buildchain-config.js";
|
|
7
|
+
|
|
8
|
+
const DEFAULT_REGISTRY = "https://registry.npmjs.org/";
|
|
9
|
+
|
|
10
|
+
function semverParts(value) {
|
|
11
|
+
const match = String(value || "")
|
|
12
|
+
.trim()
|
|
13
|
+
.match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?$/);
|
|
14
|
+
if (!match) return undefined;
|
|
15
|
+
return {
|
|
16
|
+
core: match.slice(1, 4).map(Number),
|
|
17
|
+
prerelease: match[4] ? match[4].split(".") : [],
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function compareSemver(left, right) {
|
|
22
|
+
const a = semverParts(left);
|
|
23
|
+
const b = semverParts(right);
|
|
24
|
+
if (!a || !b) throw new Error(`invalid semver comparison: ${left}, ${right}`);
|
|
25
|
+
for (let index = 0; index < 3; index += 1) {
|
|
26
|
+
if (a.core[index] !== b.core[index]) return a.core[index] - b.core[index];
|
|
27
|
+
}
|
|
28
|
+
if (a.prerelease.length === 0 || b.prerelease.length === 0) {
|
|
29
|
+
return a.prerelease.length === b.prerelease.length
|
|
30
|
+
? 0
|
|
31
|
+
: a.prerelease.length === 0
|
|
32
|
+
? 1
|
|
33
|
+
: -1;
|
|
34
|
+
}
|
|
35
|
+
const length = Math.max(a.prerelease.length, b.prerelease.length);
|
|
36
|
+
for (let index = 0; index < length; index += 1) {
|
|
37
|
+
const av = a.prerelease[index];
|
|
38
|
+
const bv = b.prerelease[index];
|
|
39
|
+
if (av === undefined || bv === undefined) return av === undefined ? -1 : 1;
|
|
40
|
+
if (av === bv) continue;
|
|
41
|
+
const an = /^\d+$/.test(av);
|
|
42
|
+
const bn = /^\d+$/.test(bv);
|
|
43
|
+
if (an && bn) return Number(av) - Number(bv);
|
|
44
|
+
if (an !== bn) return an ? -1 : 1;
|
|
45
|
+
return av.localeCompare(bv);
|
|
46
|
+
}
|
|
47
|
+
return 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function run(command, args, options = {}) {
|
|
51
|
+
return execFileSync(command, args, {
|
|
52
|
+
encoding: "utf8",
|
|
53
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
54
|
+
...options,
|
|
55
|
+
}).trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function packageVersions(packageName, registry, commandRunner) {
|
|
59
|
+
try {
|
|
60
|
+
const output = commandRunner("npm", [
|
|
61
|
+
"view",
|
|
62
|
+
packageName,
|
|
63
|
+
"versions",
|
|
64
|
+
"--json",
|
|
65
|
+
`--registry=${registry}`,
|
|
66
|
+
]);
|
|
67
|
+
const parsed = JSON.parse(output || "[]");
|
|
68
|
+
return Array.isArray(parsed) ? parsed : parsed ? [parsed] : [];
|
|
69
|
+
} catch (error) {
|
|
70
|
+
const detail = [error?.message, error?.stderr, error?.stdout]
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.join("\n");
|
|
73
|
+
if (/E404|404 Not Found|is not in this registry/i.test(detail)) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function hydratePublishedPublicationRegistry({
|
|
81
|
+
cwd = process.cwd(),
|
|
82
|
+
outputDir = ".buildchain/publication/registry-inputs",
|
|
83
|
+
evidencePath = ".buildchain/publication/registry-hydration.json",
|
|
84
|
+
registry = DEFAULT_REGISTRY,
|
|
85
|
+
commandRunner = run,
|
|
86
|
+
} = {}) {
|
|
87
|
+
const resolvedCwd = path.resolve(cwd);
|
|
88
|
+
const config = loadBuildchainConfig(resolvedCwd).config;
|
|
89
|
+
const packageName =
|
|
90
|
+
config.publish?.package || config.publish?.mainPackage || "";
|
|
91
|
+
const currentVersion = config.publication?.version || "";
|
|
92
|
+
const registryPath = config.publication?.archive?.registryPath || "";
|
|
93
|
+
const resolvedOutputDir = path.resolve(resolvedCwd, outputDir);
|
|
94
|
+
fs.rmSync(resolvedOutputDir, { recursive: true, force: true });
|
|
95
|
+
fs.mkdirSync(resolvedOutputDir, { recursive: true });
|
|
96
|
+
if (!packageName || !currentVersion || !registryPath) {
|
|
97
|
+
return {
|
|
98
|
+
status: "not-configured",
|
|
99
|
+
packageName,
|
|
100
|
+
currentVersion,
|
|
101
|
+
inputDir: path
|
|
102
|
+
.relative(resolvedCwd, resolvedOutputDir)
|
|
103
|
+
.split(path.sep)
|
|
104
|
+
.join("/"),
|
|
105
|
+
sources: [],
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const publishedVersions = packageVersions(
|
|
109
|
+
packageName,
|
|
110
|
+
registry,
|
|
111
|
+
commandRunner,
|
|
112
|
+
)
|
|
113
|
+
.filter(
|
|
114
|
+
(version) =>
|
|
115
|
+
semverParts(version) && compareSemver(version, currentVersion) < 0,
|
|
116
|
+
)
|
|
117
|
+
.sort(compareSemver);
|
|
118
|
+
const sources = [];
|
|
119
|
+
for (const version of publishedVersions) {
|
|
120
|
+
const packageDir = path.join(resolvedOutputDir, `.package-${version}`);
|
|
121
|
+
fs.mkdirSync(packageDir, { recursive: true });
|
|
122
|
+
const packed = JSON.parse(
|
|
123
|
+
commandRunner(
|
|
124
|
+
"npm",
|
|
125
|
+
[
|
|
126
|
+
"pack",
|
|
127
|
+
`${packageName}@${version}`,
|
|
128
|
+
"--json",
|
|
129
|
+
"--ignore-scripts",
|
|
130
|
+
"--pack-destination",
|
|
131
|
+
packageDir,
|
|
132
|
+
`--registry=${registry}`,
|
|
133
|
+
],
|
|
134
|
+
{ cwd: resolvedCwd },
|
|
135
|
+
),
|
|
136
|
+
);
|
|
137
|
+
const pack = Array.isArray(packed) ? packed[0] : packed;
|
|
138
|
+
if (!pack?.filename || !pack?.integrity) {
|
|
139
|
+
throw new Error(
|
|
140
|
+
`npm pack did not report filename and integrity for ${packageName}@${version}`,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
const tarball = path.resolve(packageDir, pack.filename);
|
|
144
|
+
const archiveRegistryPath = `package/${registryPath}`;
|
|
145
|
+
const archiveEntries = commandRunner("tar", ["-tzf", tarball]).split(/\r?\n/);
|
|
146
|
+
if (!archiveEntries.includes(archiveRegistryPath)) {
|
|
147
|
+
sources.push({
|
|
148
|
+
package: packageName,
|
|
149
|
+
version,
|
|
150
|
+
integrity: pack.integrity,
|
|
151
|
+
status: "registry-not-present",
|
|
152
|
+
});
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const target = path.join(resolvedOutputDir, `${version}.json`);
|
|
156
|
+
const registryJson = commandRunner("tar", ["-xOzf", tarball, archiveRegistryPath]);
|
|
157
|
+
fs.writeFileSync(target, `${registryJson}\n`);
|
|
158
|
+
sources.push({
|
|
159
|
+
package: packageName,
|
|
160
|
+
version,
|
|
161
|
+
integrity: pack.integrity,
|
|
162
|
+
status: "accepted-input",
|
|
163
|
+
registryPath: path
|
|
164
|
+
.relative(resolvedCwd, target)
|
|
165
|
+
.split(path.sep)
|
|
166
|
+
.join("/"),
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
for (const entry of fs.readdirSync(resolvedOutputDir)) {
|
|
170
|
+
if (entry.startsWith(".package-")) {
|
|
171
|
+
fs.rmSync(path.join(resolvedOutputDir, entry), {
|
|
172
|
+
recursive: true,
|
|
173
|
+
force: true,
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const evidence = {
|
|
178
|
+
schemaVersion: 1,
|
|
179
|
+
contract: "kungfu-buildchain-publication-registry-hydration",
|
|
180
|
+
status: sources.some((source) => source.status === "accepted-input")
|
|
181
|
+
? "hydrated"
|
|
182
|
+
: "bootstrap",
|
|
183
|
+
authentication: "npm-registry-https-plus-package-integrity",
|
|
184
|
+
registry,
|
|
185
|
+
packageName,
|
|
186
|
+
currentVersion,
|
|
187
|
+
inputDir: path
|
|
188
|
+
.relative(resolvedCwd, resolvedOutputDir)
|
|
189
|
+
.split(path.sep)
|
|
190
|
+
.join("/"),
|
|
191
|
+
sources,
|
|
192
|
+
};
|
|
193
|
+
const resolvedEvidencePath = path.resolve(resolvedCwd, evidencePath);
|
|
194
|
+
fs.mkdirSync(path.dirname(resolvedEvidencePath), { recursive: true });
|
|
195
|
+
fs.writeFileSync(
|
|
196
|
+
resolvedEvidencePath,
|
|
197
|
+
`${JSON.stringify(evidence, null, 2)}\n`,
|
|
198
|
+
);
|
|
199
|
+
return evidence;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (
|
|
203
|
+
process.argv[1] &&
|
|
204
|
+
import.meta.url === pathToFileURL(process.argv[1]).href
|
|
205
|
+
) {
|
|
206
|
+
try {
|
|
207
|
+
const cwdIndex = process.argv.indexOf("--cwd");
|
|
208
|
+
const result = hydratePublishedPublicationRegistry({
|
|
209
|
+
cwd: cwdIndex === -1 ? process.cwd() : process.argv[cwdIndex + 1],
|
|
210
|
+
});
|
|
211
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
212
|
+
} catch (error) {
|
|
213
|
+
console.error(`publication-registry-hydrate: ${error.message}`);
|
|
214
|
+
process.exitCode = 1;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
@@ -446,6 +446,9 @@ export function runLifecycle({
|
|
|
446
446
|
|
|
447
447
|
if (command.trim()) {
|
|
448
448
|
commandSource = "workflow-input";
|
|
449
|
+
const lifecycle = loadedConfig?.config?.lifecycle || {};
|
|
450
|
+
const configuredStage = stageName ? lifecycle[stageName] : undefined;
|
|
451
|
+
const commandShell = configuredStage?.shell || true;
|
|
449
452
|
const startedAt = Date.now();
|
|
450
453
|
userLog.info("lifecycle.command.start", {
|
|
451
454
|
attributes: {
|
|
@@ -457,6 +460,8 @@ export function runLifecycle({
|
|
|
457
460
|
try {
|
|
458
461
|
const commandEnv = {
|
|
459
462
|
...process.env,
|
|
463
|
+
...(lifecycle.env || {}),
|
|
464
|
+
...(configuredStage?.env || {}),
|
|
460
465
|
...(resolvedLogPath
|
|
461
466
|
? {
|
|
462
467
|
BUILDCHAIN_LOG_PATH: resolvedLogPath,
|
|
@@ -469,7 +474,7 @@ export function runLifecycle({
|
|
|
469
474
|
command,
|
|
470
475
|
cwd: resolvedCwd,
|
|
471
476
|
env: commandEnv,
|
|
472
|
-
shell:
|
|
477
|
+
shell: commandShell,
|
|
473
478
|
label: `lifecycle-${stageName || "command"}`,
|
|
474
479
|
processSummaryPath: resolvedProcessSummaryPath,
|
|
475
480
|
processSamplesPath: resolvedProcessSamplesPath,
|
|
@@ -480,7 +485,7 @@ export function runLifecycle({
|
|
|
480
485
|
execSync(command, {
|
|
481
486
|
cwd: resolvedCwd,
|
|
482
487
|
env: commandEnv,
|
|
483
|
-
shell:
|
|
488
|
+
shell: commandShell,
|
|
484
489
|
stdio: "inherit",
|
|
485
490
|
});
|
|
486
491
|
}
|