├── tests ├── __init__.py ├── test_action_integration.py └── test_calver_auto_release.py ├── .github ├── workflows │ ├── toc.yaml │ ├── pytest.yml │ ├── release.yml │ ├── test-action.yml │ └── update-readme.yml ├── renovate.json └── test-cases │ └── test-action.yml ├── scripts └── test-action.sh ├── .pre-commit-config.yaml ├── LICENSE ├── .gitignore ├── action.yml ├── pyproject.toml ├── README.md └── calver_auto_release.py /tests/__init__.py: -------------------------------------------------------------------------------- 1 | """Tests for the ``calver-auto-release`` package.""" 2 | -------------------------------------------------------------------------------- /.github/workflows/toc.yaml: -------------------------------------------------------------------------------- 1 | on: push 2 | name: TOC Generator 3 | jobs: 4 | generateTOC: 5 | name: TOC Generator 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: technote-space/toc-generator@v4 9 | with: 10 | TOC_TITLE: "" 11 | -------------------------------------------------------------------------------- /scripts/test-action.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | # Install act if not already installed 5 | if ! command -v act &> /dev/null; then 6 | echo "Installing act..." 7 | curl https://raw.githubusercontent.com/nektos/act/master/install.sh | sudo bash 8 | fi 9 | 10 | # Run tests 11 | echo "Running normal release test..." 12 | act -j test-normal -W .github/test-cases/test-action.yml 13 | 14 | echo "Running skip release test..." 15 | act -j test-skip -W .github/test-cases/test-action.yml 16 | 17 | echo "Running custom footer test..." 18 | act -j test-custom-footer -W .github/test-cases/test-action.yml 19 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: check-added-large-files 6 | - id: trailing-whitespace 7 | - id: end-of-file-fixer 8 | - id: mixed-line-ending 9 | - repo: https://github.com/astral-sh/ruff-pre-commit 10 | rev: "v0.8.2" 11 | hooks: 12 | - id: ruff 13 | args: ["--fix"] 14 | - id: ruff-format 15 | - repo: https://github.com/pre-commit/mirrors-mypy 16 | rev: "v1.13.0" 17 | hooks: 18 | - id: mypy 19 | additional_dependencies: 20 | - types-PyYAML 21 | -------------------------------------------------------------------------------- /.github/workflows/pytest.yml: -------------------------------------------------------------------------------- 1 | name: pytest 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [main] 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | fail-fast: false 13 | matrix: 14 | python-version: ["3.10", "3.11", "3.12", "3.13"] 15 | steps: 16 | - uses: actions/checkout@v4 17 | with: 18 | fetch-depth: 0 19 | 20 | - name: Install uv 21 | uses: astral-sh/setup-uv@v5 22 | 23 | - name: Run pytest 24 | run: | 25 | uv run --extra test --python ${{ matrix.python-version }} pytest 26 | 27 | - name: Upload coverage to Codecov 28 | if: matrix.python-version == '3.13' 29 | uses: codecov/codecov-action@v5 30 | env: 31 | CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 32 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Upload Python Package 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | environment: 11 | name: pypi 12 | url: https://pypi.org/p/${{ github.repository }} 13 | permissions: 14 | id-token: write 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Set up Python 18 | uses: actions/setup-python@v5 19 | with: 20 | python-version: "3.13.7" 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel build 25 | - name: Build 26 | run: | 27 | python -m build 28 | - name: Publish package distributions to PyPI 29 | uses: pypa/gh-action-pypi-publish@release/v1 30 | -------------------------------------------------------------------------------- /.github/renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "rebaseWhen": "behind-base-branch", 4 | "dependencyDashboard": true, 5 | "labels": [ 6 | "dependencies", 7 | "no-stale" 8 | ], 9 | "commitMessagePrefix": "⬆️", 10 | "commitMessageTopic": "{{depName}}", 11 | "prBodyDefinitions": { 12 | "Release": "yes" 13 | }, 14 | "packageRules": [ 15 | { 16 | "matchManagers": [ 17 | "github-actions" 18 | ], 19 | "addLabels": [ 20 | "github_actions" 21 | ], 22 | "rangeStrategy": "pin" 23 | }, 24 | { 25 | "matchManagers": [ 26 | "github-actions" 27 | ], 28 | "matchUpdateTypes": [ 29 | "minor", 30 | "patch" 31 | ], 32 | "automerge": true 33 | } 34 | ], 35 | "extends": [ 36 | "config:recommended" 37 | ] 38 | } 39 | -------------------------------------------------------------------------------- /.github/test-cases/test-action.yml: -------------------------------------------------------------------------------- 1 | name: Test Cases 2 | on: push 3 | 4 | jobs: 5 | test-normal: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - uses: ./ 10 | with: 11 | github_token: ${{ secrets.GITHUB_TOKEN }} 12 | 13 | test-skip: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v4 17 | - name: Create commit to skip 18 | run: | 19 | git config --global user.name "Test User" 20 | git config --global user.email "test@example.com" 21 | echo "[skip release] Test" > test.txt 22 | git add test.txt 23 | git commit -m "[skip release] Test commit" 24 | - uses: ./ 25 | with: 26 | github_token: ${{ secrets.GITHUB_TOKEN }} 27 | 28 | test-custom-footer: 29 | runs-on: ubuntu-latest 30 | steps: 31 | - uses: actions/checkout@v4 32 | - uses: ./ 33 | with: 34 | github_token: ${{ secrets.GITHUB_TOKEN }} 35 | footer: "Custom footer" 36 | -------------------------------------------------------------------------------- /.github/workflows/test-action.yml: -------------------------------------------------------------------------------- 1 | name: Test Action 2 | on: 3 | pull_request: 4 | push: 5 | branches: [main] 6 | 7 | jobs: 8 | test-action: 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v4 12 | with: 13 | fetch-depth: 0 14 | 15 | # Test normal release in dry-run mode 16 | - name: Test normal release 17 | uses: ./ 18 | with: 19 | github_token: ${{ secrets.GITHUB_TOKEN }} 20 | dry_run: true # Add this parameter to action.yml 21 | 22 | # Test with custom skip pattern 23 | - name: Test with custom skip pattern 24 | uses: ./ 25 | with: 26 | github_token: ${{ secrets.GITHUB_TOKEN }} 27 | skip_patterns: "[no-release],[skip]" 28 | dry_run: true 29 | 30 | # Test with custom footer 31 | - name: Test with custom footer 32 | uses: ./ 33 | with: 34 | github_token: ${{ secrets.GITHUB_TOKEN }} 35 | footer: "Custom footer for testing" 36 | dry_run: true 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Bas Nijholt 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /.github/workflows/update-readme.yml: -------------------------------------------------------------------------------- 1 | name: Update README.md 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | 9 | jobs: 10 | update_readme: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out repository 14 | uses: actions/checkout@v4 15 | with: 16 | persist-credentials: false 17 | fetch-depth: 0 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v5 21 | with: 22 | python-version: '3.13.7' 23 | 24 | - name: Install Python dependencies 25 | run: | 26 | python -m pip install --upgrade pip 27 | pip install markdown-code-runner 28 | pip install -e . 29 | 30 | - name: Run markdown-code-runner 31 | run: markdown-code-runner README.md 32 | 33 | - name: Commit updated README.md 34 | id: commit 35 | run: | 36 | git add README.md 37 | git config --local user.email "github-actions[bot]@users.noreply.github.com" 38 | git config --local user.name "github-actions[bot]" 39 | if git diff --quiet && git diff --staged --quiet; then 40 | echo "No changes in README.md, skipping commit." 41 | echo "commit_status=skipped" >> $GITHUB_ENV 42 | else 43 | git commit -m "Update README.md" 44 | echo "commit_status=committed" >> $GITHUB_ENV 45 | fi 46 | 47 | - name: Push changes 48 | if: env.commit_status == 'committed' 49 | uses: ad-m/github-push-action@master 50 | with: 51 | github_token: ${{ secrets.GITHUB_TOKEN }} 52 | branch: ${{ github.head_ref }} 53 | -------------------------------------------------------------------------------- /action.yml: -------------------------------------------------------------------------------- 1 | name: "CalVer Auto Release" 2 | description: "Automatically create GitHub releases using Calendar Versioning (CalVer)" 3 | inputs: 4 | github_token: 5 | description: "GitHub token for creating releases" 6 | required: true 7 | skip_patterns: 8 | description: "Comma-separated list of patterns to skip releases" 9 | required: false 10 | default: "[skip release],[pre-commit.ci],⬆️ Update" 11 | footer: 12 | description: "Custom footer text for release notes" 13 | required: false 14 | default: "" 15 | dry_run: 16 | description: 'Run in dry-run mode (no actual releases created)' 17 | required: false 18 | default: 'false' 19 | release_notes_type: 20 | description: "Source of release notes: 'github' for GitHub's auto-generated notes or 'tag_message' to use the tag message" 21 | required: false 22 | default: 'tag_message' 23 | 24 | outputs: 25 | version: 26 | description: 'The version number of the created release (empty if no release was created)' 27 | value: ${{ steps.generate_version.outputs.version }} 28 | 29 | runs: 30 | using: "composite" 31 | steps: 32 | - uses: actions/checkout@v4 33 | with: 34 | fetch-depth: 0 35 | 36 | - name: Set up Python 37 | uses: actions/setup-python@v5 38 | with: 39 | python-version: "3.13" 40 | 41 | - name: Install uv 42 | uses: astral-sh/setup-uv@v5 43 | 44 | - name: Configure Git 45 | shell: bash 46 | env: 47 | GITHUB_TOKEN: ${{ inputs.github_token }} 48 | run: | 49 | git config --global user.name "github-actions" 50 | git config --global user.email "github-actions@users.noreply.github.com" 51 | git config --global credential.helper store 52 | echo "https://$GITHUB_TOKEN:@github.com" > ~/.git-credentials 53 | 54 | - name: Install dependencies 55 | shell: bash 56 | run: | 57 | if [ -f "pyproject.toml" ] && grep -q "name = \"calver-auto-release\"" pyproject.toml; then 58 | uv tool install . 59 | else 60 | # In production, install from PyPI 61 | uv tool install calver-auto-release 62 | fi 63 | 64 | - name: Generate new version 65 | shell: bash 66 | id: generate_version 67 | env: 68 | SKIP_PATTERNS: ${{ inputs.skip_patterns }} 69 | FOOTER: ${{ inputs.footer }} 70 | CALVER_DRY_RUN: ${{ inputs.dry_run }} 71 | run: calver-auto-release 72 | 73 | - name: Get Tag Message 74 | if: steps.generate_version.outputs.version != '' && inputs.dry_run != 'true' && inputs.release_notes_type == 'tag_message' 75 | shell: bash 76 | id: tag_message 77 | run: | 78 | echo "content<> $GITHUB_OUTPUT 79 | git tag -l --format='%(contents:body)' ${{ steps.generate_version.outputs.version }} >> $GITHUB_OUTPUT 80 | echo "EOF" >> $GITHUB_OUTPUT 81 | 82 | - name: Create GitHub Release 83 | if: steps.generate_version.outputs.version != '' && inputs.dry_run != 'true' 84 | uses: softprops/action-gh-release@v2 85 | with: 86 | tag_name: ${{ steps.generate_version.outputs.version }} 87 | generate_release_notes: ${{ inputs.release_notes_type == 'github' }} 88 | body: ${{ inputs.release_notes_type == 'tag_message' && steps.tag_message.outputs.content || '' }} 89 | env: 90 | GITHUB_TOKEN: ${{ inputs.github_token }} 91 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | build-backend = "hatchling.build" 3 | requires = ["hatchling", "versioningit"] 4 | 5 | [project] 6 | name = "calver-auto-release" 7 | description = "Automatically create GitHub releases using Calendar Versioning (CalVer)" 8 | requires-python = ">=3.10" 9 | dynamic = ["version"] 10 | maintainers = [{ name = "Bas Nijholt", email = "bas@nijho.lt" }] 11 | license = { text = "MIT" } 12 | classifiers = [ 13 | "Development Status :: 4 - Beta", 14 | "Intended Audience :: Developers", 15 | "License :: OSI Approved :: MIT License", 16 | "Operating System :: OS Independent", 17 | "Programming Language :: Python", 18 | "Programming Language :: Python :: 3", 19 | "Programming Language :: Python :: 3.10", 20 | "Programming Language :: Python :: 3.11", 21 | "Programming Language :: Python :: 3.12", 22 | "Programming Language :: Python :: 3.13", 23 | "Topic :: Software Development :: Version Control :: Git", 24 | ] 25 | dependencies = ["gitpython>=3.1.0", "packaging>=23.0", "rich>=13.0.0"] 26 | 27 | [project.optional-dependencies] 28 | test = [ 29 | "coverage", 30 | "pytest-cov", 31 | "pytest", 32 | "versioningit", 33 | "PyYAML", # for parsing action.yml 34 | 35 | ] 36 | dev = [ 37 | "black", 38 | "mypy", 39 | "pre-commit", 40 | "ruff", 41 | "versioningit", 42 | "calver-auto-release[test]", 43 | ] 44 | 45 | [project.urls] 46 | homepage = "https://github.com/basnijholt/calver-auto-release" 47 | repository = "https://github.com/basnijholt/calver-auto-release" 48 | 49 | [project.readme] 50 | content-type = "text/markdown" 51 | file = "README.md" 52 | 53 | [project.scripts] 54 | calver-auto-release = "calver_auto_release:cli" 55 | 56 | [tool.hatch.version] 57 | source = "versioningit" 58 | 59 | [tool.hatch.build] 60 | include = ["calver_auto_release.py"] 61 | 62 | [tool.hatch.build.targets.wheel] 63 | packages = ["."] 64 | 65 | [tool.hatch.build.hooks.versioningit-onbuild] 66 | build-file = "calver_auto_release.py" 67 | source-file = "calver_auto_release.py" 68 | 69 | [tool.versioningit] 70 | default-version = "0.0.0" 71 | 72 | [tool.versioningit.vcs] 73 | method = "git" 74 | match = ["v*"] 75 | default-tag = "0.0.0" 76 | 77 | [tool.versioningit.format] 78 | dirty = "{version}.dev{distance}+{branch}.{vcs}{rev}.dirty" 79 | distance = "{version}.dev{distance}+{branch}.{vcs}{rev}" 80 | distance-dirty = "{version}.dev{distance}+{branch}.{vcs}{rev}.dirty" 81 | 82 | [tool.pytest.ini_options] 83 | addopts = """ 84 | -vvv 85 | --cov=calver_auto_release 86 | --cov-report term 87 | --cov-report html 88 | --cov-report xml 89 | --cov-fail-under=90 90 | """ 91 | 92 | [tool.coverage.report] 93 | exclude_lines = [ 94 | "pragma: no cover", 95 | "raise NotImplementedError", 96 | "if TYPE_CHECKING:", 97 | "if __name__ == .__main__.:", 98 | ] 99 | 100 | [tool.ruff] 101 | line-length = 100 102 | target-version = "py310" 103 | 104 | [tool.ruff.lint] 105 | select = ["ALL"] 106 | ignore = [ 107 | "T20", # flake8-print 108 | "S101", # Use of assert detected 109 | "D402", # First line should not be the function's signature 110 | "D401", # First line of docstring should be in imperative mood 111 | "PLW0603", # Using the global statement to update `X` is discouraged 112 | ] 113 | 114 | [tool.ruff.lint.per-file-ignores] 115 | "tests/*" = ["SLF001", "ANN", "D", "PLR2004", "ARG001", "S603", "S607"] 116 | ".github/*" = ["INP001"] 117 | 118 | [tool.ruff.lint.mccabe] 119 | max-complexity = 18 120 | 121 | [tool.mypy] 122 | python_version = "3.10" 123 | strict = true 124 | -------------------------------------------------------------------------------- /tests/test_action_integration.py: -------------------------------------------------------------------------------- 1 | """Integration tests for the GitHub Action.""" 2 | 3 | import os 4 | import subprocess 5 | from pathlib import Path 6 | 7 | import git 8 | import pytest 9 | 10 | 11 | def test_action_yml_structure() -> None: 12 | """Test the structure of action.yml.""" 13 | import yaml 14 | 15 | with open("action.yml") as f: # noqa: PTH123 16 | action = yaml.safe_load(f) 17 | 18 | # Verify required fields 19 | assert "name" in action 20 | assert "description" in action 21 | assert "inputs" in action 22 | assert "runs" in action 23 | 24 | # Verify inputs 25 | assert "github_token" in action["inputs"] 26 | assert action["inputs"]["github_token"]["required"] is True 27 | 28 | # Verify it's a composite action 29 | assert action["runs"]["using"] == "composite" 30 | 31 | 32 | @pytest.fixture # type: ignore[misc] 33 | def test_repo(tmp_path: Path) -> git.Repo: 34 | """Create a temporary git repository for testing.""" 35 | repo = git.Repo.init(tmp_path) 36 | 37 | # Configure git user 38 | repo.config_writer().set_value("user", "name", "Test User").release() 39 | repo.config_writer().set_value("user", "email", "test@example.com").release() 40 | 41 | # Create and commit a dummy file 42 | dummy_file = tmp_path / "dummy.txt" 43 | dummy_file.write_text("Hello, World!") 44 | repo.index.add([str(dummy_file)]) 45 | repo.index.commit("Initial commit") 46 | 47 | return repo 48 | 49 | 50 | def test_action_execution( 51 | test_repo: git.Repo, 52 | ) -> None: 53 | """Test that the action executes successfully.""" 54 | result = subprocess.run( 55 | [ 56 | "calver-auto-release", 57 | "--repo-path", 58 | str(test_repo.working_dir), 59 | "--dry-run", 60 | ], 61 | capture_output=True, 62 | text=True, 63 | check=False, 64 | ) 65 | assert result.returncode == 0 66 | assert "Would create new tag:" in result.stdout 67 | 68 | 69 | @pytest.mark.skipif( # type: ignore[misc] 70 | "GITHUB_ACTIONS" not in os.environ, 71 | reason="Only runs on GitHub Actions", 72 | ) 73 | def test_github_environment() -> None: 74 | """Test that the action executes in the GitHub Actions environment.""" 75 | # Verify we're in GitHub Actions 76 | assert "GITHUB_ACTIONS" in os.environ 77 | assert "GITHUB_WORKSPACE" in os.environ 78 | assert "GITHUB_OUTPUT" in os.environ 79 | 80 | # Verify git is available and initialized 81 | assert Path("action.yml").exists() 82 | assert ( 83 | subprocess.run( 84 | ["git", "rev-parse", "--git-dir"], 85 | capture_output=True, 86 | check=False, 87 | ).returncode 88 | == 0 89 | ) 90 | 91 | # Verify GitHub-specific paths 92 | workspace = Path(os.environ["GITHUB_WORKSPACE"]) 93 | assert workspace.exists() 94 | assert (workspace / "action.yml").exists() 95 | 96 | 97 | def test_action_functionality(test_repo: git.Repo) -> None: 98 | """Test the core functionality of the action in a controlled environment.""" 99 | result = subprocess.run( 100 | [ 101 | "calver-auto-release", 102 | "--repo-path", 103 | str(test_repo.working_dir), 104 | "--dry-run", 105 | ], 106 | capture_output=True, 107 | text=True, 108 | check=False, 109 | ) 110 | assert result.returncode == 0 111 | assert "Would create new tag:" in result.stdout 112 | 113 | 114 | def test_action_skip_patterns(test_repo: git.Repo) -> None: 115 | """Test that skip patterns work correctly.""" 116 | # Create a commit that should be skipped 117 | test_file = Path(test_repo.working_dir) / "skip.txt" 118 | test_file.write_text("skip") 119 | test_repo.index.add([str(test_file)]) 120 | test_repo.index.commit("[skip release] Test skip") 121 | 122 | result = subprocess.run( 123 | [ 124 | "calver-auto-release", 125 | "--repo-path", 126 | str(test_repo.working_dir), 127 | "--dry-run", 128 | ], 129 | capture_output=True, 130 | text=True, 131 | check=False, 132 | ) 133 | assert result.returncode == 0 134 | assert "Skipping release" in result.stdout 135 | 136 | 137 | def test_action_custom_footer(test_repo: git.Repo) -> None: 138 | """Test that custom footer is included.""" 139 | custom_footer = "Custom footer for testing" 140 | result = subprocess.run( 141 | [ 142 | "calver-auto-release", 143 | "--repo-path", 144 | str(test_repo.working_dir), 145 | "--dry-run", 146 | "--footer", 147 | custom_footer, 148 | ], 149 | capture_output=True, 150 | text=True, 151 | check=False, 152 | ) 153 | assert result.returncode == 0 154 | assert "Would create new tag:" in result.stdout 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # calver-auto-release 🏷️ 2 | [![PyPI](https://img.shields.io/pypi/v/calver-auto-release)](https://pypi.org/project/calver-auto-release/) 3 | [![Python Versions](https://img.shields.io/pypi/pyversions/calver-auto-release)](https://pypi.org/project/calver-auto-release/) 4 | [![Coverage](https://img.shields.io/codecov/c/github/basnijholt/calver-auto-release)](https://codecov.io/gh/basnijholt/calver-auto-release) 5 | [![Ruff](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json)](https://github.com/astral-sh/ruff) 6 | [![pytest](https://github.com/basnijholt/calver-auto-release/actions/workflows/pytest.yml/badge.svg)](https://github.com/basnijholt/calver-auto-release/actions/workflows/pytest.yml) 7 | 8 | 🏷️ Automatically create GitHub releases using Calendar Versioning (CalVer) on every commit. 9 | 10 | This tool is perfect for: 11 | - 📦 Packages where users should always use the latest version 12 | - 🔬 Research software where releases are not tied to specific feature completions 13 | - 🔄 Projects with continuous deployment where each merge to main is a potential release 14 | - 🤖 Automating away the decision of "when should we release?" 15 | 16 | By automatically creating a release on every commit to your main branch, you ensure that: 17 | 1. Users always have access to the latest changes 18 | 2. Version numbers clearly indicate when changes were made 19 | 3. Each change is properly documented through commit messages 20 | 4. The release process is completely automated 21 | 22 | > [!NOTE] 23 | > For the best experience, we recommend using **squash merges** for your Pull Requests. 24 | > This ensures that: 25 | > - Each release corresponds to one logical change 26 | > - Release notes are clean and meaningful 27 | > - The git history remains linear and easy to understand 28 | > 29 | > Configure this in your GitHub repository settings under "Pull Requests" → "Allow squash merging" and uncheck other merge methods. 30 | 31 |

