Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workflows/docker-image.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
30 changes: 20 additions & 10 deletions docs/deployment-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
},
Expand Down Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion railway.app.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
23 changes: 22 additions & 1 deletion railway.worker.json
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 10 additions & 4 deletions scripts/ci-change-scope.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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)$/,
];
Expand Down Expand Up @@ -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,
Expand Down
104 changes: 104 additions & 0 deletions tests/railway-config.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});