Skip to main content
All Tutorials
beginnerclaude

Build an automated changelog generator from Git commits with Claude

developer-productivity

~30 min hands-on6 min readMay 14, 2026
Recipe code coming soon — subscribe to get notified

Prerequisites

  • Node.js 20+
  • Anthropic API key (console.anthropic.com)
  • A Git repository with at least a few weeks of commit history

What you'll build

A Node.js script that:

  1. Reads the Git log for a configurable date range
  2. Sends the commit messages to Claude with a prompt that groups and summarizes them
  3. Outputs a formatted markdown changelog — ready to paste into your CHANGELOG.md, release notes, or a Notion doc
  4. Optionally runs on a weekly schedule via GitHub Actions

The whole script is under 100 lines. You don't need a database or a server.

Why this is worth automating

Most teams write changelogs manually, which means they either don't write them at all, or they write them at release time from memory. Both outcomes are bad: users don't know what changed, and the team loses the institutional knowledge that comes from a consistent record.

Automating the first draft removes the friction. Claude is good at this task because changelog writing is pattern recognition — it knows the difference between a feat commit and a fix commit, it can group related changes, and it writes in plain English without being told to.

Before you start

You'll need an Anthropic API key. Get one at console.anthropic.com. The script uses claude-haiku-4-5, which costs a fraction of a cent per run on a typical week of commits.

Set the key as an environment variable:

export ANTHROPIC_API_KEY=your_api_key_here

Step 1 — Initialize the project

mkdir changelog-agent
cd changelog-agent
npm init -y
npm install @anthropic-ai/sdk
npm install -D tsx @types/node

Add a tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2022",
    "module": "NodeNext",
    "moduleResolution": "NodeNext",
    "strict": true,
    "esModuleInterop": true
  }
}

Step 2 — Read the Git log

Create src/git-log.ts:

import { execSync } from 'child_process'

export interface Commit {
  hash: string
  date: string
  author: string
  message: string
}

export function getCommits(since: string, until = 'HEAD'): Commit[] {
  const separator = '|||'
  const format = `%H${separator}%ad${separator}%an${separator}%s`

  const output = execSync(
    `git log --since="${since}" --until="${until}" --format="${format}" --date=short`,
    { encoding: 'utf-8' }
  ).trim()

  if (!output) return []

  return output.split('\n').map(line => {
    const [hash, date, author, ...messageParts] = line.split(separator)
    return { hash, date, author, message: messageParts.join(separator) }
  })
}

Test it locally to make sure you're reading commits correctly:

npx tsx -e "
import { getCommits } from './src/git-log.ts'
const commits = getCommits('7 days ago')
console.log(commits.length, 'commits found')
console.log(commits.slice(0, 3))
"

Step 3 — Generate the changelog with Claude

Create src/generate-changelog.ts:

import Anthropic from '@anthropic-ai/sdk'

const client = new Anthropic()

const SYSTEM_PROMPT = `You are a technical writer who specializes in developer changelogs.
Your job is to take a list of Git commit messages and produce a clean, readable changelog entry.

Rules:
- Group commits into sections: Features, Bug Fixes, Improvements, and Internal (for chores/refactors)
- Skip commits that are pure internal housekeeping (e.g. "fix typo", "update deps", "merge branch")
  unless they affect users
- Write in plain English, not Git jargon. "Fixed a crash when uploading large files" not "fix: null check in upload handler"
- Each item should be one sentence. No bullet nesting.
- If a section has no items, omit it entirely.
- Start the output with a date heading in the format: ## YYYY-MM-DD`

export async function generateChangelog(
  commits: Array<{ hash: string; date: string; author: string; message: string }>,
  dateLabel: string
): Promise<string> {
  if (commits.length === 0) {
    return `## ${dateLabel}\n\nNo notable changes this period.`
  }

  const commitList = commits
    .map(c => `${c.date} — ${c.message} (${c.hash.slice(0, 7)})`)
    .join('\n')

  const message = await client.messages.create({
    model: 'claude-haiku-4-5',
    max_tokens: 1024,
    system: SYSTEM_PROMPT,
    messages: [
      {
        role: 'user',
        content: `Here are the commits from ${dateLabel}:\n\n${commitList}\n\nGenerate a changelog entry.`,
      },
    ],
  })

  const content = message.content[0]
  return content.type === 'text' ? content.text : ''
}

Step 4 — Wire it together

Create src/agent.ts:

import { getCommits } from './git-log.js'
import { generateChangelog } from './generate-changelog.js'
import { writeFileSync, readFileSync, existsSync } from 'fs'