calver

32 | 33 | 34 |
35 | ToC 36 | 37 | 38 | 39 | - [Features](#features) 40 | - [Usage](#usage) 41 | - [GitHub Action](#github-action) 42 | - [CLI Usage](#cli-usage) 43 | - [Python API](#python-api) 44 | - [Release Notes Format](#release-notes-format) 45 | - [Requirements](#requirements) 46 | - [Installation](#installation) 47 | - [Configuration](#configuration) 48 | - [Skip Release Patterns](#skip-release-patterns) 49 | - [Version Format](#version-format) 50 | - [Custom Footer](#custom-footer) 51 | - [License](#license) 52 | - [Contributing](#contributing) 53 | - [Development](#development) 54 | 55 | 56 |
57 | 58 | ## Features 59 | - 📅 Automatic Calendar Versioning (`v{YYYY}.{MM}.{PATCH}`) 60 | - 🤖 Creates GitHub releases automatically 61 | - 📝 Generates release notes from commit messages 62 | - 🏷️ Supports release skipping with commit message flags 63 | - 🔄 Integrates with GitHub Actions 64 | - 🐍 Can be used as a Python package 65 | - 🖥️ Command-line interface included 66 | - 🧪 Dry-run mode for testing 67 | - 📋 Customizable release notes format 68 | 69 | ## Usage 70 | 71 | ### GitHub Action 72 | 73 | Add this to your workflow file (e.g., `.github/workflows/release.yml`): 74 | 75 | > [!NOTE] 76 | > See the [`basnijholt/home-assistant-streamdeck-yaml`'s `release.yml` workflow](https://github.com/basnijholt/home-assistant-streamdeck-yaml/blob/main/.github/workflows/release.yml)'s for a full example, and see it's [releases page](https://github.com/basnijholt/home-assistant-streamdeck-yaml/releases) for the result. 77 | 78 | A minimal example: 79 | 80 | ```yaml 81 | name: Create Release 82 | on: 83 | push: 84 | branches: [main] 85 | jobs: 86 | release: 87 | runs-on: ubuntu-latest 88 | steps: 89 | - uses: basnijholt/calver-auto-release@v1 90 | id: release 91 | with: 92 | github_token: ${{ secrets.GITHUB_TOKEN }} 93 | ``` 94 | 95 | And full example including publishing to PyPI: 96 | 97 | ```yaml 98 | name: Create Release 99 | on: 100 | push: 101 | branches: 102 | - main 103 | jobs: 104 | release: 105 | runs-on: ubuntu-latest 106 | environment: # Needed for `pypa/gh-action-pypi-publish` 107 | name: pypi 108 | url: https://pypi.org/p/${{ github.repository }} 109 | permissions: # Needed for `pypa/gh-action-pypi-publish` 110 | id-token: write # for PyPI publishing 111 | steps: 112 | # Create release with CalVer 113 | - uses: basnijholt/calver-auto-release@v1 114 | id: release 115 | with: 116 | github_token: ${{ secrets.GITHUB_TOKEN }} 117 | # Optional: custom configuration 118 | skip_patterns: "[skip release],[no-release]" 119 | footer: "Custom footer text" 120 | generate_release_notes: true # Add GitHub's automatic release notes 121 | 122 | # Optional: publish to PyPI 123 | # Only run if a new version was created 124 | - name: Build package 125 | if: steps.release.outputs.version != '' 126 | run: | 127 | python -m pip install build 128 | python -m build 129 | 130 | # Option 1: Publish with official PyPA action 131 | - name: Publish package distributions to PyPI 132 | if: steps.release.outputs.version != '' 133 | uses: pypa/gh-action-pypi-publish@release/v1 134 | 135 | # Option 2: Publish with twine 136 | # - name: Publish package distributions to PyPI 137 | # if: steps.release.outputs.version != '' 138 | # env: 139 | # TWINE_USERNAME: __token__ 140 | # TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 141 | # run: | 142 | # python -m pip install twine 143 | # twine upload dist/* 144 | ``` 145 | 146 | The action creates a new release with CalVer versioning, and you can optionally add your preferred method for publishing to PyPI or any other post-release tasks. 147 | 148 | > [!IMPORTANT] 149 | > The `secrets.GITHUB_TOKEN` variable is automatically populated (see [docs](https://docs.github.com/en/actions/security-guides/automatic-token-authentication)). 150 | > However, releases created using `GITHUB_TOKEN` will not trigger other workflows that run on the `release` event. 151 | > If you need to trigger other workflows when a release is created, you'll need to: 152 | > 1. Create a Personal Access Token (PAT) with `contents: write` permissions at https://github.com/settings/tokens 153 | > 2. Add it to your repository secrets (e.g., as `PAT`) 154 | > 3. Use it in the workflow: 155 | > ```yaml 156 | > - uses: basnijholt/calver-auto-release@v1 157 | > with: 158 | > github_token: ${{ secrets.PAT }} # Instead of secrets.GITHUB_TOKEN 159 | > ``` 160 | 161 | ### CLI Usage 162 | ```bash 163 | # Basic usage 164 | calver-auto-release --repo-path /path/to/repo 165 | 166 | # Dry run (show what would happen without creating the release) 167 | calver-auto-release --repo-path /path/to/repo --dry-run 168 | 169 | # Custom skip patterns 170 | calver-auto-release --repo-path /path/to/repo --skip-pattern "[no-release]" --skip-pattern "[skip]" 171 | ``` 172 | 173 | ### Python API 174 | ```python 175 | from calver_auto_release import create_release 176 | 177 | # Basic usage 178 | create_release() # Uses current directory 179 | 180 | # With custom configuration 181 | create_release( 182 | repo_path="/path/to/repo", 183 | skip_patterns=["[skip]", "[no-release]"], 184 | footer="\nCustom footer text", 185 | dry_run=True, # Show what would happen without creating the release 186 | ) 187 | ``` 188 | 189 | ### Release Notes Format 190 | The generated release notes will have this format: 191 | ``` 192 | 🚀 Release YYYY.MM.PATCH 193 | 194 | 📝 This release includes the following changes: 195 | 196 | - First commit message 197 | - Second commit message 198 | - etc. 199 | 200 | 🙏 Thank you for using this project! Please report any issues or feedback on the GitHub repository 201 | ``` 202 | 203 | ### Requirements 204 | - Git repository with an 'origin' remote configured 205 | - Python 3.10 or higher 206 | - Git command-line tools installed 207 | 208 | ## Installation 209 | 210 | Install using pip: 211 | ```bash 212 | pip install calver-auto-release 213 | ``` 214 | 215 | Or using [uv](https://github.com/astral-sh/uv): 216 | ```bash 217 | uv pip install calver-auto-release 218 | ``` 219 | 220 | ## Configuration 221 | 222 | ### Skip Release Patterns 223 | 224 | You can skip creating a release by including these patterns in your commit message: 225 | - `[skip release]` 226 | - `[pre-commit.ci]` 227 | - `⬆️ Update` 228 | 229 | ### Version Format 230 | 231 | The version format follows CalVer: `YYYY.MM.PATCH` 232 | - `YYYY`: Current year 233 | - `MM`: Current month 234 | - `PATCH`: Incremental number, resets when year or month changes 235 | 236 | ### Custom Footer 237 | 238 | You can customize the footer text that appears at the end of each release note: 239 | 240 | ```python 241 | create_release( 242 | footer="\nCustom footer text for all releases" 243 | ) 244 | ``` 245 | 246 | Or via CLI: 247 | ```bash 248 | calver-auto-release --footer "Custom footer text" 249 | ``` 250 | 251 | Or in the GitHub Action: 252 | ```yaml 253 | - uses: basnijholt/calver-auto-release@v1 254 | with: 255 | github_token: ${{ secrets.GITHUB_TOKEN }} 256 | footer: "Custom footer text" 257 | ``` 258 | 259 | ## License 260 | 261 | MIT License 262 | 263 | ## Contributing 264 | 265 | Contributions are welcome! Please feel free to submit a Pull Request. 266 | 267 | ### Development 268 | 269 | 1. Clone the repository 270 | 2. Install development dependencies: 271 | ```bash 272 | pip install -e ".[dev]" 273 | ``` 274 | 3. Install pre-commit hooks: 275 | ```bash 276 | pre-commit install 277 | ``` 278 | 4. Run tests: 279 | ```bash 280 | pytest 281 | ``` 282 | -------------------------------------------------------------------------------- /calver_auto_release.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | """calver-auto-release: Create new release tags with CalVer format. 3 | 4 | Creates tags in the format vYYYY.MM.PATCH (e.g., v2024.3.1) and corresponding 5 | GitHub releases with automatically generated release notes. 6 | """ 7 | 8 | from __future__ import annotations 9 | 10 | import datetime 11 | import operator 12 | import os 13 | from typing import TYPE_CHECKING 14 | 15 | import git 16 | from packaging import version 17 | from rich.console import Console 18 | from rich.panel import Panel 19 | from rich.syntax import Syntax 20 | from rich.table import Table 21 | 22 | if TYPE_CHECKING: 23 | from collections.abc import Sequence 24 | from pathlib import Path 25 | 26 | DEFAULT_SKIP_PATTERNS = ["[skip release]", "[pre-commit.ci]", "⬆️ Update"] 27 | DEFAULT_FOOTER = ( 28 | "\n\n🙏 Thank you for using this project! Please report any issues " 29 | "or feedback on the GitHub repository." 30 | ) 31 | 32 | console = Console(soft_wrap=True) 33 | 34 | 35 | def create_release( 36 | *, 37 | repo_path: str | Path = ".", 38 | skip_patterns: Sequence[str] | None = None, 39 | footer: str | None = None, 40 | dry_run: bool = False, 41 | ) -> str | None: 42 | """Create a new release tag with CalVer format. 43 | 44 | Parameters 45 | ---------- 46 | repo_path 47 | Path to the git repository. 48 | skip_patterns 49 | List of patterns to check in commit messages to skip release. 50 | footer 51 | Custom footer to add to release notes. 52 | dry_run 53 | If True, only return the version without creating the release. 54 | 55 | Returns 56 | ------- 57 | str | None 58 | The new version number (in format vYYYY.MM.PATCH) if a release was created 59 | or would be created (dry_run), None if release was skipped. 60 | 61 | """ 62 | skip_patterns = skip_patterns or DEFAULT_SKIP_PATTERNS 63 | footer = footer or DEFAULT_FOOTER 64 | 65 | with console.status("[bold green]Checking repository..."): 66 | repo = git.Repo(repo_path) 67 | 68 | if _is_already_tagged(repo): 69 | console.print("[yellow]Current commit is already tagged![/yellow]") 70 | return None 71 | 72 | if _should_skip_release(repo, skip_patterns): 73 | console.print("[yellow]Skipping release due to commit message![/yellow]") 74 | return None 75 | 76 | new_version = _get_new_version(repo) 77 | commit_messages = _get_commit_messages_since_last_release(repo) 78 | release_notes = _format_release_notes(commit_messages, new_version, footer, repo=repo) 79 | 80 | # Show release information 81 | _display_release_info(new_version, commit_messages.split("\n"), dry_run, release_notes) 82 | 83 | if not dry_run: 84 | with console.status("[bold green]Creating release..."): 85 | _create_tag(repo, new_version, release_notes) 86 | _push_tag(repo, new_version) 87 | 88 | # Write the output version to the GITHUB_OUTPUT environment file if it exists 89 | if "GITHUB_OUTPUT" in os.environ: 90 | with open(os.environ["GITHUB_OUTPUT"], "a") as f: # noqa: PTH123 91 | f.write(f"version={new_version}\n") 92 | 93 | console.print(f"[bold green]✨ Created new tag: {new_version}[/bold green]") 94 | 95 | return new_version 96 | 97 | 98 | def _display_release_info( 99 | version: str, 100 | commits: list[str], 101 | dry_run: bool, # noqa: FBT001 102 | release_notes: str, 103 | ) -> None: 104 | """Display formatted release information.""" 105 | # Create a table for commit messages 106 | table = Table(title="📝 Commits included in this release") 107 | table.add_column("Commit Message", style="cyan") 108 | 109 | for commit in commits: 110 | table.add_row(commit) 111 | 112 | # Create a panel with release information 113 | mode = "[yellow]DRY RUN[/yellow]" if dry_run else "[green]RELEASE[/green]" 114 | info_panel = Panel( 115 | f"[bold]Version:[/bold] {version}\n" 116 | f"[bold]Mode:[/bold] {mode}\n" 117 | f"[bold]Number of commits:[/bold] {len(commits)}", 118 | title="🚀 Release Information", 119 | border_style="blue", 120 | ) 121 | 122 | # Create a panel for release notes with syntax highlighting 123 | release_notes_panel = Panel( 124 | Syntax(release_notes, "markdown", theme="monokai", line_numbers=False), 125 | title="📋 Release Notes Preview", 126 | border_style="green", 127 | ) 128 | 129 | # Print everything 130 | console.print(info_panel) 131 | console.print(table) 132 | console.print(release_notes_panel) 133 | console.print() 134 | 135 | 136 | def _is_already_tagged(repo: git.Repo) -> bool: 137 | """Check if the current commit is already tagged.""" 138 | return bool(repo.git.tag(points_at="HEAD")) 139 | 140 | 141 | def _should_skip_release(repo: git.Repo, skip_patterns: Sequence[str]) -> bool: 142 | """Check if the commit message contains any skip patterns.""" 143 | commit_message = repo.head.commit.message.split("\n")[0] 144 | return any(pattern in commit_message for pattern in skip_patterns) 145 | 146 | 147 | def _get_new_version(repo: git.Repo) -> str: 148 | """Get the new version number. 149 | 150 | Returns a version string in the format vYYYY.MM.PATCH, e.g., v2024.3.1 151 | """ 152 | try: 153 | latest_tag = max(repo.tags, key=operator.attrgetter("commit.committed_datetime")) 154 | # Remove 'v' prefix for version parsing 155 | last_version = version.parse(latest_tag.name.lstrip("v")) 156 | now = datetime.datetime.now(tz=datetime.timezone.utc) 157 | patch = ( 158 | last_version.micro + 1 159 | if last_version.major == now.year and last_version.minor == now.month 160 | else 0 161 | ) 162 | except ValueError: # No tags exist 163 | now = datetime.datetime.now(tz=datetime.timezone.utc) 164 | patch = 0 165 | 166 | return f"v{now.year}.{now.month}.{patch}" 167 | 168 | 169 | def _set_author(repo: git.Repo) -> None: 170 | """Set author information.""" 171 | author_name = repo.head.commit.author.name 172 | author_email = repo.head.commit.author.email 173 | os.environ["GIT_AUTHOR_NAME"] = author_name 174 | os.environ["GIT_AUTHOR_EMAIL"] = author_email 175 | os.environ["GIT_COMMITTER_NAME"] = author_name 176 | os.environ["GIT_COMMITTER_EMAIL"] = author_email 177 | 178 | 179 | def _create_tag(repo: git.Repo, new_version: str, release_notes: str) -> None: 180 | """Create a new tag.""" 181 | _set_author(repo) 182 | repo.create_tag( 183 | new_version, 184 | message=f"Release {new_version}\n\n{release_notes}", 185 | cleanup="verbatim", 186 | ) 187 | 188 | 189 | def _push_tag(repo: git.Repo, new_version: str) -> None: 190 | """Push the new tag to the remote repository.""" 191 | origin = repo.remote("origin") 192 | origin.push(new_version) 193 | 194 | 195 | def _get_commit_messages_since_last_release(repo: git.Repo) -> str: 196 | """Get the commit messages since the last release.""" 197 | try: 198 | latest_tag = max(repo.tags, key=operator.attrgetter("commit.committed_datetime")) 199 | return repo.git.log(f"{latest_tag}..HEAD", "--pretty=format:%s") # type: ignore[no-any-return] 200 | except ValueError: # No tags exist 201 | return repo.git.log("--pretty=format:%s") # type: ignore[no-any-return] 202 | 203 | 204 | def _get_commit_details(repo: git.Repo, since_ref: str | None = None) -> list[tuple[str, str, str]]: 205 | """Get detailed commit information since the last release. 206 | 207 | Returns 208 | ------- 209 | list of (hash, author, message) tuples 210 | 211 | """ 212 | log_format = "--pretty=format:%h|%an|%s" # hash|author|subject 213 | if since_ref is None: 214 | log = repo.git.log(log_format) 215 | else: 216 | log = repo.git.log(f"{since_ref}..HEAD", log_format) 217 | 218 | commits = [] 219 | for line in log.split("\n"): 220 | if line: 221 | hash_, author, message = line.split("|", 2) 222 | commits.append((hash_, author, message)) 223 | return commits 224 | 225 | 226 | def _format_release_notes( 227 | commit_messages: str, 228 | new_version: str, 229 | footer: str, 230 | *, 231 | repo: git.Repo | None = None, 232 | ) -> str: 233 | """Format the release notes with enhanced information. 234 | 235 | The version number will be displayed without the 'v' prefix in the release notes 236 | for better readability. 237 | """ 238 | # Remove 'v' prefix for display in release notes 239 | display_version = new_version.lstrip("v") 240 | 241 | # Get repository URL if available 242 | repo_url = "" 243 | if repo is not None: 244 | try: 245 | remote_url = repo.remote("origin").url 246 | if remote_url.endswith(".git"): 247 | remote_url = remote_url[:-4] 248 | if "github.com" in remote_url: 249 | repo_url = remote_url.replace("git@github.com:", "https://github.com/") 250 | except (git.exc.GitCommandError, ValueError): 251 | # Ignore if we can't get the remote URL 252 | pass 253 | 254 | # Get detailed commit information if repo is available 255 | commits_info = [] 256 | unique_authors = set() 257 | if repo is not None: 258 | try: 259 | latest_tag = max(repo.tags, key=operator.attrgetter("commit.committed_datetime")) 260 | commits_info = _get_commit_details(repo, latest_tag.name) 261 | except ValueError: # No tags exist 262 | commits_info = _get_commit_details(repo) 263 | except git.exc.GitCommandError: 264 | # Fallback to simple commit messages if git commands fail 265 | commits_info = [("", "", msg) for msg in commit_messages.split("\n") if msg] 266 | else: 267 | # Use simple commit messages when no repo is provided 268 | commits_info = [("", "", msg) for msg in commit_messages.split("\n") if msg] 269 | 270 | unique_authors = {author for _, author, _ in commits_info if author} 271 | 272 | # Format the release notes with markdown 273 | parts = [ 274 | f"# Release {display_version}\n", 275 | "## 📊 Statistics", 276 | f"- 📦 **{len(commits_info)}** commits", 277 | f"- 👥 **{len(unique_authors)}** contributors\n", 278 | ] 279 | 280 | parts.append("## 📝 Changes\n") 281 | 282 | # Add commits with links if repo_url is available 283 | for hash_, author, message in commits_info: 284 | commit_line = f"- {message}" 285 | if repo_url and hash_: 286 | commit_line = ( 287 | f"- [{message}]({repo_url}/commit/{hash_}) " 288 | f"by @{author.lower().replace(' ', '')}" 289 | ) 290 | parts.append(commit_line) 291 | 292 | if unique_authors: 293 | parts.extend( 294 | [ 295 | "## 👥 Contributors", 296 | ", ".join( 297 | f"@{author.lower().replace(' ', '')}" for author in sorted(unique_authors) 298 | ), 299 | "", 300 | ], 301 | ) 302 | 303 | # Add footer with markdown formatting 304 | if footer: 305 | parts.extend(["", "---", footer.lstrip()]) 306 | 307 | return "\n".join(parts) 308 | 309 | 310 | def cli() -> None: 311 | """Command-line interface for calver-auto-release.""" 312 | import argparse 313 | 314 | parser = argparse.ArgumentParser(description="Create a new release with CalVer format.") 315 | parser.add_argument( 316 | "--repo-path", 317 | type=str, 318 | default=".", 319 | help="Path to the git repository (default: current directory)", 320 | ) 321 | parser.add_argument( 322 | "--skip-pattern", 323 | action="append", 324 | help="Pattern to check in commit messages to skip release (can be specified multiple times)", # noqa: E501 325 | ) 326 | parser.add_argument( 327 | "--footer", 328 | type=str, 329 | help="Custom footer to add to release notes", 330 | ) 331 | parser.add_argument( 332 | "--dry-run", 333 | action="store_true", 334 | help="Only show what would be done without creating the release", 335 | ) 336 | 337 | args = parser.parse_args() 338 | 339 | # Handle environment variables if CLI args are not set 340 | if args.skip_pattern is None and "CALVER_SKIP_PATTERNS" in os.environ: 341 | skip_patterns = os.environ["CALVER_SKIP_PATTERNS"].split(",") 342 | args.skip_pattern = [p.strip() for p in skip_patterns] 343 | 344 | if args.footer is None and "CALVER_FOOTER" in os.environ: 345 | args.footer = os.environ["CALVER_FOOTER"] 346 | 347 | if not args.dry_run and "CALVER_DRY_RUN" in os.environ: 348 | args.dry_run = os.environ["CALVER_DRY_RUN"].lower() == "true" 349 | 350 | try: 351 | version = create_release( 352 | repo_path=args.repo_path, 353 | skip_patterns=args.skip_pattern, 354 | footer=args.footer, 355 | dry_run=args.dry_run, 356 | ) 357 | 358 | if version and args.dry_run: 359 | console.print( 360 | f"[yellow]Would create new tag:[/yellow] [bold cyan]{version}[/bold cyan]", 361 | ) 362 | except Exception as e: # pragma: no cover 363 | console.print(f"[bold red]Error:[/bold red] {e!s}") 364 | raise 365 | 366 | 367 | if __name__ == "__main__": 368 | cli() 369 | -------------------------------------------------------------------------------- /tests/test_calver_auto_release.py: -------------------------------------------------------------------------------- 1 | """Tests for calver-auto-release.""" 2 | 3 | from __future__ import annotations 4 | 5 | import datetime 6 | import os 7 | import subprocess 8 | from pathlib import Path 9 | from unittest.mock import patch 10 | 11 | import git 12 | import pytest 13 | 14 | from calver_auto_release import ( 15 | DEFAULT_FOOTER, 16 | DEFAULT_SKIP_PATTERNS, 17 | _format_release_notes, 18 | _get_new_version, 19 | _should_skip_release, 20 | cli, 21 | create_release, 22 | ) 23 | 24 | 25 | @pytest.fixture # type: ignore[misc] 26 | def git_repo(tmp_path: Path) -> git.Repo: 27 | """Create a temporary git repository.""" 28 | repo = git.Repo.init(tmp_path) 29 | 30 | # Configure git user 31 | repo.config_writer().set_value("user", "name", "Test User").release() 32 | repo.config_writer().set_value("user", "email", "test@example.com").release() 33 | 34 | # Create and commit a dummy file 35 | dummy_file = tmp_path / "dummy.txt" 36 | dummy_file.write_text("Hello, World!") 37 | repo.index.add([str(dummy_file)]) 38 | repo.index.commit("Initial commit") 39 | 40 | # Create a fake remote 41 | remote_path = tmp_path / "remote" 42 | remote_path.mkdir() 43 | git.Repo.init(remote_path, bare=True) 44 | repo.create_remote("origin", url=str(remote_path)) 45 | 46 | return repo 47 | 48 | 49 | @pytest.fixture # type: ignore[misc] 50 | def git_repo_with_tag(git_repo: git.Repo) -> git.Repo: 51 | """Create a temporary git repository with a tag.""" 52 | now = datetime.datetime.now(tz=datetime.timezone.utc) 53 | tag_name = f"{now.year}.{now.month}.0" 54 | git_repo.create_tag(tag_name, message=f"Release {tag_name}") 55 | return git_repo 56 | 57 | 58 | def test_create_release_basic(git_repo: git.Repo) -> None: 59 | """Test basic release creation.""" 60 | version = create_release(repo_path=git_repo.working_dir) 61 | assert version is not None 62 | now = datetime.datetime.now(tz=datetime.timezone.utc) 63 | assert version == f"v{now.year}.{now.month}.0" 64 | assert version in [tag.name for tag in git_repo.tags] 65 | 66 | 67 | def test_create_release_with_existing_tag(git_repo_with_tag: git.Repo) -> None: 68 | """Test release creation with existing tag.""" 69 | # Create a new commit 70 | dummy_file = Path(git_repo_with_tag.working_dir) / "dummy2.txt" 71 | dummy_file.write_text("New file") 72 | git_repo_with_tag.index.add([str(dummy_file)]) 73 | git_repo_with_tag.index.commit("Second commit") 74 | 75 | version = create_release(repo_path=git_repo_with_tag.working_dir) 76 | assert version is not None 77 | now = datetime.datetime.now(tz=datetime.timezone.utc) 78 | assert version == f"v{now.year}.{now.month}.1" 79 | 80 | 81 | def test_create_release_skip_patterns(git_repo: git.Repo) -> None: 82 | """Test release skipping based on commit message.""" 83 | # Create a commit with skip pattern 84 | dummy_file = Path(git_repo.working_dir) / "dummy2.txt" 85 | dummy_file.write_text("New file") 86 | git_repo.index.add([str(dummy_file)]) 87 | git_repo.index.commit("[skip release] Skip this release") 88 | 89 | version = create_release(repo_path=git_repo.working_dir) 90 | assert version is None 91 | assert not git_repo.tags 92 | 93 | 94 | def test_create_release_dry_run(git_repo: git.Repo) -> None: 95 | """Test dry run mode.""" 96 | version = create_release(repo_path=git_repo.working_dir, dry_run=True) 97 | assert version is not None 98 | now = datetime.datetime.now(tz=datetime.timezone.utc) 99 | assert version == f"v{now.year}.{now.month}.0" 100 | assert not git_repo.tags 101 | 102 | 103 | def test_create_release_already_tagged(git_repo_with_tag: git.Repo) -> None: 104 | """Test when commit is already tagged.""" 105 | version = create_release(repo_path=git_repo_with_tag.working_dir) 106 | assert version is None 107 | 108 | 109 | def test_create_release_custom_footer(git_repo: git.Repo) -> None: 110 | """Test release creation with custom footer.""" 111 | custom_footer = "\nCustom footer" 112 | version = create_release(repo_path=git_repo.working_dir, footer=custom_footer) 113 | assert version is not None 114 | tag = git_repo.tags[0] 115 | assert custom_footer in tag.tag.message 116 | 117 | 118 | def test_create_release_custom_skip_patterns(git_repo: git.Repo) -> None: 119 | """Test release creation with custom skip patterns.""" 120 | # Create a commit with custom skip pattern 121 | dummy_file = Path(git_repo.working_dir) / "dummy2.txt" 122 | dummy_file.write_text("New file") 123 | git_repo.index.add([str(dummy_file)]) 124 | git_repo.index.commit("[custom-skip] Skip this release") 125 | 126 | version = create_release( 127 | repo_path=git_repo.working_dir, 128 | skip_patterns=["[custom-skip]"], 129 | ) 130 | assert version is None 131 | assert not git_repo.tags 132 | 133 | 134 | def test_format_release_notes(git_repo: git.Repo) -> None: 135 | """Test release notes formatting.""" 136 | # First test without repo 137 | commit_messages = "First commit\nSecond commit" 138 | version = "2024.1.0" 139 | notes = _format_release_notes(commit_messages, version, DEFAULT_FOOTER) 140 | 141 | # Check the main structure 142 | assert "# Release 2024.1.0" in notes 143 | assert "## 📊 Statistics" in notes 144 | assert "## 📝 Changes" in notes 145 | 146 | # Check the commit messages 147 | assert "First commit" in notes 148 | assert "Second commit" in notes 149 | 150 | # Check statistics 151 | assert "**2** commits" in notes # 2 commits from the commit_messages 152 | assert "**0** contributors" in notes # 0 because no author information without repo 153 | 154 | # Check footer 155 | footer_content = "🙏 Thank you for using this project! Please report any issues or feedback on the GitHub repository" # noqa: E501 156 | assert footer_content in notes 157 | 158 | # Now test with actual repo 159 | # Create two more commits with different authors 160 | dummy_file2 = Path(git_repo.working_dir) / "file2.txt" 161 | dummy_file2.write_text("Second file") 162 | git_repo.index.add([str(dummy_file2)]) 163 | git_repo.index.commit( 164 | "First commit", 165 | author=git.Actor("John Doe", "john@example.com"), 166 | committer=git.Actor("John Doe", "john@example.com"), 167 | ) 168 | 169 | dummy_file3 = Path(git_repo.working_dir) / "file3.txt" 170 | dummy_file3.write_text("Third file") 171 | git_repo.index.add([str(dummy_file3)]) 172 | git_repo.index.commit( 173 | "Second commit", 174 | author=git.Actor("Jane Doe", "jane@example.com"), 175 | committer=git.Actor("Jane Doe", "jane@example.com"), 176 | ) 177 | 178 | notes_with_repo = _format_release_notes( 179 | commit_messages, 180 | version, 181 | DEFAULT_FOOTER, 182 | repo=git_repo, 183 | ) 184 | 185 | # Check if contributors section exists 186 | assert "## 👥 Contributors" in notes_with_repo 187 | assert "@johndoe" in notes_with_repo 188 | assert "@janedoe" in notes_with_repo 189 | assert "@testuser" in notes_with_repo # From the initial commit 190 | 191 | # Check statistics with actual commits 192 | assert "**3** commits" in notes_with_repo # Initial + 2 new commits 193 | assert "**3** contributors" in notes_with_repo # Test User + John Doe + Jane Doe 194 | 195 | 196 | def test_should_skip_release(git_repo: git.Repo) -> None: 197 | """Test skip release detection.""" 198 | for pattern in DEFAULT_SKIP_PATTERNS: 199 | dummy_file = Path(git_repo.working_dir) / f"dummy_{pattern}.txt" 200 | dummy_file.write_text("New file") 201 | git_repo.index.add([str(dummy_file)]) 202 | git_repo.index.commit(f"Test commit {pattern}") 203 | assert _should_skip_release(git_repo, DEFAULT_SKIP_PATTERNS) 204 | 205 | 206 | def test_get_new_version_no_tags(git_repo: git.Repo) -> None: 207 | """Test version generation with no existing tags.""" 208 | version = _get_new_version(git_repo) 209 | now = datetime.datetime.now(tz=datetime.timezone.utc) 210 | assert version == f"v{now.year}.{now.month}.0" 211 | 212 | 213 | def test_cli(git_repo: git.Repo, capsys: pytest.CaptureFixture) -> None: 214 | """Test CLI interface.""" 215 | with patch("sys.argv", ["calver-auto-release", "--repo-path", git_repo.working_dir]): 216 | cli() 217 | 218 | captured = capsys.readouterr() 219 | assert "Created new tag:" in captured.out 220 | assert git_repo.tags 221 | 222 | 223 | def test_cli_dry_run(git_repo: git.Repo, capsys: pytest.CaptureFixture) -> None: 224 | """Test CLI interface with dry run.""" 225 | with patch( 226 | "sys.argv", 227 | ["calver-auto-release", "--repo-path", git_repo.working_dir, "--dry-run"], 228 | ): 229 | cli() 230 | 231 | captured = capsys.readouterr() 232 | assert "Would create new tag:" in captured.out 233 | assert not git_repo.tags 234 | 235 | 236 | def test_cli_skip_pattern(git_repo: git.Repo) -> None: 237 | """Test CLI interface with custom skip pattern.""" 238 | # Create a commit with custom skip pattern 239 | dummy_file = Path(git_repo.working_dir) / "dummy2.txt" 240 | dummy_file.write_text("New file") 241 | git_repo.index.add([str(dummy_file)]) 242 | git_repo.index.commit("[custom-skip] Skip this release") 243 | 244 | with patch( 245 | "sys.argv", 246 | [ 247 | "calver-auto-release", 248 | "--repo-path", 249 | git_repo.working_dir, 250 | "--skip-pattern", 251 | "[custom-skip]", 252 | ], 253 | ): 254 | cli() 255 | 256 | assert not git_repo.tags 257 | 258 | 259 | def test_github_environment(git_repo: git.Repo, tmp_path: Path) -> None: 260 | """Test GitHub Actions environment handling.""" 261 | github_output = tmp_path / "github_output" 262 | with patch.dict(os.environ, {"GITHUB_OUTPUT": str(github_output)}): 263 | version = create_release(repo_path=git_repo.working_dir) 264 | 265 | assert version is not None 266 | assert github_output.exists() 267 | assert f"version={version}" in github_output.read_text() 268 | 269 | 270 | def test_real_git_operations(tmp_path: Path) -> None: 271 | """Test actual git operations using subprocess.""" 272 | # Initialize a new repo 273 | repo_path = tmp_path / "test_repo" 274 | repo_path.mkdir() 275 | remote_path = tmp_path / "remote" 276 | remote_path.mkdir() 277 | 278 | # Initialize bare remote repository 279 | subprocess.run(["git", "init", "--bare"], cwd=remote_path, check=True) 280 | 281 | # Initialize local repository 282 | subprocess.run(["git", "init"], cwd=repo_path, check=True) 283 | subprocess.run( 284 | ["git", "config", "user.name", "Test User"], 285 | cwd=repo_path, 286 | check=True, 287 | ) 288 | subprocess.run( 289 | ["git", "config", "user.email", "test@example.com"], 290 | cwd=repo_path, 291 | check=True, 292 | ) 293 | 294 | # Add remote 295 | subprocess.run( 296 | ["git", "remote", "add", "origin", str(remote_path)], 297 | cwd=repo_path, 298 | check=True, 299 | ) 300 | 301 | # Create and commit a file 302 | test_file = repo_path / "test.txt" 303 | test_file.write_text("Hello, World!") 304 | subprocess.run(["git", "add", "test.txt"], cwd=repo_path, check=True) 305 | subprocess.run( 306 | ["git", "commit", "-m", "Initial commit"], 307 | cwd=repo_path, 308 | check=True, 309 | ) 310 | 311 | # Run the CLI 312 | with patch("sys.argv", ["calver-auto-release", "--repo-path", str(repo_path)]): 313 | cli() 314 | 315 | # Verify the tag was created 316 | result = subprocess.run( 317 | ["git", "tag"], 318 | cwd=repo_path, 319 | check=True, 320 | capture_output=True, 321 | text=True, 322 | ) 323 | assert result.stdout.strip() 324 | 325 | 326 | @pytest.fixture # type: ignore[misc] 327 | def cli_repo(tmp_path: Path) -> git.Repo: 328 | """Create a temporary git repository for CLI testing.""" 329 | repo = git.Repo.init(tmp_path) 330 | repo.config_writer().set_value("user", "name", "Test User").release() 331 | repo.config_writer().set_value("user", "email", "test@example.com").release() 332 | 333 | # Set up remote 334 | remote_path = tmp_path / "remote" 335 | remote_path.mkdir() 336 | git.Repo.init(remote_path, bare=True) 337 | repo.create_remote("origin", url=str(remote_path)) 338 | 339 | return repo 340 | 341 | 342 | def test_cli_environment_variables(cli_repo: git.Repo, monkeypatch: pytest.MonkeyPatch) -> None: 343 | """Test CLI behavior with environment variables.""" 344 | # Create and commit a test file 345 | test_file = Path(cli_repo.working_dir) / "test.txt" 346 | test_file.write_text("test") 347 | cli_repo.index.add([str(test_file)]) 348 | cli_repo.index.commit("Initial commit") 349 | 350 | # Test skip patterns from environment (with dry run) 351 | monkeypatch.setenv("CALVER_SKIP_PATTERNS", "[no-release], [skip-ci]") 352 | monkeypatch.setenv("CALVER_DRY_RUN", "true") # Add this line 353 | with patch("sys.argv", ["calver-auto-release", "--repo-path", cli_repo.working_dir]): 354 | cli() 355 | assert not cli_repo.tags # No tags should be created with dry run 356 | 357 | # Test custom footer from environment (with dry run) 358 | custom_footer = "Custom footer from env" 359 | monkeypatch.setenv("CALVER_FOOTER", custom_footer) 360 | monkeypatch.setenv("CALVER_DRY_RUN", "true") # Keep dry run 361 | with patch("sys.argv", ["calver-auto-release", "--repo-path", cli_repo.working_dir]): 362 | cli() 363 | assert not cli_repo.tags # Still no tags 364 | 365 | # Test actual release with different dry run values 366 | for dry_run_value in ["false", "FALSE", "False"]: 367 | monkeypatch.setenv("CALVER_DRY_RUN", dry_run_value) 368 | with patch("sys.argv", ["calver-auto-release", "--repo-path", cli_repo.working_dir]): 369 | cli() 370 | assert len(cli_repo.tags) == 1 # Now we should have a tag 371 | cli_repo.delete_tag(cli_repo.tags[0]) # Clean up for next iteration 372 | 373 | 374 | def test_cli_environment_variables_precedence( 375 | cli_repo: git.Repo, 376 | monkeypatch: pytest.MonkeyPatch, 377 | ) -> None: 378 | """Test that command line arguments take precedence over environment variables.""" 379 | # Create and commit a test file with skip pattern 380 | test_file = Path(cli_repo.working_dir) / "test.txt" 381 | test_file.write_text("test") 382 | cli_repo.index.add([str(test_file)]) 383 | cli_repo.index.commit("[no-release] Test skip") 384 | 385 | # Set environment variables that would allow release 386 | monkeypatch.setenv("CALVER_SKIP_PATTERNS", "[different-pattern]") 387 | monkeypatch.setenv("CALVER_FOOTER", "Footer from env") 388 | monkeypatch.setenv("CALVER_DRY_RUN", "false") 389 | 390 | # CLI args should take precedence and prevent release 391 | with patch( 392 | "sys.argv", 393 | [ 394 | "calver-auto-release", 395 | "--repo-path", 396 | cli_repo.working_dir, 397 | "--skip-pattern", 398 | "[no-release]", 399 | ], 400 | ): 401 | cli() 402 | 403 | assert not cli_repo.tags # No tags should be created due to skip pattern 404 | 405 | 406 | def test_cli_environment_variables_invalid( 407 | cli_repo: git.Repo, 408 | monkeypatch: pytest.MonkeyPatch, 409 | ) -> None: 410 | """Test CLI behavior with invalid environment variables.""" 411 | # Create and commit a test file 412 | test_file = Path(cli_repo.working_dir) / "test.txt" 413 | test_file.write_text("test") 414 | cli_repo.index.add([str(test_file)]) 415 | cli_repo.index.commit("Initial commit") 416 | 417 | # Test invalid dry run value 418 | monkeypatch.setenv("CALVER_DRY_RUN", "invalid") 419 | with patch("sys.argv", ["calver-auto-release", "--repo-path", cli_repo.working_dir]): 420 | version = create_release(repo_path=cli_repo.working_dir) 421 | assert version is not None # Should default to False for invalid value 422 | --------------------------------------------------------------------------------