Skip to content

Commit c56cb24

Browse files
feat: Implement towncrier-based release system with fragment-driven version scheme
Implement a comprehensive CI/CD pipeline for managing releases using towncrier changelog fragments and automated workflows with manual approval gates. ## Version Scheme Add new 'towncrier-fragments' version scheme that determines version bumps based on changelog fragment types: - Major bump (X.0.0): 'removal' fragments indicate breaking changes - Minor bump (0.X.0): 'feature' or 'deprecation' fragments - Patch bump (0.0.X): 'bugfix', 'doc', or 'misc' fragments - Falls back to guess-next-dev when no fragments exist The version scheme is the single source of truth for version calculation, used consistently in both development builds and release workflows. New files: - vcs-versioning/src/vcs_versioning/_version_schemes_towncrier.py - vcs-versioning/testing_vcs/test_version_scheme_towncrier.py (33 tests) ## Towncrier Integration Configure towncrier for both projects with separate changelog.d/ directories: - setuptools-scm/changelog.d/ - vcs-versioning/changelog.d/ Each includes: - Template for changelog rendering - README with fragment naming conventions - .gitkeep to preserve directory structure Fragment types: removal, deprecation, feature, bugfix, doc, misc ## GitHub Workflows ### Release Proposal Workflow (.github/workflows/release-proposal.yml) - Manual trigger with checkboxes for project selection - Queries vcs-versioning CLI to determine next version from fragments - Runs towncrier build to update CHANGELOG.md - Creates labeled PR for review - Strict validation: fails if fragments or version data missing ### Tag Creation Workflow (.github/workflows/create-release-tags.yml) - Triggers on PR merge with release labels - Creates project-prefixed tags: setuptools-scm-vX.Y.Z, vcs-versioning-vX.Y.Z - Creates GitHub releases with changelog excerpts - Strict validation: fails if CHANGELOG.md or version extraction fails ### Modified Upload Workflow (.github/workflows/python-tests.yml) - Split dist_upload into separate jobs per project - Tag prefix filtering: only upload package matching the tag - Separate upload-release-assets jobs per project ### Reusable Workflow (.github/workflows/reusable-towncrier-release.yml) - Reusable components for other projects - Strict validation with no fallback values - Clear error messages for troubleshooting ## Scripts Add minimal helper script: - .github/scripts/extract_version.py: Extract version from CHANGELOG.md Removed duplicate logic: version calculation is only in the version scheme, not in scripts. Workflows use vcs-versioning CLI to query the scheme. ## Configuration Updated pyproject.toml files: - Workspace: Add towncrier to release dependency group - setuptools-scm: Add [tool.towncrier] configuration - vcs-versioning: Add [tool.towncrier] and version scheme entry point Add towncrier start markers to CHANGELOG.md files. ## Documentation New comprehensive documentation: - CONTRIBUTING.md: Complete guide for changelog fragments and release process - RELEASE_SYSTEM.md: Implementation summary and architecture overview - .github/workflows/README.md: Guide for reusing workflows in other projects Updated existing documentation: - TESTING.md: Add sections on testing the version scheme and release workflows ## Key Design Principles 1. Version scheme is single source of truth (no duplicate logic in scripts) 2. Fail fast: workflows fail explicitly if required data is missing 3. Manual approval: release PRs must be reviewed and merged 4. Project-prefixed tags: enable monorepo releases (project-vX.Y.Z) 5. Reusable workflows: other projects can use the same components 6. Fully auditable: complete history in PRs, tags, and releases ## Testing All 33 tests passing for towncrier version scheme: - Fragment detection and categorization - Version bump type determination with precedence - Version calculation for all bump types - Edge cases: 0.x versions, missing directories, dirty working tree Note: Version scheme may need refinement based on real-world usage.
1 parent d6c911b commit c56cb24

23 files changed

+2056
-11
lines changed

