293 lines
7.5 KiB
Bash
Executable File
293 lines
7.5 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -Eeuo pipefail
|
|
|
|
readonly REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
readonly BACKEND_DIR="${REPO_ROOT}/backend"
|
|
readonly FRONTEND_DIR="${REPO_ROOT}/frontend"
|
|
readonly MODE="${1:-quick}"
|
|
readonly SCOPE="${2:-}"
|
|
|
|
if [[ ${MODE} != "quick" && ${MODE} != "test" && ${MODE} != "release" ]]; then
|
|
printf 'Usage: %s {quick|test|release} [--staged]\n' "$0" >&2
|
|
exit 2
|
|
fi
|
|
if [[ -n ${SCOPE} && ${SCOPE} != "--staged" ]]; then
|
|
printf 'Unknown option: %s\n' "${SCOPE}" >&2
|
|
exit 2
|
|
fi
|
|
|
|
log() {
|
|
printf '\n==> %s\n' "$*"
|
|
}
|
|
|
|
require_command() {
|
|
if ! command -v "$1" >/dev/null 2>&1; then
|
|
printf 'Required command is unavailable: %s\n' "$1" >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
python_command() {
|
|
if [[ -x ${BACKEND_DIR}/.venv/bin/python ]]; then
|
|
printf '%s\n' "${BACKEND_DIR}/.venv/bin/python"
|
|
elif command -v python3 >/dev/null 2>&1; then
|
|
command -v python3
|
|
else
|
|
printf 'Python is unavailable. Create backend/.venv first.\n' >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
npm_command() {
|
|
if command -v npm >/dev/null 2>&1; then
|
|
printf '%s\n' "$(command -v npm)"
|
|
elif command -v corepack >/dev/null 2>&1; then
|
|
printf '%s\n' "$(command -v corepack) npm"
|
|
else
|
|
printf 'npm is unavailable. Install the project Node.js version first.\n' >&2
|
|
exit 1
|
|
fi
|
|
}
|
|
|
|
resolve_comparison_base() {
|
|
local candidate="${QUALITY_BASE_SHA:-}"
|
|
if [[ -n ${candidate} && ! ${candidate} =~ ^0+$ ]] \
|
|
&& git -C "${REPO_ROOT}" cat-file -e "${candidate}^{commit}" 2>/dev/null; then
|
|
printf '%s\n' "${candidate}"
|
|
return
|
|
fi
|
|
|
|
if git -C "${REPO_ROOT}" show-ref --verify --quiet refs/remotes/origin/master; then
|
|
git -C "${REPO_ROOT}" merge-base HEAD origin/master
|
|
return
|
|
fi
|
|
if git -C "${REPO_ROOT}" rev-parse --verify HEAD^ >/dev/null 2>&1; then
|
|
git -C "${REPO_ROOT}" rev-parse HEAD^
|
|
return
|
|
fi
|
|
git -C "${REPO_ROOT}" hash-object -t tree /dev/null
|
|
}
|
|
|
|
collect_changed_files() {
|
|
local base
|
|
if [[ ${SCOPE} == "--staged" ]]; then
|
|
git -C "${REPO_ROOT}" diff --cached --name-only --diff-filter=ACMR -z
|
|
else
|
|
base="$(resolve_comparison_base)"
|
|
git -C "${REPO_ROOT}" diff "${base}" HEAD --name-only --diff-filter=ACMR -z
|
|
fi
|
|
}
|
|
|
|
check_partially_staged_files() {
|
|
local -a staged_files=()
|
|
local file
|
|
while IFS= read -r -d '' file; do
|
|
staged_files+=("${file}")
|
|
done < <(git -C "${REPO_ROOT}" diff --cached --name-only --diff-filter=ACMR -z)
|
|
|
|
for file in "${staged_files[@]}"; do
|
|
if ! git -C "${REPO_ROOT}" diff --quiet -- "${file}"; then
|
|
printf 'Partially staged file cannot be checked reliably: %s\n' "${file}" >&2
|
|
printf 'Stage all its changes or commit them separately.\n' >&2
|
|
exit 1
|
|
fi
|
|
done
|
|
}
|
|
|
|
run_changed_quality() {
|
|
local python
|
|
local -a npm=()
|
|
local -a python_files=()
|
|
local -a eslint_files=()
|
|
local -a prettier_files=()
|
|
local -a shell_files=()
|
|
local file relative
|
|
|
|
if [[ ${SCOPE} == "--staged" ]]; then
|
|
check_partially_staged_files
|
|
git -C "${REPO_ROOT}" diff --cached --check
|
|
else
|
|
git -C "${REPO_ROOT}" diff --check
|
|
fi
|
|
|
|
while IFS= read -r -d '' file; do
|
|
case "${file}" in
|
|
frontend/package-lock.json|frontend/pnpm-lock.yaml)
|
|
# Package managers own lockfile formatting and verify it during install.
|
|
;;
|
|
backend/*.py)
|
|
python_files+=("./${file#backend/}")
|
|
;;
|
|
frontend/*.js|frontend/*.jsx|frontend/*.ts|frontend/*.tsx|frontend/*.mjs|frontend/*.cjs)
|
|
relative="./${file#frontend/}"
|
|
if [[ ${relative} != "./next-env.d.ts" ]]; then
|
|
eslint_files+=("${relative}")
|
|
prettier_files+=("${relative}")
|
|
fi
|
|
;;
|
|
frontend/*.json|frontend/*.css|frontend/*.scss|frontend/*.md|frontend/*.yaml|frontend/*.yml)
|
|
prettier_files+=("./${file#frontend/}")
|
|
;;
|
|
.githooks/*|tools/quality/*.sh)
|
|
shell_files+=("${REPO_ROOT}/${file}")
|
|
;;
|
|
esac
|
|
done < <(collect_changed_files)
|
|
|
|
if (( ${#shell_files[@]} )); then
|
|
log "Bash syntax: changed hook and quality scripts"
|
|
bash -n "${shell_files[@]}"
|
|
fi
|
|
|
|
if (( ${#python_files[@]} )); then
|
|
python="$(python_command)"
|
|
log "Ruff: changed Python files"
|
|
(
|
|
cd "${BACKEND_DIR}"
|
|
"${python}" -m ruff check "${python_files[@]}"
|
|
"${python}" -m ruff format --check "${python_files[@]}"
|
|
)
|
|
fi
|
|
|
|
if (( ${#eslint_files[@]} || ${#prettier_files[@]} )); then
|
|
read -r -a npm <<<"$(npm_command)"
|
|
fi
|
|
if (( ${#eslint_files[@]} )); then
|
|
log "ESLint: changed frontend files"
|
|
(
|
|
cd "${FRONTEND_DIR}"
|
|
"${FRONTEND_DIR}/node_modules/.bin/eslint" \
|
|
--max-warnings=0 "${eslint_files[@]}"
|
|
)
|
|
fi
|
|
if (( ${#prettier_files[@]} )); then
|
|
log "Prettier: changed frontend files"
|
|
(
|
|
cd "${FRONTEND_DIR}"
|
|
"${FRONTEND_DIR}/node_modules/.bin/prettier" \
|
|
--check "${prettier_files[@]}"
|
|
)
|
|
fi
|
|
}
|
|
|
|
run_application_checks() {
|
|
local python
|
|
python="$(python_command)"
|
|
log "Django system and migration checks"
|
|
(
|
|
cd "${BACKEND_DIR}"
|
|
DJANGO_SETTINGS_MODULE=eusrr_backend.settings_test \
|
|
"${python}" manage.py check
|
|
DJANGO_SETTINGS_MODULE=eusrr_backend.settings_test \
|
|
"${python}" manage.py makemigrations --check --dry-run
|
|
)
|
|
}
|
|
|
|
run_test_suite() {
|
|
local python
|
|
local -a npm=()
|
|
python="$(python_command)"
|
|
read -r -a npm <<<"$(npm_command)"
|
|
|
|
log "Backend test suite"
|
|
(
|
|
cd "${BACKEND_DIR}"
|
|
"${python}" -m pytest -q
|
|
)
|
|
|
|
log "Frontend test suite"
|
|
(
|
|
cd "${FRONTEND_DIR}"
|
|
"${npm[@]}" test
|
|
)
|
|
|
|
log "Frontend production build"
|
|
(
|
|
cd "${FRONTEND_DIR}"
|
|
"${npm[@]}" run build
|
|
)
|
|
}
|
|
|
|
run_postgresql_migration_check() (
|
|
local python container port
|
|
python="$(python_command)"
|
|
require_command docker
|
|
container="eusrr-quality-postgres-$$"
|
|
|
|
cleanup_postgres() {
|
|
docker rm --force "${container}" >/dev/null 2>&1 || true
|
|
}
|
|
trap cleanup_postgres EXIT HUP INT TERM
|
|
|
|
log "Fresh PostgreSQL migration"
|
|
docker run --rm --detach --name "${container}" \
|
|
--env POSTGRES_DB=eusrr_quality \
|
|
--env POSTGRES_USER=postgres \
|
|
--env POSTGRES_PASSWORD=quality \
|
|
--publish 127.0.0.1::5432 postgres:17-alpine >/dev/null
|
|
port="$(docker port "${container}" 5432/tcp)"
|
|
port="${port%%$'\n'*}"
|
|
port="${port##*:}"
|
|
[[ ${port} =~ ^[0-9]+$ ]]
|
|
|
|
for _attempt in {1..30}; do
|
|
if docker exec "${container}" pg_isready \
|
|
--username postgres --dbname eusrr_quality >/dev/null 2>&1; then
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
docker exec "${container}" pg_isready \
|
|
--username postgres --dbname eusrr_quality >/dev/null
|
|
|
|
(
|
|
cd "${BACKEND_DIR}"
|
|
USE_SQLITE=false \
|
|
LDAP_WRITE_ENABLED=false \
|
|
LDAP_ENABLED=false \
|
|
DB_HOST=127.0.0.1 \
|
|
DB_PORT="${port}" \
|
|
POSTGRES_DB=eusrr_quality \
|
|
POSTGRES_USER=postgres \
|
|
POSTGRES_PASSWORD=quality \
|
|
"${python}" manage.py migrate --noinput
|
|
)
|
|
cleanup_postgres
|
|
trap - EXIT HUP INT TERM
|
|
)
|
|
|
|
run_release_lint() {
|
|
local python
|
|
local -a npm=()
|
|
python="$(python_command)"
|
|
read -r -a npm <<<"$(npm_command)"
|
|
|
|
log "Ruff: complete backend"
|
|
(
|
|
cd "${BACKEND_DIR}"
|
|
"${python}" -m ruff check .
|
|
"${python}" -m ruff format --check .
|
|
)
|
|
|
|
log "ESLint and Prettier: complete frontend"
|
|
(
|
|
cd "${FRONTEND_DIR}"
|
|
"${npm[@]}" run lint -- --max-warnings=0
|
|
"${FRONTEND_DIR}/node_modules/.bin/prettier" \
|
|
--check . --ignore-path "${REPO_ROOT}/.prettierignore"
|
|
)
|
|
}
|
|
|
|
run_changed_quality
|
|
run_application_checks
|
|
|
|
if [[ ${MODE} == "release" ]]; then
|
|
run_release_lint
|
|
fi
|
|
if [[ ${MODE} == "test" || ${MODE} == "release" ]]; then
|
|
run_test_suite
|
|
run_postgresql_migration_check
|
|
fi
|
|
|
|
log "Quality gate '${MODE}' passed"
|