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..f0cb3e2 --- /dev/null +++ b/.github/workflows/release.yml @@ -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/* 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. diff --git a/pycoupler/release.py b/pycoupler/release.py index d139c2d..58eb055 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,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 --tags" + ) def update_citation_file(version, date=None): @@ -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.") @@ -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") 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")