git-commit-guard 0.20.1__tar.gz → 0.22.0__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 (27) hide show
  1. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.github/workflows/lint-commits.yml +1 -1
  2. git_commit_guard-0.22.0/.github/workflows/lint-md.yml +68 -0
  3. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.github/workflows/release.yml +0 -3
  4. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/PKG-INFO +38 -18
  5. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/README.md +37 -17
  6. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/action.yml +10 -2
  7. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/docs/index.html +47 -19
  8. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/src/git_commit_guard/__init__.py +88 -16
  9. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/tests/test_git_commit_guard.py +253 -2
  10. git_commit_guard-0.20.1/.github/workflows/lint-md.yml +0 -26
  11. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.editorconfig +0 -0
  12. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.github/workflows/coverage-baseline.yml +0 -0
  13. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.github/workflows/coverage-comment.yml +0 -0
  14. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.github/workflows/lint-python.yml +0 -0
  15. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.github/workflows/lint-workflows.yml +0 -0
  16. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.github/workflows/test.yml +0 -0
  17. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.gitignore +0 -0
  18. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.markdownlint.json +0 -0
  19. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.pre-commit-hooks.yaml +0 -0
  20. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.python-version +0 -0
  21. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/LICENSE +0 -0
  22. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/cliff.toml +0 -0
  23. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/docs/commit-guard-icon.svg +0 -0
  24. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/pyproject.toml +0 -0
  25. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/ruff.toml +0 -0
  26. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/tests/__init__.py +0 -0
  27. {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/uv.lock +0 -0
@@ -22,6 +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@cab0d35c41a3f99fc5f61895f491d0ce3f5aff1c # v0.20.0
25
+ uses: benner/commit-guard@22a3b0fdb044e874250fc6525ff0905b20fa3a62 # v0.21.0
26
26
  with:
27
27
  range: origin/${{ github.base_ref }}..HEAD
@@ -0,0 +1,68 @@
1
+ ---
2
+ name: Lint Markdown
3
+ on: # yamllint disable-line rule:truthy
4
+ pull_request:
5
+ permissions:
6
+ contents: read
7
+ jobs:
8
+ lint-md:
9
+ runs-on: ubuntu-latest
10
+ permissions:
11
+ contents: read
12
+ pull-requests: write
13
+ steps:
14
+ - name: Checkout code
15
+ # yamllint disable-line rule:line-length
16
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
17
+ with:
18
+ persist-credentials: false
19
+ - name: Lint Markdown files with reviewdog
20
+ # yamllint disable-line rule:line-length
21
+ uses: reviewdog/action-markdownlint@3667398db9118d7e78f7a63d10e26ce454ba5f58 # v0.26.2
22
+ with:
23
+ github_token: ${{ secrets.GITHUB_TOKEN }}
24
+ reporter: github-pr-review
25
+ level: info
26
+ fail_level: any
27
+ lint-md-rumdl:
28
+ runs-on: ubuntu-latest
29
+ permissions:
30
+ contents: read
31
+ pull-requests: write
32
+ steps:
33
+ - name: Checkout code
34
+ # yamllint disable-line rule:line-length
35
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
36
+ with:
37
+ persist-credentials: false
38
+ - name: Set up uv
39
+ # yamllint disable-line rule:line-length
40
+ uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
41
+ with:
42
+ enable-cache: false
43
+ - name: Set up reviewdog
44
+ # yamllint disable-line rule:line-length
45
+ uses: reviewdog/action-setup@d8a7baabd7f3e8544ee4dbde3ee41d0011c3a93f # ratchet:reviewdog/action-setup@v1
46
+ - name: Lint Markdown files with rumdl via reviewdog
47
+ env:
48
+ REVIEWDOG_GITHUB_API_TOKEN: ${{ secrets.GITHUB_TOKEN }}
49
+ run: |-
50
+ uvx rumdl check . --output-format json-lines > rumdl.jsonl || true
51
+ jq -c '
52
+ {
53
+ source: {name: "rumdl"},
54
+ severity: (.severity | ascii_upcase),
55
+ message: .message,
56
+ location: {
57
+ path: .file,
58
+ range: {start: {line: .line, column: .column}}
59
+ },
60
+ code: {value: .rule}
61
+ }
62
+ ' rumdl.jsonl \
63
+ | reviewdog \
64
+ -f=rdjsonl \
65
+ -name=rumdl \
66
+ -reporter=github-pr-review \
67
+ -filter-mode=added \
68
+ -fail-level=any
@@ -18,9 +18,6 @@ jobs:
18
18
  with:
19
19
  persist-credentials: false
20
20
  fetch-depth: 0
21
- - name: Set up Python
22
- # yamllint disable-line rule:line-length
23
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
24
21
  - name: Set up uv
25
22
  # yamllint disable-line rule:line-length
26
23
  uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: git-commit-guard
3
- Version: 0.20.1
3
+ Version: 0.22.0
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
@@ -21,19 +21,35 @@ Description-Content-Type: text/markdown
21
21
 
22
22
  # commit-guard
23
23
 
24
+ <!-- markdownlint-disable MD013 -->
25
+ [![PyPI version](https://img.shields.io/pypi/v/git-commit-guard.svg)](https://pypi.org/project/git-commit-guard/)
26
+ [![PyPI downloads](https://img.shields.io/pypi/dm/git-commit-guard.svg)](https://pypi.org/project/git-commit-guard/)
27
+ [![CI](https://github.com/benner/commit-guard/actions/workflows/test.yml/badge.svg)](https://github.com/benner/commit-guard/actions/workflows/test.yml)
28
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://pre-commit.com/)
29
+ <!-- markdownlint-restore -->
30
+
24
31
  Opinionated conventional commit message linter with imperative mood detection.
25
32
 
26
- Unlike regular expression only tools, commit-guard uses
27
- NLP (nltk POS tagging) to verify that commit descriptions start with an
28
- imperative verb.
33
+ ## Why commit-guard?
34
+
35
+ * **NLP imperative detection.** Descriptions must start with an imperative
36
+ verb, verified via nltk POS tagging — not a hand-coded regex of "bad"
37
+ words.
38
+ * **Signature verification without a local keyring.** Resolves the commit
39
+ author via the GitHub API and verifies GPG/SSH against their published
40
+ `.gpg`/`.keys` — no per-runner key management.
41
+ * **Strict by default.** Subject format, body, trailers, `Signed-off-by`,
42
+ and signature all enforced out of the box; opt out with `--disable`.
29
43
 
30
44
  ## Example
31
45
 
32
46
  ```bash
33
47
  $ commit-guard
34
48
  ✗ [subject] subject does not match 'type(scope): description': WIP
35
- ✗ [signed-off] missing 'Signed-off-by' trailer
36
- ✗ [signature] commit is not signed (GPG/SSH)
49
+ ✗ [signed-off] missing 'Signed-off-by' trailer — use 'git commit -s'
50
+ ✗ [signature] signature could not be verified — commit may be
51
+ unsigned, or signed with a key not uploaded as a
52
+ Signing key on https://github.com/settings/keys
37
53
  ```
38
54
 
39
55
  ## Installation
@@ -94,12 +110,12 @@ commit-guard --disable body,signed-off,signature
94
110
  Available checks:
95
111
 
96
112
  * `subject` - Format matches `type(scope): description`, valid type,
97
- lowercase start, no trailing `.` `!` `?` or space, max 72 chars
113
+ lowercase start, no trailing `.` `!` `?` or space, max 72 chars
98
114
  * `imperative` - First word is an imperative verb (for example `add` not `added`)
99
115
  * `body` - Blank line separates subject from body, and body is non-empty
100
116
  * `signed-off` - `Signed-off-by:` trailer exists
101
117
  * `signature` - Verify GPG or SSH signature via the GitHub Commits API or
102
- public key lookup
118
+ public key lookup
103
119
 
104
120
  ### Subject length
105
121
 
@@ -235,8 +251,10 @@ The `signature` check verifies the commit without any local keyring setup:
235
251
  `{username}@users.noreply.github.com`) — no API call needed.
236
252
  3. If neither of the above resolves a username, fall back to searching GitHub
237
253
  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`.
254
+ 4. Fetch the resolved user's public keys from `github.com/{username}.gpg`
255
+ (GPG) and the `/users/{username}/ssh_signing_keys` API (SSH keys tagged
256
+ with the **Signing key** role). Auth-only SSH keys are deliberately not
257
+ accepted — this mirrors GitHub's "Verified" badge semantics.
240
258
  5. Try GPG verification: import the fetched key into a temporary keyring and
241
259
  run `git verify-commit`.
242
260
  6. Try SSH verification: write a temporary `allowed_signers` file and run
@@ -247,7 +265,9 @@ If the author cannot be resolved via either method, or the GitHub API is
247
265
  unreachable, the check fails with a clear error.
248
266
 
249
267
  For private repositories, set `GITHUB_TOKEN` or `GH_TOKEN` so the Commits API
250
- can authenticate.
268
+ can authenticate. The official GitHub Action wires the workflow's automatic
269
+ token via the `github-token` input, so no manual `env:` is required; override
270
+ with a PAT only for cross-repo lookups.
251
271
 
252
272
  ### Configuration file
253
273
 
@@ -296,7 +316,7 @@ COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
296
316
  In GitHub Actions, set it at the step or job level:
297
317
 
298
318
  ```yaml
299
- - uses: benner/commit-guard@v0.20.1
319
+ - uses: benner/commit-guard@v0.22.0
300
320
  env:
301
321
  COMMIT_GUARD_GIT_TIMEOUT: 30
302
322
  with:
@@ -380,7 +400,7 @@ steps:
380
400
  - uses: actions/checkout@v4
381
401
  with:
382
402
  fetch-depth: 0
383
- - uses: benner/commit-guard@v0.20.1
403
+ - uses: benner/commit-guard@v0.22.0
384
404
  ```
385
405
 
386
406
  Check all commits in a pull request:
@@ -396,7 +416,7 @@ jobs:
396
416
  - uses: actions/checkout@v4
397
417
  with:
398
418
  fetch-depth: 0
399
- - uses: benner/commit-guard@v0.20.1
419
+ - uses: benner/commit-guard@v0.22.0
400
420
  with:
401
421
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
402
422
  ```
@@ -404,7 +424,7 @@ jobs:
404
424
  Check a specific commit SHA (mirrors the positional CLI argument):
405
425
 
406
426
  ```yaml
407
- - uses: benner/commit-guard@v0.20.1
427
+ - uses: benner/commit-guard@v0.22.0
408
428
  with:
409
429
  rev: ${{ github.sha }}
410
430
  ```
@@ -422,7 +442,7 @@ jobs:
422
442
  - uses: actions/checkout@v4
423
443
  with:
424
444
  fetch-depth: 0
425
- - uses: benner/commit-guard@v0.20.1
445
+ - uses: benner/commit-guard@v0.22.0
426
446
  with:
427
447
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
428
448
  disable: signed-off,signature
@@ -442,7 +462,7 @@ jobs:
442
462
  When `output-file` is set the action exposes the path as an output:
443
463
 
444
464
  ```yaml
445
- - uses: benner/commit-guard@v0.20.1
465
+ - uses: benner/commit-guard@v0.22.0
446
466
  id: cg
447
467
  with:
448
468
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -458,7 +478,7 @@ Add to your `.pre-commit-config.yaml`:
458
478
  ---
459
479
  repos:
460
480
  - repo: https://github.com/benner/commit-guard
461
- rev: v0.20.1
481
+ rev: v0.22.0
462
482
  hooks:
463
483
  - id: commit-guard
464
484
  - id: commit-guard-signature
@@ -1,18 +1,34 @@
1
1
  # commit-guard
2
2
 
3
+ <!-- markdownlint-disable MD013 -->
4
+ [![PyPI version](https://img.shields.io/pypi/v/git-commit-guard.svg)](https://pypi.org/project/git-commit-guard/)
5
+ [![PyPI downloads](https://img.shields.io/pypi/dm/git-commit-guard.svg)](https://pypi.org/project/git-commit-guard/)
6
+ [![CI](https://github.com/benner/commit-guard/actions/workflows/test.yml/badge.svg)](https://github.com/benner/commit-guard/actions/workflows/test.yml)
7
+ [![pre-commit](https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white)](https://pre-commit.com/)
8
+ <!-- markdownlint-restore -->
9
+
3
10
  Opinionated conventional commit message linter with imperative mood detection.
4
11
 
5
- Unlike regular expression only tools, commit-guard uses
6
- NLP (nltk POS tagging) to verify that commit descriptions start with an
7
- imperative verb.
12
+ ## Why commit-guard?
13
+
14
+ * **NLP imperative detection.** Descriptions must start with an imperative
15
+ verb, verified via nltk POS tagging — not a hand-coded regex of "bad"
16
+ words.
17
+ * **Signature verification without a local keyring.** Resolves the commit
18
+ author via the GitHub API and verifies GPG/SSH against their published
19
+ `.gpg`/`.keys` — no per-runner key management.
20
+ * **Strict by default.** Subject format, body, trailers, `Signed-off-by`,
21
+ and signature all enforced out of the box; opt out with `--disable`.
8
22
 
9
23
  ## Example
10
24
 
11
25
  ```bash
12
26
  $ commit-guard
13
27
  ✗ [subject] subject does not match 'type(scope): description': WIP
14
- ✗ [signed-off] missing 'Signed-off-by' trailer
15
- ✗ [signature] commit is not signed (GPG/SSH)
28
+ ✗ [signed-off] missing 'Signed-off-by' trailer — use 'git commit -s'
29
+ ✗ [signature] signature could not be verified — commit may be
30
+ unsigned, or signed with a key not uploaded as a
31
+ Signing key on https://github.com/settings/keys
16
32
  ```
17
33
 
18
34
  ## Installation
@@ -73,12 +89,12 @@ commit-guard --disable body,signed-off,signature
73
89
  Available checks:
74
90
 
75
91
  * `subject` - Format matches `type(scope): description`, valid type,
76
- lowercase start, no trailing `.` `!` `?` or space, max 72 chars
92
+ lowercase start, no trailing `.` `!` `?` or space, max 72 chars
77
93
  * `imperative` - First word is an imperative verb (for example `add` not `added`)
78
94
  * `body` - Blank line separates subject from body, and body is non-empty
79
95
  * `signed-off` - `Signed-off-by:` trailer exists
80
96
  * `signature` - Verify GPG or SSH signature via the GitHub Commits API or
81
- public key lookup
97
+ public key lookup
82
98
 
83
99
  ### Subject length
84
100
 
@@ -214,8 +230,10 @@ The `signature` check verifies the commit without any local keyring setup:
214
230
  `{username}@users.noreply.github.com`) — no API call needed.
215
231
  3. If neither of the above resolves a username, fall back to searching GitHub
216
232
  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`.
233
+ 4. Fetch the resolved user's public keys from `github.com/{username}.gpg`
234
+ (GPG) and the `/users/{username}/ssh_signing_keys` API (SSH keys tagged
235
+ with the **Signing key** role). Auth-only SSH keys are deliberately not
236
+ accepted — this mirrors GitHub's "Verified" badge semantics.
219
237
  5. Try GPG verification: import the fetched key into a temporary keyring and
220
238
  run `git verify-commit`.
221
239
  6. Try SSH verification: write a temporary `allowed_signers` file and run
@@ -226,7 +244,9 @@ If the author cannot be resolved via either method, or the GitHub API is
226
244
  unreachable, the check fails with a clear error.
227
245
 
228
246
  For private repositories, set `GITHUB_TOKEN` or `GH_TOKEN` so the Commits API
229
- can authenticate.
247
+ can authenticate. The official GitHub Action wires the workflow's automatic
248
+ token via the `github-token` input, so no manual `env:` is required; override
249
+ with a PAT only for cross-repo lookups.
230
250
 
231
251
  ### Configuration file
232
252
 
@@ -275,7 +295,7 @@ COMMIT_GUARD_GIT_TIMEOUT=30 commit-guard --range origin/main..HEAD
275
295
  In GitHub Actions, set it at the step or job level:
276
296
 
277
297
  ```yaml
278
- - uses: benner/commit-guard@v0.20.1
298
+ - uses: benner/commit-guard@v0.22.0
279
299
  env:
280
300
  COMMIT_GUARD_GIT_TIMEOUT: 30
281
301
  with:
@@ -359,7 +379,7 @@ steps:
359
379
  - uses: actions/checkout@v4
360
380
  with:
361
381
  fetch-depth: 0
362
- - uses: benner/commit-guard@v0.20.1
382
+ - uses: benner/commit-guard@v0.22.0
363
383
  ```
364
384
 
365
385
  Check all commits in a pull request:
@@ -375,7 +395,7 @@ jobs:
375
395
  - uses: actions/checkout@v4
376
396
  with:
377
397
  fetch-depth: 0
378
- - uses: benner/commit-guard@v0.20.1
398
+ - uses: benner/commit-guard@v0.22.0
379
399
  with:
380
400
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
381
401
  ```
@@ -383,7 +403,7 @@ jobs:
383
403
  Check a specific commit SHA (mirrors the positional CLI argument):
384
404
 
385
405
  ```yaml
386
- - uses: benner/commit-guard@v0.20.1
406
+ - uses: benner/commit-guard@v0.22.0
387
407
  with:
388
408
  rev: ${{ github.sha }}
389
409
  ```
@@ -401,7 +421,7 @@ jobs:
401
421
  - uses: actions/checkout@v4
402
422
  with:
403
423
  fetch-depth: 0
404
- - uses: benner/commit-guard@v0.20.1
424
+ - uses: benner/commit-guard@v0.22.0
405
425
  with:
406
426
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
407
427
  disable: signed-off,signature
@@ -421,7 +441,7 @@ jobs:
421
441
  When `output-file` is set the action exposes the path as an output:
422
442
 
423
443
  ```yaml
424
- - uses: benner/commit-guard@v0.20.1
444
+ - uses: benner/commit-guard@v0.22.0
425
445
  id: cg
426
446
  with:
427
447
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -437,7 +457,7 @@ Add to your `.pre-commit-config.yaml`:
437
457
  ---
438
458
  repos:
439
459
  - repo: https://github.com/benner/commit-guard
440
- rev: v0.20.1
460
+ rev: v0.22.0
441
461
  hooks:
442
462
  - id: commit-guard
443
463
  - id: commit-guard-signature
@@ -39,7 +39,8 @@ inputs:
39
39
  required: false
40
40
  default: 'false'
41
41
  no-trailing-chars:
42
- description: Forbidden trailing characters, comma-separated (default '.', '!', '?', ' ')
42
+ description: Forbidden trailing characters, comma-separated (default '.', '!',
43
+ '?', ' ')
43
44
  required: false
44
45
  allow-empty:
45
46
  description: Exit 0 when --range yields no commits
@@ -58,9 +59,15 @@ inputs:
58
59
  output-file:
59
60
  description: Write JSONL results to this file path (text still goes to stdout)
60
61
  required: false
62
+ github-token:
63
+ description: GitHub token for Commits API access (signature check). Defaults to
64
+ the workflow's automatic token; override with a PAT for cross-repo lookups.
65
+ required: false
66
+ default: ${{ github.token }}
61
67
  outputs:
62
68
  output-file:
63
- description: Path to the JSONL output file (set only when output-file input is provided)
69
+ description: Path to the JSONL output file (set only when output-file input is
70
+ provided)
64
71
  value: ${{ steps.run.outputs.output-file }}
65
72
  runs:
66
73
  using: composite
@@ -76,6 +83,7 @@ runs:
76
83
  - name: Run commit-guard
77
84
  id: run
78
85
  env:
86
+ GITHUB_TOKEN: ${{ inputs.github-token }}
79
87
  CG_REV: ${{ inputs.rev }}
80
88
  CG_RANGE: ${{ inputs.range }}
81
89
  CG_ENABLE: ${{ inputs.enable }}
@@ -115,7 +115,7 @@
115
115
  .hero-terminal pre {
116
116
  margin: 0;
117
117
  font-size: 0.95rem;
118
- width: 64ch;
118
+ width: 80ch;
119
119
  height: 12em;
120
120
  overflow: hidden;
121
121
  }
@@ -291,12 +291,12 @@
291
291
  <h1>commit-guard</h1>
292
292
  <p>Conventional commit linting with imperative mood detection.</p>
293
293
  <div class="hero-terminal">
294
- <pre><code id="hero-code">$ commit-guard --range origin/main..HEAD
295
- <span class="c-dim">abc1234</span> feat: add user authentication
296
- <span class="c-green">✓</span> all checks passed
297
- <span class="c-dim">def5678</span> wip: still working
298
- <span class="c-red">✗</span> <span class="c-dim">[subject]</span> unknown type: wip
299
- <span class="c-red">✗</span> <span class="c-dim">[body]</span> missing body</code></pre>
294
+ <pre><code id="hero-code">$ commit-guard
295
+ <span class="c-red">✗</span> <span class="c-dim">[subject]</span> subject does not match 'type(scope): description': WIP
296
+ <span class="c-red">✗</span> <span class="c-dim">[signed-off]</span> missing 'Signed-off-by' trailer — use 'git commit -s'
297
+ <span class="c-red">✗</span> <span class="c-dim">[signature]</span> signature could not be verified — commit may be
298
+ unsigned, or signed with a key not uploaded as a
299
+ Signing key on https://github.com/settings/keys</code></pre>
300
300
  </div>
301
301
  <div class="hero-dots">
302
302
  <button class="hero-dot" data-i="0"></button>
@@ -305,6 +305,28 @@
305
305
  </div>
306
306
  </section>
307
307
 
308
+ <section id="why">
309
+ <h2>Why commit-guard? <a href="#why" class="anchor">#</a></h2>
310
+ <ul>
311
+ <li>
312
+ <strong>NLP imperative detection.</strong> Descriptions must start
313
+ with an imperative verb, verified via nltk POS tagging — not a
314
+ hand-coded regex of "bad" words.
315
+ </li>
316
+ <li>
317
+ <strong>Signature verification without a local keyring.</strong>
318
+ Resolves the commit author via the GitHub API and verifies GPG/SSH
319
+ against their published <code>.gpg</code>/<code>.keys</code> — no
320
+ per-runner key management.
321
+ </li>
322
+ <li>
323
+ <strong>Strict by default.</strong> Subject format, body, trailers,
324
+ <code>Signed-off-by</code>, and signature all enforced out of the
325
+ box; opt out with <code>--disable</code>.
326
+ </li>
327
+ </ul>
328
+ </section>
329
+
308
330
  <section id="install">
309
331
  <h2>Install <a href="#install" class="anchor">#</a></h2>
310
332
  <div class="install-tabs">
@@ -444,8 +466,11 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
444
466
  <li>If neither of the above resolves a username, fall back to
445
467
  searching GitHub by the commit author's email.</li>
446
468
  <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>
469
+ <code>github.com/{username}.gpg</code> (GPG) and
470
+ <code>/users/{username}/ssh_signing_keys</code> (SSH keys tagged
471
+ with the <strong>Signing key</strong> role). Auth-only SSH keys
472
+ are deliberately not accepted — this mirrors GitHub's
473
+ &ldquo;Verified&rdquo; badge semantics.</li>
449
474
  <li>Try GPG verification using a temporary keyring.</li>
450
475
  <li>Try SSH verification using a temporary <code>allowed_signers</code> file.</li>
451
476
  <li>Pass if any key verifies; fail if none do.</li>
@@ -454,7 +479,10 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
454
479
  If the author cannot be resolved via either method, or the GitHub API
455
480
  is unreachable, the check fails with a clear error. For private
456
481
  repositories, set <code>GITHUB_TOKEN</code> or <code>GH_TOKEN</code>
457
- so the Commits API can authenticate. Disable the
482
+ so the Commits API can authenticate. The official GitHub Action wires
483
+ the workflow's automatic token via the <code>github-token</code>
484
+ input, so no manual <code>env:</code> is required; override with a
485
+ PAT only for cross-repo lookups. Disable the
458
486
  <code>signature</code> check if GitHub API access is unavailable:
459
487
  </p>
460
488
  <pre><code class="language-bash">commit-guard --disable signature</code></pre>
@@ -538,13 +566,13 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
538
566
  - uses: actions/checkout@v4
539
567
  with:
540
568
  fetch-depth: 0
541
- - uses: benner/commit-guard@v0.20.1
569
+ - uses: benner/commit-guard@v0.22.0
542
570
  with:
543
571
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
544
572
  disable: signed-off,signature</code></pre>
545
573
 
546
574
  <p>Check a specific commit SHA:</p>
547
- <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.20.1
575
+ <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.22.0
548
576
  with:
549
577
  rev: ${{ github.sha }}</code></pre>
550
578
 
@@ -563,7 +591,7 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
563
591
  When <code>output-file</code> is set the action exposes the path as
564
592
  a step output, making JSONL results available to subsequent steps:
565
593
  </p>
566
- <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.20.1
594
+ <pre><code class="language-yaml"> - uses: benner/commit-guard@v0.22.0
567
595
  id: cg
568
596
  with:
569
597
  range: ${{ env.PR_BASE }}..${{ env.PR_HEAD }}
@@ -576,7 +604,7 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
576
604
  <p>Add to <code>.pre-commit-config.yaml</code>:</p>
577
605
  <pre><code class="language-yaml">repos:
578
606
  - repo: https://github.com/benner/commit-guard
579
- rev: v0.20.1
607
+ rev: v0.22.0
580
608
  hooks:
581
609
  - id: commit-guard
582
610
  - id: commit-guard-signature</code></pre>
@@ -596,16 +624,16 @@ require-subject-pattern = "[A-Z]+-[0-9]+"</code></pre>
596
624
  const EXAMPLES = [
597
625
  [
598
626
  ["$ commit-guard\n ", null],
599
- ["✗", "c-red"], [" [subject]", "c-dim"], [" unknown type: wip\n ", null],
600
- ["✗", "c-red"], [" [body]", "c-dim"], [" missing body\n ", null],
601
- ["✗", "c-red"], [" [imperative]", "c-dim"], [" got 'added', want imperative verb", null],
627
+ ["✗", "c-red"], [" [subject]", "c-dim"], [" subject does not match 'type(scope): description': WIP\n ", null],
628
+ ["✗", "c-red"], [" [signed-off]", "c-dim"], [" missing 'Signed-off-by' trailer — use 'git commit -s'\n ", null],
629
+ ["✗", "c-red"], [" [signature]", "c-dim"], [" signature could not be verified — commit may be\n unsigned, or signed with a key not uploaded as a\n Signing key on https://github.com/settings/keys", null],
602
630
  ],
603
631
  [
604
632
  ["$ commit-guard --range origin/main..HEAD\n", null],
605
633
  ["abc1234", "c-dim"], [" feat: add user authentication\n ", null],
606
634
  ["✓", "c-green"], [" all checks passed\n", null],
607
- ["def5678", "c-dim"], [" wip: still working\n ", null],
608
- ["✗", "c-red"], [" [subject]", "c-dim"], [" unknown type: wip\n ", null],
635
+ ["def5678", "c-dim"], [" chore: added logging\n ", null],
636
+ ["✗", "c-red"], [" [imperative]", "c-dim"], [" expected imperative verb, got 'added' (non-imperative suffix)\n ", null],
609
637
  ["✗", "c-red"], [" [body]", "c-dim"], [" missing body", null],
610
638
  ],
611
639
  [
@@ -11,6 +11,7 @@ import urllib.request
11
11
  from argparse import ArgumentParser
12
12
  from dataclasses import dataclass, field
13
13
  from enum import StrEnum
14
+ from http import HTTPStatus
14
15
  from pathlib import Path
15
16
 
16
17
  import nltk
@@ -150,6 +151,12 @@ def _download_if_missing(resource):
150
151
  nltk.download(resource.rsplit("/", maxsplit=1)[-1], quiet=True)
151
152
 
152
153
 
154
+ def _format_allowed_hint(allowed, kind):
155
+ if len(allowed) <= len(TYPES):
156
+ return f"(allowed: {', '.join(sorted(allowed))})"
157
+ return f"(see configured {kind})"
158
+
159
+
153
160
  def _strip_comments(message):
154
161
  return "\n".join(
155
162
  line for line in message.split("\n") if not line.lstrip().startswith("#")
@@ -177,13 +184,16 @@ def check_subject( # noqa: PLR0913 Too many arguments in function definition (9
177
184
  return None
178
185
 
179
186
  if m.group("type") not in allowed_types:
180
- result.error(f"unknown type: {m.group('type')}", check=Check.SUBJECT)
187
+ bad_type = m.group("type")
188
+ hint = _format_allowed_hint(allowed_types, "types")
189
+ result.error(f"unknown type: {bad_type} {hint}", check=Check.SUBJECT)
181
190
 
182
191
  scope = m.group("scope")
183
192
  if require_scope and scope is None:
184
193
  result.error("scope is required", check=Check.SUBJECT)
185
194
  if allowed_scopes and scope is not None and scope not in allowed_scopes:
186
- result.error(f"unknown scope: {scope}", check=Check.SUBJECT)
195
+ hint = _format_allowed_hint(allowed_scopes, "scopes")
196
+ result.error(f"unknown scope: {scope} {hint}", check=Check.SUBJECT)
187
197
 
188
198
  desc = m.group("desc")
189
199
  if require_lowercase and desc[0].isupper():
@@ -248,7 +258,10 @@ def check_body(lines, result):
248
258
 
249
259
  def check_signed_off(message, result):
250
260
  if not SIGNED_OFF_RE.search(message):
251
- result.error("missing 'Signed-off-by' trailer", check=Check.SIGNED_OFF)
261
+ result.error(
262
+ "missing 'Signed-off-by' trailer — use 'git commit -s'",
263
+ check=Check.SIGNED_OFF,
264
+ )
252
265
 
253
266
 
254
267
  def check_subject_pattern(subject, pattern, result):
@@ -323,9 +336,21 @@ def _fetch_url(url):
323
336
  return resp.read().decode()
324
337
 
325
338
 
339
+ def _fetch_github_signing_keys(username):
340
+ url = f"https://api.github.com/users/{username}/ssh_signing_keys"
341
+ headers = {"Accept": "application/vnd.github+json"}
342
+ token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
343
+ if token:
344
+ headers["Authorization"] = f"Bearer {token}"
345
+ req = urllib.request.Request(url, headers=headers) # noqa: S310 Audit URL open for permitted schemes
346
+ with urllib.request.urlopen(req, timeout=_git_timeout()) as resp: # noqa: S310 Audit URL open for permitted schemes
347
+ data = json.loads(resp.read())
348
+ return "\n".join(item["key"] for item in data)
349
+
350
+
326
351
  def _fetch_github_keys(username):
327
352
  gpg = _fetch_url(f"https://github.com/{username}.gpg")
328
- ssh = _fetch_url(f"https://github.com/{username}.keys")
353
+ ssh = _fetch_github_signing_keys(username)
329
354
  return gpg.strip(), ssh.strip()
330
355
 
331
356
 
@@ -387,22 +412,46 @@ def _verify_ssh(rev, email, ssh_text):
387
412
  Path(signers_path).unlink(missing_ok=True)
388
413
 
389
414
 
415
+ def _resolve_github_username(rev, email):
416
+ username = None
417
+ commits_api_404 = False
418
+ remote = _get_github_remote_info()
419
+ if remote:
420
+ owner, repo = remote
421
+ try:
422
+ username = _fetch_github_commit_author(owner, repo, rev)
423
+ except urllib.error.HTTPError as e:
424
+ if e.code == HTTPStatus.NOT_FOUND:
425
+ commits_api_404 = True
426
+ elif e.code in (HTTPStatus.UNAUTHORIZED, HTTPStatus.FORBIDDEN):
427
+ raise
428
+ except (urllib.error.URLError, TimeoutError):
429
+ pass
430
+ if username is None:
431
+ username = _parse_noreply_username(email)
432
+ if username is None:
433
+ username = _fetch_github_username(email)
434
+ return username, commits_api_404
435
+
436
+
437
+ def _author_not_found_message(commits_api_404):
438
+ had_token = bool(os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN"))
439
+ if commits_api_404 and not had_token:
440
+ return (
441
+ "commit author not found on GitHub — if the repo is private, "
442
+ "set GITHUB_TOKEN in the workflow step "
443
+ "(env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }})"
444
+ )
445
+ return "commit author not found on GitHub — cannot verify signature"
446
+
447
+
390
448
  def check_signature(rev, result):
391
449
  try:
392
450
  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)
451
+ username, commits_api_404 = _resolve_github_username(rev, email)
403
452
  if username is None:
404
453
  result.error(
405
- "commit author not found on GitHub — cannot verify signature",
454
+ _author_not_found_message(commits_api_404),
406
455
  check=Check.SIGNATURE,
407
456
  )
408
457
  return
@@ -413,12 +462,35 @@ def check_signature(rev, result):
413
462
  if _verify_ssh(rev, email, ssh_text):
414
463
  result.info("signature type: SSH", check=Check.SIGNATURE)
415
464
  return
416
- result.error("commit is not signed (GPG/SSH)", check=Check.SIGNATURE)
465
+ result.error(
466
+ "signature could not be verified — commit may be unsigned, "
467
+ "or signed with a key not uploaded as a Signing key on "
468
+ "https://github.com/settings/keys",
469
+ check=Check.SIGNATURE,
470
+ )
417
471
  except subprocess.TimeoutExpired:
418
472
  result.error(
419
473
  "git operation timed out — cannot verify signature",
420
474
  check=Check.SIGNATURE,
421
475
  )
476
+ except urllib.error.HTTPError as e:
477
+ if e.code == HTTPStatus.UNAUTHORIZED:
478
+ result.error(
479
+ "GitHub API rejected token (HTTP 401) — "
480
+ "GITHUB_TOKEN may be invalid or expired",
481
+ check=Check.SIGNATURE,
482
+ )
483
+ elif e.code == HTTPStatus.FORBIDDEN:
484
+ result.error(
485
+ "GitHub API forbidden (HTTP 403) — GITHUB_TOKEN may lack "
486
+ "'repo' scope, or you are unauthenticated and rate-limited",
487
+ check=Check.SIGNATURE,
488
+ )
489
+ else:
490
+ result.error(
491
+ f"GitHub API error (HTTP {e.code}) — cannot verify signature",
492
+ check=Check.SIGNATURE,
493
+ )
422
494
  except (urllib.error.URLError, TimeoutError):
423
495
  result.error(
424
496
  "GitHub API unreachable — cannot verify signature",
@@ -17,6 +17,7 @@ from git_commit_guard import (
17
17
  _ensure_nltk_data,
18
18
  _fetch_github_commit_author,
19
19
  _fetch_github_keys,
20
+ _fetch_github_signing_keys,
20
21
  _fetch_github_username,
21
22
  _fetch_url,
22
23
  _get_author_email,
@@ -124,6 +125,25 @@ class TestCheckSubject:
124
125
  check_subject("unknown: add thing", r)
125
126
  assert not r.ok
126
127
 
128
+ def test_unknown_type_with_default_lists_all_allowed(self):
129
+ r = Result()
130
+ check_subject("unknown: add thing", r)
131
+ assert any(
132
+ "allowed: " in m and "feat" in m and "fix" in m for _, _, m in r.errors
133
+ )
134
+
135
+ def test_unknown_type_with_smaller_set_lists_them(self):
136
+ r = Result()
137
+ check_subject("foo: add thing", r, allowed_types=frozenset({"feat", "fix"}))
138
+ assert any("allowed: feat, fix" in m for _, _, m in r.errors)
139
+
140
+ def test_unknown_type_with_larger_than_default_points_at_config(self):
141
+ r = Result()
142
+ oversized = frozenset({f"t{i}" for i in range(20)})
143
+ check_subject("foo: add thing", r, allowed_types=oversized)
144
+ assert any("see configured types" in m for _, _, m in r.errors)
145
+ assert not any("allowed:" in m for _, _, m in r.errors)
146
+
127
147
  def test_uppercase_description(self):
128
148
  r = Result()
129
149
  check_subject("fix: Add token", r)
@@ -184,6 +204,22 @@ class TestCheckSubject:
184
204
  check_subject("fix(api): add token", r, allowed_scopes=frozenset(["auth"]))
185
205
  assert not r.ok
186
206
 
207
+ def test_unknown_scope_with_small_set_lists_them(self):
208
+ r = Result()
209
+ check_subject(
210
+ "fix(api): add token",
211
+ r,
212
+ allowed_scopes=frozenset({"auth", "db"}),
213
+ )
214
+ assert any("allowed: auth, db" in m for _, _, m in r.errors)
215
+
216
+ def test_unknown_scope_with_larger_than_default_points_at_config(self):
217
+ r = Result()
218
+ oversized = frozenset({f"s{i}" for i in range(20)})
219
+ check_subject("fix(foo): add token", r, allowed_scopes=oversized)
220
+ assert any("see configured scopes" in m for _, _, m in r.errors)
221
+ assert not any("allowed:" in m for _, _, m in r.errors)
222
+
187
223
  def test_no_scope_with_allowlist_passes(self):
188
224
  r = Result()
189
225
  check_subject("fix: add token", r, allowed_scopes=frozenset(["auth"]))
@@ -325,6 +361,11 @@ class TestCheckSignedOff:
325
361
  check_signed_off("fix: add thing\n\nbody", r)
326
362
  assert not r.ok
327
363
 
364
+ def test_missing_message_hints_at_git_commit_dash_s(self):
365
+ r = Result()
366
+ check_signed_off("fix: add thing\n\nbody", r)
367
+ assert any("git commit -s" in m for _, _, m in r.errors)
368
+
328
369
  def test_malformed_no_email(self):
329
370
  r = Result()
330
371
  check_signed_off("fix: add thing\n\nSigned-off-by: John Doe", r)
@@ -695,10 +736,69 @@ class TestFetchGithubCommitAuthor:
695
736
  assert captured[0].get_header("Authorization") == "Bearer ghtoken"
696
737
 
697
738
 
739
+ class TestFetchGithubSigningKeys:
740
+ def _mock_response(self, data):
741
+ mock_resp = MagicMock()
742
+ mock_resp.__enter__ = lambda s: s
743
+ mock_resp.__exit__ = MagicMock(return_value=False)
744
+ mock_resp.read.return_value = json.dumps(data).encode()
745
+ return mock_resp
746
+
747
+ def test_returns_keys_joined_by_newline(self):
748
+ resp = self._mock_response(
749
+ [{"key": "ssh-ed25519 AAAA"}, {"key": "ssh-rsa BBBB"}]
750
+ )
751
+ with patch("git_commit_guard.urllib.request.urlopen", return_value=resp):
752
+ assert (
753
+ _fetch_github_signing_keys("testuser")
754
+ == "ssh-ed25519 AAAA\nssh-rsa BBBB"
755
+ )
756
+
757
+ def test_empty_list_returns_empty_string(self):
758
+ resp = self._mock_response([])
759
+ with patch("git_commit_guard.urllib.request.urlopen", return_value=resp):
760
+ assert _fetch_github_signing_keys("testuser") == ""
761
+
762
+ def test_github_token_sent_in_header(self):
763
+ resp = self._mock_response([])
764
+ captured = []
765
+
766
+ def mock_urlopen(req, **_):
767
+ captured.append(req)
768
+ return resp
769
+
770
+ with (
771
+ patch("git_commit_guard.urllib.request.urlopen", side_effect=mock_urlopen),
772
+ patch.dict("os.environ", {"GITHUB_TOKEN": "mytoken"}, clear=False),
773
+ ):
774
+ _fetch_github_signing_keys("testuser")
775
+ assert captured[0].get_header("Authorization") == "Bearer mytoken"
776
+
777
+ def test_gh_token_used_when_github_token_absent(self):
778
+ resp = self._mock_response([])
779
+ captured = []
780
+
781
+ def mock_urlopen(req, **_):
782
+ captured.append(req)
783
+ return resp
784
+
785
+ env = {k: v for k, v in os.environ.items() if k != "GITHUB_TOKEN"}
786
+ env["GH_TOKEN"] = "ghtoken" # noqa: S105 Possible hardcoded password assigned to: "GH_TOKEN"
787
+ with (
788
+ patch("git_commit_guard.urllib.request.urlopen", side_effect=mock_urlopen),
789
+ patch.dict("os.environ", env, clear=True),
790
+ ):
791
+ _fetch_github_signing_keys("testuser")
792
+ assert captured[0].get_header("Authorization") == "Bearer ghtoken"
793
+
794
+
698
795
  class TestFetchGithubKeys:
699
796
  def test_returns_gpg_and_ssh(self):
700
- with patch(
701
- "git_commit_guard._fetch_url", side_effect=["GPG KEY\n", "SSH KEY\n"]
797
+ with (
798
+ patch("git_commit_guard._fetch_url", return_value="GPG KEY\n"),
799
+ patch(
800
+ "git_commit_guard._fetch_github_signing_keys", return_value="SSH KEY\n"
801
+ ),
702
802
  ):
703
803
  gpg, ssh = _fetch_github_keys("testuser")
704
804
  assert gpg == "GPG KEY"
@@ -816,6 +916,157 @@ class TestCheckSignature:
816
916
  assert not r.ok
817
917
  assert any("not found on GitHub" in msg for _, _, msg in r.errors)
818
918
 
919
+ def test_commits_api_404_without_token_hints_at_token(self):
920
+ r = Result()
921
+ env = {
922
+ k: v for k, v in os.environ.items() if k not in ("GITHUB_TOKEN", "GH_TOKEN")
923
+ }
924
+ with (
925
+ patch(
926
+ "git_commit_guard._get_author_email", return_value="user@example.com"
927
+ ),
928
+ patch(
929
+ "git_commit_guard._get_github_remote_info",
930
+ return_value=("owner", "repo"),
931
+ ),
932
+ patch(
933
+ "git_commit_guard._fetch_github_commit_author",
934
+ side_effect=urllib.error.HTTPError(
935
+ url="", code=404, msg="Not Found", hdrs=None, fp=None
936
+ ),
937
+ ),
938
+ patch("git_commit_guard._fetch_github_username", return_value=None),
939
+ patch.dict("os.environ", env, clear=True),
940
+ ):
941
+ check_signature("abc123", r)
942
+ assert not r.ok
943
+ assert any("set GITHUB_TOKEN" in msg for _, _, msg in r.errors)
944
+
945
+ def test_commits_api_404_with_token_keeps_generic_message(self):
946
+ r = Result()
947
+ with (
948
+ patch(
949
+ "git_commit_guard._get_author_email", return_value="user@example.com"
950
+ ),
951
+ patch(
952
+ "git_commit_guard._get_github_remote_info",
953
+ return_value=("owner", "repo"),
954
+ ),
955
+ patch(
956
+ "git_commit_guard._fetch_github_commit_author",
957
+ side_effect=urllib.error.HTTPError(
958
+ url="", code=404, msg="Not Found", hdrs=None, fp=None
959
+ ),
960
+ ),
961
+ patch("git_commit_guard._fetch_github_username", return_value=None),
962
+ patch.dict("os.environ", {"GITHUB_TOKEN": "x"}, clear=False),
963
+ ):
964
+ check_signature("abc123", r)
965
+ assert not r.ok
966
+ assert not any("set GITHUB_TOKEN" in msg for _, _, msg in r.errors)
967
+ assert any("cannot verify signature" in msg for _, _, msg in r.errors)
968
+
969
+ def test_commits_api_non_404_http_error_falls_through(self):
970
+ r = Result()
971
+ with (
972
+ patch(
973
+ "git_commit_guard._get_author_email", return_value="user@example.com"
974
+ ),
975
+ patch(
976
+ "git_commit_guard._get_github_remote_info",
977
+ return_value=("owner", "repo"),
978
+ ),
979
+ patch(
980
+ "git_commit_guard._fetch_github_commit_author",
981
+ side_effect=urllib.error.HTTPError(
982
+ url="", code=500, msg="Server Error", hdrs=None, fp=None
983
+ ),
984
+ ),
985
+ patch("git_commit_guard._fetch_github_username", return_value="emailuser"),
986
+ patch("git_commit_guard._fetch_github_keys", return_value=("GPG KEY", "")),
987
+ patch("git_commit_guard._verify_gpg", return_value=True),
988
+ ):
989
+ check_signature("abc123", r)
990
+ assert r.ok
991
+
992
+ def test_commits_api_401_surfaces_token_message(self):
993
+ r = Result()
994
+ with (
995
+ patch(
996
+ "git_commit_guard._get_author_email", return_value="user@example.com"
997
+ ),
998
+ patch(
999
+ "git_commit_guard._get_github_remote_info",
1000
+ return_value=("owner", "repo"),
1001
+ ),
1002
+ patch(
1003
+ "git_commit_guard._fetch_github_commit_author",
1004
+ side_effect=urllib.error.HTTPError(
1005
+ url="", code=401, msg="Unauthorized", hdrs=None, fp=None
1006
+ ),
1007
+ ),
1008
+ ):
1009
+ check_signature("abc123", r)
1010
+ assert not r.ok
1011
+ assert any("rejected token (HTTP 401)" in msg for _, _, msg in r.errors)
1012
+
1013
+ def test_commits_api_403_surfaces_token_message(self):
1014
+ r = Result()
1015
+ with (
1016
+ patch(
1017
+ "git_commit_guard._get_author_email", return_value="user@example.com"
1018
+ ),
1019
+ patch(
1020
+ "git_commit_guard._get_github_remote_info",
1021
+ return_value=("owner", "repo"),
1022
+ ),
1023
+ patch(
1024
+ "git_commit_guard._fetch_github_commit_author",
1025
+ side_effect=urllib.error.HTTPError(
1026
+ url="", code=403, msg="Forbidden", hdrs=None, fp=None
1027
+ ),
1028
+ ),
1029
+ ):
1030
+ check_signature("abc123", r)
1031
+ assert not r.ok
1032
+ assert any("forbidden (HTTP 403)" in msg for _, _, msg in r.errors)
1033
+
1034
+ def test_search_api_403_surfaces_rate_limit_message(self):
1035
+ r = Result()
1036
+ with (
1037
+ patch(
1038
+ "git_commit_guard._get_author_email", return_value="corp@example.com"
1039
+ ),
1040
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
1041
+ patch(
1042
+ "git_commit_guard._fetch_github_username",
1043
+ side_effect=urllib.error.HTTPError(
1044
+ url="", code=403, msg="Forbidden", hdrs=None, fp=None
1045
+ ),
1046
+ ),
1047
+ ):
1048
+ check_signature("abc123", r)
1049
+ assert not r.ok
1050
+ assert any("forbidden (HTTP 403)" in msg for _, _, msg in r.errors)
1051
+
1052
+ def test_other_http_error_uses_generic_http_message(self):
1053
+ r = Result()
1054
+ with (
1055
+ patch(
1056
+ "git_commit_guard._get_author_email", return_value="corp@example.com"
1057
+ ),
1058
+ patch("git_commit_guard._get_github_remote_info", return_value=None),
1059
+ patch(
1060
+ "git_commit_guard._fetch_github_username",
1061
+ side_effect=urllib.error.HTTPError(
1062
+ url="", code=500, msg="Server Error", hdrs=None, fp=None
1063
+ ),
1064
+ ),
1065
+ ):
1066
+ check_signature("abc123", r)
1067
+ assert not r.ok
1068
+ assert any("GitHub API error (HTTP 500)" in msg for _, _, msg in r.errors)
1069
+
819
1070
  def test_url_error_fails(self):
820
1071
  r = Result()
821
1072
  with patch(
@@ -1,26 +0,0 @@
1
- ---
2
- name: Lint Markdown
3
- on: # yamllint disable-line rule:truthy
4
- pull_request:
5
- permissions:
6
- contents: read
7
- jobs:
8
- lint-md:
9
- runs-on: ubuntu-latest
10
- permissions:
11
- contents: read
12
- pull-requests: write
13
- steps:
14
- - name: Checkout code
15
- # yamllint disable-line rule:line-length
16
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
17
- with:
18
- persist-credentials: false
19
- - name: Lint Markdown files with reviewdog
20
- # yamllint disable-line rule:line-length
21
- uses: reviewdog/action-markdownlint@3667398db9118d7e78f7a63d10e26ce454ba5f58 # v0.26.2
22
- with:
23
- github_token: ${{ secrets.GITHUB_TOKEN }}
24
- reporter: github-pr-review
25
- level: info
26
- fail_level: any