git-commit-guard 0.19.0__tar.gz → 0.20.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.github/workflows/coverage-baseline.yml +1 -1
  2. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.github/workflows/lint-commits.yml +1 -2
  3. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.github/workflows/lint-md.yml +1 -1
  4. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.github/workflows/lint-workflows.yml +8 -5
  5. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.github/workflows/release.yml +1 -0
  6. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.github/workflows/test.yml +3 -1
  7. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/PKG-INFO +43 -12
  8. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/README.md +42 -11
  9. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/docs/index.html +48 -5
  10. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/src/git_commit_guard/__init__.py +167 -14
  11. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/tests/test_git_commit_guard.py +431 -13
  12. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.editorconfig +0 -0
  13. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.github/workflows/coverage-comment.yml +0 -0
  14. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.github/workflows/lint-python.yml +0 -0
  15. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.gitignore +0 -0
  16. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.markdownlint.json +0 -0
  17. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.pre-commit-hooks.yaml +0 -0
  18. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.python-version +0 -0
  19. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/LICENSE +0 -0
  20. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/action.yml +0 -0
  21. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/cliff.toml +0 -0
  22. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/docs/commit-guard-icon.svg +0 -0
  23. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/pyproject.toml +0 -0
  24. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/ruff.toml +0 -0
  25. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/tests/__init__.py +0 -0
  26. {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/uv.lock +0 -0
@@ -19,7 +19,7 @@ jobs:
19
19
  uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # ratchet:astral-sh/setup-uv@v7
20
20
  - name: Cache NLTK data
21
21
  # yamllint disable-line rule:line-length
22
- uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # ratchet:actions/cache@v5
22
+ uses: actions/cache@668228422ae6a00e4ad889ee87cd7109ec5666a7 # v5.0.4
23
23
  with:
24
24
  path: ~/nltk_data
25
25
  key: nltk-averaged-perceptron-tagger-punkt
@@ -22,7 +22,6 @@ jobs:
22
22
  key: nltk-averaged-perceptron-tagger-punkt
23
23
  - name: Lint commits
24
24
  # yamllint disable-line rule:line-length
25
- uses: benner/commit-guard@8a007fe3ebc1346ec111f7782b26af7e4ca32025 # v0.18.0
25
+ uses: benner/commit-guard@cab0d35c41a3f99fc5f61895f491d0ce3f5aff1c # v0.20.0
26
26
  with:
27
27
  range: origin/${{ github.base_ref }}..HEAD
28
- disable: signature
@@ -23,4 +23,4 @@ jobs:
23
23
  github_token: ${{ secrets.GITHUB_TOKEN }}
24
24
  reporter: github-pr-review
25
25
  level: info
26
- fail_on_error: true
26
+ fail_level: any
@@ -12,7 +12,7 @@ jobs:
12
12
  steps:
13
13
  - name: Checkout code
14
14
  # yamllint disable-line rule:line-length
15
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4
15
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
16
16
  with:
17
17
  persist-credentials: false
18
18
  - name: Run actionlint
@@ -21,6 +21,7 @@ jobs:
21
21
  with:
22
22
  github_token: ${{ github.token }}
23
23
  reporter: github-pr-review
24
+ fail_level: any
24
25
  yamlfix:
25
26
  runs-on: ubuntu-latest
26
27
  permissions:
@@ -28,7 +29,7 @@ jobs:
28
29
  steps:
29
30
  - name: Checkout code
30
31
  # yamllint disable-line rule:line-length
31
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4
32
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
32
33
  with:
33
34
  persist-credentials: false
34
35
  - name: Set up uv
@@ -41,10 +42,11 @@ jobs:
41
42
  env:
42
43
  REVIEWDOG_GITHUB_API_TOKEN: ${{ github.token }}
43
44
  run: |-
45
+ set -o pipefail
44
46
  uvx yamlfix .github/workflows/
45
47
  git diff .github/workflows/ |
46
48
  reviewdog -f=diff -name=yamlfix \
47
- -reporter=github-pr-review -fail-on-error
49
+ -reporter=github-pr-review -fail-level=any
48
50
  zizmor:
49
51
  runs-on: ubuntu-latest
50
52
  permissions:
@@ -52,7 +54,7 @@ jobs:
52
54
  steps:
53
55
  - name: Checkout code
54
56
  # yamllint disable-line rule:line-length
55
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # ratchet:actions/checkout@v4
57
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
56
58
  with:
57
59
  persist-credentials: false
58
60
  - name: Set up uv
@@ -65,6 +67,7 @@ jobs:
65
67
  env:
66
68
  REVIEWDOG_GITHUB_API_TOKEN: ${{ github.token }}
67
69
  run: |-
70
+ set -o pipefail
68
71
  uvx zizmor==1.24.1 --format=sarif . |
69
72
  reviewdog -f=sarif -name=zizmor \
70
- -reporter=github-pr-review -fail-on-error
73
+ -reporter=github-pr-review -fail-level=any
@@ -10,6 +10,7 @@ permissions:
10
10
  jobs:
11
11
  release:
12
12
  runs-on: ubuntu-latest
13
+ environment: pypi
13
14
  steps:
14
15
  - name: Checkout code
15
16
  # yamllint disable-line rule:line-length
@@ -27,9 +27,11 @@ jobs:
27
27
  tests/ --cov=git_commit_guard --cov-report=term-missing \
28
28
  --cov-report=xml
29
29
  - name: Save coverage artifact
30
+ env:
31
+ PR_NUMBER: ${{ github.event.number }}
30
32
  run: |
31
33
  mkdir -p ./pr
32
- echo ${{ github.event.number }} > ./pr/NR
34
+ echo "$PR_NUMBER" > ./pr/NR
33
35
  cp coverage.xml ./pr/
34
36
  - name: Upload PR artifact
35
37
  # yamllint disable-line rule:line-length
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-guard
3
- Version: 0.19.0
3
+ Version: 0.20.1
4
4
  Summary: Opinionated conventional commit message linter with imperative mood detection
5
5
  Project-URL: Homepage, https://github.com/benner/commit-guard
6
6
  Project-URL: Repository, https://github.com/benner/commit-guard
@@ -98,7 +98,8 @@ Available checks:
98
98
  * `imperative` - First word is an imperative verb (for example `add` not `added`)
99
99
  * `body` - Blank line separates subject from body, and body is non-empty
100
100
  * `signed-off` - `Signed-off-by:` trailer exists
101
- * `signature` - Verify GPG or SSH signature
101
+ * `signature` - Verify GPG or SSH signature via the GitHub Commits API or
102
+ public key lookup
102
103
 
103
104
  ### Subject length
104
105
 
@@ -220,6 +221,34 @@ Trailer matching is case-sensitive and requires at least one non-space
220
221
  character after the colon (e.g. `Closes: #42`). This check runs
221
222
  independently of `--enable`/`--disable`.
222
223
 
224
+ ### Signature verification
225
+
226
+ The `signature` check verifies the commit without any local keyring setup:
227
+
228
+ 1. If the repo has a GitHub remote, call the Commits API
229
+ (`GET /repos/{owner}/{repo}/commits/{sha}`) to resolve the author's GitHub
230
+ username — this works for corporate emails, noreply addresses, or any email
231
+ not listed publicly on a GitHub profile.
232
+ 2. If the Commits API is unavailable (no GitHub remote, commit not yet pushed,
233
+ or API error), parse the username directly from a GitHub noreply address
234
+ (`{id}+{username}@users.noreply.github.com` or
235
+ `{username}@users.noreply.github.com`) — no API call needed.
236
+ 3. If neither of the above resolves a username, fall back to searching GitHub
237
+ by the commit author's email.
238
+ 4. Fetch the resolved user's public keys from `github.com/{username}.gpg` and
239
+ `github.com/{username}.keys`.
240
+ 5. Try GPG verification: import the fetched key into a temporary keyring and
241
+ run `git verify-commit`.
242
+ 6. Try SSH verification: write a temporary `allowed_signers` file and run
243
+ `git verify-commit` with the SSH allowed-signers config.
244
+ 7. If any key verifies, the check passes. If none do, it fails.
245
+
246
+ If the author cannot be resolved via either method, or the GitHub API is
247
+ unreachable, the check fails with a clear error.
248
+
249
+ For private repositories, set `GITHUB_TOKEN` or `GH_TOKEN` so the Commits API
250
+ can authenticate.
251
+
223
252
  ### Configuration file
224
253
 
225
254
  Place `.commit-guard.toml` in your project root (or any parent directory) to
@@ -254,9 +283,11 @@ full precedence and ignore config file values when provided.
254
283
 
255
284
  ### Environment variables
256
285
 
257
- | Variable | Default | Description |
258
- | -------------------------- | ------- | -------------------------------------------- |
259
- | `COMMIT_GUARD_GIT_TIMEOUT` | `10` | Timeout in seconds for git subprocess calls. |
286
+ | Variable | Default | Description |
287
+ | -------------------------- | ------- | ------------------------------------------------------------------------- |
288
+ | `COMMIT_GUARD_GIT_TIMEOUT` | `10` | Timeout in seconds for git subprocess calls. |
289
+ | `GITHUB_TOKEN` | — | GitHub token for Commits API access on private repos (signature check). |
290
+ | `GH_TOKEN` | — | Alias for `GITHUB_TOKEN`; used when `GITHUB_TOKEN` is not set. |
260
291
 
261
292
  ```bash
262
293
  COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
@@ -265,7 +296,7 @@ COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
265
296
  In GitHub Actions, set it at the step or job level:
266
297
 
267
298
  ```yaml
268
- - uses: benner/commit-guard@v0.19.0
299
+ - uses: benner/commit-guard@v0.20.1
269
300
  env:
270
301
  COMMIT_GUARD_GIT_TIMEOUT: 30
271
302
  with:
@@ -349,7 +380,7 @@ steps:
349
380
  - uses: actions/checkout@v4
350
381
  with:
351
382
  fetch-depth: 0
352
- - uses: benner/commit-guard@v0.19.0
383
+ - uses: benner/commit-guard@v0.20.1
353
384
  ```
354
385
 
355
386
  Check all commits in a pull request:
@@ -365,7 +396,7 @@ jobs:
365
396
  - uses: actions/checkout@v4
366
397
  with:
367
398
  fetch-depth: 0
368
- - uses: benner/commit-guard@v0.19.0
399
+ - uses: benner/commit-guard@v0.20.1
369
400
  with:
370
401
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
371
402
  ```
@@ -373,7 +404,7 @@ jobs:
373
404
  Check a specific commit SHA (mirrors the positional CLI argument):
374
405
 
375
406
  ```yaml
376
- - uses: benner/commit-guard@v0.19.0
407
+ - uses: benner/commit-guard@v0.20.1
377
408
  with:
378
409
  rev: ${{ github.sha }}
379
410
  ```
@@ -391,7 +422,7 @@ jobs:
391
422
  - uses: actions/checkout@v4
392
423
  with:
393
424
  fetch-depth: 0
394
- - uses: benner/commit-guard@v0.19.0
425
+ - uses: benner/commit-guard@v0.20.1
395
426
  with:
396
427
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
397
428
  disable: signed-off,signature
@@ -411,7 +442,7 @@ jobs:
411
442
  When `output-file` is set the action exposes the path as an output:
412
443
 
413
444
  ```yaml
414
- - uses: benner/commit-guard@v0.19.0
445
+ - uses: benner/commit-guard@v0.20.1
415
446
  id: cg
416
447
  with:
417
448
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -427,7 +458,7 @@ Add to your `.pre-commit-config.yaml`:
427
458
  ---
428
459
  repos:
429
460
  - repo: https://github.com/benner/commit-guard
430
- rev: v0.19.0
461
+ rev: v0.20.1
431
462
  hooks:
432
463
  - id: commit-guard
433
464
  - id: commit-guard-signature
@@ -77,7 +77,8 @@ Available checks:
77
77
  * `imperative` - First word is an imperative verb (for example `add` not `added`)
78
78
  * `body` - Blank line separates subject from body, and body is non-empty
79
79
  * `signed-off` - `Signed-off-by:` trailer exists
80
- * `signature` - Verify GPG or SSH signature
80
+ * `signature` - Verify GPG or SSH signature via the GitHub Commits API or
81
+ public key lookup
81
82
 
82
83
  ### Subject length
83
84
 
@@ -199,6 +200,34 @@ Trailer matching is case-sensitive and requires at least one non-space
199
200
  character after the colon (e.g. `Closes: #42`). This check runs
200
201
  independently of `--enable`/`--disable`.
201
202
 
203
+ ### Signature verification
204
+
205
+ The `signature` check verifies the commit without any local keyring setup:
206
+
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), parse the username directly from a GitHub noreply address
213
+ (`{id}+{username}@users.noreply.github.com` or
214
+ `{username}@users.noreply.github.com`) — no API call needed.
215
+ 3. If neither of the above resolves a username, fall back to searching GitHub
216
+ by the commit author's email.
217
+ 4. Fetch the resolved user's public keys from `github.com/{username}.gpg` and
218
+ `github.com/{username}.keys`.
219
+ 5. Try GPG verification: import the fetched key into a temporary keyring and
220
+ run `git verify-commit`.
221
+ 6. Try SSH verification: write a temporary `allowed_signers` file and run
222
+ `git verify-commit` with the SSH allowed-signers config.
223
+ 7. If any key verifies, the check passes. If none do, it fails.
224
+
225
+ If the author cannot be resolved via either method, or the GitHub API is
226
+ unreachable, the check fails with a clear error.
227
+
228
+ For private repositories, set `GITHUB_TOKEN` or `GH_TOKEN` so the Commits API
229
+ can authenticate.
230
+
202
231
  ### Configuration file
