Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .githooks/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# Git hooks

Versioned hooks, enabled per clone by pointing `core.hooksPath` here:

```bash
sh scripts/install-git-hooks.sh # macOS / Linux / Git Bash
pwsh scripts/install-git-hooks.ps1 # Windows PowerShell
```

`npm ci` or `npm install` in `src/frontend` also runs the installer, so a frontend setup enables
them without a separate step.

## What runs when

| Hook | Checks | Typical cost |
|------|--------|--------------|
| `pre-commit` | Secret scan of the staged diff; `eslint` if frontend files changed; `dotnet build` if backend files changed | seconds |
| `commit-msg` | Conventional Commits subject line | instant |
| `pre-push` | `dotnet test` (Application.Tests), `npm test`, `npm run build`; refuses a direct push to `main` | ~1 minute |

Work is scoped to what the commit actually touches, so a docs-only change runs neither toolchain.

## What is deliberately not here

Suites needing PostgreSQL, RabbitMQ or a browser — Pact provider verification and Playwright — stay
in CI. A hook that only passes on a machine with the right services running is a hook people learn
to bypass, and a bypassed hook enforces nothing.

CI remains the authority. These hooks exist to catch the cheap mistakes before review, not to
replace it.

## Bypassing

```bash
SKIP_HOOKS=1 git commit ...
SKIP_HOOKS=1 git push ...
```

Preferred over `--no-verify` because it is visible in the command that used it, and it works for
`commit-msg`, which `--no-verify` also skips.

`ALLOW_PUSH_TO_MAIN=1` overrides the `main` branch guard specifically.

## The secret scan

Only lines the commit *adds* are scanned, for:

- PEM private key blocks.
- `VITE_*` variables holding a key, secret, token or password. Vite inlines these into the browser
bundle as literal strings, so a secret named this way is readable by every visitor — the defect
fixed in #90.
- Non-empty `SigningKey`, `SpPrivateKey`, `ClientSecret`, `Secret`, `Password` or `ApiKey` values in
configuration. Recognisable local placeholders (`dev-`, `test-`, `example`, `change-in-production`)
are allowed so committed development defaults stay editable.

It is a backstop for the obvious cases, not a substitute for secret scanning on the remote.
68 changes: 68 additions & 0 deletions .githooks/_lib.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
#!/bin/sh
# Shared helpers for the repository's git hooks.
#
# Sourced by every hook in this directory. POSIX sh: Git for Windows runs hooks under its bundled
# sh, so bashisms are avoided.

REPO_ROOT=$(git rev-parse --show-toplevel)
export REPO_ROOT

# Colour only when attached to a terminal, so hook output stays readable when piped or logged.
if [ -t 1 ]; then
C_RED=$(printf '\033[31m')
C_GREEN=$(printf '\033[32m')
C_YELLOW=$(printf '\033[33m')
C_DIM=$(printf '\033[2m')
C_OFF=$(printf '\033[0m')
else
C_RED='' C_GREEN='' C_YELLOW='' C_DIM='' C_OFF=''
fi

hook_info() { printf '%s\n' "${C_DIM}[hook] $1${C_OFF}"; }
hook_ok() { printf '%s\n' "${C_GREEN}[hook] $1${C_OFF}"; }
hook_warn() { printf '%s\n' "${C_YELLOW}[hook] $1${C_OFF}"; }
hook_error() { printf '%s\n' "${C_RED}[hook] $1${C_OFF}" >&2; }

# Every hook honours SKIP_HOOKS=1, which is friendlier than --no-verify because it is visible in
# the command that used it.
hooks_skipped() {
if [ "${SKIP_HOOKS:-0}" = "1" ]; then
hook_warn "SKIP_HOOKS=1 set - skipping $1."
return 0
fi
return 1
}

# Files staged for commit, added/copied/modified/renamed only: a path being deleted must not
# trigger a build of the area it used to live in.
staged_files() {
git diff --cached --name-only --diff-filter=ACMR
}

# True when any of the given paths match the supplied prefix pattern.
paths_touch() {
pattern=$1
shift
printf '%s\n' "$@" | grep -Eq "$pattern"
}

require_command() {
if ! command -v "$1" >/dev/null 2>&1; then
hook_error "'$1' is not on PATH; cannot run $2."
hook_error "Install it, or bypass this once with SKIP_HOOKS=1."
return 1
fi
return 0
}

# Runs a command from the repository root, printing it first and failing loudly.
run_step() {
label=$1
shift
hook_info "$label"
if ! ( cd "$REPO_ROOT" && "$@" ); then
hook_error "$label FAILED"
return 1
fi
return 0
}
53 changes: 53 additions & 0 deletions .githooks/commit-msg
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
#!/bin/sh
# Enforces Conventional Commits, the format this repository's history already follows.
#
# The format is not decoration: `feat:` / `fix:` / `BREAKING CHANGE` are what let a release tool
# derive a version and changelog from the log.
set -e

. "$(dirname "$0")/_lib.sh"

hooks_skipped "commit-msg" && exit 0

MSG_FILE=$1
SUBJECT=$(head -n 1 "$MSG_FILE")

# Git's own generated messages and rebase helpers are not authored commits.
case "$SUBJECT" in
"Merge "*|"Revert "*|"fixup!"*|"squash!"*|"amend!"*)
exit 0
;;
esac

# Comment-only content means an aborted commit; git handles that itself.
if [ -z "$(printf '%s' "$SUBJECT" | tr -d '[:space:]')" ]; then
exit 0
fi

TYPES='build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test'
PATTERN="^(${TYPES})(\([a-z0-9._/-]+\))?!?: .+"

if ! printf '%s' "$SUBJECT" | grep -Eq "$PATTERN"; then
hook_error "Commit message does not follow Conventional Commits."
hook_error ""
hook_error " got: $SUBJECT"
hook_error " expected: <type>[(scope)][!]: <description>"
hook_error " types: ${TYPES}"
hook_error ""
hook_error " e.g. feat(saml): support encrypted assertions"
hook_error " fix: propagate the cancellation token through webhook retries"
hook_error ""
hook_error "Bypass once with: SKIP_HOOKS=1 git commit ..."
exit 1
fi

# A subject that needs more than 100 characters belongs in the body.
SUBJECT_LENGTH=$(printf '%s' "$SUBJECT" | wc -c | tr -d ' ')
if [ "$SUBJECT_LENGTH" -gt 100 ]; then
hook_error "Commit subject is ${SUBJECT_LENGTH} characters; keep it under 100 and use the body."
exit 1
fi

if [ "$SUBJECT_LENGTH" -gt 72 ]; then
hook_warn "Commit subject is ${SUBJECT_LENGTH} characters; 72 or fewer reads better in git log."
fi
76 changes: 76 additions & 0 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
#!/bin/sh
# Fast checks on what is actually staged.
#
# Kept to seconds: a slow pre-commit gets bypassed, and a bypassed hook enforces nothing. The full
# test suites run at pre-push, and CI remains the authority.
set -e

. "$(dirname "$0")/_lib.sh"

hooks_skipped "pre-commit" && exit 0

FILES=$(staged_files)
if [ -z "$FILES" ]; then
exit 0
fi

FAILED=0

# ── 1. Secrets ────────────────────────────────────────────────────────────────
# Scans only the lines this commit adds. A credential is far cheaper to catch here than to rotate
# after it reaches a branch.
ADDED=$(git diff --cached --unified=0 --diff-filter=ACMR | grep '^+' | grep -v '^+++' || true)

if printf '%s\n' "$ADDED" | grep -Eq -- '-----BEGIN [A-Z ]*PRIVATE KEY-----'; then
hook_error "A PEM private key block is being committed."
FAILED=1
fi

# Vite inlines every VITE_* variable into the published bundle as a literal string, so a secret
# named this way is readable by every visitor. This is the defect #90 fixed.
if printf '%s\n' "$ADDED" | grep -Eiq '^\+[^#]*VITE_[A-Z0-9_]*(KEY|SECRET|TOKEN|PASSWORD)[A-Z0-9_]*[[:space:]]*[=:][[:space:]]*[^[:space:]"'"'"']'; then
hook_error "A VITE_* secret is being committed with a value."
hook_error "Vite inlines these into the browser bundle - every visitor can read them."
FAILED=1
fi

# Non-empty secret values in configuration. Obvious local placeholders are allowed so the committed
# development defaults stay editable.
SECRET_ASSIGNMENTS=$(printf '%s\n' "$ADDED" \
| grep -Ei '"(SigningKey|SpPrivateKey|ClientSecret|Secret|Password|ApiKey)"[[:space:]]*:[[:space:]]*"[^"]+"' \
| grep -Eiv '(change-in-production|^\+[^"]*"(dev|test|example|sample|placeholder|postgres)[^"]*"|"(dev|test|example|sample|placeholder|postgres)-)' \
|| true)

