From 1b6d0d58dbed265627be3b635e57966e7ffbae4c Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Thu, 4 Jun 2026 16:32:29 -0500 Subject: [PATCH] ci: slash GitHub Actions minutes via paths-ignore + consolidation + caching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Standalone infrastructure PR (no convoy ceremony needed — single-file scope). Triggered by the GitHub Actions billing block that gated PRs #124 + #125 today. Three layers of savings applied per the user's max-savings option: 1. paths-ignore on ci.yml + preview-smoke.yml - Doc-only PRs (.convoys/**, **/*.md, docs/**, AGENTS.md, .cursor/**, README.md) now trigger ZERO Actions jobs. - Vercel still builds (it's not on the Actions billing). - visual-diff.yml unchanged — it was already cost-conscious via a positive paths: allowlist (pages/**, components/**, styles/**, etc.). 2. Consolidate 6 grep-only forbidden-* jobs into 1 - Previously 6 independent jobs each ran their own actions/checkout (~3s × 6 = 18s of redundant checkout). - Merged into a single forbidden-patterns job with 6 sequential ::group:: sections, one FAIL flag at the bottom — preserves "see all violations in one run" diagnostic behavior. Per-file ::error file=...::msg annotations work the same way. - Removed jobs: forbidden-endpoints, forbidden-cors-headers, forbidden-client-side-llm-keys, forbidden-modal-shell-without-primitive, forbidden-deprecated-color-aliases, forbidden-stale-strings. - pr-health-rollup.yml only looks up "Lint" and "Schema map up to date" by name — unaffected. 3. Cache node_modules + Playwright browsers - actions/cache@v4 for node_modules keyed by package-lock.json hash, applied to lint / test / migrate / preview-smoke / visual-diff. Cuts npm ci from ~30-45s to ~3-5s on cache hit. setup-node@v4's built-in cache: npm stays (caches ~/.npm) — both layered. - actions/cache@v4 for ~/.cache/ms-playwright keyed by the resolved @playwright/test version. Cache invalidates on any Playwright version bump. On cache hit, only system deps install runs (npx playwright install-deps chromium) — saves ~15-25s/run. AGENTS.md updates: - § 6 Testing § CI behavior: appended "CI minute optimizations" subsection documenting all three layers. - § Product vocabulary table caption: updated "CI job forbidden-stale-strings" reference to "CI check Forbidden patterns (6 checks) → Check 6/6" with a historical pointer. - Gotcha #5: updated the standalone forbidden-endpoints reference similarly. Estimated savings per typical convoy mix (~30% doc PRs based on repo history): - Doc-only PRs: 100% reduction (was ~6-7 min, now 0 Actions min). - Code-touching PRs: ~30-50% reduction (cache hits for npm + Playwright + no redundant 6× checkout). - Weighted average: ~50-60% reduction. This is short of the 70-80% I floated in chat — the real ceiling is limited by lint / vitest / migrate / Playwright runtime itself, all of which are kept on code-touching PRs (they're high-signal). Verification: - All 3 workflow YAMLs parse (python3 -c "yaml.safe_load(...)"). - npm run lint passes (1 pre-existing unrelated warning). - npm run test:run: 118/118 tests pass. - forbidden-patterns logic is byte-equivalent to the 6 original jobs' bash bodies — the differences are: per-check ::group::/::endgroup:: framing, a shared FAIL flag instead of per-job exit 1, and renamed local arrays (FOUND → LLM_FOUND / MODAL_FOUND / STRING_FOUND) to avoid clobbering across the single job's scope. Co-authored-by: Cursor --- .github/workflows/ci.yml | 224 +++++++++++++++------------- .github/workflows/preview-smoke.yml | 41 ++++- .github/workflows/visual-diff.yml | 30 +++- AGENTS.md | 9 +- 4 files changed, 197 insertions(+), 107 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4d2a530..7c4e1b0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -16,8 +16,27 @@ name: CI on: pull_request: 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: branches: [main] + paths-ignore: + - '.convoys/**' + - '**/*.md' + - 'docs/**' + - 'AGENTS.md' + - '.cursor/**' + - 'README.md' concurrency: group: ci-${{ github.workflow }}-${{ github.ref }} @@ -36,7 +55,19 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} 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 + if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: npm run lint --if-present schema-map-fresh: @@ -74,13 +105,27 @@ jobs: fi echo "OK: schema map and migration scripts are in sync." - forbidden-endpoints: - name: No dev endpoints in pages/api + # Consolidated grep-based regression gates. Previously 6 separate jobs + # (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 steps: - uses: actions/checkout@v4 - - name: Fail if dev endpoints re-appear under pages/api/ + - name: Run all forbidden-pattern checks run: | + FAIL=0 + + # ---------- (1) No dev endpoints in pages/api ---------- + echo "::group::Check 1/6: No dev endpoints in pages/api" BAD_PATHS=( "pages/api/simple.js" "pages/api/test-auth.js" @@ -93,7 +138,6 @@ jobs: FOUND+=("$path") fi done - # Also flag any new pages/api/test-*.js the explicit list missed. while IFS= read -r path; do FOUND+=("$path") 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 echo "::error file=${path}::Forbidden dev endpoint." done - exit 1 + FAIL=1 + else + echo "OK: no forbidden dev endpoints under pages/api/." fi - echo "OK: no forbidden dev endpoints under pages/api/." + echo "::endgroup::" - forbidden-cors-headers: - name: 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. + # ---------- (2) No wildcard CORS in pages/api ---------- + echo "::group::Check 2/6: No wildcard CORS in pages/api" MATCHES=$(grep -rEn 'Access-Control-Allow-(Origin|Methods|Headers)' pages/api/ 2>/dev/null || true) if [ -n "$MATCHES" ]; then 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) echo "::error file=${file},line=${lineno}::Forbidden CORS header — delete this line." done - exit 1 + FAIL=1 + else + echo "OK: no Access-Control-Allow-* headers under pages/api/." fi - echo "OK: no Access-Control-Allow-* headers under pages/api/." + echo "::endgroup::" - forbidden-client-side-llm-keys: - name: 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. + # ---------- (3) No client-side LLM key leakage ---------- + echo "::group::Check 3/6: No client-side LLM key leakage" 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." - exit 1 + FAIL=1 fi CONFIG_MATCHES=$(grep -rEn 'apiKey:' pages/api/config/ 2>/dev/null || true) if [ -n "$CONFIG_MATCHES" ]; then @@ -155,7 +182,7 @@ jobs: lineno=$(echo "$line" | cut -d: -f2) echo "::error file=${file},line=${lineno}::Do not return API keys from config endpoints." done - exit 1 + FAIL=1 fi AI_OCR_IMPORTS=$(grep -rEn 'from ['\''"].*ai-ocr|import.*ai-ocr' components/ 2>/dev/null || true) if [ -n "$AI_OCR_IMPORTS" ]; then @@ -165,14 +192,13 @@ jobs: lineno=$(echo "$line" | cut -d: -f2) echo "::error file=${file},line=${lineno}::Remove ai-ocr import; call server-side scan API." done - exit 1 + FAIL=1 fi - # lib/ may hold server-only helpers imported only from pages/api/. SERVER_ONLY=( lib/scan-vision.js ) LLM_PATTERN='generativelanguage\.googleapis\.com|api\.openai\.com|ai-gateway\.vercel\.sh' - FOUND=() + LLM_FOUND=() while IFS= read -r file; do skip=false for so in "${SERVER_ONLY[@]}"; do @@ -185,66 +211,40 @@ jobs: continue fi if grep -qE "$LLM_PATTERN" "$file" 2>/dev/null; then - FOUND+=("$file") + LLM_FOUND+=("$file") fi 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/." - 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." done - exit 1 + FAIL=1 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: - name: No new modal shells without primitive - runs-on: ubuntu-latest - 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 `` 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=() + # ---------- (4) No legacy modal shells ---------- + echo "::group::Check 4/6: No new modal shells without primitive" + MODAL_FOUND=() while IFS= read -r file; do - FOUND+=("$file") + MODAL_FOUND+=("$file") done < <(grep -lE 'fixed inset-0 bg-black bg-opacity-' \ pages components -r --include='*.js' 2>/dev/null \ | sort -u || true) - if [ ${#FOUND[@]} -gt 0 ]; then + if [ ${#MODAL_FOUND[@]} -gt 0 ]; then echo "::error::Legacy modal-shell pattern detected. Use the 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 from components/ui." done - exit 1 + FAIL=1 + else + echo "OK: no legacy modal shells anywhere in pages/ or components/." fi - echo "OK: no legacy modal shells anywhere in pages/ or components/." + echo "::endgroup::" - forbidden-deprecated-color-aliases: - name: 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. + # ---------- (5) No deprecated color aliases ---------- + echo "::group::Check 5/6: No use of deprecated color aliases" PATTERNS=( 'gradient-text-purple' 'gradient-text-pink' @@ -258,9 +258,9 @@ jobs: ) ALL_MATCHES="" for pattern in "${PATTERNS[@]}"; do - MATCHES=$(grep -rFn "$pattern" pages components --include='*.js' 2>/dev/null || true) - if [ -n "$MATCHES" ]; then - ALL_MATCHES=$(printf '%s\n%s' "$ALL_MATCHES" "$MATCHES") + P_MATCHES=$(grep -rFn "$pattern" pages components --include='*.js' 2>/dev/null || true) + if [ -n "$P_MATCHES" ]; then + ALL_MATCHES=$(printf '%s\n%s' "$ALL_MATCHES" "$P_MATCHES") fi done if [ -n "$ALL_MATCHES" ]; then @@ -271,43 +271,47 @@ jobs: lineno=$(echo "$line" | cut -d: -f2) echo "::error file=${file},line=${lineno}::Deprecated color alias — replace with the canonical Deck Hearth warm-palette equivalent." done - exit 1 + FAIL=1 + else + echo "OK: no deprecated color aliases in pages/ or components/." fi - echo "OK: no deprecated color aliases in pages/ or components/." + echo "::endgroup::" - forbidden-stale-strings: - name: No stale ownership/collection copy - runs-on: ubuntu-latest - 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=( + # ---------- (6) No stale ownership/collection copy ---------- + echo "::group::Check 6/6: No stale ownership/collection copy" + STRING_PATTERNS=( 'Mark Owned' 'Owned Cards' 'All My Cards' ) - FOUND=() - for pattern in "${PATTERNS[@]}"; do + STRING_FOUND=() + for pattern in "${STRING_PATTERNS[@]}"; do while IFS= read -r line; do - FOUND+=("$line") + STRING_FOUND+=("$line") done < <(grep -rFn "$pattern" pages/ components/ \ --include='*.js' \ --exclude-dir=api 2>/dev/null || true) 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." - 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) lineno=$(echo "$line" | cut -d: -f2) text=$(echo "$line" | cut -d: -f3-) echo "::error file=${file},line=${lineno}::${text}" 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 fi - echo "OK: no forbidden stale strings in pages/ or components/." + echo "All 6 forbidden-pattern checks passed." migrate: name: Migrations apply (node-pg-migrate) @@ -334,7 +338,14 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} 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 + if: steps.cache-node-modules.outputs.cache-hit != 'true' - name: Write migrate env file run: echo "POSTGRES_URL=${POSTGRES_URL}" >> .env.local - run: npm run migrate up @@ -348,7 +359,14 @@ jobs: with: node-version: ${{ env.NODE_VERSION }} 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 + if: steps.cache-node-modules.outputs.cache-hit != 'true' - run: npm run test:run env: JWT_SECRET: ci-secret-only-for-tests-do-not-use-in-prod diff --git a/.github/workflows/preview-smoke.yml b/.github/workflows/preview-smoke.yml index 2f65fc8..50dbde3 100644 --- a/.github/workflows/preview-smoke.yml +++ b/.github/workflows/preview-smoke.yml @@ -14,6 +14,16 @@ on: pull_request: branches: [main] 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: group: preview-smoke-${{ github.event.pull_request.number }} @@ -89,10 +99,39 @@ jobs: node-version: '20' 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 + 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 - 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 run: npx playwright test --project=smoke diff --git a/.github/workflows/visual-diff.yml b/.github/workflows/visual-diff.yml index fc352d3..00b239b 100644 --- a/.github/workflows/visual-diff.yml +++ b/.github/workflows/visual-diff.yml @@ -87,8 +87,36 @@ jobs: node-version: '20' 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: 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) run: npx playwright test --project=visual --update-snapshots=none diff --git a/AGENTS.md b/AGENTS.md index 3eb4f39..4c598e4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 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 @@ -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` already ran post-rename — which would indicate an ordering error). 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. - **#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 `Sign in` 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. - **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. +- **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). ## 7. Deployment