.github/scripts/extract_version.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
#!/usr/bin/env python3
2+
"""Extract version from CHANGELOG.md file.
3+
4+
This script extracts the most recent version number from a CHANGELOG.md file
5+
by finding the first version heading.
6+
"""
7+
8+
import re
9+
import sys
10+
from pathlib import Path
11+
12+
13+
def extract_version_from_changelog(changelog_path: Path) -> str | None:
14+
"""Extract the first version number from a changelog file.
15+
16+
Args:
17+
changelog_path: Path to CHANGELOG.md
18+
19+
Returns:
20+
Version string (e.g., "9.2.2") or None if not found
21+
"""
22+
if not changelog_path.exists():
23+
return None
24+
25+
content = changelog_path.read_text()
26+
27+
# Look for version patterns like:
28+
# ## 9.2.2 (2024-01-15)
29+
# ## v9.2.2
30+
# ## [9.2.2]
31+
version_pattern = r"^##\s+(?:\[)?v?(\d+\.\d+\.\d+(?:\.\d+)?)"
32+
33+
for line in content.splitlines():
34+
match = re.match(version_pattern, line)
35+
if match:
36+
return match.group(1)
37+
38+
return None
39+
40+
41+
def main() -> None:
42+
"""Main entry point."""
43+
if len(sys.argv) != 2:
44+
print("Usage: extract_version.py <path-to-changelog>", file=sys.stderr)
45+
sys.exit(1)
46+
47+
changelog_path = Path(sys.argv[1])
48+
version = extract_version_from_changelog(changelog_path)
49+
50+
if version:
51+
print(version)
52+
else:
53+
print("No version found in changelog", file=sys.stderr)
54+
sys.exit(1)
55+
56+
57+
if __name__ == "__main__":
58+
main()

