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
26 changes: 18 additions & 8 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ jobs:
node-version: "24"

- name: Install pnpm
run: |
npm install -g pnpm@latest
pnpm --version
uses: pnpm/action-setup@v4
with:
version: 11.9.0

- name: Install dependencies
run: pnpm install --frozen-lockfile
Expand All @@ -46,9 +46,9 @@ jobs:
- name: Build production image and verify it boots
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
# The database is external; CI only verifies the app builds and the
# (DB-independent) health endpoint responds. Dummy values keep
# NextAuth and the MySQL client from erroring at startup.
# The CI Compose overlay supplies a disposable MySQL service and
# overrides these values. Dummy auth values keep the app from
# erroring at startup.
NEXTAUTH_SECRET: ci-dummy-secret
JWT_SECRET: ci-dummy-secret
MYSQL_HOST: localhost
Expand All @@ -57,7 +57,7 @@ jobs:
MYSQL_PASSWORD: ci
run: |
# Build overlay, so the PR runs an image built from this branch.
docker compose -f ./docker-compose.yml -f ./docker-compose.build.yml -p "va-stats-pr-${PR_NUMBER}" up --build -d
docker compose -f ./docker-compose.yml -f ./docker-compose.ci.yml -f ./docker-compose.build.yml -p "va-stats-pr-${PR_NUMBER}" up --build -d

echo "Waiting for the app to become healthy..."
for i in {1..60}; do
Expand All @@ -76,11 +76,21 @@ jobs:
sleep 2
done

docker compose -p "va-stats-pr-${PR_NUMBER}" logs va-stats-app | grep -q "Migrations applied" || {
echo "Migrations did not complete"
docker compose -p "va-stats-pr-${PR_NUMBER}" logs va-stats-app
exit 1
}

- name: Clean up
if: always()
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
docker compose -p "va-stats-pr-${PR_NUMBER}" -f ./docker-compose.yml down --volumes --remove-orphans || true
docker compose -p "va-stats-pr-${PR_NUMBER}" \
-f ./docker-compose.yml \
-f ./docker-compose.ci.yml \
-f ./docker-compose.build.yml \
down --volumes --remove-orphans || true
docker ps -a --filter "name=va-stats-pr-${PR_NUMBER}" -q | xargs -r docker rm -f || true
docker network ls --filter "name=va-stats-pr-${PR_NUMBER}" -q | xargs -r docker network rm || true
16 changes: 11 additions & 5 deletions .github/workflows/publish.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -53,8 +53,8 @@ jobs:
- name: Smoke test the published image
env:
IMAGE_TAG: ${{ github.sha }}
# External database; /api/health is DB-independent, so this checks the
# published image boots and serves, not that it can reach MySQL.
# The CI Compose overlay supplies a disposable MySQL service, so the
# smoke test exercises migrations as well as the published image.
MYSQL_HOST: localhost
MYSQL_DATABASE: smoke
MYSQL_USER: smoke
Expand All @@ -64,7 +64,7 @@ jobs:
GOOGLE_CLIENT_ID: ""
GOOGLE_CLIENT_SECRET: ""
run: |
docker compose -f docker-compose.yml -p smoke up -d --pull always
docker compose -f docker-compose.yml -f docker-compose.ci.yml -p smoke up -d --pull always

app=$(docker compose -p smoke ps -q --all va-stats-app)

Expand All @@ -81,12 +81,18 @@ jobs:
exit 1
fi

docker compose -p smoke logs va-stats-app | grep -q "Migrations applied" || {
echo "::error::published image did not apply Prisma migrations"
docker compose -p smoke logs va-stats-app
exit 1
}

docker exec "$app" wget -qO- http://localhost:3000/api/health
echo "OK — pulled image served"
echo "OK — pulled image migrated and served"

- name: Stop smoke test stack
if: always()
run: docker compose -f docker-compose.yml -p smoke down --volumes --remove-orphans || true
run: docker compose -f docker-compose.yml -f docker-compose.ci.yml -p smoke down --volumes --remove-orphans || true

- name: Summary
run: |
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@
!data/.gitkeep
!sql/
!/sql/*.sql

# Prisma migrations are source-controlled SQL, unlike ad hoc SQL scripts.
!prisma/migrations/
!prisma/migrations/**/*.sql
# dependencies
/node_modules
/.pnp
Expand Down
33 changes: 26 additions & 7 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,19 +1,21 @@
# Base image with Node.js
FROM node:24-alpine AS base

# Enable corepack and prepare pnpm
RUN corepack enable && corepack prepare pnpm@latest --activate
# Enable corepack and prepare the version pinned in package.json
RUN corepack enable && corepack prepare pnpm@11.9.0 --activate

# Rebuild the source code only when needed
FROM base AS builder
RUN apk add --no-cache libc6-compat
RUN apk add --no-cache libc6-compat openssl
WORKDIR /app

