@treeseed/sdk 0.6.1 → 0.6.3

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 (33) hide show
  1. package/dist/operations/services/bootstrap-runner.d.ts +33 -0
  2. package/dist/operations/services/bootstrap-runner.js +136 -0
  3. package/dist/operations/services/config-runtime.d.ts +27 -8
  4. package/dist/operations/services/config-runtime.js +303 -127
  5. package/dist/operations/services/github-api.d.ts +34 -0
  6. package/dist/operations/services/github-api.js +187 -4
  7. package/dist/operations/services/github-automation.d.ts +30 -0
  8. package/dist/operations/services/github-automation.js +107 -1
  9. package/dist/operations/services/project-platform.d.ts +38 -2
  10. package/dist/operations/services/project-platform.js +319 -15
  11. package/dist/operations/services/railway-deploy.d.ts +6 -2
  12. package/dist/operations/services/railway-deploy.js +26 -18
  13. package/dist/operations/services/runtime-tools.d.ts +0 -2
  14. package/dist/operations/services/runtime-tools.js +0 -2
  15. package/dist/platform/env.yaml +71 -96
  16. package/dist/platform/environment.d.ts +4 -0
  17. package/dist/platform/environment.js +54 -0
  18. package/dist/reconcile/bootstrap-systems.d.ts +32 -0
  19. package/dist/reconcile/bootstrap-systems.js +175 -0
  20. package/dist/reconcile/builtin-adapters.js +1 -9
  21. package/dist/reconcile/desired-state.js +16 -14
  22. package/dist/reconcile/engine.d.ts +9 -4
  23. package/dist/reconcile/engine.js +57 -14
  24. package/dist/reconcile/index.d.ts +1 -0
  25. package/dist/reconcile/index.js +1 -0
  26. package/dist/scripts/config-treeseed.js +30 -0
  27. package/dist/scripts/package-tools.js +0 -2
  28. package/dist/scripts/tenant-deploy.js +16 -36
  29. package/dist/scripts/test-cloudflare-local.js +0 -2
  30. package/dist/workflow/operations.js +23 -4
  31. package/dist/workflow.d.ts +5 -0
  32. package/package.json +1 -1
  33. package/templates/github/deploy.workflow.yml +15 -15
@@ -6,6 +6,7 @@ import { spawn, spawnSync } from "node:child_process";
6
6
  import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
