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.
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.github/workflows/lint-commits.yml +1 -1
- git_commit_guard-0.22.0/.github/workflows/lint-md.yml +68 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.github/workflows/release.yml +0 -3
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/PKG-INFO +38 -18
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/README.md +37 -17
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/action.yml +10 -2
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/docs/index.html +47 -19
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/src/git_commit_guard/__init__.py +88 -16
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/tests/test_git_commit_guard.py +253 -2
- git_commit_guard-0.20.1/.github/workflows/lint-md.yml +0 -26
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.editorconfig +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.github/workflows/coverage-baseline.yml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.github/workflows/coverage-comment.yml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.github/workflows/lint-python.yml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.github/workflows/lint-workflows.yml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.github/workflows/test.yml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.gitignore +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.markdownlint.json +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.pre-commit-hooks.yaml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/.python-version +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/LICENSE +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/cliff.toml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/docs/commit-guard-icon.svg +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/pyproject.toml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/ruff.toml +0 -0
- {git_commit_guard-0.20.1 → git_commit_guard-0.22.0}/tests/__init__.py +0 -0
- {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@
|
|
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.
|
|
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
|
+
[](https://pypi.org/project/git-commit-guard/)
|
|
26
|
+
[](https://pypi.org/project/git-commit-guard/)
|
|
27
|
+
[](https://github.com/benner/commit-guard/actions/workflows/test.yml)
|
|
28
|
+
[](https://pre-commit.com/)
|
|
29
|
+
<!-- markdownlint-restore -->
|
|
30
|
+
|
|
24
31
|
Opinionated conventional commit message linter with imperative mood detection.
|
|
25
32
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
imperative
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
239
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
+
[](https://pypi.org/project/git-commit-guard/)
|
|
5
|
+
[](https://pypi.org/project/git-commit-guard/)
|
|
6
|
+
[](https://github.com/benner/commit-guard/actions/workflows/test.yml)
|
|
7
|
+
[](https://pre-commit.com/)
|
|
8
|
+
<!-- markdownlint-restore -->
|
|
9
|
+
|
|
3
10
|
Opinionated conventional commit message linter with imperative mood detection.
|
|
4
11
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
imperative
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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`
|
|
218
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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:
|
|
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
|
|
295
|
-
<span class="c-dim">
|
|
296
|
-
<span class="c-
|
|
297
|
-
<span class="c-dim">
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
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
|
+
“Verified” 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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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"], ["
|
|
600
|
-
["✗", "c-red"], [" [
|
|
601
|
-
["✗", "c-red"], [" [
|
|
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"], ["
|
|
608
|
-
["✗", "c-red"], [" [
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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(
|
|
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
|
|
701
|
-
"git_commit_guard._fetch_url",
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|