Skip to content

Commit d5b37f0

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

3 files changed

Lines changed: 144 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: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
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."
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)