Skip to content
Santekno.com | Level Up Your Engineering Skills
EN
πŸ“– 0%
21 Oct 2026 Β· 14 min read Β·Article 71 / 208
Go

Auto Changelog Golang CI: AI Generate Release Notes from Commit Messages

Automate changelog and release notes creation for a Go project with AI. From commit messages to an informative CHANGELOG.md with GitHub Actions.

IH
Ihsan Arif
Writer at Santekno Β· Backend Engineer

Auto Changelog Golang CI: AI Generate Release Notes from Commits

Auto changelog for a Go project with AI turns the most frequently ignored document into an asset that’s always up to date. Release notes are a document rarely written but most sought after when something goes wrong: “What changed in yesterday’s release?” is the classic question after a production incident. With Conventional Commits as the foundation and AI as the writer, we can generate informative release notes automatically β€” from commit messages to a document readable by developers, QA, and even product managers.

In this article we assemble the complete pipeline in GitHub Actions: commit format, a Go-based generator, semantic version auto-bump, and finally Slack notifications.


11.1 Conventional Commits: The Foundation for Auto-Changelog

Changelog automation is only as good as the commit messages it parses. Conventional Commits is the format that makes every commit machine-categorizable. The reference below maps commit types to their version-bump impact.

text
 1Format: <type>(<scope>): <description>
 2
 3Types:
 4  feat:     new feature (triggers MINOR version bump)
 5  fix:      bug fix (triggers PATCH version bump)
 6  chore:    maintenance (no version bump)
 7  refactor: code refactoring (no version bump)
 8  perf:     performance improvement (triggers PATCH)
 9  security: security fix (triggers PATCH, highlighted)
10  BREAKING CHANGE: breaks existing API (triggers MAJOR)
11
12Examples of good commits:
13  feat(order): implement cancel order with stock restore
14  fix(payment): handle Midtrans callback timeout correctly
15  security(auth): rotate JWT secret rotation mechanism
16  BREAKING CHANGE: rename OrderStatus.Pending to OrderStatus.PENDING
17
18Without Conventional Commits:
19  "fix bug" / "update stuff" / "changes"
20  ← cannot be parsed accurately by AI

Note the direct relationship between commit type and version bump β€” this is what will later make semantic versioning automatic. Without the discipline of this format, the entire pipeline collapses into a list of meaningless hashes.


11.2 Setting Up Conventional Commits in a Go Project

A format is only useful if it’s enforced. The commitlint configuration below restricts the valid types and scopes for Santekno Shop, then installs itself as a git hook so non-standard commits are rejected before they land.

yaml
 1# .commitlintrc.yml
 2extends: ['@commitlint/config-conventional']
 3
 4rules:
 5  type-enum:
 6    - 2  # error
 7    - always
 8    - [feat, fix, chore, refactor, test, docs, perf, security, ci, build]
 9  scope-enum:
10    - 1  # warning only
11    - always
12    - [order, payment, notification, catalog, user, auth, ci, deps, infra]
13  subject-max-length:
14    - 2
15    - always
16    - 72

Once the config exists, install the tooling and its hook so validation runs automatically on every commit.

bash
1# Install commitlint
2npm install -g @commitlint/cli @commitlint/config-conventional
3
4# Git hook (via husky or pre-commit):
5# .git/hooks/commit-msg
6commitlint --edit "$1"

With type-enum at the error level, a commit that doesn’t match the format is rejected immediately β€” forcing discipline from the earliest point. Now our commit history is ready to be read by the generator.


11.3 Release Notes Generator Script

The heart of the system is a Go binary that takes the commits since the last tag, parses them, then selects a generation strategy. The program below uses AI for small releases (quality) and a template for large releases (speed), with an automatic fallback if the AI fails.