7
7
  import {
8
8
  getTreeseedEnvironmentSuggestedValues,
9
+ isTreeseedEnvironmentEntryRelevant,
9
10
  isTreeseedEnvironmentEntryRequired,
10
11
  resolveTreeseedEnvironmentRegistry,
11
12
  TREESEED_ENVIRONMENT_SCOPES,
@@ -19,8 +20,15 @@ import {
19
20
  loadDeployState,
20
21
  syncCloudflareSecrets
21
22
  } from "./deploy.js";
22
- import { collectTreeseedReconcileStatus, reconcileTreeseedTarget } from "../../reconcile/index.js";
23
- import { maybeResolveGitHubRepositorySlug } from "./github-automation.js";
23
+ import {
24
+ collectTreeseedReconcileStatus,
25
+ reconcileTreeseedTarget,
26
+ resolveTreeseedBootstrapSelection
27
+ } from "../../reconcile/index.js";
28
+ import {
29
+ ensureGitHubBootstrapRepository,
30
+ maybeResolveGitHubRepositorySlug
31
+ } from "./github-automation.js";
24
32
  import {
25
33
  buildRailwayCommandEnv
26
34
  } from "./railway-deploy.js";
@@ -30,12 +38,12 @@ import {
30
38
  } from "./railway-api.js";
31
39
  import {
32
40
  createGitHubApiClient,
41
+ ensureGitHubActionsEnvironment,
33
42
  ensureGitHubBranchFromBase,
34
- listGitHubRepositorySecretNames,
35
- listGitHubRepositoryVariableNames,
36
- upsertGitHubRepositorySecret,
37
- upsertGitHubRepositoryVariable,
38
- upsertGitHubRepositoryVariableWithGhCli
43
+ listGitHubEnvironmentSecretNames,
44
+ listGitHubEnvironmentVariableNames,
45
+ upsertGitHubEnvironmentSecret,
46
+ upsertGitHubEnvironmentVariable
39
47
  } from "./github-api.js";
40
48
  import { loadCliDeployConfig, packageScriptPath, resolveWranglerBin, withProcessCwd } from "./runtime-tools.js";
41
49
  import { PRODUCTION_BRANCH, STAGING_BRANCH } from "./git-workflow.js";
@@ -53,9 +61,6 @@ import {
53
61
  } from "./key-agent.js";
54
62
  import { TREESEED_MACHINE_KEY_PASSPHRASE_ENV as TREESEED_MACHINE_KEY_PASSPHRASE_ENV2, TreeseedKeyAgentError as TreeseedKeyAgentError2 } from "./key-agent.js";
55
63
  const MACHINE_CONFIG_RELATIVE_PATH = ".treeseed/config/machine.yaml";
56
- const LEGACY_ENVIRONMENT_ALIASES = {
57
- RAILWAY_API_KEY: "RAILWAY_API_TOKEN"
58
- };
59
64
  const MACHINE_KEY_HOME_RELATIVE_PATH = ".treeseed/config/machine.key";
60
65
  const LEGACY_MACHINE_KEY_RELATIVE_PATH = ".treeseed/config/machine.key";
61
66
  const REMOTE_AUTH_RELATIVE_PATH = ".treeseed/config/remote-auth.json";
@@ -72,8 +77,8 @@ const DEPRECATED_LOCAL_ENV_FILES = [".env.local", ".dev.vars"];
72
77
  const warnedDeprecatedLocalEnvRoots = /* @__PURE__ */ new Set();
73
78
  const inlineTreeseedSecretSessions = /* @__PURE__ */ new Map();
74
79
  const railwayConnectionCheckCache = /* @__PURE__ */ new Map();
75
- function filterEnvironmentValuesByRegistry(values, registry) {
76
- const registeredKeys = new Set(registry.entries.map((entry) => entry.id));
80
+ function filterEnvironmentValuesByRegistry(values, registry, scope, purpose = "config") {
81
+ const registeredKeys = new Set(registry.entries.filter((entry) => isTreeseedEnvironmentEntryRelevant(entry, registry.context, scope, purpose)).map((entry) => entry.id));
77
82
  return Object.fromEntries(
78
83
  Object.entries(values).filter(([key]) => registeredKeys.has(key))
79
84
  );
@@ -1399,21 +1404,15 @@ function collectTreeseedConfigSeedValues(tenantRoot, scope, env = process.env) {
1399
1404
  throw error;
1400
1405
  }
1401
1406
  }
1402
- const normalizedEnv = { ...env };
1403
- for (const [legacyKey, canonicalKey] of Object.entries(LEGACY_ENVIRONMENT_ALIASES)) {
1404
- if ((!normalizedEnv[canonicalKey] || String(normalizedEnv[canonicalKey]).length === 0) && normalizedEnv[legacyKey]) {
1405
- normalizedEnv[canonicalKey] = normalizedEnv[legacyKey];
1406
- }
1407
- }
1408
1407
  return filterEnvironmentValuesByRegistry({
1409
1408
  ...machineValues,
1410
- ...Object.fromEntries(Object.entries(normalizedEnv).map(([key, value]) => [key, value ?? void 0]))
1411
- }, registry);
1409
+ ...Object.fromEntries(Object.entries(env).map(([key, value]) => [key, value ?? void 0]))
1410
+ }, registry, scope);
1412
1411
  }
1413
1412
  function collectTreeseedConfigSeedValueSources(tenantRoot, scope, env = process.env) {
1414
1413
  warnDeprecatedTreeseedLocalEnvFiles(tenantRoot);
1415
1414
  const registry = collectTreeseedEnvironmentContext(tenantRoot);
1416
- const registeredKeys = new Set(registry.entries.map((entry) => entry.id));
1415
+ const registeredKeys = new Set(registry.entries.filter((entry) => isTreeseedEnvironmentEntryRelevant(entry, registry.context, scope, "config")).map((entry) => entry.id));
1417
1416
  const values = {};
1418
1417
  const sources = {};
1419
1418
  const merge = (source, entries) => {
@@ -1856,8 +1855,31 @@ function formatTreeseedProviderConnectionFailures(reports) {
1856
1855
  function writeProviderConnectionReport(write, report) {
1857
1856
  write(formatTreeseedProviderConnectionReport(report));
1858
1857
  }
1859
- async function syncTreeseedGitHubEnvironment({ tenantRoot, scope = "prod", dryRun = false } = {}) {
1860
- const repository = maybeResolveGitHubRepositorySlug(tenantRoot);
1858
+ async function runBounded(items, limit, worker) {
1859
+ const concurrency = Math.max(1, Math.min(limit, items.length || 1));
1860
+ let nextIndex = 0;
1861
+ const workers = Array.from({ length: concurrency }, async () => {
1862
+ for (; ; ) {
1863
+ const index = nextIndex;
1864
+ nextIndex += 1;
1865
+ if (index >= items.length) {
1866
+ return;
1867
+ }
1868
+ await worker(items[index], index);
1869
+ }
1870
+ });
1871
+ await Promise.all(workers);
1872
+ }
1873
+ async function syncTreeseedGitHubEnvironment({
1874
+ tenantRoot,
1875
+ scope = "prod",
1876
+ dryRun = false,
1877
+ repository: repositoryInput,
1878
+ execution = "parallel",
1879
+ concurrency = 4,
1880
+ onProgress
1881
+ }) {
1882
+ const repository = repositoryInput ?? maybeResolveGitHubRepositorySlug(tenantRoot);
1861
1883
  if (!repository) {
1862
1884
  throw new Error("Unable to determine the GitHub repository from the origin remote.");
1863
1885
  }
@@ -1870,41 +1892,63 @@ async function syncTreeseedGitHubEnvironment({ tenantRoot, scope = "prod", dryRu
1870
1892
  GITHUB_TOKEN: ghToken
1871
1893
  } : {};
1872
1894
  const githubClient = createGitHubApiClient({ env: ghEnv });
1873
- const secretNames = await listGitHubRepositorySecretNames(repository, { client: githubClient });
1874
- const variableNames = await listGitHubRepositoryVariableNames(repository, { client: githubClient });
1895
+ const environment = scope === "prod" ? "production" : scope;
1896
+ const deploymentBranch = scope === "prod" ? PRODUCTION_BRANCH : scope === "staging" ? STAGING_BRANCH : null;
1897
+ const progress = (message, stream = "stdout") => onProgress?.(message, stream);
1898
+ if (!dryRun) {
1899
+ progress(`[${scope}][github][environment] Ensuring GitHub environment ${environment} exists...`);
1900
+ await ensureGitHubActionsEnvironment(repository, environment, {
1901
+ client: githubClient,
1902
+ branchName: deploymentBranch
1903
+ });
1904
+ }
1905
+ progress(`[${scope}][github][sync] Loading existing GitHub secrets and variables...`);
1906
+ const [secretNames, variableNames] = dryRun ? [/* @__PURE__ */ new Set(), /* @__PURE__ */ new Set()] : await Promise.all([
1907
+ listGitHubEnvironmentSecretNames(repository, environment, { client: githubClient }),
1908
+ listGitHubEnvironmentVariableNames(repository, environment, { client: githubClient })
1909
+ ]);
1875
1910
  const synced = {
1876
1911
  secrets: [],
1877
1912
  variables: []
1878
1913
  };
1914
+ const items = [];
1879
1915
  for (const entry of relevant) {
1880
1916
  const value = values[entry.id];
1881
1917
  if (!value) {
1882
1918
  continue;
1883
1919
  }
1884
- if (entry.targets.includes("github-secret")) {
1885
- if (!dryRun) {
1886
- await upsertGitHubRepositorySecret(repository, entry.id, value, { client: githubClient });
1887
- }
1888
- synced.secrets.push({ name: entry.id, existed: secretNames.has(entry.id) });
1920
+ if (entry.sensitivity === "secret") {
1921
+ items.push({ kind: "secret", name: entry.id, value, existed: secretNames.has(entry.id) });
1922
+ } else {
1923
+ items.push({ kind: "variable", name: entry.id, value, existed: variableNames.has(entry.id) });
1889
1924
  }
1890
- if (entry.targets.includes("github-variable")) {
1891
- if (!dryRun) {
1892
- try {
1893
- upsertGitHubRepositoryVariableWithGhCli(repository, entry.id, value, { env: ghEnv });
1894
- } catch (error) {
1895
- const message = error instanceof Error ? error.message : String(error ?? "");
1896
- if (!/not found|ENOENT|timed out|timeout|aborted|gh api exited/iu.test(message)) {
1897
- throw error;
1898
- }
1899
- await upsertGitHubRepositoryVariable(repository, entry.id, value, { client: githubClient });
1900
- }
1925
+ }
1926
+ let completed = 0;
1927
+ const total = items.length;
1928
+ progress(`[${scope}][github][sync] Syncing GitHub environment ${environment}: 0/${total} items...`);
1929
+ const limit = execution === "sequential" ? 1 : concurrency;
1930
+ await runBounded(items, limit, async (item) => {
1931
+ if (!dryRun) {
1932
+ if (item.kind === "secret") {
1933
+ await upsertGitHubEnvironmentSecret(repository, environment, item.name, item.value, { client: githubClient });
1934
+ } else {
1935
+ await upsertGitHubEnvironmentVariable(repository, environment, item.name, item.value, { client: githubClient });
1901
1936
  }
1902
- synced.variables.push({ name: entry.id, existed: variableNames.has(entry.id) });
1903
1937
  }
1904
- }
1938
+ completed += 1;
1939
+ const action = item.existed ? "updated" : "created";
1940
+ progress(`[${scope}][github][${item.kind}] ${action} ${item.name} (${completed}/${total})`);
1941
+ if (item.kind === "secret") {
1942
+ synced.secrets.push({ name: item.name, existed: item.existed });
1943
+ } else {
1944
+ synced.variables.push({ name: item.name, existed: item.existed });
1945
+ }
1946
+ });
1947
+ progress(`[${scope}][github][sync] Complete: ${synced.secrets.length} secrets, ${synced.variables.length} variables, ${total} total.`);
1905
1948
  return {
1906
1949
  repository,
1907
1950
  scope,
1951
+ environment,
1908
1952
  ...synced
1909
1953
  };
1910
1954
  }
@@ -1990,7 +2034,7 @@ async function initializeTreeseedPersistentEnvironment({ tenantRoot, scope = "pr
1990
2034
  secrets: summary.results.filter((result) => result.unit.provider === "cloudflare").flatMap((result) => Object.keys(result.resourceLocators ?? {}))
1991
2035
  };
1992
2036
  }
1993
- async function summarizePersistentReadiness(tenantRoot, scope, validation, connectionChecks, env = process.env, { includeReconcileStatus = true } = {}) {
2037
+ async function summarizePersistentReadiness(tenantRoot, scope, validation, connectionChecks, env = process.env, { includeReconcileStatus = true, systems } = {}) {
1994
2038
  const validationProblems = [...validation.missing, ...validation.invalid];
1995
2039
  const validationBlockers = validationProblems.map((problem) => problem.message);
1996
2040
  const connectionReady = connectionChecks.every((check) => check.ready || check.skipped);
@@ -2051,7 +2095,8 @@ async function summarizePersistentReadiness(tenantRoot, scope, validation, conne
2051
2095
  const reconcile = await collectTreeseedReconcileStatus({
2052
2096
  tenantRoot,
2053
2097
  target: createPersistentDeployTarget(scope),
2054
- env
2098
+ env,
2099
+ systems
2055
2100
  });
2056
2101
  const provisioned = reconcile.ready;
2057
2102
  const deployable = configured && provisioned && connectionReady;
@@ -2165,21 +2210,31 @@ function createConfigReadiness(values, validation) {
2165
2210
  ...(validation?.invalid ?? []).map((problem) => problem.id)
2166
2211
  ]);
2167
2212
  const validConfigValue = (key) => hasConfigValue(values, key) && !invalidIds.has(key);
2168
- const cloudflareReady = validConfigValue("CLOUDFLARE_API_TOKEN");
2169
- const railwayReady = validConfigValue("RAILWAY_API_TOKEN");
2170
- const localDevelopmentIssues = [
2213
+ const configProblems = [
2171
2214
  ...validation?.missing ?? [],
2172
2215
  ...validation?.invalid ?? []
2216
+ ];
2217
+ const providerIssues = (provider) => configProblems.filter((problem) => {
2218
+ if (provider === "github") {
2219
+ return problem.id === "GH_TOKEN" || problem.id === "GITHUB_TOKEN" || problem.entry.group === "github";
2220
+ }
2221
+ if (provider === "cloudflare") {
2222
+ return problem.id.startsWith("CLOUDFLARE_") || problem.id.includes("TURNSTILE") || problem.entry.group === "cloudflare";
2223
+ }
2224
+ return problem.id.startsWith("RAILWAY_") || problem.entry.group === "railway";
2225
+ });
2226
+ const localDevelopmentIssues = [
2227
+ ...configProblems
2173
2228
  ].filter((problem) => problem.entry.group === "local-development");
2174
2229
  return {
2175
2230
  github: {
2176
2231
  configured: validConfigValue("GH_TOKEN")
2177
2232
  },
2178
2233
  cloudflare: {
2179
- configured: cloudflareReady
2234
+ configured: providerIssues("cloudflare").length === 0
2180
2235
  },
2181
2236
  railway: {
2182
- configured: railwayReady
2237
+ configured: providerIssues("railway").length === 0
2183
2238
  },
2184
2239
  localDevelopment: {
2185
2240
  configured: localDevelopmentIssues.length === 0
@@ -2193,7 +2248,7 @@ function configGroupRank(group) {
2193
2248
  }
2194
2249
  function listRelevantTreeseedConfigEntries(registry, scope) {
2195
2250
  return registry.entries.filter(
2196
- (entry) => entry.scopes.includes(scope) && (!entry.isRelevant || entry.isRelevant(registry.context, scope, "config") || Boolean(entry.onboardingFeature))
2251
+ (entry) => entry.visibility !== "system" && entry.scopes.includes(scope) && (!entry.isRelevant || entry.isRelevant(registry.context, scope, "config") || Boolean(entry.onboardingFeature))
2197
2252
  ).sort((left, right) => {
2198
2253
  const leftRequired = isTreeseedEnvironmentEntryRequired(left, registry.context, scope, "config");
2199
2254
  const rightRequired = isTreeseedEnvironmentEntryRequired(right, registry.context, scope, "config");
@@ -2363,6 +2418,37 @@ function applyTreeseedConfigValues({
2363
2418
  sharedStorageMigrations
2364
2419
  };
2365
2420
  }
2421
+ function configProblemBootstrapSystems(problem) {
2422
+ switch (problem?.id) {
2423
+ case "GH_TOKEN":
2424
+ case "GITHUB_TOKEN":
2425
+ return ["github"];
2426
+ case "CLOUDFLARE_API_TOKEN":
2427
+ case "CLOUDFLARE_ACCOUNT_ID":
2428
+ case "CLOUDFLARE_ZONE_ID":
2429
+ return ["data", "web"];
2430
+ case "RAILWAY_API_TOKEN":
2431
+ case "TREESEED_RAILWAY_WORKSPACE":
2432
+ return ["api", "agents"];
2433
+ default:
2434
+ return null;
2435
+ }
2436
+ }
2437
+ function filterValidationForBootstrapSystems(validation, runnableSystems) {
2438
+ const runnable = new Set(runnableSystems);
2439
+ const keepProblem = (problem) => {
2440
+ const systems = configProblemBootstrapSystems(problem);
2441
+ return !systems || systems.some((system) => runnable.has(system));
2442
+ };
2443
+ const missing = validation.missing.filter(keepProblem);
2444
+ const invalid = validation.invalid.filter(keepProblem);
2445
+ return {
2446
+ ...validation,
2447
+ ok: missing.length === 0 && invalid.length === 0,
2448
+ missing,
2449
+ invalid
2450
+ };
2451
+ }
2366
2452
  async function finalizeTreeseedConfig({
2367
2453
  tenantRoot,
2368
2454
  scopes = [...TREESEED_ENVIRONMENT_SCOPES],
@@ -2370,6 +2456,9 @@ async function finalizeTreeseedConfig({
2370
2456
  env = process.env,
2371
2457
  checkConnections = true,
2372
2458
  initializePersistent = true,
2459
+ systems,
2460
+ skipUnavailable,
2461
+ bootstrapExecution = "parallel",
2373
2462
  onProgress
2374
2463
  }) {
2375
2464
  const registry = collectTreeseedEnvironmentContext(tenantRoot);
@@ -2381,17 +2470,39 @@ async function finalizeTreeseedConfig({
2381
2470
  resourceInventoryByScope: {},
2382
2471
  connectionChecks: [],
2383
2472
  validationByScope: {},
2384
- readinessByScope: {}
2473
+ bootstrapSystemsByScope: {},
2474
+ githubRepository: null,
2475
+ readinessByScope: {},
2476
+ bootstrapExecution
2385
2477
  };
2386
- const progress = (message) => {
2478
+ const progress = (message, stream = "stdout") => {
2387
2479
  if (typeof onProgress === "function") {
2388
- onProgress(message);
2480
+ onProgress(message, stream);
2389
2481
  }
2390
2482
  };
2391
2483
  progress(`Validating configuration for ${scopes.join(", ")}...`);
2392
2484
  const scopeSeedValues = Object.fromEntries(
2393
2485
  scopes.map((scope) => [scope, collectTreeseedConfigSeedValues(tenantRoot, scope, env)])
2394
2486
  );
2487
+ for (const scope of scopes) {
2488
+ const selection = resolveTreeseedBootstrapSelection({
2489
+ deployConfig: registry.context.deployConfig,
2490
+ env: scopeSeedValues[scope],
2491
+ systems: scope === "local" ? ["github"] : systems,
2492
+ skipUnavailable: scope === "local" ? true : skipUnavailable
2493
+ });
2494
+ summary.bootstrapSystemsByScope[scope] = selection;
2495
+ const strictUnavailable = selection.unavailable.filter(
2496
+ (status) => !selection.skipped.some((skipped) => skipped.system === status.system && skipped.reason === status.reason)
2497
+ );
2498
+ if (initializePersistent && strictUnavailable.length > 0) {
2499
+ throw new Error(`Treeseed bootstrap cannot run the selected systems for ${scope}:
2500
+ - ${strictUnavailable.map((status) => `${status.system}: ${status.reason}`).join("\n- ")}`);
2501
+ }
2502
+ for (const skipped of selection.skipped) {
2503
+ progress(`[${scope}][${skipped.system}][skip] ${skipped.reason}`);
2504
+ }
2505
+ }
2395
2506
  for (const scope of scopes) {
2396
2507
  const seedValues = scopeSeedValues[scope];
2397
2508
  const suggestedValues = getTreeseedEnvironmentSuggestedValues({
@@ -2413,7 +2524,7 @@ async function finalizeTreeseedConfig({
2413
2524
  tenantConfig: registry.context.tenantConfig,
2414
2525
  plugins: registry.context.plugins
2415
2526
  });
2416
- summary.validationByScope[scope] = validation;
2527
+ summary.validationByScope[scope] = initializePersistent ? filterValidationForBootstrapSystems(validation, summary.bootstrapSystemsByScope[scope].runnable) : validation;
2417
2528
  if (checkConnections) {
2418
2529
  progress(`Checking provider connectivity for ${scope}...`);
2419
2530
  summary.connectionChecks.push(await checkTreeseedProviderConnections({ tenantRoot, scope, env: seedValues }));
@@ -2436,7 +2547,10 @@ async function finalizeTreeseedConfig({
2436
2547
  summary.validationByScope[scope],
2437
2548
  summary.connectionChecks.find((report) => report.scope === scope)?.checks ?? [],
2438
2549
  scopeSeedValues[scope],
2439
- { includeReconcileStatus: !initializePersistent }
2550
+ {
2551
+ includeReconcileStatus: initializePersistent && summary.bootstrapSystemsByScope[scope].runnable.some((system) => system !== "github"),
2552
+ systems: summary.bootstrapSystemsByScope[scope].runnable.filter((system) => system !== "github")
2553
+ }
2440
2554
  );
2441
2555
  }
2442
2556
  const invalidScopes = scopes.filter((scope) => summary.validationByScope[scope]?.ok !== true);
@@ -2449,39 +2563,66 @@ async function finalizeTreeseedConfig({
2449
2563
  }
2450
2564
  progress("Syncing managed service settings from treeseed.site.yaml...");
2451
2565
  syncManagedServiceSettingsFromDeployConfig(tenantRoot);
2566
+ const githubSelected = scopes.some((scope) => scope !== "local" && summary.bootstrapSystemsByScope[scope].runnable.includes("github"));
2567
+ let githubRepository = maybeResolveGitHubRepositorySlug(tenantRoot);
2568
+ if (githubSelected && initializePersistent) {
2569
+ const localSeedValues = collectTreeseedConfigSeedValues(tenantRoot, "local", env);
2570
+ const localSuggestedValues = getTreeseedEnvironmentSuggestedValues({
2571
+ scope: "local",
2572
+ purpose: "config",
2573
+ deployConfig: registry.context.deployConfig,
2574
+ tenantConfig: registry.context.tenantConfig,
2575
+ plugins: registry.context.plugins,
2576
+ values: localSeedValues
2577
+ });
2578
+ const repositoryBootstrap = await ensureGitHubBootstrapRepository(tenantRoot, {
2579
+ values: {
2580
+ ...localSuggestedValues,
2581
+ ...localSeedValues
2582
+ },
2583
+ defaultName: registry.context.deployConfig.slug,
2584
+ onProgress: (line) => progress(line)
2585
+ });
2586
+ summary.githubRepository = repositoryBootstrap;
2587
+ githubRepository = repositoryBootstrap.repository;
2588
+ }
2452
2589
  if (initializePersistent) {
2453
2590
  for (const scope of scopes) {
2454
2591
  if (scope === "local") {
2455
2592
  continue;
2456
2593
  }
2457
- progress(`Deriving desired units for ${scope}...`);
2458
- const initialized = await reconcileTreeseedTarget({
2459
- tenantRoot,
2460
- target: createPersistentDeployTarget(scope),
2461
- env: scopeSeedValues[scope],
2462
- write: progress
2463
- });
2464
- summary.reconciled.push({
2465
- scope,
2466
- target: scope,
2467
- units: initialized.units.length,
2468
- actions: initialized.results.map((result) => ({
2469
- unitId: result.unit.unitId,
2470
- unitType: result.unit.unitType,
2471
- provider: result.unit.provider,
2472
- action: result.action,
2473
- verified: result.verification?.verified === true,
2474
- missing: result.verification?.missing ?? [],
2475
- drifted: result.verification?.drifted ?? []
2476
- }))
2477
- });
2478
- if (scope === "staging") {
2479
- progress(`Ensuring ${STAGING_BRANCH} exists on origin from ${PRODUCTION_BRANCH}...`);
2480
- const repository = maybeResolveGitHubRepositorySlug(tenantRoot);
2481
- if (!repository) {
2594
+ const selection = summary.bootstrapSystemsByScope[scope];
2595
+ const reconcileSystems = selection.runnable.filter((system) => system !== "github");
2596
+ if (reconcileSystems.length > 0) {
2597
+ progress(`[${scope}][bootstrap][plan] Deriving desired units for ${reconcileSystems.join(", ")}...`);
2598
+ const initialized = await reconcileTreeseedTarget({
2599
+ tenantRoot,
2600
+ target: createPersistentDeployTarget(scope),
2601
+ env: scopeSeedValues[scope],
2602
+ systems: reconcileSystems,
2603
+ write: (line) => progress(`[${scope}][reconcile] ${line}`)
2604
+ });
2605
+ summary.reconciled.push({
2606
+ scope,
2607
+ target: scope,
2608
+ units: initialized.units.length,
2609
+ actions: initialized.results.map((result) => ({
2610
+ unitId: result.unit.unitId,
2611
+ unitType: result.unit.unitType,
2612
+ provider: result.unit.provider,
2613
+ action: result.action,
2614
+ verified: result.verification?.verified === true,
2615
+ missing: result.verification?.missing ?? [],
2616
+ drifted: result.verification?.drifted ?? []
2617
+ }))
2618
+ });
2619
+ }
2620
+ if (scope === "staging" && selection.runnable.includes("github")) {
2621
+ progress(`[${scope}][github][branch] Ensuring ${STAGING_BRANCH} exists on origin from ${PRODUCTION_BRANCH}...`);
2622
+ if (!githubRepository) {
2482
2623
  throw new Error("Unable to determine the GitHub repository from the origin remote for staging branch bootstrap.");
2483
2624
  }
2484
- const branchBootstrap = await ensureGitHubBranchFromBase(repository, STAGING_BRANCH, {
2625
+ const branchBootstrap = await ensureGitHubBranchFromBase(githubRepository, STAGING_BRANCH, {
2485
2626
  baseBranch: PRODUCTION_BRANCH,
2486
2627
  client: createGitHubApiClient({
2487
2628
  env: scopeSeedValues[scope]
@@ -2493,57 +2634,88 @@ async function finalizeTreeseedConfig({
2493
2634
  result: {}
2494
2635
  });
2495
2636
  }
2496
- progress(`Deploying ${scope}...`);
2497
- applyTreeseedEnvironmentToProcess({ tenantRoot, scope, override: true });
2498
- process.env.TREESEED_RAILWAY_WORKSPACE = process.env.TREESEED_RAILWAY_WORKSPACE || scopeSeedValues[scope].TREESEED_RAILWAY_WORKSPACE || resolveRailwayWorkspace(scopeSeedValues[scope]);
2499
- const { deployProjectPlatform } = await import("./project-platform.js");
2500
- const deployResult = await deployProjectPlatform({
2501
- tenantRoot,
2502
- scope,
2503
- skipProvision: true
2504
- });
2505
- const deployEntry = summary.deployed.find((entry) => entry.scope === scope);
2506
- if (deployEntry) {
2507
- deployEntry.result = deployResult;
2508
- } else {
2509
- summary.deployed.push({
2637
+ const deploySystems = selection.runnable.filter((system) => system === "data" || system === "web" || system === "api" || system === "agents");
2638
+ if (deploySystems.length > 0) {
2639
+ progress(`[${scope}][bootstrap][deploy] Deploying ${deploySystems.join(", ")}...`);
2640
+ applyTreeseedEnvironmentToProcess({ tenantRoot, scope, override: true });
2641
+ process.env.TREESEED_RAILWAY_WORKSPACE = process.env.TREESEED_RAILWAY_WORKSPACE || scopeSeedValues[scope].TREESEED_RAILWAY_WORKSPACE || resolveRailwayWorkspace(scopeSeedValues[scope]);
2642
+ const { deployProjectPlatform } = await import("./project-platform.js");
2643
+ const deployResult = await deployProjectPlatform({
2644
+ tenantRoot,
2510
2645
  scope,
2511
- branchBootstrap: null,
2512
- result: deployResult
2646
+ skipProvision: true,
2647
+ bootstrapSystems: deploySystems,
2648
+ bootstrapExecution,
2649
+ env: scopeSeedValues[scope],
2650
+ write: (line, stream) => progress(line, stream)
2513
2651
  });
2652
+ const deployEntry = summary.deployed.find((entry) => entry.scope === scope);
2653
+ if (deployEntry) {
2654
+ deployEntry.result = deployResult;
2655
+ } else {
2656
+ summary.deployed.push({
2657
+ scope,
2658
+ branchBootstrap: null,
2659
+ result: deployResult
2660
+ });
2661
+ }
2662
+ progress(`[${scope}][bootstrap][verify] Re-verifying after deployment...`);
2663
+ const finalized = await reconcileTreeseedTarget({
2664
+ tenantRoot,
2665
+ target: createPersistentDeployTarget(scope),
2666
+ env: scopeSeedValues[scope],
2667
+ systems: reconcileSystems,
2668
+ write: (line) => progress(`[${scope}][verify] ${line}`)
2669
+ });
2670
+ const index = summary.reconciled.findIndex((entry) => entry.scope === scope);
2671
+ const nextSummary = {
2672
+ scope,
2673
+ target: scope,
2674
+ units: finalized.units.length,
2675
+ actions: finalized.results.map((result) => ({
2676
+ unitId: result.unit.unitId,
2677
+ unitType: result.unit.unitType,
2678
+ provider: result.unit.provider,
2679
+ action: result.action,
2680
+ verified: result.verification?.verified === true,
2681
+ missing: result.verification?.missing ?? [],
2682
+ drifted: result.verification?.drifted ?? []
2683
+ }))
2684
+ };
2685
+ if (index >= 0) {
2686
+ summary.reconciled[index] = nextSummary;
2687
+ } else {
2688
+ summary.reconciled.push(nextSummary);
2689
+ }
2514
2690
  }
2515
- progress(`Re-verifying ${scope} after deployment...`);
2516
- const finalized = await reconcileTreeseedTarget({
2691
+ }
2692
+ }
2693
+ if (sync === "github" || sync === "all") {
2694
+ const githubScopes = scopes.filter((scope) => scope !== "local" && summary.bootstrapSystemsByScope[scope].runnable.includes("github"));
2695
+ const syncScope = async (scope) => {
2696
+ progress(`[${scope}][github][sync] Syncing GitHub environment...`);
2697
+ return await syncTreeseedGitHubEnvironment({
2517
2698
  tenantRoot,
2518
- target: createPersistentDeployTarget(scope),
2519
- env: scopeSeedValues[scope],
2520
- write: progress
2521
- });
2522
- const index = summary.reconciled.findIndex((entry) => entry.scope === scope);
2523
- const nextSummary = {
2524
2699
  scope,
2525
- target: scope,
2526
- units: finalized.units.length,
2527
- actions: finalized.results.map((result) => ({
2528
- unitId: result.unit.unitId,
2529
- unitType: result.unit.unitType,
2530
- provider: result.unit.provider,
2531
- action: result.action,
2532
- verified: result.verification?.verified === true,
2533
- missing: result.verification?.missing ?? [],
2534
- drifted: result.verification?.drifted ?? []
2535
- }))
2536
- };
2537
- if (index >= 0) {
2538
- summary.reconciled[index] = nextSummary;
2539
- } else {
2540
- summary.reconciled.push(nextSummary);
2700
+ repository: githubRepository,
2701
+ execution: bootstrapExecution,
2702
+ onProgress: progress
2703
+ });
2704
+ };
2705
+ const githubResults = [];
2706
+ if (bootstrapExecution === "sequential") {
2707
+ for (const scope of githubScopes) {
2708
+ githubResults.push(await syncScope(scope));
2541
2709
  }
2710
+ } else {
2711
+ githubResults.push(...await Promise.all(githubScopes.map((scope) => syncScope(scope))));
2542
2712
  }
2543
- }
2544
- if (sync === "github" || sync === "all") {
2545
- progress(`Syncing GitHub environment for ${scopes.at(-1) ?? "prod"}...`);
2546
- summary.synced.github = await syncTreeseedGitHubEnvironment({ tenantRoot, scope: scopes.at(-1) ?? "prod" });
2713
+ summary.synced.github = {
2714
+ scopes: githubResults,
2715
+ repository: githubResults[0]?.repository ?? githubRepository ?? maybeResolveGitHubRepositorySlug(tenantRoot),
2716
+ secrets: githubResults.flatMap((entry) => entry.secrets),
2717
+ variables: githubResults.flatMap((entry) => entry.variables)
2718
+ };
2547
2719
  }
2548
2720
  for (const scope of scopes) {
2549
2721
  const reconciled = summary.reconciled.find((entry) => entry.scope === scope) ?? null;
@@ -2557,7 +2729,11 @@ async function finalizeTreeseedConfig({
2557
2729
  scope,
2558
2730
  summary.validationByScope[scope],
2559
2731
  summary.connectionChecks.find((report) => report.scope === scope)?.checks ?? [],
2560
- env
2732
+ scopeSeedValues[scope],
2733
+ {
2734
+ includeReconcileStatus: initializePersistent && summary.bootstrapSystemsByScope[scope].runnable.some((system) => system !== "github"),
2735
+ systems: summary.bootstrapSystemsByScope[scope].runnable.filter((system) => system !== "github")
2736
+ }
2561
2737
  );
2562
2738
  }
2563
2739
  return summary;
@@ -63,18 +63,52 @@ export declare function listGitHubRepositoryVariableNames(repository: string | {
63
63
  }, { client }?: {
64
64
  client?: GitHubApiClient;
65
65
  }): Promise<Set<string>>;
66
+ export declare function ensureGitHubActionsEnvironment(repository: string | {
67
+ owner: string;
68
+ name: string;
69
+ }, environmentName: string, { client, branchName, }?: {
70
+ client?: GitHubApiClient;
71
+ branchName?: string | null;
72
+ }): Promise<{
73
+ repository: string;
74
+ environment: string;
75
+ }>;
76
+ export declare function listGitHubEnvironmentSecretNames(repository: string | {
77
+ owner: string;
78
+ name: string;
79
+ }, environmentName: string, { client }?: {
80
+ client?: GitHubApiClient;
81
+ }): Promise<Set<string>>;
82
+ export declare function listGitHubEnvironmentVariableNames(repository: string | {
83
+ owner: string;
84
+ name: string;
85
+ }, environmentName: string, { client }?: {
86
+ client?: GitHubApiClient;
87
+ }): Promise<Set<string>>;
66
88
  export declare function upsertGitHubRepositorySecret(repository: string | {
67
89
  owner: string;
68
90
  name: string;
69
91
  }, name: string, value: string, { client }?: {
70
92
  client?: GitHubApiClient;
71
93
  }): Promise<void>;
94
+ export declare function upsertGitHubEnvironmentSecret(repository: string | {
95
+ owner: string;
96
+ name: string;
97
+ }, environmentName: string, name: string, value: string, { client }?: {
98
+ client?: GitHubApiClient;
99
+ }): Promise<void>;
72
100
  export declare function upsertGitHubRepositoryVariable(repository: string | {
73
101
  owner: string;
74
102
  name: string;
75
103
  }, name: string, value: string, { client }?: {
76
104
  client?: GitHubApiClient;
77
105
  }): Promise<void>;
106
+ export declare function upsertGitHubEnvironmentVariable(repository: string | {
107
+ owner: string;
108
+ name: string;
109
+ }, environmentName: string, name: string, value: string, { client }?: {
110
+ client?: GitHubApiClient;
111
+ }): Promise<void>;
78
112
  export declare function upsertGitHubRepositoryVariableWithGhCli(repository: string | {
79
113
  owner: string;
80
114
  name: string;