Skip to content

Commit c43292f

Browse files
committed
Add automated patch releases on every merge to dev
- Add workflow_run trigger to auto-release.yml that fires after tests pass - Add safeguard to skip release if commit already has a tag (prevents duplicates) - Add conditional checks to all release steps - Add explanation documentation for the release system - Closes #1539 item 2
1 parent b834893 commit c43292f

File tree

3 files changed

+258
-5
lines changed

3 files changed

+258
-5
lines changed

.github/workflows/auto-release.yml

Lines changed: 57 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,22 @@
11
name: Auto-release
22

33
on:
4-
# The below workflow_dispatch section is for a "manual" kick off of the
5-
# auto-release script. To cut a new release, navigate to the Actions section
6-
# of the repo and select this workflow (Auto-release) on the right hand side.
7-
# Then, click "Run workflow" and you will be prompted to input the new
8-
# version (which should be major, minor, or patch).
4+
# Automatic patch release after tests pass on dev branch.
5+
# This triggers whenever the "pyjanitor tests" workflow completes.
6+
# The job-level `if` condition ensures we only release when:
7+
# 1. Tests passed (conclusion == 'success')
8+
# 2. It was a push event (not a PR) - i.e., code was merged to dev
9+
workflow_run:
10+
workflows: ["pyjanitor tests"]
11+
types:
12+
- completed
13+
branches:
14+
- dev
15+
16+
# Manual release for minor/major versions.
17+
# To cut a new release, navigate to the Actions section of the repo
18+
# and select this workflow (Auto-release) on the right hand side.
19+
# Then, click "Run workflow" and select major, minor, or patch.
920
workflow_dispatch:
1021
inputs:
1122
version_name:
@@ -28,6 +39,13 @@ jobs:
2839
release:
2940
name: Create a new release
3041
runs-on: ubuntu-latest
42+
# Run if:
43+
# 1. Manual trigger (workflow_dispatch), OR
44+
# 2. Automatic trigger where tests passed AND it was a push (not PR)
45+
if: |
46+
github.event_name == 'workflow_dispatch' ||
47+
(github.event.workflow_run.conclusion == 'success' &&
48+
github.event.workflow_run.event == 'push')
3149
3250
defaults:
3351
run:
@@ -45,56 +63,84 @@ jobs:
4563
git checkout dev
4664
git pull
4765
66+
- name: Check if already released
67+
id: check_release
68+
run: |
69+
CURRENT_COMMIT=$(git rev-parse HEAD)
70+
echo "Current commit: $CURRENT_COMMIT"
71+
if git tag --points-at $CURRENT_COMMIT | grep -q "^v"; then
72+
echo "already_released=true" >> $GITHUB_OUTPUT
73+
echo "This commit already has a release tag. Will skip release."
74+
git tag --points-at $CURRENT_COMMIT
75+
else
76+
echo "already_released=false" >> $GITHUB_OUTPUT
77+
echo "No release tag found. Proceeding with release."
78+
fi
79+
4880
- name: Setup Python environment
81+
if: steps.check_release.outputs.already_released == 'false'
4982
uses: actions/setup-python@v6
5083
with:
5184
python-version: 3.13
5285

5386
- name: Install uv
87+
if: steps.check_release.outputs.already_released == 'false'
5488
uses: astral-sh/setup-uv@v7
5589

5690
- name: Setup Pixi Environment
91+
if: steps.check_release.outputs.already_released == 'false'
5792
uses: prefix-dev/setup-pixi@v0.9.3
5893
with:
5994
pixi-version: latest
6095
cache: true
6196
cache-write: true
6297

6398
- name: Set up Python tools
99+
if: steps.check_release.outputs.already_released == 'false'
64100
run: uv tool install bump2version
65101

66102
- name: Set version name
103+
if: steps.check_release.outputs.already_released == 'false'
67104
run: echo "VERSION_NAME=${{ github.event.inputs.version_name || env.DEFAULT_VERSION_NAME }}" >> $GITHUB_ENV
68105

69106
- name: Dry run bump2version
107+
if: steps.check_release.outputs.already_released == 'false'
70108
run: bump2version --dry-run ${{ env.VERSION_NAME }} --allow-dirty --verbose
71109

72110
- name: Store new version number
111+
if: steps.check_release.outputs.already_released == 'false'
73112
run: echo "version_number=$(bump2version --dry-run --list ${{ env.VERSION_NAME }} | grep new_version | sed -r 's/^.*=//')" >> $GITHUB_ENV
74113

75114
- name: Display new version number
115+
if: steps.check_release.outputs.already_released == 'false'
76116
run: |
77117
echo "version_name: ${{ env.VERSION_NAME }}"
78118
echo "version_number: v${{ env.version_number }}"
79119
80120
- name: Ensure repo status is clean
121+
if: steps.check_release.outputs.already_released == 'false'
81122
run: git status
82123

