Harden production deployment workflow

This commit is contained in:
Codex
2026-04-28 01:09:17 +08:00
parent c8ccaa9925
commit 46a72f815e
23 changed files with 243 additions and 21 deletions
+3 -1
View File
@@ -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
+2
View File
@@ -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.
+61
View File
@@ -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.
+10
View File
@@ -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.
+58
View File
@@ -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.
+35
View File
@@ -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',
},
},
]
+4 -1
View File
@@ -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",
+1 -1
View File
@@ -164,7 +164,7 @@ async function getContactUsPageData(locale: Locale) {
return payload.findGlobal({
depth: 1,
locale,
slug: 'contactUsPage' as any,
slug: 'contactUsPage',
})
}
@@ -341,7 +341,7 @@ async function getDownloadsPageSettings(locale: Locale): Promise<DownloadsPageSe
return (await payload.findGlobal({
depth: 1,
locale,
slug: 'downloadsPage' as any,
slug: 'downloadsPage',
})) as DownloadsPageSettings
}
+1 -1
View File
@@ -2,7 +2,7 @@ import type { Metadata } from 'next'
import { NextIntlClientProvider } from 'next-intl'
import { getMessages, setRequestLocale } from 'next-intl/server'
import { notFound } from 'next/navigation'
import { isLocale, locales } from '@/i18n/config'
import { isLocale } from '@/i18n/config'
import { CookieConsent } from '@/components/site/cookie-consent'
import { ScrollToTop } from '@/components/site/scroll-to-top'
import { SiteHeader } from '@/components/site/site-header'
@@ -15,7 +15,7 @@ import { ProductContentTabs } from './product-content-tabs'
import { ProductGallerySection } from './product-gallery-section'
import { ProductTemplateMount } from './product-template-mount'
import { ProductVideosSection } from './product-videos-section'
import type { Media, News, NewsCategory, Product, ProductCategory, ProductReview, ProductVideo } from '@/payload-types'
import type { Media, News, NewsCategory, Product, ProductCategory, ProductReview } from '@/payload-types'
export const revalidate = 300
@@ -191,7 +191,7 @@ export default async function ProductsPage({
const payload = await getPayloadClient()
const page = await payload.findGlobal({
depth: 1,
slug: 'productsPage' as any,
slug: 'productsPage',
locale,
})
const categories = await payload.find({
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server'
import { getPayloadClient } from '@/lib/payload'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
export async function GET() {
try {
const payload = await getPayloadClient()
await payload.find({
collection: 'products',
depth: 0,
limit: 1,
pagination: false,
})
return NextResponse.json({
ok: true,
service: 'eversoloweb',
database: 'ok',
timestamp: new Date().toISOString(),
})
} catch (error) {
return NextResponse.json(
{
ok: false,
service: 'eversoloweb',
database: 'error',
error: error instanceof Error ? error.message : 'Unknown health check failure',
timestamp: new Date().toISOString(),
},
{ status: 503 },
)
}
}
@@ -90,7 +90,6 @@ function ProductImage({
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={cardImageUrl}
alt={getProductDisplayAlt(product)}
@@ -101,7 +100,6 @@ function ProductImage({
].join(' ')}
/>
{lifestyleImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={lifestyleImageUrl}
alt={getProductDisplayAlt(product)}
+1 -1
View File
@@ -54,7 +54,7 @@ export function HomeHeroBanners({ items, locale }: Props) {
const isExternal = isExternalHref(item.href)
const content = (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
{ }
<img
src={item.imageUrl}
alt={item.alt}
-1
View File
@@ -57,7 +57,6 @@ export function HomeHero({
playsInline
/>
) : heroImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={heroImageUrl}
alt={headline || 'Eversolo'}
-1
View File
@@ -46,7 +46,6 @@ export function HomeNews({ items, locale }: Props) {
className="block overflow-hidden bg-[linear-gradient(135deg,#e7e0d4_0%,#f7f4ee_100%)]"
>
{item.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={item.imageUrl}
alt={item.title}
-2
View File
@@ -61,7 +61,6 @@ export function HomeRecognition({
const content = (
<div className="flex min-h-[84px] min-w-[220px] items-center gap-4 px-5 py-4 md:min-w-[250px] md:px-6">
{mediaUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={mediaUrl}
alt={award.label || ''}
@@ -111,7 +110,6 @@ export function HomeRecognition({
<div className="flex items-center gap-3">
<div className="h-11 w-11 shrink-0 overflow-hidden rounded-full bg-[linear-gradient(180deg,#f3efe8_0%,#ece7df_100%)]">
{mediaUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={mediaUrl}
alt={sourceName}
+1 -1
View File
@@ -26,7 +26,7 @@ export function HomeStatement({
<div className="absolute inset-0 bg-[radial-gradient(circle_at_top_right,rgba(190,169,129,0.18),transparent_32%),radial-gradient(circle_at_bottom_left,rgba(255,255,255,0.05),transparent_26%)]" />
{imageUrl ? (
<div className="pointer-events-none absolute inset-y-0 right-0 hidden w-[42%] overflow-hidden lg:block">
{/* eslint-disable-next-line @next/next/no-img-element */}
{ }
<img
src={imageUrl}
alt={title || 'Eversolo'}
+11 -2
View File
@@ -44,6 +44,15 @@ const dirname = path.dirname(filename)
const shouldPushSchema =
process.env.PAYLOAD_DB_PUSH === 'true' ||
(process.env.NODE_ENV !== 'production' && process.env.PAYLOAD_DB_PUSH !== 'false')
const shouldRunProdMigrationsOnStart = process.env.PAYLOAD_RUN_MIGRATIONS_ON_START === 'true'
const payloadSecret = process.env.PAYLOAD_SECRET
if (
process.env.NODE_ENV === 'production' &&
(!payloadSecret || payloadSecret === 'change-me' || payloadSecret === 'replace-me-with-a-long-random-string')
) {
throw new Error('PAYLOAD_SECRET must be set to a strong production value before starting Payload.')
}
export default buildConfig({
admin: {
@@ -108,7 +117,7 @@ export default buildConfig({
defaultLocale: 'en',
fallback: false,
},
secret: process.env.PAYLOAD_SECRET ?? 'change-me',
secret: payloadSecret ?? 'development-payload-secret',
typescript: {
outputFile: path.resolve(dirname, 'payload-types.ts'),
},
@@ -118,7 +127,7 @@ export default buildConfig({
},
migrationDir: path.resolve(dirname, 'migrations'),
push: shouldPushSchema,
prodMigrations: migrations,
prodMigrations: shouldRunProdMigrationsOnStart ? migrations : undefined,
}),
upload: {
limits: {
@@ -39,6 +39,7 @@ export function HtmlPreviewTextareaField(props: TextareaFieldClientProps) {
aria-selected={mode === 'html'}
className="html-preview-textarea__tab"
onClick={() => 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
+3 -3
View File
@@ -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<string, unknown> | null
originalDoc?: Record<string, unknown> | 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 } })
+12 -1
View File
@@ -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}` : ''