Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -209,14 +209,18 @@ The `signature` check verifies the commit without any local keyring setup:
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,
or API error), fall back to searching GitHub by the commit author's email.
3. Fetch the resolved user's public keys from `github.com/{username}.gpg` and
or API error), parse the username directly from a GitHub noreply address
(`{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.
4. Fetch the resolved user's public keys from `github.com/{username}.gpg` and
`github.com/{username}.keys`.
4. Try GPG verification: import the fetched key into a temporary keyring and
5. Try GPG verification: import the fetched key into a temporary keyring and
run `git verify-commit`.
5. Try SSH verification: write a temporary `allowed_signers` file and run
6. Try SSH verification: write a temporary `allowed_signers` file and run
`git verify-commit` with the SSH allowed-signers config.
6. If any key verifies, the check passes. If none do, it fails.
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
unreachable, the check fails with a clear error.
Expand Down
8 changes: 6 additions & 2 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -437,8 +437,12 @@ <h3>Signature verification</h3>
the author's GitHub username — works for corporate emails, noreply
addresses, or any email not listed publicly on a GitHub profile.</li>
<li>If the Commits API is unavailable (no GitHub remote, commit not
yet pushed, or API error), fall back to searching GitHub by the
commit author's email.</li>
yet pushed, or API error), parse the username directly from a
GitHub noreply address
(<code>{id}+{username}@users.noreply.github.com</code>) — no API
call needed.</li>
<li>If neither of the above resolves a username, fall back to
searching GitHub by the commit author's email.</li>
<li>Fetch the resolved user's public keys from
<code>github.com/{username}.gpg</code> and
<code>github.com/{username}.keys</code>.</li>
Expand Down
8 changes: 8 additions & 0 deletions src/git_commit_guard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
_GITHUB_REMOTE_RE = re.compile(
r"github\.com[:/](?P<owner>[^/]+)/(?P<repo>[^/\s]+?)(?:\.git)?$"
)
_NOREPLY_RE = re.compile(r"^(?:\d+\+)?(?P<username>[^@]+)@users\.noreply\.github\.com$")

SUBJECT_RE = re.compile(
r"^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?!?:\s+(?P<desc>.+)$",
Expand Down Expand Up @@ -300,6 +301,11 @@ def _fetch_github_commit_author(owner, repo, sha):
return author["login"] if author else None


def _parse_noreply_username(email):
match = _NOREPLY_RE.match(email)
return match.group("username") if match else None


def _fetch_github_username(email):
url = f"https://api.github.com/search/users?q={email}+in:email"
req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) # noqa: S310 Audit URL open for permitted schemes
Expand Down Expand Up @@ -385,6 +391,8 @@ def check_signature(rev, result):
owner, repo = remote
with contextlib.suppress(urllib.error.URLError, TimeoutError):
username = _fetch_github_commit_author(owner, repo, rev)
if username is None:
username = _parse_noreply_username(email)
if username is None:
username = _fetch_github_username(email)
if username is None:
Expand Down
57 changes: 57 additions & 0 deletions tests/test_git_commit_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
_load_config,
_parse_checks,
_parse_config_checks,
_parse_noreply_username,
_report_jsonl,
_report_text,
_resolve_max_subject_length,
Expand Down Expand Up @@ -569,6 +570,23 @@ def test_returns_decoded_content(self):
assert _fetch_url("https://github.com/user.keys") == "key data"


class TestParseNoreplyUsername:
def test_id_plus_username_format(self):
assert (
_parse_noreply_username("12345678+alice@users.noreply.github.com")
== "alice"
)

def test_plain_username_format(self):
assert _parse_noreply_username("alice@users.noreply.github.com") == "alice"

def test_regular_email_returns_none(self):
assert _parse_noreply_username("alice@example.com") is None

def test_wrong_domain_returns_none(self):
assert _parse_noreply_username("alice@users.noreply.gitlab.com") is None


class TestFetchGithubUsername:
def _mock_response(self, data):
mock_resp = MagicMock()
Expand Down Expand Up @@ -887,6 +905,45 @@ def test_no_github_remote_uses_email_search(self):
assert r.ok
mock_commits_api.assert_not_called()

def test_noreply_email_skips_email_search(self):
r = Result()
with (
patch(
"git_commit_guard._get_author_email",
return_value="12345678+alice@users.noreply.github.com",
),
patch("git_commit_guard._get_github_remote_info", return_value=None),
patch("git_commit_guard._fetch_github_username") as mock_email_search,
patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
patch("git_commit_guard._verify_gpg", return_value=True),
):
check_signature("abc123", r)
assert r.ok
mock_email_search.assert_not_called()

def test_noreply_fallback_after_commits_api_failure(self):
r = Result()
with (
patch(
"git_commit_guard._get_author_email",
return_value="12345678+alice@users.noreply.github.com",
),
patch(
"git_commit_guard._get_github_remote_info",
return_value=("owner", "repo"),
),
patch(
"git_commit_guard._fetch_github_commit_author",
side_effect=urllib.error.URLError("not found"),
),
patch("git_commit_guard._fetch_github_username") as mock_email_search,
patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
patch("git_commit_guard._verify_gpg", return_value=True),
):
check_signature("abc123", r)
assert r.ok
mock_email_search.assert_not_called()


class TestGetMessage:
def test_success(self):
Expand Down
Loading