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 @@

Why commit-guard? #

  • Signature verification without a local keyring. - Resolves the commit author via the GitHub API and verifies GPG/SSH + Resolves the commit committer via the GitHub API and verifies GPG/SSH against their published .gpg/.keys — no per-runner key management.
  • @@ -456,7 +456,7 @@

    Signature verification

    1. If the repo has a GitHub remote, call the Commits API (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.
    2. If the Commits API is unavailable (no GitHub remote, commit not yet pushed, or API error), parse the username directly from a @@ -464,7 +464,7 @@

      Signature verification

      ({id}+{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.
    4. + searching GitHub by the commit committer's email.
    5. Fetch the resolved user's public keys from github.com/{username}.gpg (GPG) and /users/{username}/ssh_signing_keys (SSH keys tagged @@ -476,7 +476,7 @@

      Signature verification

    6. Pass if any key verifies; fail if none do.

    - 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"),