diff --git a/docs/changelog.md b/docs/changelog.md index 0e46c23..530cf00 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -11,6 +11,7 @@ below and to the page that documents the feature properly. | Version | What changed | Documented in | |---|---|---| +| [2.16.0](#v2160) | Tag name validation, and file size, path and pattern policies | [CC401](rules.md#cc401) · [CC302–CC304](rules.md#push-rules) | | [2.15.1](#v2151) | Color and rule-ID links appear only where they render; `NO_COLOR` honoured | [Color and links](example.md#color-and-links) | | [2.15.0](#v2150) | `--rev` names the commit under test; skipped checks are named on stderr | [Command-line recipes](example.md#checking-a-range-of-commits) | | [2.14.0](#v2140) | CC003 judges imperative mood by a word's form, not by a list of verbs | [CC003](rules.md#cc003) | @@ -26,6 +27,36 @@ below and to the page that documents the feature properly. | [2.5.0](#v250) | Organization-wide config with `inherit_from` | [Integrations](guides/integrations.md#across-an-organization) | | [2.0.0](#v200) | Configuration moved from YAML to TOML — breaking | [Migrating from v1](migration.md) | +## v2.16.0 (2026-08-31) { #v2160 } + +### Added + +* **Tag names can be checked** — `--tag` (`-t`) validates the names of the tags + pointing at the commit under test against a pattern, defaulting to SemVer + with an optional leading `v`. GitHub can require a tag name to match a + regular expression only through its Enterprise-plan + [metadata restrictions](https://docs.github.com/en/enterprise-cloud@latest/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets#metadata-restrictions) + — tag rulesets themselves are not gated that high; this brings the same + policy to every plan and forge. A commit with no tag is a skip, not a + failure: the rule governs how tags are named, and the absence of one is + not a naming violation. A `check-tag` pre-push hook validates exactly + what a push carries. See [CC401](rules.md#cc401). + +* **The files a commit touches can be policed** — `--files` (`-f`) checks file + size ([CC302](rules.md#cc302)), prohibited path patterns + ([CC303](rules.md#cc303)) and path length ([CC304](rules.md#cc304)). GitHub + sells the same three as push rules, on Team and Enterprise plans for private + repositories. + + All three read only the paths and sizes recorded in a commit — never file + contents, which keeps content scanning where it belongs, with tools like + gitleaks. All three are off until their `[files]` setting carries a usable + value, and a value that cannot be used says so on stderr rather than + disabling its rule in silence. + + A `check-files` pre-push hook checks every commit a push adds, not just the + tip; the Action and the App check every commit of a push or pull request. + ## v2.15.1 (2026-08-16) { #v2151 } ### Fixed diff --git a/docs/configuration.md b/docs/configuration.md index 531fd7a..28a968f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -167,7 +167,7 @@ Used from a hook definition, with no config file anywhere in the repository: ```yaml title=".pre-commit-config.yaml" repos: - repo: https://github.com/commit-check/commit-check - rev: v2.15.1 + rev: v2.16.0 hooks: - id: check-message args: @@ -216,6 +216,10 @@ The full mapping between the three forms: | `allow_force_push = true` | `CCHK_ALLOW_FORCE_PUSH=true` | `--no-force-push` (sets `allow_force_push` to `false`) | | `ai_attribution = "forbid"` | `CCHK_AI_ATTRIBUTION=forbid` | `--ai-attribution=forbid` | | `ignore_authors = ["bot"]` (in branch section) | `CCHK_BRANCH_IGNORE_AUTHORS=bot,user` | `--branch-ignore-authors=bot,user` | +| `regex = "^v\\d+\\.\\d+\\.\\d+$"` (in tag section) | `CCHK_TAG_REGEX=^v\\d+\\.\\d+\\.\\d+$` | `--tag-regex=^v\\d+\\.\\d+\\.\\d+$` | +| `max_size = "5MB"` (in files section) | `CCHK_FILES_MAX_SIZE=5MB` | `--files-max-size=5MB` | +| `prohibited_patterns = ["*.pem"]` (in files section) | `CCHK_FILES_PROHIBITED_PATTERNS=*.pem,.env` | `--files-prohibited-patterns=*.pem,.env` | +| `max_path_length = 250` (in files section) | `CCHK_FILES_MAX_PATH_LENGTH=250` | `--files-max-path-length=250` | ## Which value wins @@ -274,3 +278,7 @@ the same thing twice, so read the description rather than the cell: | branch | require_rebase_target | str | "" (no requirement) | Target branch for rebase requirement. If not set, no rebase validation is performed. | | push | allow_force_push | bool | true | Allow force pushes. Set to `false` to block force pushes when used as a pre-push hook or with `--no-force-push`. | | branch | ignore_authors | list[str] | [] (none ignored) | List of authors to ignore (i.e., always allow). | +| tag | regex | str | `^v?(?:0\|[1-9]\d*)\.(?:0\|[1-9]\d*)\.(?:0\|[1-9]\d*)(?:-(?:0\|[1-9]\d*\|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0\|[1-9]\d*\|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(?:\+[0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*)?$` | Pattern a tag name must match. The default is the [official SemVer pattern](https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string) with an optional leading `v`, so both `v1.2.3` and `1.2.3` pass. An empty value disables the pattern match. | +| files | max_size | str | "" (disabled) | Largest a committed file may be, in bytes or with a `KB`/`MB`/`GB` suffix (binary units, so `5MB` is 5 × 1024²). Empty disables the rule. | +| files | prohibited_patterns | list[str] | [] (empty list) | fnmatch patterns a committed path may not match, e.g. `["*.pem", ".env", "id_rsa*"]`. A bare pattern also matches the file name at any depth. Matching is case-sensitive on every platform, like git pathspecs. Empty disables the rule. | +| files | max_path_length | int | 0 (disabled) | Longest a committed file path may be, in characters. `0` disables the rule. | diff --git a/docs/example.md b/docs/example.md index 73390ff..b042480 100644 --- a/docs/example.md +++ b/docs/example.md @@ -131,7 +131,7 @@ pushed: ```yaml title=".pre-commit-config.yaml" repos: - repo: https://github.com/commit-check/commit-check - rev: v2.15.1 + rev: v2.16.0 hooks: - id: check-no-force-push stages: [pre-push] diff --git a/docs/guides/integrations.md b/docs/guides/integrations.md index a5816f0..e042df8 100644 --- a/docs/guides/integrations.md +++ b/docs/guides/integrations.md @@ -23,7 +23,7 @@ Add Commit Check to `.pre-commit-config.yaml`: ```yaml title=".pre-commit-config.yaml" repos: - repo: https://github.com/commit-check/commit-check - rev: v2.15.1 + rev: v2.16.0 hooks: - id: check-message - id: check-branch @@ -70,7 +70,7 @@ Options can be passed as hook arguments, which keeps everything in one file: ```yaml title=".pre-commit-config.yaml" repos: - repo: https://github.com/commit-check/commit-check - rev: v2.15.1 + rev: v2.16.0 hooks: - id: check-message args: diff --git a/docs/index.md b/docs/index.md index d583bd0..c6561f4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -36,7 +36,7 @@ whatever your AI agent is committing on your behalf. ```yaml title=".pre-commit-config.yaml" repos: - repo: https://github.com/commit-check/commit-check - rev: v2.15.1 + rev: v2.16.0 hooks: - id: check-message - id: check-branch diff --git a/docs/rules.md b/docs/rules.md index 6a82ba8..fb5aebe 100644 --- a/docs/rules.md +++ b/docs/rules.md @@ -29,7 +29,8 @@ Rule IDs are grouped by what they inspect: | `CC0xx` | [Commit message](#commit-message-rules) | The subject, body, and trailers of a commit message | | `CC1xx` | [Author](#author-rules) | The committer's configured name and email | | `CC2xx` | [Branch](#branch-rules) | The current branch's name and its position relative to a target branch | -| `CC3xx` | [Push](#push-rules) | The push operation itself | +| `CC3xx` | [Push and files](#push-rules) | The push operation, and the files a commit touches | +| `CC4xx` | [Tag](#tag-rules) | The names of the tags pointing at the commit | Two things determine whether a rule runs: @@ -106,13 +107,32 @@ Run with `-b` / `--branch`. -### Push rules (`CC3xx`) { #push-rules } +### Push and file rules (`CC3xx`) { #push-rules } + +The file rules run with `-f` / `--files`. They read only the paths and sizes +recorded in a commit — never file contents, which is a +[different tool's job](#cc303).
| Code | Name | Message | Check | Default | |---|---|---|---|---| | [CC301](#cc301) | `no-force-push` | Force push is not allowed | `--no-force-push` | ⚪ Off | +| [CC302](#cc302) | `file-size` | File exceeds the maximum size of `{max_size}` | `-f` | ⚪ Off | +| [CC303](#cc303) | `file-pattern` | File path matches a prohibited pattern | `-f` | ⚪ Off | +| [CC304](#cc304) | `path-length` | File path exceeds `{max_len}` characters | `-f` | ⚪ Off | + +
+ +### Tag rules (`CC4xx`) { #tag-rules } + +Run with `-t` / `--tag`. + +
+ +| Code | Name | Message | Check | Default | +|---|---|---|---|---| +| [CC401](#cc401) | `tag` | The tag name does not match the required pattern | `-t` | ✅ On |
@@ -683,7 +703,7 @@ git push --force-with-lease * `branch.require_rebase_target` — the target branch, for example `"main"`. Unset by default, meaning no rebase requirement. -## Push rules +## Push and file rules ### no-force-push (CC301) { #cc301 } @@ -720,3 +740,133 @@ git push --force-with-lease **Options** * `push.allow_force_push` — set to `false` to enable this rule. + +### file-size (CC302) { #cc302 } + +**What it does** + +Rejects a commit that adds or updates a file larger than `files.max_size`. The +size read is the one recorded in the commit, not whatever the working tree +holds now, so the verdict is the same wherever the check runs. + +**Why is this bad?** + +Git stores every version of a file forever. A large binary committed once stays +in the pack for the life of the repository, and every clone downloads it — +including the clones of people who will never open it. Removing the file later +does not shrink history; only a rewrite does, and by then everyone has already +pulled it. Large assets belong in Git LFS or an artifact store. + +**Example** + +```toml title="cchk.toml" +[files] +max_size = "5MB" +``` + +**Options** + +* `files.max_size` — largest a committed file may be, in bytes or with a + `KB`/`MB`/`GB` suffix. Empty (the default) disables this rule. + +### file-pattern (CC303) { #cc303 } + +**What it does** + +Rejects a commit that touches a path matching any `files.prohibited_patterns` +entry. Patterns are fnmatch globs, matched against the full path *and* against +the file name alone, so a bare `*.pem` catches the file at any depth. Matching +is case-sensitive on every platform, like git pathspecs. + +**Why is this bad?** + +Some files are never meant to be in a repository: private keys, `.env` files, +credential dumps, build output. A path rule is the cheapest possible guard — +it costs one glob comparison and needs no knowledge of what is inside the file. +This organization once shipped a GitHub App private key as a committed `.pem`; +one line of configuration would have stopped that commit. + +A path rule is not secret scanning. It cannot see a token pasted into a `.txt` +file, and it happily allows an empty `secrets.pem`. Pair it with a content +scanner such as [gitleaks](https://github.com/gitleaks/gitleaks) — each catches +what the other cannot. + +**Example** + +```toml title="cchk.toml" +[files] +prohibited_patterns = ["*.pem", "*.key", ".env", "id_rsa*"] +``` + +**Options** + +* `files.prohibited_patterns` — fnmatch patterns a committed path may not + match. Empty (the default) disables this rule. + +### path-length (CC304) { #cc304 } + +**What it does** + +Rejects a commit whose file paths exceed `files.max_path_length` characters. + +**Why is this bad?** + +Path length limits are a portability problem, not a style preference. Windows +caps a path at 260 characters unless long paths are explicitly enabled, so a +deeply nested file added on Linux can make the repository impossible to check +out on a teammate's machine — and the failure surfaces at clone time, far from +the commit that caused it. + +**Example** + +```toml title="cchk.toml" +[files] +max_path_length = 250 +``` + +**Options** + +* `files.max_path_length` — longest a committed file path may be, in + characters. `0` (the default) disables this rule. + +## Tag rules + +### tag (CC401) { #cc401 } + +**What it does** + +Checks the names of the tags pointing at the commit under test against +`tag.regex`. The default is the +[official SemVer pattern](https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string) +with an optional leading `v`, so `v1.2.3` and `1.2.3` both pass while `v1.2` +and `release-1.2.3` do not. + +A commit with no tag is a **skip**, not a failure: the rule governs how tags +are named, and the absence of one is not a naming violation. + +**Why is this bad?** + +Tags are the public interface of a release. Package managers, changelog +generators, and `pre-commit`'s own `rev:` all resolve a version string to a +tag, and every one of them assumes a shape. A single `release-2.0` among a +history of `v1.x.y` tags breaks version sorting silently — the tag still +exists, it just stops being found. + +**Example** + +```bash +git tag v1.2 # not SemVer: no patch version +git tag release-1.2.3 # not SemVer: prefixed +``` + +Use instead: + +```bash +git tag v1.2.3 +git tag v1.2.3-rc.1 +``` + +**Options** + +* `tag.regex` — the pattern a tag name must match. An empty value disables the + pattern match. diff --git a/tests/docs_sync_test.py b/tests/docs_sync_test.py index de9abf0..33edb9f 100644 --- a/tests/docs_sync_test.py +++ b/tests/docs_sync_test.py @@ -168,10 +168,13 @@ def test_pinned_revisions_match_the_released_version(self): _OPTIONS_ROW = re.compile( - r"^\|\s*(commit|branch|push)\s*" # section + r"^\|\s*(commit|branch|push|files|tag)\s*" # section r"\|\s*(\w+)\s*" # option name r"\|\s*(bool|int|str|list\[str\])\s*" # type - r"\|\s*(.+?)\s*\|", # documented default + # A default may itself contain a pipe (a regex alternation, say), which a + # table cell can only carry escaped -- so the cell runs to the first + # *unescaped* pipe. + r"\|\s*((?:[^|\\]|\\.)+?)\s*\|", # documented default re.M, ) @@ -193,7 +196,9 @@ def _documented_default(type_: str, cell: str) -> Any: Cells carry a human annotation after the value itself (``"" (disabled)``), so the value is read from the front of the cell and the rest ignored. """ - cell = cell.strip() + # Undo the table escaping so the value compares against the runtime as + # the runtime holds it. + cell = cell.strip().replace("\\|", "|") if type_ == "bool": return cell.startswith("true") if type_ == "int":