Skill: alirezarezvani/changelog-generator (version 1.0.0)

--- name: "changelog-generator" description: "Changelog Generator" --- # Changelog Generator **Tier:** POWERFUL **Category:** Engineering **Domain:** Release Management / Documentation ## Overview Use this skill to produce consistent, auditable release notes from Conventional Commits. It separates commit parsing, semantic bump logic, and changelog rendering so teams can automate releases without losing editorial control. ## Core Capabilities - Parse commit messages using Conventional Commit rules - Detect semantic bump (`major`, `minor`, `patch`) from commit stream - Render Keep a Changelog sections (`Added`, `Changed`, `Fixed`, etc.) - Generate release entries from git ranges or provided commit input - Enforce commit format with a dedicated linter script - Support CI integration via machine-readable JSON output ## When to Use - Before publishing a release tag - During CI to generate release notes automatically - During PR checks to block invalid commit message formats - In monorepos where package changelogs require scoped filtering - When converting raw git history into user-facing notes ## Key Workflows ### 1. Generate Changelog Entry From Git ```bash python3 scripts/generate_changelog.py \ --from-tag v1.3.0 \ --to-tag v1.4.0 \ --next-version v1.4.0 \ --format markdown ``` ### 2. Generate Entry From stdin/File Input ```bash git log v1.3.0..v1.4.0 --pretty=format:'%s' | \ python3 scripts/generate_changelog.py --next-version v1.4.0 --format markdown python3 scripts/generate_changelog.py --input commits.txt --next-version v1.4.0 --format json ``` ### 3. Update `CHANGELOG.md` ```bash python3 scripts/generate_changelog.py \ --from-tag v1.3.0 \ --to-tag HEAD \ --next-version v1.4.0 \ --write CHANGELOG.md ``` ### 4. Lint Commits Before Merge ```bash python3 scripts/commit_linter.py --from-ref origin/main --to-ref HEAD --strict --format text ``` Or file/stdin: ```bash python3 scripts/commit_linter.py --input commits.txt --strict cat commits.txt | python3 scripts/commit_linter.py --format json ``` ## Conventional Commit Rules Supported types: - `feat`, `fix`, `perf`, `refactor`, `docs`, `test`, `build`, `ci`, `chore` - `security`, `deprecated`, `remove` Breaking changes: - `type(scope)!: summary` - Footer/body includes `BREAKING CHANGE:` SemVer mapping: - breaking -> `major` - non-breaking `feat` -> `minor` - all others -> `patch` ## Script Interfaces - `python3 scripts/generate_changelog.py --help` - Reads commits from git or stdin/`--input` - Renders markdown or JSON - Optional in-place changelog prepend - `python3 scripts/commit_linter.py --help` - Validates commit format - Returns non-zero in `--strict` mode on violations ## Common Pitfalls 1. Mixing merge commit messages with release commit parsing 2. Using vague commit summaries that cannot become release notes 3. Failing to include migration guidance for breaking changes 4. Treating docs/chore changes as user-facing features 5. Overwriting historical changelog sections instead of prepending ## Best Practices 1. Keep commits small and intent-driven. 2. Scope commit messages (`feat(api): ...`) in multi-package repos. 3. Enforce linter checks in PR pipelines. 4. Review generated markdown before publishing. 5. Tag releases only after changelog generation succeeds. 6. Keep an `[Unreleased]` section for manual curation when needed. ## References - [references/ci-integration.md](references/ci-integration.md) - [references/changelog-formatting-guide.md](references/changelog-formatting-guide.md) - [references/monorepo-strategy.md](references/monorepo-strategy.md) - [README.md](README.md) ## Release Governance Use this release flow for predictability: 1. Lint commit history for target release range. 2. Generate changelog draft from commits. 3. Manually adjust wording for customer clarity. 4. Validate semver bump recommendation. 5. Tag release only after changelog is approved. ## Output Quality Checks - Each bullet is user-meaningful, not implementation noise. - Breaking changes include migration action. - Security fixes are isolated in `Security` section. - Sections with no entries are omitted. - Duplicate bullets across sections are removed. ## CI Policy - Run `commit_linter.py --strict` on all PRs. - Block merge on invalid conventional commits. - Auto-generate draft release notes on tag push. - Require human approval before writing into `CHANGELOG.md` on main branch. ## Monorepo Guidance - Prefer commit scopes aligned to package names. - Filter commit stream by scope for package-specific releases. - Keep infra-wide changes in root changelog. - Store package changelogs near package roots for ownership clarity. ## Failure Handling - If no valid conventional commits found: fail early, do not generate misleading empty notes. - If git range invalid: surface explicit range in error output. - If write target missing: create safe changelog header scaffolding.

