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. Two exceptions, both Check 10 only, both always emitted: SKIP-DECLARED — continue, but emit one line naming the declared credential scope (a verification waiver exists to be reviewable, so silencing it defeats it); and SKIP-NOSANDBOX — continue, but emit one line stating that Check 10 did not run at all on this host. The second is the more important of the two: it means a security-relevant gate is dark, and folded into the silent set it is indistinguishable from the gate having nothing to do.
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."
Step 0.1: Compute changed-file path-set (diff classifier)
Run git diff --name-only origin/main...HEAD ONCE up-front and cache the result so each path-gated check re-uses the same path-set instead of re-running diff. Skill convention forbids $() command substitution — write the diff to a tmpfile so checks can grep -E the predicate they care about.
Use git rev-parse --git-dir to resolve a writable tmp path that works in both regular checkouts and worktrees: in a worktree .git is a file (gitdir pointer), not a directory, so > .git/<filename> fails with Not a directory (os error 20). The resolver returns the worktree's actual gitdir (e.g., <bare>/worktrees/<name>/):
PREFLIGHT_TMP="$(git rev-parse --git-dir)"
git diff --name-only origin/main...HEAD > "$PREFLIGHT_TMP/preflight-diff-files.txt"
All downstream cat .git/<filename> references in the predicates below must use "$PREFLIGHT_TMP/<filename>" instead. The pre-existing .git/... literals worked in non-worktree checkouts but silently broke in worktrees (where every preflight increasingly happens by default).
If the command fails (e.g., offline, no remote), every path-gated check falls back to its existing git diff form — operators do not need to handle this case explicitly. Between Phase 0 and Phase 1 nothing mutates the working tree or fetches origin/main, so the cache is valid for the duration of one preflight invocation. The cache lives under the per-worktree gitdir (user-owned, scoped to one worktree) — this isolates concurrent preflight runs across sibling worktrees and avoids the /tmp/ symlink-clobber class that affects fixed-name shared paths on multi-user hosts.
Fast-path SKIP overview. For diffs whose path-set matches a recognized "guaranteed-SKIP" shape, the relevant check returns SKIP at its first step without further work. The predicates below are the existing inner predicates of each check — only the diff source is changed.
| Check | Fast-path SKIP predicate (against "$PREFLIGHT_TMP/preflight-diff-files.txt") | | --- | --- | | 1 (Migrations) | Zero matches for (^\|/)supabase/migrations/.\.sql$. | | 2 (Sec headers) | Zero matches for \.(tsx\|css\|html)$, middleware\.ts$, next\.config\., \.tf$, Dockerfile, nginx, \.github/workflows/. | | 3 (Lockfiles) | Existing predicate (uses --name-status, status letters load-bearing — does NOT use the cached path-set; see Sharp Edges). | | 4 (Env isolation) | Always runs (no fast-path SKIP). | | 5 (Bundle host) | Zero matches for the listed Supabase client/validator paths, Dockerfile, reusable-release.yml, or verify-required-secrets.sh. | | 6 (Brand-survival) | Zero matches for the canonical sensitive-path regex. | | 7 (Canary) | apps/web-platform/infra/ci-deploy.sh not in path-set. | | 8 (SW cache bump) | No fix(/fix:/hotfix commit subject AND zero client-bundle surface matches. | | 9 (Node-only encodings) | Always runs (uses git ls-files, full-universe scan — does NOT use the cached path-set; see Sharp Edges). | | 10 (Discoverability test) | Zero matches for the canonical sensitive-path regex (re-use Check 6 SSOT). | | 11 (Register drift) | Zero matches for (^\|/)apps/web-platform/supabase/migrations/.\.sql$, (^\|/)apps/web-platform/server/workspace-resolver\.ts$, or (^\|/)knowledge-base/engineering/architecture/domain-model\.md$ (empty/missing cache → run, never SKIP). | | 12 (Encryption posture) | Zero matches for \.tf$, supabase/migrations/.\.sql$, cloud-init.\.ya?ml$, docker-compose.*\.ya?ml$ (empty/missing cache → run, never SKIP). | | Not-Bare-Repo | Always runs. |
For PR #3488-class diffs (lockfile bumps + orphan-cleanup deletions), Checks 1, 2, 5, 6, 7, 8 fast-skip → Checks 3 (lockfile fires), 4 (env isolation always), 9 (always), Not-Bare-Repo (always) execute. Of those four, only Check 3 and Check 9 do "real work" against the diff; Check 4 and Not-Bare-Repo are constant-cost.
Phase 1: Run All Checks in Parallel
Run all checks below (plus the Not-Bare-Repo assertion) 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
Shared Plan-File Resolution
Both Check 6 (Brand-Survival Self-Review) and Check 10 (Discoverability Test Execution) need the same input: the PR body scrubbed of HTML comments + fenced code blocks, concatenated with the linked plan file (also scrubbed). This sub-section is the single source of truth for that resolution. Mirrored consumers: Check 6 Step 6.4, Check 10 Step 10.3. If a future PR changes the scrub/extract logic, edit here once — both consumers pick up the change automatically.
# Step S.1: fetch PR body to a private temp file (umask 077; mktemp).
PR_BODY_FILE=$(umask 077 && mktemp -t preflight-pr-body.XXXXXXXX.md)
gh pr view --json body --jq .body > "$PR_BODY_FILE"
trap 'rm -f "$PR_BODY_FILE" "$COMBINED"' EXIT
# Step S.2: strip HTML comments and fenced code blocks from PR body.
SCRUBBED_BODY=$(awk 'BEGIN{f=0} /^[[:space:]]*```/{f=!f; next} !f{print}' "$PR_BODY_FILE" \
| perl -0777 -pe 's/<!--.*?-->//gs')
# Step S.3: extract any linked plan file path (knowledge-base/project/plans/*.md).
PLAN_PATH=$(printf '%s' "$SCRUBBED_BODY" | grep -Eo 'knowledge-base/project/plans/[^[:space:])"`'\'']+\.md' | head -n 1 || true)
# Step S.4: build the combined check input.
COMBINED=$(mktemp -t preflight-combined.XXXXXXXX.md)
printf '%s\n' "$SCRUBBED_BODY" > "$COMBINED"
if [[ -n "$PLAN_PATH" && -f "$PLAN_PATH" ]]; then
awk 'BEGIN{f=0} /^[[:space:]]*```/{f=!f; next} !f{print}' "$PLAN_PATH" \
| perl -0777 -pe 's/<!--.*?-->//gs' >> "$COMBINED"
fi
If gh pr view fails (no PR exists for the current branch), the caller returns SKIP — Shared Plan-File Resolution itself does not decide; it provides $COMBINED (and $PLAN_PATH) for the caller.
Check 1: DB Migration Status
Step 1.1: Detect new migration files in this branch.
Re-use the cached path-set from Phase 0 Step 0.1 ("$PREFLIGHT_TMP/preflight-diff-files.txt"):
grep -E '(^|/)supabase/migrations/.*\.sql$' "$PREFLIGHT_TMP/preflight-diff-files.txt