go
  1// cmd/release-notes/main.go
  2package main
  3
  4import (
  5    "context"
  6    "fmt"
  7    "os"
  8    "os/exec"
  9    "strings"
 10    "text/template"
 11    "time"
 12
 13    anthropic "github.com/anthropics/anthropic-sdk-go"
 14    "github.com/anthropics/anthropic-sdk-go/option"
 15)
 16
 17type ReleaseContext struct {
 18    Version string
 19    Date    string
 20    PrevTag string
 21    Commits []CommitInfo
 22    Stats   ReleaseStats
 23}
 24
 25type CommitInfo struct {
 26    Hash       string
 27    Type       string
 28    Scope      string
 29    Subject    string
 30    IsBreaking bool
 31}
 32
 33type ReleaseStats struct {
 34    Features, Fixes, Security, Breaking, Other int
 35}
 36
 37func main() {
 38    version := os.Args[1] // e.g., "v1.5.0"
 39    prevTag := getPreviousTag()
 40    commits := getCommitsSince(prevTag)
 41    stats := calculateStats(commits)
 42
 43    ctx := ReleaseContext{
 44        Version: version,
 45        Date:    time.Now().Format("2006-01-02"),
 46        PrevTag: prevTag,
 47        Commits: commits,
 48        Stats:   stats,
 49    }
 50
 51    // Pick strategy based on the number of commits
 52    var notes string
 53    var err error
 54    if len(commits) < 50 {
 55        notes, err = generateWithAI(ctx)
 56    } else {
 57        notes, err = generateFromTemplate(ctx)
 58    }
 59    if err != nil {
 60        fmt.Fprintln(os.Stderr, "Error:", err)
 61        notes, _ = generateFromTemplate(ctx) // Fallback to template
 62    }
 63
 64    fmt.Print(notes)
 65}
 66
 67func getPreviousTag() string {
 68    out, err := exec.Command("git", "describe", "--tags", "--abbrev=0", "HEAD^").Output()
 69    if err != nil {
 70        return "initial"
 71    }
 72    return strings.TrimSpace(string(out))
 73}
 74
 75func getCommitsSince(since string) []CommitInfo {
 76    rangeArg := "HEAD"
 77    if since != "initial" {
 78        rangeArg = since + "..HEAD"
 79    }
 80    out, err := exec.Command("git", "log", rangeArg, "--no-merges", "--format=%h|%s").Output()
 81    if err != nil {
 82        return nil
 83    }
 84
 85    var commits []CommitInfo
 86    for _, line := range strings.Split(strings.TrimSpace(string(out)), "\n") {
 87        parts := strings.SplitN(line, "|", 2)
 88        if len(parts) != 2 {
 89            continue
 90        }
 91        commits = append(commits, parseConventionalCommit(parts[0], parts[1]))
 92    }
 93    return commits
 94}
 95
 96func parseConventionalCommit(hash, subject string) CommitInfo {
 97    isBreaking := strings.Contains(subject, "BREAKING CHANGE")
 98    parts := strings.SplitN(subject, ":", 2)
 99    if len(parts) != 2 {
100        return CommitInfo{Hash: hash, Type: "other", Subject: subject}
101    }
102    typeScope := parts[0]
103    desc := strings.TrimSpace(parts[1])
104
105    commitType, scope := typeScope, ""
106    if idx := strings.Index(typeScope, "("); idx != -1 {
107        commitType = typeScope[:idx]
108        scope = typeScope[idx+1 : len(typeScope)-1]
109    }
110    return CommitInfo{Hash: hash, Type: commitType, Scope: scope, Subject: desc, IsBreaking: isBreaking}
111}
112
113func calculateStats(commits []CommitInfo) ReleaseStats {
114    var s ReleaseStats
115    for _, c := range commits {
116        switch c.Type {
117        case "feat":
118            s.Features++
119        case "fix":
120            s.Fixes++
121        case "security":
122            s.Security++
123        }
124        if c.IsBreaking {
125            s.Breaking++
126        }
127    }
128    s.Other = len(commits) - s.Features - s.Fixes - s.Security
129    return s
130}
131
132func generateWithAI(ctx ReleaseContext) (string, error) {
133    client := anthropic.NewClient(option.WithAPIKey(os.Getenv("ANTHROPIC_API_KEY")))
134
135    commitList := make([]string, len(ctx.Commits))
136    for i, c := range ctx.Commits {
137        line := fmt.Sprintf("%s: %s", c.Type, c.Subject)
138        if c.Scope != "" {
139            line = fmt.Sprintf("%s (%s): %s", c.Type, c.Scope, c.Subject)
140        }
141        if c.IsBreaking {
142            line += " [BREAKING]"
143        }
144        commitList[i] = line
145    }
146
147    prompt := fmt.Sprintf(`Generate release notes for the Santekno Shop Go service.
148
149Version: %s (previous: %s)
150Commits (%d total):
151%s
152
153Stats: %d features, %d fixes, %d security, %d breaking
154
155Write release notes in English with sections:
156## πŸš€ What's New in %s
157### ✨ New Features (group feat, explain user impact)
158### πŸ› Bug Fixes (group fix, what was broken)
159### πŸ”§ Technical Improvements (brief)
160
161Be specific: mention feature names, affected services, user impact.
162Skip test/docs/ci commits unless significant.`,
163        ctx.Version, ctx.PrevTag, len(ctx.Commits), strings.Join(commitList, "\n"),
164        ctx.Stats.Features, ctx.Stats.Fixes, ctx.Stats.Security, ctx.Stats.Breaking,
165        ctx.Version)
166
167    msg, err := client.Messages.New(context.Background(), anthropic.MessageNewParams{
168        Model:     anthropic.F(anthropic.ModelClaudeHaiku4_5),
169        MaxTokens: anthropic.F(int64(1200)),
170        Messages: anthropic.F([]anthropic.MessageParam{
171            anthropic.NewUserMessage(anthropic.NewTextBlock(prompt)),
172        }),
173    })
174    if err != nil {
175        return "", fmt.Errorf("generateWithAI: %w", err)
176    }
177    return msg.Content[0].Text, nil
178}
179
180var releaseTemplate = `## {{ .Version }} ({{ .Date }})
181
182### ✨ New Features
183{{ range .Commits }}{{ if eq .Type "feat" }}- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
184{{ end }}{{ end }}
185### πŸ› Bug Fixes
186{{ range .Commits }}{{ if eq .Type "fix" }}- {{ if .Scope }}**{{ .Scope }}:** {{ end }}{{ .Subject }}
187{{ end }}{{ end }}`
188
189func generateFromTemplate(ctx ReleaseContext) (string, error) {
190    tmpl, err := template.New("release").Parse(releaseTemplate)
191    if err != nil {
192        return "", err
193    }
194    var buf strings.Builder
195    err = tmpl.Execute(&buf, ctx)
196    return buf.String(), err
197}

