Extract admin dashboard from ghabilee-frontend2 into a dedicated Next.js app for backoffice.ghabilee.ir (no SEO indexing / Clarity).
304 lines
9.0 KiB
Bash
Executable File
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 ""
|