From 24bf739a9506e0c7ddc932e182ff3d993f1e1d88 Mon Sep 17 00:00:00 2001 From: byigitt Date: Wed, 27 May 2026 12:41:26 +0300 Subject: [PATCH 1/2] feat(health): expose running app version metadata --- .github/workflows/ci.yml | 9 +++++++ .github/workflows/images.yml | 6 +++++ README.md | 6 +++++ apps/sim/.env.example | 2 ++ apps/sim/app/api/health/route.test.ts | 38 ++++++++++++++++++++++++--- apps/sim/app/api/health/route.ts | 25 ++++++++++++++++-- apps/sim/lib/api/contracts/health.ts | 22 ++++++++++++++++ apps/sim/lib/api/contracts/index.ts | 1 + docker/app.Dockerfile | 6 ++++- 9 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 apps/sim/lib/api/contracts/health.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1dc73e648f..155716a18a2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,9 @@ jobs: platforms: linux/amd64 push: true tags: ${{ steps.login-ecr.outputs.registry }}/${{ steps.ecr-repo.outputs.name }}:dev + build-args: | + APP_VERSION=dev + GIT_SHA=${{ github.sha }} provenance: false sbom: false @@ -206,6 +209,9 @@ jobs: platforms: linux/amd64 push: true tags: ${{ steps.meta.outputs.tags }} + build-args: | + APP_VERSION=${{ needs.detect-version.outputs.version || github.ref_name }} + GIT_SHA=${{ github.sha }} provenance: false sbom: false @@ -266,6 +272,9 @@ jobs: platforms: linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} + build-args: | + APP_VERSION=${{ needs.detect-version.outputs.version || github.ref_name }} + GIT_SHA=${{ github.sha }} provenance: false sbom: false diff --git a/.github/workflows/images.yml b/.github/workflows/images.yml index f1ed176d350..8c8a48b991a 100644 --- a/.github/workflows/images.yml +++ b/.github/workflows/images.yml @@ -97,6 +97,9 @@ jobs: platforms: linux/amd64 push: true tags: ${{ steps.meta.outputs.tags }} + build-args: | + APP_VERSION=${{ github.ref_name }} + GIT_SHA=${{ github.sha }} provenance: false sbom: false @@ -143,6 +146,9 @@ jobs: platforms: linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} + build-args: | + APP_VERSION=${{ github.ref_name }} + GIT_SHA=${{ github.sha }} provenance: false sbom: false diff --git a/README.md b/README.md index 989452870fd..85ac88108ec 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,12 @@ docker compose -f docker-compose.prod.yml up -d Open [http://localhost:3000](http://localhost:3000) +Check the running app version and build metadata: + +```bash +curl http://localhost:3000/api/health +``` + Sim also supports local models via [Ollama](https://ollama.ai) and [vLLM](https://docs.vllm.ai/) — see the [Docker self-hosting docs](https://docs.sim.ai/self-hosting/docker) for setup details. ### Self-hosted: Manual Setup diff --git a/apps/sim/.env.example b/apps/sim/.env.example index ca6012c7bb1..71a869e5b01 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -12,6 +12,8 @@ BETTER_AUTH_URL=http://localhost:3000 NEXT_PUBLIC_APP_URL=http://localhost:3000 # INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL # TRUSTED_ORIGINS=https://www.example.com,https://app.example.com # Optional: comma-separated additional public origins to trust for auth (apex+www, alias domains). Merged into Better Auth trustedOrigins. +# APP_VERSION=v0.6.91 # Optional: shown by /api/health for self-hosted version checks +# GIT_SHA= # Optional: commit/build SHA shown by /api/health # Security (Required) ENCRYPTION_KEY=your_encryption_key # Use `openssl rand -hex 32` to generate, used to encrypt environment variables diff --git a/apps/sim/app/api/health/route.test.ts b/apps/sim/app/api/health/route.test.ts index 6abca827579..ab9a32410c9 100644 --- a/apps/sim/app/api/health/route.test.ts +++ b/apps/sim/app/api/health/route.test.ts @@ -1,17 +1,49 @@ /** * @vitest-environment node */ -import { describe, expect, it } from 'vitest' +import { NextRequest } from 'next/server' +import { afterEach, describe, expect, it } from 'vitest' import { GET } from '@/app/api/health/route' +afterEach(() => { + process.env.APP_VERSION = '' + process.env.NEXT_PUBLIC_APP_VERSION = '' + process.env.GIT_SHA = '' + process.env.VERCEL_GIT_COMMIT_SHA = '' + process.env.COMMIT_SHA = '' +}) + describe('GET /api/health', () => { - it('returns an ok status payload', async () => { - const response = await GET() + it('returns status with runtime version metadata', async () => { + process.env.APP_VERSION = 'v1.2.3' + process.env.GIT_SHA = 'abc123' + + const response = await GET(new NextRequest('http://localhost/api/health')) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + status: 'ok', + timestamp: expect.any(String), + version: 'v1.2.3', + commit: 'abc123', + }) + }) + + it('falls back to the package version when runtime metadata is not provided', async () => { + process.env.APP_VERSION = '' + process.env.NEXT_PUBLIC_APP_VERSION = '' + process.env.GIT_SHA = '' + process.env.VERCEL_GIT_COMMIT_SHA = '' + process.env.COMMIT_SHA = '' + + const response = await GET(new NextRequest('http://localhost/api/health')) expect(response.status).toBe(200) await expect(response.json()).resolves.toEqual({ status: 'ok', timestamp: expect.any(String), + version: '0.1.0', + commit: null, }) }) }) diff --git a/apps/sim/app/api/health/route.ts b/apps/sim/app/api/health/route.ts index 5486272998c..60cf0d54e8b 100644 --- a/apps/sim/app/api/health/route.ts +++ b/apps/sim/app/api/health/route.ts @@ -1,11 +1,32 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { healthContract } from '@/lib/api/contracts/health' +import { parseRequest } from '@/lib/api/server' +import appPackage from '@/package.json' + +const DEFAULT_VERSION = appPackage.version + +function getAppVersion(): string { + return process.env.APP_VERSION || process.env.NEXT_PUBLIC_APP_VERSION || DEFAULT_VERSION +} + +function getAppCommit(): string | null { + return process.env.GIT_SHA || process.env.VERCEL_GIT_COMMIT_SHA || process.env.COMMIT_SHA || null +} + /** * Health check endpoint for deployment platforms and container probes. */ -export async function GET(): Promise { - return Response.json( +export async function GET(request: NextRequest): Promise { + const parsed = await parseRequest(healthContract, request, {}) + if (!parsed.success) return parsed.response + + return NextResponse.json( { status: 'ok', timestamp: new Date().toISOString(), + version: getAppVersion(), + commit: getAppCommit(), }, { status: 200 } ) diff --git a/apps/sim/lib/api/contracts/health.ts b/apps/sim/lib/api/contracts/health.ts new file mode 100644 index 00000000000..3be75ec7742 --- /dev/null +++ b/apps/sim/lib/api/contracts/health.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const healthResponseSchema = z.object({ + status: z.literal('ok'), + timestamp: z.string(), + version: z.string(), + commit: z.string().nullable(), +}) + +export type HealthResponse = z.output + +export const healthContract = defineRouteContract({ + method: 'GET', + path: '/api/health', + query: noInputSchema, + response: { + mode: 'json', + schema: healthResponseSchema, + }, +}) diff --git a/apps/sim/lib/api/contracts/index.ts b/apps/sim/lib/api/contracts/index.ts index 9e27ab47723..7dbd6c51896 100644 --- a/apps/sim/lib/api/contracts/index.ts +++ b/apps/sim/lib/api/contracts/index.ts @@ -14,6 +14,7 @@ export * from './environment' export * from './execution-payloads' export * from './file-uploads' export * from './folders' +export * from './health' export * from './hotspots' export * from './inbox' export * from './media' diff --git a/docker/app.Dockerfile b/docker/app.Dockerfile index 67eb5f02c77..8a7b8602839 100644 --- a/docker/app.Dockerfile +++ b/docker/app.Dockerfile @@ -88,6 +88,8 @@ RUN --mount=type=cache,id=next-cache-${TARGETPLATFORM},target=/app/apps/sim/.nex FROM base AS runner WORKDIR /app +ARG APP_VERSION="0.1.0" +ARG GIT_SHA="" # Node.js 22, Python, ffmpeg, etc. are already installed in base stage ENV NODE_ENV=production @@ -134,6 +136,8 @@ USER nextjs EXPOSE 3000 ENV PORT=3000 \ - HOSTNAME="0.0.0.0" + HOSTNAME="0.0.0.0" \ + APP_VERSION=${APP_VERSION} \ + GIT_SHA=${GIT_SHA} CMD ["bun", "apps/sim/server.js"] From c1f7e4def8849d791d863957ced9bd482e08a99a Mon Sep 17 00:00:00 2001 From: byigitt Date: Wed, 27 May 2026 13:13:44 +0300 Subject: [PATCH 2/2] fix(health): accept query params in health checks --- apps/sim/app/api/health/route.test.ts | 9 ++++++++- apps/sim/app/api/health/route.ts | 1 - apps/sim/lib/api/contracts/health.ts | 4 ++-- 3 files changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/sim/app/api/health/route.test.ts b/apps/sim/app/api/health/route.test.ts index ab9a32410c9..d3de6651f59 100644 --- a/apps/sim/app/api/health/route.test.ts +++ b/apps/sim/app/api/health/route.test.ts @@ -1,6 +1,7 @@ /** * @vitest-environment node */ +import appPackage from '@/package.json' import { NextRequest } from 'next/server' import { afterEach, describe, expect, it } from 'vitest' import { GET } from '@/app/api/health/route' @@ -29,6 +30,12 @@ describe('GET /api/health', () => { }) }) + it('accepts query parameters from health-checking clients', async () => { + const response = await GET(new NextRequest('http://localhost/api/health?_=123')) + + expect(response.status).toBe(200) + }) + it('falls back to the package version when runtime metadata is not provided', async () => { process.env.APP_VERSION = '' process.env.NEXT_PUBLIC_APP_VERSION = '' @@ -42,7 +49,7 @@ describe('GET /api/health', () => { await expect(response.json()).resolves.toEqual({ status: 'ok', timestamp: expect.any(String), - version: '0.1.0', + version: appPackage.version, commit: null, }) }) diff --git a/apps/sim/app/api/health/route.ts b/apps/sim/app/api/health/route.ts index 60cf0d54e8b..3a8d66a8b0b 100644 --- a/apps/sim/app/api/health/route.ts +++ b/apps/sim/app/api/health/route.ts @@ -20,7 +20,6 @@ function getAppCommit(): string | null { export async function GET(request: NextRequest): Promise { const parsed = await parseRequest(healthContract, request, {}) if (!parsed.success) return parsed.response - return NextResponse.json( { status: 'ok', diff --git a/apps/sim/lib/api/contracts/health.ts b/apps/sim/lib/api/contracts/health.ts index 3be75ec7742..7e24f83f1ec 100644 --- a/apps/sim/lib/api/contracts/health.ts +++ b/apps/sim/lib/api/contracts/health.ts @@ -1,7 +1,7 @@ import { z } from 'zod' -import { noInputSchema } from '@/lib/api/contracts/primitives' import { defineRouteContract } from '@/lib/api/contracts/types' +const healthQuerySchema = z.object({}).passthrough() export const healthResponseSchema = z.object({ status: z.literal('ok'), timestamp: z.string(), @@ -14,7 +14,7 @@ export type HealthResponse = z.output export const healthContract = defineRouteContract({ method: 'GET', path: '/api/health', - query: noInputSchema, + query: healthQuerySchema, response: { mode: 'json', schema: healthResponseSchema,