203
232
 
204
233
  Place `.commit-guard.toml` in your project root (or any parent directory) to
@@ -233,9 +262,11 @@ full precedence and ignore config file values when provided.
233
262
 
234
263
  ### Environment variables
235
264
 
236
- | Variable | Default | Description |
237
- | -------------------------- | ------- | -------------------------------------------- |
238
- | `COMMIT_GUARD_GIT_TIMEOUT` | `10` | Timeout in seconds for git subprocess calls. |
265
+ | Variable | Default | Description |
266
+ | -------------------------- | ------- | ------------------------------------------------------------------------- |
267
+ | `COMMIT_GUARD_GIT_TIMEOUT` | `10` | Timeout in seconds for git subprocess calls. |
268
+ | `GITHUB_TOKEN` | — | GitHub token for Commits API access on private repos (signature check). |
269
+ | `GH_TOKEN` | — | Alias for `GITHUB_TOKEN`; used when `GITHUB_TOKEN` is not set. |
239
270
 
240
271
  ```bash
241
272
  COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
@@ -244,7 +275,7 @@ COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
244
275
  In GitHub Actions, set it at the step or job level:
245
276
 
246
277
  ```yaml
247
- - uses: benner/commit-guard@v0.19.0
278
+ - uses: benner/commit-guard@v0.20.1
248
279
  env:
