Add local quality gates and Git hooks
This commit is contained in:
parent
a65b9d77aa
commit
bf0df3613d
5
.githooks/pre-commit
Executable file
5
.githooks/pre-commit
Executable file
@ -0,0 +1,5 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
exec "${repo_root}/tools/quality/quality.sh" quick --staged
|
||||
20
.githooks/pre-push
Executable file
20
.githooks/pre-push
Executable file
@ -0,0 +1,20 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
mode="quick"
|
||||
|
||||
while read -r _local_ref _local_sha remote_ref _remote_sha; do
|
||||
case "${remote_ref}" in
|
||||
refs/heads/master)
|
||||
mode="release"
|
||||
;;
|
||||
refs/heads/develop)
|
||||
if [[ ${mode} != "release" ]]; then
|
||||
mode="test"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
exec "${repo_root}/tools/quality/quality.sh" "${mode}"
|
||||
7
.prettierignore
Normal file
7
.prettierignore
Normal file
@ -0,0 +1,7 @@
|
||||
backend/static/**
|
||||
frontend/.next/**
|
||||
frontend/node_modules/**
|
||||
frontend/public/pdf.worker.min.mjs
|
||||
frontend/next-env.d.ts
|
||||
frontend/package-lock.json
|
||||
frontend/pnpm-lock.yaml
|
||||
5
backend/requirements-ci.txt
Normal file
5
backend/requirements-ci.txt
Normal file
@ -0,0 +1,5 @@
|
||||
-r requirements.txt
|
||||
|
||||
# Local and CI quality tooling. Keep this separate from production runtime
|
||||
# dependencies while pinning the exact version used by the project hooks.
|
||||
ruff==0.15.8
|
||||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@ -49,6 +49,7 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.1",
|
||||
"prettier": "3.6.2",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^5"
|
||||
@ -7753,6 +7754,22 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prettier": {
|
||||
"version": "3.6.2",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.6.2.tgz",
|
||||
"integrity": "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/process-nextick-args": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
|
||||
|
||||
@ -53,6 +53,7 @@
|
||||
"cross-env": "^10.1.0",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.3.1",
|
||||
"prettier": "3.6.2",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "^5"
|
||||
|
||||
10
frontend/pnpm-lock.yaml
generated
10
frontend/pnpm-lock.yaml
generated
@ -123,6 +123,9 @@ importers:
|
||||
eslint-config-next:
|
||||
specifier: 16.3.1
|
||||
version: 16.3.1(@typescript-eslint/parser@8.66.0(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3))(eslint@9.39.5(jiti@2.7.0)(supports-color@7.2.0))(supports-color@7.2.0)(typescript@5.9.3)
|
||||
prettier:
|
||||
specifier: 3.6.2
|
||||
version: 3.6.2
|
||||
tailwindcss:
|
||||
specifier: ^4
|
||||
version: 4.3.3
|
||||
@ -2708,6 +2711,11 @@ packages:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
prettier@3.6.2:
|
||||
resolution: {integrity: sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ==}
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
process-nextick-args@2.0.1:
|
||||
resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==}
|
||||
|
||||
@ -5848,6 +5856,8 @@ snapshots:
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier@3.6.2: {}
|
||||
|
||||
process-nextick-args@2.0.1: {}
|
||||
|
||||
prop-types@15.8.1:
|
||||
|
||||
42
tools/quality/README.md
Normal file
42
tools/quality/README.md
Normal file
@ -0,0 +1,42 @@
|
||||
# Local quality gates
|
||||
|
||||
The checks run entirely on the development computer. GitHub and Gitea are not
|
||||
contacted, except by the `git push` that starts the `pre-push` hook.
|
||||
|
||||
## Setup
|
||||
|
||||
Install the project dependencies, then enable the versioned hooks for this
|
||||
checkout:
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
python -m pip install -r requirements-ci.txt
|
||||
cd ../frontend
|
||||
npm ci
|
||||
cd ..
|
||||
./tools/quality/install-hooks.sh
|
||||
```
|
||||
|
||||
Each clone or independently configured worktree needs the one-time activation
|
||||
command. Merely receiving `.githooks` through a mirror does not activate them.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
./tools/quality/quality.sh quick
|
||||
./tools/quality/quality.sh test
|
||||
./tools/quality/quality.sh release
|
||||
```
|
||||
|
||||
- `quick` checks formatting, lint, Django configuration and missing migrations.
|
||||
- `test` also runs both test suites, the production frontend build and all
|
||||
migrations on a fresh temporary PostgreSQL database.
|
||||
- `release` first requires the complete repository to pass Ruff, ESLint and
|
||||
Prettier, then runs everything from `test`.
|
||||
|
||||
`pre-commit` runs `quick --staged`. It refuses partially staged files so the
|
||||
checked content is exactly the content being committed. `pre-push` chooses
|
||||
`test` for `develop`, `release` for `master`, and `quick` for other branches.
|
||||
|
||||
PostgreSQL validation requires Docker with the `postgres:17-alpine` image. The
|
||||
temporary container is removed after the check.
|
||||
6
tools/quality/install-hooks.sh
Executable file
6
tools/quality/install-hooks.sh
Executable file
@ -0,0 +1,6 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
git -C "${repo_root}" config --local core.hooksPath .githooks
|
||||
printf 'Git hooks enabled for %s\n' "${repo_root}"
|
||||
289
tools/quality/quality.sh
Executable file
289
tools/quality/quality.sh
Executable file
@ -0,0 +1,289 @@
|
||||
#!/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 | sed 's/.*://')"
|
||||
|
||||
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"
|
||||
Loading…
x
Reference in New Issue
Block a user