Automated Changelog Generator with Bash and Conventional Commits
AI generated
$_
#!/
Bash · Git · Automation · Real World
Automated Changelog Generator
using Conventional Commits as your data source

A good changelog generator reads the commit history, recognizes the type and scope of every change, and builds a structured CHANGELOG.md from it, with no manual editing required. This article shows how such a changelog generator can be built as a plain Bash script, what pitfalls appear when parsing Conventional Commits, and how the result plugs cleanly into a release pipeline.

18 min read git log · awk · Conventional Commits · semantic versioning Bash 4.x · 5.x · Git 2.3x

1. Why a changelog generator pays off

In many projects a CHANGELOG.md is maintained by hand, usually as the last step before a release, and usually incomplete. Whoever forgets to add an entry makes it impossible for users and colleagues to understand what changed between two versions. An automated changelog generator solves exactly this problem by pulling the information from where it already lives: the git history itself.

The advantage of a Bash based changelog generator lies in control. Instead of installing a Node package with dozens of dependencies, a single, readable script that works with the tools already on hand, git, awk, and sed, is enough. For teams maintaining many small microservices, that means a script that works everywhere without runtime dependencies and without version conflicts between projects.

For a changelog generator to work reliably, it needs structured input. This is exactly where Conventional Commits come in, a format that splits commit messages into type, scope, and description and makes them machine readable. The following sections build a complete changelog generator step by step, from raw data extraction to the finished Markdown file.

2. Conventional Commits as a structured data source

Conventional Commits define a fixed prefix for every commit message, such as feat:, fix:, docs:, refactor:, or chore:, optionally followed by a scope in parentheses like feat(api):. A changelog generator uses exactly this prefix to automatically assign every line of history to the right category, without anyone having to classify the change afterward.

A breaking change marker is important: either an exclamation mark right after the type, so feat!:, or a BREAKING CHANGE: paragraph in the extended commit body. A clean changelog generator must recognize both variants, because breaking changes always belong in their own, prominently placed section at the top of the changelog, regardless of the rest of the sorting.

Teams switching to Conventional Commits for the first time should enforce the convention through a commit-msg hook with a regular expression. That guarantees the changelog generator will never have to process unstructured messages that it can hardly classify correctly.


#!/usr/bin/env bash
# commit-msg hook — enforces Conventional Commits format
set -euo pipefail

commit_msg_file="$1"
first_line="$(head -n 1 "$commit_msg_file")"

pattern='^(feat|fix|docs|style|refactor|perf|test|build|ci|chore)(\([a-z0-9_-]+\))?!?: .{1,72}$'

if [[ ! "$first_line" =~ $pattern ]]; then
  echo "[ERROR] Commit message does not follow Conventional Commits:" >&2
  echo "  $first_line" >&2
  echo "  Expected: type(scope): description" >&2
  echo "  Allowed types: feat fix docs style refactor perf test build ci chore" >&2
  exit 1
fi

3. Parsing git log: extracting the raw material

The first functional step of every changelog generator is a clean git log call with a defined delimiter, so the output can be processed line by line. Instead of the default output, --pretty=format with a unique separator such as a pipe character is used to cleanly isolate hash, subject line, and author from one another.

A common mistake is not correctly bounding the log range since the last tag. The changelog generator must find the last annotated tag and process only the commits after it, otherwise already published entries reappear in the new section. git describe --tags --abbrev=0 returns the last tag, and git log ${last_tag}..HEAD bounds the range precisely.


#!/usr/bin/env bash
# extract-commits.sh — pull raw commit data since last tag
set -euo pipefail

readonly SEP=$'\x1f'   # unit separator, never appears in commit text
readonly last_tag="$(git describe --tags --abbrev=0 2>/dev/null || echo "")"
readonly range="${last_tag:+${last_tag}..}HEAD"

echo "Generating changelog for range: ${range}" >&2

# Format: hash|type|scope|breaking|subject|author
git log "$range" --no-merges \
  --pretty=format:"%H${SEP}%s${SEP}%an" |
while IFS="$SEP" read -r hash subject author; do
  if [[ "$subject" =~ ^([a-z]+)(\(([a-z0-9_-]+)\))?(!)?:\ (.+)$ ]]; then
    type="${BASH_REMATCH[1]}"
    scope="${BASH_REMATCH[3]:-}"
    breaking="${BASH_REMATCH[4]:-}"
    description="${BASH_REMATCH[5]}"
    printf '%s|%s|%s|%s|%s|%s\n' "$hash" "$type" "$scope" "$breaking" "$description" "$author"
  fi
done

Using the unit separator character \x1f instead of an ordinary pipe symbol prevents the changelog generator from breaking on commit messages that happen to contain a pipe character. This invisible control character practically never appears in normal text, making it a robust yet unobtrusive delimiter for internal data processing.