path: README.md
content: # Changelog Generator

Automates release notes from Conventional Commits with Keep a Changelog output and strict commit linting. Designed for CI-friendly release workflows.

## Quick Start

```bash
# Generate entry from git range
python3 scripts/generate_changelog.py \
  --from-tag v1.2.0 \
  --to-tag v1.3.0 \
  --next-version v1.3.0 \
  --format markdown

# Lint commit subjects
python3 scripts/commit_linter.py --from-ref origin/main --to-ref HEAD --strict --format text
```

## Included Tools

- `scripts/generate_changelog.py`: parse commits, infer semver bump, render markdown/JSON, optional file prepend
- `scripts/commit_linter.py`: validate commit subjects against Conventional Commits rules

## References

- `references/ci-integration.md`
- `references/changelog-formatting-guide.md`
- `references/monorepo-strategy.md`

## Installation

### Claude Code

```bash
cp -R engineering/changelog-generator ~/.claude/skills/changelog-generator
```

### OpenAI Codex

```bash
cp -R engineering/changelog-generator ~/.codex/skills/changelog-generator
```

### OpenClaw

```bash
cp -R engineering/changelog-generator ~/.openclaw/skills/changelog-generator
```


path: references/changelog-formatting-guide.md
content: # Changelog Formatting Guide

Use Keep a Changelog section ordering:

1. Security
2. Added
3. Changed
4. Deprecated
5. Removed
6. Fixed

Rules:

- One bullet = one user-visible change.
- Lead with impact, not implementation detail.
- Keep bullets short and actionable.
- Include migration note for breaking changes.


path: references/ci-integration.md
content: # CI Integration Examples

## GitHub Actions

```yaml
name: Changelog Check
on: [pull_request]

jobs:
  changelog:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: python3 engineering/changelog-generator/scripts/commit_linter.py \
          --from-ref origin/main --to-ref HEAD --strict
```

## GitLab CI

```yaml
changelog_lint:
  image: python:3.12
  stage: test
  script:
    - python3 engineering/changelog-generator/scripts/commit_linter.py --to-ref HEAD --strict
```


path: references/monorepo-strategy.md
content: # Monorepo Changelog Strategy

## Approaches

| Strategy | When to use | Tradeoff |
|----------|-------------|----------|
| Single root changelog | Product-wide releases, small teams | Simple but loses package-level detail |
| Per-package changelogs | Independent versioning, large teams | Clear ownership but harder to see full picture |
| Hybrid model | Root summary + package-specific details | Best of both, more maintenance |

## Commit Scoping Pattern

Enforce scoped conventional commits to enable per-package filtering:

```
feat(payments): add Stripe webhook handler
fix(auth): handle expired refresh tokens
chore(infra): bump base Docker image
```

**Rules:**
- Scope must match a package/directory name exactly
- Unscoped commits go to root changelog only
- Multi-package changes get separate scoped commits (not one mega-commit)

## Filtering for Package Releases

```bash
# Generate changelog for 'payments' package only
git log v1.3.0..HEAD --pretty=format:'%s' | grep '^[a-z]*\(payments\)' | \
  python3 scripts/generate_changelog.py --next-version v1.4.0 --format markdown
```

