pre-push 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112
  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. # Installed automatically by: bun install (via prepare script)
  11. while read -r local_ref local_sha remote_ref remote_sha; do
  12. # Only validate v* tag pushes
  13. if [[ "$local_ref" != refs/tags/v* ]]; then
  14. continue
  15. fi
  16. # Skip tag deletions
  17. if [[ "$local_sha" == "0000000000000000000000000000000000000000" ]]; then
  18. continue
  19. fi
  20. TAG="${local_ref#refs/tags/}"
  21. VERSION="${TAG#v}"
  22. echo "Validating release $TAG..."
  23. # --- 1. package.json version must match the tag ---
  24. PKG_VERSION=$(jq -r .version package.json)
  25. if [[ "$PKG_VERSION" != "$VERSION" ]]; then
  26. echo ""
  27. echo "ABORT: package.json version is $PKG_VERSION but tag is $TAG"
  28. echo "Run: jq --arg v '$VERSION' '.version = \$v' package.json > tmp && mv tmp package.json"
  29. exit 1
  30. fi
  31. echo " package.json version: $PKG_VERSION"
  32. # --- 2. CHANGELOG.md must have an entry for this version ---
  33. if [[ ! -f CHANGELOG.md ]]; then
  34. echo ""
  35. echo "ABORT: CHANGELOG.md not found"
  36. exit 1
  37. fi
  38. if ! grep -q "^## \[$VERSION\] - " CHANGELOG.md; then
  39. echo ""
  40. echo "ABORT: CHANGELOG.md has no entry for this release"
  41. echo "Expected heading: ## [$VERSION] - $(date +%Y-%m-%d)"
  42. echo ""
  43. echo "Write the changelog entry first. See CLAUDE.md for guidelines."
  44. exit 1
  45. fi
  46. echo " CHANGELOG.md: entry found"
  47. # --- 3. CI must have passed on GitHub for this commit ---
  48. COMMIT=$(git rev-parse "$TAG" 2>/dev/null || git rev-parse HEAD)
  49. if ! command -v gh &>/dev/null; then
  50. echo " CI check: skipped (gh CLI not installed)"
  51. continue
  52. fi
  53. # Check GitHub Actions check runs
  54. CHECK_JSON=$(gh api "repos/{owner}/{repo}/commits/$COMMIT/check-runs" 2>/dev/null || echo "")
  55. if [[ -z "$CHECK_JSON" ]]; then
  56. echo " CI check: skipped (could not reach GitHub API)"
  57. continue
  58. fi
  59. TOTAL=$(echo "$CHECK_JSON" | jq -r '.total_count // 0' 2>/dev/null || echo "0")
  60. if [[ "$TOTAL" -eq 0 ]] 2>/dev/null; then
  61. # No checks found — commit may not be pushed yet
  62. MAIN_SHA=$(git rev-parse origin/main 2>/dev/null || echo "")
  63. if [[ "$COMMIT" != "$MAIN_SHA" ]]; then
  64. echo ""
  65. echo "WARNING: No CI runs found for $COMMIT and it's not the tip of origin/main."
  66. echo "Push the commit to main first and wait for CI to pass."
  67. read -p "Push tag anyway? [y/N] " -n 1 -r </dev/tty
  68. echo ""
  69. [[ $REPLY =~ ^[Yy]$ ]] || exit 1
  70. else
  71. echo " CI check: no runs found (commit is on main)"
  72. fi
  73. else
  74. FAILED=$(echo "$CHECK_JSON" | jq '[.check_runs // [] | .[] | select(.conclusion == "failure")] | length' 2>/dev/null || echo "0")
  75. PENDING=$(echo "$CHECK_JSON" | jq '[.check_runs // [] | .[] | select(.status != "completed")] | length' 2>/dev/null || echo "0")
  76. if [[ "$FAILED" -gt 0 ]] 2>/dev/null; then
  77. echo ""
  78. echo "ABORT: CI failed for commit $COMMIT"
  79. echo "Check: https://github.com/tobi/qmd/commit/$COMMIT"
  80. exit 1
  81. fi
  82. if [[ "$PENDING" -gt 0 ]] 2>/dev/null; then
  83. echo ""
  84. echo "WARNING: CI still running for commit $COMMIT ($PENDING pending)"
  85. read -p "Push tag anyway? [y/N] " -n 1 -r </dev/tty
  86. echo ""
  87. [[ $REPLY =~ ^[Yy]$ ]] || exit 1
  88. else
  89. echo " CI check: all passed"
  90. fi
  91. fi
  92. echo "All checks passed for $TAG"
  93. done
  94. exit 0