From 1f076e71b3f843d8dc195f21ede2b7907ad207f8 Mon Sep 17 00:00:00 2001 From: fzsf163 Date: Mon, 20 Jul 2026 20:18:04 +0600 Subject: [PATCH] docker and CI for git actions --- .claude/agents/docker-builder.md | 42 +++++++++++++ .dockerignore | 40 ++++++++++++ .github/workflows/ci.yml | 104 +++++++++++++++++++++++++++++++ Dockerfile | 80 ++++++++++++++++++++++++ docker/nginx.conf | 20 ++++++ 5 files changed, 286 insertions(+) create mode 100644 .claude/agents/docker-builder.md create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 Dockerfile create mode 100644 docker/nginx.conf diff --git a/.claude/agents/docker-builder.md b/.claude/agents/docker-builder.md new file mode 100644 index 0000000..9299c84 --- /dev/null +++ b/.claude/agents/docker-builder.md @@ -0,0 +1,42 @@ +--- +name: docker-builder +description: Authors and reviews Dockerfiles, .dockerignore, and container build steps for this repo against a strict build/optimization/security/maintainability rule set. Use when adding or changing containerization, the Docker CI job, or when reviewing a Dockerfile before merge. +tools: Read, Grep, Glob, Edit, Write, Bash +model: sonnet +--- + +You containerize a **client-side Vite SPA** (React 19, TypeScript, pnpm). The build compiles to static assets in `dist/`; the runtime just serves them (non-root nginx). **No Node.js, pnpm, or build tooling belongs in the runtime image**, and no auth/secret logic runs in the container — the app only calls the backend. + +Read `CLAUDE.md`, `package.json` (`packageManager` pins the pnpm version), and the existing `Dockerfile` / `docker/nginx.conf` / `.dockerignore` first, and match their patterns. When you edit a Dockerfile, keep it passing `hadolint` and the repo's Docker CI job. + +Apply these rules. When reviewing, cite the offending line and give the minimal fix; when authoring, satisfy every applicable rule. + +### Build +1. **Minimal base images** — alpine/slim variants (`node:*-alpine` build stage, `nginx-unprivileged:*-alpine` runtime). +2. **Multi-stage build** — a `build` stage (deps + `pnpm build`) and a lean `runtime` stage that only `COPY --from=build` the `dist/` output. +3. **Derive the version from the project** — take it from `package.json` (`APP_VERSION` build arg) rather than hardcoding, and surface it via an OCI label. + +### Optimization +1. **Layer caching** — copy `package.json` + `pnpm-lock.yaml` and install *before* copying source, so source edits don't invalidate the deps layer. Put rarely-changing steps first; keep layer count reasonable. Use a BuildKit cache mount for the pnpm store. +2. **Combine RUN commands** — chain related shell steps with `&&`, clean caches in the same layer, and don't leave separate throwaway layers. +3. **Explicit COPY** — copy named paths (configs, `src`, `public`); never `COPY . .`. Keep `.dockerignore` tight as defense-in-depth. +4. **Production deps only in the final image** — the runtime image carries zero Node deps (static nginx). The build stage may use dev deps for `tsc`/`vite`; they must not leak into runtime. + +### Security +1. **Non-root user** — run as a non-root UID (the `nginx-unprivileged` base already does; if you switch base, add a `USER`). +2. **Pin image versions** — pin base images to a specific tag, ideally a digest (`image@sha256:…`); never `latest`. +3. **Official images** — only official/trusted publishers (Docker Official / verified org images). +4. **No secrets in the image** — no tokens/keys in `ENV`, `ARG` defaults, or copied files. Only public `VITE_*` values may be build args (they ship in the client bundle); everything sensitive stays backend-side. +5. **No sudo** — never install or invoke `sudo`. +6. **Minimal packages** — install nothing beyond what's required; no `apk add`/`apt-get install` of convenience tools. +7. **COPY over ADD** — use `COPY`; avoid `ADD` (no implicit URL fetches or auto-extraction). +8. **No debugging tools** — no curl/wget/vim/netcat/shells-as-tools baked into the runtime image. + +### Maintainability +1. **Sort arguments** — keep `ARG`/`ENV`/multi-value lists alphabetically ordered where practical. +2. **Use WORKDIR** — set `WORKDIR` instead of `cd` in `RUN`. +3. **Exec form for CMD/ENTRYPOINT** — `CMD ["nginx", "-g", "daemon off;"]`, not shell form, so the process is PID 1 and gets signals. +4. **Comment non-obvious decisions** — explain *why* (cache mounts, unprivileged port 8080, SPA fallback), not the obvious. +5. **Add OCI labels** — `org.opencontainers.image.*` (title, description, version, source, licenses). + +Do not add auth, session, or secret-handling logic to any container artifact. If a change would require a runtime backend or secrets in the image, stop and flag it rather than baking them in. diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..b9d62df --- /dev/null +++ b/.dockerignore @@ -0,0 +1,40 @@ +# Keep the build context tiny and deterministic. The Dockerfile uses explicit +# COPY lines, so this is defense-in-depth against leaking local/dev artifacts. + +# Dependencies & build output (reinstalled/rebuilt inside the image) +node_modules +dist +dist-ssr +.tanstack +.dependencygraph + +# VCS & CI +.git +.github + +# Agent / tooling config (not needed to build the app) +.claude +.agents +.mcp.json +skills-lock.json + +# Env & secrets — never ship these into the image +.env +.env.* +*.local + +# Editor / OS noise +.vscode +.idea +.DS_Store +*.log + +# Docs & non-build project files +docs +*.md +LICENSE +netlify.toml +cz.yaml + +# Foreign lockfile — this project uses pnpm-lock.yaml +package-lock.json diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d24af6e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,104 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +# Least-privilege by default; jobs opt into more only if they need it. +permissions: + contents: read + +# Only the latest run per ref matters — cancel superseded runs. +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + # --------------------------------------------------------------------------- + # Verify the app with pnpm: lint, format, typecheck + build. + # --------------------------------------------------------------------------- + verify: + name: Lint, format & build (pnpm) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Installs the exact pnpm from package.json's "packageManager" field. + - name: Install pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Lint + run: pnpm lint + + - name: Format check + run: pnpm format:check + + - name: Build + run: pnpm build + env: + # ENV RULE: only public VITE_* values here — Vite inlines them into the + # client bundle. Never reference secrets; use repo/environment *variables* + # (vars.*), not secrets.*, so nothing sensitive can leak into shipped JS. + VITE_AVATAR: ${{ vars.VITE_AVATAR }} + VITE_API_URL: ${{ vars.VITE_API_URL }} # placeholder until the backend lands + + # --------------------------------------------------------------------------- + # Enforce the Docker rules: lint the Dockerfile, build the image, scan it. + # --------------------------------------------------------------------------- + docker: + name: Docker lint, build & scan + runs-on: ubuntu-latest + needs: verify + steps: + - name: Checkout + uses: actions/checkout@v4 + + # Static Dockerfile analysis (best-practice + security lint). + - name: Lint Dockerfile (hadolint) + uses: hadolint/hadolint-action@v3.1.0 + with: + dockerfile: Dockerfile + + # Derive the image version from the project (package.json). + - name: Resolve app version + id: meta + run: echo "version=$(node -p "require('./package.json').version")" >> "$GITHUB_OUTPUT" + + - name: Set up Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build image + uses: docker/build-push-action@v6 + with: + context: . + push: false + load: true + tags: faishon-e-com:ci + build-args: | + APP_VERSION=${{ steps.meta.outputs.version }} + VITE_AVATAR=${{ vars.VITE_AVATAR }} + VITE_API_URL=${{ vars.VITE_API_URL }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # Fail the build on known HIGH/CRITICAL vulns and any leaked secrets. + - name: Scan image (Trivy) + uses: aquasecurity/trivy-action@0.28.0 + with: + image-ref: faishon-e-com:ci + scanners: vuln,secret + severity: HIGH,CRITICAL + ignore-unfixed: true + exit-code: '1' diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1986ce6 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,80 @@ +# syntax=docker/dockerfile:1 + +# ============================================================================= +# Fashion e-commerce SPA — multi-stage image. +# Stage 1 (build) compiles the Vite SPA with pnpm. +# Stage 2 (runtime) serves the static bundle from a minimal, non-root nginx. +# No Node.js, pnpm, or build tooling survives into the runtime image. +# ============================================================================= + +# ---- Build arguments (sorted; pinned versions) ----------------------------- +# NOTE: pin to digests (image@sha256:...) for fully reproducible builds. +ARG NGINX_VERSION=1.27-alpine +ARG NODE_VERSION=22-alpine +ARG PNPM_VERSION=9.12.1 + +# ----------------------------------------------------------------------------- +# Stage 1: build the static assets +# ----------------------------------------------------------------------------- +FROM node:${NODE_VERSION} AS build + +# Activate the exact pnpm from package.json's "packageManager" via corepack. +ARG PNPM_VERSION +ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 +RUN corepack enable && corepack prepare "pnpm@${PNPM_VERSION}" --activate + +WORKDIR /app + +# Install deps first, keyed only on the manifest + lockfile, so this layer is +# reused until dependencies actually change (source edits must not bust it). +# The BuildKit cache mount keeps the pnpm store warm across builds. +COPY package.json pnpm-lock.yaml ./ +RUN --mount=type=cache,id=pnpm-store,target=/root/.local/share/pnpm/store \ + pnpm install --frozen-lockfile + +# Explicit copies only — never `COPY . .`. Add build inputs here if new +# top-level config the build needs is introduced. +COPY tsconfig.json tsconfig.app.json tsconfig.node.json vite.config.ts postcss.config.js index.html ./ +COPY public ./public +COPY src ./src + +# Vite inlines VITE_*-prefixed vars into the client bundle at build time. +# ONLY public values may be passed here — a secret build-arg would be baked +# into shipped JS. Real auth/tokens are handled by the backend at runtime. +# VITE_AVATAR — currently consumed by the app (public asset URL). +# VITE_API_URL — placeholder for the planned backend base URL (see roadmap). +ARG VITE_AVATAR="" +ARG VITE_API_URL="" +ENV VITE_API_URL=${VITE_API_URL} \ + VITE_AVATAR=${VITE_AVATAR} + +RUN pnpm build + +# ----------------------------------------------------------------------------- +# Stage 2: runtime — static files on non-root nginx +# ----------------------------------------------------------------------------- +# nginxinc/nginx-unprivileged runs as UID 101 (non-root) and listens on 8080. +FROM nginxinc/nginx-unprivileged:${NGINX_VERSION} AS runtime + +# Version is derived from the project (package.json) and passed by build tooling. +ARG APP_VERSION=0.0.0 + +# OCI image metadata for provenance and tooling. +LABEL org.opencontainers.image.title="faishon-e-com" \ + org.opencontainers.image.description="Fashion e-commerce SPA (storefront + admin)" \ + org.opencontainers.image.version="${APP_VERSION}" \ + org.opencontainers.image.source="https://github.com/fzsf163/faishon-e-com-tanstack" \ + org.opencontainers.image.licenses="MIT" + +# SPA config with history fallback (mirrors the Netlify redirect). +COPY docker/nginx.conf /etc/nginx/conf.d/default.conf + +# Static assets only — no shell utilities, package managers, or debug tooling +# are installed into this layer. +COPY --from=build /app/dist /usr/share/nginx/html + +# Already runs as the non-root UID baked into the base image (USER 101). +EXPOSE 8080 + +# Exec form so nginx is PID 1 and receives signals directly. +CMD ["nginx", "-g", "daemon off;"] diff --git a/docker/nginx.conf b/docker/nginx.conf new file mode 100644 index 0000000..6b5bd40 --- /dev/null +++ b/docker/nginx.conf @@ -0,0 +1,20 @@ +server { + # nginx-unprivileged listens on 8080 as a non-root user. + listen 8080; + server_name _; + + root /usr/share/nginx/html; + index index.html; + + # Vite emits content-hashed filenames under /assets — safe to cache forever. + location /assets/ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + # SPA history fallback — mirrors the Netlify `/* -> /index.html` redirect. + location / { + try_files $uri $uri/ /index.html; + } +}