preflight
This skill should be used when running pre-ship checks on migrations, security headers, and lockfiles.
preflight Skill
Purpose: Validate technical readiness of code changes before a PR is created, catching the class of bugs that only appear in production context -- unapplied database migrations, CSP violations from injected scripts, and bare-repo stale file reads.
CRITICAL: No command substitution. Never use $() in Bash commands. When a step says "get value X, then use it in command Y", run them as two separate Bash tool calls -- first get the value, then use it literally in the next call.
Headless Mode Detection
If $ARGUMENTS contains --headless, set HEADLESS_MODE=true. Strip --headless from $ARGUMENTS before processing remaining args.
When HEADLESS_MODE=true:
- On any FAIL: abort with error details, no prompt
- On all PASS/SKIP: continue silently
Phase 0: Context Detection
Run git rev-parse --abbrev-ref HEAD to get the current branch name.
Branch safety check (defense-in-depth): If the branch is main or master, abort immediately with: "Error: preflight cannot run on main/master. Checkout a feature branch first."
Phase 1: Run All Checks in Parallel
Run these four validations as parallel Bash tool calls. Each returns PASS, FAIL, or SKIP.
Assertion: Not-Bare-Repo
This assertion runs first conceptually (fail-fast) but executes in parallel with the checks.
git rev-parse --is-bare-repository
- If the result is
true: FAIL -- "Running from bare repo root. Create a worktree first." - If the result is
false: PASS
Check 1: DB Migration Status
Step 1.1: Detect new migration files in this branch.
git diff --name-only origin/main...HEAD -- '*/supabase/migrations/*.sql'
If no migration files are found, return SKIP.
Step 1.2: Parse migration SQL for table/column pairs.
For each migration file found in Step 1.1, extract table and column names:
grep -iE 'ADD COLUMN|CREATE TABLE' <migration_file>
Parse the output to extract <table> <column> pairs. The project uses ALTER TABLE public.<table> ADD COLUMN IF NOT EXISTS <column> <type> patterns.
Step 1.3: Get Supabase credentials from Doppler.
Run these as two separate Bash calls (no command substitution):
doppler secrets get NEXT_PUBLIC_SUPABASE_URL -p soleur -c prd --plain
doppler secrets get SUPABASE_SERVICE_ROLE_KEY -p soleur -c prd --plain
If either credential is missing, return SKIP with note: "Supabase credentials not available in Doppler prd config."
Step 1.4: Verify each column via Supabase REST API.
For each table/column pair extracted in Step 1.2, issue a read-only GET request:
curl -sf "<SUPABASE_URL>/rest/v1/<table>?select=<column>&limit=1" -H "apikey: <SERVICE_ROLE_KEY>" -H "Authorization: Bearer <SERVICE_ROLE_KEY>"
- A 200 response (even empty
[]) confirms the column exists -- migration is applied. - A 400 response with "column does not exist" confirms the migration was NOT applied.
IMPORTANT: Use only GET requests. The service role key has full write access -- never issue POST, PUT, PATCH, or DELETE.
Result:
- PASS -- No migrations in PR, or all migrations verified as applied
- FAIL -- Unapplied migration found (column query returned 400)
- SKIP -- No migration files in PR, or credentials unavailable
Check 2: Security Headers and Parity
Step 2.1: Detect relevant file changes.
git diff --name-only origin/main...HEAD
Check if any changed files match these patterns: .tsx, .css, .html, middleware.ts, next.config.*, .tf, Dockerfile, nginx*, .github/workflows/*.
If no relevant files changed, return SKIP.
Step 2.2: Get production URL from Doppler.
doppler secrets get NEXT_PUBLIC_SITE_URL -p soleur -c prd --plain
If no URL is available, return SKIP with note: "Production URL not available."
Step 2.3: Fetch response headers.
Fetch headers from the root page (not /health -- the health endpoint skips CSP):
curl -sI <PRODUCTION_URL>/
Step 2.4: Validate mandatory security headers.
Check the response against the project's security header policy derived from apps/web-platform/lib/security-headers.ts:
| Header | Expected Value | Severity if Missing |
|---|---|---|
| Content-Security-Policy | Contains strict-dynamic + nonce | FAIL |
| X-Frame-Options | DENY | FAIL |
| X-Content-Type-Options | nosniff | FAIL |
| Strict-Transport-Security | max-age=63072000; includeSubDomains; preload | FAIL |
| Referrer-Policy | strict-origin-when-cross-origin | PASS (non-critical) |
| Permissions-Policy | Contains camera=(), microphone=() | PASS (non-critical) |
| Cross-Origin-Opener-Policy | same-origin | PASS (non-critical) |
| Cross-Origin-Resource-Policy | same-origin | PASS (non-critical) |
| X-DNS-Prefetch-Control | on | PASS (non-critical) |
Step 2.5: Validate CSP directive structure.
CSP is generated per-request in middleware.ts with a per-request nonce via crypto.randomUUID(). Validate the CSP structure, not the specific nonce value:
- Verify
strict-dynamicis present inscript-src - Verify no standalone
unsafe-inlinewithoutstrict-dynamicoverride - Parse CSP as directive-level tokens
Notes:
- Cloudflare-injected headers (
cf-ray,server: cloudflare) are expected in production -- ignore them. - The
/healthendpoint explicitly skips CSP in middleware -- always fetch/for the full header set. - Limitation (v1): This check validates the current production deployment, not the branch under review. It catches existing header regressions but cannot detect regressions introduced by the current PR until after deployment. Preview deployments would enable pre-merge header validation (deferred to v2).
Result:
- PASS -- All critical headers present and valid (CSP, X-Frame-Options, X-Content-Type-Options, HSTS)
- FAIL -- Any critical header missing or invalid
- SKIP -- No relevant file changes or no production URL available
Check 3: Lockfile Consistency
Step 3.1: Detect lockfile modifications in this branch.
git diff --name-status origin/main...HEAD -- '*/bun.lock' '*/package-lock.json' 'bun.lock' 'package-lock.json'
This returns status letters (M=modified, A=added, D=deleted) alongside file paths. If no output, return SKIP.
Only lockfiles with status M (modified) trigger the consistency check. Added (A) or deleted (D) lockfiles are one-time structural changes that do not require sibling updates.
Step 3.2: For each modified lockfile, verify its sibling.
For each lockfile with status M in the Step 3.1 output:
- Extract the directory path (e.g.,
apps/web-platformfromapps/web-platform/bun.lock). For root-level lockfiles (bun.lockorpackage-lock.jsonwith no path prefix), use the repository root directory. - Determine the sibling: if the modified file is
bun.lock, the sibling ispackage-lock.json(and vice versa). - Check if the sibling exists in the working tree (separate Bash call):
test -f <directory>/package-lock.json && echo "exists" || echo "missing"
- If the sibling does NOT exist (single-lockfile directory), skip this file -- no consistency check needed.
- If the sibling exists (dual-lockfile directory), check whether the sibling also appears in the Step 3.1 output (any status: M, A, or D). If the sibling is NOT in the diff at all, report FAIL: "
<directory>/modifiedbun.lockbut notpackage-lock.json. Both lockfiles must be updated together (see AGENTS.md dual-lockfile rule). Runnpm installin<directory>/to regeneratepackage-lock.json." (Ifpackage-lock.jsonwas modified withoutbun.lock, say: "Runbun installin<directory>/to regeneratebun.lock.")
If multiple files fail, report each one. Any single failure means the overall check result is FAIL.
Result:
- PASS -- All modified lockfiles in dual-lockfile directories have consistent sibling updates
- FAIL -- One or more dual-lockfile directories have a modified lockfile without its sibling updated (message names each directory and missing file)
- SKIP -- No lockfile changes in this branch
Phase 2: Aggregate Go/No-Go Report
After all checks complete, aggregate results into a structured report:
## Preflight Results
| Check | Result | Details |
|-------|--------|---------|
| Not-Bare-Repo | PASS/FAIL | <details> |
| DB Migration Status | PASS/FAIL/SKIP | <details> |
| Security Headers | PASS/FAIL/SKIP | <details> |
| Lockfile Consistency | PASS/FAIL/SKIP | <details> |
**Overall: PASS / FAIL**
If any FAIL
Headless mode: Abort with: "Preflight FAILED. See results above. Fix the issues and re-run /ship."
Interactive mode: Present findings table, then use AskUserQuestion tool:
- Question: "Preflight found issues. How to proceed?"
- Options:
- "Fix and retry" -- fix the issues, then re-run preflight from Phase 1
- "Abort" -- stop the pipeline
If all PASS or SKIP
Print the summary table and continue.
Preflight Complete
Preflight validation passed. Return control to the calling orchestrator.