@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.
- package/dist/api/auth/d1-provider.d.ts +1 -0
- package/dist/api/auth/d1-store.d.ts +2 -0
- package/dist/api/auth/d1-store.js +17 -1
- package/dist/operations/services/deploy.d.ts +6 -2
- package/dist/operations/services/deploy.js +31 -8
- package/dist/operations/services/github-api.js +21 -1
- package/dist/operations/services/hosted-service-checks.js +5 -3
- package/dist/operations/services/live-hosted-service-checks.js +24 -9
- package/dist/operations/services/project-platform.js +23 -22
- package/dist/reconcile/builtin-adapters.js +4 -2
- package/dist/reconcile/providers/github-private.js +21 -0
- package/dist/reconcile/providers/railway-iac.d.ts +1 -0
- package/dist/reconcile/providers/railway-iac.js +25 -2
- package/dist/workflow/operations.d.ts +23 -1
- package/dist/workflow/operations.js +126 -17
- package/package.json +1 -1
|
@@ -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
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
|
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((
|
|
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:
|
|
133
|
-
...
|
|
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) =>
|
|
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
|
|
426
|
-
|
|
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) =>
|
|
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
|
-
...
|
|
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
|
|
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
|
|
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
|
|
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(
|
|
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
|
|
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" &&
|
|
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" &&
|
|
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:
|
|
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 () =>
|
|
5528
|
-
"
|
|
5529
|
-
|
|
5530
|
-
|
|
5531
|
-
|
|
5532
|
-
|
|
5533
|
-
|
|
5534
|
-
|
|
5535
|
-
|
|
5536
|
-
|
|
5537
|
-
|
|
5538
|
-
|
|
5539
|
-
|
|
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 ?? ""),
|