@treeseed/sdk 0.12.20 → 0.12.22

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.
@@ -54,6 +54,7 @@ export declare class D1AuthProvider implements ApiAuthProvider {
54
54
  }>;
55
55
  createUser(input: {
56
56
  email?: string | null;
57
+ username?: string | null;
57
58
  displayName?: string | null;
58
59
  metadata?: Record<string, unknown>;
59
60
  }): Promise<{
@@ -31,6 +31,8 @@ export declare class D1AuthStore {
31
31
  private loadUser;
32
32
  private loadIdentityByProvider;
33
33
  private loadUserByVerifiedEmail;
34
+ private loadUserByUsername;
35
+ private canAdoptUsernameMatch;
34
36
  private rolesForUser;
35
37
  private permissionsForUser;
36
38
  private permissionsForRoles;
@@ -250,6 +250,20 @@ class D1AuthStore {
250
250
  [email]
251
251
  );
252
252
  }
253
+ async loadUserByUsername(username) {
254
+ return this.first(
255
+ `SELECT * FROM users WHERE LOWER(username) = LOWER(?) AND status = 'active' LIMIT 1`,
256
+ [username]
257
+ );
258
+ }
259
+ canAdoptUsernameMatch(identity, user) {
260
+ if (!user?.id || !identity.username) return false;
261
+ const profile = identity.profile && typeof identity.profile === "object" ? identity.profile : {};
262
+ if (identity.provider === "acceptance" || profile.acceptance === true) return true;
263
+ const existingEmail = typeof user.email === "string" ? user.email.trim().toLowerCase() : "";
264
+ const requestedEmail = typeof identity.email === "string" ? identity.email.trim().toLowerCase() : "";
265
+ return Boolean(requestedEmail && existingEmail && requestedEmail === existingEmail && identity.emailVerified);
266
+ }
253
267
  async rolesForUser(userId) {
254
268
  const rows = await this.all(
255
269
  `SELECT roles.key AS key
@@ -381,7 +395,9 @@ class D1AuthStore {
381
395
  const existingIdentity = await this.loadIdentityByProvider(identity.provider, identity.providerSubject);
382
396
  let userId = existingIdentity?.user_id;
383
397
  if (!userId) {
384
- const linkedUser = identity.email && identity.emailVerified ? await this.loadUserByVerifiedEmail(identity.email) : null;
398
+ const emailLinkedUser = identity.email && identity.emailVerified ? await this.loadUserByVerifiedEmail(identity.email) : null;
399
+ const usernameLinkedUser = !emailLinkedUser && identity.username ? await this.loadUserByUsername(identity.username) : null;
400
+ const linkedUser = emailLinkedUser ?? (this.canAdoptUsernameMatch(identity, usernameLinkedUser) ? usernameLinkedUser : null);
385
401
  userId = linkedUser?.id ?? randomUUID();
386
402
  if (linkedUser) {
387
403
  await this.run(
@@ -294,7 +294,8 @@ export declare function queueName(entry: any): any;
294
294
  export declare function queueId(entry: any): any;
295
295
  export declare function hasProvisionedCloudflareResources(state: any): boolean;
296
296
  export declare function purgeSourcePageCaches(tenantRoot: any, options?: {}): {
297
- skipped: any;
297
+ skipped: boolean;
298
+ reason: string;
298
299
  urls: (string | null)[];
299
300
  results: never[];
300
301
  } | {
@@ -305,9 +306,11 @@ export declare function purgeSourcePageCaches(tenantRoot: any, options?: {}): {
305
306
  success: boolean;
306
307
  }[];
307
308
  skipped?: undefined;
309
+ reason?: undefined;
308
310
  };
309
311
  export declare function purgePublishedContentCaches(tenantRoot: any, urls: any, options?: {}): {
310
- skipped: any;
312
+ skipped: boolean;
313
+ reason: string;
311
314
  urls: any;
312
315
  results: never[];
313
316
  } | {
@@ -318,6 +321,7 @@ export declare function purgePublishedContentCaches(tenantRoot: any, urls: any,
318
321
  success: boolean;
319
322
  }[];
320
323
  skipped?: undefined;
324
+ reason?: undefined;
321
325
  };
322
326
  export declare function resolveConfiguredCloudflareAccountId(deployConfig: any): any;
323
327
  export declare function collectMissingDeployInputs(tenantRoot: any): {
@@ -1683,19 +1683,30 @@ function recordCachePurgeResult(targetState, results, error = null) {
1683
1683
  targetState.purgeCount = Array.isArray(results) ? results.reduce((sum, result) => sum + (result?.count ?? 0), 0) : 0;
1684
1684
  targetState.lastError = null;
1685
1685
  }
1686
+ function resolveCloudflareCachePurgeEnv(options = {}) {
1687
+ const env = options.env ?? {};
1688
+ const token = env.TREESEED_CLOUDFLARE_API_TOKEN ?? env.CLOUDFLARE_API_TOKEN ?? process.env.TREESEED_CLOUDFLARE_API_TOKEN ?? process.env.CLOUDFLARE_API_TOKEN;
1689
+ return token ? { ...env, TREESEED_CLOUDFLARE_API_TOKEN: token, CLOUDFLARE_API_TOKEN: token } : null;
1690
+ }
1686
1691
  function purgeSourcePageCaches(tenantRoot, options = {}) {
1687
1692
  const target = normalizeTarget(options.scope ?? options.target ?? "prod");
1688
1693
  const deployConfig = loadTenantDeployConfig(tenantRoot);
1689
1694
  const state = loadDeployState(tenantRoot, deployConfig, { target });
1690
1695
  const urls = resolveSourcePagePurgeUrls(deployConfig);
1691
- if ((options.dryRun ?? false) || urls.length === 0 || !process.env.TREESEED_CLOUDFLARE_API_TOKEN) {
1696
+ const env = resolveCloudflareCachePurgeEnv(options);
1697
+ if ((options.dryRun ?? false) || urls.length === 0 || !env) {
1692
1698
  recordCachePurgeResult(state.webCache.deployPurge, urls.map((url) => ({ count: url ? 1 : 0 })));
1693
1699
  writeDeployState(tenantRoot, state, { target });
1694
- return { skipped: options.dryRun ?? false, urls, results: [] };
1700
+ return {
1701
+ skipped: true,
1702
+ reason: options.dryRun ? "dry_run" : urls.length === 0 ? "no_urls" : "missing_cloudflare_token",
1703
+ urls,
1704
+ results: []
1705
+ };
1695
1706
  }
1696
1707
  try {
1697
1708
  const results = purgeCloudflareCacheByUrls(urls, deployConfig, {
1698
- env: { CLOUDFLARE_API_TOKEN: process.env.TREESEED_CLOUDFLARE_API_TOKEN }
1709
+ env
1699
1710
  });
1700
1711
  recordCachePurgeResult(state.webCache.deployPurge, results);
1701
1712
  writeDeployState(tenantRoot, state, { target });
@@ -1710,14 +1721,20 @@ function purgePublishedContentCaches(tenantRoot, urls, options = {}) {
1710
1721
  const target = normalizeTarget(options.scope ?? options.target ?? "prod");
1711
1722
  const deployConfig = loadTenantDeployConfig(tenantRoot);
1712
1723
  const state = loadDeployState(tenantRoot, deployConfig, { target });
1713
- if ((options.dryRun ?? false) || !urls?.length || !process.env.TREESEED_CLOUDFLARE_API_TOKEN) {
1724
+ const env = resolveCloudflareCachePurgeEnv(options);
1725
+ if ((options.dryRun ?? false) || !urls?.length || !env) {
1714
1726
  recordCachePurgeResult(state.webCache.contentPurge, (urls ?? []).map((url) => ({ count: url ? 1 : 0 })));
1715
1727
  writeDeployState(tenantRoot, state, { target });
1716
- return { skipped: options.dryRun ?? false, urls: urls ?? [], results: [] };
1728
+ return {
1729
+ skipped: true,
1730
+ reason: options.dryRun ? "dry_run" : !urls?.length ? "no_urls" : "missing_cloudflare_token",
1731
+ urls: urls ?? [],
1732
+ results: []
1733
+ };
1717
1734
  }
1718
1735
  try {
1719
1736
  const results = purgeCloudflareCacheByUrls(urls, deployConfig, {
1720
- env: { CLOUDFLARE_API_TOKEN: process.env.TREESEED_CLOUDFLARE_API_TOKEN }
1737
+ env
1721
1738
  });
1722
1739
  recordCachePurgeResult(state.webCache.contentPurge, results);
1723
1740
  writeDeployState(tenantRoot, state, { target });
@@ -3657,8 +3674,14 @@ function finalizeDeploymentState(tenantRoot, options = {}) {
3657
3674
  writeDeployState(tenantRoot, state, { target });
3658
3675
  if (target.kind === "persistent") {
3659
3676
  try {
3660
- purgeSourcePageCaches(tenantRoot, { target });
3661
- } catch {
3677
+ const purgeResult = purgeSourcePageCaches(tenantRoot, { target, env: options.env });
3678
+ if (target.scope === "prod" && purgeResult?.skipped) {
3679
+ throw new Error(`Production source-page cache purge was skipped: ${purgeResult.reason ?? "unknown"}.`);
3680
+ }
3681
+ } catch (error) {
3682
+ if (target.scope === "prod") {
3683
+ throw error;
3684
+ }
3662
3685
  }
3663
3686
  return loadDeployState(tenantRoot, deployConfig, { target });
3664
3687
  }
@@ -778,7 +778,27 @@ async function waitForGitHubWorkflowRunCompletion(repository, {
778
778
  });
779
779
  monitorErrorStartedAt = null;
780
780
  lastMonitorError = null;
781
- const match = listed.data.workflow_runs.map((run) => normalizeWorkflowRun(run)).find((run) => (!headSha || run.headSha === headSha) && (!branch || run.headBranch === branch));
781
+ const matchingRuns = listed.data.workflow_runs.map((run) => normalizeWorkflowRun(run)).filter((run) => (!headSha || run.headSha === headSha) && (!branch || run.headBranch === branch));
782
+ const failedMatch = matchingRuns.find((run) => run.status === "completed" && run.conclusion !== "success");
783
+ if (failedMatch?.id) {
784
+ const failedJobs = await listWorkflowJobsForProgress(client, owner, name, failedMatch.id);
785
+ emitProgress("completed", failedMatch, failedJobs);
786
+ return {
787
+ status: "completed",
788
+ repository: `${owner}/${name}`,
789
+ workflow,
790
+ runId: failedMatch.id,
791
+ headSha: failedMatch.headSha,
792
+ branch: failedMatch.headBranch,
793
+ createdAt: failedMatch.createdAt,
794
+ updatedAt: failedMatch.updatedAt,
795
+ conclusion: failedMatch.conclusion,
796
+ url: failedMatch.url,
797
+ jobs: failedJobs,
798
+ failedJobs: failedJobs.filter((job) => job.conclusion && job.conclusion !== "success" && job.conclusion !== "skipped")
799
+ };
800
+ }
801
+ const match = matchingRuns[0];
782
802
  if (!match?.id) {
783
803
  emitProgress("waiting");
784
804
  if (dispatchIfMissing && branch && !dispatchedMissingRun && Date.now() - startedAt >= dispatchAfterSeconds * 1e3) {
@@ -160,11 +160,12 @@ function collectTreeseedHostedServiceChecks(options) {
160
160
  const selectedAppId = options.appId?.trim() || null;
161
161
  const applications = discoverTreeseedApplications(tenantRoot);
162
162
  const selectedApplication = selectedAppId ? applications.find((application) => application.id === selectedAppId || application.relativeRoot === selectedAppId) : null;
163
+ const selectedService = (serviceKey) => selectedServiceKeys.size === 0 || selectedServiceKeys.has(serviceKey);
163
164
  const workspaceHasApiApplication = applications.some(
164
165
  (application) => application.roles.includes("api") || application.config.surfaces?.api?.enabled === true || application.config.services?.api?.enabled !== false && Boolean(application.config.services?.api)
165
166
  );
166
- const includeWeb = !selectedAppId || selectedAppId === "web" || selectedApplication?.roles.includes("web") === true;
167
- const includeApi = !selectedAppId || selectedAppId === "api" || selectedApplication?.roles.includes("api") === true;
167
+ const includeWeb = selectedService("web") && (!selectedAppId || selectedAppId === "web" || selectedApplication?.roles.includes("web") === true);
168
+ const includeApi = selectedService("api") && (!selectedAppId || selectedAppId === "api" || selectedApplication?.roles.includes("api") === true);
168
169
  const selectedAppHasApi = Boolean(
169
170
  selectedApplication?.roles.includes("api") || selectedApplication?.config.surfaces?.api?.enabled === true || selectedApplication?.config.services?.api?.enabled !== false && selectedApplication?.config.services?.api || selectedAppId === "web" && workspaceHasApiApplication || selectedAppId === "web" && (deployConfig.surfaces?.api?.enabled === true || deployConfig.services?.api?.enabled !== false && deployConfig.services?.api)
170
171
  );
@@ -190,12 +191,13 @@ function collectTreeseedHostedServiceChecks(options) {
190
191
  if (domain) {
191
192
  const url = String(domain).startsWith("http") ? String(domain) : `https://${domain}`;
192
193
  checks.push({ ...httpStatus(url, options), id: `http:${selectedWeb.appId}`, serviceKey: selectedWeb.appId, serviceType: "web", description: "Web public URL responds." });
193
- if (!selectedAppId || selectedAppHasApi) {
194
+ if (selectedServiceKeys.size === 0 && (!selectedAppId || selectedAppHasApi)) {
194
195
  checks.push({ ...httpStatus(`${url.replace(/\/+$/u, "")}/v1/healthz`, options), id: `http:${selectedWeb.appId}:v1-healthz`, serviceKey: selectedWeb.appId, serviceType: "web", description: "Web proxy reaches API health." });
195
196
  }
196
197
  }
197
198
  }
198
199
  for (const [surfaceKey, surface] of Object.entries(deployConfig.surfaces ?? {})) {
200
+ if (!selectedService(surfaceKey)) continue;
199
201
  if (selectedAppId === "api" && surfaceKey === "web") continue;
200
202
  if (surface && typeof surface === "object" && surface.enabled !== false && surface.provider && !["cloudflare", "railway"].includes(surface.provider)) {
201
203
  checks.push(check({
@@ -1,3 +1,4 @@
1
+ import { resolve } from "node:path";
1
2
  import { loadTreeseedPlatformConfig } from "../../platform/config.js";
2
3
  import { resolveTreeseedLaunchEnvironment } from "./config-runtime.js";
3
4
  import {
@@ -21,7 +22,7 @@ const DEFAULT_RETRY_INTERVAL_MS = 1500;
21
22
  const DEFAULT_RAILWAY_DEPLOYMENT_SETTLE_ATTEMPTS = 60;
22
23
  const DEFAULT_RAILWAY_DEPLOYMENT_SETTLE_INTERVAL_MS = 5e3;
23
24
  function sleep(ms) {
24
- return new Promise((resolve) => setTimeout(resolve, ms));
25
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
25
26
  }
26
27
  function liveCheckErrorMessage(error, fallback) {
27
28
  const message = error instanceof Error ? error.message : String(error ?? "");
@@ -125,12 +126,22 @@ function selectedServiceKeySet(options) {
125
126
  function serviceIsSelected(selected, serviceKey) {
126
127
  return selected.size === 0 || selected.has(serviceKey);
127
128
  }
129
+ function serviceMatchesAppSelection(service, tenantRoot, appId, applications) {
130
+ if (!appId) return true;
131
+ if (service.application?.id === appId) return true;
132
+ if (!service.application) {
133
+ const rootApplication = applications.find((application) => application.root === tenantRoot);
134
+ return rootApplication?.id === appId || rootApplication?.relativeRoot === appId;
135
+ }
136
+ return false;
137
+ }
128
138
  function treeseedDatabaseDescriptors(tenantRoot, options) {
129
139
  const descriptors = [];
130
140
  const rootConfig = loadTreeseedPlatformConfig({ tenantRoot, environment: options.target, env: process.env }).deployConfig;
141
+ const applications = discoverTreeseedApplications(tenantRoot);
131
142
  const candidates = [
132
- { applicationId: "web", applicationRoot: tenantRoot, config: rootConfig },
133
- ...discoverTreeseedApplications(tenantRoot).map((application) => ({
143
+ { applicationId: null, applicationRoot: tenantRoot, config: rootConfig },
144
+ ...applications.map((application) => ({
134
145
  applicationId: application.id,
135
146
  applicationRoot: application.root,
136
147
  config: application.config
@@ -152,8 +163,9 @@ function treeseedDatabaseDescriptors(tenantRoot, options) {
152
163
  return descriptors;
153
164
  }
154
165
  async function verifyRailwayPostgresTopology(input) {
166
+ const descriptorRoot = resolve(input.descriptor.applicationRoot);
155
167
  const ownerService = input.configuredServices.find(
156
- (service) => ["api", "operationsRunner"].includes(service.key) && (!input.descriptor.applicationId || service.application?.id === input.descriptor.applicationId)
168
+ (service) => ["api", "operationsRunner"].includes(service.key) && (!input.descriptor.applicationId || service.application?.id === input.descriptor.applicationId || service.application?.root === descriptorRoot || !service.application && resolve(service.rootDir) === descriptorRoot)
157
169
  );
158
170
  if (!ownerService) {
159
171
  input.issues.push(`${input.descriptor.serviceName}: no Railway API or operations runner service is configured to own the database.`);
@@ -215,10 +227,11 @@ async function collectRailwayObservations(options) {
215
227
  const inspectedVolumeScopes = /* @__PURE__ */ new Set();
216
228
  const inspectedRunnerScopes = /* @__PURE__ */ new Set();
217
229
  const selectedServiceKeys = selectedServiceKeySet(options);
230
+ const applications = discoverTreeseedApplications(options.tenantRoot);
218
231
  try {
219
232
  const workspace = await resolveRailwayWorkspaceContext({ env: options.env, fetchImpl: options.fetchImpl });
220
233
  const projects = await listRailwayProjects({ workspaceId: workspace.id, env: options.env, fetchImpl: options.fetchImpl });
221
- const configuredServices = configuredRailwayServices(options.tenantRoot, options.target, options.env).filter((entry) => !options.appId || entry.application?.id === options.appId).filter((entry) => serviceIsSelected(selectedServiceKeys, entry.key));
234
+ const configuredServices = configuredRailwayServices(options.tenantRoot, options.target, options.env).filter((entry) => serviceMatchesAppSelection(entry, options.tenantRoot, options.appId, applications)).filter((entry) => serviceIsSelected(selectedServiceKeys, entry.key));
222
235
  if (selectedServiceKeys.size === 0 || selectedServiceKeys.has("api") || selectedServiceKeys.has("operationsRunner")) {
223
236
  for (const descriptor of treeseedDatabaseDescriptors(options.tenantRoot, options)) {
224
237
  await verifyRailwayPostgresTopology({ descriptor, configuredServices, projects, options, issues });
@@ -422,14 +435,16 @@ async function collectHttpObservations(options) {
422
435
  const urls = /* @__PURE__ */ new Set();
423
436
  const fallbacks = /* @__PURE__ */ new Map();
424
437
  const selectedServiceKeys = selectedServiceKeySet(options);
425
- const selectedApplication = options.appId ? discoverTreeseedApplications(options.tenantRoot).find((application) => application.id === options.appId || application.relativeRoot === options.appId) : null;
426
- if (!options.appId || options.appId === "web" || selectedApplication?.roles.includes("web")) {
438
+ const applications = discoverTreeseedApplications(options.tenantRoot);
439
+ const selectedApplication = options.appId ? applications.find((application) => application.id === options.appId || application.relativeRoot === options.appId) : null;
440
+ const webHttpSelected = selectedServiceKeys.size === 0 || selectedServiceKeys.has("web");
441
+ if (webHttpSelected && (!options.appId || options.appId === "web" || selectedApplication?.roles.includes("web"))) {
427
442
  const webConfig = selectedWebConfig(deployConfig, selectedApplication);
428
443
  const webDomain = webConfig.surfaces?.web?.environments?.[options.target]?.domain ?? webConfig.surfaces?.web?.publicBaseUrl ?? webConfig.siteUrl;
429
444
  const webUrl = urlForDomain(webDomain);
430
445
  if (webUrl) {
431
446
  urls.add(webUrl);
432
- if (!options.appId || options.appId === "web" || selectedApplication?.roles.includes("api")) {
447
+ if (selectedServiceKeys.size === 0 && (!options.appId || options.appId === "web" || selectedApplication?.roles.includes("api"))) {
433
448
  urls.add(`${webUrl}/v1/healthz`);
434
449
  }
435
450
  const pagesProjectName = webConfig.cloudflare?.pages?.projectName;
@@ -440,7 +455,7 @@ async function collectHttpObservations(options) {
440
455
  }
441
456
  }
442
457
  }
443
- for (const service of configuredRailwayServices(options.tenantRoot, options.target, options.env).filter((entry) => !options.appId || entry.application?.id === options.appId).filter((entry) => serviceIsSelected(selectedServiceKeys, entry.key))) {
458
+ for (const service of configuredRailwayServices(options.tenantRoot, options.target, options.env).filter((entry) => serviceMatchesAppSelection(entry, options.tenantRoot, options.appId, applications)).filter((entry) => serviceIsSelected(selectedServiceKeys, entry.key))) {
444
459
  const serviceConfig = deployConfig.services?.[service.key];
445
460
  const domain = service.publicBaseUrl ?? serviceConfig?.environments?.[options.target]?.baseUrl ?? serviceConfig?.environments?.[options.target]?.domain ?? (service.key === "api" ? deployConfig.surfaces?.api?.environments?.[options.target]?.domain : null);
446
461
  const baseUrl = urlForDomain(domain);
@@ -196,7 +196,7 @@ function isTransientWranglerOutput(output) {
196
196
  return /fetch failed|timed out|etimedout|econnreset|enetunreach|temporarily unavailable|connectivity issue|internal error|aborted/i.test(output);
197
197
  }
198
198
  async function runPrefixedWranglerWithRetry(tenantRoot, args, {
199
- env = {},
199
+ env: env2 = {},
200
200
  write,
201
201
  prefix
202
202
  }) {
@@ -208,7 +208,7 @@ async function runPrefixedWranglerWithRetry(tenantRoot, args, {
208
208
  }
209
209
  const result = await runPrefixedCommand(wrangler.command, [...wrangler.argsPrefix, ...args], {
210
210
  cwd: tenantRoot,
211
- env,
211
+ env: env2,
212
212
  write,
213
213
  prefix
214
214
  });
@@ -235,7 +235,7 @@ function prepareTenantCloudflareDeploy({
235
235
  target: explicitTarget,
236
236
  dryRun,
237
237
  write,
238
- env = process.env
238
+ env: env2 = process.env
239
239
  }) {
240
240
  const target = explicitTarget ?? createPersistentDeployTarget(scope === "local" ? "staging" : scope);
241
241
  if (scope !== "local") {
@@ -260,7 +260,7 @@ function prepareTenantCloudflareDeploy({
260
260
  pagesBranchName,
261
261
  env: {
262
262
  ...process.env,
263
- ...env,
263
+ ...env2,
264
264
  CLOUDFLARE_ACCOUNT_ID: resolveConfiguredCloudflareAccountId(deployConfig),
265
265
  ...target.kind === "persistent" && target.scope !== "local" ? { TREESEED_CONTENT_SERVING_MODE: "published_runtime" } : {}
266
266
  },
@@ -478,12 +478,12 @@ async function repairHostingAfterSuccessfulDeploy(options, systems) {
478
478
  };
479
479
  }
480
480
  const environment = options.scope === "prod" ? "prod" : "staging";
481
- const env = { ...process.env, ...options.env ?? {} };
481
+ const env2 = { ...process.env, ...options.env ?? {} };
482
482
  const audit = await runTreeseedHostingAudit({
483
483
  tenantRoot: options.tenantRoot,
484
484
  environment,
485
485
  repair: false,
486
- env,
486
+ env: env2,
487
487
  hostKinds: [...hostKinds]
488
488
  });
489
489
  if (audit.ok) {
@@ -494,7 +494,7 @@ async function repairHostingAfterSuccessfulDeploy(options, systems) {
494
494
  tenantRoot: options.tenantRoot,
495
495
  environment,
496
496
  repair: true,
497
- env,
497
+ env: env2,
498
498
  hostKinds: [...hostKinds],
499
499
  write: (line) => options.write?.(`[${environment}][hosting][repair] ${line}`)
500
500
  });
@@ -955,7 +955,7 @@ async function publishContent(options, reporter, publishOptions = {}) {
955
955
  }, manifestFile, uploadOptions);
956
956
  if (contentPurgeUrls.size > 0) {
957
957
  try {
958
- purgePublishedContentCaches(options.tenantRoot, [...contentPurgeUrls].filter(Boolean), { target });
958
+ purgePublishedContentCaches(options.tenantRoot, [...contentPurgeUrls].filter(Boolean), { target, env });
959
959
  } catch {
960
960
  }
961
961
  }
@@ -1020,12 +1020,12 @@ async function provisionProjectPlatform(options) {
1020
1020
  writeWorkflowStatus("provision:resolve-bootstrap-systems");
1021
1021
  const bootstrapSystems = resolveProjectPlatformBootstrapSystems(options, siteConfig);
1022
1022
  const selectedSystems = new Set(bootstrapSystems);
1023
- const env = { ...process.env, ...options.env ?? {} };
1023
+ const env2 = { ...process.env, ...options.env ?? {} };
1024
1024
  writeWorkflowStatus(`provision:reconcile:start systems=${bootstrapSystems.join(",") || "(none)"}`);
1025
1025
  const summary = await timedPhase(timings, "provision:reconcile", () => reconcileTreeseedTarget({
1026
1026
  tenantRoot: options.tenantRoot,
1027
1027
  target,
1028
- env,
1028
+ env: env2,
1029
1029
  systems: bootstrapSystems,
1030
1030
  write: options.write,
1031
1031
  dryRun: options.dryRun
@@ -1036,7 +1036,7 @@ async function provisionProjectPlatform(options) {
1036
1036
  const verification = await timedPhase(timings, "provision:collect-reconcile-status", () => collectTreeseedReconcileStatus({
1037
1037
  tenantRoot: options.tenantRoot,
1038
1038
  target,
1039
- env,
1039
+ env: env2,
1040
1040
  systems: bootstrapSystems
1041
1041
  }));
1042
1042
  writeWorkflowStatus("provision:collect-reconcile-status:done");
@@ -1050,7 +1050,7 @@ async function provisionProjectPlatform(options) {
1050
1050
  writeWorkflowStatus("provision:ensure-wrangler-config:skipped");
1051
1051
  }
1052
1052
  const shouldValidateRailway = selectedSystems.has("api") || selectedSystems.has("agents");
1053
- const railwayValidation = shouldValidateRailway ? options.scope === "local" ? validateRailwayServiceConfiguration(options.tenantRoot, options.scope) : validateRailwayDeployPrerequisites(options.tenantRoot, options.scope, { env }) : { services: [] };
1053
+ const railwayValidation = shouldValidateRailway ? options.scope === "local" ? validateRailwayServiceConfiguration(options.tenantRoot, options.scope) : validateRailwayDeployPrerequisites(options.tenantRoot, options.scope, { env: env2 }) : { services: [] };
1054
1054
  const railwaySchedules = [];
1055
1055
  const railwayScheduleVerification = {
1056
1056
  ok: true,
@@ -1210,7 +1210,7 @@ async function deployProjectPlatform(options) {
1210
1210
  const selectedSystems = new Set(bootstrapSystems);
1211
1211
  const execution = options.bootstrapExecution ?? "parallel";
1212
1212
  const write = options.write;
1213
- const env = { ...process.env, ...options.env ?? {} };
1213
+ const env2 = { ...process.env, ...options.env ?? {} };
1214
1214
  writeWorkflowStatus(`deploy:bootstrap-systems ${bootstrapSystems.join(",") || "(none)"}`);
1215
1215
  writeWorkflowStatus("deploy:report-running");
1216
1216
  await reportDeployment(reporter, {
@@ -1238,7 +1238,7 @@ async function deployProjectPlatform(options) {
1238
1238
  tenantRoot: options.tenantRoot,
1239
1239
  scope: "local",
1240
1240
  dryRun: options.dryRun,
1241
- env,
1241
+ env: env2,
1242
1242
  write
1243
1243
  })
1244
1244
  });
@@ -1248,7 +1248,7 @@ async function deployProjectPlatform(options) {
1248
1248
  scope: options.scope,
1249
1249
  dryRun: options.dryRun,
1250
1250
  write,
1251
- env
1251
+ env: env2
1252
1252
  });
1253
1253
  }
1254
1254
  if (cloudflareContext && selectedSystems.has("data")) {
@@ -1283,11 +1283,11 @@ async function deployProjectPlatform(options) {
1283
1283
  const serviceResultsByKey = /* @__PURE__ */ new Map();
1284
1284
  let selectedRailwayServiceKeys = [];
1285
1285
  if (options.scope !== "local" && (selectedSystems.has("api") || selectedSystems.has("agents"))) {
1286
- const validation = validateRailwayDeployPrerequisites(options.tenantRoot, options.scope, { env });
1286
+ const validation = validateRailwayDeployPrerequisites(options.tenantRoot, options.scope, { env: env2 });
1287
1287
  const selectedServices = validation.services.filter(
1288
1288
  (service) => service.key === "api" ? selectedSystems.has("api") : selectedSystems.has("agents")
1289
1289
  );
1290
- const sequentialRailwayDeploys = String(env.TREESEED_RAILWAY_DEPLOY_SEQUENTIAL ?? "").trim() === "1";
1290
+ const sequentialRailwayDeploys = String(env2.TREESEED_RAILWAY_DEPLOY_SEQUENTIAL ?? "").trim() === "1";
1291
1291
  let previousRailwayDeployNodeId = null;
1292
1292
  for (const service of selectedServices) {
1293
1293
  const system = service.key === "api" ? "api" : "agents";
@@ -1304,7 +1304,7 @@ async function deployProjectPlatform(options) {
1304
1304
  const result = await deployRailwayService(options.tenantRoot, service, {
1305
1305
  dryRun: options.dryRun,
1306
1306
  write,
1307
- env,
1307
+ env: env2,
1308
1308
  prefix: {
1309
1309
  scope: options.scope,
1310
1310
  system,
@@ -1336,7 +1336,7 @@ async function deployProjectPlatform(options) {
1336
1336
  task: "schedules",
1337
1337
  stage: "deploy"
1338
1338
  }, "Reconciling Railway schedules...");
1339
- railwaySchedules = await ensureRailwayScheduledJobs(options.tenantRoot, options.scope, { dryRun: options.dryRun, env });
1339
+ railwaySchedules = await ensureRailwayScheduledJobs(options.tenantRoot, options.scope, { dryRun: options.dryRun, env: env2 });
1340
1340
  railwayScheduleVerification = !options.dryRun ? await verifyRailwayScheduledJobs(options.tenantRoot, options.scope) : { ok: true, checks: railwaySchedules, skipped: true, reason: "dry_run" };
1341
1341
  return {
1342
1342
  service: "railway-schedules",
@@ -1358,7 +1358,8 @@ async function deployProjectPlatform(options) {
1358
1358
  if (options.scope !== "local" && !options.dryRun && (selectedSystems.has("web") || serviceResults.length > 0)) {
1359
1359
  finalizeDeploymentState(options.tenantRoot, {
1360
1360
  target: createPersistentDeployTarget(options.scope),
1361
- serviceResults
1361
+ serviceResults,
1362
+ env: env2
1362
1363
  });
1363
1364
  }
1364
1365
  if (!managesRailwaySchedules || !selectedSystems.has("agents")) {
@@ -1423,7 +1424,7 @@ async function publishProjectContent(options) {
1423
1424
  async function monitorProjectPlatform(options) {
1424
1425
  const timings = [];
1425
1426
  const reporter = resolveReporter(options.tenantRoot, options.reporter);
1426
- const env = { ...process.env, ...options.env ?? {} };
1427
+ const env2 = { ...process.env, ...options.env ?? {} };
1427
1428
  const target = createPersistentDeployTarget(options.scope === "local" ? "staging" : options.scope);
1428
1429
  const siteConfig = loadCliDeployConfig(options.tenantRoot);
1429
1430
  const selectedSystems = new Set(resolveProjectPlatformBootstrapSystems(options, siteConfig));
@@ -1434,7 +1435,7 @@ async function monitorProjectPlatform(options) {
1434
1435
  const apiBaseUrl = resolveImmediateApiProbeUrl(siteConfig, state, target);
1435
1436
  const apiMonitorEndpoints = resolveApiMonitorEndpoints(siteConfig, apiBaseUrl);
1436
1437
  const railwayResourcesPromise = options.scope === "local" || !apiSelected && !agentsSelected ? { ok: true, skipped: true, reason: options.scope === "local" ? "local_scope" : "railway_not_selected" } : timedPhase(timings, "monitor:railway-resources", () => verifyRailwayManagedResources(options.tenantRoot, options.scope, {
1437
- env,
1438
+ env: env2,
1438
1439
  settleDeployments: true,
1439
1440
  onProgress: options.write
1440
1441
  }));
@@ -3877,7 +3877,8 @@ async function syncRailwayEnvironmentForScope(input, { dryRun = false, serviceKe
3877
3877
  services: rendered.serviceNames,
3878
3878
  volumes: rendered.volumeNames,
3879
3879
  database: rendered.databaseName,
3880
- scope
3880
+ scope,
3881
+ serviceSourceModes: Object.fromEntries(effectiveIacInput.services.map((service) => [service.serviceName, service.sourceMode ?? null]))
3881
3882
  });
3882
3883
  if (!validation.ok && effectiveIacInput.database && !effectiveIacInput.database.useNativePostgres && railwayIacPlanDeletesResource(plan.changeSet, effectiveIacInput.database.serviceName)) {
3883
3884
  traceRailwayReconcile(topology.env, "sync:iac-native-postgres-adopt", `${project.name}/${environment.name}:${effectiveIacInput.database.serviceName}`);
@@ -3899,7 +3900,8 @@ async function syncRailwayEnvironmentForScope(input, { dryRun = false, serviceKe
3899
3900
  services: rendered.serviceNames,
3900
3901
  volumes: rendered.volumeNames,
3901
3902
  database: rendered.databaseName,
3902
- scope
3903
+ scope,
3904
+ serviceSourceModes: Object.fromEntries(effectiveIacInput.services.map((service) => [service.serviceName, service.sourceMode ?? null]))
3903
3905
  });
3904
3906
  }
3905
3907
  if (!validation.ok) {
@@ -2,6 +2,7 @@ import {
2
2
  createGitHubApiClient,
3
3
  dispatchGitHubWorkflowRun,
4
4
  ensureGitHubActionsEnvironment,
5
+ formatGitHubWorkflowFailure,
5
6
  getLatestGitHubWorkflowRun,
6
7
  listGitHubEnvironmentSecretNames,
7
8
  listGitHubEnvironmentVariableNames,
@@ -106,6 +107,26 @@ async function dispatchReconcileGitHubWorkflow(input) {
106
107
  branch: input.branch,
107
108
  timeoutSeconds: input.timeoutMs ? Math.ceil(input.timeoutMs / 1e3) : void 0
108
109
  }) : null;
110
+ if (completed && completed.conclusion !== "success") {
111
+ const failedJob = completed.failedJobs?.[0] ?? completed.jobs?.find((job) => job.conclusion && job.conclusion !== "success" && job.conclusion !== "skipped") ?? null;
112
+ const failedStep = failedJob?.steps?.find((step) => step.conclusion && step.conclusion !== "success" && step.conclusion !== "skipped") ?? null;
113
+ const failure = formatGitHubWorkflowFailure({
114
+ repository: input.repository,
115
+ workflow: input.workflow,
116
+ runId: completed.runId,
117
+ runUrl: completed.url,
118
+ conclusion: completed.conclusion,
119
+ failedJobName: failedJob?.name,
120
+ lastActiveStep: failedStep?.name,
121
+ message: `GitHub workflow ${input.workflow} in ${input.repository} completed with conclusion ${completed.conclusion ?? "unknown"}.`,
122
+ resumeSafe: false
123
+ });
124
+ throw new Error([
125
+ failure.summary,
126
+ failure.runUrl ? `Run: ${failure.runUrl}` : null,
127
+ failure.inspectCommand ? `Inspect: ${failure.inspectCommand}` : null
128
+ ].filter(Boolean).join("\n"));
129
+ }
109
130
  return { dispatch, latest, completed };
110
131
  }
111
132
  export {
@@ -60,6 +60,7 @@ export declare function validateRailwayIacChangeSet(changeSet: RailwayChangeSet
60
60
  volumes: string[];
61
61
  database: string | null;
62
62
  scope: string;
63
+ serviceSourceModes?: Record<string, string | null | undefined>;
63
64
  }): RailwayIacValidationResult;
64
65
  export declare function planRailwayIacProject(input: TreeseedRailwayIacProjectInput, rendered?: TreeseedRailwayIacRenderResult): Promise<RailwayIacPlanResponse>;
65
66
  export declare function applyRailwayIacProject(input: TreeseedRailwayIacProjectInput, rendered?: TreeseedRailwayIacRenderResult): Promise<RailwayIacApplyResponse>;
@@ -203,6 +203,28 @@ ${declarations.join("\n")}
203
203
  function changeName(change) {
204
204
  return String(change?.resource?.name ?? change?.previous?.name ?? change?.address ?? change?.path ?? "");
205
205
  }
206
+ function changeFieldText(change) {
207
+ return [
208
+ change?.field,
209
+ change?.path,
210
+ change?.address,
211
+ change?.summary
212
+ ].map((value) => String(value ?? "").toLowerCase()).join(" ");
213
+ }
214
+ function isRailwaySourceChange(change) {
215
+ const field = String(change?.field ?? "").toLowerCase();
216
+ const path = String(change?.path ?? "").toLowerCase();
217
+ const summary = String(change?.summary ?? "").toLowerCase();
218
+ return field === "source" || /\.source\b/u.test(path) || /source/u.test(summary) && !/\b(env|environment|variable|variables)\b/u.test(summary);
219
+ }
220
+ function isRailwayImageSourceChange(change) {
221
+ if (!isRailwaySourceChange(change)) return false;
222
+ return /image|docker-image/u.test(changeFieldText(change));
223
+ }
224
+ function isRailwayGitSourceChange(change) {
225
+ if (!isRailwaySourceChange(change)) return false;
226
+ return /github|repo|branch/u.test(changeFieldText(change));
227
+ }
206
228
  function validateRailwayIacChangeSet(changeSet, desiredNames) {
207
229
  const blockedReasons = [];
208
230
  const destructiveChanges = [];
@@ -210,6 +232,7 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
210
232
  const created = new Set((changeSet?.changes ?? []).filter((change) => change.kind === "resource.create").map((change) => changeName(change)));
211
233
  for (const change of changeSet?.changes ?? []) {
212
234
  const name = changeName(change);
235
+ const sourceMode = desiredNames.serviceSourceModes?.[name] ?? desiredNames.serviceSourceModes?.[name.replace(/^(service|database)\./u, "")] ?? null;
213
236
  if (change.kind === "resource.delete") {
214
237
  destructiveChanges.push(change.summary);
215
238
  blockedReasons.push(`Railway IaC plan would delete resource ${name || change.summary}; hosting reconciliation only updates or creates resources. Use the explicit destroy workflow for deletions.`);
@@ -217,10 +240,10 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
217
240
  blockedReasons.push(`Railway IaC plan would delete desired resource ${name}.`);
218
241
  }
219
242
  }
220
- if (desiredNames.scope === "staging" && change.kind === "resource.update" && /image/iu.test(String(change.summary))) {
243
+ if (desiredNames.scope === "staging" && change.kind === "resource.update" && isRailwayImageSourceChange(change) && (!sourceMode || sourceMode === "image")) {
221
244
  blockedReasons.push(`Railway IaC plan would switch staging resource ${name} to an image source.`);
222
245
  }
223
- if (desiredNames.scope === "prod" && change.kind === "resource.update" && /github|repo|branch/iu.test(String(change.summary))) {
246
+ if (desiredNames.scope === "prod" && change.kind === "resource.update" && isRailwayGitSourceChange(change) && (!sourceMode || sourceMode === "git")) {
224
247
  blockedReasons.push(`Railway IaC plan would switch production resource ${name} to a Git source.`);
225
248
  }
226
249
  }
@@ -1010,7 +1010,10 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
1010
1010
  }[];
1011
1011
  blockers: string[];
1012
1012
  };
1013
- releaseGates: Record<string, unknown>;
1013
+ releaseGates: {
1014
+ workspaceLinks: import("../operations/services/workspace-dependency-mode.js").WorkspaceDependencyModeReport;
1015
+ gates: Record<string, unknown>;
1016
+ };
1014
1017
  workspaceUnlink: import("../operations/services/workspace-dependency-mode.js").WorkspaceDependencyModeReport;
1015
1018
  releaseMetadata: {
1016
1019
  versions: {
@@ -1100,6 +1103,24 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
1100
1103
  publishedArtifacts: {
1101
1104
  checks: PublishedArtifactCheck[];
1102
1105
  };
1106
+ productionPackageDeployWorkflows: {
1107
+ workflowGates: Record<string, unknown>[] | {
1108
+ name: string;
1109
+ repository: string | null;
1110
+ workflow: string;
1111
+ branch: string;
1112
+ headSha: string;
1113
+ status: string;
1114
+ reason: string;
1115
+ conclusion: null;
1116
+ runId: null;
1117
+ url: null;
1118
+ createdAt: null;
1119
+ updatedAt: null;
1120
+ timeoutSeconds: number | null;
1121
+ cached: boolean;
1122
+ }[];
1123
+ };
1103
1124
  productionHosting: {
1104
1125
  status: "skipped";
1105
1126
  reason: string;
@@ -1167,6 +1188,7 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
1167
1188
  releaseBlockingFailures: number;
1168
1189
  };
1169
1190
  };
1191
+ productionWebVerification: Record<string, unknown> | null;
1170
1192
  backMerge: {
1171
1193
  packages: {
1172
1194
  status: string;
@@ -132,6 +132,10 @@ import {
132
132
  import { classifyTreeseedGitMode, runTreeseedGit, runTreeseedGitOk, runTreeseedGitText } from "../operations/services/git-runner.js";
133
133
  import { collectTreeseedDeploymentReadiness } from "../operations/services/deployment-readiness.js";
134
134
  import { collectTreeseedLiveHostedServiceChecks } from "../operations/services/live-hosted-service-checks.js";
135
+ import {
136
+ configuredRailwayServices,
137
+ waitForRailwayManagedDeploymentsSettled
138
+ } from "../operations/services/railway-deploy.js";
135
139
  import { discoverTreeseedApplications } from "../hosting/apps.js";
136
140
  import { compileTreeseedHostingGraph } from "../hosting/graph.js";
137
141
  import { resolveTreeseedWorkflowState } from "../workflow-state.js";
@@ -228,7 +232,7 @@ function readPackageScript(root, packageDir, scriptName) {
228
232
  }
229
233
  function ensureWorkflowWorkspacePackageArtifacts(root, helpers) {
230
234
  const packages = [
231
- { name: "@treeseed/sdk", dir: "packages/sdk", artifacts: ["dist/workflow-support.js", "dist/plugin-default.js", "dist/platform/env.yaml"] },
235
+ { name: "@treeseed/sdk", dir: "packages/sdk", artifacts: ["dist/index.js", "dist/workflow-support.js", "dist/plugin-default.js", "dist/platform/env.yaml"] },
232
236
  { name: "@treeseed/ui", dir: "packages/ui", artifacts: ["dist/index.js"] },
233
237
  { name: "@treeseed/agent", dir: "packages/agent", artifacts: ["dist/api/index.js", "dist/services/manager.js", "dist/provider/runner.js"] },
234
238
  { name: "@treeseed/core", dir: "packages/core", artifacts: ["dist/plugin-default.js"] },
@@ -582,6 +586,23 @@ ${status.blockers.join("\n")}`, {
582
586
  details: { environment, selector, status, reconcile }
583
587
  });
584
588
  }
589
+ const selectedRailwayServiceNames = new Set(graph.units.filter((unit) => unit.host.id === "railway").map((unit) => typeof unit.config.serviceName === "string" ? unit.config.serviceName : null).filter((value) => Boolean(value)));
590
+ const selectedRailwayServices = configuredRailwayServices(root, environment, env).filter((service) => selectedRailwayServiceNames.has(service.serviceName));
591
+ if (selectedRailwayServices.length > 0) {
592
+ const deployments = await waitForRailwayManagedDeploymentsSettled(root, environment, {
593
+ services: selectedRailwayServices,
594
+ env,
595
+ timeoutMs: operation === "release" ? 9e5 : 6e5,
596
+ onProgress: (line, stream) => helpers.write(`[${operation}][railway] ${line}`, stream)
597
+ });
598
+ if (!deployments.ok) {
599
+ const deploymentFailures = deployments.checks.filter((check) => check.ok !== true && check.skipped !== true).map((check) => `${check.serviceName ?? check.service}: ${check.message ?? check.status ?? "deployment did not settle"}`);
600
+ workflowError(operation, "hosted_deployment_failed", `Hosted Railway deployments for ${environment} did not settle:
601
+ ${deploymentFailures.join("\n")}`, {
602
+ details: { environment, selector, deployments, reconcile }
603
+ });
604
+ }
605
+ }
585
606
  const live = await collectTreeseedLiveHostedServiceChecks({
586
607
  tenantRoot: root,
587
608
  target: environment,
@@ -617,6 +638,33 @@ ${liveFailures.join("\n")}`, {
617
638
  liveVerification: live
618
639
  };
619
640
  }
641
+ async function runReleaseWebLiveVerification(root, environment, helpers, operation) {
642
+ const env = {
643
+ ...helpers.context.env,
644
+ ...collectTreeseedConfigSeedValues(root, environment, helpers.context.env)
645
+ };
646
+ const live = await collectTreeseedLiveHostedServiceChecks({
647
+ tenantRoot: root,
648
+ target: environment,
649
+ appId: "web",
650
+ serviceKeys: ["web"],
651
+ strict: true,
652
+ requireLiveRailway: false,
653
+ requireLiveHttp: true,
654
+ env
655
+ });
656
+ const liveFailures = [
657
+ ...live.checks.filter((check) => check.status === "failed").map((check) => `${check.id}: ${check.issues.join("; ") || "failed"}`),
658
+ ...live.liveObservation.issues
659
+ ];
660
+ if (liveFailures.length > 0) {
661
+ workflowError(operation, "hosted_live_verification_failed", `Production web live verification failed after root deployment:
662
+ ${liveFailures.join("\n")}`, {
663
+ details: { environment, live }
664
+ });
665
+ }
666
+ return live;
667
+ }
620
668
  function productionReleaseImageRefEnv(selectedVersions) {
621
669
  const refs = {};
622
670
  const apiVersion = selectedVersions.get("@treeseed/api");
@@ -1952,6 +2000,48 @@ function releaseWorkflowForPackage(root, packageName) {
1952
2000
  void packageName;
1953
2001
  return "publish.yml";
1954
2002
  }
2003
+ function productionDeployWorkflowForPackage(root, packageName) {
2004
+ const adapter = discoverTreeseedPackageAdapters(root).find((entry) => entry.id === packageName || entry.name === packageName);
2005
+ const repoPath = adapter?.dir;
2006
+ if (!repoPath || !existsSync(resolve(repoPath, "treeseed.site.yaml"))) {
2007
+ return null;
2008
+ }
2009
+ if (!workflowFileExists(repoPath, "deploy.yml")) {
2010
+ return null;
2011
+ }
2012
+ return "deploy.yml";
2013
+ }
2014
+ function tagCommitSha(repoDir, tagName) {
2015
+ try {
2016
+ return runGit(["rev-list", "-n", "1", tagName], { cwd: repoDir, capture: true }).trim();
2017
+ } catch {
2018
+ return "";
2019
+ }
2020
+ }
2021
+ function productionPackageDeployGates(root, versions) {
2022
+ return discoverTreeseedPackageAdapters(root).flatMap((adapter) => {
2023
+ const name = adapter.id;
2024
+ const version = versions.get(name);
2025
+ const path = adapter.dir;
2026
+ const workflow = productionDeployWorkflowForPackage(root, name);
2027
+ if (!name || !version || !path || !workflow) {
2028
+ return [];
2029
+ }
2030
+ const headSha = tagCommitSha(path, version);
2031
+ if (!headSha) {
2032
+ workflowError("release", "github_workflow_failed", `${name} ${workflow} cannot be checked because release tag ${version} is missing locally.`, {
2033
+ details: { packageName: name, workflow, version, repoPath: path }
2034
+ });
2035
+ }
2036
+ return [hostedDeployGate({
2037
+ name,
2038
+ repoPath: path,
2039
+ workflow,
2040
+ branch: version,
2041
+ headSha
2042
+ })];
2043
+ });
2044
+ }
1955
2045
  function prepareAdapterReleaseMetadata(root, pkg, version) {
1956
2046
  const adapter = discoverTreeseedPackageAdapters(root).find((entry) => entry.id === pkg.name || entry.name === pkg.name);
1957
2047
  if (adapter?.kind === "beam-elixir-rust" && existsSync(resolve(pkg.dir, "scripts", "bump-release-version.ts"))) {
@@ -5296,7 +5386,7 @@ async function runReleaseGateReconcileFacade(operation, helpers, root, target, i
5296
5386
  provider: ["treeseed"]
5297
5387
  };
5298
5388
  const desiredGraph = compileTreeseedDesiredResourceGraph({ tenantRoot: root, target });
5299
- const rawUnits = compileTreeseedDesiredUnitsFromGraph(desiredGraph).filter((unit) => unit.provider === "treeseed" && (unit.unitType === "package-manifest" || unit.unitType.startsWith("release-gate:")) && (includeHostedReleaseGates || unit.unitType !== "release-gate:hosted-reconcile" && unit.unitType !== "release-gate:live-verify") || unit.provider === "github" && (unit.unitType === "github-environment" || unit.unitType === "github-secret-binding" || unit.unitType === "github-variable-binding"));
5389
+ const rawUnits = compileTreeseedDesiredUnitsFromGraph(desiredGraph).filter((unit) => unit.provider === "treeseed" && (unit.unitType === "package-manifest" || unit.unitType.startsWith("release-gate:")) && unit.unitType !== "release-gate:npm-publish" && unit.unitType !== "release-gate:image-publish" && (includeHostedReleaseGates || unit.unitType !== "release-gate:hosted-reconcile" && unit.unitType !== "release-gate:live-verify") || unit.provider === "github" && (unit.unitType === "github-environment" || unit.unitType === "github-secret-binding" || unit.unitType === "github-variable-binding"));
5300
5390
  const rawUnitIds = new Set(rawUnits.map((unit) => unit.unitId));
5301
5391
  const units = rawUnits.map((unit) => ({
5302
5392
  ...unit,
@@ -5501,6 +5591,7 @@ ${blockers.join("\n")}`, {
5501
5591
  };
5502
5592
  }),
5503
5593
  { id: "verify-published-artifacts", description: "Verify immutable registry artifacts exist after publish workflows", repoName: rootRepo.name, repoPath: rootRepo.path, branch: PRODUCTION_BRANCH, resumable: true },
5594
+ { id: "production-package-deploy-workflows", description: "Wait for production package deploy workflows before live verification", repoName: rootRepo.name, repoPath: rootRepo.path, branch: PRODUCTION_BRANCH, resumable: true },
5504
5595
  { id: "production-hosting", description: "Reconcile and live-verify production hosted resources before root deploy", repoName: rootRepo.name, repoPath: rootRepo.path, branch: PRODUCTION_BRANCH, resumable: true },
5505
5596
  { id: "production-api-guarantees", description: "Run production API release guarantees before root deploy", repoName: rootRepo.name, repoPath: rootRepo.path, branch: PRODUCTION_BRANCH, resumable: true },
5506
5597
  { id: "release-root", description: `Release market ${plannedRelease.rootVersion}`, repoName: rootRepo.name, repoPath: rootRepo.path, branch: STAGING_BRANCH, resumable: true },
@@ -5524,21 +5615,25 @@ ${blockers.join("\n")}`, {
5524
5615
  ...releaseBasePayload,
5525
5616
  freshArchivedRuns: freshPreparation.archived
5526
5617
  }));
5527
- const releaseGates = await executeJournalStep(root, workflowRun.runId, "release-gates", async () => runReleaseGateReconcileFacade(
5528
- "release",
5529
- helpers,
5530
- root,
5531
- { kind: "persistent", scope: "prod" },
5532
- {
5533
- execute: true,
5534
- verifyDeployedResources: effectiveInput.verifyDeployedResources,
5535
- releaseImageRefs: productionReleaseImageRefEnv(selectedVersions)
5536
- },
5537
- {
5538
- ...releaseBasePayload,
5539
- freshArchivedRuns: freshPreparation.archived
5540
- }
5541
- ));
5618
+ const releaseGates = await executeJournalStep(root, workflowRun.runId, "release-gates", async () => {
5619
+ const workspaceLinks2 = ensureWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto");
5620
+ const gates = await runReleaseGateReconcileFacade(
5621
+ "release",
5622
+ helpers,
5623
+ root,
5624
+ { kind: "persistent", scope: "prod" },
5625
+ {
5626
+ execute: true,
5627
+ verifyDeployedResources: effectiveInput.verifyDeployedResources,
5628
+ releaseImageRefs: productionReleaseImageRefEnv(selectedVersions)
5629
+ },
5630
+ {
5631
+ ...releaseBasePayload,
5632
+ freshArchivedRuns: freshPreparation.archived
5633
+ }
5634
+ );
5635
+ return { workspaceLinks: workspaceLinks2, gates };
5636
+ });
5542
5637
  const workspaceUnlink = await executeJournalStep(root, workflowRun.runId, "workspace-unlink", () => unlinkWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto"));
5543
5638
  const releaseMetadata = await executeJournalStep(root, workflowRun.runId, "prepare-release-metadata", () => {
5544
5639
  applyStableWorkspaceVersionChanges(root, allVersions);
@@ -5612,6 +5707,17 @@ ${rendered}`);
5612
5707
  packageReleases.push(packageRelease);
5613
5708
  }
5614
5709
  const publishedArtifacts = await executeJournalStep(root, workflowRun.runId, "verify-published-artifacts", () => verifyPublishedReleaseArtifacts(selectedVersions));
5710
+ const productionPackageDeployWorkflows = await executeJournalStep(root, workflowRun.runId, "production-package-deploy-workflows", () => {
5711
+ const deployGates = productionPackageDeployGates(root, allVersions);
5712
+ if (deployGates.length === 0) {
5713
+ return { workflowGates: [], status: "skipped", reason: "no selected production package deploy workflows" };
5714
+ }
5715
+ return waitForWorkflowGates("release", deployGates, ciMode, {
5716
+ root,
5717
+ runId: workflowRun.runId,
5718
+ onProgress: (line, stream) => helpers.write(line, stream)
5719
+ }).then((workflowGates) => ({ workflowGates }));
5720
+ });
5615
5721
  const productionHosting = await executeJournalStep(root, workflowRun.runId, "production-hosting", () => reconcileSaveHostedEnvironment(root, "prod", helpers, workflowRun.runId, "release", productionReleaseImageRefEnv(selectedVersions), { liveAppId: "api" }));
5616
5722
  const productionApiGuarantees = await executeJournalStep(root, workflowRun.runId, "production-api-guarantees", () => runReleaseApiGuarantees(root, "prod", helpers, "release", normalizeSceneArtifactsMode(effectiveInput.sceneArtifacts)));
5617
5723
  const rootRelease = await executeJournalStep(root, workflowRun.runId, "release-root", () => {
@@ -5664,6 +5770,7 @@ ${rendered}`);
5664
5770
  runId: workflowRun.runId,
5665
5771
  onProgress: (line, stream) => helpers.write(line, stream)
5666
5772
  }).then((workflowGates) => ({ workflowGates })));
5773
+ const productionWebVerification = await executeJournalStep(root, workflowRun.runId, "production-web-live-verification", () => runReleaseWebLiveVerification(root, "prod", helpers, "release"));
5667
5774
  const backMerge = await executeJournalStep(root, workflowRun.runId, "release-back-merge", () => {
5668
5775
  const packageBackMerges = checkedOutWorkspacePackageRepos(root).filter((pkg) => selectedPackageSet.has(pkg.name)).map((pkg) => backMergeProductionIntoStaging(pkg.dir, pkg.name, releaseAdminMessage({
5669
5776
  subject: `release: back-merge ${PRODUCTION_BRANCH} into ${STAGING_BRANCH}`,
@@ -5690,8 +5797,10 @@ ${rendered}`);
5690
5797
  rootRelease,
5691
5798
  publishWait: publishWait.workflowGates,
5692
5799
  publishedArtifacts,
5800
+ productionPackageDeployWorkflows,
5693
5801
  productionHosting,
5694
5802
  productionApiGuarantees,
5803
+ productionWebVerification,
5695
5804
  backMerge,
5696
5805
  workspaceLinks,
5697
5806
  releasedCommit: String(rootRelease.commit.commitSha ?? ""),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treeseed/sdk",
3
- "version": "0.12.20",
3
+ "version": "0.12.22",
4
4
  "description": "Shared Treeseed SDK for content-backed and D1-backed object models.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {