Skip to content

Commit 5cc32ae

Browse files
authored
Merge pull request #82 from benner/feat/signature-commits-api-lookup
feat(signature): resolve author via Commits API before email search
2 parents 031022f + e420c52 commit 5cc32ae

4 files changed

Lines changed: 262 additions & 20 deletions

File tree

README.md

Lines changed: 23 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -77,8 +77,8 @@ Available checks:
7777
* `imperative` - First word is an imperative verb (for example `add` not `added`)
7878
* `body` - Blank line separates subject from body, and body is non-empty
7979
* `signed-off` - `Signed-off-by:` trailer exists
80-
* `signature` - Verify GPG or SSH signature via GitHub public key lookup, with
81-
fallback to `git verify-commit`
80+
* `signature` - Verify GPG or SSH signature via the GitHub Commits API or
81+
public key lookup
8282

8383
### Subject length
8484

@@ -202,20 +202,27 @@ independently of `--enable`/`--disable`.
202202

203203
### Signature verification
204204

205-
The `signature` check tries to verify the commit without any local keyring setup:
205+
The `signature` check verifies the commit without any local keyring setup:
206206

207-
1. Look up the commit author's email in the GitHub API to find their GitHub
208-
username.
209-
2. Fetch their public keys from `github.com/{username}.gpg` and
207+
1. If the repo has a GitHub remote, call the Commits API
208+
(`GET /repos/{owner}/{repo}/commits/{sha}`) to resolve the author's GitHub
209+
username — this works for corporate emails, noreply addresses, or any email
210+
not listed publicly on a GitHub profile.
211+
2. If the Commits API is unavailable (no GitHub remote, commit not yet pushed,
212+
or API error), fall back to searching GitHub by the commit author's email.
213+
3. Fetch the resolved user's public keys from `github.com/{username}.gpg` and
210214
`github.com/{username}.keys`.
211-
3. Try GPG verification: import the fetched key into a temporary keyring and
215+
4. Try GPG verification: import the fetched key into a temporary keyring and
212216
run `git verify-commit`.
213-
4. Try SSH verification: write a temporary `allowed_signers` file and run
217+
5. Try SSH verification: write a temporary `allowed_signers` file and run
214218
`git verify-commit` with the SSH allowed-signers config.
215-
5. If any key verifies, the check passes. If none do, it fails.
219+
6. If any key verifies, the check passes. If none do, it fails.
216220

217-
If the author's email is not found on GitHub, or the API is unreachable, the
218-
check fails with a clear error — there is no silent fallback.
221+
If the author cannot be resolved via either method, or the GitHub API is
222+
unreachable, the check fails with a clear error.
223+
224+
For private repositories, set `GITHUB_TOKEN` or `GH_TOKEN` so the Commits API
225+
can authenticate.
219226

220227
### Configuration file
221228

@@ -251,9 +258,11 @@ full precedence and ignore config file values when provided.
251258

252259
### Environment variables
253260

254-
| Variable | Default | Description |
255-
| -------------------------- | ------- | -------------------------------------------- |
256-
| `COMMIT_GUARD_GIT_TIMEOUT` | `10` | Timeout in seconds for git subprocess calls. |
261+
| Variable | Default | Description |
262+
| -------------------------- | ------- | ------------------------------------------------------------------------- |
263+
| `COMMIT_GUARD_GIT_TIMEOUT` | `10` | Timeout in seconds for git subprocess calls. |
264+
| `GITHUB_TOKEN` || GitHub token for Commits API access on private repos (signature check). |
265+
| `GH_TOKEN` || Alias for `GITHUB_TOKEN`; used when `GITHUB_TOKEN` is not set. |
257266

