|
| 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 | +# - The subject is at most 70 characters long, counting the ` (#<pr-number>)` suffix (already there |
| 12 | +# or added at merge). It is separated from the type/scope by exactly one space, starts with a |
| 13 | +# lowercase letter or a non-letter character, and does not end with a period. |
| 14 | +# - If the commit has a body, the second line of the message must be blank. |
| 15 | +# - A commit that only touches files in `tests/` must have a scope starting with `tests/` |
| 16 | +# (even for `chore`), and a scope starting with `tests/` is only allowed for such commits. |
| 17 | +# - A `chore` commit touching files in `src/` or `tests/` only triggers a warning (never a failure). |
| 18 | +# - If the pull request has several commits, each one must end with ` (#<pr-number>)`. |
| 19 | +# With a single commit nothing is checked: the ID is added when the pull request is merged. |
| 20 | + |
| 21 | +set -euo pipefail |
| 22 | + |
| 23 | +readonly SCOPES_FILE_PATH="$(dirname "$0")/../commit-scopes.txt" |
| 24 | + |
| 25 | +readonly MAX_SUBJECT_LENGTH=70 |
| 26 | + |
| 27 | +# ============================================================================== |
| 28 | + |
| 29 | +if [ "$#" -ne 3 ]; then |
| 30 | + echo "Usage: $0 <base-sha> <head-sha> <pr-number>" >&2 |
| 31 | + exit 2 |
| 32 | +fi |
| 33 | + |
| 34 | +readonly BASE_SHA=$1 |
| 35 | +readonly HEAD_SHA=$2 |
| 36 | +readonly PR_NUMBER=$3 |
| 37 | + |
| 38 | +# ------------------------------------------------------------------------------ |
| 39 | + |
| 40 | +report_error() { |
| 41 | + echo "::error::$1" |
| 42 | + errors=$((errors + 1)) |
| 43 | +} |
| 44 | + |
| 45 | +report_warning() { |
| 46 | + echo "::warning::$1" |
| 47 | +} |
| 48 | + |
| 49 | +# ------------------------------------------------------------------------------ |
| 50 | + |
| 51 | +# Print the reason why a message (without PR ID) does not match `COMMIT_REGEX`. |
| 52 | +get_format_error() { |
| 53 | + local message=$1 |
| 54 | + if ! [[ $message =~ ^(feat|fix|docs|chore)($|[^[:alnum:]_-]) ]]; then |
| 55 | + echo "the type must be \`feat\`, \`fix\`, \`docs\` or \`chore\` " |
| 56 | + elif [[ $message =~ ^(feat|fix|docs)($|[^\(]) ]]; then |
| 57 | + echo "this type requires a scope, one of [${DISPLAYED_SCOPES%, }]" |
| 58 | + elif [[ $message =~ ^[a-z]+\( ]] && ! [[ $message =~ ^[a-z]+${SCOPE_REGEX} ]]; then |
| 59 | + echo "invalid scope, expected one of [${DISPLAYED_SCOPES%, }]" |
| 60 | + else |
| 61 | + echo "expected \`: \` (colon and one space) after the type or the scope, then a summary" |
| 62 | + fi |
| 63 | +} |
| 64 | + |
| 65 | +# ------------------------------------------------------------------------------ |
| 66 | + |
| 67 | +# Read the allowed scopes, one per line, ignoring comments and blank lines. |
| 68 | +mapfile -t scopes < <(grep -vE '^[[:space:]]*(#|$)' "$SCOPES_FILE_PATH" | sed -E 's/^[[:space:]]+|[[:space:]]+$//g') |
| 69 | +if [ "${#scopes[@]}" -eq 0 ]; then |
| 70 | + report_error "No allowed scope found in \`${SCOPES_FILE_PATH}\`." |
| 71 | + exit 2 |
| 72 | +fi |
| 73 | + |
| 74 | +# Escape the regex special characters of each scope, then join them with `|`. |
| 75 | +ALLOWED_SCOPES=$(printf '%s\n' "${scopes[@]}" | sed -E 's/[][\\.^$*+?(){}|]/\\&/g' | paste -sd'|') |
| 76 | +readonly ALLOWED_SCOPES |
| 77 | + |
| 78 | +DISPLAYED_SCOPES=$(printf '`%s`, ' "${scopes[@]}") |
| 79 | +readonly DISPLAYED_SCOPES |
| 80 | + |
| 81 | +readonly PR_ID_SUFFIX=" (#${PR_NUMBER})" |
| 82 | + |
| 83 | +readonly SUBJECT_REGEX='.*[^ ]' |
| 84 | +readonly SCOPE_REGEX="\\((${ALLOWED_SCOPES})\\)" |
| 85 | +readonly COMMIT_REGEX="^((feat|fix|docs)${SCOPE_REGEX}|chore(${SCOPE_REGEX})?): ${SUBJECT_REGEX}$" |
| 86 | + |
| 87 | +# ------------------------------------------------------------------------------ |
| 88 | + |
| 89 | +mapfile -t hashes < <(git log --no-merges --format=%H "${BASE_SHA}..${HEAD_SHA}") |
| 90 | + |
| 91 | +# ------------------------------------------------------------------------------ |
| 92 | + |
| 93 | +get_changed_files() { |
| 94 | + git diff-tree --root --no-commit-id --name-only -r "$1" |
| 95 | +} |
| 96 | + |
| 97 | +is_touching_only_tests() { |
| 98 | + local files |
| 99 | + files=$(get_changed_files "$1") |
| 100 | + [ -n "$files" ] && ! grep -qv '^tests/' <<< "$files" |
| 101 | +} |
| 102 | + |
| 103 | +is_touching_src_or_tests() { |
| 104 | + local files |
| 105 | + files=$(get_changed_files "$1") |
| 106 | + grep -qE '^(src|tests)/' <<< "$files" |
| 107 | +} |
| 108 | + |
| 109 | +# ------------------------------------------------------------------------------ |
| 110 | + |
| 111 | +errors=0 |
| 112 | +for hash in "${hashes[@]}"; do |
| 113 | + subject=$(git log -1 --format=%s "$hash") |
| 114 | + # The ID is optional here: the format is checked without it. |
| 115 | + message=${subject% (#[0-9]*)} |
| 116 | + |
| 117 | + if ! [[ $message =~ $COMMIT_REGEX ]]; then |
| 118 | + reason=$(get_format_error "$message") |
| 119 | + report_error "Invalid commit format, ${reason}: \"${subject}\"." |
| 120 | + continue |
| 121 | + fi |
| 122 | + |
| 123 | + subject_length=$((${#message} + ${#PR_ID_SUFFIX})) |
| 124 | + if [ "$subject_length" -gt "$MAX_SUBJECT_LENGTH" ]; then |
| 125 | + report_error "Commit subject must be at most ${MAX_SUBJECT_LENGTH} characters long including \`${PR_ID_SUFFIX}\`, got ${subject_length}: \"${subject}\"." |
| 126 | + fi |
| 127 | + |
| 128 | + summary=${message#*: } |
| 129 | + if [[ $summary == " "* ]]; then |
| 130 | + report_error "Commit subject must be separated from the type by exactly one space: \"${subject}\"." |
| 131 | + fi |
| 132 | + if [[ $summary =~ ^[[:upper:]] ]]; then |
| 133 | + report_error "Commit subject must not start with an uppercase letter: \"${subject}\"." |
| 134 | + fi |
| 135 | + if [[ $summary == *. ]]; then |
| 136 | + report_error "Commit subject must not end with a period: \"${subject}\"." |
| 137 | + fi |
| 138 | + |
| 139 | + scope="" |
| 140 | + if [[ $message =~ ^[a-z]+\(([^\)]+)\) ]]; then |
| 141 | + scope=${BASH_REMATCH[1]} |
| 142 | + fi |
| 143 | + if is_touching_only_tests "$hash"; then |
| 144 | + if [[ $scope != tests/* ]]; then |
| 145 | + report_error "Commit only touches \`tests/\`, its scope must start with \`tests/\`: \"${subject}\"." |
| 146 | + fi |
| 147 | + elif [[ $scope == tests/* ]]; then |
| 148 | + report_error "Scope \`${scope}\` is only allowed for commits that only touch \`tests/\`: \"${subject}\"." |
| 149 | + fi |
| 150 | + |
| 151 | + if [[ $message == chore* ]] && is_touching_src_or_tests "$hash"; then |
| 152 | + report_warning "Commit \`chore\` touches files in \`src/\` or \`tests/\`, consider \`feat\`, \`fix\` or \`docs\`: \"${subject}\"." |
| 153 | + fi |
| 154 | + |
| 155 | + if [ "${#hashes[@]}" -gt 1 ] && [[ $subject != *"$PR_ID_SUFFIX" ]]; then |
| 156 | + report_error "Commit message must end with \"${PR_ID_SUFFIX}\": \"${subject}\"." |
| 157 | + fi |
| 158 | + |
| 159 | + second_line=$(git log -1 --format=%B "$hash" | sed -n 2p) |
| 160 | + if [ -n "$second_line" ]; then |
| 161 | + report_error "Commit message must have a blank line between the subject and the body: \"${subject}\"." |
| 162 | + fi |
| 163 | +done |
| 164 | + |
| 165 | +if [ "$errors" -ne 0 ]; then |
| 166 | + exit 1 |
| 167 | +fi |
| 168 | +echo "${#hashes[@]} commit message(s) checked: OK." |
0 commit comments