|
| 1 | +#!/usr/bin/env bash |
| 2 | +set -euo pipefail |
| 3 | + |
| 4 | +if [[ "${SKIP_DOCS_PREPUSH:-0}" == "1" ]]; then |
| 5 | + echo "[pre-push] SKIP_DOCS_PREPUSH=1, skipping checks." |
| 6 | + exit 0 |
| 7 | +fi |
| 8 | + |
| 9 | +REPO_ROOT="$(git rev-parse --show-toplevel)" |
| 10 | +BRANCH="$(git rev-parse --abbrev-ref HEAD)" |
| 11 | +HEAD_SHA="$(git rev-parse HEAD)" |
| 12 | +BASE_REF="${DOCS_BASE_REF:-upstream/9.x}" |
| 13 | +POLICY_MODE="$(python3 "$REPO_ROOT/scripts/docs_push_policy.py" "$BRANCH")" |
| 14 | + |
| 15 | +PUSH_LOCAL_SHA="$HEAD_SHA" |
| 16 | +REMOTE_SHA="" |
| 17 | +while read -r _ local_sha _ remote_sha; do |
| 18 | + [[ -z "${local_sha:-}" ]] && continue |
| 19 | + PUSH_LOCAL_SHA="$local_sha" |
| 20 | + if [[ "${remote_sha:-}" =~ ^0+$ ]]; then |
| 21 | + REMOTE_SHA="" |
| 22 | + else |
| 23 | + REMOTE_SHA="$remote_sha" |
| 24 | + fi |
| 25 | +done |
| 26 | + |
| 27 | +if ! git rev-parse --verify "$BASE_REF" >/dev/null 2>&1; then |
| 28 | + BASE_REF="origin/9.x" |
| 29 | +fi |
| 30 | + |
| 31 | +if [[ "$POLICY_MODE" == "transitional" ]]; then |
| 32 | + echo "[pre-push] Transitional mode enabled for branch '$BRANCH'." |
| 33 | + if [[ -n "$REMOTE_SHA" ]]; then |
| 34 | + DIFF_RANGE="${REMOTE_SHA}..${PUSH_LOCAL_SHA}" |
| 35 | + elif git rev-parse --verify "refs/remotes/publish/${BRANCH}" >/dev/null 2>&1; then |
| 36 | + DIFF_RANGE="refs/remotes/publish/${BRANCH}..${PUSH_LOCAL_SHA}" |
| 37 | + elif git rev-parse --verify "refs/remotes/origin/${BRANCH}" >/dev/null 2>&1; then |
| 38 | + DIFF_RANGE="refs/remotes/origin/${BRANCH}..${PUSH_LOCAL_SHA}" |
| 39 | + else |
| 40 | + UPSTREAM_REF="$(git rev-parse --abbrev-ref --symbolic-full-name "${BRANCH}@{upstream}" 2>/dev/null || true)" |
| 41 | + UPSTREAM_BRANCH="${UPSTREAM_REF##*/}" |
| 42 | + if [[ -n "$UPSTREAM_REF" && "$UPSTREAM_BRANCH" == "$BRANCH" ]] && git rev-parse --verify "$UPSTREAM_REF" >/dev/null 2>&1; then |
| 43 | + DIFF_RANGE="${UPSTREAM_REF}..${PUSH_LOCAL_SHA}" |
| 44 | + else |
| 45 | + DIFF_RANGE="" |
| 46 | + fi |
| 47 | + fi |
| 48 | +else |
| 49 | + echo "[pre-push] Strict mode for branch '$BRANCH'." |
| 50 | + DIFF_RANGE="${BASE_REF}...HEAD" |
| 51 | +fi |
| 52 | + |
| 53 | +if [[ "$POLICY_MODE" != "transitional" ]]; then |
| 54 | + if [[ "$BRANCH" =~ ^(local|wip|scratch)/ ]]; then |
| 55 | + echo "[pre-push] Push is blocked for branch '$BRANCH'." |
| 56 | + echo "[pre-push] Rename branch to feat/* or fix/* before publishing." |
| 57 | + exit 1 |
| 58 | + fi |
| 59 | + |
| 60 | + if [[ ! "$BRANCH" =~ ^(feat|fix)/ ]]; then |
| 61 | + echo "[pre-push] Branch '$BRANCH' does not match allowed patterns feat/* or fix/*." |
| 62 | + exit 1 |
| 63 | + fi |
| 64 | +fi |
| 65 | + |
| 66 | +FORBIDDEN_RE='^(\.specstory/|tmp_groups/|\.cursor/plans/|\.reference/)' |
| 67 | +if [[ "$POLICY_MODE" == "transitional" && -z "$DIFF_RANGE" ]]; then |
| 68 | + echo "[pre-push] Transitional mode: no remote push range resolved; skipping forbidden-path scan." |
| 69 | + echo "[pre-push] Set upstream to the same branch on publish/origin or pass PUSH_RANGE to make qa-final-transitional." |
| 70 | +elif [[ -n "$DIFF_RANGE" ]] && git diff --name-only --diff-filter=ACMR "$DIFF_RANGE" | rg -q "$FORBIDDEN_RE"; then |
| 71 | + echo "[pre-push] Found forbidden paths in checked diff range ($DIFF_RANGE):" |
| 72 | + git diff --name-only --diff-filter=ACMR "$DIFF_RANGE" | rg "$FORBIDDEN_RE" || true |
| 73 | + exit 1 |
| 74 | +fi |
| 75 | + |
| 76 | +STAMP_FILE="$REPO_ROOT/.git/qa/docs_gate_final_${BRANCH//\//__}.json" |
| 77 | +if [[ ! -f "$STAMP_FILE" ]]; then |
| 78 | + echo "[pre-push] Missing final QA stamp for branch '$BRANCH'." |
| 79 | + if [[ "$POLICY_MODE" == "transitional" ]]; then |
| 80 | + echo "[pre-push] Run:" |
| 81 | + echo " make qa-final-transitional" |
| 82 | + else |
| 83 | + echo "[pre-push] Run:" |
| 84 | + echo " make qa-final" |
| 85 | + fi |
| 86 | + exit 1 |
| 87 | +fi |
| 88 | + |
| 89 | +read_stamp_field() { |
| 90 | + python3 - "$STAMP_FILE" "$1" <<'PY' |
| 91 | +import json, pathlib, sys |
| 92 | +p = pathlib.Path(sys.argv[1]) |
| 93 | +field = sys.argv[2] |
| 94 | +data = json.loads(p.read_text(encoding="utf-8")) |
| 95 | +print(data.get(field, "")) |
| 96 | +PY |
| 97 | +} |
| 98 | + |
| 99 | +STAMP_SHA="$(read_stamp_field sha)" |
| 100 | +STAMP_MODE="$(read_stamp_field mode)" |
| 101 | +STAMP_RANGE="$(read_stamp_field diff_range)" |
| 102 | + |
| 103 | +if [[ "$STAMP_SHA" != "$HEAD_SHA" ]]; then |
| 104 | + echo "[pre-push] Final QA stamp is stale." |
| 105 | + echo "[pre-push] stamped_sha=$STAMP_SHA" |
| 106 | + echo "[pre-push] current_sha=$HEAD_SHA" |
| 107 | + echo "[pre-push] Re-run final QA after latest commit." |
| 108 | + exit 1 |
| 109 | +fi |
| 110 | + |
| 111 | +if [[ "$POLICY_MODE" == "transitional" ]]; then |
| 112 | + if [[ "$STAMP_MODE" != "transitional" ]]; then |
| 113 | + echo "[pre-push] Branch requires transitional QA stamp (mode=transitional)." |
| 114 | + echo "[pre-push] Run: make qa-final-transitional" |
| 115 | + exit 1 |
| 116 | + fi |
| 117 | +else |
| 118 | + if [[ -n "$STAMP_MODE" && "$STAMP_MODE" != "strict" ]]; then |
| 119 | + echo "[pre-push] Branch requires strict QA stamp (mode=strict)." |
| 120 | + echo "[pre-push] Run: make qa-final" |
| 121 | + exit 1 |
| 122 | + fi |
| 123 | +fi |
| 124 | + |
| 125 | +if [[ "$POLICY_MODE" == "transitional" && -n "$STAMP_RANGE" && -n "$DIFF_RANGE" && "$STAMP_RANGE" != "$DIFF_RANGE" ]]; then |
| 126 | + echo "[pre-push] Transitional QA stamp range does not match push range." |
| 127 | + echo "[pre-push] stamped_range=$STAMP_RANGE" |
| 128 | + echo "[pre-push] push_range=$DIFF_RANGE" |
| 129 | + echo "[pre-push] Re-run: make qa-final-transitional PUSH_RANGE='$DIFF_RANGE'" |
| 130 | + exit 1 |
| 131 | +fi |
| 132 | + |
| 133 | +echo "[pre-push] All push gates passed ($POLICY_MODE mode${DIFF_RANGE:+, range: $DIFF_RANGE})." |
0 commit comments