Two architectural decisions stand out here: parseConventionalCommit turns commit text into structured data that can be counted, and the two-path strategy (generateWithAI vs generateFromTemplate) ensures a release is never delayed because the AI API is down. This binary is what the release workflow will invoke.


11.4 GitHub Actions: Release Workflow

The generator needs to be triggered when a semver tag is pushed. The workflow below builds the binary, generates the notes with a fallback to git log, creates a GitHub Release, then commits the updated CHANGELOG.md.

yaml
 1# .github/workflows/release.yml
 2name: Create Release with Auto Notes
 3
 4on:
 5  push:
 6    tags:
 7      - 'v[0-9]+.[0-9]+.[0-9]+'  # semver tags: v1.2.3
 8
 9env:
10  ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
11
12jobs:
13  create-release:
14    runs-on: ubuntu-latest
15    permissions:
16      contents: write
17    steps:
18      - uses: actions/checkout@v4
19        with:
20          fetch-depth: 0  # Need full history for git log
21
22      - uses: actions/setup-go@v5
23        with:
24          go-version: '1.22'
25          cache: true
26
27      - name: Build release notes generator
28        run: go build -o /tmp/release-notes ./cmd/release-notes/
29
30      - name: Generate release notes
31        id: notes
32        run: |
33          VERSION="${{ github.ref_name }}"
34          NOTES=$(/tmp/release-notes "$VERSION" 2>&1) || {
35            echo "AI generation failed, using git log fallback"
36            NOTES=$(git log $(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")..HEAD \
37              --no-merges --pretty=format:"- %s (%h)" | head -50)
38          }
39          echo "notes<<EOF" >> $GITHUB_OUTPUT
40          echo "$NOTES" >> $GITHUB_OUTPUT
41          echo "EOF" >> $GITHUB_OUTPUT
42
43      - name: Create GitHub Release
44        uses: actions/github-script@v7
45        with:
46          script: |
47            const tag = context.ref.replace('refs/tags/', '');
48            await github.rest.repos.createRelease({
49              owner: context.repo.owner,
50              repo: context.repo.repo,
51              tag_name: tag,
52              name: `Release ${tag}`,
53              body: `${{ steps.notes.outputs.notes }}`,
54              draft: false,
55              prerelease: tag.includes('-'),
56            });
57
58      - name: Commit CHANGELOG.md
59        run: |
60          git config user.name "github-actions[bot]"
61          git config user.email "github-actions[bot]@users.noreply.github.com"
62          git add CHANGELOG.md
63          git diff --cached --quiet || \
64            git commit -m "chore: update CHANGELOG.md for ${{ github.ref_name }} [skip ci]"
65          git push origin HEAD:main

Note the mandatory fetch-depth: 0 β€” without full history, git describe can’t find the previous tag. The git log fallback also matters: the release must still publish even if the AI fails. But who decides the version number on that tag?


11.5 Semantic Versioning with Auto-Bump

Deciding the next version manually is error-prone and inconsistent. The script below analyzes the commit types since the last tag to decide major/minor/patch automatically.

bash
 1#!/bin/bash
 2# scripts/bump-version.sh
 3set -euo pipefail
 4
 5CURRENT=$(git describe --tags --abbrev=0 2>/dev/null || echo "v0.0.0")
 6IFS='.' read -r -a PARTS <<< "${CURRENT#v}"
 7MAJOR=${PARTS[0]}; MINOR=${PARTS[1]}; PATCH=${PARTS[2]}
 8
 9COMMITS=$(git log "${CURRENT}..HEAD" --no-merges --pretty=format:"%s")
10BUMP="patch"  # default
11
12echo "$COMMITS" | grep -q "^BREAKING CHANGE\|^feat!" && BUMP="major"
13echo "$COMMITS" | grep -qE "^feat(\(.+\))?:" && [ "$BUMP" != "major" ] && BUMP="minor"
14
15case $BUMP in
16  major) NEXT="v$((MAJOR+1)).0.0" ;;
17  minor) NEXT="v${MAJOR}.$((MINOR+1)).0" ;;
18  patch) NEXT="v${MAJOR}.${MINOR}.$((PATCH+1))" ;;
19esac
20
21echo "version=$NEXT" >> "${GITHUB_OUTPUT:-/dev/stdout}"
22echo "Next version: $NEXT (bump: $BUMP)"

