diff --git a/.github/workflows/docker-image.yml b/.github/workflows/docker-image.yml index c6a306ee6..2547a647d 100644 --- a/.github/workflows/docker-image.yml +++ b/.github/workflows/docker-image.yml @@ -31,6 +31,8 @@ on: - ".npmrc" - ".nvmrc" - "next.config.ts" + - "railway.app.json" + - "railway.worker.json" - "worker/python/requirements.txt" - "scripts/check-node-engine.cjs" - "scripts/guard-next-build.mjs" diff --git a/docs/deployment-architecture.md b/docs/deployment-architecture.md index b89cedd76..656ba7241 100644 --- a/docs/deployment-architecture.md +++ b/docs/deployment-architecture.md @@ -185,17 +185,24 @@ The app service's build/deploy config is captured in `railway.app.json` at the repo root (Railway schema). It is intentionally **not** named `railway.json` because a default-named file is auto-loaded by _every_ service in the project and would clash with the worker (which needs a different Dockerfile and no -healthcheck). To activate it, set the app service's config-as-code path to -`railway.app.json` (dashboard → service → Settings → Config-as-code, or the -service-settings API). Until wired, the live service settings are the source of -truth and the file mirrors them: +healthcheck). The production app service uses `railway.app.json` as its +config-as-code path (dashboard → service → Settings → Config-as-code, or the +service-settings API); the worker uses `railway.worker.json`. Keep both paths +wired: Railway does not auto-discover these service-specific filenames. After +changing either file, confirm the deployment metadata reports the tracked health +check and watch patterns rather than relying on dashboard defaults. ```jsonc -// railway.app.json (mirrors the live app service) +// railway.app.json (source of truth for the live app service) { - "build": { "builder": "DOCKERFILE", "dockerfilePath": "Dockerfile" }, + "build": { + "builder": "DOCKERFILE", + "dockerfilePath": "Dockerfile", + "watchPatterns": ["/src/**", "/public/**", "/data/**", "..."], + }, "deploy": { - "healthcheckPath": "/api/health", + "healthcheckPath": "/api/health/ready", + "healthcheckTimeout": 60, "restartPolicyType": "ON_FAILURE", "multiRegionConfig": { "asia-southeast1-eqsg3a": { "numReplicas": 1 } }, }, @@ -346,9 +353,12 @@ Rules: requests. It deliberately does not push to a registry; Railway builds the deployable image itself from the tree on deploy, after the standard gates (`verify` + `ui-smoke` + the clinical governance preflight where relevant). -- **Deploy:** `railway up --service app` / `--service worker` (or a connected - GitHub source) builds and releases. Railway does a rolling deploy and marks the - release `SUCCESS` only after the `/api/health` check passes. +- **Deploy:** `railway up --service Database` / `--service worker` (or the + connected GitHub source) builds and releases. Per-service watch patterns skip + docs, tests, and CI-only commits while retaining every runtime, dependency, + Docker, and service-config input. Railway does a rolling app deploy and marks + the release `SUCCESS` only after `/api/health/ready` passes. The non-HTTP worker + is verified through deployment status, logs, and `npm run reindex:health`. - **Rollback = redeploy the previous Railway deployment** (`railway redeploy`, or the dashboard's per-deployment rollback). Database migrations follow the existing rule: committed migrations + `schema.sql` reconciliation only, never diff --git a/railway.app.json b/railway.app.json index 1d0253be4..70fabdc0b 100644 --- a/railway.app.json +++ b/railway.app.json @@ -2,11 +2,32 @@ "$schema": "https://railway.com/railway.schema.json", "build": { "builder": "DOCKERFILE", - "dockerfilePath": "Dockerfile" + "dockerfilePath": "Dockerfile", + "watchPatterns": [ + "/Dockerfile", + "/.dockerignore", + "/.npmrc", + "/.nvmrc", + "/package.json", + "/package-lock.json", + "/next.config.ts", + "/postcss.config.mjs", + "/tsconfig.json", + "/railway.app.json", + "/data/**", + "/public/**", + "/src/**", + "/scripts/check-client-bundle-secrets.mjs", + "/scripts/check-node-engine.cjs", + "/scripts/guard-next-build.mjs", + "/scripts/install-git-hooks.mjs" + ] }, "deploy": { "healthcheckPath": "/api/health/ready", + "healthcheckTimeout": 60, "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, "multiRegionConfig": { "asia-southeast1-eqsg3a": { "numReplicas": 1 diff --git a/railway.worker.json b/railway.worker.json index 41edf20a5..9bf05e65e 100644 --- a/railway.worker.json +++ b/railway.worker.json @@ -2,10 +2,31 @@ "$schema": "https://railway.com/railway.schema.json", "build": { "builder": "DOCKERFILE", - "dockerfilePath": "Dockerfile.worker" + "dockerfilePath": "Dockerfile.worker", + "watchPatterns": [ + "/Dockerfile.worker", + "/.dockerignore", + "/.npmrc", + "/.nvmrc", + "/package.json", + "/package-lock.json", + "/tsconfig.json", + "/railway.worker.json", + "/data/**", + "/src/**", + "/worker/**", + "/scripts/check-node-engine.cjs", + "/scripts/enable-server-only-stub.mjs", + "/scripts/install-git-hooks.mjs", + "/scripts/register-server-only.mjs", + "/scripts/resolve-tsx-cli.mjs", + "/scripts/run-tsx.mjs", + "/tests/stubs/server-only.ts" + ] }, "deploy": { "restartPolicyType": "ON_FAILURE", + "restartPolicyMaxRetries": 10, "multiRegionConfig": { "asia-southeast1-eqsg3a": { "numReplicas": 1 diff --git a/scripts/ci-change-scope.mjs b/scripts/ci-change-scope.mjs index a975b6363..090731171 100644 --- a/scripts/ci-change-scope.mjs +++ b/scripts/ci-change-scope.mjs @@ -131,6 +131,8 @@ const containerPatterns = [ "next.config.ts", "package.json", "package-lock.json", + "railway.app.json", + "railway.worker.json", "worker/python/requirements.txt", /^scripts\/(check-node-engine|guard-next-build)\.(?:cjs|mjs)$/, ]; @@ -437,10 +439,14 @@ function selfTest() { source_changed: true, build_changed: true, }); - assertScope("container", ["Dockerfile.worker", "worker/python/requirements.txt"], { - container_changed: true, - build_changed: true, - }); + assertScope( + "container", + ["Dockerfile.worker", "railway.app.json", "railway.worker.json", "worker/python/requirements.txt"], + { + container_changed: true, + build_changed: true, + }, + ); assertScope("renamed-destination", parseStatusPorcelain("R src/lib/rag-new.ts\0docs/rag-old.md\0"), { source_changed: true, rag_eval_changed: true, diff --git a/tests/railway-config.test.ts b/tests/railway-config.test.ts new file mode 100644 index 000000000..db645b29c --- /dev/null +++ b/tests/railway-config.test.ts @@ -0,0 +1,104 @@ +import { readFileSync } from "node:fs"; + +import { describe, expect, it } from "vitest"; + +type RailwayConfig = { + build?: { + dockerfilePath?: string; + watchPatterns?: string[]; + }; + deploy?: { + healthcheckPath?: string; + healthcheckTimeout?: number; + restartPolicyType?: string; + restartPolicyMaxRetries?: number; + }; +}; + +function readConfig(fileName: string): RailwayConfig { + return JSON.parse(readFileSync(new URL(`../${fileName}`, import.meta.url), "utf8")) as RailwayConfig; +} + +function watchPatternMatches(pattern: string, filePath: string) { + const normalizedPattern = pattern.replace(/^\/+/, ""); + const normalizedPath = filePath.replace(/^\/+/, ""); + if (normalizedPattern.endsWith("/**")) { + const prefix = normalizedPattern.slice(0, -3); + return normalizedPath === prefix || normalizedPath.startsWith(`${prefix}/`); + } + return normalizedPath === normalizedPattern; +} + +function triggersDeploy(config: RailwayConfig, filePath: string) { + return (config.build?.watchPatterns ?? []).some((pattern) => watchPatternMatches(pattern, filePath)); +} + +describe("Railway config as code", () => { + const app = readConfig("railway.app.json"); + const worker = readConfig("railway.worker.json"); + + it("uses the deep readiness endpoint for app rolling deploys", () => { + expect(app.deploy).toMatchObject({ + healthcheckPath: "/api/health/ready", + healthcheckTimeout: 60, + restartPolicyType: "ON_FAILURE", + restartPolicyMaxRetries: 10, + }); + }); + + it.each([ + "Dockerfile", + ".dockerignore", + "package.json", + "package-lock.json", + "next.config.ts", + "tsconfig.json", + "railway.app.json", + "data/services-snapshot.json", + "public/logo.svg", + "src/app/page.tsx", + "scripts/guard-next-build.mjs", + ])("deploys the app for runtime input %s", (filePath) => { + expect(triggersDeploy(app, filePath)).toBe(true); + }); + + it.each([ + "Dockerfile.worker", + ".dockerignore", + "package.json", + "package-lock.json", + "tsconfig.json", + "railway.worker.json", + "data/services-snapshot.json", + "src/lib/rag.ts", + "worker/main.ts", + "worker/python/requirements.txt", + "scripts/enable-server-only-stub.mjs", + "scripts/register-server-only.mjs", + "scripts/resolve-tsx-cli.mjs", + "scripts/run-tsx.mjs", + "tests/stubs/server-only.ts", + ])("deploys the worker for runtime input %s", (filePath) => { + expect(triggersDeploy(worker, filePath)).toBe(true); + }); + + it.each([ + ".github/workflows/codex-autofix-review-comments.yml", + "AGENTS.md", + "README.md", + "docs/deployment-architecture.md", + "tests/rag-trust.test.ts", + "scripts/check-codex-autofix-workflow.mjs", + ])("does not deploy either service for non-runtime input %s", (filePath) => { + expect(triggersDeploy(app, filePath)).toBe(false); + expect(triggersDeploy(worker, filePath)).toBe(false); + }); + + it("keeps service-specific inputs isolated", () => { + expect(triggersDeploy(app, "Dockerfile.worker")).toBe(false); + expect(triggersDeploy(app, "worker/main.ts")).toBe(false); + expect(triggersDeploy(worker, "Dockerfile")).toBe(false); + expect(triggersDeploy(worker, "next.config.ts")).toBe(false); + expect(triggersDeploy(worker, "public/logo.svg")).toBe(false); + }); +});