diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9f154d8 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,118 @@ +name: CI + +# Build + static checks (lint, typecheck, unit) run on every push/PR. The dynamic +# Playwright suite (integration + e2e) runs against the dockerized stack and is +# gated behind `static`. See docs/ci.md and docs/adr/0008-github-actions-ci.md. +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +# Supersede in-flight runs on the same ref. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + static: + name: Build & static checks + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + + # Node version comes from .nvmrc (single source of truth, kept in lockstep + # with the Dockerfiles' digest-pinned NODE_IMAGE). + - uses: actions/setup-node@v5 + with: + node-version-file: .nvmrc + cache: npm + cache-dependency-path: package-lock.json + + - name: Install dependencies + run: npm ci + + # Next.js build cache — makes `next build` incremental across runs. + - name: Cache Next.js build + uses: actions/cache@v5 + with: + path: frontend/.next/cache + key: ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-${{ hashFiles('frontend/**/*.[jt]s', 'frontend/**/*.[jt]sx') }} + restore-keys: | + ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}- + ${{ runner.os }}-nextjs- + + # Build the shared contract first, then the two apps (mirrors the images). + - name: Build + run: | + npm run build:shared + npm run build --workspace backend + npm run build --workspace frontend + + - name: Lint + run: npm run lint + + - name: Typecheck + run: npm run typecheck + + # Pure unit suites (slot engine determinism/RTP + economy helpers) — no DB. + - name: Unit tests + run: npm test --workspace backend + + # Advisory: surface HIGH/CRITICAL CVEs without failing the run (mirrors `make audit`). + - name: Audit (advisory) + run: npm audit --audit-level=high + continue-on-error: true + + e2e: + name: Dynamic (integration + e2e) + needs: static + runs-on: ubuntu-latest + env: + # Layer the CI-only Bake cache override on top of the prod + test compose files. + # docker-compose.override.yml is NOT included (CI must not get the dev target). + COMPOSE_FILES: -f docker-compose.yml -f docker-compose.test.yml -f docker-compose.ci.yml + steps: + - uses: actions/checkout@v5 + + # Buildx enables the gha layer-cache backend used by Compose Bake. + - uses: docker/setup-buildx-action@v4 + + # Compose reads .env for variable substitution; the example values are + # self-consistent and safe for the ephemeral test stack. + - name: Create .env + run: cp .env.example .env + + # Build the images (Compose Bake + gha layer cache) and run the in-container + # Playwright suite against fresh db/api/web. The `tests` container's exit code + # is the suite result. Mirrors `make test`. + - name: Run integration + e2e + env: + COMPOSE_BAKE: "1" + run: docker compose ${{ env.COMPOSE_FILES }} up --build --abort-on-container-exit --exit-code-from tests tests + + - name: Dump stack logs on failure + if: failure() + run: docker compose ${{ env.COMPOSE_FILES }} logs --no-color + + # The HTML report is written inside the tests container; copy it out before teardown. + - name: Extract Playwright report + if: ${{ !cancelled() }} + continue-on-error: true + run: docker compose ${{ env.COMPOSE_FILES }} cp tests:/app/tests/playwright-report ./playwright-report + + - name: Upload Playwright report + if: ${{ !cancelled() }} + uses: actions/upload-artifact@v7 + with: + name: playwright-report + path: playwright-report + if-no-files-found: ignore + retention-days: 7 + + - name: Tear down stack + if: always() + run: docker compose ${{ env.COMPOSE_FILES }} down -v diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 0000000..b832e40 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24.16.0 diff --git a/Makefile b/Makefile index 4d90c85..9f4764c 100644 --- a/Makefile +++ b/Makefile @@ -85,6 +85,13 @@ fmt: ## Format with Prettier audit: ## Fail on high/critical dependency CVEs npm audit --audit-level=high +.PHONY: scan +scan: ## Fail on high/critical CVEs in built runtime images (needs trivy) + docker build -t lunaland-api:scan -f backend/Dockerfile --target runner . + docker build -t lunaland-web:scan -f frontend/Dockerfile --target runner . + trivy image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed lunaland-api:scan + trivy image --severity HIGH,CRITICAL --exit-code 1 --ignore-unfixed lunaland-web:scan + ## ─── Repo ──────────────────────────────────────────────────────────────── .PHONY: push push: ## Push main to origin (github.com/mitekk/casino-land) diff --git a/README.md b/README.md index b14088c..4fe420b 100644 --- a/README.md +++ b/README.md @@ -45,9 +45,10 @@ make psql # psql shell make help # list everything ## Architecture & docs - **Product:** [`docs/prd/PRD-current.md`](docs/prd/PRD-current.md) -- **Decisions:** [`docs/adr/`](docs/adr/) (ADR 0001–0007) +- **Decisions:** [`docs/adr/`](docs/adr/) (ADR 0001–0008) - **API contract (BE↔FE):** [`docs/api-contract.md`](docs/api-contract.md) - **DB schema (DB↔BE):** [`docs/db-schema.md`](docs/db-schema.md) +- **CI (GitHub Actions):** [`docs/ci.md`](docs/ci.md) ### Repository layout & track ownership diff --git a/backend/Dockerfile b/backend/Dockerfile index cd4e0a1..57222c7 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -1,8 +1,14 @@ # syntax=docker/dockerfile:1 # Build context is the repo root (see docker-compose.yml). +# Pinned Node 24 LTS on Alpine 3.23, digest-locked for reproducibility. Bump via +# `docker pull node:24-alpine && docker inspect --format '{{index .RepoDigests 0}}' node:24-alpine` +# then re-run `make scan` to confirm the new base is free of HIGH/CRITICAL CVEs. +# Keep the major/minor in lockstep with `.nvmrc` (the CI host + local Node version). +ARG NODE_IMAGE=node:24.16.0-alpine3.23@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14 + # ─── deps (cached install from manifests) ──────────────────────────────── -FROM node:22-alpine AS base +FROM ${NODE_IMAGE} AS base WORKDIR /app COPY package.json package-lock.json tsconfig.base.json ./ COPY packages/shared/package.json packages/shared/ @@ -26,11 +32,18 @@ COPY backend backend RUN npm run build --workspace @casino/shared \ && npm run build --workspace backend +# ─── prod-deps (runtime-only node_modules) ─────────────────────────────── +# Drop devDependencies so the production image doesn't ship build tooling. +# This also removes the vendored esbuild Go binary (drizzle-kit/tsx), whose +# embedded Go stdlib otherwise trips the `make scan` HIGH/CRITICAL gate. +FROM build AS prod-deps +RUN npm prune --omit=dev + # ─── runner (production) ───────────────────────────────────────────────── -FROM node:22-alpine AS runner +FROM ${NODE_IMAGE} AS runner WORKDIR /app ENV NODE_ENV=production -COPY --from=build /app/node_modules ./node_modules +COPY --from=prod-deps /app/node_modules ./node_modules COPY --from=build /app/package.json ./package.json COPY --from=build /app/packages/shared/package.json ./packages/shared/package.json COPY --from=build /app/packages/shared/dist ./packages/shared/dist diff --git a/backend/package.json b/backend/package.json index 8dd0c4e..bb2ae10 100644 --- a/backend/package.json +++ b/backend/package.json @@ -42,7 +42,7 @@ "@nestjs/testing": "^11.1.0", "@types/bcryptjs": "^2.4.6", "@types/jest": "^29.5.14", - "@types/node": "^22.10.0", + "@types/node": "^24.0.0", "@types/passport-jwt": "^4.0.1", "@types/pg": "^8.11.10", "@types/supertest": "^6.0.2", diff --git a/docker-compose.ci.yml b/docker-compose.ci.yml new file mode 100644 index 0000000..a815f12 --- /dev/null +++ b/docker-compose.ci.yml @@ -0,0 +1,39 @@ +# CI-ONLY override — layered LAST, after docker-compose.yml + docker-compose.test.yml: +# +# COMPOSE_BAKE=1 docker compose \ +# -f docker-compose.yml -f docker-compose.test.yml -f docker-compose.ci.yml \ +# up --build --abort-on-container-exit --exit-code-from tests tests +# +# Purpose: wire the GitHub Actions layer cache into the image builds so repeated +# CI runs reuse Docker layers instead of rebuilding from scratch. We stay +# compose-native by letting Compose Bake (COMPOSE_BAKE=1, backed by Buildx) read +# the `x-bake` cache directives below. This file makes NO runtime/service changes +# and is never used outside CI, keeping the production compose files clean. +# +# `type=gha` uses the Actions cache API directly (no registry needed); `mode=max` +# caches intermediate stage layers too (worthwhile for the multi-stage Dockerfiles). +# Distinct `scope=` per service keeps their caches from clobbering each other. +services: + api: + build: + x-bake: + cache-from: + - type=gha,scope=api + cache-to: + - type=gha,mode=max,scope=api + + web: + build: + x-bake: + cache-from: + - type=gha,scope=web + cache-to: + - type=gha,mode=max,scope=web + + tests: + build: + x-bake: + cache-from: + - type=gha,scope=tests + cache-to: + - type=gha,mode=max,scope=tests diff --git a/docs/adr/0008-github-actions-ci.md b/docs/adr/0008-github-actions-ci.md new file mode 100644 index 0000000..0188e2e --- /dev/null +++ b/docs/adr/0008-github-actions-ci.md @@ -0,0 +1,56 @@ +# ADR 0008 — Continuous Integration via GitHub Actions + +- Status: Accepted +- Date: 2026-06-03 + +## Context + +The repo already has a full local quality gate — build, lint, typecheck, unit tests, and a +dockerized integration/e2e suite — wired through the root scripts and the `Makefile`. +Nothing ran it automatically on push/PR. ADR‑0006 anticipated that tests "exercise real +containers"; CI can reuse the same compose flow rather than re‑implement it. + +## Decision + +- A single workflow, [`.github/workflows/ci.yml`](../../.github/workflows/ci.yml), runs on + push to `main`, on every PR, and via manual dispatch, with a `concurrency` group that + cancels superseded runs. +- **Two jobs that mirror the `Makefile`, not re‑implementations:** + - **`static`** — `npm ci` → build (`build:shared` → backend → frontend) → `lint` → + `typecheck` → `npm test --workspace backend` (the pure unit suites). Equivalent to + `make lint` + `make typecheck` + `make test-be`. + - **`e2e`** — builds & boots the dockerized stack and runs the **in‑container** Playwright + integration + e2e suite against fresh `db`/`api`/`web`, then tears it down. Equivalent + to `make test`. The explicit `-f docker-compose.yml -f docker-compose.test.yml + -f docker-compose.ci.yml` set keeps `docker-compose.override.yml` (dev target) out. +- **`e2e` is gated behind `static`** (`needs: static`): don't spend Docker build/boot time + when the cheap static checks already failed. +- **Node is single‑sourced** via [`.nvmrc`](../../.nvmrc) (`24.16.0`): the `static` job reads + it with `setup-node`'s `node-version-file`, and the images pin the same major/minor through + their digest‑locked `NODE_IMAGE`, kept in lockstep by comment. +- **Caching is mandatory** so installs/builds are reused across runs: the npm download cache, + the Next.js build cache (`frontend/.next/cache`), and Docker build layers. The last uses + **Compose Bake + Buildx** with a CI‑only override, + [`docker-compose.ci.yml`](../../docker-compose.ci.yml), that adds `x-bake` + `cache-from`/`cache-to: type=gha` to the `api`/`web`/`tests` builds — keeping the production + compose files untouched. See [`docs/ci.md`](../ci.md). +- **`npm audit`** runs as an advisory (non‑blocking) step. **Prettier** is not a CI gate — + there is no shared config and `make fmt` is `--write`‑only. + +## Alternatives considered + +- **Run e2e on every PR ungated** — wastes Docker build/boot minutes on PRs that already + fail lint/typecheck/unit; gating gives the same signal cheaper. +- **Run Playwright on the host** (boot `db`/`api`/`web`, drive published ports) — viable, but + diverges from our committed container‑targeted suite (`make test`); the in‑container run + keeps CI identical to the proven local flow. +- **No caching** — simpler YAML, but every run reinstalls deps and rebuilds all images from + scratch; unacceptably slow for a Docker‑building e2e job. + +## Consequences + +- Push/PR now gets the same guarantees as the local `make` flow, automatically. +- Repeat runs are fast: Buildx logs `CACHED` layers when image inputs are unchanged, and the + npm/Next caches skip redundant work. +- One CI‑only compose file (`docker-compose.ci.yml`) exists purely for build‑cache wiring; it + makes no runtime changes and is never used locally. diff --git a/docs/adr/README.md b/docs/adr/README.md index d38111f..789336d 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -11,3 +11,4 @@ MADR‑style records of significant decisions for the Lunaland Casino POC. | [0005](./0005-jwt-auth.md) | Email/password + JWT authentication | | [0006](./0006-docker-compose-topology.md) | Docker Compose topology (db/api/web) | | [0007](./0007-parallel-subagent-build.md) | Contract‑first, parallel sub‑agent build | +| [0008](./0008-github-actions-ci.md) | GitHub Actions CI pipeline (build + static/dynamic, cached) | diff --git a/docs/ci.md b/docs/ci.md new file mode 100644 index 0000000..4516891 --- /dev/null +++ b/docs/ci.md @@ -0,0 +1,119 @@ +# Continuous Integration + +GitHub Actions runs the same quality gate as the local `Makefile`, automatically on +every push and pull request. The workflow lives in +[`.github/workflows/ci.yml`](../.github/workflows/ci.yml); the decision behind it is +recorded in [ADR‑0008](adr/0008-github-actions-ci.md). + +## Overview + +Two jobs, mapped onto the existing `make` targets: + +| Job | What it does | Local equivalent | +|---|---|---| +| **`static`** | build → lint → typecheck → unit tests (in‑process) | `make lint` + `make typecheck` + `make test-be` | +| **`e2e`** | build & boot the dockerized stack, run the Playwright integration + e2e suite in‑container, tear down | `make test` | + +`e2e` is **gated behind `static`** (`needs: static`) — if the cheap static checks fail, +we don't spend time building and booting Docker images. + +## Triggers & concurrency + +- `push` to `main` +- every `pull_request` +- manual `workflow_dispatch` + +A `concurrency` group keyed on the ref cancels superseded in‑flight runs, so a new push +to a branch supersedes the previous run. + +## Node version + +A single source of truth, [`.nvmrc`](../.nvmrc) (`24.16.0`): + +- The **`static`** job reads it via `actions/setup-node` `node-version-file`. +- Locally, `nvm use` picks it up. +- The **images** pin the same major/minor through a digest‑locked `NODE_IMAGE` + (`node:24.16.0-alpine3.23@sha256:…`) in the Dockerfiles — kept in lockstep with + `.nvmrc` by comment. The image keeps the digest pin (not `.nvmrc` literally) for + reproducibility and the `make scan` base‑image CVE gate. + +## `static` job — build & static checks + +Runs on `ubuntu-latest`, Node from `.nvmrc` (`engines.node >=20`): + +1. `npm ci` — clean install from the lockfile (works on Linux; our images install the + same way, so no lockfile workaround is needed). +2. `npm run build:shared` → `npm run build --workspace backend` → + `npm run build --workspace frontend` — build the `@casino/shared` contract first, + then the two apps (the order the images use). +3. `npm run lint` — eslint across the workspaces. +4. `npm run typecheck` — `tsc --noEmit` across the workspaces. +5. `npm test --workspace backend` — the pure unit suites: slot‑engine determinism + + a 200k‑spin RTP check, and the economy helpers. **No DB / Nest** involved. +6. `npm audit --audit-level=high` — **advisory** (`continue-on-error`): surfaces + HIGH/CRITICAL CVEs without failing the run. + +## `e2e` job — integration + e2e (dynamic) + +Runs on `ubuntu-latest`. Mirrors `make test` — the suite runs **inside a `tests` +container** (Playwright image, browsers preinstalled) against fresh `db`/`api`/`web`: + +1. **Buildx** is set up (`docker/setup-buildx-action`) to enable the layer‑cache backend. +2. `cp .env.example .env` — Compose reads `.env` for variable substitution; the example + values are self‑consistent and safe for the ephemeral test stack. +3. **Build & run:** + ``` + COMPOSE_BAKE=1 docker compose \ + -f docker-compose.yml -f docker-compose.test.yml -f docker-compose.ci.yml \ + up --build --abort-on-container-exit --exit-code-from tests tests + ``` + - The three `-f` files are passed explicitly so `docker-compose.override.yml` (the dev + target + bind mounts) is **not** merged. + - `docker-compose.test.yml` swaps in an ephemeral DB and adds the `tests` runner, which + `depends_on` `api`/`web` being healthy. + - The api `runner` image **migrates + seeds on boot**, so the fresh DB is provisioned + automatically before tests start. + - `--abort-on-container-exit --exit-code-from tests` makes the `tests` container's exit + code the suite result. +4. **Always:** on failure the stack logs are dumped; the HTML report is copied out of the + `tests` container and uploaded as the **`playwright-report`** artifact; the stack is torn + down with `down -v`. + +### Reading the report + +When `e2e` fails, open the run's **Artifacts → `playwright-report`**, unzip it, and open +`index.html`. Traces are attached for first‑retry / failed specs (`trace: on-first-retry`). + +## Caching + +Every install and build is cached so repeat runs reuse work instead of starting from scratch: + +| Layer | How | Effect | +|---|---|---| +| npm download cache (`~/.npm`) | `actions/setup-node` `cache: npm`, keyed on `package-lock.json` | faster `npm ci` in `static` | +| Next.js build cache (`frontend/.next/cache`) | `actions/cache`, keyed on the lockfile + frontend sources | `next build` becomes incremental across runs | +| Docker build layers | Buildx + Compose **Bake** (`COMPOSE_BAKE=1`) reading `x-bake type=gha` from [`docker-compose.ci.yml`](../docker-compose.ci.yml) | unchanged image stages restored as `CACHED` | + +Playwright browsers don't need a separate cache: they're baked into the `tests` image's +base, so the Docker layer cache covers them. + +[`docker-compose.ci.yml`](../docker-compose.ci.yml) is a **CI‑only** override (layered last) +that adds `x-bake` `cache-from`/`cache-to: type=gha` to the `api`/`web`/`tests` builds, with a +distinct `scope=` per service. It makes no runtime changes, so the production compose files +stay clean and it is never used locally. + +## Reproduce locally + +Each CI step has a `make` / npm equivalent: + +| CI step | Local command | +|---|---| +| static: build | `npm run build:shared && npm run build --workspace backend && npm run build --workspace frontend` | +| static: lint | `make lint` | +| static: typecheck | `make typecheck` | +| static: unit tests | `make test-be` | +| static: audit | `make audit` | +| e2e: full dynamic suite | `make test` | + +> `make test` uses `docker-compose.yml` + `docker-compose.test.yml` only — the +> `docker-compose.ci.yml` cache override is CI‑specific and not needed locally. diff --git a/frontend/Dockerfile b/frontend/Dockerfile index 1205a22..48c9a02 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -1,8 +1,14 @@ # syntax=docker/dockerfile:1 # Build context is the repo root (see docker-compose.yml). +# Pinned Node 24 LTS on Alpine 3.23, digest-locked for reproducibility. Bump via +# `docker pull node:24-alpine && docker inspect --format '{{index .RepoDigests 0}}' node:24-alpine` +# then re-run `make scan` to confirm the new base is free of HIGH/CRITICAL CVEs. +# Keep the major/minor in lockstep with `.nvmrc` (the CI host + local Node version). +ARG NODE_IMAGE=node:24.16.0-alpine3.23@sha256:2bdb65ed1dab192432bc31c95f94155ca5ad7fc1392fb7eb7526ab682fa5bf14 + # ─── deps ──────────────────────────────────────────────────────────────── -FROM node:22-alpine AS base +FROM ${NODE_IMAGE} AS base WORKDIR /app COPY package.json package-lock.json tsconfig.base.json ./ COPY packages/shared/package.json packages/shared/ @@ -30,7 +36,7 @@ RUN npm run build --workspace @casino/shared \ && npm run build --workspace frontend # ─── runner (production) ───────────────────────────────────────────────── -FROM node:22-alpine AS runner +FROM ${NODE_IMAGE} AS runner WORKDIR /app ENV NODE_ENV=production ENV PORT=3000 diff --git a/frontend/package.json b/frontend/package.json index 0bcf535..0356a8c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -21,7 +21,7 @@ "@eslint/eslintrc": "^3.2.0", "@tailwindcss/postcss": "^4.3.0", "@types/canvas-confetti": "^1.9.0", - "@types/node": "^22.10.0", + "@types/node": "^24.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "eslint": "^9.17.0", diff --git a/package-lock.json b/package-lock.json index 76df27d..804be1b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,7 +45,7 @@ "@nestjs/testing": "^11.1.0", "@types/bcryptjs": "^2.4.6", "@types/jest": "^29.5.14", - "@types/node": "^22.10.0", + "@types/node": "^24.0.0", "@types/passport-jwt": "^4.0.1", "@types/pg": "^8.11.10", "@types/supertest": "^6.0.2", @@ -72,7 +72,7 @@ "@eslint/eslintrc": "^3.2.0", "@tailwindcss/postcss": "^4.3.0", "@types/canvas-confetti": "^1.9.0", - "@types/node": "^22.10.0", + "@types/node": "^24.0.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "eslint": "^9.17.0", @@ -4356,12 +4356,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.19.19", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", - "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/passport": { @@ -14748,9 +14748,9 @@ } }, "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, "node_modules/universalify": {