@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.
@@ -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
- fetchSha({
356
- targetPath,
357
- remoteName: "origin",
358
- remoteUrl,
359
- sha,
360
- fetchRef,
361
- timeoutMs,
362
- env: githubAuthEnv(githubToken),
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: readFlag(args, "cwd", process.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: true,
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: true,
488
+ shell: commandShell,
484
489
  stdio: "inherit",
485
490
  });
486
491
  }