@treeseed/sdk 0.12.58 → 0.12.60

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.
@@ -83,7 +83,7 @@ function publicTreeDxSourcePolicy(input, config, launchEnv) {
83
83
  imageRef,
84
84
  imageTagRef: "TREESEED_PUBLIC_TREEDX_IMAGE_REF"
85
85
  };
86
- const serviceName = input.environment === "prod" ? "public-treedx-node-production-01" : "public-treedx-node-01";
86
+ const serviceName = "public-treedx-node-01";
87
87
  assertApiRailwaySourcePolicy(input.environment, { key: serviceName, serviceName, ...policy2 });
88
88
  return policy2;
89
89
  }
@@ -176,8 +176,7 @@ function railwaySourcePolicy(input, serviceKey, service, imageRef) {
176
176
  const configuredSource = service.railway?.source && typeof service.railway.source === "object" && !Array.isArray(service.railway.source) ? service.railway.source : {};
177
177
  const configuredMode = typeof service.railway?.sourceMode === "string" ? service.railway.sourceMode : null;
178
178
  const baseServiceName = service.railway?.serviceName ?? null;
179
- const configuredEnvironmentServiceName = service.environments?.[input.environment]?.railwayServiceName;
180
- const serviceName = typeof configuredEnvironmentServiceName === "string" && configuredEnvironmentServiceName.trim() ? configuredEnvironmentServiceName.trim() : input.environment === "prod" && ["api", "operationsRunner"].includes(serviceKey) && baseServiceName ? `${baseServiceName}-production` : baseServiceName;
179
+ const serviceName = baseServiceName;
181
180
  const repository = typeof service.railway?.sourceRepo === "string" ? service.railway.sourceRepo : typeof configuredSource.repository === "string" ? configuredSource.repository : typeof configuredSource.repo === "string" ? configuredSource.repo : readPackageRepository(resolveRailwayServiceSourceRoot(input, serviceKey, service)) ?? readPackageRepository(input.tenantRoot) ?? apiRailwayDefaultSourceRepo({ key: serviceKey, serviceName });
182
181
  const dockerfilePath = service.railway?.dockerfilePath ?? apiRailwayDefaultDockerfilePath({ key: serviceKey, serviceName });
183
182
  const apiPackageSourceEligible = ["api", "operationsRunner"].includes(serviceKey);
@@ -384,9 +383,8 @@ function buildProfileFromDeployConfig(input) {
384
383
  const imageRefEnv = railwayImageRefEnvForService(serviceKey);
385
384
  const imageRef = service.railway?.imageRef ?? (input.environment === "prod" && imageRefEnv ? launchEnv[imageRefEnv] ?? null : null) ?? defaultRailwayImageRefForService(serviceKey, input.environment) ?? null;
386
385
  const sourcePolicy = railwaySourcePolicy(input, serviceKey, service, imageRef);
387
- const environmentConfig = service.environments?.[input.environment];
388
386
  const baseServiceName = service.railway?.serviceName ?? null;
389
- const effectiveServiceName = environmentConfig?.railwayServiceName ?? (input.environment === "prod" && ["api", "operationsRunner"].includes(serviceKey) && baseServiceName ? `${baseServiceName}-production` : baseServiceName);
387
+ const effectiveServiceName = baseServiceName;
390
388
  services.push({
391
389
  id: serviceKey,
392
390
  label: placement === "runner-capacity" ? "Runner Capacity" : serviceKey === "api" ? "API Runtime" : serviceKey,
@@ -472,7 +470,7 @@ function buildProfileFromDeployConfig(input) {
472
470
  const treeDxSourcePolicy = publicTreeDxSourcePolicy(input, config, launchEnv);
473
471
  const treeDxNodeUnits = Array.from({ length: treeDxNodePool.bootstrapCount }, (_, offset) => {
474
472
  const nodeIndex = offset + 1;
475
- const serviceName = indexedName(input.environment === "prod" ? "public-treedx-node-production" : "public-treedx-node", nodeIndex);
473
+ const serviceName = indexedName("public-treedx-node", nodeIndex);
476
474
  return {
477
475
  id: serviceName,
478
476
  label: `Public TreeDX node ${String(nodeIndex).padStart(2, "0")}`,
@@ -2584,7 +2584,7 @@ function configuredRailwayDestroyTargets(tenantRoot, deployConfig, scope) {
2584
2584
  services.push({
2585
2585
  key: "treeseedDatabase",
2586
2586
  projectName: deployConfig.services?.api?.railway?.projectName ?? identity.deploymentKey,
2587
- serviceName: `${baseName.replace(/-(staging|prod|production)$/u, "")}-${normalizedScope === "prod" ? "prod" : normalizedScope}`,
2587
+ serviceName: baseName.replace(/-(staging|prod|production)$/u, ""),
2588
2588
  railwayEnvironment: normalizeRailwayEnvironmentName(normalizedScope),
2589
2589
  domain: null,
2590
2590
  volumeMountPath: null,
@@ -143,8 +143,8 @@ function collectTreeseedDeploymentReadiness(options) {
143
143
  checks.push(check("hosting:api:healthcheckPath", api.config?.healthcheckPath, "/healthz", "API healthcheck path is /healthz.", "Set services.api.railway.healthcheckPath to /healthz.", "healthcheckPath"));
144
144
  }
145
145
  if (railwayApi) {
146
- const expectedServiceName = environment === "prod" ? "treeseed-api-production" : "treeseed-api";
147
- checks.push(check("railway-config:api:serviceName", railwayApi.serviceName, expectedServiceName, "Railway API service uses the environment-isolated canonical name.", `Set the API service identity to ${expectedServiceName} for ${environment}.`, "serviceName"));
146
+ const expectedServiceName = "treeseed-api";
147
+ checks.push(check("railway-config:api:serviceName", railwayApi.serviceName, expectedServiceName, "Railway API service uses the project-wide canonical name.", `Set the API service identity to ${expectedServiceName} for every environment.`, "serviceName"));
148
148
  checks.push(check("railway-config:api:rootDirectory", relRoot(tenantRoot, railwayApi.rootDir), expectedApiRailwayRoot, "Railway API effective rootDirectory points at the API app.", "Set services.api.railway.rootDir relative to the owning API manifest.", "rootDirectory"));
149
149
  }
150
150
  if (!runner) {
@@ -177,8 +177,8 @@ function collectTreeseedDeploymentReadiness(options) {
177
177
  checks.push(check("hosting:operationsRunner:volumeMountPath", runner.config?.volumeMountPath, "/data", "Runner volume mount path is stable.", "Set services.operationsRunner.railway.volumeMountPath to /data.", "volumeMountPath"));
178
178
  }
179
179
  if (railwayRunner) {
180
- const expectedServiceName = environment === "prod" ? "treeseed-api-operations-runner-production-01" : "treeseed-api-operations-runner-01";
181
- checks.push(check("railway-config:operationsRunner:serviceName", railwayRunner.serviceName, expectedServiceName, "Railway runner service instance uses the environment-isolated canonical name.", `Set the runner service identity to ${expectedServiceName} for ${environment}.`, "serviceName"));
180
+ const expectedServiceName = "treeseed-api-operations-runner-01";
181
+ checks.push(check("railway-config:operationsRunner:serviceName", railwayRunner.serviceName, expectedServiceName, "Railway runner uses the project-wide canonical service name.", `Set the runner service identity to ${expectedServiceName} for every environment.`, "serviceName"));
182
182
  checks.push(check("railway-config:operationsRunner:rootDirectory", relRoot(tenantRoot, railwayRunner.rootDir), expectedRunnerRailwayRoot, "Railway runner effective rootDirectory points at the API app.", "Set services.operationsRunner.railway.rootDir relative to the owning API manifest.", "rootDirectory"));
183
183
  }
184
184
  if (!database) {
@@ -15,7 +15,8 @@ import {
15
15
  import {
16
16
  configuredRailwayServices,
17
17
  findStaleTreeseedOperationsRunnerResources,
18
- isTreeseedOperationsRunnerResourceName
18
+ isTreeseedOperationsRunnerResourceName,
19
+ railwayLegacyAliasMigrationPolicy
19
20
  } from "./railway-deploy.js";
20
21
  import { discoverTreeseedApplications } from "../../hosting/apps.js";
21
22
  import {
@@ -241,6 +242,7 @@ async function collectRailwayObservations(options) {
241
242
  const configuredServices = configuredRailwayServices(options.tenantRoot, options.target, options.env).filter((entry) => serviceMatchesAppSelection(entry, options.tenantRoot, options.appId, applications)).filter((entry) => serviceIsSelected(selectedServiceKeys, entry.key));
242
243
  const siblingTarget = options.target === "staging" ? "prod" : options.target === "prod" ? "staging" : null;
243
244
  const configuredSiblingServices = siblingTarget ? configuredRailwayServices(options.tenantRoot, siblingTarget, options.env, { identityOnly: true }).filter((entry) => entry.enabled !== false).filter((entry) => entry.key === "operationsRunner") : [];
245
+ const retainedMigrationAliases = options.target === "staging" ? railwayLegacyAliasMigrationPolicy("staging", configuredServices).retainedResourceNames : [];
244
246
  if (selectedServiceKeys.size === 0 || selectedServiceKeys.has("api") || selectedServiceKeys.has("operationsRunner")) {
245
247
  for (const descriptor of treeseedDatabaseDescriptors(options.tenantRoot, options)) {
246
248
  await verifyRailwayPostgresTopology({ descriptor, configuredServices, projects, options, issues });
@@ -286,14 +288,18 @@ async function collectRailwayObservations(options) {
286
288
  inspectedRunnerScopes.add(runnerScope);
287
289
  const desiredRunnerNames = /* @__PURE__ */ new Set([
288
290
  ...configuredServices.filter((entry) => entry.key === "operationsRunner").filter((entry) => entry.projectId ? entry.projectId === project.id : entry.projectName === project.name).filter((entry) => normalizeRailwayEnvironmentName(entry.railwayEnvironment) === normalizeRailwayEnvironmentName(environment.name)).map((entry) => entry.serviceName).filter(Boolean),
289
- ...configuredSiblingServices.filter((entry) => entry.projectId ? entry.projectId === project.id : entry.projectName === project.name).map((entry) => entry.serviceName).filter((name) => Boolean(name) && isTreeseedOperationsRunnerResourceName(name))
291
+ ...configuredSiblingServices.filter((entry) => entry.projectId ? entry.projectId === project.id : entry.projectName === project.name).map((entry) => entry.serviceName).filter((name) => Boolean(name) && isTreeseedOperationsRunnerResourceName(name)),
292
+ ...retainedMigrationAliases.filter((name) => isTreeseedOperationsRunnerResourceName(name) && !name.endsWith("-volume"))
290
293
  ]);
291
294
  const desiredRunnerServiceIds = new Set(services.filter((entry) => desiredRunnerNames.has(entry.name)).map((entry) => entry.id));
292
295
  for (const staleService of findStaleTreeseedOperationsRunnerResources(services, desiredRunnerNames)) {
293
296
  issues.push(`${staleService.name}: stale operations runner Railway service remains in project ${project.name}.`);
294
297
  }
295
298
  const volumes2 = await listRailwayVolumes({ projectId: project.id, env: options.env, fetchImpl: options.fetchImpl }).catch(() => []);
296
- const desiredRunnerVolumeNames = new Set([...desiredRunnerNames].map((name) => `${name}-volume`));
299
+ const desiredRunnerVolumeNames = /* @__PURE__ */ new Set([
300
+ ...[...desiredRunnerNames].map((name) => `${name}-volume`),
301
+ ...retainedMigrationAliases.filter((name) => name.endsWith("-volume"))
302
+ ]);
297
303
  for (const staleVolume of findStaleTreeseedOperationsRunnerResources(volumes2, desiredRunnerVolumeNames)) {
298
304
  const activeInstances = activeRailwayVolumeInstances(staleVolume);
299
305
  const relevant = staleVolume.instances.length === 0 || activeInstances.length > 0;
@@ -60,6 +60,11 @@ export declare function waitForRailwayManagedDeploymentsSettled(tenantRoot: any,
60
60
  message: string;
61
61
  }>;
62
62
  export declare function configuredRailwayServices(tenantRoot: any, scope: any, envOverlay?: {}, options?: {}): unknown[];
63
+ export declare function legacyEnvironmentSpecificRailwayResourceNames(services: ReturnType<typeof configuredRailwayServices>): string[];
64
+ export declare function railwayLegacyAliasMigrationPolicy(scope: 'staging' | 'prod', services: ReturnType<typeof configuredRailwayServices>): {
65
+ retainedResourceNames: string[];
66
+ allowedResourceDeletions: string[];
67
+ };
63
68
  export declare function configuredRailwayScheduledJobs(tenantRoot: any, scope: any, { phase }?: {
64
69
  phase?: string | undefined;
65
70
  }): any[];
@@ -686,7 +686,7 @@ function configuredRailwayServicesForConfig(tenantRoot, scope, deployConfig, app
686
686
  const publicBaseUrl = service.environments?.[normalizedScope]?.baseUrl ?? service.publicBaseUrl ?? (serviceKey === "api" ? configuredApiPublicBaseUrl(deployConfig, normalizedScope) : null);
687
687
  const environmentConfig = service.environments?.[normalizedScope];
688
688
  const baseServiceName = service.railway?.serviceName ?? (serviceKey === "workerRunner" ? deriveRailwayWorkerRunnerServiceName(identity.deploymentKey) : `${identity.deploymentKey}-${railwayServiceNameSuffix(serviceKey)}`);
689
- const configuredServiceName = environmentConfig?.railwayServiceName ?? (normalizedScope === "prod" && ["api", "operationsRunner"].includes(serviceKey) ? `${serviceKey === "operationsRunner" ? String(baseServiceName).replace(/-\d+$/u, "") : baseServiceName}-production` : baseServiceName);
689
+ const configuredServiceName = baseServiceName;
690
690
  const configuredRunnerPool = service.railway?.runnerPool && typeof service.railway.runnerPool === "object" ? service.railway.runnerPool : null;
691
691
  const runnerPool = serviceKey === "operationsRunner" || serviceKey === "capacityProviderRunner" ? {
692
692
  bootstrapCount: Math.max(1, Number.parseInt(String(configuredRunnerPool?.bootstrapCount ?? (serviceKey === "capacityProviderRunner" ? 1 : OPERATIONS_RUNNER_BOOTSTRAP_COUNT)), 10) || (serviceKey === "capacityProviderRunner" ? 1 : OPERATIONS_RUNNER_BOOTSTRAP_COUNT)),
@@ -797,7 +797,7 @@ function configuredPublicTreeDxRailwayServices({ tenantRoot, scope, deployConfig
797
797
  const baseImageRef = envValue("TREESEED_PUBLIC_TREEDX_IMAGE_REF", imageRefEnv) || "treeseed/treedx";
798
798
  return Array.from({ length: bootstrapCount }, (_, offset) => {
799
799
  const index = offset + 1;
800
- const serviceName = scope === "prod" ? `public-treedx-node-production-${String(index).padStart(2, "0")}` : `${PUBLIC_TREEDX_NODE_SERVICE_KEY_PREFIX}${String(index).padStart(2, "0")}`;
800
+ const serviceName = `${PUBLIC_TREEDX_NODE_SERVICE_KEY_PREFIX}${String(index).padStart(2, "0")}`;
801
801
  const service = {
802
802
  key: serviceName,
803
803
  serviceName,
@@ -985,6 +985,31 @@ function configuredRailwayServices(tenantRoot, scope, envOverlay = {}, options =
985
985
  ));
986
986
  return [...direct, ...nested];
987
987
  }
988
+ function legacyEnvironmentSpecificRailwayResourceNames(services) {
989
+ const aliases = /* @__PURE__ */ new Set();
990
+ for (const service of services) {
991
+ let alias = null;
992
+ if (service.key === "api") {
993
+ alias = `${service.serviceName}-production`;
994
+ } else if (service.key === "operationsRunner") {
995
+ alias = service.serviceName.replace(/-(\d+)$/u, "-production-$1");
996
+ } else {
997
+ const treeDxMatch = service.serviceName.match(/^public-treedx-node-(\d+)$/u);
998
+ if (treeDxMatch?.[1]) alias = `public-treedx-node-production-${treeDxMatch[1]}`;
999
+ }
1000
+ if (!alias || alias === service.serviceName) continue;
1001
+ aliases.add(alias);
1002
+ if (service.volumeMountPath) aliases.add(`${alias}-volume`);
1003
+ }
1004
+ return [...aliases];
1005
+ }
1006
+ function railwayLegacyAliasMigrationPolicy(scope, services) {
1007
+ const aliases = legacyEnvironmentSpecificRailwayResourceNames(services);
1008
+ return {
1009
+ retainedResourceNames: scope === "staging" ? aliases : [],
1010
+ allowedResourceDeletions: scope === "prod" ? aliases : []
1011
+ };
1012
+ }
988
1013
  function configuredRailwayScheduledJobs(tenantRoot, scope, { phase = "deploy" } = {}) {
989
1014
  if (!shouldManageRailwaySchedules(scope, phase)) {
990
1015
  return [];
@@ -1823,7 +1848,9 @@ export {
1823
1848
  findStaleTreeseedOperationsRunnerResources,
1824
1849
  isTreeseedOperationsRunnerResourceName,
1825
1850
  isUsableRailwayToken,
1851
+ legacyEnvironmentSpecificRailwayResourceNames,
1826
1852
  parseRailwayJsonOutput,
1853
+ railwayLegacyAliasMigrationPolicy,
1827
1854
  railwayServiceRuntimeStartCommand,
1828
1855
  resolveRailwayAuthToken,
1829
1856
  resolveRailwayDeploymentProfile,
@@ -28,7 +28,7 @@ function assertApiRailwaySourcePolicy(scope, service) {
28
28
  const label = service.serviceName ?? service.key ?? "Railway service";
29
29
  if (normalizedScope === "staging") {
30
30
  const issues = [
31
- /-production(?:-|$)/u.test(String(service.serviceName ?? "")) ? "serviceName must use the staging-only identity" : null,
31
+ /-production(?:-|$)/u.test(String(service.serviceName ?? "")) ? "serviceName must use the project-wide canonical identity" : null,
32
32
  service.sourceMode === "git" ? null : "sourceMode must be git",
33
33
  service.imageRef ? "imageRef must be empty" : null,
34
34
  service.sourceRepo ? null : "sourceRepo must be set",
@@ -43,7 +43,7 @@ function assertApiRailwaySourcePolicy(scope, service) {
43
43
  }
44
44
  if (normalizedScope === "prod") {
45
45
  const issues = [
46
- /-production(?:-|$)/u.test(String(service.serviceName ?? "")) ? null : "serviceName must use a production-only identity",
46
+ /-production(?:-|$)/u.test(String(service.serviceName ?? "")) ? "serviceName must use the project-wide canonical identity" : null,
47
47
  service.sourceMode === "image" ? null : "sourceMode must be image",
48
48
  isImmutableRailwayImageRef(service.imageRef) ? null : "imageRef must be an immutable released image tag",
49
49
  service.sourceRepo ? "sourceRepo must be empty" : null,
@@ -944,9 +944,10 @@ async function runGitDependencySmoke(node, options, reference) {
944
944
  void reference;
945
945
  }
946
946
  async function runNpmInstallWithRetry(node, options, gitDependencyRefreshSpecs = []) {
947
- if (shouldSkipNetworkInstall()) {
948
- emitProgress(options, node, "install", "Skipped npm install because network install mode is disabled.");
949
- return { status: "skipped", attempts: 0, reason: "disabled" };
947
+ if (shouldSkipNetworkInstall() || options.deferPushUntilVerified === true) {
948
+ const reason = options.deferPushUntilVerified === true ? "atomic-save" : "disabled";
949
+ emitProgress(options, node, "install", `Skipped npm install because ${reason === "atomic-save" ? "atomic save validates local lock metadata before publishing commits" : "network install mode is disabled"}.`);
950
+ return { status: "skipped", attempts: 0, reason };
950
951
  }
951
952
  let lastError = null;
952
953
  const packageJson = node.packageJson ?? (existsSync(resolve(node.path, "package.json")) ? readJson(resolve(node.path, "package.json")) : null);
@@ -1030,9 +1031,10 @@ async function validateRepositoryLockfile(node, options) {
1030
1031
  }
1031
1032
  const { command, args } = lockfileValidationCommand(node, options);
1032
1033
  const commandText = `${command} ${args.join(" ")}`;
1033
- if (shouldSkipNetworkInstall()) {
1034
- emitProgress(options, node, "lockfile", `Skipped ${commandText} because network install mode is disabled.`);
1035
- return { status: "skipped", command: commandText, issues: [], error: "disabled" };
1034
+ if (shouldSkipNetworkInstall() || options.deferPushUntilVerified === true) {
1035
+ const reason = options.deferPushUntilVerified === true ? "atomic-save" : "disabled";
1036
+ emitProgress(options, node, "lockfile", `Validated lockfile structure without ${commandText} because ${reason === "atomic-save" ? "upstream commits are intentionally unpublished" : "network install mode is disabled"}.`);
1037
+ return { status: "passed", command: "treeseed structural lockfile validation", issues: [], error: null };
1036
1038
  }
1037
1039
  try {
1038
1040
  runCapturedCommand(node, options, "lockfile", command, args, { timeoutMs: lockfileValidationTimeoutMs(node, options), emitOutputOnSuccess: false });
@@ -1773,8 +1775,9 @@ async function saveOneRepository(node, options, state) {
1773
1775
  return value && typeof value === "object" && !Array.isArray(value) ? Object.keys(value) : [];
1774
1776
  }));
1775
1777
  const gitDependencyRefreshReferences = [...state.finalizedReferences.values()].filter((reference) => reference.mode === "dev-git-commit" && directDependencyNames.has(reference.packageName));
1778
+ const deferredGitDependencyValidation = options.deferPushUntilVerified === true && gitDependencyRefreshReferences.length > 0;
1776
1779
  const lockfileGitDependenciesSynced = syncDirectGitDependencyLockfileEntries(node, options, gitDependencyRefreshReferences);
1777
- if (!isRootWorkspaceRepository(node, options) && (lockfileGitDependenciesSynced || gitDependencyRefreshReferences.length > 0 && !existsSync(resolve(node.path, "package-lock.json")))) {
1780
+ if (!isRootWorkspaceRepository(node, options) && (lockfileGitDependenciesSynced || gitDependencyRefreshReferences.length > 0 && !existsSync(resolve(node.path, "package-lock.json"))) && !deferredGitDependencyValidation) {
1778
1781
  validateStandaloneGitDependencyLockfile(node, options);
1779
1782
  }
1780
1783
  const gitDependencyRefreshSpecs = lockfileGitDependenciesSynced ? [] : gitDependencyRefreshReferences.map((reference) => `${reference.packageName}@${reference.installSpec ?? reference.spec}`);
@@ -1809,7 +1812,7 @@ async function saveOneRepository(node, options, state) {
1809
1812
  } else if (node.kind === "project" && (dependencyChanged || node.path === options.root && submodulesChanged) && hasNpmLockfile(node.path)) {
1810
1813
  report.install = await runNpmInstallWithRetry(node, options, gitDependencyRefreshSpecs);
1811
1814
  }
1812
- if (!isRootWorkspaceRepository(node, options) && hasNpmLockfile(node.path) && (packageNeedsVersion || dependencyChanged)) {
1815
+ if (!isRootWorkspaceRepository(node, options) && hasNpmLockfile(node.path) && (packageNeedsVersion || dependencyChanged) && !deferredGitDependencyValidation) {
1813
1816
  validateStandaloneGitDependencyLockfile(node, options);
1814
1817
  }
1815
1818
  if (hasNpmLockfile(node.path) && (node.kind === "project" || packageNeedsVersion || dependencyChanged || submodulesChanged)) {
@@ -43,6 +43,7 @@ import {
43
43
  configuredRailwayServices,
44
44
  findStaleTreeseedOperationsRunnerResources,
45
45
  isTreeseedOperationsRunnerResourceName,
46
+ railwayLegacyAliasMigrationPolicy,
46
47
  validateRailwayDeployPrerequisites
47
48
  } from "../operations/services/railway-deploy.js";
48
49
  import { shouldExposeManagedHostRuntimeSecret } from "../operations/services/managed-host-security.js";
@@ -3996,7 +3997,12 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
3996
3997
  })),
3997
3998
  database: databaseForIac
3998
3999
  };
3999
- const siblingResourceNames = configuredRailwaySiblingResourceNames(input, scope, project.name);
4000
+ const aliasMigration = railwayLegacyAliasMigrationPolicy(scope, projectServices);
4001
+ const siblingResourceNames = [
4002
+ ...configuredRailwaySiblingResourceNames(input, scope, project.name),
4003
+ ...aliasMigration.retainedResourceNames
4004
+ ];
4005
+ const { allowedResourceDeletions } = aliasMigration;
4000
4006
  const {
4001
4007
  applyRailwayIacProject,
4002
4008
  cleanupRailwayIacRender,
@@ -4023,7 +4029,8 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
4023
4029
  serviceSourceRefs: Object.fromEntries(effectiveIacInput.services.map((service) => [
4024
4030
  service.serviceName,
4025
4031
  service.sourceMode === "git" && service.sourceRepo ? `github:${service.sourceRepo}:${service.sourceBranch ?? ""}:${service.sourceRootDirectory ?? ""}:${service.sourceCommit ?? ""}` : service.sourceMode === "image" && service.imageRef ? `image:${service.imageRef}` : null
4026
- ]))
4032
+ ])),
4033
+ allowedResourceDeletions
4027
4034
  });
4028
4035
  if (!validation.ok && effectiveIacInput.database && !effectiveIacInput.database.useNativePostgres && railwayIacPlanDeletesResource(plan.changeSet, effectiveIacInput.database.serviceName)) {
4029
4036
  traceRailwayReconcile(topology.env, "sync:iac-native-postgres-adopt", `${project.name}/${environment.name}:${effectiveIacInput.database.serviceName}`);
@@ -4050,7 +4057,8 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
4050
4057
  serviceSourceRefs: Object.fromEntries(effectiveIacInput.services.map((service) => [
4051
4058
  service.serviceName,
4052
4059
  service.sourceMode === "git" && service.sourceRepo ? `github:${service.sourceRepo}:${service.sourceBranch ?? ""}:${service.sourceRootDirectory ?? ""}:${service.sourceCommit ?? ""}` : service.sourceMode === "image" && service.imageRef ? `image:${service.imageRef}` : null
4053
- ]))
4060
+ ])),
4061
+ allowedResourceDeletions
4054
4062
  });
4055
4063
  }
4056
4064
  const retainedResources = selectRailwayIacRetainedResources(plan, siblingResourceNames);
@@ -4077,7 +4085,8 @@ async function syncRailwayEnvironmentForScope(input, { planOnly = false, service
4077
4085
  serviceSourceRefs: Object.fromEntries(effectiveIacInput.services.map((service) => [
4078
4086
  service.serviceName,
4079
4087
  service.sourceMode === "git" && service.sourceRepo ? `github:${service.sourceRepo}:${service.sourceBranch ?? ""}:${service.sourceRootDirectory ?? ""}:${service.sourceCommit ?? ""}` : service.sourceMode === "image" && service.imageRef ? `image:${service.imageRef}` : null
4080
- ]))
4088
+ ])),
4089
+ allowedResourceDeletions
4081
4090
  });
4082
4091
  }
4083
4092
  if (!validation.ok) {
@@ -66,6 +66,7 @@ export declare function validateRailwayIacChangeSet(changeSet: RailwayChangeSet
66
66
  scope: string;
67
67
  serviceSourceModes?: Record<string, string | null | undefined>;
68
68
  serviceSourceRefs?: Record<string, string | null | undefined>;
69
+ allowedResourceDeletions?: string[];
69
70
  }): RailwayIacValidationResult;
70
71
  export declare function planRailwayIacProject(input: TreeseedRailwayIacProjectInput, rendered?: TreeseedRailwayIacRenderResult): Promise<RailwayIacPlanResponse>;
71
72
  export declare function applyRailwayIacProject(input: TreeseedRailwayIacProjectInput, rendered?: TreeseedRailwayIacRenderResult): Promise<RailwayIacApplyResponse>;
@@ -255,10 +255,11 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
255
255
  const blockedReasons = [];
256
256
  const destructiveChanges = [];
257
257
  const desired = /* @__PURE__ */ new Set([...desiredNames.services, ...desiredNames.volumes, ...desiredNames.database ? [desiredNames.database] : []]);
258
+ const allowedResourceDeletions = new Set(desiredNames.allowedResourceDeletions ?? []);
258
259
  const created = new Set((changeSet?.changes ?? []).filter((change) => change.kind === "resource.create").map((change) => changeName(change)));
259
260
  for (const change of changeSet?.changes ?? []) {
260
261
  const name = changeName(change);
261
- const serviceName = name.replace(/^(service|database)\./u, "");
262
+ const serviceName = name.replace(/^(service|database|volume)\./u, "");
262
263
  const sourceMode = desiredNames.serviceSourceModes?.[name] ?? desiredNames.serviceSourceModes?.[serviceName] ?? null;
263
264
  const sourceRef = desiredNames.serviceSourceRefs?.[name] ?? desiredNames.serviceSourceRefs?.[serviceName] ?? null;
264
265
  const sourceChanged = change.kind === "resource.update" && isRailwaySourceChange(change);
@@ -269,7 +270,9 @@ function validateRailwayIacChangeSet(changeSet, desiredNames) {
269
270
  const apiPolicyService = isApiRailwaySourcePolicyService({ serviceName });
270
271
  if (change.kind === "resource.delete") {
271
272
  destructiveChanges.push(change.summary);
272
- 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.`);
273
+ if (!allowedResourceDeletions.has(name) && !allowedResourceDeletions.has(serviceName)) {
274
+ blockedReasons.push(`Railway IaC plan would delete resource ${name || change.summary}; hosting reconciliation only deletes explicitly recognized obsolete aliases. Use the explicit destroy workflow for other deletions.`);
275
+ }
273
276
  if (desired.has(name) && !created.has(name)) {
274
277
  blockedReasons.push(`Railway IaC plan would delete desired resource ${name}.`);
275
278
  }
@@ -1144,6 +1144,10 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
1144
1144
  cached: boolean;
1145
1145
  }[];
1146
1146
  };
1147
+ productionImageRefs: {
1148
+ persisted: Record<string, string>;
1149
+ readiness: import("../workflow-support.js").TreeseedDeploymentReadinessReport;
1150
+ };
1147
1151
  backMerge: {
1148
1152
  packages: {
1149
1153
  status: string;
@@ -17,6 +17,7 @@ import {
17
17
  applyTreeseedSafeRepairs,
18
18
  assertTreeseedCommandEnvironment,
19
19
  checkTreeseedProviderConnections,
20
+ collectTreeseedEnvironmentContext,
20
21
  collectTreeseedConfigContext,
21
22
  collectTreeseedConfigSeedValues,
22
23
  collectTreeseedPrintEnvReport,
@@ -33,6 +34,7 @@ import {
33
34
  resolveTreeseedRemoteSession,
34
35
  rotateTreeseedMachineKey,
35
36
  setTreeseedRemoteSession,
37
+ setTreeseedMachineEnvironmentValue,
36
38
  writeTreeseedMachineConfig
37
39
  } from "../operations/services/config-runtime.js";
38
40
  import { formatTreeseedDependencyFailureDetails, installTreeseedDependencies } from "../managed-dependencies.js";
@@ -746,6 +748,20 @@ function productionReleaseImageRefVersions(root, selectedVersions) {
746
748
  }
747
749
  return versions;
748
750
  }
751
+ function persistProductionReleaseImageRefs(root, releaseImageRefs) {
752
+ const registry = collectTreeseedEnvironmentContext(root);
753
+ const entries = new Map(registry.entries.map((entry) => [entry.id, entry]));
754
+ const persisted = {};
755
+ for (const [id, value] of Object.entries(releaseImageRefs)) {
756
+ const entry = entries.get(id) ?? { id, storage: "scoped", sensitivity: "plain" };
757
+ if (entry.sensitivity === "secret") {
758
+ workflowError("release", "validation_failed", `Production release image ref ${id} must be a non-secret environment value.`);
759
+ }
760
+ setTreeseedMachineEnvironmentValue(root, "prod", entry, value);
761
+ persisted[id] = value;
762
+ }
763
+ return persisted;
764
+ }
749
765
  function stableVersionLine(version) {
750
766
  const match = version.match(/^(\d+\.\d+)\.\d+(?:-[0-9A-Za-z.-]+)?$/u);
751
767
  return match?.[1] ?? null;
@@ -2225,7 +2241,7 @@ function runReleaseNpmInstall(repoDir, options = {}) {
2225
2241
  if (shouldSkipReleaseInstall()) {
2226
2242
  return { status: "skipped", reason: "disabled" };
2227
2243
  }
2228
- const args = repoDir === options.workspaceRoot ? ["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"] : ["install", "--package-lock-only", "--ignore-scripts", "--workspaces=false", "--no-audit", "--no-fund"];
2244
+ const args = repoDir === options.workspaceRoot ? ["install", "--package-lock-only", "--ignore-scripts", "--workspaces=false", "--no-audit", "--no-fund"] : ["install", "--package-lock-only", "--ignore-scripts", "--workspaces=false", "--no-audit", "--no-fund"];
2229
2245
  const spawnCommand = npmCommandForWorkflowSpawn(args);
2230
2246
  let lastDetail = "";
2231
2247
  for (let attempt = 1; attempt <= 10; attempt += 1) {
@@ -4722,7 +4738,7 @@ async function workflowSave(helpers, input) {
4722
4738
  verifyMode: normalizeSaveVerifyMode(effectiveInput.verify === false ? "skip" : effectiveInput.verifyMode),
4723
4739
  commitMessageMode: effectiveInput.commitMessageMode ?? "auto",
4724
4740
  workflowRunId: workflowRun.runId,
4725
- deferPushUntilVerified: false,
4741
+ deferPushUntilVerified: true,
4726
4742
  onProgress: (line, stream) => helpers.write(line, stream),
4727
4743
  onWaveSaved: branch === STAGING_BRANCH && shouldUseHostedSaveCi(effectiveInput, branch, saveLane) ? async ({ nodes, reports, rootRepo: waveRootRepo }) => {
4728
4744
  const nonRootReportsForWave = reports.filter((repo, index) => nodes[index]?.id !== ".");
@@ -5695,10 +5711,12 @@ ${currentBlockers.map((entry) => `- ${entry}`).join("\n")}`, {
5695
5711
  { root, runId: workflowRun.runId, onProgress: (line, stream) => helpers.write(line, stream) }
5696
5712
  )) : (skipJournalStep(root, workflowRun.runId, "hosted-ci", { skippedReason: "ci off" }), { status: "skipped", reason: "ci off" });
5697
5713
  const workspaceLinks = await executeJournalStep(root, workflowRun.runId, "workspace-link-restore", () => ensureWorkflowWorkspaceLinks(root, helpers, effectiveInput.workspaceLinks ?? "auto"));
5698
- for (const repo of checkedOutStagePromotionRepos(root)) {
5699
- syncBranchWithOrigin(repo.dir, STAGING_BRANCH);
5714
+ if (!managedWorkflowWorktreeMetadata(root)) {
5715
+ for (const repo of checkedOutStagePromotionRepos(root)) {
5716
+ syncBranchWithOrigin(repo.dir, STAGING_BRANCH);
5717
+ }
5718
+ syncBranchWithOrigin(repoRoot(root), STAGING_BRANCH);
5700
5719
  }
5701
- syncBranchWithOrigin(repoRoot(root), STAGING_BRANCH);
5702
5720
  const cleanup = cleanupMode === "success" ? await executeJournalStep(root, workflowRun.runId, "cleanup-source", () => cleanupStageSourceBranches(root, featureBranch, typedManifest)) : (skipJournalStep(root, workflowRun.runId, "cleanup-source", { skippedReason: "manual cleanup selected" }), { status: "skipped", reason: "manual cleanup selected" });
5703
5721
  const payload = {
5704
5722
  ...basePayload,
@@ -6000,6 +6018,7 @@ ${blockers.join("\n")}`, {
6000
6018
  },
6001
6019
  { id: "verify-published-artifacts", description: "Verify immutable registry artifacts exist after publish workflows", repoName: rootRepo.name, repoPath: rootRepo.path, branch: PRODUCTION_BRANCH, resumable: true },
6002
6020
  { 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 },
6021
+ { id: "persist-production-image-refs", description: "Persist released production image refs and verify deployment readiness", repoName: rootRepo.name, repoPath: rootRepo.path, branch: PRODUCTION_BRANCH, resumable: true },
6003
6022
  { id: "release-root", description: `Release market ${plannedRelease.rootVersion}`, repoName: rootRepo.name, repoPath: rootRepo.path, branch: STAGING_BRANCH, resumable: true },
6004
6023
  { id: "publish-wait", description: "Wait for production release workflows", repoName: rootRepo.name, repoPath: rootRepo.path, branch: PRODUCTION_BRANCH, resumable: true },
6005
6024
  { id: "release-back-merge", description: "Back-merge production release history into staging", repoName: rootRepo.name, repoPath: rootRepo.path, branch: STAGING_BRANCH, resumable: true },
@@ -6132,6 +6151,26 @@ ${rendered}`);
6132
6151
  onProgress: (line, stream) => helpers.write(line, stream)
6133
6152
  }).then((workflowGates) => ({ workflowGates }));
6134
6153
  });
6154
+ const productionImageRefs = await executeJournalStep(root, workflowRun.runId, "persist-production-image-refs", async () => {
6155
+ const persisted = persistProductionReleaseImageRefs(root, releaseImageRefs);
6156
+ if (helpers.context.env) {
6157
+ Object.assign(helpers.context.env, persisted);
6158
+ Object.assign(process.env, persisted);
6159
+ }
6160
+ const readiness = await withContextEnv(persisted, () => collectTreeseedDeploymentReadiness({
6161
+ tenantRoot: root,
6162
+ environment: "prod",
6163
+ appId: singleSelectedWorkflowAppId(plannedRelease.applicationSelection)
6164
+ }));
6165
+ const failures = readiness.checks.filter((check) => check.status === "failed").map((check) => `${check.id}: ${check.message}${check.remediation ? ` Remediation: ${check.remediation}` : ""}`);
6166
+ if (failures.length > 0) {
6167
+ workflowError("release", "hosted_live_verification_failed", `Production readiness failed after persisting released image refs:
6168
+ ${failures.join("\n")}`, {
6169
+ details: { releaseImageRefs: persisted, readiness }
6170
+ });
6171
+ }
6172
+ return { persisted, readiness };
6173
+ });
6135
6174
  const rootRelease = await executeJournalStep(root, workflowRun.runId, "release-root", () => {
6136
6175
  const rootInstall = runReleaseNpmInstall(root, { workspaceRoot: root });
6137
6176
  const changelog = updateReleaseChangelog(repoRoot(root), {
@@ -6205,6 +6244,7 @@ ${rendered}`);
6205
6244
  publishWait: publishWait.workflowGates,
6206
6245
  publishedArtifacts,
6207
6246
  productionPackageDeployWorkflows,
6247
+ productionImageRefs,
6208
6248
  backMerge,
6209
6249
  workspaceLinks,
6210
6250
  releasedCommit: String(rootRelease.commit.commitSha ?? ""),
@@ -435,6 +435,18 @@ function collectSaveExpectedHeads(journal) {
435
435
  }
436
436
  return heads;
437
437
  }
438
+ function collectStageExpectedHeads(journal) {
439
+ const heads = {};
440
+ const promotion = stringRecord(journal.steps.find((step) => step.id === "promote-to-staging")?.data);
441
+ const results = Array.isArray(promotion?.results) ? promotion.results : [];
442
+ for (const entry of results) {
443
+ const repo = stringRecord(entry);
444
+ if (typeof repo?.name === "string" && typeof repo.commitSha === "string" && repo.verified === true) {
445
+ heads[repo.name] = repo.commitSha;
446
+ }
447
+ }
448
+ return heads;
449
+ }
438
450
  function classifyWorkflowRunJournal(journal, options = {}) {
439
451
  const reasons = [];
440
452
  const now = options.now ?? nowIso();
@@ -516,6 +528,15 @@ function classifyWorkflowRunJournal(journal, options = {}) {
516
528
  }
517
529
  }
518
530
  }
531
+ if (journal.command === "stage" && options.currentHeads) {
532
+ const expectedHeads = collectStageExpectedHeads(journal);
533
+ for (const [name, expectedHead] of Object.entries(expectedHeads)) {
534
+ const currentHead = options.currentHeads[name];
535
+ if (currentHead && currentHead !== expectedHead) {
536
+ reasons.push(`${name} head changed from staged candidate ${expectedHead} to ${currentHead}`);
537
+ }
538
+ }
539
+ }
519
540
  return {
520
541
  state: reasons.length > 0 ? "stale" : "resumable",
521
542
  reasons: reasons.length > 0 ? reasons : releaseGateOnlyCompletion ? ["release commits already exist; remaining release gates can be rechecked"] : ["workflow run can be resumed"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treeseed/sdk",
3
- "version": "0.12.58",
3
+ "version": "0.12.60",
4
4
  "description": "Shared Treeseed SDK for content-backed and D1-backed object models.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {