fix: pre-push hook writes to stderr, no interactive prompts

Git hooks can't rely on tty access. Remove all interactive
prompting — just validate and exit non-zero on failure.
This commit is contained in:
Tobi Lutke 2026-02-16 11:53:52 -04:00
parent 51c03d9445
commit 1781e7bf61
No known key found for this signature in database

View File

@ -8,28 +8,7 @@ set -euo pipefail
# 2. CHANGELOG.md has a "## [{version}] - {date}" entry # 2. CHANGELOG.md has a "## [{version}] - {date}" entry
# 3. CI passed upstream on GitHub for the tagged commit # 3. CI passed upstream on GitHub for the tagged commit
# #
# In non-interactive shells (no tty), warnings auto-proceed instead of # All failures block the push and write to stderr.
# 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 </dev/tty
echo ""
[[ $REPLY =~ ^[Yy]$ ]] || exit 1
else
echo "$msg — auto-proceeding (non-interactive)"
fi
}
while read -r local_ref local_sha remote_ref remote_sha; do while read -r local_ref local_sha remote_ref remote_sha; do
# Only validate v* tag pushes # Only validate v* tag pushes
@ -45,83 +24,70 @@ while read -r local_ref local_sha remote_ref remote_sha; do
TAG="${local_ref#refs/tags/}" TAG="${local_ref#refs/tags/}"
VERSION="${TAG#v}" VERSION="${TAG#v}"
echo "Validating release $TAG..." echo >&2 "Validating release $TAG..."
# --- 1. package.json version must match the tag --- # --- 1. package.json version must match the tag ---
PKG_VERSION=$(jq -r .version package.json) PKG_VERSION=$(jq -r .version package.json)
if [[ "$PKG_VERSION" != "$VERSION" ]]; then if [[ "$PKG_VERSION" != "$VERSION" ]]; then
echo "" echo >&2 "ABORT: package.json version is $PKG_VERSION but tag is $TAG"
echo "ABORT: package.json version is $PKG_VERSION but tag is $TAG" echo >&2 "Run: jq --arg v '$VERSION' '.version = \$v' package.json > tmp && mv tmp package.json"
echo "Run: jq --arg v '$VERSION' '.version = \$v' package.json > tmp && mv tmp package.json"
exit 1 exit 1
fi fi
echo " package.json version: $PKG_VERSION ✓" echo >&2 " package.json: $PKG_VERSION ✓"
# --- 2. CHANGELOG.md must have an entry for this version --- # --- 2. CHANGELOG.md must have an entry for this version ---
if [[ ! -f CHANGELOG.md ]]; then if [[ ! -f CHANGELOG.md ]]; then
echo "" echo >&2 "ABORT: CHANGELOG.md not found"
echo "ABORT: CHANGELOG.md not found"
exit 1 exit 1
fi fi
if ! grep -q "^## \[$VERSION\] - " CHANGELOG.md; then if ! grep -q "^## \[$VERSION\] - " CHANGELOG.md; then
echo "" echo >&2 "ABORT: CHANGELOG.md has no entry for [$VERSION]"
echo "ABORT: CHANGELOG.md has no entry for this release" echo >&2 "Expected: ## [$VERSION] - $(date +%Y-%m-%d)"
echo "Expected heading: ## [$VERSION] - $(date +%Y-%m-%d)"
echo ""
echo "Write the changelog entry first. See CLAUDE.md for guidelines."
exit 1 exit 1
fi fi
echo " CHANGELOG.md: entry found ✓" echo >&2 " CHANGELOG.md: [$VERSION] ✓"
# --- 3. CI must have passed on GitHub for this commit --- # --- 3. CI must have passed on GitHub for this commit ---
# Resolve annotated tag to its underlying commit # Resolve annotated tag to its underlying commit
COMMIT=$(git rev-list -n 1 "$TAG" 2>/dev/null || git rev-parse HEAD) COMMIT=$(git rev-list -n 1 "$TAG" 2>/dev/null || git rev-parse HEAD)
if ! command -v gh &>/dev/null; then if ! command -v gh &>/dev/null; then
echo " CI check: skipped (gh CLI not installed)" echo >&2 " CI: skipped (no gh CLI)"
continue continue
fi fi
CHECK_JSON=$(gh api "repos/{owner}/{repo}/commits/$COMMIT/check-runs" 2>/dev/null || echo "") CHECK_JSON=$(gh api "repos/{owner}/{repo}/commits/$COMMIT/check-runs" 2>/dev/null || echo "")
if [[ -z "$CHECK_JSON" ]]; then if [[ -z "$CHECK_JSON" ]]; then
echo " CI check: skipped (could not reach GitHub API)" echo >&2 " CI: skipped (GitHub API unreachable)"
continue continue
fi fi
TOTAL=$(echo "$CHECK_JSON" | jq -r '.total_count // 0' 2>/dev/null || echo "0") TOTAL=$(echo "$CHECK_JSON" | jq -r '.total_count // 0' 2>/dev/null || echo "0")
if [[ "$TOTAL" -eq 0 ]] 2>/dev/null; then if [[ "$TOTAL" -eq 0 ]] 2>/dev/null; then
MAIN_SHA=$(git rev-parse origin/main 2>/dev/null || echo "") echo >&2 " CI: no runs found (push commit to main first and wait for CI)"
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 else
FAILED=$(echo "$CHECK_JSON" | jq '[.check_runs // [] | .[] | select(.conclusion == "failure")] | length' 2>/dev/null || echo "0") 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") PENDING=$(echo "$CHECK_JSON" | jq '[.check_runs // [] | .[] | select(.status != "completed")] | length' 2>/dev/null || echo "0")
if [[ "$FAILED" -gt 0 ]] 2>/dev/null; then if [[ "$FAILED" -gt 0 ]] 2>/dev/null; then
echo "" echo >&2 "ABORT: CI failed for $COMMIT"
echo "ABORT: CI failed for commit $COMMIT" echo >&2 "https://github.com/tobi/qmd/commit/$COMMIT"
echo "Check: https://github.com/tobi/qmd/commit/$COMMIT"
exit 1 exit 1
fi fi
if [[ "$PENDING" -gt 0 ]] 2>/dev/null; then if [[ "$PENDING" -gt 0 ]] 2>/dev/null; then
echo "" echo >&2 "ABORT: CI still running ($PENDING pending)"
echo "WARNING: CI still running for commit $COMMIT ($PENDING pending)" echo >&2 "Wait for CI to finish, then push again."
prompt_or_continue "Push tag anyway?" exit 1
else
echo " CI check: all passed ✓"
fi fi
echo >&2 " CI: passed ✓"
fi fi
echo "All checks passed for $TAG ✓" echo >&2 "All checks passed for $TAG ✓"
done done
exit 0 exit 0