|
| 1 | +#!/usr/bin/env bash |
| 2 | +# |
| 3 | +# Check the commit message conventions of a pull request. |
| 4 | +# |
| 5 | +# Usage: check-commit-messages.sh <base-sha> <head-sha> <pr-number> |
| 6 | +# |
| 7 | +# Rules: |
| 8 | +# - Format: `<type>(<scope>): <subject>`, with type in `feat`, `fix`, `docs` or `chore`. |
| 9 | +# - `feat`, `fix` and `docs` require a scope. `chore` accepts an optional one. |
| 10 | +# - A scope must be in the allowed list of `.github/commit-scopes.txt`. |
| 11 | +# - A commit that only touches files in `tests/` must have a scope starting with `tests/` |
| 12 | +# (even for `chore`), and a scope starting with `tests/` is only allowed for such commits. |
| 13 | +# - A `chore` commit touching files in `src/` or `tests/` only triggers a warning (never a failure). |
| 14 | +# - If the pull request has several commits, each one must end with ` (#<pr-number>)`. |
| 15 | +# With a single commit nothing is checked: the ID is added when the pull request is merged. |
| 16 | + |
| 17 | +set -euo pipefail |
| 18 | + |
| 19 | +readonly SCOPES_FILE="$(dirname "$0")/../commit-scopes.txt" |
| 20 | + |
| 21 | +# ============================================================================== |
| 22 | + |
| 23 | +if [ "$#" -ne 3 ]; then |
| 24 | + echo "Usage: $0 <base-sha> <head-sha> <pr-number>" >&2 |
| 25 | + exit 2 |
| 26 | +fi |
| 27 | + |
| 28 | +readonly BASE_SHA=$1 |
| 29 | +readonly HEAD_SHA=$2 |
| 30 | +readonly PR_NUMBER=$3 |
| 31 | + |
| 32 | +# ------------------------------------------------------------------------------ |
| 33 | + |
| 34 | +# Read the allowed scopes, one per line, ignoring comments and blank lines. |
| 35 | +mapfile -t scopes < <(grep -vE '^[[:space:]]*(#|$)' "$SCOPES_FILE" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') |
| 36 | +if [ "${#scopes[@]}" -eq 0 ]; then |
| 37 | + echo "::error::No allowed scope found in `${SCOPES_FILE}`." |
| 38 | + exit 2 |
| 39 | +fi |
| 40 | + |
| 41 | +# Escape the regex special characters of each scope, then join them with `|`. |
| 42 | +ALLOWED_SCOPES=$(printf '%s\n' "${scopes[@]}" | sed -E 's/[][\\.^$*+?(){}|]/\\&/g' | paste -sd'|') |
| 43 | +readonly ALLOWED_SCOPES |
| 44 | + |
| 45 | +DISPLAYED_SCOPES=$(printf '`%s`, ' "${scopes[@]}") |
| 46 | +readonly DISPLAYED_SCOPES |
| 47 | + |
| 48 | +readonly SUBJECT_REGEX='.*[^ ]' |
| 49 | +readonly SCOPE_REGEX="\\((${ALLOWED_SCOPES})\\)" |
| 50 | +readonly COMMIT_REGEX="^((feat|fix|docs)${SCOPE_REGEX}|chore(${SCOPE_REGEX})?): ${SUBJECT_REGEX}$" |
| 51 | + |
| 52 | +# ------------------------------------------------------------------------------ |
| 53 | + |
| 54 | +mapfile -t hashes < <(git log --no-merges --format=%H "${BASE_SHA}..${HEAD_SHA}") |
| 55 | + |
| 56 | +# ------------------------------------------------------------------------------ |
| 57 | + |
| 58 | +get_changed_files() { |
| 59 | + git diff-tree --root --no-commit-id --name-only -r "$1" |
| 60 | +} |
| 61 | + |
| 62 | +is_touching_only_tests() { |
| 63 | + local files |
| 64 | + files=$(get_changed_files "$1") |
| 65 | + [ -n "$files" ] && ! grep -qv '^tests/' <<< "$files" |
| 66 | +} |
| 67 | + |
| 68 | +is_touching_src_or_tests() { |
| 69 | + local files |
| 70 | + files=$(get_changed_files "$1") |
| 71 | + grep -qE '^(src|tests)/' <<< "$files" |
| 72 | +} |
| 73 | + |
| 74 | +# ------------------------------------------------------------------------------ |
| 75 | + |
| 76 | +errors=0 |
| 77 | +for hash in "${hashes[@]}"; do |
| 78 | + subject=$(git log -1 --format=%s "$hash") |
| 79 | + # The ID is optional here: the format is checked without it. |
| 80 | + message=${subject% (#[0-9]*)} |
| 81 | + |
| 82 | + if ! [[ $message =~ $COMMIT_REGEX ]]; then |
| 83 | + echo "::error::Invalid commit message. Expected \`feat|fix|docs(<scope>): ...\` or \`chore[(<scope>)]: ...\`, with scope in [${DISPLAYED_SCOPES%, }]: \"${subject}\"" |
| 84 | + errors=$((errors + 1)) |
| 85 | + continue |
| 86 | + fi |
| 87 | + |
| 88 | + scope="" |
| 89 | + if [[ $message =~ ^[a-z]+\(([^\)]+)\) ]]; then |
| 90 | + scope=${BASH_REMATCH[1]} |
| 91 | + fi |
| 92 | + if is_touching_only_tests "$hash"; then |
| 93 | + if [[ $scope != tests/* ]]; then |
| 94 | + echo "::error::Commit only touches \`tests/\`, its scope must start with \`tests/\`: \"${subject}\"." |
| 95 | + errors=$((errors + 1)) |
| 96 | + fi |
| 97 | + elif [[ $scope == tests/* ]]; then |
| 98 | + echo "::error::Scope \`${scope}\` is only allowed for commits that only touch \`tests/\`: \"${subject}\"." |
| 99 | + errors=$((errors + 1)) |
| 100 | + fi |
| 101 | + |
| 102 | + if [[ $message == chore* ]] && is_touching_src_or_tests "$hash"; then |
| 103 | + echo "::warning::Commit \`chore\` touches files in \`src/\` or \`tests/\`, consider \`feat\`, \`fix\` or \`docs\`: \"${subject}\"." |
| 104 | + fi |
| 105 | + |
| 106 | + if [ "${#hashes[@]}" -gt 1 ] && [[ $subject != *" (#${PR_NUMBER})" ]]; then |
| 107 | + echo "::error::Commit message must end with \" (#${PR_NUMBER})\": \"${subject}\"." |
| 108 | + errors=$((errors + 1)) |
| 109 | + fi |
| 110 | +done |
| 111 | + |
| 112 | +if [ "$errors" -ne 0 ]; then |
| 113 | + exit 1 |
| 114 | +fi |
| 115 | +echo "${#hashes[@]} commit message(s) checked: OK." |
0 commit comments