## Ownership Model

- Package maintainers own their scoped changelog
- Platform/infra team owns root changelog
- CI enforces scope presence on all commits touching package directories
- Root changelog aggregates breaking changes from all packages for visibility


path: scripts/commit_linter.py
content: #!/usr/bin/env python3
"""Lint commit messages against Conventional Commits.

Input sources (priority order):
1) --input file (one commit subject per line)
2) stdin lines
3) git range via --from-ref/--to-ref

Use --strict for non-zero exit on violations.
"""

import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass, asdict
from pathlib import Path
from typing import List, Optional


CONVENTIONAL_RE = re.compile(
    r"^(feat|fix|perf|refactor|docs|test|build|ci|chore|security|deprecated|remove)"
    r"(\([a-z0-9._/-]+\))?(!)?:\s+.{1,120}$"
)


class CLIError(Exception):
    """Raised for expected CLI errors."""


@dataclass
class LintReport:
    total: int
    valid: int
    invalid: int
    violations: List[str]


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Validate conventional commit subjects.")
    parser.add_argument("--input", help="File with commit subjects (one per line).")
    parser.add_argument("--from-ref", help="Git ref start (exclusive).")
    parser.add_argument("--to-ref", help="Git ref end (inclusive).")
    parser.add_argument("--strict", action="store_true", help="Exit non-zero when violations exist.")
    parser.add_argument("--format", choices=["text", "json"], default="text", help="Output format.")
    return parser.parse_args()


def lines_from_file(path: str) -> List[str]:
    try:
        return [line.strip() for line in Path(path).read_text(encoding="utf-8").splitlines() if line.strip()]
    except Exception as exc:
        raise CLIError(f"Failed reading --input file: {exc}") from exc


def lines_from_stdin() -> List[str]:
    if sys.stdin.isatty():
        return []
    data = sys.stdin.read()
    return [line.strip() for line in data.splitlines() if line.strip()]


def lines_from_git(args: argparse.Namespace) -> List[str]:
    if not args.to_ref:
        return []
    range_spec = f"{args.from_ref}..{args.to_ref}" if args.from_ref else args.to_ref
    try:
        proc = subprocess.run(
            ["git", "log", range_spec, "--pretty=format:%s", "--no-merges"],
            text=True,
            capture_output=True,
            check=True,
        )
    except subprocess.CalledProcessError as exc:
        raise CLIError(f"git log failed for range '{range_spec}': {exc.stderr.strip()}") from exc
    return [line.strip() for line in proc.stdout.splitlines() if line.strip()]


def load_lines(args: argparse.Namespace) -> List[str]:
    if args.input:
        return lines_from_file(args.input)
    stdin_lines = lines_from_stdin()
    if stdin_lines:
        return stdin_lines
    git_lines = lines_from_git(args)
    if git_lines:
        return git_lines
    raise CLIError("No commit input found. Use --input, stdin, or --to-ref.")


def lint(lines: List[str]) -> LintReport:
    violations: List[str] = []
    valid = 0

    for idx, line in enumerate(lines, start=1):
        if CONVENTIONAL_RE.match(line):
            valid += 1
            continue
        violations.append(f"line {idx}: {line}")

    return LintReport(total=len(lines), valid=valid, invalid=len(violations), violations=violations)


def format_text(report: LintReport) -> str:
    lines = [
        "Conventional commit lint report",
        f"- total: {report.total}",
        f"- valid: {report.valid}",
        f"- invalid: {report.invalid}",
    ]
    if report.violations:
        lines.append("Violations:")
        lines.extend([f"- {v}" for v in report.violations])
    return "\n".join(lines)


def main() -> int:
    args = parse_args()
    lines = load_lines(args)
    report = lint(lines)

    if args.format == "json":
        print(json.dumps(asdict(report), indent=2))
    else:
        print(format_text(report))

    if args.strict and report.invalid > 0:
        return 1
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except CLIError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        raise SystemExit(2)


