@treeseed/sdk 0.12.21 → 0.12.23
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/github-api.js +21 -1
- package/dist/operations/services/live-hosted-service-checks.js +21 -7
- package/dist/operations/services/project-platform.js +13 -6
- 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 +22 -1
- package/dist/workflow/operations.js +97 -17
- package/package.json +1 -1
- package/templates/github/deploy-web.workflow.yml +1 -0
|
@@ -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(
|
|
@@ -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) {
|
|
@@ -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,7 +435,8 @@ 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
|
|
438
|
+
const applications = discoverTreeseedApplications(options.tenantRoot);
|
|
439
|
+
const selectedApplication = options.appId ? applications.find((application) => application.id === options.appId || application.relativeRoot === options.appId) : null;
|
|
426
440
|
const webHttpSelected = selectedServiceKeys.size === 0 || selectedServiceKeys.has("web");
|
|
427
441
|
if (webHttpSelected && (!options.appId || options.appId === "web" || selectedApplication?.roles.includes("web"))) {
|
|
428
442
|
const webConfig = selectedWebConfig(deployConfig, selectedApplication);
|
|
@@ -441,7 +455,7 @@ async function collectHttpObservations(options) {
|
|
|
441
455
|
}
|
|
442
456
|
}
|
|
443
457
|
}
|
|
444
|
-
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))) {
|
|
445
459
|
const serviceConfig = deployConfig.services?.[service.key];
|
|
446
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);
|
|
447
461
|
const baseUrl = urlForDomain(domain);
|
|
@@ -192,8 +192,13 @@ function runWrangler(tenantRoot, args, extraEnv = {}, options = {}) {
|
|
|
192
192
|
}
|
|
193
193
|
return result;
|
|
194
194
|
}
|
|
195
|
+
const WRANGLER_TRANSIENT_MAX_ATTEMPTS = 6;
|
|
196
|
+
const WRANGLER_COMMAND_TIMEOUT_MS = 18e4;
|
|
197
|
+
function wranglerTransientRetryDelayMs(attempt) {
|
|
198
|
+
return Math.min(5e3 * 2 ** (attempt - 1), 6e4);
|
|
199
|
+
}
|
|
195
200
|
function isTransientWranglerOutput(output) {
|
|
196
|
-
return /fetch failed|timed out|etimedout|econnreset|enetunreach|temporarily unavailable|connectivity issue|internal error|aborted/i.test(output);
|
|
201
|
+
return /fetch failed|timed out|etimedout|econnreset|enetunreach|temporarily unavailable|connectivity issue|internal error|code:\s*7500|aborted/i.test(output);
|
|
197
202
|
}
|
|
198
203
|
async function runPrefixedWranglerWithRetry(tenantRoot, args, {
|
|
199
204
|
env: env2 = {},
|
|
@@ -201,7 +206,7 @@ async function runPrefixedWranglerWithRetry(tenantRoot, args, {
|
|
|
201
206
|
prefix
|
|
202
207
|
}) {
|
|
203
208
|
let lastOutput = "";
|
|
204
|
-
for (let attempt = 1; attempt <=
|
|
209
|
+
for (let attempt = 1; attempt <= WRANGLER_TRANSIENT_MAX_ATTEMPTS; attempt += 1) {
|
|
205
210
|
const wrangler = resolveTreeseedToolCommand("wrangler");
|
|
206
211
|
if (!wrangler) {
|
|
207
212
|
throw new Error("Wrangler CLI is unavailable.");
|
|
@@ -210,22 +215,24 @@ async function runPrefixedWranglerWithRetry(tenantRoot, args, {
|
|
|
210
215
|
cwd: tenantRoot,
|
|
211
216
|
env: env2,
|
|
212
217
|
write,
|
|
213
|
-
prefix
|
|
218
|
+
prefix,
|
|
219
|
+
timeoutMs: WRANGLER_COMMAND_TIMEOUT_MS
|
|
214
220
|
});
|
|
215
221
|
if (result.status === 0) {
|
|
216
222
|
return result;
|
|
217
223
|
}
|
|
218
224
|
lastOutput = [result.stderr?.trim(), result.stdout?.trim()].filter(Boolean).join("\n");
|
|
219
|
-
if (attempt ===
|
|
225
|
+
if (attempt === WRANGLER_TRANSIENT_MAX_ATTEMPTS || !isTransientWranglerOutput(lastOutput)) {
|
|
220
226
|
throw new Error(lastOutput || `wrangler ${args.join(" ")} failed`);
|
|
221
227
|
}
|
|
228
|
+
const retryDelayMs = wranglerTransientRetryDelayMs(attempt);
|
|
222
229
|
writeTreeseedBootstrapLine(
|
|
223
230
|
write,
|
|
224
231
|
{ ...prefix, stage: "retry" },
|
|
225
|
-
`Wrangler command hit a transient failure; retrying in ${
|
|
232
|
+
`Wrangler command hit a transient failure; retrying in ${Math.round(retryDelayMs / 1e3)}s...`,
|
|
226
233
|
"stderr"
|
|
227
234
|
);
|
|
228
|
-
await sleep(
|
|
235
|
+
await sleep(retryDelayMs);
|
|
229
236
|
}
|
|
230
237
|
throw new Error(lastOutput || `wrangler ${args.join(" ")} failed`);
|
|
231
238
|
}
|
|
@@ -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;
|
|
@@ -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,
|
|
@@ -1979,6 +2000,48 @@ function releaseWorkflowForPackage(root, packageName) {
|
|
|
1979
2000
|
void packageName;
|
|
1980
2001
|
return "publish.yml";
|
|
1981
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
|
+
}
|
|
1982
2045
|
function prepareAdapterReleaseMetadata(root, pkg, version) {
|
|
1983
2046
|
const adapter = discoverTreeseedPackageAdapters(root).find((entry) => entry.id === pkg.name || entry.name === pkg.name);
|
|
1984
2047
|
if (adapter?.kind === "beam-elixir-rust" && existsSync(resolve(pkg.dir, "scripts", "bump-release-version.ts"))) {
|
|
@@ -5323,7 +5386,7 @@ async function runReleaseGateReconcileFacade(operation, helpers, root, target, i
|
|
|
5323
5386
|
provider: ["treeseed"]
|
|
5324
5387
|
};
|
|
5325
5388
|
const desiredGraph = compileTreeseedDesiredResourceGraph({ tenantRoot: root, target });
|
|
5326
|
-
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"));
|
|
5327
5390
|
const rawUnitIds = new Set(rawUnits.map((unit) => unit.unitId));
|
|
5328
5391
|
const units = rawUnits.map((unit) => ({
|
|
5329
5392
|
...unit,
|
|
@@ -5528,6 +5591,7 @@ ${blockers.join("\n")}`, {
|
|
|
5528
5591
|
};
|
|
5529
5592
|
}),
|
|
5530
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 },
|
|
5531
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 },
|
|
5532
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 },
|
|
5533
5597
|
{ id: "release-root", description: `Release market ${plannedRelease.rootVersion}`, repoName: rootRepo.name, repoPath: rootRepo.path, branch: STAGING_BRANCH, resumable: true },
|
|
@@ -5551,21 +5615,25 @@ ${blockers.join("\n")}`, {
|
|
|
5551
5615
|
...releaseBasePayload,
|
|
5552
5616
|
freshArchivedRuns: freshPreparation.archived
|
|
5553
5617
|
}));
|
|
5554
|
-
const releaseGates = await executeJournalStep(root, workflowRun.runId, "release-gates", async () =>
|
|
5555
|
-
"
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
|
|
5559
|
-
|
|
5560
|
-
|
|
5561
|
-
|
|
5562
|
-
|
|
5563
|
-
|
|
5564
|
-
|
|
5565
|
-
|
|
5566
|
-
|
|
5567
|
-
|
|
5568
|
-
|
|
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
|
+
});
|
|
5569
5637
|
const workspaceUnlink = await executeJournalStep(root, workflowRun.runId, "workspace-unlink", () => unlinkWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto"));
|
|
5570
5638
|
const releaseMetadata = await executeJournalStep(root, workflowRun.runId, "prepare-release-metadata", () => {
|
|
5571
5639
|
applyStableWorkspaceVersionChanges(root, allVersions);
|
|
@@ -5639,6 +5707,17 @@ ${rendered}`);
|
|
|
5639
5707
|
packageReleases.push(packageRelease);
|
|
5640
5708
|
}
|
|
5641
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
|
+
});
|
|
5642
5721
|
const productionHosting = await executeJournalStep(root, workflowRun.runId, "production-hosting", () => reconcileSaveHostedEnvironment(root, "prod", helpers, workflowRun.runId, "release", productionReleaseImageRefEnv(selectedVersions), { liveAppId: "api" }));
|
|
5643
5722
|
const productionApiGuarantees = await executeJournalStep(root, workflowRun.runId, "production-api-guarantees", () => runReleaseApiGuarantees(root, "prod", helpers, "release", normalizeSceneArtifactsMode(effectiveInput.sceneArtifacts)));
|
|
5644
5723
|
const rootRelease = await executeJournalStep(root, workflowRun.runId, "release-root", () => {
|
|
@@ -5718,6 +5797,7 @@ ${rendered}`);
|
|
|
5718
5797
|
rootRelease,
|
|
5719
5798
|
publishWait: publishWait.workflowGates,
|
|
5720
5799
|
publishedArtifacts,
|
|
5800
|
+
productionPackageDeployWorkflows,
|
|
5721
5801
|
productionHosting,
|
|
5722
5802
|
productionApiGuarantees,
|
|
5723
5803
|
productionWebVerification,
|
package/package.json
CHANGED