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
37 changes: 23 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,8 @@ Available checks:
* `imperative` - First word is an imperative verb (for example `add` not `added`)
* `body` - Blank line separates subject from body, and body is non-empty
* `signed-off` - `Signed-off-by:` trailer exists
* `signature` - Verify GPG or SSH signature via GitHub public key lookup, with
fallback to `git verify-commit`
* `signature` - Verify GPG or SSH signature via the GitHub Commits API or
public key lookup

### Subject length

Expand Down Expand Up @@ -202,20 +202,27 @@ independently of `--enable`/`--disable`.

### Signature verification

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

1. Look up the commit author's email in the GitHub API to find their GitHub
username.
2. Fetch their public keys from `github.com/{username}.gpg` and
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 — 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
`github.com/{username}.keys`.
3. Try GPG verification: import the fetched key into a temporary keyring and
4. Try GPG verification: import the fetched key into a temporary keyring and
run `git verify-commit`.
4. Try SSH verification: write a temporary `allowed_signers` file and run
5. Try SSH verification: write a temporary `allowed_signers` file and run
`git verify-commit` with the SSH allowed-signers config.
5. If any key verifies, the check passes. If none do, it fails.
6. If any key verifies, the check passes. If none do, it fails.

If the author's email is not found on GitHub, or the API is unreachable, the
check fails with a clear error — there is no silent fallback.
If the author 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.

### Configuration file

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

### Environment variables

| Variable | Default | Description |
| -------------------------- | ------- | -------------------------------------------- |
| `COMMIT_GUARD_GIT_TIMEOUT` | `10` | Timeout in seconds for git subprocess calls. |
| Variable | Default | Description |
| -------------------------- | ------- | ------------------------------------------------------------------------- |
| `COMMIT_GUARD_GIT_TIMEOUT` | `10` | Timeout in seconds for git subprocess calls. |
| `GITHUB_TOKEN` | — | GitHub token for Commits API access on private repos (signature check). |
| `GH_TOKEN` | — | Alias for `GITHUB_TOKEN`; used when `GITHUB_TOKEN` is not set. |

```bash
COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
Expand Down
30 changes: 25 additions & 5 deletions docs/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ <h3>Checks</h3>
</tr>
<tr>
<td><code>signature</code></td>
<td>GPG or SSH signature is valid — verified via GitHub public key lookup</td>
<td>GPG or SSH signature is valid — verified via GitHub Commits API or public key lookup</td>
</tr>
</tbody>
</table>
Expand Down Expand Up @@ -432,15 +432,25 @@ <h3>Signature verification</h3>
pre-configured local keyring:
</p>
<ol>
<li>Look up the commit author's email via the GitHub search API to find their username.</li>
<li>Fetch their public keys from <code>github.com/{username}.gpg</code> and <code>github.com/{username}.keys</code>.</li>
<li>If the repo has a GitHub remote, call the Commits API
(<code>GET /repos/{owner}/{repo}/commits/{sha}</code>) to resolve
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>
<li>Fetch the resolved user's public keys from
<code>github.com/{username}.gpg</code> and
<code>github.com/{username}.keys</code>.</li>
<li>Try GPG verification using a temporary keyring.</li>
<li>Try SSH verification using a temporary <code>allowed_signers</code> file.</li>
<li>Pass if any key verifies; fail if none do.</li>
</ol>
<p>
If the author's email is not found on GitHub, or the API is
unreachable, the check fails with a clear error. Disable the
If the author cannot be resolved via either method, or the GitHub API
is unreachable, the check fails with a clear error. For private
repositories, set <code>GITHUB_TOKEN</code> or <code>GH_TOKEN</code>
so the Commits API can authenticate. Disable the
<code>signature</code> check if GitHub API access is unavailable:
</p>
<pre><code class="language-bash">commit-guard --disable signature</code></pre>
Expand Down Expand Up @@ -470,6 +480,16 @@ <h3>Environment variables</h3>
<td><code>10</code></td>
<td>Timeout in seconds for git subprocess calls</td>
</tr>
<tr>
<td><code>GITHUB_TOKEN</code></td>
<td>—</td>
<td>GitHub token for Commits API access on private repos (signature check)</td>
</tr>
<tr>
<td><code>GH_TOKEN</code></td>
<td>—</td>
<td>Alias for <code>GITHUB_TOKEN</code>; used when <code>GITHUB_TOKEN</code> is not set</td>
</tr>
</tbody>
</table>
</figure>
Expand Down
41 changes: 40 additions & 1 deletion src/git_commit_guard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@

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

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


def _get_github_remote_info():
try:
url = subprocess.check_output(
["git", "remote", "get-url", "origin"], # noqa: S607 Starting a process with a partial executable path
text=True,
stderr=subprocess.PIPE,
timeout=_git_timeout(),
).strip()
except subprocess.CalledProcessError:
return None
match = _GITHUB_REMOTE_RE.search(url)
if not match:
return None
return match.group("owner"), match.group("repo")


def _fetch_github_commit_author(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")
if token:
headers["Authorization"] = f"Bearer {token}"
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


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 @@ -347,7 +379,14 @@ def _verify_ssh(rev, email, ssh_text):
def check_signature(rev, result):
try:
email = _get_author_email(rev)
username = _fetch_github_username(email)
username = None
remote = _get_github_remote_info()
if remote:
owner, repo = remote
with contextlib.suppress(urllib.error.URLError, TimeoutError):
username = _fetch_github_commit_author(owner, repo, rev)
if username is None:
username = _fetch_github_username(email)
if username is None:
result.error(
"commit author not found on GitHub — cannot verify signature",
Expand Down
Loading
Loading