GRM-20: fix: enforce GRM-N: conventional on master commits and PR titles
CI / lint (push) Has been cancelled
CI / unit-tests (push) Has been cancelled
CI / molecule-tests (push) Has been cancelled

This commit is contained in:
Emil Simeonov
2026-06-19 15:10:32 +02:00
parent 129cfe3c79
commit 3048d7ade7
4 changed files with 62 additions and 16 deletions
+9
View File
@@ -21,6 +21,15 @@ jobs:
fi
PR_TITLE="${{ github.event.pull_request.title }}"
# Validate PR title follows conventional commits so squash merge message is valid
if ! echo "$PR_TITLE" | grep -qE '^(feat|fix|chore|docs|style|refactor|perf|test|ci|build|revert|BREAKING CHANGE)(\(.+\))?: .+'; then
echo "ERROR: PR title must follow conventional commit format."
echo " Expected: <type>: <description>"
echo " Got: $PR_TITLE"
exit 1
fi
MERGE_TITLE="${TASK_ID}: ${PR_TITLE}"
curl -X POST \
+11 -9
View File
@@ -14,6 +14,7 @@ The `GRM-N` prefix is mandatory — CI extracts it for merge messages and Vikunj
## Commit Format
### Feature branches
Use **conventional commits** on feature branches:
```
@@ -25,17 +26,18 @@ docs: improve README
Allowed types: `feat`, `fix`, `chore`, `docs`, `style`, `refactor`, `perf`, `test`, `ci`, `build`, `revert`, `BREAKING CHANGE`.
**Do NOT** include the `GRM-N:` prefix in commit messages on feature branches — it is added automatically during squash merge.
**Do NOT** include the `GRM-N:` prefix in commit messages on feature branches.
## PR Workflow
### Master branch (squash merges)
Squash commits on `master` must follow:
1. Create a branch `GRM-N` or `GRM-N-brief-description` from `master`
2. Make changes, committing with conventional format
3. Push and open a PR against `master`
4. CI runs lint, unit tests, and molecule tests
5. When all checks pass, add the `ready-to-merge` label
6. The auto-merge workflow squash-merges with message `GRM-N: <PR title>`
7. The post-merge workflow updates the Vikunja task
```
GRM-N: <conventional commit message>
```
Example: `GRM-24: fix: resolve molecule idempotence`.
This format is enforced by the auto-merge workflow, which validates the PR title is a conventional commit before squash-merging and prepending the task ID.
## Local Testing
+18 -5
View File
@@ -2,8 +2,9 @@
"""Validate commit messages for GRM.
Rules:
- On master branch: allow anything (merge commits already have task ID).
- On feature branches: must use conventional commit format, must NOT include GRM-N prefix.
- On feature branches: conventional commits ONLY, must NOT include GRM-N prefix.
- On master branch: must follow '<task-id>: <conventional commit>' pattern,
e.g. 'GRM-24: fix: resolve timeout'.
"""
import re
import subprocess
@@ -41,11 +42,23 @@ def main(args=None) -> None:
branch = get_branch()
if branch == "master":
return
subject = first_line(msg)
if branch == "master":
if not TASK_ID_RE.match(subject):
print("ERROR: Master branch commits must start with a task ID.")
print(" Expected: GRM-N: <conventional commit message>")
print(f" Got: {subject}")
sys.exit(1)
# Strip task-id prefix and validate the remainder as conventional
remainder = TASK_ID_RE.sub("", subject).strip()
if not CONVENTIONAL_RE.match(remainder):
print("ERROR: Master branch commit message must follow conventional format after task ID.")
print(" Expected: GRM-N: <type>: <description>")
print(f" Got: {subject}")
sys.exit(1)
return
if TASK_ID_RE.match(subject):
print("ERROR: Do not include task ID (GRM-N) in feature branch commits.")
print(" Task ID will be added automatically on merge via CI.")
+24 -2
View File
@@ -82,11 +82,25 @@ class TestMain:
with patch("scripts.validate_commit_msg.get_branch", return_value="GRM-19"):
main(["validate_commit_msg.py", msg_path])
def test_accepts_anything_on_master(self) -> None:
def test_accepts_valid_master_commit(self) -> None:
msg_path = self._write_msg("GRM-19: feat: add feature")
with patch("scripts.validate_commit_msg.get_branch", return_value="master"):
main(["validate_commit_msg.py", msg_path])
def test_rejects_master_without_task_id(self) -> None:
msg_path = self._write_msg("feat: add feature")
with patch("scripts.validate_commit_msg.get_branch", return_value="master"):
with pytest.raises(SystemExit) as exc:
main(["validate_commit_msg.py", msg_path])
assert exc.value.code == 1
def test_rejects_master_with_non_conventional_after_task_id(self) -> None:
msg_path = self._write_msg("GRM-19: random message")
with patch("scripts.validate_commit_msg.get_branch", return_value="master"):
with pytest.raises(SystemExit) as exc:
main(["validate_commit_msg.py", msg_path])
assert exc.value.code == 1
def test_rejects_non_conventional_on_feature_branch(self) -> None:
msg_path = self._write_msg("random message")
with patch("scripts.validate_commit_msg.get_branch", return_value="feature"):
@@ -106,6 +120,12 @@ class TestMain:
def test_main_module_block() -> None:
import tempfile
with tempfile.NamedTemporaryFile(mode="w", delete=False) as f:
f.write("GRM-1: feat: test")
msg_path = f.name
with patch("scripts.validate_commit_msg.get_branch", return_value="master"):
import scripts.validate_commit_msg as vcm
@@ -115,4 +135,6 @@ def test_main_module_block() -> None:
source = source.replace('if __name__ == "__main__":\n main()\n', "")
namespace = dict(vcm.__dict__)
exec(compile(source, vcm.__file__, "exec"), namespace)
namespace["main"](["validate_commit_msg.py", "/dev/null"])
namespace["main"](["validate_commit_msg.py", msg_path])
os.unlink(msg_path)