From c8f9c5951855844b00b07c407377a93413dcfc6a Mon Sep 17 00:00:00 2001 From: Vlad Bisceanu <7993591+vladbisceanu@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:25:41 +0200 Subject: [PATCH] feat: gate production on readiness --- .github/workflows/test-web.yml | 21 ++++-- apps/web/src/app/api/health/route.api.test.ts | 66 ++++++++++++++++++- apps/web/src/app/api/health/route.ts | 42 +++++++++++- docker/start.sh | 3 +- turbo.json | 3 +- 5 files changed, 121 insertions(+), 14 deletions(-) diff --git a/.github/workflows/test-web.yml b/.github/workflows/test-web.yml index aaf0374f..1d0d14f8 100644 --- a/.github/workflows/test-web.yml +++ b/.github/workflows/test-web.yml @@ -1,17 +1,21 @@ -name: Web Tests +name: Production Gate on: pull_request: - paths: - - "apps/web/**" + branches: + - growthpath-mail push: branches: - - main - paths: - - "apps/web/**" + - growthpath-mail + workflow_dispatch: + +concurrency: + group: production-gate-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: - web-tests: + production-gate: + name: Production gate runs-on: ubuntu-latest env: NODE_ENV: test @@ -87,3 +91,6 @@ jobs: - name: Run integration tests run: pnpm --filter=web test:integration + + - name: Build production image + run: docker build --file docker/Dockerfile --tag usesend:${{ github.sha }} . diff --git a/apps/web/src/app/api/health/route.api.test.ts b/apps/web/src/app/api/health/route.api.test.ts index 393d5f27..a8f97bd3 100644 --- a/apps/web/src/app/api/health/route.api.test.ts +++ b/apps/web/src/app/api/health/route.api.test.ts @@ -1,12 +1,72 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + ping: vi.fn(), + queryRaw: vi.fn(), +})); + +vi.mock("~/server/db", () => ({ + db: { $queryRaw: mocks.queryRaw }, +})); + +vi.mock("~/server/redis", () => ({ + getRedis: () => ({ ping: mocks.ping }), +})); + import { GET } from "~/app/api/health/route"; describe("health route", () => { - it("returns healthy response", async () => { + beforeEach(() => { + mocks.ping.mockResolvedValue("PONG"); + mocks.queryRaw.mockResolvedValue([{ "?column?": 1 }]); + vi.stubEnv("RAILWAY_GIT_COMMIT_SHA", "abc123"); + }); + + afterEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + vi.useRealTimers(); + }); + + it("returns the deployed SHA when dependencies are ready", async () => { const response = await GET(); const body = await response.json(); expect(response.status).toBe(200); - expect(body).toEqual({ data: "Healthy" }); + expect(response.headers.get("cache-control")).toBe("no-store"); + expect(body).toEqual({ data: "Healthy", commitSha: "abc123" }); + expect(mocks.queryRaw).toHaveBeenCalledOnce(); + expect(mocks.ping).toHaveBeenCalledOnce(); + }); + + it("fails readiness when Postgres is unavailable", async () => { + mocks.queryRaw.mockRejectedValue(new Error("database unavailable")); + + const response = await GET(); + + expect(response.status).toBe(503); + await expect(response.json()).resolves.toEqual({ + data: "Unhealthy", + commitSha: "abc123", + }); + }); + + it("fails readiness when Redis is unavailable", async () => { + mocks.ping.mockRejectedValue(new Error("redis unavailable")); + + const response = await GET(); + + expect(response.status).toBe(503); + }); + + it("bounds dependency checks", async () => { + vi.useFakeTimers(); + mocks.queryRaw.mockReturnValue(new Promise(() => undefined)); + + const responsePromise = GET(); + await vi.advanceTimersByTimeAsync(2_000); + const response = await responsePromise; + + expect(response.status).toBe(503); }); }); diff --git a/apps/web/src/app/api/health/route.ts b/apps/web/src/app/api/health/route.ts index abc938c6..8ca432b0 100644 --- a/apps/web/src/app/api/health/route.ts +++ b/apps/web/src/app/api/health/route.ts @@ -1,5 +1,45 @@ +import { db } from "~/server/db"; +import { getRedis } from "~/server/redis"; + export const dynamic = "force-dynamic"; +const HEALTHCHECK_TIMEOUT_MS = 2_000; + +async function waitForDependencies() { + let timeout: ReturnType | undefined; + + try { + await Promise.race([ + Promise.all([db.$queryRaw`SELECT 1`, getRedis().ping()]), + new Promise((_, reject) => { + timeout = setTimeout( + () => reject(new Error("Healthcheck timed out")), + HEALTHCHECK_TIMEOUT_MS, + ); + }), + ]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + export async function GET() { - return Response.json({ data: "Healthy" }); + const commitSha = process.env.RAILWAY_GIT_COMMIT_SHA ?? "unknown"; + + try { + await waitForDependencies(); + + return Response.json( + { data: "Healthy", commitSha }, + { headers: { "Cache-Control": "no-store" } }, + ); + } catch { + return Response.json( + { data: "Unhealthy", commitSha }, + { + status: 503, + headers: { "Cache-Control": "no-store" }, + }, + ); + } } diff --git a/docker/start.sh b/docker/start.sh index e725ef27..83ad9490 100644 --- a/docker/start.sh +++ b/docker/start.sh @@ -1,6 +1,6 @@ #!/bin/sh -set -x +set -eu echo "Deploying prisma migrations" @@ -9,4 +9,3 @@ pnpx prisma@6.6.0 migrate deploy --schema ./apps/web/prisma/schema.prisma echo "Starting web server" node apps/web/server.js - diff --git a/turbo.json b/turbo.json index 6217cc9e..09292080 100644 --- a/turbo.json +++ b/turbo.json @@ -49,7 +49,8 @@ "S3_COMPATIBLE_ACCESS_KEY", "S3_COMPATIBLE_SECRET_KEY", "S3_COMPATIBLE_API_URL", - "S3_COMPATIBLE_PUBLIC_URL" + "S3_COMPATIBLE_PUBLIC_URL", + "RAILWAY_GIT_COMMIT_SHA" ] }, "lint": {