Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 1 addition & 26 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,29 +61,4 @@ jobs:
pip install dist/*.whl
python -c "import pycoupler; print(f'pycoupler version: {pycoupler.__version__}')"

- name: Extract version
if: startsWith(github.ref, 'refs/tags/v')
id: get_version
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$VERSION" >> $GITHUB_OUTPUT

- name: Publish to PyPI
if: startsWith(github.ref, 'refs/tags/v')
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: twine upload dist/*

- name: Create Release
if: startsWith(github.ref, 'refs/tags/v')
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ github.ref }}
name: Release of version ${{ steps.get_version.outputs.version }}
body: |
Changes in this Release
- Automated release notes
draft: false
prerelease: false
files: dist/*
# Publishing is handled by the separate release.yml workflow
116 changes: 116 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
name: release

on:
push:
tags: ['v*']

jobs:
check-tag:
runs-on: ubuntu-latest
outputs:
should-release: ${{ steps.check.outputs.should-release }}
steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history and tags
fetch-tags: true # Ensure all tags are fetched for version resolution

- name: Check if tag is on main/master branch
id: check
run: |
# Get the commit that the tag points to
TAG_COMMIT=$(git rev-list -n 1 ${{ github.ref_name }})
echo "Tag ${{ github.ref_name }} points to commit: $TAG_COMMIT"

# Check if this commit is reachable from main or master
if git branch -r --contains $TAG_COMMIT | grep -E "(origin/main|origin/master)" > /dev/null; then
echo "✅ Tag ${{ github.ref_name }} is on main/master branch"
echo "should-release=true" >> $GITHUB_OUTPUT
else
echo "⚠️ Tag ${{ github.ref_name }} is NOT on main/master branch"
echo "Available branches containing this commit:"
git branch -r --contains $TAG_COMMIT
echo "This release will be skipped for security reasons."
echo "To release, merge your changes to main/master first, then tag from there."
echo "should-release=false" >> $GITHUB_OUTPUT
fi

release:
needs: check-tag
if: needs.check-tag.outputs.should-release == 'true'
runs-on: ubuntu-latest
permissions:
contents: write

steps:
- name: Checkout code
uses: actions/checkout@v4
with:
fetch-depth: 0 # Fetch all history and tags
fetch-tags: true # Ensure all tags are fetched for version resolution

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install build twine
pip install .
pip install -e .[dev]

- name: Run tests
run: |
pytest --cov=pycoupler --cov-report=xml

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}

- name: Check code formatting with Black
run: |
black --check .

- name: Lint code with Flake8
run: |
flake8 .

- name: Build package
run: |
python -m build

- name: Check package with twine
run: |
twine check dist/*

- name: Test package installation
run: |
pip install dist/*.whl

- name: Extract version
id: get_version
run: |
VERSION=${GITHUB_REF#refs/tags/v}
echo "version=$VERSION" >> $GITHUB_OUTPUT

- name: Publish to PyPI
env:
TWINE_USERNAME: __token__
TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
run: twine upload dist/*

- name: Create Release
uses: softprops/action-gh-release@v1
with:
tag_name: ${{ github.ref }}
name: Release of version ${{ steps.get_version.outputs.version }}
body: |
Changes in this Release
- Automated release notes
draft: false
prerelease: false
files: dist/*
2 changes: 1 addition & 1 deletion CITATION.cff
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ cff-version: 1.2.0
message: If you use this software, please cite it using the metadata from this file.
type: software
title: 'pycoupler: dynamic model coupling of LPJmL'
version: 1.6.1
version: 1.6.2
date-released: '2025-09-22'
abstract: An LPJmL-Python interface for operating LPJmL in a Python environment
and coupling it with Python models, programmes or simple programming scripts.
Expand Down
31 changes: 22 additions & 9 deletions pycoupler/release.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,8 @@ def run_command(cmd, description, fail_on_error=True):


def delete_tag_if_exists(tag_name):
"""Delete a tag locally if it exists. Remote tags are handled during manual push."""
print(f"Checking if tag {tag_name} already exists locally...")
"""Delete a tag locally and remotely if it exists."""
print(f"Checking if tag {tag_name} already exists...")

# Check if tag exists locally (fast)
try:
Expand All @@ -163,8 +163,23 @@ def delete_tag_if_exists(tag_name):
except (subprocess.CalledProcessError, subprocess.TimeoutExpired):
print(f"Tag {tag_name} does not exist locally")

print(f"Local tag {tag_name} cleanup completed.")
print("Note: Remote tags will be handled when you manually push with --tags")
# Always try to delete remote tag (in case it exists)
print(f"Deleting remote tag {tag_name} (if it exists)...")
try:
run_command(
f"git push origin :refs/tags/{tag_name}",
f"Deleting remote tag {tag_name}",
fail_on_error=False, # Don't fail if tag doesn't exist remotely
)
except Exception:
# Ignore errors - tag might not exist remotely
pass

print(f"Tag {tag_name} cleanup completed.")
print(
"Note: You can now push your new commit and tag manually with: "
"git push origin <branch> --tags"
)


def update_citation_file(version, date=None):
Expand Down Expand Up @@ -338,7 +353,7 @@ def main():
print("CITATION.cff updated, committing changes...")
run_command("git add CITATION.cff", "Adding CITATION.cff to staging")
run_command(
f'git commit -m "Release version {version}\\n\\n- Update CITATION.cff to version {version}"', # noqa: E501
f'git commit -m "Version {version}"',
"Committing CITATION.cff changes",
)
print("CITATION.cff changes committed successfully.")
Expand All @@ -360,10 +375,8 @@ def main():
print("To push to repository, run:")
print(f" git push origin {current_branch} --tags")
print("")
print("Note: If this is a re-release and the tag exists remotely,")
print("you may need to force push the tag:")
print(f" git push origin :refs/tags/v{version} # Delete remote tag")
print(f" git push origin v{version} # Push new tag")
print("Note: Remote tag has been automatically deleted if it existed.")
print("You can now safely push your new commit and tag together.")
print("")
print("The CI pipeline will automatically:")
print(" - Run tests with pytest and coverage")
Expand Down
3 changes: 2 additions & 1 deletion tests/test_release.py
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,8 @@ def test_main_success(
main()

# Verify calls
assert mock_run_command.call_count == 4 # black, pytest, flake8, git add
# black, pytest, flake8, git add, git push (remote tag deletion)
assert mock_run_command.call_count == 5
mock_update_citation.assert_called_once_with("1.0.0")

@patch("pycoupler.release.run_command")
Expand Down