diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8e665c67..9937034e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -56,7 +56,8 @@ jobs: MYSQL_USER: ci MYSQL_PASSWORD: ci run: | - docker compose -f ./docker-compose.yml -p "va-stats-pr-${PR_NUMBER}" up --build -d + # 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 echo "Waiting for the app to become healthy..." for i in {1..60}; do diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 00000000..ee52b846 --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,115 @@ +name: Publish Image + +# Builds the image in CI so the shared Coolify host only pulls and restarts. +# Push to main deploys va-stats-test; production is promoted by pinning IMAGE_TAG. + +on: + push: + branches: [main] + workflow_dispatch: + +concurrency: + group: publish-${{ github.ref }} + cancel-in-progress: true + +env: + # Must be lowercase — GHCR rejects mixed-case image names, and the org is "C4G". + # Keep in sync with the `image:` value in docker-compose.yml. + IMAGE: ghcr.io/c4g/va-stats + +jobs: + publish: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - uses: actions/checkout@v4 + + - uses: docker/setup-buildx-action@v3 + + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # No build-args, so one image works against both origins. See Dockerfile. + - name: Build and push app image + uses: docker/build-push-action@v6 + with: + context: . + file: ./Dockerfile + push: true + tags: | + ${{ env.IMAGE }}:latest + ${{ env.IMAGE }}:${{ github.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # CI only ever builds the PR head; the squashed commit on main is not + # exercised until here. Runs the production compose with no build overlay. + - 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. + MYSQL_HOST: localhost + MYSQL_DATABASE: smoke + MYSQL_USER: smoke + MYSQL_PASSWORD: smoke + NEXTAUTH_SECRET: smoke-only-not-a-real-secret + JWT_SECRET: smoke-only-not-a-real-secret + GOOGLE_CLIENT_ID: "" + GOOGLE_CLIENT_SECRET: "" + run: | + docker compose -f docker-compose.yml -p smoke up -d --pull always + + app=$(docker compose -p smoke ps -q --all va-stats-app) + + for i in $(seq 1 60); do + status=$(docker inspect -f '{{.State.Health.Status}}' "$app") + echo "attempt $i: $status" + case "$status" in healthy|unhealthy) break ;; esac + sleep 2 + done + + if [ "$(docker inspect -f '{{.State.Health.Status}}' "$app")" != "healthy" ]; then + echo "::error::published image never became healthy" + docker compose -p smoke logs + exit 1 + fi + + docker exec "$app" wget -qO- http://localhost:3000/api/health + echo "OK — pulled image served" + + - name: Stop smoke test stack + if: always() + run: docker compose -f docker-compose.yml -p smoke down --volumes --remove-orphans || true + + - name: Summary + run: | + { + echo "### Image published" + echo '' + echo '```' + echo "${IMAGE}:latest" + echo "${IMAGE}:${GITHUB_SHA}" + echo '```' + echo '' + echo "To promote this build to production, set \`IMAGE_TAG=${GITHUB_SHA}\`" + echo "on the \`va-stats\` Coolify application and redeploy it." + } >> "$GITHUB_STEP_SUMMARY" + + # Deploy from here, not Coolify's git webhook (switched off), so it cannot + # pull `:latest` mid-push. Secrets need `env` because `if:` cannot see them. + - name: Trigger Coolify deployment (va-stats-test) + env: + COOLIFY_TOKEN: ${{ secrets.COOLIFY_TOKEN }} + COOLIFY_APP_UUID: ${{ vars.COOLIFY_APP_UUID }} + if: ${{ env.COOLIFY_TOKEN != '' && env.COOLIFY_APP_UUID != '' }} + run: | + curl -fsS -X GET \ + "https://coolify.c4g.dev/api/v1/deploy?uuid=${COOLIFY_APP_UUID}" \ + -H "Authorization: Bearer ${COOLIFY_TOKEN}" diff --git a/Dockerfile b/Dockerfile index ef1da776..c4e0ce7a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,11 +9,8 @@ FROM base AS builder RUN apk add --no-cache libc6-compat WORKDIR /app -# Declare build arguments for Next.js public variables (inlined at build time) -ARG NEXT_PUBLIC_BASE_URL - -# Set environment variables from build args (baked into the client bundle) -ENV NEXT_PUBLIC_BASE_URL=$NEXT_PUBLIC_BASE_URL +# 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 ./ diff --git a/README.md b/README.md index 056e0fe0..523048d5 100644 --- a/README.md +++ b/README.md @@ -41,11 +41,56 @@ To learn more about Next.js, take a look at the following resources: You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js/) - your feedback and contributions are welcome! -## Deploy on Vercel +## Deployment (Coolify + GHCR) -This app is deployed on [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js. +`.github/workflows/publish.yaml` runs on every push to `main` (and on manual +dispatch). It builds the image, pushes `ghcr.io/c4g/va-stats:latest` and +`:`, then triggers a Coolify deployment of **va-stats-test**. -Check out [Next.js deployment documentation](https://nextjs.org/docs/deployment) for more details. +`docker-compose.yml` references that published image and has **no `build:` +key**, which is what keeps the shared Coolify host from compiling the +application on every deploy — it only pulls and restarts. Coolify's own git +auto-deploy is switched off so the workflow is the single trigger, which +prevents Coolify from pulling `:latest` before the push has finished. + +### Environments + +| Environment | Coolify app | Image tag | +| ----------------------- | --------------- | ----------------------------------------- | +| `va-stats-test.c4g.dev` | `va-stats-test` | `latest` (deployed automatically on main) | +| `va-stats.c4g.dev` | `va-stats` | `IMAGE_TAG` pinned to a commit SHA | + +**Promoting to production** is manual and explicit: set `IMAGE_TAG` to the +commit SHA of a build already verified on va-stats-test in the `va-stats` +application's Coolify environment variables, then redeploy it. Production then +runs the exact image that was tested — no rebuild. + +### One image, two origins + +`NEXT_PUBLIC_BASE_URL` differs per environment, and Next.js normally inlines +`NEXT_PUBLIC_*` variables at build time, which would tie an image to a single +origin. The Dockerfile deliberately leaves the variable unset during the build: +Next only substitutes `NEXT_PUBLIC_*` values that exist at build time, so +`process.env.NEXT_PUBLIC_BASE_URL` survives in the compiled server output as a +real runtime lookup and each environment supplies its own value through Coolify. + +This works only because the value is read server-side (`utils/auditLogger.js`, +imported solely by `pages/api/*`). Reading it from client code would yield +`undefined` in the browser — see the comment in the Dockerfile before changing +this. + +### Required repository/organization configuration + +| Name | Kind | Purpose | +| ------------------ | -------- | -------------------------------------- | +| `COOLIFY_TOKEN` | secret | Coolify API token (organization-level) | +| `COOLIFY_APP_UUID` | variable | UUID of the va-stats-test Coolify app | + +### Building locally + +```bash +docker compose -f docker-compose.yml -f docker-compose.build.yml up --build -d +``` ## Code modifications required when changing hosting provider diff --git a/docker-compose.build.yml b/docker-compose.build.yml new file mode 100644 index 00000000..28df427a --- /dev/null +++ b/docker-compose.build.yml @@ -0,0 +1,7 @@ +# Adds the `build:` key that docker-compose.yml omits, for building locally: +# docker compose -f docker-compose.yml -f docker-compose.build.yml up --build -d +services: + va-stats-app: + build: + context: . + dockerfile: Dockerfile diff --git a/docker-compose.yml b/docker-compose.yml index a1a1ba79..c64335cf 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,13 +1,10 @@ +# Production stack for both va-stats-test and va-stats. No `build:` key on purpose +# — publish.yaml builds the image; IMAGE_TAG picks which build each app runs. services: # Next.js application. The database is an external MySQL instance, so it is # NOT managed here — connection details are provided via environment variables. va-stats-app: - build: - context: . - dockerfile: Dockerfile - args: - # NEXT_PUBLIC_* vars are inlined into the bundle at build time - - NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-} + image: ghcr.io/c4g/va-stats:${IMAGE_TAG:-latest} expose: - "3000" environment: @@ -23,8 +20,7 @@ services: - JWT_SECRET=${JWT_SECRET} - GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID} - GOOGLE_CLIENT_SECRET=${GOOGLE_CLIENT_SECRET} - # Public config (also passed as a build arg above, where it is inlined; - # kept here for the server-side runtime fallback) + # Read at runtime, not baked in — see Dockerfile. - NEXT_PUBLIC_BASE_URL=${NEXT_PUBLIC_BASE_URL:-} command: node server.js healthcheck: