From 46a72f815ec9d4ac66abcd86d98311272fdf39b9 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 28 Apr 2026 01:09:17 +0800 Subject: [PATCH] Harden production deployment workflow --- .env.example | 4 +- README.md | 2 + docs/deployment-runbook.md | 61 +++++++++++++++++++ docs/production-readiness.md | 10 +++ docs/production-release-checklist.md | 58 ++++++++++++++++++ eslint.config.mjs | 35 +++++++++++ package.json | 5 +- src/app/(frontend)/[locale]/contact/page.tsx | 2 +- .../(frontend)/[locale]/downloads/page.tsx | 2 +- src/app/(frontend)/[locale]/layout.tsx | 2 +- .../[locale]/products/[slug]/page.tsx | 2 +- src/app/(frontend)/[locale]/products/page.tsx | 2 +- src/app/api/health/route.ts | 35 +++++++++++ .../home/home-featured-products.tsx | 2 - src/components/home/home-hero-banners.tsx | 2 +- src/components/home/home-hero.tsx | 1 - src/components/home/home-news.tsx | 1 - src/components/home/home-recognition.tsx | 2 - src/components/home/home-statement.tsx | 2 +- src/payload.config.ts | 13 +++- .../admin/HtmlPreviewTextareaField.tsx | 2 + src/payload/collections/Products.ts | 6 +- src/scripts/smoke-check.mjs | 13 +++- 23 files changed, 243 insertions(+), 21 deletions(-) create mode 100644 docs/deployment-runbook.md create mode 100644 docs/production-release-checklist.md create mode 100644 eslint.config.mjs create mode 100644 src/app/api/health/route.ts diff --git a/.env.example b/.env.example index 7671f47..c87d719 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,8 @@ # Next.js -NEXT_PUBLIC_SITE_URL=http://localhost:3000 +NEXT_PUBLIC_SITE_URL=https://www.eversolo.com # Payload PAYLOAD_SECRET=replace-me-with-a-long-random-string DATABASE_URI=postgres://postgres:postgres@localhost:5432/eversoloweb +PAYLOAD_DB_PUSH=false +PAYLOAD_RUN_MIGRATIONS_ON_START=false diff --git a/README.md b/README.md index 8fbbb24..67e4e41 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ Project documentation for the Eversolo brand website redesign. - `ROADMAP.md` — phases, deliverables, roles - `DISCOVERY-QUESTIONS.md` — remaining open items - `docs/production-readiness.md` — launch hardening rules for CMS content, cache, admin UX, and QA +- `docs/deployment-runbook.md` — production deployment, data export/import, migration, and rollback procedure +- `docs/production-release-checklist.md` — preflight checklist for each release ## Status Phase 2/3 — Build and seed integration are now being hardened toward production readiness. diff --git a/docs/deployment-runbook.md b/docs/deployment-runbook.md new file mode 100644 index 0000000..059a83c --- /dev/null +++ b/docs/deployment-runbook.md @@ -0,0 +1,61 @@ +# Deployment Runbook + +This runbook is the source of truth for taking the Eversolo Payload/Next.js site from a local database and media folder to a production deployment. + +## Release Inputs + +- Git commit to deploy. +- PostgreSQL database dump exported from the approved local database. +- `media/` and `files/` directories exported from the same local workspace as the database dump. +- Production environment variables from `.env.example`, filled with production values. + +## Required Environment + +Use Node.js 22 LTS or newer. The project declares `>=20.9.0`, but production should use one pinned LTS line. + +Required variables: + +- `NEXT_PUBLIC_SITE_URL`: public canonical origin, for example `https://www.eversolo.com`. +- `PAYLOAD_SECRET`: long random secret, never use the example value. +- `DATABASE_URI`: PostgreSQL connection string. +- `PAYLOAD_DB_PUSH=false`: production must use migrations, not schema push. +- `PAYLOAD_RUN_MIGRATIONS_ON_START=false`: production startup should not run migrations interactively. + +Optional variables: + +- `PORT`: runtime port for `next start`. +- `SMOKE_BASE_URL`: base URL used by `npm run smoke`. + +## Deployment Steps + +1. Restore the approved PostgreSQL dump into the production database. +2. Copy `media/` and `files/` to the production persistent upload volume. +3. Install dependencies with `npm ci`. +4. Run `npm run generate:types` only during build validation, not as a production data mutation step. +5. Run `npm run typecheck`. +6. Run `npm run build`. +7. Run `npm run payload:migrate`. +8. Run `npm run payload:migrate:status` and confirm every migration is `Yes`. +9. Start the app with `npm run start`. +10. Run `npm run smoke -- "$NEXT_PUBLIC_SITE_URL"`. + +## Data Rules + +- Do not run `PAYLOAD_DB_PUSH=true` against production. +- Do not rely on application startup to run migrations. Use the explicit migration step. +- Before exporting a local database for production, confirm `payload_migrations` has no `batch = -1` dev marker. That marker means schema push history is still recorded and production migration commands can prompt interactively. +- Do not edit migrations after they have shipped to production. Add a new forward migration instead. +- Keep schema migrations and data backfills separate. Backfill scripts in `src/scripts/` are manual operational tools, not automatic boot steps. +- The deployed database and upload directories must come from the same local export, otherwise media relationships can point at missing files. + +## Rollback + +Rollback means switching code and data together: + +1. Stop traffic or move traffic back to the previous deployment. +2. Restore the previous database dump. +3. Restore the matching previous `media/` and `files/` directories. +4. Deploy the previous Git commit. +5. Re-run smoke checks before reopening traffic. + +Do not run migration down scripts on production as the default rollback path. diff --git a/docs/production-readiness.md b/docs/production-readiness.md index eea0b8d..e0c97ce 100644 --- a/docs/production-readiness.md +++ b/docs/production-readiness.md @@ -23,3 +23,13 @@ This project is being tightened toward launch as a CMS-first Eversolo brand site - Prefer compact admin groups, list-style arrays, and explicit per-locale documents for user-facing content. - Re-run `npm run generate:types`, `npm run build`, and the smoke check after schema or cache changes. - After restarting a local preview, check both `/en` and `/zh` for homepage, product detail, downloads, news, dealers, support, and admin access. + +## Deployment Discipline + +- Production uses migrations only. `PAYLOAD_DB_PUSH` must be `false`. +- Production startup does not auto-run migrations by default. Run `npm run payload:migrate` as an explicit release step. +- The local release database must not contain Payload's `dev` migration marker (`batch = -1`) before it is exported. +- Local database exports must be paired with the same `media/` and `files/` directories. +- Backfill scripts are manual release tools. They should be run before export, verified, and then treated as data already present in the production dump. +- Production startup should fail fast if required secrets are missing instead of booting with development defaults. +- `/api/health` should return `200` before traffic is switched to the deployment. diff --git a/docs/production-release-checklist.md b/docs/production-release-checklist.md new file mode 100644 index 0000000..7e73881 --- /dev/null +++ b/docs/production-release-checklist.md @@ -0,0 +1,58 @@ +# Production Release Checklist + +Use this checklist before every production deployment. + +## Code + +- [ ] Working tree is clean. +- [ ] Latest commit is the intended release commit. +- [ ] `npm ci` succeeds from a clean install. +- [ ] `npm run typecheck` passes. +- [ ] `npm run build` passes. +- [ ] `npm run payload:migrate:status` shows all migrations as `Yes` on the release database. +- [ ] `payload_migrations` has no `batch = -1` dev marker before exporting/importing the database. +- [ ] No new user-facing fallback copy was introduced without a CMS field. + +## Environment + +- [ ] `NEXT_PUBLIC_SITE_URL` is the final public domain. +- [ ] `PAYLOAD_SECRET` is set and is not `change-me` or the example value. +- [ ] `DATABASE_URI` points to the production PostgreSQL database. +- [ ] `PAYLOAD_DB_PUSH=false` is set. +- [ ] `PAYLOAD_RUN_MIGRATIONS_ON_START=false` is set unless a one-off controlled migration boot is intentionally planned. +- [ ] Upload directories `media/` and `files/` are persistent and backed up. + +## CMS Data + +- [ ] English and Chinese homepage content render. +- [ ] Product list and at least one product detail page render in both locales. +- [ ] Downloads list and one download detail page render. +- [ ] News list and one news detail page render. +- [ ] Reviews page tabs render, with local media covers where present. +- [ ] Support page and video tutorials render, with local media covers. +- [ ] Dealers page map visibility matches CMS setting. + +## Smoke Routes + +Run: + +```bash +npm run smoke -- "$NEXT_PUBLIC_SITE_URL" +``` + +Required manual spot checks: + +- `/en` +- `/zh` +- `/en/products/dmp-a10` +- `/en/reviews?tab=reviews` +- `/en/support` +- `/en/support/tutorial` +- `/admin` +- `/api/health` + +## Launch Notes + +- Keep old production database and upload volume snapshots until the new release has passed smoke checks and editorial QA. +- If schema push or migration prompts appear during app startup, stop. Production startup should not be running schema push or migrations. +- If media thumbnails are missing, verify the upload volume before changing CMS records. diff --git a/eslint.config.mjs b/eslint.config.mjs new file mode 100644 index 0000000..287abb0 --- /dev/null +++ b/eslint.config.mjs @@ -0,0 +1,35 @@ +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { FlatCompat } from '@eslint/eslintrc' + +const dirname = path.dirname(fileURLToPath(import.meta.url)) +const compat = new FlatCompat({ + baseDirectory: dirname, +}) + +export default [ + { + ignores: [ + '.next/**', + '.next-dev/**', + '.turbo/**', + '.claude/**', + '.tmp/**', + 'media/**', + 'files/**', + 'node_modules/**', + 'public/src/**', + 'src/seed/raw/**', + 'src/seed/structured/**', + 'src/seed/normalized/**', + 'src/payload-types.ts', + ], + }, + ...compat.extends('next/core-web-vitals', 'next/typescript'), + { + rules: { + '@next/next/no-img-element': 'off', + '@typescript-eslint/no-unused-vars': 'off', + }, + }, +] diff --git a/package.json b/package.json index d4df882..4257845 100644 --- a/package.json +++ b/package.json @@ -10,9 +10,12 @@ "build": "next build && node ./src/scripts/fix-next-main-app.mjs", "start": "next start", "smoke": "node ./src/scripts/smoke-check.mjs", - "lint": "next lint", + "typecheck": "tsc --noEmit", + "lint": "eslint src next.config.mjs tailwind.config.ts --max-warnings=0", "generate:types": "payload generate:types", "payload": "payload", + "payload:migrate": "payload migrate", + "payload:migrate:status": "payload migrate:status", "seed:fetch-legacy": "node ./src/seed/fetch-legacy-html.mjs", "seed:parse-legacy": "node ./src/seed/parse-legacy-html.mjs", "seed:extract-structured": "node ./src/seed/extract-structured-data.mjs", diff --git a/src/app/(frontend)/[locale]/contact/page.tsx b/src/app/(frontend)/[locale]/contact/page.tsx index 9122f97..fc44dae 100644 --- a/src/app/(frontend)/[locale]/contact/page.tsx +++ b/src/app/(frontend)/[locale]/contact/page.tsx @@ -164,7 +164,7 @@ async function getContactUsPageData(locale: Locale) { return payload.findGlobal({ depth: 1, locale, - slug: 'contactUsPage' as any, + slug: 'contactUsPage', }) } diff --git a/src/app/(frontend)/[locale]/downloads/page.tsx b/src/app/(frontend)/[locale]/downloads/page.tsx index 1935a68..7793ca5 100644 --- a/src/app/(frontend)/[locale]/downloads/page.tsx +++ b/src/app/(frontend)/[locale]/downloads/page.tsx @@ -341,7 +341,7 @@ async function getDownloadsPageSettings(locale: Locale): Promise - {/* eslint-disable-next-line @next/next/no-img-element */} {getProductDisplayAlt(product)} {lifestyleImageUrl ? ( - // eslint-disable-next-line @next/next/no-img-element {getProductDisplayAlt(product)} - {/* eslint-disable-next-line @next/next/no-img-element */} + { } {item.alt} ) : heroImageUrl ? ( - // eslint-disable-next-line @next/next/no-img-element {headline {item.imageUrl ? ( - // eslint-disable-next-line @next/next/no-img-element {item.title} {mediaUrl ? ( - // eslint-disable-next-line @next/next/no-img-element {award.label
{mediaUrl ? ( - // eslint-disable-next-line @next/next/no-img-element {sourceName} {imageUrl ? (
- {/* eslint-disable-next-line @next/next/no-img-element */} + { } {title setMode('html')} + role="tab" type="button" > HTML @@ -47,6 +48,7 @@ export function HtmlPreviewTextareaField(props: TextareaFieldClientProps) { aria-selected={mode === 'preview'} className="html-preview-textarea__tab" onClick={() => setMode('preview')} + role="tab" type="button" > Preview diff --git a/src/payload/collections/Products.ts b/src/payload/collections/Products.ts index decaa3b..2a26447 100644 --- a/src/payload/collections/Products.ts +++ b/src/payload/collections/Products.ts @@ -1,4 +1,4 @@ -import type { CollectionConfig, Field } from 'payload' +import type { CollectionConfig, Field, Payload, Where } from 'payload' import { getProductTemplateOptions } from '@/lib/admin-template-options' import { compactArrayAdmin } from '@/payload/admin/compact-array-admin' import { independentDocListFilter } from '@/payload/admin/localized-list-filter' @@ -31,7 +31,7 @@ const seoFields: Field[] = [ async function preventDuplicateProductIdentity(args: { data?: Record | null originalDoc?: Record | null - req?: { payload?: any } + req?: { payload?: Payload } }) { const { data, originalDoc, req } = args if (!data || !req?.payload) return data @@ -40,7 +40,7 @@ async function preventDuplicateProductIdentity(args: { const model = typeof data.model === 'string' ? data.model.trim() : typeof originalDoc?.model === 'string' ? originalDoc.model.trim() : '' const slug = typeof data.slug === 'string' ? data.slug.trim() : typeof originalDoc?.slug === 'string' ? originalDoc.slug.trim() : '' const currentId = originalDoc?.id - const duplicateChecks = [] + const duplicateChecks: Where[] = [] if (docLocale && model) { duplicateChecks.push({ model: { equals: model } }) diff --git a/src/scripts/smoke-check.mjs b/src/scripts/smoke-check.mjs index c231297..537ac63 100644 --- a/src/scripts/smoke-check.mjs +++ b/src/scripts/smoke-check.mjs @@ -18,8 +18,16 @@ const routes = [ '/zh/dealers', '/en/dealers?tab=stores', '/zh/dealers?tab=stores', + '/en/reviews', + '/en/reviews?tab=reviews', + '/zh/reviews', + '/en/support', + '/zh/support', '/en/support/contact', '/zh/support/contact', + '/en/support/tutorial', + '/zh/support/tutorial', + '/api/health', '/favicon.ico', '/Product/index/model/DAC-Z10/target/X4C68nRijzjeq7k9e%5Bld%5D3ulg%3D%3D.html', ] @@ -40,7 +48,10 @@ async function check(route) { } } -const results = await Promise.all(routes.map((route) => check(route))) +const results = [] +for (const route of routes) { + results.push(await check(route)) +} for (const result of results) { const suffix = result.location ? ` -> ${result.location}` : ''