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.
Table of contents
- 1. Why a changelog generator pays off
- 2. Conventional Commits as a structured data source
- 3. Parsing git log: extracting the raw material
- 4. Grouping commits by type
- 5. Deriving a version suggestion from commit types
- 6. Markdown output and merging with the existing file
- 7. Wiring the changelog generator into CI/CD
- 8. Edge cases and common failure sources
- 9. Changelog generator compared to alternatives
- 10. Summary
- 11. FAQ
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.