name: Deploy to Railway on: # Push deploys re-enabled 2026-07-30. They were made explicit-only when # Railway rollout healthchecks were failing for unchanged backend images; # the four rollouts between 2026-07-23 and 2026-07-29 (CI + CLI paths) all # passed /readiness on the first attempt, and per-container memory sits at # ~1.3 GB against a 24 GB replica limit. The wait-for-ci job below still # blocks any SHA without a green Backend CI Status Gate, and the # cancel-in-progress concurrency group collapses push storms. push: branches: ["main"] # Deploy only on backend-relevant changes. This list MUST stay a subset # of backend-ci.yml's push paths: a path that triggers deploy without # triggering backend-ci leaves the SHA with no "Backend CI Status Gate", # and wait-for-ci then fails after its 45-minute discovery window # (fail-on-no-checks, #2537) — a guaranteed-red run for e.g. a # frontend-only merge (first observed on 98de9bd5, 2026-07-30). paths: - 'alembic/**' - 'server/**' - 'services/**' - 'shared/**' - 'kernel/**' - 'plugins/fsma/**' - 'Dockerfile' - 'railway.toml' - 'requirements.in' - 'requirements.lock' - 'scripts/**' - 'tests/**' workflow_dispatch: permissions: checks: read contents: read concurrency: group: deploy-main cancel-in-progress: true jobs: # Gate: manual deploys must wait for a green CI Status Gate check on the # selected ref before Railway deployment can start. wait-for-ci: name: Wait for CI runs-on: ubuntu-latest timeout-minutes: 50 steps: - name: Require backend CI green uses: lewagon/wait-on-check-action@369769072fe522a3a8a85c03c96af1e5242a1994 # v1.9.1 with: ref: ${{ github.sha }} running-workflow-name: 'Wait for CI' repo-token: ${{ secrets.GITHUB_TOKEN }} # The deploy ships the backend monolith image, so it must wait on the # BACKEND gate specifically. Backend and frontend used to share the # name "CI Status Gate", so a frontend-only SHA (whose only run was # frontend-ci) satisfied this wait with the backend never verified. # The gates are now "Backend CI Status Gate" / "Frontend CI Status # Gate"; matching the backend name means a SHA with no backend run has # no matching check, so this step times out (checks-discovery-timeout) # instead of passing — deploy does not proceed. (#2537) # GitHub Checks API returns job names, not "workflow / job" format. # Backend CI can take longer than 15m to create its summary gate, so # keep discovery patient. check-name: 'Backend CI Status Gate' wait-interval: 30 checks-discovery-timeout: 2700 # Only a passing backend gate clears the deploy. `skipped` is NOT in # this list (the action default is `success,skipped`), so a skipped # gate cannot green-light a deploy. allowed-conclusions: success # If no "Backend CI Status Gate" check exists for the dispatched SHA # (e.g. a frontend-only SHA never ran backend-ci), fail rather than # treat "no matching check" as success — deploy must not proceed on a # ref the backend never verified (#2537). fail-on-no-checks: true deploy: name: Deploy RegEngine Service needs: [wait-for-ci] runs-on: ubuntu-latest timeout-minutes: 30 steps: - name: Checkout code uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v6 - name: Setup Node.js uses: actions/setup-node@949feb2413d6458794dcd2491c4babbbce0c15c1 # v6 with: node-version-file: .nvmrc - name: Install Railway CLI run: | npm install -g "@railway/cli@4.44.0" railway --version - name: Validate Railway deployment secrets id: validate_railway env: RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_API_TOKEN }} RAILWAY_PROJECT_ID: ${{ secrets.RAILWAY_PROJECT_ID }} RAILWAY_ENVIRONMENT_ID: ${{ secrets.RAILWAY_ENVIRONMENT_ID }} RAILWAY_SERVICE_ID: ${{ secrets.RAILWAY_SERVICE_ID }} run: | missing=() [ -z "$RAILWAY_API_TOKEN" ] && missing+=("RAILWAY_API_TOKEN") [ -z "$RAILWAY_PROJECT_ID" ] && missing+=("RAILWAY_PROJECT_ID") [ -z "$RAILWAY_ENVIRONMENT_ID" ] && missing+=("RAILWAY_ENVIRONMENT_ID") [ -z "$RAILWAY_SERVICE_ID" ] && missing+=("RAILWAY_SERVICE_ID") if [ ${#missing[@]} -gt 0 ]; then echo "::error::Railway deployment secrets are missing: ${missing[*]}" exit 1 fi # Stamp the commit into the build context. .dockerignore excludes .git, # so without this the image has no way to say which commit it is — # which is what made the class of failure below undetectable. - name: Stamp build identity run: | printf '%s\n' "${{ github.sha }}" > build-sha.txt echo "Stamped build-sha.txt with ${{ github.sha }}" - name: Deploy to Railway env: # Use the account/workspace token path. The previous project token # secret was unauthorized for this project/service. RAILWAY_API_TOKEN: ${{ secrets.RAILWAY_API_TOKEN }} RAILWAY_PROJECT_ID: ${{ secrets.RAILWAY_PROJECT_ID }} RAILWAY_ENVIRONMENT_ID: ${{ secrets.RAILWAY_ENVIRONMENT_ID }} RAILWAY_SERVICE_ID: ${{ secrets.RAILWAY_SERVICE_ID }} run: | railway up \ --ci \ --project "$RAILWAY_PROJECT_ID" \ --environment "$RAILWAY_ENVIRONMENT_ID" \ --service "$RAILWAY_SERVICE_ID" # `railway up --ci` returns once the upload is accepted, not once the new # image is serving. railway.toml sets restartPolicyType = "ON_FAILURE" # with a /readiness healthcheck, so a rollout that fails its healthcheck # reverts to the previous image *after* this workflow has already gone # green. Nothing downstream could notice: every probe asserts behaviour # an older image satisfies perfectly. # # This closes that hole by asserting identity rather than behaviour — # the deployed /health must report the commit we just shipped. - name: Verify the deployed commit is the one we shipped env: BACKEND_URL: https://regengine-production.up.railway.app EXPECTED_SHA: ${{ github.sha }} run: | set -uo pipefail # Rollout budget. railway.toml allows healthcheckTimeout=300 plus up # to 3 ON_FAILURE restarts, so a slow-but-succeeding rollout must not # be called a failure here. deadline=$(( SECONDS + 900 )) attempt=0 last_sha="" last_code="" while [ "$SECONDS" -lt "$deadline" ]; do attempt=$(( attempt + 1 )) body=$(mktemp) last_code=$(curl -sS --max-time 20 -o "$body" -w '%{http_code}' "${BACKEND_URL}/health" || true) last_code="${last_code:-000}" if [ "$last_code" = "200" ]; then # `// empty` also covers a payload with no build key at all, # which is what an image predating build stamping returns. last_sha=$(jq -r '.build.commit_sha // empty' "$body" 2>/dev/null || true) if [ "$last_sha" = "$EXPECTED_SHA" ]; then rm -f "$body" echo "Deployed commit matches: ${EXPECTED_SHA}" exit 0 fi fi rm -f "$body" echo "attempt ${attempt}: status=${last_code} deployed_sha=${last_sha:-(none)} expected=${EXPECTED_SHA}" sleep 15 done echo "::error::Deploy did not converge. /health last reported status=${last_code} commit_sha=${last_sha:-(none)}, expected ${EXPECTED_SHA}." # Three distinct causes, three distinct messages. A single generic # failure string would send whoever reads this at 3am down the wrong # path, which is most of what makes an alert useless. if [ "$last_code" != "200" ]; then echo "::error::/health never returned 200 (last status ${last_code}). The service is not serving — check the Railway deploy logs for a crashed or unhealthy rollout." elif [ -z "$last_sha" ]; then echo "::error::/health answered but carried no commit_sha. Either the rollout reverted to an image predating build stamping, or build-sha.txt did not reach the image." else echo "::error::A different commit is serving — the rollout reverted (railway.toml restartPolicyType=ON_FAILURE) or another deploy raced this one." fi exit 1