83124
- name: Configure Git
125+
if: steps.check_release.outputs.already_released == 'false'
84126
run: |
85127
git config user.name github-actions
86128
git config user.email github-actions@github.com
87129
88130
- name: Run bump2version
131+
if: steps.check_release.outputs.already_released == 'false'
89132
run: bump2version ${{ env.VERSION_NAME }} --verbose
90133

91134
- name: Ensure tag creation
135+
if: steps.check_release.outputs.already_released == 'false'
92136
run: git tag | grep ${{ env.version_number }}
93137

94138
- name: Install llamabot package
139+
if: steps.check_release.outputs.already_released == 'false'
95140
run: pixi run pip install llamabot[cli]
96141

97142
- name: Write release notes
143+
if: steps.check_release.outputs.already_released == 'false'
98144
env:
99145
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
100146
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -103,27 +149,33 @@ jobs:
103149
llamabot git write-release-notes
104150
105151
- name: Commit release notes
152+
if: steps.check_release.outputs.already_released == 'false'
106153
run: |
107154
pixi run pre-commit run --all-files || pixi run pre-commit run --all-files
108155
git add .
109156
git commit -m "Add release notes for ${{ env.version_number }}"
110157
111158
- name: Build package
159+
if: steps.check_release.outputs.already_released == 'false'
112160
run: pixi run release
113161

114162
- name: Publish package (trusted publishing)
163+
if: steps.check_release.outputs.already_released == 'false'
115164
run: uv publish --trusted-publishing always
116165

117166
- name: Push changes with tags
167+
if: steps.check_release.outputs.already_released == 'false'
118168
run: |
119169
git push && git push --tags
120170
121171
- name: Create release in GitHub repo
172+
if: steps.check_release.outputs.already_released == 'false'
122173
uses: ncipollo/release-action@v1
123174
with:
124175
bodyFile: "docs/releases/v${{ env.version_number }}.md"
125176
token: ${{ secrets.GITHUB_TOKEN }}
126177
tag: v${{ env.version_number }}
127178

128179
- name: Ensure complete
180+
if: steps.check_release.outputs.already_released == 'false'
129181
run: echo "Auto-release complete!"

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ nav:
5252
- Development:
5353
- Multi-Python Environments: development/multi_python_environments.md
5454
- Lazy Imports: development/lazy_imports.md
55+
- Automated Releases: development/auto_releases.md
5556
- Changelog: CHANGELOG.md
5657