249
280
  COMMIT_GUARD_GIT_TIMEOUT: 30
250
281
  with:
@@ -328,7 +359,7 @@ steps:
328
359
  - uses: actions/checkout@v4
329
360
  with:
330
361
  fetch-depth: 0
331
- - uses: benner/commit-guard@v0.19.0
362
+ - uses: benner/commit-guard@v0.20.1
332
363
  ```
333
364
 
334
365
  Check all commits in a pull request:
@@ -344,7 +375,7 @@ jobs:
344
375
  - uses: actions/checkout@v4
345
376
  with:
346
377
  fetch-depth: 0
347
- - uses: benner/commit-guard@v0.19.0
378
+ - uses: benner/commit-guard@v0.20.1
348
379
  with:
349
380
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
350
381
  ```
@@ -352,7 +383,7 @@ jobs:
352
383
  Check a specific commit SHA (mirrors the positional CLI argument):
353
384
 
354
385
  ```yaml
355
- - uses: benner/commit-guard@v0.19.0
386
+ - uses: benner/commit-guard@v0.20.1
356
387
  with:
357
388
  rev: ${{ github.sha }}
358
389
  ```
@@ -370,7 +401,7 @@ jobs:
370
401
  - uses: actions/checkout@v4
371
402
  with:
372
403
  fetch-depth: 0
373
- - uses: benner/commit-guard@v0.19.0
404
+ - uses: benner/commit-guard@v0.20.1
374
405
  with:
375
406
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
376
407
  disable: signed-off,signature
@@ -390,7 +421,7 @@ jobs:
390
421
  When `output-file` is set the action exposes the path as an output:
391
422
 
