diff --git a/README.md b/README.md index 338a911..c0f69bf 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Opinionated conventional commit message linter with imperative mood detection. verb, verified via nltk POS tagging — not a hand-coded regex of "bad" words. * **Signature verification without a local keyring.** Resolves the commit - author via the GitHub API and verifies GPG/SSH against their published + committer via the GitHub API and verifies GPG/SSH against their published `.gpg`/`.keys` — no per-runner key management. * **Strict by default.** Subject format, body, trailers, `Signed-off-by`, and signature all enforced out of the box; opt out with `--disable`. @@ -221,7 +221,7 @@ independently of `--enable`/`--disable`. The `signature` check verifies the commit without any local keyring setup: 1. If the repo has a GitHub remote, call the Commits API - (`GET /repos/{owner}/{repo}/commits/{sha}`) to resolve the author's GitHub + (`GET /repos/{owner}/{repo}/commits/{sha}`) to resolve the committer's GitHub username — this works for corporate emails, noreply addresses, or any email not listed publicly on a GitHub profile. 2. If the Commits API is unavailable (no GitHub remote, commit not yet pushed, @@ -229,7 +229,7 @@ The `signature` check verifies the commit without any local keyring setup: (`{id}+{username}@users.noreply.github.com` or `{username}@users.noreply.github.com`) — no API call needed. 3. If neither of the above resolves a username, fall back to searching GitHub - by the commit author's email. + by the commit committer's email. 4. Fetch the resolved user's public keys from `github.com/{username}.gpg` (GPG) and the `/users/{username}/ssh_signing_keys` API (SSH keys tagged with the **Signing key** role). Auth-only SSH keys are deliberately not @@ -240,7 +240,7 @@ The `signature` check verifies the commit without any local keyring setup: `git verify-commit` with the SSH allowed-signers config. 7. If any key verifies, the check passes. If none do, it fails. -If the author cannot be resolved via either method, or the GitHub API is +If the committer cannot be resolved via either method, or the GitHub API is unreachable, the check fails with a clear error. For private repositories, set `GITHUB_TOKEN` or `GH_TOKEN` so the Commits API diff --git a/docs/index.html b/docs/index.html index 6598c6e..79b68e4 100644 --- a/docs/index.html +++ b/docs/index.html @@ -315,7 +315,7 @@
.gpg/.keys — no
per-runner key management.
GET /repos/{owner}/{repo}/commits/{sha}) to resolve
- the author's GitHub username — works for corporate emails, noreply
+ the committer's GitHub username — works for corporate emails, noreply
addresses, or any email not listed publicly on a GitHub profile.{id}+{username}@users.noreply.github.com) — no API
call needed.github.com/{username}.gpg (GPG) and
/users/{username}/ssh_signing_keys (SSH keys tagged
@@ -476,7 +476,7 @@
- If the author cannot be resolved via either method, or the GitHub API
+ If the committer cannot be resolved via either method, or the GitHub API
is unreachable, the check fails with a clear error. For private
repositories, set GITHUB_TOKEN or GH_TOKEN
so the Commits API can authenticate. The official GitHub Action wires
diff --git a/src/git_commit_guard/__init__.py b/src/git_commit_guard/__init__.py
index ba4f2e4..3966ae8 100644
--- a/src/git_commit_guard/__init__.py
+++ b/src/git_commit_guard/__init__.py
@@ -295,9 +295,9 @@ def check_required_trailers(message, required, result):
result.error(f"missing required trailer: {trailer}")
-def _get_author_email(rev):
+def _get_committer_email(rev):
return subprocess.check_output( # noqa: S603
- ["git", "log", "-1", "--format=%ae", rev], # noqa: S607
+ ["git", "log", "-1", "--format=%ce", rev], # noqa: S607
text=True,
stderr=subprocess.PIPE,
timeout=_git_timeout(),
@@ -320,7 +320,7 @@ def _get_github_remote_info():
return match.group("owner"), match.group("repo")
-def _fetch_github_commit_author(owner, repo, sha):
+def _fetch_github_commit_committer(owner, repo, sha):
url = f"https://api.github.com/repos/{owner}/{repo}/commits/{sha}"
headers = {"Accept": "application/vnd.github+json"}
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
@@ -329,8 +329,8 @@ def _fetch_github_commit_author(owner, repo, sha):
req = urllib.request.Request(url, headers=headers) # noqa: S310 Audit URL open for permitted schemes
with urllib.request.urlopen(req, timeout=_git_timeout()) as resp: # noqa: S310 Audit URL open for permitted schemes
data = json.loads(resp.read())
- author = data.get("author")
- return author["login"] if author else None
+ committer = data.get("committer")
+ return committer["login"] if committer else None
def _parse_noreply_username(email):
@@ -435,7 +435,7 @@ def _resolve_github_username(rev, email):
if remote:
owner, repo = remote
try:
- username = _fetch_github_commit_author(owner, repo, rev)
+ username = _fetch_github_commit_committer(owner, repo, rev)
except urllib.error.HTTPError as e:
if e.code == HTTPStatus.NOT_FOUND:
commits_api_404 = True
@@ -450,24 +450,24 @@ def _resolve_github_username(rev, email):
return username, commits_api_404
-def _author_not_found_message(commits_api_404):
+def _committer_not_found_message(commits_api_404):
had_token = bool(os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"))
if commits_api_404 and not had_token:
return (
- "commit author not found on GitHub — if the repo is private, "
+ "committer not found on GitHub — if the repo is private, "
"set GITHUB_TOKEN in the workflow step "
"(env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }})"
)
- return "commit author not found on GitHub — cannot verify signature"
+ return "committer not found on GitHub — cannot verify signature"
def check_signature(rev, result):
try:
- email = _get_author_email(rev)
+ email = _get_committer_email(rev)
username, commits_api_404 = _resolve_github_username(rev, email)
if username is None:
result.error(
- _author_not_found_message(commits_api_404),
+ _committer_not_found_message(commits_api_404),
check=Check.SIGNATURE,
)
return
diff --git a/tests/test_git_commit_guard.py b/tests/test_git_commit_guard.py
index 7656167..4435964 100644
--- a/tests/test_git_commit_guard.py
+++ b/tests/test_git_commit_guard.py
@@ -15,12 +15,12 @@
Result,
_download_if_missing,
_ensure_nltk_data,
- _fetch_github_commit_author,
+ _fetch_github_commit_committer,
_fetch_github_keys,
_fetch_github_signing_keys,
_fetch_github_username,
_fetch_url,
- _get_author_email,
+ _get_committer_email,
_get_github_remote_info,
_get_message,
_get_range_revs,
@@ -625,13 +625,14 @@ def test_downloads_when_missing(self):
mock_dl.assert_called_once_with("punkt_tab", quiet=True)
-class TestGetAuthorEmail:
+class TestGetCommitterEmail:
def test_returns_stripped_email(self):
with patch(
"git_commit_guard.subprocess.check_output",
return_value="user@example.com\n",
- ):
- assert _get_author_email("abc123") == "user@example.com"
+ ) as mock_check:
+ assert _get_committer_email("abc123") == "user@example.com"
+ assert "--format=%ce" in mock_check.call_args.args[0]
class TestFetchUrl:
@@ -716,7 +717,7 @@ def test_non_github_remote_returns_none(self):
assert _get_github_remote_info() is None
-class TestFetchGithubCommitAuthor:
+class TestFetchGithubCommitCommitter:
def _mock_response(self, data):
mock_resp = MagicMock()
mock_resp.__enter__ = lambda s: s
@@ -724,20 +725,31 @@ def _mock_response(self, data):
mock_resp.read.return_value = json.dumps(data).encode()
return mock_resp
- def test_returns_author_login(self):
- resp = self._mock_response({"author": {"login": "commituser"}})
+ def test_returns_committer_login(self):
+ resp = self._mock_response({"committer": {"login": "commituser"}})
+ with patch("git_commit_guard.urllib.request.urlopen", return_value=resp):
+ assert (
+ _fetch_github_commit_committer("owner", "repo", "abc123")
+ == "commituser"
+ )
+
+ def test_returns_committer_login_not_author(self):
+ resp = self._mock_response(
+ {"author": {"login": "the-author"}, "committer": {"login": "the-committer"}}
+ )
with patch("git_commit_guard.urllib.request.urlopen", return_value=resp):
assert (
- _fetch_github_commit_author("owner", "repo", "abc123") == "commituser"
+ _fetch_github_commit_committer("owner", "repo", "abc123")
+ == "the-committer"
)
- def test_null_author_returns_none(self):
- resp = self._mock_response({"author": None})
+ def test_null_committer_returns_none(self):
+ resp = self._mock_response({"committer": None})
with patch("git_commit_guard.urllib.request.urlopen", return_value=resp):
- assert _fetch_github_commit_author("owner", "repo", "abc123") is None
+ assert _fetch_github_commit_committer("owner", "repo", "abc123") is None
def test_github_token_sent_in_header(self):
- resp = self._mock_response({"author": {"login": "user"}})
+ resp = self._mock_response({"committer": {"login": "user"}})
captured = []
def mock_urlopen(req, **_):
@@ -748,11 +760,11 @@ def mock_urlopen(req, **_):
patch("git_commit_guard.urllib.request.urlopen", side_effect=mock_urlopen),
patch.dict("os.environ", {"GITHUB_TOKEN": "mytoken"}, clear=False),
):
- _fetch_github_commit_author("owner", "repo", "abc123")
+ _fetch_github_commit_committer("owner", "repo", "abc123")
assert captured[0].get_header("Authorization") == "Bearer mytoken"
def test_gh_token_used_when_github_token_absent(self):
- resp = self._mock_response({"author": {"login": "user"}})
+ resp = self._mock_response({"committer": {"login": "user"}})
captured = []
def mock_urlopen(req, **_):
@@ -765,7 +777,7 @@ def mock_urlopen(req, **_):
patch("git_commit_guard.urllib.request.urlopen", side_effect=mock_urlopen),
patch.dict("os.environ", env, clear=True),
):
- _fetch_github_commit_author("owner", "repo", "abc123")
+ _fetch_github_commit_committer("owner", "repo", "abc123")
assert captured[0].get_header("Authorization") == "Bearer ghtoken"
@@ -894,7 +906,7 @@ def test_gpg_verified_via_github(self):
r = Result()
with (
patch(
- "git_commit_guard._get_author_email", return_value="user@example.com"
+ "git_commit_guard._get_committer_email", return_value="user@example.com"
),
patch("git_commit_guard._get_github_remote_info", return_value=None),
patch("git_commit_guard._fetch_github_username", return_value="testuser"),
@@ -909,7 +921,7 @@ def test_ssh_verified_via_github(self):
r = Result()
with (
patch(
- "git_commit_guard._get_author_email", return_value="user@example.com"
+ "git_commit_guard._get_committer_email", return_value="user@example.com"
),
patch("git_commit_guard._get_github_remote_info", return_value=None),
patch("git_commit_guard._fetch_github_username", return_value="testuser"),
@@ -925,7 +937,7 @@ def test_no_matching_key_fails(self):
r = Result()
with (
patch(
- "git_commit_guard._get_author_email", return_value="user@example.com"
+ "git_commit_guard._get_committer_email", return_value="user@example.com"
),
patch("git_commit_guard._get_github_remote_info", return_value=None),
patch("git_commit_guard._fetch_github_username", return_value="testuser"),
@@ -940,7 +952,7 @@ def test_username_not_found_fails(self):
r = Result()
with (
patch(
- "git_commit_guard._get_author_email", return_value="user@example.com"
+ "git_commit_guard._get_committer_email", return_value="user@example.com"
),
patch("git_commit_guard._get_github_remote_info", return_value=None),
patch("git_commit_guard._fetch_github_username", return_value=None),
@@ -956,14 +968,14 @@ def test_commits_api_404_without_token_hints_at_token(self):
}
with (
patch(
- "git_commit_guard._get_author_email", return_value="user@example.com"
+ "git_commit_guard._get_committer_email", return_value="user@example.com"
),
patch(
"git_commit_guard._get_github_remote_info",
return_value=("owner", "repo"),
),
patch(
- "git_commit_guard._fetch_github_commit_author",
+ "git_commit_guard._fetch_github_commit_committer",
side_effect=urllib.error.HTTPError(
url="", code=404, msg="Not Found", hdrs=None, fp=None
),
@@ -979,14 +991,14 @@ def test_commits_api_404_with_token_keeps_generic_message(self):
r = Result()
with (
patch(
- "git_commit_guard._get_author_email", return_value="user@example.com"
+ "git_commit_guard._get_committer_email", return_value="user@example.com"
),
patch(
"git_commit_guard._get_github_remote_info",
return_value=("owner", "repo"),
),
patch(
- "git_commit_guard._fetch_github_commit_author",
+ "git_commit_guard._fetch_github_commit_committer",
side_effect=urllib.error.HTTPError(
url="", code=404, msg="Not Found", hdrs=None, fp=None
),
@@ -1003,14 +1015,14 @@ def test_commits_api_non_404_http_error_falls_through(self):
r = Result()
with (
patch(
- "git_commit_guard._get_author_email", return_value="user@example.com"
+ "git_commit_guard._get_committer_email", return_value="user@example.com"
),
patch(
"git_commit_guard._get_github_remote_info",
return_value=("owner", "repo"),
),
patch(
- "git_commit_guard._fetch_github_commit_author",
+ "git_commit_guard._fetch_github_commit_committer",
side_effect=urllib.error.HTTPError(
url="", code=500, msg="Server Error", hdrs=None, fp=None
),
@@ -1026,14 +1038,14 @@ def test_commits_api_401_surfaces_token_message(self):
r = Result()
with (
patch(
- "git_commit_guard._get_author_email", return_value="user@example.com"
+ "git_commit_guard._get_committer_email", return_value="user@example.com"
),
patch(
"git_commit_guard._get_github_remote_info",
return_value=("owner", "repo"),
),
patch(
- "git_commit_guard._fetch_github_commit_author",
+ "git_commit_guard._fetch_github_commit_committer",
side_effect=urllib.error.HTTPError(
url="", code=401, msg="Unauthorized", hdrs=None, fp=None
),
@@ -1047,14 +1059,14 @@ def test_commits_api_403_surfaces_token_message(self):
r = Result()
with (
patch(
- "git_commit_guard._get_author_email", return_value="user@example.com"
+ "git_commit_guard._get_committer_email", return_value="user@example.com"
),
patch(
"git_commit_guard._get_github_remote_info",
return_value=("owner", "repo"),
),
patch(
- "git_commit_guard._fetch_github_commit_author",
+ "git_commit_guard._fetch_github_commit_committer",
side_effect=urllib.error.HTTPError(
url="", code=403, msg="Forbidden", hdrs=None, fp=None
),
@@ -1068,7 +1080,7 @@ def test_search_api_403_surfaces_rate_limit_message(self):
r = Result()
with (
patch(
- "git_commit_guard._get_author_email", return_value="corp@example.com"
+ "git_commit_guard._get_committer_email", return_value="corp@example.com"
),
patch("git_commit_guard._get_github_remote_info", return_value=None),
patch(
@@ -1086,7 +1098,7 @@ def test_other_http_error_uses_generic_http_message(self):
r = Result()
with (
patch(
- "git_commit_guard._get_author_email", return_value="corp@example.com"
+ "git_commit_guard._get_committer_email", return_value="corp@example.com"
),
patch("git_commit_guard._get_github_remote_info", return_value=None),
patch(
@@ -1103,7 +1115,7 @@ def test_other_http_error_uses_generic_http_message(self):
def test_url_error_fails(self):
r = Result()
with patch(
- "git_commit_guard._get_author_email",
+ "git_commit_guard._get_committer_email",
side_effect=urllib.error.URLError("unreachable"),
):
check_signature("abc123", r)
@@ -1112,7 +1124,7 @@ def test_url_error_fails(self):
def test_timeout_error_fails(self):
r = Result()
- with patch("git_commit_guard._get_author_email", side_effect=TimeoutError()):
+ with patch("git_commit_guard._get_committer_email", side_effect=TimeoutError()):
check_signature("abc123", r)
assert not r.ok
assert any("API unreachable" in msg for _, _, msg in r.errors)
@@ -1120,7 +1132,7 @@ def test_timeout_error_fails(self):
def test_subprocess_timeout_fails_gracefully(self):
r = Result()
with patch(
- "git_commit_guard._get_author_email",
+ "git_commit_guard._get_committer_email",
side_effect=subprocess.TimeoutExpired(cmd="git", timeout=10),
):
check_signature("abc123", r)
@@ -1131,14 +1143,15 @@ def test_commits_api_resolves_username(self):
r = Result()
with (
patch(
- "git_commit_guard._get_author_email", return_value="corp@example.com"
+ "git_commit_guard._get_committer_email", return_value="corp@example.com"
),
patch(
"git_commit_guard._get_github_remote_info",
return_value=("owner", "repo"),
),
patch(
- "git_commit_guard._fetch_github_commit_author", return_value="corpuser"
+ "git_commit_guard._fetch_github_commit_committer",
+ return_value="corpuser",
),
patch("git_commit_guard._fetch_github_username") as mock_email_search,
patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
@@ -1152,14 +1165,14 @@ def test_commits_api_error_falls_back_to_email_search(self):
r = Result()
with (
patch(
- "git_commit_guard._get_author_email", return_value="corp@example.com"
+ "git_commit_guard._get_committer_email", return_value="corp@example.com"
),
patch(
"git_commit_guard._get_github_remote_info",
return_value=("owner", "repo"),
),
patch(
- "git_commit_guard._fetch_github_commit_author",
+ "git_commit_guard._fetch_github_commit_committer",
side_effect=urllib.error.URLError("not found"),
),
patch("git_commit_guard._fetch_github_username", return_value="emailuser"),
@@ -1169,17 +1182,17 @@ def test_commits_api_error_falls_back_to_email_search(self):
check_signature("abc123", r)
assert r.ok
- def test_commits_api_null_author_falls_back_to_email_search(self):
+ def test_commits_api_null_committer_falls_back_to_email_search(self):
r = Result()
with (
patch(
- "git_commit_guard._get_author_email", return_value="user@example.com"
+ "git_commit_guard._get_committer_email", return_value="user@example.com"
),
patch(
"git_commit_guard._get_github_remote_info",
return_value=("owner", "repo"),
),
- patch("git_commit_guard._fetch_github_commit_author", return_value=None),
+ patch("git_commit_guard._fetch_github_commit_committer", return_value=None),
patch("git_commit_guard._fetch_github_username", return_value="emailuser"),
patch("git_commit_guard._fetch_github_keys", return_value=("", "SSH KEY")),
patch("git_commit_guard._verify_gpg", return_value=False),
@@ -1192,10 +1205,12 @@ def test_no_github_remote_uses_email_search(self):
r = Result()
with (
patch(
- "git_commit_guard._get_author_email", return_value="user@example.com"
+ "git_commit_guard._get_committer_email", return_value="user@example.com"
),
patch("git_commit_guard._get_github_remote_info", return_value=None),
- patch("git_commit_guard._fetch_github_commit_author") as mock_commits_api,
+ patch(
+ "git_commit_guard._fetch_github_commit_committer"
+ ) as mock_commits_api,
patch("git_commit_guard._fetch_github_username", return_value="emailuser"),
patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
patch("git_commit_guard._verify_gpg", return_value=True),
@@ -1208,7 +1223,7 @@ def test_noreply_email_skips_email_search(self):
r = Result()
with (
patch(
- "git_commit_guard._get_author_email",
+ "git_commit_guard._get_committer_email",
return_value="12345678+alice@users.noreply.github.com",
),
patch("git_commit_guard._get_github_remote_info", return_value=None),
@@ -1224,7 +1239,7 @@ def test_noreply_fallback_after_commits_api_failure(self):
r = Result()
with (
patch(
- "git_commit_guard._get_author_email",
+ "git_commit_guard._get_committer_email",
return_value="12345678+alice@users.noreply.github.com",
),
patch(
@@ -1232,7 +1247,7 @@ def test_noreply_fallback_after_commits_api_failure(self):
return_value=("owner", "repo"),
),
patch(
- "git_commit_guard._fetch_github_commit_author",
+ "git_commit_guard._fetch_github_commit_committer",
side_effect=urllib.error.URLError("not found"),
),
patch("git_commit_guard._fetch_github_username") as mock_email_search,
@@ -1630,7 +1645,7 @@ def test_signature_with_rev(self):
patch("sys.argv", ["cg", "abc123", "--enable", "signature"]),
patch("git_commit_guard._get_message", return_value=_VALID_MSG),
patch(
- "git_commit_guard._get_author_email", return_value="user@example.com"
+ "git_commit_guard._get_committer_email", return_value="user@example.com"
),
patch("git_commit_guard._get_github_remote_info", return_value=None),
patch("git_commit_guard._fetch_github_username", return_value="testuser"),