The priority logic here matters: BREAKING CHANGE wins over feat, feat wins over fix. This way, the semantic versioning rules are enforced by the machine β€” developers don’t have to remember to bump the version number. The result is tidied into the changelog file.


11.6 CHANGELOG.md Format

Release notes need a permanent home that’s easy to read and parse. The format below follows the Keep a Changelog standard, with consistent emoji sections so both tooling and humans are equally comfortable.

markdown
 1# Changelog β€” Santekno Shop Go Service
 2
 3Format: [Keep a Changelog](https://keepachangelog.com)
 4Versioning: [Semantic Versioning](https://semver.org)
 5
 6---
 7
 8## [v1.5.0] β€” 2026-07-20
 9
10### ✨ New Features
11- **order:** Implement cancel order with atomic stock restore
12  Customers can now cancel a PENDING order within 30 minutes
13- **payment:** Add support for automatic refund when an order is cancelled
14
15### πŸ› Bug Fixes
16- **order:** Fix race condition on concurrent cancel requests
17
18### πŸ”’ Security
19- Upgrade pgx to v5.6.0 which fixes CVE-2024-xxxxx
20
21### πŸ”§ Technical
22- Refactor CancelOrderUseCase to extract domain logic into entity methods

The section consistency (Features/Fixes/Security/Technical) is exactly what makes the changelog usable as an audit source: the question “when was CVE-X patched?” can be answered with grep. Next, let’s announce this release to the team.


11.7 Slack/Discord Notification for Releases

A good changelog is useless if the team doesn’t know a new release exists. The step below sends a release notes summary to Slack with a button linking to the release page.

yaml
 1- name: Notify Slack about release
 2  if: success()
 3  run: |
 4    VERSION="${{ github.ref_name }}"
 5    NOTES=$(head -20 <<< "${{ steps.notes.outputs.notes }}")
 6
 7    curl -X POST "${{ secrets.SLACK_WEBHOOK }}" \
 8      -H "Content-Type: application/json" \
 9      -d "$(jq -n \
10        --arg v "$VERSION" --arg n "$NOTES" \
11        --arg url "https://github.com/santekno/santekno-shop/releases/tag/$VERSION" \
12        '{blocks: [
13          {type: "header", text: {type: "plain_text", text: ("πŸš€ New Release: " + $v)}},
14          {type: "section", text: {type: "mrkdwn", text: $n}},
15          {type: "actions", elements: [{
16            type: "button", text: {type: "plain_text", text: "View Release"}, url: $url
17          }]}
18        ]}'
19      )"

With this notification, product managers and QA know what changed without having to monitor tags in the repository. The same pattern can be extended to a monorepo architecture.


11.8 Changelog for Multiple Services in a Monorepo

In a monorepo, a single tag can span many services, so we need a per-service changelog. The script below filters commits touching a specific service path, then calls the AI to summarize them.

bash
 1#!/bin/bash
 2# scripts/generate-service-changelog.sh
 3set -euo pipefail
 4
 5SERVICE="${1}"
 6VERSION="${2}"
 7PREV_TAG="${3:-}"
 8
 9# Take only the commits that touch this service
