Skip to content

Commit c456ae4

Browse files
committed
chore: add GH commit conventions check
Signed-off-by: Ronan Abhamon <ronan.abhamon@vates.tech>
1 parent 2c8cc00 commit c456ae4

3 files changed

Lines changed: 197 additions & 0 deletions

File tree

‎.github/commit-scopes.txt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Allowed commit scopes, one per line (blank lines and lines starting with `#` are ignored).
2+
# Used by `.github/scripts/check-commit-messages.sh`.
3+
core
4+
tests/core
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
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."
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
name: Commit conventions
2+
3+
on:
4+
pull_request:
5+
types: [opened, synchronize, reopened, ready_for_review]
6+
7+
permissions:
8+
contents: read
9+
10+
jobs:
11+
commit-messages:
12+
name: Commit messages
13+
if: ${{ !github.event.pull_request.draft }}
14+
runs-on: ubuntu-latest
15+
steps:
16+
- uses: actions/checkout@v6
17+
with:
18+
fetch-depth: 0
19+
20+
- name: Check commit messages
21+
env:
22+
BASE_SHA: ${{ github.event.pull_request.base.sha }}
23+
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
24+
PR_NUMBER: ${{ github.event.pull_request.number }}
25+
run: .github/scripts/check-commit-messages.sh "$BASE_SHA" "$HEAD_SHA" "$PR_NUMBER"

0 commit comments

Comments
 (0)