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.
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.github/workflows/coverage-baseline.yml +1 -1
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.github/workflows/lint-commits.yml +1 -2
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.github/workflows/lint-md.yml +1 -1
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.github/workflows/lint-workflows.yml +8 -5
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.github/workflows/release.yml +1 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.github/workflows/test.yml +3 -1
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/PKG-INFO +43 -12
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/README.md +42 -11
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/docs/index.html +48 -5
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/src/git_commit_guard/__init__.py +167 -14
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/tests/test_git_commit_guard.py +431 -13
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.editorconfig +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.github/workflows/coverage-comment.yml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.github/workflows/lint-python.yml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.gitignore +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.markdownlint.json +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.pre-commit-hooks.yaml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/.python-version +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/LICENSE +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/action.yml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/cliff.toml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/docs/commit-guard-icon.svg +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/pyproject.toml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/ruff.toml +0 -0
- {git_commit_guard-0.19.0 → git_commit_guard-0.20.1}/tests/__init__.py +0 -0
- {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@
|
|
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@
|
|
25
|
+
uses: benner/commit-guard@cab0d35c41a3f99fc5f61895f491d0ce3f5aff1c # v0.20.0
|
|
26
26
|
with:
|
|
27
27
|
range: origin/${{ github.base_ref }}..HEAD
|
|
28
|
-
disable: signature
|
|
@@ -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@
|
|
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@
|
|
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-
|
|
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@
|
|
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-
|
|
73
|
+
-reporter=github-pr-review -fail-level=any
|
|
@@ -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 $
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
["git", "
|
|
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
|
-
|
|
273
|
+
stderr=subprocess.PIPE,
|
|
265
274
|
timeout=_git_timeout(),
|
|
266
|
-
)
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
544
|
-
def
|
|
545
|
-
|
|
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
|
-
|
|
549
|
-
assert not r.ok
|
|
715
|
+
assert _verify_gpg("abc123", "gpg key data") is False
|
|
550
716
|
|
|
551
|
-
def
|
|
552
|
-
|
|
553
|
-
|
|
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
|
|
775
|
+
def test_ssh_verified_via_github(self):
|
|
560
776
|
r = Result()
|
|
561
|
-
|
|
562
|
-
|
|
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(
|
|
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
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|