GitHub Actions CI for Persimmon Projects
Philosophy
CI proves the code builds and passes static checks. It does NOT deploy — Railway does that on push to main. Keep CI fast, deterministic, and secret-free.
CI responsibilities:
- Lint (ESLint).
- Typecheck (
tsc --noEmit). - Validate Prisma schema (
prisma validate). - Build (
next build).
CI non-responsibilities:
- Deploying anything.
- Running migrations.
- Talking to the real DB or Anthropic API.
- Running E2E tests against prod.
If a Persimmon CI check needs real secrets, you've either added the wrong check or you should move it to a post-deploy smoke test running on Railway itself.
1. Canonical .github/workflows/ci.yml
Commit this at the repo root. Works as-is for any Persimmon Next.js 16 + Prisma project.
# .github/workflows/ci.yml
name: CI
on:
pull_request:
branches: [main]
push:
branches: [main]
# Cancel stale PR runs when a new commit is pushed to the same PR.
concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
jobs:
verify:
name: Lint / Typecheck / Build
runs-on: ubuntu-latest
timeout-minutes: 15
# Dummy env vars so next build + prisma generate don't complain.
# CI never connects to real services.
env:
# Prisma requires a URL to generate, but not to connect.
DATABASE_URL: "postgresql://noop:noop@localhost:5432/noop?schema=public"
# NextAuth requires these at build time even if not exercised.
NEXTAUTH_SECRET: "ci-dummy-secret-do-not-use-in-prod-ci-dummy-secret"
NEXTAUTH_URL: "http://localhost:3000"
NEXTAUTH_TRUST_HOST: "true"
# Anthropic — never called during build, dummy is fine.
ANTHROPIC_API_KEY: "sk-ant-ci-dummy"
# S3/Tigris — never called during build.
TIGRIS_ACCESS_KEY_ID: "ci-dummy"
TIGRIS_SECRET_ACCESS_KEY: "ci-dummy"
TIGRIS_ENDPOINT_URL_S3: "https://ci-dummy.invalid"
BUCKET_NAME: "ci-dummy"
NODE_ENV: "production"
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: "20"
cache: "npm"
- name: Install dependencies
run: npm ci
- name: Prisma generate
run: npx prisma generate
- name: Prisma validate
run: npx prisma validate
- name: Lint
run: npm run lint
- name: Typecheck
run: npx tsc --noEmit
- name: Cache .next
uses: actions/cache@v4
with:
path: |
${{ github.workspace }}/.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-${{ hashFiles('**/*.ts', '**/*.tsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('**/package-lock.json') }}-
- name: Build
run: npm run build
Why each piece
concurrency.cancel-in-progresson PRs only — kills obsolete PR runs when you push new commits, but never cancelsmainruns (those are load-bearing for branch protection).timeout-minutes: 15— default is 6 hours. 15 is generous for a Next 16 build and catches runaway jobs.DATABASE_URL="postgresql://noop..."— Prisma needs a parseable URL to generate the client. It does NOT connect duringprisma generateorprisma validateornext build(as long as every DB-reading page hasexport const dynamic = "force-dynamic"— seeinfra-railway-deployskill §9).- Cache key includes source file hashes — tighter invalidation than lockfile-only. The cache is a performance optimization; correctness doesn't depend on it.
npm cinotnpm install— deterministic, fails on lockfile drift, cache-friendly.- No secrets — every env var is dummy. If a build needs a real secret, you've wired something wrong (likely missing
force-dynamic).
2. prisma validate — Catch Schema Drift Early
prisma validate parses schema.prisma and fails on syntax errors, invalid relations, or unknown types. Cheap and fast. Run it before lint so schema breakage surfaces before TS complains about missing types.
If your project uses prisma db push (no migrations), that's fine — validate doesn't require migrations. If you use prisma migrate, also add:
- name: Check for pending migrations
run: |
if ! git diff --name-only HEAD~1 HEAD | grep -q "prisma/migrations/"; then
echo "No new migrations."
else
echo "New migrations detected — ensure prisma migrate deploy runs on Railway."
fi
3. The force-dynamic Audit as a CI Check (Optional)
The single biggest Railway build failure on Persimmon projects is a page that reads the DB without export const dynamic = "force-dynamic". Add a CI check that greps for it:
- name: Audit force-dynamic
run: |
set -e
OFFENDERS=$(grep -rL 'dynamic = "force-dynamic"' src/app \
| xargs -r grep -l 'from "@/lib/db"\|from "@/lib/auth"' \
| grep -v node_modules || true)
if [ -n "$OFFENDERS" ]; then
echo "::error::Files read db/auth but missing force-dynamic:"
echo "$OFFENDERS"
exit 1
fi
echo "force-dynamic audit passed."
Add this before Build. It fails fast with a clear error instead of a 3-minute Next build that dies mid-prerender.
4. Branch Protection on main
Configure once in the GitHub repo settings:
- Settings → Branches → Branch protection rules → Add rule
- Branch name pattern:
main - Require a pull request before merging → on.
- Require status checks to pass before merging → on. Select:
Lint / Typecheck / Build(exact name of the job fromci.yml).
- Require branches to be up to date → on.
- Do not allow bypassing for admins (optional but recommended).
- Restrict force pushes → on.
- Restrict deletions → on.
Once branch protection is live, main is gated by CI passing. Railway deploys only after merge, so a green CI is the deploy gate.
5. dependabot.yml — npm + Actions Updates
Commit at .github/dependabot.yml:
# .github/dependabot.yml
version: 2
updates:
# npm deps — weekly batch.
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
time: "09:00"
timezone: "America/Sao_Paulo"
open-pull-requests-limit: 5
labels:
- "deps"
- "npm"
commit-message:
prefix: "chore(deps)"
groups:
# Group patch updates to reduce PR noise.
patch-updates:
update-types:
- "patch"
# Keep Next.js, Prisma, and Anthropic SDK pinned for manual review.
next-prisma-ai:
patterns:
- "next"
- "@prisma/*"
- "prisma"
- "@anthropic-ai/*"
update-types:
- "minor"
- "major"
ignore:
# Example: if a lib has a known breaking major, pin major.
# - dependency-name: "some-lib"
# update-types: ["version-update:semver-major"]
# GitHub Actions — weekly.
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
labels:
- "deps"
- "actions"
commit-message:
prefix: "chore(ci)"
Dependabot opens PRs; CI validates them; you merge. Zero manual version chasing.
Auto-merge for low-risk updates (optional)
Add .github/workflows/dependabot-auto-merge.yml:
name: Dependabot auto-merge
on: pull_request
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
if: github.actor == 'dependabot[bot]'
runs-on: ubuntu-latest
steps:
- name: Fetch metadata
id: meta
uses: dependabot/fetch-metadata@v2
- name: Enable auto-merge (patch + dev-deps only)
if: |
steps.meta.outputs.update-type == 'version-update:semver-patch' ||
steps.meta.outputs.dependency-type == 'direct:development'
run: gh pr merge --auto --squash "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Only patch updates and dev-dep updates auto-merge. Minor/major need human review.
6. CodeQL (Optional)
Worth adding for client projects handling PII. Copy-paste, zero config:
# .github/workflows/codeql.yml
name: CodeQL
on:
push:
branches: [main]
pull_request:
branches: [main]
schedule:
- cron: "0 6 * * 1" # Mondays 06:00 UTC
jobs:
analyze:
name: Analyze (${{ matrix.language }})
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [javascript-typescript]
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Initialize CodeQL
uses: github/codeql-action/init@v3
with:
languages: ${{ matrix.language }}
queries: security-and-quality
- name: Autobuild
uses: github/codeql-action/autobuild@v3
- name: Analyze
uses: github/codeql-action/analyze@v3
with:
category: /language:${{ matrix.language }}
Findings land in Security → Code scanning alerts. Review weekly.
7. Matrix — Don't Use It Unless You Need To
Resist the urge to matrix Node versions (18, 20, 22) for Persimmon projects. Railway runs a specific Node version; CI should match it. Matrix is for libraries, not apps.
Valid uses of matrix in Persimmon CI:
- Multi-OS smoke test for a CLI tool (rare).
- Checking behavior across Prisma versions during an upgrade (temporary branch-local workflow).
If you're about to matrix Node for a client app, don't — pin 20 and move on.
8. Secrets — When You Actually Need Them
You don't, for CI. But if a future workflow needs one (e.g., uploading build artifacts to S3 for a release pipeline):
- Repo Settings → Secrets and variables → Actions → New repository secret.
- Reference as
${{ secrets.NAME }}in the workflow. - Never log secret values. GitHub masks known secret values in logs, but format tricks (base64, concat) leak them.
- Never give secrets to PR runs from forks. Use
pull_request_targetwith extreme care, or just don't — Persimmon projects are private anyway.
For Persimmon, the Anthropic key, Tigris creds, and DATABASE_URL live in Railway, not GitHub. CI has no reason to hold them.
9. Debugging a Red CI
npm ci fails with lockfile mismatch
- Someone edited
package.jsonwithout runningnpm install. - Fix: run
npm installlocally, commit the updatedpackage-lock.json.
prisma generate fails: Environment variable not found: DATABASE_URL
- The workflow
env:block is missingDATABASE_URL. Add the dummy URL from §1.
tsc --noEmit fails with Cannot find module '@prisma/client'
prisma generatewasn't run before typecheck. Move thePrisma generatestep aboveTypecheck.
next build fails with PrismaClientInitializationError
- A page is prerendering and hitting the dummy DB URL. Add
force-dynamicto the offending page (build log names it).
Cache restore takes longer than the build
- Cache key too loose (every change invalidates), or cache size > 5GB and GitHub evicts fast.
- Narrow the cache path to
.next/cacheonly (never cache.next/server).
Concurrency cancels main runs
cancel-in-progress: truewithout thegithub.event_name == 'pull_request'guard.- Use the conditional form from §1.
Dependabot PR red, but main green
- Dependabot bumped a minor that broke a type. Merge after fixing, or pin the version in
ignore.
CodeQL timing out
- Default is 6 hours;
autobuildcan stall. Drop totimeout-minutes: 30and if it still fails, specify a manual build step.
npm run lint fails only on CI, passes locally
.eslintrcrelies on a globally installed package, or IDE has lenient rules. Verifynpm run lintworks in a fresh clone. Add--max-warnings 0to fail on warnings too.
10. Checklist
-
.github/workflows/ci.ymlcommitted and passing onmain. - Dummy env vars in workflow — no real secrets.
-
prisma generateruns beforetscandnext build. -
concurrencycancels stale PR runs (and ONLY PR runs). -
timeout-minutes≤ 15 on the verify job. -
.next/cachecached between runs. - Branch protection on
mainrequires the CI job. -
.github/dependabot.ymlcovers npm + actions. - (Optional) auto-merge workflow for patch + dev-deps.
- (Optional) CodeQL workflow for security scanning.
- (Optional) force-dynamic audit step before build.
- No secrets referenced unless genuinely needed (Persimmon default: zero).