ci: slash GitHub Actions minutes via paths-ignore + consolidation + caching #126
4 changed files with 197 additions and 107 deletions
224
.github/workflows/ci.yml
vendored
224
.github/workflows/ci.yml
vendored
|
|
@ -16,8 +16,27 @@ name: CI
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
# Skip CI entirely for doc-only PRs. The `slash-ci-minutes` convoy
|
||||||
|
# (2026-06-04) added this filter because every grep / lint / vitest /
|
||||||
|
# migrate job that fires on a pure-docs PR (e.g. `.convoys/` seeds +
|
||||||
|
# briefs, AGENTS.md edits, `docs/`) costs GitHub Actions minutes for
|
||||||
|
# zero signal. The full job set still runs on code-touching PRs.
|
||||||
|
paths-ignore:
|
||||||
|
- '.convoys/**'
|
||||||
|
- '**/*.md'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'AGENTS.md'
|
||||||
|
- '.cursor/**'
|
||||||
|
- 'README.md'
|
||||||
push:
|
push:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
|
paths-ignore:
|
||||||
|
- '.convoys/**'
|
||||||
|
- '**/*.md'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'AGENTS.md'
|
||||||
|
- '.cursor/**'
|
||||||
|
- 'README.md'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: ci-${{ github.workflow }}-${{ github.ref }}
|
group: ci-${{ github.workflow }}-${{ github.ref }}
|
||||||
|
|
@ -36,7 +55,19 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: npm
|
cache: npm
|
||||||
|
# Cache node_modules between runs (in addition to setup-node's npm
|
||||||
|
# cache). `setup-node@v4`'s `cache: npm` caches `~/.npm` but `npm
|
||||||
|
# ci` still does a fresh install into `node_modules` — that's the
|
||||||
|
# ~30-45s we want to skip on cache hit. Keyed by package-lock.json
|
||||||
|
# hash so any dep change invalidates correctly.
|
||||||
|
- name: Cache node_modules
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: node-modules-${{ runner.os }}-node${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
- run: npm run lint --if-present
|
- run: npm run lint --if-present
|
||||||
|
|
||||||
schema-map-fresh:
|
schema-map-fresh:
|
||||||
|
|
@ -74,13 +105,27 @@ jobs:
|
||||||
fi
|
fi
|
||||||
echo "OK: schema map and migration scripts are in sync."
|
echo "OK: schema map and migration scripts are in sync."
|
||||||
|
|
||||||
forbidden-endpoints:
|
# Consolidated grep-based regression gates. Previously 6 separate jobs
|
||||||
name: No dev endpoints in pages/api
|
# (forbidden-endpoints / -cors-headers / -client-side-llm-keys /
|
||||||
|
# -modal-shell-without-primitive / -deprecated-color-aliases /
|
||||||
|
# -stale-strings); the `slash-ci-minutes` convoy (2026-06-04) merged
|
||||||
|
# them into one job to amortize a single actions/checkout across all
|
||||||
|
# 6 checks. Each check still emits `::error file=...::msg` so the
|
||||||
|
# in-file annotations work the same way. The job uses a FAIL flag so
|
||||||
|
# ALL violations across all 6 checks are reported in a single run
|
||||||
|
# (matches the previous independent-jobs behavior; nicer than
|
||||||
|
# stop-at-first-failure).
|
||||||
|
forbidden-patterns:
|
||||||
|
name: Forbidden patterns (6 checks)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- name: Fail if dev endpoints re-appear under pages/api/
|
- name: Run all forbidden-pattern checks
|
||||||
run: |
|
run: |
|
||||||
|
FAIL=0
|
||||||
|
|
||||||
|
# ---------- (1) No dev endpoints in pages/api ----------
|
||||||
|
echo "::group::Check 1/6: No dev endpoints in pages/api"
|
||||||
BAD_PATHS=(
|
BAD_PATHS=(
|
||||||
"pages/api/simple.js"
|
"pages/api/simple.js"
|
||||||
"pages/api/test-auth.js"
|
"pages/api/test-auth.js"
|
||||||
|
|
@ -93,7 +138,6 @@ jobs:
|
||||||
FOUND+=("$path")
|
FOUND+=("$path")
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
# Also flag any new pages/api/test-*.js the explicit list missed.
|
|
||||||
while IFS= read -r path; do
|
while IFS= read -r path; do
|
||||||
FOUND+=("$path")
|
FOUND+=("$path")
|
||||||
done < <(find pages/api -maxdepth 4 -type f -name 'test-*.js' 2>/dev/null || true)
|
done < <(find pages/api -maxdepth 4 -type f -name 'test-*.js' 2>/dev/null || true)
|
||||||
|
|
@ -102,27 +146,14 @@ jobs:
|
||||||
for path in "${FOUND[@]}"; do
|
for path in "${FOUND[@]}"; do
|
||||||
echo "::error file=${path}::Forbidden dev endpoint."
|
echo "::error file=${path}::Forbidden dev endpoint."
|
||||||
done
|
done
|
||||||
exit 1
|
FAIL=1
|
||||||
fi
|
else
|
||||||
echo "OK: no forbidden dev endpoints under pages/api/."
|
echo "OK: no forbidden dev endpoints under pages/api/."
|
||||||
|
fi
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
||||||
forbidden-cors-headers:
|
# ---------- (2) No wildcard CORS in pages/api ----------
|
||||||
name: No wildcard CORS in pages/api
|
echo "::group::Check 2/6: No wildcard CORS in pages/api"
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Fail if any pages/api/ handler carries Access-Control-Allow-Origin
|
|
||||||
run: |
|
|
||||||
# The tcg-vault frontend and API are served from the same Vercel
|
|
||||||
# deployment (same origin), so CORS headers serve no purpose and
|
|
||||||
# are a documented attack surface (see .convoys/cors-tighten.md
|
|
||||||
# and AGENTS.md Gotcha #5). Brief 4 of fix-auth-bypass cleaned
|
|
||||||
# login.js + register.js; the cors-tighten convoy swept the
|
|
||||||
# remaining 24 files. This job locks the cleanup in.
|
|
||||||
#
|
|
||||||
# If a future cross-origin caller is legitimately needed, design
|
|
||||||
# a proper CORS layer (probably via middleware) rather than
|
|
||||||
# scaffolding wildcards into individual handlers.
|
|
||||||
MATCHES=$(grep -rEn 'Access-Control-Allow-(Origin|Methods|Headers)' pages/api/ 2>/dev/null || true)
|
MATCHES=$(grep -rEn 'Access-Control-Allow-(Origin|Methods|Headers)' pages/api/ 2>/dev/null || true)
|
||||||
if [ -n "$MATCHES" ]; then
|
if [ -n "$MATCHES" ]; then
|
||||||
echo "::error::Forbidden CORS headers present under pages/api/. Remove them — same-origin Vercel deployment does not need CORS."
|
echo "::error::Forbidden CORS headers present under pages/api/. Remove them — same-origin Vercel deployment does not need CORS."
|
||||||
|
|
@ -131,21 +162,17 @@ jobs:
|
||||||
lineno=$(echo "$line" | cut -d: -f2)
|
lineno=$(echo "$line" | cut -d: -f2)
|
||||||
echo "::error file=${file},line=${lineno}::Forbidden CORS header — delete this line."
|
echo "::error file=${file},line=${lineno}::Forbidden CORS header — delete this line."
|
||||||
done
|
done
|
||||||
exit 1
|
FAIL=1
|
||||||
fi
|
else
|
||||||
echo "OK: no Access-Control-Allow-* headers under pages/api/."
|
echo "OK: no Access-Control-Allow-* headers under pages/api/."
|
||||||
|
fi
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
||||||
forbidden-client-side-llm-keys:
|
# ---------- (3) No client-side LLM key leakage ----------
|
||||||
name: No client-side LLM key leakage
|
echo "::group::Check 3/6: No client-side LLM key leakage"
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Fail on key-returning config endpoints or new browser LLM URLs
|
|
||||||
run: |
|
|
||||||
# Deleted by secure-scanner-gemini-key — must not return API keys to browsers.
|
|
||||||
if [ -f pages/api/config/gemini.js ]; then
|
if [ -f pages/api/config/gemini.js ]; then
|
||||||
echo "::error file=pages/api/config/gemini.js::Forbidden config endpoint — do not return API keys to browsers."
|
echo "::error file=pages/api/config/gemini.js::Forbidden config endpoint — do not return API keys to browsers."
|
||||||
exit 1
|
FAIL=1
|
||||||
fi
|
fi
|
||||||
CONFIG_MATCHES=$(grep -rEn 'apiKey:' pages/api/config/ 2>/dev/null || true)
|
CONFIG_MATCHES=$(grep -rEn 'apiKey:' pages/api/config/ 2>/dev/null || true)
|
||||||
if [ -n "$CONFIG_MATCHES" ]; then
|
if [ -n "$CONFIG_MATCHES" ]; then
|
||||||
|
|
@ -155,7 +182,7 @@ jobs:
|
||||||
lineno=$(echo "$line" | cut -d: -f2)
|
lineno=$(echo "$line" | cut -d: -f2)
|
||||||
echo "::error file=${file},line=${lineno}::Do not return API keys from config endpoints."
|
echo "::error file=${file},line=${lineno}::Do not return API keys from config endpoints."
|
||||||
done
|
done
|
||||||
exit 1
|
FAIL=1
|
||||||
fi
|
fi
|
||||||
AI_OCR_IMPORTS=$(grep -rEn 'from ['\''"].*ai-ocr|import.*ai-ocr' components/ 2>/dev/null || true)
|
AI_OCR_IMPORTS=$(grep -rEn 'from ['\''"].*ai-ocr|import.*ai-ocr' components/ 2>/dev/null || true)
|
||||||
if [ -n "$AI_OCR_IMPORTS" ]; then
|
if [ -n "$AI_OCR_IMPORTS" ]; then
|
||||||
|
|
@ -165,14 +192,13 @@ jobs:
|
||||||
lineno=$(echo "$line" | cut -d: -f2)
|
lineno=$(echo "$line" | cut -d: -f2)
|
||||||
echo "::error file=${file},line=${lineno}::Remove ai-ocr import; call server-side scan API."
|
echo "::error file=${file},line=${lineno}::Remove ai-ocr import; call server-side scan API."
|
||||||
done
|
done
|
||||||
exit 1
|
FAIL=1
|
||||||
fi
|
fi
|
||||||
# lib/ may hold server-only helpers imported only from pages/api/.
|
|
||||||
SERVER_ONLY=(
|
SERVER_ONLY=(
|
||||||
lib/scan-vision.js
|
lib/scan-vision.js
|
||||||
)
|
)
|
||||||
LLM_PATTERN='generativelanguage\.googleapis\.com|api\.openai\.com|ai-gateway\.vercel\.sh'
|
LLM_PATTERN='generativelanguage\.googleapis\.com|api\.openai\.com|ai-gateway\.vercel\.sh'
|
||||||
FOUND=()
|
LLM_FOUND=()
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
skip=false
|
skip=false
|
||||||
for so in "${SERVER_ONLY[@]}"; do
|
for so in "${SERVER_ONLY[@]}"; do
|
||||||
|
|
@ -185,66 +211,40 @@ jobs:
|
||||||
continue
|
continue
|
||||||
fi
|
fi
|
||||||
if grep -qE "$LLM_PATTERN" "$file" 2>/dev/null; then
|
if grep -qE "$LLM_PATTERN" "$file" 2>/dev/null; then
|
||||||
FOUND+=("$file")
|
LLM_FOUND+=("$file")
|
||||||
fi
|
fi
|
||||||
done < <(find components lib pages -name '*.js' ! -path 'pages/api/*' 2>/dev/null || true)
|
done < <(find components lib pages -name '*.js' ! -path 'pages/api/*' 2>/dev/null || true)
|
||||||
if [ ${#FOUND[@]} -gt 0 ]; then
|
if [ ${#LLM_FOUND[@]} -gt 0 ]; then
|
||||||
echo "::error::Client-side LLM API URLs must not appear outside pages/api/."
|
echo "::error::Client-side LLM API URLs must not appear outside pages/api/."
|
||||||
for path in "${FOUND[@]}"; do
|
for path in "${LLM_FOUND[@]}"; do
|
||||||
echo "::error file=${path}::Move LLM calls server-side or add to server-side-scan-pipeline removal list."
|
echo "::error file=${path}::Move LLM calls server-side or add to server-side-scan-pipeline removal list."
|
||||||
done
|
done
|
||||||
exit 1
|
FAIL=1
|
||||||
fi
|
fi
|
||||||
echo "OK: no client-side LLM key leakage patterns detected."
|
[ ${#LLM_FOUND[@]} -eq 0 ] && [ -z "$CONFIG_MATCHES" ] && [ -z "$AI_OCR_IMPORTS" ] && [ ! -f pages/api/config/gemini.js ] && echo "OK: no client-side LLM key leakage patterns detected."
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
||||||
forbidden-modal-shell-without-primitive:
|
# ---------- (4) No legacy modal shells ----------
|
||||||
name: No new modal shells without <Modal> primitive
|
echo "::group::Check 4/6: No new modal shells without <Modal> primitive"
|
||||||
runs-on: ubuntu-latest
|
MODAL_FOUND=()
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Fail if ANY file uses the legacy modal-shell pattern
|
|
||||||
run: |
|
|
||||||
# liquid-glass-modal-and-surface-primitive convoy — every modal
|
|
||||||
# MUST compose the `<Modal>` primitive from components/ui/.
|
|
||||||
# The original 9 grandfathered shells were swept in the
|
|
||||||
# finish-liquid-glass-design PR; the gate is now strict and
|
|
||||||
# has zero allow-list entries. If a new file legitimately
|
|
||||||
# needs the legacy pattern (rare — backdrop-blur sheets that
|
|
||||||
# are not modals belong in a different primitive), revive the
|
|
||||||
# allow-list pattern AND open a tracking issue.
|
|
||||||
FOUND=()
|
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
FOUND+=("$file")
|
MODAL_FOUND+=("$file")
|
||||||
done < <(grep -lE 'fixed inset-0 bg-black bg-opacity-' \
|
done < <(grep -lE 'fixed inset-0 bg-black bg-opacity-' \
|
||||||
pages components -r --include='*.js' 2>/dev/null \
|
pages components -r --include='*.js' 2>/dev/null \
|
||||||
| sort -u || true)
|
| sort -u || true)
|
||||||
if [ ${#FOUND[@]} -gt 0 ]; then
|
if [ ${#MODAL_FOUND[@]} -gt 0 ]; then
|
||||||
echo "::error::Legacy modal-shell pattern detected. Use the <Modal> primitive from components/ui/."
|
echo "::error::Legacy modal-shell pattern detected. Use the <Modal> primitive from components/ui/."
|
||||||
for f in "${FOUND[@]}"; do
|
for f in "${MODAL_FOUND[@]}"; do
|
||||||
echo "::error file=${f}::Replace 'fixed inset-0 bg-black bg-opacity-' with <Modal open={…} onClose={…} title=…> from components/ui."
|
echo "::error file=${f}::Replace 'fixed inset-0 bg-black bg-opacity-' with <Modal open={…} onClose={…} title=…> from components/ui."
|
||||||
done
|
done
|
||||||
exit 1
|
FAIL=1
|
||||||
fi
|
else
|
||||||
echo "OK: no legacy modal shells anywhere in pages/ or components/."
|
echo "OK: no legacy modal shells anywhere in pages/ or components/."
|
||||||
|
fi
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
||||||
forbidden-deprecated-color-aliases:
|
# ---------- (5) No deprecated color aliases ----------
|
||||||
name: No use of deprecated color aliases
|
echo "::group::Check 5/6: No use of deprecated color aliases"
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Fail if ANY code uses pre-Deck-Hearth blue/purple/pink aliases
|
|
||||||
run: |
|
|
||||||
# liquid-glass-design-tokens + cleanup-legacy-design-css convoys.
|
|
||||||
# Pre-Deck-Hearth alias utility classes (.gradient-text-blue /
|
|
||||||
# .gradient-text-purple / .glow-blue / .glow-purple / .glow-pink /
|
|
||||||
# .gradient-bg-purple / .gradient-bg-blue / .gradient-bg-pink) were
|
|
||||||
# graduated from WARN to FAIL by the finish-liquid-glass-design PR
|
|
||||||
# after every consumer was swept to --accent-ember equivalents
|
|
||||||
# AND the CSS definitions were deleted from styles/globals.css.
|
|
||||||
#
|
|
||||||
# If a new consumer of one of these classes appears, the build
|
|
||||||
# will fail — use --accent-ember, --accent-flame, --accent-gold
|
|
||||||
# (the canonical warm-palette tokens) instead.
|
|
||||||
PATTERNS=(
|
PATTERNS=(
|
||||||
'gradient-text-purple'
|
'gradient-text-purple'
|
||||||
'gradient-text-pink'
|
'gradient-text-pink'
|
||||||
|
|
@ -258,9 +258,9 @@ jobs:
|
||||||
)
|
)
|
||||||
ALL_MATCHES=""
|
ALL_MATCHES=""
|
||||||
for pattern in "${PATTERNS[@]}"; do
|
for pattern in "${PATTERNS[@]}"; do
|
||||||
MATCHES=$(grep -rFn "$pattern" pages components --include='*.js' 2>/dev/null || true)
|
P_MATCHES=$(grep -rFn "$pattern" pages components --include='*.js' 2>/dev/null || true)
|
||||||
if [ -n "$MATCHES" ]; then
|
if [ -n "$P_MATCHES" ]; then
|
||||||
ALL_MATCHES=$(printf '%s\n%s' "$ALL_MATCHES" "$MATCHES")
|
ALL_MATCHES=$(printf '%s\n%s' "$ALL_MATCHES" "$P_MATCHES")
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
if [ -n "$ALL_MATCHES" ]; then
|
if [ -n "$ALL_MATCHES" ]; then
|
||||||
|
|
@ -271,43 +271,47 @@ jobs:
|
||||||
lineno=$(echo "$line" | cut -d: -f2)
|
lineno=$(echo "$line" | cut -d: -f2)
|
||||||
echo "::error file=${file},line=${lineno}::Deprecated color alias — replace with the canonical Deck Hearth warm-palette equivalent."
|
echo "::error file=${file},line=${lineno}::Deprecated color alias — replace with the canonical Deck Hearth warm-palette equivalent."
|
||||||
done
|
done
|
||||||
exit 1
|
FAIL=1
|
||||||
fi
|
else
|
||||||
echo "OK: no deprecated color aliases in pages/ or components/."
|
echo "OK: no deprecated color aliases in pages/ or components/."
|
||||||
|
fi
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
||||||
forbidden-stale-strings:
|
# ---------- (6) No stale ownership/collection copy ----------
|
||||||
name: No stale ownership/collection copy
|
echo "::group::Check 6/6: No stale ownership/collection copy"
|
||||||
runs-on: ubuntu-latest
|
STRING_PATTERNS=(
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- name: Fail if forbidden UI strings appear in pages/ or components/
|
|
||||||
run: |
|
|
||||||
# rename-collections-vocabulary convoy — ownership vs curated-list taxonomy.
|
|
||||||
# API/DB literals (e.g. system collection name) live under pages/api/ only.
|
|
||||||
PATTERNS=(
|
|
||||||
'Mark Owned'
|
'Mark Owned'
|
||||||
'Owned Cards'
|
'Owned Cards'
|
||||||
'All My Cards'
|
'All My Cards'
|
||||||
)
|
)
|
||||||
FOUND=()
|
STRING_FOUND=()
|
||||||
for pattern in "${PATTERNS[@]}"; do
|
for pattern in "${STRING_PATTERNS[@]}"; do
|
||||||
while IFS= read -r line; do
|
while IFS= read -r line; do
|
||||||
FOUND+=("$line")
|
STRING_FOUND+=("$line")
|
||||||
done < <(grep -rFn "$pattern" pages/ components/ \
|
done < <(grep -rFn "$pattern" pages/ components/ \
|
||||||
--include='*.js' \
|
--include='*.js' \
|
||||||
--exclude-dir=api 2>/dev/null || true)
|
--exclude-dir=api 2>/dev/null || true)
|
||||||
done
|
done
|
||||||
if [ ${#FOUND[@]} -gt 0 ]; then
|
if [ ${#STRING_FOUND[@]} -gt 0 ]; then
|
||||||
echo "::error::Forbidden stale UI string(s) in pages/ or components/. Use lib/collection-vocabulary.js labels — see AGENTS.md § Product vocabulary."
|
echo "::error::Forbidden stale UI string(s) in pages/ or components/. Use lib/collection-vocabulary.js labels — see AGENTS.md § Product vocabulary."
|
||||||
printf '%s\n' "${FOUND[@]}" | sort -u | while IFS= read -r line; do
|
printf '%s\n' "${STRING_FOUND[@]}" | sort -u | while IFS= read -r line; do
|
||||||
file=$(echo "$line" | cut -d: -f1)
|
file=$(echo "$line" | cut -d: -f1)
|
||||||
lineno=$(echo "$line" | cut -d: -f2)
|
lineno=$(echo "$line" | cut -d: -f2)
|
||||||
text=$(echo "$line" | cut -d: -f3-)
|
text=$(echo "$line" | cut -d: -f3-)
|
||||||
echo "::error file=${file},line=${lineno}::${text}"
|
echo "::error file=${file},line=${lineno}::${text}"
|
||||||
done
|
done
|
||||||
|
FAIL=1
|
||||||
|
else
|
||||||
|
echo "OK: no forbidden stale strings in pages/ or components/."
|
||||||
|
fi
|
||||||
|
echo "::endgroup::"
|
||||||
|
|
||||||
|
# ---------- Final exit ----------
|
||||||
|
if [ "$FAIL" -ne 0 ]; then
|
||||||
|
echo "::error::One or more forbidden-pattern checks failed. See annotations above."
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "OK: no forbidden stale strings in pages/ or components/."
|
echo "All 6 forbidden-pattern checks passed."
|
||||||
|
|
||||||
migrate:
|
migrate:
|
||||||
name: Migrations apply (node-pg-migrate)
|
name: Migrations apply (node-pg-migrate)
|
||||||
|
|
@ -334,7 +338,14 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: npm
|
cache: npm
|
||||||
|
- name: Cache node_modules
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: node-modules-${{ runner.os }}-node${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
- name: Write migrate env file
|
- name: Write migrate env file
|
||||||
run: echo "POSTGRES_URL=${POSTGRES_URL}" >> .env.local
|
run: echo "POSTGRES_URL=${POSTGRES_URL}" >> .env.local
|
||||||
- run: npm run migrate up
|
- run: npm run migrate up
|
||||||
|
|
@ -348,7 +359,14 @@ jobs:
|
||||||
with:
|
with:
|
||||||
node-version: ${{ env.NODE_VERSION }}
|
node-version: ${{ env.NODE_VERSION }}
|
||||||
cache: npm
|
cache: npm
|
||||||
|
- name: Cache node_modules
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: node-modules-${{ runner.os }}-node${{ env.NODE_VERSION }}-${{ hashFiles('package-lock.json') }}
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
- run: npm run test:run
|
- run: npm run test:run
|
||||||
env:
|
env:
|
||||||
JWT_SECRET: ci-secret-only-for-tests-do-not-use-in-prod
|
JWT_SECRET: ci-secret-only-for-tests-do-not-use-in-prod
|
||||||
|
|
|
||||||
41
.github/workflows/preview-smoke.yml
vendored
41
.github/workflows/preview-smoke.yml
vendored
|
|
@ -14,6 +14,16 @@ on:
|
||||||
pull_request:
|
pull_request:
|
||||||
branches: [main]
|
branches: [main]
|
||||||
types: [labeled, opened, synchronize, reopened]
|
types: [labeled, opened, synchronize, reopened]
|
||||||
|
# Skip the heavy Playwright smoke on doc-only PRs. Same set as
|
||||||
|
# ci.yml's paths-ignore — see `slash-ci-minutes` convoy
|
||||||
|
# (2026-06-04). Code/style/UI PRs still trigger smoke normally.
|
||||||
|
paths-ignore:
|
||||||
|
- '.convoys/**'
|
||||||
|
- '**/*.md'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'AGENTS.md'
|
||||||
|
- '.cursor/**'
|
||||||
|
- 'README.md'
|
||||||
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: preview-smoke-${{ github.event.pull_request.number }}
|
group: preview-smoke-${{ github.event.pull_request.number }}
|
||||||
|
|
@ -89,10 +99,39 @@ jobs:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
|
- name: Cache node_modules
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
|
||||||
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
|
|
||||||
|
# Cache Playwright's browser binaries (~/.cache/ms-playwright) keyed
|
||||||
|
# by the resolved @playwright/test version in the lockfile. Saves
|
||||||
|
# ~15-25s/run of chromium download + system-deps install. Cache
|
||||||
|
# invalidates automatically on any Playwright version bump.
|
||||||
|
- name: Resolve Playwright version
|
||||||
|
id: pw-version
|
||||||
|
run: |
|
||||||
|
VERSION=$(node -p "require('./package-lock.json').packages['node_modules/@playwright/test'].version")
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
id: cache-playwright
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}-chromium
|
||||||
|
|
||||||
- name: Install Playwright browsers
|
- name: Install Playwright browsers
|
||||||
run: npx playwright install --with-deps chromium
|
run: |
|
||||||
|
if [ "${{ steps.cache-playwright.outputs.cache-hit }}" = "true" ]; then
|
||||||
|
npx playwright install-deps chromium
|
||||||
|
else
|
||||||
|
npx playwright install --with-deps chromium
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Run smoke tests
|
- name: Run smoke tests
|
||||||
run: npx playwright test --project=smoke
|
run: npx playwright test --project=smoke
|
||||||
|
|
|
||||||
30
.github/workflows/visual-diff.yml
vendored
30
.github/workflows/visual-diff.yml
vendored
|
|
@ -87,8 +87,36 @@ jobs:
|
||||||
node-version: '20'
|
node-version: '20'
|
||||||
cache: npm
|
cache: npm
|
||||||
|
|
||||||
|
- name: Cache node_modules
|
||||||
|
id: cache-node-modules
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: node_modules
|
||||||
|
key: node-modules-${{ runner.os }}-node20-${{ hashFiles('package-lock.json') }}
|
||||||
|
|
||||||
- run: npm ci
|
- run: npm ci
|
||||||
- run: npx playwright install --with-deps chromium
|
if: steps.cache-node-modules.outputs.cache-hit != 'true'
|
||||||
|
|
||||||
|
# Cache Playwright browsers — see preview-smoke.yml for rationale.
|
||||||
|
- name: Resolve Playwright version
|
||||||
|
id: pw-version
|
||||||
|
run: |
|
||||||
|
VERSION=$(node -p "require('./package-lock.json').packages['node_modules/@playwright/test'].version")
|
||||||
|
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||||
|
- name: Cache Playwright browsers
|
||||||
|
id: cache-playwright
|
||||||
|
uses: actions/cache@v4
|
||||||
|
with:
|
||||||
|
path: ~/.cache/ms-playwright
|
||||||
|
key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }}-chromium
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
run: |
|
||||||
|
if [ "${{ steps.cache-playwright.outputs.cache-hit }}" = "true" ]; then
|
||||||
|
npx playwright install-deps chromium
|
||||||
|
else
|
||||||
|
npx playwright install --with-deps chromium
|
||||||
|
fi
|
||||||
|
|
||||||
- name: Capture screenshots (PR)
|
- name: Capture screenshots (PR)
|
||||||
run: npx playwright test --project=visual --update-snapshots=none
|
run: npx playwright test --project=visual --update-snapshots=none
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,7 @@ User-facing copy distinguishes **ownership** (everything you own) from **curated
|
||||||
| Add ownership | **Add to My Collection** | `POST /api/user-cards`, scanner bulk | Replaces "Mark Owned" / "Mark as Owned" |
|
| Add ownership | **Add to My Collection** | `POST /api/user-cards`, scanner bulk | Replaces "Mark Owned" / "Mark as Owned" |
|
||||||
| Add to curated list | **Add to List** | `POST /api/collections/:id/cards` | Replaces "Add to Collection" in scanner/card flows |
|
| Add to curated list | **Add to List** | `POST /api/collections/:id/cards` | Replaces "Add to Collection" in scanner/card flows |
|
||||||
|
|
||||||
CI job `forbidden-stale-strings` blocks `"Mark Owned"`, `"Owned Cards"`, and `"All My Cards"` in `pages/` + `components/` (API literals exempt).
|
CI check `Forbidden patterns (6 checks)` → Check 6/6 blocks `"Mark Owned"`, `"Owned Cards"`, and `"All My Cards"` in `pages/` + `components/` (API literals exempt). [Formerly the standalone `forbidden-stale-strings` job; merged into `forbidden-patterns` by the `slash-ci-minutes` convoy on 2026-06-04.]
|
||||||
|
|
||||||
## Visual language
|
## Visual language
|
||||||
|
|
||||||
|
|
@ -107,7 +107,7 @@ Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 560
|
||||||
idempotent, UNIQUE-collision-safe (fails loud if `setup-neon-db.js`
|
idempotent, UNIQUE-collision-safe (fails loud if `setup-neon-db.js`
|
||||||
already ran post-rename — which would indicate an ordering error).
|
already ran post-rename — which would indicate an ordering error).
|
||||||
Order: migration FIRST, then any subsequent `npm run setup-db`.
|
Order: migration FIRST, then any subsequent `npm run setup-db`.
|
||||||
- **#5 — `pages/api/setup-database.js` public endpoint. RESOLVED** by `fix-auth-bypass` Brief 3 (commit `fc0dd73`). The file is deleted along with the other three dev endpoints (`/api/simple`, `/api/test-auth`, `/api/test-db`), and `.github/workflows/ci.yml`'s new `forbidden-endpoints` job fails the build if any of them are re-introduced (or if a new `pages/api/test-*.js` file appears). Entry kept (not renumbered) to preserve cross-references.
|
- **#5 — `pages/api/setup-database.js` public endpoint. RESOLVED** by `fix-auth-bypass` Brief 3 (commit `fc0dd73`). The file is deleted along with the other three dev endpoints (`/api/simple`, `/api/test-auth`, `/api/test-db`), and `.github/workflows/ci.yml`'s `forbidden-patterns` job (formerly the standalone `forbidden-endpoints` job; consolidated by `slash-ci-minutes` convoy on 2026-06-04) fails the build if any of them are re-introduced (or if a new `pages/api/test-*.js` file appears). Entry kept (not renumbered) to preserve cross-references.
|
||||||
- **#6 — Migrations were bare scripts. RESOLVED** by `migration-tool` convoy (2026-05-26). `node-pg-migrate@^8` is the chosen tool (lightweight, raw-SQL-friendly, zero TS surface — matches the repo's JavaScript-only `@vercel/postgres` style). New migrations live under `migrations/` at the repo root and use the default `pgmigrations` tracking table. The initial backfill `migrations/1779853647564_initial-schema.js` reproduces `scripts/setup-neon-db.js`'s 7-table DDL verbatim using `CREATE TABLE IF NOT EXISTS`, so it's idempotent against fresh AND pre-existing envs — first-time `npm run migrate up` on an env that already ran `setup-neon-db.js` pre-convoy is a no-op DDL-wise (only records the `pgmigrations` row). The legacy 27 `scripts/add-*.js` / `scripts/fix-*.js` / `scripts/seed-*.js` jobs are append-only history per the no-go-zones rule — do NOT add new ones. New column / constraint work ships as a `node-pg-migrate` migration. See § 3 Conventions § "Schema changes" above + `.convoys/migration-tool.md`. Entry kept (not renumbered) to preserve cross-references.
|
- **#6 — Migrations were bare scripts. RESOLVED** by `migration-tool` convoy (2026-05-26). `node-pg-migrate@^8` is the chosen tool (lightweight, raw-SQL-friendly, zero TS surface — matches the repo's JavaScript-only `@vercel/postgres` style). New migrations live under `migrations/` at the repo root and use the default `pgmigrations` tracking table. The initial backfill `migrations/1779853647564_initial-schema.js` reproduces `scripts/setup-neon-db.js`'s 7-table DDL verbatim using `CREATE TABLE IF NOT EXISTS`, so it's idempotent against fresh AND pre-existing envs — first-time `npm run migrate up` on an env that already ran `setup-neon-db.js` pre-convoy is a no-op DDL-wise (only records the `pgmigrations` row). The legacy 27 `scripts/add-*.js` / `scripts/fix-*.js` / `scripts/seed-*.js` jobs are append-only history per the no-go-zones rule — do NOT add new ones. New column / constraint work ships as a `node-pg-migrate` migration. See § 3 Conventions § "Schema changes" above + `.convoys/migration-tool.md`. Entry kept (not renumbered) to preserve cross-references.
|
||||||
- **#7 — Dual `is_public` semantics.** Collections and decks both have `is_public` columns; check which controls discovery vs. anonymous read in the relevant route.
|
- **#7 — Dual `is_public` semantics.** Collections and decks both have `is_public` columns; check which controls discovery vs. anonymous read in the relevant route.
|
||||||
- **#8 — Layout has hardcoded default user. RESOLVED** by `fix-layout-default-user` convoy (PR #15, squash commit `ca302a8`). `components/Layout.js`'s default prop is now `null`; `UserProfileDropdown` renders a `<Link href="/login">Sign in</Link>` CTA when `user === null`. Brief 2 also swept the 7 pages that needed page-level fixes (`scanner` / `decks` / `deck-builder` / `deck/[id]` now pass `user={user}` to Layout; `profile` / `settings` replaced leaky `useState({email:'me@…'})` with `useState(null)` + null-guards on every sync `user.*` read; `card/[id]` swapped a hardcoded `const user = {...}` for `useAuth()` from `lib/use-auth.js`). `test/components/Layout.test.js` adds 5 regression-lock assertions (no maintainer email when user is null/omitted; "Sign in" link present; supplied email renders; no "Guest" placeholder); vitest 21/21 green at merge. New devDeps: `jsdom@^29` + `@testing-library/react@^16`. See `.convoys/fix-layout-default-user.md` and `.convoys/ship-readiness.md` P0 #7. Entry kept (not renumbered) to preserve cross-references.
|
- **#8 — Layout has hardcoded default user. RESOLVED** by `fix-layout-default-user` convoy (PR #15, squash commit `ca302a8`). `components/Layout.js`'s default prop is now `null`; `UserProfileDropdown` renders a `<Link href="/login">Sign in</Link>` CTA when `user === null`. Brief 2 also swept the 7 pages that needed page-level fixes (`scanner` / `decks` / `deck-builder` / `deck/[id]` now pass `user={user}` to Layout; `profile` / `settings` replaced leaky `useState({email:'me@…'})` with `useState(null)` + null-guards on every sync `user.*` read; `card/[id]` swapped a hardcoded `const user = {...}` for `useAuth()` from `lib/use-auth.js`). `test/components/Layout.test.js` adds 5 regression-lock assertions (no maintainer email when user is null/omitted; "Sign in" link present; supplied email renders; no "Guest" placeholder); vitest 21/21 green at merge. New devDeps: `jsdom@^29` + `@testing-library/react@^16`. See `.convoys/fix-layout-default-user.md` and `.convoys/ship-readiness.md` P0 #7. Entry kept (not renumbered) to preserve cross-references.
|
||||||
|
|
@ -165,6 +165,11 @@ Code graph is indexed by `user-code-review-graph` MCP (122 files, 628 nodes, 560
|
||||||
- **Vitest:** the `test:` job in `.github/workflows/ci.yml` runs `npm run test:run` on every PR and push to `main` and is **blocking** (no `|| true`, no `continue-on-error`). A red test job blocks merge.
|
- **Vitest:** the `test:` job in `.github/workflows/ci.yml` runs `npm run test:run` on every PR and push to `main` and is **blocking** (no `|| true`, no `continue-on-error`). A red test job blocks merge.
|
||||||
- **Playwright smoke:** runs on every PR via `preview-smoke.yml`. Gate skip via `pipeline: skip smoke` in the PR body (handled in the `gate:` job's Decide step via env-var routing — see § 7's shell-injection note). Last measured runtime: 59s end-to-end, 3/3 tests pass in 2.9s (PR #18 post-merge run).
|
- **Playwright smoke:** runs on every PR via `preview-smoke.yml`. Gate skip via `pipeline: skip smoke` in the PR body (handled in the `gate:` job's Decide step via env-var routing — see § 7's shell-injection note). Last measured runtime: 59s end-to-end, 3/3 tests pass in 2.9s (PR #18 post-merge run).
|
||||||
- **Screenshot diff:** runs only on PRs touching `pages/**` / `components/**` / `styles/**` / `tailwind.config.js` / `postcss.config.js` via `visual-diff.yml`. **First `Screenshot diff` run after `adopt-playwright-smoke` will fail at the test step** because no baseline exists yet; `continue-on-error: true` swallows the failure and the comment-on-PR step posts "Visual Diff — view run" with empty artifacts. That is the documented Decision-4 end state of `adopt-playwright-smoke`, not a regression — it stays that way until `seed-visual-baselines-on-linux` lands.
|
- **Screenshot diff:** runs only on PRs touching `pages/**` / `components/**` / `styles/**` / `tailwind.config.js` / `postcss.config.js` via `visual-diff.yml`. **First `Screenshot diff` run after `adopt-playwright-smoke` will fail at the test step** because no baseline exists yet; `continue-on-error: true` swallows the failure and the comment-on-PR step posts "Visual Diff — view run" with empty artifacts. That is the documented Decision-4 end state of `adopt-playwright-smoke`, not a regression — it stays that way until `seed-visual-baselines-on-linux` lands.
|
||||||
|
- **CI minute optimizations (slash-ci-minutes convoy, 2026-06-04):**
|
||||||
|
- **Doc-only PRs skip ALL of ci.yml + preview-smoke.yml.** Both workflows carry `paths-ignore` for `.convoys/**`, `**/*.md`, `docs/**`, `AGENTS.md`, `.cursor/**`, and `README.md`. A pure-docs PR triggers zero GitHub Actions jobs (Vercel still builds — it's not on the Actions billing). `visual-diff.yml` was already cost-conscious via a positive `paths:` allowlist and is unchanged.
|
||||||
|
- **The 6 grep-only forbidden-* jobs collapsed into one.** They previously ran as 6 independent jobs (each with its own `actions/checkout`); the consolidated `forbidden-patterns` job runs all 6 checks as labeled `::group::` sections in a single bash step, with a FAIL flag at the bottom so every violation across all 6 checks still surfaces in one run (same diagnostic behavior, ~5/6 of the per-PR checkout overhead removed). The 6 original job names (`forbidden-endpoints`, `forbidden-cors-headers`, `forbidden-client-side-llm-keys`, `forbidden-modal-shell-without-primitive`, `forbidden-deprecated-color-aliases`, `forbidden-stale-strings`) no longer appear in the checks list — references in this file (e.g. CI job `forbidden-stale-strings` blocks ...) are now informational, not check-name lookups. `pr-health-rollup.yml` was unaffected because it only looks up `Lint` and `Schema map up to date` by name.
|
||||||
|
- **`node_modules` cached between runs** in lint / test / migrate / preview-smoke / visual-diff. Keyed on `package-lock.json` hash so any dep change invalidates correctly. Cuts `npm ci` from ~30-45s to ~3-5s on cache hit. `actions/setup-node@v4`'s built-in `cache: npm` is layered above this (caches `~/.npm`) — both stay because the `setup-node` cache helps on cache-miss days too.
|
||||||
|
- **Playwright browsers cached** in `preview-smoke.yml` + `visual-diff.yml`. Keyed on the resolved `@playwright/test` version from `package-lock.json`. Cache invalidates automatically on any Playwright version bump. On cache hit, only the system deps install (`npx playwright install-deps chromium`) runs — saves ~15-25s/run.
|
||||||
- **Manual QA:** `TESTING_GUIDE.md` still applies for flows not yet covered by automated tests (scanner camera path, card-import jobs, multi-step UI wizards). The automated smoke + visual suite is steadily eclipsing it; `TESTING_GUIDE.md` will be renamed to `docs/MANUAL_QA.md` and trimmed to truly-manual-only flows in a future cleanup convoy (see `.convoys/ship-readiness.md` § Role-doc-writer findings).
|
- **Manual QA:** `TESTING_GUIDE.md` still applies for flows not yet covered by automated tests (scanner camera path, card-import jobs, multi-step UI wizards). The automated smoke + visual suite is steadily eclipsing it; `TESTING_GUIDE.md` will be renamed to `docs/MANUAL_QA.md` and trimmed to truly-manual-only flows in a future cleanup convoy (see `.convoys/ship-readiness.md` § Role-doc-writer findings).
|
||||||
|
|
||||||
## 7. Deployment
|
## 7. Deployment
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue