Release Automation Guide
This guide documents the automated release process for the Cancelable project using python-semantic-release.
Overview
The Cancelable project uses fully automated semantic versioning. Every commit to the main branch is analyzed, and when appropriate, a new version is automatically released.
Workflow Diagram
┌─────────────────┐
│ Push to main │
└────────┬────────┘
│
v
┌─────────────────┐
│ Analyze commits │ ← python-semantic-release
│ (conventional) │
└────────┬────────┘
│
v
┌────┴────┐
│ Release │
│ needed? │
└────┬────┘
│
┌────┴────┐
│ No │ ──→ [Stop]
│ │
└─────────┘
│
┌────┴────┐
│ Yes │
│ │
└────┬────┘
│
v
┌─────────────────┐
│ 1. Bump version │ ── Update pyproject.toml
│ 2. Update │ ── Update CHANGELOG.md
│ 3. Create tag │ ── Git tag (GPG signed)
│ 4. Build dist │ ── uv build
│ 5. Publish PyPI │ ── Trusted Publishing
│ 6. Create GH │ ── GitHub Release
│ Release │
│ 7. Deploy docs │ ── mike deploy
└─────────────────┘
Conventional Commits
Format
Commit Types
| Type | Description | Version Bump | Example |
|---|---|---|---|
feat |
New feature | Minor (0.5.0 → 0.6.0) | feat: add signal-based cancellation |
fix |
Bug fix | Patch (0.5.0 → 0.5.1) | fix: resolve race condition in token |
perf |
Performance improvement | Patch | perf: optimize registry lookup |
refactor |
Code refactoring | Patch | refactor: simplify token linking |
docs |
Documentation only | None | docs: update README examples |
chore |
Build/tooling changes | None | chore: update dependencies |
ci |
CI configuration | None | ci: add coverage reporting |
style |
Code style/formatting | None | style: format with ruff |
test |
Add/update tests | None | test: add timeout source tests |
revert |
Revert previous commit | None | revert: "feat: add feature X" |
Breaking Changes
Breaking changes trigger a major version bump (0.5.0 → 1.0.0):
Option 1: Use ! after type:
git commit -m "feat!: redesign cancellation API
The CancellationToken.cancel() method is now async.
Synchronous code should use cancel_sync() instead."
Option 2: Use BREAKING CHANGE: footer:
git commit -m "feat: redesign cancellation API
BREAKING CHANGE: The CancellationToken.cancel() method is now
async. Synchronous code should use cancel_sync() instead."
Examples
Feature (minor bump):
git commit -m "feat: add condition-based cancellation source
Adds ConditionSource that polls a predicate function and
cancels when it returns True. Useful for resource monitoring."
Bug fix (patch bump):
git commit -m "fix: prevent deadlock in cross-thread cancellation
Ensures proper lock ordering when cancelling from different threads."
Performance (patch bump):
Documentation (no release):
Multiple changes (use highest priority):
# This will trigger a minor bump (feat takes precedence)
git commit -m "feat: add new source type
Also fixes minor bug in existing timeout source."
Enforcement
Pre-commit Validation
Commit messages are validated before commit using lefthook:
# lefthook.yml
commit-msg:
commands:
conventional:
run: |
if ! head -1 {1} | grep -qE '^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\(.+?\))?: .{1,}$'; then
echo "❌ Commit message must follow Conventional Commits format"
exit 1
fi
Bypass if needed (not recommended):
GitHub PR Title Check
PR titles are also validated in CI to ensure they follow conventional commits.
Local Development
Preview Next Version
Check what version would be released without making changes:
# Must be on main branch
git checkout main
git pull
# Preview next version
uv run semantic-release --noop version --print
# With debug output
uv run semantic-release --noop --verbose version --print
Example output:
Preview Changelog
Generate changelog for unreleased commits:
Test Configuration
Validate semantic-release configuration:
# Show configuration
uv run semantic-release generate-config
# Check if release would happen
uv run semantic-release --noop version --print
Manual Release Trigger
While releases are automatic, you can manually trigger the workflow:
Via GitHub UI
- Go to Actions → Semantic Release
- Click Run workflow
- Select branch:
main - Click Run workflow
Via GitHub CLI
PyPI Trusted Publishing
The project uses PyPI's Trusted Publishing for secure, token-free releases.
How It Works
- GitHub Actions workflow runs with
id-token: writepermission - GitHub provides OIDC token proving workflow identity
- PyPI verifies token and authorizes publish
- No API tokens stored or managed!
Configuration
On PyPI:
- Publisher: GitHub Actions
- Owner: hotherio
- Repository: cancelable
- Workflow: semantic-release.yml
- Environment: (none)
In workflow:
permissions:
id-token: write # Required for Trusted Publishing
contents: write # Create tags and releases
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
# No password/token needed!
Setup for New Projects
- Create package on PyPI (one-time manual publish)
- Go to PyPI → Project → Publishing
- Add GitHub publisher: - Owner: organization/user - Repository: repo-name - Workflow: semantic-release.yml
- Save configuration
- Future releases publish automatically!
Troubleshooting
No Release Created
Problem: Pushed to main, but no release was created.
Possible causes:
- No releasable commits since last release:
Solution: Only feat, fix, perf, and refactor trigger releases.
- Branch not configured for releases:
Solution: Releases only happen from main branch.
- Invalid commit format:
Solution: Ensure commits follow conventional commits format.
Version Conflict
Problem: Version x.y.z already exists on PyPI
Cause: Tag exists but PyPI publish failed previously.
Solution:
# Option 1: Delete local and remote tag, create new commit
git tag -d vX.Y.Z
git push origin :refs/tags/vX.Y.Z
# Make a small change
git commit --allow-empty -m "chore: trigger new release"
git push
# Option 2: Manually publish to PyPI
uv build
uv publish
PyPI Upload Fails
Problem: Error: Trusted publishing exchange failure
Possible causes:
-
Publisher not configured on PyPI: - Go to PyPI project settings → Publishing - Add GitHub publisher with correct details
-
Workflow permissions incorrect:
-
Wrong workflow name: - PyPI publisher must match exact workflow filename - Default:
semantic-release.yml
Documentation Not Deployed
Problem: Release succeeded but docs not updated.
Cause: Docs workflow depends on release.published event.
Check:
# Verify docs.yaml uses correct event
grep -A 3 "release:" .github/workflows/docs.yaml
# Should show:
# release:
# types:
# - published
Manual deploy:
Migration from git-cliff
What Changed
| Before (git-cliff) | After (PSR) |
|---|---|
| Manual trigger via GitHub Actions | Automatic on push to main |
| Three-stage releases (dev/rc/final) | Single semantic release |
tools/release.py custom script |
python-semantic-release |
cliff.toml configuration |
pyproject.toml configuration |
| Manual version bumping | Automatic from commits |
| Separate changelog generation | Integrated changelog |
Removed Files
tools/release.py- Custom release scriptcliff.toml- git-cliff configuration.github/workflows/bump.yml- Manual bump workflow.github/workflows/release.yml- Tag-triggered release
Preserved
- Conventional commits - Still required (now enforced)
- GPG signing - Tags and commits still signed
- CHANGELOG.md - Still auto-generated
- PyPI publishing - Now via Trusted Publishing
- Documentation deployment - Still automatic
Best Practices
Commit Messages
-
Write clear, descriptive messages:
-
Include context in body:
-
Document breaking changes:
Pull Requests
-
Use conventional format in PR title:
-
PR title becomes the commit message when squash merging
-
Include breaking changes in PR description if applicable
Versioning Strategy
- 0.x.y: Pre-1.0 development (current)
- Breaking changes allowed on minor bumps
-
Set
major_on_zero = falsein config -
1.x.y: Stable releases (future)
- Breaking changes require major bump
- Set
major_on_zero = truewhen ready
Advanced Configuration
Custom Commit Types
To add custom commit types, edit pyproject.toml:
[tool.semantic_release.commit_parser_options]
allowed_tags = [
"build", "chore", "ci", "docs", "feat", "fix",
"perf", "refactor", "style", "test", "revert",
"custom", # Add custom type
]
minor_tags = ["feat", "custom"] # Types that trigger minor bump
patch_tags = ["fix", "perf", "refactor"] # Types that trigger patch bump
Changelog Template
Custom changelog templates can be added in templates/ directory:
Version Variables
Access version in other files using template variables:
[tool.semantic_release]
version_toml = [
"pyproject.toml:project.version",
"src/hother/cancelable/__init__.py:__version__",
]