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.
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.
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 AINote 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.
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 - 72Once the config exists, install the tooling and its hook so validation runs automatically on every commit.
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.
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.
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:mainNote 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.
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.
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 methodsThe 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.
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.
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" 500The 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.
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.
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.