.github/workflows/README.md

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
# Reusable Workflows for Towncrier-based Releases
2+
3+
This directory contains reusable GitHub Actions workflows that other projects can use to implement the same towncrier-based release process.
4+
5+
## Available Reusable Workflows
6+
7+
### `reusable-towncrier-release.yml`
8+
9+
Determines the next version using the `towncrier-fragments` version scheme and builds the changelog.
10+
11+
**Inputs:**
12+
- `project_name` (required): Name of the project (used for labeling and tag prefix)
13+
- `project_directory` (required): Directory containing the project (relative to repository root)
14+
15+
**Outputs:**
16+
- `version`: The determined next version
17+
- `has_fragments`: Whether fragments were found
18+
19+
**Behavior:**
20+
- ✅ Strict validation - workflow fails if changelog fragments or version data is missing
21+
- ✅ No fallback values - ensures data integrity for releases
22+
- ✅ Clear error messages to guide troubleshooting
23+
24+
**Example usage:**
25+
26+
```yaml
27+
jobs:
28+
determine-version:
29+
uses: pypa/setuptools-scm/.github/workflows/reusable-towncrier-release.yml@main
30+
with:
31+
project_name: my-project
32+
project_directory: ./
33+
```
34+
35+
## Using These Workflows in Your Project
36+
37+
### Prerequisites
38+
39+
1. **Add vcs-versioning dependency** to your project
40+
2. **Configure towncrier** in your `pyproject.toml`:
41+
42+
```toml
43+
[tool.towncrier]
44+
directory = "changelog.d"
45+
filename = "CHANGELOG.md"
46+
start_string = "<!-- towncrier release notes start -->\n"
47+
template = "changelog.d/template.md"
48+
title_format = "## {version} ({project_date})"
49+
50+
[[tool.towncrier.type]]
51+
directory = "removal"
52+
name = "Removed"
53+
showcontent = true
54+
55+
[[tool.towncrier.type]]
56+
directory = "feature"
57+
name = "Added"
58+
showcontent = true
59+
60+
[[tool.towncrier.type]]
61+
directory = "bugfix"
62+
name = "Fixed"
63+
showcontent = true
64+
```
65+
66+
3. **Create changelog structure**:
67+
- `changelog.d/` directory
68+
- `changelog.d/template.md` (towncrier template)
69+
- `CHANGELOG.md` with the start marker
70+
71+
4. **Add the version scheme entry point** (if using vcs-versioning):
72+
73+
The `towncrier-fragments` version scheme is provided by vcs-versioning 0.2.0+.
74+
75+
### Complete Example Workflow
76+
77+
```yaml
78+
name: Create Release
79+
80+
on:
81+
workflow_dispatch:
82+
inputs:
83+
create_release:
84+
description: 'Create release'
85+
required: true
86+
type: boolean
87+
default: false
88+
89+
permissions:
90+
contents: write
91+
pull-requests: write
92+
93+
jobs:
94+
determine-version:
95+
uses: pypa/setuptools-scm/.github/workflows/reusable-towncrier-release.yml@main
96+
with:
97+
project_name: my-project
98+
project_directory: ./
99+
100+
create-release-pr:
101+
needs: determine-version
102+
if: needs.determine-version.outputs.has_fragments == 'true'
103+
runs-on: ubuntu-latest
104+
steps:
105+
- uses: actions/checkout@v5
106+
107+
- name: Download changelog artifacts
108+
uses: actions/download-artifact@v4
109+
with:
110+
name: changelog-my-project
111+
112+
- name: Create Pull Request
113+
uses: peter-evans/create-pull-request@v7
114+
with:
115+
commit-message: "Release v${{ needs.determine-version.outputs.version }}"
116+
branch: release-${{ needs.determine-version.outputs.version }}
117+
title: "Release v${{ needs.determine-version.outputs.version }}"
118+
labels: release:my-project
119+
body: |
120+
Automated release PR for version ${{ needs.determine-version.outputs.version }}
121+
```
122+
123+
## Architecture
124+
125+
The workflow system is designed with these principles:
126+
127+
1. **Version scheme is single source of truth** - No version calculation in scripts
128+
2. **Reusable components** - Other projects can use the same workflows
129+
3. **Manual approval** - Release PRs must be reviewed and merged
130+
4. **Project-prefixed tags** - Enable monorepo releases (`project-vX.Y.Z`)
131+
5. **Automated but controlled** - Automation with human approval gates
132+
6. **Fail fast** - No fallback values; workflows fail explicitly if required data is missing
133+
134+
## Version Bump Logic
135+
136+
The `towncrier-fragments` version scheme determines bumps based on fragment types:
137+
138+
| Fragment Type | Version Bump | Example |
139+
|---------------|--------------|---------|
140+
| `removal` | Major (X.0.0) | Breaking changes |
141+
| `feature`, `deprecation` | Minor (0.X.0) | New features |
142+
| `bugfix`, `doc`, `misc` | Patch (0.0.X) | Bug fixes |
143+
144+
## Support
145+
146+
For issues or questions about these workflows:
147+
- Open an issue at https://github.com/pypa/setuptools-scm/issues
148+
- See full documentation in [CONTRIBUTING.md](../../CONTRIBUTING.md)
149+
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
name: Create Release Tags
2+
3+
on:
4+
pull_request:
5+
types: [closed]
6+
branches:
7+
- main
8+
9+
permissions:
10+
contents: write
11+
12+
jobs:
13+
create-tags:
14+
# Only run if PR was merged and has release labels
15+
if: |
16+
github.event.pull_request.merged == true &&
17+
(contains(github.event.pull_request.labels.*.name, 'release:setuptools-scm') ||
18+
contains(github.event.pull_request.labels.*.name, 'release:vcs-versioning'))
19+
runs-on: ubuntu-latest
20+
steps:
21+
- uses: actions/checkout@v5
22+
with:
23+
fetch-depth: 0
24+
ref: ${{ github.event.pull_request.merge_commit_sha }}
25+
26+
- name: Setup Python
27+
uses: actions/setup-python@v6
28+
with:
29+
python-version: '3.11'
30+
31+
- name: Configure git
32+
run: |
33+
git config user.name "github-actions[bot]"
34+
git config user.email "github-actions[bot]@users.noreply.github.com"
35+
36+
- name: Create tags
37+
id: create-tags
38+
run: |
39+
set -e
40+
41+
TAGS_CREATED=""
42+
43+
# Check if we should release setuptools-scm
44+
if echo "${{ toJson(github.event.pull_request.labels.*.name) }}" | grep -q "release:setuptools-scm"; then
45+
cd setuptools-scm
46+
47+
if [ ! -f "CHANGELOG.md" ]; then
48+
echo "ERROR: CHANGELOG.md not found for setuptools-scm"
49+
exit 1
50+
fi
51+
52+
VERSION=$(python ../.github/scripts/extract_version.py CHANGELOG.md)
53+
54+
if [ -z "$VERSION" ]; then
55+
echo "ERROR: Failed to extract version from setuptools-scm CHANGELOG.md"
56+
echo "The CHANGELOG.md file must contain a version heading"
57+
exit 1
58+
fi
59+
60+
TAG="setuptools-scm-v$VERSION"
61+
echo "Creating tag: $TAG"
62+
63+
git tag -a "$TAG" -m "Release setuptools-scm v$VERSION"
64+
git push origin "$TAG"
65+
66+
TAGS_CREATED="$TAGS_CREATED $TAG"
67+
echo "setuptools_scm_tag=$TAG" >> $GITHUB_OUTPUT
68+
echo "setuptools_scm_version=$VERSION" >> $GITHUB_OUTPUT
69+
70+
cd ..
71+
fi
72+
73+
# Check if we should release vcs-versioning
74+
if echo "${{ toJson(github.event.pull_request.labels.*.name) }}" | grep -q "release:vcs-versioning"; then
75+
cd vcs-versioning
76+
77+
if [ ! -f "CHANGELOG.md" ]; then
78+
echo "ERROR: CHANGELOG.md not found for vcs-versioning"
79+
exit 1
80+
fi
81+
82+
VERSION=$(python ../.github/scripts/extract_version.py CHANGELOG.md)
83+
84+
if [ -z "$VERSION" ]; then
85+
echo "ERROR: Failed to extract version from vcs-versioning CHANGELOG.md"
86+
echo "The CHANGELOG.md file must contain a version heading"
87+
exit 1
88+
fi
89+
90+
TAG="vcs-versioning-v$VERSION"
91+
echo "Creating tag: $TAG"
92+
93+
git tag -a "$TAG" -m "Release vcs-versioning v$VERSION"
94+
git push origin "$TAG"
95+
96+
TAGS_CREATED="$TAGS_CREATED $TAG"
97+
echo "vcs_versioning_tag=$TAG" >> $GITHUB_OUTPUT
98+
echo "vcs_versioning_version=$VERSION" >> $GITHUB_OUTPUT
99+
100+
cd ..
101+
fi
102+
103+
echo "tags_created=$TAGS_CREATED" >> $GITHUB_OUTPUT
104+
105+
- name: Extract changelog for setuptools-scm
106+
if: steps.create-tags.outputs.setuptools_scm_version
107+
id: changelog-setuptools-scm
108+
run: |
109+
VERSION="${{ steps.create-tags.outputs.setuptools_scm_version }}"
110+
cd setuptools-scm
111+
112+
# Extract the changelog section for this version
113+
# Read from version heading until next version heading or EOF
114+
CHANGELOG=$(awk "/^## $VERSION/,/^## [0-9]/" CHANGELOG.md | sed '1d;$d')
115+
116+
# Save to file for GitHub release
117+
echo "$CHANGELOG" > /tmp/changelog-setuptools-scm.md
118+
119+
- name: Extract changelog for vcs-versioning
120+
if: steps.create-tags.outputs.vcs_versioning_version
121+
id: changelog-vcs-versioning
122+
run: |
123+
VERSION="${{ steps.create-tags.outputs.vcs_versioning_version }}"
124+
cd vcs-versioning
125+
126+
# Extract the changelog section for this version
127+
CHANGELOG=$(awk "/^## $VERSION/,/^## [0-9]/" CHANGELOG.md | sed '1d;$d')
128+
129+
# Save to file for GitHub release
130+
echo "$CHANGELOG" > /tmp/changelog-vcs-versioning.md
131+
132+
- name: Create GitHub Release for setuptools-scm
133+
if: steps.create-tags.outputs.setuptools_scm_tag
134+
uses: softprops/action-gh-release@v2
135+
with:
136+
tag_name: ${{ steps.create-tags.outputs.setuptools_scm_tag }}
137+
name: setuptools-scm v${{ steps.create-tags.outputs.setuptools_scm_version }}
138+
body_path: /tmp/changelog-setuptools-scm.md
139+
draft: false
140+
prerelease: false
141+
142+
- name: Create GitHub Release for vcs-versioning
143+
if: steps.create-tags.outputs.vcs_versioning_tag
144+
uses: softprops/action-gh-release@v2
145+
with:
146+
tag_name: ${{ steps.create-tags.outputs.vcs_versioning_tag }}
147+
name: vcs-versioning v${{ steps.create-tags.outputs.vcs_versioning_version }}
148+
body_path: /tmp/changelog-vcs-versioning.md
149+
draft: false
150+
prerelease: false
151+
152+
- name: Summary
153+
run: |
154+
echo "## Tags Created" >> $GITHUB_STEP_SUMMARY
155+
echo "${{ steps.create-tags.outputs.tags_created }}" >> $GITHUB_STEP_SUMMARY
156+
echo "" >> $GITHUB_STEP_SUMMARY
157+
echo "PyPI upload will be triggered automatically by tag push." >> $GITHUB_STEP_SUMMARY
158+

0 commit comments

Comments
 (0)