Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 61 additions & 7 deletions .github/workflows/commitlint.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
56 changes: 56 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -116,6 +170,8 @@ jobs:
with:
config: "conventional"
pr-mode: "smart"
# enforce: "warn"
# ai-attribution: "block"
# ignore-message-patterns: |
# ^Initial plan$
```
Expand Down
5 changes: 5 additions & 0 deletions caller-template.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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$
Expand Down
134 changes: 134 additions & 0 deletions docs/superpowers/specs/2026-07-19-config-features-design.md
Original file line number Diff line number Diff line change
@@ -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.
Loading