#!/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 # # All failures block the push and write to stderr. while read -r local_ref local_sha remote_ref remote_sha; do # Only validate v* tag pushes if [[ "$local_ref" != refs/tags/v* ]]; then continue fi # Skip tag deletions if [[ "$local_sha" == "0000000000000000000000000000000000000000" ]]; then continue fi TAG="${local_ref#refs/tags/}" VERSION="${TAG#v}" echo >&2 "Validating release $TAG..." # --- 1. package.json version must match the tag --- PKG_VERSION=$(jq -r .version package.json) if [[ "$PKG_VERSION" != "$VERSION" ]]; then echo >&2 "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" exit 1 fi echo >&2 " package.json: $PKG_VERSION ✓" # --- 2. CHANGELOG.md must have an entry for this version --- if [[ ! -f CHANGELOG.md ]]; then echo >&2 "ABORT: CHANGELOG.md not found" exit 1 fi if ! grep -q "^## \[$VERSION\] - " CHANGELOG.md; then echo >&2 "ABORT: CHANGELOG.md has no entry for [$VERSION]" echo >&2 "Expected: ## [$VERSION] - $(date +%Y-%m-%d)" exit 1 fi echo >&2 " CHANGELOG.md: [$VERSION] ✓" # --- 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 >&2 " CI: skipped (no gh CLI)" continue fi CHECK_JSON=$(gh api "repos/{owner}/{repo}/commits/$COMMIT/check-runs" 2>/dev/null || echo "") if [[ -z "$CHECK_JSON" ]]; then echo >&2 " CI: skipped (GitHub API unreachable)" continue fi TOTAL=$(echo "$CHECK_JSON" | jq -r '.total_count // 0' 2>/dev/null || echo "0") if [[ "$TOTAL" -eq 0 ]] 2>/dev/null; then echo >&2 " CI: no runs found (push commit to main first and wait for CI)" 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 >&2 "ABORT: CI failed for $COMMIT" echo >&2 "https://github.com/tobi/qmd/commit/$COMMIT" exit 1 fi if [[ "$PENDING" -gt 0 ]] 2>/dev/null; then echo >&2 "ABORT: CI still running ($PENDING pending)" echo >&2 "Wait for CI to finish, then push again." exit 1 fi echo >&2 " CI: passed ✓" fi echo >&2 "All checks passed for $TAG ✓" done exit 0