admin/scripts/pre-push-checks.sh
alisaza e1eaf5eff5 feat: initial ghabilee-admin backoffice app
Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js
app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
2026-09-05 13:12:59 +03:30

304 lines
9.0 KiB
Bash
Executable File

#!/usr/bin/env bash
# Mirrors .github/workflows/frontend-quality.yml (verify job).
# Intentionally not mirrored locally: OSV scan, gitleaks (PR-only on GitHub).
#
# Resume: after a failed push, re-push skips steps whose inputs are unchanged
# (checkpoint in .git/ghabilee-prepush-checkpoint). Force full rerun:
# PREPUSH_NO_CACHE=1 git push
set -euo pipefail
ROOT="$(git rev-parse --show-toplevel)"
cd "$ROOT"
ZERO="0000000000000000000000000000000000000000"
CHECKPOINT_FILE="$ROOT/.git/ghabilee-prepush-checkpoint"
# Same idea as CI path filters — docs/ops-only pushes skip the heavy suite.
FRONTEND_PATH_RE='^(app/|components/|context/|features/|hooks/|services/|lib/|helpers/|validation/|types/|queries/|texts/|constants/|content/|config/|api/|e2e/|public/|styles/|messages/|middleware\.|next\.config|orval\.config|vitest|playwright|openapi\.json$|package\.json$|pnpm-lock\.yaml$|tsconfig|eslint\.config|postcss\.config|tailwind\.config|knip\.json$|\.github/workflows/frontend-quality\.yml$|scripts/)'
PUSH_LOCAL_SHA=""
PUSH_REMOTE_SHA=""
CHECKPOINT_REMOTE_SHA=""
if [[ "${SKIP_PREPUSH_CHECKS:-}" == "1" ]]; then
echo "⚠ SKIP_PREPUSH_CHECKS=1 — skipping pre-push checks"
exit 0
fi
run_step() {
local title="$1"
shift
echo ""
echo "==> $title"
local start
start="$(date +%s)"
"$@"
echo " ($(( $(date +%s) - start ))s)"
}
hash_paths() {
local files=""
local spec
for spec in "$@"; do
local chunk
chunk="$(git ls-files -co --exclude-standard -- "$spec" 2>/dev/null || true)"
[[ -n "$chunk" ]] && files+="${chunk}"$'\n'
done
files="$(printf '%s' "$files" | LC_ALL=C sort -u | sed '/^$/d')"
if [[ -z "$files" ]]; then
printf '%s' "empty"
return 0
fi
printf '%s\0' "$files" | LC_ALL=C sort -z | xargs -0 git hash-object 2>/dev/null | shasum -a 256 | awk '{print $1}'
}
checkpoint_clear() {
rm -f "$CHECKPOINT_FILE"
}
checkpoint_get_remote_sha() {
[[ -f "$CHECKPOINT_FILE" ]] || return 0
grep '^remote_sha=' "$CHECKPOINT_FILE" | head -1 | cut -d= -f2-
}
checkpoint_get_step_hash() {
local step_id="$1"
[[ -f "$CHECKPOINT_FILE" ]] || return 0
grep "^step:${step_id}=" "$CHECKPOINT_FILE" | head -1 | cut -d= -f2- || true
}
checkpoint_mark_step() {
local step_id="$1"
local step_hash="$2"
local remote_line="remote_sha=${PUSH_REMOTE_SHA}"
local tmp
tmp="$(mktemp)"
if [[ -f "$CHECKPOINT_FILE" ]]; then
grep -v "^step:${step_id}=" "$CHECKPOINT_FILE" | grep -v '^remote_sha=' >"$tmp" || true
fi
{
echo "$remote_line"
if [[ -s "$tmp" ]]; then
cat "$tmp"
fi
echo "step:${step_id}=${step_hash}"
} >"$CHECKPOINT_FILE"
rm -f "$tmp"
}
checkpoint_init_run() {
CHECKPOINT_REMOTE_SHA="$(checkpoint_get_remote_sha)"
if [[ "$CHECKPOINT_REMOTE_SHA" != "$PUSH_REMOTE_SHA" ]]; then
checkpoint_clear
echo "remote_sha=${PUSH_REMOTE_SHA}" >"$CHECKPOINT_FILE"
elif [[ ! -f "$CHECKPOINT_FILE" ]]; then
echo "remote_sha=${PUSH_REMOTE_SHA}" >"$CHECKPOINT_FILE"
fi
}
checkpoint_step_hash_matches() {
local step_id="$1"
local current_hash="$2"
[[ "${PREPUSH_NO_CACHE:-}" == "1" ]] && return 1
[[ "$(checkpoint_get_remote_sha)" == "$PUSH_REMOTE_SHA" ]] || return 1
[[ "$(checkpoint_get_step_hash "$step_id")" == "$current_hash" ]]
}
run_cached_step() {
local step_id="$1"
local title="$2"
local input_hash="$3"
shift 3
if checkpoint_step_hash_matches "$step_id" "$input_hash"; then
echo ""
echo "==> $title (skipped — inputs unchanged since last run)"
return 0
fi
run_step "$title" "$@"
checkpoint_mark_step "$step_id" "$input_hash"
}
frontend_orval_drift() {
pnpm exec orval --config orval.config.ts
git diff --exit-code -- api/generated
}
frontend_build() {
NEXT_PUBLIC_API_URL="${NEXT_PUBLIC_API_URL:-http://127.0.0.1:3000}" \
pnpm exec next build
pnpm bundle:check
}
run_frontend_quality() {
checkpoint_init_run
echo ""
echo "── Frontend quality (frontend-quality.yml) ──"
if [[ "${PREPUSH_NO_CACHE:-}" != "1" && -s "$CHECKPOINT_FILE" ]] && grep -q '^step:' "$CHECKPOINT_FILE" 2>/dev/null; then
echo " Resume enabled — unchanged steps are skipped (PREPUSH_NO_CACHE=1 to rerun all)."
fi
run_cached_step generate_api "Generate API client from committed OpenAPI" \
"$(hash_paths openapi.json orval.config.ts scripts/generate-api.mjs)" \
pnpm generate:api
# Always run full typecheck (no checkpoint skip) — same surface as CI `pnpm typecheck`.
run_step "TypeScript" pnpm typecheck
run_cached_step lint "ESLint" \
"$(hash_paths '*.ts' '*.tsx' '*.js' '*.jsx' eslint.config.mjs)" \
pnpm lint
run_cached_step build "Production build and bundle budget" \
"$(hash_paths app components context features hooks services lib helpers validation types queries texts constants content config api public styles messages scripts '*.ts' '*.tsx' next.config.js postcss.config.mjs tailwind.config.ts package.json pnpm-lock.yaml)" \
frontend_build
run_cached_step orval_drift "Verify generated API client" \
"$(hash_paths openapi.json orval.config.ts api scripts/generate-api.mjs)" \
frontend_orval_drift
run_cached_step test "Unit tests" \
"$(hash_paths '*.ts' '*.tsx' vitest.config.ts vitest.consumer.config.ts package.json pnpm-lock.yaml)" \
pnpm test
run_cached_step architecture "Architecture (no circular dependencies)" \
"$(hash_paths app components context hooks services lib config '*.ts' '*.tsx')" \
pnpm architecture:check
run_cached_step pwa "PWA policy" \
"$(hash_paths scripts/check-service-worker-policy.mjs public package.json '*.ts' '*.tsx')" \
pnpm pwa:check
run_cached_step knip "Unused code and dependencies" \
"$(hash_paths knip.json package.json pnpm-lock.yaml '*.ts' '*.tsx' '*.js' '*.jsx' '*.mjs')" \
pnpm unused:check
checkpoint_clear
}
echo ""
echo "Pre-push CI guard — mirrors frontend-quality.yml on matching paths."
echo "Pre-commit already auto-fixes staged files (ESLint --fix + Prettier)."
echo "Skip once: HUSKY=0 git push or SKIP_PREPUSH_CHECKS=1 git push"
run_frontend=0
changed_files=""
compared=0
version_checked=0
compare_and_collect() {
local local_sha="$1"
local remote_sha="$2"
[[ "$local_sha" == "$ZERO" ]] && return 0
if [[ "$remote_sha" == "$ZERO" ]]; then
if git rev-parse --verify --quiet origin/main >/dev/null; then
remote_sha="$(git rev-parse origin/main)"
elif git rev-parse --verify --quiet main >/dev/null; then
remote_sha="$(git rev-parse main)"
else
return 0
fi
fi
[[ "$local_sha" == "$remote_sha" ]] && return 0
PUSH_LOCAL_SHA="$local_sha"
PUSH_REMOTE_SHA="$remote_sha"
compared=1
changed_files+="$(git diff --name-only "$remote_sha" "$local_sha")"$'\n'
}
check_version_bump() {
local local_sha="$1"
local remote_sha="$2"
[[ "$local_sha" == "$ZERO" || "$local_sha" == "$remote_sha" ]] && return 0
if [[ "$remote_sha" == "$ZERO" ]]; then
if git rev-parse --verify --quiet origin/main >/dev/null; then
remote_sha="$(git rev-parse origin/main)"
elif git rev-parse --verify --quiet main >/dev/null; then
remote_sha="$(git rev-parse main)"
else
return 0
fi
fi
[[ "$local_sha" == "$remote_sha" ]] && return 0
node "$ROOT/scripts/check-version-bump.mjs" "$remote_sha" "$local_sha"
version_checked=1
}
if [[ -t 0 ]]; then
echo " (no git refs on stdin — comparing HEAD to origin/main, running full suite)"
PUSH_LOCAL_SHA="$(git rev-parse HEAD)"
if git rev-parse --verify --quiet origin/main >/dev/null; then
PUSH_REMOTE_SHA="$(git rev-parse origin/main)"
else
PUSH_REMOTE_SHA="$ZERO"
fi
compare_and_collect "$PUSH_LOCAL_SHA" "$PUSH_REMOTE_SHA"
compared=0
else
push_refs="$(mktemp)"
trap 'rm -f "$push_refs"' EXIT
cat >"$push_refs"
while read -r _local_ref local_sha _remote_ref remote_sha; do
compare_and_collect "$local_sha" "$remote_sha"
done <"$push_refs"
fi
if [[ -z "$PUSH_LOCAL_SHA" ]]; then
PUSH_LOCAL_SHA="$(git rev-parse HEAD)"
fi
if [[ -z "$PUSH_REMOTE_SHA" || "$PUSH_REMOTE_SHA" == "$ZERO" ]]; then
if git rev-parse --verify --quiet origin/main >/dev/null; then
PUSH_REMOTE_SHA="$(git rev-parse origin/main)"
else
PUSH_REMOTE_SHA="$ZERO"
fi
fi
if [[ "$compared" -eq 0 ]]; then
run_frontend=1
elif echo "$changed_files" | grep -qE "$FRONTEND_PATH_RE"; then
run_frontend=1
fi
if [[ "$run_frontend" -eq 1 ]]; then
echo ""
echo "==> Frontend version bump"
if [[ -t 0 ]]; then
check_version_bump "$PUSH_LOCAL_SHA" "$PUSH_REMOTE_SHA"
else
while read -r _local_ref local_sha _remote_ref remote_sha; do
if [[ "$local_sha" != "$ZERO" && "$remote_sha" != "$ZERO" && "$local_sha" != "$remote_sha" ]]; then
check_version_bump "$local_sha" "$remote_sha"
fi
done <"$push_refs"
fi
if [[ "$version_checked" -eq 0 ]]; then
echo " (no refs to compare — skipping version check)"
fi
fi
if [[ "$run_frontend" -eq 0 ]]; then
echo ""
echo "No frontend code paths in this push — skipping quality suite (same as CI path filters)."
checkpoint_clear
echo "✓ Pre-push checks passed"
echo ""
exit 0
fi
run_frontend_quality
echo ""
echo "✓ Pre-push checks passed"
echo ""