258267
```bash
259268
COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD

docs/index.html

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -377,7 +377,7 @@ <h3>Checks</h3>
377377
</tr>
378378
<tr>
379379
<td><code>signature</code></td>
380-
<td>GPG or SSH signature is valid — verified via GitHub public key lookup</td>
380+
<td>GPG or SSH signature is valid — verified via GitHub Commits API or public key lookup</td>
381381
</tr>
382382
</tbody>
383383
</table>
@@ -432,15 +432,25 @@ <h3>Signature verification</h3>
432432
pre-configured local keyring:
433433
</p>
434434
<ol>
435-
<li>Look up the commit author's email via the GitHub search API to find their username.</li>
436-
<li>Fetch their public keys from <code>github.com/{username}.gpg</code> and <code>github.com/{username}.keys</code>.</li>
435+
<li>If the repo has a GitHub remote, call the Commits API
436+
(<code>GET /repos/{owner}/{repo}/commits/{sha}</code>) to resolve
437+
the author's GitHub username — works for corporate emails, noreply
438+
addresses, or any email not listed publicly on a GitHub profile.</li>
439+
<li>If the Commits API is unavailable (no GitHub remote, commit not
440+
yet pushed, or API error), fall back to searching GitHub by the
441+
commit author's email.</li>
442+
<li>Fetch the resolved user's public keys from
443+
<code>github.com/{username}.gpg</code> and
444+
<code>github.com/{username}.keys</code>.</li>
437445
<li>Try GPG verification using a temporary keyring.</li>
438446
<li>Try SSH verification using a temporary <code>allowed_signers</code> file.</li>
439447
<li>Pass if any key verifies; fail if none do.</li>
440448
</ol>
441449
<p>
442-
If the author's email is not found on GitHub, or the API is
443-
unreachable, the check fails with a clear error. Disable the
450+
If the author cannot be resolved via either method, or the GitHub API
451+
is unreachable, the check fails with a clear error. For private
452+
repositories, set <code>GITHUB_TOKEN</code> or <code>GH_TOKEN</code>
453+
so the Commits API can authenticate. Disable the
444454
<code>signature</code> check if GitHub API access is unavailable:
445455
</p>
446456
<pre><code class="language-bash">commit-guard --disable signature</code></pre>
@@ -470,6 +480,16 @@ <h3>Environment variables</h3>
470480
<td><code>10</code></td>
471481
<td>Timeout in seconds for git subprocess calls</td>
472482
</tr>
483+
<tr>
484+
<td><code>GITHUB_TOKEN</code></td>
485+
<td></td>
486+
<td>GitHub token for Commits API access on private repos (signature check)</td>
487+
</tr>
488+
<tr>
489+
<td><code>GH_TOKEN</code></td>
490+
<td></td>
491+
<td>Alias for <code>GITHUB_TOKEN</code>; used when <code>GITHUB_TOKEN</code> is not set</td>
492+
</tr>
473493
</tbody>
474494
</table>
475495
</figure>

src/git_commit_guard/__init__.py

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,9 @@
3434

3535
_NON_IMPERATIVE_SUFFIX_RE = re.compile(r"(?:ing|ed)$")
3636
_TRAILER_RE = re.compile(r"^[\w-]+:\s+\S")
37+
_GITHUB_REMOTE_RE = re.compile(
38+
r"github\.com[:/](?P<owner>[^/]+)/(?P<repo>[^/\s]+?)(?:\.git)?$"
39+
)
3740

3841
SUBJECT_RE = re.compile(
3942
r"^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?!?:\s+(?P<desc>.+)$",
@@ -268,6 +271,35 @@ def _get_author_email(rev):
268271
).strip()
269272

270273

274+
def _get_github_remote_info():
275+
try:
276+
url = subprocess.check_output(
277+
["git", "remote", "get-url", "origin"], # noqa: S607 Starting a process with a partial executable path
278+
text=True,
279+
stderr=subprocess.PIPE,
280+
timeout=_git_timeout(),
281+
).strip()
282+
except subprocess.CalledProcessError:
283+
return None
284+
match = _GITHUB_REMOTE_RE.search(url)
285+
if not match:
286+
return None
287+
return match.group("owner"), match.group("repo")
288+
289+
290+
def _fetch_github_commit_author(owner, repo, sha):
291+
url = f"https://api.github.com/repos/{owner}/{repo}/commits/{sha}"
292+
headers = {"Accept": "application/vnd.github+json"}
293+
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
294+
if token:
295+
headers["Authorization"] = f"Bearer {token}"
296+
req = urllib.request.Request(url, headers=headers) # noqa: S310 Audit URL open for permitted schemes
297+
with urllib.request.urlopen(req, timeout=_git_timeout()) as resp: # noqa: S310 Audit URL open for permitted schemes
298+
data = json.loads(resp.read())
299+
author = data.get("author")
300+
return author["login"] if author else None
301+
302+
271303
def _fetch_github_username(email):
272304
url = f"https://api.github.com/search/users?q={email}+in:email"
273305
req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) # noqa: S310 Audit URL open for permitted schemes
@@ -347,7 +379,14 @@ def _verify_ssh(rev, email, ssh_text):
347379
def check_signature(rev, result):
348380
try:
349381
email = _get_author_email(rev)
350-
username = _fetch_github_username(email)
382+
username = None
383+
remote = _get_github_remote_info()
384+
if remote:
385+
owner, repo = remote
386+
with contextlib.suppress(urllib.error.URLError, TimeoutError):
387+
username = _fetch_github_commit_author(owner, repo, rev)
388+
if username is None:
389+
username = _fetch_github_username(email)
351390
if username is None:
352391
result.error(
353392
"commit author not found on GitHub — cannot verify signature",

0 commit comments

Comments
 (0)