if [ -n "$SECRET_ASSIGNMENTS" ]; then
hook_error "A non-placeholder secret value is being committed in configuration:"
printf '%s\n' "$SECRET_ASSIGNMENTS" >&2
hook_error "Supply it as an environment variable or deployment secret instead."
FAILED=1
fi

if [ "$FAILED" -ne 0 ]; then
hook_error ""
hook_error "If this is a false positive, bypass once with: SKIP_HOOKS=1 git commit ..."
exit 1
fi

# ── 2. Frontend ───────────────────────────────────────────────────────────────
if paths_touch '^src/frontend/.*\.(ts|tsx|js|jsx)$' $FILES; then
require_command npm "the frontend lint step" || exit 1
run_step "frontend: eslint" sh -c 'cd src/frontend && npm run --silent lint' || FAILED=1
fi

# ── 3. Backend ────────────────────────────────────────────────────────────────
# Compile only. Tests are at pre-push, where the wait is expected.
if paths_touch '^src/backend/.*\.cs$|^src/backend/.*\.csproj$|^src/backend/.*\.slnx$' $FILES; then
require_command dotnet "the backend build step" || exit 1
run_step "backend: build" dotnet build src/backend/OpenOnboarding.slnx --nologo -v quiet || FAILED=1
fi

if [ "$FAILED" -ne 0 ]; then
hook_error ""
hook_error "Commit aborted. Fix the above, or bypass once with: SKIP_HOOKS=1 git commit ..."
exit 1
fi

hook_ok "pre-commit checks passed."
50 changes: 50 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
#!/bin/sh
# The real gate: the test suites that run without external dependencies.
#
# Scope is deliberate. These run against an in-memory database and mocked transports, so they pass
# on any machine with the two SDKs installed. The suites that need PostgreSQL, a broker or a
# browser (Pact provider verification, Playwright) stay in CI, where those services exist.
set -e

. "$(dirname "$0")/_lib.sh"

hooks_skipped "pre-push" && exit 0

# ── Protect the default branch ────────────────────────────────────────────────
# Changes reach main through a reviewed PR; CI is wired to run on pull requests.
REMOTE_REF=$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo '')
if [ "$REMOTE_REF" = "main" ] && [ "${ALLOW_PUSH_TO_MAIN:-0}" != "1" ]; then
hook_error "Refusing to push directly to main."
hook_error "Branch and open a pull request, or set ALLOW_PUSH_TO_MAIN=1 if you mean it."
exit 1
fi

FAILED=0

# ── Backend ───────────────────────────────────────────────────────────────────
if [ -d "$REPO_ROOT/src/backend" ]; then
require_command dotnet "the backend test step" || exit 1
run_step "backend: dotnet test" \
dotnet test src/backend/OpenOnboarding.Application.Tests/OpenOnboarding.Application.Tests.csproj \
--nologo -v quiet || FAILED=1
fi

# ── Frontend ──────────────────────────────────────────────────────────────────
if [ -d "$REPO_ROOT/src/frontend" ]; then
require_command npm "the frontend test step" || exit 1

if [ ! -d "$REPO_ROOT/src/frontend/node_modules" ]; then
hook_warn "src/frontend/node_modules is missing; skipping frontend checks. Run npm ci."
else
run_step "frontend: vitest" sh -c 'cd src/frontend && npm test --silent' || FAILED=1
run_step "frontend: build" sh -c 'cd src/frontend && npm run --silent build' || FAILED=1
fi
fi

if [ "$FAILED" -ne 0 ]; then
hook_error ""
hook_error "Push aborted. Fix the above, or bypass once with: SKIP_HOOKS=1 git push ..."
exit 1
fi

hook_ok "pre-push checks passed."
5 changes: 4 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ jobs:

# ── Frontend ───────────────────────────────────────────────
frontend-ci:
name: Frontend — lint & build (Node 22)
name: Frontend — lint, test & build (Node 22)
runs-on: ubuntu-latest

defaults:
Expand All @@ -116,6 +116,9 @@ jobs:
- name: Lint
run: npm run lint

- name: Unit tests (vitest)
run: npm test

- name: Build
run: npm run build

Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,7 @@
**/node_modules/
**/dist/
.copilot-specs-cache/

# Playwright output (generated by test:e2e, not committed)
playwright-report/
test-results/
Loading
Loading