# NEXT_PUBLIC_BASE_URL deliberately unset: Next.js only inlines NEXT_PUBLIC_* vars
# that exist at build time, so one image works for both test and production.

# Copy package files
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY prisma ./prisma
COPY prisma.config.ts ./prisma.config.ts

# Install dependencies (ignore-scripts skips husky's prepare hook;
# native packages such as sharp ship prebuilt binaries so no build step is needed)
Expand All @@ -26,7 +28,7 @@ COPY . .
# Build the Next.js application (produces the standalone server output)
RUN pnpm run build

# Production image, copy only what is needed and run next
# Production image, copy only what is needed and run migrations before Next.js
FROM base AS runner
WORKDIR /app

Expand All @@ -35,13 +37,26 @@ ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

# Install wget for the container healthcheck
RUN apk add --no-cache wget
# Install the Prisma runtime requirements and wget for the container healthcheck
RUN apk add --no-cache openssl wget

# Copy the standalone build output with correct ownership
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
COPY --from=builder --chown=nextjs:nodejs /app/prisma.config.ts ./prisma.config.ts

# The runtime image is intentionally minimal, so provide the Prisma CLI and
# dotenv used by prisma.config.ts without copying the full pnpm dependency tree.
RUN npm install -g --allow-scripts=prisma,@prisma/engines prisma@7.10.0 dotenv@16.6.1 \
&& mkdir -p /app/node_modules \
&& rm -f /app/node_modules/dotenv \
&& cp -RL /usr/local/lib/node_modules/dotenv /app/node_modules/dotenv \
&& rm -f /app/node_modules/prisma \
&& cp -RL /usr/local/lib/node_modules/prisma /app/node_modules/prisma

ENV NODE_PATH=/usr/local/lib/node_modules

USER nextjs

Expand All @@ -50,5 +65,9 @@ EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

# Start the application
# Apply pending migrations before starting the application.
COPY --chown=nextjs:nodejs docker-entrypoint.sh ./docker-entrypoint.sh
RUN chmod +x ./docker-entrypoint.sh

ENTRYPOINT ["./docker-entrypoint.sh"]
CMD ["node", "server.js"]
28 changes: 28 additions & 0 deletions docker-compose.ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
services:
# Disposable MySQL used only by CI and the published-image smoke test.
db:
image: mysql:8.4
cap_add:
- SYS_NICE
environment:
MYSQL_DATABASE: ci
MYSQL_USER: ci
MYSQL_PASSWORD: ci
MYSQL_ROOT_PASSWORD: root
healthcheck:
test: ["CMD-SHELL", "mysqladmin ping -h localhost -uci -pci --silent"]
interval: 5s
timeout: 5s
retries: 20
start_period: 20s

va-stats-app:
environment:
MYSQL_HOST: db
MYSQL_PORT: 3306
MYSQL_DATABASE: ci
MYSQL_USER: ci
MYSQL_PASSWORD: ci
depends_on:
db:
condition: service_healthy
36 changes: 36 additions & 0 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
#!/bin/sh

# Apply committed Prisma migrations before starting the application.
set -eu

ATTEMPTS=30

log() {
echo "[entrypoint] $*"
}

i=1
while [ "$i" -le "$ATTEMPTS" ]; do
log "Applying Prisma migrations (attempt $i/$ATTEMPTS)..."
if output=$(prisma migrate deploy 2>&1); then
echo "$output"
log "Migrations applied. Starting server..."
exec "$@"
fi

echo "$output"
case "$output" in
*P1001*|*reach*database*|*ECONNREFUSED*)
log "Database is not reachable yet; retrying in 2s..."
i=$((i + 1))
sleep 2
;;
*)
log "Migration failed with a non-connection error; aborting."
exit 1
;;
esac
done

log "Database never became reachable after $ATTEMPTS attempts; aborting."
exit 1
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"@mui/icons-material": "6.5.0",
"@mui/material": "6.5.0",
"@mui/styled-engine-sc": "7.3.2",
"@prisma/client": "7.10.0",
"@swc/helpers": "0.5.17",
"ag-grid-react": "32.2.2",
"better-auth": "^1.7.1",
Expand All @@ -52,6 +53,7 @@
"@types/react": "18.3.24",
"@types/react-dom": "18.3.7",
"autoprefixer": "10.4.16",
"dotenv": "16.6.1",
"eslint": "8.57.0",
"eslint-config-next": "14.2.13",
"husky": "^9.1.7",
Expand All @@ -60,6 +62,7 @@
"lint-staged": "^15.5.2",
"prettier": "^3.4.2",
"prettier-plugin-tailwindcss": "^0.6.9",
"prisma": "7.10.0",
"tailwindcss": "3.4.1",
"typescript": "5.9.2"
},
Expand Down
Loading
Loading