From 92e745cf3887931e7b3de7e8bc862594dd288c73 Mon Sep 17 00:00:00 2001 From: jnnsbrr Date: Wed, 24 Sep 2025 11:04:15 +0200 Subject: [PATCH 1/6] add new release workflow to separate from check --- .github/workflows/check.yml | 27 +-------- .github/workflows/release.yml | 100 ++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+), 26 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index e4ebc94..7fb7963 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -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/* \ No newline at end of file + # Publishing is handled by the separate release.yml workflow \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..e7f8d38 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,100 @@ +name: release + +on: + push: + tags: ['v*'] + +jobs: + release: + 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: Verify tag is on main/master branch + 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" + 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." + exit 1 + fi + + - 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/* From c797607d673b5155726e3d642c4db49394ebef6f Mon Sep 17 00:00:00 2001 From: jnnsbrr Date: Wed, 24 Sep 2025 11:05:37 +0200 Subject: [PATCH 2/6] Release version 1.6.2\n\n- Update CITATION.cff to version 1.6.2 --- CITATION.cff | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CITATION.cff b/CITATION.cff index d68f812..90f5577 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -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. From fae6373d7b4ba92711bd26c9211597b60eb21c24 Mon Sep 17 00:00:00 2001 From: jnnsbrr Date: Wed, 24 Sep 2025 11:25:44 +0200 Subject: [PATCH 3/6] improve workflow --- .github/workflows/release.yml | 30 +++++++++++++++++++++++------- CITATION.cff | 2 +- pycoupler/release.py | 2 +- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e7f8d38..f0cb3e2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -5,11 +5,10 @@ on: tags: ['v*'] jobs: - release: + check-tag: runs-on: ubuntu-latest - permissions: - contents: write - + outputs: + should-release: ${{ steps.check.outputs.should-release }} steps: - name: Checkout code uses: actions/checkout@v4 @@ -17,7 +16,8 @@ jobs: fetch-depth: 0 # Fetch all history and tags fetch-tags: true # Ensure all tags are fetched for version resolution - - name: Verify tag is on main/master branch + - 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 }}) @@ -26,14 +26,30 @@ jobs: # 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 "⚠️ 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." - exit 1 + 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: diff --git a/CITATION.cff b/CITATION.cff index 90f5577..10154e5 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -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.2 +version: 1.6.3 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. diff --git a/pycoupler/release.py b/pycoupler/release.py index d139c2d..5ce026e 100644 --- a/pycoupler/release.py +++ b/pycoupler/release.py @@ -338,7 +338,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.") From 68d08a9c5785336afd214b19a2f42e9ff4cdba8e Mon Sep 17 00:00:00 2001 From: jnnsbrr Date: Wed, 24 Sep 2025 11:26:00 +0200 Subject: [PATCH 4/6] Version 1.6.2 --- CITATION.cff | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CITATION.cff b/CITATION.cff index 10154e5..90f5577 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -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.3 +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. From 60a60c005fd1ec2e43c128a2dae1c47030b9e9ce Mon Sep 17 00:00:00 2001 From: jnnsbrr Date: Wed, 24 Sep 2025 11:44:43 +0200 Subject: [PATCH 5/6] improve workflow --- pycoupler/release.py | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/pycoupler/release.py b/pycoupler/release.py index 5ce026e..932b5e5 100644 --- a/pycoupler/release.py +++ b/pycoupler/release.py @@ -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: @@ -163,8 +163,20 @@ 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 --tags") def update_citation_file(version, date=None): @@ -360,10 +372,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") From 82fdfcfadc33d78fdd9e5cc0970fe2620e06a700 Mon Sep 17 00:00:00 2001 From: jnnsbrr Date: Wed, 24 Sep 2025 11:52:25 +0200 Subject: [PATCH 6/6] improve workflow --- pycoupler/release.py | 7 +++++-- tests/test_release.py | 3 ++- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/pycoupler/release.py b/pycoupler/release.py index 932b5e5..58eb055 100644 --- a/pycoupler/release.py +++ b/pycoupler/release.py @@ -169,14 +169,17 @@ def delete_tag_if_exists(tag_name): 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 + 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 --tags") + print( + "Note: You can now push your new commit and tag manually with: " + "git push origin --tags" + ) def update_citation_file(version, date=None): diff --git a/tests/test_release.py b/tests/test_release.py index e9d8100..8e71c3b 100644 --- a/tests/test_release.py +++ b/tests/test_release.py @@ -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")