const CHANGELOG_FILE = 'CHANGELOG.md'
const DAYS_BACK = parseInt(process.env.DAYS_BACK ?? '7', 10)

async function main() {
  const since = `${DAYS_BACK} days ago`
  const dateLabel = new Date().toISOString().slice(0, 10)

  console.log(`Reading commits from the last ${DAYS_BACK} days...`)
  const commits = getCommits(since)
  console.log(`Found ${commits.length} commits`)

  console.log('Generating changelog with Claude...')
  const entry = await generateChangelog(commits, dateLabel)

  console.log('\n--- Generated Entry ---')
  console.log(entry)
  console.log('--- End Entry ---\n')

  if (process.env.WRITE_CHANGELOG === 'true') {
    const existing = existsSync(CHANGELOG_FILE)
      ? readFileSync(CHANGELOG_FILE, 'utf-8')
      : '# Changelog\n\n'

    const updated = existing.replace(
      '# Changelog\n\n',
      `# Changelog\n\n${entry}\n\n`
    )
    writeFileSync(CHANGELOG_FILE, updated)
    console.log(`Prepended to ${CHANGELOG_FILE}`)
  } else {
    console.log('Set WRITE_CHANGELOG=true to prepend to CHANGELOG.md automatically.')
  }
}

main().catch(err => {
  console.error(err)
  process.exit(1)
})

Add the run script to package.json:

{
  "scripts": {
    "changelog": "tsx src/agent.ts"
  },
  "type": "module"
}

Run it:

ANTHROPIC_API_KEY=your_key npm run changelog

You should see the generated changelog in the console. To write it directly to CHANGELOG.md:

ANTHROPIC_API_KEY=your_key WRITE_CHANGELOG=true npm run changelog

Step 5 — Automate with GitHub Actions

Create .github/workflows/weekly-changelog.yml:

name: Weekly Changelog

on:
  schedule:
    - cron: '0 9 * * 1'  # Every Monday at 9 AM UTC
  workflow_dispatch:
    inputs:
      days_back:
        description: 'Number of days to look back'
        default: '7'

jobs:
  generate-changelog:
    runs-on: ubuntu-latest
    permissions:
      contents: write
      pull-requests: write

    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Need full history for git log

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - run: npm ci

      - name: Generate changelog
        run: npm run changelog
        env:
          ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
          DAYS_BACK: ${{ inputs.days_back || '7' }}
          WRITE_CHANGELOG: 'true'

      - name: Open PR with changes
        uses: peter-evans/create-pull-request@v6
        with:
          token: ${{ secrets.GITHUB_TOKEN }}
          commit-message: "docs: add weekly changelog entry"
          title: "Weekly changelog — ${{ github.run_id }}"
          body: "Auto-generated by the Claude changelog agent. Review and merge when ready."
          branch: changelog/weekly-${{ github.run_id }}

This creates a pull request each week with the new entry — so a human reviews it before it lands in main. You can also set it to auto-merge if you trust the output.

Customizing the output

Change the tone. Add a line to the system prompt: "Write in a friendly tone suitable for a public changelog that non-technical users will read." or "Write in a terse, technical style for an internal engineering team." The model follows register instructions reliably.

Focus on a subdirectory. Pass a -- path filter to git log to limit commits to a specific service or package:

const output = execSync(
  `git log --since="${since}" --format="${format}" --date=short -- packages/api`,
  { encoding: 'utf-8' }
)

Output JSON instead of Markdown. Change the prompt to request JSON with keys features, fixes, improvements, and you can feed the structured output into a release notes system, a Notion database, or a Slack message.

Common errors

fatal: your current branch does not have any commits yet — The script ran against an empty or shallow clone. Add fetch-depth: 0 to your actions/checkout step.

Claude groups everything as "Internal" — Your commit messages are too vague. The model can only work with the information in the commit. Switching to Conventional Commits format (feat:, fix:, chore:) gives Claude much better signal.

The output is too long — Increase max_tokens or add "Keep each section to a maximum of five items, prioritizing user-facing changes" to the system prompt.

Next steps

  • Feed the changelog into a Beehiiv or Resend email to notify subscribers automatically
  • Store each entry in a database and build a /changelog page on your site
  • Try the Marc Lou stack story to see how this fits into a broader AI-accelerated product workflow

Get the full recipe

Clone the starter repo and follow along in your own environment.

Related Stacks

Marc Lou on YouTube

How Marc Lou ships a new product every month with Claude, Cursor, and v0

by Marc Lou

claudecursorv0notion
6 min readMay 2026