392
423
  ```yaml
393
- - uses: benner/commit-guard@v0.19.0
424
+ - uses: benner/commit-guard@v0.20.1
394
425
  id: cg
395
426
  with:
396
427
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -406,7 +437,7 @@ Add to your `.pre-commit-config.yaml`:
406
437
  ---
407
438
  repos:
408
439
  - repo: https://github.com/benner/commit-guard
409
- rev: v0.19.0
440
+ rev: v0.20.1
410
441
  hooks:
411
442
  - id: commit-guard
412
443
  - id: commit-guard-signature
@@ -377,7 +377,7 @@ $ echo "fix(auth): add token refresh" | commit-guard</code></pre>
377
377
  </tr>
378
378
  <tr>
379
379
  <td><code>signature</code></td>
380
- <td>GPG or SSH signature is valid</td>
380
+ <td>GPG or SSH signature is valid — verified via GitHub Commits API or public key lookup</td>
381
381
  </tr>
382
382
  </tbody>
383
383
  </table>
@@ -426,6 +426,39 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
426
426
  </p>
427
427
  <pre><code class="language-bash">commit-guard --require-trailer "Closes,Reviewed-by"</code></pre>
428
428
 
429
+ <h3>Signature verification</h3>
430
+ <p>
431
+ The <code>signature</code> check verifies commits without requiring a
432
+ pre-configured local keyring:
433
+ </p>
434
+ <ol>
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), parse the username directly from a
441
+ GitHub noreply address
442
+ (<code>{id}+{username}@users.noreply.github.com</code>) — no API
443
+ call needed.</li>
444
+ <li>If neither of the above resolves a username, fall back to
445
+ searching GitHub by the commit author's email.</li>
446
+ <li>Fetch the resolved user's public keys from
447
+ <code>github.com/{username}.gpg</code> and
448
+ <code>github.com/{username}.keys</code>.</li>
449
+ <li>Try GPG verification using a temporary keyring.</li>
450
+ <li>Try SSH verification using a temporary <code>allowed_signers</code> file.</li>
451
+ <li>Pass if any key verifies; fail if none do.</li>
452
+ </ol>
453
+ <p>
454
+ If the author cannot be resolved via either method, or the GitHub API
455
+ is unreachable, the check fails with a clear error. For private
456
+ repositories, set <code>GITHUB_TOKEN</code> or <code>GH_TOKEN</code>
457
+ so the Commits API can authenticate. Disable the
458
+ <code>signature</code> check if GitHub API access is unavailable:
459
+ </p>
460
+ <pre><code class="language-bash">commit-guard --disable signature</code></pre>
461
+
429
462
  <h3>Range options</h3>
430
463
  <p>
431
464
  When using <code>--range</code>, merge commits are excluded by
@@ -451,6 +484,16 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
451
484
  <td><code>10</code></td>
452
485
  <td>Timeout in seconds for git subprocess calls</td>
453
486
  </tr>
487
+ <tr>
488
+ <td><code>GITHUB_TOKEN</code></td>
489
+ <td>—</td>
490
+ <td>GitHub token for Commits API access on private repos (signature check)</td>
491
+ </tr>
492
+ <tr>
493
+ <td><code>GH_TOKEN</code></td>
494
+ <td>—</td>
495
+ <td>Alias for <code>GITHUB_TOKEN</code>; used when <code>GITHUB_TOKEN</code> is not set</td>
496
+ </tr>
454
497
  </tbody>
455
498
  </table>
456
499
  </figure>
@@ -495,13 +538,13 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
495
538
  - uses: actions/checkout@v4
496
539
  with:
497
540
  fetch-depth: 0
498
- - uses: benner/commit-guard@v0.19.0
541
+ - uses: benner/commit-guard@v0.20.1
499
542
  with:
500
543
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
501
544
  disable: signed-off,signature</code></pre>
502
545
 
503
546
  <p>Check a specific commit SHA:</p>
504
- <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.19.0
547
+ <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.20.1
505
548
  with:
506
549
  rev: ${{ github.sha }}</code></pre>
507
550
 
@@ -520,7 +563,7 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
520
563
  When <code>output-file</code> is set the action exposes the path as
521
564
  a step output, making JSONL results available to subsequent steps:
522
565
  </p>
523
- <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.19.0
566
+ <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.20.1
524
567
  id: cg
525
568
  with:
526
569
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -533,7 +576,7 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
533
576
  <p>Add to <code>.pre-commit-config.yaml</code>:</p>
534
577
  <pre><code class="language-yaml">repos:
535
578
  - repo: https://github.com/benner/commit-guard
536
- rev: v0.19.0
579
+ rev: v0.20.1
537
580
  hooks:
538
581
  - id: commit-guard
539
582
  - id: commit-guard-signature</code></pre>
@@ -4,7 +4,10 @@ import os
4
4
  import re
5
5
  import subprocess
6
6
  import sys
7
+ import tempfile
7
8
  import tomllib
9
+ import urllib.error
10
+ import urllib.request
8
11
  from argparse import ArgumentParser
9
12
  from dataclasses import dataclass, field
10
13
  from enum import StrEnum
@@ -31,6 +34,10 @@ TYPES = frozenset(
31
34
 
32
35
  _NON_IMPERATIVE_SUFFIX_RE = re.compile(r"(?:ing|ed)$")
33
36
  _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
+ )
40
+ _NOREPLY_RE = re.compile(r"^(?:\d+\+)?(?P<username>[^@]+)@users\.noreply\.github\.com$")
34
41
 
35
42
  SUBJECT_RE = re.compile(
36
43
  r"^(?P<type>\w+)(?:\((?P<scope>[^)]+)\))?!?:\s+(?P<desc>.+)$",
@@ -71,7 +78,10 @@ def _load_config(start=None):
71
78
  config_path = directory / ".commit-guard.toml"
72
79
  if config_path.exists():
73
80
  with config_path.open("rb") as f:
74
- return tomllib.load(f)
81
+ try:
82
+ return tomllib.load(f)
83
+ except tomllib.TOMLDecodeError as e:
84
+ sys.exit(f"{config_path}: {e}")
75
85
  return {}
76
86
 
77
87
 
@@ -256,21 +266,164 @@ def check_required_trailers(message, required, result):
256
266
  result.error(f"missing required trailer: {trailer}")
257
267
 
258
268
 
259
- def check_signature(rev, result):
260
- proc = subprocess.run( # noqa: S603
261
- ["git", "verify-commit", rev], # noqa: S607
262
- capture_output=True,
269
+ def _get_author_email(rev):
270
+ return subprocess.check_output( # noqa: S603
271
+ ["git", "log", "-1", "--format=%ae", rev], # noqa: S607
263
272
  text=True,
264
- check=False,
273
+ stderr=subprocess.PIPE,
265
274
  timeout=_git_timeout(),
266
- )
267
- if proc.returncode != 0:
268
- result.error("commit is not signed (GPG/SSH)", check=Check.SIGNATURE)
269
- return
275
+ ).strip()
276
+
277
+
278
+ def _get_github_remote_info():
279
+ try:
280
+ url = subprocess.check_output(
281
+ ["git", "remote", "get-url", "origin"], # noqa: S607 Starting a process with a partial executable path
282
+ text=True,
283
+ stderr=subprocess.PIPE,
284
+ timeout=_git_timeout(),
285
+ ).strip()
286
+ except subprocess.CalledProcessError:
287
+ return None
288
+ match = _GITHUB_REMOTE_RE.search(url)
289
+ if not match:
290
+ return None
291
+ return match.group("owner"), match.group("repo")
292
+
293
+
294
+ def _fetch_github_commit_author(owner, repo, sha):
295
+ url = f"https://api.github.com/repos/{owner}/{repo}/commits/{sha}"
296
+ headers = {"Accept": "application/vnd.github+json"}
297
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
298
+ if token:
299
+ headers["Authorization"] = f"Bearer {token}"
300
+ req = urllib.request.Request(url, headers=headers) # noqa: S310 Audit URL open for permitted schemes
301
+ with urllib.request.urlopen(req, timeout=_git_timeout()) as resp: # noqa: S310 Audit URL open for permitted schemes
302
+ data = json.loads(resp.read())
303
+ author = data.get("author")
304
+ return author["login"] if author else None
305
+
306
+
307
+ def _parse_noreply_username(email):
308
+ match = _NOREPLY_RE.match(email)
309
+ return match.group("username") if match else None
310
+
311
+
312
+ def _fetch_github_username(email):
313
+ url = f"https://api.github.com/search/users?q={email}+in:email"
314
+ req = urllib.request.Request(url, headers={"Accept": "application/vnd.github+json"}) # noqa: S310 Audit URL open for permitted schemes
315
+ with urllib.request.urlopen(req, timeout=_git_timeout()) as resp: # noqa: S310 Audit URL open for permitted schemes
316
+ data = json.loads(resp.read())
317
+ items = data.get("items", [])
318
+ return items[0]["login"] if items else None
319
+
320
+
321
+ def _fetch_url(url):
322
+ with urllib.request.urlopen(url, timeout=_git_timeout()) as resp: # noqa: S310
323
+ return resp.read().decode()
324
+
325
+
326
+ def _fetch_github_keys(username):
327
+ gpg = _fetch_url(f"https://github.com/{username}.gpg")
328
+ ssh = _fetch_url(f"https://github.com/{username}.keys")
329
+ return gpg.strip(), ssh.strip()
270
330
 
271
- output = proc.stderr.lower()
272
- sig_type = "SSH" if "ssh" in output else "GPG"
273
- result.info(f"signature type: {sig_type}", check=Check.SIGNATURE)
331
+
332
+ def _verify_gpg(rev, gpg_text):
333
+ if not gpg_text:
334
+ return False
335
+ with tempfile.TemporaryDirectory() as homedir:
336
+ env = {**os.environ, "GNUPGHOME": homedir}
337
+ import_proc = subprocess.run(
338
+ ["gpg", "--batch", "--import"], # noqa: S607
339
+ input=gpg_text,
340
+ text=True,
341
+ capture_output=True,
342
+ env=env,
343
+ check=False,
344
+ )
345
+ if import_proc.returncode != 0:
346
+ return False
347
+ verify_proc = subprocess.run( # noqa: S603
348
+ ["git", "-c", "gpg.ssh.allowedSignersFile=/dev/null", "verify-commit", rev], # noqa: S607
349
+ capture_output=True,
350
+ text=True,
351
+ env=env,
352
+ check=False,
353
+ timeout=_git_timeout(),
354
+ )
355
+ return verify_proc.returncode == 0
356
+
357
+
358
+ def _verify_ssh(rev, email, ssh_text):
359
+ if not ssh_text:
360
+ return False
361
+ with tempfile.NamedTemporaryFile(
362
+ mode="w", suffix=".allowedSigners", delete=False
363
+ ) as f:
364
+ for raw_line in ssh_text.splitlines():
365
+ stripped = raw_line.strip()
366
+ if stripped:
367
+ f.write(f"{email} {stripped}\n")
368
+ signers_path = f.name
369
+ try:
370
+ proc = subprocess.run( # noqa: S603
371
+ [ # noqa: S607
372
+ "git",
373
+ "-c",
374
+ "gpg.format=ssh",
375
+ "-c",
376
+ f"gpg.ssh.allowedSignersFile={signers_path}",
377
+ "verify-commit",
378
+ rev,
379
+ ],
380
+ capture_output=True,
381
+ text=True,
382
+ check=False,
383
+ timeout=_git_timeout(),
384
+ )
385
+ return proc.returncode == 0
386
+ finally:
387
+ Path(signers_path).unlink(missing_ok=True)
388
+
389
+
390
+ def check_signature(rev, result):
391
+ try:
392
+ email = _get_author_email(rev)
393
+ username = None
394
+ remote = _get_github_remote_info()
395
+ if remote:
396
+ owner, repo = remote
397
+ with contextlib.suppress(urllib.error.URLError, TimeoutError):
398
+ username = _fetch_github_commit_author(owner, repo, rev)
399
+ if username is None:
400
+ username = _parse_noreply_username(email)
401
+ if username is None:
402
+ username = _fetch_github_username(email)
403
+ if username is None:
404
+ result.error(
405
+ "commit author not found on GitHub — cannot verify signature",
406
+ check=Check.SIGNATURE,
407
+ )
408
+ return
409
+ gpg_text, ssh_text = _fetch_github_keys(username)
410
+ if _verify_gpg(rev, gpg_text):
411
+ result.info("signature type: GPG", check=Check.SIGNATURE)
412
+ return
413
+ if _verify_ssh(rev, email, ssh_text):
414
+ result.info("signature type: SSH", check=Check.SIGNATURE)
415
+ return
416
+ result.error("commit is not signed (GPG/SSH)", check=Check.SIGNATURE)
417
+ except subprocess.TimeoutExpired:
418
+ result.error(
419
+ "git operation timed out — cannot verify signature",
420
+ check=Check.SIGNATURE,
421
+ )
422
+ except (urllib.error.URLError, TimeoutError):
423
+ result.error(
424
+ "GitHub API unreachable — cannot verify signature",
425
+ check=Check.SIGNATURE,
426
+ )
274
427
 
275
428
 
276
429
  def _get_message(rev):
@@ -553,7 +706,7 @@ def _parse_args(): # noqa: PLR0915 Too many statements (59 > 50)
553
706
  message = ""
554
707
  elif args.message_file:
555
708
  rev = None
556
- message = _strip_comments(args.message_file.read_text().strip())
709
+ message = _strip_comments(args.message_file.read_text(encoding="utf-8").strip())
557
710
  elif args.rev:
558
711
  rev = args.rev
559
712
  message = _strip_comments(_get_message(rev))
@@ -1,6 +1,8 @@
1
1
  import json
2
+ import os
2
3
  import re
3
4
  import subprocess
5
+ import urllib.error
4
6
  from argparse import ArgumentParser, Namespace
5
7
  from unittest.mock import MagicMock, patch
6
8
 
@@ -13,12 +15,19 @@ from git_commit_guard import (
13
15
  Result,
14
16
  _download_if_missing,
15
17
  _ensure_nltk_data,
18
+ _fetch_github_commit_author,
19
+ _fetch_github_keys,
20
+ _fetch_github_username,
21
+ _fetch_url,
22
+ _get_author_email,
23
+ _get_github_remote_info,
16
24
  _get_message,
17
25
  _get_range_revs,
18
26
  _git_timeout,
19
27
  _load_config,
20
28
  _parse_checks,
21
29
  _parse_config_checks,
30
+ _parse_noreply_username,
22
31
  _report_jsonl,
23
32
  _report_text,
24
33
  _resolve_max_subject_length,
@@ -29,6 +38,8 @@ from git_commit_guard import (
29
38
  _resolve_subject_pattern,
30
39
  _resolve_types,
31
40
  _strip_comments,
41
+ _verify_gpg,
42
+ _verify_ssh,
32
43
  check_body,
33
44
  check_imperative,
34
45
  check_required_trailers,
@@ -540,30 +551,414 @@ class TestDownloadIfMissing:
540
551
  mock_dl.assert_called_once_with("punkt_tab", quiet=True)
541
552
 
542
553
 
543
- class TestCheckSignature:
544
- def test_unsigned_commit(self):
545
- r = Result()
554
+ class TestGetAuthorEmail:
555
+ def test_returns_stripped_email(self):
556
+ with patch(
557
+ "git_commit_guard.subprocess.check_output",
558
+ return_value="user@example.com\n",
559
+ ):
560
+ assert _get_author_email("abc123") == "user@example.com"
561
+
562
+
563
+ class TestFetchUrl:
564
+ def test_returns_decoded_content(self):
565
+ mock_resp = MagicMock()
566
+ mock_resp.__enter__ = lambda s: s
567
+ mock_resp.__exit__ = MagicMock(return_value=False)
568
+ mock_resp.read.return_value = b"key data"
569
+ with patch("git_commit_guard.urllib.request.urlopen", return_value=mock_resp):
570
+ assert _fetch_url("https://github.com/user.keys") == "key data"
571
+
572
+
573
+ class TestParseNoreplyUsername:
574
+ def test_id_plus_username_format(self):
575
+ assert (
576
+ _parse_noreply_username("12345678+alice@users.noreply.github.com")
577
+ == "alice"
578
+ )
579
+
580
+ def test_plain_username_format(self):
581
+ assert _parse_noreply_username("alice@users.noreply.github.com") == "alice"
582
+
583
+ def test_regular_email_returns_none(self):
584
+ assert _parse_noreply_username("alice@example.com") is None
585
+
586
+ def test_wrong_domain_returns_none(self):
587
+ assert _parse_noreply_username("alice@users.noreply.gitlab.com") is None
588
+
589
+
590
+ class TestFetchGithubUsername:
591
+ def _mock_response(self, data):
592
+ mock_resp = MagicMock()
593
+ mock_resp.__enter__ = lambda s: s
594
+ mock_resp.__exit__ = MagicMock(return_value=False)
595
+ mock_resp.read.return_value = json.dumps(data).encode()
596
+ return mock_resp
597
+
598
+ def test_found_returns_login(self):
599
+ resp = self._mock_response({"items": [{"login": "testuser"}]})
600
+ with patch("git_commit_guard.urllib.request.urlopen", return_value=resp):
601
+ assert _fetch_github_username("test@example.com") == "testuser"
602
+
603
+ def test_not_found_returns_none(self):
604
+ resp = self._mock_response({"items": []})
605
+ with patch("git_commit_guard.urllib.request.urlopen", return_value=resp):
606
+ assert _fetch_github_username("unknown@example.com") is None
607
+
608
+
609
+ class TestGetGithubRemoteInfo:
610
+ def test_https_url_returns_owner_repo(self):
611
+ with patch(
612
+ "git_commit_guard.subprocess.check_output",
613
+ return_value="https://github.com/owner/repo.git\n",
614
+ ):
615
+ assert _get_github_remote_info() == ("owner", "repo")
616
+
617
+ def test_ssh_url_returns_owner_repo(self):
618
+ with patch(
619
+ "git_commit_guard.subprocess.check_output",
620
+ return_value="git@github.com:owner/repo.git\n",
621
+ ):
622
+ assert _get_github_remote_info() == ("owner", "repo")
623
+
624
+ def test_https_url_without_git_suffix(self):
625
+ with patch(
626
+ "git_commit_guard.subprocess.check_output",
627
+ return_value="https://github.com/owner/repo\n",
628
+ ):
629
+ assert _get_github_remote_info() == ("owner", "repo")
630
+
631
+ def test_no_remote_returns_none(self):
632
+ err = subprocess.CalledProcessError(128, "git")
633
+ err.stderr = "fatal: No such remote 'origin'"
634
+ with patch("git_commit_guard.subprocess.check_output", side_effect=err):
635
+ assert _get_github_remote_info() is None
636
+
637
+ def test_non_github_remote_returns_none(self):
638
+ with patch(
639
+ "git_commit_guard.subprocess.check_output",
640
+ return_value="https://gitlab.com/owner/repo.git\n",
641
+ ):
642
+ assert _get_github_remote_info() is None
643
+
644
+
645
+ class TestFetchGithubCommitAuthor:
646
+ def _mock_response(self, data):
647
+ mock_resp = MagicMock()
648
+ mock_resp.__enter__ = lambda s: s
649
+ mock_resp.__exit__ = MagicMock(return_value=False)
650
+ mock_resp.read.return_value = json.dumps(data).encode()
651
+ return mock_resp
652
+
653
+ def test_returns_author_login(self):
654
+ resp = self._mock_response({"author": {"login": "commituser"}})
655
+ with patch("git_commit_guard.urllib.request.urlopen", return_value=resp):
656
+ assert (
657
+ _fetch_github_commit_author("owner", "repo", "abc123") == "commituser"
658
+ )
659
+
660
+ def test_null_author_returns_none(self):
661
+ resp = self._mock_response({"author": None})
662
+ with patch("git_commit_guard.urllib.request.urlopen", return_value=resp):
663
+ assert _fetch_github_commit_author("owner", "repo", "abc123") is None
664
+
665
+ def test_github_token_sent_in_header(self):
666
+ resp = self._mock_response({"author": {"login": "user"}})
667
+ captured = []
668
+
669
+ def mock_urlopen(req, **_):
670
+ captured.append(req)
671
+ return resp
672
+
673
+ with (
674
+ patch("git_commit_guard.urllib.request.urlopen", side_effect=mock_urlopen),
675
+ patch.dict("os.environ", {"GITHUB_TOKEN": "mytoken"}, clear=False),
676
+ ):
677
+ _fetch_github_commit_author("owner", "repo", "abc123")
678
+ assert captured[0].get_header("Authorization") == "Bearer mytoken"
679
+
680
+ def test_gh_token_used_when_github_token_absent(self):
681
+ resp = self._mock_response({"author": {"login": "user"}})
682
+ captured = []
683
+
684
+ def mock_urlopen(req, **_):
685
+ captured.append(req)
686
+ return resp
687
+
688
+ env = {k: v for k, v in os.environ.items() if k != "GITHUB_TOKEN"}
689
+ env["GH_TOKEN"] = "ghtoken" # noqa: S105 Possible hardcoded password assigned to: "GH_TOKEN"
690
+ with (
691
+ patch("git_commit_guard.urllib.request.urlopen", side_effect=mock_urlopen),
692
+ patch.dict("os.environ", env, clear=True),
693
+ ):
694
+ _fetch_github_commit_author("owner", "repo", "abc123")
695
+ assert captured[0].get_header("Authorization") == "Bearer ghtoken"
696
+
697
+
698
+ class TestFetchGithubKeys:
699
+ def test_returns_gpg_and_ssh(self):
700
+ with patch(
701
+ "git_commit_guard._fetch_url", side_effect=["GPG KEY\n", "SSH KEY\n"]
702
+ ):
703
+ gpg, ssh = _fetch_github_keys("testuser")
704
+ assert gpg == "GPG KEY"
705
+ assert ssh == "SSH KEY"
706
+
707
+
708
+ class TestVerifyGpg:
709
+ def test_empty_gpg_returns_false(self):
710
+ assert _verify_gpg("abc123", "") is False
711
+
712
+ def test_import_failure_returns_false(self):
546
713
  proc = MagicMock(returncode=1)
547
714
  with patch("git_commit_guard.subprocess.run", return_value=proc):
548
- check_signature("abc123", r)
549
- assert not r.ok
715
+ assert _verify_gpg("abc123", "gpg key data") is False
550
716
 
551
- def test_gpg_signed_commit(self):
552
- r = Result()
553
- proc = MagicMock(returncode=0, stderr="gpg signature verified")
717
+ def test_verify_success_returns_true(self):
718
+ import_proc = MagicMock(returncode=0)
719
+ verify_proc = MagicMock(returncode=0)
720
+ with patch(
721
+ "git_commit_guard.subprocess.run", side_effect=[import_proc, verify_proc]
722
+ ) as mock_run:
723
+ assert _verify_gpg("abc123", "gpg key data") is True
724
+ verify_cmd = mock_run.call_args_list[1][0][0]
725
+ assert "gpg.ssh.allowedSignersFile=/dev/null" in verify_cmd
726
+
727
+ def test_verify_failure_returns_false(self):
728
+ import_proc = MagicMock(returncode=0)
729
+ verify_proc = MagicMock(returncode=1)
730
+ with patch(
731
+ "git_commit_guard.subprocess.run", side_effect=[import_proc, verify_proc]
732
+ ):
733
+ assert _verify_gpg("abc123", "gpg key data") is False
734
+
735
+
736
+ class TestVerifySSH:
737
+ def test_empty_ssh_returns_false(self):
738
+ assert _verify_ssh("abc123", "user@example.com", "") is False
739
+
740
+ def test_verify_success_returns_true(self):
741
+ proc = MagicMock(returncode=0)
742
+ with patch("git_commit_guard.subprocess.run", return_value=proc) as mock_run:
743
+ assert (
744
+ _verify_ssh("abc123", "user@example.com", "ssh-ed25519 AAAA...") is True
745
+ )
746
+ verify_cmd = mock_run.call_args[0][0]
747
+ assert "-c" in verify_cmd
748
+ assert "gpg.format=ssh" in verify_cmd
749
+
750
+ def test_verify_failure_returns_false(self):
751
+ proc = MagicMock(returncode=1)
554
752
  with patch("git_commit_guard.subprocess.run", return_value=proc):
753
+ assert (
754
+ _verify_ssh("abc123", "user@example.com", "ssh-ed25519 AAAA...")
755
+ is False
756
+ )
757
+
758
+
759
+ class TestCheckSignature:
760
+ def test_gpg_verified_via_github(self):
761
+ r = Result()
762
+ with (
763
+ patch(
764
+ "git_commit_guard._get_author_email", return_value="user@example.com"
765
+ ),
766
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
767
+ patch("git_commit_guard._fetch_github_username", return_value="testuser"),
768
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
769
+ patch("git_commit_guard._verify_gpg", return_value=True),
770
+ ):
555
771
  check_signature("abc123", r)
556
772
  assert r.ok
557
773
  assert any("GPG" in msg for _, _, msg in r.errors)
558
774
 
559
- def test_ssh_signed_commit(self):
775
+ def test_ssh_verified_via_github(self):
560
776
  r = Result()
561
- proc = MagicMock(returncode=0, stderr="Good ssh signature")
562
- with patch("git_commit_guard.subprocess.run", return_value=proc):
777
+ with (
778
+ patch(
779
+ "git_commit_guard._get_author_email", return_value="user@example.com"
780
+ ),
781
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
782
+ patch("git_commit_guard._fetch_github_username", return_value="testuser"),
783
+ patch("git_commit_guard._fetch_github_keys", return_value=("", "SSH KEY")),
784
+ patch("git_commit_guard._verify_gpg", return_value=False),
785
+ patch("git_commit_guard._verify_ssh", return_value=True),
786
+ ):
563
787
  check_signature("abc123", r)
564
788
  assert r.ok
565
789
  assert any("SSH" in msg for _, _, msg in r.errors)
566
790
 
791
+ def test_no_matching_key_fails(self):
792
+ r = Result()
793
+ with (
794
+ patch(
795
+ "git_commit_guard._get_author_email", return_value="user@example.com"
796
+ ),
797
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
798
+ patch("git_commit_guard._fetch_github_username", return_value="testuser"),
799
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG", "SSH")),
800
+ patch("git_commit_guard._verify_gpg", return_value=False),
801
+ patch("git_commit_guard._verify_ssh", return_value=False),
802
+ ):
803
+ check_signature("abc123", r)
804
+ assert not r.ok
805
+
806
+ def test_username_not_found_fails(self):
807
+ r = Result()
808
+ with (
809
+ patch(
810
+ "git_commit_guard._get_author_email", return_value="user@example.com"
811
+ ),
812
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
813
+ patch("git_commit_guard._fetch_github_username", return_value=None),
814
+ ):
815
+ check_signature("abc123", r)
816
+ assert not r.ok
817
+ assert any("not found on GitHub" in msg for _, _, msg in r.errors)
818
+
819
+ def test_url_error_fails(self):
820
+ r = Result()
821
+ with patch(
822
+ "git_commit_guard._get_author_email",
823
+ side_effect=urllib.error.URLError("unreachable"),
824
+ ):
825
+ check_signature("abc123", r)
826
+ assert not r.ok
827
+ assert any("API unreachable" in msg for _, _, msg in r.errors)
828
+
829
+ def test_timeout_error_fails(self):
830
+ r = Result()
831
+ with patch("git_commit_guard._get_author_email", side_effect=TimeoutError()):
832
+ check_signature("abc123", r)
833
+ assert not r.ok
834
+ assert any("API unreachable" in msg for _, _, msg in r.errors)
835
+
836
+ def test_subprocess_timeout_fails_gracefully(self):
837
+ r = Result()
838
+ with patch(
839
+ "git_commit_guard._get_author_email",
840
+ side_effect=subprocess.TimeoutExpired(cmd="git", timeout=10),
841
+ ):
842
+ check_signature("abc123", r)
843
+ assert not r.ok
844
+ assert any("git operation timed out" in msg for _, _, msg in r.errors)
845
+
846
+ def test_commits_api_resolves_username(self):
847
+ r = Result()
848
+ with (
849
+ patch(
850
+ "git_commit_guard._get_author_email", return_value="corp@example.com"
851
+ ),
852
+ patch(
853
+ "git_commit_guard._get_github_remote_info",
854
+ return_value=("owner", "repo"),
855
+ ),
856
+ patch(
857
+ "git_commit_guard._fetch_github_commit_author", return_value="corpuser"
858
+ ),
859
+ patch("git_commit_guard._fetch_github_username") as mock_email_search,
860
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
861
+ patch("git_commit_guard._verify_gpg", return_value=True),
862
+ ):
863
+ check_signature("abc123", r)
864
+ assert r.ok
865
+ mock_email_search.assert_not_called()
866
+
867
+ def test_commits_api_error_falls_back_to_email_search(self):
868
+ r = Result()
869
+ with (
870
+ patch(
871
+ "git_commit_guard._get_author_email", return_value="corp@example.com"
872
+ ),
873
+ patch(
874
+ "git_commit_guard._get_github_remote_info",
875
+ return_value=("owner", "repo"),
876
+ ),
877
+ patch(
878
+ "git_commit_guard._fetch_github_commit_author",
879
+ side_effect=urllib.error.URLError("not found"),
880
+ ),
881
+ patch("git_commit_guard._fetch_github_username", return_value="emailuser"),
882
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
883
+ patch("git_commit_guard._verify_gpg", return_value=True),
884
+ ):
885
+ check_signature("abc123", r)
886
+ assert r.ok
887
+
888
+ def test_commits_api_null_author_falls_back_to_email_search(self):
889
+ r = Result()
890
+ with (
891
+ patch(
892
+ "git_commit_guard._get_author_email", return_value="user@example.com"
893
+ ),
894
+ patch(
895
+ "git_commit_guard._get_github_remote_info",
896
+ return_value=("owner", "repo"),
897
+ ),
898
+ patch("git_commit_guard._fetch_github_commit_author", return_value=None),
899
+ patch("git_commit_guard._fetch_github_username", return_value="emailuser"),
900
+ patch("git_commit_guard._fetch_github_keys", return_value=("", "SSH KEY")),
901
+ patch("git_commit_guard._verify_gpg", return_value=False),
902
+ patch("git_commit_guard._verify_ssh", return_value=True),
903
+ ):
904
+ check_signature("abc123", r)
905
+ assert r.ok
906
+
907
+ def test_no_github_remote_uses_email_search(self):
908
+ r = Result()
909
+ with (
910
+ patch(
911
+ "git_commit_guard._get_author_email", return_value="user@example.com"
912
+ ),
913
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
914
+ patch("git_commit_guard._fetch_github_commit_author") as mock_commits_api,
915
+ patch("git_commit_guard._fetch_github_username", return_value="emailuser"),
916
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
917
+ patch("git_commit_guard._verify_gpg", return_value=True),
918
+ ):
919
+ check_signature("abc123", r)
920
+ assert r.ok
921
+ mock_commits_api.assert_not_called()
922
+
923
+ def test_noreply_email_skips_email_search(self):
924
+ r = Result()
925
+ with (
926
+ patch(
927
+ "git_commit_guard._get_author_email",
928
+ return_value="12345678+alice@users.noreply.github.com",
929
+ ),
930
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
931
+ patch("git_commit_guard._fetch_github_username") as mock_email_search,
932
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
933
+ patch("git_commit_guard._verify_gpg", return_value=True),
934
+ ):
935
+ check_signature("abc123", r)
936
+ assert r.ok
937
+ mock_email_search.assert_not_called()
938
+
939
+ def test_noreply_fallback_after_commits_api_failure(self):
940
+ r = Result()
941
+ with (
942
+ patch(
943
+ "git_commit_guard._get_author_email",
944
+ return_value="12345678+alice@users.noreply.github.com",
945
+ ),
946
+ patch(
947
+ "git_commit_guard._get_github_remote_info",
948
+ return_value=("owner", "repo"),
949
+ ),
950
+ patch(
951
+ "git_commit_guard._fetch_github_commit_author",
952
+ side_effect=urllib.error.URLError("not found"),
953
+ ),
954
+ patch("git_commit_guard._fetch_github_username") as mock_email_search,
955
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
956
+ patch("git_commit_guard._verify_gpg", return_value=True),
957
+ ):
958
+ check_signature("abc123", r)
959
+ assert r.ok
960
+ mock_email_search.assert_not_called()
961
+
567
962
 
568
963
  class TestGetMessage:
569
964
  def test_success(self):
@@ -622,6 +1017,12 @@ class TestLoadConfig:
622
1017
  (subdir / ".commit-guard.toml").write_text('disable = ["signature"]\n')
623
1018
  assert _load_config(subdir) == {"disable": ["signature"]}
624
1019
 
1020
+ def test_malformed_toml_exits_with_path(self, tmp_path):
1021
+ config_path = tmp_path / ".commit-guard.toml"
1022
+ config_path.write_text("disable = [unclosed\n")
1023
+ with pytest.raises(SystemExit, match=re.escape(str(config_path))):
1024
+ _load_config(tmp_path)
1025
+
625
1026
 
626
1027
  class TestParseConfigChecks:
627
1028
  def test_disable_list(self):
@@ -859,6 +1260,18 @@ class TestMain:
859
1260
  assert main() == 0
860
1261
  assert "all checks passed" in capsys.readouterr().out
861
1262
 
1263
+ def test_from_message_file_utf8_non_ascii(self, tmp_path):
1264
+ f = tmp_path / "msg"
1265
+ f.write_bytes(
1266
+ "fix: handle non-ascii\n\nbody\n\n"
1267
+ "Signed-off-by: Nerijus Bendžiūnas <a@b.com>".encode()
1268
+ )
1269
+ with patch(
1270
+ "sys.argv",
1271
+ ["cg", "--message-file", str(f), "--disable", "signature"],
1272
+ ):
1273
+ assert main() == 0
1274
+
862
1275
  def test_from_stdin(self):
863
1276
  stdin = MagicMock()
864
1277
  stdin.isatty.return_value = False
@@ -929,11 +1342,16 @@ class TestMain:
929
1342
  assert main() == 0
930
1343
 
931
1344
  def test_signature_with_rev(self):
932
- proc = MagicMock(returncode=0, stderr="gpg signature verified")
933
1345
  with (
934
1346
  patch("sys.argv", ["cg", "abc123", "--enable", "signature"]),
935
1347
  patch("git_commit_guard._get_message", return_value=_VALID_MSG),
936
- patch("git_commit_guard.subprocess.run", return_value=proc),
1348
+ patch(
1349
+ "git_commit_guard._get_author_email", return_value="user@example.com"
1350
+ ),
1351
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
1352
+ patch("git_commit_guard._fetch_github_username", return_value="testuser"),
1353
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
1354
+ patch("git_commit_guard._verify_gpg", return_value=True),
937
1355
  ):
938
1356
  assert main() == 0
939
1357