@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.
Files changed (35) hide show
  1. package/README.md +262 -0
  2. package/bin/buildchain.mjs +222 -0
  3. package/docs/cli.md +124 -0
  4. package/docs/lifecycle-protocol.md +422 -0
  5. package/docs/publish-transaction.md +285 -0
  6. package/docs/reusable-build-surface.md +350 -0
  7. package/docs/web-surface-deployments.md +211 -0
  8. package/package.json +52 -1
  9. package/packages/core/README.md +15 -0
  10. package/packages/core/buildchain-config.js +721 -0
  11. package/packages/core/index.js +40 -0
  12. package/packages/core/package-manager.js +291 -0
  13. package/packages/core/publish-transaction.js +418 -0
  14. package/packages/core/release-line-dry-run.js +296 -0
  15. package/scripts/aggregate-build-summary.mjs +88 -0
  16. package/scripts/build-contract-core.mjs +731 -0
  17. package/scripts/check-inventory.mjs +325 -0
  18. package/scripts/init-repo.mjs +316 -0
  19. package/scripts/npm-publish-dry-run.mjs +176 -0
  20. package/scripts/npm-publish-transaction.mjs +268 -0
  21. package/scripts/publish-source-ref-resolver.mjs +113 -0
  22. package/scripts/release-line-dry-run.mjs +53 -0
  23. package/scripts/release-line-policy.mjs +141 -0
  24. package/scripts/release-transaction.mjs +212 -0
  25. package/scripts/resolve-build-contract.mjs +63 -0
  26. package/scripts/resolve-publish-gate.mjs +33 -0
  27. package/scripts/resolve-publish-source.mjs +99 -0
  28. package/scripts/run-lifecycle-core.mjs +162 -0
  29. package/scripts/run-lifecycle.mjs +40 -0
  30. package/scripts/strip-trailing-whitespace.mjs +14 -0
  31. package/scripts/tsup-action.config.mjs +19 -0
  32. package/scripts/verify-publish-source-lock.mjs +37 -0
  33. package/scripts/verify-release-pr.mjs +34 -0
  34. package/scripts/web-surface-core.mjs +382 -0
  35. package/scripts/web-surface.mjs +112 -0