5758
plugins:
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# Automated release system
2+
3+
This document explains how pyjanitor's automated release system works
4+
and the design decisions behind it.
5+
Understanding this system helps maintainers know
6+
when releases happen and how to control versioning.
7+
8+
## Release philosophy
9+
10+
pyjanitor follows a continuous delivery approach to releases:
11+
12+
- **Patch releases** happen automatically on every merge to `dev`
13+
- **Minor and major releases** are triggered manually by maintainers
14+
15+
The rationale is straightforward:
16+
most merges are bug fixes, documentation improvements,
17+
or small enhancements that don't require human decision-making
18+
about version numbers.
19+
By automating patch releases,
20+
we reduce the friction of getting changes into users' hands
21+
while preserving human control for significant version bumps.
22+
23+
## How automatic releases work
24+
25+
When code is merged to the `dev` branch, the following sequence occurs:
26+
27+
1. **Tests run first**: The `pyjanitor tests` workflow executes,
28+
running the full test suite across Python 3.11, 3.12, and 3.13.
29+
30+
2. **Release triggers on success**: If all tests pass,
31+
the `Auto-release` workflow automatically triggers
32+
via GitHub's `workflow_run` event.
33+
34+
3. **Duplicate check**: The workflow checks
35+
if the current commit already has a release tag.
36+
This prevents re-releasing
37+
when the workflow's own version bump commits trigger another test run.
38+
39+
4. **Version bump**: If no tag exists,
40+
`bump2version` increments the patch version
41+
(e.g., 0.32.3 becomes 0.32.4).
42+
43+
5. **Release notes generation**: The `llamabot` CLI generates release notes
44+
using an LLM, summarizing changes since the last release.
45+
46+
6. **Build and publish**: The package is built and published to PyPI
47+
using trusted publishing.
48+
49+
7. **GitHub release**: A GitHub release is created
50+
with the generated release notes.
51+
52+
The entire process is hands-off after merging.
53+
Maintainers don't need to do anything for patch releases.
54+
55+
## Manual releases for minor and major versions
56+
57+
When a change warrants a minor or major version bump,
58+
maintainers trigger the release manually:
59+
60+
**When to use minor version (e.g., 0.32.0 to 0.33.0):**
61+
62+
- New features that don't break existing functionality
63+
- Significant enhancements to existing features
64+
- New optional dependencies or modules
65+
66+
**When to use major version (e.g., 0.x.x to 1.0.0):**
67+
68+
- Breaking changes to the public API
69+
- Removal of deprecated features
70+
- Fundamental changes to how the library works
71+
72+
**How to trigger a manual release:**
73+
74+
1. Go to the [Actions tab][actions] in the repository
75+
2. Select "Auto-release" from the workflows list
76+
3. Click "Run workflow"
77+
4. Choose `major`, `minor`, or `patch` from the dropdown
78+
5. Click the green "Run workflow" button
79+
80+
[actions]: https://github.com/pyjanitor-devs/pyjanitor/actions
81+
82+
The workflow will run the same steps as an automatic release,
83+
but with your chosen version bump type.
84+
85+
## Technical implementation
86+
87+
The release system uses GitHub Actions' `workflow_run` trigger,
88+
which fires when another workflow completes.
89+
Here's how the triggers work:
90+
91+
```yaml
92+
on:
93+
# Automatic: triggers after tests complete on dev
94+
workflow_run:
95+
workflows: ["pyjanitor tests"]
96+
types:
97+
- completed
98+
branches:
99+
- dev
100+
101+
# Manual: maintainer-triggered releases
102+
workflow_dispatch:
103+
inputs:
104+
version_name:
105+
type: choice
106+
options:
107+
- major
108+
- minor
109+
- patch
110+
```
111+
112+
The job only runs when appropriate:
113+
114+
```yaml
115+
if: |
116+
github.event_name == 'workflow_dispatch' ||
117+
(github.event.workflow_run.conclusion == 'success' &&
118+
github.event.workflow_run.event == 'push')
119+
```
120+
121+
This condition ensures:
122+
123+
- Manual triggers always run
124+
- Automatic triggers only run when tests *passed* (not failed)
125+
- Automatic triggers only run for *push* events (not PR test runs)
126+
127+
## Preventing duplicate releases
128+
129+
When the auto-release workflow runs,
130+
it creates a version bump commit and pushes it to `dev`.
131+
This push triggers another test run,
132+
which could trigger another release.
133+
To prevent this infinite loop,
134+
the workflow checks if the current commit already has a release tag:
135+
136+
```yaml
137+
- name: Check if already released
138+
id: check_release
139+
run: |
140+
CURRENT_COMMIT=$(git rev-parse HEAD)
141+
if git tag --points-at $CURRENT_COMMIT | grep -q "^v"; then
142+
echo "already_released=true" >> $GITHUB_OUTPUT
143+
else
144+
echo "already_released=false" >> $GITHUB_OUTPUT
145+
fi
146+
```
147+
148+
If a tag exists, all subsequent release steps are skipped.
149+
150+
## Troubleshooting
151+
152+
### Auto-release didn't trigger after merge
153+
154+
Check these common causes:
155+
156+
1. **Tests failed**: The release only triggers on successful test runs.
157+
Check the Actions tab for test failures.
158+
159+
2. **Not a push event**: Releases only trigger for pushes to `dev`,
160+
not for pull request test runs. This is intentional.
161+
162+
3. **Already released**: If the commit already has a tag,
163+
the release is skipped. Check `git tag --points-at HEAD`.
164+
165+
### Release failed mid-way
166+
167+
If a release fails after bumping the version but before publishing:
168+
169+
1. Check the Actions log to identify the failure point
170+
2. If the tag was created but not pushed,
171+
you may need to manually push it or delete and recreate
172+
3. If PyPI publish failed,
173+
you can re-run the failed job or trigger a manual release
174+
175+
### Wrong version was released
176+
177+
If you need to correct a version:
178+
179+
1. Do not try to overwrite an existing PyPI release
180+
(PyPI doesn't allow this)
181+
2. Instead, create a new patch release with the fix
182+
3. If the version number itself is wrong,
183+
update `.bumpversion.cfg` and `pyproject.toml` manually,
184+
commit, and trigger a new release
185+
186+
### How to skip a release for a specific merge
187+
188+
Currently, all merges to `dev` trigger a release.
189+
If you need to merge something without releasing
190+
(e.g., CI-only changes), you have two options:
191+
192+
1. Merge to a different branch first,
193+
then batch multiple changes into a single merge to `dev`
194+
2. Cancel the auto-release workflow run manually
195+
in the Actions tab before it completes
196+
197+
**Note:** There's an open issue ([#1549][issue])
198+
to consider adding filters for non-code changes.
199+
200+
[issue]: https://github.com/pyjanitor-devs/pyjanitor/issues/1549

0 commit comments

Comments
 (0)