diff --git a/.github/changed-files-spec.yml b/.github/changed-files-spec.yml index 7f8e40353..8d479d1db 100644 --- a/.github/changed-files-spec.yml +++ b/.github/changed-files-spec.yml @@ -2,8 +2,6 @@ build: - MANIFEST.in - - Dockerfile - - .dockerignore - scripts/** docs: - docs/** @@ -11,8 +9,15 @@ docs: - AUTHORS.rst - CONTRIBUTING.rst - CHANGELOG.rst +gha_src: + - src/gh_action/** src: - - src/** + - src/semantic_release/** - pyproject.toml +gha_tests: + - tests/gh_action/** tests: - - tests/** + - tests/e2e/** + - tests/fixtures/** + - tests/unit/** + - tests/*.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 36b693d01..8658260eb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,7 +25,7 @@ jobs: with: fetch-depth: 0 - - uses: wagoid/commitlint-github-action@v6 + - uses: wagoid/commitlint-github-action@b948419dd99f3fd78a6548d48f94e3df7f6bf3ed # v6.2.1 eval-changes: @@ -41,13 +41,13 @@ jobs: - name: Evaluate | Check common file types for changes id: core-changed-files - uses: tj-actions/changed-files@v45.0.7 + uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c #v46.0.5 with: files_yaml_from_source_file: .github/changed-files-spec.yml - name: Evaluate | Check specific file types for changes id: ci-changed-files - uses: tj-actions/changed-files@v45.0.7 + uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c #v46.0.5 with: files_yaml: | ci: @@ -62,8 +62,10 @@ jobs: [ "${{ steps.ci-changed-files.outputs.ci_any_changed }}" == "true" ] || \ [ "${{ steps.core-changed-files.outputs.docs_any_changed }}" == "true" ] || \ [ "${{ steps.core-changed-files.outputs.src_any_changed }}" == "true" ] || \ - [ "${{ steps.core-changed-files.outputs.tests_any_changed }}" == "true" ]; then - printf '%s\n' "any_changed=true" >> $GITHUB_OUTPUT + [ "${{ steps.core-changed-files.outputs.tests_any_changed }}" == "true" ] || \ + [ "${{ steps.core-changed-files.outputs.gha_src_any_changed }}" == "true" ] || \ + [ "${{ steps.core-changed-files.outputs.gha_tests_any_changed }}" == "true" ]; then + printf '%s\n' "any_changed=true" >> $GITHUB_OUTPUT fi outputs: @@ -74,6 +76,8 @@ jobs: doc-changes: ${{ steps.core-changed-files.outputs.docs_any_changed }} src-changes: ${{ steps.core-changed-files.outputs.src_any_changed }} test-changes: ${{ steps.core-changed-files.outputs.tests_any_changed }} + gha-src-changes: ${{ steps.core-changed-files.outputs.gha_src_any_changed }} + gha-test-changes: ${{ steps.core-changed-files.outputs.gha_tests_any_changed }} validate: @@ -91,5 +95,7 @@ jobs: doc-files-changed: ${{ needs.eval-changes.outputs.doc-changes }} src-files-changed: ${{ needs.eval-changes.outputs.src-changes }} test-files-changed: ${{ needs.eval-changes.outputs.test-changes }} + gha-src-files-changed: ${{ needs.eval-changes.outputs.gha-src-changes }} + gha-test-files-changed: ${{ needs.eval-changes.outputs.gha-test-changes }} permissions: {} secrets: {} diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index bcf298dde..8c8b45c45 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -26,14 +26,14 @@ jobs: - name: Evaluate | Check common file types for changes id: core-changed-files - uses: tj-actions/changed-files@v45.0.7 + uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c #v46.0.5 with: base_sha: ${{ github.event.push.before }} files_yaml_from_source_file: .github/changed-files-spec.yml - name: Evaluate | Check specific file types for changes id: ci-changed-files - uses: tj-actions/changed-files@v45.0.7 + uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c #v46.0.5 with: base_sha: ${{ github.event.push.before }} files_yaml: | @@ -49,8 +49,10 @@ jobs: [ "${{ steps.ci-changed-files.outputs.ci_any_changed }}" == "true" ] || \ [ "${{ steps.core-changed-files.outputs.docs_any_changed }}" == "true" ] || \ [ "${{ steps.core-changed-files.outputs.src_any_changed }}" == "true" ] || \ - [ "${{ steps.core-changed-files.outputs.tests_any_changed }}" == "true" ]; then - printf '%s\n' "any_changed=true" >> $GITHUB_OUTPUT + [ "${{ steps.core-changed-files.outputs.tests_any_changed }}" == "true" ] || \ + [ "${{ steps.core-changed-files.outputs.gha_src_any_changed }}" == "true" ] || \ + [ "${{ steps.core-changed-files.outputs.gha_tests_any_changed }}" == "true" ]; then + printf '%s\n' "any_changed=true" >> $GITHUB_OUTPUT fi outputs: @@ -60,6 +62,8 @@ jobs: doc-changes: ${{ steps.core-changed-files.outputs.docs_any_changed }} src-changes: ${{ steps.core-changed-files.outputs.src_any_changed }} test-changes: ${{ steps.core-changed-files.outputs.tests_any_changed }} + gha-src-changes: ${{ steps.core-changed-files.outputs.gha_src_any_changed }} + gha-test-changes: ${{ steps.core-changed-files.outputs.gha_tests_any_changed }} validate: @@ -77,6 +81,8 @@ jobs: doc-files-changed: ${{ needs.eval-changes.outputs.doc-changes }} src-files-changed: ${{ needs.eval-changes.outputs.src-changes }} test-files-changed: ${{ needs.eval-changes.outputs.test-changes }} + gha-src-files-changed: ${{ needs.eval-changes.outputs.gha-src-changes }} + gha-test-files-changed: ${{ needs.eval-changes.outputs.gha-test-changes }} permissions: {} secrets: {} @@ -139,14 +145,14 @@ jobs: - name: Release | Python Semantic Release id: release - uses: python-semantic-release/python-semantic-release@v9.20.0 + uses: python-semantic-release/python-semantic-release@092ace20f4ebed6a656da54b499076f1a5b803c8 # v10.0.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} - root_options: "-v" + verbosity: 1 build: false - name: Release | Add distribution artifacts to GitHub Release Assets - uses: python-semantic-release/publish-action@v9.20.0 + uses: python-semantic-release/publish-action@d62706ce15a7c98325c51a3e5cc789fdbe843e5a # v10.0.0 if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml index 4e54606dd..e1f292d66 100644 --- a/.github/workflows/manual.yml +++ b/.github/workflows/manual.yml @@ -65,7 +65,7 @@ jobs: python-version: ${{ env.COMMON_PYTHON_VERSION }} - name: Setup | Write file - uses: DamianReeves/write-file-action@v1.3 + uses: DamianReeves/write-file-action@6929a9a6d1807689191dcc8bbe62b54d70a32b42 #v1.3 with: path: .github/manual_eval_input.py write-mode: overwrite @@ -133,6 +133,8 @@ jobs: doc-files-changed: true src-files-changed: true test-files-changed: true + gha-src-files-changed: true + gha-test-files-changed: true files-changed: true permissions: {} secrets: {} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 6f4b50d5f..1e93ff0d6 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -43,6 +43,16 @@ on: type: string required: false default: 'false' + gha-src-files-changed: + description: 'Boolean string result for if GitHub Action source files have changed' + type: string + required: false + default: 'false' + gha-test-files-changed: + description: 'Boolean string result for if GitHub Action test files have changed' + type: string + required: false + default: 'false' outputs: new-release-detected: description: Boolean string result for if new release is available @@ -102,10 +112,10 @@ jobs: - name: Build | Build next version artifacts id: version - uses: python-semantic-release/python-semantic-release@v9.20.0 + uses: python-semantic-release/python-semantic-release@092ace20f4ebed6a656da54b499076f1a5b803c8 # v10.0.0 with: github_token: "" - root_options: "-v" + verbosity: 1 build: true changelog: true commit: false @@ -170,6 +180,8 @@ jobs: - name: Test | Run pytest -m unit --comprehensive id: tests + env: + COLUMNS: 150 run: | pytest \ -vv \ @@ -183,7 +195,7 @@ jobs: --junit-xml=tests/reports/pytest-results.xml - name: Report | Upload Test Results - uses: mikepenz/action-junit-report@v5.3.0 + uses: mikepenz/action-junit-report@cf701569b05ccdd861a76b8607a66d76f6fd4857 # v5.5.1 if: ${{ always() && steps.tests.outcome != 'skipped' }} with: report_paths: ./tests/reports/*.xml @@ -238,6 +250,8 @@ jobs: - name: Test | Run pytest -m e2e --comprehensive id: tests + env: + COLUMNS: 150 run: | pytest \ -vv \ @@ -271,7 +285,7 @@ jobs: retention-days: 1 - name: Report | Upload Test Results - uses: mikepenz/action-junit-report@v5.3.0 + uses: mikepenz/action-junit-report@cf701569b05ccdd861a76b8607a66d76f6fd4857 # v5.5.1 if: ${{ always() && steps.tests.outcome != 'skipped' }} with: report_paths: ./tests/reports/*.xml @@ -330,12 +344,15 @@ jobs: - name: Test | Run pytest -m e2e id: tests shell: pwsh + # env: # Required for GitPython to work on Windows because of getpass.getuser() # USERNAME: "runneradmin" + # COLUMNS: 150 # Because GHA is currently broken on Windows to pass these varables, we do it manually run: | $env:USERNAME = "runneradmin" + $env:COLUMNS = 150 pytest ` -vv ` -nauto ` @@ -366,13 +383,63 @@ jobs: retention-days: 1 - name: Report | Upload Test Results - uses: mikepenz/action-junit-report@v5.3.0 + uses: mikepenz/action-junit-report@cf701569b05ccdd861a76b8607a66d76f6fd4857 # v5.5.1 if: ${{ always() && steps.tests.outcome != 'skipped' }} with: report_paths: ./tests/reports/*.xml annotate_only: true + test-gh-action: + name: Validate Action Build & Execution + runs-on: ubuntu-latest + if: inputs.gha-src-files-changed == 'true' || inputs.gha-test-files-changed == 'true' || inputs.ci-files-changed == 'true' + + needs: + - build + - unit-test + + env: + TEST_CONTAINER_TAG: psr-action:latest + ACTION_SRC_DIR: src/gh_action + + steps: + - name: Setup | Checkout Repository + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + fetch-depth: 1 + ref: ${{ github.sha }} + + - name: Setup | Download Distribution Artifacts + uses: actions/download-artifact@v4 + with: + name: ${{ needs.build.outputs.distribution-artifacts }} + path: ${{ env.ACTION_SRC_DIR }} + + - name: Setup | Update Dependency list with latest version + working-directory: ${{ env.ACTION_SRC_DIR }} + run: | + find . -name '*.whl' > requirements.txt + + - name: Setup | Allow Docker build to include wheel files + working-directory: ${{ env.ACTION_SRC_DIR }} + run: | + printf '%s\n' "!*.whl" >> .dockerignore + + - name: Build | Action Container + id: container-builder + uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 # v6.17.0 + with: + context: ${{ env.ACTION_SRC_DIR }} + load: true # add to `docker images` + push: false + platforms: linux/amd64 + tags: ${{ env.TEST_CONTAINER_TAG }} + + - name: Test | Action Container + run: bash tests/gh_action/run.sh + + lint: name: Lint if: ${{ inputs.files-changed == 'true' }} diff --git a/.gitignore b/.gitignore index 7b02cda5b..db2c6f98d 100644 --- a/.gitignore +++ b/.gitignore @@ -61,7 +61,7 @@ coverage.xml # Sphinx documentation docs/_build/ -docs/api/ +docs/api/modules/ # PyBuilder target/ diff --git a/AUTHORS.rst b/AUTHORS.rst deleted file mode 100644 index 0a7309b92..000000000 --- a/AUTHORS.rst +++ /dev/null @@ -1,7 +0,0 @@ -Contributors ------------- - -|contributors| - -.. |contributors| image:: https://contributors-img.web.app/image?repo=relekang/python-semantic-release - :target: https://github.com/relekang/python-semantic-release/graphs/contributors diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0e07247dc..ec7decbe1 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,309 @@ CHANGELOG ========= +.. _changelog-v10.0.0: + +v10.0.0 (2025-05-25) +==================== + +✨ Features +----------- + +* **cmd-version**: Enable ``version_variables`` version stamp of vars with double-equals + (`PR#1244`_, `080e4bc`_) + +* **parser-conventional**: Set parser to evaluate all squashed commits by default (`6fcdc99`_) + +* **parser-conventional**: Set parser to ignore merge commits by default (`59bf084`_) + +* **parser-emoji**: Set parser to evaluate all squashed commits by default (`514a922`_) + +* **parser-emoji**: Set parser to ignore merge commits by default (`8a51525`_) + +* **parser-scipy**: Set parser to evaluate all squashed commits by default (`634fffe`_) + +* **parser-scipy**: Set parser to ignore merge commits by default (`d4f128e`_) + +🪲 Bug Fixes +------------ + +* **changelog-md**: Change to 1-line descriptions in markdown template, closes `#733`_ (`e7ac155`_) + +* **changelog-rst**: Change to 1-line descriptions in the default ReStructuredText template, closes + `#733`_ (`731466f`_) + +* **cli**: Adjust verbosity parameter to enable silly-level logging (`bd3e7bf`_) + +* **github-action**: Resolve command injection vulnerability in action script (`fb3da27`_) + +* **parser-conventional**: Remove breaking change footer messages from commit descriptions + (`b271cbb`_) + +* **parser-conventional**: Remove issue footer messages from commit descriptions (`b1bb0e5`_) + +* **parser-conventional**: Remove PR/MR references from commit subject line (`eed63fa`_) + +* **parser-conventional**: Remove release notice footer messages from commit descriptions + (`7e8dc13`_) + +* **parser-emoji**: Remove issue footer messages from commit descriptions (`b757603`_) + +* **parser-emoji**: Remove PR/MR references from commit subject line (`16465f1`_) + +* **parser-emoji**: Remove release notice footer messages from commit descriptions (`b6307cb`_) + +* **parser-scipy**: Remove issue footer messages from commit descriptions (`3cfee76`_) + +* **parser-scipy**: Remove PR/MR references from commit subject line (`da4140f`_) + +* **parser-scipy**: Remove release notice footer messages from commit descriptions (`58308e3`_) + +📖 Documentation +---------------- + +* Refactor documentation page navigation (`4e52f4b`_) + +* **algorithm**: Remove out-of-date algorithm description (`6cd0fbe`_) + +* **commit-parsing**: Define limitation of revert commits with the scipy parser (`5310d0c`_) + +* **configuration**: Change default value for ``allow_zero_version`` in the description (`203d29d`_) + +* **configuration**: Change the default for the base changelog's ``mask_initial_release`` value + (`5fb02ab`_) + +* **configuration**: Change the default value for ``changelog.mode`` in the setting description + (`0bed906`_) + +* **configuration**: Update ``version_variables`` section to include double-equals operand support + (`PR#1244`_, `080e4bc`_) + +* **contributing**: Refactor contributing & contributors layout (`8bed5bc`_) + +* **github-actions**: Add reference to manual release workflow example (`6aad7f1`_) + +* **github-actions**: Change recommended workflow to separate release from deploy (`67b2ae0`_) + +* **github-actions**: Update ``python-semantic-release/publish-action`` parameter notes (`c4d45ec`_) + +* **github-actions**: Update PSR action parameter documentation (`a082896`_) + +* **upgrading**: Re-locate version upgrade guides into ``Upgrading PSR`` (`a5f5e04`_) + +* **upgrading-v10**: Added migration guide for v9 to v10 (`4ea92ec`_) + +⚙️ Build System +---------------- + +* **deps**: Prevent update to ``click@8.2.0`` (`PR#1245`_, `4aa6a6e`_) + +♻️ Refactoring +--------------- + +* **config**: Change ``allow_zero_version`` default to ``false`` (`c6b6eab`_) + +* **config**: Change ``changelog.default_templates.mask_initial_release`` default to ``true`` + (`0e114c3`_) + +* **config**: Change ``changelog.mode`` default to ``update`` (`7d39e76`_) + +💥 Breaking Changes +------------------- + +* **changelog-md**: The default Markdown changelog template and release notes template will no + longer print out the entire commit message contents, instead, it will only print the commit + subject line. This comes to meet the high demand of better formatted changelogs and requests for + subject line only. Originally, it was a decision to not hide commit subjects that were included in + the commit body via the ``git merge --squash`` command and PSR did not have another alternative. + At this point, all the built-in parsers have the ability to parse squashed commits and separate + them out into their own entry on the changelog. Therefore, the default template no longer needs to + write out the full commit body. See the commit parser options if you want to enable/disable + parsing squash commits. + +* **changelog-rst**: The default ReStructured changelog template will no longer print out the entire + commit message contents, instead, it will only print the commit subject line. This comes to meet + the high demand of better formatted changelogs and requests for subject line only. Originally, it + was a decision to not hide commit subjects that were included in the commit body via the ``git + merge --squash`` command and PSR did not have another alternative. At this point, all the built-in + parsers have the ability to parse squashed commits and separate them out into their own entry on + the changelog. Therefore, the default template no longer needs to write out the full commit body. + See the commit parser options if you want to enable/disable parsing squash commits. + +* **config**: This release switches the ``allow_zero_version`` default to ``false``. This change is + to encourage less ``0.x`` releases as the default but rather allow the experienced developer to + choose when ``0.x`` is appropriate. There are way too many projects in the ecosystems that never + leave ``0.x`` and that is problematic for the industry tools that help auto-update based on + SemVer. We should strive for publishing usable tools and maintaining good forethought for when + compatibility must break. If your configuration already sets the ``allow_zero_version`` value, + this change will have no effect on your project. If you want to use ``0.x`` versions, from the + start then change ``allow_zero_version`` to ``true`` in your configuration. + +* **config**: This release switches the ``changelog.default_templates.mask_initial_release`` default + to ``true``. This change is intended to toggle better recommended outputs of the default + changelog. Conceptually, the very first release is hard to describe--one can only provide new + features as nothing exists yet for the end user. No changelog should be written as there is no + start point to compare the "changes" to. The recommendation instead is to only list a simple + message as ``Initial Release``. This is now the default for PSR when providing the very first + release (no pre-existing tags) in the changelog and release notes. If your configuration already + sets the ``changelog.default_templates.mask_initial_release`` value, then this change will have no + effect on your project. If you do NOT want to mask the first release information, then set + ``changelog.default_templates.mask_initial_release`` to ``false`` in your configuration. + +* **config**: This release switches the ``changelog.mode`` default to ``update``. In this mode, if a + changelog exists, PSR will update the changelog **IF AND ONLY IF** the configured insertion flag + exists in the changelog. The Changelog output will remain unchanged if no insertion flag exists. + The insertion flag may be configured with the ``changelog.insertion_flag`` setting. When upgrading + to ``v10``, you must add the insertion flag manually or you can just delete the changelog file and + run PSR's changelog generation and it will rebuild the changelog (similar to init mode) but it + will add the insertion flag. If your configuration already sets the ``changelog.mode`` value, then + this change will have no effect on your project. If you would rather the changelog be generated + from scratch every release, than set the ``changelog.mode`` value to ``init`` in your + configuration. + +* **github-action**: The ``root_options`` action input parameter has been removed because it created + a command injection vulnerability for arbitrary code to execute within the container context of + the GitHub action if a command injection code was provided as part of the ``root_options`` + parameter string. To eliminate the vulnerability, each relevant option that can be provided to + ``semantic-release`` has been individually added as its own parameter and will be processed + individually to prevent command injection. Please review our `Github Actions Configuration`__ page + to review the newly available configuration options that replace the ``root_options`` parameter. + + __ https://github.com/python-semantic-release/python-semantic-release/blob/v10.0.0/docs/configuration/automatic-releases/github-actions.rst + +* **parser-conventional**: Any breaking change footer messages that the conventional commit parser + detects will now be removed from the ``commit.descriptions[]`` list but maintained in and only in + the ``commit.breaking_descriptions[]`` list. Previously, the descriptions included all text from + the commit message but that was redundant as the default changelog now handles breaking change + footers in its own section. + +* **parser-conventional, parser-emoji, parser-scipy**: Any issue resolution footers that the parser + detects will now be removed from the ``commit.descriptions[]`` list. Previously, the descriptions + included all text from the commit message but now that the parser pulls out the issue numbers the + numbers will be included in the ``commit.linked_issues`` tuple for user extraction in any + changelog generation. + +* **parser-conventional, parser-emoji, parser-scipy**: Any release notice footer messages that the + commit parser detects will now be removed from the ``commit.descriptions[]`` list but maintained + in and only in the ``commit.notices[]`` list. Previously, the descriptions included all text from + the commit message but that was redundant as the default changelog now handles release notice + footers in its own section. + +* **parser-conventional, parser-emoji, parser-scipy**: Generally, a pull request or merge request + number reference is included in the subject line at the end within parentheses on some common + VCS's like GitHub. PSR now looks for this reference and extracts it into the + ``commit.linked_merge_request`` and the ``commit.linked_pull_request`` attributes of a commit + object. Since this is now pulled out individually, it is cleaner to remove this from the first + line of the ``commit.descriptions`` list (ie. the subject line) so that changelog macros do not + have to replace the text but instead only append a PR/MR link to the end of the line. The + reference does maintain the PR/MR prefix indicator (`#` or ``!``). + +* **parser-conventional, parser-emoji, parser-scipy**: The configuration setting + ``commit_parser_options.ignore_merge_commits`` is now set to ``true`` by default. The feature to + ignore squash commits was introduced in ``v9.18.0`` and was originally set to ``false`` to + prevent unexpected results on a non-breaking update. The ignore merge commits feature prevents + additional unnecessary processing on a commit message that likely will not match a commit message + syntax. Most merge commits are syntactically pre-defined by Git or Remote Version Control System + (ex. GitHub, etc.) and do not follow a commit convention (nor should they). The larger issue with + merge commits is that they ultimately are a full copy of all the changes that were previously + created and committed. The merge commit itself ensures that the previous commit tree is + maintained in history, therefore the commit message always exists. If merge commits are parsed, + it generally creates duplicate messages that will end up in your changelog, which is less than + desired in most cases. If you have previously used the ``changelog.exclude_commit_patterns`` + functionality to ignore merge commit messages then you will want this setting set to ``true`` to + improve parsing speed. You can also now remove the merge commit exclude pattern from the list as + well to improve parsing speed. If this functionality is not desired, you will need to update your + configuration to change the new setting to ``false``. + +* **parser-conventional, parser-emoji, parser-scipy**: The configuration setting + ``commit_parser_options.parse_squash_commits`` is now set to ``true`` by default. The feature to + parse squash commits was introduced in ``v9.17.0`` and was originally set to ``false`` to prevent + unexpected results on a non-breaking update. The parse squash commits feature attempts to find + additional commits of the same commit type within the body of a single commit message. When + squash commits are found, Python Semantic Release will separate out each commit into its own + artificial commit object and parse them individually. This potentially can change the resulting + version bump if a larger bump was detected within the squashed components. It also allows for the + changelog and release notes to separately order and display each commit as originally written. If + this is not desired, you will need to update your configuration to change the new setting to + ``false``. + +.. _#733: https://github.com/python-semantic-release/python-semantic-release/issues/733 +.. _080e4bc: https://github.com/python-semantic-release/python-semantic-release/commit/080e4bcb14048a2dd10445546a7ee3159b3ab85c +.. _0bed906: https://github.com/python-semantic-release/python-semantic-release/commit/0bed9069df67ae806ad0a15f8434ac4efcc6ba31 +.. _0e114c3: https://github.com/python-semantic-release/python-semantic-release/commit/0e114c3458a24b87bfd2d6b0cd3f5cfdc9497084 +.. _16465f1: https://github.com/python-semantic-release/python-semantic-release/commit/16465f133386b09627d311727a6f8d24dd8f174f +.. _203d29d: https://github.com/python-semantic-release/python-semantic-release/commit/203d29d9d6b8e862eabe2f99dbd27eabf04e75e2 +.. _3cfee76: https://github.com/python-semantic-release/python-semantic-release/commit/3cfee76032662bda6fbdd7e2585193213e4f9da2 +.. _4aa6a6e: https://github.com/python-semantic-release/python-semantic-release/commit/4aa6a6edbff75889e09f32f7cba52cb90c9fb626 +.. _4e52f4b: https://github.com/python-semantic-release/python-semantic-release/commit/4e52f4bba46e96a4762f97d306f15ae52c5cea1b +.. _4ea92ec: https://github.com/python-semantic-release/python-semantic-release/commit/4ea92ec34dcd45d8cbab24e38e55289617b2d728 +.. _514a922: https://github.com/python-semantic-release/python-semantic-release/commit/514a922fa87721e2500062dcae841bedd84dc1fe +.. _5310d0c: https://github.com/python-semantic-release/python-semantic-release/commit/5310d0c700840538f27874394b9964bf09cd69b1 +.. _58308e3: https://github.com/python-semantic-release/python-semantic-release/commit/58308e31bb6306aac3a985af01eb779dc923d3f0 +.. _59bf084: https://github.com/python-semantic-release/python-semantic-release/commit/59bf08440a15269afaac81d78dd03ee418f9fd6b +.. _5fb02ab: https://github.com/python-semantic-release/python-semantic-release/commit/5fb02ab6e3b8278ecbf92ed35083ffb595bc19b8 +.. _634fffe: https://github.com/python-semantic-release/python-semantic-release/commit/634fffea29157e9b6305b21802c78ac245454265 +.. _67b2ae0: https://github.com/python-semantic-release/python-semantic-release/commit/67b2ae0050cce540a4126fe280cca6dc4bcf5d3f +.. _6aad7f1: https://github.com/python-semantic-release/python-semantic-release/commit/6aad7f17e64fb4717ddd7a9e94d2a730be6a3bd9 +.. _6cd0fbe: https://github.com/python-semantic-release/python-semantic-release/commit/6cd0fbeb44e16d394c210216c7099afa51f5a4a3 +.. _6fcdc99: https://github.com/python-semantic-release/python-semantic-release/commit/6fcdc99e9462b1186ea9488fc14e4e18f8c7fdb3 +.. _731466f: https://github.com/python-semantic-release/python-semantic-release/commit/731466fec4e06fe71f6c4addd4ae2ec2182ae9c1 +.. _7d39e76: https://github.com/python-semantic-release/python-semantic-release/commit/7d39e7675f859463b54751d59957b869d5d8395c +.. _7e8dc13: https://github.com/python-semantic-release/python-semantic-release/commit/7e8dc13c0b048a95d01f7aecfbe4eeedcddec9a4 +.. _8a51525: https://github.com/python-semantic-release/python-semantic-release/commit/8a5152573b9175f01be06d0c4531ea0ca4de8dd4 +.. _8bed5bc: https://github.com/python-semantic-release/python-semantic-release/commit/8bed5bcca4a5759af0e3fb24eadf14aa4e4f53c9 +.. _a082896: https://github.com/python-semantic-release/python-semantic-release/commit/a08289693085153effdafe3c6ff235a1777bb1fa +.. _a5f5e04: https://github.com/python-semantic-release/python-semantic-release/commit/a5f5e042ae9af909ee9e3ddf57c78adbc92ce378 +.. _b1bb0e5: https://github.com/python-semantic-release/python-semantic-release/commit/b1bb0e55910715754eebef6cb5b21ebed5ee8d68 +.. _b271cbb: https://github.com/python-semantic-release/python-semantic-release/commit/b271cbb2d3e8b86d07d1358b2e7424ccff6ae186 +.. _b6307cb: https://github.com/python-semantic-release/python-semantic-release/commit/b6307cb649043bbcc7ad9f15ac5ac6728914f443 +.. _b757603: https://github.com/python-semantic-release/python-semantic-release/commit/b757603e77ebe26d8a14758d78fd21163a9059b2 +.. _bd3e7bf: https://github.com/python-semantic-release/python-semantic-release/commit/bd3e7bfa86d53a03f03ac419399847712c523b02 +.. _c4d45ec: https://github.com/python-semantic-release/python-semantic-release/commit/c4d45ec46dfa81f645c25ea18ffffe9635922603 +.. _c6b6eab: https://github.com/python-semantic-release/python-semantic-release/commit/c6b6eabbfe100d2c741620eb3fa12a382531fa94 +.. _d4f128e: https://github.com/python-semantic-release/python-semantic-release/commit/d4f128e75e33256c0163fbb475c7c41e18f65147 +.. _da4140f: https://github.com/python-semantic-release/python-semantic-release/commit/da4140f3e3a2ed03c05064f35561b4584f517105 +.. _e7ac155: https://github.com/python-semantic-release/python-semantic-release/commit/e7ac155a91fc2e735d3cbf9b66fb4e5ff40a1466 +.. _eed63fa: https://github.com/python-semantic-release/python-semantic-release/commit/eed63fa9f6e762f55700fc85ef3ebdc0d3144f21 +.. _fb3da27: https://github.com/python-semantic-release/python-semantic-release/commit/fb3da27650ff15bcdb3b7badc919bd8a9a73238d +.. _PR#1244: https://github.com/python-semantic-release/python-semantic-release/pull/1244 +.. _PR#1245: https://github.com/python-semantic-release/python-semantic-release/pull/1245 + + +.. _changelog-v9.21.1: + +v9.21.1 (2025-05-05) +==================== + +🪲 Bug Fixes +------------ + +* **changelog-filters**: Fixes url resolution when prefix & path share letters, closes `#1204`_ + (`PR#1239`_, `f61f8a3`_) + +📖 Documentation +---------------- + +* **github-actions**: Expound on monorepo example to include publishing actions (`PR#1229`_, + `550e85f`_) + +⚙️ Build System +---------------- + +* **deps**: Bump ``rich`` dependency from ``13.0`` to ``14.0`` (`PR#1224`_, `691536e`_) + +* **deps**: Expand ``python-gitlab`` dependency to include ``v5.0.0`` (`PR#1228`_, `a0cd1be`_) + +.. _#1204: https://github.com/python-semantic-release/python-semantic-release/issues/1204 +.. _550e85f: https://github.com/python-semantic-release/python-semantic-release/commit/550e85f5ec2695d5aa680014127846d58c680e31 +.. _691536e: https://github.com/python-semantic-release/python-semantic-release/commit/691536e98f311d0fc6d29a72c41ce5a65f1f4b6c +.. _a0cd1be: https://github.com/python-semantic-release/python-semantic-release/commit/a0cd1be4e3aa283cbdc544785e5f895c8391dfb8 +.. _f61f8a3: https://github.com/python-semantic-release/python-semantic-release/commit/f61f8a38a1a3f44a7a56cf9dcb7dde748f90ca1e +.. _PR#1224: https://github.com/python-semantic-release/python-semantic-release/pull/1224 +.. _PR#1228: https://github.com/python-semantic-release/python-semantic-release/pull/1228 +.. _PR#1229: https://github.com/python-semantic-release/python-semantic-release/pull/1229 +.. _PR#1239: https://github.com/python-semantic-release/python-semantic-release/pull/1239 + + .. _changelog-v9.21.0: v9.21.0 (2025-02-23) @@ -2275,7 +2578,7 @@ v8.0.0 (2023-07-16) 💥 BREAKING CHANGES -------------------- -* numerous breaking changes, see :ref:`migrating-from-v7` for more information +* numerous breaking changes, see :ref:`upgrade_v8` for more information .. _ec30564: https://github.com/python-semantic-release/python-semantic-release/commit/ec30564b4ec732c001d76d3c09ba033066d2b6fe .. _PR#619: https://github.com/python-semantic-release/python-semantic-release/pull/619 diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst index e546295cf..bb728da1d 100644 --- a/CONTRIBUTING.rst +++ b/CONTRIBUTING.rst @@ -1,3 +1,5 @@ +.. _contributing_guide: + Contributing ------------ @@ -7,7 +9,7 @@ Please remember to write tests for the cool things you create or fix. Unsure about something? No worries, `open an issue`_. -.. _open an issue: https://github.com/relekang/python-semantic-release/issues/new +.. _open an issue: https://github.com/python-semantic-release/python-semantic-release/issues/new Commit messages ~~~~~~~~~~~~~~~ diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 489b41bee..000000000 --- a/Dockerfile +++ /dev/null @@ -1,33 +0,0 @@ -# This Dockerfile is only for GitHub Actions -FROM python:3.13-bookworm - -# Copy python-semantic-release source code into container -COPY . /psr - -RUN \ - # Install desired packages - apt update && apt install -y --no-install-recommends \ - # install git with git-lfs support - git git-lfs \ - # install python cmodule / binary module build utilities - python3-dev gcc make cmake cargo \ - # Configure global pip - && { \ - printf '%s\n' "[global]"; \ - printf '%s\n' "no-cache-dir = true"; \ - printf '%s\n' "disable-pip-version-check = true"; \ - } > /etc/pip.conf \ - # Create virtual environment for python-semantic-release - && python3 -m venv /psr/.venv \ - # Update core utilities in the virtual environment - && /psr/.venv/bin/pip install -U pip setuptools wheel \ - # Install psr & its dependencies from source into virtual environment - && /psr/.venv/bin/pip install /psr \ - # Cleanup - && apt clean -y - -ENV PSR_DOCKER_GITHUB_ACTION=true - -ENV PYTHONDONTWRITEBYTECODE=1 - -ENTRYPOINT ["/bin/bash", "-l", "/psr/action.sh"] diff --git a/MANIFEST.in b/MANIFEST.in index 9953cfd42..c6e688b9f 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,5 +2,11 @@ graft src/**/data/ # include docs & testing into sdist, ignored for wheel build -graft tests/ graft docs/ +prune docs/_build/ +graft tests/ +prune tests/gh_action/ + +# Remove any generated files +prune **/__pycache__/ +prune src/*.egg-info diff --git a/action.yml b/action.yml index 992b5d05d..2cf7cf5ec 100644 --- a/action.yml +++ b/action.yml @@ -7,11 +7,14 @@ branding: color: orange inputs: - root_options: - default: "-v" + + config_file: + default: "" required: false description: | - Additional options for the main command. Example: -vv --noop + Path to a custom semantic-release configuration file. By default, an empty + string will look for a pyproject.toml file in the current directory. This is the same + as passing the `-c` or `--config` parameter to semantic-release. directory: default: "." @@ -19,64 +22,78 @@ inputs: description: Sub-directory to cd into before running semantic-release github_token: - type: string required: true description: GitHub token used to push release notes and new commits/tags git_committer_name: - type: string required: false description: The human name for the “committer” field git_committer_email: - type: string required: false description: The email address for the “committer” field + no_operation_mode: + default: "false" + required: false + description: | + If set to true, the github action will pass the `--noop` parameter to + semantic-release. This will cause semantic-release to run in "no operation" + mode. See the documentation for more information on this parameter. Note that, + this parameter will not affect the output of the action, so you will still get + the version determination decision as output values. + ssh_public_signing_key: - type: string required: false description: The ssh public key used to sign commits ssh_private_signing_key: - type: string required: false description: The ssh private key used to sign commits + strict: + default: "false" + required: false + description: | + If set to true, the github action will pass the `--strict` parameter to + semantic-release. See the documentation for more information on this parameter. + + verbosity: + default: "1" + required: false + description: | + Set the verbosity level of the output as the number of -v's to pass to + semantic-release. 0 is no extra output, 1 is info level output, 2 is + debug output, and 3 is silly debug level of output. + # `semantic-release version` command line options prerelease: - type: string required: false description: | Force the next version to be a prerelease. Set to "true" or "false". prerelease_token: - type: string required: false description: "Force the next version to use this prerelease token, if it is a prerelease" force: - type: string required: false description: | Force the next version to be a major release. Must be set to one of "prerelease", "patch", "minor", or "major". commit: - type: string required: false description: Whether or not to commit changes locally. Defaults are handled by python-semantic-release internal version command. tag: - type: string required: false description: | Whether or not to make a local version tag. Defaults are handled by python-semantic-release internal version command. push: - type: string required: false description: | Whether or not to push local commits to the Git repository. See @@ -84,26 +101,22 @@ inputs: for how the default is determined between push, tag, & commit. changelog: - type: string required: false description: | Whether or not to update the changelog. vcs_release: - type: string required: false description: | Whether or not to create a release in the remote VCS, if supported build: - type: string required: false description: | Whether or not to run the build_command for the project. Defaults are handled by python-semantic-release internal version command. build_metadata: - type: string required: false description: | Build metadata to append to the new version @@ -127,4 +140,4 @@ outputs: runs: using: docker - image: Dockerfile + image: src/gh_action/Dockerfile diff --git a/docs/algorithm.rst b/docs/algorithm.rst deleted file mode 100644 index 4ea9fa6dd..000000000 --- a/docs/algorithm.rst +++ /dev/null @@ -1,205 +0,0 @@ -.. _algorithm: - -Python Semantic Release's Version Bumping Algorithm -=================================================== - -Below is a technical description of the algorithm which Python Semantic Release -uses to calculate a new version for a project. - -.. _algorithm-assumptions: - -Assumptions -~~~~~~~~~~~ - -* At runtime, we are in a Git repository with HEAD referring to a commit on - some branch of the repository (i.e. not in detached HEAD state). -* We know in advance whether we want to produce a prerelease or not (based on - the configuration and command-line flags). -* We can parse the tags of the repository into semantic versions, as we are given - the format that those Git tags should follow via configuration, but cannot - cherry-pick only tags that apply to commits on specific branches. We must parse - all tags in order to ensure we have parsed any that might apply to commits in - this branch's history. -* If we can identify a commit as a ``merge-base`` between our HEAD commit and one - or more tags, then that merge-base should be unique. -* We know ahead of time what ``prerelease_token`` to use for prereleases - e.g. - ``rc``. -* We know ahead of time whether ``major`` changes introduced by commits - should cause the new version to remain on ``0.y.z`` if the project is already - on a ``0.`` version - see :ref:`major_on_zero `. - -.. _algorithm-implementation: - -Implementation -~~~~~~~~~~~~~~ - -1. Parse all the Git tags of the repository into semantic versions, and **sort** - in descending (most recent first) order according to `semver precedence`_. - Ignore any tags which do not correspond to valid semantic versions according - to ``tag_format``. - - -2. Find the ``merge-base`` of HEAD and the latest tag according to the sort above. - Call this commit ``M``. - If there are no tags in the repo's history, we set ``M=HEAD``. - -3. Find the latest non-prerelease version whose tag references a commit that is - an ancestor of ``M``. We do this via a breadth-first search through the commit - lineage, starting against ``M``, and for each tag checking if the tag - corresponds to that commit. We break from the search when we find such a tag. - If no such tag is found, see 4a). - Else, suppose that tag corresponds to a commit ``L`` - goto 4b). - -4. - a. If no commit corresponding to the last non-prerelease version is found, - the entire history of the repository is considered. We parse every commit - that is an ancestor of HEAD to determine the type of change introduced - - either ``major``, ``minor``, ``patch``, ``prerelease_revision`` or - ``no_release``. We store this levels in a ``set`` as we only require - the distinct types of change that were introduced. - b. However, if we found a commit ``L`` which is the commit against which the - last non-prerelease was tagged, then we parse only the commits from HEAD - as far back as ``L``, to understand what changes have been introduced - since the previous non-prerelease. We store these levels - either - ``major``, ``minor``, ``patch``, ``prerelease_revision``, or - ``no_release``, in a set, as we only require the distinct types of change - that were introduced. - - c. We look for tags that correspond to each commit during this process, to - identify the latest pre-release that was made within HEAD's ancestry. - -5. If there have been no changes since the last non-prerelease, or all commits - since that release result in a ``no_release`` type according to the commit - parser, then we **terminate the algorithm.** - -6. If we have not exited by this point, we know the following information: - - * The latest version, by `semver precedence`_, within the whole repository. - Call this ``LV``. This might not be within the ancestry of HEAD. - * The latest version, prerelease or non-prerelease, within the whole repository. - Call this ``LVH``. This might not be within the ancestry of HEAD. - This may be the same as ``LV``. - * The latest non-prerelease version within the ancestry of HEAD. Call this - ``LVHF``. This may be the same as ``LVH``. - * The most significant type of change introduced by the commits since the - previous full release. Call this ``level`` - * Whether or not we wish to produce a prerelease from this version increment. - Call this a boolean flag, ``prerelease``. (Assumption) - * Whether or not to increment the major digit if a major change is introduced - against an existing ``0.`` version. Call this ``major_on_zero``, a boolean - flag. (Assumption) - - Using this information, the new version is decided according to the following - criteria: - - a. If ``LV`` has a major digit of ``0``, ``major_on_zero`` is ``False`` and - ``level`` is ``major``, reduce ``level`` to ``minor``. - - b. If ``prerelease=True``, then - - i. Diff ``LV`` with ``LVHF``, to understand if the ``major``, ``minor`` or - ``patch`` digits have changed. For example, diffing ``1.2.1`` and - ``1.2.0`` is a ``patch`` diff, while diffing ``2.1.1`` and ``1.17.2`` is - a ``major`` diff. Call this ``DIFF`` - - ii. If ``DIFF`` is less semantically significant than ``level``, for example - if ``DIFF=patch`` and ``level=minor``, then - - 1. Increment the digit of ``LVF`` corresponding to ``level``, for example - the minor digit if ``level=minor``, setting all less significant - digits to zero. - - 2. Add ``prerelease_token`` as a suffix result of 1., together with a - prerelease revision number of ``1``. Return this new version and - **terminate the algorithm.** - - Thus if ``DIFF=patch``, ``level=minor``, ``prerelease=True``, - ``prerelease_token="rc"``, and ``LVF=1.1.1``, - then the version returned by the algorithm is ``1.2.0-rc.1``. - - iii. If ``DIFF`` is semantically less significant than or equally - significant to ``level``, then this means that the significance - of change introduced by ``level`` is already reflected in a - prerelease version that has been created since the last full release. - For example, if ``LVHF=1.1.1``, ``LV=1.2.0-rc.1`` and ``level=minor``. - - In this case we: - - 1. If the prerelease token of ``LV`` is different from - ``prerelease_token``, take the major, minor and patch digits - of ``LV`` and construct a prerelease version using our given - ``prerelease_token`` and a prerelease revision of ``1``. We - then return this version and **terminate the algorithm.** - - For example, if ``LV=1.2.0-rc.1`` and ``prerelease_token=alpha``, - we return ``1.2.0-alpha.1``. - - 2. If the prerelease token of ``LV`` is the same as ``prerelease_token``, - we increment the revision number of ``LV``, return this version, and - - **terminate the algorithm.** - For example, if ``LV=1.2.0-rc.1`` and ``prerelease_token=rc``, - we return ``1.2.0-rc.2``. - - c. If ``prerelease=False``, then - - i. If ``LV`` is not a prerelease, then we increment the digit of ``LV`` - corresponding to ``level``, for example the minor digit if ``level=minor``, - setting all less significant digits to zero. - We return the result of this and **terminate the algorithm**. - - ii. If ``LV`` is a prerelease, then: - - 1. Diff ``LV`` with ``LVHF``, to understand if the ``major``, ``minor`` or - ``patch`` digits have changed. Call this ``DIFF`` - - 2. If ``DIFF`` is less semantically significant than ``level``, then - - i. Increment the digit of ``LV`` corresponding to ``level``, for example - the minor digit if ``level=minor``, setting all less significant - digits to zero. - - ii. Remove the prerelease token and revision number from the result of i., - ("Finalize" the result of i.) return the result and **terminate the - algorithm.** - - For example, if ``LV=1.2.2-alpha.1`` and ``level=minor``, we return - ``1.3.0``. - - 3. If ``DIFF`` is semantically less significant than or equally - significant to ``level``, then we finalize ``LV``, return the - result and **terminate the algorithm**. - -.. _semver precedence: https://semver.org/#spec-item-11 - -.. _algorithm-complexity: - -Complexity -~~~~~~~~~~ - -**Space:** - -A list of parsed tags takes ``O(number of tags)`` in space. Parsing each commit during -the breadth-first search between ``merge-base`` and the latest tag in the ancestry -of HEAD takes at worst ``O(number of commits)`` in space to track visited commits. -Therefore worst-case space complexity will be linear in the number of commits in the -repo, unless the number of tags significantly exceeds the number of commits -(in which case it will be linear in the number of tags). - -**Time:** - -Assuming using regular expression parsing of each tag is a constant-time operation, -then the following steps contribute to the time complexity of the algorithm: - -* Parsing each tag - ``O(number of tags)`` -* Sorting tags by `semver precedence`_ - - ``O(number of tags * log(number of tags))`` -* Finding the merge-base of HEAD and the latest release tag - - ``O(number of commits)`` (worst case) -* Parsing each commit and checking each tag against each commit - - ``O(number of commits) + O(number of tags * number of commits)`` - (worst case) - -Overall, assuming that the number of tags is less than or equal to the number -of commits in the repository, this would lead to a worst-case time complexity -that's quadratic in the number of commits in the repo. diff --git a/docs/commands.rst b/docs/api/commands.rst similarity index 98% rename from docs/commands.rst rename to docs/api/commands.rst index e7e8e5f21..344c7f5f4 100644 --- a/docs/commands.rst +++ b/docs/api/commands.rst @@ -1,7 +1,7 @@ .. _commands: -Commands -======== +Command Line Interface (CLI) +============================ All commands accept a ``-h/--help`` option, which displays the help text for the command and exits immediately. @@ -23,10 +23,11 @@ Correct:: semantic-release -vv --noop version --print With the exception of :ref:`cmd-main` and :ref:`cmd-generate-config`, all -commands require that you have set up your project's configuration. To help with -this step, :ref:`cmd-generate-config` can create the default configuration for you, -which will allow you to tweak it to your needs rather than write it from scratch. +commands require that you have set up your project's configuration. +To help with setting up your project configuration, :ref:`cmd-generate-config` +will print out the default configuration to the console, which +you can then modify it to match your project & environment. .. _cmd-main: @@ -495,4 +496,4 @@ corresponding release is found in the remote VCS, then Python Semantic Release w attempt to create one. If using this option, the relevant authentication token *must* be supplied via the -relevant environment variable. For more information, see :ref:`index-creating-vcs-releases`. +relevant environment variable. diff --git a/docs/changelog_templates.rst b/docs/concepts/changelog_templates.rst similarity index 99% rename from docs/changelog_templates.rst rename to docs/concepts/changelog_templates.rst index 1b6e5fc7d..d42a210d7 100644 --- a/docs/changelog_templates.rst +++ b/docs/concepts/changelog_templates.rst @@ -248,7 +248,7 @@ Configuration Examples If identified or supported by the parser, the default changelog templates will include a separate section of breaking changes and additional release information. Refer to the -:ref:`commit parsing ` section to see how to write commit messages that +:ref:`commit parsing ` section to see how to write commit messages that will be properly parsed and displayed in these sections. @@ -331,7 +331,7 @@ be copied to its target location without being rendered by the template engine. When initially starting out at customizing your own changelog templates, you should reference the default template embedded within PSR. The template directory is located at ``data/templates/`` within the PSR package. Within our templates - directory we separate out each type of commit parser (e.g. angular) and the + directory we separate out each type of commit parser (e.g. conventional) and the content format type (e.g. markdown). You can copy this directory to your repository's templates directory and then customize the templates to your liking. diff --git a/docs/commit_parsing.rst b/docs/concepts/commit_parsing.rst similarity index 99% rename from docs/commit_parsing.rst rename to docs/concepts/commit_parsing.rst index 65e523d98..163927c39 100644 --- a/docs/commit_parsing.rst +++ b/docs/concepts/commit_parsing.rst @@ -1,4 +1,4 @@ -.. _commit-parsing: +.. _commit_parsing: Commit Parsing ============== @@ -294,6 +294,11 @@ Guidelines`_ with all different commit types. Because of this small variance, th only extends our :ref:`commit_parser-builtin-angular` parser with pre-defined scipy commit types in the default Scipy Parser Options and all other features are inherited. +**Limitations**: + +- Commits with the ``REV`` type are not currently supported. Track the implementation + of this feature in the issue `#402`_. + If no commit parser options are provided via the configuration, the parser will use PSR's built-in :py:class:`defaults `. @@ -636,7 +641,7 @@ should inherit from the The "options" class is used to validate the options which are configured in the repository, and to provide default values for these options where appropriate. -.. _commit-parsing-commit-parsers: +.. _commit_parsing-commit-parsers: Commit Parsers """""""""""""" diff --git a/docs/concepts/getting_started.rst b/docs/concepts/getting_started.rst new file mode 100644 index 000000000..63007948e --- /dev/null +++ b/docs/concepts/getting_started.rst @@ -0,0 +1,391 @@ +.. _getting-started-guide: + +Getting Started +=============== + +If you haven't done so already, install Python Semantic Release locally following the +:ref:`installation instructions `. + +If you are using a CI/CD service, you may not have to add Python Semantic Release to your +project's dependencies permanently, but for the duration of this guide for the initial +setup, you will need to have it installed locally. + + +Configuring PSR +--------------- + +Python Semantic Release ships with a reasonable default configuration but some aspects **MUST** be +customized to your project. To view the default configuration, run the following command: + +.. code-block:: bash + + semantic-release generate-config + +The output of the above command is the default configuration in TOML format without any modifications. +If this is fine for your project, then you do not need to configure anything else. + +PSR accepts overrides to the default configuration keys individually. If you don't define the +key-value pair in your configuration file, the default value will be used. + +By default, Python Semantic Release will look for configuration overrides in ``pyproject.toml`` under +the TOML table ``[tool.semantic_release]``. You may specify a different file using the +``-c/--config`` option, for example: + +.. code-block:: bash + + # In TOML format with top level table [semantic_release] + semantic-release -c releaserc.toml + + # In JSON format with top level object key {"semantic_release": {}} + semantic-release -c releaserc.json + +The easiest way to get started is to output the default configuration to a file, +delete keys you do not need to override, and then edit the remaining keys to suit your project. + +To set up in ``pyproject.toml``, run the following command: + +.. code-block:: bash + + # In file redirect in bash + semantic-release generate-config --pyproject >> pyproject.toml + + # Open your editor to edit the configuration + vim pyproject.toml + +.. seealso:: + - :ref:`cmd-generate-config` + - :ref:`configuration` + + +Configuring the Version Stamp Feature +''''''''''''''''''''''''''''''''''''' + +One of the best features of Python Semantic Release is the ability to automatically stamp the +new version number into your project files, so you don't have to manually update the version upon +each release. The version that is stamped is automatically determined by Python Semantic Release +from your commit messages which compliments automated versioning seamlessly. + +The most crucial version stamp is the one in your project metadata, which is used by +the Python Package Index (PyPI) and other package managers to identify the version of your package. + +For Python projects, this is typically the ``version`` field in your ``pyproject.toml`` file. First, +set up your project metadata with the base ``version`` value. If you are starting with a brand new project, +set ``project.version = "0.0.0"``. If you are working on an existing project, set it to the last +version number you released. Do not include any ``v`` prefix. + +.. important:: + The version number must be a valid SemVer version, which means it should follow the format + ``MAJOR.MINOR.PATCH`` (e.g., ``1.0.0``). Python Semantic Release does NOT support Canonical + version values defined in the `PEP 440`_ specification at this time. See + `Issue #455 `_ + for more details. Note that you can still define a SemVer version in the ``project.version`` + field, and when your build is generated, the build tool will automatically generate a PEP 440 + compliant version as long as you do **NOT** use a non-pep440 compliant pre-release token. + +.. _PEP 440: https://peps.python.org/pep-0440/ + +Your project metadata might look like this in ``pyproject.toml``:: + + [project] + name = "my-package" + version = "0.0.0" # Set this to the last released version or "0.0.0" for new projects + description = "A sample Python package" + +To configure PSR to automatically update this version number, you need to specify the file and value +to update in your configuration. Since ``pyproject.toml`` uses TOML format, you will add the +replacement specification to the ``tool.semantic_release.version_toml`` list. Update the following +configuration in your ``pyproject.toml`` file to include the version variable location: + +.. code-block:: toml + + [tool.semantic_release] + version_toml = ["pyproject.toml:project.version"] + + # Alternatively, if you are using poetry's 'version' key, then you would use: + version_toml = ["pyproject.toml:tool.poetry.version"] + +If you have other TOML files where you want to stamp the version, you can add them to the +``version_toml`` list as well. In the above example, there is an implicit assumption that +you only want the version as the raw number format. If you want to specify the full tag +value (e.g. v-prefixed version), then include ``:tf`` for "tag format" at the end of the +version variable specification. + +For non-TOML formatted files (such as JSON or YAML files), you can use the +:ref:`config-version_variables` configuration key instead. This feature uses an advanced +Regular Expression to find and replace the version variable in the specified files. + +For Python files, its much more effective to use ``importlib`` instead which will allow you to +dynamically import the version from your package metadata and not require your project to commit +the version number bump to the repository. For example, in your package's base ``__init__.py`` + +.. code-block:: python + + # my_package/__init__.py + from importlib.metadata import version as get_version + + __version__ = get_version(__package__) + # Note: __package__ must match your 'project.name' as defined in pyproject.toml + +.. seealso:: + - Configuration specification of :ref:`config-version_toml` + - Configuration specification of :ref:`config-version_variables` + + +Using PSR to Build your Project +''''''''''''''''''''''''''''''' + +PSR provides a convenient way to build your project artifacts as part of the versioning process +now that you have stamped the version into your project files. To enable this, you will need +to specify the build command in your configuration. This command will be executed after +the next version has been determined, and stamped into your files but before a release tag has +been created. + +To set up the build command, add the following to your ``pyproject.toml`` file: + +.. code-block:: toml + + [tool.semantic_release] + build_command = "python -m build --sdist --wheel ." + +.. seealso:: + - :ref:`config-build_command` - Configuration specification for the build command. + - :ref:`config-build_command_env` - Configuration specification for the build command environment variables. + + +Choosing a Commit Message Parser +'''''''''''''''''''''''''''''''' + +PSR uses commit messages to determine the type of version bump that should be applied +to your project. PSR supports multiple commit message parsing styles, allowing you to choose +the one that best fits your project's needs. Choose **one** of the supported commit parsers +defined in :ref:`commit_parsing`, or provide your own and configure it in your +``pyproject.toml`` file. + +Each commit parser has its own default configuration options so if you want to customize the parser +behavior, you will need to specify the parser options you want to override. + +.. code-block:: toml + + [tool.semantic_release] + commit_parser = "conventional" + + [tool.semantic_release.commit_parser_options] + minor_tags = ["feat"] + patch_tags = ["fix", "perf"] + parse_squash_commits = true + ignore_merge_commits = true + +.. important:: + Python Semantic Release does not currently support Monorepo projects. You will need to provide + a custom commit parser that is built for Monorepos. Follow the Monorepo-support progress in + `Issue #168 `_, + `Issue #614 `_, + and `PR #1143 `_. + + +Choosing your Changelog +''''''''''''''''''''''' + +Prior to creating a release, PSR will generate a changelog from the commit messages of your +project. The changelog is extremely customizable from the format to the content of each section. +PSR ships with a default changelog template that will be used if you do not provide custom +templates. The default should be sufficient for most projects and has its own set of configuration +options. + +For basic customization, you can choose either an traditional Markdown formatted changelog (default) +or if you want to integrate with a Sphinx Documentation project, you can use the +reStructuredText (RST) format. You can also choose the file name and location of where to write the +default changelog. + +To set your changelog location and changelog format, add the following to your ``pyproject.toml`` file: + +.. code-block:: toml + + [tool.semantic_release.changelog.default_templates] + changelog_file = "docs/source/CHANGELOG.rst" + output_format = "rst" # or "md" for Markdown format + +Secondly, the more important aspect of configuring your changelog is to define Commit Exclusion +Patterns or patterns that will be used to filter out commits from the changelog. PSR does **NOT** (yet) +come with a built-in set of exclusion patterns, so you will need to define them yourself. These commit +patterns should be in line with your project's commit parser configuration. + +To set commit exclusion patterns for a conventional commits parsers, add the following to your +``pyproject.toml`` file: + +.. code-block:: toml + + [tool.semantic_release.changelog.exclude_commit_patterns] + # Recommended patterns for conventional commits parser that is scope aware + exclude_commit_patterns = [ + '''chore(?:\([^)]*?\))?: .+''', + '''ci(?:\([^)]*?\))?: .+''', + '''refactor(?:\([^)]*?\))?: .+''', + '''style(?:\([^)]*?\))?: .+''', + '''test(?:\([^)]*?\))?: .+''', + '''build\((?!deps\): .+)''', + '''Initial [Cc]ommit.*''', + ] + +.. seealso:: + - :ref:`Changelog ` - Customize your changelog + - :ref:`changelog.mode ` - Choose the changelog mode ('update' or 'init') + - :ref:`changelog-templates-migrating-existing-changelog` + + +Defining your Release Branches +'''''''''''''''''''''''''''''' + +PSR provides a powerful feature to manage release types across multiple branches which can +allow you to configure your project to have different release branches for different purposes, +such as pre-release branches, beta branches, and your stable releases. + +.. note:: + Most projects that do **NOT** publish pre-releases will be fine with PSR's built-in default. + +To define an alpha pre-release branch when you are working on a fix or new feature, you can +add the following to your ``pyproject.toml`` file: + +.. code-block:: toml + + [tool.semantic_release.branches.alpha] + # Matches branches with the prefixes 'feat/', 'fix/', or 'perf/'. + match = "^(feat|fix|perf)/.+" + prerelease = true + prerelease_token = "alpha" + +Any time you execute ``semantic-release version`` on a branch with the prefix +``feat/``, ``fix/``, or ``perf/``, PSR will determine if a version bump is needed and if so, +the resulting version will be a pre-release version with the ``alpha`` token. For example, + ++-----------+--------------+-----------------+-------------------+ +| Branch | Version Bump | Current Version | Next Version | ++===========+==============+=================+===================+ +| main | Patch | ``1.0.0`` | ``1.0.1`` | ++-----------+--------------+-----------------+-------------------+ +| fix/bug-1 | Patch | ``1.0.0`` | ``1.0.1-alpha.1`` | ++-----------+--------------+-----------------+-------------------+ + +.. seealso:: + - :ref:`multibranch-releases` - Learn about multi-branch releases and how to configure them. + + +Configuring VCS Releases +'''''''''''''''''''''''' + +You can set up Python Semantic Release to create Releases in your remote version +control system, so you can publish assets and release notes for your project. + +In order to do so, you will need to place an authentication token in the +appropriate environment variable so that Python Semantic Release can authenticate +with the remote VCS to push tags, create releases, or upload files. + +GitHub (``GH_TOKEN``) +""""""""""""""""""""" + +For local publishing to GitHub, you should use a personal access token and +store it in your environment variables. Specify the name of the environment +variable in your configuration setting :ref:`remote.token `. +The default is ``GH_TOKEN``. + +To generate a token go to https://github.com/settings/tokens and click on +"Generate new token". + +For Personal Access Token (classic), you will need the ``repo`` scope to write +(ie. push) to the repository. + +For fine-grained Personal Access Tokens, you will need the `contents`__ +permission. + +__ https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens#repository-permissions-for-contents + +GitLab (``GITLAB_TOKEN``) +""""""""""""""""""""""""" + +A personal access token from GitLab. This is used for authenticating when pushing +tags, publishing releases etc. This token should be stored in the ``GITLAB_TOKEN`` +environment variable. + +Gitea (``GITEA_TOKEN``) +""""""""""""""""""""""" + +A personal access token from Gitea. This token should be stored in the ``GITEA_TOKEN`` +environment variable. + +Bitbucket (``BITBUCKET_TOKEN``) +""""""""""""""""""""""""""""""" + +Bitbucket does not support uploading releases but can still benefit from automated tags +and changelogs. The user has three options to push changes to the repository: + +#. Use SSH keys. + +#. Use an `App Secret`_, store the secret in the ``BITBUCKET_TOKEN`` environment variable + and the username in ``BITBUCKET_USER``. + +#. Use an `Access Token`_ for the repository and store it in the ``BITBUCKET_TOKEN`` + environment variable. + +.. _App Secret: https://support.atlassian.com/bitbucket-cloud/docs/push-back-to-your-repository/#App-secret +.. _Access Token: https://support.atlassian.com/bitbucket-cloud/docs/repository-access-tokens + +.. seealso:: + - :ref:`Changelog ` - customize your project's changelog. + + - :ref:`changelog-templates-custom_release_notes` - customize the published release notes + + - :ref:`version --vcs-release/--no-vcs-release ` - enable/disable VCS release + creation. + + +Testing your Configuration +-------------------------- + +It's time to test your configuration! + +.. code-block:: bash + + # 1. Run the command in no-operation mode to see what would happen + semantic-release -v --noop version + + # 2. If the output looks reasonable, try to run the command without any history changes + # '-vv' will give you verbose debug output, which is useful for troubleshooting + # commit parsing issues. + semantic-release -vv version --no-commit --no-tag + + # 3. Evaluate your repository to see the changes that were made but not committed + # - Check the version number in your pyproject.toml + # - Check the distribution files from the build command + # - Check the changelog file for the new release notes + + # 4. If everything looks good, make sure to commit/save your configuration changes + git add pyproject.toml + git commit -m "chore(config): configure Python Semantic Release" + + # 5. Now, try to run the release command with your history changes but without pushing + semantic-release -v version --no-push --no-vcs-release + + # 6. Check the result on your local repository + git status + git log --graph --decorate --all + + # 7a. If you are happy with the release history and resulting commit & tag, + # reverse your changes before trying the full release command. + git tag -d v0.0.1 # replace with the actual version you released + git reset --hard HEAD~1 + + # 7b. [Optional] Once you have configured a remote VCS token, try + # running the full release command to update the remote repository. + semantic-release version --push --vcs-release + # This is optional as you may not want a personal access token set up or make + # make the release permanent yet. + +.. seealso:: + - :ref:`cmd-version` + - :ref:`troubleshooting-verbosity` + +Configuring CI/CD +----------------- + +PSR is meant to help you release at speed! See our CI/CD Configuration guides under the +:ref:`automatic` section. diff --git a/docs/concepts/index.rst b/docs/concepts/index.rst new file mode 100644 index 000000000..efa3077a8 --- /dev/null +++ b/docs/concepts/index.rst @@ -0,0 +1,17 @@ +.. _concepts: + +Concepts +======== + +This section covers the core concepts of Python Semantic Release and how it +works. Understanding these concepts will help you effectively use Python +Semantic Release in your projects. + +.. toctree:: + :maxdepth: 1 + + getting_started + commit_parsing + changelog_templates + multibranch_releases + strict_mode diff --git a/docs/concepts/installation.rst b/docs/concepts/installation.rst new file mode 100644 index 000000000..3d99b13a5 --- /dev/null +++ b/docs/concepts/installation.rst @@ -0,0 +1,14 @@ +.. _installation: + +Installation +============ + +.. code-block:: bash + + python3 -m pip install python-semantic-release + semantic-release --help + +Python Semantic Release is also available from `conda-forge`_ or as a +:ref:`GitHub Action `. + +.. _conda-forge: https://anaconda.org/conda-forge/python-semantic-release diff --git a/docs/multibranch_releases.rst b/docs/concepts/multibranch_releases.rst similarity index 100% rename from docs/multibranch_releases.rst rename to docs/concepts/multibranch_releases.rst diff --git a/docs/strict_mode.rst b/docs/concepts/strict_mode.rst similarity index 100% rename from docs/strict_mode.rst rename to docs/concepts/strict_mode.rst diff --git a/docs/conf.py b/docs/conf.py index 7db91dd85..0d37bcf7a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -1,5 +1,6 @@ import os import sys +from datetime import datetime, timezone sys.path.insert(0, os.path.abspath(".")) sys.path.insert(0, os.path.abspath("..")) @@ -24,7 +25,8 @@ source_suffix = ".rst" master_doc = "index" project = "python-semantic-release" -copyright = f"2024, {author_name}" # noqa: A001 +current_year = datetime.now(timezone.utc).astimezone().year +copyright = f"{current_year}, {author_name}" # noqa: A001 version = semantic_release.__version__ release = semantic_release.__version__ @@ -39,7 +41,7 @@ # -- Automatically run sphinx-apidoc -------------------------------------- docs_path = os.path.dirname(__file__) -apidoc_output_dir = os.path.join(docs_path, "api") +apidoc_output_dir = os.path.join(docs_path, "api", "modules") apidoc_module_dir = os.path.join(docs_path, "..", "src") apidoc_separate_modules = True apidoc_module_first = True diff --git a/docs/automatic-releases/cronjobs.rst b/docs/configuration/automatic-releases/cronjobs.rst similarity index 96% rename from docs/automatic-releases/cronjobs.rst rename to docs/configuration/automatic-releases/cronjobs.rst index c61e44ba8..0ecf6ca58 100644 --- a/docs/automatic-releases/cronjobs.rst +++ b/docs/configuration/automatic-releases/cronjobs.rst @@ -1,7 +1,7 @@ .. _cronjobs: -Publish with cronjobs -~~~~~~~~~~~~~~~~~~~~~ +Cron Job Publishing +=================== This is for you if for some reason you cannot publish from your CI or you would like releases to drop at a certain interval. Before you start, answer this: Are you sure you do not want a CI to diff --git a/docs/automatic-releases/github-actions.rst b/docs/configuration/automatic-releases/github-actions.rst similarity index 71% rename from docs/automatic-releases/github-actions.rst rename to docs/configuration/automatic-releases/github-actions.rst index d67f6c590..d8a8bd012 100644 --- a/docs/automatic-releases/github-actions.rst +++ b/docs/configuration/automatic-releases/github-actions.rst @@ -163,6 +163,25 @@ to the index during the build command. This option is equivalent to adding eithe ---- +.. _gh_actions-psr-inputs-config_file: + +``config_file`` +""""""""""""""" + +Path to a custom semantic-release configuration file. By default, an empty +string will look for to the ``pyproject.toml`` file in the current directory. +This is the same as passing the ``-c`` or ``--config`` parameter to semantic-release. + +**Required:** ``false`` + +**Default:** ``""`` + +.. seealso:: + + - :ref:`cmd-main-option-config` for the :ref:`semantic-release ` command + +---- + .. _gh_actions-psr-inputs-directory: ``directory`` @@ -248,6 +267,27 @@ The token should have the following `permissions`_: ---- +.. _gh_actions-psr-inputs-noop: + +``no_operation_mode`` +""""""""""""""""""""" + +If set to true, the github action will pass the ``--noop`` parameter to +semantic-release. This will cause semantic-release to run in "no operation" +mode. + +This is useful for testing the action without making any permanent changes to the repository. + +**Required:** ``false`` + +**Default:** ``false`` + +.. seealso:: + + - :ref:`cmd-main-option-noop` option for the :ref:`semantic-release ` command + +---- + .. _gh_actions-psr-inputs-prerelease: ``prerelease`` @@ -330,6 +370,11 @@ to the remote repository. This option is equivalent to adding either ``--push`` ``root_options`` """""""""""""""" +.. important:: + This option has been removed in v10.0.0 and newer because of a + command injection vulnerability. Please update as to v10.0.0 as soon + as possible. + Additional options for the main ``semantic-release`` command, which will come before the :ref:`version ` subcommand. @@ -337,7 +382,7 @@ before the :ref:`version ` subcommand. .. code:: yaml - - uses: python-semantic-release/python-semantic-release@v9.21.0 + - uses: python-semantic-release/python-semantic-release@v10.0.0 with: root_options: "-vv --noop" @@ -377,6 +422,20 @@ The private key used to sign a commit and tag. ---- +.. _gh_actions-psr-inputs-strict: + +``strict`` +"""""""""" + +If set to true, the github action will pass the `--strict` parameter to +``semantic-release``. + +.. seealso:: + + - :ref:`cmd-main-option-strict` option for the :ref:`semantic-release ` command + +---- + .. _gh_actions-psr-inputs-tag: ``tag`` @@ -424,6 +483,25 @@ equivalent to adding either ``--vcs-release`` (on ``true``) or ``--no-vcs-releas ---- +.. _gh_actions-psr-inputs-verbosity: + +``verbosity`` +""""""""""""" + +Set the verbosity level of the output as the number of ``-v``'s to pass to +``semantic-release``. 0 is no extra output, 1 is info level output, 2 is debug output, and +3 is a silly amount of debug output. + +**Required:** ``false`` + +**Default:** ``"1"`` + +.. seealso:: + + - :ref:`cmd-main-option-verbosity` for the :ref:`semantic-release ` command + +---- + .. _gh_actions-psr-outputs: Outputs @@ -531,6 +609,25 @@ supported input and its purpose. ---- +.. _gh_actions-publish-inputs-config_file: + +``config_file`` +""""""""""""""" + +Path to a custom semantic-release configuration file. By default, an empty +string will look for to the ``pyproject.toml`` file in the current directory. +This is the same as passing the ``-c`` or ``--config`` parameter to semantic-release. + +**Required:** ``false`` + +**Default:** ``""`` + +.. seealso:: + + - :ref:`cmd-main-option-config` for the :ref:`semantic-release ` command + +---- + .. _gh_actions-publish-inputs-directory: ``directory`` @@ -564,11 +661,37 @@ The token should have the following `permissions`_: ---- +.. _gh_actions-publish-inputs-noop: + +``no_operation_mode`` +""""""""""""""""""""" + +If set to true, the github action will pass the ``--noop`` parameter to +semantic-release. This will cause semantic-release to run in "no operation" +mode. + +This is useful for testing the action without actually publishing anything. + +**Required:** ``false`` + +**Default:** ``false`` + +.. seealso:: + + - :ref:`cmd-main-option-noop` option for the :ref:`semantic-release ` command + +---- + .. _gh_actions-publish-inputs-root_options: ``root_options`` """""""""""""""" +.. important:: + This option has been removed in v10.0.0 and newer because of a + command injection vulnerability. Please update as to v10.0.0 as soon + as possible. + Additional options for the main ``semantic-release`` command, which will come before the :ref:`publish ` subcommand. @@ -576,7 +699,7 @@ before the :ref:`publish ` subcommand. .. code:: yaml - - uses: python-semantic-release/publish-action@v9.21.0 + - uses: python-semantic-release/publish-action@v10.0.0 with: root_options: "-vv --noop" @@ -620,6 +743,25 @@ Python Semantic Release will automatically determine the latest release if no ---- +.. _gh_actions-publish-inputs-verbosity: + +``verbosity`` +""""""""""""" + +Set the verbosity level of the output as the number of ``-v``'s to pass to +``semantic-release``. 0 is no extra output, 1 is info level output, 2 is debug output, and +3 is a silly amount of debug output. + +**Required:** ``false`` + +**Default:** ``"1"`` + +.. seealso:: + + - :ref:`cmd-main-option-verbosity` for the :ref:`semantic-release ` command + +---- + .. _gh_actions-publish-outputs: Outputs @@ -658,6 +800,10 @@ to the GitHub Release Assets as well. branches: - main + # default: least privileged permissions across all jobs + permissions: + contents: read + jobs: release: runs-on: ubuntu-latest @@ -666,7 +812,6 @@ to the GitHub Release Assets as well. cancel-in-progress: false permissions: - id-token: write contents: write steps: @@ -728,23 +873,63 @@ to the GitHub Release Assets as well. - name: Action | Semantic Version Release id: release # Adjust tag with desired version if applicable. - uses: python-semantic-release/python-semantic-release@v9.21.0 + uses: python-semantic-release/python-semantic-release@v10.0.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} git_committer_name: "github-actions" git_committer_email: "actions@users.noreply.github.com" - - name: Publish | Upload package to PyPI - uses: pypa/gh-action-pypi-publish@v1 - if: steps.release.outputs.released == 'true' - - name: Publish | Upload to GitHub Release Assets - uses: python-semantic-release/publish-action@v9.21.0 + uses: python-semantic-release/publish-action@v10.0.0 if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} tag: ${{ steps.release.outputs.tag }} + - name: Upload | Distribution Artifacts + uses: actions/upload-artifact@v4 + with: + name: distribution-artifacts + path: dist + if-no-files-found: error + + deploy: + # 1. Separate out the deploy step from the publish step to run each step at + # the least amount of token privilege + # 2. Also, deployments can fail, and its better to have a separate job if you need to retry + # and it won't require reversing the release. + runs-on: ubuntu-latest + needs: release + if: ${{ needs.release.outputs.released == 'true' }} + + permissions: + contents: read + id-token: write + + steps: + - name: Setup | Download Build Artifacts + uses: actions/download-artifact@v4 + id: artifact-download + with: + name: distribution-artifacts + path: dist + + # ------------------------------------------------------------------- # + # Python Semantic Release is not responsible for publishing your # + # python artifacts to PyPI. Use the official PyPA publish action # + # instead. The following steps are an example but is not guaranteed # + # to work as the action is not maintained by the # + # python-semantic-release team. # + # ------------------------------------------------------------------- # + + # see https://docs.pypi.org/trusted-publishers/ + - name: Publish package distributions to PyPI + uses: pypa/gh-action-pypi-publish@@SHA1_HASH # vX.X.X + with: + packages-dir: dist + print-hash: true + verbose: true + .. important:: The `concurrency`_ directive is used on the job to prevent race conditions of more than one release job in the case if there are multiple pushes to ``main`` in a short period @@ -794,13 +979,22 @@ The equivalent GitHub Action configuration would be: - name: Action | Semantic Version Release # Adjust tag with desired version if applicable. - uses: python-semantic-release/python-semantic-release@v9.21.0 + uses: python-semantic-release/python-semantic-release@v10.0.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} force: patch changelog: false build_metadata: abc123 +.. seealso:: + + - `Publish Action Manual Release Workflow`_: To maintain the Publish Action at the same + version as Python Semantic Release, we use a Manual release workflow which forces the + matching bump type as the root project. Check out this workflow to see how you can + manually provide input that triggers the desired version bump. + +.. _Publish Action Manual Release Workflow: https://github.com/python-semantic-release/publish-action/blob/main/.github/workflows/release.yml + .. _gh_actions-monorepo: Actions with Monorepos @@ -816,19 +1010,84 @@ For multiple packages, you would need to run the action multiple times, to relea each project. The following example demonstrates how to release two projects in a monorepo. +Remember that for each release of each submodule you will then need to handle publishing +each package separately as well. This is dependent on the result of your build commands. +In the example below, we assume a simple ``build`` module command to build a ``sdist`` +and wheel artifacts into the submodule's ``dist`` directory. + The ``directory`` input directive is also available for the Python Semantic Release Publish Action. .. code:: yaml - - name: Release Project 1 - uses: python-semantic-release/python-semantic-release@v9.21.0 - with: - directory: ./project1 - github_token: ${{ secrets.GITHUB_TOKEN }} - - - name: Release Project 2 - uses: python-semantic-release/python-semantic-release@v9.21.0 - with: - directory: ./project2 - github_token: ${{ secrets.GITHUB_TOKEN }} + jobs: + + release: + + env: + SUBMODULE_1_DIR: project1 + SUBMODULE_2_DIR: project2 + + steps: + + # ------------------------------------------------------------------- # + # Note the use of different IDs to distinguish which submodule was # + # identified to be released. The subsequent actions then reference # + # their specific release ID to determine if a release occurred. # + # ------------------------------------------------------------------- # + + - name: Release submodule 1 + id: release-submod-1 + uses: python-semantic-release/python-semantic-release@v10.0.0 + with: + directory: ${{ env.SUBMODULE_1_DIR }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + - name: Release submodule 2 + id: release-submod-2 + uses: python-semantic-release/python-semantic-release@v10.0.0 + with: + directory: ${{ env.SUBMODULE_2_DIR }} + github_token: ${{ secrets.GITHUB_TOKEN }} + + # ------------------------------------------------------------------- # + # For each submodule, you will have to publish the package separately # + # and only attempt to publish if the release for that submodule was # + # deemed a release (and the release was successful). # + # ------------------------------------------------------------------- # + + - name: Publish | Upload package 1 to GitHub Release Assets + uses: python-semantic-release/publish-action@v10.0.0 + if: steps.release-submod-1.outputs.released == 'true' + with: + directory: ${{ env.SUBMODULE_1_DIR }} + github_token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ steps.release-submod-1.outputs.tag }} + + - name: Publish | Upload package 2 to GitHub Release Assets + uses: python-semantic-release/publish-action@v10.0.0 + if: steps.release-submod-2.outputs.released == 'true' + with: + directory: ${{ env.SUBMODULE_2_DIR }} + github_token: ${{ secrets.GITHUB_TOKEN }} + tag: ${{ steps.release-submod-2.outputs.tag }} + + # ------------------------------------------------------------------- # + # Python Semantic Release is not responsible for publishing your # + # python artifacts to PyPI. Use the official PyPA publish action # + # instead. The following steps are an example but is not guaranteed # + # to work as the action is not maintained by the # + # python-semantic-release team. # + # ------------------------------------------------------------------- # + + - name: Publish | Upload package 1 to PyPI + uses: pypa/gh-action-pypi-publish@SHA1_HASH # vX.X.X + if: steps.release-submod-1.outputs.released == 'true' + with: + packages-dir: ${{ format('{}/dist', env.SUBMODULE_1_DIR) }} + + - name: Publish | Upload package 2 to PyPI + uses: pypa/gh-action-pypi-publish@SHA1_HASH # vX.X.X + if: steps.release-submod-2.outputs.released == 'true' + with: + packages-dir: ${{ format('{}/dist', env.SUBMODULE_2_DIR) }} diff --git a/docs/automatic-releases/index.rst b/docs/configuration/automatic-releases/index.rst similarity index 88% rename from docs/automatic-releases/index.rst rename to docs/configuration/automatic-releases/index.rst index c5e6d3453..3c3d8265b 100644 --- a/docs/automatic-releases/index.rst +++ b/docs/configuration/automatic-releases/index.rst @@ -1,13 +1,13 @@ .. _automatic: -Automatic Releases +Automated Releases ------------------ The key point with using this package is to automate your releases and stop worrying about version numbers. Different approaches to automatic releases and publishing with the help of this package can be found below. Using a CI is the recommended approach. -.. _automatic-guides: +.. _automated-release-guides: Guides ^^^^^^ diff --git a/docs/automatic-releases/travis.rst b/docs/configuration/automatic-releases/travis.rst similarity index 92% rename from docs/automatic-releases/travis.rst rename to docs/configuration/automatic-releases/travis.rst index 175f57447..60ee68ce8 100644 --- a/docs/automatic-releases/travis.rst +++ b/docs/configuration/automatic-releases/travis.rst @@ -1,5 +1,7 @@ -Setting up python-semantic-release on Travis CI -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +.. _travis_ci: + +Travis CI +========= This guide expects you to have activated the repository on Travis CI. If this is not the case, please refer to `Travis documentation`_ on how to do that. @@ -16,7 +18,7 @@ You will need to set up an environment variable in Travis. An easy way to do tha is to go to the settings page for your package and add it there. Make sure that the secret toggle is set correctly. -You need to set the :ref:`GH_TOKEN ` environment +You need to set the :ref:`GH_TOKEN ` environment variable with a personal access token for Github. It will need either ``repo`` or ``public_repo`` scope depending on whether the repository is private or public. diff --git a/docs/configuration.rst b/docs/configuration/configuration.rst similarity index 99% rename from docs/configuration.rst rename to docs/configuration/configuration.rst index 78e20d183..08368b337 100644 --- a/docs/configuration.rst +++ b/docs/configuration/configuration.rst @@ -1,4 +1,4 @@ -.. _configuration: +.. _config: Configuration ============= @@ -142,7 +142,9 @@ version to be ``1.0.0``, regardless of patch, minor, or major change level. Additionally, when ``allow_zero_version`` is set to ``false``, the :ref:`config-major_on_zero` setting is ignored. -**Default:** ``true`` +*Default changed to ``false`` in v10.0.0* + +**Default:** ``false`` ---- @@ -388,7 +390,9 @@ is there to document? The message details can be found in the ``first_release.md.j2`` and ``first_release.rst.j2`` templates of the default changelog template directory. -**Default:** ``false`` +*Default changed to ``true`` in v10.0.0.* + +**Default:** ``true`` .. seealso:: - :ref:`changelog-templates-default_changelog` @@ -660,7 +664,7 @@ The patterns in this list are treated as regular expressions. ``mode`` ******** -*Introduced in v9.10.0* +*Introduced in v9.10.0. Default changed to `update` in v10.0.0.* **Type:** ``Literal["init", "update"]`` @@ -678,7 +682,7 @@ version information at that location. If you are using a custom template directory, the `context.changelog_mode` value will exist in the changelog context but it is up to your implementation to determine if and/or how to use it. -**Default:** ``init`` +**Default:** ``update`` .. seealso:: - :ref:`changelog-templates-default_changelog` @@ -798,7 +802,7 @@ Built-in parsers: You can set any of the built-in parsers by their keyword but you can also specify your own commit parser in ``path/to/module_file.py:Class`` or ``module:Class`` form. -For more information see :ref:`commit-parsing`. +For more information see :ref:`commit_parsing`. **Default:** ``"conventional"`` @@ -1347,7 +1351,8 @@ The regular expression generated from the ``version_variables`` definition will: 2. The variable name defined by ``variable`` and the version must be separated by an operand symbol (``=``, ``:``, ``:=``, or ``@``). Whitespace is optional around - the symbol. + the symbol. As of v10.0.0, a double-equals (``==``) operator is also supported + as a valid operand symbol. 3. The value of the variable must match a `SemVer`_ regular expression and can be enclosed by single (``'``) or double (``"``) quotation marks but they must match. However, @@ -1400,6 +1405,9 @@ will be matched and replaced by the new version: # Custom Tag Format with tag_format set (monorepos) __release__ = "module-v1.2.3" + # requirements.txt + my-package == 1.2.3 + .. important:: The Regular Expression expects a version value to exist in the file to be replaced. It cannot be an empty string or a non-semver compliant string. If this is the very diff --git a/docs/configuration/index.rst b/docs/configuration/index.rst new file mode 100644 index 000000000..3b5dade61 --- /dev/null +++ b/docs/configuration/index.rst @@ -0,0 +1,18 @@ +.. _configuration: + +Configuration +============= + +Python Semantic Release is highly configurable, allowing you to tailor it to your project's needs. It supports +various runtime environments and can be integrated with different CI/CD services. + +1. Check out the :ref:`Configuration Options ` to customize your release process. + +2. Configure your :ref:`CI/CD services ` to use Python Semantic Release. + +.. toctree:: + :maxdepth: 1 + :hidden: + + Configuration Options + automatic-releases/index diff --git a/docs/contributing.rst b/docs/contributing.rst deleted file mode 100644 index e582053ea..000000000 --- a/docs/contributing.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../CONTRIBUTING.rst diff --git a/docs/contributing/contributing_guide.rst b/docs/contributing/contributing_guide.rst new file mode 100644 index 000000000..ac7b6bcf3 --- /dev/null +++ b/docs/contributing/contributing_guide.rst @@ -0,0 +1 @@ +.. include:: ../../CONTRIBUTING.rst diff --git a/docs/contributing/index.rst b/docs/contributing/index.rst new file mode 100644 index 000000000..164cc99a0 --- /dev/null +++ b/docs/contributing/index.rst @@ -0,0 +1,35 @@ +.. _contributing: + +Contributing +============ + +Love Python Semantic Release? Want to help out? There are many ways you can contribute to the project! + +You can help by: + +- Reporting bugs and issues +- Suggesting new features +- Improving the documentation +- Reviewing pull requests +- Contributing code +- Helping with translations +- Spreading the word about Python Semantic Release +- Participating in discussions +- Testing new features and providing feedback + +No matter how you choose to contribute, please check out our +:ref:`Contributing Guidelines ` and know we appreciate your help! + +**Check out all the folks whom already contributed to Python Semantic Release and become one of them today!** + +|contributors| + +.. |contributors| image:: https://contributors-img.web.app/image?repo=python-semantic-release/python-semantic-release + :target: https://github.com/python-semantic-release/python-semantic-release/graphs/contributors + + +.. toctree:: + :hidden: + :maxdepth: 1 + + Contributing Guide diff --git a/docs/contributors.rst b/docs/contributors.rst deleted file mode 100644 index e122f914a..000000000 --- a/docs/contributors.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../AUTHORS.rst diff --git a/docs/index.rst b/docs/index.rst index f8273c5b7..c49f1d334 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -1,53 +1,55 @@ Python Semantic Release *********************** -|Ruff| |Test Status| |PyPI Version| |conda-forge version| |Read the Docs Status| |Pre-Commit Enabled| +|PyPI Version| |conda-forge version| |Last Release| |Monthly Downloads| |PSR License| |Issues| -Automatic Semantic Versioning for Python projects. This is a Python -implementation of `semantic-release`_ for JS by Stephan Bönnemann. If -you find this topic interesting you should check out his `talk from -JSConf Budapest`_. +**Python Semantic Release (PSR)** provides an automated release mechanism +determined by SemVer and Commit Message Conventions for your Git projects. -The general idea is to be able to detect what the next version of the -project should be based on the commits. This tool will use that to -automate the whole release, upload to an artifact repository and post changelogs to -GitHub. You can run the tool on a CI service, or just run it locally. +The purpose of this project is to detect what the next version of the +project should be from parsing the latest commit messages. If the commit messages +describe changes that would require a major, minor or patch version bump, PSR +will automatically bump the version number accordingly. PSR, however, does not +stop there but will help automate the whole release process. It will update the +project code and distribution artifact, upload the artifact and post changelogs +to a remotely hosted Version Control System (VCS). -Installation -============ +The tool is designed to run inside of a CI/CD pipeline service, but it can +also be run locally. -:: +This project was originally inspired by the `semantic-release`_ project for JavaScript +by *Stephan Bönnemann*, but the codebases have significantly deviated since then, as +PSR as driven towards the goal of providing flexible changelogs and simple initial setup. - python3 -m pip install python-semantic-release - semantic-release --help +.. include:: concepts/installation.rst -Python Semantic Release is also available from `conda-forge`_ or as a `GitHub Action`_. -Read more about the setup and configuration in our `getting started guide`_. +Read more about the setup and configuration in our :ref:`Getting Started Guide `. .. _semantic-release: https://github.com/semantic-release/semantic-release -.. _talk from JSConf Budapest: https://www.youtube.com/watch?v=tc2UgG5L7WM -.. _getting started guide: https://python-semantic-release.readthedocs.io/en/latest/#getting-started -.. _GitHub Action: https://python-semantic-release.readthedocs.io/en/latest/automatic-releases/github-actions.html -.. _conda-forge: https://anaconda.org/conda-forge/python-semantic-release - -.. |Test Status| image:: https://img.shields.io/github/actions/workflow/status/python-semantic-release/python-semantic-release/cicd.yml?branch=master&label=Test%20Status&logo=github - :target: https://github.com/python-semantic-release/python-semantic-release/actions/workflows/cicd.yml - :alt: test-status + .. |PyPI Version| image:: https://img.shields.io/pypi/v/python-semantic-release?label=PyPI&logo=pypi :target: https://pypi.org/project/python-semantic-release/ :alt: pypi + .. |conda-forge Version| image:: https://img.shields.io/conda/vn/conda-forge/python-semantic-release?logo=anaconda :target: https://anaconda.org/conda-forge/python-semantic-release :alt: conda-forge -.. |Read the Docs Status| image:: https://img.shields.io/readthedocs/python-semantic-release?label=Read%20the%20Docs&logo=Read%20the%20Docs - :target: https://python-semantic-release.readthedocs.io/en/latest/ - :alt: docs -.. |Pre-Commit Enabled| image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit - :target: https://github.com/pre-commit/pre-commit - :alt: pre-commit -.. |Ruff| image:: https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/astral-sh/ruff/main/assets/badge/v2.json - :target: https://github.com/astral-sh/ruff - :alt: Ruff + +.. |Last Release| image:: https://img.shields.io/github/release-date/python-semantic-release/python-semantic-release?display_date=published_at + :target: https://github.com/python-semantic-release/python-semantic-release/releases/latest + :alt: GitHub Release Date + +.. |PSR License| image:: https://img.shields.io/pypi/l/python-semantic-release?color=blue + :target: https://github.com/python-semantic-release/python-semantic-release/blob/master/LICENSE + :alt: PyPI - License + +.. |Issues| image:: https://img.shields.io/github/issues/python-semantic-release/python-semantic-release + :target: https://github.com/python-semantic-release/python-semantic-release/issues + :alt: GitHub Issues + +.. |Monthly Downloads| image:: https://img.shields.io/pypi/dm/python-semantic-release + :target: https://pypistats.org/packages/python-semantic-release + :alt: PyPI - Downloads Documentation Contents @@ -56,196 +58,19 @@ Documentation Contents .. toctree:: :maxdepth: 1 - commands - Strict Mode - configuration - commit_parsing - Changelog Templates - Multibranch Releases - automatic-releases/index - troubleshooting - contributing - contributors - Migrating from Python Semantic Release v7 - Internal API - Algorithm - Changelog + What's New + Concepts + CLI + configuration/index + upgrading/index + misc/troubleshooting + API + Contributing View on GitHub -Getting Started -=============== - -If you haven't done so already, install Python Semantic Release following the -instructions above. - -There is no strict requirement to have it installed locally if you intend on -:ref:`using a CI service `, however running with :ref:`cmd-main-option-noop` can be -useful to test your configuration. - -Generating your configuration ------------------------------ - -Python Semantic Release ships with a command-line interface, ``semantic-release``. You can -inspect the default configuration in your terminal by running - -``semantic-release generate-config`` - -You can also use the :ref:`-f/--format ` option to specify what format you would like this configuration -to be. The default is TOML, but JSON can also be used. - -You can append the configuration to your existing ``pyproject.toml`` file using a standard redirect, -for example: - -``semantic-release generate-config --pyproject >> pyproject.toml`` - -and then editing to your project's requirements. - -.. seealso:: - - :ref:`cmd-generate-config` - - :ref:`configuration` - - -Setting up version numbering ----------------------------- - -Create a variable set to the current version number. This could be anywhere in -your project, for example ``setup.py``:: - - from setuptools import setup - - __version__ = "0.0.0" - - setup( - name="my-package", - version=__version__, - # And so on... - ) - -Python Semantic Release can be configured using a TOML or JSON file; the default configuration file is -``pyproject.toml``, if you wish to use another file you will need to use the ``-c/--config`` option to -specify the file. - -Set :ref:`version_variables ` to a list, the only element of which should be the location of your -version variable inside any Python file, specified in standard ``module:attribute`` syntax: - -``pyproject.toml``:: - - [tool.semantic_release] - version_variables = ["setup.py:__version__"] - -.. seealso:: - - :ref:`configuration` - tailor Python Semantic Release to your project - -Setting up commit parsing -------------------------- - -We rely on commit messages to detect when a version bump is needed. -By default, Python Semantic Release uses the `Conventional Commits Specification`_ -to parse commit messages. You can find out more about this in :ref:`commit-parsing`. - -.. seealso:: - - :ref:`config-branches` - Adding configuration for releases from multiple branches. - - :ref:`commit_parser ` - use a different parser for commit messages. - For example, Python Semantic Release also ships with emoji and scipy-style parsers. - - :ref:`remote.type ` - specify the type of your remote VCS. - -.. _Conventional Commits Specification: https://www.conventionalcommits.org/en/v1.0.0 - -Setting up the changelog ------------------------- - -.. seealso:: - - :ref:`Changelog ` - Customize the changelog generated by Python Semantic Release. - - :ref:`changelog-templates-migrating-existing-changelog` - -.. _index-creating-vcs-releases: - -Creating VCS Releases ---------------------- - -You can set up Python Semantic Release to create Releases in your remote version -control system, so you can publish assets and release notes for your project. - -In order to do so, you will need to place an authentication token in the -appropriate environment variable so that Python Semantic Release can authenticate -with the remote VCS to push tags, create releases, or upload files. - -GitHub (``GH_TOKEN``) -""""""""""""""""""""" - -For local publishing to GitHub, you should use a personal access token and -store it in your environment variables. Specify the name of the environment -variable in your configuration setting :ref:`remote.token `. -The default is ``GH_TOKEN``. - -To generate a token go to https://github.com/settings/tokens and click on -"Generate new token". - -For Personal Access Token (classic), you will need the ``repo`` scope to write -(ie. push) to the repository. - -For fine-grained Personal Access Tokens, you will need the `contents`__ -permission. - -__ https://docs.github.com/en/rest/authentication/permissions-required-for-fine-grained-personal-access-tokens#repository-permissions-for-contents - -GitLab (``GITLAB_TOKEN``) -""""""""""""""""""""""""" - -A personal access token from GitLab. This is used for authenticating when pushing -tags, publishing releases etc. This token should be stored in the ``GITLAB_TOKEN`` -environment variable. - -Gitea (``GITEA_TOKEN``) -""""""""""""""""""""""" - -A personal access token from Gitea. This token should be stored in the ``GITEA_TOKEN`` -environment variable. - -Bitbucket (``BITBUCKET_TOKEN``) -""""""""""""""""""""""""""""""" - -Bitbucket does not support uploading releases but can still benefit from automated tags -and changelogs. The user has three options to push changes to the repository: - -#. Use SSH keys. -#. Use an `App Secret`_, store the secret in the ``BITBUCKET_TOKEN`` environment variable and the username in ``BITBUCKET_USER``. -#. Use an `Access Token`_ for the repository and store it in the ``BITBUCKET_TOKEN`` environment variable. - -.. _App Secret: https://support.atlassian.com/bitbucket-cloud/docs/push-back-to-your-repository/#App-secret -.. _Access Token: https://support.atlassian.com/bitbucket-cloud/docs/repository-access-tokens - -.. seealso:: - - :ref:`Changelog ` - customize your project's changelog. - - :ref:`changelog-templates-custom_release_notes` - customize the published release notes - - :ref:`upload_to_vcs_release ` - - enable/disable uploading artifacts to VCS releases - - :ref:`version --vcs-release/--no-vcs-release ` - enable/disable VCS release - creation. - - `upload-to-gh-release`_, a GitHub Action for running ``semantic-release publish`` - -.. _upload-to-gh-release: https://github.com/python-semantic-release/upload-to-gh-release - -.. _running-from-setuppy: - -Running from setup.py ---------------------- - -Add the following hook to your ``setup.py`` and you will be able to run -``python setup.py `` as you would ``semantic-release ``:: - - try: - from semantic_release import setup_hook - setup_hook(sys.argv) - except ImportError: - pass - -.. note:: - Only the :ref:`version `, :ref:`publish `, and - :ref:`changelog ` commands may be invoked from setup.py in this way. +---- -Running on CI -------------- +.. _inline-getting-started-guide: -Getting a fully automated setup with releases from CI can be helpful for some -projects. See :ref:`automatic`. +.. include:: concepts/getting_started.rst + :start-after: .. _getting-started-guide: diff --git a/docs/misc/psr_changelog.rst b/docs/misc/psr_changelog.rst new file mode 100644 index 000000000..09929fe43 --- /dev/null +++ b/docs/misc/psr_changelog.rst @@ -0,0 +1 @@ +.. include:: ../../CHANGELOG.rst diff --git a/docs/troubleshooting.rst b/docs/misc/troubleshooting.rst similarity index 100% rename from docs/troubleshooting.rst rename to docs/misc/troubleshooting.rst diff --git a/docs/psr_changelog.rst b/docs/psr_changelog.rst deleted file mode 100644 index 565b0521d..000000000 --- a/docs/psr_changelog.rst +++ /dev/null @@ -1 +0,0 @@ -.. include:: ../CHANGELOG.rst diff --git a/docs/migrating_from_v7.rst b/docs/upgrading/08-upgrade.rst similarity index 92% rename from docs/migrating_from_v7.rst rename to docs/upgrading/08-upgrade.rst index be4cbc14a..a6ce7a652 100644 --- a/docs/migrating_from_v7.rst +++ b/docs/upgrading/08-upgrade.rst @@ -1,9 +1,9 @@ -.. _migrating-from-v7: +.. _upgrade_v8: -Migrating from Python Semantic Release v7 -========================================= +Upgrading to v8 +=============== -Python Semantic Release 8.0.0 introduced a number of breaking changes. +Python Semantic Release v8.0.0 introduced a number of breaking changes. The internals have been changed significantly to better support highly-requested features and to streamline the maintenance of the project. @@ -12,18 +12,18 @@ exhibit different behavior to earlier versions of Python Semantic Release. This page is a guide to help projects to ``pip install python-semantic-release>=8.0.0`` with fewer surprises. -.. _breaking-github-action: +.. _upgrade_v8-github-action: Python Semantic Release GitHub Action ------------------------------------- -.. _breaking-removed-artefact-upload: +.. _upgrade_v8-removed-artefact-upload: GitHub Action no longer publishes artifacts to PyPI or GitHub Releases """""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" Python Semantic Release no longer uploads distributions to PyPI - see -:ref:`breaking-commands-repurposed-version-and-publish`. If you are +:ref:`upgrade_v8-commands-repurposed-version-and-publish`. If you are using Python Semantic Release to publish release notes and artifacts to GitHub releases, there is a new GitHub Action `upload-to-gh-release`_ which will perform this action for you. @@ -111,7 +111,7 @@ GitHub Action: .. _upload-to-gh-release: https://github.com/python-semantic-release/upload-to-gh-release .. _pypa/gh-action-pypi-publish: https://github.com/pypa/gh-action-pypi-publish -.. _breaking-github-action-removed-pypi-token: +.. _upgrade_v8-github-action-removed-pypi-token: Removal of ``pypi_token``, ``repository_username`` and ``repository_password`` inputs """"""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""""" @@ -121,7 +121,7 @@ Since the library no longer supports publishing to PyPI, the ``pypi_token``, all been removed. See the above section for how to publish to PyPI using the official GitHub Action from the Python Packaging Authority (PyPA). -.. _breaking-options-inputs: +.. _upgrade_v8-options-inputs: Rename ``additional_options`` to ``root_options`` """"""""""""""""""""""""""""""""""""""""""""""""" @@ -132,12 +132,12 @@ reason, and because the usage of the CLI has changed, ``additional_options`` has been renamed to ``root_options`` to reflect the fact that the options are for the main :ref:`cmd-main` command group. -.. _breaking-commands: +.. _upgrade_v8-commands: Commands -------- -.. _breaking-commands-repurposed-version-and-publish: +.. _upgrade_v8-commands-repurposed-version-and-publish: Repurposing of ``version`` and ``publish`` commands """"""""""""""""""""""""""""""""""""""""""""""""""" @@ -189,7 +189,7 @@ With steps 1-6 being handled by the :ref:`cmd-version` command, step 7 being lef to the developer to handle, and lastly step 8 to be handled by the :ref:`cmd-publish` command. -.. _breaking-removed-define-option: +.. _upgrade_v8-removed-define-option: Removal of ``-D/--define`` command-line option """""""""""""""""""""""""""""""""""""""""""""" @@ -206,7 +206,7 @@ specify using just command-line options. .. _#600: https://github.com/python-semantic-release/python-semantic-release/issues/600 -.. _breaking-commands-no-verify-ci: +.. _upgrade_v8-commands-no-verify-ci: Removal of CI verifications """"""""""""""""""""""""""" @@ -230,7 +230,7 @@ shell commands *before* invoking ``semantic-release`` to verify your environment (e.g. via ``export RELEASE_BRANCH=main`` and/or replace the variable with the branch name you want to verify the CI environment for. -.. _breaking-commands-no-verify-ci-travis: +.. _upgrade_v8-commands-no-verify-ci-travis: Travis ~~~~~~ @@ -249,7 +249,7 @@ Travis fi -.. _breaking-commands-no-verify-ci-semaphore: +.. _upgrade_v8-commands-no-verify-ci-semaphore: Semaphore ~~~~~~~~~ @@ -269,7 +269,7 @@ Semaphore fi -.. _breaking-commands-no-verify-ci-frigg: +.. _upgrade_v8-commands-no-verify-ci-frigg: Frigg ~~~~~ @@ -287,7 +287,7 @@ Frigg exit 1 fi -.. _breaking-commands-no-verify-ci-circle-ci: +.. _upgrade_v8-commands-no-verify-ci-circle-ci: Circle CI ~~~~~~~~~ @@ -305,7 +305,7 @@ Circle CI exit 1 fi -.. _breaking-commands-no-verify-ci-gitlab-ci: +.. _upgrade_v8-commands-no-verify-ci-gitlab-ci: GitLab CI ~~~~~~~~~ @@ -320,7 +320,7 @@ GitLab CI exit 1 fi -.. _breaking-commands-no-verify-ci-bitbucket: +.. _upgrade_v8-commands-no-verify-ci-bitbucket: **Condition**: environment variable ``BITBUCKET_BUILD_NUMBER`` is set @@ -335,7 +335,7 @@ GitLab CI exit 1 fi -.. _breaking-commands-no-verify-ci-jenkins: +.. _upgrade_v8-commands-no-verify-ci-jenkins: Jenkins ~~~~~~~ @@ -359,7 +359,7 @@ Jenkins exit 1 fi -.. _breaking-removed-build-status-checking: +.. _upgrade_v8-removed-build-status-checking: Removal of Build Status Checking """""""""""""""""""""""""""""""" @@ -368,7 +368,7 @@ Prior to v8, Python Semantic Release contained a configuration option, ``check_build_status``, which would attempt to prevent a release being made if it was possible to identify that a corresponding build pipeline was failing. For similar reasons to those motivating the removal of -:ref:`CI Checks `, this feature has also been removed. +:ref:`CI Checks `, this feature has also been removed. If you are leveraging this feature in Python Semantic Release v7, the following bash commands will replace the functionality, and you can add these to your pipeline. @@ -386,7 +386,7 @@ installed, you can download it from `the curl website`_ .. _installation guide for jq: https://jqlang.github.io/jq/download/ .. _the curl website: https://curl.se/ -.. _breaking-removed-build-status-checking-github: +.. _upgrade_v8-removed-build-status-checking-github: GitHub ~~~~~~ @@ -407,7 +407,7 @@ GitHub Note that ``$GITHUB_API_DOMAIN`` is typically ``api.github.com`` unless you are using GitHub Enterprise with a custom domain name. -.. _breaking-removed-build-status-checking-gitea: +.. _upgrade_v8-removed-build-status-checking-gitea: Gitea ~~~~~ @@ -425,7 +425,7 @@ Gitea exit 1 fi -.. _breaking-removed-build-status-checking-gitlab: +.. _upgrade_v8-removed-build-status-checking-gitlab: Gitlab ~~~~~~ @@ -451,7 +451,7 @@ Gitlab done -.. _breaking-commands-multibranch-releases: +.. _upgrade_v8-commands-multibranch-releases: Multibranch releases """""""""""""""""""" @@ -462,7 +462,7 @@ has been changed - you must manually check out the branch which you would like t against, and if you would like to create releases against this branch you must also ensure that it belongs to a :ref:`release group `. -.. _breaking-commands-changelog: +.. _upgrade_v8-commands-changelog: ``changelog`` command """"""""""""""""""""" @@ -477,7 +477,7 @@ tag ``v1.1.4``, you should run:: semantic-release changelog --post-to-release-tag v1.1.4 -.. _breaking-changelog-customization: +.. _upgrade_v8-changelog-customization: Changelog customization """"""""""""""""""""""" @@ -492,7 +492,7 @@ fully open up customizing the changelog's appearance. .. _Jinja: https://jinja.palletsprojects.com/en/3.1.x/ -.. _breaking-configuration: +.. _upgrade_v8-configuration: Configuration ------------- @@ -501,7 +501,7 @@ The configuration structure has been completely reworked, so you should read :ref:`configuration` carefully during the process of upgrading to v8+. However, some common pitfalls and potential sources of confusion are summarized here. -.. _breaking-configuration-setup-cfg-unsupported: +.. _upgrade_v8-configuration-setup-cfg-unsupported: ``setup.cfg`` is no longer supported """""""""""""""""""""""""""""""""""" @@ -532,7 +532,7 @@ needs. .. _pip issue: https://github.com/pypa/pip/issues/8437#issuecomment-805313362 -.. _breaking-commit-parser-options: +.. _upgrade_v8-commit-parser-options: Commit parser options """"""""""""""""""""" @@ -547,7 +547,7 @@ and if you need to parse multiple commit styles for a single project it's recomm that you create a parser following :ref:`commit_parser-custom_parser` that is tailored to the specific needs of your project. -.. _breaking-version-variable-rename: +.. _upgrade_v8-version-variable-rename: ``version_variable`` """""""""""""""""""" @@ -555,7 +555,7 @@ is tailored to the specific needs of your project. This option has been renamed to :ref:`version_variables ` as it refers to a list of variables which can be updated. -.. _breaking-version-pattern-removed: +.. _upgrade_v8-version-pattern-removed: ``version_pattern`` """"""""""""""""""" @@ -567,7 +567,7 @@ for a project and store this in an environment variable like so:: export VERSION=$(semantic-release version --print) -.. _breaking-version-toml-type: +.. _upgrade_v8-version-toml-type: ``version_toml`` """""""""""""""" @@ -588,7 +588,7 @@ simply wrap the value in ``[]``: version_toml = ["pyproject.toml:tool.poetry.version"] -.. _breaking-tag-format-validation: +.. _upgrade_v8-tag-format-validation: ``tag_format`` """""""""""""" @@ -597,7 +597,7 @@ This option has the same effect as it did in Python Semantic Release prior to v8 but Python Semantic Release will now verify that it has a ``{version}`` format key and raise an error if this is not the case. -.. _breaking-upload-to-release-rename: +.. _upgrade_v8-upload-to-release-rename: ``upload_to_release`` """"""""""""""""""""" @@ -605,7 +605,7 @@ key and raise an error if this is not the case. This option has been renamed to :ref:`upload_to_vcs_release `. -.. _breaking-custom-commit-parsers: +.. _upgrade_v8-custom-commit-parsers: Custom Commit Parsers --------------------- diff --git a/docs/upgrading/09-upgrade.rst b/docs/upgrading/09-upgrade.rst new file mode 100644 index 000000000..10c0d76e9 --- /dev/null +++ b/docs/upgrading/09-upgrade.rst @@ -0,0 +1,11 @@ +.. _upgrade_v9: + +Upgrading to v9 +=============== + +You are in luck! The upgrade to ``v9`` is a simple one. + +The breaking change for this version is the removal of support for **Python 3.7**, as it has passed +End-Of-Life (EOL). This means that if you are using Python 3.7, you will need to upgrade +to at least Python 3.8 in order to use ``v9``. This will be permanent as all future versions of +``python-semantic-release`` will require Python 3.8 or later. diff --git a/docs/upgrading/10-upgrade.rst b/docs/upgrading/10-upgrade.rst new file mode 100644 index 000000000..ffd6b0276 --- /dev/null +++ b/docs/upgrading/10-upgrade.rst @@ -0,0 +1,189 @@ +.. _upgrade_v10: + +Upgrading to v10 +================ + +The upgrade to v10 is primarily motivated by a command injection security vulnerability +found in the GitHub Actions configuration interpreter (see details +:ref:`below `). We also bundled a number of other changes, +including new default configuration values and most importantly, a return to 1-line +commit subjects in the default changelog format. + +For more specific change details for v10, please refer to the :ref:`changelog-v10.0.0` +section of the :ref:`changelog`. + + +.. _upgrade_v10-root_options: + +Security Fix: Command Injection Vulnerability (GitHub Actions) +-------------------------------------------------------------- + +In the previous versions of the GitHub Actions configuration, we used a single +``root_options`` parameter to pass any options you wanted to pass to the +``semantic-release`` main command. This parameter was interpreted as a string and +passed directly to the command line, which made it vulnerable to command injection +attacks. An attacker could exploit this by crafting a malicious string as the +:ref:`gh_actions-psr-inputs-root_options` input, and then it would be executed +as part of the command line, potentially allowing them to run arbitrary commands within +the GitHub Actions Docker container. The ability to exploit this vulnerability is limited +to people whom can modify the GitHub Actions workflow file, which is typically only the +repository maintainers unless you are pointing at an organizational workflow file or +another third-party workflow file. + +To mitigate this vulnerability, we have removed the ``root_options`` parameter completely +and replaced it with individual boolean flag inputs which are then used to select the proper +cli parameters for the ``semantic-release`` command. Additionally, users can protect themselves +by limiting the access to secrets in their GitHub Actions workflows and the permissions of +the GitHub Actions CI TOKEN. + +This vulnerability existed in both the +:ref:`python-semantic-release/python-semantic-release ` and +:ref:`python-semantic-release/publish-action ` actions. + +For the main :ref:`python-semantic-release/python-semantic-release ` action, +the following inputs are now available (in place of the old ``root_options`` parameter): + +- :ref:`gh_actions-psr-inputs-config_file` +- :ref:`gh_actions-psr-inputs-noop` +- :ref:`gh_actions-psr-inputs-strict` +- :ref:`gh_actions-psr-inputs-verbosity` + +For the :ref:`python-semantic-release/publish-action ` action, +the following inputs are now available (in place of the old ``root_options`` parameter): + +- :ref:`gh_actions-publish-inputs-config_file` +- :ref:`gh_actions-publish-inputs-noop` +- :ref:`gh_actions-publish-inputs-verbosity` + + +.. _upgrade_v10-changelog_format-1_line_commit_subjects: + +Changelog Format: 1-Line Commit Subjects +---------------------------------------- + +In v10, the default changelog format has been changed to use 1-line commit subjects instead of +including the full commit message. This change was made to improve the readability of the changelog +as many commit messages are long and contain unnecessary details for the changelog. + +.. important:: + If you use a squash commit merge strategy, it is recommended that you use the default + ``parse_squash_commits`` commit parser option to ensure that all the squashed commits are + parsed for version bumping and changelog generation. This is the default behavior in v10 across + all supported commit parsers. If you are upgrading, you likely will need to manually set this + option in your configuration file to ensure that the changelog is generated correctly. + + If you do not enable ``parse_squash_commits``, then version will only be determined by the + commit subject line and the changelog will only include the commit subject line as well. + + +.. _upgrade_v10-changelog_format-mask_initial_release: + +Changelog Format: Mask Initial Release +-------------------------------------- + +In v10, the default behavior for the changelog generation has been changed to mask the initial +release in the changelog. This means that the first release will not contain a break down of the +different types of changes (e.g., features, fixes, etc.), but instead it will just simply state +that this is the initial release. + + +.. _upgrade_v10-changelog_format-commit_parsing: + +Changelog Format: Commit Parsing +-------------------------------- + +We have made some minor changes to the commit parsing logic in *v10* to +separate out components of the commit message more clearly. You will find that the +:py:class:`ParsedCommit ` object's +descriptions list will no longer contain any Breaking Change footers, Release Notice footers, +PR/MR references, or Issue Closure footers. These were all previously extracted and placed +into their own attributes but were still included in the descriptions list. In *v10*, +the descriptions list will only contain the actual commit subject line and any additional +commit body text that is not part of the pre-defined footers. + +If you were relying on the descriptions list to contain these footers, you will need to +update your code and changelog templates to reference the specific attributes you want to use. + + +.. _upgrade_v10-default_config: + +Default Configuration Changes +----------------------------- + +The following table summarizes the changes to the default configuration values in v10: + +.. list-table:: + :widths: 5 55 20 20 + :header-rows: 1 + + * - # + - Configuration Option + - Previous Default Value + - New Default Value + + * - 1 + - :ref:`config-allow_zero_version` + - ``true`` + - ``false`` + + * - 2 + - :ref:`changelog.mode ` + - ``init`` + - ``update`` + + * - 3 + - :ref:`changelog.default_templates.mask_initial_release ` + - ``false`` + - ``true`` + + * - 4 + - :ref:`commit_parser_options.parse_squash_commits ` + - ``false`` + - ``true`` + + * - 5 + - :ref:`commit_parser_options.ignore_merge_commits ` + - ``false`` + - ``true`` + + +.. _upgrade_v10-deprecations: + +Deprecations & Removals +----------------------- + +No additional deprecations were made in *v10*, but the following are staged +for removal in v11: + +.. list-table:: Deprecated Features & Functions + :widths: 5 30 10 10 45 + :header-rows: 1 + + * - # + - Component + - Deprecated + - Planned Removal + - Notes + + * - 1 + - :ref:`GitHub Actions root_options ` + - v10.0.0 + - v10.0.0 + - Replaced with individual boolean flag inputs. See :ref:`above ` for details. + + * - 2 + - :ref:`Angular Commit Parser ` + - v9.19.0 + - v11.0.0 + - Replaced by the :ref:`Conventional Commit Parser `. + + * - 3 + - :ref:`Tag Commit Parser ` + - v9.12.0 + - v11.0.0 + - Replaced by the :ref:`Emoji Commit Parser `. + +.. note:: + For the most up-to-date information on the next version deprecations and removals, please + refer to the issue + `#1066 `_. diff --git a/docs/upgrading/index.rst b/docs/upgrading/index.rst new file mode 100644 index 000000000..4925d807e --- /dev/null +++ b/docs/upgrading/index.rst @@ -0,0 +1,27 @@ +.. _upgrading: + +============= +Upgrading PSR +============= + +Upgrading PSR is a process that may involve several steps, depending on the version you +are upgrading from and to. This section provides a guide for upgrading from older +versions of PSR to the latest version. + +.. important:: + If you are upgrading across **more than one** major version, you should incrementally + upgrade through each major version and its configuration update guide to ensure a + smooth transition. + + For example, if you are upgrading from v7 to v10, you should first + upgrade to v8 and then to v9, and then lastly to v10 while following the upgrade + guide for each version. At each step you should confirm execution works as expected + before proceeding to the next version. + +.. toctree:: + :caption: Upgrade Guides + :maxdepth: 1 + + Upgrading to v10 <10-upgrade> + Upgrading to v9 <09-upgrade> + Upgrading to v8 <08-upgrade> diff --git a/pyproject.toml b/pyproject.toml index 64f665d0b..ec0bc1f8e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ # Ref: https://packaging.python.org/en/latest/specifications/declaring-project-metadata/ # and https://setuptools.pypa.io/en/latest/userguide/pyproject_config.html [build-system] -requires = ["setuptools ~= 75.3.0", "wheel ~= 0.42"] +requires = ["setuptools >= 75.3.0, < 81.0.0", "wheel ~= 0.42"] build-backend = "setuptools.build_meta" [project] name = "python-semantic-release" -version = "9.21.0" +version = "10.0.0" description = "Automatic Semantic Versioning for Python projects" requires-python = ">=3.8" license = { text = "MIT" } @@ -23,17 +23,17 @@ classifiers = [ readme = "README.rst" authors = [{ name = "Rolf Erik Lekang", email = "me@rolflekang.com" }] dependencies = [ - "click ~= 8.0", + "click ~= 8.1.0", "click-option-group ~= 0.5", "gitpython ~= 3.0", "requests ~= 2.25", "jinja2 ~= 3.1", - "python-gitlab ~= 4.0", + "python-gitlab >= 4.0.0, < 6.0.0", "tomlkit ~= 0.11", "dotty-dict ~= 1.3", "importlib-resources ~= 6.0", "pydantic ~= 2.0", - "rich ~= 13.0", + "rich ~= 14.0", "shellingham ~= 1.5", "Deprecated ~= 1.2", # Backport of deprecated decorator for python 3.8 ] @@ -68,7 +68,7 @@ test = [ "pyyaml ~= 6.0", "pytest ~= 8.3", "pytest-clarity ~= 1.0", - "pytest-cov ~= 5.0", + "pytest-cov >= 5.0.0, < 7.0.0", "pytest-env ~= 1.0", "pytest-lazy-fixtures ~= 1.1.1", "pytest-mock ~= 3.0", @@ -412,7 +412,9 @@ build_command = """ python -m build . """ major_on_zero = true -version_variables = ["src/semantic_release/__init__.py:__version__"] +version_variables = [ + "src/gh_action/requirements.txt:python-semantic-release:nf", +] version_toml = ["pyproject.toml:project.version"] [tool.semantic_release.changelog] @@ -434,7 +436,7 @@ mode = "update" template_dir = "config/release-templates" [tool.semantic_release.branches.main] -match = "(main|master)" +match = "^(main|master)$" prerelease = false prerelease_token = "rc" diff --git a/scripts/bump_version_in_docs.py b/scripts/bump_version_in_docs.py index f8c67331a..7c6104791 100644 --- a/scripts/bump_version_in_docs.py +++ b/scripts/bump_version_in_docs.py @@ -60,7 +60,8 @@ def envsubst(filepath: Path, version: str, release_tag: str) -> None: exit(1) update_github_actions_example( - DOCS_DIR / "automatic-releases" / "github-actions.rst", new_release_tag + DOCS_DIR / "configuration" / "automatic-releases" / "github-actions.rst", + new_release_tag, ) for doc_file in DOCS_DIR.rglob("*.rst"): diff --git a/src/gh_action/.dockerignore b/src/gh_action/.dockerignore new file mode 100644 index 000000000..e9783640d --- /dev/null +++ b/src/gh_action/.dockerignore @@ -0,0 +1,6 @@ +# Default, ignore everything +* + +# Except +!requirements.txt +!action.sh diff --git a/src/gh_action/Dockerfile b/src/gh_action/Dockerfile new file mode 100644 index 000000000..138b95a29 --- /dev/null +++ b/src/gh_action/Dockerfile @@ -0,0 +1,42 @@ +# This Dockerfile is only for GitHub Actions +FROM python:3.13-bookworm +ARG WORK_DIR="/opt/psr" + +WORKDIR ${WORK_DIR} + +ENV PSR_DOCKER_GITHUB_ACTION=true \ + PYTHONDONTWRITEBYTECODE=1 \ + PSR_VENV_BIN="${WORK_DIR}/.venv/bin" + +# Copy action utilities into container +COPY . ./ + +RUN \ + # Install desired packages + apt update && apt install -y --no-install-recommends \ + # install git with git-lfs support + git git-lfs \ + # install python cmodule / binary module build utilities + python3-dev gcc make cmake cargo \ + # Configure global pip + && { \ + printf '%s\n' "[global]"; \ + printf '%s\n' "disable-pip-version-check = true"; \ + } > /etc/pip.conf \ + # Create virtual environment for python-semantic-release + && python3 -m venv "$(dirname "${PSR_VENV_BIN}")" \ + # Update core utilities in the virtual environment + && "${PSR_VENV_BIN}/pip" install --upgrade pip setuptools wheel \ + # Install psr & its dependencies from source into virtual environment + && "${PSR_VENV_BIN}/pip" install --pre -r requirements.txt \ + # Validate binary availability + && bash -c "${PSR_VENV_BIN}/semantic-release --help" \ + # make action script executable + && chmod +x "${WORK_DIR}/action.sh" \ + # Put action script in PATH + && ln -s "${WORK_DIR}/action.sh" /usr/local/bin/action-entrypoint \ + # Clean up + && apt clean && rm -rf /var/lib/apt/lists/* \ + && find /tmp -mindepth 1 -delete + +ENTRYPOINT ["/usr/local/bin/action-entrypoint"] diff --git a/action.sh b/src/gh_action/action.sh similarity index 71% rename from action.sh rename to src/gh_action/action.sh index 48ab72c35..cd862d9a9 100644 --- a/action.sh +++ b/src/gh_action/action.sh @@ -2,6 +2,13 @@ set -e +explicit_run_cmd() { + local cmd="" + cmd="$(printf '%s' "$*" | sed 's/^ *//g' | sed 's/ *$//g')" + printf '%s\n' "$> $cmd" + eval "$cmd" +} + # Convert "true"/"false" into command line args, returns "" if not defined eval_boolean_action_input() { local -r input_name="$1" @@ -13,11 +20,11 @@ eval_boolean_action_input() { local -r if_false="$1" if [ -z "$flag_value" ]; then - echo "" + printf "" elif [ "$flag_value" = "true" ]; then - echo "$if_true" + printf '%s\n' "$if_true" elif [ "$flag_value" = "false" ]; then - echo "$if_false" + printf '%s\n' "$if_false" else printf 'Error: Invalid value for input %s: %s is not "true" or "false\n"' \ "$input_name" "$flag_value" >&2 @@ -25,8 +32,53 @@ eval_boolean_action_input() { fi } +# Convert string input into command line args, returns "" if undefined +eval_string_input() { + local -r input_name="$1" + shift + local -r if_defined="$1" + shift + local value + value="$(printf '%s' "$1" | tr -d ' ')" + + if [ -z "$value" ]; then + printf "" + return 0 + fi + + printf '%s' "${if_defined/\%s/$value}" +} + # Convert inputs to command line arguments -export ARGS=() +ROOT_OPTIONS=() + +if ! printf '%s\n' "$INPUT_VERBOSITY" | grep -qE '^[0-9]+$'; then + printf "Error: Input 'verbosity' must be a positive integer\n" >&2 + exit 1 +fi + +VERBOSITY_OPTIONS="" +for ((i = 0; i < INPUT_VERBOSITY; i++)); do + [ "$i" -eq 0 ] && VERBOSITY_OPTIONS="-" + VERBOSITY_OPTIONS+="v" +done + +ROOT_OPTIONS+=("$VERBOSITY_OPTIONS") + +if [ -n "$INPUT_CONFIG_FILE" ]; then + # Check if the file exists + if [ ! -f "$INPUT_CONFIG_FILE" ]; then + printf "Error: Input 'config_file' does not exist: %s\n" "$INPUT_CONFIG_FILE" >&2 + exit 1 + fi + + ROOT_OPTIONS+=("$(eval_string_input "config_file" "--config %s" "$INPUT_CONFIG_FILE")") || exit 1 +fi + +ROOT_OPTIONS+=("$(eval_boolean_action_input "strict" "$INPUT_STRICT" "--strict" "")") || exit 1 +ROOT_OPTIONS+=("$(eval_boolean_action_input "no_operation_mode" "$INPUT_NO_OPERATION_MODE" "--noop" "")") || exit 1 + +ARGS=() # v10 Breaking change as prerelease should be as_prerelease to match ARGS+=("$(eval_boolean_action_input "prerelease" "$INPUT_PRERELEASE" "--as-prerelease" "")") || exit 1 ARGS+=("$(eval_boolean_action_input "commit" "$INPUT_COMMIT" "--commit" "--no-commit")") || exit 1 @@ -110,5 +162,8 @@ fi # Copy inputs into correctly-named environment variables export GH_TOKEN="${INPUT_GITHUB_TOKEN}" +# normalize extra spaces into single spaces as you combine the arguments +CMD_ARGS="$(printf '%s' "${ROOT_OPTIONS[*]} version ${ARGS[*]}" | sed 's/ [ ]*/ /g' | sed 's/^ *//g')" + # Run Semantic Release (explicitly use the GitHub action version) -eval "/psr/.venv/bin/semantic-release $INPUT_ROOT_OPTIONS version ${ARGS[*]}" +explicit_run_cmd "$PSR_VENV_BIN/semantic-release $CMD_ARGS" diff --git a/src/gh_action/requirements.txt b/src/gh_action/requirements.txt new file mode 100644 index 000000000..2fabec60d --- /dev/null +++ b/src/gh_action/requirements.txt @@ -0,0 +1 @@ +python-semantic-release == 10.0.0 diff --git a/src/semantic_release/__init__.py b/src/semantic_release/__init__.py index fa54ef662..25deef686 100644 --- a/src/semantic_release/__init__.py +++ b/src/semantic_release/__init__.py @@ -2,6 +2,8 @@ from __future__ import annotations +import importlib.metadata + from semantic_release.commit_parser import ( CommitParser, ParsedCommit, @@ -24,7 +26,7 @@ tags_and_versions, ) -__version__ = "9.21.0" +__version__ = importlib.metadata.version(f"python_{__package__}".replace("_", "-")) __all__ = [ "CommitParser", diff --git a/src/semantic_release/changelog/release_history.py b/src/semantic_release/changelog/release_history.py index 887fa5492..b947a66ad 100644 --- a/src/semantic_release/changelog/release_history.py +++ b/src/semantic_release/changelog/release_history.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging from collections import defaultdict from datetime import datetime, timedelta, timezone from typing import TYPE_CHECKING, TypedDict @@ -11,6 +10,7 @@ from semantic_release.commit_parser.token import ParsedCommit from semantic_release.commit_parser.util import force_str from semantic_release.enums import LevelBump +from semantic_release.globals import logger from semantic_release.helpers import validate_types_in_sequence from semantic_release.version.algorithm import tags_and_versions @@ -29,8 +29,6 @@ from semantic_release.version.translator import VersionTranslator from semantic_release.version.version import Version -log = logging.getLogger(__name__) - class ReleaseHistory: @classmethod @@ -72,17 +70,17 @@ def from_git_history( for commit in repo.iter_commits("HEAD", topo_order=True): # Determine if we have found another release - log.debug("checking if commit %s matches any tags", commit.hexsha[:7]) + logger.debug("checking if commit %s matches any tags", commit.hexsha[:7]) t_v = tag_sha_2_version_lookup.get(commit.hexsha, None) if t_v is None: - log.debug("no tags correspond to commit %s", commit.hexsha) + logger.debug("no tags correspond to commit %s", commit.hexsha) else: # Unpack the tuple (overriding the current version) tag, the_version = t_v # we have found the latest commit introduced by this tag # so we create a new Release entry - log.debug("found commit %s for tag %s", commit.hexsha, tag.name) + logger.debug("found commit %s for tag %s", commit.hexsha, tag.name) # tag.object is a Commit if the tag is lightweight, otherwise # it is a TagObject with additional metadata about the tag @@ -110,7 +108,7 @@ def from_git_history( released.setdefault(the_version, release) - log.info( + logger.info( "parsing commit [%s] %s", commit.hexsha[:8], str(commit.message).replace("\n", " ")[:54], @@ -153,7 +151,7 @@ def from_git_history( if isinstance(parsed_result, ParseError) else parsed_result.type ) - log.debug("commit has type '%s'", commit_type) + logger.debug("commit has type '%s'", commit_type) has_exclusion_match = any( pattern.match(commit_message) for pattern in exclude_commit_patterns @@ -166,7 +164,7 @@ def from_git_history( ) if ignore_merge_commits and parsed_result.is_merge_commit(): - log.info("Excluding merge commit[%s]", parsed_result.short_hash) + logger.info("Excluding merge commit[%s]", parsed_result.short_hash) continue # Skip excluded commits except for any commit causing a version bump @@ -174,7 +172,7 @@ def from_git_history( # are included, then the changelog will be empty. Even if ther was other # commits included, the true reason for a version bump would be missing. if has_exclusion_match and commit_level_bump == LevelBump.NO_RELEASE: - log.info( + logger.info( "Excluding %s commit[%s] %s", "piece of squashed" if is_squash_commit else "", parsed_result.short_hash, @@ -186,7 +184,7 @@ def from_git_history( isinstance(parsed_result, ParsedCommit) and not parsed_result.include_in_changelog ): - log.info( + logger.info( str.join( " ", [ @@ -199,7 +197,7 @@ def from_git_history( continue if the_version is None: - log.info( + logger.info( "[Unreleased] adding commit[%s] to unreleased '%s'", parsed_result.short_hash, commit_type, @@ -207,7 +205,7 @@ def from_git_history( unreleased[commit_type].append(parsed_result) continue - log.info( + logger.info( "[%s] adding commit[%s] to release '%s'", the_version, parsed_result.short_hash, diff --git a/src/semantic_release/changelog/template.py b/src/semantic_release/changelog/template.py index 2b80d8f65..d74295404 100644 --- a/src/semantic_release/changelog/template.py +++ b/src/semantic_release/changelog/template.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging import os import shutil from pathlib import Path, PurePosixPath @@ -9,6 +8,7 @@ from jinja2 import FileSystemLoader from jinja2.sandbox import SandboxedEnvironment +from semantic_release.globals import logger from semantic_release.helpers import dynamic_import if TYPE_CHECKING: # pragma: no cover @@ -17,9 +17,6 @@ from jinja2 import Environment -log = logging.getLogger(__name__) - - # pylint: disable=too-many-arguments,too-many-locals def environment( template_dir: Path | str = ".", @@ -107,7 +104,7 @@ def recursive_render( and not file.startswith(".") ): output_path = (_root_dir / root.relative_to(template_dir)).resolve() - log.info("Rendering templates from %s to %s", root, output_path) + logger.info("Rendering templates from %s to %s", root, output_path) output_path.mkdir(parents=True, exist_ok=True) if file.endswith(".j2"): # We know the file ends with .j2 by the filter in the for-loop @@ -122,18 +119,20 @@ def recursive_render( # contents of a file during the rendering of the template. This mechanism # is used for inserting into a current changelog. When using stream rendering # of the same file, it always came back empty - log.debug("rendering %s to %s", src_file_path, output_file_path) + logger.debug("rendering %s to %s", src_file_path, output_file_path) rendered_file = environment.get_template(src_file_path).render().rstrip() with open(output_file_path, "w", encoding="utf-8") as output_file: output_file.write(f"{rendered_file}\n") rendered_paths.append(output_file_path) + else: src_file = str((root / file).resolve()) target_file = str((output_path / file).resolve()) - log.debug( + logger.debug( "source file %s is not a template, copying to %s", src_file, target_file ) shutil.copyfile(src_file, target_file) rendered_paths.append(target_file) + return rendered_paths diff --git a/src/semantic_release/cli/changelog_writer.py b/src/semantic_release/cli/changelog_writer.py index 5ee86457e..96020b73a 100644 --- a/src/semantic_release/cli/changelog_writer.py +++ b/src/semantic_release/cli/changelog_writer.py @@ -2,7 +2,6 @@ import os from contextlib import suppress -from logging import getLogger from pathlib import Path from typing import TYPE_CHECKING @@ -25,6 +24,7 @@ ) from semantic_release.cli.util import noop_report from semantic_release.errors import InternalError +from semantic_release.globals import logger from semantic_release.helpers import sort_numerically if TYPE_CHECKING: # pragma: no cover @@ -36,9 +36,6 @@ from semantic_release.hvcs._base import HvcsBase -log = getLogger(__name__) - - def get_default_tpl_dir(style: str, sub_dir: str | None = None) -> Path: module_base_path = Path(str(files(semantic_release.__name__))) default_templates_path = module_base_path.joinpath( @@ -210,7 +207,9 @@ def write_changelog_files( noop=noop, ) - log.info("No contents found in %r, using default changelog template", template_dir) + logger.info( + "No contents found in %r, using default changelog template", template_dir + ) return [ write_default_changelog( changelog_file=runtime_ctx.changelog_file, diff --git a/src/semantic_release/cli/commands/changelog.py b/src/semantic_release/cli/commands/changelog.py index 316b44450..523c1d2a5 100644 --- a/src/semantic_release/cli/commands/changelog.py +++ b/src/semantic_release/cli/commands/changelog.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging from contextlib import suppress from pathlib import Path from typing import TYPE_CHECKING @@ -15,15 +14,13 @@ write_changelog_files, ) from semantic_release.cli.util import noop_report +from semantic_release.globals import logger from semantic_release.hvcs.remote_hvcs_base import RemoteHvcsBase if TYPE_CHECKING: # pragma: no cover from semantic_release.cli.cli_context import CliContextObj -log = logging.getLogger(__name__) - - def get_license_name_for_release(tag_name: str, project_root: Path) -> str: # Retrieve the license name at the time of the specific release tag project_metadata: dict[str, str] = {} @@ -174,7 +171,7 @@ def changelog(cli_ctx: CliContextObj, release_tag: str | None) -> None: hvcs_client=hvcs_client, noop=runtime.global_cli_options.noop, ) - except Exception as e: - log.exception(e) + except Exception as e: # noqa: BLE001 # TODO: catch specific exceptions + logger.exception(e) click.echo("Failed to post release notes to remote", err=True) ctx.exit(1) diff --git a/src/semantic_release/cli/commands/main.py b/src/semantic_release/cli/commands/main.py index 7f0d170e2..c10d3f647 100644 --- a/src/semantic_release/cli/commands/main.py +++ b/src/semantic_release/cli/commands/main.py @@ -21,7 +21,13 @@ # pass -FORMAT = "[%(module)s.%(funcName)s] %(message)s" +FORMAT = "%(message)s" +LOG_LEVELS = [ + SemanticReleaseLogLevels.WARNING, + SemanticReleaseLogLevels.INFO, + SemanticReleaseLogLevels.DEBUG, + SemanticReleaseLogLevels.SILLY, +] class Cli(click.MultiCommand): @@ -79,7 +85,7 @@ def get_command(self, _ctx: click.Context, name: str) -> click.Command | None: default=0, count=True, show_default=True, - type=click.IntRange(0, 2, clamp=True), + type=click.IntRange(0, len(LOG_LEVELS) - 1, clamp=True), ) @click.option( "--strict", @@ -107,29 +113,20 @@ def main( For more information, visit https://python-semantic-release.readthedocs.io/ """ - console = Console(stderr=True) - - log_levels = [ - SemanticReleaseLogLevels.WARNING, - SemanticReleaseLogLevels.INFO, - SemanticReleaseLogLevels.DEBUG, - SemanticReleaseLogLevels.SILLY, - ] - - globals.log_level = log_levels[verbosity] - - logging.basicConfig( - level=globals.log_level, - format=FORMAT, - datefmt="[%X]", - handlers=[ - RichHandler( - console=console, rich_tracebacks=True, tracebacks_suppress=[click] - ), - ], - ) + globals.log_level = LOG_LEVELS[verbosity] - logger = logging.getLogger(__name__) + # Set up our pretty console formatter + rich_handler = RichHandler( + console=Console(stderr=True), rich_tracebacks=True, tracebacks_suppress=[click] + ) + rich_handler.setFormatter(logging.Formatter(FORMAT, datefmt="[%X]")) + + # Set up logging with our pretty console formatter + logger = globals.logger + logger.handlers.clear() + logger.filters.clear() + logger.addHandler(rich_handler) + logger.setLevel(globals.log_level) logger.debug("logging level set to: %s", logging.getLevelName(globals.log_level)) if noop: diff --git a/src/semantic_release/cli/commands/publish.py b/src/semantic_release/cli/commands/publish.py index 0d354c387..4efab72de 100644 --- a/src/semantic_release/cli/commands/publish.py +++ b/src/semantic_release/cli/commands/publish.py @@ -1,12 +1,12 @@ from __future__ import annotations -import logging from typing import TYPE_CHECKING import click from git import Repo from semantic_release.cli.util import noop_report +from semantic_release.globals import logger from semantic_release.hvcs.remote_hvcs_base import RemoteHvcsBase from semantic_release.version.algorithm import tags_and_versions @@ -14,9 +14,6 @@ from semantic_release.cli.cli_context import CliContextObj -log = logging.getLogger(__name__) - - def publish_distributions( tag: str, hvcs_client: RemoteHvcsBase, @@ -36,7 +33,7 @@ def publish_distributions( ) return - log.info("Uploading distributions to release") + logger.info("Uploading distributions to release") for pattern in dist_glob_patterns: hvcs_client.upload_dists(tag=tag, dist_glob=pattern) # type: ignore[attr-defined] diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index 86d209937..8a8cf94a1 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging import os import subprocess import sys @@ -30,6 +29,7 @@ UnexpectedResponse, ) from semantic_release.gitproject import GitProject +from semantic_release.globals import logger from semantic_release.hvcs.remote_hvcs_base import RemoteHvcsBase from semantic_release.version.algorithm import ( next_version, @@ -48,9 +48,6 @@ from semantic_release.version.version import Version -log = logging.getLogger(__name__) - - def is_forced_prerelease( as_prerelease: bool, forced_level_bump: LevelBump | None, prerelease: bool ) -> bool: @@ -62,7 +59,7 @@ def is_forced_prerelease( Otherwise (``force_level is None``) use the value of ``prerelease`` """ local_vars = list(locals().items()) - log.debug( + logger.debug( "%s: %s", is_forced_prerelease.__name__, str.join(", ", iter(f"{k} = {v}" for k, v in local_vars)), @@ -143,7 +140,7 @@ def apply_version_to_source_files( return [] if not noop: - log.debug("Updating version %s in repository files...", version) + logger.debug("Updating version %s in repository files...", version) paths = list( map( @@ -181,8 +178,8 @@ def shell( try: shell, _ = shellingham.detect_shell() except shellingham.ShellDetectionFailure: - log.warning("failed to detect shell, using default shell: %s", DEFAULT_SHELL) - log.debug("stack trace", exc_info=True) + logger.warning("failed to detect shell, using default shell: %s", DEFAULT_SHELL) + logger.debug("stack trace", exc_info=True) shell = DEFAULT_SHELL if not shell: @@ -231,6 +228,7 @@ def get_windows_env() -> Mapping[str, str | None]: "SYSTEMROOT", "TEMP", "TMP", + "USERNAME", # must include for python getpass.getuser() on windows "USERPROFILE", "USERSID", "WINDIR", @@ -261,7 +259,7 @@ def build_distributions( noop_report(f"would have run the build_command {build_command}") return - log.info("Running build command %s", build_command) + logger.info("Running build command %s", build_command) rprint(f"[bold green]:hammer_and_wrench: Running build command: {build_command}") build_env_vars: dict[str, str] = dict( @@ -295,8 +293,8 @@ def build_distributions( shell(build_command, env=build_env_vars, check=True) rprint("[bold green]Build completed successfully!") except subprocess.CalledProcessError as exc: - log.exception(exc) - log.error("Build command failed with exit code %s", exc.returncode) # noqa: TRY400 + logger.exception(exc) + logger.error("Build command failed with exit code %s", exc.returncode) # noqa: TRY400 raise BuildDistributionsError from exc @@ -453,7 +451,7 @@ def version( # noqa: C901 if not ( last_release := last_released(config.repo_dir, tag_format=config.tag_format) ): - log.warning("No release tags found.") + logger.warning("No release tags found.") return click.echo(last_release[0] if print_last_released_tag else last_release[1]) @@ -482,22 +480,24 @@ def version( # noqa: C901 ) if prerelease_token: - log.info("Forcing use of %s as the prerelease token", prerelease_token) + logger.info("Forcing use of %s as the prerelease token", prerelease_token) translator.prerelease_token = prerelease_token # Only push if we're committing changes if push_changes and not commit_changes and not create_tag: - log.info("changes will not be pushed because --no-commit disables pushing") + logger.info("changes will not be pushed because --no-commit disables pushing") push_changes &= commit_changes # Only push if we're creating a tag if push_changes and not create_tag and not commit_changes: - log.info("new tag will not be pushed because --no-tag disables pushing") + logger.info("new tag will not be pushed because --no-tag disables pushing") push_changes &= create_tag # Only make a release if we're pushing the changes if make_vcs_release and not push_changes: - log.info("No vcs release will be created because pushing changes is disabled") + logger.info( + "No vcs release will be created because pushing changes is disabled" + ) make_vcs_release &= push_changes if not forced_level_bump: @@ -511,7 +511,7 @@ def version( # noqa: C901 allow_zero_version=runtime.allow_zero_version, ) else: - log.warning( + logger.warning( "Forcing a '%s' release due to '--%s' command-line flag", force_level, ( @@ -665,7 +665,7 @@ def version( # noqa: C901 noop=opts.noop, ) except GitCommitEmptyIndexError: - log.info("No local changes to add to any commit, skipping") + logger.info("No local changes to add to any commit, skipping") # Tag the version after potentially creating a new HEAD commit. # This way if no source code is modified, i.e. all metadata updates @@ -711,7 +711,7 @@ def version( # noqa: C901 return if not isinstance(hvcs_client, RemoteHvcsBase): - log.info("Remote does not support releases. Skipping release creation...") + logger.info("Remote does not support releases. Skipping release creation...") return license_cfg = runtime.project_metadata.get( @@ -774,7 +774,7 @@ def version( # noqa: C901 exception = err finally: if exception is not None: - log.exception(exception) + logger.exception(exception) click.echo(str(exception), err=True) if help_message: click.echo(help_message, err=True) diff --git a/src/semantic_release/cli/config.py b/src/semantic_release/cli/config.py index 41ac02058..59df69834 100644 --- a/src/semantic_release/cli/config.py +++ b/src/semantic_release/cli/config.py @@ -54,13 +54,13 @@ NotAReleaseBranch, ParserLoadError, ) +from semantic_release.globals import logger from semantic_release.helpers import dynamic_import from semantic_release.version.declarations.i_version_replacer import IVersionReplacer from semantic_release.version.declarations.pattern import PatternVersionDeclaration from semantic_release.version.declarations.toml import TomlVersionDeclaration from semantic_release.version.translator import VersionTranslator -log = logging.getLogger(__name__) NonEmptyString = Annotated[str, Field(..., min_length=1)] @@ -128,8 +128,7 @@ class ChangelogEnvironmentConfig(BaseModel): class DefaultChangelogTemplatesConfig(BaseModel): changelog_file: str = "CHANGELOG.md" output_format: ChangelogOutputFormat = ChangelogOutputFormat.NONE - # TODO: Breaking Change v10, it will become True - mask_initial_release: bool = False + mask_initial_release: bool = True @model_validator(mode="after") def interpret_output_format(self) -> Self: @@ -158,7 +157,7 @@ class ChangelogConfig(BaseModel): ) environment: ChangelogEnvironmentConfig = ChangelogEnvironmentConfig() exclude_commit_patterns: Tuple[str, ...] = () - mode: ChangelogMode = ChangelogMode.INIT + mode: ChangelogMode = ChangelogMode.UPDATE insertion_flag: str = "" template_dir: str = "templates" @@ -179,7 +178,7 @@ def validate_match(cls, patterns: Tuple[str, ...]) -> Tuple[str, ...]: @field_validator("changelog_file", mode="after") @classmethod def changelog_file_deprecation_warning(cls, val: str) -> str: - log.warning( + logger.warning( str.join( " ", [ @@ -329,7 +328,7 @@ def check_insecure_flag(self, url_str: str, field_name: str) -> None: ) if scheme == "https" and self.insecure: - log.warning( + logger.warning( str.join( "\n", [ @@ -360,7 +359,7 @@ class RawConfig(BaseModel): commit_parser_options: Dict[str, Any] = {} logging_use_named_masks: bool = False major_on_zero: bool = True - allow_zero_version: bool = True + allow_zero_version: bool = False repo_dir: Annotated[Path, Field(validate_default=True)] = Path(".") remote: RemoteConfig = RemoteConfig() no_git_verify: bool = False @@ -402,7 +401,7 @@ def verify_git_repo_dir(cls, dir_path: Path) -> Path: @classmethod def tag_commit_parser_deprecation_warning(cls, val: str) -> str: if val == "tag": - log.warning( + logger.warning( str.join( " ", [ @@ -418,7 +417,7 @@ def tag_commit_parser_deprecation_warning(cls, val: str) -> str: @classmethod def angular_commit_parser_deprecation_warning(cls, val: str) -> str: if val == "angular": - log.warning( + logger.warning( str.join( " ", [ @@ -585,14 +584,14 @@ def select_branch_options( ) -> BranchConfig: for group, options in choices.items(): if regexp(options.match).match(active_branch): - log.info( + logger.info( "Using group %r options, as %r matches %r", group, options.match, active_branch, ) return options - log.debug( + logger.debug( "Rejecting group %r as %r doesn't match %r", group, options.match, @@ -718,7 +717,7 @@ def from_raw_config( # noqa: C901 # TODO: add any other placeholders here ), # We use re.escape to ensure that the commit message is treated as a literal - regex_escape(raw.commit_message), + regex_escape(raw.commit_message.strip()), ) ) changelog_excluded_commit_patterns = ( @@ -774,10 +773,10 @@ def from_raw_config( # noqa: C901 # Provide warnings if the token is missing if not raw.remote.token: - log.debug("hvcs token is not set") + logger.debug("hvcs token is not set") if not raw.remote.ignore_token_for_push: - log.warning("Token value is missing!") + logger.warning("Token value is missing!") # hvcs_client hvcs_client_cls = _known_hvcs[raw.remote.type] @@ -859,11 +858,11 @@ def from_raw_config( # noqa: C901 # Here we just assume the desired changelog style matches the parser name # as we provide templates specific to each parser type. Unfortunately if the user has # provided a custom parser, it would be up to the user to provide custom templates - # but we just assume the base template is angular + # but we just assume the base template is conventional # changelog_style = ( # raw.commit_parser # if raw.commit_parser in _known_commit_parsers - # else "angular" + # else "conventional" # ) self = cls( @@ -887,8 +886,7 @@ def from_raw_config( # noqa: C901 changelog_excluded_commit_patterns=changelog_excluded_commit_patterns, # TODO: change when we have other styles per parser # changelog_style=changelog_style, - # TODO: Breaking Change v10, change to conventional - changelog_style="angular", + changelog_style="conventional", changelog_output_format=raw.changelog.default_templates.output_format, prerelease=branch_config.prerelease, ignore_token_for_push=raw.remote.ignore_token_for_push, diff --git a/src/semantic_release/cli/github_actions_output.py b/src/semantic_release/cli/github_actions_output.py index 253b2419c..7d7782922 100644 --- a/src/semantic_release/cli/github_actions_output.py +++ b/src/semantic_release/cli/github_actions_output.py @@ -1,12 +1,10 @@ from __future__ import annotations -import logging import os +from semantic_release.globals import logger from semantic_release.version.version import Version -log = logging.getLogger(__name__) - class VersionGitHubActionsOutput: OUTPUT_ENV_VAR = "GITHUB_OUTPUT" @@ -71,7 +69,7 @@ def to_output_text(self) -> str: def write_if_possible(self, filename: str | None = None) -> None: output_file = filename or os.getenv(self.OUTPUT_ENV_VAR) if not output_file: - log.info("not writing GitHub Actions output, as no file specified") + logger.info("not writing GitHub Actions output, as no file specified") return with open(output_file, "a", encoding="utf-8") as f: diff --git a/src/semantic_release/cli/masking_filter.py b/src/semantic_release/cli/masking_filter.py index 2c0fdb947..f2e4f825f 100644 --- a/src/semantic_release/cli/masking_filter.py +++ b/src/semantic_release/cli/masking_filter.py @@ -1,16 +1,20 @@ from __future__ import annotations -import logging import re from collections import defaultdict -from typing import Iterable +from logging import Filter as LoggingFilter +from typing import TYPE_CHECKING -log = logging.getLogger(__name__) +from semantic_release.globals import logger + +if TYPE_CHECKING: # pragma: no cover + from logging import LogRecord + from typing import Iterable # https://relaxdiego.com/2014/07/logging-in-python.html # Updated/adapted for Python3 -class MaskingFilter(logging.Filter): +class MaskingFilter(LoggingFilter): REPLACE_STR = "*" * 4 _UNWANTED = frozenset([s for obj in ("", None) for s in (repr(obj), str(obj))]) @@ -27,11 +31,11 @@ def __init__( def add_mask_for(self, data: str, name: str = "redacted") -> MaskingFilter: if data and data not in self._UNWANTED: - log.debug("Adding redact pattern '%r' to redact_patterns", name) + logger.debug("Adding redact pattern '%r' to redact_patterns", name) self._redact_patterns[name].add(data) return self - def filter(self, record: logging.LogRecord) -> bool: + def filter(self, record: LogRecord) -> bool: # Note if we blindly mask all types, we will actually cast arguments to # log functions from external libraries to strings before they are # formatted into the message - for example, a dependency calling @@ -58,7 +62,7 @@ def filter(self, record: logging.LogRecord) -> bool: def mask(self, msg: str) -> str: if not isinstance(msg, str): - log.debug( # type: ignore[unreachable] + logger.debug( # type: ignore[unreachable] "cannot mask object of type %s", type(msg) ) return msg diff --git a/src/semantic_release/cli/util.py b/src/semantic_release/cli/util.py index 97d264b02..0f62d3d10 100644 --- a/src/semantic_release/cli/util.py +++ b/src/semantic_release/cli/util.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import logging import sys from pathlib import Path from textwrap import dedent, indent @@ -14,8 +13,7 @@ from tomlkit.exceptions import TOMLKitError from semantic_release.errors import InvalidConfiguration - -log = logging.getLogger(__name__) +from semantic_release.globals import logger def rprint(msg: str) -> None: @@ -76,21 +74,21 @@ def load_raw_config_file(config_file: Path | str) -> dict[Any, Any]: This function will also raise FileNotFoundError if it is raised while trying to read the specified configuration file """ - log.info("Loading configuration from %s", config_file) + logger.info("Loading configuration from %s", config_file) raw_text = (Path() / config_file).resolve().read_text(encoding="utf-8") try: - log.debug("Trying to parse configuration %s in TOML format", config_file) + logger.debug("Trying to parse configuration %s in TOML format", config_file) return parse_toml(raw_text) except InvalidConfiguration as e: - log.debug("Configuration %s is invalid TOML: %s", config_file, str(e)) - log.debug("trying to parse %s as JSON", config_file) + logger.debug("Configuration %s is invalid TOML: %s", config_file, str(e)) + logger.debug("trying to parse %s as JSON", config_file) try: # could be a "parse_json" function but it's a one-liner here return json.loads(raw_text)["semantic_release"] except KeyError: # valid configuration, but no "semantic_release" or "tool.semantic_release" # top level key - log.debug( + logger.debug( "configuration has no 'semantic_release' or 'tool.semantic_release' " "top-level key" ) diff --git a/src/semantic_release/commit_parser/angular.py b/src/semantic_release/commit_parser/angular.py index ca739cc91..eeef82796 100644 --- a/src/semantic_release/commit_parser/angular.py +++ b/src/semantic_release/commit_parser/angular.py @@ -5,7 +5,6 @@ from __future__ import annotations -import logging import re from functools import reduce from itertools import zip_longest @@ -31,15 +30,13 @@ ) from semantic_release.enums import LevelBump from semantic_release.errors import InvalidParserOptions +from semantic_release.globals import logger from semantic_release.helpers import sort_numerically, text_reducer if TYPE_CHECKING: # pragma: no cover from git.objects.commit import Commit -logger = logging.getLogger(__name__) - - def _logged_parse_error(commit: Commit, error: str) -> ParseError: logger.debug(error) return ParseError(commit, error=error) diff --git a/src/semantic_release/commit_parser/conventional.py b/src/semantic_release/commit_parser/conventional.py index 9ee0b27fe..3cd50d9c7 100644 --- a/src/semantic_release/commit_parser/conventional.py +++ b/src/semantic_release/commit_parser/conventional.py @@ -1,19 +1,124 @@ from __future__ import annotations +import re +from functools import reduce +from itertools import zip_longest +from re import compile as regexp +from textwrap import dedent +from typing import TYPE_CHECKING, Tuple + +from git.objects.commit import Commit from pydantic.dataclasses import dataclass -from semantic_release.commit_parser.angular import ( - AngularCommitParser, - AngularParserOptions, +from semantic_release.commit_parser._base import CommitParser, ParserOptions +from semantic_release.commit_parser.token import ( + ParsedCommit, + ParsedMessageResult, + ParseError, + ParseResult, +) +from semantic_release.commit_parser.util import ( + breaking_re, + deep_copy_commit, + force_str, + parse_paragraphs, ) +from semantic_release.enums import LevelBump +from semantic_release.errors import InvalidParserOptions +from semantic_release.globals import logger +from semantic_release.helpers import sort_numerically, text_reducer + +if TYPE_CHECKING: # pragma: no cover + from git.objects.commit import Commit + + +def _logged_parse_error(commit: Commit, error: str) -> ParseError: + logger.debug(error) + return ParseError(commit, error=error) + + +# TODO: Remove from here, allow for user customization instead via options +# types with long names in changelog +LONG_TYPE_NAMES = { + "build": "build system", + "ci": "continuous integration", + "chore": "chores", + "docs": "documentation", + "feat": "features", + "fix": "bug fixes", + "perf": "performance improvements", + "refactor": "refactoring", + "style": "code style", + "test": "testing", +} @dataclass -class ConventionalCommitParserOptions(AngularParserOptions): +class ConventionalCommitParserOptions(ParserOptions): """Options dataclass for the ConventionalCommitParser.""" + minor_tags: Tuple[str, ...] = ("feat",) + """Commit-type prefixes that should result in a minor release bump.""" + + patch_tags: Tuple[str, ...] = ("fix", "perf") + """Commit-type prefixes that should result in a patch release bump.""" + + other_allowed_tags: Tuple[str, ...] = ( + "build", + "chore", + "ci", + "docs", + "style", + "refactor", + "test", + ) + """Commit-type prefixes that are allowed but do not result in a version bump.""" + + allowed_tags: Tuple[str, ...] = ( + *minor_tags, + *patch_tags, + *other_allowed_tags, + ) + """ + All commit-type prefixes that are allowed. + + These are used to identify a valid commit message. If a commit message does not start with + one of these prefixes, it will not be considered a valid commit message. + """ + + default_bump_level: LevelBump = LevelBump.NO_RELEASE + """The minimum bump level to apply to valid commit message.""" + + parse_squash_commits: bool = True + """Toggle flag for whether or not to parse squash commits""" + + ignore_merge_commits: bool = True + """Toggle flag for whether or not to ignore merge commits""" -class ConventionalCommitParser(AngularCommitParser): + @property + def tag_to_level(self) -> dict[str, LevelBump]: + """A mapping of commit tags to the level bump they should result in.""" + return self._tag_to_level + + def __post_init__(self) -> None: + self._tag_to_level: dict[str, LevelBump] = { + str(tag): level + for tag, level in [ + # we have to do a type ignore as zip_longest provides a type that is not specific enough + # for our expected output. Due to the empty second array, we know the first is always longest + # and that means no values in the first entry of the tuples will ever be a LevelBump. We + # apply a str() to make mypy happy although it will never happen. + *zip_longest(self.allowed_tags, (), fillvalue=self.default_bump_level), + *zip_longest(self.patch_tags, (), fillvalue=LevelBump.PATCH), + *zip_longest(self.minor_tags, (), fillvalue=LevelBump.MINOR), + ] + if "|" not in str(tag) + } + + +class ConventionalCommitParser( + CommitParser[ParseResult, ConventionalCommitParserOptions] +): """ A commit parser for projects conforming to the conventional commits specification. @@ -26,6 +131,366 @@ class ConventionalCommitParser(AngularCommitParser): def __init__(self, options: ConventionalCommitParserOptions | None = None) -> None: super().__init__(options) + try: + commit_type_pattern = regexp( + r"(?P%s)" % str.join("|", self.options.allowed_tags) + ) + except re.error as err: + raise InvalidParserOptions( + str.join( + "\n", + [ + f"Invalid options for {self.__class__.__name__}", + "Unable to create regular expression from configured commit-types.", + "Please check the configured commit-types and remove or escape any regular expression characters.", + ], + ) + ) from err + + self.commit_subject = regexp( + str.join( + "", + [ + f"^{commit_type_pattern.pattern}", + r"(?:\((?P[^\n]+)\))?", + r"(?P!)?:\s+", + r"(?P[^\n]+)", + ], + ) + ) + + self.commit_msg_pattern = regexp( + str.join( + "", + [ + self.commit_subject.pattern, + r"(?:\n\n(?P.+))?", # commit body + ], + ), + flags=re.DOTALL, + ) + + # GitHub & Gitea use (#123), GitLab uses (!123), and BitBucket uses (pull request #123) + self.mr_selector = regexp( + r"[\t ]+\((?:pull request )?(?P[#!]\d+)\)[\t ]*$" + ) + self.issue_selector = regexp( + str.join( + "", + [ + r"^(?:clos(?:e|es|ed|ing)|fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|implement(?:s|ed|ing)?):", + r"[\t ]+(?P.+)[\t ]*$", + ], + ), + flags=re.MULTILINE | re.IGNORECASE, + ) + self.notice_selector = regexp(r"^NOTICE: (?P.+)$") + self.filters = { + "typo-extra-spaces": (regexp(r"(\S) +(\S)"), r"\1 \2"), + "git-header-commit": ( + regexp(r"^[\t ]*commit [0-9a-f]+$\n?", flags=re.MULTILINE), + "", + ), + "git-header-author": ( + regexp(r"^[\t ]*Author: .+$\n?", flags=re.MULTILINE), + "", + ), + "git-header-date": ( + regexp(r"^[\t ]*Date: .+$\n?", flags=re.MULTILINE), + "", + ), + "git-squash-heading": ( + regexp( + r"^[\t ]*Squashed commit of the following:.*$\n?", + flags=re.MULTILINE, + ), + "", + ), + "git-squash-commit-prefix": ( + regexp( + str.join( + "", + [ + r"^(?:[\t ]*[*-][\t ]+|[\t ]+)?", # bullet points or indentation + commit_type_pattern.pattern + r"\b", # prior to commit type + ], + ), + flags=re.MULTILINE, + ), + # move commit type to the start of the line + r"\1", + ), + } + @staticmethod def get_default_options() -> ConventionalCommitParserOptions: return ConventionalCommitParserOptions() + + def commit_body_components_separator( + self, accumulator: dict[str, list[str]], text: str + ) -> dict[str, list[str]]: + if (match := breaking_re.match(text)) and (brk_desc := match.group(1)): + accumulator["breaking_descriptions"].append(brk_desc) + return accumulator + + if (match := self.notice_selector.match(text)) and ( + notice := match.group("notice") + ): + accumulator["notices"].append(notice) + return accumulator + + if match := self.issue_selector.search(text): + # if match := self.issue_selector.search(text): + predicate = regexp(r",? and | *[,;/& ] *").sub( + ",", match.group("issue_predicate") or "" + ) + # Almost all issue trackers use a number to reference an issue so + # we use a simple regexp to validate the existence of a number which helps filter out + # any non-issue references that don't fit our expected format + has_number = regexp(r"\d+") + new_issue_refs: set[str] = set( + filter( + lambda issue_str, validator=has_number: validator.search(issue_str), # type: ignore[arg-type] + predicate.split(","), + ) + ) + if new_issue_refs: + accumulator["linked_issues"] = sort_numerically( + set(accumulator["linked_issues"]).union(new_issue_refs) + ) + return accumulator + + # Prevent appending duplicate descriptions + if text not in accumulator["descriptions"]: + accumulator["descriptions"].append(text) + + return accumulator + + def parse_message(self, message: str) -> ParsedMessageResult | None: + if not (parsed := self.commit_msg_pattern.match(message)): + return None + + parsed_break = parsed.group("break") + parsed_scope = parsed.group("scope") or "" + parsed_subject = parsed.group("subject") + parsed_text = parsed.group("text") + parsed_type = parsed.group("type") + + linked_merge_request = "" + if mr_match := self.mr_selector.search(parsed_subject): + linked_merge_request = mr_match.group("mr_number") + parsed_subject = self.mr_selector.sub("", parsed_subject).strip() + + body_components: dict[str, list[str]] = reduce( + self.commit_body_components_separator, + [ + # Insert the subject before the other paragraphs + parsed_subject, + *parse_paragraphs(parsed_text or ""), + ], + { + "breaking_descriptions": [], + "descriptions": [], + "notices": [], + "linked_issues": [], + }, + ) + + level_bump = ( + LevelBump.MAJOR + if body_components["breaking_descriptions"] or parsed_break + else self.options.tag_to_level.get( + parsed_type, self.options.default_bump_level + ) + ) + + return ParsedMessageResult( + bump=level_bump, + type=parsed_type, + category=LONG_TYPE_NAMES.get(parsed_type, parsed_type), + scope=parsed_scope, + descriptions=tuple(body_components["descriptions"]), + breaking_descriptions=tuple(body_components["breaking_descriptions"]), + release_notices=tuple(body_components["notices"]), + linked_issues=tuple(body_components["linked_issues"]), + linked_merge_request=linked_merge_request, + ) + + @staticmethod + def is_merge_commit(commit: Commit) -> bool: + return len(commit.parents) > 1 + + def parse_commit(self, commit: Commit) -> ParseResult: + if not (parsed_msg_result := self.parse_message(force_str(commit.message))): + return _logged_parse_error( + commit, + f"Unable to parse commit message: {commit.message!r}", + ) + + return ParsedCommit.from_parsed_message_result(commit, parsed_msg_result) + + # Maybe this can be cached as an optimization, similar to how + # mypy/pytest use their own caching directories, for very large commit + # histories? + # The problem is the cache likely won't be present in CI environments + def parse(self, commit: Commit) -> ParseResult | list[ParseResult]: + """ + Parse a commit message + + If the commit message is a squashed merge commit, it will be split into + multiple commits, each of which will be parsed separately. Single commits + will be returned as a list of a single ParseResult. + """ + if self.options.ignore_merge_commits and self.is_merge_commit(commit): + return _logged_parse_error( + commit, "Ignoring merge commit: %s" % commit.hexsha[:8] + ) + + separate_commits: list[Commit] = ( + self.unsquash_commit(commit) + if self.options.parse_squash_commits + else [commit] + ) + + # Parse each commit individually if there were more than one + parsed_commits: list[ParseResult] = list( + map(self.parse_commit, separate_commits) + ) + + def add_linked_merge_request( + parsed_result: ParseResult, mr_number: str + ) -> ParseResult: + return ( + parsed_result + if not isinstance(parsed_result, ParsedCommit) + else ParsedCommit( + **{ + **parsed_result._asdict(), + "linked_merge_request": mr_number, + } + ) + ) + + # TODO: improve this for other VCS systems other than GitHub & BitBucket + # Github works as the first commit in a squash merge commit has the PR number + # appended to the first line of the commit message + lead_commit = next(iter(parsed_commits)) + + if isinstance(lead_commit, ParsedCommit) and lead_commit.linked_merge_request: + # If the first commit has linked merge requests, assume all commits + # are part of the same PR and add the linked merge requests to all + # parsed commits + parsed_commits = [ + lead_commit, + *map( + lambda parsed_result, mr=lead_commit.linked_merge_request: ( # type: ignore[misc] + add_linked_merge_request(parsed_result, mr) + ), + parsed_commits[1:], + ), + ] + + elif isinstance(lead_commit, ParseError) and ( + mr_match := self.mr_selector.search(force_str(lead_commit.message)) + ): + # Handle BitBucket Squash Merge Commits (see #1085), which have non angular commit + # format but include the PR number in the commit subject that we want to extract + linked_merge_request = mr_match.group("mr_number") + + # apply the linked MR to all commits + parsed_commits = [ + add_linked_merge_request(parsed_result, linked_merge_request) + for parsed_result in parsed_commits + ] + + return parsed_commits + + def unsquash_commit(self, commit: Commit) -> list[Commit]: + # GitHub EXAMPLE: + # feat(changelog): add autofit_text_width filter to template environment (#1062) + # + # This change adds an equivalent style formatter that can apply a text alignment + # to a maximum width and also maintain an indent over paragraphs of text + # + # * docs(changelog-templates): add definition & usage of autofit_text_width template filter + # + # * test(changelog-context): add test cases to check autofit_text_width filter use + # + # `git merge --squash` EXAMPLE: + # Squashed commit of the following: + # + # commit 63ec09b9e844e616dcaa7bae35a0b66671b59fbb + # Author: codejedi365 + # Date: Sun Oct 13 12:05:23 2024 -0600 + # + # feat(release-config): some commit subject + # + + # Return a list of artificial commits (each with a single commit message) + return [ + # create a artificial commit object (copy of original but with modified message) + Commit( + **{ + **deep_copy_commit(commit), + "message": commit_msg, + } + ) + for commit_msg in self.unsquash_commit_message(force_str(commit.message)) + ] or [commit] + + def unsquash_commit_message(self, message: str) -> list[str]: + normalized_message = message.replace("\r", "").strip() + + # split by obvious separate commits (applies to manual git squash merges) + obvious_squashed_commits = self.filters["git-header-commit"][0].split( + normalized_message + ) + + separate_commit_msgs: list[str] = reduce( + lambda all_msgs, msgs: all_msgs + msgs, + map(self._find_squashed_commits_in_str, obvious_squashed_commits), + [], + ) + + return list(filter(None, separate_commit_msgs)) + + def _find_squashed_commits_in_str(self, message: str) -> list[str]: + separate_commit_msgs: list[str] = [] + current_msg = "" + + for paragraph in filter(None, message.strip().split("\n\n")): + # Apply filters to normalize the paragraph + clean_paragraph = reduce(text_reducer, self.filters.values(), paragraph) + + # remove any filtered (and now empty) paragraphs (ie. the git headers) + if not clean_paragraph.strip(): + continue + + # Check if the paragraph is the start of a new conventional commit + # Note: that we check that the subject has more than one word to differentiate from + # a closing footer (e.g. "fix: #123", or "fix: ABC-123") + if (match := self.commit_subject.search(clean_paragraph)) and len( + match.group("subject").split(" ") + ) > 1: + # Since we found the start of the new commit, store any previous commit + # message separately and start the new commit message + if current_msg: + separate_commit_msgs.append(current_msg) + + current_msg = clean_paragraph + continue + + if not separate_commit_msgs and not current_msg: + # if there are no separate commit messages and no current message + # then this is the first commit message + current_msg = dedent(clean_paragraph) + continue + + # append the paragraph as part of the previous commit message + if current_msg: + current_msg += f"\n\n{dedent(clean_paragraph)}" + + # else: drop the paragraph + continue + + return [*separate_commit_msgs, current_msg] diff --git a/src/semantic_release/commit_parser/emoji.py b/src/semantic_release/commit_parser/emoji.py index bb830b395..801160208 100644 --- a/src/semantic_release/commit_parser/emoji.py +++ b/src/semantic_release/commit_parser/emoji.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging import re from functools import reduce from itertools import zip_longest @@ -27,10 +26,9 @@ ) from semantic_release.enums import LevelBump from semantic_release.errors import InvalidParserOptions +from semantic_release.globals import logger from semantic_release.helpers import sort_numerically, text_reducer -logger = logging.getLogger(__name__) - @dataclass class EmojiParserOptions(ParserOptions): @@ -94,12 +92,10 @@ class EmojiParserOptions(ParserOptions): a whitespace separator. """ - # TODO: breaking change v10, change default to True - parse_squash_commits: bool = False + parse_squash_commits: bool = True """Toggle flag for whether or not to parse squash commits""" - # TODO: breaking change v10, change default to True - ignore_merge_commits: bool = False + ignore_merge_commits: bool = True """Toggle flag for whether or not to ignore merge commits""" @property @@ -239,10 +235,9 @@ def commit_body_components_separator( notice := match.group("notice") ): accumulator["notices"].append(notice) - # TODO: breaking change v10, removes notice footers from descriptions - # return accumulator + return accumulator - elif self.options.parse_linked_issues and ( + if self.options.parse_linked_issues and ( match := self.issue_selector.search(text) ): predicate = regexp(r",? and | *[,;/& ] *").sub( @@ -262,8 +257,7 @@ def commit_body_components_separator( accumulator["linked_issues"] = sort_numerically( set(accumulator["linked_issues"]).union(new_issue_refs) ) - # TODO: breaking change v10, removes resolution footers from descriptions - # return accumulator + return accumulator # Prevent appending duplicate descriptions if text not in accumulator["descriptions"]: @@ -272,14 +266,14 @@ def commit_body_components_separator( return accumulator def parse_message(self, message: str) -> ParsedMessageResult: - subject = message.split("\n", maxsplit=1)[0] + msg_parts = message.split("\n", maxsplit=1) + subject = msg_parts[0] + msg_body = msg_parts[1] if len(msg_parts) > 1 else "" linked_merge_request = "" if mr_match := self.mr_selector.search(subject): linked_merge_request = mr_match.group("mr_number") - # TODO: breaking change v10, removes PR number from subject/descriptions - # expects changelog template to format the line accordingly - # subject = self.mr_selector.sub("", subject).strip() + subject = self.mr_selector.sub("", subject).strip() # Search for emoji of the highest importance in the subject match = self.emoji_selector.search(subject) @@ -293,7 +287,10 @@ def parse_message(self, message: str) -> ParsedMessageResult: # All emojis will remain part of the returned description body_components: dict[str, list[str]] = reduce( self.commit_body_components_separator, - parse_paragraphs(message), + [ + subject, + *parse_paragraphs(msg_body), + ], { "descriptions": [], "notices": [], @@ -308,11 +305,9 @@ def parse_message(self, message: str) -> ParsedMessageResult: type=primary_emoji, category=primary_emoji, scope=parsed_scope, - # TODO: breaking change v10, removes breaking change footers from descriptions - # descriptions=( - # descriptions[:1] if level_bump is LevelBump.MAJOR else descriptions - # ) - descriptions=descriptions, + descriptions=( + descriptions[:1] if level_bump is LevelBump.MAJOR else descriptions + ), breaking_descriptions=( descriptions[1:] if level_bump is LevelBump.MAJOR else () ), @@ -450,7 +445,7 @@ def _find_squashed_commits_in_str(self, message: str) -> list[str]: if not clean_paragraph.strip(): continue - # Check if the paragraph is the start of a new angular commit + # Check if the paragraph is the start of a new emoji commit if not self.emoji_selector.search(clean_paragraph): if not separate_commit_msgs and not current_msg: # if there are no separate commit messages and no current message diff --git a/src/semantic_release/commit_parser/scipy.py b/src/semantic_release/commit_parser/scipy.py index 6234cfdf3..7e0e6b246 100644 --- a/src/semantic_release/commit_parser/scipy.py +++ b/src/semantic_release/commit_parser/scipy.py @@ -46,26 +46,36 @@ from __future__ import annotations -import logging +import re +from functools import reduce +from itertools import zip_longest +from re import compile as regexp +from textwrap import dedent from typing import TYPE_CHECKING, Tuple +from git.objects.commit import Commit from pydantic.dataclasses import dataclass -from semantic_release.commit_parser.angular import ( - AngularCommitParser, - AngularParserOptions, -) +from semantic_release.commit_parser._base import CommitParser, ParserOptions from semantic_release.commit_parser.token import ( + ParsedCommit, ParsedMessageResult, ParseError, + ParseResult, +) +from semantic_release.commit_parser.util import ( + deep_copy_commit, + force_str, + parse_paragraphs, ) from semantic_release.enums import LevelBump +from semantic_release.errors import InvalidParserOptions +from semantic_release.globals import logger +from semantic_release.helpers import sort_numerically, text_reducer if TYPE_CHECKING: # pragma: no cover from git.objects.commit import Commit -logger = logging.getLogger(__name__) - def _logged_parse_error(commit: Commit, error: str) -> ParseError: logger.debug(error) @@ -93,7 +103,7 @@ def _logged_parse_error(commit: Commit, error: str) -> ParseError: @dataclass -class ScipyParserOptions(AngularParserOptions): +class ScipyParserOptions(ParserOptions): """ Options dataclass for ScipyCommitParser @@ -101,19 +111,18 @@ class ScipyParserOptions(AngularParserOptions): just with different tag names. """ - major_tags: Tuple[str, ...] = ("API",) + major_tags: Tuple[str, ...] = ("API", "DEP") """Commit-type prefixes that should result in a major release bump.""" - minor_tags: Tuple[str, ...] = ("DEP", "DEV", "ENH", "REV", "FEAT") + minor_tags: Tuple[str, ...] = ("ENH", "FEAT") """Commit-type prefixes that should result in a minor release bump.""" patch_tags: Tuple[str, ...] = ("BLD", "BUG", "MAINT") """Commit-type prefixes that should result in a patch release bump.""" - allowed_tags: Tuple[str, ...] = ( - *major_tags, - *minor_tags, - *patch_tags, + other_allowed_tags: Tuple[str, ...] = ( + # "REV", # Revert commits are NOT Currently Supported + "DEV", "BENCH", "DOC", "STY", @@ -121,6 +130,14 @@ class ScipyParserOptions(AngularParserOptions): "REL", "TEST", ) + """Commit-type prefixes that are allowed but do not result in a version bump.""" + + allowed_tags: Tuple[str, ...] = ( + *major_tags, + *minor_tags, + *patch_tags, + *other_allowed_tags, + ) """ All commit-type prefixes that are allowed. @@ -132,15 +149,37 @@ class ScipyParserOptions(AngularParserOptions): default_level_bump: LevelBump = LevelBump.NO_RELEASE """The minimum bump level to apply to valid commit message.""" + parse_squash_commits: bool = True + """Toggle flag for whether or not to parse squash commits""" + + ignore_merge_commits: bool = True + """Toggle flag for whether or not to ignore merge commits""" + + @property + def tag_to_level(self) -> dict[str, LevelBump]: + """A mapping of commit tags to the level bump they should result in.""" + return self._tag_to_level + def __post_init__(self) -> None: # TODO: breaking v10, remove as the name is now consistent self.default_bump_level = self.default_level_bump - super().__post_init__() - for tag in self.major_tags: - self._tag_to_level[tag] = LevelBump.MAJOR - - -class ScipyCommitParser(AngularCommitParser): + self._tag_to_level: dict[str, LevelBump] = { + str(tag): level + for tag, level in [ + # we have to do a type ignore as zip_longest provides a type that is not specific enough + # for our expected output. Due to the empty second array, we know the first is always longest + # and that means no values in the first entry of the tuples will ever be a LevelBump. We + # apply a str() to make mypy happy although it will never happen. + *zip_longest(self.allowed_tags, (), fillvalue=self.default_bump_level), + *zip_longest(self.patch_tags, (), fillvalue=LevelBump.PATCH), + *zip_longest(self.minor_tags, (), fillvalue=LevelBump.MINOR), + *zip_longest(self.major_tags, (), fillvalue=LevelBump.MAJOR), + ] + if "|" not in str(tag) + } + + +class ScipyCommitParser(CommitParser[ParseResult, ScipyParserOptions]): """Parser for scipy-style commit messages""" # TODO: Deprecate in lieu of get_default_options() @@ -149,18 +188,354 @@ class ScipyCommitParser(AngularCommitParser): def __init__(self, options: ScipyParserOptions | None = None) -> None: super().__init__(options) + try: + commit_type_pattern = regexp( + r"(?P%s)" % str.join("|", self.options.allowed_tags) + ) + except re.error as err: + raise InvalidParserOptions( + str.join( + "\n", + [ + f"Invalid options for {self.__class__.__name__}", + "Unable to create regular expression from configured commit-types.", + "Please check the configured commit-types and remove or escape any regular expression characters.", + ], + ) + ) from err + + self.commit_prefix = regexp( + str.join( + "", + [ + f"^{commit_type_pattern.pattern}", + r"(?::[\t ]*(?P[^:\n]+))?", + r":[\t ]+", + ], + ) + ) + + self.commit_msg_pattern = regexp( + str.join( + "", + [ + self.commit_prefix.pattern, + r"(?P[^\n]+)", + r"(?:\n\n(?P.+))?", # commit body + ], + ), + flags=re.DOTALL, + ) + + # GitHub & Gitea use (#123), GitLab uses (!123), and BitBucket uses (pull request #123) + self.mr_selector = regexp( + r"[\t ]+\((?:pull request )?(?P[#!]\d+)\)[\t ]*$" + ) + self.issue_selector = regexp( + str.join( + "", + [ + r"^(?:clos(?:e|es|ed|ing)|fix(?:es|ed|ing)?|resolv(?:e|es|ed|ing)|implement(?:s|ed|ing)?):", + r"[\t ]+(?P.+)[\t ]*$", + ], + ), + flags=re.MULTILINE | re.IGNORECASE, + ) + self.notice_selector = regexp(r"^NOTICE: (?P.+)$") + self.filters = { + "typo-extra-spaces": (regexp(r"(\S) +(\S)"), r"\1 \2"), + "git-header-commit": ( + regexp(r"^[\t ]*commit [0-9a-f]+$\n?", flags=re.MULTILINE), + "", + ), + "git-header-author": ( + regexp(r"^[\t ]*Author: .+$\n?", flags=re.MULTILINE), + "", + ), + "git-header-date": ( + regexp(r"^[\t ]*Date: .+$\n?", flags=re.MULTILINE), + "", + ), + "git-squash-heading": ( + regexp( + r"^[\t ]*Squashed commit of the following:.*$\n?", + flags=re.MULTILINE, + ), + "", + ), + "git-squash-commit-prefix": ( + regexp( + str.join( + "", + [ + r"^(?:[\t ]*[*-][\t ]+|[\t ]+)?", # bullet points or indentation + commit_type_pattern.pattern + r"\b", # prior to commit type + ], + ), + flags=re.MULTILINE, + ), + # move commit type to the start of the line + r"\1", + ), + } + @staticmethod def get_default_options() -> ScipyParserOptions: return ScipyParserOptions() + def commit_body_components_separator( + self, accumulator: dict[str, list[str]], text: str + ) -> dict[str, list[str]]: + if (match := self.notice_selector.match(text)) and ( + notice := match.group("notice") + ): + accumulator["notices"].append(notice) + return accumulator + + if match := self.issue_selector.search(text): + # if match := self.issue_selector.search(text): + predicate = regexp(r",? and | *[,;/& ] *").sub( + ",", match.group("issue_predicate") or "" + ) + # Almost all issue trackers use a number to reference an issue so + # we use a simple regexp to validate the existence of a number which helps filter out + # any non-issue references that don't fit our expected format + has_number = regexp(r"\d+") + new_issue_refs: set[str] = set( + filter( + lambda issue_str, validator=has_number: validator.search(issue_str), # type: ignore[arg-type] + predicate.split(","), + ) + ) + if new_issue_refs: + accumulator["linked_issues"] = sort_numerically( + set(accumulator["linked_issues"]).union(new_issue_refs) + ) + return accumulator + + # Prevent appending duplicate descriptions + if text not in accumulator["descriptions"]: + accumulator["descriptions"].append(text) + + return accumulator + def parse_message(self, message: str) -> ParsedMessageResult | None: - return ( - None - if not (pmsg_result := super().parse_message(message)) - else ParsedMessageResult( + if not (parsed := self.commit_msg_pattern.match(message)): + return None + + parsed_scope = parsed.group("scope") or "" + parsed_subject = parsed.group("subject") + parsed_text = parsed.group("text") + parsed_type = parsed.group("type") + + linked_merge_request = "" + if mr_match := self.mr_selector.search(parsed_subject): + linked_merge_request = mr_match.group("mr_number") + parsed_subject = self.mr_selector.sub("", parsed_subject).strip() + + body_components: dict[str, list[str]] = reduce( + self.commit_body_components_separator, + [ + # Insert the subject before the other paragraphs + parsed_subject, + *parse_paragraphs(parsed_text or ""), + ], + { + "descriptions": [], + "notices": [], + "linked_issues": [], + }, + ) + + level_bump = self.options.tag_to_level.get( + parsed_type, self.options.default_bump_level + ) + + return ParsedMessageResult( + bump=level_bump, + type=parsed_type, + category=tag_to_section.get(parsed_type, "None"), + scope=parsed_scope, + descriptions=tuple( + body_components["descriptions"] + if level_bump != LevelBump.MAJOR + else [parsed_subject] + ), + breaking_descriptions=tuple( + body_components["descriptions"][1:] + if level_bump == LevelBump.MAJOR + else [] + ), + release_notices=tuple(body_components["notices"]), + linked_issues=tuple(body_components["linked_issues"]), + linked_merge_request=linked_merge_request, + ) + + @staticmethod + def is_merge_commit(commit: Commit) -> bool: + return len(commit.parents) > 1 + + def parse_commit(self, commit: Commit) -> ParseResult: + if not (parsed_msg_result := self.parse_message(force_str(commit.message))): + return _logged_parse_error( + commit, + f"Unable to parse commit message: {commit.message!r}", + ) + + return ParsedCommit.from_parsed_message_result(commit, parsed_msg_result) + + def parse(self, commit: Commit) -> ParseResult | list[ParseResult]: + """ + Parse a commit message + + If the commit message is a squashed merge commit, it will be split into + multiple commits, each of which will be parsed separately. Single commits + will be returned as a list of a single ParseResult. + """ + if self.options.ignore_merge_commits and self.is_merge_commit(commit): + return _logged_parse_error( + commit, "Ignoring merge commit: %s" % commit.hexsha[:8] + ) + + separate_commits: list[Commit] = ( + self.unsquash_commit(commit) + if self.options.parse_squash_commits + else [commit] + ) + + # Parse each commit individually if there were more than one + parsed_commits: list[ParseResult] = list( + map(self.parse_commit, separate_commits) + ) + + def add_linked_merge_request( + parsed_result: ParseResult, mr_number: str + ) -> ParseResult: + return ( + parsed_result + if not isinstance(parsed_result, ParsedCommit) + else ParsedCommit( + **{ + **parsed_result._asdict(), + "linked_merge_request": mr_number, + } + ) + ) + + # TODO: improve this for other VCS systems other than GitHub & BitBucket + # Github works as the first commit in a squash merge commit has the PR number + # appended to the first line of the commit message + lead_commit = next(iter(parsed_commits)) + + if isinstance(lead_commit, ParsedCommit) and lead_commit.linked_merge_request: + # If the first commit has linked merge requests, assume all commits + # are part of the same PR and add the linked merge requests to all + # parsed commits + parsed_commits = [ + lead_commit, + *map( + lambda parsed_result, mr=lead_commit.linked_merge_request: ( # type: ignore[misc] + add_linked_merge_request(parsed_result, mr) + ), + parsed_commits[1:], + ), + ] + + elif isinstance(lead_commit, ParseError) and ( + mr_match := self.mr_selector.search(force_str(lead_commit.message)) + ): + # Handle BitBucket Squash Merge Commits (see #1085), which have non angular commit + # format but include the PR number in the commit subject that we want to extract + linked_merge_request = mr_match.group("mr_number") + + # apply the linked MR to all commits + parsed_commits = [ + add_linked_merge_request(parsed_result, linked_merge_request) + for parsed_result in parsed_commits + ] + + return parsed_commits + + def unsquash_commit(self, commit: Commit) -> list[Commit]: + # GitHub EXAMPLE: + # feat(changelog): add autofit_text_width filter to template environment (#1062) + # + # This change adds an equivalent style formatter that can apply a text alignment + # to a maximum width and also maintain an indent over paragraphs of text + # + # * docs(changelog-templates): add definition & usage of autofit_text_width template filter + # + # * test(changelog-context): add test cases to check autofit_text_width filter use + # + # `git merge --squash` EXAMPLE: + # Squashed commit of the following: + # + # commit 63ec09b9e844e616dcaa7bae35a0b66671b59fbb + # Author: codejedi365 + # Date: Sun Oct 13 12:05:23 2024 -0600 + # + # feat(release-config): some commit subject + # + + # Return a list of artificial commits (each with a single commit message) + return [ + # create a artificial commit object (copy of original but with modified message) + Commit( **{ - **pmsg_result._asdict(), - "category": tag_to_section.get(pmsg_result.type, "None"), + **deep_copy_commit(commit), + "message": commit_msg, } ) + for commit_msg in self.unsquash_commit_message(force_str(commit.message)) + ] or [commit] + + def unsquash_commit_message(self, message: str) -> list[str]: + normalized_message = message.replace("\r", "").strip() + + # split by obvious separate commits (applies to manual git squash merges) + obvious_squashed_commits = self.filters["git-header-commit"][0].split( + normalized_message + ) + + separate_commit_msgs: list[str] = reduce( + lambda all_msgs, msgs: all_msgs + msgs, + map(self._find_squashed_commits_in_str, obvious_squashed_commits), + [], ) + + return list(filter(None, separate_commit_msgs)) + + def _find_squashed_commits_in_str(self, message: str) -> list[str]: + separate_commit_msgs: list[str] = [] + current_msg = "" + + for paragraph in filter(None, message.strip().split("\n\n")): + # Apply filters to normalize the paragraph + clean_paragraph = reduce(text_reducer, self.filters.values(), paragraph) + + # remove any filtered (and now empty) paragraphs (ie. the git headers) + if not clean_paragraph.strip(): + continue + + # Check if the paragraph is the start of a new angular commit + if not self.commit_prefix.search(clean_paragraph): + if not separate_commit_msgs and not current_msg: + # if there are no separate commit messages and no current message + # then this is the first commit message + current_msg = dedent(clean_paragraph) + continue + + # append the paragraph as part of the previous commit message + if current_msg: + current_msg += f"\n\n{dedent(clean_paragraph)}" + # else: drop the paragraph + continue + + # Since we found the start of the new commit, store any previous commit + # message separately and start the new commit message + if current_msg: + separate_commit_msgs.append(current_msg) + + current_msg = clean_paragraph + + return [*separate_commit_msgs, current_msg] diff --git a/src/semantic_release/commit_parser/tag.py b/src/semantic_release/commit_parser/tag.py index b9a042cc7..c6c90a936 100644 --- a/src/semantic_release/commit_parser/tag.py +++ b/src/semantic_release/commit_parser/tag.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging import re from git.objects.commit import Commit @@ -12,8 +11,7 @@ from semantic_release.commit_parser.token import ParsedCommit, ParseError, ParseResult from semantic_release.commit_parser.util import breaking_re, parse_paragraphs from semantic_release.enums import LevelBump - -logger = logging.getLogger(__name__) +from semantic_release.globals import logger re_parser = re.compile(r"(?P[^\n]+)" + r"(:?\n\n(?P.+))?", re.DOTALL) diff --git a/src/semantic_release/data/templates/angular/md/.components/changelog_header.md.j2 b/src/semantic_release/data/templates/conventional/md/.components/changelog_header.md.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/md/.components/changelog_header.md.j2 rename to src/semantic_release/data/templates/conventional/md/.components/changelog_header.md.j2 diff --git a/src/semantic_release/data/templates/angular/md/.components/changelog_init.md.j2 b/src/semantic_release/data/templates/conventional/md/.components/changelog_init.md.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/md/.components/changelog_init.md.j2 rename to src/semantic_release/data/templates/conventional/md/.components/changelog_init.md.j2 diff --git a/src/semantic_release/data/templates/angular/md/.components/changelog_update.md.j2 b/src/semantic_release/data/templates/conventional/md/.components/changelog_update.md.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/md/.components/changelog_update.md.j2 rename to src/semantic_release/data/templates/conventional/md/.components/changelog_update.md.j2 diff --git a/src/semantic_release/data/templates/angular/md/.components/changes.md.j2 b/src/semantic_release/data/templates/conventional/md/.components/changes.md.j2 similarity index 92% rename from src/semantic_release/data/templates/angular/md/.components/changes.md.j2 rename to src/semantic_release/data/templates/conventional/md/.components/changes.md.j2 index b2b89ff12..c81b0faa1 100644 --- a/src/semantic_release/data/templates/angular/md/.components/changes.md.j2 +++ b/src/semantic_release/data/templates/conventional/md/.components/changes.md.j2 @@ -46,16 +46,8 @@ EXAMPLE: #}{% set commit_descriptions = [] %}{# #}{% for commit in ns.commits -%}{# # Update the first line with reference links and if commit description - # has more than one line, add the rest of the lines - # NOTE: This is specifically to make sure to not hide contents - # of squash commits (until parse support is added) +%}{# # Add reference links to the commit summary line #}{% set description = "- %s" | format(format_commit_summary_line(commit)) -%}{% if commit.descriptions | length > 1 -%}{% set description = "%s\n\n%s" | format( - description, commit.descriptions[1:] | join("\n\n") - ) -%}{% endif %}{% set description = description | autofit_text_width(max_line_width, hanging_indent) %}{% set _ = commit_descriptions.append(description) %}{% endfor diff --git a/src/semantic_release/data/templates/angular/md/.components/first_release.md.j2 b/src/semantic_release/data/templates/conventional/md/.components/first_release.md.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/md/.components/first_release.md.j2 rename to src/semantic_release/data/templates/conventional/md/.components/first_release.md.j2 diff --git a/src/semantic_release/data/templates/angular/md/.components/macros.md.j2 b/src/semantic_release/data/templates/conventional/md/.components/macros.md.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/md/.components/macros.md.j2 rename to src/semantic_release/data/templates/conventional/md/.components/macros.md.j2 diff --git a/src/semantic_release/data/templates/angular/md/.components/unreleased_changes.md.j2 b/src/semantic_release/data/templates/conventional/md/.components/unreleased_changes.md.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/md/.components/unreleased_changes.md.j2 rename to src/semantic_release/data/templates/conventional/md/.components/unreleased_changes.md.j2 diff --git a/src/semantic_release/data/templates/angular/md/.components/versioned_changes.md.j2 b/src/semantic_release/data/templates/conventional/md/.components/versioned_changes.md.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/md/.components/versioned_changes.md.j2 rename to src/semantic_release/data/templates/conventional/md/.components/versioned_changes.md.j2 diff --git a/src/semantic_release/data/templates/angular/md/.release_notes.md.j2 b/src/semantic_release/data/templates/conventional/md/.release_notes.md.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/md/.release_notes.md.j2 rename to src/semantic_release/data/templates/conventional/md/.release_notes.md.j2 diff --git a/src/semantic_release/data/templates/angular/md/CHANGELOG.md.j2 b/src/semantic_release/data/templates/conventional/md/CHANGELOG.md.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/md/CHANGELOG.md.j2 rename to src/semantic_release/data/templates/conventional/md/CHANGELOG.md.j2 diff --git a/src/semantic_release/data/templates/angular/rst/.components/changelog_header.rst.j2 b/src/semantic_release/data/templates/conventional/rst/.components/changelog_header.rst.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/rst/.components/changelog_header.rst.j2 rename to src/semantic_release/data/templates/conventional/rst/.components/changelog_header.rst.j2 diff --git a/src/semantic_release/data/templates/angular/rst/.components/changelog_init.rst.j2 b/src/semantic_release/data/templates/conventional/rst/.components/changelog_init.rst.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/rst/.components/changelog_init.rst.j2 rename to src/semantic_release/data/templates/conventional/rst/.components/changelog_init.rst.j2 diff --git a/src/semantic_release/data/templates/angular/rst/.components/changelog_update.rst.j2 b/src/semantic_release/data/templates/conventional/rst/.components/changelog_update.rst.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/rst/.components/changelog_update.rst.j2 rename to src/semantic_release/data/templates/conventional/rst/.components/changelog_update.rst.j2 diff --git a/src/semantic_release/data/templates/angular/rst/.components/changes.rst.j2 b/src/semantic_release/data/templates/conventional/rst/.components/changes.rst.j2 similarity index 93% rename from src/semantic_release/data/templates/angular/rst/.components/changes.rst.j2 rename to src/semantic_release/data/templates/conventional/rst/.components/changes.rst.j2 index c6ef1cced..7498aa787 100644 --- a/src/semantic_release/data/templates/angular/rst/.components/changes.rst.j2 +++ b/src/semantic_release/data/templates/conventional/rst/.components/changes.rst.j2 @@ -72,16 +72,8 @@ Additional Release Information %}{% set _ = post_paragraph_links.append(commit_hash_link_reference) %}{# # Generate the commit summary line and format it for RST - # Update the first line with reference links and if commit description - # has more than one line, add the rest of the lines - # NOTE: This is specifically to make sure to not hide contents - # of squash commits (until parse support is added) + # autoformatting the reference links #}{% set description = "* %s" | format(format_commit_summary_line(commit)) -%}{% if commit.descriptions | length > 1 -%}{% set description = "%s\n\n%s" | format( - description, commit.descriptions[1:] | join("\n\n") | trim - ) -%}{% endif %}{% set description = description | convert_md_to_rst %}{% set description = description | autofit_text_width(max_line_width, hanging_indent) %}{% set _ = commit_descriptions.append(description) diff --git a/src/semantic_release/data/templates/angular/rst/.components/first_release.rst.j2 b/src/semantic_release/data/templates/conventional/rst/.components/first_release.rst.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/rst/.components/first_release.rst.j2 rename to src/semantic_release/data/templates/conventional/rst/.components/first_release.rst.j2 diff --git a/src/semantic_release/data/templates/angular/rst/.components/macros.rst.j2 b/src/semantic_release/data/templates/conventional/rst/.components/macros.rst.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/rst/.components/macros.rst.j2 rename to src/semantic_release/data/templates/conventional/rst/.components/macros.rst.j2 diff --git a/src/semantic_release/data/templates/angular/rst/.components/unreleased_changes.rst.j2 b/src/semantic_release/data/templates/conventional/rst/.components/unreleased_changes.rst.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/rst/.components/unreleased_changes.rst.j2 rename to src/semantic_release/data/templates/conventional/rst/.components/unreleased_changes.rst.j2 diff --git a/src/semantic_release/data/templates/angular/rst/.components/versioned_changes.rst.j2 b/src/semantic_release/data/templates/conventional/rst/.components/versioned_changes.rst.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/rst/.components/versioned_changes.rst.j2 rename to src/semantic_release/data/templates/conventional/rst/.components/versioned_changes.rst.j2 diff --git a/src/semantic_release/data/templates/angular/rst/CHANGELOG.rst.j2 b/src/semantic_release/data/templates/conventional/rst/CHANGELOG.rst.j2 similarity index 100% rename from src/semantic_release/data/templates/angular/rst/CHANGELOG.rst.j2 rename to src/semantic_release/data/templates/conventional/rst/CHANGELOG.rst.j2 diff --git a/src/semantic_release/gitproject.py b/src/semantic_release/gitproject.py index ef174d85c..0e4592599 100644 --- a/src/semantic_release/gitproject.py +++ b/src/semantic_release/gitproject.py @@ -4,7 +4,6 @@ from contextlib import nullcontext from datetime import datetime -from logging import getLogger from pathlib import Path from typing import TYPE_CHECKING @@ -19,6 +18,7 @@ GitPushError, GitTagError, ) +from semantic_release.globals import logger if TYPE_CHECKING: # pragma: no cover from contextlib import _GeneratorContextManager @@ -36,7 +36,7 @@ def __init__( credential_masker: MaskingFilter | None = None, ) -> None: self._project_root = Path(directory).resolve() - self._logger = getLogger(__name__) + self._logger = logger self._cred_masker = credential_masker or MaskingFilter() self._commit_author = commit_author diff --git a/src/semantic_release/globals.py b/src/semantic_release/globals.py index a0ac61ddb..deb6af987 100644 --- a/src/semantic_release/globals.py +++ b/src/semantic_release/globals.py @@ -2,7 +2,17 @@ from __future__ import annotations +from logging import getLogger +from typing import TYPE_CHECKING + from semantic_release.enums import SemanticReleaseLogLevels +if TYPE_CHECKING: + from logging import Logger + +# GLOBAL VARIABLES log_level: SemanticReleaseLogLevels = SemanticReleaseLogLevels.WARNING """int: Logging level for semantic-release""" + +logger: Logger = getLogger(__package__) +"""Logger for semantic-release""" diff --git a/src/semantic_release/helpers.py b/src/semantic_release/helpers.py index 5f6723ec4..c50369575 100644 --- a/src/semantic_release/helpers.py +++ b/src/semantic_release/helpers.py @@ -1,7 +1,6 @@ from __future__ import annotations import importlib.util -import logging import os import re import string @@ -12,13 +11,14 @@ from typing import TYPE_CHECKING, Any, Callable, NamedTuple, Sequence, TypeVar from urllib.parse import urlsplit +from semantic_release.globals import logger + if TYPE_CHECKING: # pragma: no cover + from logging import Logger from re import Pattern from typing import Iterable -log = logging.getLogger(__name__) - number_pattern = regexp(r"(?P\S*?)(?P\d[\d,]*)\b") hex_number_pattern = regexp( r"(?P\S*?)(?:0x)?(?P[0-9a-f]+)\b", IGNORECASE @@ -118,7 +118,7 @@ def check_tag_format(tag_format: str) -> None: _FuncType = Callable[..., _R] -def logged_function(logger: logging.Logger) -> Callable[[_FuncType[_R]], _FuncType[_R]]: +def logged_function(logger: Logger) -> Callable[[_FuncType[_R]], _FuncType[_R]]: """ Decorator which adds debug logging of a function's input arguments and return value. @@ -151,7 +151,7 @@ def _wrapper(*args: Any, **kwargs: Any) -> _R: return _logged_function -@logged_function(log) +@logged_function(logger) def dynamic_import(import_path: str) -> Any: """ Dynamically import an object from a conventionally formatted "module:attribute" @@ -175,7 +175,7 @@ def dynamic_import(import_path: str) -> Any: ) if module_path not in sys.modules: - log.debug("Loading '%s' from file '%s'", module_path, module_filepath) + logger.debug("Loading '%s' from file '%s'", module_path, module_filepath) spec = importlib.util.spec_from_file_location( module_path, str(module_filepath) ) @@ -190,9 +190,9 @@ def dynamic_import(import_path: str) -> Any: # Otherwise, import as a module try: - log.debug("Importing module '%s'", module_name) + logger.debug("Importing module '%s'", module_name) module = importlib.import_module(module_name) - log.debug("Loading '%s' from module '%s'", attr, module_name) + logger.debug("Loading '%s' from module '%s'", attr, module_name) return getattr(module, attr) except TypeError as err: raise ImportError( @@ -242,7 +242,7 @@ def parse_git_url(url: str) -> ParsedGitUrl: Raises ValueError if the url can't be parsed. """ - log.debug("Parsing git url %r", url) + logger.debug("Parsing git url %r", url) # Normalizers are a list of tuples of (pattern, replacement) normalizers = [ diff --git a/src/semantic_release/hvcs/_base.py b/src/semantic_release/hvcs/_base.py index 60c6a5f87..fc6668dcd 100644 --- a/src/semantic_release/hvcs/_base.py +++ b/src/semantic_release/hvcs/_base.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging import warnings from abc import ABCMeta, abstractmethod from functools import lru_cache @@ -14,10 +13,6 @@ from typing import Any, Callable -# Globals -logger = logging.getLogger(__name__) - - class HvcsBase(metaclass=ABCMeta): """ Interface for subclasses interacting with a remote vcs environment diff --git a/src/semantic_release/hvcs/bitbucket.py b/src/semantic_release/hvcs/bitbucket.py index e0f9e8656..08c2aa6be 100644 --- a/src/semantic_release/hvcs/bitbucket.py +++ b/src/semantic_release/hvcs/bitbucket.py @@ -5,7 +5,6 @@ from __future__ import annotations -import logging import os from functools import lru_cache from pathlib import PurePosixPath @@ -14,16 +13,13 @@ from urllib3.util.url import Url, parse_url +from semantic_release.globals import logger from semantic_release.hvcs.remote_hvcs_base import RemoteHvcsBase if TYPE_CHECKING: # pragma: no cover from typing import Any, Callable -# Globals -log = logging.getLogger(__name__) - - class Bitbucket(RemoteHvcsBase): """ Bitbucket HVCS interface for interacting with BitBucket repositories @@ -161,7 +157,7 @@ def _derive_api_url_from_base_domain(self) -> Url: def _get_repository_owner_and_name(self) -> tuple[str, str]: # ref: https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets/ if "BITBUCKET_REPO_FULL_NAME" in os.environ: - log.info("Getting repository owner and name from environment variables.") + logger.info("Getting repository owner and name from environment variables.") owner, name = os.environ["BITBUCKET_REPO_FULL_NAME"].rsplit("/", 1) return owner, name diff --git a/src/semantic_release/hvcs/gitea.py b/src/semantic_release/hvcs/gitea.py index c8e241122..994c2459c 100644 --- a/src/semantic_release/hvcs/gitea.py +++ b/src/semantic_release/hvcs/gitea.py @@ -3,7 +3,6 @@ from __future__ import annotations import glob -import logging import os from pathlib import PurePosixPath from re import compile as regexp @@ -18,6 +17,7 @@ IncompleteReleaseError, UnexpectedResponse, ) +from semantic_release.globals import logger from semantic_release.helpers import logged_function from semantic_release.hvcs.remote_hvcs_base import RemoteHvcsBase from semantic_release.hvcs.token_auth import TokenAuth @@ -27,10 +27,6 @@ from typing import Any, Callable -# Globals -log = logging.getLogger(__name__) - - class Gitea(RemoteHvcsBase): """Gitea helper class""" @@ -82,7 +78,7 @@ def __init__( allow_insecure=allow_insecure, ) - @logged_function(log) + @logged_function(logger) def create_release( self, tag: str, @@ -126,7 +122,7 @@ def create_release( ) return -1 - log.info("Creating release for tag %s", tag) + logger.info("Creating release for tag %s", tag) releases_endpoint = self.create_api_url( endpoint=f"/repos/{self.owner}/{self.repo_name}/releases", ) @@ -146,7 +142,7 @@ def create_release( try: release_id: int = response.json()["id"] - log.info("Successfully created release with ID: %s", release_id) + logger.info("Successfully created release with ID: %s", release_id) except JSONDecodeError as err: raise UnexpectedResponse("Unreadable json response") from err except KeyError as err: @@ -154,7 +150,7 @@ def create_release( errors = [] for asset in assets or []: - log.info("Uploading asset %s", asset) + logger.info("Uploading asset %s", asset) try: self.upload_release_asset(release_id, asset) except HTTPError as err: @@ -168,13 +164,13 @@ def create_release( return release_id for error in errors: - log.exception(error) + logger.exception(error) raise IncompleteReleaseError( f"Failed to upload asset{'s' if len(errors) > 1 else ''} to release!" ) - @logged_function(log) + @logged_function(logger) @suppress_not_found def get_release_id_by_tag(self, tag: str) -> int | None: """ @@ -200,7 +196,7 @@ def get_release_id_by_tag(self, tag: str) -> int | None: except KeyError as err: raise UnexpectedResponse("JSON response is missing an id") from err - @logged_function(log) + @logged_function(logger) def edit_release_notes(self, release_id: int, release_notes: str) -> int: """ Edit a release with updated change notes @@ -210,7 +206,7 @@ def edit_release_notes(self, release_id: int, release_notes: str) -> int: :return: The ID of the release that was edited """ - log.info("Updating release %s", release_id) + logger.info("Updating release %s", release_id) release_endpoint = self.create_api_url( endpoint=f"/repos/{self.owner}/{self.repo_name}/releases/{release_id}", ) @@ -225,7 +221,7 @@ def edit_release_notes(self, release_id: int, release_notes: str) -> int: return release_id - @logged_function(log) + @logged_function(logger) def create_or_update_release( self, tag: str, release_notes: str, prerelease: bool = False ) -> int: @@ -236,12 +232,12 @@ def create_or_update_release( :return: The status of the request """ - log.info("Creating release for %s", tag) + logger.info("Creating release for %s", tag) try: return self.create_release(tag, release_notes, prerelease) except HTTPError as err: - log.debug("error creating release: %s", err) - log.debug("looking for an existing release to update") + logger.debug("error creating release: %s", err) + logger.debug("looking for an existing release to update") release_id = self.get_release_id_by_tag(tag) if release_id is None: @@ -250,10 +246,10 @@ def create_or_update_release( ) # If this errors we let it die - log.debug("Found existing release %s, updating", release_id) + logger.debug("Found existing release %s, updating", release_id) return self.edit_release_notes(release_id, release_notes) - @logged_function(log) + @logged_function(logger) def asset_upload_url(self, release_id: str) -> str: """ Get the correct upload url for a release @@ -264,7 +260,7 @@ def asset_upload_url(self, release_id: str) -> str: endpoint=f"/repos/{self.owner}/{self.repo_name}/releases/{release_id}/assets", ) - @logged_function(log) + @logged_function(logger) def upload_release_asset( self, release_id: int, @@ -301,7 +297,7 @@ def upload_release_asset( # Raise an error if the request was not successful response.raise_for_status() - log.info( + logger.info( "Successfully uploaded %s to Gitea, url: %s, status code: %s", file, response.url, @@ -310,7 +306,7 @@ def upload_release_asset( return True - @logged_function(log) + @logged_function(logger) def upload_dists(self, tag: str, dist_glob: str) -> int: """ Upload distributions to a release @@ -322,7 +318,7 @@ def upload_dists(self, tag: str, dist_glob: str) -> int: # Find the release corresponding to this tag release_id = self.get_release_id_by_tag(tag=tag) if not release_id: - log.warning("No release corresponds to tag %s, can't upload dists", tag) + logger.warning("No release corresponds to tag %s, can't upload dists", tag) return 0 # Upload assets @@ -334,7 +330,7 @@ def upload_dists(self, tag: str, dist_glob: str) -> int: self.upload_release_asset(release_id, file_path) n_succeeded += 1 except HTTPError: # noqa: PERF203 - log.exception("error uploading asset %s", file_path) + logger.exception("error uploading asset %s", file_path) return n_succeeded diff --git a/src/semantic_release/hvcs/github.py b/src/semantic_release/hvcs/github.py index 016643267..5ccc9004b 100644 --- a/src/semantic_release/hvcs/github.py +++ b/src/semantic_release/hvcs/github.py @@ -3,7 +3,6 @@ from __future__ import annotations import glob -import logging import mimetypes import os from functools import lru_cache @@ -20,6 +19,7 @@ IncompleteReleaseError, UnexpectedResponse, ) +from semantic_release.globals import logger from semantic_release.helpers import logged_function from semantic_release.hvcs.remote_hvcs_base import RemoteHvcsBase from semantic_release.hvcs.token_auth import TokenAuth @@ -29,10 +29,6 @@ from typing import Any, Callable -# Globals -log = logging.getLogger(__name__) - - # Add a mime type for wheels # Fix incorrect entries in the `mimetypes` registry. # On Windows, the Python standard library's `mimetypes` reads in @@ -200,13 +196,13 @@ def _derive_api_url_from_base_domain(self) -> Url: def _get_repository_owner_and_name(self) -> tuple[str, str]: # Github actions context if "GITHUB_REPOSITORY" in os.environ: - log.debug("getting repository owner and name from environment variables") + logger.debug("getting repository owner and name from environment variables") owner, name = os.environ["GITHUB_REPOSITORY"].rsplit("/", 1) return owner, name return super()._get_repository_owner_and_name() - @logged_function(log) + @logged_function(logger) def create_release( self, tag: str, @@ -253,7 +249,7 @@ def create_release( ) return -1 - log.info("Creating release for tag %s", tag) + logger.info("Creating release for tag %s", tag) releases_endpoint = self.create_api_url( endpoint=f"/repos/{self.owner}/{self.repo_name}/releases", ) @@ -273,7 +269,7 @@ def create_release( try: release_id: int = response.json()["id"] - log.info("Successfully created release with ID: %s", release_id) + logger.info("Successfully created release with ID: %s", release_id) except JSONDecodeError as err: raise UnexpectedResponse("Unreadable json response") from err except KeyError as err: @@ -281,7 +277,7 @@ def create_release( errors = [] for asset in assets or []: - log.info("Uploading asset %s", asset) + logger.info("Uploading asset %s", asset) try: self.upload_release_asset(release_id, asset) except HTTPError as err: @@ -295,13 +291,13 @@ def create_release( return release_id for error in errors: - log.exception(error) + logger.exception(error) raise IncompleteReleaseError( f"Failed to upload asset{'s' if len(errors) > 1 else ''} to release!" ) - @logged_function(log) + @logged_function(logger) @suppress_not_found def get_release_id_by_tag(self, tag: str) -> int | None: """ @@ -326,7 +322,7 @@ def get_release_id_by_tag(self, tag: str) -> int | None: except KeyError as err: raise UnexpectedResponse("JSON response is missing an id") from err - @logged_function(log) + @logged_function(logger) def edit_release_notes(self, release_id: int, release_notes: str) -> int: """ Edit a release with updated change notes @@ -335,7 +331,7 @@ def edit_release_notes(self, release_id: int, release_notes: str) -> int: :param release_notes: The release notes for this version :return: The ID of the release that was edited """ - log.info("Updating release %s", release_id) + logger.info("Updating release %s", release_id) release_endpoint = self.create_api_url( endpoint=f"/repos/{self.owner}/{self.repo_name}/releases/{release_id}", ) @@ -350,7 +346,7 @@ def edit_release_notes(self, release_id: int, release_notes: str) -> int: return release_id - @logged_function(log) + @logged_function(logger) def create_or_update_release( self, tag: str, release_notes: str, prerelease: bool = False ) -> int: @@ -361,12 +357,12 @@ def create_or_update_release( :param prerelease: Whether or not this release should be created as a prerelease :return: The status of the request """ - log.info("Creating release for %s", tag) + logger.info("Creating release for %s", tag) try: return self.create_release(tag, release_notes, prerelease) except HTTPError as err: - log.debug("error creating release: %s", err) - log.debug("looking for an existing release to update") + logger.debug("error creating release: %s", err) + logger.debug("looking for an existing release to update") release_id = self.get_release_id_by_tag(tag) if release_id is None: @@ -374,11 +370,11 @@ def create_or_update_release( f"release id for tag {tag} not found, and could not be created" ) - log.debug("Found existing release %s, updating", release_id) + logger.debug("Found existing release %s, updating", release_id) # If this errors we let it die return self.edit_release_notes(release_id, release_notes) - @logged_function(log) + @logged_function(logger) @suppress_not_found def asset_upload_url(self, release_id: str) -> str | None: """ @@ -405,7 +401,7 @@ def asset_upload_url(self, release_id: str) -> str | None: "JSON response is missing a key 'upload_url'" ) from err - @logged_function(log) + @logged_function(logger) def upload_release_asset( self, release_id: int, file: str, label: str | None = None ) -> bool: @@ -442,7 +438,7 @@ def upload_release_asset( # Raise an error if the upload was unsuccessful response.raise_for_status() - log.debug( + logger.debug( "Successfully uploaded %s to Github, url: %s, status code: %s", file, response.url, @@ -451,7 +447,7 @@ def upload_release_asset( return True - @logged_function(log) + @logged_function(logger) def upload_dists(self, tag: str, dist_glob: str) -> int: """ Upload distributions to a release @@ -462,7 +458,7 @@ def upload_dists(self, tag: str, dist_glob: str) -> int: # Find the release corresponding to this version release_id = self.get_release_id_by_tag(tag=tag) if not release_id: - log.warning("No release corresponds to tag %s, can't upload dists", tag) + logger.warning("No release corresponds to tag %s, can't upload dists", tag) return 0 # Upload assets @@ -474,14 +470,14 @@ def upload_dists(self, tag: str, dist_glob: str) -> int: self.upload_release_asset(release_id, file_path) n_succeeded += 1 except HTTPError: # noqa: PERF203 - log.exception("error uploading asset %s", file_path) + logger.exception("error uploading asset %s", file_path) return n_succeeded def remote_url(self, use_token: bool = True) -> str: """Get the remote url including the token for authentication if requested""" if not (self.token and use_token): - log.info("requested to use token for push but no token set, ignoring...") + logger.info("requested to use token for push but no token set, ignoring...") return self._remote_url actor = os.getenv("GITHUB_ACTOR", None) diff --git a/src/semantic_release/hvcs/gitlab.py b/src/semantic_release/hvcs/gitlab.py index 67b8e7512..198d22e00 100644 --- a/src/semantic_release/hvcs/gitlab.py +++ b/src/semantic_release/hvcs/gitlab.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging import os from functools import lru_cache from pathlib import PurePosixPath @@ -17,6 +16,7 @@ from semantic_release.cli.util import noop_report from semantic_release.errors import UnexpectedResponse +from semantic_release.globals import logger from semantic_release.helpers import logged_function from semantic_release.hvcs.remote_hvcs_base import RemoteHvcsBase from semantic_release.hvcs.util import suppress_not_found @@ -27,13 +27,6 @@ from gitlab.v4.objects import Project as GitLabProject -log = logging.getLogger(__name__) - - -# Globals -log = logging.getLogger(__name__) - - class Gitlab(RemoteHvcsBase): """Gitlab HVCS interface for interacting with Gitlab repositories""" @@ -91,12 +84,12 @@ def _get_repository_owner_and_name(self) -> tuple[str, str]: available, otherwise from parsing the remote url """ if "CI_PROJECT_NAMESPACE" in os.environ and "CI_PROJECT_NAME" in os.environ: - log.debug("getting repository owner and name from environment variables") + logger.debug("getting repository owner and name from environment variables") return os.environ["CI_PROJECT_NAMESPACE"], os.environ["CI_PROJECT_NAME"] return super()._get_repository_owner_and_name() - @logged_function(log) + @logged_function(logger) def create_release( self, tag: str, @@ -112,7 +105,7 @@ def create_release( :param release_notes: The changelog description for this version only :param prerelease: This parameter has no effect in GitLab :param assets: A list of paths to files to upload as assets (TODO: not implemented) - :param noop: If True, do not perform any actions, only log intents + :param noop: If True, do not perform any actions, only logger intents :return: The tag of the release @@ -123,7 +116,7 @@ def create_release( noop_report(f"would have created a release for tag {tag}") return tag - log.info("Creating release for %s", tag) + logger.info("Creating release for %s", tag) # ref: https://docs.gitlab.com/ee/api/releases/index.html#create-a-release self.project.releases.create( { @@ -133,10 +126,10 @@ def create_release( "description": release_notes, } ) - log.info("Successfully created release for %s", tag) + logger.info("Successfully created release for %s", tag) return tag - @logged_function(log) + @logged_function(logger) @suppress_not_found def get_release_by_tag(self, tag: str) -> gitlab.v4.objects.ProjectRelease | None: """ @@ -151,12 +144,12 @@ def get_release_by_tag(self, tag: str) -> gitlab.v4.objects.ProjectRelease | Non try: return self.project.releases.get(tag) except gitlab.exceptions.GitlabGetError: - log.debug("Release %s not found", tag) + logger.debug("Release %s not found", tag) return None except KeyError as err: raise UnexpectedResponse("JSON response is missing commit.id") from err - @logged_function(log) + @logged_function(logger) def edit_release_notes( # type: ignore[override] self, release: gitlab.v4.objects.ProjectRelease, @@ -174,7 +167,7 @@ def edit_release_notes( # type: ignore[override] :raises: GitlabUpdateError: If the server cannot perform the request """ - log.info( + logger.info( "Updating release %s [%s]", release.name, release.attributes.get("commit", {}).get("id"), @@ -183,7 +176,7 @@ def edit_release_notes( # type: ignore[override] release.save() return str(release.get_id()) - @logged_function(log) + @logged_function(logger) def create_or_update_release( self, tag: str, release_notes: str, prerelease: bool = False ) -> str: @@ -205,7 +198,7 @@ def create_or_update_release( tag=tag, release_notes=release_notes, prerelease=prerelease ) except gitlab.GitlabCreateError: - log.info( + logger.info( "New release %s could not be created for project %s", tag, self.project_namespace, @@ -216,7 +209,7 @@ def create_or_update_release( f"release for tag {tag} could not be found, and could not be created" ) - log.debug( + logger.debug( "Found existing release commit %s, updating", release_obj.commit.get("id") ) # If this errors we let it die diff --git a/src/semantic_release/hvcs/remote_hvcs_base.py b/src/semantic_release/hvcs/remote_hvcs_base.py index e7cd93ab3..14e7a5e2f 100644 --- a/src/semantic_release/hvcs/remote_hvcs_base.py +++ b/src/semantic_release/hvcs/remote_hvcs_base.py @@ -2,7 +2,6 @@ from __future__ import annotations -import logging from abc import ABCMeta, abstractmethod from pathlib import PurePosixPath from typing import TYPE_CHECKING @@ -15,10 +14,6 @@ from typing import Any -# Globals -logger = logging.getLogger(__name__) - - class RemoteHvcsBase(HvcsBase, metaclass=ABCMeta): """ Interface for subclasses interacting with a remote VCS @@ -95,10 +90,15 @@ def create_server_url( query: str | None = None, fragment: str | None = None, ) -> str: - # Ensure any path prefix is transfered but not doubled up on the derived url + # Ensure any path prefix is transferred but not doubled up on the derived url + normalized_path = ( + f"{self.hvcs_domain.path}/{path}" + if self.hvcs_domain.path and not path.startswith(self.hvcs_domain.path) + else path + ) return self._derive_url( self.hvcs_domain, - path=f"{self.hvcs_domain.path or ''}/{path.lstrip(self.hvcs_domain.path)}", + path=normalized_path, auth=auth, query=query, fragment=fragment, @@ -123,10 +123,15 @@ def create_api_url( query: str | None = None, fragment: str | None = None, ) -> str: - # Ensure any api path prefix is transfered but not doubled up on the derived api url + # Ensure any api path prefix is transferred but not doubled up on the derived api url + normalized_endpoint = ( + f"{self.api_url.path}/{endpoint}" + if self.api_url.path and not endpoint.startswith(self.api_url.path) + else endpoint + ) return self._derive_url( self.api_url, - path=f"{self.api_url.path or ''}/{endpoint.lstrip(self.api_url.path)}", + path=normalized_endpoint, auth=auth, query=query, fragment=fragment, diff --git a/src/semantic_release/hvcs/util.py b/src/semantic_release/hvcs/util.py index f54e08b6b..e125cf638 100644 --- a/src/semantic_release/hvcs/util.py +++ b/src/semantic_release/hvcs/util.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging from functools import wraps from typing import TYPE_CHECKING, Any, Callable, TypeVar @@ -8,11 +7,11 @@ from requests.adapters import HTTPAdapter from requests.packages.urllib3.util.retry import Retry # type: ignore[import] +from semantic_release.globals import logger + if TYPE_CHECKING: # pragma: no cover from semantic_release.hvcs.token_auth import TokenAuth -logger = logging.getLogger(__name__) - def build_requests_session( raise_for_status: bool = True, diff --git a/src/semantic_release/version/algorithm.py b/src/semantic_release/version/algorithm.py index c738e6581..fa24e3fa1 100644 --- a/src/semantic_release/version/algorithm.py +++ b/src/semantic_release/version/algorithm.py @@ -11,6 +11,7 @@ from semantic_release.const import DEFAULT_VERSION from semantic_release.enums import LevelBump, SemanticReleaseLogLevels from semantic_release.errors import InternalError, InvalidVersion +from semantic_release.globals import logger from semantic_release.helpers import validate_types_in_sequence if TYPE_CHECKING: # pragma: no cover @@ -29,9 +30,6 @@ from semantic_release.version.version import Version -logger = logging.getLogger(__name__) - - def tags_and_versions( tags: Iterable[Tag], translator: VersionTranslator ) -> list[tuple[Tag, Version]]: @@ -247,9 +245,9 @@ def next_version( repo: Repo, translator: VersionTranslator, commit_parser: CommitParser[ParseResult, ParserOptions], + allow_zero_version: bool, + major_on_zero: bool, prerelease: bool = False, - major_on_zero: bool = True, - allow_zero_version: bool = True, ) -> Version: """ Evaluate the history within `repo`, and based on the tags and commits in the repo diff --git a/src/semantic_release/version/declaration.py b/src/semantic_release/version/declaration.py index 3c225d1b5..e0400f3df 100644 --- a/src/semantic_release/version/declaration.py +++ b/src/semantic_release/version/declaration.py @@ -1,13 +1,13 @@ from __future__ import annotations -# TODO: Remove v10 +# TODO: Remove v11 from abc import ABC, abstractmethod -from logging import getLogger from pathlib import Path from typing import TYPE_CHECKING from deprecated.sphinx import deprecated +from semantic_release.globals import logger from semantic_release.version.declarations.enum import VersionStampType from semantic_release.version.declarations.i_version_replacer import IVersionReplacer from semantic_release.version.declarations.pattern import PatternVersionDeclaration @@ -25,7 +25,6 @@ "TomlVersionDeclaration", "VersionDeclarationABC", ] -log = getLogger(__name__) @deprecated( @@ -58,7 +57,7 @@ def content(self) -> str: is cached in the instance variable _content """ if self._content is None: - log.debug( + logger.debug( "No content stored, reading from source file %s", self.path.resolve() ) self._content = self.path.read_text() @@ -66,7 +65,7 @@ def content(self) -> str: @content.deleter def content(self) -> None: - log.debug("resetting instance-stored source file contents") + logger.debug("resetting instance-stored source file contents") self._content = None @abstractmethod @@ -102,6 +101,6 @@ def write(self, content: str) -> None: >>> vd = MyVD("path", r"__version__ = (?P\d+\d+\d+)") >>> vd.write(vd.replace(new_version)) """ - log.debug("writing content to %r", self.path.resolve()) + logger.debug("writing content to %r", self.path.resolve()) self.path.write_text(content) self._content = None diff --git a/src/semantic_release/version/declarations/pattern.py b/src/semantic_release/version/declarations/pattern.py index 55873ce0a..f08c208a4 100644 --- a/src/semantic_release/version/declarations/pattern.py +++ b/src/semantic_release/version/declarations/pattern.py @@ -1,6 +1,5 @@ from __future__ import annotations -from logging import getLogger from pathlib import Path from re import ( MULTILINE, @@ -14,6 +13,7 @@ from semantic_release.cli.util import noop_report from semantic_release.const import SEMVER_REGEX +from semantic_release.globals import logger from semantic_release.version.declarations.enum import VersionStampType from semantic_release.version.declarations.i_version_replacer import IVersionReplacer from semantic_release.version.version import Version @@ -22,9 +22,6 @@ from re import Match -log = getLogger(__name__) - - class VersionSwapper: """Callable to replace a version number in a string with a new version number.""" @@ -78,7 +75,7 @@ def __init__( def content(self) -> str: """A cached property that stores the content of the configured source file.""" if self._content is None: - log.debug("No content stored, reading from source file %s", self._path) + logger.debug("No content stored, reading from source file %s", self._path) if not self._path.exists(): raise FileNotFoundError(f"path {self._path!r} does not exist") @@ -109,7 +106,7 @@ def parse(self) -> set[Version]: # pragma: no cover for m in self._search_pattern.finditer(self.content) } - log.debug( + logger.debug( "Parsing current version: path=%r pattern=%r num_matches=%s", self._path.resolve(), self._search_pattern, @@ -136,7 +133,7 @@ def replace(self, new_version: Version) -> str: self.content, ) - log.debug( + logger.debug( "path=%r pattern=%r num_matches=%r", self._path, self._search_pattern, @@ -230,9 +227,9 @@ def from_string_definition( # Supports optional matching quotations around variable name # Negative lookbehind to ensure we don't match part of a variable name f"""(?x)(?P['"])?(?['"])?{value_replace_pattern_str}(?P=quote2)?""", ], diff --git a/src/semantic_release/version/declarations/toml.py b/src/semantic_release/version/declarations/toml.py index ed9542870..59e6996ac 100644 --- a/src/semantic_release/version/declarations/toml.py +++ b/src/semantic_release/version/declarations/toml.py @@ -1,6 +1,5 @@ from __future__ import annotations -from logging import getLogger from pathlib import Path from typing import Any, Dict, cast @@ -9,13 +8,11 @@ from dotty_dict import Dotty from semantic_release.cli.util import noop_report +from semantic_release.globals import logger from semantic_release.version.declarations.enum import VersionStampType from semantic_release.version.declarations.i_version_replacer import IVersionReplacer from semantic_release.version.version import Version -# globals -log = getLogger(__name__) - class TomlVersionDeclaration(IVersionReplacer): def __init__( @@ -30,7 +27,7 @@ def __init__( def content(self) -> str: """A cached property that stores the content of the configured source file.""" if self._content is None: - log.debug("No content stored, reading from source file %s", self._path) + logger.debug("No content stored, reading from source file %s", self._path) if not self._path.exists(): raise FileNotFoundError(f"path {self._path!r} does not exist") @@ -52,7 +49,7 @@ def parse(self) -> set[Version]: # pragma: no cover content = self._load() maybe_version: str = content.get(self._search_text) # type: ignore[return-value] if maybe_version is not None: - log.debug( + logger.debug( "Found a key %r that looks like a version (%r)", self._search_text, maybe_version, @@ -69,7 +66,7 @@ def replace(self, new_version: Version) -> str: """ content = self._load() if self._search_text in content: - log.info( + logger.info( "found %r in source file contents, replacing with %s", self._search_text, new_version, diff --git a/src/semantic_release/version/translator.py b/src/semantic_release/version/translator.py index 7a4ce275f..6340701da 100644 --- a/src/semantic_release/version/translator.py +++ b/src/semantic_release/version/translator.py @@ -1,14 +1,12 @@ from __future__ import annotations -import logging import re from semantic_release.const import SEMVER_REGEX +from semantic_release.globals import logger from semantic_release.helpers import check_tag_format from semantic_release.version.version import Version -log = logging.getLogger(__name__) - class VersionTranslator: """ @@ -37,7 +35,7 @@ def _invert_tag_format_to_re(cls, tag_format: str) -> re.Pattern[str]: tag_format.replace(r"{version}", r"(?P.*)"), flags=re.VERBOSE, ) - log.debug("inverted tag_format %r to %r", tag_format, pat.pattern) + logger.debug("inverted tag_format %r to %r", tag_format, pat.pattern) return pat def __init__( diff --git a/src/semantic_release/version/version.py b/src/semantic_release/version/version.py index 41ec5e107..032596e4a 100644 --- a/src/semantic_release/version/version.py +++ b/src/semantic_release/version/version.py @@ -1,6 +1,5 @@ from __future__ import annotations -import logging import re from functools import wraps from itertools import zip_longest @@ -9,11 +8,9 @@ from semantic_release.const import SEMVER_REGEX from semantic_release.enums import LevelBump from semantic_release.errors import InvalidVersion +from semantic_release.globals import logger from semantic_release.helpers import check_tag_format -log = logging.getLogger(__name__) - - # Very heavily inspired by semver.version:_comparator, I don't think there's # a cleaner way to do this # https://github.com/python-semver/python-semver/blob/b5317af9a7e99e6a86df98320e73be72d5adf0de/src/semver/version.py#L32 @@ -116,7 +113,7 @@ def parse( if not isinstance(version_str, str): raise InvalidVersion(f"{version_str!r} cannot be parsed as a Version") - log.debug("attempting to parse string %r as Version", version_str) + logger.debug("attempting to parse string %r as Version", version_str) match = cls._VERSION_REGEX.fullmatch(version_str) if not match: raise InvalidVersion(f"{version_str!r} is not a valid Version") @@ -131,7 +128,7 @@ def parse( r"'1.2.3-my-custom-3rc.4'." ) prerelease_token, prerelease_revision = pm.groups() - log.debug( + logger.debug( "parsed prerelease_token %s, prerelease_revision %s from version " "string %s", prerelease_token, @@ -140,10 +137,10 @@ def parse( ) else: prerelease_revision = None - log.debug("version string %s parsed as a non-prerelease", version_str) + logger.debug("version string %s parsed as a non-prerelease", version_str) build_metadata = match.group("buildmetadata") or "" - log.debug( + logger.debug( "parsed build metadata %r from version string %s", build_metadata, version_str, @@ -218,7 +215,7 @@ def bump(self, level: LevelBump) -> Version: if type(level) != LevelBump: raise TypeError(f"Unexpected level {level!r}: expected {LevelBump!r}") - log.debug("performing a %s level bump", level) + logger.debug("performing a %s level bump", level) if level is LevelBump.MAJOR: return Version( self.major + 1, diff --git a/tests/conftest.py b/tests/conftest.py index 933a0cfd1..16298e98b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -10,6 +10,7 @@ from pathlib import Path from tempfile import NamedTemporaryFile from typing import TYPE_CHECKING +from unittest import mock import pytest from click.testing import CliRunner @@ -24,11 +25,36 @@ from tempfile import _TemporaryFileWrapper from typing import Any, Callable, Generator, Protocol, Sequence, TypedDict + from click.testing import Result from filelock import AcquireReturnProxy from git import Actor from tests.fixtures.git_repo import RepoActions + class RunCliFn(Protocol): + """ + Run the CLI with the provided arguments and a clean environment. + + :param argv: The arguments to pass to the CLI. + :type argv: list[str] | None + + :param env: The environment variables to set for the CLI. + :type env: dict[str, str] | None + + :param invoke_kwargs: Additional arguments to pass to the invoke method. + :type invoke_kwargs: dict[str, Any] | None + + :return: The result of the CLI invocation. + :rtype: Result + """ + + def __call__( + self, + argv: list[str] | None = None, + env: dict[str, str] | None = None, + invoke_kwargs: dict[str, Any] | None = None, + ) -> Result: ... + class MakeCommitObjFn(Protocol): def __call__(self, message: str) -> Commit: ... @@ -170,6 +196,25 @@ def cli_runner() -> CliRunner: return CliRunner(mix_stderr=False) +@pytest.fixture(scope="session") +def run_cli(clean_os_environment: dict[str, str]) -> RunCliFn: + def _run_cli( + argv: list[str] | None = None, + env: dict[str, str] | None = None, + invoke_kwargs: dict[str, Any] | None = None, + ) -> Result: + from semantic_release.cli.commands.main import main + + cli_runner = CliRunner(mix_stderr=False) + env_vars = {**clean_os_environment, **(env or {})} + + with mock.patch.dict(os.environ, env_vars, clear=True): + # run the CLI with the provided arguments + return cli_runner.invoke(main, args=(argv or []), **(invoke_kwargs or {})) + + return _run_cli + + @pytest.fixture(scope="session") def default_netrc_username() -> str: return "username" @@ -396,6 +441,7 @@ def _make_commit(message: str) -> Commit: authored_date=commit_timestamp, committer=commit_author, committed_date=commit_timestamp, + parents=[], ) return _make_commit diff --git a/tests/const.py b/tests/const.py index 88f5677eb..41df4533d 100644 --- a/tests/const.py +++ b/tests/const.py @@ -92,7 +92,7 @@ class RepoActionStep(str, Enum): *EMOJI_COMMITS_PATCH, ":sparkles::pencil: docs for something special\n", # Emoji in description should not be used to evaluate change type - ":sparkles: last minute rush order\n\n:boom: Good thing we're 10x developers\n", + ":sparkles: last minute rush order\n\nGood thing we're 10x developers :boom:\n", ) EMOJI_COMMITS_MAJOR = ( *EMOJI_COMMITS_MINOR, diff --git a/tests/e2e/cmd_changelog/test_changelog.py b/tests/e2e/cmd_changelog/test_changelog.py index d717df497..edc2a8c63 100644 --- a/tests/e2e/cmd_changelog/test_changelog.py +++ b/tests/e2e/cmd_changelog/test_changelog.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -import sys from textwrap import dedent from typing import TYPE_CHECKING from unittest import mock @@ -13,7 +12,6 @@ import semantic_release.hvcs.github from semantic_release.changelog.context import ChangelogMode -from semantic_release.cli.commands.main import main from semantic_release.cli.config import ChangelogOutputFormat from semantic_release.hvcs.github import Github from semantic_release.version.version import Version @@ -77,9 +75,9 @@ from pathlib import Path from typing import TypedDict - from click.testing import CliRunner from requests_mock import Mocker + from tests.conftest import RunCliFn from tests.e2e.conftest import RetrieveRuntimeContextFn from tests.fixtures.example_project import ( ExProjectDir, @@ -123,7 +121,7 @@ class Commit2SectionCommit(TypedDict): def test_changelog_noop_is_noop( repo_result: BuiltRepoResult, arg0: str | None, - cli_runner: CliRunner, + run_cli: RunCliFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, ): repo = repo_result["repo"] @@ -152,7 +150,7 @@ def test_changelog_noop_is_noop( ), requests_mock.Mocker(session=session) as mocker: args = [arg0, f"v{version_str}"] if version_str and arg0 else [] cli_cmd = [MAIN_PROG_NAME, "--noop", CHANGELOG_SUBCMD, *args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -227,7 +225,7 @@ def test_changelog_noop_is_noop( ) def test_changelog_content_regenerated( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, changelog_file: Path, insertion_flag: str, @@ -255,7 +253,7 @@ def test_changelog_content_regenerated( # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -289,7 +287,7 @@ def test_changelog_content_regenerated_masked_initial_release( build_repo_from_definition: BuildRepoFromDefinitionFn, get_repo_definition_4_trunk_only_repo_w_tags: GetRepoDefinitionFn, example_project_dir: ExProjectDir, - cli_runner: CliRunner, + run_cli: RunCliFn, changelog_file: Path, insertion_flag: str, ): @@ -319,7 +317,7 @@ def test_changelog_content_regenerated_masked_initial_release( # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -353,7 +351,7 @@ def test_changelog_content_regenerated_masked_initial_release( ) def test_changelog_update_mode_unchanged( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, changelog_file: Path, ): @@ -376,7 +374,7 @@ def test_changelog_update_mode_unchanged( # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -413,7 +411,7 @@ def test_changelog_update_mode_unchanged( ) def test_changelog_update_mode_no_prev_changelog( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, changelog_file: Path, ): @@ -439,7 +437,7 @@ def test_changelog_update_mode_no_prev_changelog( # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -481,7 +479,7 @@ def test_changelog_update_mode_no_prev_changelog( ) def test_changelog_update_mode_no_flag( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, changelog_file: Path, insertion_flag: str, @@ -514,7 +512,7 @@ def test_changelog_update_mode_no_flag( # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -555,7 +553,7 @@ def test_changelog_update_mode_no_flag( ) def test_changelog_update_mode_no_header( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, changelog_format: ChangelogOutputFormat, changelog_file: Path, @@ -613,7 +611,7 @@ def test_changelog_update_mode_no_header( # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -657,7 +655,7 @@ def test_changelog_update_mode_no_header( ) def test_changelog_update_mode_no_footer( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, changelog_format: ChangelogOutputFormat, changelog_file: Path, @@ -717,7 +715,7 @@ def test_changelog_update_mode_no_footer( # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -761,7 +759,7 @@ def test_changelog_update_mode_no_footer( ) def test_changelog_update_mode_no_releases( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, changelog_file: Path, insertion_flag: str, @@ -816,7 +814,7 @@ def test_changelog_update_mode_no_releases( # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -862,7 +860,7 @@ def test_changelog_update_mode_unreleased_n_released( repo_result: BuiltRepoResult, commit_type: CommitConvention, changelog_format: ChangelogOutputFormat, - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, example_git_ssh_url: str, file_in_repo: str, @@ -933,7 +931,10 @@ def test_changelog_update_mode_unreleased_n_released( repo, commit_n_section[commit_type]["commit"], ) - hvcs = Github(example_git_ssh_url, hvcs_domain=EXAMPLE_HVCS_DOMAIN) + + with mock.patch.dict(os.environ, {}, clear=True): + hvcs = Github(example_git_ssh_url, hvcs_domain=EXAMPLE_HVCS_DOMAIN) + assert hvcs.repo_name # force caching of repo values (ignoring the env) unreleased_change_variants = { ChangelogOutputFormat.MARKDOWN: dedent( @@ -992,7 +993,7 @@ def test_changelog_update_mode_unreleased_n_released( # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -1015,11 +1016,11 @@ def test_changelog_update_mode_unreleased_n_released( ) def test_changelog_release_tag_not_in_history( args: list[str], - cli_runner: CliRunner, + run_cli: RunCliFn, ): # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD, *args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_exit_code(2, result, cli_cmd) @@ -1035,7 +1036,7 @@ def test_changelog_release_tag_not_in_history( ("--post-to-release-tag", "v0.2.0"), # latest release ], ) -def test_changelog_post_to_release(args: list[str], cli_runner: CliRunner): +def test_changelog_post_to_release(args: list[str], run_cli: RunCliFn): # Set up a requests HTTP session so we can catch the HTTP calls and ensure they're # made @@ -1055,59 +1056,22 @@ def test_changelog_post_to_release(args: list[str], cli_runner: CliRunner): repo_name=EXAMPLE_REPO_NAME, ) - clean_os_environment = dict( - filter( - lambda k_v: k_v[1] is not None, - { - "CI": "true", - "PATH": os.getenv("PATH"), - "HOME": os.getenv("HOME"), - "VIRTUAL_ENV": os.getenv("VIRTUAL_ENV", "./.venv"), - **( - {} - if sys.platform != "win32" - else { - # Windows Required variables - "ALLUSERSAPPDATA": os.getenv("ALLUSERSAPPDATA"), - "ALLUSERSPROFILE": os.getenv("ALLUSERSPROFILE"), - "APPDATA": os.getenv("APPDATA"), - "COMMONPROGRAMFILES": os.getenv("COMMONPROGRAMFILES"), - "COMMONPROGRAMFILES(X86)": os.getenv("COMMONPROGRAMFILES(X86)"), - "DEFAULTUSERPROFILE": os.getenv("DEFAULTUSERPROFILE"), - "HOMEPATH": os.getenv("HOMEPATH"), - "PATHEXT": os.getenv("PATHEXT"), - "PROFILESFOLDER": os.getenv("PROFILESFOLDER"), - "PROGRAMFILES": os.getenv("PROGRAMFILES"), - "PROGRAMFILES(X86)": os.getenv("PROGRAMFILES(X86)"), - "SYSTEM": os.getenv("SYSTEM"), - "SYSTEM16": os.getenv("SYSTEM16"), - "SYSTEM32": os.getenv("SYSTEM32"), - "SYSTEMDRIVE": os.getenv("SYSTEMDRIVE"), - "SYSTEMROOT": os.getenv("SYSTEMROOT"), - "TEMP": os.getenv("TEMP"), - "TMP": os.getenv("TMP"), - "USERPROFILE": os.getenv("USERPROFILE"), - "USERSID": os.getenv("USERSID"), - "USERNAME": os.getenv("USERNAME"), - "WINDIR": os.getenv("WINDIR"), - } - ), - }.items(), - ) - ) - # Patch out env vars that affect changelog URLs but only get set in e.g. # Github actions with mock.patch( # Patching the specific module's reference to the build_requests_session function f"{semantic_release.hvcs.github.__name__}.{semantic_release.hvcs.github.build_requests_session.__name__}", return_value=session, - ) as build_requests_session_mock, mock.patch.dict( - os.environ, clean_os_environment, clear=True - ): + ) as build_requests_session_mock: # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD, *args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli( + cli_cmd[1:], + env={ + "CI": "true", + "VIRTUAL_ENV": os.getenv("VIRTUAL_ENV", "./.venv"), + }, + ) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -1127,7 +1091,7 @@ def test_custom_release_notes_template( use_release_notes_template: UseReleaseNotesTemplateFn, retrieve_runtime_context: RetrieveRuntimeContextFn, post_mocker: Mocker, - cli_runner: CliRunner, + run_cli: RunCliFn, ) -> None: """Verify the template `.release_notes.md.j2` from `template_dir` is used.""" expected_call_count = 1 @@ -1157,7 +1121,7 @@ def test_custom_release_notes_template( # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD, "--post-to-release-tag", tag] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Assert assert_successful_exit_code(result, cli_cmd) @@ -1174,7 +1138,7 @@ def test_changelog_default_on_empty_template_dir( changelog_template_dir: Path, example_project_template_dir: Path, update_pyproject_toml: UpdatePyprojectTomlFn, - cli_runner: CliRunner, + run_cli: RunCliFn, ): # Setup: Make sure default changelog doesn't already exist example_changelog_md.unlink(missing_ok=True) @@ -1190,7 +1154,7 @@ def test_changelog_default_on_empty_template_dir( # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -1205,7 +1169,7 @@ def test_changelog_default_on_incorrect_config_template_file( changelog_template_dir: Path, example_project_template_dir: Path, update_pyproject_toml: UpdatePyprojectTomlFn, - cli_runner: CliRunner, + run_cli: RunCliFn, ): # Setup: Make sure default changelog doesn't already exist example_changelog_md.unlink(missing_ok=True) @@ -1222,7 +1186,7 @@ def test_changelog_default_on_incorrect_config_template_file( # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -1236,7 +1200,7 @@ def test_changelog_default_on_incorrect_config_template_file( def test_changelog_prevent_malicious_path_traversal_file( update_pyproject_toml: UpdatePyprojectTomlFn, bad_changelog_file_str: str, - cli_runner: CliRunner, + run_cli: RunCliFn, ): # Setup: A malicious path traversal filepath outside of the repository update_pyproject_toml( @@ -1246,7 +1210,7 @@ def test_changelog_prevent_malicious_path_traversal_file( # Act cli_cmd = [MAIN_PROG_NAME, "--noop", CHANGELOG_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_exit_code(1, result, cli_cmd) @@ -1261,7 +1225,7 @@ def test_changelog_prevent_malicious_path_traversal_file( def test_changelog_prevent_external_path_traversal_dir( update_pyproject_toml: UpdatePyprojectTomlFn, template_dir_path: str, - cli_runner: CliRunner, + run_cli: RunCliFn, ): # Setup: A malicious path traversal filepath outside of the repository update_pyproject_toml( @@ -1271,7 +1235,7 @@ def test_changelog_prevent_external_path_traversal_dir( # Act cli_cmd = [MAIN_PROG_NAME, "--noop", CHANGELOG_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_exit_code(1, result, cli_cmd) diff --git a/tests/e2e/cmd_changelog/test_changelog_custom_parser.py b/tests/e2e/cmd_changelog/test_changelog_custom_parser.py index 3c6d88f7a..0173cb49d 100644 --- a/tests/e2e/cmd_changelog/test_changelog_custom_parser.py +++ b/tests/e2e/cmd_changelog/test_changelog_custom_parser.py @@ -7,7 +7,6 @@ from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture from semantic_release.changelog.context import ChangelogMode -from semantic_release.cli.commands.main import main from tests.const import CHANGELOG_SUBCMD, MAIN_PROG_NAME from tests.fixtures.repos import repo_w_no_tags_conventional_commits @@ -19,8 +18,7 @@ if TYPE_CHECKING: from pathlib import Path - from click.testing import CliRunner - + from tests.conftest import RunCliFn from tests.fixtures.example_project import UpdatePyprojectTomlFn, UseCustomParserFn from tests.fixtures.git_repo import BuiltRepoResult, GetCommitDefFn @@ -30,7 +28,7 @@ ) def test_changelog_custom_parser_remove_from_changelog( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, use_custom_parser: UseCustomParserFn, get_commit_def_of_conventional_commit: GetCommitDefFn, @@ -70,7 +68,7 @@ def test_changelog_custom_parser_remove_from_changelog( # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Take measurement after action actual_content = changelog_md_file.read_text() diff --git a/tests/e2e/cmd_changelog/test_changelog_parsing.py b/tests/e2e/cmd_changelog/test_changelog_parsing.py index 40b6923cc..4c5c8f2e6 100644 --- a/tests/e2e/cmd_changelog/test_changelog_parsing.py +++ b/tests/e2e/cmd_changelog/test_changelog_parsing.py @@ -10,7 +10,6 @@ from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture from semantic_release.changelog.context import ChangelogMode -from semantic_release.cli.commands.main import main from semantic_release.cli.const import JINJA2_EXTENSION from tests.const import CHANGELOG_SUBCMD, MAIN_PROG_NAME @@ -29,8 +28,7 @@ from tests.util import assert_successful_exit_code if TYPE_CHECKING: - from click.testing import CliRunner - + from tests.conftest import RunCliFn from tests.fixtures.example_project import UpdatePyprojectTomlFn from tests.fixtures.git_repo import BuiltRepoResult @@ -68,7 +66,7 @@ ], ) def test_changelog_parsing_ignore_merge_commits( - cli_runner: CliRunner, + run_cli: RunCliFn, repo_result: BuiltRepoResult, update_pyproject_toml: UpdatePyprojectTomlFn, example_project_template_dir: Path, @@ -131,7 +129,7 @@ def test_changelog_parsing_ignore_merge_commits( # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) diff --git a/tests/e2e/cmd_changelog/test_changelog_release_notes.py b/tests/e2e/cmd_changelog/test_changelog_release_notes.py index e585f8b63..ca6d26563 100644 --- a/tests/e2e/cmd_changelog/test_changelog_release_notes.py +++ b/tests/e2e/cmd_changelog/test_changelog_release_notes.py @@ -6,7 +6,6 @@ import pytest from pytest_lazy_fixtures import lf as lazy_fixture -from semantic_release.cli.commands.main import main from semantic_release.version.version import Version from tests.const import CHANGELOG_SUBCMD, EXAMPLE_PROJECT_LICENSE, MAIN_PROG_NAME @@ -20,10 +19,9 @@ from tests.util import assert_successful_exit_code if TYPE_CHECKING: - from click.testing import CliRunner from requests_mock import Mocker - from tests.conftest import GetStableDateNowFn + from tests.conftest import GetStableDateNowFn, RunCliFn from tests.fixtures.example_project import UpdatePyprojectTomlFn from tests.fixtures.git_repo import ( BuiltRepoResult, @@ -49,7 +47,7 @@ def test_changelog_latest_release_notes( get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, get_hvcs_client_from_repo_def: GetHvcsClientFromRepoDefFn, - cli_runner: CliRunner, + run_cli: RunCliFn, post_mocker: Mocker, split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, generate_default_release_notes_from_def: GenerateDefaultReleaseNotesFromDefFn, @@ -77,7 +75,7 @@ def test_changelog_latest_release_notes( # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD, "--post-to-release-tag", release_tag] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -122,7 +120,7 @@ def test_changelog_previous_release_notes( get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, get_hvcs_client_from_repo_def: GetHvcsClientFromRepoDefFn, - cli_runner: CliRunner, + run_cli: RunCliFn, post_mocker: Mocker, split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, generate_default_release_notes_from_def: GenerateDefaultReleaseNotesFromDefFn, @@ -157,7 +155,7 @@ def test_changelog_previous_release_notes( # Act cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD, "--post-to-release-tag", release_tag] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -213,7 +211,7 @@ def test_changelog_release_notes_license_change( get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, get_hvcs_client_from_repo_def: GetHvcsClientFromRepoDefFn, - cli_runner: CliRunner, + run_cli: RunCliFn, post_mocker: Mocker, split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, generate_default_release_notes_from_def: GenerateDefaultReleaseNotesFromDefFn, @@ -296,7 +294,7 @@ def test_changelog_release_notes_license_change( "--post-to-release-tag", latest_release_tag, ] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -316,7 +314,7 @@ def test_changelog_release_notes_license_change( "--post-to-release-tag", prev_release_tag, ] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) diff --git a/tests/e2e/cmd_config/test_generate_config.py b/tests/e2e/cmd_config/test_generate_config.py index 3d49a3136..4a21f0be7 100644 --- a/tests/e2e/cmd_config/test_generate_config.py +++ b/tests/e2e/cmd_config/test_generate_config.py @@ -6,7 +6,6 @@ import pytest import tomlkit -from semantic_release.cli.commands.main import main from semantic_release.cli.config import RawConfig from tests.const import GENERATE_CONFIG_SUBCMD, MAIN_PROG_NAME, VERSION_SUBCMD @@ -17,8 +16,7 @@ from pathlib import Path from typing import Any - from click.testing import CliRunner - + from tests.conftest import RunCliFn from tests.fixtures.example_project import ExProjectDir @@ -30,7 +28,7 @@ def raw_config_dict() -> dict[str, Any]: @pytest.mark.parametrize("args", [(), ("--format", "toml"), ("--format", "TOML")]) @pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_generate_config_toml( - cli_runner: CliRunner, + run_cli: RunCliFn, args: tuple[str], raw_config_dict: dict[str, Any], example_project_dir: ExProjectDir, @@ -42,7 +40,7 @@ def test_generate_config_toml( # Act: Print the generated configuration to stdout cli_cmd = [MAIN_PROG_NAME, GENERATE_CONFIG_SUBCMD, *args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate: Check that the command ran successfully and that the output matches the expected configuration assert_successful_exit_code(result, cli_cmd) @@ -62,7 +60,7 @@ def test_generate_config_toml( VERSION_SUBCMD, "--print", ] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate: Check that the version command in noop mode ran successfully # which means PSR loaded the configuration successfully @@ -72,7 +70,7 @@ def test_generate_config_toml( @pytest.mark.parametrize("args", [("--format", "json"), ("--format", "JSON")]) @pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_generate_config_json( - cli_runner: CliRunner, + run_cli: RunCliFn, args: tuple[str], raw_config_dict: dict[str, Any], example_project_dir: ExProjectDir, @@ -84,7 +82,7 @@ def test_generate_config_json( # Act: Print the generated configuration to stdout cli_cmd = [MAIN_PROG_NAME, GENERATE_CONFIG_SUBCMD, *args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate: Check that the command ran successfully and that the output matches the expected configuration assert_successful_exit_code(result, cli_cmd) @@ -104,7 +102,7 @@ def test_generate_config_json( VERSION_SUBCMD, "--print", ] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate: Check that the version command in noop mode ran successfully # which means PSR loaded the configuration successfully @@ -113,7 +111,7 @@ def test_generate_config_json( @pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_generate_config_pyproject_toml( - cli_runner: CliRunner, + run_cli: RunCliFn, raw_config_dict: dict[str, Any], example_pyproject_toml: Path, ): @@ -135,7 +133,7 @@ def test_generate_config_pyproject_toml( "toml", "--pyproject", ] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate: Check that the command ran successfully and that the output matches the expected configuration assert_successful_exit_code(result, cli_cmd) @@ -154,7 +152,7 @@ def test_generate_config_pyproject_toml( # Act: Validate that the generated config is a valid configuration for PSR cli_cmd = [MAIN_PROG_NAME, "--noop", "--strict", VERSION_SUBCMD, "--print"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate: Check that the version command in noop mode ran successfully # which means PSR loaded the configuration successfully diff --git a/tests/e2e/cmd_publish/test_publish.py b/tests/e2e/cmd_publish/test_publish.py index ba5307fec..3b4fca2bf 100644 --- a/tests/e2e/cmd_publish/test_publish.py +++ b/tests/e2e/cmd_publish/test_publish.py @@ -6,7 +6,6 @@ import pytest from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture -from semantic_release.cli.commands.main import main from semantic_release.hvcs import Github from tests.const import MAIN_PROG_NAME, PUBLISH_SUBCMD @@ -16,8 +15,7 @@ if TYPE_CHECKING: from typing import Sequence - from click.testing import CliRunner - + from tests.conftest import RunCliFn from tests.fixtures.git_repo import BuiltRepoResult, GetVersionsFromRepoBuildDefFn @@ -27,7 +25,7 @@ ) def test_publish_latest_uses_latest_tag( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, cmd_args: Sequence[str], get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, ): @@ -41,7 +39,7 @@ def test_publish_latest_uses_latest_tag( cli_cmd = [MAIN_PROG_NAME, PUBLISH_SUBCMD, *cmd_args] # Act - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -53,7 +51,7 @@ def test_publish_latest_uses_latest_tag( ) def test_publish_to_tag_uses_tag( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, ): # Testing a non-latest tag to distinguish from test_publish_latest_uses_latest_tag() @@ -64,7 +62,7 @@ def test_publish_to_tag_uses_tag( cli_cmd = [MAIN_PROG_NAME, PUBLISH_SUBCMD, "--tag", previous_tag] # Act - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -74,14 +72,14 @@ def test_publish_to_tag_uses_tag( @pytest.mark.usefixtures(repo_w_trunk_only_conventional_commits.__name__) -def test_publish_fails_on_nonexistant_tag(cli_runner: CliRunner): +def test_publish_fails_on_nonexistant_tag(run_cli: RunCliFn): non_existant_tag = "nonexistant-tag" with mock.patch.object(Github, Github.upload_dists.__name__) as mocked_upload_dists: cli_cmd = [MAIN_PROG_NAME, PUBLISH_SUBCMD, "--tag", non_existant_tag] # Act - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_exit_code(1, result, cli_cmd) diff --git a/tests/e2e/cmd_version/bump_version/conftest.py b/tests/e2e/cmd_version/bump_version/conftest.py index 23924b579..da36ff1d2 100644 --- a/tests/e2e/cmd_version/bump_version/conftest.py +++ b/tests/e2e/cmd_version/bump_version/conftest.py @@ -1,15 +1,23 @@ from __future__ import annotations -import shutil from typing import TYPE_CHECKING import pytest from git import Repo +from semantic_release.hvcs.github import Github + +from tests.const import MAIN_PROG_NAME, VERSION_SUBCMD +from tests.util import assert_successful_exit_code + if TYPE_CHECKING: from pathlib import Path from typing import Protocol + from click.testing import Result + + from tests.conftest import RunCliFn + from tests.fixtures.example_project import UpdatePyprojectTomlFn from tests.fixtures.git_repo import BuildRepoFromDefinitionFn, RepoActionConfigure class InitMirrorRepo4RebuildFn(Protocol): @@ -19,13 +27,19 @@ def __call__( configuration_step: RepoActionConfigure, ) -> Path: ... + class RunPSReleaseFn(Protocol): + def __call__( + self, + next_version_str: str, + git_repo: Repo, + ) -> Result: ... + @pytest.fixture(scope="session") def init_mirror_repo_for_rebuild( - default_changelog_md_template: Path, - default_changelog_rst_template: Path, - changelog_template_dir: Path, build_repo_from_definition: BuildRepoFromDefinitionFn, + changelog_md_file: Path, + changelog_rst_file: Path, ) -> InitMirrorRepo4RebuildFn: def _init_mirror_repo_for_rebuild( mirror_repo_dir: Path, @@ -40,21 +54,80 @@ def _init_mirror_repo_for_rebuild( repo_construction_steps=[configuration_step], ) - # Force custom changelog to be a copy of the default changelog (md and rst) - shutil.copytree( - src=default_changelog_md_template.parent, - dst=mirror_repo_dir / changelog_template_dir, - dirs_exist_ok=True, - ) - shutil.copytree( - src=default_changelog_rst_template.parent, - dst=mirror_repo_dir / changelog_template_dir, - dirs_exist_ok=True, - ) - with Repo(mirror_repo_dir) as mirror_git_repo: - mirror_git_repo.git.add(str(changelog_template_dir)) + # remove the default changelog files to enable Update Mode (new default of v10) + mirror_git_repo.git.rm(str(changelog_md_file), force=True) + mirror_git_repo.git.rm(str(changelog_rst_file), force=True) return mirror_repo_dir return _init_mirror_repo_for_rebuild + + +@pytest.fixture(scope="session") +def run_psr_release( + run_cli: RunCliFn, + changelog_rst_file: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +) -> RunPSReleaseFn: + base_version_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD] + write_changelog_only_cmd = [ + *base_version_cmd, + "--changelog", + "--no-commit", + "--no-tag", + "--skip-build", + ] + + def _run_psr_release( + next_version_str: str, + git_repo: Repo, + ) -> Result: + version_n_buildmeta = next_version_str.split("+", maxsplit=1) + version_n_prerelease = version_n_buildmeta[0].split("-", maxsplit=1) + + build_metadata_args = ( + ["--build-metadata", version_n_buildmeta[-1]] + if len(version_n_buildmeta) > 1 + else [] + ) + + prerelease_args = ( + [ + "--as-prerelease", + "--prerelease-token", + version_n_prerelease[-1].split(".", maxsplit=1)[0], + ] + if len(version_n_prerelease) > 1 + else [] + ) + + # Initial run to write the RST changelog + # 1. configure PSR to write the RST changelog with the RST default insertion flag + update_pyproject_toml( + "tool.semantic_release.changelog.default_templates.changelog_file", + str(changelog_rst_file), + ) + cli_cmd = [*write_changelog_only_cmd, *prerelease_args, *build_metadata_args] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + assert_successful_exit_code(result, cli_cmd) + + # Reset the index in case PSR added anything to the index + git_repo.git.reset("--mixed", "HEAD") + + # Add the changelog file to the git index but reset the working directory + git_repo.git.add(str(changelog_rst_file)) + git_repo.git.checkout("--", ".") + + # Actual run to release & write the MD changelog + cli_cmd = [ + *base_version_cmd, + *prerelease_args, + *build_metadata_args, + ] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + assert_successful_exit_code(result, cli_cmd) + + return result + + return _run_psr_release diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py index e12ca31e6..e257448f8 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_1_channel.py @@ -7,27 +7,23 @@ from flatdict import FlatDict from freezegun import freeze_time -from semantic_release.cli.commands.main import main - -from tests.const import ( - MAIN_PROG_NAME, - VERSION_SUBCMD, -) from tests.fixtures.repos.git_flow import ( repo_w_git_flow_conventional_commits, repo_w_git_flow_emoji_commits, repo_w_git_flow_scipy_commits, ) -from tests.util import assert_successful_exit_code, temporary_working_directory +from tests.util import temporary_working_directory if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock - from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.cmd_version.bump_version.conftest import ( + InitMirrorRepo4RebuildFn, + RunPSReleaseFn, + ) from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( @@ -55,7 +51,7 @@ ) def test_gitflow_repo_rebuild_1_channel( repo_fixture_name: str, - cli_runner: CliRunner, + run_psr_release: RunPSReleaseFn, build_git_flow_repo_w_1_release_channels: BuildSpecificRepoFn, split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, @@ -133,19 +129,10 @@ def test_gitflow_repo_rebuild_1_channel( with freeze_time( release_action_step["details"]["datetime"] ), temporary_working_directory(mirror_repo_dir): - build_metadata_args = ( - [ - "--build-metadata", - release_action_step["details"]["version"].split("+", maxsplit=1)[ - -1 - ], - ] - if len(release_action_step["details"]["version"].split("+", maxsplit=1)) - > 1 - else [] + run_psr_release( + next_version_str=release_action_step["details"]["version"], + git_repo=mirror_git_repo, ) - cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] - result = cli_runner.invoke(main, cli_cmd[1:]) # take measurement after running the version command actual_release_commit_text = mirror_git_repo.head.commit.message @@ -159,7 +146,7 @@ def test_gitflow_repo_rebuild_1_channel( ) # Evaluate (normal release actions should have occurred as expected) - assert_successful_exit_code(result, cli_cmd) + # ------------------------------------------------------------------ # Make sure version file is updated assert expected_pyproject_toml_content == actual_pyproject_toml_content assert expected_version_file_content == actual_version_file_content diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py index 035f679bd..2f6b30c76 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_2_channels.py @@ -7,27 +7,23 @@ from flatdict import FlatDict from freezegun import freeze_time -from semantic_release.cli.commands.main import main - -from tests.const import ( - MAIN_PROG_NAME, - VERSION_SUBCMD, -) from tests.fixtures.repos.git_flow import ( repo_w_git_flow_w_alpha_prereleases_n_conventional_commits, repo_w_git_flow_w_alpha_prereleases_n_emoji_commits, repo_w_git_flow_w_alpha_prereleases_n_scipy_commits, ) -from tests.util import assert_successful_exit_code, temporary_working_directory +from tests.util import temporary_working_directory if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock - from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.cmd_version.bump_version.conftest import ( + InitMirrorRepo4RebuildFn, + RunPSReleaseFn, + ) from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( @@ -55,7 +51,7 @@ ) def test_gitflow_repo_rebuild_2_channels( repo_fixture_name: str, - cli_runner: CliRunner, + run_psr_release: RunPSReleaseFn, build_git_flow_repo_w_2_release_channels: BuildSpecificRepoFn, split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, @@ -133,19 +129,10 @@ def test_gitflow_repo_rebuild_2_channels( with freeze_time( release_action_step["details"]["datetime"] ), temporary_working_directory(mirror_repo_dir): - build_metadata_args = ( - [ - "--build-metadata", - release_action_step["details"]["version"].split("+", maxsplit=1)[ - -1 - ], - ] - if len(release_action_step["details"]["version"].split("+", maxsplit=1)) - > 1 - else [] + run_psr_release( + next_version_str=release_action_step["details"]["version"], + git_repo=mirror_git_repo, ) - cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] - result = cli_runner.invoke(main, cli_cmd[1:]) # take measurement after running the version command actual_release_commit_text = mirror_git_repo.head.commit.message @@ -159,7 +146,7 @@ def test_gitflow_repo_rebuild_2_channels( ) # Evaluate (normal release actions should have occurred as expected) - assert_successful_exit_code(result, cli_cmd) + # ------------------------------------------------------------------ # Make sure version file is updated assert expected_pyproject_toml_content == actual_pyproject_toml_content assert expected_version_file_content == actual_version_file_content diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py index 825b7f7c3..a4dc00675 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_3_channels.py @@ -7,28 +7,24 @@ from flatdict import FlatDict from freezegun import freeze_time -from semantic_release.cli.commands.main import main - -from tests.const import ( - MAIN_PROG_NAME, - VERSION_SUBCMD, -) from tests.fixtures.repos.git_flow import ( repo_w_git_flow_w_rc_n_alpha_prereleases_n_conventional_commits, repo_w_git_flow_w_rc_n_alpha_prereleases_n_conventional_commits_using_tag_format, repo_w_git_flow_w_rc_n_alpha_prereleases_n_emoji_commits, repo_w_git_flow_w_rc_n_alpha_prereleases_n_scipy_commits, ) -from tests.util import assert_successful_exit_code, temporary_working_directory +from tests.util import temporary_working_directory if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock - from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.cmd_version.bump_version.conftest import ( + InitMirrorRepo4RebuildFn, + RunPSReleaseFn, + ) from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( @@ -57,7 +53,7 @@ ) def test_gitflow_repo_rebuild_3_channels( repo_fixture_name: str, - cli_runner: CliRunner, + run_psr_release: RunPSReleaseFn, build_git_flow_repo_w_3_release_channels: BuildSpecificRepoFn, split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, @@ -135,20 +131,10 @@ def test_gitflow_repo_rebuild_3_channels( with freeze_time( release_action_step["details"]["datetime"] ), temporary_working_directory(mirror_repo_dir): - build_metadata_args = ( - [ - "--build-metadata", - release_action_step["details"]["version"].split("+", maxsplit=1)[ - -1 - ], - ] - if len(release_action_step["details"]["version"].split("+", maxsplit=1)) - > 1 - else [] + run_psr_release( + next_version_str=release_action_step["details"]["version"], + git_repo=mirror_git_repo, ) - cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] - result = cli_runner.invoke(main, cli_cmd[1:]) - # take measurement after running the version command actual_release_commit_text = mirror_git_repo.head.commit.message actual_pyproject_toml_content = (mirror_repo_dir / "pyproject.toml").read_text() @@ -161,7 +147,7 @@ def test_gitflow_repo_rebuild_3_channels( ) # Evaluate (normal release actions should have occurred as expected) - assert_successful_exit_code(result, cli_cmd) + # ------------------------------------------------------------------ # Make sure version file is updated assert expected_pyproject_toml_content == actual_pyproject_toml_content assert expected_version_file_content == actual_version_file_content diff --git a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py index e1cadb5a6..eeeaa7598 100644 --- a/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py +++ b/tests/e2e/cmd_version/bump_version/git_flow/test_repo_4_channels.py @@ -7,27 +7,23 @@ from flatdict import FlatDict from freezegun import freeze_time -from semantic_release.cli.commands.main import main - -from tests.const import ( - MAIN_PROG_NAME, - VERSION_SUBCMD, -) from tests.fixtures.repos.git_flow import ( repo_w_git_flow_w_beta_alpha_rev_prereleases_n_conventional_commits, repo_w_git_flow_w_beta_alpha_rev_prereleases_n_emoji_commits, repo_w_git_flow_w_beta_alpha_rev_prereleases_n_scipy_commits, ) -from tests.util import assert_successful_exit_code, temporary_working_directory +from tests.util import temporary_working_directory if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock - from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.cmd_version.bump_version.conftest import ( + InitMirrorRepo4RebuildFn, + RunPSReleaseFn, + ) from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( @@ -55,7 +51,7 @@ ) def test_gitflow_repo_rebuild_4_channels( repo_fixture_name: str, - cli_runner: CliRunner, + run_psr_release: RunPSReleaseFn, build_git_flow_repo_w_4_release_channels: BuildSpecificRepoFn, split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, @@ -133,19 +129,10 @@ def test_gitflow_repo_rebuild_4_channels( with freeze_time( release_action_step["details"]["datetime"] ), temporary_working_directory(mirror_repo_dir): - build_metadata_args = ( - [ - "--build-metadata", - release_action_step["details"]["version"].split("+", maxsplit=1)[ - -1 - ], - ] - if len(release_action_step["details"]["version"].split("+", maxsplit=1)) - > 1 - else [] + run_psr_release( + next_version_str=release_action_step["details"]["version"], + git_repo=mirror_git_repo, ) - cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] - result = cli_runner.invoke(main, cli_cmd[1:]) # take measurement after running the version command actual_release_commit_text = mirror_git_repo.head.commit.message @@ -159,7 +146,7 @@ def test_gitflow_repo_rebuild_4_channels( ) # Evaluate (normal release actions should have occurred as expected) - assert_successful_exit_code(result, cli_cmd) + # ------------------------------------------------------------------ # Make sure version file is updated assert expected_pyproject_toml_content == actual_pyproject_toml_content assert expected_version_file_content == actual_version_file_content diff --git a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py index e836716d6..2dec4e393 100644 --- a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py +++ b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel.py @@ -7,27 +7,23 @@ from flatdict import FlatDict from freezegun import freeze_time -from semantic_release.cli.commands.main import main - -from tests.const import ( - MAIN_PROG_NAME, - VERSION_SUBCMD, -) from tests.fixtures.repos.github_flow import ( repo_w_github_flow_w_default_release_channel_conventional_commits, repo_w_github_flow_w_default_release_channel_emoji_commits, repo_w_github_flow_w_default_release_channel_scipy_commits, ) -from tests.util import assert_successful_exit_code, temporary_working_directory +from tests.util import temporary_working_directory if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock - from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.cmd_version.bump_version.conftest import ( + InitMirrorRepo4RebuildFn, + RunPSReleaseFn, + ) from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( @@ -55,7 +51,7 @@ ) def test_githubflow_repo_rebuild_1_channel( repo_fixture_name: str, - cli_runner: CliRunner, + run_psr_release: RunPSReleaseFn, build_repo_w_github_flow_w_default_release_channel: BuildSpecificRepoFn, split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, @@ -133,19 +129,10 @@ def test_githubflow_repo_rebuild_1_channel( with freeze_time( release_action_step["details"]["datetime"] ), temporary_working_directory(mirror_repo_dir): - build_metadata_args = ( - [ - "--build-metadata", - release_action_step["details"]["version"].split("+", maxsplit=1)[ - -1 - ], - ] - if len(release_action_step["details"]["version"].split("+", maxsplit=1)) - > 1 - else [] + run_psr_release( + next_version_str=release_action_step["details"]["version"], + git_repo=mirror_git_repo, ) - cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] - result = cli_runner.invoke(main, cli_cmd[1:]) # take measurement after running the version command actual_release_commit_text = mirror_git_repo.head.commit.message @@ -159,7 +146,7 @@ def test_githubflow_repo_rebuild_1_channel( ) # Evaluate (normal release actions should have occurred as expected) - assert_successful_exit_code(result, cli_cmd) + # ------------------------------------------------------------------ # Make sure version file is updated assert expected_pyproject_toml_content == actual_pyproject_toml_content assert expected_version_file_content == actual_version_file_content diff --git a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py index 03054ac6a..8d2ebd3c3 100644 --- a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py +++ b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_2_channels.py @@ -7,27 +7,23 @@ from flatdict import FlatDict from freezegun import freeze_time -from semantic_release.cli.commands.main import main - -from tests.const import ( - MAIN_PROG_NAME, - VERSION_SUBCMD, -) from tests.fixtures.repos.github_flow import ( repo_w_github_flow_w_feature_release_channel_conventional_commits, repo_w_github_flow_w_feature_release_channel_emoji_commits, repo_w_github_flow_w_feature_release_channel_scipy_commits, ) -from tests.util import assert_successful_exit_code, temporary_working_directory +from tests.util import temporary_working_directory if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock - from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.cmd_version.bump_version.conftest import ( + InitMirrorRepo4RebuildFn, + RunPSReleaseFn, + ) from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( @@ -55,7 +51,7 @@ ) def test_githubflow_repo_rebuild_2_channels( repo_fixture_name: str, - cli_runner: CliRunner, + run_psr_release: RunPSReleaseFn, build_repo_w_github_flow_w_feature_release_channel: BuildSpecificRepoFn, split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, @@ -133,19 +129,10 @@ def test_githubflow_repo_rebuild_2_channels( with freeze_time( release_action_step["details"]["datetime"] ), temporary_working_directory(mirror_repo_dir): - build_metadata_args = ( - [ - "--build-metadata", - release_action_step["details"]["version"].split("+", maxsplit=1)[ - -1 - ], - ] - if len(release_action_step["details"]["version"].split("+", maxsplit=1)) - > 1 - else [] + run_psr_release( + next_version_str=release_action_step["details"]["version"], + git_repo=mirror_git_repo, ) - cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] - result = cli_runner.invoke(main, cli_cmd[1:]) # take measurement after running the version command actual_release_commit_text = mirror_git_repo.head.commit.message @@ -159,7 +146,7 @@ def test_githubflow_repo_rebuild_2_channels( ) # Evaluate (normal release actions should have occurred as expected) - assert_successful_exit_code(result, cli_cmd) + # ------------------------------------------------------------------ # Make sure version file is updated assert expected_pyproject_toml_content == actual_pyproject_toml_content assert expected_version_file_content == actual_version_file_content diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py index fac01bdff..d079b6638 100644 --- a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk.py @@ -7,27 +7,23 @@ from flatdict import FlatDict from freezegun import freeze_time -from semantic_release.cli.commands.main import main - -from tests.const import ( - MAIN_PROG_NAME, - VERSION_SUBCMD, -) from tests.fixtures.repos.trunk_based_dev import ( repo_w_trunk_only_conventional_commits, repo_w_trunk_only_emoji_commits, repo_w_trunk_only_scipy_commits, ) -from tests.util import assert_successful_exit_code, temporary_working_directory +from tests.util import temporary_working_directory if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock - from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.cmd_version.bump_version.conftest import ( + InitMirrorRepo4RebuildFn, + RunPSReleaseFn, + ) from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( @@ -57,7 +53,7 @@ ) def test_trunk_repo_rebuild_only_official_releases( repo_fixture_name: str, - cli_runner: CliRunner, + run_psr_release: RunPSReleaseFn, build_trunk_only_repo_w_tags: BuildSpecificRepoFn, split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, @@ -135,19 +131,10 @@ def test_trunk_repo_rebuild_only_official_releases( with freeze_time( release_action_step["details"]["datetime"] ), temporary_working_directory(mirror_repo_dir): - build_metadata_args = ( - [ - "--build-metadata", - release_action_step["details"]["version"].split("+", maxsplit=1)[ - -1 - ], - ] - if len(release_action_step["details"]["version"].split("+", maxsplit=1)) - > 1 - else [] + run_psr_release( + next_version_str=release_action_step["details"]["version"], + git_repo=mirror_git_repo, ) - cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] - result = cli_runner.invoke(main, cli_cmd[1:]) # take measurement after running the version command actual_release_commit_text = mirror_git_repo.head.commit.message @@ -161,7 +148,7 @@ def test_trunk_repo_rebuild_only_official_releases( ) # Evaluate (normal release actions should have occurred as expected) - assert_successful_exit_code(result, cli_cmd) + # ------------------------------------------------------------------ # Make sure version file is updated assert expected_pyproject_toml_content == actual_pyproject_toml_content assert expected_version_file_content == actual_version_file_content diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support.py index 6c15f2bd8..f68bf817e 100644 --- a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support.py +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support.py @@ -7,28 +7,26 @@ from flatdict import FlatDict from freezegun import freeze_time -from semantic_release.cli.commands.main import main - from tests.const import ( DEFAULT_BRANCH_NAME, - MAIN_PROG_NAME, - VERSION_SUBCMD, ) from tests.fixtures.repos.trunk_based_dev import ( repo_w_trunk_only_dual_version_spt_conventional_commits, repo_w_trunk_only_dual_version_spt_emoji_commits, repo_w_trunk_only_dual_version_spt_scipy_commits, ) -from tests.util import assert_successful_exit_code, temporary_working_directory +from tests.util import temporary_working_directory if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock - from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.cmd_version.bump_version.conftest import ( + InitMirrorRepo4RebuildFn, + RunPSReleaseFn, + ) from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( @@ -56,7 +54,7 @@ ) def test_trunk_repo_rebuild_dual_version_spt_official_releases_only( repo_fixture_name: str, - cli_runner: CliRunner, + run_psr_release: RunPSReleaseFn, build_trunk_only_repo_w_dual_version_support: BuildSpecificRepoFn, split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, @@ -139,19 +137,10 @@ def test_trunk_repo_rebuild_dual_version_spt_official_releases_only( with freeze_time( release_action_step["details"]["datetime"] ), temporary_working_directory(mirror_repo_dir): - build_metadata_args = ( - [ - "--build-metadata", - release_action_step["details"]["version"].split("+", maxsplit=1)[ - -1 - ], - ] - if len(release_action_step["details"]["version"].split("+", maxsplit=1)) - > 1 - else [] + run_psr_release( + next_version_str=release_action_step["details"]["version"], + git_repo=mirror_git_repo, ) - cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, *build_metadata_args] - result = cli_runner.invoke(main, cli_cmd[1:]) # take measurement after running the version command actual_release_commit_text = mirror_git_repo.head.commit.message @@ -165,7 +154,7 @@ def test_trunk_repo_rebuild_dual_version_spt_official_releases_only( ) # Evaluate (normal release actions should have occurred as expected) - assert_successful_exit_code(result, cli_cmd) + # ------------------------------------------------------------------ # Make sure version file is updated assert expected_pyproject_toml_content == actual_pyproject_toml_content assert expected_version_file_content == actual_version_file_content diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support_w_prereleases.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support_w_prereleases.py index 74d5f361f..1514dac38 100644 --- a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support_w_prereleases.py +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_dual_version_support_w_prereleases.py @@ -7,28 +7,26 @@ from flatdict import FlatDict from freezegun import freeze_time -from semantic_release.cli.commands.main import main - from tests.const import ( DEFAULT_BRANCH_NAME, - MAIN_PROG_NAME, - VERSION_SUBCMD, ) from tests.fixtures.repos.trunk_based_dev import ( repo_w_trunk_only_dual_version_spt_w_prereleases_conventional_commits, repo_w_trunk_only_dual_version_spt_w_prereleases_emoji_commits, repo_w_trunk_only_dual_version_spt_w_prereleases_scipy_commits, ) -from tests.util import assert_successful_exit_code, temporary_working_directory +from tests.util import temporary_working_directory if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock - from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.cmd_version.bump_version.conftest import ( + InitMirrorRepo4RebuildFn, + RunPSReleaseFn, + ) from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( @@ -56,7 +54,7 @@ ) def test_trunk_repo_rebuild_dual_version_spt_w_official_n_prereleases( repo_fixture_name: str, - cli_runner: CliRunner, + run_psr_release: RunPSReleaseFn, build_trunk_only_repo_w_dual_version_spt_w_prereleases: BuildSpecificRepoFn, split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, @@ -139,39 +137,10 @@ def test_trunk_repo_rebuild_dual_version_spt_w_official_n_prereleases( with freeze_time( release_action_step["details"]["datetime"] ), temporary_working_directory(mirror_repo_dir): - build_metadata_args = ( - [ - "--build-metadata", - release_action_step["details"]["version"].split("+", maxsplit=1)[ - -1 - ], - ] - if len(release_action_step["details"]["version"].split("+", maxsplit=1)) - > 1 - else [] - ) - prerelease_args = ( - [ - "--as-prerelease", - "--prerelease-token", - ( - release_action_step["details"]["version"] - .split("-", maxsplit=1)[-1] - .split(".", maxsplit=1)[0] - ), - ] - if len(release_action_step["details"]["version"].split("-", maxsplit=1)) - > 1 - else [] + run_psr_release( + next_version_str=release_action_step["details"]["version"], + git_repo=mirror_git_repo, ) - cli_cmd = [ - MAIN_PROG_NAME, - "--strict", - VERSION_SUBCMD, - *build_metadata_args, - *prerelease_args, - ] - result = cli_runner.invoke(main, cli_cmd[1:]) # take measurement after running the version command actual_release_commit_text = mirror_git_repo.head.commit.message @@ -185,7 +154,7 @@ def test_trunk_repo_rebuild_dual_version_spt_w_official_n_prereleases( ) # Evaluate (normal release actions should have occurred as expected) - assert_successful_exit_code(result, cli_cmd) + # ------------------------------------------------------------------ # Make sure version file is updated assert expected_pyproject_toml_content == actual_pyproject_toml_content assert expected_version_file_content == actual_version_file_content diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py index 9d1171e59..67af5d56a 100644 --- a/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev/test_repo_trunk_w_prereleases.py @@ -7,27 +7,23 @@ from flatdict import FlatDict from freezegun import freeze_time -from semantic_release.cli.commands.main import main - -from tests.const import ( - MAIN_PROG_NAME, - VERSION_SUBCMD, -) from tests.fixtures.repos.trunk_based_dev import ( repo_w_trunk_only_n_prereleases_conventional_commits, repo_w_trunk_only_n_prereleases_emoji_commits, repo_w_trunk_only_n_prereleases_scipy_commits, ) -from tests.util import assert_successful_exit_code, temporary_working_directory +from tests.util import temporary_working_directory if TYPE_CHECKING: from pathlib import Path from unittest.mock import MagicMock - from click.testing import CliRunner from requests_mock import Mocker - from tests.e2e.cmd_version.bump_version.conftest import InitMirrorRepo4RebuildFn + from tests.e2e.cmd_version.bump_version.conftest import ( + InitMirrorRepo4RebuildFn, + RunPSReleaseFn, + ) from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import ExProjectDir from tests.fixtures.git_repo import ( @@ -55,7 +51,7 @@ ) def test_trunk_repo_rebuild_w_prereleases( repo_fixture_name: str, - cli_runner: CliRunner, + run_psr_release: RunPSReleaseFn, build_trunk_only_repo_w_prerelease_tags: BuildSpecificRepoFn, split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, init_mirror_repo_for_rebuild: InitMirrorRepo4RebuildFn, @@ -133,39 +129,10 @@ def test_trunk_repo_rebuild_w_prereleases( with freeze_time( release_action_step["details"]["datetime"] ), temporary_working_directory(mirror_repo_dir): - build_metadata_args = ( - [ - "--build-metadata", - release_action_step["details"]["version"].split("+", maxsplit=1)[ - -1 - ], - ] - if len(release_action_step["details"]["version"].split("+", maxsplit=1)) - > 1 - else [] - ) - prerelease_args = ( - [ - "--as-prerelease", - "--prerelease-token", - ( - release_action_step["details"]["version"] - .split("-", maxsplit=1)[-1] - .split(".", maxsplit=1)[0] - ), - ] - if len(release_action_step["details"]["version"].split("-", maxsplit=1)) - > 1 - else [] + run_psr_release( + next_version_str=release_action_step["details"]["version"], + git_repo=mirror_git_repo, ) - cli_cmd = [ - MAIN_PROG_NAME, - "--strict", - VERSION_SUBCMD, - *build_metadata_args, - *prerelease_args, - ] - result = cli_runner.invoke(main, cli_cmd[1:]) # take measurement after running the version command actual_release_commit_text = mirror_git_repo.head.commit.message @@ -179,7 +146,7 @@ def test_trunk_repo_rebuild_w_prereleases( ) # Evaluate (normal release actions should have occurred as expected) - assert_successful_exit_code(result, cli_cmd) + # ------------------------------------------------------------------ # Make sure version file is updated assert expected_pyproject_toml_content == actual_pyproject_toml_content assert expected_version_file_content == actual_version_file_content diff --git a/tests/e2e/cmd_version/test_version.py b/tests/e2e/cmd_version/test_version.py index 9586073c3..5cb9700cf 100644 --- a/tests/e2e/cmd_version/test_version.py +++ b/tests/e2e/cmd_version/test_version.py @@ -7,7 +7,7 @@ import pytest from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture -from semantic_release.cli.commands.main import main +from semantic_release.hvcs.github import Github from tests.const import ( MAIN_PROG_NAME, @@ -22,10 +22,10 @@ if TYPE_CHECKING: from unittest.mock import MagicMock - from click.testing import CliRunner from git import Repo from requests_mock import Mocker + from tests.conftest import RunCliFn from tests.fixtures.example_project import GetWheelFileFn, UpdatePyprojectTomlFn from tests.fixtures.git_repo import BuiltRepoResult, GetVersionsFromRepoBuildDefFn @@ -35,12 +35,12 @@ "repo_result, next_release_version", # must use a repo that is ready for a release to prevent no release # logic from being triggered before the noop logic - [(lazy_fixture(repo_w_no_tags_conventional_commits.__name__), "0.1.0")], + [(lazy_fixture(repo_w_no_tags_conventional_commits.__name__), "1.0.0")], ) def test_version_noop_is_noop( repo_result: BuiltRepoResult, next_release_version: str, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, get_wheel_file: GetWheelFileFn, @@ -57,7 +57,7 @@ def test_version_noop_is_noop( # Act cli_cmd = [MAIN_PROG_NAME, "--noop", VERSION_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command head_after = repo.head.commit @@ -88,7 +88,7 @@ def test_version_noop_is_noop( ) def test_version_no_git_verify( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, mocked_git_push: MagicMock, post_mocker: Mocker, @@ -127,7 +127,7 @@ def test_version_no_git_verify( # Execute cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--patch"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Take measurement after the command head_after = repo.head.commit @@ -148,7 +148,7 @@ def test_version_no_git_verify( ) def test_version_on_nonrelease_branch( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -170,7 +170,7 @@ def test_version_on_nonrelease_branch( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate (expected -> actual) assert_successful_exit_code(result, cli_cmd) @@ -193,7 +193,7 @@ def test_version_on_nonrelease_branch( def test_version_on_last_release( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -219,7 +219,7 @@ def test_version_on_last_release( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) # take measurement after running the version command repo_status_after = repo.git.status(short=True) @@ -244,7 +244,7 @@ def test_version_on_last_release( ) def test_version_only_tag_push( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, ) -> None: @@ -265,7 +265,7 @@ def test_version_only_tag_push( "--no-commit", "--tag", ] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # capture values after the command tag_after = repo.tags[-1].name @@ -273,7 +273,7 @@ def test_version_only_tag_push( # Assert only tag was created, it was pushed and then release was created assert_successful_exit_code(result, cli_cmd) - assert tag_after == "v0.1.0" + assert tag_after == "v1.0.0" assert head_before == head_after assert mocked_git_push.call_count == 1 # 0 for commit, 1 for tag assert post_mocker.call_count == 1 diff --git a/tests/e2e/cmd_version/test_version_build.py b/tests/e2e/cmd_version/test_version_build.py index e2b42045f..390882f9e 100644 --- a/tests/e2e/cmd_version/test_version_build.py +++ b/tests/e2e/cmd_version/test_version_build.py @@ -13,15 +13,12 @@ from flatdict import FlatDict from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture -from semantic_release.cli.commands.main import main - from tests.const import MAIN_PROG_NAME, VERSION_SUBCMD from tests.fixtures.repos import repo_w_trunk_only_conventional_commits from tests.util import assert_successful_exit_code, get_func_qual_name if TYPE_CHECKING: - from click.testing import CliRunner - + from tests.conftest import RunCliFn from tests.fixtures.example_project import GetWheelFileFn, UpdatePyprojectTomlFn from tests.fixtures.git_repo import BuiltRepoResult @@ -67,7 +64,7 @@ def test_version_runs_build_command( repo_result: BuiltRepoResult, cli_args: list[str], next_release_version: str, - cli_runner: CliRunner, + run_cli: RunCliFn, shell: str, get_wheel_file: GetWheelFileFn, example_pyproject_toml: Path, @@ -100,10 +97,10 @@ def test_version_runs_build_command( wraps=subprocess.run, ) as patched_subprocess_run, mock.patch( get_func_qual_name(shellingham.detect_shell), return_value=(shell, shell) - ), mock.patch.dict(os.environ, patched_os_environment, clear=True): + ): # ACT: run & force a new version that will trigger the build command cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *cli_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:], env=patched_os_environment) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -147,13 +144,14 @@ def test_version_runs_build_command_windows( repo_result: BuiltRepoResult, cli_args: list[str], next_release_version: str, - cli_runner: CliRunner, + run_cli: RunCliFn, shell: str, get_wheel_file: GetWheelFileFn, example_pyproject_toml: Path, update_pyproject_toml: UpdatePyprojectTomlFn, mocked_git_push: mock.MagicMock, post_mocker: mock.Mock, + clean_os_environment: dict[str, str], ): if shell == "cmd": build_result_file = get_wheel_file("%NEW_VERSION%") @@ -177,9 +175,8 @@ def test_version_runs_build_command_windows( ) build_command = pyproject_config.get("tool.semantic_release.build_command", "") patched_os_environment = { + **clean_os_environment, "CI": "true", - "PATH": os.getenv("PATH", ""), - "HOME": "/home/username", "VIRTUAL_ENV": "./.venv", # Simulate that all CI's are set "GITHUB_ACTIONS": "true", @@ -187,29 +184,6 @@ def test_version_runs_build_command_windows( "GITEA_ACTIONS": "true", "BITBUCKET_REPO_FULL_NAME": "python-semantic-release/python-semantic-release.git", "PSR_DOCKER_GITHUB_ACTION": "true", - # Windows - "ALLUSERSAPPDATA": "C:\\ProgramData", - "ALLUSERSPROFILE": "C:\\ProgramData", - "APPDATA": "C:\\Users\\Username\\AppData\\Roaming", - "COMMONPROGRAMFILES": "C:\\Program Files\\Common Files", - "COMMONPROGRAMFILES(X86)": "C:\\Program Files (x86)\\Common Files", - "DEFAULTUSERPROFILE": "C:\\Users\\Default", - "HOMEPATH": "\\Users\\Username", - "PATHEXT": ".COM;.EXE;.BAT;.CMD;.VBS;.VBE;.JS;.JSE;.WSF;.WSH;.MSC", - "PROFILESFOLDER": "C:\\Users", - "PROGRAMFILES": "C:\\Program Files", - "PROGRAMFILES(X86)": "C:\\Program Files (x86)", - "SYSTEM": "C:\\Windows\\System32", - "SYSTEM16": "C:\\Windows\\System16", - "SYSTEM32": "C:\\Windows\\System32", - "SYSTEMDRIVE": "C:", - "SYSTEMROOT": "C:\\Windows", - "TEMP": "C:\\Users\\Username\\AppData\\Local\\Temp", - "TMP": "C:\\Users\\Username\\AppData\\Local\\Temp", - "USERPROFILE": "C:\\Users\\Username", - "USERSID": "S-1-5-21-1234567890-123456789-123456789-1234", - "USERNAME": "Username", # must include for python getpass.getuser() on windows - "WINDIR": "C:\\Windows", } # Wrap subprocess.run to capture the arguments to the call @@ -218,10 +192,10 @@ def test_version_runs_build_command_windows( wraps=subprocess.run, ) as patched_subprocess_run, mock.patch( get_func_qual_name(shellingham.detect_shell), return_value=(shell, shell) - ), mock.patch.dict(os.environ, patched_os_environment, clear=True): + ): # ACT: run & force a new version that will trigger the build command cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *cli_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:], env=patched_os_environment) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -229,42 +203,17 @@ def test_version_runs_build_command_windows( [shell, "/c" if shell == "cmd" else "-Command", build_command], check=True, env={ + **clean_os_environment, "NEW_VERSION": next_release_version, # injected into environment "CI": patched_os_environment["CI"], "BITBUCKET_CI": "true", # Converted "GITHUB_ACTIONS": patched_os_environment["GITHUB_ACTIONS"], "GITEA_ACTIONS": patched_os_environment["GITEA_ACTIONS"], "GITLAB_CI": patched_os_environment["GITLAB_CI"], - "HOME": patched_os_environment["HOME"], - "PATH": patched_os_environment["PATH"], "VIRTUAL_ENV": patched_os_environment["VIRTUAL_ENV"], "PSR_DOCKER_GITHUB_ACTION": patched_os_environment[ "PSR_DOCKER_GITHUB_ACTION" ], - # Windows - "ALLUSERSAPPDATA": patched_os_environment["ALLUSERSAPPDATA"], - "ALLUSERSPROFILE": patched_os_environment["ALLUSERSPROFILE"], - "APPDATA": patched_os_environment["APPDATA"], - "COMMONPROGRAMFILES": patched_os_environment["COMMONPROGRAMFILES"], - "COMMONPROGRAMFILES(X86)": patched_os_environment[ - "COMMONPROGRAMFILES(X86)" - ], - "DEFAULTUSERPROFILE": patched_os_environment["DEFAULTUSERPROFILE"], - "HOMEPATH": patched_os_environment["HOMEPATH"], - "PATHEXT": patched_os_environment["PATHEXT"], - "PROFILESFOLDER": patched_os_environment["PROFILESFOLDER"], - "PROGRAMFILES": patched_os_environment["PROGRAMFILES"], - "PROGRAMFILES(X86)": patched_os_environment["PROGRAMFILES(X86)"], - "SYSTEM": patched_os_environment["SYSTEM"], - "SYSTEM16": patched_os_environment["SYSTEM16"], - "SYSTEM32": patched_os_environment["SYSTEM32"], - "SYSTEMDRIVE": patched_os_environment["SYSTEMDRIVE"], - "SYSTEMROOT": patched_os_environment["SYSTEMROOT"], - "TEMP": patched_os_environment["TEMP"], - "TMP": patched_os_environment["TMP"], - "USERPROFILE": patched_os_environment["USERPROFILE"], - "USERSID": patched_os_environment["USERSID"], - "WINDIR": patched_os_environment["WINDIR"], }, ) @@ -288,18 +237,16 @@ def test_version_runs_build_command_w_user_env( repo_result: BuiltRepoResult, cli_args: list[str], next_release_version: str, - cli_runner: CliRunner, + run_cli: RunCliFn, example_pyproject_toml: Path, update_pyproject_toml: UpdatePyprojectTomlFn, + clean_os_environment: dict[str, str], ): # Setup patched_os_environment = { + **clean_os_environment, "CI": "true", - "PATH": os.getenv("PATH", ""), - "HOME": "/home/username", "VIRTUAL_ENV": "./.venv", - # Windows - "USERNAME": "Username", # must include for python getpass.getuser() on windows # Simulate that all CI's are set "GITHUB_ACTIONS": "true", "GITLAB_CI": "true", @@ -337,7 +284,7 @@ def test_version_runs_build_command_w_user_env( ) as patched_subprocess_run, mock.patch( get_func_qual_name(shellingham.detect_shell), return_value=("bash", "/usr/bin/bash"), - ), mock.patch.dict(os.environ, patched_os_environment, clear=True): + ): cli_cmd = [ MAIN_PROG_NAME, VERSION_SUBCMD, @@ -349,7 +296,7 @@ def test_version_runs_build_command_w_user_env( ] # ACT: run & force a new version that will trigger the build command - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:], env=patched_os_environment) # Evaluate # [1] Make sure it did not error internally @@ -360,14 +307,13 @@ def test_version_runs_build_command_w_user_env( ["bash", "-c", build_command], check=True, env={ + **clean_os_environment, "NEW_VERSION": next_release_version, # injected into environment "CI": patched_os_environment["CI"], "BITBUCKET_CI": "true", # Converted "GITHUB_ACTIONS": patched_os_environment["GITHUB_ACTIONS"], "GITEA_ACTIONS": patched_os_environment["GITEA_ACTIONS"], "GITLAB_CI": patched_os_environment["GITLAB_CI"], - "HOME": patched_os_environment["HOME"], - "PATH": patched_os_environment["PATH"], "VIRTUAL_ENV": patched_os_environment["VIRTUAL_ENV"], "PSR_DOCKER_GITHUB_ACTION": patched_os_environment[ "PSR_DOCKER_GITHUB_ACTION" @@ -384,7 +330,7 @@ def test_version_runs_build_command_w_user_env( @pytest.mark.usefixtures(repo_w_trunk_only_conventional_commits.__name__) def test_version_skips_build_command_with_skip_build( - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: mock.MagicMock, post_mocker: mock.Mock, ): @@ -395,7 +341,7 @@ def test_version_skips_build_command_with_skip_build( return_value=subprocess.CompletedProcess(args=(), returncode=0), ) as patched_subprocess_run: # Act: force a new version - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) diff --git a/tests/e2e/cmd_version/test_version_bump.py b/tests/e2e/cmd_version/test_version_bump.py index 245c05505..c08efb9ee 100644 --- a/tests/e2e/cmd_version/test_version_bump.py +++ b/tests/e2e/cmd_version/test_version_bump.py @@ -11,7 +11,6 @@ # Limitation in pytest-lazy-fixture - see https://stackoverflow.com/a/69884019 from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture -from semantic_release.cli.commands.main import main from semantic_release.commit_parser.conventional import ConventionalCommitParser from semantic_release.commit_parser.emoji import EmojiCommitParser from semantic_release.commit_parser.scipy import ScipyCommitParser @@ -35,6 +34,7 @@ repo_w_github_flow_w_feature_release_channel_conventional_commits, repo_w_initial_commit, repo_w_no_tags_conventional_commits, + repo_w_no_tags_conventional_commits_w_zero_version, repo_w_no_tags_emoji_commits, repo_w_no_tags_scipy_commits, repo_w_trunk_only_conventional_commits, @@ -58,10 +58,9 @@ if TYPE_CHECKING: from unittest.mock import MagicMock - from click.testing import CliRunner from requests_mock import Mocker - from tests.conftest import GetStableDateNowFn + from tests.conftest import GetStableDateNowFn, RunCliFn from tests.fixtures.example_project import ExProjectDir, UpdatePyprojectTomlFn from tests.fixtures.git_repo import BuiltRepoResult @@ -71,7 +70,9 @@ [ *( ( - lazy_fixture(repo_w_no_tags_conventional_commits.__name__), + lazy_fixture( + repo_w_no_tags_conventional_commits_w_zero_version.__name__ + ), cli_args, next_release_version, ) @@ -315,7 +316,7 @@ def test_version_force_level( next_release_version: str, example_project_dir: ExProjectDir, example_pyproject_toml: Path, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -347,7 +348,7 @@ def test_version_force_level( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *cli_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command head_after = repo.head.commit @@ -515,7 +516,7 @@ def test_version_next_greater_than_version_one_conventional( prerelease_token: str, next_release_version: str, branch_name: str, - cli_runner: CliRunner, + run_cli: RunCliFn, file_in_repo: str, mocked_git_push: MagicMock, post_mocker: Mocker, @@ -554,7 +555,7 @@ def test_version_next_greater_than_version_one_conventional( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *prerelease_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command head_after = repo.head.commit @@ -654,7 +655,7 @@ def test_version_next_greater_than_version_one_no_bump_conventional( prerelease_token: str, next_release_version: str, branch_name: str, - cli_runner: CliRunner, + run_cli: RunCliFn, file_in_repo: str, mocked_git_push: MagicMock, post_mocker: Mocker, @@ -693,7 +694,7 @@ def test_version_next_greater_than_version_one_no_bump_conventional( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *prerelease_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command head_after = repo.head.commit @@ -814,7 +815,7 @@ def test_version_next_greater_than_version_one_emoji( prerelease_token: str, next_release_version: str, branch_name: str, - cli_runner: CliRunner, + run_cli: RunCliFn, file_in_repo: str, mocked_git_push: MagicMock, post_mocker: Mocker, @@ -853,7 +854,7 @@ def test_version_next_greater_than_version_one_emoji( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *prerelease_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command head_after = repo.head.commit @@ -953,7 +954,7 @@ def test_version_next_greater_than_version_one_no_bump_emoji( prerelease_token: str, next_release_version: str, branch_name: str, - cli_runner: CliRunner, + run_cli: RunCliFn, file_in_repo: str, mocked_git_push: MagicMock, post_mocker: Mocker, @@ -992,7 +993,7 @@ def test_version_next_greater_than_version_one_no_bump_emoji( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *prerelease_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command head_after = repo.head.commit @@ -1113,7 +1114,7 @@ def test_version_next_greater_than_version_one_scipy( prerelease_token: str, next_release_version: str, branch_name: str, - cli_runner: CliRunner, + run_cli: RunCliFn, file_in_repo: str, mocked_git_push: MagicMock, post_mocker: Mocker, @@ -1152,7 +1153,7 @@ def test_version_next_greater_than_version_one_scipy( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *prerelease_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command head_after = repo.head.commit @@ -1252,7 +1253,7 @@ def test_version_next_greater_than_version_one_no_bump_scipy( prerelease_token: str, next_release_version: str, branch_name: str, - cli_runner: CliRunner, + run_cli: RunCliFn, file_in_repo: str, mocked_git_push: MagicMock, post_mocker: Mocker, @@ -1291,7 +1292,7 @@ def test_version_next_greater_than_version_one_no_bump_scipy( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *prerelease_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command head_after = repo.head.commit @@ -1592,7 +1593,7 @@ def test_version_next_w_zero_dot_versions_conventional( branch_name: str, major_on_zero: bool, allow_zero_version: bool, - cli_runner: CliRunner, + run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, mocked_git_push: MagicMock, @@ -1638,7 +1639,7 @@ def test_version_next_w_zero_dot_versions_conventional( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *prerelease_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command head_after = repo.head.commit @@ -1746,7 +1747,7 @@ def test_version_next_w_zero_dot_versions_no_bump_conventional( branch_name: str, major_on_zero: bool, allow_zero_version: bool, - cli_runner: CliRunner, + run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, mocked_git_push: MagicMock, @@ -1792,7 +1793,7 @@ def test_version_next_w_zero_dot_versions_no_bump_conventional( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *prerelease_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command head_after = repo.head.commit @@ -2072,7 +2073,7 @@ def test_version_next_w_zero_dot_versions_emoji( branch_name: str, major_on_zero: bool, allow_zero_version: bool, - cli_runner: CliRunner, + run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, mocked_git_push: MagicMock, @@ -2118,7 +2119,7 @@ def test_version_next_w_zero_dot_versions_emoji( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *prerelease_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command head_after = repo.head.commit @@ -2226,7 +2227,7 @@ def test_version_next_w_zero_dot_versions_no_bump_emoji( branch_name: str, major_on_zero: bool, allow_zero_version: bool, - cli_runner: CliRunner, + run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, mocked_git_push: MagicMock, @@ -2272,7 +2273,7 @@ def test_version_next_w_zero_dot_versions_no_bump_emoji( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *prerelease_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command head_after = repo.head.commit @@ -2552,7 +2553,7 @@ def test_version_next_w_zero_dot_versions_scipy( branch_name: str, major_on_zero: bool, allow_zero_version: bool, - cli_runner: CliRunner, + run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, mocked_git_push: MagicMock, @@ -2598,7 +2599,7 @@ def test_version_next_w_zero_dot_versions_scipy( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *prerelease_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command head_after = repo.head.commit @@ -2706,7 +2707,7 @@ def test_version_next_w_zero_dot_versions_no_bump_scipy( branch_name: str, major_on_zero: bool, allow_zero_version: bool, - cli_runner: CliRunner, + run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, mocked_git_push: MagicMock, @@ -2752,7 +2753,7 @@ def test_version_next_w_zero_dot_versions_no_bump_scipy( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *prerelease_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command head_after = repo.head.commit @@ -3095,7 +3096,7 @@ def test_version_next_w_zero_dot_versions_minimums( branch_name: str, major_on_zero: bool, allow_zero_version: bool, - cli_runner: CliRunner, + run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, mocked_git_push: MagicMock, @@ -3142,7 +3143,7 @@ def test_version_next_w_zero_dot_versions_minimums( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *prerelease_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command head_after = repo.head.commit diff --git a/tests/e2e/cmd_version/test_version_changelog.py b/tests/e2e/cmd_version/test_version_changelog.py index 19a3bb3ba..a2ce88bf4 100644 --- a/tests/e2e/cmd_version/test_version_changelog.py +++ b/tests/e2e/cmd_version/test_version_changelog.py @@ -9,7 +9,6 @@ from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture from semantic_release.changelog.context import ChangelogMode -from semantic_release.cli.commands.main import main from semantic_release.cli.config import ChangelogOutputFormat from tests.const import MAIN_PROG_NAME, VERSION_SUBCMD @@ -34,6 +33,7 @@ repo_w_github_flow_w_feature_release_channel_emoji_commits, repo_w_github_flow_w_feature_release_channel_scipy_commits, repo_w_no_tags_conventional_commits, + repo_w_no_tags_conventional_commits_unmasked_initial_release, repo_w_no_tags_emoji_commits, repo_w_no_tags_scipy_commits, repo_w_trunk_only_conventional_commits, @@ -48,9 +48,7 @@ if TYPE_CHECKING: from pathlib import Path - from click.testing import CliRunner - - from tests.conftest import FormatDateStrFn, GetStableDateNowFn + from tests.conftest import FormatDateStrFn, GetStableDateNowFn, RunCliFn from tests.fixtures.example_project import UpdatePyprojectTomlFn from tests.fixtures.git_repo import ( BuiltRepoResult, @@ -176,7 +174,7 @@ def test_version_updates_changelog_w_new_version( get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, tag_format: str, update_pyproject_toml: UpdatePyprojectTomlFn, - cli_runner: CliRunner, + run_cli: RunCliFn, changelog_file: Path, insertion_flag: str, cache: pytest.Cache, @@ -255,7 +253,7 @@ def test_version_updates_changelog_w_new_version( with freeze_time(now_datetime.astimezone(timezone.utc)): cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--no-push", "--changelog"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Capture the new changelog content (os aware because of expected content) with changelog_file.open(newline=os.linesep) as rfd: @@ -307,7 +305,152 @@ def test_version_updates_changelog_wo_prev_releases( repo_result: BuiltRepoResult, cache_key: str, cache: pytest.Cache, - cli_runner: CliRunner, + run_cli: RunCliFn, + update_pyproject_toml: UpdatePyprojectTomlFn, + changelog_format: ChangelogOutputFormat, + changelog_file: Path, + insertion_flag: str, + stable_now_date: GetStableDateNowFn, + format_date_str: FormatDateStrFn, +): + """ + Given the repository has no releases and the user has provided a initialized changelog, + When the version command is run with changelog.mode set to "update", + Then the version is created and the changelog file is updated with only an initial release statement + """ + if not (repo_build_data := cache.get(cache_key, None)): + pytest.fail("Repo build date not found in cache") + + repo_build_datetime = datetime.strptime(repo_build_data["build_date"], "%Y-%m-%d") + now_datetime = stable_now_date().replace( + year=repo_build_datetime.year, + month=repo_build_datetime.month, + day=repo_build_datetime.day, + ) + repo_build_date_str = format_date_str(now_datetime) + + # Custom text to maintain (must be different from the default) + custom_text = "---{ls}{ls}Custom footer text{ls}".format(ls=os.linesep) + + # Set the project configurations + update_pyproject_toml( + "tool.semantic_release.changelog.mode", ChangelogMode.UPDATE.value + ) + update_pyproject_toml( + "tool.semantic_release.changelog.default_templates.changelog_file", + str(changelog_file.name), + ) + + version = "v1.0.0" + rst_version_header = f"{version} ({repo_build_date_str})" + txt_after_insertion_flag = { + ChangelogOutputFormat.MARKDOWN: str.join( + os.linesep, + [ + f"## {version} ({repo_build_date_str})", + "", + "- Initial Release", + ], + ), + ChangelogOutputFormat.RESTRUCTURED_TEXT: str.join( + os.linesep, + [ + f".. _changelog-{version}:", + "", + rst_version_header, + f"{'=' * len(rst_version_header)}", + "", + "* Initial Release", + ], + ), + } + + # Capture and modify the current changelog content to become the expected output + # We much use os.linesep here since the insertion flag is os-specific + with changelog_file.open(newline=os.linesep) as rfd: + initial_changelog_parts = rfd.read().split(insertion_flag) + + # content is os-specific because of the insertion flag & how we read the original file + expected_changelog_content = str.join( + insertion_flag, + [ + initial_changelog_parts[0], + str.join( + os.linesep, + [ + os.linesep, + txt_after_insertion_flag[changelog_format], + "", + custom_text, + ], + ), + ], + ) + + # Grab the Unreleased changelog & create the initialized user changelog + # force output to not perform any newline translations + with changelog_file.open(mode="w", newline="") as wfd: + wfd.write( + str.join( + insertion_flag, + [initial_changelog_parts[0], f"{os.linesep * 2}{custom_text}"], + ) + ) + wfd.flush() + + # Act + with freeze_time(now_datetime.astimezone(timezone.utc)): + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--no-push", "--changelog"] + result = run_cli(cli_cmd[1:]) + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + + # Ensure changelog exists + assert changelog_file.exists() + + # Capture the new changelog content (os aware because of expected content) + with changelog_file.open(newline=os.linesep) as rfd: + actual_content = rfd.read() + + # Check that the changelog footer is maintained and updated with Unreleased info + assert expected_changelog_content == actual_content + + +@pytest.mark.parametrize( + "changelog_format, changelog_file, insertion_flag", + [ + ( + ChangelogOutputFormat.MARKDOWN, + lazy_fixture(example_changelog_md.__name__), + lazy_fixture(default_md_changelog_insertion_flag.__name__), + ), + ( + ChangelogOutputFormat.RESTRUCTURED_TEXT, + lazy_fixture(example_changelog_rst.__name__), + lazy_fixture(default_rst_changelog_insertion_flag.__name__), + ), + ], +) +@pytest.mark.parametrize( + "repo_result, cache_key", + [ + pytest.param( + lazy_fixture(repo_fixture), + f"psr/repos/{repo_fixture}", + marks=pytest.mark.comprehensive, + ) + for repo_fixture in [ + # Must not have a single release/tag + repo_w_no_tags_conventional_commits_unmasked_initial_release.__name__, + ] + ], +) +def test_version_updates_changelog_wo_prev_releases_n_unmasked_initial_release( + repo_result: BuiltRepoResult, + cache_key: str, + cache: pytest.Cache, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, changelog_format: ChangelogOutputFormat, changelog_file: Path, @@ -343,7 +486,7 @@ def test_version_updates_changelog_wo_prev_releases( str(changelog_file.name), ) - version = "v0.1.0" + version = "v1.0.0" rst_version_header = f"{version} ({repo_build_date_str})" search_n_replacements = { ChangelogOutputFormat.MARKDOWN: ( @@ -408,7 +551,7 @@ def test_version_updates_changelog_wo_prev_releases( # Act with freeze_time(now_datetime.astimezone(timezone.utc)): cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--no-push", "--changelog"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -532,7 +675,7 @@ def test_version_initializes_changelog_in_update_mode_w_no_prev_changelog( cache_key: str, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, tag_format: str, - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, changelog_file: Path, cache: pytest.Cache, @@ -581,7 +724,7 @@ def test_version_initializes_changelog_in_update_mode_w_no_prev_changelog( # Act with freeze_time(now_datetime.astimezone(timezone.utc)): cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--no-push", "--changelog"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -611,7 +754,7 @@ def test_version_initializes_changelog_in_update_mode_w_no_prev_changelog( @pytest.mark.usefixtures(repo_w_trunk_only_conventional_commits.__name__) def test_version_maintains_changelog_in_update_mode_w_no_flag( changelog_file: Path, - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, insertion_flag: str, ): @@ -641,7 +784,7 @@ def test_version_maintains_changelog_in_update_mode_w_no_flag( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--no-push", "--changelog"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -687,7 +830,7 @@ def test_version_updates_changelog_w_new_version_n_filtered_commit( commit_type: CommitConvention, tag_format: str, update_pyproject_toml: UpdatePyprojectTomlFn, - cli_runner: CliRunner, + run_cli: RunCliFn, changelog_file: Path, stable_now_date: GetStableDateNowFn, get_commits_from_repo_build_def: GetCommitsFromRepoBuildDefFn, @@ -740,7 +883,7 @@ def test_version_updates_changelog_w_new_version_n_filtered_commit( # Act with freeze_time(now_datetime.astimezone(timezone.utc)): cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--no-push", "--changelog"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Capture the new changelog content (os aware because of expected content) actual_content = changelog_file.read_text() @@ -748,4 +891,6 @@ def test_version_updates_changelog_w_new_version_n_filtered_commit( # Evaluate assert_successful_exit_code(result, cli_cmd) assert expected_changelog_content == actual_content - assert expected_bump_message in actual_content + + for msg_part in expected_bump_message.split("\n\n"): + assert msg_part.capitalize() in actual_content diff --git a/tests/e2e/cmd_version/test_version_changelog_custom_commit_msg.py b/tests/e2e/cmd_version/test_version_changelog_custom_commit_msg.py index d7ee1d08f..acff3e728 100644 --- a/tests/e2e/cmd_version/test_version_changelog_custom_commit_msg.py +++ b/tests/e2e/cmd_version/test_version_changelog_custom_commit_msg.py @@ -10,7 +10,6 @@ from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture from semantic_release.changelog.context import ChangelogMode -from semantic_release.cli.commands.main import main from tests.const import ( MAIN_PROG_NAME, @@ -35,9 +34,7 @@ from pathlib import Path from typing import TypedDict - from click.testing import CliRunner - - from tests.conftest import GetStableDateNowFn + from tests.conftest import GetStableDateNowFn, RunCliFn from tests.e2e.conftest import GetSanitizedChangelogContentFn from tests.fixtures.example_project import UpdatePyprojectTomlFn from tests.fixtures.git_repo import ( @@ -102,7 +99,7 @@ class Commit2SectionCommit(TypedDict): ) for commit_msg in [ dedent( - # Conventional compliant prefix with skip-ci idicator + # Conventional compliant prefix with skip-ci indicator """\ chore(release): v{version} [skip ci] @@ -120,7 +117,7 @@ def test_version_changelog_content_custom_commit_message_excluded_automatically( get_cfg_value_from_def: GetCfgValueFromDefFn, split_repo_actions_by_release_tags: SplitRepoActionsByReleaseTagsFn, build_repo_from_definition: BuildRepoFromDefinitionFn, - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, changelog_file: Path, changelog_mode: ChangelogMode, @@ -192,7 +189,7 @@ def test_version_changelog_content_custom_commit_message_excluded_automatically( # Act: make the first release again with freeze_time(now_datetime.astimezone(timezone.utc)): - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) assert_successful_exit_code(result, cli_cmd) # Act: apply commits for change of version @@ -206,7 +203,7 @@ def test_version_changelog_content_custom_commit_message_excluded_automatically( # Act: make the second release again with freeze_time(now_datetime.astimezone(timezone.utc) + timedelta(minutes=1)): - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) actual_content = get_sanitized_changelog_content( repo_dir=example_project_dir, diff --git a/tests/e2e/cmd_version/test_version_github_actions.py b/tests/e2e/cmd_version/test_version_github_actions.py index c79e34b15..53917e706 100644 --- a/tests/e2e/cmd_version/test_version_github_actions.py +++ b/tests/e2e/cmd_version/test_version_github_actions.py @@ -4,8 +4,6 @@ import pytest -from semantic_release.cli.commands.main import main - from tests.const import MAIN_PROG_NAME, VERSION_SUBCMD from tests.fixtures.repos import ( repo_w_git_flow_w_alpha_prereleases_n_conventional_commits, @@ -13,26 +11,30 @@ from tests.util import actions_output_to_dict, assert_successful_exit_code if TYPE_CHECKING: - from pathlib import Path - - from click.testing import CliRunner + from tests.conftest import RunCliFn + from tests.fixtures.example_project import ExProjectDir @pytest.mark.usefixtures( repo_w_git_flow_w_alpha_prereleases_n_conventional_commits.__name__ ) def test_version_writes_github_actions_output( - cli_runner: CliRunner, - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, + run_cli: RunCliFn, + example_project_dir: ExProjectDir, ): - mock_output_file = tmp_path / "action.out" - monkeypatch.setenv("GITHUB_OUTPUT", str(mock_output_file.resolve())) + mock_output_file = example_project_dir / "action.out" + # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--patch", "--no-push"] + result = run_cli( + cli_cmd[1:], env={"GITHUB_OUTPUT": str(mock_output_file.resolve())} + ) + assert_successful_exit_code(result, cli_cmd) - # Act - result = cli_runner.invoke(main, cli_cmd[1:]) + if not mock_output_file.exists(): + pytest.fail( + f"Expected output file {mock_output_file} to be created, but it does not exist." + ) # Extract the output action_outputs = actions_output_to_dict( @@ -40,7 +42,6 @@ def test_version_writes_github_actions_output( ) # Evaluate - assert_successful_exit_code(result, cli_cmd) assert "released" in action_outputs assert action_outputs["released"] == "true" assert "version" in action_outputs diff --git a/tests/e2e/cmd_version/test_version_print.py b/tests/e2e/cmd_version/test_version_print.py index a259036cb..4efd1e02f 100644 --- a/tests/e2e/cmd_version/test_version_print.py +++ b/tests/e2e/cmd_version/test_version_print.py @@ -5,7 +5,7 @@ import pytest from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture -from semantic_release.cli.commands.main import main +from semantic_release.hvcs.github import Github from tests.const import ( MAIN_PROG_NAME, @@ -21,6 +21,7 @@ ) from tests.fixtures.repos.trunk_based_dev.repo_w_no_tags import ( repo_w_no_tags_conventional_commits_using_tag_format, + repo_w_no_tags_conventional_commits_w_zero_version, ) from tests.util import ( add_text_to_file, @@ -31,9 +32,9 @@ if TYPE_CHECKING: from unittest.mock import MagicMock - from click.testing import CliRunner from requests_mock import Mocker + from tests.conftest import RunCliFn from tests.fixtures.git_repo import ( BuiltRepoResult, GetCfgValueFromDefFn, @@ -96,7 +97,7 @@ def test_version_print_next_version( force_args: list[str], next_release_version: str, file_in_repo: str, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -128,7 +129,7 @@ def test_version_print_next_version( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print", *force_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) # take measurement after running the version command repo_status_after = repo.git.status(short=True) @@ -138,7 +139,6 @@ def test_version_print_next_version( # Evaluate assert_successful_exit_code(result, cli_cmd) - assert not result.stderr assert f"{next_release_version}\n" == result.stdout # assert nothing else happened (no code changes, no commit, no tag, no push, no vcs release) @@ -213,8 +213,7 @@ def test_version_print_next_version( marks=pytest.mark.comprehensive, ) for repo_fixture_name in ( - repo_w_no_tags_conventional_commits.__name__, - repo_w_no_tags_conventional_commits_using_tag_format.__name__, + repo_w_no_tags_conventional_commits_w_zero_version.__name__, ) for cli_args, next_release_version in ( # Dynamic version bump determination (based on commits) @@ -262,7 +261,7 @@ def test_version_print_tag_prints_next_tag( next_release_version: str, get_cfg_value_from_def: GetCfgValueFromDefFn, file_in_repo: str, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -298,7 +297,122 @@ def test_version_print_tag_prints_next_tag( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-tag", *force_args] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + + # take measurement after running the version command + repo_status_after = repo.git.status(short=True) + head_after = repo.head.commit.hexsha + tags_after = {tag.name for tag in repo.tags} + tags_set_difference = set.difference(tags_after, tags_before) + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + assert f"{next_release_tag}\n" == result.stdout + + # assert nothing else happened (no code changes, no commit, no tag, no push, no vcs release) + assert repo_status_before == repo_status_after + assert head_before == head_after + assert not tags_set_difference + assert mocked_git_push.call_count == 0 + assert post_mocker.call_count == 0 + + +@pytest.mark.parametrize( + "repo_result, commits, force_args, next_release_version", + [ + pytest.param( + lazy_fixture(repo_fixture_name), + [], + cli_args, + next_release_version, + marks=pytest.mark.comprehensive, + ) + for repo_fixture_name in ( + repo_w_no_tags_conventional_commits.__name__, + repo_w_no_tags_conventional_commits_using_tag_format.__name__, + ) + for cli_args, next_release_version in ( + # Dynamic version bump determination (based on commits) + ([], "1.0.0"), + # Dynamic version bump determination (based on commits) with build metadata + (["--build-metadata", "build.12345"], "1.0.0+build.12345"), + # Forced version bump + (["--prerelease"], "0.0.0-rc.1"), + (["--patch"], "0.0.1"), + (["--minor"], "0.1.0"), + (["--major"], "1.0.0"), + # Forced version bump with --build-metadata + (["--patch", "--build-metadata", "build.12345"], "0.0.1+build.12345"), + # Forced version bump with --as-prerelease + (["--prerelease", "--as-prerelease"], "0.0.0-rc.1"), + (["--patch", "--as-prerelease"], "0.0.1-rc.1"), + (["--minor", "--as-prerelease"], "0.1.0-rc.1"), + (["--major", "--as-prerelease"], "1.0.0-rc.1"), + # Forced version bump with --as-prerelease and modified --prerelease-token + ( + ["--patch", "--as-prerelease", "--prerelease-token", "beta"], + "0.0.1-beta.1", + ), + # Forced version bump with --as-prerelease and modified --prerelease-token + # and --build-metadata + ( + [ + "--patch", + "--as-prerelease", + "--prerelease-token", + "beta", + "--build-metadata", + "build.12345", + ], + "0.0.1-beta.1+build.12345", + ), + ) + ], +) +def test_version_print_tag_prints_next_tag_no_zero_versions( + repo_result: BuiltRepoResult, + commits: list[str], + force_args: list[str], + next_release_version: str, + get_cfg_value_from_def: GetCfgValueFromDefFn, + file_in_repo: str, + run_cli: RunCliFn, + mocked_git_push: MagicMock, + post_mocker: Mocker, +): + """ + Given a generic repository at the latest release version and a subsequent commit, + When running the version command with the --print-tag flag, + Then the expected next release tag should be printed and exit without + making any changes to the repository. + + Note: The point of this test is to only verify that the `--print-tag` flag does not + make any changes to the repository--not to validate if the next version is calculated + correctly per the repository structure (see test_version_release & + test_version_force_level for correctness). + + However, we do validate that --print-tag & a force option and/or --as-prerelease options + work together to print the next release tag correctly but not make a change to the repo. + """ + repo = repo_result["repo"] + repo_def = repo_result["definition"] + tag_format_str: str = get_cfg_value_from_def(repo_def, "tag_format_str") # type: ignore[assignment] + next_release_tag = tag_format_str.format(version=next_release_version) + + if len(commits) > 1: + # Make a commit to ensure we have something to release + # otherwise the "no release will be made" logic will kick in first + add_text_to_file(repo, file_in_repo) + repo.git.commit(m=commits[-1], a=True) + + # Setup: take measurement before running the version command + repo_status_before = repo.git.status(short=True) + head_before = repo.head.commit.hexsha + tags_before = {tag.name for tag in repo.tags} + + # Act + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-tag", *force_args] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) # take measurement after running the version command repo_status_after = repo.git.status(short=True) @@ -308,7 +422,6 @@ def test_version_print_tag_prints_next_tag( # Evaluate assert_successful_exit_code(result, cli_cmd) - assert not result.stderr assert f"{next_release_tag}\n" == result.stdout # assert nothing else happened (no code changes, no commit, no tag, no push, no vcs release) @@ -326,7 +439,7 @@ def test_version_print_tag_prints_next_tag( def test_version_print_last_released_prints_version( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -342,7 +455,7 @@ def test_version_print_last_released_prints_version( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-last-released"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) # take measurement after running the version command repo_status_after = repo.git.status(short=True) @@ -376,7 +489,7 @@ def test_version_print_last_released_prints_released_if_commits( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, commits: list[str], - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, file_in_repo: str, @@ -397,7 +510,7 @@ def test_version_print_last_released_prints_released_if_commits( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-last-released"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command repo_status_after = repo.git.status(short=True) @@ -424,7 +537,7 @@ def test_version_print_last_released_prints_released_if_commits( ) def test_version_print_last_released_prints_nothing_if_no_tags( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, caplog: pytest.LogCaptureFixture, @@ -438,7 +551,7 @@ def test_version_print_last_released_prints_nothing_if_no_tags( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-last-released"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command repo_status_after = repo.git.status(short=True) @@ -469,7 +582,7 @@ def test_version_print_last_released_prints_nothing_if_no_tags( def test_version_print_last_released_on_detached_head( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -488,7 +601,7 @@ def test_version_print_last_released_on_detached_head( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-last-released"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command repo_status_after = repo.git.status(short=True) @@ -516,7 +629,7 @@ def test_version_print_last_released_on_detached_head( def test_version_print_last_released_on_nonrelease_branch( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -535,7 +648,7 @@ def test_version_print_last_released_on_nonrelease_branch( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-last-released"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command repo_status_after = repo.git.status(short=True) @@ -572,7 +685,7 @@ def test_version_print_last_released_tag_prints_correct_tag( repo_result: BuiltRepoResult, get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -589,7 +702,7 @@ def test_version_print_last_released_tag_prints_correct_tag( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-last-released-tag"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command repo_status_after = repo.git.status(short=True) @@ -631,7 +744,7 @@ def test_version_print_last_released_tag_prints_released_if_commits( get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, commits: list[str], - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, file_in_repo: str, @@ -653,7 +766,7 @@ def test_version_print_last_released_tag_prints_released_if_commits( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-last-released-tag"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command repo_status_after = repo.git.status(short=True) @@ -680,7 +793,7 @@ def test_version_print_last_released_tag_prints_released_if_commits( ) def test_version_print_last_released_tag_prints_nothing_if_no_tags( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, caplog: pytest.LogCaptureFixture, @@ -694,7 +807,7 @@ def test_version_print_last_released_tag_prints_nothing_if_no_tags( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-last-released-tag"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command repo_status_after = repo.git.status(short=True) @@ -734,7 +847,7 @@ def test_version_print_last_released_tag_on_detached_head( repo_result: BuiltRepoResult, get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -754,7 +867,7 @@ def test_version_print_last_released_tag_on_detached_head( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-last-released-tag"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command repo_status_after = repo.git.status(short=True) @@ -791,7 +904,7 @@ def test_version_print_last_released_tag_on_nonrelease_branch( repo_result: BuiltRepoResult, get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -811,7 +924,7 @@ def test_version_print_last_released_tag_on_nonrelease_branch( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-last-released-tag"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command repo_status_after = repo.git.status(short=True) @@ -843,7 +956,7 @@ def test_version_print_last_released_tag_on_nonrelease_branch( ) def test_version_print_next_version_fails_on_detached_head( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, simulate_change_commits_n_rtn_changelog_entry: SimulateChangeCommitsNReturnChangelogEntryFn, get_commit_def_fn: GetCommitDefFn, mocked_git_push: MagicMock, @@ -870,7 +983,7 @@ def test_version_print_next_version_fails_on_detached_head( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command repo_status_after = repo.git.status(short=True) @@ -902,7 +1015,7 @@ def test_version_print_next_version_fails_on_detached_head( ) def test_version_print_next_tag_fails_on_detached_head( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, simulate_change_commits_n_rtn_changelog_entry: SimulateChangeCommitsNReturnChangelogEntryFn, get_commit_def_fn: GetCommitDefFn, mocked_git_push: MagicMock, @@ -929,7 +1042,7 @@ def test_version_print_next_tag_fails_on_detached_head( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--print-tag"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command repo_status_after = repo.git.status(short=True) diff --git a/tests/e2e/cmd_version/test_version_release_notes.py b/tests/e2e/cmd_version/test_version_release_notes.py index ccd82dc77..e21059335 100644 --- a/tests/e2e/cmd_version/test_version_release_notes.py +++ b/tests/e2e/cmd_version/test_version_release_notes.py @@ -8,7 +8,6 @@ from freezegun import freeze_time from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture -from semantic_release.cli.commands.main import main from semantic_release.version.version import Version from tests.const import ( @@ -27,10 +26,9 @@ if TYPE_CHECKING: from unittest.mock import MagicMock - from click.testing import CliRunner from requests_mock import Mocker - from tests.conftest import GetStableDateNowFn + from tests.conftest import GetStableDateNowFn, RunCliFn from tests.e2e.conftest import ( RetrieveRuntimeContextFn, ) @@ -48,13 +46,13 @@ @pytest.mark.parametrize( "repo_result, next_release_version", [ - (lazy_fixture(repo_w_no_tags_conventional_commits.__name__), "0.1.0"), + (lazy_fixture(repo_w_no_tags_conventional_commits.__name__), "1.0.0"), ], ) def test_custom_release_notes_template( repo_result: BuiltRepoResult, next_release_version: str, - cli_runner: CliRunner, + run_cli: RunCliFn, use_release_notes_template: UseReleaseNotesTemplateFn, retrieve_runtime_context: RetrieveRuntimeContextFn, mocked_git_push: MagicMock, @@ -69,7 +67,7 @@ def test_custom_release_notes_template( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--vcs-release"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Must run this after the action because the release history object should be pulled from the # repository after a tag is created @@ -100,14 +98,16 @@ def test_custom_release_notes_template( @pytest.mark.parametrize( - "repo_result, license_name, license_setting", + "repo_result, license_name, license_setting, mask_initial_release", [ pytest.param( lazy_fixture(repo_fixture_name), license_name, license_setting, + mask_initial_release, marks=pytest.mark.comprehensive, ) + for mask_initial_release in [True, False] for license_name in ["", "MIT", "GPL-3.0"] for license_setting in [ "project.license-expression", @@ -123,9 +123,10 @@ def test_custom_release_notes_template( ) def test_default_release_notes_license_statement( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, license_name: str, license_setting: str, + mask_initial_release: bool, update_pyproject_toml: UpdatePyprojectTomlFn, mocked_git_push: MagicMock, post_mocker: Mocker, @@ -133,7 +134,7 @@ def test_default_release_notes_license_statement( get_hvcs_client_from_repo_def: GetHvcsClientFromRepoDefFn, generate_default_release_notes_from_def: GenerateDefaultReleaseNotesFromDefFn, ): - new_version = "0.1.0" + new_version = "1.0.0" # Setup now_datetime = stable_now_date() @@ -153,18 +154,24 @@ def test_default_release_notes_license_statement( # Setup: set the license for the test update_pyproject_toml(license_setting, license_name) + # Setup: set mask_initial_release value in configuration + update_pyproject_toml( + "tool.semantic_release.changelog.default_templates.mask_initial_release", + mask_initial_release, + ) + expected_release_notes = generate_default_release_notes_from_def( version_actions=repo_def, hvcs=get_hvcs_client_from_repo_def(repo_def), previous_version=None, license_name=license_name, - mask_initial_release=False, + mask_initial_release=mask_initial_release, ) # Act with freeze_time(now_datetime.astimezone(timezone.utc)): cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--no-changelog", "--vcs-release"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) diff --git a/tests/e2e/cmd_version/test_version_stamp.py b/tests/e2e/cmd_version/test_version_stamp.py index 9d45b6019..a12059f37 100644 --- a/tests/e2e/cmd_version/test_version_stamp.py +++ b/tests/e2e/cmd_version/test_version_stamp.py @@ -11,7 +11,6 @@ from dotty_dict import Dotty from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture -from semantic_release.cli.commands.main import main from semantic_release.version.declarations.enum import VersionStampType from tests.const import EXAMPLE_PROJECT_NAME, MAIN_PROG_NAME, VERSION_SUBCMD @@ -29,8 +28,7 @@ if TYPE_CHECKING: from unittest.mock import MagicMock - from click.testing import CliRunner - + from tests.conftest import RunCliFn from tests.fixtures.example_project import ExProjectDir, UpdatePyprojectTomlFn from tests.fixtures.git_repo import BuiltRepoResult @@ -58,7 +56,7 @@ def test_version_only_stamp_version( repo_result: BuiltRepoResult, expected_new_version: str, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: MagicMock, example_pyproject_toml: Path, @@ -93,7 +91,7 @@ def test_version_only_stamp_version( # Act (stamp the version but also create the changelog) cli_cmd = [*VERSION_STAMP_CMD, "--minor"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # take measurement after running the version command head_after = repo.head.commit @@ -145,11 +143,11 @@ def test_version_only_stamp_version( @pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_stamp_version_variables_python( - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, example_project_dir: ExProjectDir, ) -> None: - new_version = "0.1.0" + new_version = "1.0.0" target_file = example_project_dir.joinpath( "src", EXAMPLE_PROJECT_NAME, "_version.py" ) @@ -162,7 +160,7 @@ def test_stamp_version_variables_python( # Act cli_cmd = VERSION_STAMP_CMD - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Check the result assert_successful_exit_code(result, cli_cmd) @@ -178,12 +176,12 @@ def test_stamp_version_variables_python( @pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_stamp_version_toml( - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, default_tag_format_str: str, ) -> None: orig_version = "0.0.0" - new_version = "0.1.0" + new_version = "1.0.0" orig_release = default_tag_format_str.format(version=orig_version) new_release = default_tag_format_str.format(version=new_version) target_file = Path("example.toml") @@ -213,7 +211,7 @@ def test_stamp_version_toml( # Act cli_cmd = VERSION_STAMP_CMD - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Check the result assert_successful_exit_code(result, cli_cmd) @@ -234,11 +232,11 @@ def test_stamp_version_toml( @pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_stamp_version_variables_yaml( - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, ) -> None: orig_version = "0.0.0" - new_version = "0.1.0" + new_version = "1.0.0" target_file = Path("example.yml") orig_yaml = dedent( f"""\ @@ -258,7 +256,7 @@ def test_stamp_version_variables_yaml( # Act cli_cmd = VERSION_STAMP_CMD - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Check the result assert_successful_exit_code(result, cli_cmd) @@ -277,7 +275,7 @@ def test_stamp_version_variables_yaml( @pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_stamp_version_variables_yaml_cff( - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, ) -> None: """ @@ -288,7 +286,7 @@ def test_stamp_version_variables_yaml_cff( Based on https://github.com/python-semantic-release/python-semantic-release/issues/962 """ orig_version = "0.0.0" - new_version = "0.1.0" + new_version = "1.0.0" target_file = Path("CITATION.cff") orig_yaml = dedent( f"""\ @@ -314,7 +312,7 @@ def test_stamp_version_variables_yaml_cff( # Act cli_cmd = VERSION_STAMP_CMD - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Check the result assert_successful_exit_code(result, cli_cmd) @@ -333,11 +331,11 @@ def test_stamp_version_variables_yaml_cff( @pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_stamp_version_variables_json( - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, ) -> None: orig_version = "0.0.0" - new_version = "0.1.0" + new_version = "1.0.0" target_file = Path("plugins.json") orig_json = { "id": "test-plugin", @@ -356,7 +354,7 @@ def test_stamp_version_variables_json( # Act cli_cmd = VERSION_STAMP_CMD - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Check the result assert_successful_exit_code(result, cli_cmd) @@ -375,7 +373,7 @@ def test_stamp_version_variables_json( @pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_stamp_version_variables_yaml_github_actions( - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, default_tag_format_str: str, ) -> None: @@ -387,7 +385,7 @@ def test_stamp_version_variables_yaml_github_actions( Based on https://github.com/python-semantic-release/python-semantic-release/issues/1156 """ orig_version = "0.0.0" - new_version = "0.1.0" + new_version = "1.0.0" target_file = Path("combined.yml") action1_yaml_filepath = "my-org/my-actions/.github/workflows/action1.yml" action2_yaml_filepath = "my-org/my-actions/.github/workflows/action2.yml" @@ -425,7 +423,7 @@ def test_stamp_version_variables_yaml_github_actions( # Act cli_cmd = VERSION_STAMP_CMD - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Check the result assert_successful_exit_code(result, cli_cmd) @@ -447,7 +445,7 @@ def test_stamp_version_variables_yaml_github_actions( @pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_stamp_version_variables_yaml_kustomization_container_spec( - cli_runner: CliRunner, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, default_tag_format_str: str, ) -> None: @@ -459,7 +457,7 @@ def test_stamp_version_variables_yaml_kustomization_container_spec( Based on https://github.com/python-semantic-release/python-semantic-release/issues/846 """ orig_version = "0.0.0" - new_version = "0.1.0" + new_version = "1.0.0" target_file = Path("kustomization.yaml") orig_yaml = dedent( f"""\ @@ -483,7 +481,7 @@ def test_stamp_version_variables_yaml_kustomization_container_spec( # Act cli_cmd = VERSION_STAMP_CMD - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Check the result assert_successful_exit_code(result, cli_cmd) diff --git a/tests/e2e/cmd_version/test_version_strict.py b/tests/e2e/cmd_version/test_version_strict.py index c8dcb56a5..951a9966f 100644 --- a/tests/e2e/cmd_version/test_version_strict.py +++ b/tests/e2e/cmd_version/test_version_strict.py @@ -5,7 +5,7 @@ import pytest from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture -from semantic_release.cli.commands.main import main +from semantic_release.hvcs.github import Github from tests.const import MAIN_PROG_NAME, VERSION_SUBCMD from tests.fixtures.repos import repo_w_trunk_only_conventional_commits @@ -14,9 +14,9 @@ if TYPE_CHECKING: from unittest.mock import MagicMock - from click.testing import CliRunner from requests_mock import Mocker + from tests.conftest import RunCliFn from tests.fixtures.git_repo import BuiltRepoResult, GetVersionsFromRepoBuildDefFn @@ -27,7 +27,7 @@ def test_version_already_released_when_strict( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -50,7 +50,7 @@ def test_version_already_released_when_strict( # Act cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) # take measurement after running the version command repo_status_after = repo.git.status(short=True) @@ -75,7 +75,7 @@ def test_version_already_released_when_strict( ) def test_version_on_nonrelease_branch_when_strict( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -98,7 +98,7 @@ def test_version_on_nonrelease_branch_when_strict( # Act cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_exit_code(2, result, cli_cmd) diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 66aa8ab3d..b64d5aecf 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -137,11 +137,10 @@ def get_sanitized_rst_changelog_content( def _get_sanitized_rst_changelog_content( repo_dir: Path, - remove_insertion_flag: bool = True, + remove_insertion_flag: bool = False, ) -> str: - # TODO: v10 change -- default turns to update so this is not needed - # Because we are in init mode, the insertion flag is not present in the changelog - # we must take it out manually because our repo generation fixture includes it automatically + # Note that our repo generation fixture includes the insertion flag automatically + # toggle remove_insertion_flag to True to remove the insertion flag, applies to Init mode repos with (repo_dir / changelog_rst_file).open(newline=os.linesep) as rfd: # use os.linesep here because the insertion flag is os-specific # but convert the content to universal newlines for comparison @@ -169,11 +168,10 @@ def get_sanitized_md_changelog_content( ) -> GetSanitizedChangelogContentFn: def _get_sanitized_md_changelog_content( repo_dir: Path, - remove_insertion_flag: bool = True, + remove_insertion_flag: bool = False, ) -> str: - # TODO: v10 change -- default turns to update so this is not needed - # Because we are in init mode, the insertion flag is not present in the changelog - # we must take it out manually because our repo generation fixture includes it automatically + # Note that our repo generation fixture includes the insertion flag automatically + # toggle remove_insertion_flag to True to remove the insertion flag, applies to Init mode repos with (repo_dir / changelog_md_file).open(newline=os.linesep) as rfd: # use os.linesep here because the insertion flag is os-specific # but convert the content to universal newlines for comparison diff --git a/tests/e2e/test_help.py b/tests/e2e/test_help.py index a31454efd..0119586d0 100644 --- a/tests/e2e/test_help.py +++ b/tests/e2e/test_help.py @@ -17,9 +17,8 @@ if TYPE_CHECKING: from click import Command - from click.testing import CliRunner - from git import Repo + from tests.conftest import RunCliFn from tests.fixtures import UpdatePyprojectTomlFn from tests.fixtures.git_repo import BuiltRepoResult @@ -39,7 +38,7 @@ def test_help_no_repo( help_option: str, command: Command, - cli_runner: CliRunner, + run_cli: RunCliFn, change_to_ex_proj_dir: None, ): """ @@ -70,7 +69,7 @@ def test_help_no_repo( ) # Run the command with the help option - result = cli_runner.invoke(main, args, prog_name=MAIN_PROG_NAME) + result = run_cli(args, invoke_kwargs={"prog_name": MAIN_PROG_NAME}) # Evaluate result assert_exit_code(HELP_EXIT_CODE, result, [MAIN_PROG_NAME, *args]) @@ -89,7 +88,7 @@ def test_help_no_repo( def test_help_valid_config( help_option: str, command: Command, - cli_runner: CliRunner, + run_cli: RunCliFn, ): """ Test that the help message is displayed when the current directory is a git repository @@ -118,7 +117,7 @@ def test_help_valid_config( ) # Run the command with the help option - result = cli_runner.invoke(main, args, prog_name=MAIN_PROG_NAME) + result = run_cli(args, invoke_kwargs={"prog_name": MAIN_PROG_NAME}) # Evaluate result assert_exit_code(HELP_EXIT_CODE, result, [MAIN_PROG_NAME, *args]) @@ -133,11 +132,11 @@ def test_help_valid_config( (main, changelog, generate_config, publish, version), ids=lambda cmd: cmd.name, ) +@pytest.mark.usefixtures(repo_w_trunk_only_conventional_commits.__name__) def test_help_invalid_config( help_option: str, command: Command, - cli_runner: CliRunner, - repo_w_trunk_only_conventional_commits: Repo, + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, ): """ @@ -171,7 +170,7 @@ def test_help_invalid_config( ) # Run the command with the help option - result = cli_runner.invoke(main, args, prog_name=MAIN_PROG_NAME) + result = run_cli(args, invoke_kwargs={"prog_name": MAIN_PROG_NAME}) # Evaluate result assert_exit_code(HELP_EXIT_CODE, result, [MAIN_PROG_NAME, *args]) @@ -192,7 +191,7 @@ def test_help_invalid_config( def test_help_non_release_branch( help_option: str, command: Command, - cli_runner: CliRunner, + run_cli: RunCliFn, repo_result: BuiltRepoResult, ): """ @@ -226,7 +225,7 @@ def test_help_non_release_branch( ) # Run the command with the help option - result = cli_runner.invoke(main, args, prog_name=MAIN_PROG_NAME) + result = run_cli(args, invoke_kwargs={"prog_name": MAIN_PROG_NAME}) # Evaluate result assert_exit_code(HELP_EXIT_CODE, result, [MAIN_PROG_NAME, *args]) diff --git a/tests/e2e/test_main.py b/tests/e2e/test_main.py index fc65c7f21..ff290146e 100644 --- a/tests/e2e/test_main.py +++ b/tests/e2e/test_main.py @@ -11,7 +11,6 @@ from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture from semantic_release import __version__ -from semantic_release.cli.commands.main import main from tests.const import MAIN_PROG_NAME, SUCCESS_EXIT_CODE, VERSION_SUBCMD from tests.fixtures.repos import repo_w_no_tags_conventional_commits @@ -20,8 +19,7 @@ if TYPE_CHECKING: from pathlib import Path - from click.testing import CliRunner - + from tests.conftest import RunCliFn from tests.fixtures.example_project import ExProjectDir, UpdatePyprojectTomlFn from tests.fixtures.git_repo import BuiltRepoResult @@ -50,20 +48,19 @@ def test_entrypoint_scripts(project_script_name: str): assert not proc.stderr -def test_main_prints_version_and_exits(cli_runner: CliRunner): +def test_main_prints_version_and_exits(run_cli: RunCliFn): cli_cmd = [MAIN_PROG_NAME, "--version"] # Act - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) assert result.output == f"semantic-release, version {__version__}\n" -def test_main_no_args_prints_help_text(cli_runner: CliRunner): - result = cli_runner.invoke(main, []) - assert_successful_exit_code(result, [MAIN_PROG_NAME]) +def test_main_no_args_prints_help_text(run_cli: RunCliFn): + assert_successful_exit_code(run_cli(), [MAIN_PROG_NAME]) @pytest.mark.parametrize( @@ -71,14 +68,14 @@ def test_main_no_args_prints_help_text(cli_runner: CliRunner): [lazy_fixture(repo_w_no_tags_conventional_commits.__name__)], ) def test_not_a_release_branch_exit_code( - repo_result: BuiltRepoResult, cli_runner: CliRunner + repo_result: BuiltRepoResult, run_cli: RunCliFn ): # Run anything that doesn't trigger the help text repo_result["repo"].git.checkout("-b", "branch-does-not-exist") # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--no-commit"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -90,14 +87,14 @@ def test_not_a_release_branch_exit_code( ) def test_not_a_release_branch_exit_code_with_strict( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, ): # Run anything that doesn't trigger the help text repo_result["repo"].git.checkout("-b", "branch-does-not-exist") # Act cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD, "--no-commit"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_exit_code(2, result, cli_cmd) @@ -109,7 +106,7 @@ def test_not_a_release_branch_exit_code_with_strict( ) def test_not_a_release_branch_detached_head_exit_code( repo_result: BuiltRepoResult, - cli_runner: CliRunner, + run_cli: RunCliFn, ): expected_err_msg = ( "Detached HEAD state cannot match any release groups; no release will be made" @@ -120,7 +117,7 @@ def test_not_a_release_branch_detached_head_exit_code( # Act cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--no-commit"] - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # detached head states should throw an error as release branches cannot be determined assert_exit_code(1, result, cli_cmd) @@ -153,7 +150,7 @@ def json_file_with_no_configuration_for_psr(tmp_path: Path) -> Path: @pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_default_config_is_used_when_none_in_toml_config_file( - cli_runner: CliRunner, + run_cli: RunCliFn, toml_file_with_no_configuration_for_psr: Path, ): cli_cmd = [ @@ -165,7 +162,7 @@ def test_default_config_is_used_when_none_in_toml_config_file( ] # Act - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -173,7 +170,7 @@ def test_default_config_is_used_when_none_in_toml_config_file( @pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_default_config_is_used_when_none_in_json_config_file( - cli_runner: CliRunner, + run_cli: RunCliFn, json_file_with_no_configuration_for_psr: Path, ): cli_cmd = [ @@ -185,7 +182,7 @@ def test_default_config_is_used_when_none_in_json_config_file( ] # Act - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) @@ -193,7 +190,7 @@ def test_default_config_is_used_when_none_in_json_config_file( @pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_errors_when_config_file_does_not_exist_and_passed_explicitly( - cli_runner: CliRunner, + run_cli: RunCliFn, ): cli_cmd = [ MAIN_PROG_NAME, @@ -204,7 +201,7 @@ def test_errors_when_config_file_does_not_exist_and_passed_explicitly( ] # Act - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_exit_code(2, result, cli_cmd) @@ -213,14 +210,14 @@ def test_errors_when_config_file_does_not_exist_and_passed_explicitly( @pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_errors_when_config_file_invalid_configuration( - cli_runner: CliRunner, update_pyproject_toml: UpdatePyprojectTomlFn + run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn ): # Setup update_pyproject_toml("tool.semantic_release.remote.type", "invalidType") cli_cmd = [MAIN_PROG_NAME, "--config", "pyproject.toml", VERSION_SUBCMD] # Act - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # preprocess results stderr_lines = result.stderr.splitlines() @@ -232,7 +229,7 @@ def test_errors_when_config_file_invalid_configuration( def test_uses_default_config_when_no_config_file_found( - cli_runner: CliRunner, + run_cli: RunCliFn, example_project_dir: ExProjectDir, change_to_ex_proj_dir: None, ): @@ -252,7 +249,7 @@ def test_uses_default_config_when_no_config_file_found( cli_cmd = [MAIN_PROG_NAME, "--noop", VERSION_SUBCMD] # Act - result = cli_runner.invoke(main, cli_cmd[1:]) + result = run_cli(cli_cmd[1:]) # Evaluate assert_successful_exit_code(result, cli_cmd) diff --git a/tests/fixtures/example_project.py b/tests/fixtures/example_project.py index 7575ec96f..5d0c60886 100644 --- a/tests/fixtures/example_project.py +++ b/tests/fixtures/example_project.py @@ -248,7 +248,7 @@ def default_changelog_md_template() -> Path: return Path( str( files(semantic_release.__name__).joinpath( - Path("data", "templates", "angular", "md", "CHANGELOG.md.j2") + Path("data", "templates", "conventional", "md", "CHANGELOG.md.j2") ) ) ).resolve() @@ -260,7 +260,7 @@ def default_changelog_rst_template() -> Path: return Path( str( files(semantic_release.__name__).joinpath( - Path("data", "templates", "angular", "rst", "CHANGELOG.rst.j2") + Path("data", "templates", "conventional", "rst", "CHANGELOG.rst.j2") ) ) ).resolve() diff --git a/tests/fixtures/git_repo.py b/tests/fixtures/git_repo.py index 048f77b3a..106bc55e4 100644 --- a/tests/fixtures/git_repo.py +++ b/tests/fixtures/git_repo.py @@ -8,7 +8,7 @@ from pathlib import Path from textwrap import dedent from time import sleep -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from unittest import mock import pytest @@ -126,7 +126,7 @@ def __call__( hvcs_domain: str = ..., tag_format_str: str | None = None, extra_configs: dict[str, TomlSerializableTypes] | None = None, - mask_initial_release: bool = False, + mask_initial_release: bool = True, # Default as of v10 ) -> tuple[Path, HvcsBase]: ... class CommitNReturnChangelogEntryFn(Protocol): @@ -167,6 +167,7 @@ def __call__( self, build_definition: Sequence[RepoActions], filter_4_changelog: bool = False, + ignore_merge_commits: bool = False, ) -> RepoDefinition: ... RepoDefinition: TypeAlias = dict[VersionStr, RepoVersionDef] # type: ignore[misc] # mypy is thoroughly confused @@ -183,7 +184,7 @@ def __call__( dest_file: Path | None = None, max_version: str | None = None, output_format: ChangelogOutputFormat = ChangelogOutputFormat.MARKDOWN, - mask_initial_release: bool = False, + mask_initial_release: bool = True, # Default as of v10 ) -> str: ... class FormatGitSquashCommitMsgFn(Protocol): @@ -342,7 +343,8 @@ def __call__( hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, tag_format_str: str | None = None, extra_configs: dict[str, TomlSerializableTypes] | None = None, - mask_initial_release: bool = False, + mask_initial_release: bool = ..., + ignore_merge_commits: bool = True, # Default as of v10 ) -> Sequence[RepoActions]: ... class BuildRepoFromDefinitionFn(Protocol): @@ -403,7 +405,7 @@ def __call__( previous_version: Version | None = None, license_name: str = "", dest_file: Path | None = None, - mask_initial_release: bool = False, + mask_initial_release: bool = True, # Default as of v10 ) -> str: ... class GetHvcsClientFromRepoDefFn(Protocol): @@ -533,15 +535,11 @@ def _get_commit_def_of_conventional_commit(msg: str) -> CommitDef: "include_in_changelog": False, } - descriptions = list(parsed_result.descriptions) - if parsed_result.linked_merge_request: - descriptions[0] = str.join("(", descriptions[0].split("(")[:-1]).strip() - return { "msg": msg, "type": parsed_result.type, "category": parsed_result.category, - "desc": str.join("\n\n", descriptions), + "desc": str.join("\n\n", parsed_result.descriptions), "brking_desc": str.join("\n\n", parsed_result.breaking_descriptions), "scope": parsed_result.scope, "mr": parsed_result.linked_merge_request, @@ -570,15 +568,11 @@ def _get_commit_def_of_emoji_commit(msg: str) -> CommitDef: "include_in_changelog": False, } - descriptions = list(parsed_result.descriptions) - if parsed_result.linked_merge_request: - descriptions[0] = str.join("(", descriptions[0].split("(")[:-1]).strip() - return { "msg": msg, "type": parsed_result.type, "category": parsed_result.category, - "desc": str.join("\n\n", descriptions), + "desc": str.join("\n\n", parsed_result.descriptions), "brking_desc": str.join("\n\n", parsed_result.breaking_descriptions), "scope": parsed_result.scope, "mr": parsed_result.linked_merge_request, @@ -607,15 +601,11 @@ def _get_commit_def_of_scipy_commit(msg: str) -> CommitDef: "include_in_changelog": False, } - descriptions = list(parsed_result.descriptions) - if parsed_result.linked_merge_request: - descriptions[0] = str.join("(", descriptions[0].split("(")[:-1]).strip() - return { "msg": msg, "type": parsed_result.type, "category": parsed_result.category, - "desc": str.join("\n\n", descriptions), + "desc": str.join("\n\n", parsed_result.descriptions), "brking_desc": str.join("\n\n", parsed_result.breaking_descriptions), "scope": parsed_result.scope, "mr": parsed_result.linked_merge_request, @@ -967,10 +957,16 @@ def _get_hvcs_client_from_repo_def( # Prevent the HVCS client from using the environment variables with mock.patch.dict(os.environ, {}, clear=True): - return hvcs_client_class( - example_git_https_url, - hvcs_domain=get_cfg_value_from_def(repo_def, "hvcs_domain"), + hvcs_client = cast( + "HvcsBase", + hvcs_client_class( + example_git_https_url, + hvcs_domain=get_cfg_value_from_def(repo_def, "hvcs_domain"), + ), ) + # Force the HVCS client to attempt to resolve the repo name (as we generally cache it) + assert hvcs_client.repo_name + return cast("Github | Gitlab | Gitea | Bitbucket", hvcs_client) return _get_hvcs_client_from_repo_def @@ -1004,7 +1000,7 @@ def _build_configured_base_repo( # noqa: C901 hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, tag_format_str: str | None = None, extra_configs: dict[str, TomlSerializableTypes] | None = None, - mask_initial_release: bool = False, + mask_initial_release: bool = True, # Default as of v10 ) -> tuple[Path, HvcsBase]: if not cached_example_git_project.exists(): raise RuntimeError("Unable to find cached git project files!") @@ -1037,7 +1033,9 @@ def _build_configured_base_repo( # noqa: C901 raise ValueError(f"Unknown HVCS client name: {hvcs_client_name}") # Create HVCS Client instance - hvcs = hvcs_class(example_git_https_url, hvcs_domain=hvcs_domain) + with mock.patch.dict(os.environ, {}, clear=True): + hvcs = hvcs_class(example_git_https_url, hvcs_domain=hvcs_domain) + assert hvcs.repo_name # Force the HVCS client to cache the repo name # Set tag format in configuration if tag_format_str is not None: @@ -1144,21 +1142,7 @@ def _separate_squashed_commit_def( "msg": squashed_message, "type": parsed_result.type, "category": parsed_result.category, - "desc": str.join( - "\n\n", - ( - [ - # Strip out any MR references (since v9 doesn't) to prep for changelog generatro - # TODO: remove in v10, as the parser will remove the MR reference - str.join( - "(", parsed_result.descriptions[0].split("(")[:-1] - ).strip(), - *parsed_result.descriptions[1:], - ] - if parsed_result.linked_merge_request - else [*parsed_result.descriptions] - ), - ), + "desc": str.join("\n\n", parsed_result.descriptions), "brking_desc": str.join("\n\n", parsed_result.breaking_descriptions), "scope": parsed_result.scope, "mr": parsed_result.linked_merge_request or squashed_commit_def["mr"], @@ -1275,7 +1259,7 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c repo_dir = Path(dest_dir) hvcs: Github | Gitlab | Gitea | Bitbucket tag_format_str: str - mask_initial_release: bool = False + mask_initial_release: bool = True # Default as of v10 current_commits: list[CommitDef] = [] current_repo_def: RepoDefinition = {} @@ -1470,6 +1454,7 @@ def get_commits_from_repo_build_def() -> GetCommitsFromRepoBuildDefFn: def _get_commits( build_definition: Sequence[RepoActions], filter_4_changelog: bool = False, + ignore_merge_commits: bool = False, ) -> RepoDefinition: # Extract the commits from the build definition repo_def: RepoDefinition = {} @@ -1494,7 +1479,14 @@ def _get_commits( if "commit_def" in build_step["details"]: commit_def = build_step["details"]["commit_def"] # type: ignore[typeddict-item] - if filter_4_changelog and not commit_def["include_in_changelog"]: + if any( + ( + ignore_merge_commits + and build_step["action"] == RepoActionStep.GIT_MERGE, + filter_4_changelog + and not commit_def["include_in_changelog"], + ) + ): continue commits.append(commit_def) @@ -1676,10 +1668,11 @@ def build_version_entry_markdown( else: commit_cl_desc = f"{commit_cl_desc} {sha_link}\n" - if len(descriptions) > 1: - commit_cl_desc += ( - "\n" + str.join("\n\n", [*descriptions[1:]]) + "\n" - ) + # COMMENTED out for v10 as the defualt changelog now only writes the subject line + # if len(descriptions) > 1: + # commit_cl_desc += ( + # "\n" + str.join("\n\n", [*descriptions[1:]]) + "\n" + # ) # Add commits to section if commit_cl_desc not in section_bullets: @@ -1789,10 +1782,11 @@ def build_version_entry_restructured_text( else: commit_cl_desc = f"{commit_cl_desc} {sha_link}\n" - if len(descriptions) > 1: - commit_cl_desc += ( - "\n" + str.join("\n\n", [*descriptions[1:]]) + "\n" - ) + # COMMENTED out for v10 as the defualt changelog now only writes the subject line + # if len(descriptions) > 1: + # commit_cl_desc += ( + # "\n" + str.join("\n\n", [*descriptions[1:]]) + "\n" + # ) # Add commits to section if commit_cl_desc not in section_bullets: @@ -1884,8 +1878,7 @@ def _mimic_semantic_release_default_changelog( dest_file: Path | None = None, max_version: str | None = None, output_format: ChangelogOutputFormat = ChangelogOutputFormat.MARKDOWN, - # TODO: Breaking v10, when default is toggled to true, also change this to True - mask_initial_release: bool = False, + mask_initial_release: bool = True, # Default as of v10 ) -> str: if output_format == ChangelogOutputFormat.MARKDOWN: header = dedent( @@ -2100,8 +2093,7 @@ def _generate_default_release_notes( previous_version: Version | None = None, license_name: str = "", dest_file: Path | None = None, - # TODO: Breaking v10, when default is toggled to true, also change this to True - mask_initial_release: bool = False, + mask_initial_release: bool = True, # Default as of v10 ) -> str: limited_repo_def: RepoDefinition = get_commits_from_repo_build_def( build_definition=version_actions, diff --git a/tests/fixtures/repos/git_flow/repo_w_1_release_channel.py b/tests/fixtures/repos/git_flow/repo_w_1_release_channel.py index c624a7965..91eb5ffa5 100644 --- a/tests/fixtures/repos/git_flow/repo_w_1_release_channel.py +++ b/tests/fixtures/repos/git_flow/repo_w_1_release_channel.py @@ -102,7 +102,8 @@ def _get_repo_from_defintion( hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, tag_format_str: str | None = None, extra_configs: dict[str, TomlSerializableTypes] | None = None, - mask_initial_release: bool = False, + mask_initial_release: bool = True, + ignore_merge_commits: bool = True, ) -> Sequence[RepoActions]: stable_now_datetime = stable_now_date() commit_timestamp_gen = ( @@ -111,7 +112,7 @@ def _get_repo_from_defintion( ) # Common static actions or components - changelog_file_definitons: Sequence[RepoActionWriteChangelogsDestFile] = [ + changelog_file_definitions: Sequence[RepoActionWriteChangelogsDestFile] = [ { "path": changelog_md_file, "format": ChangelogOutputFormat.MARKDOWN, @@ -156,7 +157,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEFAULT_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -272,7 +273,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -295,7 +296,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -359,7 +360,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -382,7 +383,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -429,7 +430,7 @@ def _get_repo_from_defintion( "\n\n", [ "API: add revolutionary feature", - "BREAKING CHANGE: this is a breaking change", + "This is a breaking change", ], ), "datetime": next(commit_timestamp_gen), @@ -464,7 +465,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -487,7 +488,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -516,9 +517,9 @@ def _get_repo_from_defintion( "commits": convert_commit_specs_to_commit_defs( [ { - "conventional": "fix: correct a bug", - "emoji": ":bug: correct a bug", - "scipy": "BUG: correct a bug", + "conventional": "fix: correct a bug\n\nCloses: #123\n", + "emoji": ":bug: correct a bug\n\nCloses: #123\n", + "scipy": "BUG: correct a bug\n\nCloses: #123\n", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -551,7 +552,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -574,7 +575,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -638,7 +639,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -661,7 +662,7 @@ def _get_repo_from_defintion( { "conventional": "feat(cli): add new config cli command", "emoji": ":sparkles: (cli) add new config cli command", - "scipy": "ENH(cli): add new config cli command", + "scipy": "ENH: cli: add new config cli command", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -694,7 +695,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -717,7 +718,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], diff --git a/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py b/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py index f4a6005bc..3d75af918 100644 --- a/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py +++ b/tests/fixtures/repos/git_flow/repo_w_2_release_channels.py @@ -102,7 +102,8 @@ def _get_repo_from_defintion( hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, tag_format_str: str | None = None, extra_configs: dict[str, TomlSerializableTypes] | None = None, - mask_initial_release: bool = False, + mask_initial_release: bool = True, + ignore_merge_commits: bool = True, ) -> Sequence[RepoActions]: stable_now_datetime = stable_now_date() commit_timestamp_gen = ( @@ -111,7 +112,7 @@ def _get_repo_from_defintion( ) # Common static actions or components - changelog_file_definitons: Sequence[RepoActionWriteChangelogsDestFile] = [ + changelog_file_definitions: Sequence[RepoActionWriteChangelogsDestFile] = [ { "path": changelog_md_file, "format": ChangelogOutputFormat.MARKDOWN, @@ -156,7 +157,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEFAULT_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -278,7 +279,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -301,7 +302,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -351,7 +352,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -388,7 +389,7 @@ def _get_repo_from_defintion( "\n\n", [ "API: add revolutionary feature", - "BREAKING CHANGE: this is a breaking change", + "This is a breaking change", ], ), "datetime": next(commit_timestamp_gen), @@ -409,7 +410,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -463,7 +464,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -486,7 +487,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -517,7 +518,7 @@ def _get_repo_from_defintion( { "conventional": "feat(cli): add new config cli command", "emoji": ":sparkles: (cli) add new config cli command", - "scipy": "ENH(cli): add new config cli command", + "scipy": "ENH: cli: add new config cli command", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -550,7 +551,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -573,7 +574,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -602,9 +603,9 @@ def _get_repo_from_defintion( "commits": convert_commit_specs_to_commit_defs( [ { - "conventional": "fix(config): fixed configuration generation", - "emoji": ":bug: (config) fixed configuration generation", - "scipy": "MAINT(config): fixed configuration generation", + "conventional": "fix(config): fixed configuration generation\n\nCloses: #123", + "emoji": ":bug: (config) fixed configuration generation\n\nCloses: #123", + "scipy": "MAINT:config: fixed configuration generation\n\nCloses: #123", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -637,7 +638,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -660,7 +661,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -710,7 +711,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -731,14 +732,14 @@ def _get_repo_from_defintion( { "conventional": "fix(scope): correct some text", "emoji": ":bug: (scope) correct some text", - "scipy": "MAINT(scope): correct some text", + "scipy": "MAINT:scope: correct some text", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, { "conventional": "feat(scope): add some more text", "emoji": ":sparkles:(scope) add some more text", - "scipy": "ENH(scope): add some more text", + "scipy": "ENH: scope: add some more text", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -757,7 +758,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], diff --git a/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py b/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py index 10cc98ff8..d52b60f97 100644 --- a/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py +++ b/tests/fixtures/repos/git_flow/repo_w_3_release_channels.py @@ -104,7 +104,8 @@ def _get_repo_from_defintion( hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, tag_format_str: str | None = None, extra_configs: dict[str, TomlSerializableTypes] | None = None, - mask_initial_release: bool = False, + mask_initial_release: bool = True, + ignore_merge_commits: bool = True, ) -> Sequence[RepoActions]: stable_now_datetime = stable_now_date() commit_timestamp_gen = ( @@ -113,7 +114,7 @@ def _get_repo_from_defintion( ) # Common static actions or components - changelog_file_definitons: Sequence[RepoActionWriteChangelogsDestFile] = [ + changelog_file_definitions: Sequence[RepoActionWriteChangelogsDestFile] = [ { "path": changelog_md_file, "format": ChangelogOutputFormat.MARKDOWN, @@ -158,7 +159,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEFAULT_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -286,7 +287,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -309,7 +310,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -359,7 +360,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -396,7 +397,7 @@ def _get_repo_from_defintion( "\n\n", [ "API: add revolutionary feature", - "BREAKING CHANGE: this is a breaking change", + "This is a breaking change", ], ), "datetime": next(commit_timestamp_gen), @@ -417,7 +418,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -454,7 +455,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -470,7 +471,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -500,7 +501,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -531,7 +532,7 @@ def _get_repo_from_defintion( { "conventional": "feat(cli): add new config cli command", "emoji": ":sparkles: (cli) add new config cli command", - "scipy": "ENH(cli): add new config cli command", + "scipy": "ENH:cli: add new config cli command", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -550,7 +551,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -571,7 +572,7 @@ def _get_repo_from_defintion( { "conventional": "feat(config): add new config option", "emoji": ":sparkles: (config) add new config option", - "scipy": "ENH(config): add new config option", + "scipy": "ENH: config: add new config option", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -590,7 +591,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -627,7 +628,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -650,7 +651,7 @@ def _get_repo_from_defintion( { "conventional": "fix(cli): fix config cli command", "emoji": ":bug: (cli) fix config cli command", - "scipy": "BUG(cli): fix config cli command", + "scipy": "BUG:cli: fix config cli command", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -683,7 +684,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -699,7 +700,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -727,9 +728,9 @@ def _get_repo_from_defintion( "commits": convert_commit_specs_to_commit_defs( [ { - "conventional": "fix(config): fix config option", - "emoji": ":bug: (config) fix config option", - "scipy": "BUG(config): fix config option", + "conventional": "fix(config): fix config option\n\nImplements: #123\n", + "emoji": ":bug: (config) fix config option\n\nImplements: #123\n", + "scipy": "BUG: config: fix config option\n\nImplements: #123\n", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -762,7 +763,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -778,7 +779,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -808,7 +809,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], diff --git a/tests/fixtures/repos/git_flow/repo_w_4_release_channels.py b/tests/fixtures/repos/git_flow/repo_w_4_release_channels.py index d6abbb5df..5bdb76d7d 100644 --- a/tests/fixtures/repos/git_flow/repo_w_4_release_channels.py +++ b/tests/fixtures/repos/git_flow/repo_w_4_release_channels.py @@ -114,7 +114,8 @@ def _get_repo_from_defintion( hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, tag_format_str: str | None = None, extra_configs: dict[str, TomlSerializableTypes] | None = None, - mask_initial_release: bool = False, + mask_initial_release: bool = True, + ignore_merge_commits: bool = True, ) -> Sequence[RepoActions]: stable_now_datetime = stable_now_date() commit_timestamp_gen = ( @@ -123,7 +124,7 @@ def _get_repo_from_defintion( ) # Common static actions or components - changelog_file_definitons: Sequence[RepoActionWriteChangelogsDestFile] = [ + changelog_file_definitions: Sequence[RepoActionWriteChangelogsDestFile] = [ { "path": changelog_md_file, "format": ChangelogOutputFormat.MARKDOWN, @@ -182,7 +183,7 @@ def _get_repo_from_defintion( tgt_branch_name=BETA_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -209,7 +210,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEFAULT_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -351,7 +352,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -381,7 +382,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -411,9 +412,9 @@ def _get_repo_from_defintion( "commits": convert_commit_specs_to_commit_defs( [ { - "conventional": "fix(cli): fix config cli command", - "emoji": ":bug: (cli) fix config cli command", - "scipy": "BUG(cli): fix config cli command", + "conventional": "fix(cli): fix config cli command\n\nCloses: #123\n", + "emoji": ":bug: (cli) fix config cli command\n\nCloses: #123\n", + "scipy": "BUG:cli: fix config cli command\n\nCloses: #123\n", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -446,7 +447,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -462,7 +463,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -492,7 +493,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -523,7 +524,7 @@ def _get_repo_from_defintion( { "conventional": "fix(config): fix config option", "emoji": ":bug: (config) fix config option", - "scipy": "BUG(config): fix config option", + "scipy": "BUG: config: fix config option", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -556,7 +557,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -572,7 +573,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -602,7 +603,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -634,7 +635,7 @@ def _get_repo_from_defintion( { "conventional": "feat(feat-2): add another primary feature", "emoji": ":sparkles: (feat-2) add another primary feature", - "scipy": "ENH(feat-2): add another primary feature", + "scipy": "ENH: feat-2: add another primary feature", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -653,7 +654,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -690,7 +691,7 @@ def _get_repo_from_defintion( tgt_branch_name=DEV_BRANCH_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -706,7 +707,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -736,7 +737,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -766,7 +767,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], diff --git a/tests/fixtures/repos/github_flow/repo_w_default_release.py b/tests/fixtures/repos/github_flow/repo_w_default_release.py index ce8877dfe..61816f0bf 100644 --- a/tests/fixtures/repos/github_flow/repo_w_default_release.py +++ b/tests/fixtures/repos/github_flow/repo_w_default_release.py @@ -91,13 +91,14 @@ def get_repo_definition_4_github_flow_repo_w_default_release_channel( for a single release channel on the default branch. """ - def _get_repo_from_defintion( + def _get_repo_from_definition( commit_type: CommitConvention, hvcs_client_name: str = "github", hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, tag_format_str: str | None = None, extra_configs: dict[str, TomlSerializableTypes] | None = None, - mask_initial_release: bool = False, + mask_initial_release: bool = True, + ignore_merge_commits: bool = True, ) -> Sequence[RepoActions]: stable_now_datetime = stable_now_date() commit_timestamp_gen = ( @@ -106,7 +107,7 @@ def _get_repo_from_defintion( ) pr_num_gen = (i for i in count(start=2, step=1)) - changelog_file_definitons: Sequence[RepoActionWriteChangelogsDestFile] = [ + changelog_file_definitions: Sequence[RepoActionWriteChangelogsDestFile] = [ { "path": changelog_md_file, "format": ChangelogOutputFormat.MARKDOWN, @@ -182,7 +183,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -193,9 +194,9 @@ def _get_repo_from_defintion( fix_branch_1_commits: Sequence[CommitSpec] = [ { - "conventional": "fix(cli): add missing text", - "emoji": ":bug: add missing text", - "scipy": "MAINT: add missing text", + "conventional": "fix(cli): add missing text\n\nResolves: #123\n", + "emoji": ":bug: add missing text\n\nResolves: #123\n", + "scipy": "MAINT: add missing text\n\nResolves: #123\n", "datetime": next(commit_timestamp_gen), }, ] @@ -326,7 +327,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -377,7 +378,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -388,7 +389,7 @@ def _get_repo_from_defintion( return repo_construction_steps - return _get_repo_from_defintion + return _get_repo_from_definition @pytest.fixture(scope="session") diff --git a/tests/fixtures/repos/github_flow/repo_w_release_channels.py b/tests/fixtures/repos/github_flow/repo_w_release_channels.py index 07be6eb5a..87c57b609 100644 --- a/tests/fixtures/repos/github_flow/repo_w_release_channels.py +++ b/tests/fixtures/repos/github_flow/repo_w_release_channels.py @@ -97,7 +97,8 @@ def _get_repo_from_defintion( hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, tag_format_str: str | None = None, extra_configs: dict[str, TomlSerializableTypes] | None = None, - mask_initial_release: bool = False, + mask_initial_release: bool = True, + ignore_merge_commits: bool = True, ) -> Sequence[RepoActions]: stable_now_datetime = stable_now_date() commit_timestamp_gen = ( @@ -106,7 +107,7 @@ def _get_repo_from_defintion( ) pr_num_gen = (i for i in count(start=2, step=1)) - changelog_file_definitons: Sequence[RepoActionWriteChangelogsDestFile] = [ + changelog_file_definitions: Sequence[RepoActionWriteChangelogsDestFile] = [ { "path": changelog_md_file, "format": ChangelogOutputFormat.MARKDOWN, @@ -188,7 +189,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -216,9 +217,9 @@ def _get_repo_from_defintion( "commits": convert_commit_specs_to_commit_defs( [ { - "conventional": "fix: correct some text", - "emoji": ":bug: correct some text", - "scipy": "MAINT: correct some text", + "conventional": "fix: correct some text\n\nResolves: #123", + "emoji": ":bug: correct some text\n\nResolves: #123", + "scipy": "MAINT: correct some text\n\nResolves: #123", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -237,7 +238,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -277,7 +278,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -315,7 +316,7 @@ def _get_repo_from_defintion( branch_name=FIX_BRANCH_1_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -331,7 +332,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -381,7 +382,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -419,7 +420,7 @@ def _get_repo_from_defintion( branch_name=FEAT_BRANCH_1_NAME, ), "datetime": next(commit_timestamp_gen), - "include_in_changelog": bool(commit_type == "emoji"), + "include_in_changelog": not ignore_merge_commits, }, commit_type, ), @@ -435,7 +436,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], diff --git a/tests/fixtures/repos/repo_initial_commit.py b/tests/fixtures/repos/repo_initial_commit.py index c6ffd952b..7bc1fc3bb 100644 --- a/tests/fixtures/repos/repo_initial_commit.py +++ b/tests/fixtures/repos/repo_initial_commit.py @@ -71,13 +71,14 @@ def get_repo_definition_4_repo_w_initial_commit( changelog_rst_file: Path, stable_now_date: GetStableDateNowFn, ) -> GetRepoDefinitionFn: - def _get_repo_from_defintion( + def _get_repo_from_definition( commit_type: CommitConvention, hvcs_client_name: str = "github", hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, tag_format_str: str | None = None, extra_configs: dict[str, TomlSerializableTypes] | None = None, - mask_initial_release: bool = False, + mask_initial_release: bool = True, + ignore_merge_commits: bool = True, ) -> Sequence[RepoActions]: repo_construction_steps: list[RepoActions] = [] repo_construction_steps.extend( @@ -142,7 +143,7 @@ def _get_repo_from_defintion( return repo_construction_steps - return _get_repo_from_defintion + return _get_repo_from_definition @pytest.fixture(scope="session") diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support.py b/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support.py index c7a33cc16..008a7b6d6 100644 --- a/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support.py +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support.py @@ -85,13 +85,14 @@ def get_repo_definition_4_trunk_only_repo_w_dual_version_support( only official releases with latest and previous version support. """ - def _get_repo_from_defintion( + def _get_repo_from_definition( commit_type: CommitConvention, hvcs_client_name: str = "github", hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, tag_format_str: str | None = None, extra_configs: dict[str, TomlSerializableTypes] | None = None, - mask_initial_release: bool = False, + mask_initial_release: bool = True, + ignore_merge_commits: bool = True, ) -> Sequence[RepoActions]: stable_now_datetime = stable_now_date() commit_timestamp_gen = ( @@ -99,7 +100,7 @@ def _get_repo_from_defintion( for i in count(step=1) ) - changelog_file_definitons: Sequence[RepoActionWriteChangelogsDestFile] = [ + changelog_file_definitions: Sequence[RepoActionWriteChangelogsDestFile] = [ { "path": changelog_md_file, "format": ChangelogOutputFormat.MARKDOWN, @@ -179,7 +180,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -219,7 +220,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -269,7 +270,7 @@ def _get_repo_from_defintion( "\n\n", [ "API: add revolutionary feature", - "BREAKING CHANGE: this is a breaking change", + "This is a breaking change", ], ), "datetime": next(commit_timestamp_gen), @@ -290,7 +291,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -313,9 +314,9 @@ def _get_repo_from_defintion( "commits": convert_commit_specs_to_commit_defs( [ { - "conventional": "fix: correct critical bug", - "emoji": ":bug: correct critical bug", - "scipy": "MAINT: correct critical bug", + "conventional": "fix: correct critical bug\n\nResolves: #123\n", + "emoji": ":bug: correct critical bug\n\nResolves: #123\n", + "scipy": "MAINT: correct critical bug\n\nResolves: #123\n", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -335,7 +336,7 @@ def _get_repo_from_defintion( "details": { "new_version": new_version, "max_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -358,7 +359,7 @@ def _get_repo_from_defintion( return repo_construction_steps - return _get_repo_from_defintion + return _get_repo_from_definition @pytest.fixture(scope="session") diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support_w_prereleases.py b/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support_w_prereleases.py index 2576ec510..4fb0d14c7 100644 --- a/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support_w_prereleases.py +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_dual_version_support_w_prereleases.py @@ -85,13 +85,14 @@ def get_repo_definition_4_trunk_only_repo_w_dual_version_spt_w_prereleases( only official releases with latest and previous version support. """ - def _get_repo_from_defintion( + def _get_repo_from_definition( commit_type: CommitConvention, hvcs_client_name: str = "github", hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, tag_format_str: str | None = None, extra_configs: dict[str, TomlSerializableTypes] | None = None, - mask_initial_release: bool = False, + mask_initial_release: bool = True, + ignore_merge_commits: bool = True, ) -> Sequence[RepoActions]: stable_now_datetime = stable_now_date() commit_timestamp_gen = ( @@ -99,7 +100,7 @@ def _get_repo_from_defintion( for i in count(step=1) ) - changelog_file_definitons: Sequence[RepoActionWriteChangelogsDestFile] = [ + changelog_file_definitions: Sequence[RepoActionWriteChangelogsDestFile] = [ { "path": changelog_md_file, "format": ChangelogOutputFormat.MARKDOWN, @@ -179,7 +180,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -219,7 +220,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -269,7 +270,7 @@ def _get_repo_from_defintion( "\n\n", [ "API: add revolutionary feature", - "BREAKING CHANGE: this is a breaking change", + "This is a breaking change", ], ), "datetime": next(commit_timestamp_gen), @@ -290,7 +291,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -314,9 +315,9 @@ def _get_repo_from_defintion( "commits": convert_commit_specs_to_commit_defs( [ { - "conventional": "fix: correct critical bug", - "emoji": ":bug: correct critical bug", - "scipy": "MAINT: correct critical bug", + "conventional": "fix: correct critical bug\n\nResolves: #123\n", + "emoji": ":bug: correct critical bug\n\nResolves: #123\n", + "scipy": "MAINT: correct critical bug\n\nResolves: #123\n", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -336,7 +337,7 @@ def _get_repo_from_defintion( "details": { "new_version": new_version, "max_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -377,7 +378,7 @@ def _get_repo_from_defintion( "details": { "new_version": new_version, "max_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -418,7 +419,7 @@ def _get_repo_from_defintion( "details": { "new_version": new_version, "max_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -441,7 +442,7 @@ def _get_repo_from_defintion( return repo_construction_steps - return _get_repo_from_defintion + return _get_repo_from_definition @pytest.fixture(scope="session") diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_no_tags.py b/tests/fixtures/repos/trunk_based_dev/repo_w_no_tags.py index 65acc4d50..a1253c8e8 100644 --- a/tests/fixtures/repos/trunk_based_dev/repo_w_no_tags.py +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_no_tags.py @@ -78,13 +78,14 @@ def get_repo_definition_4_trunk_only_repo_w_no_tags( any releases. """ - def _get_repo_from_defintion( + def _get_repo_from_definition( commit_type: CommitConvention, hvcs_client_name: str = "github", hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, tag_format_str: str | None = None, extra_configs: dict[str, TomlSerializableTypes] | None = None, - mask_initial_release: bool = False, + mask_initial_release: bool = True, + ignore_merge_commits: bool = True, ) -> Sequence[RepoActions]: stable_now_datetime = stable_now_date() commit_timestamp_gen = ( @@ -142,9 +143,9 @@ def _get_repo_from_defintion( "include_in_changelog": True, }, { - "conventional": "fix: correct more text", - "emoji": ":bug: correct more text", - "scipy": "MAINT: correct more text", + "conventional": "fix: correct more text\n\nCloses: #123", + "emoji": ":bug: correct more text\n\nCloses: #123", + "scipy": "MAINT: correct more text\n\nCloses: #123", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -174,7 +175,7 @@ def _get_repo_from_defintion( return repo_construction_steps - return _get_repo_from_defintion + return _get_repo_from_definition @pytest.fixture(scope="session") @@ -258,6 +259,90 @@ def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: } +@pytest.fixture +def repo_w_no_tags_conventional_commits_w_zero_version( + build_repo_from_definition: BuildRepoFromDefinitionFn, + get_repo_definition_4_trunk_only_repo_w_no_tags: GetRepoDefinitionFn, + get_cached_repo_data: GetCachedRepoDataFn, + build_repo_or_copy_cache: BuildRepoOrCopyCacheFn, + build_spec_hash_4_repo_w_no_tags: str, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> BuiltRepoResult: + """Replicates repo with no tags, but with allow_zero_version=True""" + repo_name = repo_w_no_tags_conventional_commits_w_zero_version.__name__ + commit_type: CommitConvention = ( + repo_name.split("_commits", maxsplit=1)[0].split("_")[-1] # type: ignore[assignment] + ) + + def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: + repo_construction_steps = get_repo_definition_4_trunk_only_repo_w_no_tags( + commit_type=commit_type, + extra_configs={ + "tool.semantic_release.allow_zero_version": True, + }, + ) + return build_repo_from_definition(cached_repo_path, repo_construction_steps) + + build_repo_or_copy_cache( + repo_name=repo_name, + build_spec_hash=build_spec_hash_4_repo_w_no_tags, + build_repo_func=_build_repo, + dest_dir=example_project_dir, + ) + + if not (cached_repo_data := get_cached_repo_data(proj_dirname=repo_name)): + raise ValueError("Failed to retrieve repo data from cache") + + return { + "definition": cached_repo_data["build_definition"], + "repo": example_project_git_repo(), + } + + +@pytest.fixture +def repo_w_no_tags_conventional_commits_unmasked_initial_release( + build_repo_from_definition: BuildRepoFromDefinitionFn, + get_repo_definition_4_trunk_only_repo_w_no_tags: GetRepoDefinitionFn, + get_cached_repo_data: GetCachedRepoDataFn, + build_repo_or_copy_cache: BuildRepoOrCopyCacheFn, + build_spec_hash_4_repo_w_no_tags: str, + example_project_git_repo: ExProjectGitRepoFn, + example_project_dir: ExProjectDir, + change_to_ex_proj_dir: None, +) -> BuiltRepoResult: + """Replicates repo with no tags, but with allow_zero_version=True""" + repo_name = repo_w_no_tags_conventional_commits_unmasked_initial_release.__name__ + commit_type: CommitConvention = ( + repo_name.split("_commits", maxsplit=1)[0].split("_")[-1] # type: ignore[assignment] + ) + + def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: + repo_construction_steps = get_repo_definition_4_trunk_only_repo_w_no_tags( + commit_type=commit_type, + extra_configs={ + "tool.semantic_release.changelog.default_templates.mask_initial_release": False, + }, + ) + return build_repo_from_definition(cached_repo_path, repo_construction_steps) + + build_repo_or_copy_cache( + repo_name=repo_name, + build_spec_hash=build_spec_hash_4_repo_w_no_tags, + build_repo_func=_build_repo, + dest_dir=example_project_dir, + ) + + if not (cached_repo_data := get_cached_repo_data(proj_dirname=repo_name)): + raise ValueError("Failed to retrieve repo data from cache") + + return { + "definition": cached_repo_data["build_definition"], + "repo": example_project_git_repo(), + } + + @pytest.fixture def repo_w_no_tags_conventional_commits( build_trunk_only_repo_w_no_tags: BuildSpecificRepoFn, diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_prereleases.py b/tests/fixtures/repos/trunk_based_dev/repo_w_prereleases.py index a2c133d21..57e46578f 100644 --- a/tests/fixtures/repos/trunk_based_dev/repo_w_prereleases.py +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_prereleases.py @@ -79,13 +79,14 @@ def get_repo_definition_4_trunk_only_repo_w_prerelease_tags( official and prereleases releases. """ - def _get_repo_from_defintion( + def _get_repo_from_definition( commit_type: CommitConvention, hvcs_client_name: str = "github", hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, tag_format_str: str | None = None, extra_configs: dict[str, TomlSerializableTypes] | None = None, - mask_initial_release: bool = False, + mask_initial_release: bool = True, + ignore_merge_commits: bool = True, ) -> Sequence[RepoActions]: stable_now_datetime = stable_now_date() commit_timestamp_gen = ( @@ -93,7 +94,7 @@ def _get_repo_from_defintion( for i in count(step=1) ) - changelog_file_definitons: Sequence[RepoActionWriteChangelogsDestFile] = [ + changelog_file_definitions: Sequence[RepoActionWriteChangelogsDestFile] = [ { "path": changelog_md_file, "format": ChangelogOutputFormat.MARKDOWN, @@ -169,7 +170,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -188,9 +189,9 @@ def _get_repo_from_defintion( "commits": convert_commit_specs_to_commit_defs( [ { - "conventional": "fix: correct some text", - "emoji": ":bug: correct some text", - "scipy": "MAINT: correct some text", + "conventional": "fix: correct some text\n\nfixes: #123\n", + "emoji": ":bug: correct some text\n\nfixes: #123\n", + "scipy": "MAINT: correct some text\n\nfixes: #123\n", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -209,7 +210,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -249,7 +250,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -270,7 +271,7 @@ def _get_repo_from_defintion( { "conventional": "feat(cli): add cli command", "emoji": ":sparkles:(cli) add cli command", - "scipy": "ENH(cli): add cli command", + "scipy": "ENH: cli: add cli command", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -289,7 +290,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -300,7 +301,7 @@ def _get_repo_from_defintion( return repo_construction_steps - return _get_repo_from_defintion + return _get_repo_from_definition @pytest.fixture(scope="session") diff --git a/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py b/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py index 9d080ed7a..b58a23865 100644 --- a/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py +++ b/tests/fixtures/repos/trunk_based_dev/repo_w_tags.py @@ -81,13 +81,14 @@ def get_repo_definition_4_trunk_only_repo_w_tags( only official releases. """ - def _get_repo_from_defintion( + def _get_repo_from_definition( commit_type: CommitConvention, hvcs_client_name: str = "github", hvcs_domain: str = EXAMPLE_HVCS_DOMAIN, tag_format_str: str | None = None, extra_configs: dict[str, TomlSerializableTypes] | None = None, - mask_initial_release: bool = False, + mask_initial_release: bool = True, + ignore_merge_commits: bool = True, ) -> Sequence[RepoActions]: stable_now_datetime = stable_now_date() commit_timestamp_gen = ( @@ -95,7 +96,7 @@ def _get_repo_from_defintion( for i in count(step=1) ) - changelog_file_definitons: Sequence[RepoActionWriteChangelogsDestFile] = [ + changelog_file_definitions: Sequence[RepoActionWriteChangelogsDestFile] = [ { "path": changelog_md_file, "format": ChangelogOutputFormat.MARKDOWN, @@ -171,7 +172,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -190,9 +191,9 @@ def _get_repo_from_defintion( "commits": convert_commit_specs_to_commit_defs( [ { - "conventional": "fix: correct some text", - "emoji": ":bug: correct some text", - "scipy": "MAINT: correct some text", + "conventional": "fix: correct some text\n\nResolves: #123\n", + "emoji": ":bug: correct some text\n\nResolves: #123\n", + "scipy": "MAINT: correct some text\n\nResolves: #123\n", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, }, @@ -211,7 +212,7 @@ def _get_repo_from_defintion( "action": RepoActionStep.WRITE_CHANGELOGS, "details": { "new_version": new_version, - "dest_files": changelog_file_definitons, + "dest_files": changelog_file_definitions, }, }, ], @@ -222,7 +223,7 @@ def _get_repo_from_defintion( return repo_construction_steps - return _get_repo_from_defintion + return _get_repo_from_definition @pytest.fixture(scope="session") diff --git a/tests/fixtures/scipy.py b/tests/fixtures/scipy.py index f9c704090..3d7b07127 100644 --- a/tests/fixtures/scipy.py +++ b/tests/fixtures/scipy.py @@ -99,6 +99,7 @@ def scipy_nonparseable_commits() -> list[str]: def scipy_chore_subjects(scipy_chore_commit_types: list[str]) -> list[str]: subjects = { "BENCH": "disable very slow benchmark in optimize_milp.py", + "DEV": "add unicode check to pre-commit-hook", "DOC": "change approx_fprime doctest (#20568)", "STY": "fixed ruff & mypy issues", "TST": "Skip Cython tests for editable installs", @@ -125,10 +126,8 @@ def scipy_patch_subjects(scipy_patch_commit_types: list[str]) -> list[str]: @pytest.fixture(scope="session") def scipy_minor_subjects(scipy_minor_commit_types: list[str]) -> list[str]: subjects = { - "DEP": "stats: switch kendalltau to kwarg-only, remove initial_lexsort", - "DEV": "add unicode check to pre-commit-hook", "ENH": "stats.ttest_1samp: add array-API support (#20545)", - "REV": "reverted a previous commit", + # "REV": "reverted a previous commit", "FEAT": "added a new feature", } # Test fixture modification failure prevention @@ -140,6 +139,7 @@ def scipy_minor_subjects(scipy_minor_commit_types: list[str]) -> list[str]: def scipy_major_subjects(scipy_major_commit_types: list[str]) -> list[str]: subjects = { "API": "dropped support for python 3.7", + "DEP": "stats: switch kendalltau to kwarg-only, remove initial_lexsort", } # Test fixture modification failure prevention assert len(subjects.keys()) == len(scipy_major_commit_types) diff --git a/tests/gh_action/example_project/pyproject.toml b/tests/gh_action/example_project/pyproject.toml new file mode 100644 index 000000000..97a158c8d --- /dev/null +++ b/tests/gh_action/example_project/pyproject.toml @@ -0,0 +1,4 @@ +[project] +name = "example" +version = "0.0.0" +description = "Example project" diff --git a/tests/gh_action/example_project/releaserc.toml b/tests/gh_action/example_project/releaserc.toml new file mode 100644 index 000000000..c5c259a34 --- /dev/null +++ b/tests/gh_action/example_project/releaserc.toml @@ -0,0 +1,2 @@ +[semantic-release] +commit_parser = "emoji" diff --git a/tests/gh_action/run.sh b/tests/gh_action/run.sh new file mode 100644 index 000000000..3d92e0d88 --- /dev/null +++ b/tests/gh_action/run.sh @@ -0,0 +1,133 @@ +#!/bin/bash + +set -eu + +if ! command -v realpath &>/dev/null; then + realpath() { + readlink -f "$1" + } +fi + +TEST_DIR="$(realpath "$(dirname "${BASH_SOURCE[0]}")")" +PROJ_DIR="$(realpath "$(dirname "$TEST_DIR")/..")" +EXAMPLE_PROJECT_BASE_DIR="${EXAMPLE_PROJECT_BASE_DIR:-"$TEST_DIR/example_project"}" + +if [ -z "${UTILS_LOADED:-}" ]; then + # shellcheck source=tests/utils.sh + source "$TEST_DIR/utils.sh" +fi + +create_example_project() { + local EXAMPLE_PROJECT_DIR="$1" + + log "Creating example project in: $EXAMPLE_PROJECT_DIR" + mkdir -vp "$(dirname "$EXAMPLE_PROJECT_DIR")" + cp -r "${EXAMPLE_PROJECT_BASE_DIR}" "$EXAMPLE_PROJECT_DIR" + + log "Constructing git history in repository" + pushd "$EXAMPLE_PROJECT_DIR" >/dev/null || return 1 + + # Initialize and configure git (remove any signature requirements) + git init + git config --local user.email "developer@users.noreply.github.com" + git config --local user.name "developer" + git config --local commit.gpgSign false + git config --local tag.gpgSign false + git remote add origin "https://github.com/python-semantic-release/example-project.git" + + # Create initial commit and tag + git add . + git commit -m "Initial commit" + + # set default branch to main + git branch -m main + + # Create the first release (with commit & tag) + cat <pyproject.toml +[project] +name = "example" +version = "1.0.0" +description = "Example project" +EOF + git commit -am '1.0.0' + git tag -a v1.0.0 -m "v1.0.0" + + popd >/dev/null || return 1 + log "Example project created successfully" +} + +# ------------------------------ +# TEST SUITE DRIVER +# ------------------------------ + +run_test_suite() { + local ALL_TEST_FNS + + # Dynamically import all test scripts + for test_script in "$TEST_DIR"/suite/test_*.sh; do + if [ -f "$test_script" ]; then + if ! source "$test_script"; then + error "Failed to load test script: $test_script" + fi + fi + done + + # Extract all test functions + tests_in_env="$(compgen -A function | grep "^test_")" + read -r -a ALL_TEST_FNS <<< "$(printf '%s' "$tests_in_env" | tr '\n' ' ')" + + log "" + log "************************" + log "* Running test suite *" + log "************************" + + # Incrementally run all test functions and flag if any fail + local test_index=1 + local test_failures=0 + for test_fn in "${ALL_TEST_FNS[@]}"; do + if command -v "$test_fn" &>/dev/null; then + if ! "$test_fn" "$test_index"; then + ((test_failures++)) + fi + fi + log "--------------------------------------------------------------------------------" + ((test_index++)) + done + + log "" + log "************************" + log "* Test Summary *" + log "************************" + log "" + log "Total tests executed: ${#ALL_TEST_FNS[@]}" + log "Successes: $((${#ALL_TEST_FNS[@]} - test_failures))" + log "Failures: $test_failures" + + if [ "$test_failures" -gt 0 ]; then + return 1 + fi +} + +# ------------------------------ +# MAIN +# ------------------------------ + +log "================================================================================" +log "|| PSR Version Action Test Runner ||" +log "================================================================================" +log "Initializing..." + +# Make absolute path to project directory +PROJECT_MOUNT_DIR="${PROJ_DIR:?}/${PROJECT_MOUNT_DIR:?}" + +log "" +log "******************************" +log "* Running test suite setup *" +log "******************************" +log "" + +# Setup project environment +create_example_project "$PROJECT_MOUNT_DIR" +trap 'rm -rf "${PROJECT_MOUNT_DIR:?}"' EXIT + +run_test_suite diff --git a/tests/gh_action/suite/test_version.sh b/tests/gh_action/suite/test_version.sh new file mode 100644 index 000000000..a853bf99f --- /dev/null +++ b/tests/gh_action/suite/test_version.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +__file__="$(realpath "${BASH_SOURCE[0]}")" +__directory__="$(dirname "${__file__}")" + +if ! [ "${UTILS_LOADED}" = "true" ]; then + # shellcheck source=tests/utils.sh + source "$__directory__/../utils.sh" +fi + +test_version() { + # Using default configuration within PSR with no modifications + # triggering the NOOP mode to prevent errors since the repo doesn't exist + # We are just trying to test that the root options & tag arguments are + # passed to the action without a fatal error + local index="${1:?Index not provided}" + local test_name="${FUNCNAME[0]}" + + # Create expectations & set env variables that will be passed in for Docker command + local WITH_VAR_GITHUB_TOKEN="ghp_1x2x3x4x5x6x7x8x9x0x1x2x3x4x5x6x7x8x9x0" + local WITH_VAR_NO_OPERATION_MODE="true" + local WITH_VAR_VERBOSITY="2" + local expected_psr_cmd=".*/bin/semantic-release -vv --noop version" + + # Execute the test & capture output + # Fatal errors if exit code is not 0 + local output="" + if ! output="$(run_test "$index. $test_name" 2>&1)"; then + # Log the output for debugging purposes + log "$output" + error "fatal error occurred!" + error "::error:: $test_name failed!" + return 1 + fi + + # Evaluate the output to ensure the expected command is present + if ! printf '%s' "$output" | grep -q -E "$expected_psr_cmd"; then + # Log the output for debugging purposes + log "$output" + error "Failed to find the expected command in the output!" + error "\tExpected Command: $expected_psr_cmd" + error "::error:: $test_name failed!" + return 1 + fi + + log "\n$index. $test_name: PASSED!" +} + +test_version_w_custom_config() { + # Using default configuration within PSR with no modifications + # triggering the NOOP mode to prevent errors since the repo doesn't exist + # We are just trying to test that the root options & tag arguments are + # passed to the action without a fatal error + local index="${1:?Index not provided}" + local test_name="${FUNCNAME[0]}" + + # Create expectations & set env variables that will be passed in for Docker command + local WITH_VAR_GITHUB_TOKEN="ghp_1x2x3x4x5x6x7x8x9x0x1x2x3x4x5x6x7x8x9x0" + local WITH_VAR_NO_OPERATION_MODE="true" + local WITH_VAR_VERBOSITY="0" + local WITH_VAR_CONFIG_FILE="releaserc.toml" + local expected_psr_cmd=".*/bin/semantic-release --config $WITH_VAR_CONFIG_FILE --noop version" + + # Execute the test & capture output + # Fatal errors if exit code is not 0 + local output="" + if ! output="$(run_test "$index. $test_name" 2>&1)"; then + # Log the output for debugging purposes + log "$output" + error "fatal error occurred!" + error "::error:: $test_name failed!" + return 1 + fi + + # Evaluate the output to ensure the expected command is present + if ! printf '%s' "$output" | grep -q "$expected_psr_cmd"; then + # Log the output for debugging purposes + log "$output" + error "Failed to find the expected command in the output!" + error "\tExpected Command: $expected_psr_cmd" + error "::error:: $test_name failed!" + return 1 + fi + + log "\n$index. $test_name: PASSED!" +} diff --git a/tests/gh_action/suite/test_version_strict.sh b/tests/gh_action/suite/test_version_strict.sh new file mode 100644 index 000000000..cd9a2b512 --- /dev/null +++ b/tests/gh_action/suite/test_version_strict.sh @@ -0,0 +1,48 @@ +#!/bin/bash + +__file__="$(realpath "${BASH_SOURCE[0]}")" +__directory__="$(dirname "${__file__}")" + +if ! [ "${UTILS_LOADED:-false}" = "true" ]; then + # shellcheck source=tests/utils.sh + source "$__directory__/../utils.sh" +fi + +test_version_strict() { + # Using default configuration within PSR with no modifications + # triggering the NOOP mode to prevent errors since the repo doesn't exist + # We are just trying to test that the root options & tag arguments are + # passed to the action without a fatal error + local index="${1:?Index not provided}" + local test_name="${FUNCNAME[0]}" + + # Create expectations & set env variables that will be passed in for Docker command + local WITH_VAR_GITHUB_TOKEN="ghp_1x2x3x4x5x6x7x8x9x0x1x2x3x4x5x6x7x8x9x0" + local WITH_VAR_NO_OPERATION_MODE="true" + local WITH_VAR_STRICT="true" + local expected_psr_cmd=".*/bin/semantic-release -v --strict --noop version" + # Since the example project is at the latest release, we expect strict mode + # to fail with a non-zero exit code + + # Execute the test & capture output + local output="" + if output="$(run_test "$index. $test_name" 2>&1)"; then + # Log the output for debugging purposes + log "$output" + error "Strict mode should of exited with a non-zero exit code but didn't!" + error "::error:: $test_name failed!" + return 1 + fi + + # Evaluate the output to ensure the expected command is present + if ! printf '%s' "$output" | grep -q "$expected_psr_cmd"; then + # Log the output for debugging purposes + log "$output" + error "Failed to find the expected command in the output!" + error "\tExpected Command: $expected_psr_cmd" + error "::error:: $test_name failed!" + return 1 + fi + + log "\n$index. $test_name: PASSED!" +} \ No newline at end of file diff --git a/tests/gh_action/utils.sh b/tests/gh_action/utils.sh new file mode 100644 index 000000000..a08f25e73 --- /dev/null +++ b/tests/gh_action/utils.sh @@ -0,0 +1,67 @@ +#!/bin/bash + +# ------------------------------ +# UTILS +# ------------------------------ +IMAGE_TAG="${TEST_CONTAINER_TAG:?TEST_CONTAINER_TAG not set}" +PROJECT_MOUNT_DIR="${PROJECT_MOUNT_DIR:-"tmp/project"}" +GITHUB_ACTIONS_CWD="/github/workspace" + +log() { + printf '%b\n' "$*" +} + +error() { + log >&2 "\033[31m$*\033[0m" +} + +explicit_run_cmd() { + local cmd="$*" + log "$> $cmd\n" + eval "$cmd" +} + +run_test() { + local test_name="${1:?Test name not provided}" + test_name="${test_name//_/ }" + test_name="$(tr "[:lower:]" "[:upper:]" <<< "${test_name:0:1}")${test_name:1}" + + # Set Defaults based on action.yml + [ -z "${WITH_VAR_DIRECTORY:-}" ] && local WITH_VAR_DIRECTORY="." + [ -z "${WITH_VAR_CONFIG_FILE:-}" ] && local WITH_VAR_CONFIG_FILE="" + [ -z "${WITH_VAR_NO_OPERATION_MODE:-}" ] && local WITH_VAR_NO_OPERATION_MODE="false" + [ -z "${WITH_VAR_VERBOSITY:-}" ] && local WITH_VAR_VERBOSITY="1" + + # Extract all WITH_VAR_ variables dynamically from environment + local ENV_ARGS=() + args_in_env="$(compgen -A variable | grep "^WITH_VAR_")" + read -r -a ENV_ARGS <<< "$(printf '%s' "$args_in_env" | tr '\n' ' ')" + + # Set Docker arguments (default: always remove the container after execution) + local DOCKER_ARGS=("--rm") + + # Add all WITH_VAR_ variables to the Docker command + local actions_input_var_name="" + for input in "${ENV_ARGS[@]}"; do + # Convert WITH_VAR_ to INPUT_ to simulate GitHub Actions input syntax + actions_input_var_name="INPUT_${input#WITH_VAR_}" + + # Add the environment variable to the Docker command + DOCKER_ARGS+=("-e ${actions_input_var_name}='${!input}'") + done + + # Add the project directory to the Docker command + DOCKER_ARGS+=("-v ${PROJECT_MOUNT_DIR}:${GITHUB_ACTIONS_CWD}") + + # Set the working directory to the project directory + DOCKER_ARGS+=("-w ${GITHUB_ACTIONS_CWD}") + + # Run the test + log "\n$test_name" + log "--------------------------------------------------------------------------------" + if ! explicit_run_cmd "docker run ${DOCKER_ARGS[*]} $IMAGE_TAG"; then + return 1 + fi +} + +export UTILS_LOADED="true" diff --git a/tests/unit/semantic_release/changelog/test_default_changelog.py b/tests/unit/semantic_release/changelog/test_default_changelog.py index adbbdc241..6e42f7fad 100644 --- a/tests/unit/semantic_release/changelog/test_default_changelog.py +++ b/tests/unit/semantic_release/changelog/test_default_changelog.py @@ -23,7 +23,7 @@ def default_changelog_template() -> str: """Retrieve the semantic-release default changelog template.""" version_notes_template = files(semantic_release.__name__).joinpath( - Path("data", "templates", "angular", "md", "CHANGELOG.md.j2") + Path("data", "templates", "conventional", "md", "CHANGELOG.md.j2") ) return version_notes_template.read_text(encoding="utf-8") @@ -112,7 +112,7 @@ def test_default_changelog_template( insertion_flag="", mask_initial_release=True, ), - changelog_style="angular", + changelog_style="conventional", ) assert expected_changelog == actual_changelog @@ -235,7 +235,7 @@ def test_default_changelog_template_w_a_brk_change( insertion_flag="", mask_initial_release=True, ), - changelog_style="angular", + changelog_style="conventional", ) assert expected_changelog == actual_changelog @@ -381,7 +381,7 @@ def test_default_changelog_template_w_multiple_brk_changes( insertion_flag="", mask_initial_release=True, ), - changelog_style="angular", + changelog_style="conventional", ) assert expected_changelog == actual_changelog @@ -475,7 +475,7 @@ def test_default_changelog_template_no_initial_release_mask( insertion_flag="", mask_initial_release=False, ), - changelog_style="angular", + changelog_style="conventional", ) assert expected_changelog == actual_changelog @@ -572,7 +572,7 @@ def test_default_changelog_template_w_unreleased_changes( insertion_flag="", mask_initial_release=True, ), - changelog_style="angular", + changelog_style="conventional", ) assert expected_changelog == actual_changelog @@ -695,7 +695,7 @@ def test_default_changelog_template_w_a_notice( insertion_flag="", mask_initial_release=False, ), - changelog_style="angular", + changelog_style="conventional", ) assert expected_changelog == actual_changelog @@ -855,7 +855,7 @@ def test_default_changelog_template_w_a_notice_n_brk_change( insertion_flag="", mask_initial_release=False, ), - changelog_style="angular", + changelog_style="conventional", ) assert expected_changelog == actual_changelog @@ -1019,7 +1019,7 @@ def test_default_changelog_template_w_multiple_notices( insertion_flag="", mask_initial_release=False, ), - changelog_style="angular", + changelog_style="conventional", ) assert expected_changelog == actual_changelog diff --git a/tests/unit/semantic_release/changelog/test_release_history.py b/tests/unit/semantic_release/changelog/test_release_history.py index 17b327cfa..2f45bbfb8 100644 --- a/tests/unit/semantic_release/changelog/test_release_history.py +++ b/tests/unit/semantic_release/changelog/test_release_history.py @@ -74,10 +74,7 @@ def _create_release_history_from_repo_def( if commit["category"] not in commits_per_group: commits_per_group[commit["category"]] = [] - commits_per_group[commit["category"]].append( - # TODO: remove the newline when our release history strips whitespace from commit messages - commit["msg"].strip() + "\n" - ) + commits_per_group[commit["category"]].append(commit["msg"].strip()) if version_str == "Unreleased": unreleased_history = commits_per_group @@ -87,7 +84,9 @@ def _create_release_history_from_repo_def( version = Version.parse(version_str) # add the PSR version commit message - commits_per_group["Unknown"].append(COMMIT_MESSAGE.format(version=version)) + commits_per_group["Unknown"].append( + COMMIT_MESSAGE.format(version=version).strip() + ) # store the organized commits for this version released_history[version] = commits_per_group @@ -132,7 +131,10 @@ def test_release_history( ): repo = repo_result["repo"] expected_release_history = create_release_history_from_repo_def( - get_commits_from_repo_build_def(repo_result["definition"]) + get_commits_from_repo_build_def( + repo_result["definition"], + ignore_merge_commits=default_conventional_parser.options.ignore_merge_commits, + ) ) expected_released_versions = sorted( map(str, expected_release_history.released.keys()) @@ -179,7 +181,7 @@ def test_release_history( "\n---\n", sorted( [ - msg + str(msg).strip() for bucket in [ CONVENTIONAL_COMMITS_MINOR[::-1], *expected_release_history.unreleased.values(), diff --git a/tests/unit/semantic_release/changelog/test_release_notes.py b/tests/unit/semantic_release/changelog/test_release_notes.py index 2b95ec827..02f217bf1 100644 --- a/tests/unit/semantic_release/changelog/test_release_notes.py +++ b/tests/unit/semantic_release/changelog/test_release_notes.py @@ -26,7 +26,7 @@ def release_notes_template() -> str: """Retrieve the semantic-release default release notes template.""" version_notes_template = files(semantic_release.__name__).joinpath( - Path("data", "templates", "angular", "md", ".release_notes.md.j2") + Path("data", "templates", "conventional", "md", ".release_notes.md.j2") ) return version_notes_template.read_text(encoding="utf-8") @@ -156,7 +156,7 @@ def test_default_release_notes_template( release=release, template_dir=Path(""), history=artificial_release_history, - style="angular", + style="conventional", mask_initial_release=mask_initial_release, license_name=license_name, ) @@ -248,7 +248,7 @@ def test_default_release_notes_template_w_a_brk_description( release=release, template_dir=Path(""), history=release_history_w_brk_change, - style="angular", + style="conventional", mask_initial_release=mask_initial_release, ) @@ -369,7 +369,7 @@ def test_default_release_notes_template_w_multiple_brk_changes( release=release, template_dir=Path(""), history=release_history_w_multiple_brk_changes, - style="angular", + style="conventional", mask_initial_release=mask_initial_release, ) @@ -417,7 +417,7 @@ def test_default_release_notes_template_first_release_masked( release=release, template_dir=Path(""), history=single_release_history, - style="angular", + style="conventional", mask_initial_release=True, license_name=license_name, ) @@ -481,7 +481,7 @@ def test_default_release_notes_template_first_release_unmasked( release=release, template_dir=Path(""), history=single_release_history, - style="angular", + style="conventional", mask_initial_release=False, license_name=license_name, ) @@ -529,7 +529,7 @@ def test_release_notes_context_sort_numerically_filter( release=release, template_dir=example_project_dir, history=single_release_history, - style="angular", + style="conventional", mask_initial_release=False, ) @@ -576,7 +576,7 @@ def test_release_notes_context_sort_numerically_filter_reversed( release=release, template_dir=example_project_dir, history=single_release_history, - style="angular", + style="conventional", mask_initial_release=False, ) @@ -603,7 +603,7 @@ def test_release_notes_context_pypi_url_filter( release=release, template_dir=example_project_dir, history=single_release_history, - style="angular", + style="conventional", mask_initial_release=False, ) @@ -630,7 +630,7 @@ def test_release_notes_context_pypi_url_filter_tagged( release=release, template_dir=example_project_dir, history=single_release_history, - style="angular", + style="conventional", mask_initial_release=False, ) @@ -675,7 +675,7 @@ def test_release_notes_context_release_url_filter( release=release, template_dir=example_project_dir, history=single_release_history, - style="angular", + style="conventional", mask_initial_release=False, ) @@ -718,7 +718,7 @@ def test_release_notes_context_format_w_official_name_filter( release=release, template_dir=example_project_dir, history=single_release_history, - style="angular", + style="conventional", mask_initial_release=False, ) @@ -807,7 +807,7 @@ def test_default_release_notes_template_w_a_notice( release=release, template_dir=Path(""), history=release_history_w_a_notice, - style="angular", + style="conventional", mask_initial_release=mask_initial_release, ) @@ -928,7 +928,7 @@ def test_default_release_notes_template_w_a_notice_n_brk_change( release=release, template_dir=Path(""), history=release_history_w_notice_n_brk_change, - style="angular", + style="conventional", mask_initial_release=mask_initial_release, ) @@ -1041,7 +1041,7 @@ def test_default_release_notes_template_w_multiple_notices( release=release, template_dir=Path(""), history=release_history_w_multiple_notices, - style="angular", + style="conventional", mask_initial_release=mask_initial_release, ) diff --git a/tests/unit/semantic_release/commit_parser/test_conventional.py b/tests/unit/semantic_release/commit_parser/test_conventional.py index 078e1ecd5..02cd4f5de 100644 --- a/tests/unit/semantic_release/commit_parser/test_conventional.py +++ b/tests/unit/semantic_release/commit_parser/test_conventional.py @@ -1,3 +1,4 @@ +# ruff: noqa: SIM300 from __future__ import annotations from textwrap import dedent @@ -80,7 +81,6 @@ def test_parser_raises_unknown_message_style( "some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -127,7 +127,6 @@ def test_parser_raises_unknown_message_style( "some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -154,8 +153,6 @@ def test_parser_raises_unknown_message_style( "scope": "cli", "descriptions": [ "changed option name", - "BREAKING CHANGE: A breaking change description", - "Closes: #555", # This is a bit unusual but its because there is no identifier that will # identify this as a separate commit so it gets included in the previous commit "invalid non-conventional formatted commit", @@ -255,7 +252,6 @@ def test_parser_squashed_commit_bitbucket_squash_style( "some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -320,7 +316,6 @@ def test_parser_squashed_commit_bitbucket_squash_style( "some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -344,8 +339,6 @@ def test_parser_squashed_commit_bitbucket_squash_style( "scope": "cli", "descriptions": [ "changed option name", - "BREAKING CHANGE: A breaking change description", - "Closes: #555", ], "breaking_descriptions": [ "A breaking change description", @@ -432,11 +425,9 @@ def test_parser_squashed_commit_git_squash_style( "type": "bug fixes", "scope": "release-config", "descriptions": [ - # TODO: v10 removal of PR number from subject - "some commit subject (#10)", + "some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -477,11 +468,9 @@ def test_parser_squashed_commit_git_squash_style( "type": "bug fixes", "scope": "release-config", "descriptions": [ - # TODO: v10 removal of PR number from subject - "some commit subject (#10)", + "some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -508,8 +497,6 @@ def test_parser_squashed_commit_git_squash_style( "scope": "cli", "descriptions": [ "changed option name", - "BREAKING CHANGE: A breaking change description", - "Closes: #555", # This is a bit unusual but its because there is no identifier that will # identify this as a separate commit so it gets included in the previous commit "* invalid non-conventional formatted commit", @@ -689,7 +676,7 @@ def test_parser_return_scope_from_commit_message( ), ( f"fix(tox): fix env \n\n{_long_text}\n\n{_footer}", - ["fix env ", _long_text, _footer], + ["fix env ", _long_text], ), ("fix: superfix", ["superfix"]), ], @@ -717,23 +704,23 @@ def test_parser_return_subject_from_commit_message( # GitHub, Gitea style ( "feat(parser): add emoji parser (#123)", - "add emoji parser (#123)", + "add emoji parser", "#123", ), # GitLab style ( "fix(parser): fix regex in conventional parser (!456)", - "fix regex in conventional parser (!456)", + "fix regex in conventional parser", "!456", ), # BitBucket style ( "feat(parser): add emoji parser (pull request #123)", - "add emoji parser (pull request #123)", + "add emoji parser", "#123", ), # Both a linked merge request and an issue footer (should return the linked merge request) - ("fix: superfix (#123)\n\nCloses: #400", "superfix (#123)", "#123"), + ("fix: superfix (#123)\n\nCloses: #400", "superfix", "#123"), # None ("fix: superfix", "superfix", ""), # None but includes an issue footer it should not be considered a linked merge request @@ -760,7 +747,6 @@ def test_parser_return_linked_merge_request_from_commit_message( @pytest.mark.parametrize( "message, linked_issues", - # TODO: in v10, we will remove the issue reference footers from the descriptions [ *[ # GitHub, Gitea, GitLab style @@ -1040,7 +1026,7 @@ def test_parser_return_linked_issues_from_commit_message( parsed_results = default_conventional_parser.parse(make_commit_obj(message)) assert isinstance(parsed_results, Iterable) - assert len(parsed_results) == 1 + assert 1 == len(parsed_results) result = next(iter(parsed_results)) assert isinstance(result, ParsedCommit) diff --git a/tests/unit/semantic_release/commit_parser/test_emoji.py b/tests/unit/semantic_release/commit_parser/test_emoji.py index ec2d83a3e..ac7708ebb 100644 --- a/tests/unit/semantic_release/commit_parser/test_emoji.py +++ b/tests/unit/semantic_release/commit_parser/test_emoji.py @@ -23,7 +23,7 @@ ":boom: Breaking changes\n\nMore description\n\nEven more description", LevelBump.MAJOR, ":boom:", - [":boom: Breaking changes", "More description", "Even more description"], + [":boom: Breaking changes"], ["More description", "Even more description"], ), # Minor bump @@ -63,7 +63,7 @@ ":sparkles: Add a new feature\n\n:boom: should not be detected", LevelBump.MINOR, ":sparkles:", - [":sparkles: Add a new feature", ":boom: should not be detected"], + [":sparkles: Add a new feature"], [], ), ], @@ -91,28 +91,27 @@ def test_default_emoji_parser( @pytest.mark.parametrize( "message, subject, merge_request_number", - # TODO: in v10, we will remove the merge request number from the subject line [ # GitHub, Gitea style ( ":sparkles: add new feature (#123)", - ":sparkles: add new feature (#123)", + ":sparkles: add new feature", "#123", ), # GitLab style ( ":bug: fix regex in parser (!456)", - ":bug: fix regex in parser (!456)", + ":bug: fix regex in parser", "!456", ), # BitBucket style ( ":sparkles: add new feature (pull request #123)", - ":sparkles: add new feature (pull request #123)", + ":sparkles: add new feature", "#123", ), # Both a linked merge request and an issue footer (should return the linked merge request) - (":bug: superfix (#123)\n\nCloses: #400", ":bug: superfix (#123)", "#123"), + (":bug: superfix (#123)\n\nCloses: #400", ":bug: superfix", "#123"), # None (":bug: superfix", ":bug: superfix", ""), # None but includes an issue footer it should not be considered a linked merge request @@ -547,9 +546,7 @@ def test_parser_return_release_notices_from_commit_message( { "bump": LevelBump.NO_RELEASE, "type": "Other", - "descriptions": [ - "Merged in feat/my-awesome-stuff (pull request #10)" - ], + "descriptions": ["Merged in feat/my-awesome-stuff"], "linked_merge_request": "#10", }, { @@ -560,7 +557,6 @@ def test_parser_return_release_notices_from_commit_message( ":bug:(release-config): some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -601,9 +597,7 @@ def test_parser_return_release_notices_from_commit_message( { "bump": LevelBump.NO_RELEASE, "type": "Other", - "descriptions": [ - "Merged in feat/my-awesome-stuff (pull request #10)" - ], + "descriptions": ["Merged in feat/my-awesome-stuff"], "linked_merge_request": "#10", }, { @@ -614,7 +608,6 @@ def test_parser_return_release_notices_from_commit_message( ":bug:(release-config): some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -643,15 +636,9 @@ def test_parser_return_release_notices_from_commit_message( "scope": "", "descriptions": [ ":boom::bug: changed option name", - "A breaking change description", - "Closes: #555", - # This is a bit unusual but its because there is no identifier that will - # identify this as a separate commit so it gets included in the previous commit - "invalid non-conventional formatted commit", ], "breaking_descriptions": [ "A breaking change description", - "Closes: #555", # This is a bit unusual but its because there is no identifier that will # identify this as a separate commit so it gets included in the previous commit "invalid non-conventional formatted commit", @@ -749,7 +736,6 @@ def test_parser_squashed_commit_bitbucket_squash_style( ":bug:(release-config): some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -814,7 +800,6 @@ def test_parser_squashed_commit_bitbucket_squash_style( ":bug:(release-config): some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -839,12 +824,9 @@ def test_parser_squashed_commit_bitbucket_squash_style( "type": ":boom:", "descriptions": [ ":boom::bug: changed option name", - "A breaking change description", - "Closes: #555", ], "breaking_descriptions": [ "A breaking change description", - "Closes: #555", ], "linked_issues": ("#555",), }, @@ -933,11 +915,9 @@ def test_parser_squashed_commit_git_squash_style( "type": ":bug:", "scope": "release-config", "descriptions": [ - # TODO: v10 removal of PR number from subject - ":bug:(release-config): some commit subject (#10)", + ":bug:(release-config): some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -978,11 +958,9 @@ def test_parser_squashed_commit_git_squash_style( "type": ":bug:", "scope": "release-config", "descriptions": [ - # TODO: v10 removal of PR number from subject - ":bug:(release-config): some commit subject (#10)", + ":bug:(release-config): some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -1011,15 +989,11 @@ def test_parser_squashed_commit_git_squash_style( "scope": "", "descriptions": [ ":boom::bug: changed option name", - "A breaking change description", - "Closes: #555", - # This is a bit unusual but its because there is no identifier that will - # identify this as a separate commit so it gets included in the previous commit - "* invalid non-conventional formatted commit", ], "breaking_descriptions": [ "A breaking change description", - "Closes: #555", + # This is a bit unusual but its because there is no identifier that will + # identify this as a separate commit so it gets included in the previous commit "* invalid non-conventional formatted commit", ], "linked_issues": ("#555",), diff --git a/tests/unit/semantic_release/commit_parser/test_scipy.py b/tests/unit/semantic_release/commit_parser/test_scipy.py index 2c15fca6f..46f70b211 100644 --- a/tests/unit/semantic_release/commit_parser/test_scipy.py +++ b/tests/unit/semantic_release/commit_parser/test_scipy.py @@ -1,3 +1,4 @@ +# ruff: noqa: SIM300 from __future__ import annotations from re import compile as regexp @@ -9,7 +10,6 @@ from semantic_release.commit_parser.scipy import ( ScipyCommitParser, ScipyParserOptions, - tag_to_section, ) from semantic_release.commit_parser.token import ParsedCommit, ParseError from semantic_release.enums import LevelBump @@ -37,163 +37,400 @@ def test_parser_raises_unknown_message_style( assert isinstance(result, ParseError) -def test_valid_scipy_parsed_chore_commits( - default_scipy_parser: ScipyCommitParser, - make_commit_obj: MakeCommitObjFn, - scipy_chore_commit_parts: list[tuple[str, str, list[str]]], - scipy_chore_commits: list[str], -): - expected_parts = scipy_chore_commit_parts - - for i, full_commit_msg in enumerate(scipy_chore_commits): - (commit_type, subject, commit_bodies) = expected_parts[i] - commit_bodies = [unwordwrap.sub(" ", body).rstrip() for body in commit_bodies] - expected_type = tag_to_section[commit_type] - expected_descriptions = [ - subject, - *[body.rstrip() for body in commit_bodies if body], - ] - expected_brk_desc: list[str] = [] - - commit = make_commit_obj(full_commit_msg) - parsed_results = default_scipy_parser.parse(commit) - assert isinstance(parsed_results, Iterable) - - result = next(iter(parsed_results)) - assert isinstance(result, ParsedCommit) - assert LevelBump.NO_RELEASE is result.bump - assert expected_type == result.type - assert expected_descriptions == result.descriptions - assert expected_brk_desc == result.breaking_descriptions - assert not result.scope +@pytest.mark.parametrize( + "commit_message, expected_commit_details", + [ + pytest.param( + commit_message, + expected_commit_details, + id=test_id, + ) + for test_id, commit_message, expected_commit_details in [ + ( + "Chore Type: Benchmark related", + dedent( + """\ + BENCH:optimize_milp.py: add new benchmark + Benchmarks the performance of the MILP solver + """ + ), + { + "bump": LevelBump.NO_RELEASE, + "type": "none", + "scope": "optimize_milp.py", + "descriptions": [ + "add new benchmark", + "Benchmarks the performance of the MILP solver", + ], + "breaking_descriptions": [], + "linked_issues": (), + "linked_merge_request": "", + }, + ), + ( + "Chore Type: Dev Tool Related", + dedent( + """\ + DEV: add unicode check to pre-commit hook + """ + ), + { + "bump": LevelBump.NO_RELEASE, + "type": "none", + "scope": "", + "descriptions": [ + "add unicode check to pre-commit hook", + ], + "breaking_descriptions": [], + "linked_issues": (), + "linked_merge_request": "", + }, + ), + ( + "Chore Type: documentation related", + dedent( + """\ + DOC: change approx_fprime doctest (#20568) + """ + ), + { + "bump": LevelBump.NO_RELEASE, + "type": "documentation", + "scope": "", + "descriptions": [ + "change approx_fprime doctest", + ], + "breaking_descriptions": [], + "linked_issues": (), + "linked_merge_request": "#20568", + }, + ), + ( + "Chore Type: style related", + dedent( + """\ + STY: fixed ruff & mypy issues + """ + ), + { + "bump": LevelBump.NO_RELEASE, + "type": "none", + "scope": "", + "descriptions": [ + "fixed ruff & mypy issues", + ], + "breaking_descriptions": [], + "linked_issues": (), + "linked_merge_request": "", + }, + ), + ( + "Chore Type: Test related", + dedent( + """\ + TST: Skip Cython tests for editable installs + """ + ), + { + "bump": LevelBump.NO_RELEASE, + "type": "none", + "scope": "", + "descriptions": [ + "Skip Cython tests for editable installs", + ], + "breaking_descriptions": [], + "linked_issues": (), + "linked_merge_request": "", + }, + ), + ( + "Chore Type: Test related", + dedent( + """\ + TEST: Skip Cython tests for editable installs + """ + ), + { + "bump": LevelBump.NO_RELEASE, + "type": "none", + "scope": "", + "descriptions": [ + "Skip Cython tests for editable installs", + ], + "breaking_descriptions": [], + "linked_issues": (), + "linked_merge_request": "", + }, + ), + ( + "Chore Type: Release related", + dedent( + """\ + REL: set version to 1.0.0 + """ + ), + { + "bump": LevelBump.NO_RELEASE, + "type": "none", + "scope": "", + "descriptions": [ + "set version to 1.0.0", + ], + "breaking_descriptions": [], + "linked_issues": (), + "linked_merge_request": "", + }, + ), + ( + "Patch Type: Build related", + dedent( + """\ + BLD: move the optimize build steps earlier into the build sequence + """ + ), + { + "bump": LevelBump.PATCH, + "type": "fix", + "scope": "", + "descriptions": [ + "move the optimize build steps earlier into the build sequence", + ], + "breaking_descriptions": [], + "linked_issues": (), + "linked_merge_request": "", + }, + ), + ( + "Patch Type: Bug fix", + dedent( + """\ + BUG: Fix invalid default bracket selection in _bracket_minimum (#20563) + """ + ), + { + "bump": LevelBump.PATCH, + "type": "fix", + "scope": "", + "descriptions": [ + "Fix invalid default bracket selection in _bracket_minimum", + ], + "breaking_descriptions": [], + "linked_issues": (), + "linked_merge_request": "#20563", + }, + ), + ( + "Patch Type: Maintenance", + dedent( + """\ + MAINT: optimize.linprog: fix bug when integrality is a list of all zeros (#20586) -def test_valid_scipy_parsed_patch_commits( - default_scipy_parser: ScipyCommitParser, - make_commit_obj: MakeCommitObjFn, - scipy_patch_commit_parts: list[tuple[str, str, list[str]]], - scipy_patch_commits: list[str], -): - expected_parts = scipy_patch_commit_parts - - for i, full_commit_msg in enumerate(scipy_patch_commits): - (commit_type, subject, commit_bodies) = expected_parts[i] - commit_bodies = [unwordwrap.sub(" ", body).rstrip() for body in commit_bodies] - expected_type = tag_to_section[commit_type] - expected_descriptions = [ - subject, - *[body.rstrip() for body in commit_bodies if body], - ] - expected_brk_desc: list[str] = [] + This is a bug fix for the linprog function in the optimize module. - commit = make_commit_obj(full_commit_msg) - parsed_results = default_scipy_parser.parse(commit) - assert isinstance(parsed_results, Iterable) + Closes: #555 + """ + ), + { + "bump": LevelBump.PATCH, + "type": "fix", + "scope": "optimize.linprog", + "descriptions": [ + "fix bug when integrality is a list of all zeros", + "This is a bug fix for the linprog function in the optimize module.", + ], + "breaking_descriptions": [], + "linked_issues": ("#555",), + "linked_merge_request": "#20586", + }, + ), + ( + "Feature Type: Enhancement", + dedent( + """\ + ENH: stats.ttest_1samp: add array-API support (#20545) - result = next(iter(parsed_results)) - assert isinstance(result, ParsedCommit) - assert LevelBump.PATCH is result.bump - assert expected_type == result.type - assert expected_descriptions == result.descriptions - assert expected_brk_desc == result.breaking_descriptions - assert not result.scope + Closes: #1444 + """ + ), + { + "bump": LevelBump.MINOR, + "type": "feature", + "scope": "stats.ttest_1samp", + "descriptions": [ + "add array-API support", + ], + "breaking_descriptions": [], + "linked_issues": ("#1444",), + "linked_merge_request": "#20545", + }, + ), + # ( + # NOT CURRENTLY SUPPORTED + # "Feature Type: Revert", + # dedent( + # """\ + # REV: revert "ENH: add new feature (#20545)" + # This reverts commit 63ec09b9e844e616dcaa7bae35a0b66671b59fbb. + # """ + # ), + # { + # "bump": LevelBump.MINOR, + # "type": "other", + # "scope": "", + # "descriptions": [ + # 'revert "ENH: add new feature (#20545)"', + # "This reverts commit 63ec09b9e844e616dcaa7bae35a0b66671b59fbb.", + # ], + # "breaking_descriptions": [], + # "linked_issues": (), + # "linked_merge_request": "", + # }, + # ), + ( + "Feature Type: FEAT", + dedent( + """\ + FEAT: add new feature (#20545) + """ + ), + { + "bump": LevelBump.MINOR, + "type": "feature", + "scope": "", + "descriptions": [ + "add new feature", + ], + "breaking_descriptions": [], + "linked_issues": (), + "linked_merge_request": "#20545", + }, + ), + ( + "Breaking Type: API", + dedent( + """\ + API: dropped support for Python 3.7 + Users of Python 3.7 should use version 1.0.0 or try to upgrade to Python 3.8 + or later to continue using this package. + """ + ), + { + "bump": LevelBump.MAJOR, + "type": "breaking", + "scope": "", + "descriptions": [ + "dropped support for Python 3.7", + ], + "breaking_descriptions": [ + "Users of Python 3.7 should use version 1.0.0 or try to upgrade to Python 3.8 or later to continue using this package.", + ], + "linked_issues": (), + "linked_merge_request": "", + }, + ), + ( + "Breaking Type: Deprecate", + dedent( + """\ + DEP: deprecated the limprog function -def test_valid_scipy_parsed_minor_commits( + The linprog function is deprecated and will be removed in a future release. + Use the new linprog2 function instead. + """ + ), + { + "bump": LevelBump.MAJOR, + "type": "breaking", + "scope": "", + "descriptions": [ + "deprecated the limprog function", + ], + "breaking_descriptions": [ + "The linprog function is deprecated and will be removed in a future release. Use the new linprog2 function instead.", + ], + "linked_issues": (), + "linked_merge_request": "", + }, + ), + ] + ], +) +def test_scipy_parser_parses_commit_message( default_scipy_parser: ScipyCommitParser, make_commit_obj: MakeCommitObjFn, - scipy_minor_commit_parts: list[tuple[str, str, list[str]]], - scipy_minor_commits: list[str], + commit_message: str, + expected_commit_details: dict | None, ): - expected_parts = scipy_minor_commit_parts - - for i, full_commit_msg in enumerate(scipy_minor_commits): - (commit_type, subject, commit_bodies) = expected_parts[i] - commit_bodies = [unwordwrap.sub(" ", body).rstrip() for body in commit_bodies] - expected_type = tag_to_section[commit_type] - expected_descriptions = [ - subject, - *[body for body in commit_bodies if body], - ] - expected_brk_desc: list[str] = [] - - commit = make_commit_obj(full_commit_msg) - parsed_results = default_scipy_parser.parse(commit) - assert isinstance(parsed_results, Iterable) - - result = next(iter(parsed_results)) - assert isinstance(result, ParsedCommit) - assert LevelBump.MINOR is result.bump - assert expected_type == result.type - assert expected_descriptions == result.descriptions - assert expected_brk_desc == result.breaking_descriptions - assert not result.scope + # Setup: Enable squash commit parsing + parser = ScipyCommitParser( + options=ScipyParserOptions( + **{ + **default_scipy_parser.options.__dict__, + "parse_squash_commits": False, + } + ) + ) + # Build the commit object and parse it + the_commit = make_commit_obj(commit_message) + parsed_results = parser.parse(the_commit) -def test_valid_scipy_parsed_major_commits( - default_scipy_parser: ScipyCommitParser, - make_commit_obj: MakeCommitObjFn, - scipy_major_commit_parts: list[tuple[str, str, list[str]]], - scipy_major_commits: list[str], -): - expected_parts = scipy_major_commit_parts - - for i, full_commit_msg in enumerate(scipy_major_commits): - (commit_type, subject, commit_bodies) = expected_parts[i] - commit_bodies = [unwordwrap.sub(" ", body).rstrip() for body in commit_bodies] - expected_type = tag_to_section[commit_type] - expected_descriptions = [ - subject, - *[body for body in commit_bodies if body], - ] - brkg_prefix = "BREAKING CHANGE: " - expected_brk_desc = [ - # TODO: Python 3.8 limitation, change to removeprefix() for 3.9+ - block[block.startswith(brkg_prefix) and len(brkg_prefix) :] - # block.removeprefix("BREAKING CHANGE: ") - for block in commit_bodies - if block.startswith("BREAKING CHANGE") - ] + # Validate the results + assert isinstance(parsed_results, Iterable) + assert 1 == len( + parsed_results + ), f"Expected 1 parsed result, but got {len(parsed_results)}" - commit = make_commit_obj(full_commit_msg) - parsed_results = default_scipy_parser.parse(commit) + result = next(iter(parsed_results)) - assert isinstance(parsed_results, Iterable) - assert len(parsed_results) == 1 + if expected_commit_details is None: + assert isinstance(result, ParseError) + return - result = next(iter(parsed_results)) - assert isinstance(result, ParsedCommit) - assert LevelBump.MAJOR is result.bump - assert expected_type == result.type - assert expected_descriptions == result.descriptions - assert expected_brk_desc == result.breaking_descriptions - assert not result.scope + assert isinstance(result, ParsedCommit) + # Required + assert expected_commit_details["bump"] == result.bump + assert expected_commit_details["type"] == result.type + # Optional + assert expected_commit_details.get("scope", "") == result.scope + # TODO: v11 change to tuples + assert expected_commit_details.get("descriptions", []) == result.descriptions + assert ( + expected_commit_details.get("breaking_descriptions", []) + == result.breaking_descriptions + ) + assert expected_commit_details.get("linked_issues", ()) == result.linked_issues + assert ( + expected_commit_details.get("linked_merge_request", "") + == result.linked_merge_request + ) @pytest.mark.parametrize( "message, subject, merge_request_number", - # TODO: in v10, we will remove the merge request number from the subject line [ # GitHub, Gitea style ( "ENH: add new feature (#123)", - "add new feature (#123)", + "add new feature", "#123", ), # GitLab style ( "BUG: fix regex in parser (!456)", - "fix regex in parser (!456)", + "fix regex in parser", "!456", ), # BitBucket style ( "ENH: add new feature (pull request #123)", - "add new feature (pull request #123)", + "add new feature", "#123", ), # Both a linked merge request and an issue footer (should return the linked merge request) - ("DEP: add dependency (#123)\n\nCloses: #400", "add dependency (#123)", "#123"), + ("DEP: add dependency (#123)\n\nCloses: #400", "add dependency", "#123"), # None ("BUG: superfix", "superfix", ""), # None but includes an issue footer it should not be considered a linked merge request @@ -233,7 +470,7 @@ def test_parser_return_linked_merge_request_from_commit_message( """\ Merged in feat/my-awesome-stuff (pull request #10) - BUG(release-config): some commit subject + BUG: release-config: some commit subject An additional description @@ -254,7 +491,6 @@ def test_parser_return_linked_merge_request_from_commit_message( "some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -268,7 +504,7 @@ def test_parser_return_linked_merge_request_from_commit_message( """\ Merged in feat/my-awesome-stuff (pull request #10) - BUG(release-config): some commit subject + BUG:release-config: some commit subject An additional description @@ -280,11 +516,11 @@ def test_parser_return_linked_merge_request_from_commit_message( ENH: implemented searching gizmos by keyword - DOC(parser): add new parser pattern + DOC: parser: add new parser pattern - MAINT(cli)!: changed option name + API:cli: changed option name - BREAKING CHANGE: A breaking change description + A breaking change description Closes: #555 @@ -301,7 +537,6 @@ def test_parser_return_linked_merge_request_from_commit_message( "some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -324,18 +559,16 @@ def test_parser_return_linked_merge_request_from_commit_message( }, { "bump": LevelBump.MAJOR, - "type": "fix", + "type": "breaking", "scope": "cli", "descriptions": [ "changed option name", - "BREAKING CHANGE: A breaking change description", - "Closes: #555", - # This is a bit unusual but its because there is no identifier that will - # identify this as a separate commit so it gets included in the previous commit - "invalid non-conventional formatted commit", ], "breaking_descriptions": [ "A breaking change description", + # This is a bit unusual but its because there is no identifier that will + # identify this as a separate commit so it gets included in the previous commit + "invalid non-conventional formatted commit", ], "linked_issues": ("#555",), "linked_merge_request": "#10", @@ -408,7 +641,7 @@ def test_parser_squashed_commit_bitbucket_squash_style( Author: author Date: Sun Jan 19 12:05:23 2025 +0000 - BUG(release-config): some commit subject + BUG: release-config: some commit subject An additional description @@ -429,7 +662,6 @@ def test_parser_squashed_commit_bitbucket_squash_style( "some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -446,7 +678,7 @@ def test_parser_squashed_commit_bitbucket_squash_style( Author: author Date: Sun Jan 19 12:05:23 2025 +0000 - BUG(release-config): some commit subject + BUG: release-config: some commit subject An additional description @@ -466,15 +698,15 @@ def test_parser_squashed_commit_bitbucket_squash_style( Author: author Date: Sat Jan 18 10:13:53 2025 +0000 - DOC(parser): add new parser pattern + DOC: parser: add new parser pattern commit 5f0292fb5a88c3a46e4a02bec35b85f5228e8e51 Author: author Date: Sat Jan 18 10:13:53 2025 +0000 - MAINT(cli): changed option name + API:cli: changed option name - BREAKING CHANGE: A breaking change description + A breaking change description Closes: #555 @@ -494,7 +726,6 @@ def test_parser_squashed_commit_bitbucket_squash_style( "some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -514,12 +745,10 @@ def test_parser_squashed_commit_bitbucket_squash_style( }, { "bump": LevelBump.MAJOR, - "type": "fix", + "type": "breaking", "scope": "cli", "descriptions": [ "changed option name", - "BREAKING CHANGE: A breaking change description", - "Closes: #555", ], "breaking_descriptions": [ "A breaking change description", @@ -589,7 +818,7 @@ def test_parser_squashed_commit_git_squash_style( "Single commit squashed via GitHub PR resolution", dedent( """\ - BUG(release-config): some commit subject (#10) + BUG: release-config: some commit subject (#10) An additional description @@ -606,10 +835,9 @@ def test_parser_squashed_commit_git_squash_style( "type": "fix", "scope": "release-config", "descriptions": [ - "some commit subject (#10)", + "some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -621,7 +849,7 @@ def test_parser_squashed_commit_git_squash_style( "Multiple commits squashed via GitHub PR resolution", dedent( """\ - BUG(release-config): some commit subject (#10) + BUG: release-config: some commit subject (#10) An additional description @@ -633,13 +861,13 @@ def test_parser_squashed_commit_git_squash_style( * ENH: implemented searching gizmos by keyword - * DOC(parser): add new parser pattern + * DOC: parser: add new parser pattern - * MAINT(cli)!: changed option name + * API:cli: changed option name - BREAKING CHANGE: A breaking change description + A breaking change description - Closes: #555 + Closes: #555 * invalid non-conventional formatted commit """ @@ -650,11 +878,9 @@ def test_parser_squashed_commit_git_squash_style( "type": "fix", "scope": "release-config", "descriptions": [ - # TODO: v10 removal of PR number from subject - "some commit subject (#10)", + "some commit subject", "An additional description", "Second paragraph with multiple lines that will be condensed", - "Resolves: #12", "Signed-off-by: author ", ], "linked_issues": ("#12",), @@ -677,18 +903,16 @@ def test_parser_squashed_commit_git_squash_style( }, { "bump": LevelBump.MAJOR, - "type": "fix", + "type": "breaking", "scope": "cli", "descriptions": [ "changed option name", - "BREAKING CHANGE: A breaking change description", - "Closes: #555", - # This is a bit unusual but its because there is no identifier that will - # identify this as a separate commit so it gets included in the previous commit - "* invalid non-conventional formatted commit", ], "breaking_descriptions": [ "A breaking change description", + # This is a bit unusual but its because there is no identifier that will + # identify this as a separate commit so it gets included in the previous commit + "* invalid non-conventional formatted commit", ], "linked_issues": ("#555",), "linked_merge_request": "#10", @@ -866,7 +1090,7 @@ def test_parser_squashed_commit_github_squash_style( *[ # JIRA style ( - f"ENH(parser): add magic parser\n\n{footer}", + f"ENH: parser: add magic parser\n\n{footer}", linked_issues, ) for footer_prefix in SUPPORTED_ISSUE_CLOSURE_PREFIXES @@ -994,7 +1218,7 @@ def test_parser_squashed_commit_github_squash_style( ], *[ ( - f"ENH(parser): add magic parser\n\n{footer}", + f"ENH: parser: add magic parser\n\n{footer}", linked_issues, ) for footer, linked_issues in [ @@ -1006,13 +1230,13 @@ def test_parser_squashed_commit_github_squash_style( ], ( # Only grabs the issue reference when there is a GitHub PR reference in the subject - "ENH(parser): add magic parser (#123)\n\nCloses: #555", + "ENH: parser: add magic parser (#123)\n\nCloses: #555", ["#555"], ), # Does not grab an issue when there is only a GitHub PR reference in the subject - ("ENH(parser): add magic parser (#123)", []), + ("ENH: parser: add magic parser (#123)", []), # Does not grab an issue when there is only a Bitbucket PR reference in the subject - ("ENH(parser): add magic parser (pull request #123)", []), + ("ENH: parser: add magic parser (pull request #123)", []), ], ) def test_parser_return_linked_issues_from_commit_message( @@ -1044,7 +1268,7 @@ def test_parser_return_linked_issues_from_commit_message( "single notice", dedent( """\ - BUG(parser): fix regex in scipy parser + BUG:parser: fix regex in scipy parser NOTICE: This is a notice """ @@ -1055,7 +1279,7 @@ def test_parser_return_linked_issues_from_commit_message( "multiline notice", dedent( """\ - BUG(parser): fix regex in scipy parser + BUG:parser: fix regex in scipy parser NOTICE: This is a notice that is longer than other notices @@ -1067,7 +1291,7 @@ def test_parser_return_linked_issues_from_commit_message( "multiple notices", dedent( """\ - BUG(parser): fix regex in scipy parser + BUG:parser: fix regex in scipy parser NOTICE: This is a notice @@ -1080,9 +1304,9 @@ def test_parser_return_linked_issues_from_commit_message( "notice with other footer", dedent( """\ - BUG(parser): fix regex in scipy parser + BUG:parser: fix regex in scipy parser - BREAKING CHANGE: This is a breaking change + This is a breaking change NOTICE: This is a notice """ diff --git a/tests/unit/semantic_release/hvcs/test_bitbucket.py b/tests/unit/semantic_release/hvcs/test_bitbucket.py index 85f1d7e46..16d77fb87 100644 --- a/tests/unit/semantic_release/hvcs/test_bitbucket.py +++ b/tests/unit/semantic_release/hvcs/test_bitbucket.py @@ -301,6 +301,29 @@ def test_commit_hash_url(default_bitbucket_client: Bitbucket): assert expected_url == default_bitbucket_client.commit_hash_url(sha) +def test_commit_hash_url_w_custom_server(): + """ + Test the commit hash URL generation for a self-hosted Bitbucket server with prefix. + + ref: https://github.com/python-semantic-release/python-semantic-release/issues/1204 + """ + sha = "244f7e11bcb1e1ce097db61594056bc2a32189a0" + expected_url = "{server}/{owner}/{repo}/commits/{sha}".format( + server=f"https://{EXAMPLE_HVCS_DOMAIN}/projects/demo-foo", + owner="foo", + repo=EXAMPLE_REPO_NAME, + sha=sha, + ) + + with mock.patch.dict(os.environ, {}, clear=True): + actual_url = Bitbucket( + remote_url=f"https://{EXAMPLE_HVCS_DOMAIN}/projects/demo-foo/foo/{EXAMPLE_REPO_NAME}.git", + hvcs_domain=f"https://{EXAMPLE_HVCS_DOMAIN}/projects/demo-foo", + ).commit_hash_url(sha) + + assert expected_url == actual_url + + @pytest.mark.parametrize("pr_number", (666, "666", "#666")) def test_pull_request_url(default_bitbucket_client: Bitbucket, pr_number: int | str): expected_url = "{server}/{owner}/{repo}/pull-requests/{pr_number}".format( diff --git a/tests/unit/semantic_release/hvcs/test_gitea.py b/tests/unit/semantic_release/hvcs/test_gitea.py index 710b01b08..d98be8e96 100644 --- a/tests/unit/semantic_release/hvcs/test_gitea.py +++ b/tests/unit/semantic_release/hvcs/test_gitea.py @@ -203,6 +203,29 @@ def test_commit_hash_url(default_gitea_client: Gitea): assert expected_url == default_gitea_client.commit_hash_url(sha) +def test_commit_hash_url_w_custom_server(): + """ + Test the commit hash URL generation for a self-hosted Bitbucket server with prefix. + + ref: https://github.com/python-semantic-release/python-semantic-release/issues/1204 + """ + sha = "244f7e11bcb1e1ce097db61594056bc2a32189a0" + expected_url = "{server}/{owner}/{repo}/commit/{sha}".format( + server=f"https://{EXAMPLE_HVCS_DOMAIN}/projects/demo-foo", + owner="foo", + repo=EXAMPLE_REPO_NAME, + sha=sha, + ) + + with mock.patch.dict(os.environ, {}, clear=True): + actual_url = Gitea( + remote_url=f"https://{EXAMPLE_HVCS_DOMAIN}/projects/demo-foo/foo/{EXAMPLE_REPO_NAME}.git", + hvcs_domain=f"https://{EXAMPLE_HVCS_DOMAIN}/projects/demo-foo", + ).commit_hash_url(sha) + + assert expected_url == actual_url + + @pytest.mark.parametrize("issue_number", (666, "666", "#666")) def test_issue_url(default_gitea_client: Gitea, issue_number: int | str): expected_url = "{server}/{owner}/{repo}/issues/{issue_number}".format( diff --git a/tests/unit/semantic_release/hvcs/test_github.py b/tests/unit/semantic_release/hvcs/test_github.py index f52482ebf..e7f69a5ea 100644 --- a/tests/unit/semantic_release/hvcs/test_github.py +++ b/tests/unit/semantic_release/hvcs/test_github.py @@ -375,6 +375,29 @@ def test_commit_hash_url(default_gh_client: Github): assert expected_url == default_gh_client.commit_hash_url(sha) +def test_commit_hash_url_w_custom_server(): + """ + Test the commit hash URL generation for a self-hosted Bitbucket server with prefix. + + ref: https://github.com/python-semantic-release/python-semantic-release/issues/1204 + """ + sha = "244f7e11bcb1e1ce097db61594056bc2a32189a0" + expected_url = "{server}/{owner}/{repo}/commit/{sha}".format( + server=f"https://{EXAMPLE_HVCS_DOMAIN}/projects/demo-foo", + owner="foo", + repo=EXAMPLE_REPO_NAME, + sha=sha, + ) + + with mock.patch.dict(os.environ, {}, clear=True): + actual_url = Github( + remote_url=f"https://{EXAMPLE_HVCS_DOMAIN}/projects/demo-foo/foo/{EXAMPLE_REPO_NAME}.git", + hvcs_domain=f"https://{EXAMPLE_HVCS_DOMAIN}/projects/demo-foo", + ).commit_hash_url(sha) + + assert expected_url == actual_url + + @pytest.mark.parametrize("issue_number", (666, "666", "#666")) def test_issue_url(default_gh_client: Github, issue_number: str | int): expected_url = "{server}/{owner}/{repo}/issues/{issue_num}".format( diff --git a/tests/unit/semantic_release/hvcs/test_gitlab.py b/tests/unit/semantic_release/hvcs/test_gitlab.py index c4a0979fe..3011de8bb 100644 --- a/tests/unit/semantic_release/hvcs/test_gitlab.py +++ b/tests/unit/semantic_release/hvcs/test_gitlab.py @@ -260,6 +260,29 @@ def test_commit_hash_url(default_gl_client: Gitlab): assert expected_url == default_gl_client.commit_hash_url(REF) +def test_commit_hash_url_w_custom_server(): + """ + Test the commit hash URL generation for a self-hosted Bitbucket server with prefix. + + ref: https://github.com/python-semantic-release/python-semantic-release/issues/1204 + """ + sha = "244f7e11bcb1e1ce097db61594056bc2a32189a0" + expected_url = "{server}/{owner}/{repo}/-/commit/{sha}".format( + server=f"https://{EXAMPLE_HVCS_DOMAIN}/projects/demo-foo", + owner="foo", + repo=EXAMPLE_REPO_NAME, + sha=sha, + ) + + with mock.patch.dict(os.environ, {}, clear=True): + actual_url = Gitlab( + remote_url=f"https://{EXAMPLE_HVCS_DOMAIN}/projects/demo-foo/foo/{EXAMPLE_REPO_NAME}.git", + hvcs_domain=f"https://{EXAMPLE_HVCS_DOMAIN}/projects/demo-foo", + ).commit_hash_url(sha) + + assert expected_url == actual_url + + @pytest.mark.parametrize("issue_number", (666, "666", "#666")) def test_issue_url(default_gl_client: Gitlab, issue_number: int | str): expected_url = "{server}/{owner}/{repo}/-/issues/{issue_num}".format( diff --git a/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py b/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py index fd7cb7dad..8280e95bb 100644 --- a/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py +++ b/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py @@ -165,6 +165,15 @@ def test_pattern_declaration_is_version_replacer(): """if version := '1.0.0': """, f"""if version := '{next_version}': """, ), + ( + "Explicit number format for requirements.txt file with double equals", + f"{test_file}:my-package:{VersionStampType.NUMBER_FORMAT.value}", + # irrelevant for this case + lazy_fixture(default_tag_format_str.__name__), + # Uses double equals separator + """my-package == 1.0.0""", + f"""my-package == {next_version}""", + ), ( "Using default number format for multi-line & quoted json", f"{test_file}:version:{VersionStampType.NUMBER_FORMAT.value}", diff --git a/tests/util.py b/tests/util.py index 63d7679ac..3d4815064 100644 --- a/tests/util.py +++ b/tests/util.py @@ -66,14 +66,13 @@ def assert_exit_code( "", # Explain what command failed "Unexpected exit code from command:", - # f" '{str.join(' ', cli_cmd)}'", indent(f"'{str.join(' ', cli_cmd)}'", " " * 2), "", # Add indentation to each line for stdout & stderr "stdout:", - indent(result.stdout, " " * 2), + indent(result.stdout, " " * 2) if result.stdout.strip() else "", "stderr:", - indent(result.stderr, " " * 2), + indent(result.stderr, " " * 2) if result.stderr.strip() else "", ], ) )