@@ -0,0 +1,721 @@
1
+ import { execSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { parse, stringify } from "smol-toml";
6
+
7
+ const CONFIG_FILE = "buildchain.toml";
8
+ const RESERVED_LIFECYCLE_KEYS = new Set(["env", "shell"]);
9
+ const SUPPORTED_VERSION_FILE_TYPES = new Set(["json", "toml", "regex"]);
10
+ const SUPPORTED_VERSION_STRATEGIES = new Set(["semver", "anchored"]);
11
+ const SUPPORTED_VERSION_NEXT = new Set(["auto", "manual"]);
12
+ const SUPPORTED_PROJECT_TYPES = new Set(["package", "web-surface"]);
13
+ const WEB_SURFACE_CHANNELS = ["preview", "staging", "production"];
14
+ const SUPPORTED_CHANNEL_VISIBILITY = new Set(["ephemeral", "protected", "public", "internal"]);
15
+ const SUPPORTED_DEPLOY_ADAPTERS = new Set([
16
+ "aws-s3-cloudfront",
17
+ "aws-elastic-beanstalk",
18
+ "aws-ecs-service",
19
+ ]);
20
+
21
+ function posixPath(value) {
22
+ return String(value || "").split(path.sep).join("/");
23
+ }
24
+
25
+ function assertPlainObject(value, label) {
26
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
27
+ throw new Error(`${label} must be a table`);
28
+ }
29
+ return value;
30
+ }
31
+
32
+ function assertString(value, label) {
33
+ if (typeof value !== "string" || value.trim() === "") {
34
+ throw new Error(`${label} must be a non-empty string`);
35
+ }
36
+ return value;
37
+ }
38
+
39
+ function optionalBoolean(value, fallback) {
40
+ return value === undefined ? fallback : Boolean(value);
41
+ }
42
+
43
+ function normalizeStringArray(value, label) {
44
+ if (value === undefined) {
45
+ return [];
46
+ }
47
+ if (!Array.isArray(value)) {
48
+ throw new Error(`${label} must be an array of strings`);
49
+ }
50
+ return value.map((entry, index) => assertString(entry, `${label}[${index}]`));
51
+ }
52
+
53
+ function getByDottedKey(target, key) {
54
+ return String(key)
55
+ .split(".")
56
+ .reduce((current, segment) => current?.[segment], target);
57
+ }
58
+
59
+ function setByDottedKey(target, key, value) {
60
+ const segments = String(key).split(".");
61
+ let current = target;
62
+ for (const segment of segments.slice(0, -1)) {
63
+ if (!current[segment] || typeof current[segment] !== "object" || Array.isArray(current[segment])) {
64
+ current[segment] = {};
65
+ }
66
+ current = current[segment];
67
+ }
68
+ current[segments[segments.length - 1]] = value;
69
+ }
70
+
71
+ export function loadBuildchainConfig(cwd = process.cwd()) {
72
+ const filePath = path.join(cwd, CONFIG_FILE);
73
+ if (!fs.existsSync(filePath)) {
74
+ return undefined;
75
+ }
76
+ const source = fs.readFileSync(filePath, "utf8");
77
+ let config;
78
+ try {
79
+ config = parse(source);
80
+ } catch (error) {
81
+ throw new Error(`${CONFIG_FILE} parse failed: ${error.message}`);
82
+ }
83
+ if (config.schema !== 1) {
84
+ throw new Error(`${CONFIG_FILE} schema must be 1`);
85
+ }
86
+ return {
87
+ path: CONFIG_FILE,
88
+ filePath,
89
+ config: normalizeBuildchainConfig(config),
90
+ };
91
+ }
92
+
93
+ export function normalizeBuildchainConfig(config) {
94
+ assertPlainObject(config, CONFIG_FILE);
95
+ const normalized = { ...config };
96
+ if (normalized.project !== undefined) {
97
+ normalized.project = normalizeProjectSection(normalized.project);
98
+ }
99
+ if (normalized.version !== undefined) {
100
+ normalized.version = normalizeVersionSection(normalized.version);
101
+ }
102
+ if (normalized.lifecycle !== undefined) {
103
+ normalized.lifecycle = normalizeLifecycleSection(normalized.lifecycle);
104
+ }
105
+ if (normalized.channels !== undefined) {
106
+ normalized.channels = normalizeChannelsSection(normalized.channels, normalized.project);
107
+ }
108
+ if (normalized.deploy !== undefined) {
109
+ normalized.deploy = normalizeDeploySection(normalized.deploy, normalized.project);
110
+ }
111
+ if (normalized.retention !== undefined) {
112
+ normalized.retention = normalizeRetentionSection(normalized.retention);
113
+ }
114
+ if (normalized.security !== undefined) {
115
+ normalized.security = normalizeSecuritySection(normalized.security);
116
+ }
117
+ validateWebSurfaceConfig(normalized);
118
+ return normalized;
119
+ }
120
+
121
+ function normalizeProjectSection(project) {
122
+ assertPlainObject(project, "project");
123
+ const type = assertString(project.type, "project.type");
124
+ if (!SUPPORTED_PROJECT_TYPES.has(type)) {
125
+ throw new Error("project.type must be one of package or web-surface");
126
+ }
127
+ const normalized = { type };
128
+ for (const key of ["name", "site"]) {
129
+ if (project[key] !== undefined) {
130
+ normalized[key] = assertString(project[key], `project.${key}`);
131
+ }
132
+ }
133
+ return normalized;
134
+ }
135
+
136
+ function normalizeChannelsSection(channels) {
137
+ assertPlainObject(channels, "channels");
138
+ return Object.fromEntries(
139
+ Object.entries(channels).map(([name, channel]) => [
140
+ name,
141
+ normalizeChannelConfig(name, channel),
142
+ ]),
143
+ );
144
+ }
145
+
146
+ function normalizeChannelConfig(name, channel) {
147
+ assertPlainObject(channel, `channels.${name}`);
148
+ const hasUrl = channel.url !== undefined;
149
+ const hasUrlPattern = channel.url_pattern !== undefined;
150
+ if (hasUrl && hasUrlPattern) {
151
+ throw new Error(`channels.${name} must define only one of url or url_pattern`);
152
+ }
153
+ if (!hasUrl && !hasUrlPattern) {
154
+ throw new Error(`channels.${name} must define url or url_pattern`);
155
+ }
156
+ const visibility = channel.visibility === undefined
157
+ ? defaultChannelVisibility(name)
158
+ : assertString(channel.visibility, `channels.${name}.visibility`);
159
+ if (!SUPPORTED_CHANNEL_VISIBILITY.has(visibility)) {
160
+ throw new Error(`channels.${name}.visibility must be one of ephemeral, protected, public, or internal`);
161
+ }
162
+ const normalized = {
163
+ name,
164
+ visibility,
165
+ requiresAuth: optionalBoolean(channel.requires_auth, name === "staging"),
166
+ noindex: optionalBoolean(channel.noindex, name !== "production"),
167
+ promotable: optionalBoolean(channel.promotable, name === "staging"),
168
+ canonical: optionalBoolean(channel.canonical, name === "production"),
169
+ };
170
+ if (hasUrl) {
171
+ normalized.url = assertString(channel.url, `channels.${name}.url`);
172
+ }
173
+ if (hasUrlPattern) {
174
+ normalized.urlPattern = assertString(channel.url_pattern, `channels.${name}.url_pattern`);
175
+ }
176
+ return normalized;
177
+ }
178
+
179
+ function defaultChannelVisibility(name) {
180
+ if (name === "preview") {
181
+ return "ephemeral";
182
+ }
183
+ if (name === "staging") {
184
+ return "protected";
185
+ }
186
+ if (name === "production") {
187
+ return "public";
188
+ }
189
+ return "internal";
190
+ }
191
+
192
+ function normalizeDeploySection(deploy) {
193
+ assertPlainObject(deploy, "deploy");
194
+ return Object.fromEntries(
195
+ Object.entries(deploy).map(([name, config]) => [
196
+ name,
197
+ normalizeDeployConfig(name, config),
198
+ ]),
199
+ );
200
+ }
201
+
202
+ function normalizeDeployConfig(name, config) {
203
+ assertPlainObject(config, `deploy.${name}`);
204
+ const adapter = assertString(config.adapter, `deploy.${name}.adapter`);
205
+ if (!SUPPORTED_DEPLOY_ADAPTERS.has(adapter)) {
206
+ throw new Error(`deploy.${name}.adapter must be one of aws-s3-cloudfront, aws-elastic-beanstalk, or aws-ecs-service`);
207
+ }
208
+ const normalized = {
209
+ ...config,
210
+ adapter,
211
+ artifactPath: config.artifact_path === undefined
212
+ ? undefined
213
+ : posixPath(assertString(config.artifact_path, `deploy.${name}.artifact_path`)),
214
+ secretRefs: normalizeStringArray(config.secret_refs, `deploy.${name}.secret_refs`),
215
+ };
216
+ delete normalized.artifact_path;
217
+ delete normalized.secret_refs;
218
+ for (const [key, value] of Object.entries(config)) {
219
+ if (key === "secret_refs" || key.endsWith("_ref") || key.endsWith("_refs")) {
220
+ continue;
221
+ }
222
+ if (/(secret|token|password)/i.test(key) && value !== undefined) {
223
+ throw new Error(`deploy.${name}.${key} must be declared as a secret reference, not a secret value`);
224
+ }
225
+ }
226
+ return normalized;
227
+ }
228
+
229
+ function normalizeRetentionSection(retention) {
230
+ assertPlainObject(retention, "retention");
231
+ return Object.fromEntries(
232
+ Object.entries(retention).map(([name, config]) => [
233
+ name,
234
+ normalizeRetentionConfig(name, config),
235
+ ]),
236
+ );
237
+ }
238
+
239
+ function normalizeRetentionConfig(name, config) {
240
+ assertPlainObject(config, `retention.${name}`);
241
+ const normalized = {};
242
+ for (const [key, value] of Object.entries(config)) {
243
+ const numberValue = Number(value);
244
+ if (!Number.isInteger(numberValue) || numberValue < 0) {
245
+ throw new Error(`retention.${name}.${key} must be a non-negative integer`);
246
+ }
247
+ normalized[key] = numberValue;
248
+ }
249
+ return normalized;
250
+ }
251
+
252
+ function normalizeSecuritySection(security) {
253
+ assertPlainObject(security, "security");
254
+ return Object.fromEntries(
255
+ Object.entries(security).map(([name, config]) => [
256
+ name,
257
+ normalizeSecurityConfig(name, config),
258
+ ]),
259
+ );
260
+ }
261
+
262
+ function normalizeSecurityConfig(name, config) {
263
+ assertPlainObject(config, `security.${name}`);
264
+ return {
265
+ requiresAuth: optionalBoolean(config.requires_auth, undefined),
266
+ noindex: optionalBoolean(config.noindex, undefined),
267
+ isolatedProviders: optionalBoolean(config.isolated_providers, undefined),
268
+ sandboxData: optionalBoolean(config.sandbox_data, undefined),
269
+ secretRefs: normalizeStringArray(config.secret_refs, `security.${name}.secret_refs`),
270
+ };
271
+ }
272
+
273
+ function validateWebSurfaceConfig(config) {
274
+ if (config.project?.type !== "web-surface") {
275
+ return;
276
+ }
277
+ for (const name of WEB_SURFACE_CHANNELS) {
278
+ if (!config.channels?.[name]) {
279
+ throw new Error(`project.type = "web-surface" requires channels.${name}`);
280
+ }
281
+ if (!config.deploy?.[name]) {
282
+ throw new Error(`project.type = "web-surface" requires deploy.${name}`);
283
+ }
284
+ }
285
+ if (!config.channels.preview.urlPattern) {
286
+ throw new Error("channels.preview.url_pattern is required for web-surface preview aliases");
287
+ }
288
+ for (const name of ["staging", "production"]) {
289
+ if (!config.channels[name].url) {
290
+ throw new Error(`channels.${name}.url is required for web-surface`);
291
+ }
292
+ }
293
+ if (!config.channels.staging.requiresAuth) {
294
+ throw new Error("channels.staging.requires_auth must be true for web-surface");
295
+ }
296
+ if (!config.channels.staging.noindex) {
297
+ throw new Error("channels.staging.noindex must be true for web-surface");
298
+ }
299
+ const stagingSecurity = config.security?.staging;
300
+ if (stagingSecurity) {
301
+ if (stagingSecurity.requiresAuth === false) {
302
+ throw new Error("security.staging.requires_auth must not disable staging auth");
303
+ }
304
+ if (stagingSecurity.noindex === false) {
305
+ throw new Error("security.staging.noindex must not disable staging noindex");
306
+ }
307
+ if (stagingSecurity.isolatedProviders === false) {
308
+ throw new Error("security.staging.isolated_providers must not be false for web-surface");
309
+ }
310
+ }
311
+ }
312
+
313
+ function normalizeVersionSection(version) {
314
+ assertPlainObject(version, "version");
315
+ const files = version.files === undefined ? [] : version.files;
316
+ if (!Array.isArray(files)) {
317
+ throw new Error("version.files must be an array of tables");
318
+ }
319
+ const strategy = version.strategy === undefined
320
+ ? "semver"
321
+ : assertString(version.strategy, "version.strategy");
322
+ if (!SUPPORTED_VERSION_STRATEGIES.has(strategy)) {
323
+ throw new Error("version.strategy must be one of semver or anchored");
324
+ }
325
+ const next = version.next === undefined
326
+ ? "auto"
327
+ : assertString(version.next, "version.next");
328
+ if (!SUPPORTED_VERSION_NEXT.has(next)) {
329
+ throw new Error("version.next must be one of auto or manual");
330
+ }
331
+ if (strategy === "semver" && next !== "auto") {
332
+ throw new Error('version.next = "manual" requires version.strategy = "anchored"');
333
+ }
334
+ if (strategy === "anchored" && next !== "manual") {
335
+ throw new Error('version.strategy = "anchored" requires version.next = "manual"');
336
+ }
337
+ return {
338
+ required: version.required === undefined ? false : Boolean(version.required),
339
+ strategy,
340
+ next,
341
+ manifest: version.manifest === undefined
342
+ ? undefined
343
+ : posixPath(assertString(version.manifest, "version.manifest")),
344
+ files: files.map((file, index) => normalizeVersionFile(file, index)),
345
+ };
346
+ }
347
+
348
+ function normalizeVersionFile(file, index) {
349
+ assertPlainObject(file, `version.files[${index}]`);
350
+ const type = assertString(file.type, `version.files[${index}].type`);
351
+ if (!SUPPORTED_VERSION_FILE_TYPES.has(type)) {
352
+ throw new Error(`version.files[${index}].type must be one of json, toml, or regex`);
353
+ }
354
+ const normalized = {
355
+ type,
356
+ path: posixPath(assertString(file.path, `version.files[${index}].path`)),
357
+ };
358
+ if (type === "json" || type === "toml") {
359
+ normalized.key = assertString(file.key, `version.files[${index}].key`);
360
+ }
361
+ if (type === "regex") {
362
+ normalized.pattern = assertString(file.pattern, `version.files[${index}].pattern`);
363
+ normalized.replacement = assertString(file.replacement, `version.files[${index}].replacement`);
364
+ }
365
+ return normalized;
366
+ }
367
+
368
+ function normalizeLifecycleSection(lifecycle) {
369
+ assertPlainObject(lifecycle, "lifecycle");
370
+ const normalized = {};
371
+ if (lifecycle.shell !== undefined) {
372
+ normalized.shell = assertString(lifecycle.shell, "lifecycle.shell");
373
+ }
374
+ if (lifecycle.env !== undefined) {
375
+ normalized.env = normalizeEnv(lifecycle.env, "lifecycle.env");
376
+ }
377
+ for (const [name, value] of Object.entries(lifecycle)) {
378
+ if (RESERVED_LIFECYCLE_KEYS.has(name)) {
379
+ continue;
380
+ }
381
+ normalized[name] = normalizeLifecycleStage(value, `lifecycle.${name}`, normalized);
382
+ }
383
+ return normalized;
384
+ }
385
+
386
+ function normalizeEnv(env, label) {
387
+ assertPlainObject(env, label);
388
+ return Object.fromEntries(
389
+ Object.entries(env).map(([key, value]) => [key, String(value)]),
390
+ );
391
+ }
392
+
393
+ export function normalizeLifecycleStage(stage, label = "lifecycle stage", lifecycle = {}) {
394
+ assertPlainObject(stage, label);
395
+ const hasCommand = stage.command !== undefined;
396
+ const hasCommands = stage.commands !== undefined;
397
+ const hasScript = stage.script !== undefined;
398
+ const modeCount = [hasCommand, hasCommands, hasScript].filter(Boolean).length;
399
+ if (modeCount !== 1) {
400
+ throw new Error(`${label} must define exactly one of command, commands, or script`);
401
+ }
402
+ const normalized = {
403
+ shell: stage.shell === undefined ? lifecycle.shell : assertString(stage.shell, `${label}.shell`),
404
+ env: stage.env === undefined ? undefined : normalizeEnv(stage.env, `${label}.env`),
405
+ timeoutMinutes: stage.timeout_minutes === undefined ? undefined : Number(stage.timeout_minutes),
406
+ retries: stage.retries === undefined ? 1 : Number(stage.retries),
407
+ };
408
+ if (normalized.timeoutMinutes !== undefined && (!Number.isFinite(normalized.timeoutMinutes) || normalized.timeoutMinutes <= 0)) {
409
+ throw new Error(`${label}.timeout_minutes must be a positive number`);
410
+ }
411
+ if (!Number.isInteger(normalized.retries) || normalized.retries < 1) {
412
+ throw new Error(`${label}.retries must be a positive integer`);
413
+ }
414
+ if (hasCommand) {
415
+ normalized.commands = [assertString(stage.command, `${label}.command`)];
416
+ normalized.mode = "command";
417
+ } else if (hasCommands) {
418
+ if (!Array.isArray(stage.commands) || stage.commands.length === 0) {
419
+ throw new Error(`${label}.commands must be a non-empty array`);
420
+ }
421
+ normalized.commands = stage.commands.map((command, index) =>
422
+ assertString(command, `${label}.commands[${index}]`),
423
+ );
424
+ normalized.mode = "commands";
425
+ } else {
426
+ normalized.script = assertString(stage.script, `${label}.script`);
427
+ normalized.mode = "script";
428
+ }
429
+ return normalized;
430
+ }
431
+
432
+ export function getLifecycleStage(loadedConfig, name) {
433
+ return loadedConfig?.config?.lifecycle?.[name];
434
+ }
435
+
436
+ export function runLifecycleStage({ cwd = process.cwd(), loadedConfig, name, stage, env: extraEnv }) {
437
+ const lifecycle = loadedConfig?.config?.lifecycle || {};
438
+ const selected = stage || getLifecycleStage(loadedConfig, name);
439
+ if (!selected) {
440
+ return false;
441
+ }
442
+ const env = {
443
+ ...process.env,
444
+ ...(lifecycle.env || {}),
445
+ ...(selected.env || {}),
446
+ ...(extraEnv || {}),
447
+ };
448
+ const timeout = selected.timeoutMinutes ? selected.timeoutMinutes * 60_000 : undefined;
449
+ const execOptions = {
450
+ cwd,
451
+ env,
452
+ stdio: "inherit",
453
+ shell: selected.shell || true,
454
+ timeout,
455
+ };
456
+ const runOnce = () => {
457
+ if (selected.mode === "script") {
458
+ execSync(selected.script, execOptions);
459
+ return;
460
+ }
461
+ for (const command of selected.commands) {
462
+ execSync(command, execOptions);
463
+ }
464
+ };
465
+ let lastError;
466
+ for (let attempt = 1; attempt <= selected.retries; attempt += 1) {
467
+ try {
468
+ runOnce();
469
+ return true;
470
+ } catch (error) {
471
+ lastError = error;
472
+ if (attempt < selected.retries) {
473
+ console.log(`> lifecycle ${name || "stage"} failed, retry ${attempt + 1}/${selected.retries}`);
474
+ }
475
+ }
476
+ }
477
+ throw lastError;
478
+ }
479
+
480
+ export function discoverConfiguredVersionStateFiles(cwd = process.cwd(), loadedConfig = loadBuildchainConfig(cwd)) {
481
+ const configured = loadedConfig?.config?.version?.files || [];
482
+ const files = configured.map((entry) => {
483
+ const filePath = path.join(cwd, entry.path);
484
+ if (!fs.existsSync(filePath)) {
485
+ throw new Error(`Configured version file does not exist: ${entry.path}`);
486
+ }
487
+ const source = fs.readFileSync(filePath, "utf8");
488
+ const file = { ...entry, source };
489
+ if (entry.type === "json") {
490
+ const content = JSON.parse(source);
491
+ if (typeof getByDottedKey(content, entry.key) !== "string") {
492
+ throw new Error(`Configured JSON version key is missing or not a string: ${entry.path}:${entry.key}`);
493
+ }
494
+ return { ...file, content };
495
+ }
496
+ if (entry.type === "toml") {
497
+ const content = parse(source);
498
+ if (typeof getByDottedKey(content, entry.key) !== "string") {
499
+ throw new Error(`Configured TOML version key is missing or not a string: ${entry.path}:${entry.key}`);
500
+ }
501
+ return { ...file, content };
502
+ }
503
+ const pattern = new RegExp(entry.pattern, "m");
504
+ const match = source.match(pattern);
505
+ if (!match) {
506
+ throw new Error(`Configured regex version pattern did not match: ${entry.path}`);
507
+ }
508
+ if (typeof match.groups?.version !== "string") {
509
+ throw new Error(`Configured regex version pattern must define a named capture group called version: ${entry.path}`);
510
+ }
511
+ return { ...file, pattern };
512
+ });
513
+ if (loadedConfig?.config?.version?.required && files.length === 0) {
514
+ throw new Error("version.required is true but no version.files are configured");
515
+ }
516
+ return files;
517
+ }
518
+
519
+ function summarizeManifestFields(content) {
520
+ assertPlainObject(content, "version.manifest content");
521
+ return Object.fromEntries(
522
+ Object.entries(content).map(([key, value]) => {
523
+ if (value === null || ["string", "number", "boolean"].includes(typeof value)) {
524
+ return [key, value];
525
+ }
526
+ return [key, JSON.stringify(value)];
527
+ }),
528
+ );
529
+ }
530
+
531
+ export function getVersionStrategy(loadedConfig) {
532
+ const version = loadedConfig?.config?.version;
533
+ return {
534
+ strategy: version?.strategy || "semver",
535
+ next: version?.next || "auto",
536
+ manifest: version?.manifest,
537
+ };
538
+ }
539
+
540
+ export function loadConfiguredAnchorManifest(cwd = process.cwd(), loadedConfig = loadBuildchainConfig(cwd)) {
541
+ const manifest = loadedConfig?.config?.version?.manifest;
542
+ if (!manifest) {
543
+ return undefined;
544
+ }
545
+ const filePath = path.join(cwd, manifest);
546
+ if (!fs.existsSync(filePath)) {
547
+ throw new Error(`Configured anchor manifest does not exist: ${manifest}`);
548
+ }
549
+ const source = fs.readFileSync(filePath, "utf8");
550
+ let content;
551
+ if (manifest.endsWith(".json")) {
552
+ content = JSON.parse(source);
553
+ } else if (manifest.endsWith(".toml")) {
554
+ content = parse(source);
555
+ } else {
556
+ throw new Error("version.manifest must point to a .json or .toml file");
557
+ }
558
+ return {
559
+ path: manifest,
560
+ filePath,
561
+ fields: summarizeManifestFields(content),
562
+ };
563
+ }
564
+
565
+ function configuredLifecycleStages(loadedConfig) {
566
+ const lifecycle = loadedConfig?.config?.lifecycle || {};
567
+ return Object.entries(lifecycle)
568
+ .filter(([, stage]) => stage && typeof stage === "object" && typeof stage.mode === "string")
569
+ .map(([name, stage]) => ({
570
+ name,
571
+ mode: stage.mode,
572
+ timeoutMinutes: stage.timeoutMinutes,
573
+ retries: stage.retries,
574
+ commandCount: stage.mode === "script" ? 1 : stage.commands.length,
575
+ }));
576
+ }
577
+
578
+ export function validateBuildchainConfig(
579
+ cwd = process.cwd(),
580
+ {
581
+ requireConfig = true,
582
+ requireVersionState = false,
583
+ requireLifecycleStages = [],
584
+ } = {},
585
+ ) {
586
+ const loadedConfig = loadBuildchainConfig(cwd);
587
+ if (!loadedConfig) {
588
+ if (requireConfig) {
589
+ throw new Error(`${CONFIG_FILE} is required`);
590
+ }
591
+ return {
592
+ config: undefined,
593
+ versionFiles: [],
594
+ lifecycleStages: [],
595
+ };
596
+ }
597
+
598
+ const versionFiles = loadedConfig.config.version
599
+ ? discoverConfiguredVersionStateFiles(cwd, loadedConfig)
600
+ : [];
601
+ const anchorManifest = loadConfiguredAnchorManifest(cwd, loadedConfig);
602
+ if (requireVersionState && versionFiles.length === 0) {
603
+ throw new Error("version state is required but no version.files are configured");
604
+ }
605
+
606
+ const lifecycleStages = configuredLifecycleStages(loadedConfig);
607
+ const stageNames = new Set(lifecycleStages.map((stage) => stage.name));
608
+ const missingStages = requireLifecycleStages.filter((stage) => !stageNames.has(stage));
609
+ if (missingStages.length > 0) {
610
+ throw new Error(`required lifecycle stage missing: ${missingStages.join(", ")}`);
611
+ }
612
+
613
+ return {
614
+ config: {
615
+ path: loadedConfig.path,
616
+ filePath: loadedConfig.filePath,
617
+ schema: loadedConfig.config.schema,
618
+ },
619
+ project: loadedConfig.config.project,
620
+ channels: loadedConfig.config.channels
621
+ ? Object.fromEntries(
622
+ Object.entries(loadedConfig.config.channels).map(([name, channel]) => [
623
+ name,
624
+ {
625
+ visibility: channel.visibility,
626
+ requiresAuth: channel.requiresAuth,
627
+ noindex: channel.noindex,
628
+ promotable: channel.promotable,
629
+ canonical: channel.canonical,
630
+ url: channel.url,
631
+ urlPattern: channel.urlPattern,
632
+ },
633
+ ]),
634
+ )
635
+ : undefined,
636
+ deploy: loadedConfig.config.deploy
637
+ ? Object.fromEntries(
638
+ Object.entries(loadedConfig.config.deploy).map(([name, deploy]) => [
639
+ name,
640
+ {
641
+ adapter: deploy.adapter,
642
+ artifactPath: deploy.artifactPath,
643
+ secretRefs: deploy.secretRefs,
644
+ },
645
+ ]),
646
+ )
647
+ : undefined,
648
+ retention: loadedConfig.config.retention,
649
+ security: loadedConfig.config.security,
650
+ version: loadedConfig.config.version
651
+ ? getVersionStrategy(loadedConfig)
652
+ : undefined,
653
+ anchorManifest: anchorManifest
654
+ ? {
655
+ path: anchorManifest.path,
656
+ fields: anchorManifest.fields,
657
+ }
658
+ : undefined,
659
+ versionFiles: versionFiles.map((file) => ({
660
+ path: file.path,
661
+ type: file.type,
662
+ key: file.key,
663
+ pattern: file.pattern?.source,
664
+ })),
665
+ lifecycleStages,
666
+ };
667
+ }
668
+
669
+ export function updateConfiguredVersionStateContents(files, version) {
670
+ return files
671
+ .map((file) => {
672
+ let content;
673
+ if (file.type === "json") {
674
+ const next = structuredClone(file.content);
675
+ setByDottedKey(next, file.key, version);
676
+ content = `${JSON.stringify(next, null, 2)}\n`;
677
+ } else if (file.type === "toml") {
678
+ const next = structuredClone(file.content);
679
+ setByDottedKey(next, file.key, version);
680
+ content = stringify(next);
681
+ } else if (file.type === "regex") {
682
+ content = file.source.replace(file.pattern, (...args) => {
683
+ const groups = args.at(-1) || {};
684
+ const current = groups.version;
685
+ if (typeof current !== "string") {
686
+ throw new Error(`Configured regex version pattern must define a named capture group called version: ${file.path}`);
687
+ }
688
+ return args[0].replace(current, file.replacement.replaceAll("${version}", version));
689
+ });
690
+ } else {
691
+ throw new Error(`Unsupported configured version file type: ${file.type}`);
692
+ }
693
+ return {
694
+ path: file.path,
695
+ kind: file.type,
696
+ changed: content !== file.source,
697
+ content,
698
+ };
699
+ })
700
+ .filter((file) => file.changed);
701
+ }
702
+
703
+ export function writeLifecycleScriptFixture(cwd, name, script) {
704
+ const filePath = path.join(cwd, `${name}-${Date.now()}.sh`);
705
+ fs.writeFileSync(filePath, script.replace(/\$\{TMPDIR\}/g, os.tmpdir()));
706
+ return filePath;
707
+ }
708
+
709
+ export default {
710
+ discoverConfiguredVersionStateFiles,
711
+ getVersionStrategy,
712
+ getLifecycleStage,
713
+ loadConfiguredAnchorManifest,
714
+ loadBuildchainConfig,
715
+ normalizeBuildchainConfig,
716
+ normalizeLifecycleStage,
717
+ runLifecycleStage,
718
+ updateConfiguredVersionStateContents,
719
+ validateBuildchainConfig,
720
+ writeLifecycleScriptFixture,
721
+ };