path: scripts/generate_changelog.py
content: #!/usr/bin/env python3
"""Generate changelog entries from Conventional Commits.

Input sources (priority order):
1) --input file with one commit subject per line
2) stdin commit subjects
3) git log from --from-tag/--to-tag or --from-ref/--to-ref

Outputs markdown or JSON and can prepend into CHANGELOG.md.
"""

import argparse
import json
import re
import subprocess
import sys
from dataclasses import dataclass, asdict, field
from datetime import date
from pathlib import Path
from typing import Dict, List, Optional


COMMIT_RE = re.compile(
    r"^(?P<type>feat|fix|perf|refactor|docs|test|build|ci|chore|security|deprecated|remove)"
    r"(?:\((?P<scope>[^)]+)\))?(?P<breaking>!)?:\s+(?P<summary>.+)$"
)

SECTION_MAP = {
    "feat": "Added",
    "fix": "Fixed",
    "perf": "Changed",
    "refactor": "Changed",
    "security": "Security",
    "deprecated": "Deprecated",
    "remove": "Removed",
}


class CLIError(Exception):
    """Raised for expected CLI failures."""


@dataclass
class ParsedCommit:
    raw: str
    ctype: str
    scope: Optional[str]
    summary: str
    breaking: bool


@dataclass
class ChangelogEntry:
    version: str
    release_date: str
    sections: Dict[str, List[str]] = field(default_factory=dict)
    breaking_changes: List[str] = field(default_factory=list)
    bump: str = "patch"


def parse_args() -> argparse.Namespace:
    parser = argparse.ArgumentParser(description="Generate changelog from conventional commits.")
    parser.add_argument("--input", help="Text file with one commit subject per line.")
    parser.add_argument("--from-tag", help="Git tag start (exclusive).")
    parser.add_argument("--to-tag", help="Git tag end (inclusive).")
    parser.add_argument("--from-ref", help="Git ref start (exclusive).")
    parser.add_argument("--to-ref", help="Git ref end (inclusive).")
    parser.add_argument("--next-version", default="Unreleased", help="Version label for the generated entry.")
    parser.add_argument("--date", dest="entry_date", default=str(date.today()), help="Release date (YYYY-MM-DD).")
    parser.add_argument("--format", choices=["markdown", "json"], default="markdown", help="Output format.")
    parser.add_argument("--write", help="Prepend generated markdown entry into this changelog file.")
    return parser.parse_args()


def read_lines_from_file(path: str) -> List[str]:
    try:
        return [line.strip() for line in Path(path).read_text(encoding="utf-8").splitlines() if line.strip()]
    except Exception as exc:
        raise CLIError(f"Failed reading --input file: {exc}") from exc


def read_lines_from_stdin() -> List[str]:
    if sys.stdin.isatty():
        return []
    payload = sys.stdin.read()
    return [line.strip() for line in payload.splitlines() if line.strip()]


def read_lines_from_git(args: argparse.Namespace) -> List[str]:
    if args.from_tag or args.to_tag:
        if not args.to_tag:
            raise CLIError("--to-tag is required when using tag range.")
        start = args.from_tag
        end = args.to_tag
    elif args.from_ref or args.to_ref:
        if not args.to_ref:
            raise CLIError("--to-ref is required when using ref range.")
        start = args.from_ref
        end = args.to_ref
    else:
        return []

    range_spec = f"{start}..{end}" if start else end
    try:
        proc = subprocess.run(
            ["git", "log", range_spec, "--pretty=format:%s", "--no-merges"],
            text=True,
            capture_output=True,
            check=True,
        )
    except subprocess.CalledProcessError as exc:
        raise CLIError(f"git log failed for range '{range_spec}': {exc.stderr.strip()}") from exc

    return [line.strip() for line in proc.stdout.splitlines() if line.strip()]


