preflight.sh 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178
  1. #!/usr/bin/env bash
  2. # preflight.sh — Full pre-push gate orchestration.
  3. #
  4. # Usage: preflight.sh [--cwd <repo-root>] <remote> <branch>
  5. # Exit codes:
  6. # 0 all gates passed; ready to push
  7. # 1 secret hit (gitleaks or regex)
  8. # 2 forbidden file added
  9. # 3 dirty working tree
  10. # 4 non-ff divergence
  11. # 5 missing dep (gitleaks / rg)
  12. # 6 bad invocation (missing remote/branch or unknown remote)
  13. #
  14. # After all gates pass, an advisory open-issue check (github-ops/check-issues.sh)
  15. # surfaces unseen external/stale issues for the target remote. It is read-only,
  16. # timeout-bounded, and NEVER changes the exit code — purely informational.
  17. set -euo pipefail
  18. # Optional --cwd <path> must come before positional args
  19. REPO_ROOT=""
  20. if [ "${1:-}" = "--cwd" ]; then
  21. REPO_ROOT="${2:?"push-gate: --cwd requires a path argument"}"
  22. shift 2
  23. fi
  24. REMOTE="${1:-}"
  25. BRANCH="${2:-}"
  26. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
  27. if [ -z "$REMOTE" ] || [ -z "$BRANCH" ]; then
  28. echo "push-gate: usage: preflight.sh [--cwd <repo-root>] <remote> <branch>" >&2
  29. exit 6
  30. fi
  31. if [ -n "$REPO_ROOT" ]; then
  32. cd "$REPO_ROOT"
  33. fi
  34. divider() { printf '%.0s─' $(seq 1 63); echo; }
  35. echo "push-gate preflight :: target = ${REMOTE}/${BRANCH}"
  36. divider
  37. # ── Step 1–2: verify remote, fetch ────────────────────────────────────────────
  38. if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
  39. echo "STEP 1 FAIL remote '${REMOTE}' not configured"
  40. echo " configured remotes:"
  41. git remote -v | sed 's/^/ /'
  42. exit 6
  43. fi
  44. REMOTE_URL="$(git remote get-url "$REMOTE")"
  45. echo "STEP 1 OK remote '${REMOTE}' = ${REMOTE_URL}"
  46. # Reject local-path remotes (use `git push . HEAD:main` pattern directly, no gate needed)
  47. case "$REMOTE_URL" in
  48. /*|[A-Za-z]:*|\.*|file:*)
  49. echo "STEP 1 INFO '${REMOTE}' looks local-filesystem; push-gate is for network remotes"
  50. echo " proceeding anyway (you can skip the gate for local updateInstead pushes)"
  51. ;;
  52. esac
  53. echo "STEP 2 RUN git fetch ${REMOTE}"
  54. if ! git fetch "$REMOTE" "$BRANCH" 2>&1 | sed 's/^/ /'; then
  55. echo "STEP 2 WARN fetch failed; proceeding with cached ${REMOTE}/${BRANCH} ref"
  56. fi
  57. # ── Step 3: clean working tree ────────────────────────────────────────────────
  58. DIRTY="$(git status --porcelain)"
  59. if [ -n "$DIRTY" ]; then
  60. echo "STEP 3 FAIL working tree dirty:"
  61. printf '%s\n' "$DIRTY" | head -20 | sed 's/^/ /'
  62. exit 3
  63. fi
  64. echo "STEP 3 OK working tree clean"
  65. # ── Step 4: pending commits ───────────────────────────────────────────────────
  66. if ! git rev-parse --verify "${REMOTE}/${BRANCH}" >/dev/null 2>&1; then
  67. echo "STEP 4 INFO ${REMOTE}/${BRANCH} does not exist yet (new remote branch)"
  68. COMMIT_COUNT="$(git rev-list --count "$BRANCH")"
  69. echo " ${COMMIT_COUNT} commits will be pushed (creating the remote branch)"
  70. else
  71. RANGE="${REMOTE}/${BRANCH}..${BRANCH}"
  72. COMMIT_COUNT="$(git rev-list --count "$RANGE")"
  73. echo "STEP 4 OK ${COMMIT_COUNT} commits pending"
  74. if [ "$COMMIT_COUNT" -eq 0 ]; then
  75. echo " nothing to push; exiting cleanly"
  76. exit 0
  77. fi
  78. git log --oneline "$RANGE" | head -20 | sed 's/^/ /'
  79. if [ "$COMMIT_COUNT" -gt 20 ]; then
  80. echo " … and $((COMMIT_COUNT - 20)) more"
  81. fi
  82. # ── Step 5: divergence ──────────────────────────────────────────────────────
  83. BEHIND="$(git rev-list --count "${BRANCH}..${REMOTE}/${BRANCH}")"
  84. if [ "$BEHIND" -gt 0 ]; then
  85. echo "STEP 5 FAIL non-ff: ${REMOTE}/${BRANCH} has ${BEHIND} commits not in local ${BRANCH}"
  86. echo " rebase or merge first: git fetch ${REMOTE} && git rebase ${REMOTE}/${BRANCH}"
  87. exit 4
  88. fi
  89. echo "STEP 5 OK clean fast-forward (local is strictly ahead)"
  90. fi
  91. divider
  92. # ── Step 6: secret scan ───────────────────────────────────────────────────────
  93. SCAN_EXIT=0
  94. bash "$SCRIPT_DIR/scan-secrets.sh" "$REMOTE" "$BRANCH" || SCAN_EXIT=$?
  95. if [ "$SCAN_EXIT" -ne 0 ]; then
  96. echo "STEP 6 FAIL secret scan (exit=$SCAN_EXIT)"
  97. exit "$SCAN_EXIT"
  98. fi
  99. echo "STEP 6 OK secret scan clean"
  100. # ── Step 7: forbidden files ───────────────────────────────────────────────────
  101. # Files that should never ship to a remote. Matched against added-file paths.
  102. # Gitignore-style patterns would be nicer; for now, a small explicit list.
  103. FORBIDDEN_REGEX='(^|/)\.env(\.|$)|(^|/)\.env\.(local|development|production|test)$|\.(pem|key|pfx|p12|asc|ppk|id_rsa|id_ed25519|id_ecdsa|id_dsa)$|(^|/)\.aws/credentials$|(^|/)\.ssh/(id_|config)|(^|/)\.claude/worktrees/|(^|/)\.claude/settings\.local\.json$|(^|/)secrets?\.(json|ya?ml|toml|ini)$'
  104. if git rev-parse --verify "${REMOTE}/${BRANCH}" >/dev/null 2>&1; then
  105. ADDED_FILES="$(git diff --name-only --diff-filter=A "${REMOTE}/${BRANCH}..${BRANCH}")"
  106. else
  107. ADDED_FILES="$(git ls-tree -r --name-only "$BRANCH")"
  108. fi
  109. FORBIDDEN_HITS="$(printf '%s\n' "$ADDED_FILES" | grep -iE "$FORBIDDEN_REGEX" || true)"
  110. if [ -n "$FORBIDDEN_HITS" ]; then
  111. echo "STEP 7 FAIL forbidden files in push:"
  112. printf '%s\n' "$FORBIDDEN_HITS" | sed 's/^/ /'
  113. echo " if any are genuinely needed on the remote, remove them from"
  114. echo " the push (git rm --cached) or relax the FORBIDDEN_REGEX in"
  115. echo " scripts/preflight.sh — the default is intentionally strict."
  116. exit 2
  117. fi
  118. echo "STEP 7 OK no forbidden file paths"
  119. # ── Step 8: size advisory ─────────────────────────────────────────────────────
  120. DIFF_BYTES=0
  121. if git rev-parse --verify "${REMOTE}/${BRANCH}" >/dev/null 2>&1; then
  122. DIFF_BYTES="$(git diff --stat="10000,10000,10000" "${REMOTE}/${BRANCH}..${BRANCH}" \
  123. | tail -1 | awk '{print $4 + $6}' 2>/dev/null || echo 0)"
  124. fi
  125. if [ "$COMMIT_COUNT" -gt 50 ]; then
  126. echo "STEP 8 WARN ${COMMIT_COUNT} commits in one push (>50). Consider whether"
  127. echo " this should be split into logical pushes for reviewability."
  128. elif [ "$COMMIT_COUNT" -gt 10 ]; then
  129. echo "STEP 8 INFO ${COMMIT_COUNT} commits (moderate batch)"
  130. else
  131. echo "STEP 8 OK ${COMMIT_COUNT} commits"
  132. fi
  133. # ── Step 9: open-issue advisory (informational; does NOT gate) ────────────────
  134. # Surface externally-authored / stale issues you may not have seen, for the remote
  135. # you're pushing to. Read-only, timeout-bounded, silent when gh is absent/unauthed
  136. # or the remote isn't GitHub. Its exit never affects the gate verdict.
  137. ISSUE_CHECK="$SCRIPT_DIR/../../github-ops/scripts/check-issues.sh"
  138. if [ -f "$ISSUE_CHECK" ]; then
  139. issue_rc=0
  140. bash "$ISSUE_CHECK" --advisory --remote "$REMOTE" || issue_rc=$?
  141. if [ "$issue_rc" -eq 10 ]; then
  142. echo "ISSUES NOTE open issues flagged above (advisory — does not block the push)"
  143. else
  144. echo "ISSUES OK no unseen open issues (or check unavailable)"
  145. fi
  146. fi
  147. divider
  148. echo "push-gate: ALL GATES PASSED"
  149. echo ""
  150. echo "Ready to push:"
  151. echo " git push ${REMOTE} ${BRANCH}"
  152. echo ""
  153. echo "push-gate does not execute the push itself. Run it explicitly to"
  154. echo "preserve 'two-human-steps' separation between gate and action."
  155. exit 0