From ca0c59bc1559741781d7cc2d28081a425e8bdb68 Mon Sep 17 00:00:00 2001 From: Joey Scales Date: Tue, 21 Jul 2026 14:17:32 -0400 Subject: [PATCH 1/2] =?UTF-8?q?chore:=20production=20hardening=20=E2=80=94?= =?UTF-8?q?=20Husky=20pre-push,=20GitHub=20Actions=20CI,=20Dockerfile?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gate and package the finished service (closes #6): - Husky pre-push hook runs `npm run verify` (typecheck → lint → test, fail-fast); `git push --no-verify` documented as the escape hatch. - GitHub Actions CI mirrors the gate (Node 20, npm ci, typecheck, lint, test) on push/PR, then a docker-build job (no registry push). - Multi-stage Dockerfile: builder compiles; slim runtime runs dev-dependency-free as the non-root `node` user, with the catalogue seed copied in. HEALTHCHECK probes GET /health via Node's global fetch. - .dockerignore excludes node_modules, dist, .git, tests, coverage. - README with CI badge documenting endpoints, scripts, gate, and Docker. Co-Authored-By: Claude Opus 4.8 (1M context) --- .dockerignore | 29 ++++++++++++++++++ .github/workflows/ci.yml | 44 +++++++++++++++++++++++++++ .husky/pre-push | 6 ++++ Dockerfile | 40 ++++++++++++++++++++++++ README.md | 66 ++++++++++++++++++++++++++++++++++++++++ package-lock.json | 17 +++++++++++ package.json | 5 ++- 7 files changed, 206 insertions(+), 1 deletion(-) create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100755 .husky/pre-push create mode 100644 Dockerfile create mode 100644 README.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f44588d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,29 @@ +# Rebuilt or irrelevant inside the image — keep the build context small and hermetic. +node_modules +dist +coverage +.git +.gitignore +.github +.husky + +# Tests and their config never run in the runtime image. +test +vitest.config.mts + +# Local tooling, docs, and secrets. +docs +*.md +.env +.env.* +.DS_Store +.vscode +.idea +.nvmrc +.prettierrc.json +.prettierignore +eslint.config.mjs + +# The image build files themselves. +Dockerfile +.dockerignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..22651cd --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +# Cancel superseded runs on the same ref to save runner minutes. +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + verify: + name: Typecheck · Lint · Test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - run: npm ci + # Mirror the pre-push hook: same order, same fail-fast semantics. + - run: npm run typecheck + - run: npm run lint + - run: npm test + + docker: + name: Docker build + needs: verify + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + # Build the runtime image to prove it assembles; no registry push. + - uses: docker/build-push-action@v6 + with: + context: . + push: false + load: true + tags: infinite-choice:ci + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 0000000..fed2b8f --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,6 @@ +# Pre-push quality gate: typecheck → lint → test (via `npm run verify`). +# `set -e` makes the fail-fast contract explicit here, independent of husky's runner. +# Mirrors GitHub Actions CI so failures surface locally first. +# Escape hatch: `git push --no-verify` to skip (use sparingly). +set -e +npm run verify diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..389ac06 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +# syntax=docker/dockerfile:1 + +# ---- Builder: install the full toolchain and compile TypeScript to dist/ ---- +FROM node:20-slim AS builder +WORKDIR /app + +# Install deps against the lockfile first so this layer caches across source edits. +# --ignore-scripts skips lifecycle hooks (e.g. husky's `prepare`, which needs a +# git repo that isn't in the build context) — the build only needs `tsc`. +COPY package.json package-lock.json ./ +RUN npm ci --ignore-scripts + +COPY tsconfig.json tsconfig.build.json ./ +COPY src ./src +RUN npm run build + +# ---- Runtime: production dependencies only, non-root, slim ---- +FROM node:20-slim AS runtime +ENV NODE_ENV=production +WORKDIR /app + +# Production dependencies only — no dev toolchain ships in the final image. +COPY package.json package-lock.json ./ +RUN npm ci --omit=dev --ignore-scripts && npm cache clean --force + +# Compiled output plus the catalogue seed the app reads from cwd/data at runtime. +COPY --from=builder /app/dist ./dist +COPY data ./data + +# Drop privileges: run as the unprivileged `node` user baked into the base image. +USER node + +EXPOSE 3000 + +# Liveness probe hits the app's own GET /health using Node's global fetch, +# so the slim image needs no curl/wget. Non-zero exit marks the container unhealthy. +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD node --no-warnings -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" + +CMD ["node", "dist/server.js"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..13f7e13 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# InfiniteChoice — Hotel Discovery API + +[![CI](https://github.com/kernvex/InfiniteChoice/actions/workflows/ci.yml/badge.svg)](https://github.com/kernvex/InfiniteChoice/actions/workflows/ci.yml) + +A lightweight, production-ready RESTful API for finding and exploring hotels. Built +with [Fastify](https://fastify.dev/), TypeScript, and Zod. See [`CONTEXT.md`](CONTEXT.md) +for the domain glossary. + +## Endpoints + +| Method | Path | Description | +| ------ | ------------------- | ----------------------------------------------- | +| GET | `/health` | Liveness probe | +| GET | `/hotels` | Search & filter properties (summary projection) | +| GET | `/hotels/:id` | Property detail | +| GET | `/hotels/:id/rooms` | Room availability & pricing for a stay window | +| GET | `/docs` | Swagger UI | + +## Requirements + +- Node.js 20 (see [`.nvmrc`](.nvmrc)) + +## Getting started + +```sh +npm ci +npm run dev # hot-reloading dev server on http://localhost:3000 +``` + +The service reads its catalogue seed from [`data/hotels.json`](data/hotels.json) at startup. + +### Scripts + +| Script | Purpose | +| ------------------- | ------------------------------------------------------- | +| `npm run dev` | Dev server with hot reload | +| `npm run build` | Compile TypeScript to `dist/` | +| `npm start` | Run the compiled server | +| `npm run typecheck` | Type-check without emitting | +| `npm run lint` | ESLint | +| `npm test` | Run the test suite once (Vitest) | +| `npm run verify` | `typecheck → lint → test`, fail-fast (the quality gate) | + +## Quality gate + +A Husky **pre-push** hook runs `npm run verify` (`typecheck → lint → test`, +fail-fast), so failures surface locally before they reach CI. To bypass it in a pinch: + +```sh +git push --no-verify +``` + +GitHub Actions [CI](.github/workflows/ci.yml) mirrors the same checks on every push +and pull request, then builds the Docker image (no registry push). + +## Docker + +Multi-stage build producing a slim, dev-dependency-free image that runs as the +non-root `node` user with a `/health` HEALTHCHECK: + +```sh +docker build -t infinite-choice . +docker run --rm -p 3000:3000 infinite-choice +``` + +Then hit . diff --git a/package-lock.json b/package-lock.json index 4a23ea4..5b14c96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@types/node": "^20.19.43", "eslint": "^10.7.0", "eslint-config-prettier": "^10.1.8", + "husky": "^9.1.7", "prettier": "^3.9.6", "tsx": "^4.23.1", "typescript": "^6.0.3", @@ -2490,6 +2491,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/husky": { + "version": "9.1.7", + "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", + "integrity": "sha512-5gs5ytaNjBrh5Ow3zrvdUUY+0VxIuWVL4i9irt6friV+BqdCfmV11CQTWMiBYWHbXhco+J1kHfTOUkePhCDvMA==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "bin.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", diff --git a/package.json b/package.json index 99e06ae..710a0c4 100644 --- a/package.json +++ b/package.json @@ -15,7 +15,9 @@ "test:watch": "vitest", "typecheck": "tsc --noEmit", "lint": "eslint .", - "format": "prettier --write ." + "verify": "npm run typecheck && npm run lint && npm test", + "format": "prettier --write .", + "prepare": "husky" }, "dependencies": { "@fastify/swagger": "^9.8.1", @@ -29,6 +31,7 @@ "@types/node": "^20.19.43", "eslint": "^10.7.0", "eslint-config-prettier": "^10.1.8", + "husky": "^9.1.7", "prettier": "^3.9.6", "tsx": "^4.23.1", "typescript": "^6.0.3", From 050577275b894f1d509d79b956afc85cd2201742 Mon Sep 17 00:00:00 2001 From: Joey Scales Date: Tue, 21 Jul 2026 14:30:47 -0400 Subject: [PATCH 2/2] =?UTF-8?q?refactor:=20address=20review=20=E2=80=94=20?= =?UTF-8?q?single-source=20CI=20gate=20and=20Docker=20port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI runs `npm run verify` instead of re-listing typecheck/lint/test, so the hook and CI share one gate definition and can't drift. - Dockerfile sets `ENV PORT=3000` and `EXPOSE ${PORT}`, giving the port a single source shared by the app, EXPOSE, and the healthcheck. - Document why CI's `push` trigger is main-only (feature branches run via PR; avoids double CI runs). Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/ci.yml | 8 ++++---- Dockerfile | 7 +++++-- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 22651cd..ad6e580 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,6 +1,8 @@ name: CI on: + # Trunk pushes plus every PR. Feature-branch work is covered via its PR, so we + # don't list branches under `push` — that would double-run CI (push + PR events). push: branches: [main] pull_request: @@ -21,10 +23,8 @@ jobs: node-version: 20 cache: npm - run: npm ci - # Mirror the pre-push hook: same order, same fail-fast semantics. - - run: npm run typecheck - - run: npm run lint - - run: npm test + # Run the same gate the pre-push hook does — one source of truth. + - run: npm run verify docker: name: Docker build diff --git a/Dockerfile b/Dockerfile index 389ac06..2455f2f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,7 +16,10 @@ RUN npm run build # ---- Runtime: production dependencies only, non-root, slim ---- FROM node:20-slim AS runtime -ENV NODE_ENV=production +# One source of truth for the port: the app, EXPOSE, and the healthcheck all +# read PORT, so overriding it at runtime keeps them in lockstep. +ENV NODE_ENV=production \ + PORT=3000 WORKDIR /app # Production dependencies only — no dev toolchain ships in the final image. @@ -30,7 +33,7 @@ COPY data ./data # Drop privileges: run as the unprivileged `node` user baked into the base image. USER node -EXPOSE 3000 +EXPOSE ${PORT} # Liveness probe hits the app's own GET /health using Node's global fetch, # so the slim image needs no curl/wget. Non-zero exit marks the container unhealthy.