def load_commits(args: argparse.Namespace) -> List[str]:
    if args.input:
        return read_lines_from_file(args.input)

    stdin_lines = read_lines_from_stdin()
    if stdin_lines:
        return stdin_lines

    git_lines = read_lines_from_git(args)
    if git_lines:
        return git_lines

    raise CLIError("No commit input found. Use --input, stdin, or git range flags.")


def parse_commits(lines: List[str]) -> List[ParsedCommit]:
    parsed: List[ParsedCommit] = []
    for line in lines:
        match = COMMIT_RE.match(line)
        if not match:
            continue
        ctype = match.group("type")
        scope = match.group("scope")
        summary = match.group("summary")
        breaking = bool(match.group("breaking")) or "BREAKING CHANGE" in line
        parsed.append(ParsedCommit(raw=line, ctype=ctype, scope=scope, summary=summary, breaking=breaking))
    return parsed


def determine_bump(commits: List[ParsedCommit]) -> str:
    if any(c.breaking for c in commits):
        return "major"
    if any(c.ctype == "feat" for c in commits):
        return "minor"
    return "patch"


def build_entry(commits: List[ParsedCommit], version: str, entry_date: str) -> ChangelogEntry:
    sections: Dict[str, List[str]] = {
        "Security": [],
        "Added": [],
        "Changed": [],
        "Deprecated": [],
        "Removed": [],
        "Fixed": [],
    }
    breaking_changes: List[str] = []

    for commit in commits:
        if commit.breaking:
            breaking_changes.append(commit.summary)
        section = SECTION_MAP.get(commit.ctype)
        if section:
            line = commit.summary if not commit.scope else f"{commit.scope}: {commit.summary}"
            sections[section].append(line)

    sections = {k: v for k, v in sections.items() if v}
    return ChangelogEntry(
        version=version,
        release_date=entry_date,
        sections=sections,
        breaking_changes=breaking_changes,
        bump=determine_bump(commits),
    )


def render_markdown(entry: ChangelogEntry) -> str:
    lines = [f"## [{entry.version}] - {entry.release_date}", ""]
    if entry.breaking_changes:
        lines.append("### Breaking")
        lines.extend([f"- {item}" for item in entry.breaking_changes])
        lines.append("")

    ordered_sections = ["Security", "Added", "Changed", "Deprecated", "Removed", "Fixed"]
    for section in ordered_sections:
        items = entry.sections.get(section, [])
        if not items:
            continue
        lines.append(f"### {section}")
        lines.extend([f"- {item}" for item in items])
        lines.append("")

    lines.append(f"<!-- recommended-semver-bump: {entry.bump} -->")
    return "\n".join(lines).strip() + "\n"


def prepend_changelog(path: Path, entry_md: str) -> None:
    if path.exists():
        original = path.read_text(encoding="utf-8")
    else:
        original = "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n"

    if original.startswith("# Changelog"):
        first_break = original.find("\n")
        head = original[: first_break + 1]
        tail = original[first_break + 1 :].lstrip("\n")
        combined = f"{head}\n{entry_md}\n{tail}"
    else:
        combined = f"# Changelog\n\n{entry_md}\n{original}"
    path.write_text(combined, encoding="utf-8")


def main() -> int:
    args = parse_args()
    lines = load_commits(args)
    parsed = parse_commits(lines)
    if not parsed:
        raise CLIError("No valid conventional commit messages found in input.")

    entry = build_entry(parsed, args.next_version, args.entry_date)

    if args.format == "json":
        print(json.dumps(asdict(entry), indent=2))
    else:
        markdown = render_markdown(entry)
        print(markdown, end="")
        if args.write:
            prepend_changelog(Path(args.write), markdown)

    if args.format == "json" and args.write:
        prepend_changelog(Path(args.write), render_markdown(entry))

    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except CLIError as exc:
        print(f"ERROR: {exc}", file=sys.stderr)
        raise SystemExit(2)


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (HIGH)