pre-push 3.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127
  1. #!/usr/bin/env bash
  2. set -euo pipefail
  3. # Pre-push hook: validates v* tag pushes before they reach the remote.
  4. #
  5. # Checks:
  6. # 1. package.json version matches the tag
  7. # 2. CHANGELOG.md has a "## [{version}] - {date}" entry
  8. # 3. CI passed upstream on GitHub for the tagged commit
  9. #
  10. # In non-interactive shells (no tty), warnings auto-proceed instead of
  11. # prompting. Hard failures (version mismatch, missing changelog) always block.
  12. # Detect if we can prompt
  13. CAN_PROMPT=false
  14. if [[ -t 0 ]] || [[ -e /dev/tty ]]; then
  15. # Verify /dev/tty is actually usable
  16. if echo -n "" > /dev/tty 2>/dev/null; then
  17. CAN_PROMPT=true
  18. fi
  19. fi
  20. prompt_or_continue() {
  21. local msg="$1"
  22. if $CAN_PROMPT; then
  23. read -p "$msg [y/N] " -n 1 -r </dev/tty
  24. echo ""
  25. [[ $REPLY =~ ^[Yy]$ ]] || exit 1
  26. else
  27. echo "$msg — auto-proceeding (non-interactive)"
  28. fi
  29. }
  30. while read -r local_ref local_sha remote_ref remote_sha; do
  31. # Only validate v* tag pushes
  32. if [[ "$local_ref" != refs/tags/v* ]]; then
  33. continue
  34. fi
  35. # Skip tag deletions
  36. if [[ "$local_sha" == "0000000000000000000000000000000000000000" ]]; then
  37. continue
  38. fi
  39. TAG="${local_ref#refs/tags/}"
  40. VERSION="${TAG#v}"
  41. echo "Validating release $TAG..."
  42. # --- 1. package.json version must match the tag ---
  43. PKG_VERSION=$(jq -r .version package.json)
  44. if [[ "$PKG_VERSION" != "$VERSION" ]]; then
  45. echo ""
  46. echo "ABORT: package.json version is $PKG_VERSION but tag is $TAG"
  47. echo "Run: jq --arg v '$VERSION' '.version = \$v' package.json > tmp && mv tmp package.json"
  48. exit 1
  49. fi
  50. echo " package.json version: $PKG_VERSION ✓"
  51. # --- 2. CHANGELOG.md must have an entry for this version ---
  52. if [[ ! -f CHANGELOG.md ]]; then
  53. echo ""
  54. echo "ABORT: CHANGELOG.md not found"
  55. exit 1
  56. fi
  57. if ! grep -q "^## \[$VERSION\] - " CHANGELOG.md; then
  58. echo ""
  59. echo "ABORT: CHANGELOG.md has no entry for this release"
  60. echo "Expected heading: ## [$VERSION] - $(date +%Y-%m-%d)"
  61. echo ""
  62. echo "Write the changelog entry first. See CLAUDE.md for guidelines."
  63. exit 1
  64. fi
  65. echo " CHANGELOG.md: entry found ✓"
  66. # --- 3. CI must have passed on GitHub for this commit ---
  67. # Resolve annotated tag to its underlying commit
  68. COMMIT=$(git rev-list -n 1 "$TAG" 2>/dev/null || git rev-parse HEAD)
  69. if ! command -v gh &>/dev/null; then
  70. echo " CI check: skipped (gh CLI not installed)"
  71. continue
  72. fi
  73. CHECK_JSON=$(gh api "repos/{owner}/{repo}/commits/$COMMIT/check-runs" 2>/dev/null || echo "")
  74. if [[ -z "$CHECK_JSON" ]]; then
  75. echo " CI check: skipped (could not reach GitHub API)"
  76. continue
  77. fi
  78. TOTAL=$(echo "$CHECK_JSON" | jq -r '.total_count // 0' 2>/dev/null || echo "0")
  79. if [[ "$TOTAL" -eq 0 ]] 2>/dev/null; then
  80. MAIN_SHA=$(git rev-parse origin/main 2>/dev/null || echo "")
  81. if [[ "$COMMIT" != "$MAIN_SHA" ]]; then
  82. echo ""
  83. echo "WARNING: No CI runs found for $COMMIT"
  84. prompt_or_continue "Push tag anyway?"
  85. else
  86. echo " CI check: no runs yet (commit is tip of origin/main)"
  87. fi
  88. else
  89. FAILED=$(echo "$CHECK_JSON" | jq '[.check_runs // [] | .[] | select(.conclusion == "failure")] | length' 2>/dev/null || echo "0")
  90. PENDING=$(echo "$CHECK_JSON" | jq '[.check_runs // [] | .[] | select(.status != "completed")] | length' 2>/dev/null || echo "0")
  91. if [[ "$FAILED" -gt 0 ]] 2>/dev/null; then
  92. echo ""
  93. echo "ABORT: CI failed for commit $COMMIT"
  94. echo "Check: https://github.com/tobi/qmd/commit/$COMMIT"
  95. exit 1
  96. fi
  97. if [[ "$PENDING" -gt 0 ]] 2>/dev/null; then
  98. echo ""
  99. echo "WARNING: CI still running for commit $COMMIT ($PENDING pending)"
  100. prompt_or_continue "Push tag anyway?"
  101. else
  102. echo " CI check: all passed ✓"
  103. fi
  104. fi
  105. echo "All checks passed for $TAG ✓"
  106. done
  107. exit 0