Compare commits

...

2 Commits

Author SHA1 Message Date
3b45a967f7 feat: show draft posts/products on testing and staging
Some checks failed
Build & Deploy / 🔍 Prepare (push) Successful in 6s
Build & Deploy / 🧪 QA (push) Successful in 2m12s
Build & Deploy / 🏗️ Build (push) Successful in 4m37s
Build & Deploy / 🚀 Deploy (push) Successful in 15s
Build & Deploy / 🔔 Notify (push) Has been cancelled
Build & Deploy / 🧪 Post-Deploy Verification (push) Has been cancelled
- Add config.showDrafts (true for dev/testing/staging, false for production)
- Replace all isDev checks in blog.ts and products.ts with config.showDrafts
- Previously only NODE_ENV=development showed drafts, missing testing/staging
2026-02-27 02:38:56 +01:00
cadb104917 feat: Payload CMS robustness - auto-detect migrations, deep health check, improved error messages
- cms-sync.sh: auto-detect migrations from src/migrations/*.ts (no manual list)
- cms-sync.sh: pre-flight container checks with actionable error messages
- api/health/cms: deep health check that queries all Payload collections
- deploy.yml: auto-detect migrations in sanitization step
- deploy.yml: CMS deep health smoke test in post-deploy
2026-02-27 02:36:59 +01:00
6 changed files with 129 additions and 40 deletions

View File

@@ -385,20 +385,29 @@ jobs:
REMOTE_DB_NAME=$(ssh root@alpha.mintel.me "grep -h '^PAYLOAD_DB_NAME=' $SITE_DIR/.env* 2>/dev/null | tail -1 | cut -d= -f2" || echo "payload")
REMOTE_DB_USER="${REMOTE_DB_USER:-payload}"
REMOTE_DB_NAME="${REMOTE_DB_NAME:-payload}"
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
DO \\\$\\\$ BEGIN
DELETE FROM payload_migrations WHERE batch = -1;
INSERT INTO payload_migrations (name, batch)
SELECT name, batch FROM (VALUES
('20260223_195005_products_collection', 1),
('20260223_195151_remove_sku_unique', 2),
('20260225_003500_add_pages_collection', 3)
) AS v(name, batch)
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
EXCEPTION WHEN undefined_table THEN
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
END \\\$\\\$;
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
# Auto-detect migrations from src/migrations/*.ts
BATCH=1
VALUES=""
for f in $(ls src/migrations/*.ts 2>/dev/null | sort); do
NAME=$(basename "$f" .ts)
[ -n "$VALUES" ] && VALUES="$VALUES,"
VALUES="$VALUES ('$NAME', $BATCH)"
((BATCH++))
done
if [ -n "$VALUES" ]; then
ssh root@alpha.mintel.me "docker exec $DB_CONTAINER psql -U $REMOTE_DB_USER -d $REMOTE_DB_NAME -c \"
DO \\\$\\\$ BEGIN
DELETE FROM payload_migrations WHERE batch = -1;
INSERT INTO payload_migrations (name, batch)
SELECT name, batch FROM (VALUES $VALUES) AS v(name, batch)
WHERE NOT EXISTS (SELECT 1 FROM payload_migrations pm WHERE pm.name = v.name);
EXCEPTION WHEN undefined_table THEN
RAISE NOTICE 'payload_migrations table does not exist yet — skipping sanitization';
END \\\$\\\$;
\"" || echo "⚠️ Migration sanitization skipped (table may not exist yet)"
fi
# Restart app to pick up clean migration state
APP_CONTAINER="${{ needs.prepare.outputs.project_name }}-klz-app-1"
@@ -474,6 +483,26 @@ jobs:
[ -f /usr/bin/chromium ] && ln -sf /usr/bin/chromium /usr/bin/chromium-browser
# ── Critical Smoke Tests (MUST pass) ──────────────────────────────────
- name: 🏥 CMS Deep Health Check
env:
DEPLOY_URL: ${{ needs.prepare.outputs.next_public_url }}
GK_PASS: ${{ secrets.GATEKEEPER_PASSWORD || 'klz2026' }}
run: |
echo "Waiting 10s for app to fully start..."
sleep 10
echo "Checking basic health..."
curl -sf "$DEPLOY_URL/health" || { echo "❌ Basic health check failed"; exit 1; }
echo "✅ Basic health OK"
echo "Checking CMS DB connectivity..."
RESPONSE=$(curl -sf "$DEPLOY_URL/api/health/cms?gk_bypass=$GK_PASS" 2>&1) || {
echo "❌ CMS health check failed!"
echo "$RESPONSE"
echo ""
echo "This usually means Payload CMS migrations failed or DB tables are missing."
echo "Check: docker logs \$APP_CONTAINER | grep -i error"
exit 1
}
echo "✅ CMS health: $RESPONSE"
- name: 🚀 OG Image Check
if: always() && steps.deps.outcome == 'success'
env:

View File

@@ -1,9 +1,41 @@
import { NextResponse } from 'next/server';
import { getPayload } from 'payload';
import configPromise from '@payload-config';
export const dynamic = 'force-dynamic';
/**
* Deep CMS Health Check
* Validates that Payload CMS can actually query the database.
* Used by post-deploy smoke tests to catch migration/schema issues.
*/
export async function GET() {
// Payload is embedded within the Next.js app, so if this route responds, the CMS is up.
// Further DB health checks can be implemented via Payload Local API later.
return NextResponse.json({ status: 'ok', message: 'Payload CMS is embedded.' }, { status: 200 });
const checks: Record<string, string> = {};
try {
const payload = await getPayload({ config: configPromise });
checks.init = 'ok';
// Verify each collection can be queried (catches missing locale tables, broken migrations)
const collections = ['posts', 'products', 'pages', 'media'] as const;
for (const collection of collections) {
try {
await payload.find({ collection, limit: 1, locale: 'en' });
checks[collection] = 'ok';
} catch (e: any) {
checks[collection] = `error: ${e.message?.substring(0, 100)}`;
}
}
const hasErrors = Object.values(checks).some(v => v.startsWith('error'));
return NextResponse.json(
{ status: hasErrors ? 'degraded' : 'ok', checks },
{ status: hasErrors ? 503 : 200 },
);
} catch (e: any) {
return NextResponse.json(
{ status: 'error', message: e.message?.substring(0, 200), checks },
{ status: 503 },
);
}
}

View File

@@ -59,15 +59,14 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
try {
const payload = await getPayload({ config: configPromise });
const isDev = process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging';
const { docs } = await payload.find({
collection: 'posts',
where: {
slug: { equals: slug },
...(!isDev ? { _status: { equals: 'published' } } : {}),
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
},
locale: locale as any,
draft: isDev,
draft: config.showDrafts,
limit: 1,
});
@@ -107,15 +106,14 @@ export async function getPostBySlug(slug: string, locale: string): Promise<PostD
export async function getAllPosts(locale: string): Promise<PostData[]> {
try {
const payload = await getPayload({ config: configPromise });
const isDev = process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging';
const { docs } = await payload.find({
collection: 'posts',
where: {
...(!isDev ? { _status: { equals: 'published' } } : {}),
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
},
locale: locale as any,
sort: '-date',
draft: isDev,
draft: config.showDrafts,
limit: 100,
});

View File

@@ -29,6 +29,7 @@ function createConfig() {
isStaging: target === 'staging',
isTesting: target === 'testing',
isDevelopment: target === 'development',
showDrafts: target === 'development' || target === 'testing' || target === 'staging',
feedbackEnabled: env.NEXT_PUBLIC_FEEDBACK_ENABLED,
gatekeeperUrl: env.GATEKEEPER_URL,
@@ -116,6 +117,9 @@ export const config = {
get isDevelopment() {
return getConfig().isDevelopment;
},
get showDrafts() {
return getConfig().showDrafts;
},
get baseUrl() {
return getConfig().baseUrl;
},

View File

@@ -1,6 +1,7 @@
import { getPayload } from 'payload';
import configPromise from '@payload-config';
import { mapSlugToFileSlug } from './slugs';
import { config } from '@/lib/config';
export interface ProductFrontmatter {
title: string;
@@ -26,13 +27,12 @@ export async function getProductMetadata(
const payload = await getPayload({ config: configPromise });
const fileSlug = await mapSlugToFileSlug(slug, locale);
const isDev = process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging';
const result = await payload.find({
collection: 'products',
where: {
and: [
{ slug: { equals: fileSlug } },
...(!isDev ? [{ _status: { equals: 'published' } }] : []),
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
],
},
locale: locale as any,
@@ -70,13 +70,12 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
const payload = await getPayload({ config: configPromise });
const fileSlug = await mapSlugToFileSlug(slug, locale);
const isDev = process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging';
const result = await payload.find({
collection: 'products',
where: {
and: [
{ slug: { equals: fileSlug } },
...(!isDev ? [{ _status: { equals: 'published' } }] : []),
...(!config.showDrafts ? [{ _status: { equals: 'published' } }] : []),
],
},
locale: locale as any,
@@ -127,11 +126,10 @@ export async function getProductBySlug(slug: string, locale: string): Promise<Pr
export async function getAllProductSlugs(locale: string): Promise<string[]> {
try {
const payload = await getPayload({ config: configPromise });
const isDev = process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging';
const result = await payload.find({
collection: 'products',
where: {
...(!isDev ? { _status: { equals: 'published' } } : {}),
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
},
locale: locale as any,
pagination: false,
@@ -157,11 +155,10 @@ export async function getAllProducts(locale: string): Promise<ProductData[]> {
images: true,
} as const;
const isDev = process.env.NODE_ENV === 'development' || process.env.TARGET === 'staging';
const result = await payload.find({
collection: 'products',
where: {
...(!isDev ? { _status: { equals: 'published' } } : {}),
...(!config.showDrafts ? { _status: { equals: 'published' } } : {}),
},
locale: locale as any,
depth: 1,

View File

@@ -53,13 +53,17 @@ TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
REMOTE_DB_USER=""
REMOTE_DB_NAME=""
# Migration names to insert after restore (keeps Payload from prompting)
MIGRATIONS=(
"20260223_195005_products_collection:1"
"20260223_195151_remove_sku_unique:2"
"20260225_003500_add_pages_collection:3"
"20260225_175000_native_localization:4"
)
# Auto-detect migrations from src/migrations/*.ts (no manual maintenance needed)
MIGRATIONS=()
BATCH=1
for migration_file in $(ls src/migrations/*.ts 2>/dev/null | sort); do
name=$(basename "$migration_file" .ts)
MIGRATIONS+=("$name:$BATCH")
((BATCH++))
done
if [ ${#MIGRATIONS[@]} -eq 0 ]; then
echo "⚠️ No migration files found in src/migrations/"
fi
# ── Resolve target environment ─────────────────────────────────────────────
resolve_target() {
@@ -159,6 +163,29 @@ backup_remote_db() {
REMOTE_BACKUP_FILE="$file"
}
# ── Pre-flight: Verify remote containers exist ─────────────────────────────
check_remote_containers() {
echo "🔍 Checking $TARGET containers..."
local missing=0
if ! ssh "$SSH_HOST" "docker ps -q -f name=$REMOTE_DB_CONTAINER" | grep -q .; then
echo "❌ Database container '$REMOTE_DB_CONTAINER' not found on $SSH_HOST"
echo " → Deploy $TARGET first: git push to trigger pipeline, or run:"
echo " ssh $SSH_HOST \"cd $REMOTE_SITE_DIR && docker compose -p $REMOTE_PROJECT --env-file .env.\$TARGET up -d\""
missing=1
fi
if ! ssh "$SSH_HOST" "docker ps -q -f name=$REMOTE_APP_CONTAINER" | grep -q .; then
echo "❌ App container '$REMOTE_APP_CONTAINER' not found on $SSH_HOST"
missing=1
fi
if [ $missing -eq 1 ]; then
echo ""
echo "💡 The $TARGET environment hasn't been deployed yet."
echo " Push to the '$TARGET' branch or run the pipeline first."
exit 1
fi
echo "✅ All $TARGET containers running."
}
# ── PUSH: local → remote ──────────────────────────────────────────────────
do_push() {
echo ""
@@ -172,8 +199,9 @@ do_push() {
echo ""
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
# 0. Ensure local DB is running
# 0. Ensure local DB is running & remote containers exist
ensure_local_db
check_remote_containers
# 1. Safety backup of remote
backup_remote_db
@@ -227,8 +255,9 @@ do_pull() {
echo ""
[[ ! $REPLY =~ ^[Yy]$ ]] && { echo "Cancelled."; exit 0; }
# 0. Ensure local DB is running
# 0. Ensure local DB is running & remote containers exist
ensure_local_db
check_remote_containers
# 1. Safety backup of local
backup_local_db