Changelog Automation
Overview¶
Automated changelog generation reduces manual work and ensures consistent release documentation. This guide defines standards for generating changelogs and release notes from conventional commits.
What This Guide Covers¶
- Conventional commit message conventions
- Semantic versioning (SemVer) strategies
- Changelog format (Keep a Changelog)
- GitHub Release Notes configuration
- Automated release workflows
- Pre-release handling (alpha, beta, rc)
- Breaking change communication
- Deprecation notice patterns
Conventional Commits¶
Conventional Commits provide a structured format for commit messages that enables automatic changelog generation and semantic versioning.
Commit Message Format¶
<type>(<scope>): <subject>
<body>
<footer>
Commit Types¶
# Standard commit types with changelog mapping
types:
# Features - triggers MINOR version bump
feat: "A new feature"
# Bug Fixes - triggers PATCH version bump
fix: "A bug fix"
# Documentation - no version bump
docs: "Documentation only changes"
# Styles - no version bump
style: "Code style changes (formatting, semicolons)"
# Refactoring - no version bump
refactor: "Code refactoring without feature/fix"
# Performance - triggers PATCH version bump
perf: "Performance improvements"
# Tests - no version bump
test: "Adding or updating tests"
# Build - no version bump
build: "Build system or external dependencies"
# CI - no version bump
ci: "CI configuration changes"
# Chores - no version bump
chore: "Other changes (no src/test modification)"
# Reverts - inherits from reverted commit
revert: "Reverts a previous commit"
Breaking Changes¶
# Method 1: Exclamation mark in type
feat!: remove deprecated authentication endpoint
# Method 2: BREAKING CHANGE in footer
feat(auth): add OAuth2 support
BREAKING CHANGE: The /auth/login endpoint has been removed.
Use /api/v2/auth/login instead.
# Method 3: Both for emphasis
feat(api)!: redesign user endpoints
BREAKING CHANGE: All user endpoints moved from /users to /api/v2/users.
Migration guide: https://docs.example.com/migration
Example Commits¶
# Feature with scope
git commit -m "feat(auth): add OAuth2 login support"
# Bug fix with issue reference
git commit -m "fix(database): resolve connection pool memory leak
Fixes #125"
# Performance improvement
git commit -m "perf(queries): optimize user list with proper indexing
Reduces query time from 500ms to 50ms for 10k+ records"
# Breaking change with migration notes
git commit -m "feat(api)!: move endpoints to v2
BREAKING CHANGE: All API endpoints moved from /api to /api/v2.
Migration steps:
1. Update base URL in configuration
2. Review response format changes
3. Update client SDK to v2.x
Fixes #200"
# Documentation update
git commit -m "docs(readme): update installation instructions"
# Chore with multiple scopes
git commit -m "chore(deps): update dependencies
- Bump lodash from 4.17.20 to 4.17.21
- Bump axios from 0.21.1 to 0.21.4"
Commitlint Configuration¶
// commitlint.config.js
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
// Type must be one of the defined types
'type-enum': [
2,
'always',
[
'feat',
'fix',
'docs',
'style',
'refactor',
'perf',
'test',
'build',
'ci',
'chore',
'revert',
],
],
// Type must be lowercase
'type-case': [2, 'always', 'lower-case'],
// Subject must not be empty
'subject-empty': [2, 'never'],
// Subject must not end with period
'subject-full-stop': [2, 'never', '.'],
// Subject must be sentence case
'subject-case': [2, 'always', 'sentence-case'],
// Header max length 100 characters
'header-max-length': [2, 'always', 100],
// Body max line length 100 characters
'body-max-line-length': [2, 'always', 100],
// Footer max line length 100 characters
'footer-max-line-length': [2, 'always', 100],
},
};
Pre-commit Hook for Commits¶
# .pre-commit-config.yaml
repos:
- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook
rev: v9.5.0
hooks:
- id: commitlint
stages: [commit-msg]
additional_dependencies:
- "@commitlint/config-conventional"
GitHub Actions Commit Validation¶
# .github/workflows/commit-lint.yml
name: Commit Lint
on:
pull_request:
types: [opened, edited, synchronize, reopened]
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install commitlint
run: |
npm install --save-dev @commitlint/cli @commitlint/config-conventional
- name: Create commitlint config
run: |
cat > commitlint.config.js << 'EOF'
module.exports = {
extends: ['@commitlint/config-conventional'],
rules: {
'type-enum': [2, 'always', [
'feat', 'fix', 'docs', 'style', 'refactor',
'perf', 'test', 'build', 'ci', 'chore', 'revert'
]],
'subject-case': [2, 'always', 'sentence-case'],
},
};
EOF
- name: Validate PR commits
run: |
npx commitlint --from ${{ github.event.pull_request.base.sha }} \
--to ${{ github.event.pull_request.head.sha }} \
--verbose
Semantic Versioning¶
Version Format¶
MAJOR.MINOR.PATCH[-PRERELEASE][+BUILD]
Examples:
1.0.0 # Initial stable release
1.1.0 # Minor feature release
1.1.1 # Patch bug fix
2.0.0 # Major breaking change
2.0.0-alpha.1 # Alpha pre-release
2.0.0-beta.2 # Beta pre-release
2.0.0-rc.1 # Release candidate
2.0.0+build.123 # Build metadata
Version Bump Rules¶
# Version bump mapping from commit types
version_bumps:
# MAJOR (X.0.0) - Breaking changes
major:
- "BREAKING CHANGE in footer"
- "! after type (feat!, fix!)"
- "Explicit major label"
# MINOR (0.X.0) - New features
minor:
- "feat: new feature"
- "feat(scope): scoped feature"
# PATCH (0.0.X) - Bug fixes and improvements
patch:
- "fix: bug fix"
- "perf: performance improvement"
- "docs: documentation (optional)"
- "refactor: code changes (optional)"
# NO BUMP - Internal changes
none:
- "style: formatting"
- "test: test changes"
- "ci: CI changes"
- "chore: maintenance"
- "build: build system"
Pre-release Versions¶
# Pre-release version progression
prerelease_flow:
# Development starts
- "1.0.0"
# Alpha releases (internal testing)
- "2.0.0-alpha.1"
- "2.0.0-alpha.2"
- "2.0.0-alpha.3"
# Beta releases (limited external testing)
- "2.0.0-beta.1"
- "2.0.0-beta.2"
# Release candidates (final testing)
- "2.0.0-rc.1"
- "2.0.0-rc.2"
# Stable release
- "2.0.0"
Changelog Format¶
Keep a Changelog Standard¶
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- New feature pending release
### Changed
- Updated behavior pending release
## [1.5.0] - 2025-01-10
### Added
- Add user authentication with OAuth2 ([#123](https://github.com/org/repo/pull/123))
- Implement caching layer with Redis ([#124](https://github.com/org/repo/pull/124))
### Changed
- Update API response format for consistency ([#130](https://github.com/org/repo/pull/130))
### Deprecated
- The `/auth/login` endpoint is deprecated, use `/api/v2/auth/login` instead
### Removed
- Remove legacy XML export feature ([#131](https://github.com/org/repo/pull/131))
### Fixed
- Fix memory leak in connection pool ([#125](https://github.com/org/repo/pull/125))
- Resolve race condition in user service ([#126](https://github.com/org/repo/pull/126))
### Security
- Update dependencies to patch CVE-2025-12345 ([#132](https://github.com/org/repo/pull/132))
## [1.4.0] - 2025-01-03
### Added
- Initial release features
## [1.3.0] - 2024-12-15
...
[Unreleased]: https://github.com/org/repo/compare/v1.5.0...HEAD
[1.5.0]: https://github.com/org/repo/compare/v1.4.0...v1.5.0
[1.4.0]: https://github.com/org/repo/compare/v1.3.0...v1.4.0
[1.3.0]: https://github.com/org/repo/releases/tag/v1.3.0
Changelog Sections¶
# Mapping conventional commits to changelog sections
changelog_sections:
Added:
- feat
Changed:
- refactor
- perf
Deprecated:
- "Commits mentioning deprecation"
Removed:
- "Commits removing features"
Fixed:
- fix
Security:
- "Commits with security fixes"
- "Dependency updates for CVEs"
# Hidden from changelog (internal changes)
hidden_sections:
- style
- test
- ci
- chore
- build
- docs # Optional: include if valuable
Conventional Changelog Configuration¶
standard-version (Node.js)¶
{
"scripts": {
"release": "standard-version",
"release:minor": "standard-version --release-as minor",
"release:major": "standard-version --release-as major",
"release:patch": "standard-version --release-as patch",
"release:alpha": "standard-version --prerelease alpha",
"release:beta": "standard-version --prerelease beta",
"release:rc": "standard-version --prerelease rc"
},
"standard-version": {
"types": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance Improvements" },
{ "type": "docs", "section": "Documentation" },
{ "type": "style", "hidden": true },
{ "type": "refactor", "section": "Code Refactoring" },
{ "type": "test", "hidden": true },
{ "type": "build", "hidden": true },
{ "type": "ci", "hidden": true },
{ "type": "chore", "hidden": true }
],
"commitUrlFormat": "https://github.com/{{owner}}/{{repository}}/commit/{{hash}}",
"compareUrlFormat": "https://github.com/{{owner}}/{{repository}}/compare/{{previousTag}}...{{currentTag}}",
"issueUrlFormat": "https://github.com/{{owner}}/{{repository}}/issues/{{id}}",
"userUrlFormat": "https://github.com/{{user}}",
"releaseCommitMessageFormat": "chore(release): {{currentTag}}",
"header": "# Changelog\n\nAll notable changes to this project will be documented in this file.\n\n",
"skip": {
"tag": false,
"commit": false,
"changelog": false
}
}
}
.versionrc Configuration File¶
{
"types": [
{ "type": "feat", "section": "Features" },
{ "type": "fix", "section": "Bug Fixes" },
{ "type": "perf", "section": "Performance" },
{ "type": "docs", "section": "Documentation", "hidden": false },
{ "type": "style", "hidden": true },
{ "type": "refactor", "section": "Refactoring" },
{ "type": "test", "hidden": true },
{ "type": "build", "section": "Build System" },
{ "type": "ci", "hidden": true },
{ "type": "chore", "hidden": true },
{ "type": "revert", "section": "Reverts" }
],
"preMajor": false,
"commitUrlFormat": "{{host}}/{{owner}}/{{repository}}/commit/{{hash}}",
"compareUrlFormat": "{{host}}/{{owner}}/{{repository}}/compare/{{previousTag}}...{{currentTag}}",
"issueUrlFormat": "{{host}}/{{owner}}/{{repository}}/issues/{{id}}",
"releaseCommitMessageFormat": "chore(release): {{currentTag}}\n\n{{changelog}}",
"bumpFiles": [
{
"filename": "package.json",
"type": "json"
},
{
"filename": "pyproject.toml",
"updater": "scripts/version-updater.js"
},
{
"filename": "version.txt",
"type": "plain-text"
}
],
"packageFiles": [
{
"filename": "package.json",
"type": "json"
}
]
}
Python Version Updater¶
// scripts/version-updater.js
// Custom updater for pyproject.toml
module.exports.readVersion = function (contents) {
const match = contents.match(/version\s*=\s*"([^"]+)"/);
return match ? match[1] : null;
};
module.exports.writeVersion = function (contents, version) {
return contents.replace(
/version\s*=\s*"[^"]+"/,
`version = "${version}"`
);
};
Python Changelog Generation¶
#!/usr/bin/env python3
"""
@module changelog_generator
@description Generate changelog from conventional commits
@version 1.0.0
@author Tyler Dukes
@last_updated 2025-01-25
@status stable
"""
import re
import subprocess
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
@dataclass
class Commit:
"""Represents a parsed conventional commit."""
hash: str
type: str
scope: Optional[str]
subject: str
body: Optional[str]
breaking: bool
references: list[str]
def parse_commit(message: str, hash: str) -> Optional[Commit]:
"""Parse a commit message into structured format."""
pattern = r"^(\w+)(?:\(([^)]+)\))?(!)?:\s*(.+)$"
match = re.match(pattern, message.split("\n")[0])
if not match:
return None
type_, scope, breaking, subject = match.groups()
body = "\n".join(message.split("\n")[1:]).strip() or None
breaking = breaking == "!" or "BREAKING CHANGE" in message
references = re.findall(r"#(\d+)", message)
return Commit(
hash=hash,
type=type_,
scope=scope,
subject=subject,
body=body,
breaking=breaking,
references=references,
)
def get_commits_since_tag(tag: str) -> list[Commit]:
"""Get all commits since the specified tag."""
result = subprocess.run(
["git", "log", f"{tag}..HEAD", "--format=%H|%B|END_COMMIT"],
capture_output=True,
text=True,
check=True,
)
commits = []
for entry in result.stdout.split("|END_COMMIT"):
entry = entry.strip()
if not entry:
continue
parts = entry.split("|", 1)
if len(parts) == 2:
commit = parse_commit(parts[1], parts[0])
if commit:
commits.append(commit)
return commits
def generate_changelog(
commits: list[Commit],
version: str,
date: Optional[str] = None,
) -> str:
"""Generate changelog markdown from commits."""
if date is None:
date = datetime.now().strftime("%Y-%m-%d")
sections = {
"Features": [],
"Bug Fixes": [],
"Performance": [],
"Documentation": [],
"Refactoring": [],
"Breaking Changes": [],
}
type_mapping = {
"feat": "Features",
"fix": "Bug Fixes",
"perf": "Performance",
"docs": "Documentation",
"refactor": "Refactoring",
}
for commit in commits:
if commit.breaking:
sections["Breaking Changes"].append(commit)
section = type_mapping.get(commit.type)
if section:
sections[section].append(commit)
output = [f"## [{version}] - {date}\n"]
for section, section_commits in sections.items():
if not section_commits:
continue
output.append(f"\n### {section}\n")
for commit in section_commits:
scope = f"**{commit.scope}**: " if commit.scope else ""
refs = "".join(f" ([#{r}](issues/{r}))" for r in commit.references)
output.append(f"- {scope}{commit.subject}{refs}")
return "\n".join(output)
if __name__ == "__main__":
import sys
tag = sys.argv[1] if len(sys.argv) > 1 else "v0.0.0"
version = sys.argv[2] if len(sys.argv) > 2 else "Unreleased"
commits = get_commits_since_tag(tag)
changelog = generate_changelog(commits, version)
print(changelog)
GitHub Release Notes¶
Release Drafter Configuration¶
# .github/release-drafter.yml
name-template: "v$RESOLVED_VERSION"
tag-template: "v$RESOLVED_VERSION"
template: |
## What's Changed
$CHANGES
**Full Changelog**: https://github.com/$OWNER/$REPOSITORY/compare/$PREVIOUS_TAG...v$RESOLVED_VERSION
categories:
- title: "Breaking Changes"
labels:
- "breaking-change"
- "major"
collapse-after: 0
- title: "Features"
labels:
- "feature"
- "enhancement"
- "type:feature"
- title: "Bug Fixes"
labels:
- "bug"
- "fix"
- "type:bug"
- title: "Security"
labels:
- "security"
- "type:security"
collapse-after: 0
- title: "Performance"
labels:
- "performance"
- title: "Documentation"
labels:
- "documentation"
- "type:docs"
- title: "Dependencies"
labels:
- "dependencies"
- "scope:dependencies"
collapse-after: 5
- title: "Maintenance"
labels:
- "chore"
- "maintenance"
- "type:maintenance"
collapse-after: 3
change-template: "- $TITLE @$AUTHOR (#$NUMBER)"
change-title-escapes: '\<*_&'
version-resolver:
major:
labels:
- "major"
- "breaking-change"
minor:
labels:
- "minor"
- "feature"
- "enhancement"
patch:
labels:
- "patch"
- "bug"
- "fix"
- "documentation"
- "maintenance"
default: patch
autolabeler:
- label: "feature"
title:
- "/^feat(\\(.+\\))?:/i"
- label: "bug"
title:
- "/^fix(\\(.+\\))?:/i"
- label: "documentation"
title:
- "/^docs(\\(.+\\))?:/i"
- label: "maintenance"
title:
- "/^chore(\\(.+\\))?:/i"
- "/^ci(\\(.+\\))?:/i"
- label: "performance"
title:
- "/^perf(\\(.+\\))?:/i"
- label: "breaking-change"
title:
- "/^\\w+!:/i"
body:
- "/BREAKING CHANGE/i"
exclude-labels:
- "skip-changelog"
- "wontfix"
- "duplicate"
no-changes-template: "No notable changes in this release."
replacers:
- search: '/\[skip ci\]/g'
replace: ""
- search: '/\[ci skip\]/g'
replace: ""
Release Drafter Workflow¶
# .github/workflows/release-drafter.yml
name: Release Drafter
on:
push:
branches:
- main
pull_request:
types:
- opened
- reopened
- synchronize
permissions:
contents: read
pull-requests: write
jobs:
update_release_draft:
runs-on: ubuntu-latest
steps:
- name: Draft Release
uses: release-drafter/release-drafter@v6
with:
config-name: release-drafter.yml
disable-autolabeler: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
Manual Release Notes Template¶
# Release v1.5.0
> Release date: 2025-01-10
## Highlights
Briefly describe the most important changes in this release.
## Features
- **Authentication**: Add OAuth2 login support (#123) @developer1
- Users can now login with Google, GitHub, or Microsoft accounts
- Includes automatic account linking for existing users
- **Caching**: Implement Redis caching layer (#124) @developer2
- Reduces database load by 60%
- Improves API response times by 40%
## Bug Fixes
- **Database**: Fix connection pool memory leak (#125) @developer3
- **Concurrency**: Resolve race condition in user service (#126) @developer4
## Performance
- **Queries**: Optimize database queries with proper indexing (#127)
- User list endpoint now 10x faster
## Documentation
- Update API docs for v1.5 endpoints (#128)
## Breaking Changes
> **IMPORTANT**: This release contains breaking changes
### API Endpoint Migration
| Old Endpoint | New Endpoint | Notes |
|-------------|--------------|-------|
| `/auth/login` | `/api/v2/auth/login` | Response format changed |
| `/users/create` | `/api/v2/users` | Use POST method |
### Configuration Changes
```yaml
# Before (deprecated)
auth:
endpoint: "/auth"
# After (required)
auth:
endpoint: "/api/v2/auth"
version: 2
```
## Deprecations
The following features are deprecated and will be removed in v2.0.0:
| Feature | Deprecated In | Removal Target | Replacement |
|---------|---------------|----------------|-------------|
| XML export | v1.5.0 | v2.0.0 | JSON export |
| Basic auth | v1.5.0 | v2.0.0 | OAuth2 |
## Migration Guide
See [MIGRATION_GUIDE.md](https://github.com/org/repo/blob/main/docs/MIGRATION_GUIDE.md)
for detailed upgrade instructions.
### Quick Migration
```bash
# Update configuration
sed -i 's|/auth|/api/v2/auth|g' config.yaml
# Update client SDK
npm install @company/sdk@2.0.0
```
## Security
- Update lodash to patch CVE-2025-12345 (#132)
- Add rate limiting to authentication endpoints
## Dependencies
<details>
<summary>Dependency updates</summary>
- Bump axios from 0.21.1 to 1.6.0
- Bump lodash from 4.17.20 to 4.17.21
- Bump webpack from 5.75.0 to 5.89.0
</details>
## Contributors
Thank you to all contributors:
- @developer1 - OAuth2 implementation
- @developer2 - Caching layer
- @developer3 - Database fixes
- @developer4 - Concurrency fixes
## Checksums
SHA256 (release-v1.5.0.tar.gz) = abc123...
SHA256 (release-v1.5.0.zip) = def456...
**Full Changelog**: <https://github.com/org/repo/compare/v1.4.0...v1.5.0>
Automated Release Workflows¶
Complete Release Workflow¶
# .github/workflows/release.yml
name: Release
on:
workflow_dispatch:
inputs:
version_bump:
description: "Version bump type"
required: true
type: choice
options:
- auto
- patch
- minor
- major
default: auto
prerelease:
description: "Pre-release identifier (alpha, beta, rc, or empty)"
required: false
type: string
default: ""
dry_run:
description: "Dry run (no actual release)"
required: false
type: boolean
default: false
permissions:
contents: write
pull-requests: write
jobs:
release:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.version }}
tag: ${{ steps.version.outputs.tag }}
changelog: ${{ steps.changelog.outputs.changelog }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_TOKEN }}
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "20"
- name: Get current version
id: current
run: |
CURRENT=$(cat package.json | jq -r '.version')
echo "version=$CURRENT" >> $GITHUB_OUTPUT
echo "Current version: $CURRENT"
- name: Determine version bump
id: bump
run: |
if [ "${{ inputs.version_bump }}" = "auto" ]; then
# Analyze commits since last tag
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$LAST_TAG" ]; then
echo "type=minor" >> $GITHUB_OUTPUT
exit 0
fi
# Check for breaking changes
if git log $LAST_TAG..HEAD --format=%B | grep -qE "^(\w+)!:|BREAKING CHANGE"; then
echo "type=major" >> $GITHUB_OUTPUT
# Check for features
elif git log $LAST_TAG..HEAD --format=%s | grep -qE "^feat(\(.+\))?:"; then
echo "type=minor" >> $GITHUB_OUTPUT
else
echo "type=patch" >> $GITHUB_OUTPUT
fi
else
echo "type=${{ inputs.version_bump }}" >> $GITHUB_OUTPUT
fi
- name: Calculate new version
id: version
run: |
CURRENT="${{ steps.current.outputs.version }}"
BUMP="${{ steps.bump.outputs.type }}"
PRERELEASE="${{ inputs.prerelease }}"
# Parse current version
IFS='.-' read -r MAJOR MINOR PATCH PRE <<< "$CURRENT"
# Calculate new version
case $BUMP in
major)
NEW_VERSION="$((MAJOR + 1)).0.0"
;;
minor)
NEW_VERSION="$MAJOR.$((MINOR + 1)).0"
;;
patch)
NEW_VERSION="$MAJOR.$MINOR.$((PATCH + 1))"
;;
esac
# Add pre-release suffix
if [ -n "$PRERELEASE" ]; then
# Find next pre-release number
EXISTING=$(git tag -l "v${NEW_VERSION}-${PRERELEASE}.*" | sort -V | tail -1)
if [ -n "$EXISTING" ]; then
PRE_NUM=$(echo "$EXISTING" | grep -oE '[0-9]+$')
NEW_VERSION="${NEW_VERSION}-${PRERELEASE}.$((PRE_NUM + 1))"
else
NEW_VERSION="${NEW_VERSION}-${PRERELEASE}.1"
fi
fi
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "tag=v$NEW_VERSION" >> $GITHUB_OUTPUT
echo "New version: $NEW_VERSION"
- name: Generate changelog
id: changelog
run: |
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
VERSION="${{ steps.version.outputs.version }}"
if [ -z "$LAST_TAG" ]; then
COMMITS=$(git log --format="- %s (%h)" | head -50)
else
COMMITS=$(git log $LAST_TAG..HEAD --format="- %s (%h)")
fi
# Generate structured changelog
FEATURES=$(echo "$COMMITS" | grep -E "^- feat" || true)
FIXES=$(echo "$COMMITS" | grep -E "^- fix" || true)
PERF=$(echo "$COMMITS" | grep -E "^- perf" || true)
DOCS=$(echo "$COMMITS" | grep -E "^- docs" || true)
BREAKING=$(echo "$COMMITS" | grep -E "^- \w+!:" || true)
CHANGELOG=""
if [ -n "$BREAKING" ]; then
CHANGELOG+="### Breaking Changes\n$BREAKING\n\n"
fi
if [ -n "$FEATURES" ]; then
CHANGELOG+="### Features\n$FEATURES\n\n"
fi
if [ -n "$FIXES" ]; then
CHANGELOG+="### Bug Fixes\n$FIXES\n\n"
fi
if [ -n "$PERF" ]; then
CHANGELOG+="### Performance\n$PERF\n\n"
fi
# Save to file for artifact
echo -e "$CHANGELOG" > changelog-$VERSION.md
# Output for use in workflow
echo "changelog<<EOF" >> $GITHUB_OUTPUT
echo -e "$CHANGELOG" >> $GITHUB_OUTPUT
echo "EOF" >> $GITHUB_OUTPUT
- name: Update version files
if: ${{ !inputs.dry_run }}
run: |
VERSION="${{ steps.version.outputs.version }}"
# Update package.json
jq ".version = \"$VERSION\"" package.json > tmp.json
mv tmp.json package.json
# Update pyproject.toml if exists
if [ -f pyproject.toml ]; then
sed -i "s/^version = .*/version = \"$VERSION\"/" pyproject.toml
fi
# Update version.txt if exists
if [ -f version.txt ]; then
echo "$VERSION" > version.txt
fi
- name: Update CHANGELOG.md
if: ${{ !inputs.dry_run }}
run: |
VERSION="${{ steps.version.outputs.version }}"
DATE=$(date +%Y-%m-%d)
CHANGELOG="${{ steps.changelog.outputs.changelog }}"
# Create new changelog entry
NEW_ENTRY="## [$VERSION] - $DATE\n\n$CHANGELOG"
# Insert after header
if [ -f CHANGELOG.md ]; then
sed -i "/^## \[Unreleased\]/a\\
\\
$NEW_ENTRY" CHANGELOG.md
else
cat > CHANGELOG.md << EOF
# Changelog
All notable changes to this project will be documented in this file.
## [Unreleased]
$NEW_ENTRY
EOF
fi
- name: Commit version bump
if: ${{ !inputs.dry_run }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add -A
git commit -m "chore(release): ${{ steps.version.outputs.tag }}"
git push
- name: Create tag
if: ${{ !inputs.dry_run }}
run: |
git tag -a "${{ steps.version.outputs.tag }}" \
-m "Release ${{ steps.version.outputs.tag }}"
git push origin "${{ steps.version.outputs.tag }}"
- name: Create GitHub Release
if: ${{ !inputs.dry_run }}
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: Release ${{ steps.version.outputs.tag }}
body: ${{ steps.changelog.outputs.changelog }}
draft: false
prerelease: ${{ inputs.prerelease != '' }}
generate_release_notes: true
- name: Dry run summary
if: ${{ inputs.dry_run }}
run: |
echo "## Dry Run Summary" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "**Version**: ${{ steps.version.outputs.version }}" >> $GITHUB_STEP_SUMMARY
echo "**Tag**: ${{ steps.version.outputs.tag }}" >> $GITHUB_STEP_SUMMARY
echo "" >> $GITHUB_STEP_SUMMARY
echo "### Changelog" >> $GITHUB_STEP_SUMMARY
echo "${{ steps.changelog.outputs.changelog }}" >> $GITHUB_STEP_SUMMARY
Tag-Based Release Workflow¶
# .github/workflows/release-on-tag.yml
name: Release on Tag
on:
push:
tags:
- "v*.*.*"
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Get tag info
id: tag
run: |
TAG=${GITHUB_REF#refs/tags/}
VERSION=${TAG#v}
echo "tag=$TAG" >> $GITHUB_OUTPUT
echo "version=$VERSION" >> $GITHUB_OUTPUT
# Check if pre-release
if [[ "$VERSION" == *"-"* ]]; then
echo "prerelease=true" >> $GITHUB_OUTPUT
else
echo "prerelease=false" >> $GITHUB_OUTPUT
fi
- name: Get previous tag
id: previous
run: |
PREVIOUS=$(git describe --tags --abbrev=0 HEAD^ 2>/dev/null || echo "")
echo "tag=$PREVIOUS" >> $GITHUB_OUTPUT
- name: Generate release notes
id: notes
run: |
TAG="${{ steps.tag.outputs.tag }}"
PREVIOUS="${{ steps.previous.outputs.tag }}"
if [ -z "$PREVIOUS" ]; then
RANGE="HEAD"
else
RANGE="$PREVIOUS..HEAD"
fi
# Generate categorized notes
cat > release-notes.md << 'EOF'
## What's Changed
EOF
# Breaking changes
BREAKING=$(git log $RANGE --format="- %s (%h)" | grep -E "^- \w+!:" || true)
if [ -n "$BREAKING" ]; then
echo -e "### Breaking Changes\n\n$BREAKING\n" >> release-notes.md
fi
# Features
FEATURES=$(git log $RANGE --format="- %s (%h)" | grep -E "^- feat" || true)
if [ -n "$FEATURES" ]; then
echo -e "### Features\n\n$FEATURES\n" >> release-notes.md
fi
# Bug fixes
FIXES=$(git log $RANGE --format="- %s (%h)" | grep -E "^- fix" || true)
if [ -n "$FIXES" ]; then
echo -e "### Bug Fixes\n\n$FIXES\n" >> release-notes.md
fi
# Other changes
OTHER=$(git log $RANGE --format="- %s (%h)" | grep -vE "^- (feat|fix|\w+!:)" || true)
if [ -n "$OTHER" ]; then
echo -e "### Other Changes\n\n$OTHER\n" >> release-notes.md
fi
cat release-notes.md
- name: Create Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: Release ${{ steps.tag.outputs.tag }}
body_path: release-notes.md
draft: false
prerelease: ${{ steps.tag.outputs.prerelease == 'true' }}
generate_release_notes: true
Scheduled Release Workflow¶
# .github/workflows/scheduled-release.yml
name: Scheduled Release
on:
schedule:
# Every Monday at 9 AM UTC
- cron: "0 9 * * 1"
workflow_dispatch:
permissions:
contents: write
pull-requests: write
jobs:
check-changes:
runs-on: ubuntu-latest
outputs:
has_changes: ${{ steps.check.outputs.has_changes }}
commits: ${{ steps.check.outputs.commits }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Check for unreleased changes
id: check
run: |
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
if [ -z "$LAST_TAG" ]; then
COMMITS=$(git log --oneline | wc -l)
else
COMMITS=$(git log $LAST_TAG..HEAD --oneline | wc -l)
fi
if [ "$COMMITS" -gt 0 ]; then
echo "has_changes=true" >> $GITHUB_OUTPUT
echo "commits=$COMMITS" >> $GITHUB_OUTPUT
echo "Found $COMMITS unreleased commits"
else
echo "has_changes=false" >> $GITHUB_OUTPUT
echo "commits=0" >> $GITHUB_OUTPUT
echo "No unreleased changes"
fi
release:
needs: check-changes
if: needs.check-changes.outputs.has_changes == 'true'
uses: ./.github/workflows/release.yml
with:
version_bump: auto
secrets: inherit
Pre-release Management¶
Pre-release Workflow¶
# .github/workflows/prerelease.yml
name: Pre-release
on:
workflow_dispatch:
inputs:
type:
description: "Pre-release type"
required: true
type: choice
options:
- alpha
- beta
- rc
default: alpha
jobs:
prerelease:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Calculate version
id: version
run: |
# Get latest stable version
STABLE=$(git tag -l "v*.*.*" | grep -v "-" | sort -V | tail -1)
STABLE=${STABLE:-v0.0.0}
VERSION=${STABLE#v}
# Parse version
IFS='.' read -r MAJOR MINOR PATCH <<< "$VERSION"
# Increment minor for pre-release
NEXT_VERSION="$MAJOR.$((MINOR + 1)).0"
# Find next pre-release number
TYPE="${{ inputs.type }}"
EXISTING=$(git tag -l "v${NEXT_VERSION}-${TYPE}.*" | sort -V | tail -1)
if [ -n "$EXISTING" ]; then
NUM=$(echo "$EXISTING" | grep -oE '[0-9]+$')
PRE_VERSION="${NEXT_VERSION}-${TYPE}.$((NUM + 1))"
else
PRE_VERSION="${NEXT_VERSION}-${TYPE}.1"
fi
echo "version=$PRE_VERSION" >> $GITHUB_OUTPUT
echo "tag=v$PRE_VERSION" >> $GITHUB_OUTPUT
- name: Create pre-release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.version.outputs.tag }}
name: ${{ steps.version.outputs.tag }}
prerelease: true
generate_release_notes: true
Pre-release Branch Strategy¶
gitGraph
commit id: "main"
branch develop
commit id: "feat: new feature"
commit id: "fix: bug fix"
branch release/2.0.0
commit id: "2.0.0-alpha.1" tag: "v2.0.0-alpha.1"
commit id: "fix: alpha feedback"
commit id: "2.0.0-alpha.2" tag: "v2.0.0-alpha.2"
commit id: "2.0.0-beta.1" tag: "v2.0.0-beta.1"
commit id: "2.0.0-rc.1" tag: "v2.0.0-rc.1"
checkout main
merge release/2.0.0 id: "2.0.0" tag: "v2.0.0"
Breaking Change Communication¶
Breaking Change Template¶
## Breaking Changes in v2.0.0
### Summary
Brief description of what changed and why.
### Changes
#### 1. API Endpoint Migration
**Before (v1.x)**:
```bash
curl -X POST https://api.example.com/auth/login \
-d '{"username": "user", "password": "pass"}'
After (v2.x):
curl -X POST https://api.example.com/api/v2/auth/login \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "pass"}'
Migration Steps:
- Update base URL from
/authto/api/v2/auth - Change
usernamefield toemail - Add
Content-Typeheader
2. Configuration Schema Change¶
Before (v1.x):
database:
host: localhost
port: 5432
After (v2.x):
database:
connection:
host: localhost
port: 5432
pool:
min: 5
max: 20
Deprecation Timeline¶
| Version | Date | Action |
|---|---|---|
| v1.5.0 | 2025-01-01 | Deprecation warning added |
| v1.6.0 | 2025-02-01 | Warning becomes error in strict mode |
| v2.0.0 | 2025-03-01 | Feature removed |
Automated Migration¶
# Run migration script
npx @company/migrate-v2
# Or manually
sed -i 's|/auth|/api/v2/auth|g' config.yaml
Support¶
For migration assistance:
- Documentation: Migration Guide
- Issues: GitHub Issues
- Discussion: GitHub Discussions
# End of breaking change template
Deprecation Notice Format¶
import warnings
from functools import wraps
from typing import Callable
def deprecated(
version: str,
removal_version: str,
replacement: str | None = None,
) -> Callable:
"""Mark a function as deprecated.
Args:
version: Version when deprecated
removal_version: Version when will be removed
replacement: Suggested replacement function
Returns:
Decorated function with deprecation warning
"""
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(*args, **kwargs):
message = (
f"{func.__name__} is deprecated since v{version} "
f"and will be removed in v{removal_version}."
)
if replacement:
message += f" Use {replacement} instead."
warnings.warn(message, DeprecationWarning, stacklevel=2)
return func(*args, **kwargs)
return wrapper
return decorator
# Usage example
@deprecated(
version="1.5.0",
removal_version="2.0.0",
replacement="authenticate_oauth",
)
def authenticate_basic(username: str, password: str) -> bool:
"""Legacy basic authentication (deprecated)."""
# Implementation
pass
def authenticate_oauth(token: str) -> bool:
"""Modern OAuth authentication."""
# Implementation
pass
Deprecation in TypeScript¶
/**
* Mark a method as deprecated with automatic warning.
*/
function deprecated(
version: string,
removalVersion: string,
replacement?: string
) {
return function (
target: any,
propertyKey: string,
descriptor: PropertyDescriptor
) {
const originalMethod = descriptor.value;
descriptor.value = function (...args: any[]) {
let message =
`${propertyKey} is deprecated since v${version} ` +
`and will be removed in v${removalVersion}.`;
if (replacement) {
message += ` Use ${replacement} instead.`;
}
console.warn(`[DEPRECATION] ${message}`);
return originalMethod.apply(this, args);
};
return descriptor;
};
}
// Usage example
class AuthService {
@deprecated("1.5.0", "2.0.0", "authenticateOAuth")
authenticateBasic(username: string, password: string): boolean {
// Legacy implementation
return true;
}
authenticateOAuth(token: string): boolean {
// Modern implementation
return true;
}
}
Best Practices¶
Changelog Maintenance¶
# Changelog best practices
changelog_guidelines:
# What to include
include:
- User-facing changes
- API changes
- Breaking changes
- Security fixes
- Performance improvements
- Dependency updates (major only)
# What to exclude
exclude:
- Internal refactoring
- Code style changes
- Test-only changes
- CI/CD changes
- Typo fixes
# Writing style
style:
- Use imperative mood ("Add feature" not "Added feature")
- Start with action verb
- Include issue/PR references
- Group related changes
- Keep entries concise
Release Checklist¶
# Pre-release checklist
release_checklist:
preparation:
- [ ] All tests passing
- [ ] Documentation updated
- [ ] CHANGELOG.md updated
- [ ] Breaking changes documented
- [ ] Migration guide written (if applicable)
- [ ] Dependencies up to date
validation:
- [ ] Version number correct
- [ ] Pre-release tags accurate
- [ ] Links in changelog work
- [ ] Release notes reviewed
post_release:
- [ ] GitHub release created
- [ ] Package published
- [ ] Documentation deployed
- [ ] Announcement posted
- [ ] Monitoring alerts checked
Automation Flow¶
flowchart TD
subgraph Development
A[Write Code] --> B[Commit with<br/>Conventional Format]
B --> C[Push to Branch]
C --> D[Create PR]
end
subgraph CI/CD
D --> E{Commit Lint<br/>Passes?}
E -->|No| F[Fix Commits]
F --> B
E -->|Yes| G[Run Tests]
G --> H[Merge to Main]
end
subgraph Release
H --> I{Trigger<br/>Release?}
I -->|Manual| J[Workflow Dispatch]
I -->|Scheduled| K[Weekly Check]
I -->|Tag| L[Push Tag]
J --> M[Analyze Commits]
K --> M
L --> N[Generate Notes]
M --> O[Calculate Version]
O --> P[Update Files]
P --> Q[Generate Changelog]
Q --> N
N --> R[Create GitHub Release]
end
subgraph Post-Release
R --> S[Publish Package]
S --> T[Deploy Docs]
T --> U[Notify Team]
end
Related Documentation¶
- GitHub Actions Guide - Complete CI/CD patterns
- Pre-commit Hooks Guide - Local validation
- Dependabot Auto-Merge - Automated dependency updates