#!/usr/bin/env bash set -euo pipefail # Pre-push hook: validates v* tag pushes before they reach the remote. # # Checks: # 1. package.json version matches the tag # 2. CHANGELOG.md has a "## [{version}] - {date}" entry # 3. CI passed upstream on GitHub for the tagged commit # # In non-interactive shells (no tty), warnings auto-proceed instead of # prompting. Hard failures (version mismatch, missing changelog) always block. # Detect if we can prompt CAN_PROMPT=false if [[ -t 0 ]] || [[ -e /dev/tty ]]; then # Verify /dev/tty is actually usable if echo -n "" > /dev/tty 2>/dev/null; then CAN_PROMPT=true fi fi prompt_or_continue() { local msg="$1" if $CAN_PROMPT; then read -p "$msg [y/N] " -n 1 -r tmp && mv tmp package.json" exit 1 fi echo " package.json version: $PKG_VERSION ✓" # --- 2. CHANGELOG.md must have an entry for this version --- if [[ ! -f CHANGELOG.md ]]; then echo "" echo "ABORT: CHANGELOG.md not found" exit 1 fi if ! grep -q "^## \[$VERSION\] - " CHANGELOG.md; then echo "" echo "ABORT: CHANGELOG.md has no entry for this release" echo "Expected heading: ## [$VERSION] - $(date +%Y-%m-%d)" echo "" echo "Write the changelog entry first. See CLAUDE.md for guidelines." exit 1 fi echo " CHANGELOG.md: entry found ✓" # --- 3. CI must have passed on GitHub for this commit --- # Resolve annotated tag to its underlying commit COMMIT=$(git rev-list -n 1 "$TAG" 2>/dev/null || git rev-parse HEAD) if ! command -v gh &>/dev/null; then echo " CI check: skipped (gh CLI not installed)" continue fi CHECK_JSON=$(gh api "repos/{owner}/{repo}/commits/$COMMIT/check-runs" 2>/dev/null || echo "") if [[ -z "$CHECK_JSON" ]]; then echo " CI check: skipped (could not reach GitHub API)" continue fi TOTAL=$(echo "$CHECK_JSON" | jq -r '.total_count // 0' 2>/dev/null || echo "0") if [[ "$TOTAL" -eq 0 ]] 2>/dev/null; then MAIN_SHA=$(git rev-parse origin/main 2>/dev/null || echo "") if [[ "$COMMIT" != "$MAIN_SHA" ]]; then echo "" echo "WARNING: No CI runs found for $COMMIT" prompt_or_continue "Push tag anyway?" else echo " CI check: no runs yet (commit is tip of origin/main)" fi else FAILED=$(echo "$CHECK_JSON" | jq '[.check_runs // [] | .[] | select(.conclusion == "failure")] | length' 2>/dev/null || echo "0") PENDING=$(echo "$CHECK_JSON" | jq '[.check_runs // [] | .[] | select(.status != "completed")] | length' 2>/dev/null || echo "0") if [[ "$FAILED" -gt 0 ]] 2>/dev/null; then echo "" echo "ABORT: CI failed for commit $COMMIT" echo "Check: https://github.com/tobi/qmd/commit/$COMMIT" exit 1 fi if [[ "$PENDING" -gt 0 ]] 2>/dev/null; then echo "" echo "WARNING: CI still running for commit $COMMIT ($PENDING pending)" prompt_or_continue "Push tag anyway?" else echo " CI check: all passed ✓" fi fi echo "All checks passed for $TAG ✓" done exit 0