10if [ -z "$PREV_TAG" ]; then
11  COMMITS=$(git log --no-merges --pretty=format:"%h|%s" -- "services/${SERVICE}/**")
12else
13  COMMITS=$(git log "${PREV_TAG}..HEAD" --no-merges \
14    --pretty=format:"%h|%s" -- "services/${SERVICE}/**")
15fi
16
17[ -z "$COMMITS" ] && { echo "No commits for $SERVICE"; exit 0; }
18
19./scripts/claude-call.sh "Generate changelog for service '${SERVICE}':
20$COMMITS
21
22Format: ## ${SERVICE} v${VERSION} with sections Features/Fixes/Security" 500

The path filter -- "services/${SERVICE}/**" is the key: each service gets a changelog containing only the changes relevant to it. Before the release actually ships, it’s wise to add a quality gate.


11.9 Release Quality Check

A release should not be created if tests fail or a critical bug remains uncovered. The job below runs a pre-release check: all tests pass and no critical bug is open.

yaml
 1pre-release-check:
 2  runs-on: ubuntu-latest
 3  steps:
 4    - uses: actions/checkout@v4
 5
 6    - name: Verify all tests pass
 7      run: go test -race -count=1 ./...
 8
 9    - name: Verify no open critical bugs
10      uses: actions/github-script@v7
11      with:
12        script: |
13          const { data: issues } = await github.rest.issues.listForRepo({
14            owner: context.repo.owner, repo: context.repo.repo,
15            state: 'open', labels: 'critical,production-bug',
16          });
17          if (issues.length > 0) {
18            core.warning(`⚠️ ${issues.length} critical bug(s) still open before release`);
19          } else {
20            console.log('βœ… No critical open bugs');
21          }

Note the use of core.warning instead of exit 1 on the bug check β€” this is a deliberate decision: the team is warned, but the decision to block stays human. Finally, let’s discuss one AI enhancement that’s often underrated.


11.10 AI Enhancement: Release Notes for Non-Technical Audiences

A technical changelog is great for developers, but confusing for product managers. The function below turns a technical changelog into an announcement centered on user impact.

javascript
 1// Generate a user-friendly version from the technical changelog
 2async function generateUserFacingNotes(technicalChangelog) {
 3  const prompt = `Convert this technical changelog to user-friendly English.
 4
 5Technical:
 6${technicalChangelog}
 7
 8Rules:
 9- Target: end users (non-technical)
10- Highlight user impact, not implementation
11- Skip: refactoring, chore, ci, test commits
12- Max: 150 words, bullet points, casual tone
13
14Example:
15  Technical: "feat(order): implement CancelOrderUseCase with atomic stock restore"
16  User-facing: "✨ You can now cancel a freshly-created order within 30 minutes!"`;
17
18  return await callClaude(prompt, 300);
19}

The core of the transformation lives in the prompt’s example: from a technical function name to a sentence a buyer understands. One and the same commit list can spawn two documents for two audiences β€” just swap the prompt.


11.11 Tips & Gotchas

The following practical lessons will keep you clear of the most common traps when adopting auto-changelog.

πŸ’‘ Tip 1: Conventional Commits is an investment. Set up commit linting in a pre-commit hook. Without this format, the quality of the auto-generated changelog is very low.

πŸ’‘ Tip 2: Generate two versions. A technical changelog for developers, user-facing notes for product managers. Same commits, different audiences.

πŸ’‘ Tip 3: AI for summarizing, template for formatting. AI excels at grouping and explaining impact; a template is more reliable for consistent formatting.

⚠️ Gotcha 1: Cluttered merge commits. Always use --no-merges in git log so uninformative merge commits don’t enter the changelog.

⚠️ Gotcha 2: Squash commits. If the team uses squash merge, the squash message must be informative because it becomes the only source.

⚠️ Gotcha 3: fetch-depth: 0 is mandatory. Without full git history, git describe --tags --abbrev=0 can’t find the previous tag.

Gotcha 3 is the most confusing cause of pipeline failure β€” the script runs perfectly locally but fails in CI just because of a shallow checkout.


11.12 Summary

Auto-changelog and release notes are a high-value automation for three parties at once: engineering doesn’t have to write them manually, product managers get automatic notifications, and the compliance team gets a documented history.

Key enabler: Conventional Commits format + consistent git tags = powerful automation.

Investment: set up the commitlint pre-commit hook (30 minutes) + train the team on the format (1 standup). Return: automatic changelog, automatic version bump, release notes with no manual effort.

Every commit with the correct format contributes to a historical documentation that grows more useful β€” searchable, structured, and accurate. The changelog becomes a self-maintained journal of the team’s development.

In the next article, we shift focus to the riskiest operation in deployment: validating database migrations before they touch production.

Related Articles

πŸ’¬ Comments