4. Grouping commits by type

After extraction, all commits are available as pipe delimited records. The changelog generator now has to bundle them by type, so that sections such as features, bug fixes, and breaking changes emerge, each with matching entries underneath. Associative arrays in Bash are excellent for this because they allow key value mappings without external tools.

A clever changelog generator appends a growing list to an array entry for each type, instead of writing a separate file per commit. This keeps processing in memory and avoids unnecessary filesystem access, which makes a noticeable speed difference especially for repositories with several thousand commits.


#!/usr/bin/env bash
# group-by-type.sh — bucket commits into changelog sections
set -euo pipefail

declare -A sections=(
  [feat]="New Features"
  [fix]="Bug Fixes"
  [perf]="Performance"
  [refactor]="Refactoring"
)
declare -A grouped=()
declare -a breaking_changes=()

while IFS='|' read -r hash type scope breaking description author; do
  [[ -z "${sections[$type]:-}" ]] && continue   # skip chore, docs, style, test etc.

  line="- ${description} (${scope:-general}) [${hash:0:7}]"
  grouped["$type"]+="${line}"$'\n'

  if [[ -n "$breaking" ]]; then
    breaking_changes+=("- ${description}")
  fi
done < commits.txt

for type in feat fix perf refactor; do
  if [[ -n "${grouped[$type]:-}" ]]; then
    echo "### ${sections[$type]}"
    echo "${grouped[$type]}"
  fi
done

5. Deriving a version suggestion from commit types

Semantic versioning defines when a major, minor, or patch release is required, and a good changelog generator can derive this suggestion automatically. Every breaking change forces a major release, every new feat commit a minor release, and everything else is enough for a patch release. This logic can be computed directly from the collected types in a few lines of Bash.

The order of checks matters: the changelog generator must first check for breaking changes, then for features, and only last for everything else. If this order is reversed, a minor release can be misclassified as a patch, which leads to incorrect version bumps in semi automated release processes.


#!/usr/bin/env bash
# suggest-version.sh — derive next semver bump from commit types
set -euo pipefail

current_version="$1"   # e.g. 2.4.1
IFS='.' read -r major minor patch <<< "$current_version"

has_breaking=0
has_feat=0

while IFS='|' read -r hash type scope breaking description author; do
  [[ -n "$breaking" ]] && has_breaking=1
  [[ "$type" == "feat" ]] && has_feat=1
done < commits.txt

if (( has_breaking )); then
  echo "$((major + 1)).0.0"
elif (( has_feat )); then
  echo "${major}.$((minor + 1)).0"
else
  echo "${major}.${minor}.$((patch + 1))"
fi

6. Markdown output and merging with the existing file

A changelog generator should never overwrite the existing CHANGELOG.md completely. Instead the new section is inserted at the top, right after the title line, while the rest of the file remains untouched. A temporary merge with cat works well here, writing header, new section, and existing history in exactly this order into a new temporary file.

The changelog generator should also add a comparison link to the previous version, something like [2.5.0]: https://github.com/org/repo/compare/v2.4.1...v2.5.0. Such links make it possible to open the full diff of a version directly on the repository host from within the changelog, without having to search for tags manually.


#!/usr/bin/env bash
# merge-changelog.sh — prepend new section, keep history intact
set -euo pipefail

readonly CHANGELOG="CHANGELOG.md"
readonly TMP_FILE="$(mktemp)"
trap 'rm -f "$TMP_FILE"' EXIT

new_version="$1"
new_section_file="$2"   # generated markdown from group-by-type.sh

{
  echo "# Changelog"
  echo
  echo "## [${new_version}] - $(date +%F)"
  echo
  cat "$new_section_file"
  echo
  # Skip the first line ("# Changelog") of the existing file
  tail -n +2 "$CHANGELOG" 2>/dev/null || true
} > "$TMP_FILE"

mv "$TMP_FILE" "$CHANGELOG"
echo "[OK] CHANGELOG.md updated to version ${new_version}"

7. Wiring the changelog generator into CI/CD

So the changelog generator does not need to be run manually, it belongs in the release pipeline as its own step, executed after merging into the main branch and before pushing the actual tag. In GitLab CI or GitHub Actions, a job running the script, creating the commit with the updated CHANGELOG.md, and setting the new tag is enough.

One important point: the changelog generator must not trigger an infinite loop in the pipeline. Since the commit with the updated CHANGELOG.md would itself trigger another pipeline run, the commit message needs a [skip ci] flag, or the job must be bound to a separate, manually triggered flow.


#!/usr/bin/env bash
# release.sh — CI entry point that runs the full changelog generator chain
set -euo pipefail

