← Articles

Automating changelogs in Flutter projects

By Ann Tech · 16 February 2026

Automating changelogs ensures every release has accurate release notes without relying on anyone to remember what changed. With conventional commits and a generation script, changelogs write themselves.

Conventional commits

The foundation is consistent commit messages. Conventional Commits is a specification:

type(scope): description

feat(orders): add order cancellation
fix(auth): fix token refresh loop
docs(readme): update installation guide
chore(deps): bump firebase_core to 3.0.0
refactor(cart): extract CartItemWidget
test(orders): add cancellation unit tests
perf(images): lazy-load product images

Types that appear in changelogs: feat, fix, perf, BREAKING CHANGE. Types that don't: docs, chore, refactor, test, style, ci.

enforce with git hooks

# .git/hooks/commit-msg (or use lefthook/husky)
#!/bin/sh

COMMIT_MSG=$(cat $1)
PATTERN='^(feat|fix|docs|style|refactor|perf|test|chore|ci)(\(.+\))?: .+'

if ! echo "$COMMIT_MSG" | grep -qE "$PATTERN"; then
  echo "ERROR: Commit message must follow Conventional Commits:"
  echo "  feat(scope): description"
  echo "  fix(scope): description"
  exit 1
fi

With Lefthook (better cross-platform support):

# lefthook.yml
commit-msg:
  commands:
    validate:
      run: npx --no -- commitlint --edit {1}

Generating changelogs with git-cliff

git-cliff reads conventional commits and generates a structured changelog:

# Install
brew install git-cliff  # macOS

# Generate changelog since last tag
git cliff --output CHANGELOG.md

# Generate for a specific range
git cliff v1.0.0..HEAD --output CHANGELOG.md

Configuration in cliff.toml:

[changelog]
header = "# Changelog\n\n"
body = """
{% if version %}## {{ version }} - {{ timestamp | date(format="%Y-%m-%d") }}{% else %}## Unreleased{% endif %}
{% for group, commits in commits | group_by(attribute="group") %}
### {{ group | upper_first }}
{% for commit in commits %}
- {{ commit.message }}
{% endfor %}
{% endfor %}
"""

[git]
conventional_commits = true
filter_unconventional = true

commit_parsers = [
  { message = "^feat", group = "Features" },
  { message = "^fix", group = "Bug Fixes" },
  { message = "^perf", group = "Performance" },
  { message = "^refactor", group = "Refactor" },
  { message = "^docs", skip = true },
  { message = "^chore", skip = true },
  { message = "^ci", skip = true },
]

Melos changelog (for monorepos)

In a Melos monorepo, changelogs are per-package:

# melos.yaml
command:
  version:
    message: "chore(release): publish packages\n\n{new_package_versions}"
    includeScopes: true
    linkToCommits: true
    branch: main
    changelogs:
      - path: CHANGELOG.md
        description: All notable changes
# Bump versions and generate changelogs
melos version

# Preview what would be generated
melos version --no-git-commit-version

GitHub Actions workflow

name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  release:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0  # Full history for git-cliff

      - name: Generate changelog
        uses: orhun/git-cliff-action@v3
        id: cliff
        with:
          config: cliff.toml
          args: --latest --strip header
        env:
          OUTPUT: CHANGELOG.md

      - name: Create GitHub Release
        uses: softprops/action-gh-release@v2
        with:
          body: ${{ steps.cliff.outputs.content }}
          token: ${{ secrets.GITHUB_TOKEN }}

Tagging releases

# Tag after bumping version
git tag v2.5.1
git push origin v2.5.1

# Or with Fastlane:
lane :tag_release do
  version = get_version_number
  git_tag(tag: "v#{version}")
  push_git_tags
end

Play Store and App Store release notes

Extract the latest changelog entry for store submission:

# Get the first section from CHANGELOG.md
awk '/^## /{if(found) exit; found=1} found' CHANGELOG.md | tail -n +2 > release_notes.txt

In Fastlane:

lane :deploy_android do
  changelog = File.read('../release_notes.txt').strip
  supply(
    track: 'production',
    rollout: '0.1',
    release_status: 'draft',
    changelog: changelog,
  )
end

Common pitfalls

Inconsistent commit message style. git-cliff only works with conventional commits. Without enforcement (commit-msg hook), developers default to free-form messages and the generated changelog is empty.

Not including fetch-depth: 0 in CI. actions/checkout defaults to a shallow clone. git-cliff needs the full commit history to see all commits since the last tag. Always use fetch-depth: 0.

Generating changelog after tagging. Tag after the changelog is committed, not before. Otherwise the release commit (chore: update changelog) appears in the next version's changelog.

Sign in to like, dislike, or report.

Automating changelogs in Flutter projects — ANN Tech