From c23f16932094f9294e5bb1e9b860d67e66f1e73a Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Thu, 1 Oct 2026 13:47:42 -0500 Subject: [PATCH 1/2] Move Railway from railway.json to Infrastructure as Code. .railway/railway.ts describes the web service in development, uat, and production, rebuilt from the live configuration with every variable preserved. Plan reports it already matches all three environments, and it has been applied to each with no change. It owns only web, so Postgres and slack-cards are untouched. RAILPACK_NODE_VERSION comes from .nvmrc. railway.json is deleted before Railway's 2026-12-01 cutoff. Co-Authored-By: Claude Opus 5.5 (1M context) --- .railway/README.md | 23 +++++++++++++++ .railway/railway.ts | 67 ++++++++++++++++++++++++++++++++++++++++++ ROADMAP.md | 41 ++++++++++++++++++++++++++ package-lock.json | 39 ++++++++++++++++++++++++ package.json | 1 + railway.json | 11 ------- test/toolchain.test.ts | 15 ++++++++-- 7 files changed, 184 insertions(+), 13 deletions(-) create mode 100644 .railway/README.md create mode 100644 .railway/railway.ts delete mode 100644 railway.json diff --git a/.railway/README.md b/.railway/README.md new file mode 100644 index 0000000..bfeb277 --- /dev/null +++ b/.railway/README.md @@ -0,0 +1,23 @@ +# Railway configuration + +`railway.ts` defines Coachatron's `web` service for every environment +(development from `develop`, uat from `staging`, production from `main`). It +replaced `railway.json`, which Railway stops reading on 2026-12-01. + +Railway does not read this folder on deploy. Changes take effect only when +someone applies them, one environment at a time, from the repo root on Node 24: + +```bash +railway environment development # then uat, then production +railway config plan # must say 0 to destroy +railway config apply +``` + +- **Partial `web`.** The file owns only the web service. Postgres, its volume, + and slack-cards are not in it and apply never touches them. +- **Variables.** Every value stays in Railway; the file marks each one + `preserve()`. Never put a secret here. +- **Node version.** `RAILPACK_NODE_VERSION` comes from `../.nvmrc`. Railpack + reads that variable before `package.json` engines, so it decides the Node a + deploy runs. To apply everything except a Node change, set + `COACHATRON_IAC_PRESERVE_NODE=1`. diff --git a/.railway/railway.ts b/.railway/railway.ts new file mode 100644 index 0000000..89722cc --- /dev/null +++ b/.railway/railway.ts @@ -0,0 +1,67 @@ +import { readFileSync } from "node:fs"; +import { defineRailway, github, preserve, project, service } from "railway/iac"; + +// Coachatron's web service, in every environment. Replaces railway.json +// (Config as Code), which Railway stops reading on 2026-12-01. +// +// Railway does not read this file on deploy. Preview and apply it per +// environment, from the repo root, with the environment linked: +// railway environment development # or uat, production +// railway config plan +// railway config apply +// +// "web" is a partial: this file owns only the web service. Postgres, its +// volume, and slack-cards (production) are managed elsewhere and are never +// touched by apply. +export const partial = "web"; + +// The Node version comes from .nvmrc, the one place the repo names it +// (test/toolchain.test.ts keeps package.json engines in step). Railpack reads +// RAILPACK_NODE_VERSION before engines, so this variable is what decides the +// Node a deploy runs. COACHATRON_IAC_PRESERVE_NODE=1 keeps the live value +// instead, for applying the rest of this file without changing Node. +function nodeVersion() { + if (process.env.COACHATRON_IAC_PRESERVE_NODE === "1") return preserve(); + return readFileSync(new URL("../.nvmrc", import.meta.url), "utf8").trim(); +} + +const ENVIRONMENTS = { + development: { branch: "develop", domains: ["dev.coachatron.com"] }, + uat: { branch: "staging", domains: ["uat.coachatron.com"] }, + production: { branch: "main", domains: ["coachatron.com", "www.coachatron.com"] }, +} as const; + +export default defineRailway((ctx) => { + const name = (Object.keys(ENVIRONMENTS) as Array).find((env) => ctx.isEnvironment(env)); + if (!name) throw new Error(`No web settings for Railway environment "${ctx.environmentName}"`); + const env = ENVIRONMENTS[name]; + + const web = service("web", { + source: github("YOLOVibeCode/coachatron", { branch: env.branch, checkSuites: false }), + build: "npm run build", + start: "npm start", + healthcheck: "/", + healthcheckTimeout: 30, + replicas: { "us-east4-eqdc4a": 1 }, + domains: env.domains.map((domain) => ({ domain, port: 3000 })), + // Values live in Railway, never in this file. preserve() keeps each one. + env: { + APP_BASE_URL: preserve(), + APP_ENV: preserve(), + DATABASE_URL: preserve(), + LITELLM_API_KEY: preserve(), + LITELLM_BASE: preserve(), + NODE_ENV: preserve(), + PORT: preserve(), + RAILPACK_NODE_VERSION: nodeVersion(), + RELAY_API_KEY: preserve(), + RELAY_BASE_URL: preserve(), + RELAY_CONNECT_PRODUCT: preserve(), + RELAY_WEBHOOK_SECRET: preserve(), + SESSION_SECRET: preserve(), + STORE_BASE_URL: preserve(), + }, + }); + + return project("coachatron", { resources: [web] }); +}); diff --git a/ROADMAP.md b/ROADMAP.md index f4bc3ab..c0588a8 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1177,3 +1177,44 @@ Notes: - To move to the next LTS: change `.nvmrc` and `engines` together (the toolchain test enforces it), after the `Next Node (lts/*)` job is green. +--- + +## M12: Railway Infrastructure as Code +Status: [x] done +Goal: Retire `railway.json` (Config as Code, which Railway stops reading on +2026-12-01) without changing anything that runs, and let `.nvmrc` decide +production's Node. +Acceptance: +- [x] `.railway/railway.ts` describes the `web` service in development + (`develop`), uat (`staging`) and production (`main`): source, build, + start, healthcheck, replicas, custom domains, and every variable as + `preserve()` (values never in git) +- [x] It is a `web` partial: Postgres, its volume, and slack-cards are not + declared and apply cannot touch them +- [x] `railway config plan` in all three environments: "already up to + date" (with the Node line preserved), so the file matches what runs; + applied to all three, which recorded ownership and changed nothing +- [x] `railway config migrate --apply --service web` run in all three: + no Config File setting was set, so nothing to clear +- [x] `railway.json` deleted; `test/toolchain.test.ts` fails if it returns, + if the file stops reading `.nvmrc`, or if it declares Postgres or + slack-cards +- [x] All five quality-bar commands still exit 0 + +Notes: +- The first `migrate` output was not safe to apply: it named the service + `coachatron` (the live one is `web`), and with only build/start/healthcheck + declared, `plan` showed it would delete all 14 web variables and detach the + GitHub source. The file was rebuilt from `railway config pull` in each + environment instead. +- `RAILPACK_NODE_VERSION` comes from `.nvmrc`, so moving Node is a one-file + change. Applying that is the only pending change in each environment: + `plan` shows exactly one update, `web.RAILPACK_NODE_VERSION` (22 to 24). + It changes the runtime of a live service, so it is applied per + environment on purpose (development, then uat, then production), not as + part of this migration. `COACHATRON_IAC_PRESERVE_NODE=1` applies the rest + of the file without it. +- The `railway` SDK checks the CLI version by running `$_`, the last command + the shell ran. Wrapping `railway config plan` in `timeout` (or anything + else) makes it fail with a misleading "requires Railway CLI 5.42.1". + diff --git a/package-lock.json b/package-lock.json index 727b65d..4aec789 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "@types/node": "^24.19.0", "@types/pg": "^8.11.6", "eslint": "^10.11.0", + "railway": "^3.12.0", "tsx": "^4.23.15", "typescript": "^6.0.3", "typescript-eslint": "^8.71.0" @@ -606,6 +607,16 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@graphql-typed-document-node/core": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@graphql-typed-document-node/core/-/core-3.2.0.tgz", + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1851,6 +1862,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graphql": { + "version": "16.14.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.14.2.tgz", + "integrity": "sha512-Chq1s4CY7jmh8gO2qvLIJyfCDIN+EHLFW/9iShnp1z8FjBQMoodWP1kDC36VAMXXIvAjj4ARa7ntfAV2BrjsbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -2509,6 +2530,24 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/railway": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/railway/-/railway-3.12.0.tgz", + "integrity": "sha512-a6th+76NE3fZ8QMTOtEIe15sPTjObWOJYx0pCHCR/rd00r++I2rZHLKTn2R1qmPAwvGdAj6vY45GDVJs0AzOXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@graphql-typed-document-node/core": "^3.2.0", + "graphql": "^16.10.0", + "tsx": "^4.20.0" + }, + "bin": { + "railway-iac-ts": "dist/iac/bin.js" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/range-parser": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", diff --git a/package.json b/package.json index 4cc001a..c981729 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,7 @@ "@types/node": "^24.19.0", "@types/pg": "^8.11.6", "eslint": "^10.11.0", + "railway": "^3.12.0", "tsx": "^4.23.15", "typescript": "^6.0.3", "typescript-eslint": "^8.71.0" diff --git a/railway.json b/railway.json deleted file mode 100644 index 51b22b7..0000000 --- a/railway.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "$schema": "https://railway.com/railway.schema.json", - "build": { - "builder": "RAILPACK", - "buildCommand": "npm run build" - }, - "deploy": { - "startCommand": "npm start", - "healthcheckPath": "/" - } -} diff --git a/test/toolchain.test.ts b/test/toolchain.test.ts index a43adb9..84bcce9 100644 --- a/test/toolchain.test.ts +++ b/test/toolchain.test.ts @@ -2,8 +2,9 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; -/** One Node version, read three ways: nvm and CI read .nvmrc, Railway - * (Railpack) reads package.json engines first. If they drift, a deploy runs +/** One Node version, read everywhere: nvm and CI read .nvmrc; Railway + * (Railpack) reads RAILPACK_NODE_VERSION first, which .railway/railway.ts + * sets from .nvmrc, then package.json engines. If they drift, a deploy runs * a Node nobody tested. */ const root = new URL('..', import.meta.url); @@ -31,3 +32,13 @@ test('the Node running these tests meets engines', () => { `running Node ${process.version}, but package.json needs ${engines}. Run "nvm use" in this folder.`, ); }); + +test('Railway takes its Node version from .nvmrc and no longer has railway.json', () => { + const iac = read('.railway/railway.ts'); + assert.match(iac, /RAILPACK_NODE_VERSION: nodeVersion\(\)/); + assert.match(iac, /readFileSync\(new URL\("\.\.\/\.nvmrc", import\.meta\.url\)/); + assert.match(iac, /export const partial = "web";/, 'the file owns only the web service'); + assert.doesNotMatch(iac, /\b(postgres|slack-cards)\(/, 'Postgres and slack-cards are managed elsewhere'); + assert.throws(() => read('railway.json'), /ENOENT/, 'railway.json (Config as Code) is retired'); +}); + From f106ffceb515026d9683ff9462bb4e83b9bad1e6 Mon Sep 17 00:00:00 2001 From: Ricardo Vega Date: Thu, 1 Oct 2026 14:42:12 -0500 Subject: [PATCH 2/2] Record the Node 24 rollout and Railway's production apply bug. Co-Authored-By: Claude Opus 5.5 (1M context) --- .railway/README.md | 7 +++++++ ROADMAP.md | 8 ++++++++ 2 files changed, 15 insertions(+) diff --git a/.railway/README.md b/.railway/README.md index bfeb277..6476535 100644 --- a/.railway/README.md +++ b/.railway/README.md @@ -21,3 +21,10 @@ railway config apply reads that variable before `package.json` engines, so it decides the Node a deploy runs. To apply everything except a Node change, set `COACHATRON_IAC_PRESERVE_NODE=1`. +- **Known Railway bug (production).** `railway config apply` with any change + fails in production with "Custom-domain registration is not supported" for + `coachatron.com` and `www.coachatron.com`, though both exist and `plan` + shows only the intended change. Until Railway fixes it, make the planned + change directly (for a variable: `railway variable set KEY=VALUE --service + web --environment production`), then run `railway config plan` to confirm + it says "already up to date". diff --git a/ROADMAP.md b/ROADMAP.md index c0588a8..484800c 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1214,6 +1214,14 @@ Notes: environment on purpose (development, then uat, then production), not as part of this migration. `COACHATRON_IAC_PRESERVE_NODE=1` applies the rest of the file without it. +- Applied 2026-10-01 with the owner's go-ahead, in order. development: + `config apply`, rebuilt `8cb1fb3`. uat: `config apply`, rebuilt `9f42394`. + production: `config apply` refused (a Railway bug that rejects the two + existing custom domains on any change), so the same single change was made + with `railway variable set`, rebuilt `97012f4`. All three now run Node + 24.21.0 (read with `railway ssh`); `/` and `/signin` return 200 and a + database-backed 404 route answers on every domain; `config plan` is + "already up to date" in all three. No code changed in any deploy. - The `railway` SDK checks the CLI version by running `$_`, the last command the shell ran. Wrapping `railway config plan` in `timeout` (or anything else) makes it fail with a misleading "requires Railway CLI 5.42.1".