diff --git a/.github/workflows/commitlint.yml b/.github/workflows/commitlint.yml index ecdf583..cb7c2f5 100644 --- a/.github/workflows/commitlint.yml +++ b/.github/workflows/commitlint.yml @@ -27,6 +27,14 @@ on: description: "Newline-delimited regex patterns for commit subjects to skip" type: string default: "" + enforce: + description: "Enforcement mode: block or warn" + type: string + default: "block" + ai-attribution: + description: "AI attribution policy: allow, warn, strip, or block (strip acts as block in CI)" + type: string + default: "allow" permissions: contents: read @@ -79,9 +87,31 @@ jobs: with: node-version: ${{ inputs.node-version }} - - name: Install commitlint + - name: Resolve config preset + id: preset env: CONFIG_PRESET: ${{ inputs.config }} + run: | + # .commit-guard.yml wins over the workflow input when present + cfg="" + for f in .commit-guard.yml .commit-guard.yaml; do + if [ -f "$f" ]; then cfg="$f"; break; fi + done + if [ -n "$cfg" ]; then + if ! yq e '.' "$cfg" >/dev/null; then + echo "error: ${cfg} is not valid YAML." >&2 + exit 1 + fi + file_preset="$(yq e '.config // ""' "$cfg")" + if [ -n "$file_preset" ]; then + CONFIG_PRESET="$file_preset" + fi + fi + echo "preset=$CONFIG_PRESET" >> "$GITHUB_OUTPUT" + + - name: Install commitlint + env: + CONFIG_PRESET: ${{ steps.preset.outputs.preset }} run: | # install in isolated temp dir to avoid peer dep conflicts with repo mkdir -p /tmp/commitlint-bin @@ -97,15 +127,36 @@ jobs: - name: Create commitlint config env: - CONFIG_PRESET: ${{ inputs.config }} + CONFIG_PRESET: ${{ steps.preset.outputs.preset }} run: | + cfg="" + for f in .commit-guard.yml .commit-guard.yaml; do + if [ -f "$f" ]; then cfg="$f"; break; fi + done + # use repo config if it exists, otherwise create a temp one - if [ ! -f commitlint.config.js ] && [ ! -f commitlint.config.mjs ] && [ ! -f commitlint.config.cjs ] && [ ! -f .commitlintrc.yml ] && [ ! -f .commitlintrc.json ]; then - if [ "$CONFIG_PRESET" = "angular" ]; then - echo 'export default { extends: ["@commitlint/config-angular"] };' > commitlint.config.mjs - else - echo 'export default { extends: ["@commitlint/config-conventional"] };' > commitlint.config.mjs + if [ -f commitlint.config.js ] || [ -f commitlint.config.mjs ] || [ -f commitlint.config.cjs ] || [ -f .commitlintrc.yml ] || [ -f .commitlintrc.json ]; then + if [ -n "$cfg" ] && [ "$(yq e '.types // [] | length' "$cfg")" != "0" ]; then + echo "repo commitlint config found; ignoring types from ${cfg}" fi + exit 0 + fi + + if [ "$CONFIG_PRESET" = "angular" ]; then + extends_pkg="@commitlint/config-angular" + else + extends_pkg="@commitlint/config-conventional" + fi + + types_json="[]" + if [ -n "$cfg" ]; then + types_json="$(yq e -o=json -I=0 '.types // []' "$cfg")" + fi + + if [ "$types_json" != "[]" ]; then + echo "export default { extends: [\"${extends_pkg}\"], rules: { \"type-enum\": [2, \"always\", ${types_json}] } };" > commitlint.config.mjs + else + echo "export default { extends: [\"${extends_pkg}\"] };" > commitlint.config.mjs fi - name: Determine commit range @@ -145,6 +196,9 @@ jobs: CG_IGNORE_BOT_COMMITS: ${{ inputs.ignore-bot-commits }} CG_IGNORE_MERGE_COMMITS: ${{ inputs.ignore-merge-commits }} CG_IGNORE_MESSAGE_PATTERNS: ${{ inputs.ignore-message-patterns }} + CG_ENFORCE: ${{ inputs.enforce }} + CG_AI_ATTRIBUTION: ${{ inputs.ai-attribution }} + CG_REF_NAME: ${{ github.ref_name }} CG_COMMITLINT_CMD: commitlint NODE_PATH: /tmp/commitlint-bin/node_modules run: .commit-guard/scripts/run-commitlint-ci.sh diff --git a/CHANGELOG.md b/CHANGELOG.md index 3cb4e4a..740f62e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## [0.3.0] + +- Add `.commit-guard.yml` per-repo config file, read by both CI and local hooks. File values override workflow inputs. Parsed with plain bash locally (flat schema), validated with `yq` in CI. +- Add `ai-attribution` policy (`allow`, `warn`, `strip`, `block`) to catch AI co-author trailers and "generated with" bylines. `strip` rewrites the message locally and acts as `block` in CI. +- Add custom `types` list, enforced by the native hook and via a generated commitlint `type-enum` rule in CI. +- Add `ban-patterns` β€” case-insensitive regexes that fail the lint when matched anywhere in a commit message. +- Add `enforce: warn` mode β€” CI annotates failures but passes; the local hook prints the error and allows the commit. +- Add `branches` filter β€” push events on unlisted branches skip linting. +- Installers gain `--ai-attribution` and `--enforce` flags and write a commented starter `.commit-guard.yml`. + ## [0.2.2] - Fix reusable-workflow self-checkout to use `github.job_workflow_sha`. The previous `github.workflow_sha` resolves to the caller's commit in a reusable-workflow context, which caused every run to fail with `remote error: upload-pack: not our ref` when trying to fetch the caller's commit from commit-guard's repo. diff --git a/README.md b/README.md index a18db8a..9ae08a2 100644 --- a/README.md +++ b/README.md @@ -24,9 +24,63 @@ Common options: ./install.sh --pr-mode title ./install.sh --hook-mode native ./install.sh --hook-mode husky --pm pnpm +./install.sh --ai-attribution strip +./install.sh --enforce warn ./install.sh --ci-only ``` +## Configuration file + +Both CI and local hooks read an optional `.commit-guard.yml` (or +`.commit-guard.yaml`) at the repo root. File values override workflow inputs; +workflow inputs apply when the file or key is absent. All keys are optional. + +```yaml +config: conventional +pr-mode: smart +enforce: block +ai-attribution: block +types: + - feat + - fix + - chore +ban-patterns: + - password +branches: + - main + - master +ignore-bot-commits: true +ignore-merge-commits: true +ignore-message-patterns: + - ^Initial plan$ +``` + +- `enforce`: `block` (default) fails on violations; `warn` annotates and passes. + Useful when adopting commit-guard on an existing repo. +- `ai-attribution`: policy for AI co-author trailers and "generated with" + bylines (`Co-Authored-By: Claude ...`, `πŸ€– Generated with Claude Code`). + - `allow` (default): no check + - `warn`: report but pass + - `strip`: the local hook removes matching lines before the commit lands; in + CI this acts as `block` since pushed commits can't be rewritten + - `block`: fail the lint +- `types`: custom allowed commit types. In CI this generates a commitlint + `type-enum` rule; if the repo has its own commitlint config, that config wins + and `types` is ignored. +- `ban-patterns`: case-insensitive regexes that fail the lint when matched + anywhere in the commit message (or PR title). +- `branches`: on push events, only these branches are linted. Empty or absent + means all branches. + +The hooks parse the file with plain bash β€” no jq, yq, or Node required. Keep +the schema flat: top-level keys and block-style lists (`- item` per line), as +shown above. Comments and quoted values are fine. CI validates the file with +`yq` and fails loudly on invalid YAML. + +The installer writes a commented starter `.commit-guard.yml` with +`ai-attribution: block` for new installs; existing configs are never +overwritten. + ## What changed in v0.2.0 - PR linting is now configurable with `smart`, `commits`, and `title` modes. @@ -116,6 +170,8 @@ jobs: with: config: "conventional" pr-mode: "smart" + # enforce: "warn" + # ai-attribution: "block" # ignore-message-patterns: | # ^Initial plan$ ``` diff --git a/caller-template.yml b/caller-template.yml index ab36abe..d717a19 100644 --- a/caller-template.yml +++ b/caller-template.yml @@ -18,10 +18,15 @@ jobs: commitlint: uses: codywilliamson/commit-guard/.github/workflows/commitlint.yml@v0.2.2 with: + ## values in .commit-guard.yml at the repo root override these inputs ## config preset: conventional, angular config: "conventional" ## PR lint strategy: smart, commits, title pr-mode: "smart" + ## enforcement: block (default), warn + # enforce: "block" + ## AI attribution policy: allow (default), warn, strip, block + # ai-attribution: "block" ## optional: skip known noisy subjects # ignore-message-patterns: | # ^Initial plan$ diff --git a/docs/superpowers/specs/2026-07-19-config-features-design.md b/docs/superpowers/specs/2026-07-19-config-features-design.md new file mode 100644 index 0000000..def9741 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-config-features-design.md @@ -0,0 +1,134 @@ +# commit-guard configuration features β€” design + +Date: 2026-07-19 +Status: approved (brainstormed interactively, implementation authorized autonomously) + +> **Amendment (same day):** the config format was changed from JSON to flat +> YAML (`.commit-guard.yml`) after review. Rationale: flat YAML is trivially +> and robustly parseable in pure bash (no jq dependency, no formatting +> constraints on the fallback parser), and supports comments in the starter +> config. CI validates syntax with `yq` (preinstalled on runners) and uses it +> to inject `types` into the generated commitlint config; the lint scripts use +> the same bash parser as the hook. Malformed YAML is caught loudly in CI; +> locally the parser is lenient (mis-indented keys fall back to defaults) but +> enum validation still rejects bad values. JSON references below are +> historical. + +## Goal + +Add a per-repo config file that both CI and local hooks read, plus four new +capabilities: AI-attribution policy, custom allowed types, custom ban patterns, +warn-only enforcement, and branch filters. + +## Config file: `.commit-guard.json` + +Single source of truth at the repo root. All keys optional. File values win +over workflow inputs; workflow inputs remain as fallbacks when the file or key +is absent. Built-in defaults preserve v0.2 behavior exactly. + +```json +{ + "config": "conventional", + "pr-mode": "smart", + "enforce": "block", + "ai-attribution": "block", + "types": ["feat", "fix", "chore", "ci", "docs", "test", "refactor", "perf", "build", "style"], + "ban-patterns": ["password", "^temp"], + "branches": ["main", "master"], + "ignore-bot-commits": true, + "ignore-merge-commits": true, + "ignore-message-patterns": ["^Initial plan$"] +} +``` + +Keys are kebab-case to mirror workflow inputs. Format is JSON per user choice. + +### Parsing strategy + +- **CI**: `jq` (preinstalled on GitHub ubuntu runners). +- **Native hook**: `jq` when available, else an inlined sed/awk fallback that + handles the flat schema. Fallback constraint (documented): arrays must be + pretty-printed one element per line, and elements must not contain `"`. + With jq installed there are no constraints. + +## Features + +### 1. AI attribution policy β€” `ai-attribution: allow | warn | strip | block` + +Built-in case-insensitive patterns matched against the full commit message: +co-authored-by trailers naming AI tools (claude, copilot, chatgpt, openai, +anthropic, gemini, cursor, devin, aider, codex, `[bot]`), "generated with/by" +AI bylines, and `noreply@anthropic.com`. + +- `allow` (default): no check β€” non-breaking for v0.2 upgrades. +- `warn`: print a warning, pass. +- `strip`: local hook rewrites the commit message file, removing matching + lines, then passes. **In CI, `strip` behaves as `block`** β€” CI cannot + rewrite pushed commits, so it acts as the backstop for commits made without + hooks installed. +- `block`: fail the lint. + +The installer writes `"ai-attribution": "block"` into the starter config so +new installs are protected by default while upgrades keep old behavior. + +### 2. Custom allowed types β€” `types` + +- Native hook: builds its validation regex from the list. +- CI: generates a commitlint config extending the preset with a `type-enum` + rule override. +- If the repo has its own commitlint config, that config wins and `types` is + ignored with a printed notice (avoids two sources of truth in the + commitlint ecosystem). + +### 3. Custom ban patterns β€” `ban-patterns` + +Case-insensitive ERE patterns; if any matches anywhere in the commit message +(or PR title in title lint path), the lint fails. Enforced by the bash layer +in both the hook and CI (commitlint cannot do arbitrary body-regex bans +without a plugin β€” rejected approach B, publishing a plugin, as YAGNI). + +### 4. Warn-only enforcement β€” `enforce: block | warn` + +CI collects all failures instead of exiting on the first, then: +- `block` (default): exit 1 if anything failed. +- `warn`: emit `::warning` annotations and exit 0. For adopting commit-guard + on messy repos. + +Local hook honors it too: `warn` prints the error but allows the commit. + +### 5. Branch filters β€” `branches` + +On push events, if the pushed branch is not in the list, CI skips linting +entirely with a notice. Empty/absent list = all branches (current behavior). +Requires passing `github.ref_name` into the lint script. + +## Components changed + +| File | Change | +|---|---| +| `scripts/validate-commit-message.sh` | read config (jq or inline fallback), custom types regex, ban patterns, ai-attribution incl. strip, enforce warn | +| `scripts/run-commitlint-ci.sh` | jq config read with env fallback, ban patterns, ai-attribution (stripβ†’block), warn mode failure collection, branch filter | +| `.github/workflows/commitlint.yml` | new inputs `enforce`, `ai-attribution`; pass `ref_name`; generate type-enum config from file | +| `install.sh` / `install.ps1` | `--ai-attribution`, `--enforce` flags; write starter `.commit-guard.json` when absent | +| `caller-template.yml` | comment pointing at `.commit-guard.json` | +| `test/*` | native hook: types/ban/ai policies incl. strip; ci: file precedence, ban, warn exit 0, branch skip | +| `README.md`, `CHANGELOG.md` | document schema, precedence, upgrade notes | + +The hook stays a single self-contained downloadable file, so the config +parser is inlined there and duplicated (compact) in the CI script rather than +shared via a lib β€” deployment simplicity beats DRY for shipped artifacts. +Each copy carries a sync note. + +## Error handling + +- Malformed JSON: jq parse failure β†’ fail loudly with a clear message (never + silently skip enforcement). +- Unknown enum values (`ai-attribution: "nope"`): fail with the allowed set. +- Missing file: use env/defaults, no error. + +## Testing + +Extend the existing bash test harness (`test/test.sh`): each feature gets +accept + reject cases; strip mode verifies the message file was rewritten; +warn mode verifies exit 0 with failing content; precedence test verifies file +beats env. diff --git a/install.ps1 b/install.ps1 index 8f26aa1..adbfa2d 100644 --- a/install.ps1 +++ b/install.ps1 @@ -18,7 +18,9 @@ param( [switch]$CIOnly, [string]$HookMode = "auto", [string]$PackageManager = "", - [string]$PRMode = "smart" + [string]$PRMode = "smart", + [string]$AIAttribution = "block", + [string]$Enforce = "block" ) $ErrorActionPreference = "Stop" @@ -36,6 +38,51 @@ function Ensure-ValidPRMode { } } +function Ensure-ValidPolicies { + if ($AIAttribution -notin @("allow", "warn", "strip", "block")) { + throw "Invalid ai-attribution '$AIAttribution'. Expected allow, warn, strip, or block." + } + if ($Enforce -notin @("block", "warn")) { + throw "Invalid enforce '$Enforce'. Expected block or warn." + } +} + +function Write-ConfigFile { + if ((Test-Path ".commit-guard.yml") -or (Test-Path ".commit-guard.yaml")) { + Write-Host " .commit-guard.yml already exists, skipping" + return + } + + @" +# commit-guard config β€” values here override the workflow inputs +# docs: https://github.com/codywilliamson/commit-guard + +# commitlint preset: conventional, angular +config: $Config +# PR lint strategy: smart, commits, title +pr-mode: $PRMode +# block fails on violations, warn only reports them +enforce: $Enforce +# AI co-author trailers and bylines: allow, warn, strip, block +ai-attribution: $AIAttribution + +# custom allowed commit types (defaults to the conventional set) +# types: +# - feat +# - fix +# - chore + +# case-insensitive regexes that fail the lint anywhere in the message +# ban-patterns: +# - password + +# only lint pushes to these branches (default: all) +# branches: +# - main +"@ | Set-Content -Path ".commit-guard.yml" + Write-Host " Created: .commit-guard.yml" -ForegroundColor Green +} + function Resolve-HookMode { switch ($HookMode) { "auto" { @@ -139,11 +186,13 @@ if (-not $PackageManager) { } Ensure-ValidPRMode +Ensure-ValidPolicies $ResolvedHookMode = Resolve-HookMode Write-Host "Installing CI workflow..." New-Item -ItemType Directory -Path $WorkflowDir -Force | Out-Null Invoke-WebRequest -Uri $TemplateUrl -OutFile $WorkflowFile -UseBasicParsing +Write-ConfigFile $content = Get-Content $WorkflowFile -Raw if ($Config -ne "conventional") { @@ -174,6 +223,7 @@ switch ($ResolvedHookMode) { Write-Host "" Write-Host "Done! Installed:" -ForegroundColor Green Write-Host " - CI workflow: $WorkflowFile" +Write-Host " - Config: .commit-guard.yml" if ($ResolvedHookMode -eq "native") { $hookPath = git config --get core.hooksPath Write-Host " - Local hook: $hookPath/commit-msg" diff --git a/install.sh b/install.sh index 9b4ced4..39f8229 100755 --- a/install.sh +++ b/install.sh @@ -17,6 +17,8 @@ CONFIG="conventional" HOOK_MODE="auto" PM="" PR_MODE="smart" +AI_ATTRIBUTION="block" +ENFORCE="block" replace_line() { local search="$1" @@ -70,6 +72,60 @@ ensure_valid_pr_mode() { esac } +ensure_valid_policies() { + case "$AI_ATTRIBUTION" in + allow|warn|strip|block) ;; + *) + echo "error: invalid ai-attribution '${AI_ATTRIBUTION}'. expected allow, warn, strip, or block." + exit 1 + ;; + esac + + case "$ENFORCE" in + block|warn) ;; + *) + echo "error: invalid enforce '${ENFORCE}'. expected block or warn." + exit 1 + ;; + esac +} + +write_config_file() { + if [[ -f ".commit-guard.yml" ]] || [[ -f ".commit-guard.yaml" ]]; then + echo " .commit-guard.yml already exists, skipping" + return + fi + + cat > .commit-guard.yml < Hook mode: auto (default), husky, native, none" echo " --pm Package manager: pnpm, npm, yarn (auto-detected if omitted)" echo " --pr-mode PR lint mode: smart (default), commits, title" + echo " --ai-attribution