version="$(bash suggest-version.sh "$(git describe --tags --abbrev=0)")"
bash extract-commits.sh > commits.txt
bash group-by-type.sh > new-section.md
bash merge-changelog.sh "$version" new-section.md

git add CHANGELOG.md
git commit -m "chore(release): update changelog for v${version} [skip ci]"
git tag -a "v${version}" -m "Release v${version}"
git push origin HEAD --tags

8. Edge cases and common failure sources

A changelog generator tested only on the happy path breaks quickly on real world commits. Merge commits without a Conventional Commits prefix should be excluded from the start with --no-merges, otherwise generic messages like Merge branch main end up as uncategorizable lines in the result. Revert commits deserve their own handling, since git revert automatically produces a Revert "..." prefix that is not a Conventional Commits type.

Another pitfall: multi line commit bodies with a BREAKING CHANGE: paragraph. Whoever reads only the first line with git log --pretty=format:%s misses this marker entirely. The changelog generator therefore also has to read %b and check separately for the keyword, otherwise breaking changes can silently end up classified as regular patches in the worst case.

9. Changelog generator compared to alternatives

There are ready made tools like conventional-changelog-cli or git-cliff that solve the same task, but each brings additional runtime environments or binary dependencies. A self built changelog generator in Bash, by contrast, is available immediately in every container with git, without extra installation, and can be adapted exactly to your own formatting.

Approach Dependencies Adaptability Suited for
Manual CHANGELOG.md None Complete, but error prone Very small projects
Bash changelog generator Only git Fully custom code Microservices, containers without Node
conventional-changelog-cli Node.js, npm packages Configurable via presets JS heavy monorepos
git-cliff (Rust) Separate binary Template based, powerful Large multi repo setups

For teams already relying on Bash based deployment scripts, a custom changelog generator fits seamlessly into the existing toolchain, without requiring additional package managers or runtime environments. Maintenance stays with the exact same team that already maintains the rest of the automation.

Mironsoft

Shell automation, release tooling, and deployment infrastructure

A changelog generator that fits your release process?

We build a tailored changelog generator for you, wire it into your CI/CD pipeline, and make sure Conventional Commits are followed consistently across the whole team.

Script development

A custom changelog generator matching your commit format

CI integration

Seamless integration into GitLab CI, GitHub Actions, or Jenkins

Commit conventions

Hooks and linting so Conventional Commits actually stick

10. Summary

A self built Bash based changelog generator takes the manual, error prone maintenance of a CHANGELOG.md off a team's hands. The prerequisite is a structured data foundation in the form of Conventional Commits, from which type, scope, and breaking change status can be extracted reliably. Git log delivers the raw material, associative arrays group it, and a simple merge step inserts the new section ahead of the existing history.

The biggest benefit appears once the changelog generator is firmly anchored in the release pipeline: every merge into the main branch can automatically suggest the correct next version and create the matching section in the changelog, with no manual intervention required. For teams with many repositories, this significantly reduces maintenance effort and ensures consistent, gapless release documentation.

Changelog generator with Bash, the essentials at a glance

Data foundation

Conventional Commits deliver type, scope, and breaking change marker as a machine readable prefix.

Extraction

git log --pretty=format with a unit separator, bounded to the range since the last tag.

Grouping

Associative arrays bundle commits by type, breaking changes get their own section.

CI integration

Run after every merge into main, commit with [skip ci] to avoid infinite pipelines.

11. FAQ: Automated changelog generator with Bash

1What is a changelog generator?
A script that automatically produces a structured CHANGELOG.md from the git history, instead of changes being added by hand.
2Do I need Conventional Commits?
Not strictly, but without structured prefixes the generator has to guess. Conventional Commits provide a reliable foundation.
3How are breaking changes recognized?
Through an exclamation mark after the type (feat!:) or a BREAKING CHANGE paragraph in the commit body. Both forms need to be checked.
4How is the commit range bounded?
git describe --tags --abbrev=0 returns the last tag, after which only the range up to HEAD is processed.
5How is the next version determined?
Following semantic versioning: breaking change equals major, feat equals minor, everything else equals patch. Check order matters.
6How do I avoid infinite pipelines?
The generator's commit should carry a [skip ci] flag, recognized by CI systems, which prevents a new run for that commit.
7What happens with merge commits?
Exclude them with --no-merges, since they usually lack Conventional Commits structure and would otherwise appear as unclassifiable.
8Are old entries overwritten?
No, the new section is only inserted at the top, the rest of the existing file remains fully intact.
9Why \x1f instead of a pipe as separator?
The unit separator character almost never appears in real text, while a pipe symbol could, which would otherwise break field splitting.
10Is a custom generator worth it?
Yes, for projects without a Node environment or with many repositories, since Bash runs everywhere without extra dependencies and stays flexible.