AI attribution policy: allow, warn, strip, block (default)" + echo " --enforce Enforcement: block (default), warn" echo " --help Show this help" exit 0 ;; @@ -194,12 +254,14 @@ if ! git rev-parse --is-inside-work-tree &>/dev/null; then fi ensure_valid_pr_mode +ensure_valid_policies detect_pm resolve_hook_mode echo "installing CI workflow..." mkdir -p "$WORKFLOW_DIR" curl -sL "$TEMPLATE_URL" -o "$WORKFLOW_FILE" +write_config_file if [[ "$CONFIG" != "conventional" ]]; then replace_line 'config: "conventional"' "config: \"${CONFIG}\"" "$WORKFLOW_FILE" @@ -231,6 +293,7 @@ esac echo "" echo "done! installed:" echo " - CI workflow: ${WORKFLOW_FILE}" +echo " - Config: .commit-guard.yml" if [[ "$HOOK_MODE" == "native" ]]; then echo " - Local hook: $(git config --get core.hooksPath)/commit-msg" diff --git a/scripts/run-commitlint-ci.sh b/scripts/run-commitlint-ci.sh index 03b6423..6f42b38 100755 --- a/scripts/run-commitlint-ci.sh +++ b/scripts/run-commitlint-ci.sh @@ -1,16 +1,145 @@ #!/usr/bin/env bash set -euo pipefail +# reads optional .commit-guard.yml in the caller repo root; file values win +# over CG_* env fallbacks. flat schema: top-level keys, block-style lists. +# config parsing mirrors validate-commit-message.sh β€” keep in sync. + EVENT_NAME="${CG_EVENT_NAME:-}" PR_MODE="${CG_PR_MODE:-smart}" PR_TITLE="${CG_PR_TITLE:-}" RANGE_FROM="${CG_RANGE_FROM:-}" RANGE_TO="${CG_RANGE_TO:-}" +REF_NAME="${CG_REF_NAME:-}" IGNORE_BOT_COMMITS="${CG_IGNORE_BOT_COMMITS:-true}" IGNORE_MERGE_COMMITS="${CG_IGNORE_MERGE_COMMITS:-true}" IGNORE_MESSAGE_PATTERNS="${CG_IGNORE_MESSAGE_PATTERNS:-}" +ENFORCE="${CG_ENFORCE:-block}" +AI_ATTRIBUTION="${CG_AI_ATTRIBUTION:-allow}" +BAN_PATTERNS="" +BRANCHES="" COMMITLINT_CMD="${CG_COMMITLINT_CMD:-commitlint}" +CONFIG_FILE="" +for candidate in ".commit-guard.yml" ".commit-guard.yaml"; do + if [[ -f "$candidate" ]]; then + CONFIG_FILE="$candidate" + break + fi +done + +AI_ATTRIBUTION_PATTERNS=( + '^co-authored-by:.*(claude|copilot|chatgpt|openai|anthropic|gemini|cursor|devin|aider|codex|\[bot\])' + 'generated (with|by).*(claude|chatgpt|copilot|gemini|cursor|aider|codex)' + 'noreply@anthropic\.com' +) + +FAILURE_COUNT=0 + +clean_yaml_value() { + local value="$1" + + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + case "$value" in + \"*\") + value="${value#\"}" + value="${value%\"}" + ;; + \'*\') + value="${value#\'}" + value="${value%\'}" + ;; + *) + value="${value%%" #"*}" + value="${value%"${value##*[![:space:]]}"}" + ;; + esac + + printf '%s\n' "$value" +} + +config_get() { + local key="$1" + local default="$2" + local raw="" + + if [[ -n "$CONFIG_FILE" ]]; then + raw="$(sed -n "s/^${key}:[[:space:]]*//p" "$CONFIG_FILE" | head -n 1)" + raw="$(clean_yaml_value "$raw")" + fi + + printf '%s\n' "${raw:-$default}" +} + +config_get_list() { + local key="$1" + local line + local cleaned + + if [[ -z "$CONFIG_FILE" ]]; then + return 0 + fi + + while IFS= read -r line; do + cleaned="$(clean_yaml_value "$line")" + if [[ -n "$cleaned" ]]; then + printf '%s\n' "$cleaned" + fi + done < <(awk -v key="$key" ' + inlist { + if ($0 ~ /^[[:space:]]*(#|$)/) next + if ($0 !~ /^[[:space:]]+-[[:space:]]*/) exit + sub(/^[[:space:]]+-[[:space:]]*/, "") + print + next + } + $0 ~ "^" key ":[[:space:]]*(#.*)?$" { inlist = 1 } + ' "$CONFIG_FILE") +} + +load_config_file() { + if [[ -z "$CONFIG_FILE" ]]; then + return 0 + fi + + PR_MODE="$(config_get pr-mode "$PR_MODE")" + ENFORCE="$(config_get enforce "$ENFORCE")" + AI_ATTRIBUTION="$(config_get ai-attribution "$AI_ATTRIBUTION")" + IGNORE_BOT_COMMITS="$(config_get ignore-bot-commits "$IGNORE_BOT_COMMITS")" + IGNORE_MERGE_COMMITS="$(config_get ignore-merge-commits "$IGNORE_MERGE_COMMITS")" + + local file_ignore_patterns + file_ignore_patterns="$(config_get_list ignore-message-patterns)" + if [[ -n "$file_ignore_patterns" ]]; then + IGNORE_MESSAGE_PATTERNS="$file_ignore_patterns" + fi + + BAN_PATTERNS="$(config_get_list ban-patterns)" + BRANCHES="$(config_get_list branches)" + + echo "loaded ${CONFIG_FILE} (file values override workflow inputs)" +} + +validate_enums() { + case "$ENFORCE" in + block|warn) ;; + *) + echo "error: invalid enforce value '${ENFORCE}'. expected block or warn." >&2 + exit 1 + ;; + esac + + case "$AI_ATTRIBUTION" in + allow|warn|strip|block) ;; + *) + echo "error: invalid ai-attribution value '${AI_ATTRIBUTION}'. expected allow, warn, strip, or block." >&2 + exit 1 + ;; + esac +} + is_truthy() { case "${1,,}" in 1|true|yes|on) return 0 ;; @@ -56,14 +185,113 @@ matches_ignore_pattern() { return 1 } +branch_filter_skips_push() { + if is_pr_event || [[ -z "$BRANCHES" ]]; then + return 1 + fi + + local branch + while IFS= read -r branch; do + if [[ "$branch" == "$REF_NAME" ]]; then + return 1 + fi + done <<< "$BRANCHES" + + return 0 +} + +record_failure() { + FAILURE_COUNT=$((FAILURE_COUNT + 1)) + if [[ "$ENFORCE" == "warn" ]]; then + echo "::warning::${1}" + else + echo "::error::${1}" + fi +} + +find_ai_attribution_lines() { + local message="$1" + local line + local lowered + local pattern + + while IFS= read -r line; do + lowered="${line,,}" + for pattern in "${AI_ATTRIBUTION_PATTERNS[@]}"; do + if [[ "$lowered" =~ $pattern ]]; then + printf '%s\n' "$line" + break + fi + done + done <<< "$message" +} + +check_ai_attribution() { + local label="$1" + local message="$2" + local matches + + if [[ "$AI_ATTRIBUTION" == "allow" ]]; then + return 0 + fi + + matches="$(find_ai_attribution_lines "$message")" + if [[ -z "$matches" ]]; then + return 0 + fi + + if [[ "$AI_ATTRIBUTION" == "warn" ]]; then + echo "::warning::${label} contains AI attribution: ${matches}" + return 0 + fi + + # strip cannot rewrite pushed commits, so it acts as block in ci + record_failure "${label} contains AI attribution: ${matches}" + return 1 +} + +check_ban_patterns() { + local label="$1" + local message="$2" + local pattern + local ok=0 + + while IFS= read -r pattern; do + if [[ -z "$pattern" ]]; then + continue + fi + if grep -Eiq -- "$pattern" <<< "$message"; then + record_failure "${label} matches banned pattern: ${pattern}" + ok=1 + fi + done <<< "$BAN_PATTERNS" + + return "$ok" +} + lint_message() { local label="$1" local message="$2" local subject + local ok=0 subject="$(printf '%s\n' "$message" | head -n 1)" echo "linting ${label}: ${subject}" - printf '%s\n' "$message" | "$COMMITLINT_CMD" --verbose + + if ! printf '%s\n' "$message" | "$COMMITLINT_CMD" --verbose; then + record_failure "${label} failed commitlint: ${subject}" + ok=1 + fi + + if ! check_ai_attribution "$label" "$message"; then + ok=1 + fi + + if ! check_ban_patterns "$label" "$message"; then + ok=1 + fi + + return "$ok" } collect_commit_shas() { @@ -82,19 +310,42 @@ collect_commit_shas() { lint_pr_title_if_present() { if [[ -z "$PR_TITLE" ]]; then echo "error: pr-mode requires a pull request title but none was provided." >&2 - return 1 + exit 1 fi - lint_message "PR title" "$PR_TITLE" + lint_message "PR title" "$PR_TITLE" || true +} + +finish() { + if [[ "$FAILURE_COUNT" -eq 0 ]]; then + return 0 + fi + + if [[ "$ENFORCE" == "warn" ]]; then + echo "::warning::commit-guard found ${FAILURE_COUNT} issue(s); enforce is 'warn', passing anyway." + return 0 + fi + + echo "commit-guard found ${FAILURE_COUNT} issue(s)." >&2 + return 1 } main() { local linted_count=0 local sha + load_config_file + validate_enums + + if branch_filter_skips_push; then + echo "branch '${REF_NAME}' is not in the configured branches list, skipping lint." + return 0 + fi + if is_pr_event && [[ "$PR_MODE" == "title" ]]; then lint_pr_title_if_present - return 0 + finish + return fi while IFS= read -r sha; do @@ -137,19 +388,22 @@ main() { continue fi - lint_message "commit ${sha}" "$message" + lint_message "commit ${sha}" "$message" || true linted_count=$((linted_count + 1)) done < <(collect_commit_shas) if is_pr_event && [[ "$PR_MODE" == "smart" ]] && [[ "$linted_count" -eq 0 ]]; then echo "no lintable commits left after filters, falling back to PR title" lint_pr_title_if_present - return 0 + finish + return fi if [[ "$linted_count" -eq 0 ]]; then echo "no commit messages to lint after filters" fi + + finish } main "$@" diff --git a/scripts/validate-commit-message.sh b/scripts/validate-commit-message.sh index 9cb4869..52d6de7 100755 --- a/scripts/validate-commit-message.sh +++ b/scripts/validate-commit-message.sh @@ -1,6 +1,11 @@ #!/usr/bin/env bash set -euo pipefail +# commit-guard native commit-msg hook +# reads optional .commit-guard.yml at the repo root (flat schema: top-level +# keys, block-style lists). config parsing is duplicated in +# run-commitlint-ci.sh β€” keep in sync + MESSAGE_FILE="${1:-}" if [[ -z "$MESSAGE_FILE" || ! -f "$MESSAGE_FILE" ]]; then @@ -8,31 +13,238 @@ if [[ -z "$MESSAGE_FILE" || ! -f "$MESSAGE_FILE" ]]; then exit 1 fi -SUBJECT="$(head -n 1 "$MESSAGE_FILE")" -CONVENTIONAL_REGEX='^(build|chore|ci|docs|feat|fix|perf|refactor|style|test)(\([[:alnum:]./_-]+\))?(!)?: .+' +REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)" +CONFIG_FILE="" +for candidate in "${REPO_ROOT}/.commit-guard.yml" "${REPO_ROOT}/.commit-guard.yaml"; do + if [[ -f "$candidate" ]]; then + CONFIG_FILE="$candidate" + break + fi +done -if [[ "$SUBJECT" =~ ^Merge[[:space:]] ]] || \ - [[ "$SUBJECT" =~ ^Revert[[:space:]] ]] || \ - [[ "$SUBJECT" =~ ^fixup!\ ]] || \ - [[ "$SUBJECT" =~ ^squash!\ ]]; then - exit 0 -fi +DEFAULT_TYPES='build|chore|ci|docs|feat|fix|perf|refactor|style|test' +AI_ATTRIBUTION_PATTERNS=( + '^co-authored-by:.*(claude|copilot|chatgpt|openai|anthropic|gemini|cursor|devin|aider|codex|\[bot\])' + 'generated (with|by).*(claude|chatgpt|copilot|gemini|cursor|aider|codex)' + 'noreply@anthropic\.com' +) -if [[ "$SUBJECT" =~ $CONVENTIONAL_REGEX ]]; then - exit 0 -fi +clean_yaml_value() { + local value="$1" + + value="${value#"${value%%[![:space:]]*}"}" + value="${value%"${value##*[![:space:]]}"}" + + case "$value" in + \"*\") + value="${value#\"}" + value="${value%\"}" + ;; + \'*\') + value="${value#\'}" + value="${value%\'}" + ;; + *) + value="${value%%" #"*}" + value="${value%"${value##*[![:space:]]}"}" + ;; + esac + + printf '%s\n' "$value" +} + +config_get() { + local key="$1" + local default="$2" + local raw="" + + if [[ -n "$CONFIG_FILE" ]]; then + raw="$(sed -n "s/^${key}:[[:space:]]*//p" "$CONFIG_FILE" | head -n 1)" + raw="$(clean_yaml_value "$raw")" + fi + + printf '%s\n' "${raw:-$default}" +} + +config_get_list() { + local key="$1" + local line + local cleaned + + if [[ -z "$CONFIG_FILE" ]]; then + return 0 + fi + + while IFS= read -r line; do + cleaned="$(clean_yaml_value "$line")" + if [[ -n "$cleaned" ]]; then + printf '%s\n' "$cleaned" + fi + done < <(awk -v key="$key" ' + inlist { + if ($0 ~ /^[[:space:]]*(#|$)/) next + if ($0 !~ /^[[:space:]]+-[[:space:]]*/) exit + sub(/^[[:space:]]+-[[:space:]]*/, "") + print + next + } + $0 ~ "^" key ":[[:space:]]*(#.*)?$" { inlist = 1 } + ' "$CONFIG_FILE") +} + +ENFORCE="$(config_get enforce block)" +AI_ATTRIBUTION="$(config_get ai-attribution allow)" + +case "$ENFORCE" in + block|warn) ;; + *) + echo "error: invalid enforce value '${ENFORCE}'. expected block or warn." >&2 + exit 1 + ;; +esac + +case "$AI_ATTRIBUTION" in + allow|warn|strip|block) ;; + *) + echo "error: invalid ai-attribution value '${AI_ATTRIBUTION}'. expected allow, warn, strip, or block." >&2 + exit 1 + ;; +esac + +TYPES="$(config_get_list types | paste -sd '|' -)" +TYPES="${TYPES:-$DEFAULT_TYPES}" +CONVENTIONAL_REGEX="^(${TYPES})(\([[:alnum:]./_-]+\))?(!)?: .+" + +VIOLATIONS="" + +add_violation() { + VIOLATIONS="${VIOLATIONS}${1}"$'\n' +} + +find_ai_attribution_lines() { + local line + local lowered + local pattern -cat >&2 <> "$temp_file" + fi + done < "$MESSAGE_FILE" + mv "$temp_file" "$MESSAGE_FILE" +} + +check_ai_attribution() { + local matches + + if [[ "$AI_ATTRIBUTION" == "allow" ]]; then + return 0 + fi + + matches="$(find_ai_attribution_lines)" + if [[ -z "$matches" ]]; then + return 0 + fi + + case "$AI_ATTRIBUTION" in + warn) + echo "warning: commit message contains AI attribution:" >&2 + printf '%s\n' "$matches" >&2 + ;; + strip) + strip_ai_attribution_lines + echo "commit-guard: stripped AI attribution from commit message:" >&2 + printf '%s\n' "$matches" >&2 + ;; + block) + add_violation "commit message contains AI attribution:"$'\n'"$matches" + ;; + esac +} + +check_ban_patterns() { + local pattern + local message + + message="$(cat "$MESSAGE_FILE")" + while IFS= read -r pattern; do + if [[ -z "$pattern" ]]; then + continue + fi + if grep -Eiq -- "$pattern" <<< "$message"; then + add_violation "commit message matches banned pattern: ${pattern}" + fi + done < <(config_get_list ban-patterns) +} + +check_conventional_subject() { + local subject + + subject="$(head -n 1 "$MESSAGE_FILE")" + + if [[ "$subject" =~ ^Merge[[:space:]] ]] || \ + [[ "$subject" =~ ^Revert[[:space:]] ]] || \ + [[ "$subject" =~ ^fixup!\ ]] || \ + [[ "$subject" =~ ^squash!\ ]]; then + return 0 + fi + + if [[ "$subject" =~ $CONVENTIONAL_REGEX ]]; then + return 0 + fi + + add_violation "commit message must follow Conventional Commits. Expected: type(scope): description -Example: - feat(ci): add smart PR title fallback +Allowed types: + $(printf '%s' "$TYPES" | tr '|' ' ') Received: - ${SUBJECT} -EOF + ${subject}" +} + +check_ai_attribution +check_ban_patterns +check_conventional_subject + +if [[ -z "$VIOLATIONS" ]]; then + exit 0 +fi + +printf 'error: %s\n' "$VIOLATIONS" >&2 + +if [[ "$ENFORCE" == "warn" ]]; then + echo "commit-guard: enforce is 'warn', allowing commit anyway." >&2 + exit 0 +fi exit 1 diff --git a/test/config-features.t.sh b/test/config-features.t.sh new file mode 100644 index 0000000..71f6b2f --- /dev/null +++ b/test/config-features.t.sh @@ -0,0 +1,263 @@ +#!/usr/bin/env bash +set -euo pipefail + +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +VALIDATOR="${ROOT_DIR}/scripts/validate-commit-message.sh" +CI_SCRIPT="${ROOT_DIR}/scripts/run-commitlint-ci.sh" + +make_config_repo() { + local repo_dir="$1" + local config_yaml="$2" + + make_git_repo "$repo_dir" + printf '%s\n' "$config_yaml" > "${repo_dir}/.commit-guard.yml" +} + +run_validator() { + local repo_dir="$1" + local message="$2" + local message_file="${repo_dir}/message.txt" + + printf '%s\n' "$message" > "$message_file" + (cd "$repo_dir" && "$VALIDATOR" message.txt) +} + +test_custom_types_accept_and_reject() { + local repo_dir + repo_dir="$(make_temp_dir)/repo" + make_config_repo "$repo_dir" 'types: + - feat + - wip' + + run_validator "$repo_dir" "wip: half-done thing" + + if run_validator "$repo_dir" "chore: not in the custom list" 2>/dev/null; then + fail "expected type outside custom list to be rejected" + fi +} + +test_parser_handles_comments_and_quotes() { + local repo_dir + repo_dir="$(make_temp_dir)/repo" + make_config_repo "$repo_dir" '# full-line comment +enforce: "block" +ai-attribution: block # trailing comment + +types: + # comment inside list + - "feat" + + - wip' + + run_validator "$repo_dir" "wip: quoted and commented config parses" + + if run_validator "$repo_dir" "feat: with AI trailer + +Co-Authored-By: Claude " 2>/dev/null; then + fail "expected ai-attribution with trailing comment to still block" + fi +} + +test_ban_patterns_reject_matching_message() { + local repo_dir + repo_dir="$(make_temp_dir)/repo" + make_config_repo "$repo_dir" 'ban-patterns: + - password + - "^temp"' + + if run_validator "$repo_dir" "feat: add Password rotation" 2>/dev/null; then + fail "expected banned pattern to reject message" + fi + + run_validator "$repo_dir" "feat: add pin rotation" +} + +test_ai_attribution_block() { + local repo_dir + repo_dir="$(make_temp_dir)/repo" + make_config_repo "$repo_dir" 'ai-attribution: block' + + if run_validator "$repo_dir" "feat: add thing + +Co-Authored-By: Claude " 2>/dev/null; then + fail "expected AI co-author trailer to be blocked" + fi + + if run_validator "$repo_dir" "feat: add thing + +πŸ€– Generated with Claude Code" 2>/dev/null; then + fail "expected AI byline to be blocked" + fi + + run_validator "$repo_dir" "feat: add thing + +Co-Authored-By: Human Person " +} + +test_ai_attribution_warn_allows() { + local repo_dir + repo_dir="$(make_temp_dir)/repo" + make_config_repo "$repo_dir" 'ai-attribution: warn' + + run_validator "$repo_dir" "feat: add thing + +Co-Authored-By: Claude " +} + +test_ai_attribution_strip_rewrites_message() { + local repo_dir + local result + repo_dir="$(make_temp_dir)/repo" + make_config_repo "$repo_dir" 'ai-attribution: strip' + + run_validator "$repo_dir" "feat: add thing + +Co-Authored-By: Claude " + + result="$(cat "${repo_dir}/message.txt")" + if [[ "$result" == *"Claude"* ]]; then + fail "expected AI trailer to be stripped from message file" + fi + assert_contains "feat: add thing" "$result" "expected real content to survive strip" +} + +test_enforce_warn_allows_bad_commit_locally() { + local repo_dir + repo_dir="$(make_temp_dir)/repo" + make_config_repo "$repo_dir" 'enforce: warn' + + run_validator "$repo_dir" "totally not conventional" +} + +test_invalid_enum_fails_loudly() { + local repo_dir + repo_dir="$(make_temp_dir)/repo" + make_config_repo "$repo_dir" 'ai-attribution: nope' + + if run_validator "$repo_dir" "feat: fine subject" 2>/dev/null; then + fail "expected invalid ai-attribution value to fail" + fi +} + +test_ci_file_overrides_env_enforce() { + local temp_dir repo_dir base_sha head_sha + temp_dir="$(make_temp_dir)" + repo_dir="${temp_dir}/repo" + make_config_repo "$repo_dir" 'enforce: warn' + + commit_file "$repo_dir" "README.md" "base" "feat: seed repo" + base_sha="$(git -C "$repo_dir" rev-parse HEAD)" + commit_file "$repo_dir" "notes.txt" "bad" "Initial plan" + head_sha="$(git -C "$repo_dir" rev-parse HEAD)" + + make_commitlint_stub "${temp_dir}/bin" "${temp_dir}/commitlint.log" + + ( + cd "$repo_dir" + PATH="${temp_dir}/bin:${PATH}" \ + COMMITLINT_LOG="${temp_dir}/commitlint.log" \ + CG_EVENT_NAME="push" \ + CG_RANGE_FROM="$base_sha" \ + CG_RANGE_TO="$head_sha" \ + CG_ENFORCE="block" \ + "$CI_SCRIPT" + ) || fail "expected enforce=warn from config file to pass despite bad commit" +} + +test_ci_ban_pattern_fails_commit() { + local temp_dir repo_dir base_sha head_sha + temp_dir="$(make_temp_dir)" + repo_dir="${temp_dir}/repo" + make_config_repo "$repo_dir" 'ban-patterns: + - hunter2' + + commit_file "$repo_dir" "README.md" "base" "feat: seed repo" + base_sha="$(git -C "$repo_dir" rev-parse HEAD)" + commit_file "$repo_dir" "notes.txt" "x" "feat: set password to hunter2" + head_sha="$(git -C "$repo_dir" rev-parse HEAD)" + + make_commitlint_stub "${temp_dir}/bin" "${temp_dir}/commitlint.log" + + if ( + cd "$repo_dir" + PATH="${temp_dir}/bin:${PATH}" \ + COMMITLINT_LOG="${temp_dir}/commitlint.log" \ + CG_EVENT_NAME="push" \ + CG_RANGE_FROM="$base_sha" \ + CG_RANGE_TO="$head_sha" \ + "$CI_SCRIPT" + ); then + fail "expected banned pattern to fail CI lint" + fi +} + +test_ci_ai_attribution_strip_acts_as_block() { + local temp_dir repo_dir base_sha head_sha + temp_dir="$(make_temp_dir)" + repo_dir="${temp_dir}/repo" + make_config_repo "$repo_dir" 'ai-attribution: strip' + + commit_file "$repo_dir" "README.md" "base" "feat: seed repo" + base_sha="$(git -C "$repo_dir" rev-parse HEAD)" + commit_file "$repo_dir" "notes.txt" "x" "feat: add thing + +Co-Authored-By: Claude " + head_sha="$(git -C "$repo_dir" rev-parse HEAD)" + + make_commitlint_stub "${temp_dir}/bin" "${temp_dir}/commitlint.log" + + if ( + cd "$repo_dir" + PATH="${temp_dir}/bin:${PATH}" \ + COMMITLINT_LOG="${temp_dir}/commitlint.log" \ + CG_EVENT_NAME="push" \ + CG_RANGE_FROM="$base_sha" \ + CG_RANGE_TO="$head_sha" \ + "$CI_SCRIPT" + ); then + fail "expected strip policy to act as block in CI" + fi +} + +test_ci_branch_filter_skips_other_branches() { + local temp_dir repo_dir base_sha head_sha output + temp_dir="$(make_temp_dir)" + repo_dir="${temp_dir}/repo" + make_config_repo "$repo_dir" 'branches: + - main + - master' + + commit_file "$repo_dir" "README.md" "base" "feat: seed repo" + base_sha="$(git -C "$repo_dir" rev-parse HEAD)" + commit_file "$repo_dir" "notes.txt" "bad" "Initial plan" + head_sha="$(git -C "$repo_dir" rev-parse HEAD)" + + make_commitlint_stub "${temp_dir}/bin" "${temp_dir}/commitlint.log" + + output="$( + cd "$repo_dir" + PATH="${temp_dir}/bin:${PATH}" \ + COMMITLINT_LOG="${temp_dir}/commitlint.log" \ + CG_EVENT_NAME="push" \ + CG_REF_NAME="feature/foo" \ + CG_RANGE_FROM="$base_sha" \ + CG_RANGE_TO="$head_sha" \ + "$CI_SCRIPT" + )" || fail "expected branch filter to skip lint and pass" + + assert_contains "skipping lint" "$output" "expected skip notice for filtered branch" +} + +test_custom_types_accept_and_reject +test_parser_handles_comments_and_quotes +test_ban_patterns_reject_matching_message +test_ai_attribution_block +test_ai_attribution_warn_allows +test_ai_attribution_strip_rewrites_message +test_enforce_warn_allows_bad_commit_locally +test_invalid_enum_fails_loudly +test_ci_file_overrides_env_enforce +test_ci_ban_pattern_fails_commit +test_ci_ai_attribution_strip_acts_as_block +test_ci_branch_filter_skips_other_branches diff --git a/test/install.t.sh b/test/install.t.sh index 369f67d..af8205b 100755 --- a/test/install.t.sh +++ b/test/install.t.sh @@ -24,6 +24,9 @@ test_installer_supports_native_hooks_and_pr_title_mode_without_node_repo() { assert_contains "created: .githooks/commit-msg" "$output" "expected native hook install output" assert_contains 'pr-mode: "title"' "$(cat "${repo_dir}/.github/workflows/commitlint.yml")" "expected PR mode to be rendered" assert_contains ".githooks" "$(git -C "$repo_dir" config --get core.hooksPath)" "expected git hooks path to point at tracked hooks" + assert_contains "created: .commit-guard.yml" "$output" "expected starter config output" + assert_contains 'pr-mode: title' "$(cat "${repo_dir}/.commit-guard.yml")" "expected pr mode in starter config" + assert_contains 'ai-attribution: block' "$(cat "${repo_dir}/.commit-guard.yml")" "expected ai-attribution default in starter config" } test_installer_supports_native_hooks_and_pr_title_mode_without_node_repo