From 4e2946d1496894f37a1d04f00e5a470ab580ed25 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Fri, 12 Sep 2025 21:49:14 -0600 Subject: [PATCH 01/35] ci(deps): bump `python-semantic-release@v10.4.0` action to `v10.4.1` --- .github/workflows/cicd.yml | 2 +- .github/workflows/validate.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 93e0957a4..c03db19c2 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -148,7 +148,7 @@ jobs: - name: Release | Python Semantic Release id: release - uses: python-semantic-release/python-semantic-release@6df5e876c8682fe0753ec2f8c81eb45547e52747 # v10.4.0 + uses: python-semantic-release/python-semantic-release@4d4cb0ab842247caea1963132c242c62aab1e4d5 # v10.4.1 with: github_token: ${{ secrets.GITHUB_TOKEN }} verbosity: 1 diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 44289f651..89274fbbb 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -112,7 +112,7 @@ jobs: - name: Build | Build next version artifacts id: version - uses: python-semantic-release/python-semantic-release@6df5e876c8682fe0753ec2f8c81eb45547e52747 # v10.4.0 + uses: python-semantic-release/python-semantic-release@4d4cb0ab842247caea1963132c242c62aab1e4d5 # v10.4.1 with: github_token: "" verbosity: 1 From 35ad6cc1dcc5f33e09cb0f0d6d42267bce7c8f2e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 13 Sep 2025 03:46:42 +0000 Subject: [PATCH 02/35] ci(deps): bump `python-semantic-release/publish-action@v10.4.0` to `v10.4.1` --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index c03db19c2..86e6c522e 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -155,7 +155,7 @@ jobs: build: false - name: Release | Add distribution artifacts to GitHub Release Assets - uses: python-semantic-release/publish-action@4681bbe581b99f950d7b6f14599870b6a117fdc1 # v10.4.0 + uses: python-semantic-release/publish-action@ae6462adc12bd3d1738070d784b65b5189b955a9 # v10.4.1 if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} From 4cd3f43abb2b04a1403f1eda827f58ba8a6c5b61 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Fri, 12 Sep 2025 22:15:02 -0600 Subject: [PATCH 03/35] chore(pr-template): update github template link to contributing guidelines (#1330) --- .github/PULL_REQUEST_TEMPLATE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 85b471448..bc6ec4f7a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -35,7 +35,7 @@ mention the rationale here. ## PR Completion Checklist -- [ ] Reviewed & followed the [Contributor Guidelines](https://python-semantic-release.readthedocs.io/en/latest/contributing.html) +- [ ] Reviewed & followed the [Contributor Guidelines](https://python-semantic-release.readthedocs.io/en/stable/contributing/contributing_guide.html) - [ ] Changes Implemented & Validation pipeline succeeds From 7f5e35f89a0ebddd0836640c8861812d22fd847e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Sep 2025 21:26:36 -0700 Subject: [PATCH 04/35] build(deps-dev): bump pre-commit ~=3.5 to ~=4.3 (#1313) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index dd3ef56d9..57065576b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -79,7 +79,7 @@ test = [ "requests-mock ~= 1.10", ] dev = [ - "pre-commit ~= 3.5", + "pre-commit ~= 4.3", "tox ~= 4.11", "ruff == 0.6.1" ] From 0bf74d594bb5c2b8ccecd4c4b64b3708a94b6e33 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 12 Sep 2025 21:29:40 -0700 Subject: [PATCH 05/35] build(deps-test): bump `pytest-lazy-fixture` to at least `1.3.4` (#1319) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 57065576b..e5629a06e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ test = [ "pytest-clarity ~= 1.0", "pytest-cov >= 5.0.0, < 7.0.0", "pytest-env ~= 1.0", - "pytest-lazy-fixtures ~= 1.1.1", + "pytest-lazy-fixtures ~= 1.3.4", "pytest-mock ~= 3.0", "pytest-order ~= 1.3", "pytest-pretty ~= 1.2", From 0fb4875fa24ed283ed2d97ff6ab1879669a787ca Mon Sep 17 00:00:00 2001 From: Olivier Morelle Date: Sat, 25 Oct 2025 17:26:21 +0200 Subject: [PATCH 06/35] docs(github-actions): adds release job outputs definition to example (#1344) --- docs/configuration/automatic-releases/github-actions.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/configuration/automatic-releases/github-actions.rst b/docs/configuration/automatic-releases/github-actions.rst index be8a8b480..fbf5110e8 100644 --- a/docs/configuration/automatic-releases/github-actions.rst +++ b/docs/configuration/automatic-releases/github-actions.rst @@ -953,6 +953,9 @@ to the GitHub Release Assets as well. path: dist if-no-files-found: error + outputs: + released: ${{ steps.release.outputs.released || 'false' }} + deploy: # 1. Separate out the deploy step from the publish step to run each step at # the least amount of token privilege From 1a237125badcb597ae7a92db4e01c2ff3293bce8 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 1 Nov 2025 23:03:57 -0700 Subject: [PATCH 07/35] build(deps): change github-actions container image to `python:3.14-slim-trixie` (#1346) --- src/gh_action/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/gh_action/Dockerfile b/src/gh_action/Dockerfile index 138b95a29..7166042ab 100644 --- a/src/gh_action/Dockerfile +++ b/src/gh_action/Dockerfile @@ -1,5 +1,5 @@ # This Dockerfile is only for GitHub Actions -FROM python:3.13-bookworm +FROM python:3.14-slim-trixie ARG WORK_DIR="/opt/psr" WORKDIR ${WORK_DIR} From 8c3b94961ec14f9585b5d8dc8323e828194d17f2 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 1 Nov 2025 23:06:27 -0600 Subject: [PATCH 08/35] ci(deps): bump `actions/stale@v10.0.0` to `v10.1.0` --- .github/workflows/stale.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index d991c59e3..69f838a67 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -28,7 +28,7 @@ jobs: steps: - name: Stale Issues/PRs - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 + uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 with: # default: 30, GitHub Actions API Rate limit is 1000/hr operations-per-run: ${{ env.OPERATIONS_RATE_LIMIT }} @@ -67,7 +67,7 @@ jobs: # that point the submitter has 14 days before a reminder/warning is given. If # no response has been received within 3 weeks, the issue is closed. There are # no exemptions besides removing the awaiting-reply label. - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 + uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 with: # GitHub Actions API Rate limit is 1000/hr operations-per-run: ${{ env.OPERATIONS_RATE_LIMIT }} @@ -97,7 +97,7 @@ jobs: # forgotten completely, this job will post a reminder message to the maintainers # No closures will occur and there are no exemptions besides removing the confirmed # label. - uses: actions/stale@3a9db7e6a41a89f618792c92c0e97cc736e1b13f # v10.0.0 + uses: actions/stale@5f858e3efba33a5ca4407a664cc011ad407f2008 # v10.1.0 with: # GitHub Actions API Rate limit is 1000/hr operations-per-run: ${{ env.OPERATIONS_RATE_LIMIT }} From 175e47f99816ad1a0ebc99714f4dc0d2ffd68671 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 1 Nov 2025 23:04:01 -0600 Subject: [PATCH 09/35] ci(deps): bump `actions/download-artifact@v5.0.0` to `v6.0.0` --- .github/workflows/cicd.yml | 4 ++-- .github/workflows/validate.yml | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index 86e6c522e..de809724e 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -125,7 +125,7 @@ jobs: git reset --hard ${{ github.sha }} - name: Setup | Download Build Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 id: artifact-download with: name: ${{ needs.validate.outputs.distribution-artifacts }} @@ -207,7 +207,7 @@ jobs: steps: - name: Setup | Download Build Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 id: artifact-download with: name: ${{ needs.validate.outputs.distribution-artifacts }} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 89274fbbb..f23246409 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -229,7 +229,7 @@ jobs: cache: 'pip' - name: Setup | Download Distribution Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: ${{ needs.build.outputs.distribution-artifacts }} path: ./dist @@ -318,7 +318,7 @@ jobs: cache: 'pip' - name: Setup | Download Distribution Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: ${{ needs.build.outputs.distribution-artifacts }} path: dist @@ -411,7 +411,7 @@ jobs: ref: ${{ github.sha }} - name: Setup | Download Distribution Artifacts - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: ${{ needs.build.outputs.distribution-artifacts }} path: ${{ env.ACTION_SRC_DIR }} From b8ee944ca4a3ff21eee107a88d87ef0b86c70895 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 1 Nov 2025 23:11:36 -0600 Subject: [PATCH 10/35] ci(deps): bump `actions/upload-artifact@v4.6.2` to `v5.0.0` --- .github/workflows/validate.yml | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index f23246409..ff85c5755 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -139,7 +139,7 @@ jobs: printf '%s\n' "artifacts_name=dist" >> $GITHUB_OUTPUT - name: Upload | Distribution Artifacts - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: ${{ steps.build.outputs.artifacts_name }} path: ${{ steps.build.outputs.dist_dir }} @@ -265,7 +265,7 @@ jobs: --junit-xml=tests/reports/pytest-results.xml - name: Report | Upload Cached Repos on Failure - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 if: ${{ failure() && steps.tests.outcome == 'failure' }} with: name: ${{ format('cached-repos-{0}-{1}', matrix.os, matrix.python-version) }} @@ -275,7 +275,7 @@ jobs: retention-days: 1 - name: Report | Upload Tested Repos on Failure - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 if: ${{ failure() && steps.tests.outcome == 'failure' }} with: name: ${{ format('tested-repos-{0}-{1}', matrix.os, matrix.python-version) }} @@ -363,7 +363,7 @@ jobs: `--junit-xml=tests/reports/pytest-results.xml - name: Report | Upload Cached Repos on Failure - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 if: ${{ failure() && steps.tests.outcome == 'failure' }} with: name: ${{ format('cached-repos-{0}-{1}', matrix.os, matrix.python-version) }} @@ -373,7 +373,7 @@ jobs: retention-days: 1 - name: Report | Upload Tested Repos on Failure - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 if: ${{ failure() && steps.tests.outcome == 'failure' }} with: name: ${{ format('tested-repos-{0}-{1}', matrix.os, matrix.python-version) }} From 4864bb2659d8e36bdddac13c8e5251cd1829064a Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 1 Nov 2025 23:00:36 -0600 Subject: [PATCH 11/35] ci(deps): bump `tj-actions/changed-files@v46.0.5` action to `v47.0.0` --- .github/workflows/ci.yml | 4 ++-- .github/workflows/cicd.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9416cc0b7..4913f923b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,13 +41,13 @@ jobs: - name: Evaluate | Check common file types for changes id: core-changed-files - uses: tj-actions/changed-files@ed68ef82c095e0d48ec87eccea555d944a631a4c #v46.0.5 + uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 #v47.0.0 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@ed68ef82c095e0d48ec87eccea555d944a631a4c #v46.0.5 + uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 #v47.0.0 with: files_yaml: | ci: diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index de809724e..e3148a9fb 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@ed68ef82c095e0d48ec87eccea555d944a631a4c #v46.0.5 + uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 #v47.0.0 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@ed68ef82c095e0d48ec87eccea555d944a631a4c #v46.0.5 + uses: tj-actions/changed-files@24d32ffd492484c1d75e0c0b894501ddb9d30d62 #v47.0.0 with: base_sha: ${{ github.event.push.before }} files_yaml: | From 669c61dbe49e8cca4c376adcae44e575e70fa785 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 1 Nov 2025 23:08:32 -0600 Subject: [PATCH 12/35] ci(deps): bump `mikepenz/action-junit-report@v5.6.2` to `v6.0.0` --- .github/workflows/validate.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ff85c5755..72aa94207 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -195,7 +195,7 @@ jobs: --junit-xml=tests/reports/pytest-results.xml - name: Report | Upload Test Results - uses: mikepenz/action-junit-report@3585e9575db828022551b4231f165eb59a0e74e3 # v5.6.2 + uses: mikepenz/action-junit-report@5b7ee5a21e8674b695313d769f3cbdfd5d4d53a4 # v6.0.0 if: ${{ always() && steps.tests.outcome != 'skipped' }} with: report_paths: ./tests/reports/*.xml @@ -285,7 +285,7 @@ jobs: retention-days: 1 - name: Report | Upload Test Results - uses: mikepenz/action-junit-report@3585e9575db828022551b4231f165eb59a0e74e3 # v5.6.2 + uses: mikepenz/action-junit-report@5b7ee5a21e8674b695313d769f3cbdfd5d4d53a4 # v6.0.0 if: ${{ always() && steps.tests.outcome != 'skipped' }} with: report_paths: ./tests/reports/*.xml @@ -383,7 +383,7 @@ jobs: retention-days: 1 - name: Report | Upload Test Results - uses: mikepenz/action-junit-report@3585e9575db828022551b4231f165eb59a0e74e3 # v5.6.2 + uses: mikepenz/action-junit-report@5b7ee5a21e8674b695313d769f3cbdfd5d4d53a4 # v6.0.0 if: ${{ always() && steps.tests.outcome != 'skipped' }} with: report_paths: ./tests/reports/*.xml From 5ca835f29c8111ea34227a15e0be06ad10464a03 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Nov 2025 07:12:30 -0800 Subject: [PATCH 13/35] build(deps-test): relax `pytest-lazy-fixtures` version requirement to allow `1.4.0` (#1336) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e5629a06e..a63b03943 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -70,7 +70,7 @@ test = [ "pytest-clarity ~= 1.0", "pytest-cov >= 5.0.0, < 7.0.0", "pytest-env ~= 1.0", - "pytest-lazy-fixtures ~= 1.3.4", + "pytest-lazy-fixtures ~= 1.4", "pytest-mock ~= 3.0", "pytest-order ~= 1.3", "pytest-pretty ~= 1.2", From 8877d8189b38a9be4f59f805d92d18fa95d2cc4b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Nov 2025 07:13:14 -0800 Subject: [PATCH 14/35] build(deps-test): expand `pytest-cov` dependency requirement to support `v7` (#1333) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index a63b03943..e27ba7ec1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -68,7 +68,7 @@ test = [ "pyyaml ~= 6.0", "pytest ~= 8.3", "pytest-clarity ~= 1.0", - "pytest-cov >= 5.0.0, < 7.0.0", + "pytest-cov >= 5.0.0, < 8.0.0", "pytest-env ~= 1.0", "pytest-lazy-fixtures ~= 1.4", "pytest-mock ~= 3.0", From e3ae8acb23114aff23e2f993b219a3e02bfdc500 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Nov 2025 08:02:26 -0800 Subject: [PATCH 15/35] build(deps-docs): bump `furo` theme version from ~=2024.1 to ~=2025.9 (#1337) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index e27ba7ec1..7e8442548 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -58,7 +58,7 @@ docs = [ "Sphinx ~= 6.0", "sphinxcontrib-apidoc == 0.5.0", "sphinx-autobuild == 2024.2.4", - "furo ~= 2024.1", + "furo ~= 2025.9", ] test = [ "coverage[toml] ~= 7.0", From 76c6f75b64edc4172d73a936930bd18b826411c0 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 2 Nov 2025 08:33:58 -0800 Subject: [PATCH 16/35] chore(scripts): add docs development script (#1350) --- scripts/watch_docs.sh | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 scripts/watch_docs.sh diff --git a/scripts/watch_docs.sh b/scripts/watch_docs.sh new file mode 100644 index 000000000..e3608442e --- /dev/null +++ b/scripts/watch_docs.sh @@ -0,0 +1,23 @@ +#!/bin/bash + +if command -v realpath >/dev/null 2>&1; then + PROJ_ROOT=$(realpath "$(dirname "${BASH_SOURCE[0]}")/..") +elif command -v readlink >/dev/null 2>&1; then + PROJ_ROOT=$(readlink -f "$(dirname "${BASH_SOURCE[0]}")/..") +else + PROJ_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd) +fi + +[ -z "$VIRTUAL_ENV" ] && VIRTUAL_ENV=".venv" +SPHINX_AUTOBUILD_EXE="$VIRTUAL_ENV/bin/sphinx-autobuild" + +cd "$PROJ_ROOT" || exit 1 + +if [ ! -f "$SPHINX_AUTOBUILD_EXE" ]; then + printf '%s\n' "sphinx-autobuild is not installed in the virtual environment. Please install the docs extras." + exit 1 +fi + +rm -rf docs/_build/html docs/api/modules + +exec "$SPHINX_AUTOBUILD_EXE" docs docs/_build/html --open-browser --port 9000 --ignore docs/api/modules From 23b8a6c99ebb3872003f5784f1a51926c72c1d1e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Nov 2025 08:44:05 -0800 Subject: [PATCH 17/35] build(deps-docs): bump `sphinxcontrib-apidoc` from 0.5.0 to 0.6.0 (#1242) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7e8442548..455646044 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,7 +56,7 @@ build = [ ] docs = [ "Sphinx ~= 6.0", - "sphinxcontrib-apidoc == 0.5.0", + "sphinxcontrib-apidoc == 0.6.0", "sphinx-autobuild == 2024.2.4", "furo ~= 2025.9", ] From 66cc9d1f867b51afa47b9990860cfcaa165a7d8c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sun, 2 Nov 2025 09:54:59 -0800 Subject: [PATCH 18/35] build(deps-docs): bump `sphinx` dependency from ~=6.0.0 to ~=7.4.0 (#712) --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 455646044..45c9c2ea1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,7 +55,7 @@ build = [ "build ~= 1.2" ] docs = [ - "Sphinx ~= 6.0", + "Sphinx ~= 7.4", "sphinxcontrib-apidoc == 0.6.0", "sphinx-autobuild == 2024.2.4", "furo ~= 2025.9", From 4ce1fcac60ac73657a4aaaaa3cb7c4afc7eac2c1 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 2 Nov 2025 10:17:53 -0800 Subject: [PATCH 19/35] feat(cmd-version): adds c-macro style version definition support to `version_variables` (#1349) Implements: #1348 * docs(configuration): update `version_variables` examples with a c-macro style replacement * test(version-variables): add c-macro style version stamp unit test --- docs/configuration/configuration.rst | 6 ++- .../version/declarations/pattern.py | 4 +- .../declarations/test_pattern_declaration.py | 53 +++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/docs/configuration/configuration.rst b/docs/configuration/configuration.rst index 2b2278382..cdd3604fb 100644 --- a/docs/configuration/configuration.rst +++ b/docs/configuration/configuration.rst @@ -1354,7 +1354,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. As of v10.0.0, a double-equals (``==``) operator is also supported - as a valid operand symbol. + as a valid operand symbol. As of $NEW_RELEASE_TAG, PSR can omit all operands as long + as there is at least one whitespace character between the variable name and the version. 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, @@ -1410,6 +1411,9 @@ will be matched and replaced by the new version: # requirements.txt my-package == 1.2.3 + # C-Macro style (no operand only whitespace required) + #define VERSION "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/src/semantic_release/version/declarations/pattern.py b/src/semantic_release/version/declarations/pattern.py index f08c208a4..4d285b5a1 100644 --- a/src/semantic_release/version/declarations/pattern.py +++ b/src/semantic_release/version/declarations/pattern.py @@ -228,8 +228,8 @@ def from_string_definition( # 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/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py b/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py index 8280e95bb..ddca3dbf6 100644 --- a/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py +++ b/tests/unit/semantic_release/version/declarations/test_pattern_declaration.py @@ -215,6 +215,59 @@ def test_pattern_declaration_is_version_replacer(): """ ), ), + ( + "Using default number format for c-macro style definition (see #1348)", + f"{test_file}:APP_VERSION:{VersionStampType.NUMBER_FORMAT.value}", + # irrelevant for this case + lazy_fixture(default_tag_format_str.__name__), + # Uses colon separator with double quotes + dedent( + """\ + #ifndef VERSION_H + #define VERSION_H + + #define APP_VERSION "0.0.0" + + #endif // VERSION_H + """ + ), + dedent( + f"""\ + #ifndef VERSION_H + #define VERSION_H + + #define APP_VERSION "{next_version}" + + #endif // VERSION_H + """ + ), + ), + ( + "Using default tag format for c-macro style definition (see #1348)", + f"{test_file}:APP_VERSION:{VersionStampType.TAG_FORMAT.value}", + lazy_fixture(default_tag_format_str.__name__), + # Uses colon separator with double quotes + dedent( + """\ + #ifndef VERSION_H + #define VERSION_H + + #define APP_VERSION "v0.0.0" + + #endif // VERSION_H + """ + ), + dedent( + f"""\ + #ifndef VERSION_H + #define VERSION_H + + #define APP_VERSION "v{next_version}" + + #endif // VERSION_H + """ + ), + ), ] ], ) From 0f7386ff085425f29fd33e75b3fcf47274502b1f Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 2 Nov 2025 12:49:22 -0800 Subject: [PATCH 20/35] ci(deps): lock `pypa/gh-action-pypi-publish` action to sha-1 of `v1.13.0` (#1359) --- .github/workflows/cicd.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index e3148a9fb..cb807c1b5 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -216,7 +216,7 @@ jobs: # see https://docs.pypi.org/trusted-publishers/ - name: Publish package distributions to PyPI id: pypi-publish - uses: pypa/gh-action-pypi-publish@v1.13.0 + uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0 with: packages-dir: dist print-hash: true From 55c94ecde1aec47b88aa172d031ab33afa7f795d Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 2 Nov 2025 13:06:49 -0800 Subject: [PATCH 21/35] build(deps): bump `tomlkit` dependency from ~=0.11.0 to ~=0.13.0 (#1355) * build(deps-build): tighten `tomlkit` requirement since its a 0-prefixed version --- pyproject.toml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 45c9c2ea1..0defd32c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -29,7 +29,7 @@ dependencies = [ "requests ~= 2.25", "jinja2 ~= 3.1", "python-gitlab >= 4.0.0, < 7.0.0", - "tomlkit ~= 0.11", + "tomlkit ~= 0.13.0", "dotty-dict ~= 1.3", "importlib-resources ~= 6.0", "pydantic ~= 2.0", @@ -52,7 +52,8 @@ repository = "http://github.com/python-semantic-release/python-semantic-release. [project.optional-dependencies] build = [ - "build ~= 1.2" + "build ~= 1.2", + "tomlkit ~= 0.13.0", ] docs = [ "Sphinx ~= 7.4", From 3d7fdd7d2b6f866ad4304dd489da1446d43b89d2 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 2 Nov 2025 13:14:48 -0800 Subject: [PATCH 22/35] ci(github): set Python 3.14 as highest version threshold for E2E testing (#1356) --- .github/workflows/ci.yml | 2 +- .github/workflows/cicd.yml | 4 ++-- .github/workflows/manual.yml | 7 +++++++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4913f923b..5e045630e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,7 +87,7 @@ jobs: # It was a bit of overkill before testing every minor version, and since this project is all about # SemVer, we should expect Python to adhere to that model to. Therefore Only test across 2 OS's but # the lowest supported minor version and the latest stable minor version (just in case). - python-versions-linux: '["3.8", "3.13"]' + python-versions-linux: '["3.8", "3.14"]' # Since the test suite takes ~4 minutes to complete on windows, and windows is billed higher # we are only going to run it on the oldest version of python we support. The older version # will be the most likely area to fail as newer minor versions maintain compatibility. diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index cb807c1b5..c7d1033c5 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -76,8 +76,8 @@ jobs: # It was a bit of overkill before testing every minor version, and since this project is all about # SemVer, we should expect Python to adhere to that model to. Therefore Only test across 2 OS's but # the lowest supported minor version and the latest stable minor version. - python-versions-linux: '["3.8", "3.13"]' - python-versions-windows: '["3.8", "3.13"]' + python-versions-linux: '["3.8", "3.14"]' + python-versions-windows: '["3.8", "3.14"]' files-changed: ${{ needs.eval-changes.outputs.any-file-changes }} build-files-changed: ${{ needs.eval-changes.outputs.build-changes }} ci-files-changed: ${{ needs.eval-changes.outputs.ci-changes }} diff --git a/.github/workflows/manual.yml b/.github/workflows/manual.yml index 509e66b87..4f9e277c4 100644 --- a/.github/workflows/manual.yml +++ b/.github/workflows/manual.yml @@ -14,6 +14,11 @@ on: type: boolean required: true default: true + python3-14: + description: 'Test Python 3.14?' + type: boolean + required: true + default: true python3-13: description: 'Test Python 3.13?' type: boolean @@ -79,6 +84,7 @@ jobs: "3.11" if str(os.getenv("INPUT_PY3_11", False)).lower() == str(True).lower() else None, "3.12" if str(os.getenv("INPUT_PY3_12", False)).lower() == str(True).lower() else None, "3.13" if str(os.getenv("INPUT_PY3_13", False)).lower() == str(True).lower() else None, + "3.14" if str(os.getenv("INPUT_PY3_14", False)).lower() == str(True).lower() else None, ])) linux_versions = ( @@ -105,6 +111,7 @@ jobs: INPUT_PY3_11: ${{ inputs.python3-11 }} INPUT_PY3_12: ${{ inputs.python3-12 }} INPUT_PY3_13: ${{ inputs.python3-13 }} + INPUT_PY3_14: ${{ inputs.python3-14 }} INPUT_LINUX: ${{ inputs.linux }} INPUT_WINDOWS: ${{ inputs.windows }} run: | From d0937133c765100fd4e74f6d269c7fa477fede91 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 2 Nov 2025 13:43:07 -0800 Subject: [PATCH 23/35] chore(scripts): add build script that strips dev dependencies from wheel (#1358) --- scripts/build.sh | 224 +++++++++++++++++++++++++++++++++++++++++++++++ scripts/utils.sh | 149 +++++++++++++++++++++++++++++++ 2 files changed, 373 insertions(+) create mode 100644 scripts/build.sh create mode 100644 scripts/utils.sh diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100644 index 000000000..2aa90ba99 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,224 @@ +#!/bin/bash + +set -eu -o pipefail + +function load_env() { + set -eu -o pipefail + + if [ "${UTILITIES_SH_LOADED:-false}" = "false" ]; then + local __FILE__="" + __FILE__="$(realpath "${BASH_SOURCE[0]}")" + + local __DIR__="" + __DIR__="$(realpath "$(dirname "$__FILE__")")" + + local ROOT_DIR="" + ROOT_DIR="$(realpath "$(dirname "$__DIR__")")" + + # shellcheck source=scripts/utils.sh + source "$ROOT_DIR/scripts/utils.sh" + + load_base_env + fi +} + +function with_temp_working_dir() { + local working_dir="${1:?"Working directory not specified, but is required!"}" + pushd "$working_dir" >/dev/null || return 1 + explicit_run_cmd "${@:2}" || return 1 + popd >/dev/null || return 1 +} + +function build_sdist() { + local status_msg="${1:?"Status message not specified, but is required!"}" + local output_dir="${2:?"Output directory not specified, but is required!"}" + + if ! explicit_run_cmd_w_status_wrapper \ + "$status_msg" \ + python3 -m build --sdist . --outdir "$output_dir" ">/dev/null"; + then + return 1 + fi + find "$output_dir" -type f -name "*.tar.gz" -exec \ + sh -c 'printf "%s\n" "Successfully built $1"' shell {} \; +} + +function unpack_sdist() { + local sdist_file="${1:?"Source distribution file not specified, but is required!"}" + local output_dir="${2:?"Output directory not specified, but is required!"}" + + mkdir -p "$output_dir" + if ! explicit_run_cmd_w_status_wrapper \ + "Unpacking sdist code into '$output_dir'" \ + tar -xzf "$sdist_file" -C "$output_dir" --strip-components=1; + then + return 1 + fi +} + + +function strip_optional_dependencies { + local -r pyproject_file="${1:?'param[1]: Path to pyproject.toml file is required'}" + local -r exclude_groups=("${@:2}") + local python_snippet="\ + from pathlib import Path + from sys import argv, exit + try: + import tomlkit + except ModuleNotFoundError: + print('Failed Import: Missing build requirement \'tomlkit\'.') + exit(1) + + pyproject_file = Path(argv[1]) + config = tomlkit.loads(pyproject_file.read_text()) + proj_config = config.get('project', {}) + + if not (opt_deps := proj_config.get('optional-dependencies', {})): + exit(0) + + if not (dep_group_to_remove := argv[2:]): + exit(0) + + for group in dep_group_to_remove: + if group in opt_deps: + opt_deps.pop(group) + + if not opt_deps: + proj_config.pop('optional-dependencies') + + pyproject_file.write_text(tomlkit.dumps(config)) + " + # make whitespace nice for python (remove indent) + python_snippet="$(printf '%s\n' "$python_snippet" | sed -E 's/([ ]{4,8}|\t)(.*)/\2/')" + + if [ "${#exclude_groups[@]}" -eq 0 ]; then + error "At least one dependency group to exclude must be specified!" + return 1 + fi + + python3 -c "$python_snippet" "$pyproject_file" "${exclude_groups[@]}" || return 1 +} + +remove_empty_init_files() { + local dirpath="${1:-.}" + + # SNIPPET: Remove empty __init__.py files + local python_snippet='\ + from pathlib import Path + from sys import exit, argv, stderr + + if len(argv) < 2 or not (dirpath := Path(argv[1])).is_dir(): + print("Usage: ", file=stderr) + exit(1) + + for filepath in dirpath.resolve().rglob("__init__.py"): + if not filepath.is_file(): + continue + if not filepath.read_text().strip(): + filepath.unlink() + print(f"Removed {filepath}") + ' + # make whitespace nice for python + python_snippet="$(printf '%s\n' "$python_snippet" | sed -E 's/([ ]{4,8}|\t)(.*)/\2/')" + + python3 -c "$python_snippet" "$dirpath" || return 1 +} + +function build_production_whl() { + # Assumes the current working directory is the directory to modify + local dest_dir="${1:?"param[1]: output directory not specified, but required!"}" + + # Strip out development dependencies + explicit_run_cmd_w_status_wrapper \ + "Masking development dependencies" \ + strip_optional_dependencies "pyproject.toml" "build" "dev" "docs" "test" "mypy" || return 1 + + # Optimize code for runtime + explicit_run_cmd_w_status_wrapper \ + "Removing empty '__init__.py' files" \ + remove_empty_init_files "src" || return 1 + + # Remove editable info from the source directory before wheel build + rm -rf src/*.egg-info/ + + # Build the wheel into the output directory + explicit_run_cmd_w_status_wrapper \ + "Constructing wheel package" \ + python3 -m build --wheel . --outdir "$dest_dir" || return 1 +} + +function build_wheel_from_sdist() { + local build_dir="${1:?"param[1]: Build directory not specified, but is required!"}" + local dest_dir="${2:?"param[2]: Output directory not specified, but is required!"}" + local tmp_src_dir="$build_dir/sdist" + + unpack_sdist "$build_dir/*.tar.gz" "$tmp_src_dir" || return 1 + + with_temp_working_dir "$tmp_src_dir" build_production_whl "$dest_dir" || return 1 + + rm -rf "$tmp_src_dir" +} + +function build_production_package() { + local dest_dir + local output_dir="${1:?"param[1]: Output directory not specified, but required!"}" + local build_dir="build" + + # If the output directory is not an absolute path, make it absolute + if ! stdout "$output_dir" | grep -q -E '^/'; then + dest_dir="$(realpath ".")/$output_dir" + else + dest_dir="$output_dir" + fi + + # Clean up any existing output directory + if [ -d "$dest_dir" ]; then + rm -rf "$dest_dir" + fi + + # Clean up any existing build directory + if [ -d "$build_dir" ]; then + rm -rf "$build_dir" + fi + + build_sdist "Bundling source code" "$build_dir" || return 1 + + explicit_run_cmd_w_status_wrapper \ + "Building production wheel from sdist" \ + build_wheel_from_sdist "$build_dir" "$dest_dir" || return 1 + + rm -rf "$build_dir" +} + +function main() { + set -eu -o pipefail + + cd "$PROJ_ROOT_DIR" + + if ! explicit_run_cmd_w_status_wrapper \ + "Verifying Python environment" \ + verify_python "$MINIMUM_PYTHON_VERSION"; + then + info "Please run the dev setup script and activate the virtual environment first." + return 1 + fi + + explicit_run_cmd_w_status_wrapper \ + "Verifying build dependencies exist" \ + python3 -m pip install -e ".[build]" ">/dev/null" + + explicit_run_cmd_w_status_wrapper \ + "Building production package" \ + build_production_package "dist" +} + +######################################################################## +# CONDITIONAL AUTO-EXECUTE # +######################################################################## + +if ! (return 0 2>/dev/null); then + # Since this script is not being sourced, run the main function + unset -v UTILITIES_SH_LOADED # Ensure utils are reloaded when called from another script + load_env + main "$@" +fi diff --git a/scripts/utils.sh b/scripts/utils.sh new file mode 100644 index 000000000..384f9735b --- /dev/null +++ b/scripts/utils.sh @@ -0,0 +1,149 @@ +#!/bin/bash + +function load_base_env() { + set -eu -o pipefail + + local __FILE__="" + __FILE__="$(realpath "${BASH_SOURCE[0]}")" + + PROJ_ROOT_DIR="$(realpath "$(dirname "$(realpath "$(dirname "$__FILE__")")")")" + export PROJ_ROOT_DIR + + export DIST_DIR="$PROJ_ROOT_DIR/dist" + export SCRIPTS_DIR="$PROJ_ROOT_DIR/scripts" + export VENV_DIR="$PROJ_ROOT_DIR/.venv" + export PROJECT_CONFIG_FILE="$PROJ_ROOT_DIR/pyproject.toml" + export MINIMUM_PYTHON_VERSION="3.9" + export PIP_DISABLE_PIP_VERSION_CHECK="true" +} + +function stdout { printf "%b\n" "$*"; } +function stderr { stdout "$@" >&2; } +function info { stdout "[+] $*"; } + +function warning { + local prefix="[!] " + if [ "${CI:-false}" = "true" ] && [ -n "${GITHUB_ACTIONS:-}" ]; then + prefix="::notice::" + fi + stderr "${prefix}WARNING: $*"; +} + +function error { + local prefix="[-] " + if [ "${CI:-false}" = "true" ] && [ -n "${GITHUB_ACTIONS:-}" ]; then + prefix="::error::" + fi + stderr "${prefix}ERROR: $*"; +} + +function is_command { + local cmd="${1:?"param[1]: missing command to check."}" + command -v "$cmd" >/dev/null || { + error "Command '$cmd' not found." + return 1 + } +} + +function explicit_run_cmd { + local cmd="${1:?"param[1]: command not specified, but is required!"}" + set -- "${@:2}" # shift off the first argument + local args="$*" + + # Default as a function call + local log_msg="$cmd($args)" + + # Needs to run in bash because zsh which will return 0 for a defined function + if bash -c "which $cmd >/dev/null"; then + log_msg="${SHELL:-/bin/sh} -c '$cmd $args'" + fi + + stderr " $log_msg" + eval "$cmd $args" +} + +function explicit_run_cmd_w_status_wrapper { + local status_msg="${1:?"param[1]: status message not specified, but is required!"}" + local cmd="${2:?"param[2]: command not specified, but is required!"}" + set -- "${@:3}" # shift off the first two arguments + + if [ -z "$cmd" ]; then + error "Command not specified, but is required!" + return 1 + fi + + info "${status_msg}..." + if ! explicit_run_cmd "$cmd" "$@"; then + error "${status_msg}...FAILED" + return 1 + fi + info "${status_msg}...DONE" +} + +function verify_python_version() { + local python3_exe="${1:?"param[1]: path to python3 executable is required"}" + local min_version="${2:?"param[2]: minimum python version is required"}" + + if ! [[ "$min_version" =~ ^v?[0-9]+(\.[0-9]+){0,2}$ ]]; then + error "Invalid minimum python version format: '$min_version'. Expected format: 'X', 'X.Y', or 'X.Y.Z'" + return 1 + fi + + local min_major_version="" + min_major_version="$(stdout "$min_version" | cut -d. -f1 | tr -d 'v')" + + local min_minor_version="" + min_minor_version="$(stdout "$min_version" | cut -d. -f2)" + min_minor_version="${min_minor_version:-0}" + + local min_patch_version="" + min_patch_version="$(stdout "$min_version" | cut -d. -f3)" + min_patch_version="${min_patch_version:-0}" + + local python_version_str="" + if ! python_version_str="$("$python3_exe" --version 2>&1 | awk '{print $2}')"; then + error "Failed to get python version string from '$python3_exe'" + return 1 + fi + + local python_major_version="" + python_major_version="$(stdout "$python_version_str" | cut -d. -f1)" + + local python_minor_version="" + python_minor_version="$(stdout "$python_version_str" | cut -d. -f2)" + + local python_patch_version="" + python_patch_version="$(stdout "$python_version_str" | cut -d. -f3)" + + if [ "$python_major_version" -ne "$min_major_version" ]; then + error "Python major version mismatch! Required version: $min_major_version, Found version: $python_version_str" + return 1 + fi + + if [ "$python_minor_version" -lt "$min_minor_version" ] || [ "$python_patch_version" -lt "$min_patch_version" ]; then + error "Python version ^${min_major_version}.${min_minor_version}.${min_patch_version}+ is required! Found version: $python_version_str" + return 1 + fi +} + +function verify_python() { + set -eu -o pipefail + local -r min_python_version="${1:?"param[1]: minimum python version parameter is required!"}" + + is_command "python3" || { + error "Python 3 is not detected. Script requires Python $min_python_version+!" + return 1 + } + + local python3_exe="" + python3_exe="$(which python3)" + + if ! [ -f "$(dirname "$python3_exe")/../pyvenv.cfg" ]; then + error "No virtual environment detected." + return 1 + fi + + verify_python_version "$python3_exe" "$min_python_version" +} + +export UTILITIES_SH_LOADED="true" From 82c5a7fdfeb83f46f0d2ded439d89d38ebc19abf Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 2 Nov 2025 13:25:33 -0700 Subject: [PATCH 24/35] build(config): declare python 3.14 compatibility --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 0defd32c4..734757b35 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -19,6 +19,7 @@ classifiers = [ "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", ] readme = "README.rst" authors = [{ name = "Rolf Erik Lekang", email = "me@rolflekang.com" }] From 4998421c6564c2f8f050780e87b085a8e94993f1 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 2 Nov 2025 13:24:00 -0700 Subject: [PATCH 25/35] build(config): add `codejedi365` as package author --- pyproject.toml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 734757b35..4b2263493 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,10 @@ classifiers = [ "Programming Language :: Python :: 3.14", ] readme = "README.rst" -authors = [{ name = "Rolf Erik Lekang", email = "me@rolflekang.com" }] +authors = [ + { name = "Rolf Erik Lekang", email = "me@rolflekang.com" }, + { name = "codejedi365", email = "codejedi365@gmail.com" }, +] dependencies = [ "click ~= 8.1.0", "click-option-group ~= 0.5", From d77193e30807968ba6a26bd356a868db62dc1098 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Thu, 6 Nov 2025 23:08:03 -0800 Subject: [PATCH 26/35] feat(cmd-version): adds upstream check into workflow to prevent commit push collisions (#1360) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> * test(cmd-version): add e2e tests to verify upstream version check before push * test(git-project): adds unit tests that evaluate all error cases of verify unchanged upstream * test(fixtures): add `git.fetch()` mock fixture & remote reference branch * test(cmd-version): add mocking of git fetch to prevent errors from upstream check * docs(github-actions): removed verify upstream status step from example workflow * docs(commands): add description of automated upstream version checking upon version creation * docs(uv-integration): remove verify upstream check from uv integration example --- docs/api/commands.rst | 12 + .../automatic-releases/github-actions.rst | 49 +-- .../configuration-guides/uv_integration.rst | 1 - src/semantic_release/cli/commands/version.py | 30 +- src/semantic_release/errors.py | 16 + src/semantic_release/gitproject.py | 96 ++++++ .../git_flow/test_repo_1_channel.py | 5 + .../git_flow/test_repo_2_channels.py | 5 + .../git_flow/test_repo_3_channels.py | 5 + .../git_flow/test_repo_4_channels.py | 5 + .../github_flow/test_repo_1_channel.py | 5 + ...test_repo_1_channel_branch_update_merge.py | 5 + .../github_flow/test_repo_2_channels.py | 5 + .../test_monorepo_1_channel.py | 5 + .../test_monorepo_2_channels.py | 5 + .../trunk_based_dev/test_repo_trunk.py | 5 + .../test_repo_trunk_dual_version_support.py | 5 + ...runk_dual_version_support_w_prereleases.py | 5 + .../test_repo_trunk_w_prereleases.py | 5 + .../test_monorepo_trunk.py | 5 + tests/e2e/cmd_version/test_version.py | 11 + tests/e2e/cmd_version/test_version_build.py | 10 + tests/e2e/cmd_version/test_version_bump.py | 93 ++++++ tests/e2e/cmd_version/test_version_print.py | 15 + .../cmd_version/test_version_release_notes.py | 4 + tests/e2e/cmd_version/test_version_stamp.py | 1 + tests/e2e/cmd_version/test_version_strict.py | 2 + .../test_version_upstream_check.py | 283 ++++++++++++++++++ tests/e2e/conftest.py | 21 ++ tests/fixtures/git_repo.py | 62 +++- tests/fixtures/monorepos/git_monorepo.py | 15 +- .../unit/semantic_release/test_gitproject.py | 246 +++++++++++++++ 32 files changed, 989 insertions(+), 48 deletions(-) create mode 100644 tests/e2e/cmd_version/test_version_upstream_check.py create mode 100644 tests/unit/semantic_release/test_gitproject.py diff --git a/docs/api/commands.rst b/docs/api/commands.rst index 3dca77474..cdf9be45c 100644 --- a/docs/api/commands.rst +++ b/docs/api/commands.rst @@ -118,6 +118,18 @@ By default (in order): #. Create a release in the remote VCS for this tag (if supported) +.. note:: + + Before pushing changes to the remote (step 6), Python Semantic Release automatically + verifies that the upstream branch has not changed since the commit that triggered + the release. This prevents push conflicts when another commit was made to the + upstream branch while the release was being prepared. If the upstream branch has + changed, the command will exit with an error, and you will need to pull the latest + changes and run the command again. + + This verification only occurs when committing changes (``--commit``). If you are + running with ``--no-commit``, the verification will not be performed. + All of these steps can be toggled on or off using the command line options described below. Some of the steps rely on others, so some options may implicitly disable others. diff --git a/docs/configuration/automatic-releases/github-actions.rst b/docs/configuration/automatic-releases/github-actions.rst index fbf5110e8..c515ed474 100644 --- a/docs/configuration/automatic-releases/github-actions.rst +++ b/docs/configuration/automatic-releases/github-actions.rst @@ -891,45 +891,6 @@ to the GitHub Release Assets as well. run: | git reset --hard ${{ github.sha }} - - name: Evaluate | Verify upstream has NOT changed - # Last chance to abort before causing an error as another PR/push was applied to - # the upstream branch while this workflow was running. This is important - # because we are committing a version change (--commit). You may omit this step - # if you have 'commit: false' in your configuration. - # - # You may consider moving this to a repo script and call it from this step instead - # of writing it in-line. - shell: bash - run: | - set +o pipefail - - UPSTREAM_BRANCH_NAME="$(git status -sb | head -n 1 | awk -F '\\.\\.\\.' '{print $2}' | cut -d ' ' -f1)" - printf '%s\n' "Upstream branch name: $UPSTREAM_BRANCH_NAME" - - set -o pipefail - - if [ -z "$UPSTREAM_BRANCH_NAME" ]; then - printf >&2 '%s\n' "::error::Unable to determine upstream branch name!" - exit 1 - fi - - git fetch "${UPSTREAM_BRANCH_NAME%%/*}" - - if ! UPSTREAM_SHA="$(git rev-parse "$UPSTREAM_BRANCH_NAME")"; then - printf >&2 '%s\n' "::error::Unable to determine upstream branch sha!" - exit 1 - fi - - HEAD_SHA="$(git rev-parse HEAD)" - - if [ "$HEAD_SHA" != "$UPSTREAM_SHA" ]; then - printf >&2 '%s\n' "[HEAD SHA] $HEAD_SHA != $UPSTREAM_SHA [UPSTREAM SHA]" - printf >&2 '%s\n' "::error::Upstream has changed, aborting release..." - exit 1 - fi - - printf '%s\n' "Verified upstream branch has not changed, continuing with release..." - - name: Action | Semantic Version Release id: release # Adjust tag with desired version if applicable. @@ -998,11 +959,6 @@ to the GitHub Release Assets as well. one release job in the case if there are multiple pushes to ``main`` in a short period of time. - Secondly the *Evaluate | Verify upstream has NOT changed* step is used to ensure that the - upstream branch has not changed while the workflow was running. This is important because - we are committing a version change (``commit: true``) and there might be a push collision - that would cause undesired behavior. Review Issue `#1201`_ for more detailed information. - .. warning:: You must set ``fetch-depth`` to 0 when using ``actions/checkout@v4``, since Python Semantic Release needs access to the full history to build a changelog @@ -1018,6 +974,11 @@ to the GitHub Release Assets as well. case, you will also need to pass the new token to ``actions/checkout`` (as the ``token`` input) in order to gain push access. +.. note:: + As of $NEW_RELEASE_TAG, the verify upstream step is no longer required as it has been + integrated into PSR directly. If you are using an older version of PSR, you will need + to review the older documentation for that step. See Issue `#1201`_ for more details. + .. _#1201: https://github.com/python-semantic-release/python-semantic-release/issues/1201 .. _concurrency: https://docs.github.com/en/actions/reference/workflow-syntax-for-github-actions#jobsjob_idconcurrency diff --git a/docs/configuration/configuration-guides/uv_integration.rst b/docs/configuration/configuration-guides/uv_integration.rst index 79039477e..bc794832e 100644 --- a/docs/configuration/configuration-guides/uv_integration.rst +++ b/docs/configuration/configuration-guides/uv_integration.rst @@ -291,7 +291,6 @@ look like this: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - bash .github/workflows/verify_upstream.sh uv run semantic-release -v --strict version --skip-build uv run semantic-release publish diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index 717f55278..627cc1fc6 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -10,7 +10,7 @@ import click import shellingham # type: ignore[import] from click_option_group import MutuallyExclusiveOptionGroup, optgroup -from git import Repo +from git import GitCommandError, Repo from requests import HTTPError from semantic_release.changelog.release_history import ReleaseHistory @@ -27,9 +27,14 @@ from semantic_release.enums import LevelBump from semantic_release.errors import ( BuildDistributionsError, + DetachedHeadGitError, GitCommitEmptyIndexError, + GitFetchError, InternalError, + LocalGitError, UnexpectedResponse, + UnknownUpstreamBranchError, + UpstreamBranchChangedError, ) from semantic_release.gitproject import GitProject from semantic_release.globals import logger @@ -727,6 +732,29 @@ def version( # noqa: C901 ) if commit_changes: + # Verify that the upstream branch has not changed before pushing + # This prevents conflicts if another commit was pushed while we were preparing the release + # We check HEAD~1 because we just made a release commit + try: + project.verify_upstream_unchanged(local_ref="HEAD~1", noop=opts.noop) + except UpstreamBranchChangedError as exc: + click.echo(str(exc), err=True) + click.echo( + "Upstream branch has changed. Please pull the latest changes and try again.", + err=True, + ) + ctx.exit(1) + except ( + DetachedHeadGitError, + GitCommandError, + UnknownUpstreamBranchError, + GitFetchError, + LocalGitError, + ) as exc: + click.echo(str(exc), err=True) + click.echo("Unable to verify upstream due to error!", err=True) + ctx.exit(1) + # TODO: integrate into push branch with Repo(str(runtime.repo_dir)) as git_repo: active_branch = git_repo.active_branch.name diff --git a/src/semantic_release/errors.py b/src/semantic_release/errors.py index 954e85d20..600106906 100644 --- a/src/semantic_release/errors.py +++ b/src/semantic_release/errors.py @@ -106,3 +106,19 @@ class GitTagError(SemanticReleaseBaseError): class GitPushError(SemanticReleaseBaseError): """Raised when there is a failure to push to the git remote.""" + + +class GitFetchError(SemanticReleaseBaseError): + """Raised when there is a failure to fetch from the git remote.""" + + +class LocalGitError(SemanticReleaseBaseError): + """Raised when there is a failure with local git operations.""" + + +class UnknownUpstreamBranchError(SemanticReleaseBaseError): + """Raised when the upstream branch cannot be determined.""" + + +class UpstreamBranchChangedError(SemanticReleaseBaseError): + """Raised when the upstream branch has changed before pushing.""" diff --git a/src/semantic_release/gitproject.py b/src/semantic_release/gitproject.py index 0e4592599..05c4b1015 100644 --- a/src/semantic_release/gitproject.py +++ b/src/semantic_release/gitproject.py @@ -12,11 +12,16 @@ from semantic_release.cli.masking_filter import MaskingFilter from semantic_release.cli.util import indented, noop_report from semantic_release.errors import ( + DetachedHeadGitError, GitAddError, GitCommitEmptyIndexError, GitCommitError, + GitFetchError, GitPushError, GitTagError, + LocalGitError, + UnknownUpstreamBranchError, + UpstreamBranchChangedError, ) from semantic_release.globals import logger @@ -282,3 +287,94 @@ def git_push_tag(self, remote_url: str, tag: str, noop: bool = False) -> None: except GitCommandError as err: self.logger.exception(str(err)) raise GitPushError(f"Failed to push tag ({tag}) to remote") from err + + def verify_upstream_unchanged( + self, local_ref: str = "HEAD", noop: bool = False + ) -> None: + """ + Verify that the upstream branch has not changed since the given local reference. + + :param local_ref: The local reference to compare against upstream (default: HEAD) + :param noop: Whether to skip the actual verification (for dry-run mode) + + :raises UpstreamBranchChangedError: If the upstream branch has changed + """ + if noop: + noop_report( + indented( + """\ + would have verified that upstream branch has not changed + """ + ) + ) + return + + with Repo(str(self.project_root)) as repo: + # Get the current active branch + try: + active_branch = repo.active_branch + except TypeError: + # When in detached HEAD state, active_branch raises TypeError + err_msg = ( + "Repository is in detached HEAD state, cannot verify upstream state" + ) + raise DetachedHeadGitError(err_msg) from None + + # Get the tracking branch (upstream branch) + if (tracking_branch := active_branch.tracking_branch()) is None: + err_msg = f"No upstream branch found for '{active_branch.name}'; cannot verify upstream state!" + raise UnknownUpstreamBranchError(err_msg) + + upstream_full_ref_name = tracking_branch.name + self.logger.info("Upstream branch name: %s", upstream_full_ref_name) + + # Extract the remote name from the tracking branch + # tracking_branch.name is in the format "remote/branch" + remote_name, remote_branch_name = upstream_full_ref_name.split( + "/", maxsplit=1 + ) + remote_ref_obj = repo.remotes[remote_name] + + # Fetch the latest changes from the remote + self.logger.info("Fetching latest changes from remote '%s'", remote_name) + try: + remote_ref_obj.fetch() + except GitCommandError as err: + self.logger.exception(str(err)) + err_msg = f"Failed to fetch from remote '{remote_name}'" + raise GitFetchError(err_msg) from err + + # Get the SHA of the upstream branch + try: + upstream_commit_ref = remote_ref_obj.refs[remote_branch_name].commit + upstream_sha = upstream_commit_ref.hexsha + except AttributeError as err: + self.logger.exception(str(err)) + err_msg = f"Unable to determine upstream branch SHA for '{upstream_full_ref_name}'" + raise GitFetchError(err_msg) from err + + # Get the SHA of the specified ref (default: HEAD) + try: + local_commit = repo.commit(repo.git.rev_parse(local_ref)) + except GitCommandError as err: + self.logger.exception(str(err)) + err_msg = f"Unable to determine the SHA for local ref '{local_ref}'" + raise LocalGitError(err_msg) from err + + # Compare the two SHAs + if local_commit.hexsha != upstream_sha and not any( + commit.hexsha == upstream_sha for commit in local_commit.iter_parents() + ): + err_msg = str.join( + "\n", + ( + f"[LOCAL SHA] {local_commit.hexsha} != {upstream_sha} [UPSTREAM SHA].", + f"Upstream branch '{upstream_full_ref_name}' has changed!", + ), + ) + raise UpstreamBranchChangedError(err_msg) + + self.logger.info( + "Verified upstream branch '%s' has not changed", + upstream_full_ref_name, + ) 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 ad720baf7..79d94b37b 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 @@ -57,6 +57,7 @@ def test_gitflow_repo_rebuild_1_channel( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_gitflow_repo_rebuild_1_channel( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_gitflow_repo_rebuild_1_channel( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred 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 c87a3f2db..2b8c046e1 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 @@ -57,6 +57,7 @@ def test_gitflow_repo_rebuild_2_channels( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_gitflow_repo_rebuild_2_channels( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_gitflow_repo_rebuild_2_channels( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred 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 cadba665a..a26988646 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 @@ -59,6 +59,7 @@ def test_gitflow_repo_rebuild_3_channels( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -105,6 +106,7 @@ def test_gitflow_repo_rebuild_3_channels( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -162,5 +164,8 @@ def test_gitflow_repo_rebuild_3_channels( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred 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 734aeceba..0adde1fa3 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 @@ -57,6 +57,7 @@ def test_gitflow_repo_rebuild_4_channels( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_gitflow_repo_rebuild_4_channels( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_gitflow_repo_rebuild_4_channels( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred 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 093b80cb3..edeb485ce 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 @@ -57,6 +57,7 @@ def test_githubflow_repo_rebuild_1_channel( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_githubflow_repo_rebuild_1_channel( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_githubflow_repo_rebuild_1_channel( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel_branch_update_merge.py b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel_branch_update_merge.py index 36d156077..1d54aa459 100644 --- a/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel_branch_update_merge.py +++ b/tests/e2e/cmd_version/bump_version/github_flow/test_repo_1_channel_branch_update_merge.py @@ -63,6 +63,7 @@ def test_github_flow_repo_w_default_release_n_branch_update_merge( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -111,6 +112,7 @@ def test_github_flow_repo_w_default_release_n_branch_update_merge( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -174,5 +176,8 @@ def test_github_flow_repo_w_default_release_n_branch_update_merge( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred 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 90b6ea16d..ab6906eea 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 @@ -57,6 +57,7 @@ def test_githubflow_repo_rebuild_2_channels( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_githubflow_repo_rebuild_2_channels( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_githubflow_repo_rebuild_2_channels( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_1_channel.py b/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_1_channel.py index fd5fb5ff2..1d82138f2 100644 --- a/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_1_channel.py +++ b/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_1_channel.py @@ -58,6 +58,7 @@ def test_githubflow_monorepo_rebuild_1_channel( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, @@ -114,6 +115,7 @@ def test_githubflow_monorepo_rebuild_1_channel( ) # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -247,5 +249,8 @@ def test_githubflow_monorepo_rebuild_1_channel( assert curr_release_str in [tag.name for tag in mirror_git_repo.tags] # Make sure publishing actions occurred + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_2_channels.py b/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_2_channels.py index 57d6cd3fb..1352995b9 100644 --- a/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_2_channels.py +++ b/tests/e2e/cmd_version/bump_version/github_flow_monorepo/test_monorepo_2_channels.py @@ -58,6 +58,7 @@ def test_githubflow_monorepo_rebuild_2_channels( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, @@ -113,6 +114,7 @@ def test_githubflow_monorepo_rebuild_2_channels( ) # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -246,5 +248,8 @@ def test_githubflow_monorepo_rebuild_2_channels( assert curr_release_str in [tag.name for tag in mirror_git_repo.tags] # Make sure publishing actions occurred + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred 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 8a68c20de..c57c6d58c 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 @@ -59,6 +59,7 @@ def test_trunk_repo_rebuild_only_official_releases( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -105,6 +106,7 @@ def test_trunk_repo_rebuild_only_official_releases( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -163,5 +165,8 @@ def test_trunk_repo_rebuild_only_official_releases( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred 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 6236f5b55..6ea7e1f54 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 @@ -60,6 +60,7 @@ def test_trunk_repo_rebuild_dual_version_spt_official_releases_only( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -106,6 +107,7 @@ def test_trunk_repo_rebuild_dual_version_spt_official_releases_only( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -169,5 +171,8 @@ def test_trunk_repo_rebuild_dual_version_spt_official_releases_only( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred 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 020a4e6ac..89819abe9 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 @@ -61,6 +61,7 @@ def test_trunk_repo_rebuild_dual_version_spt_w_official_n_prereleases( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -107,6 +108,7 @@ def test_trunk_repo_rebuild_dual_version_spt_w_official_n_prereleases( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -170,5 +172,8 @@ def test_trunk_repo_rebuild_dual_version_spt_w_official_n_prereleases( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred 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 4473f56e9..78eca0bd6 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 @@ -57,6 +57,7 @@ def test_trunk_repo_rebuild_w_prereleases( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, version_py_file: Path, @@ -103,6 +104,7 @@ def test_trunk_repo_rebuild_w_prereleases( curr_release_tag = curr_version.as_tag() # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -161,5 +163,8 @@ def test_trunk_repo_rebuild_w_prereleases( assert expected_release_commit_text == actual_release_commit_text # Make sure tag is created assert curr_release_tag in [tag.name for tag in mirror_git_repo.tags] + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/bump_version/trunk_based_dev_monorepo/test_monorepo_trunk.py b/tests/e2e/cmd_version/bump_version/trunk_based_dev_monorepo/test_monorepo_trunk.py index ec4ccd60a..c68a35f5d 100644 --- a/tests/e2e/cmd_version/bump_version/trunk_based_dev_monorepo/test_monorepo_trunk.py +++ b/tests/e2e/cmd_version/bump_version/trunk_based_dev_monorepo/test_monorepo_trunk.py @@ -58,6 +58,7 @@ def test_trunk_monorepo_rebuild_1_channel( example_project_dir: ExProjectDir, git_repo_for_directory: GetGitRepo4DirFn, build_repo_from_definition: BuildRepoFromDefinitionFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, get_sanitized_md_changelog_content: GetSanitizedChangelogContentFn, @@ -114,6 +115,7 @@ def test_trunk_monorepo_rebuild_1_channel( ) # make sure mocks are clear + mocked_git_fetch.reset_mock() mocked_git_push.reset_mock() post_mocker.reset_mock() @@ -247,5 +249,8 @@ def test_trunk_monorepo_rebuild_1_channel( assert curr_release_str in [tag.name for tag in mirror_git_repo.tags] # Make sure publishing actions occurred + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/test_version.py b/tests/e2e/cmd_version/test_version.py index 0af19f8a3..2bf952647 100644 --- a/tests/e2e/cmd_version/test_version.py +++ b/tests/e2e/cmd_version/test_version.py @@ -42,6 +42,7 @@ def test_version_noop_is_noop( repo_result: BuiltRepoResult, next_release_version: str, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, get_wheel_file: GetWheelFileFn, @@ -91,6 +92,7 @@ def test_version_no_git_verify( repo_result: BuiltRepoResult, run_cli: RunCliFn, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -101,6 +103,11 @@ def test_version_no_git_verify( repo.git.commit( m="chore: adjust project configuration for --no-verify release commits", a=True ) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # setup: create executable pre-commit script precommit_hook = Path(repo.git_dir, "hooks", "pre-commit") @@ -140,6 +147,7 @@ def test_version_no_git_verify( # A commit has been made (regardless of precommit) assert [head_sha_before] == [head.hexsha for head in head_after.parents] assert len(tags_set_difference) == 1 # A tag has been created + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -150,6 +158,7 @@ def test_version_no_git_verify( def test_version_on_nonrelease_branch( repo_result: BuiltRepoResult, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -196,6 +205,7 @@ def test_version_on_last_release( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -248,6 +258,7 @@ def test_version_on_last_release( def test_version_only_tag_push( repo_result: BuiltRepoResult, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, ) -> None: diff --git a/tests/e2e/cmd_version/test_version_build.py b/tests/e2e/cmd_version/test_version_build.py index c9380683c..51022da87 100644 --- a/tests/e2e/cmd_version/test_version_build.py +++ b/tests/e2e/cmd_version/test_version_build.py @@ -68,6 +68,7 @@ def test_version_runs_build_command( shell: str, get_wheel_file: GetWheelFileFn, example_pyproject_toml: Path, + mocked_git_fetch: mock.MagicMock, mocked_git_push: mock.MagicMock, post_mocker: mock.Mock, ): @@ -125,6 +126,9 @@ def test_version_runs_build_command( ) assert built_wheel_file.exists() + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 assert post_mocker.call_count == 1 @@ -150,6 +154,7 @@ def test_version_runs_build_command_windows( get_wheel_file: GetWheelFileFn, example_pyproject_toml: Path, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: mock.MagicMock, mocked_git_push: mock.MagicMock, post_mocker: mock.Mock, clean_os_environment: dict[str, str], @@ -223,6 +228,9 @@ def test_version_runs_build_command_windows( dist_file_exists = built_wheel_file.exists() assert dist_file_exists, f"\n Expected wheel file to be created at {built_wheel_file}, but it does not exist." + assert ( + mocked_git_fetch.call_count == 1 + ) # fetch called to check for remote changes assert mocked_git_push.call_count == 2 assert post_mocker.call_count == 1 @@ -338,6 +346,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( run_cli: RunCliFn, + mocked_git_fetch: mock.MagicMock, mocked_git_push: mock.MagicMock, post_mocker: mock.Mock, ): @@ -354,5 +363,6 @@ def test_version_skips_build_command_with_skip_build( assert_successful_exit_code(result, cli_cmd) patched_subprocess_run.assert_not_called() + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 assert post_mocker.call_count == 1 diff --git a/tests/e2e/cmd_version/test_version_bump.py b/tests/e2e/cmd_version/test_version_bump.py index 5feade30c..5739b8f3b 100644 --- a/tests/e2e/cmd_version/test_version_bump.py +++ b/tests/e2e/cmd_version/test_version_bump.py @@ -321,6 +321,7 @@ def test_version_force_level( example_project_dir: ExProjectDir, example_pyproject_toml: Path, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, pyproject_toml_file: Path, @@ -390,6 +391,7 @@ def test_version_force_level( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -534,6 +536,7 @@ def test_version_next_greater_than_version_one_conventional( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -553,6 +556,11 @@ def test_version_next_greater_than_version_one_conventional( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -587,6 +595,7 @@ def test_version_next_greater_than_version_one_conventional( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -673,6 +682,7 @@ def test_version_next_greater_than_version_one_no_bump_conventional( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -692,6 +702,11 @@ def test_version_next_greater_than_version_one_no_bump_conventional( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -724,6 +739,7 @@ def test_version_next_greater_than_version_one_no_bump_conventional( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -833,6 +849,7 @@ def test_version_next_greater_than_version_one_emoji( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -852,6 +869,11 @@ def test_version_next_greater_than_version_one_emoji( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -886,6 +908,7 @@ def test_version_next_greater_than_version_one_emoji( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -972,6 +995,7 @@ def test_version_next_greater_than_version_one_no_bump_emoji( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -991,6 +1015,11 @@ def test_version_next_greater_than_version_one_no_bump_emoji( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -1023,6 +1052,7 @@ def test_version_next_greater_than_version_one_no_bump_emoji( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -1132,6 +1162,7 @@ def test_version_next_greater_than_version_one_scipy( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -1151,6 +1182,11 @@ def test_version_next_greater_than_version_one_scipy( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -1185,6 +1221,7 @@ def test_version_next_greater_than_version_one_scipy( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -1271,6 +1308,7 @@ def test_version_next_greater_than_version_one_no_bump_scipy( branch_name: str, run_cli: RunCliFn, file_in_repo: str, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -1290,6 +1328,11 @@ def test_version_next_greater_than_version_one_no_bump_scipy( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -1322,6 +1365,7 @@ def test_version_next_greater_than_version_one_no_bump_scipy( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -1612,6 +1656,7 @@ def test_version_next_w_zero_dot_versions_conventional( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -1637,6 +1682,11 @@ def test_version_next_w_zero_dot_versions_conventional( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -1671,6 +1721,7 @@ def test_version_next_w_zero_dot_versions_conventional( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -1766,6 +1817,7 @@ def test_version_next_w_zero_dot_versions_no_bump_conventional( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -1791,6 +1843,11 @@ def test_version_next_w_zero_dot_versions_no_bump_conventional( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -1823,6 +1880,7 @@ def test_version_next_w_zero_dot_versions_no_bump_conventional( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -2092,6 +2150,7 @@ def test_version_next_w_zero_dot_versions_emoji( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -2117,6 +2176,11 @@ def test_version_next_w_zero_dot_versions_emoji( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -2151,6 +2215,7 @@ def test_version_next_w_zero_dot_versions_emoji( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -2246,6 +2311,7 @@ def test_version_next_w_zero_dot_versions_no_bump_emoji( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -2271,6 +2337,11 @@ def test_version_next_w_zero_dot_versions_no_bump_emoji( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -2303,6 +2374,7 @@ def test_version_next_w_zero_dot_versions_no_bump_emoji( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -2572,6 +2644,7 @@ def test_version_next_w_zero_dot_versions_scipy( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -2597,6 +2670,11 @@ def test_version_next_w_zero_dot_versions_scipy( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -2631,6 +2709,7 @@ def test_version_next_w_zero_dot_versions_scipy( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred @@ -2726,6 +2805,7 @@ def test_version_next_w_zero_dot_versions_no_bump_scipy( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -2751,6 +2831,11 @@ def test_version_next_w_zero_dot_versions_no_bump_scipy( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -2783,6 +2868,7 @@ def test_version_next_w_zero_dot_versions_no_bump_scipy( # No commit has been made assert head_sha_before == head_after.hexsha assert len(tags_set_difference) == 0 # No tag created + assert mocked_git_fetch.call_count == 0 # no git fetch called assert mocked_git_push.call_count == 0 # no git push of tag or commit assert post_mocker.call_count == 0 # no vcs release @@ -3115,6 +3201,7 @@ def test_version_next_w_zero_dot_versions_minimums( run_cli: RunCliFn, file_in_repo: str, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -3141,6 +3228,11 @@ def test_version_next_w_zero_dot_versions_minimums( for commit_message in commit_messages or []: add_text_to_file(repo, file_in_repo) repo.git.commit(m=commit_message, a=True, date=next(commit_timestamp_gen)) + # Fake an automated push to remote by updating the remote tracking branch + repo.git.update_ref( + f"refs/remotes/origin/{repo.active_branch.name}", + repo.head.commit.hexsha, + ) # Setup: take measurement before running the version command head_sha_before = repo.head.commit.hexsha @@ -3175,5 +3267,6 @@ def test_version_next_w_zero_dot_versions_minimums( assert len(tags_set_difference) == 1 # A tag has been created assert f"v{next_release_version}" in tags_set_difference + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 # vcs release creation occurred diff --git a/tests/e2e/cmd_version/test_version_print.py b/tests/e2e/cmd_version/test_version_print.py index 9bcc2dea8..c9a9bd034 100644 --- a/tests/e2e/cmd_version/test_version_print.py +++ b/tests/e2e/cmd_version/test_version_print.py @@ -105,6 +105,7 @@ def test_version_print_next_version( next_release_version: str, file_in_repo: str, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -269,6 +270,7 @@ def test_version_print_tag_prints_next_tag( get_cfg_value_from_def: GetCfgValueFromDefFn, file_in_repo: str, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -384,6 +386,7 @@ def test_version_print_tag_prints_next_tag_no_zero_versions( get_cfg_value_from_def: GetCfgValueFromDefFn, file_in_repo: str, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, ): @@ -447,6 +450,7 @@ def test_version_print_last_released_prints_version( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -498,6 +502,7 @@ def test_version_print_last_released_prints_released_if_commits( get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, commits: list[str], run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, file_in_repo: str, @@ -547,6 +552,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, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, caplog: pytest.LogCaptureFixture, @@ -592,6 +598,7 @@ def test_version_print_last_released_on_detached_head( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -640,6 +647,7 @@ def test_version_print_last_released_on_nonrelease_branch( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -697,6 +705,7 @@ def test_version_print_last_released_tag_prints_correct_tag( get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -757,6 +766,7 @@ def test_version_print_last_released_tag_prints_released_if_commits( get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, commits: list[str], run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, file_in_repo: str, @@ -807,6 +817,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, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, caplog: pytest.LogCaptureFixture, @@ -861,6 +872,7 @@ def test_version_print_last_released_tag_on_detached_head( get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -919,6 +931,7 @@ def test_version_print_last_released_tag_on_nonrelease_branch( get_cfg_value_from_def: GetCfgValueFromDefFn, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -976,6 +989,7 @@ def test_version_print_next_version_fails_on_detached_head( simulate_change_commits_n_rtn_changelog_entry: SimulateChangeCommitsNReturnChangelogEntryFn, get_commit_def_fn: GetCommitDefFn[CommitParser[ParseResult, ParserOptions]], default_parser: CommitParser[ParseResult, ParserOptions], + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -1038,6 +1052,7 @@ def test_version_print_next_tag_fails_on_detached_head( simulate_change_commits_n_rtn_changelog_entry: SimulateChangeCommitsNReturnChangelogEntryFn, get_commit_def_fn: GetCommitDefFn[CommitParser[ParseResult, ParserOptions]], default_parser: CommitParser[ParseResult, ParserOptions], + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, diff --git a/tests/e2e/cmd_version/test_version_release_notes.py b/tests/e2e/cmd_version/test_version_release_notes.py index e21059335..08032332d 100644 --- a/tests/e2e/cmd_version/test_version_release_notes.py +++ b/tests/e2e/cmd_version/test_version_release_notes.py @@ -55,6 +55,7 @@ def test_custom_release_notes_template( run_cli: RunCliFn, use_release_notes_template: UseReleaseNotesTemplateFn, retrieve_runtime_context: RetrieveRuntimeContextFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, ) -> None: @@ -89,6 +90,7 @@ def test_custom_release_notes_template( # Assert assert_successful_exit_code(result, cli_cmd) + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 assert post_mocker.last_request is not None @@ -128,6 +130,7 @@ def test_default_release_notes_license_statement( license_setting: str, mask_initial_release: bool, update_pyproject_toml: UpdatePyprojectTomlFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, stable_now_date: GetStableDateNowFn, @@ -175,6 +178,7 @@ def test_default_release_notes_license_statement( # Evaluate assert_successful_exit_code(result, cli_cmd) + assert mocked_git_fetch.call_count == 1 # fetch called to check for remote changes assert mocked_git_push.call_count == 2 # 1 for commit, 1 for tag assert post_mocker.call_count == 1 assert post_mocker.last_request is not None diff --git a/tests/e2e/cmd_version/test_version_stamp.py b/tests/e2e/cmd_version/test_version_stamp.py index 892ce59d7..2bce55901 100644 --- a/tests/e2e/cmd_version/test_version_stamp.py +++ b/tests/e2e/cmd_version/test_version_stamp.py @@ -57,6 +57,7 @@ def test_version_only_stamp_version( repo_result: BuiltRepoResult, expected_new_version: str, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: MagicMock, example_pyproject_toml: Path, diff --git a/tests/e2e/cmd_version/test_version_strict.py b/tests/e2e/cmd_version/test_version_strict.py index a41998ded..e90970bd3 100644 --- a/tests/e2e/cmd_version/test_version_strict.py +++ b/tests/e2e/cmd_version/test_version_strict.py @@ -29,6 +29,7 @@ def test_version_already_released_when_strict( repo_result: BuiltRepoResult, get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, @@ -78,6 +79,7 @@ def test_version_already_released_when_strict( def test_version_on_nonrelease_branch_when_strict( repo_result: BuiltRepoResult, run_cli: RunCliFn, + mocked_git_fetch: MagicMock, mocked_git_push: MagicMock, post_mocker: Mocker, strip_logging_messages: StripLoggingMessagesFn, diff --git a/tests/e2e/cmd_version/test_version_upstream_check.py b/tests/e2e/cmd_version/test_version_upstream_check.py new file mode 100644 index 000000000..24fd82d81 --- /dev/null +++ b/tests/e2e/cmd_version/test_version_upstream_check.py @@ -0,0 +1,283 @@ +"""E2E tests for upstream verification during version command.""" + +from __future__ import annotations + +import contextlib +from typing import TYPE_CHECKING, cast + +import pytest +from git import Repo +from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture + +from semantic_release.hvcs.github import Github + +from tests.const import MAIN_PROG_NAME, VERSION_SUBCMD +from tests.fixtures.example_project import change_to_ex_proj_dir +from tests.fixtures.repos.trunk_based_dev import ( + repo_w_trunk_only_conventional_commits, +) +from tests.fixtures.repos.trunk_based_dev.repo_w_tags import ( + build_trunk_only_repo_w_tags, +) +from tests.util import ( + add_text_to_file, + assert_exit_code, + assert_successful_exit_code, + temporary_working_directory, +) + +if TYPE_CHECKING: + from pathlib import Path + + from requests_mock import Mocker + + from tests.conftest import RunCliFn + from tests.fixtures.example_project import ExProjectDir, UpdatePyprojectTomlFn + from tests.fixtures.git_repo import ( + BuildSpecificRepoFn, + CommitConvention, + GetCfgValueFromDefFn, + GetGitRepo4DirFn, + GetVersionsFromRepoBuildDefFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name, build_repo_fn", + [ + ( + repo_fixture_name, + lazy_fixture(build_repo_fn_name), + ) + for repo_fixture_name, build_repo_fn_name in [ + ( + repo_w_trunk_only_conventional_commits.__name__, + build_trunk_only_repo_w_tags.__name__, + ), + ] + ], +) +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_version_upstream_check_success_no_changes( + repo_fixture_name: str, + run_cli: RunCliFn, + build_repo_fn: BuildSpecificRepoFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + post_mocker: Mocker, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + pyproject_toml_file: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +): + remote_name = "origin" + # Create a bare remote (simulating origin) + local_origin = Repo.init(str(example_project_dir / "local_origin"), bare=True) + + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_repo_fn( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # Configure the source repo to use the bare remote (removing any existing 'origin') + with contextlib.suppress(AttributeError): + target_git_repo.delete_remote(target_git_repo.remotes[remote_name]) + + target_git_repo.create_remote(remote_name, str(local_origin.working_dir)) + + # Remove last release before pushing to upstream + tag_format_str = cast( + "str", get_cfg_value_from_def(target_repo_definition, "tag_format_str") + ) + latest_tag = tag_format_str.format( + version=get_versions_from_repo_build_def(target_repo_definition)[-1] + ) + target_git_repo.git.tag("-d", latest_tag) + target_git_repo.git.reset("--hard", "HEAD~1") + + # TODO: when available, switch this to use hvcs=none or similar config to avoid token use for push + update_pyproject_toml( + "tool.semantic_release.remote.ignore_token_for_push", + True, + target_repo_dir / pyproject_toml_file, + ) + target_git_repo.git.commit(amend=True, no_edit=True, all=True) + + # push the current state to establish the remote (cannot push tags and branches at the same time) + target_git_repo.git.push(remote_name, all=True) # all branches + target_git_repo.git.push(remote_name, tags=True) # all tags + + # ensure bare remote HEAD points to the active branch so clones can checkout + local_origin.git.symbolic_ref( + "HEAD", f"refs/heads/{target_git_repo.active_branch.name}" + ) + + # current remote tags + remote_origin_tags_before = {tag.name for tag in local_origin.tags} + + # Clone the repo to simulate a local workspace + test_repo = Repo.clone_from( + f"file://{local_origin.working_dir}", + str(example_project_dir / "repo_clone"), + no_local=True, + ) + with test_repo.config_writer("repository") as config: + config.set_value("core", "hookspath", "") + config.set_value("commit", "gpgsign", False) + config.set_value("tag", "gpgsign", False) + + current_head_sha = test_repo.head.commit.hexsha + + # Act: run PSR on the cloned repo - it should verify upstream and succeed + with temporary_working_directory(str(test_repo.working_dir)): + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + + remote_origin_tags_after = {tag.name for tag in local_origin.tags} + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + + # Verify release occurred as expected + with test_repo: + assert latest_tag in test_repo.tags, "Expected release tag to be created" + assert current_head_sha in [ + parent.hexsha for parent in test_repo.head.commit.parents + ], "Expected new commit to be created on HEAD" + different_tags = remote_origin_tags_after.difference(remote_origin_tags_before) + assert latest_tag in different_tags, "Expected new tag to be pushed to remote" + + # Verify VCS release was created + expected_vcs_url_post = 1 + assert expected_vcs_url_post == post_mocker.call_count # one vcs release created + + +@pytest.mark.parametrize( + "repo_fixture_name, build_repo_fn", + [ + ( + repo_fixture_name, + lazy_fixture(build_repo_fn_name), + ) + for repo_fixture_name, build_repo_fn_name in [ + ( + repo_w_trunk_only_conventional_commits.__name__, + build_trunk_only_repo_w_tags.__name__, + ), + ] + ], +) +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_version_upstream_check_fails_when_upstream_changed( + repo_fixture_name: str, + run_cli: RunCliFn, + build_repo_fn: BuildSpecificRepoFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + post_mocker: Mocker, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + pyproject_toml_file: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, + file_in_repo: str, +): + remote_name = "origin" + # Create a bare remote (simulating origin) + local_origin = Repo.init(str(example_project_dir / "local_origin"), bare=True) + + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_repo_fn( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # Configure the source repo to use the bare remote (removing any existing 'origin') + with contextlib.suppress(AttributeError): + target_git_repo.delete_remote(target_git_repo.remotes[remote_name]) + + target_git_repo.create_remote(remote_name, f"file://{local_origin.working_dir}") + + # Remove last release before pushing to upstream + tag_format_str = cast( + "str", get_cfg_value_from_def(target_repo_definition, "tag_format_str") + ) + latest_tag = tag_format_str.format( + version=get_versions_from_repo_build_def(target_repo_definition)[-1] + ) + target_git_repo.git.tag("-d", latest_tag) + target_git_repo.git.reset("--hard", "HEAD~1") + + # TODO: when available, switch this to use hvcs=none or similar config to avoid token use for push + update_pyproject_toml( + "tool.semantic_release.remote.ignore_token_for_push", + True, + target_repo_dir / pyproject_toml_file, + ) + target_git_repo.git.commit(amend=True, no_edit=True, all=True) + + # push the current state to establish the remote (cannot push tags and branches at the same time) + target_git_repo.git.push(remote_name, all=True) # all branches + target_git_repo.git.push(remote_name, tags=True) # all tags + + # ensure bare remote HEAD points to the branch name used in the pushed repo + local_origin.git.symbolic_ref( + "HEAD", f"refs/heads/{target_git_repo.active_branch.name}" + ) + + # current remote tags + remote_origin_tags_before = {tag.name for tag in local_origin.tags} + + # Clone the repo to simulate a local workspace + test_repo = Repo.clone_from( + f"file://{local_origin.working_dir}", + str(example_project_dir / "repo_clone"), + no_local=True, + ) + with test_repo.config_writer("repository") as config: + config.set_value("core", "hookspath", "") + config.set_value("commit", "gpgsign", False) + config.set_value("tag", "gpgsign", False) + + # Apply new commit to the original repo to simulate another developer pushing to upstream + add_text_to_file(target_git_repo, str(target_repo_dir / file_in_repo)) + target_git_repo.index.add([str(file_in_repo)]) + target_git_repo.index.commit("feat: upstream change by another developer") + target_git_repo.git.push(remote_name, target_git_repo.active_branch.name) + + # Act: run PSR - it should detect upstream changed and fail + with temporary_working_directory(str(test_repo.working_dir)): + cli_cmd = [MAIN_PROG_NAME, "--strict", VERSION_SUBCMD] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + + remote_origin_tags_after = {tag.name for tag in local_origin.tags} + + # Evaluate + assert_exit_code(1, result, cli_cmd) + expected_err_msg = ( + f"Upstream branch '{remote_name}/{test_repo.active_branch.name}' has changed!" + ) + # Verify error message mentions upstream + assert ( + expected_err_msg in result.stderr + ), f"Expected '{expected_err_msg}' in error output, got: {result.stderr}" + + assert ( + remote_origin_tags_before == remote_origin_tags_after + ), "Expected no new tags to be pushed to remote" + + # Verify no VCS release was created + expected_vcs_url_post = 0 + assert expected_vcs_url_post == post_mocker.call_count # no vcs release created diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index 2cfcd67ea..328718fa0 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from unittest.mock import MagicMock +import git.remote as git_remote import pytest from requests_mock import ANY @@ -77,6 +78,26 @@ def mocked_git_push(monkeypatch: MonkeyPatch) -> MagicMock: return mocked_push +@pytest.fixture +def mocked_git_fetch(monkeypatch: MonkeyPatch) -> MagicMock: + """ + Mock the `Repo.git.fetch()` method in `semantic_release.cli.main` and + `git.Repo.remotes.Remote.fetch()`. + """ + mocked_fetch = MagicMock() + cls = prepare_mocked_git_command_wrapper_type(fetch=mocked_fetch) + monkeypatch.setattr(cli_config_module.Repo, "GitCommandWrapperType", cls) + + # define a small wrapper so the MagicMock does not receive `self` + def _fetch(self, *args, **kwargs): + return mocked_fetch(*args, **kwargs) + + # Replace the method on the Remote class used by GitPython + monkeypatch.setattr(git_remote.Remote, "fetch", _fetch, raising=True) + + return mocked_fetch + + @pytest.fixture def config_path(example_project_dir: ExProjectDir) -> Path: return example_project_dir / DEFAULT_CONFIG_FILE diff --git a/tests/fixtures/git_repo.py b/tests/fixtures/git_repo.py index e4051183f..46ebed0d6 100644 --- a/tests/fixtures/git_repo.py +++ b/tests/fixtures/git_repo.py @@ -2,6 +2,7 @@ import os import sys +from contextlib import suppress from copy import deepcopy from datetime import datetime, timedelta from functools import reduce @@ -566,15 +567,28 @@ def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: # the implementation on Windows holds some file descriptors open until close is called. with Repo.init(cached_repo_path) as repo: rmtree(str(Path(repo.git_dir, "hooks"))) + + # set up remote origin (including a refs directory) + git_origin_dir = Path(repo.common_dir, "refs", "remotes", "origin") + repo.create_remote(name=git_origin_dir.name, url=example_git_https_url) + git_origin_dir.mkdir(parents=True, exist_ok=True) + # Without this the global config may set it to "master", we want consistency repo.git.branch("-M", DEFAULT_BRANCH_NAME) + with repo.config_writer("repository") as config: config.set_value("user", "name", commit_author.name) config.set_value("user", "email", commit_author.email) config.set_value("commit", "gpgsign", False) config.set_value("tag", "gpgsign", False) - repo.create_remote(name="origin", url=example_git_https_url) + # set up a remote tracking branch for the default branch + config.set_value(f'branch "{DEFAULT_BRANCH_NAME}"', "remote", "origin") + config.set_value( + f'branch "{DEFAULT_BRANCH_NAME}"', + "merge", + f"refs/heads/{DEFAULT_BRANCH_NAME}", + ) # make sure all base files are in index to enable initial commit repo.index.add(("*", ".gitignore")) @@ -981,6 +995,15 @@ def _create_squash_merge_commit( date=commit_dt.isoformat(timespec="seconds"), ) + # After a merge we need to ensure the remote tracking branch is updated + with suppress(TypeError): + # Fake an automated push to remote by updating the remote tracking branch + # Detached HEAD commits won't have an active branch and throw a TypeError + git_repo.git.update_ref( + f"refs/remotes/origin/{git_repo.active_branch.name}", + git_repo.head.commit.hexsha, + ) + # return the commit definition with the sha & message updated return { **commit_def, @@ -1030,6 +1053,14 @@ def _mimic_semantic_release_commit( date=commit_dt.isoformat(timespec="seconds"), ) + with suppress(TypeError): + # Fake an automated push to remote by updating the remote tracking branch + # Detached HEAD commits won't have an active branch and throw a TypeError + git_repo.git.update_ref( + f"refs/remotes/origin/{git_repo.active_branch.name}", + git_repo.head.commit.hexsha, + ) + # ensure commit timestamps are unique (adding one second even though a nanosecond has gone by) commit_dt += timedelta(seconds=1) @@ -1067,6 +1098,14 @@ def _commit_n_rtn_changelog_entry( date=commit_dt.isoformat(timespec="seconds"), ) + with suppress(TypeError): + # Fake an automated push to remote by updating the remote tracking branch + # Detached HEAD commits won't have an active branch and throw a TypeError + git_repo.git.update_ref( + f"refs/remotes/origin/{git_repo.active_branch.name}", + git_repo.head.commit.hexsha, + ) + # Capture the resulting commit message and sha return { **commit_def, @@ -1680,6 +1719,18 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c create_branch_def["name"], commit=start_head.commit, ) + # set up a remote tracking branch for the new branch + with git_repo.config_writer("repository") as config: + config.set_value( + f'branch "{create_branch_def["name"]}"', + "remote", + "origin", + ) + config.set_value( + f'branch "{create_branch_def["name"]}"', + "merge", + f'refs/heads/{create_branch_def["name"]}', + ) new_branch_head.checkout() elif "branch" in ckout_def: @@ -1761,6 +1812,15 @@ def _build_repo_from_definition( # noqa: C901, its required and its just test c } ) + # After a merge we need to ensure the remote tracking branch is updated + with suppress(TypeError): + # Fake an automated push to remote by updating the remote tracking branch + # Detached HEAD commits won't have an active branch and throw a TypeError + git_repo.git.update_ref( + f"refs/remotes/origin/{git_repo.active_branch.name}", + git_repo.head.commit.hexsha, + ) + else: raise ValueError(f"Unknown action: {action}") diff --git a/tests/fixtures/monorepos/git_monorepo.py b/tests/fixtures/monorepos/git_monorepo.py index c88fbdb29..59da65df9 100644 --- a/tests/fixtures/monorepos/git_monorepo.py +++ b/tests/fixtures/monorepos/git_monorepo.py @@ -94,15 +94,28 @@ def _build_repo(cached_repo_path: Path) -> Sequence[RepoActions]: # the implementation on Windows holds some file descriptors open until close is called. with Repo.init(cached_repo_path) as repo: rmtree(str(Path(repo.git_dir, "hooks"))) + + # set up remote origin (including a refs directory) + git_origin_dir = Path(repo.common_dir, "refs", "remotes", "origin") + repo.create_remote(name=git_origin_dir.name, url=example_git_https_url) + git_origin_dir.mkdir(parents=True, exist_ok=True) + # Without this the global config may set it to "master", we want consistency repo.git.branch("-M", DEFAULT_BRANCH_NAME) + with repo.config_writer("repository") as config: config.set_value("user", "name", commit_author.name) config.set_value("user", "email", commit_author.email) config.set_value("commit", "gpgsign", False) config.set_value("tag", "gpgsign", False) - repo.create_remote(name="origin", url=example_git_https_url) + # set up a remote tracking branch for the default branch + config.set_value(f'branch "{DEFAULT_BRANCH_NAME}"', "remote", "origin") + config.set_value( + f'branch "{DEFAULT_BRANCH_NAME}"', + "merge", + f"refs/heads/{DEFAULT_BRANCH_NAME}", + ) # make sure all base files are in index to enable initial commit repo.index.add(("*", ".gitignore")) diff --git a/tests/unit/semantic_release/test_gitproject.py b/tests/unit/semantic_release/test_gitproject.py new file mode 100644 index 000000000..d5795fff7 --- /dev/null +++ b/tests/unit/semantic_release/test_gitproject.py @@ -0,0 +1,246 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, PropertyMock, patch + +import pytest +from git import GitCommandError + +from semantic_release.errors import ( + DetachedHeadGitError, + GitFetchError, + LocalGitError, + UnknownUpstreamBranchError, + UpstreamBranchChangedError, +) +from semantic_release.gitproject import GitProject + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture +def mock_repo(): + """Create a mock Git repository with proper structure for new implementation.""" + repo = MagicMock() + + # Mock active branch + active_branch = MagicMock() + active_branch.name = "main" + + # Mock tracking branch + tracking_branch = MagicMock() + tracking_branch.name = "origin/main" + active_branch.tracking_branch = MagicMock(return_value=tracking_branch) + + repo.active_branch = active_branch + + # Mock remotes + remote_obj = MagicMock() + remote_obj.fetch = MagicMock() + + # Mock refs for the remote + ref_obj = MagicMock() + commit_obj = MagicMock() + commit_obj.hexsha = "abc123" + ref_obj.commit = commit_obj + + remote_obj.refs = {"main": ref_obj} + repo.remotes = {"origin": remote_obj} + + # Mock git.rev_parse + repo.git = MagicMock() + repo.git.rev_parse = MagicMock(return_value="abc123") + + # Ensure repo.commit returns a commit-like object with the expected hexsha + # and no parents so that comparisons in verify_upstream_unchanged succeed. + commit_obj.iter_parents = MagicMock(return_value=[]) + repo.commit = MagicMock(return_value=commit_obj) + + return repo + + +def test_verify_upstream_unchanged_success(tmp_path: Path, mock_repo: MagicMock): + """Test that verify_upstream_unchanged succeeds when upstream has not changed.""" + git_project = GitProject(directory=tmp_path) + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + # Should not raise an exception + git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + # Verify fetch was called + mock_repo.remotes["origin"].fetch.assert_called_once() + # Verify rev_parse was called for HEAD + mock_repo.git.rev_parse.assert_called_once_with("HEAD") + + +def test_verify_upstream_unchanged_fails_when_changed( + tmp_path: Path, mock_repo: MagicMock +): + """Test that verify_upstream_unchanged raises error when upstream has changed.""" + git_project = GitProject(directory=tmp_path) + + # Mock git operations with different SHAs + mock_repo.git.rev_parse = MagicMock( + return_value="def456" # Different from upstream + ) + + # Ensure repo.commit returns a commit-like object with the different hexsha + changed_commit = MagicMock() + changed_commit.hexsha = "def456" + changed_commit.iter_parents = MagicMock(return_value=[]) + mock_repo.commit = MagicMock(return_value=changed_commit) + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + with pytest.raises( + UpstreamBranchChangedError, match=r"Upstream branch .* has changed" + ): + git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_noop(tmp_path: Path): + """Test that verify_upstream_unchanged does nothing in noop mode.""" + git_project = GitProject(directory=tmp_path) + + mock_repo = MagicMock() + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + # Should not raise an exception and should not call git operations + git_project.verify_upstream_unchanged(noop=True) + + # Verify Repo was not instantiated at all in noop mode + mock_repo_class.assert_not_called() + + +def test_verify_upstream_unchanged_no_tracking_branch( + tmp_path: Path, mock_repo: MagicMock +): + """Test that verify_upstream_unchanged raises error when no tracking branch exists.""" + git_project = GitProject(directory=tmp_path) + + # Mock no tracking branch + mock_repo.active_branch.tracking_branch = MagicMock(return_value=None) + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + # Should raise UnknownUpstreamBranchError + with pytest.raises( + UnknownUpstreamBranchError, match="No upstream branch found" + ): + git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_detached_head(tmp_path: Path): + """Test that verify_upstream_unchanged raises error in detached HEAD state.""" + git_project = GitProject(directory=tmp_path) + + mock_repo = MagicMock() + # Simulate detached HEAD by having active_branch raise TypeError + # This is what GitPython does when in a detached HEAD state + type(mock_repo).active_branch = PropertyMock(side_effect=TypeError("detached HEAD")) + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + # Should raise DetachedHeadGitError + with pytest.raises(DetachedHeadGitError, match="detached HEAD state"): + git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_fetch_fails(tmp_path: Path, mock_repo: MagicMock): + """Test that verify_upstream_unchanged raises GitFetchError when fetch fails.""" + git_project = GitProject(directory=tmp_path) + + # Mock fetch to raise an error + mock_repo.remotes["origin"].fetch = MagicMock( + side_effect=GitCommandError("fetch", "error") + ) + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + with pytest.raises(GitFetchError, match="Failed to fetch from remote"): + git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_upstream_sha_fails( + tmp_path: Path, mock_repo: MagicMock +): + """Test that verify_upstream_unchanged raises error when upstream SHA cannot be determined.""" + git_project = GitProject(directory=tmp_path) + + # Mock refs to raise AttributeError (simulating missing branch) + mock_repo.remotes["origin"].refs = MagicMock() + mock_repo.remotes["origin"].refs.__getitem__ = MagicMock( + side_effect=AttributeError("No such ref") + ) + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + with pytest.raises( + GitFetchError, match="Unable to determine upstream branch SHA" + ): + git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_local_ref_sha_fails( + tmp_path: Path, mock_repo: MagicMock +): + """Test that verify_upstream_unchanged raises error when local ref SHA cannot be determined.""" + git_project = GitProject(directory=tmp_path) + + # Mock git operations - rev_parse fails + mock_repo.git.rev_parse = MagicMock( + side_effect=GitCommandError("rev-parse", "error") + ) + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + with pytest.raises( + LocalGitError, + match="Unable to determine the SHA for local ref", + ): + git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + + +def test_verify_upstream_unchanged_with_custom_ref( + tmp_path: Path, mock_repo: MagicMock +): + """Test that verify_upstream_unchanged works with a custom ref like HEAD~1.""" + git_project = GitProject(directory=tmp_path) + + # Mock Repo as a context manager + with patch("semantic_release.gitproject.Repo") as mock_repo_class: + mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) + mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + + # Should not raise an exception + git_project.verify_upstream_unchanged(local_ref="HEAD~1", noop=False) + + # Verify rev_parse was called with custom ref + mock_repo.git.rev_parse.assert_called_once_with("HEAD~1") From f6475ac4f9ad026a23826dbb876ca334dae796b8 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Thu, 6 Nov 2025 21:58:58 -0700 Subject: [PATCH 27/35] refactor(logging): remove excessive & irrelevant debug logging messages --- src/semantic_release/changelog/context.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/semantic_release/changelog/context.py b/src/semantic_release/changelog/context.py index fe16ca2c2..563f6a3f4 100644 --- a/src/semantic_release/changelog/context.py +++ b/src/semantic_release/changelog/context.py @@ -119,7 +119,7 @@ def read_file(filepath: str) -> str: return rfd.read() except FileNotFoundError as err: - logging.warning(err) + logging.warning(str(err)) return "" From a929dcfa1c23ec2872e9e541fcc2eabfed86d294 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Thu, 6 Nov 2025 22:12:59 -0700 Subject: [PATCH 28/35] style(logging): remove type ignore for non error syntax --- src/semantic_release/cli/masking_filter.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/semantic_release/cli/masking_filter.py b/src/semantic_release/cli/masking_filter.py index f2e4f825f..255ab77db 100644 --- a/src/semantic_release/cli/masking_filter.py +++ b/src/semantic_release/cli/masking_filter.py @@ -62,9 +62,7 @@ def filter(self, record: LogRecord) -> bool: def mask(self, msg: str) -> str: if not isinstance(msg, str): - logger.debug( # type: ignore[unreachable] - "cannot mask object of type %s", type(msg) - ) + logger.debug("cannot mask object of type %s", type(msg)) return msg for mask, values in self._redact_patterns.items(): repl_string = ( From 2418719544fc0c361722144ba901553d43e8a58e Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 8 Nov 2025 15:11:24 -0800 Subject: [PATCH 29/35] chore(config): update PSR config to use conventional commit style version commit (#1365) --- pyproject.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 4b2263493..259975c08 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -412,6 +412,11 @@ ignore_names = ["change_to_ex_proj_dir", "init_example_project"] logging_use_named_masks = true commit_parser = "conventional" commit_parser_options = { parse_squash_commits = true, ignore_merge_commits = true } +commit_message = """\ +chore: release v{version} + +Automatically generated by python-semantic-release +""" build_command = """ python -m pip install -e .[build] python -m build . From 8b7077cae52f4d1378e48090d9788286a3ff6cb4 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Nov 2025 20:19:46 -0800 Subject: [PATCH 30/35] chore(copilot): add GitHub Copilot instructions for repository (#1354) Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> --- .github/copilot-instructions.md | 550 ++++++++++++++++++++++++++++++++ 1 file changed, 550 insertions(+) create mode 100644 .github/copilot-instructions.md diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..97a92c06a --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,550 @@ +# Copilot Instructions for python-semantic-release + +This document explains how GitHub Copilot-like automated agents should interact with +the python-semantic-release repository. + +## Project Overview + +Python Semantic Release is a tool for automating semantic versioning and marking releases for +various types of software projects. It analyzes commit messages with various commit parsers +(the most notable being the Conventional Commits specification) to determine what the next +version should be and facilitates release steps that the developer generally has to do. This +includes generating changelogs, stamping the code with version strings, creating a repository +tag and annotating releases on a remote Git server with version-specific release notes. + +**Key Components:** +- **CLI**: Command-line interface for version management, changelog generation, and publishing +- **Commit Parsers**: Parse commit messages to determine version bumps + (Supports Conventional-Commits, Emoji, and Scipy format) +- **HVCS Integration**: Integrations with GitHub, GitLab, Gitea, and Bitbucket for releasing +- **Version Management**: Semantic versioning logic and version calculation +- **Changelog Generation**: Automated and customizable changelog creation using Jinja2 templates + +## Development Setup + +### Installation + +Requires 3.9+ for development dependencies, but runtime supports 3.8+. + +```bash +# Set up for development +pip install -e .[build,dev,docs,mypy,test] +``` + +### Running the Application + +```bash +# See the CLI help +semantic-release --help + +# Common commands +semantic-release version +semantic-release changelog +semantic-release publish +``` + +### Making Changes + +Minimal PR checklist (run locally before proposing a PR): + +- [ ] Run pre-PR checklist script (see below) +- [ ] If you added dependencies: update `pyproject.toml` and mention them in the PR. +- [ ] Review the helpful tips at the bottom of this document to ensure best practices. +- [ ] Verify that commit messages follow the Commit Message Conventions section below. + +Runnable pre-PR checklist script (copyable): + +```bash +# lint & format +ruff format . +ruff check --unsafe-fixes . +# run type checks +mypy . +# run unit tests +pytest -m unit +# run e2e tests +pytest -m e2e +# optional docs build when docs changed +sphinx-build -b html docs docs/_build/html +``` + +## Code Style and Quality + +### Linting and Formatting + +- **Ruff**: Primary linter and formatter (replaces Black, isort, flake8) + + ```bash + # run check for lint errors + ruff check --unsafe-fixes . + + # apply lint fixes + ruff check --unsafe-fixes --fix . + + # check for formatting issues + ruff format --check . + + # apply formatting fixes + ruff format . + ``` + +- **Type Checking**: Use mypy for type checking + + ```bash + mypy . + ``` + +### Code Style Guidelines + +1. **Type Hints**: All functions must have complete type hints (enforced by mypy) + +2. **Docstrings**: Use sphinx-style docstrings (though currently many are missing - add + only when modifying a function or adding new code) + +3. **Line Length**: 88 characters (enforced by Ruff) + +4. **Import Style**: + + - Absolute imports only (no relative imports) + - All files must use `from __future__ import annotations` for ignoring type hints at runtime + - Prefer `from module import Class` over `import module` when using classes/functions + - Running Ruff with `--unsafe-fixes` and `--fix` will automatically sort and group imports + - All files should have a `if TYPE_CHECKING: # pragma: no cover` block for type-only imports + - Prefer renaming `re` imports for clarity (e.g. `from re import compile as regexp, escape as regexp_escape`) + +5. **String Quotes**: Use double quotes for strings + +6. **Error Handling**: Create specific exception classes inheriting from `SemanticReleaseBaseError` + and defined in `errors.py` + +### Common Patterns + +- Configuration uses Pydantic models (v2) for validation +- CLI uses Click framework with click-option-group for organization +- Git operations use GitPython library +- Templating uses Jinja2 for changelogs and release notes + +## Testing + +### Test Structure + +- **Unit Tests**: `tests/unit/` - Fast, isolated tests + +- **E2E Tests**: `tests/e2e/` - End-to-end integration tests performed on real git repos + (as little mocking as possible, external network calls to HVCS should be mocked). Repos are + cached into `.pytest_cache/` for faster test setup/runs after the first build. E2E tests are + built to exercise the CLI commands and options against real git repositories with various commit + histories and configurations. + +- **Fixtures**: `tests/fixtures/` - Reusable test data and fixtures + +- **Repository Fixtures**: `tests/fixtures/repos/` - Example git repositories for testing and rely on + `tests/fixtures/example_project.py` and `tests/fixtures/git_repo.py` for setup + +- **Monorepo Fixtures**: `tests/fixtures/monorepo/` - Example monorepos for testing monorepo support + +- **GitHub Action Tests**: `tests/gh_action/` - Tests for simulating GitHub Docker Action functionality + +### Running Tests + +```bash +# Run only unit tests +pytest -m unit + +# Run only e2e tests +pytest -m e2e + +# Run comprehensive (unit & e2e) test suite with full verbosity (all variations of repositories) +# Warning: long runtime (14mins+) only necessary for testing all git repository variations +pytest -vv --comprehensive + +# Run GitHub Docker Action tests (requires Docker, see .github/workflows/validate.yml for setup) +# Only required when modifying the GitHub Action code (src/gh_action/, and action.yml) +bash tests/gh_action/run.sh +``` + +### Testing Guidelines + +1. **Test Organization**: + + - Group unit tests by module structure mirroring `src/` under `tests/unit/` + - Group e2e tests by CLI command under `tests/e2e/` + - Use descriptive test function names that clearly indicate the scenario being tested + - Test docstrings should follow the format: `"""Given , when , then ."""` + +2. **Fixtures**: Use pytest fixtures from `tests/conftest.py` and `tests/fixtures/` + +3. **Markers**: Apply appropriate markers (`@pytest.mark.unit`, `@pytest.mark.e2e`, `@pytest.mark.comprehensive`) + +4. **Mocking**: Use `pytest-mock` for mocking, `responses` for HTTP mocking + +5. **Parametrization**: Use `@pytest.mark.parametrize` for testing multiple scenarios + +6. **Test Data**: Use `tests/fixtures/repos/` for specific git repository workflow strategies + + - Git repository strategies include: + + - Git Flow: + - branch & merge commit strategy + - varying number of branches & release branches + + - GitHub Flow: + - squash merge strategy + - branch & merge commit strategy + - varying number of release branches & simulated simultaneous work branches + - varying branch update strategies (e.g. rebasing, merging) + + - Trunk-Based Development (no branches): + - unreleased repo (no tags) + - trunk with only official release tags + - trunk with mixed release and pre-release tags + - concurrent major version support + + - ReleaseFlow (Not supported yet) + + - Monorepo (multiple packages): + - trunk based development with only official release tags + - github flow with squash merge strategy + - github flow with branch & merge commit strategy + +### Test Coverage + +- Maintain high test coverage for core functionality +- Unit tests should be fast and not touch filesystem/network when possible +- E2E tests should test realistic workflows with actual git operations + +### Pull Request testing + +Each PR will be evaluated through an GitHub Actions workflow before it can be merged. +The workflow is very specialized to run the tests in a specific order and with specific +parameters. Please refer to `.github/workflows/ci.yml` for details on how the tests are +structured and run. + +## Commit Message Conventions + +This project uses **Conventional Commits** specification and is versioned by itself. See +the `CHANGELOG.rst` for reference of how the conventional commits and specific rules this +project uses are used in practice to communicate changes to users. + +It is highly important to separate the code changes into their respective commit types +and scopes to ensure that the changelog is generated correctly and that users can +understand the changes in each release. The commit message format is strictly enforced +and should be followed for all commits. + +When submitting a pull request, it is recommended to commit any end-2-end test cases +first as a `test` type commit, then the implementation changes as `feat`, `fix`, etc. +This order allows reviewers to run the test which demonstrates the failure case before +validating the implementation changes by doing a `git merge origin/` to run the +test again and see it pass. Unit test cases will need to be committed after the source +code implementation changes as they will not run without the implementation code. +Documentation changes should be committed last and the commit scope should be a short +reference to the page its modifying (e.g. `docs(github-actions): ` or +`docs(configuration): `). Commit types should be chosen based on reference +to the default branch as opposed to its previous commits on the branch. For example, if +you are fixing a bug in a feature that was added in the same branch, the commit type +should be `refactor` instead of `fix` since the bug was introduced in the same branch +and is not present in the default branch. + +### Format + +``` +(): + + + +[optional footer(s)] +``` + +Scopes by the specification are optional but for this project, they are required and +only by exception can they be omitted. + +Footers include: + +- `BREAKING CHANGE: ` for breaking changes + +- `NOTICE: ` for additional release information that should be included + in the changelog to give users more context about the release + +- `Resolves: #` for linking to bug fixes. Use `Implements: #` + for new features. + +You should not have a breaking change and a notice in the same commit. If you have a +breaking change, the breaking change description should include all relevant information +about the change and how to update. + +### Types + +- `feat`: New feature (minor version bump) +- `fix`: Bug fix (patch version bump) +- `perf`: Performance improvement (patch version bump) +- `docs`: Documentation only changes +- `style`: Code style changes (formatting, missing semicolons, etc.) +- `refactor`: Code refactoring without feature changes or bug fixes +- `test`: Adding or updating tests +- `build`: Changes to build system or dependencies +- `ci`: Changes to CI configuration +- `chore`: Other changes that don't modify src or test files + +### Breaking Changes + +- Add `!` after the scope: `feat(scope)!: breaking change` and add + `BREAKING CHANGE:` in footer with detailed description of what was changed, + why, and how to update. + +### Notices + +- Add `NOTICE: ` in footer to include important information about the + release that should be included in the changelog. This is for things that require + more explanation than a simple commit message and are not breaking changes. + +### Scopes + +Use scopes as categories to indicate the area of change. They are most important for the +types of changes that are included in the changelog (bug fixes, features, performance +improvements, documentation, build dependencies) to tell the user what area was changed. + +Common scopes include: + +- `changelog`: Changes related to changelog generation +- `config`: Changes related to user configuration +- `fixtures`: Changes related to test fixtures +- `deps`: Changes related to runtime dependencies +- `deps-dev`: Changes related to development dependencies + (as defined in `pyproject.toml:project.optional-dependencies.dev`) +- `deps-build`: Changes related to build dependencies + (as defined in `pyproject.toml:project.optional-dependencies.build`) +- `deps-docs`: Changes related to documentation dependencies + (as defined in `pyproject.toml:project.optional-dependencies.docs`) +- `deps-test`: Changes related to testing dependencies + (as defined in `pyproject.toml:project.optional-dependencies.test`) + +We use hyphenated scopes to group related changes together in a category to subcategory +format. The most common categories are: + +- `cmd-`: Changes related to a specific CLI command +- `parser-`: Changes related to a specific commit parser +- `hvcs-`: Changes related to a specific hosting service integration + +## Architecture + +The project's primary entrypoint is `src/semantic_release/__main__.py:main`, as defined +in `pyproject.toml:project.scripts`. This is the CLI interface that users interact with. +The CLI is built using Click and lazy-loaded subcommands for version management, +changelog generation, and publishing. + +Although the project is primarily a CLI tool, the code is under development to become +more modular and pluggable to allow for more flexible usage in other contexts (e.g. as a +library). + +This repository also is provided as a GitHub Action (see `src/gh_action/`) for users +who want a pre-built solution for their GitHub repositories. The action is built using Docker +and wraps the built wheel of the project before it runs the CLI version command in a +containerized environment. The publish command is also available as a GitHub Action but +that code is hosted in a separate repository (https://github.com/python-semantic-release/publish-action). + +### Key Components + +- `src/semantic_release/cli/`: Click-based CLI interface + - `commands/`: Individual CLI commands (version, changelog, publish) + - `config.py`: Configuration loading and validation with Pydantic + +- `src/semantic_release/commit_parser/`: Commit message parsers + - `_base.py`: Base parser interface + - `conventional/parser.py`: Conventional Commits parser + - `conventional/options.py`: Conventional Commits parser options + - `conventional/parser_monorepo.py`: Conventional Commits parser for monorepos + - `conventional/options_monorepo.py`: Conventional Commits monorepo parser options + - `angular.py`, `emoji.py`, `scipy.py`, `tag.py`: Parser implementations + +- `src/semantic_release/hvcs/`: Hosting service integrations + - `_base.py`: Base HVCS interface + - `remote_hvcs_base.py`: Base class for remote HVCS implementations + - `github.py`, `gitlab.py`, `gitea.py`, `bitbucket.py`: Service implementations + +- `src/semantic_release/version/`: Version management + - `version.py`: Version class and comparison logic + - `declarations/`: Implementations of how to stamp versions into various types of code + from users' configuration + - `translator.py`: Version translation between different formats + +- `src/gh_action/`: GitHub Docker Action implementation + - `action.sh`: Main entrypoint for the action + - `Dockerfile`: Dockerfile for the action + +- `action.yml`: GitHub Action definition + +### Design Patterns + +- **Strategy Pattern**: Commit parsers and HVCS implementations are pluggable +- **Template Method**: Base classes define workflow, subclasses implement specifics +- **Builder Pattern**: Version calculation builds up changes from commits +- **Factory Pattern**: Parser and HVCS selection based on configuration +- **Composition Pattern**: The future of the project's design for pluggable components + +## Building and Releasing + +### Local Build + +```bash +pip install -e .[build] +bash scripts/build.sh +``` + +### Release Process + +This project is released via GitHub Actions (see `.github/workflows/cicd.yml`) after +a successful validation workflow. During release, it runs a previous version of +itself to perform the release steps. The release process includes: + +1. Commits are analyzed from the last tag that exists on the current branch +2. Version is determined based on commit types +3. Changelog is generated from commits +4. Source code is stamped with new version +5. Documentation is stamped with the new version (`$NEW_VERSION`) + or new release tag (`$NEW_RELEASE_TAG`) (see `scripts/bump_version_in_docs.py` for details) +6. Package is built with stamped version +7. Code changes from steps 4-6 are committed and pushed to the repository +8. A new tag is created for the release and pushed to the repository +9. A new release is created on the hosting service with version-specific generated release notes +10. Assets are uploaded to the release +11. The package is published to PyPI +12. ReadTheDocs is triggered to build & publish the documentation + +### Version Configuration + +- Version stored in `pyproject.toml:project.version` +- Additional version locations in `tool.semantic_release.version_variables` +- Follows semantic versioning: MAJOR.MINOR.PATCH + +## Common Tasks + +### Adding a New Commit Parser + +1. Create new parser in `src/semantic_release/commit_parser/` +2. Inherit from `CommitParser` base class +3. Implement `parse()` method +4. Add parser to `KNOWN_COMMIT_PARSERS` in config +5. Add tests in `tests/unit/semantic_release/commit_parser/` +6. Add fixtures that can select the new parser for e2e tests + +### Adding a New HVCS Integration + +1. Create new HVCS in `src/semantic_release/hvcs/` +2. Inherit from `HvcsBase` base class or `RemoteHvcsBase` if it is a remote service +3. Implement required methods (token creation, asset upload, release creation) +4. Add HVCS to configuration options +5. Add tests in `tests/unit/semantic_release/hvcs/` +6. Add fixtures that can select the new HVCS for e2e tests + +### Adding a New CLI Command + +1. Create command in `src/semantic_release/cli/commands/` +2. Use Click decorators for arguments/options +3. Access shared context via `ctx.obj` (RuntimeContext) +4. Add command to main command group in `src/semantic_release/cli/commands/main.py` +5. Add tests in `tests/e2e/cmd_/` +6. Add documentation for the command in `docs/api/commands.rst` + +### Modifying the included default changelog templates source + +1. Update the default templates in `src/semantic_release/data/templates///` +2. Update the fixtures in `tests/fixtures/git_repo.py` to correctly replicate the + format of the new templates via code. +3. Update the unit tests for changelog generation + +### Adding a new configuration option + +1. Update the Pydantic models in `cli/config.py` with validation +2. Add option over into the RuntimeContext if necessary +3. Add option description to documentation in + `docs/configuration/configuration.rst` +4. Add unit tests for the validation of the option in `tests/unit/semantic_release/cli/config.py` +5. Add e2e tests for the option in `tests/e2e/` depending on the option's scope + and functionality. + +### Adding a new command line option + +1. Add the option to the appropriate CLI command in + `src/semantic_release/cli/commands/` +2. Add the option to the GitHub Action if it is for the `version` command +3. Add the option to the documentation in `docs/api/commands.rst` +4. Add the option to the GitHub Action documentation in + `docs/configuration/automatic-releases/github-actions.rst` +5. Add e2e tests for the option in `tests/e2e/cmd_/` + +### Adding a new changelog context filter + +1. Implement the filter in `src/semantic_release/changelog/context.py` +2. Add the filter to the changelog context and release notes context objects +3. Add unit tests for the filter in `tests/unit/semantic_release/changelog/**` +4. Add description and example of how to use the filter in the documentation + in `docs/concepts/changelog_templates.rst` + +## Important Files + +- `pyproject.toml`: Project configuration, dependencies, tool settings +- `action.yml`: GitHub Action definition +- `config/release-templates/`: Project-specific Jinja2 templates for changelog and release notes +- `.pre-commit-config.yaml`: Pre-commit hooks configuration +- `.readthedocs.yml`: ReadTheDocs configuration +- `CONTRIBUTING.rst`: Contribution guidelines + +## Documentation + +- Hosted on ReadTheDocs: https://python-semantic-release.readthedocs.io +- Source in `docs/` directory +- Uses Sphinx with Furo theme +- Build locally: `sphinx-build -b html docs docs/_build/html` +- View locally: open `docs/_build/html/index.html` + +## Python Version Support + +- Runtime Minimum: Python 3.8 +- Development Dependencies: Python 3.9+ +- Tested on: Python 3.8, 3.14 +- Target version for type checking: Python 3.8 + +## Dependencies to Know + +- **Click**: CLI framework +- **GitPython**: Git operations +- **Pydantic v2**: Configuration validation and models +- **Jinja2**: Template engine for changelogs +- **requests**: HTTP client for HVCS APIs +- **python-gitlab**: GitLab API client +- **tomlkit**: TOML parsing with formatting preservation +- **rich**: Rich terminal output + +## Helpful Tips + +- Never add real secrets, tokens, or credentials to source, commits, fixtures, or logs. + +- All proposed changes must include tests (unit and/or e2e as appropriate) and pass the + local quality gate before creating a PR. + +- When modifying configuration, update the Pydantic models in `cli/config.py` + +- Jinja2 changelog templates for this project are in `config/release-templates/`, whereas + the default changelog templates provided to users as a part of this project are in + `src/semantic_release/data/templates///**`. + +- The `RuntimeContext` object holds shared state across CLI commands + +- Use `--noop` flag to test commands without making changes + +- Version detection respects git tags - use annotated tags + +- The project uses its own tool for versioning, so commit messages matter! + +- When creating a Pull Request, create a PR description that fills out the + PR template found in `.github/PULL_REQUEST_TEMPLATE.md`. This will help + reviewers understand the changes and the impact of the PR. + +- If creating an issue, fill out one of the issue templates found in + `.github/ISSUE_TEMPLATE/` related to the type of issue (bug, feature request, etc.). + This will help maintainers understand the issue and its impact. + +- When adding new features, consider how they will affect the changelog and + versioning. Make as few breaking changes as possible by adding backwards compatibility + and if you do make a breaking change, be sure to include a detailed description in the + `BREAKING CHANGE` footer of the commit message. From c95c6083749972aaef1e949eb596192309d0d8d1 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Sat, 8 Nov 2025 20:24:11 -0800 Subject: [PATCH 31/35] ci(deps): bump `mikepenz/action-junit-report@v6.0.0` action to `v6.0.1` (#1361) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/validate.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 72aa94207..6f447d461 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -195,7 +195,7 @@ jobs: --junit-xml=tests/reports/pytest-results.xml - name: Report | Upload Test Results - uses: mikepenz/action-junit-report@5b7ee5a21e8674b695313d769f3cbdfd5d4d53a4 # v6.0.0 + uses: mikepenz/action-junit-report@e08919a3b1fb83a78393dfb775a9c37f17d8eea6 # v6.0.1 if: ${{ always() && steps.tests.outcome != 'skipped' }} with: report_paths: ./tests/reports/*.xml @@ -285,7 +285,7 @@ jobs: retention-days: 1 - name: Report | Upload Test Results - uses: mikepenz/action-junit-report@5b7ee5a21e8674b695313d769f3cbdfd5d4d53a4 # v6.0.0 + uses: mikepenz/action-junit-report@e08919a3b1fb83a78393dfb775a9c37f17d8eea6 # v6.0.1 if: ${{ always() && steps.tests.outcome != 'skipped' }} with: report_paths: ./tests/reports/*.xml @@ -383,7 +383,7 @@ jobs: retention-days: 1 - name: Report | Upload Test Results - uses: mikepenz/action-junit-report@5b7ee5a21e8674b695313d769f3cbdfd5d4d53a4 # v6.0.0 + uses: mikepenz/action-junit-report@e08919a3b1fb83a78393dfb775a9c37f17d8eea6 # v6.0.1 if: ${{ always() && steps.tests.outcome != 'skipped' }} with: report_paths: ./tests/reports/*.xml From 90a1ffa55c5a1605c59cb26a1797f9a37fdfa784 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sat, 8 Nov 2025 20:28:24 -0800 Subject: [PATCH 32/35] feat(cmd-version): add automatic repository un-shallowing to version workflow (#1366) NOTICE: If you were previously handling the unshallowing of a repository clone in your CI/CD pipelines, you may now remove that step from your workflow. PSR will now detect a shallow repository and unshallow it before evaluating the commit history. Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> * docs(github-actions): update example to remove need to specify repo checkout's fetch depth * docs(uv-integration): update example to remove need to specify repo checkout's fetch depth * test(cmd-version): add E2E test cases to verify automatic un-shallowing of repos * test(gitproject): refactor verify_upstream tests to use common mocking fixture * test(gitproject): add unit tests to exercise the auto-unshallow use & error cases --- .../automatic-releases/github-actions.rst | 26 +- .../configuration-guides/uv_integration.rst | 2 - src/semantic_release/cli/commands/version.py | 17 +- src/semantic_release/gitproject.py | 36 +++ tests/e2e/cmd_version/test_version_shallow.py | 306 ++++++++++++++++++ .../unit/semantic_release/test_gitproject.py | 254 +++++++++------ 6 files changed, 515 insertions(+), 126 deletions(-) create mode 100644 tests/e2e/cmd_version/test_version_shallow.py diff --git a/docs/configuration/automatic-releases/github-actions.rst b/docs/configuration/automatic-releases/github-actions.rst index c515ed474..cf8610c10 100644 --- a/docs/configuration/automatic-releases/github-actions.rst +++ b/docs/configuration/automatic-releases/github-actions.rst @@ -875,17 +875,16 @@ to the GitHub Release Assets as well. contents: write steps: - # Note: We checkout the repository at the branch that triggered the workflow - # with the entire history to ensure to match PSR's release branch detection - # and history evaluation. - # However, we forcefully reset the branch to the workflow sha because it is - # possible that the branch was updated while the workflow was running. This - # prevents accidentally releasing un-evaluated changes. + # Note: We checkout the repository at the branch that triggered the workflow. + # Python Semantic Release will automatically convert shallow clones to full clones + # if needed to ensure proper history evaluation. However, we forcefully reset the + # branch to the workflow sha because it is possible that the branch was updated + # while the workflow was running, which prevents accidentally releasing un-evaluated + # changes. - name: Setup | Checkout Repository on Release Branch uses: actions/checkout@v4 with: ref: ${{ github.ref_name }} - fetch-depth: 0 - name: Setup | Force release branch to be at workflow sha run: | @@ -959,11 +958,6 @@ to the GitHub Release Assets as well. one release job in the case if there are multiple pushes to ``main`` in a short period of time. -.. warning:: - You must set ``fetch-depth`` to 0 when using ``actions/checkout@v4``, since - Python Semantic Release needs access to the full history to build a changelog - and at least the latest tags to determine the next version. - .. warning:: The ``GITHUB_TOKEN`` secret is automatically configured by GitHub, with the same permissions role as the user who triggered the workflow run. This causes @@ -974,6 +968,14 @@ to the GitHub Release Assets as well. case, you will also need to pass the new token to ``actions/checkout`` (as the ``token`` input) in order to gain push access. +.. note:: + As of $NEW_RELEASE_TAG, Python Semantic Release automatically detects and converts + shallow clones to full clones when needed. While you can still use ``fetch-depth: 0`` + with ``actions/checkout@v4`` to fetch the full history upfront, it is no longer + required. If you use the default shallow clone, Python Semantic Release will + automatically fetch the full history before evaluating commits. If you are using + an older version of PSR, you will need to unshallow the repository prior to use. + .. note:: As of $NEW_RELEASE_TAG, the verify upstream step is no longer required as it has been integrated into PSR directly. If you are using an older version of PSR, you will need diff --git a/docs/configuration/configuration-guides/uv_integration.rst b/docs/configuration/configuration-guides/uv_integration.rst index bc794832e..ac9f2359e 100644 --- a/docs/configuration/configuration-guides/uv_integration.rst +++ b/docs/configuration/configuration-guides/uv_integration.rst @@ -161,7 +161,6 @@ look like this: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.sha }} - fetch-depth: 0 - name: Setup | Force correct release branch on workflow sha run: git checkout -B ${{ github.ref_name }} @@ -259,7 +258,6 @@ look like this: uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 with: ref: ${{ github.ref_name }} - fetch-depth: 0 - name: Setup | Force release branch to be at workflow sha run: git reset --hard ${{ github.sha }} diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index 627cc1fc6..ad60b95aa 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -496,6 +496,17 @@ def version( # noqa: C901 logger.info("Forcing use of %s as the prerelease token", prerelease_token) translator.prerelease_token = prerelease_token + # Check if the repository is shallow and unshallow it if necessary + # This ensures we have the full history for commit analysis + project = GitProject( + directory=runtime.repo_dir, + commit_author=runtime.commit_author, + credential_masker=runtime.masker, + ) + if project.is_shallow_clone(): + logger.info("Repository is a shallow clone, converting to full clone...") + project.git_unshallow(noop=opts.noop) + # Only push if we're committing changes if push_changes and not commit_changes and not create_tag: logger.info("changes will not be pushed because --no-commit disables pushing") @@ -688,12 +699,6 @@ def version( # noqa: C901 license_name="" if not isinstance(license_cfg, str) else license_cfg, ) - project = GitProject( - directory=runtime.repo_dir, - commit_author=runtime.commit_author, - credential_masker=runtime.masker, - ) - # Preparing for committing changes; we always stage files even if we're not committing them in order to support a two-stage commit project.git_add(paths=all_paths_to_add, noop=opts.noop) if commit_changes: diff --git a/src/semantic_release/gitproject.py b/src/semantic_release/gitproject.py index 05c4b1015..a29bb41de 100644 --- a/src/semantic_release/gitproject.py +++ b/src/semantic_release/gitproject.py @@ -90,6 +90,42 @@ def is_dirty(self) -> bool: with Repo(str(self.project_root)) as repo: return repo.is_dirty() + def is_shallow_clone(self) -> bool: + """ + Check if the repository is a shallow clone. + + :return: True if the repository is a shallow clone, False otherwise + """ + with Repo(str(self.project_root)) as repo: + shallow_file = Path(repo.git_dir, "shallow") + return shallow_file.exists() + + def git_unshallow(self, noop: bool = False) -> None: + """ + Convert a shallow clone to a full clone by fetching the full history. + + :param noop: Whether or not to actually run the unshallow command + """ + if noop: + noop_report("would have run:\n" " git fetch --unshallow") + return + + with Repo(str(self.project_root)) as repo: + try: + self.logger.info("Converting shallow clone to full clone...") + repo.git.fetch("--unshallow") + self.logger.info("Repository unshallowed successfully") + except GitCommandError as err: + # If the repository is already a full clone, git fetch --unshallow will fail + # with "fatal: --unshallow on a complete repository does not make sense" + # We can safely ignore this error by checking the stderr message + stderr = str(err.stderr) if err.stderr else "" + if "does not make sense" in stderr or "complete repository" in stderr: + self.logger.debug("Repository is already a full clone") + else: + self.logger.exception(str(err)) + raise + def git_add( self, paths: Sequence[Path | str], diff --git a/tests/e2e/cmd_version/test_version_shallow.py b/tests/e2e/cmd_version/test_version_shallow.py new file mode 100644 index 000000000..fa088e548 --- /dev/null +++ b/tests/e2e/cmd_version/test_version_shallow.py @@ -0,0 +1,306 @@ +"""Tests for version command with shallow repositories.""" + +from __future__ import annotations + +from contextlib import suppress +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import pytest +from git import Repo +from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture + +from semantic_release.hvcs.github import Github + +from tests.const import MAIN_PROG_NAME, VERSION_SUBCMD +from tests.fixtures.example_project import change_to_ex_proj_dir +from tests.fixtures.repos import repo_w_trunk_only_conventional_commits +from tests.fixtures.repos.trunk_based_dev.repo_w_tags import ( + build_trunk_only_repo_w_tags, +) +from tests.util import assert_successful_exit_code, temporary_working_directory + +if TYPE_CHECKING: + from requests_mock import Mocker + + from tests.conftest import RunCliFn + from tests.fixtures.example_project import ExProjectDir, UpdatePyprojectTomlFn + from tests.fixtures.git_repo import ( + BuildSpecificRepoFn, + CommitConvention, + GetCfgValueFromDefFn, + GetGitRepo4DirFn, + GetVersionsFromRepoBuildDefFn, + ) + + +@pytest.mark.parametrize( + "repo_fixture_name, build_repo_fn", + [ + ( + repo_fixture_name, + lazy_fixture(build_repo_fn_name), + ) + for repo_fixture_name, build_repo_fn_name in [ + ( + repo_w_trunk_only_conventional_commits.__name__, + build_trunk_only_repo_w_tags.__name__, + ), + ] + ], +) +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_version_w_shallow_repo_unshallows( + repo_fixture_name: str, + run_cli: RunCliFn, + build_repo_fn: BuildSpecificRepoFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + post_mocker: Mocker, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + pyproject_toml_file: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +) -> None: + """ + Test that the version command automatically unshallows a shallow repository. + + Given a shallow repository, + When running the version command, + Then the repository should be unshallowed and release should succeed + """ + remote_name = "origin" + + # Create a bare remote (simulating origin) + local_origin = Repo.init(str(example_project_dir / "local_origin"), bare=True) + + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_repo_fn( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # Configure the source repo to use the bare remote (removing any existing 'origin') + with suppress(AttributeError): + target_git_repo.delete_remote(target_git_repo.remotes[remote_name]) + + target_git_repo.create_remote(remote_name, str(local_origin.working_dir)) + + # Remove last release before pushing to upstream + tag_format_str = cast( + "str", get_cfg_value_from_def(target_repo_definition, "tag_format_str") + ) + latest_tag = tag_format_str.format( + version=get_versions_from_repo_build_def(target_repo_definition)[-1] + ) + target_git_repo.git.tag("-d", latest_tag) + target_git_repo.git.reset("--hard", "HEAD~1") + + # TODO: when available, switch this to use hvcs=none or similar config to avoid token use for push + update_pyproject_toml( + "tool.semantic_release.remote.ignore_token_for_push", + True, + target_repo_dir / pyproject_toml_file, + ) + target_git_repo.git.commit(amend=True, no_edit=True, all=True) + + # push the current state to establish the remote (cannot push tags and branches at the same time) + target_git_repo.git.push(remote_name, all=True) # all branches + target_git_repo.git.push(remote_name, tags=True) # all tags + + # ensure bare remote HEAD points to the active branch so clones can checkout + local_origin.git.symbolic_ref( + "HEAD", f"refs/heads/{target_git_repo.active_branch.name}" + ) + + # current remote tags + remote_origin_tags_before = {tag.name for tag in local_origin.tags} + + # Create a shallow clone from the remote using file:// protocol for depth support + shallow_repo = Repo.clone_from( + f"file://{local_origin.working_dir}", + str(example_project_dir / "shallow_clone"), + no_local=True, + depth=1, + ) + with shallow_repo.config_writer("repository") as config: + config.set_value("core", "hookspath", "") + config.set_value("commit", "gpgsign", False) + config.set_value("tag", "gpgsign", False) + + with shallow_repo: + # Verify it's a shallow clone + shallow_file = Path(shallow_repo.git_dir, "shallow") + assert shallow_file.exists(), "Repository should be shallow" + + # Capture expected values from the full repo + expected_vcs_url_post = 1 + commit_sha_before = shallow_repo.head.commit.hexsha + + # Run PSR on the shallow clone + with temporary_working_directory(str(shallow_repo.working_dir)): + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, "--patch"] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + + # Initial execution check + assert_successful_exit_code(result, cli_cmd) + + # Take measurements after running PSR + remote_origin_tags_after = {tag.name for tag in local_origin.tags} + different_tags = remote_origin_tags_after.difference(remote_origin_tags_before) + with shallow_repo: + parent_commit_shas = [ + parent.hexsha for parent in shallow_repo.head.commit.parents + ] + commit_sha_after = shallow_repo.head.commit.hexsha + + # Verify the shallow file is gone (repo was unshallowed) + assert not shallow_file.exists(), "Repository should be unshallowed" + + # Verify release was successful + assert commit_sha_before != commit_sha_after, "Expected commit SHA to change" + assert ( + commit_sha_before in parent_commit_shas + ), "Expected new commit to be created on HEAD" + assert ( + latest_tag in different_tags + ), "Expected a new tag to be created and pushed to remote" + assert expected_vcs_url_post == post_mocker.call_count # 1x vcs release created + + +@pytest.mark.parametrize( + "repo_fixture_name, build_repo_fn", + [ + ( + repo_fixture_name, + lazy_fixture(build_repo_fn_name), + ) + for repo_fixture_name, build_repo_fn_name in [ + ( + repo_w_trunk_only_conventional_commits.__name__, + build_trunk_only_repo_w_tags.__name__, + ), + ] + ], +) +@pytest.mark.usefixtures(change_to_ex_proj_dir.__name__) +def test_version_noop_w_shallow_repo( + repo_fixture_name: str, + run_cli: RunCliFn, + build_repo_fn: BuildSpecificRepoFn, + example_project_dir: ExProjectDir, + git_repo_for_directory: GetGitRepo4DirFn, + post_mocker: Mocker, + get_cfg_value_from_def: GetCfgValueFromDefFn, + get_versions_from_repo_build_def: GetVersionsFromRepoBuildDefFn, + pyproject_toml_file: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +) -> None: + """ + Test that the version command in noop mode reports unshallow action. + + Given a shallow repository, + When running the version command with --noop, + Then the command should report what it would do but not actually unshallow + """ + remote_name = "origin" + + # Create a bare remote (simulating origin) + local_origin = Repo.init(str(example_project_dir / "local_origin"), bare=True) + + # build target repo into a temporary directory + target_repo_dir = example_project_dir / repo_fixture_name + commit_type: CommitConvention = ( + repo_fixture_name.split("commits", 1)[0].split("_")[-2] # type: ignore[assignment] + ) + target_repo_definition = build_repo_fn( + repo_name=repo_fixture_name, + commit_type=commit_type, + dest_dir=target_repo_dir, + ) + target_git_repo = git_repo_for_directory(target_repo_dir) + + # Configure the source repo to use the bare remote (removing any existing 'origin') + with suppress(AttributeError): + target_git_repo.delete_remote(target_git_repo.remotes[remote_name]) + + target_git_repo.create_remote(remote_name, str(local_origin.working_dir)) + + # Remove last release before pushing to upstream + tag_format_str = cast( + "str", get_cfg_value_from_def(target_repo_definition, "tag_format_str") + ) + latest_tag = tag_format_str.format( + version=get_versions_from_repo_build_def(target_repo_definition)[-1] + ) + target_git_repo.git.tag("-d", latest_tag) + target_git_repo.git.reset("--hard", "HEAD~1") + + # TODO: when available, switch this to use hvcs=none or similar config to avoid token use for push + update_pyproject_toml( + "tool.semantic_release.remote.ignore_token_for_push", + True, + target_repo_dir / pyproject_toml_file, + ) + target_git_repo.git.commit(amend=True, no_edit=True, all=True) + + # push the current state to establish the remote (cannot push tags and branches at the same time) + target_git_repo.git.push(remote_name, all=True) # all branches + target_git_repo.git.push(remote_name, tags=True) # all tags + + # ensure bare remote HEAD points to the active branch so clones can checkout + local_origin.git.symbolic_ref( + "HEAD", f"refs/heads/{target_git_repo.active_branch.name}" + ) + + # Create a shallow clone from the remote using file:// protocol for depth support + shallow_repo = Repo.clone_from( + f"file://{local_origin.working_dir}", + str(example_project_dir / "shallow_clone"), + no_local=True, + depth=1, + ) + with shallow_repo.config_writer("repository") as config: + config.set_value("core", "hookspath", "") + config.set_value("commit", "gpgsign", False) + config.set_value("tag", "gpgsign", False) + + with shallow_repo: + # Verify it's a shallow clone + shallow_file = Path(shallow_repo.git_dir, "shallow") + assert shallow_file.exists(), "Repository should be shallow" + + # Capture expected values from the full repo + expected_vcs_url_post = 0 + commit_sha_before = shallow_repo.head.commit.hexsha + remote_origin_tags_before = {tag.name for tag in local_origin.tags} + + # Run PSR in noop mode on the shallow clone + with temporary_working_directory(str(shallow_repo.working_dir)): + cli_cmd = [MAIN_PROG_NAME, "--noop", VERSION_SUBCMD, "--patch"] + result = run_cli(cli_cmd[1:], env={Github.DEFAULT_ENV_TOKEN_NAME: "1234"}) + + # Initial execution check + assert_successful_exit_code(result, cli_cmd) + + # Take measurements after running PSR + remote_origin_tags_after = {tag.name for tag in local_origin.tags} + different_tags = remote_origin_tags_after.difference(remote_origin_tags_before) + with shallow_repo: + commit_sha_after = shallow_repo.head.commit.hexsha + + # Verify the shallow file still exists (repo was NOT actually unshallowed in noop) + assert shallow_file.exists(), "Repository should still be shallow in noop mode" + + # Verify no actual changes were made + assert ( + commit_sha_before == commit_sha_after + ), "Expected commit SHA to remain unchanged in noop mode" + assert not different_tags, "Expected no new tags to be created in noop mode" + assert expected_vcs_url_post == post_mocker.call_count diff --git a/tests/unit/semantic_release/test_gitproject.py b/tests/unit/semantic_release/test_gitproject.py index d5795fff7..7b37a9756 100644 --- a/tests/unit/semantic_release/test_gitproject.py +++ b/tests/unit/semantic_release/test_gitproject.py @@ -1,11 +1,14 @@ +"""Tests for the GitProject class.""" + from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast from unittest.mock import MagicMock, PropertyMock, patch import pytest from git import GitCommandError +import semantic_release.gitproject from semantic_release.errors import ( DetachedHeadGitError, GitFetchError, @@ -13,16 +16,36 @@ UnknownUpstreamBranchError, UpstreamBranchChangedError, ) -from semantic_release.gitproject import GitProject if TYPE_CHECKING: from pathlib import Path + from typing import Generator + + from semantic_release.gitproject import GitProject + + class MockGit(MagicMock): + """A mock Git object that can be used in tests.""" + + rev_parse: MagicMock + fetch: MagicMock + push: MagicMock + + class RepoMock(MagicMock): + """A mock Git repository that can be used in tests.""" + + active_branch: MagicMock + remotes: dict[str, MagicMock] + git: MockGit + git_dir: str + commit: MagicMock @pytest.fixture -def mock_repo(): +def mock_repo(tmp_path: Path) -> RepoMock: """Create a mock Git repository with proper structure for new implementation.""" - repo = MagicMock() + repo = cast("RepoMock", MagicMock()) + + repo.git_dir = str(tmp_path / ".git") # Mock active branch active_branch = MagicMock() @@ -60,17 +83,30 @@ def mock_repo(): return repo -def test_verify_upstream_unchanged_success(tmp_path: Path, mock_repo: MagicMock): - """Test that verify_upstream_unchanged succeeds when upstream has not changed.""" - git_project = GitProject(directory=tmp_path) +@pytest.fixture +def git_project(tmp_path: Path) -> GitProject: + """Create a GitProject instance for testing.""" + return semantic_release.gitproject.GitProject(directory=tmp_path) - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: + +@pytest.fixture +def mock_gitproject( + git_project: GitProject, mock_repo: RepoMock +) -> Generator[GitProject, None, None]: + """Patch the GitProject to use the mock Repo.""" + module_path = semantic_release.gitproject.__name__ + with patch(f"{module_path}.Repo") as mock_repo_class: mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + yield git_project - # Should not raise an exception - git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + +def test_verify_upstream_unchanged_success( + mock_gitproject: GitProject, mock_repo: RepoMock +): + """Test that verify_upstream_unchanged succeeds when upstream has not changed.""" + # Should not raise an exception + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) # Verify fetch was called mock_repo.remotes["origin"].fetch.assert_called_once() @@ -79,11 +115,9 @@ def test_verify_upstream_unchanged_success(tmp_path: Path, mock_repo: MagicMock) def test_verify_upstream_unchanged_fails_when_changed( - tmp_path: Path, mock_repo: MagicMock + mock_gitproject: GitProject, mock_repo: RepoMock ): """Test that verify_upstream_unchanged raises error when upstream has changed.""" - git_project = GitProject(directory=tmp_path) - # Mock git operations with different SHAs mock_repo.git.rev_parse = MagicMock( return_value="def456" # Different from upstream @@ -95,152 +129,160 @@ def test_verify_upstream_unchanged_fails_when_changed( changed_commit.iter_parents = MagicMock(return_value=[]) mock_repo.commit = MagicMock(return_value=changed_commit) - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: - mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) - mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) - - with pytest.raises( - UpstreamBranchChangedError, match=r"Upstream branch .* has changed" - ): - git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + with pytest.raises( + UpstreamBranchChangedError, match=r"Upstream branch .* has changed" + ): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) -def test_verify_upstream_unchanged_noop(tmp_path: Path): +def test_verify_upstream_unchanged_noop( + mock_gitproject: GitProject, mock_repo: RepoMock +): """Test that verify_upstream_unchanged does nothing in noop mode.""" - git_project = GitProject(directory=tmp_path) - - mock_repo = MagicMock() - - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: - mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) - mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) - - # Should not raise an exception and should not call git operations - git_project.verify_upstream_unchanged(noop=True) + # Should not raise an exception and should not call git operations + mock_gitproject.verify_upstream_unchanged(noop=True) # Verify Repo was not instantiated at all in noop mode - mock_repo_class.assert_not_called() + mock_repo.assert_not_called() def test_verify_upstream_unchanged_no_tracking_branch( - tmp_path: Path, mock_repo: MagicMock + mock_gitproject: GitProject, mock_repo: RepoMock ): """Test that verify_upstream_unchanged raises error when no tracking branch exists.""" - git_project = GitProject(directory=tmp_path) - # Mock no tracking branch mock_repo.active_branch.tracking_branch = MagicMock(return_value=None) - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: - mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) - mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) - - # Should raise UnknownUpstreamBranchError - with pytest.raises( - UnknownUpstreamBranchError, match="No upstream branch found" - ): - git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + # Should raise UnknownUpstreamBranchError + with pytest.raises(UnknownUpstreamBranchError, match="No upstream branch found"): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) -def test_verify_upstream_unchanged_detached_head(tmp_path: Path): +def test_verify_upstream_unchanged_detached_head( + mock_gitproject: GitProject, mock_repo: RepoMock +): """Test that verify_upstream_unchanged raises error in detached HEAD state.""" - git_project = GitProject(directory=tmp_path) - - mock_repo = MagicMock() # Simulate detached HEAD by having active_branch raise TypeError # This is what GitPython does when in a detached HEAD state type(mock_repo).active_branch = PropertyMock(side_effect=TypeError("detached HEAD")) - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: - mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) - mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) + # Should raise DetachedHeadGitError + with pytest.raises(DetachedHeadGitError, match="detached HEAD state"): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) - # Should raise DetachedHeadGitError - with pytest.raises(DetachedHeadGitError, match="detached HEAD state"): - git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) - -def test_verify_upstream_unchanged_fetch_fails(tmp_path: Path, mock_repo: MagicMock): +def test_verify_upstream_unchanged_fetch_fails( + mock_gitproject: GitProject, mock_repo: RepoMock +): """Test that verify_upstream_unchanged raises GitFetchError when fetch fails.""" - git_project = GitProject(directory=tmp_path) - # Mock fetch to raise an error mock_repo.remotes["origin"].fetch = MagicMock( side_effect=GitCommandError("fetch", "error") ) - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: - mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) - mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) - - with pytest.raises(GitFetchError, match="Failed to fetch from remote"): - git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + with pytest.raises(GitFetchError, match="Failed to fetch from remote"): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) def test_verify_upstream_unchanged_upstream_sha_fails( - tmp_path: Path, mock_repo: MagicMock + mock_gitproject: GitProject, mock_repo: RepoMock ): """Test that verify_upstream_unchanged raises error when upstream SHA cannot be determined.""" - git_project = GitProject(directory=tmp_path) - # Mock refs to raise AttributeError (simulating missing branch) mock_repo.remotes["origin"].refs = MagicMock() mock_repo.remotes["origin"].refs.__getitem__ = MagicMock( side_effect=AttributeError("No such ref") ) - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: - mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) - mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) - - with pytest.raises( - GitFetchError, match="Unable to determine upstream branch SHA" - ): - git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + with pytest.raises(GitFetchError, match="Unable to determine upstream branch SHA"): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) def test_verify_upstream_unchanged_local_ref_sha_fails( - tmp_path: Path, mock_repo: MagicMock + mock_gitproject: GitProject, mock_repo: RepoMock ): """Test that verify_upstream_unchanged raises error when local ref SHA cannot be determined.""" - git_project = GitProject(directory=tmp_path) - # Mock git operations - rev_parse fails mock_repo.git.rev_parse = MagicMock( side_effect=GitCommandError("rev-parse", "error") ) - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: - mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) - mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) - - with pytest.raises( - LocalGitError, - match="Unable to determine the SHA for local ref", - ): - git_project.verify_upstream_unchanged(local_ref="HEAD", noop=False) + with pytest.raises( + LocalGitError, + match="Unable to determine the SHA for local ref", + ): + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD", noop=False) def test_verify_upstream_unchanged_with_custom_ref( - tmp_path: Path, mock_repo: MagicMock + mock_gitproject: GitProject, mock_repo: RepoMock ): """Test that verify_upstream_unchanged works with a custom ref like HEAD~1.""" - git_project = GitProject(directory=tmp_path) - - # Mock Repo as a context manager - with patch("semantic_release.gitproject.Repo") as mock_repo_class: - mock_repo_class.return_value.__enter__ = MagicMock(return_value=mock_repo) - mock_repo_class.return_value.__exit__ = MagicMock(return_value=False) - - # Should not raise an exception - git_project.verify_upstream_unchanged(local_ref="HEAD~1", noop=False) + # Should not raise an exception + mock_gitproject.verify_upstream_unchanged(local_ref="HEAD~1", noop=False) # Verify rev_parse was called with custom ref mock_repo.git.rev_parse.assert_called_once_with("HEAD~1") + + +def test_is_shallow_clone_true(mock_gitproject: GitProject, tmp_path: Path) -> None: + """Test is_shallow_clone returns True when shallow file exists.""" + # Create a shallow file + shallow_file = tmp_path / ".git" / "shallow" + shallow_file.parent.mkdir(parents=True, exist_ok=True) + shallow_file.touch() + + assert mock_gitproject.is_shallow_clone() is True + + +def test_is_shallow_clone_false(mock_gitproject: GitProject, tmp_path: Path) -> None: + """Test is_shallow_clone returns False when shallow file does not exist.""" + # Ensure shallow file does not exist + shallow_file = tmp_path / ".git" / "shallow" + if shallow_file.exists(): + shallow_file.unlink() + + assert mock_gitproject.is_shallow_clone() is False + + +def test_git_unshallow_success( + mock_gitproject: GitProject, mock_repo: RepoMock +) -> None: + """Test git_unshallow successfully unshallows a repository.""" + mock_gitproject.git_unshallow(noop=False) + mock_repo.git.fetch.assert_called_once_with("--unshallow") + + +def test_git_unshallow_noop(mock_gitproject: GitProject, mock_repo: RepoMock) -> None: + """Test git_unshallow in noop mode does not execute the command.""" + mock_gitproject.git_unshallow(noop=True) + mock_repo.git.fetch.assert_not_called() + + +def test_git_unshallow_already_complete( + mock_gitproject: GitProject, mock_repo: RepoMock +) -> None: + """Test git_unshallow handles already-complete repository gracefully.""" + # Simulate error from git when repo is already complete + error_msg = "fatal: --unshallow on a complete repository does not make sense" + mock_repo.git.fetch.side_effect = GitCommandError( + "fetch", status=128, stderr=error_msg + ) + + # Should not raise an exception + mock_gitproject.git_unshallow(noop=False) + + +def test_git_unshallow_other_error( + mock_gitproject: GitProject, mock_repo: RepoMock +) -> None: + """Test git_unshallow raises exception for other errors.""" + # Simulate a different error + error_msg = "fatal: some other error" + mock_repo.git.fetch.side_effect = GitCommandError( + "fetch", status=128, stderr=error_msg + ) + + # Should raise the exception + with pytest.raises(GitCommandError): + mock_gitproject.git_unshallow(noop=False) From e7d7aa74a216cd2fdd78afc1e0e8b6b8044954ec Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 9 Nov 2025 13:32:38 -0800 Subject: [PATCH 33/35] fix(cmd-version): prevent regular expression errors on `tag_format` (#1367) * docs(configuration): fix `tag_format` definition * test(algorithm): update unit tests for `tag_format` to match likely usage --- docs/configuration/configuration.rst | 20 ++-- src/semantic_release/version/translator.py | 16 +++- .../version/test_algorithm.py | 93 +++++++++---------- 3 files changed, 63 insertions(+), 66 deletions(-) diff --git a/docs/configuration/configuration.rst b/docs/configuration/configuration.rst index cdd3604fb..685bfa8e4 100644 --- a/docs/configuration/configuration.rst +++ b/docs/configuration/configuration.rst @@ -1170,17 +1170,8 @@ from the :ref:`remote.name ` location of your git repository **Type:** ``str`` Specify the format to be used for the Git tag that will be added to the repo during -a release invoked via :ref:`cmd-version`. The format string is a regular expression, -which also must include the format keys below, otherwise an exception will be thrown. -It *may* include any of the optional format keys, in which case the contents -described will be formatted into the specified location in the Git tag that is created. - -For example, ``"(dev|stg|prod)-v{version}"`` is a valid ``tag_format`` matching tags such -as: - -- ``dev-v1.2.3`` -- ``stg-v0.1.0-rc.1`` -- ``prod-v2.0.0+20230701`` +a release invoked via :ref:`cmd-version`. The string is used as a template for the tag +name, and must include the ``{version}`` format key. This format will also be used for parsing tags already present in the repository into semantic versions; therefore if the tag format changes at some point in the @@ -1196,6 +1187,13 @@ Format Key Mandatory Contents Tags which do not match this format will not be considered as versions of your project. +This is critical for Monorepo projects where the tag format defines which package the +version tag belongs to. Generally, the tag format for each package of the monorepo will +include the package name as the prefix of the tag format. For example, if the package +is named ``pkg1``, the tag format would be ``pkg1-v{version}`` and in the other package +``pkg2``, the tag format would be ``pkg2-v{version}``. This allows PSR to determine +which tags to use to determine the version for each package. + **Default:** ``"v{version}"`` ---- diff --git a/src/semantic_release/version/translator.py b/src/semantic_release/version/translator.py index 6340701da..16885af10 100644 --- a/src/semantic_release/version/translator.py +++ b/src/semantic_release/version/translator.py @@ -1,12 +1,16 @@ from __future__ import annotations -import re +from re import VERBOSE, compile as regexp, escape as regex_escape +from typing import TYPE_CHECKING 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 +if TYPE_CHECKING: + from re import Pattern + class VersionTranslator: """ @@ -17,7 +21,7 @@ class VersionTranslator: _VERSION_REGEX = SEMVER_REGEX @classmethod - def _invert_tag_format_to_re(cls, tag_format: str) -> re.Pattern[str]: + def _invert_tag_format_to_re(cls, tag_format: str) -> Pattern[str]: r""" Unpick the "tag_format" format string and create a regex which can be used to convert a tag to a version string. @@ -31,9 +35,11 @@ def _invert_tag_format_to_re(cls, tag_format: str) -> re.Pattern[str]: >>> assert m is not None >>> assert m.expand(r"\g") == version """ - pat = re.compile( - tag_format.replace(r"{version}", r"(?P.*)"), - flags=re.VERBOSE, + pat = regexp( + regex_escape(tag_format).replace( + regex_escape(r"{version}"), r"(?P.+)" + ), + flags=VERBOSE, ) logger.debug("inverted tag_format %r to %r", tag_format, pat.pattern) return pat diff --git a/tests/unit/semantic_release/version/test_algorithm.py b/tests/unit/semantic_release/version/test_algorithm.py index a7fded2ef..f470e78ce 100644 --- a/tests/unit/semantic_release/version/test_algorithm.py +++ b/tests/unit/semantic_release/version/test_algorithm.py @@ -155,56 +155,49 @@ def test_sorted_repo_tags_and_versions(tags: list[str], sorted_tags: list[str]): @pytest.mark.parametrize( "tag_format, invalid_tags, valid_tags", [ - ( - "v{version}", - ("test-v1.1.0", "v1.1.0-test-test"), - [ - "v1.0.0-rc.1", - "v1.0.0-beta.2", - "v1.0.0-beta.11", - "v1.0.0-alpha.1", - "v1.0.0-alpha.beta.1", - "v1.0.0", - ], - ), - ( - "v{version}", - ("0.3", "0.4"), - [ - "v1.0.0-rc.1", - "v1.0.0-beta.2", - "v1.0.0-beta.11", - "v1.0.0-alpha.1", - "v1.0.0-alpha.beta.1", - "v1.0.0", - ], - ), - ( - r"(\w+--)?v{version}", - ("v1.1.0-test-test", "test_v1.1.0"), - [ - "v1.0.0-rc.1", - "test--v1.1.0", - "v1.0.0-beta.2", - "v1.0.0-beta.11", - "v1.0.0-alpha.1", - "v1.0.0-alpha.beta.1", - "v1.0.0", - ], - ), - ( - r"(?Pfeature|fix)/v{version}--(?Pdev|stg|prod)", - ("v1.1.0--test", "test_v1.1.0", "docs/v1.2.0--dev"), - [ - "feature/v1.0.0-rc.1--dev", - "fix/v1.1.0--stg", - "feature/v1.0.0-beta.2--stg", - "fix/v1.0.0-beta.11--dev", - "fix/v1.0.0-alpha.1--dev", - "feature/v1.0.0-alpha.beta.1--dev", - "feature/v1.0.0--prod", - ], - ), + pytest.param( + tag_format, + invalid_tags, + valid_tags, + id=test_id, + ) + for test_id, tag_format, invalid_tags, valid_tags in [ + ( + "traditional-v-prefixed-versions", + "v{version}", + ( + "0.3", # no v-prefix + "test-v1.1.0", # extra prefix + "v1.1.0-test-test", # bad suffix + ), + [ + "v1.0.0-rc.1", + "v1.0.0-beta.2", + "v1.0.0-beta.11", + "v1.0.0-alpha.1", + "v1.0.0-alpha.beta.1", + "v1.0.0", + ], + ), + ( + "monorepo-style-versions", + "pkg1-v{version}", + ( + "0.3", # no pkg or version prefix + "v1.1.0", # no pkg prefix + "pkg1-v1.1.0-test-test", # bad suffix + "pkg2-v1.1.0", # wrong package prefix + ), + [ + "pkg1-v1.0.0-rc.1", + "pkg1-v1.0.0-beta.2", + "pkg1-v1.0.0-beta.11", + "pkg1-v1.0.0-alpha.1", + "pkg1-v1.0.0-alpha.beta.1", + "pkg1-v1.0.0", + ], + ), + ] ], ) def test_tags_and_versions_ignores_invalid_tags_as_versions( From a28f9401c4b285aa1007b72eb051d42567f33f93 Mon Sep 17 00:00:00 2001 From: Matthieu Sarter Date: Sun, 9 Nov 2025 23:07:02 +0100 Subject: [PATCH 34/35] feat(cmd-version): add functionality to create & update partial version tags (#1115) * docs(configuration): add description for `add_partial_tags` setting & usage examples * test(cmd-version): add e2e test cases for managing partial version tag creation & updates --- docs/configuration/configuration.rst | 46 +++ src/semantic_release/cli/commands/version.py | 35 +- src/semantic_release/cli/config.py | 5 +- src/semantic_release/gitproject.py | 51 ++- src/semantic_release/version/translator.py | 12 + src/semantic_release/version/version.py | 9 + .../cmd_version/test_version_partial_tag.py | 346 ++++++++++++++++++ 7 files changed, 480 insertions(+), 24 deletions(-) create mode 100644 tests/e2e/cmd_version/test_version_partial_tag.py diff --git a/docs/configuration/configuration.rst b/docs/configuration/configuration.rst index 685bfa8e4..342239152 100644 --- a/docs/configuration/configuration.rst +++ b/docs/configuration/configuration.rst @@ -1162,6 +1162,52 @@ from the :ref:`remote.name ` location of your git repository ---- +.. _config-add_partial_tags: + +``add_partial_tags`` +"""""""""""""""""""" + +**Type:** ``bool`` + +Specify if partial version tags should be handled when creating a new version. If set to +``true``, a ``major`` and a ``major.minor`` tag will be created or updated, using the format +specified in :ref:`tag_format`. If version has build metadata, a ``major.minor.patch`` tag +will also be created or updated. + +Partial version tags are **disabled** for pre-release versions. + +**Example** + +.. code-block:: toml + + [semantic_release] + tag_format = "v{version}" + add_partial_tags = true + +This configuration with the next version of ``1.2.3`` will result in: + +.. code-block:: bash + + git log --decorate --oneline --graph --all + # * 4d4cb0a (tag: v1.2.3, tag: v1.2, tag: v1, origin/main, main) 1.2.3 + # * 3a2b1c0 fix: some bug + # * 2b1c0a9 (tag: v1.2.2) 1.2.2 + # ... + +If build-metadata is used, the next version of ``1.2.3+20251109`` will result in: + +.. code-block:: bash + + git log --decorate --oneline --graph --all + # * 4d4cb0a (tag: v1.2.3+20251109, tag: v1.2.3, tag: v1.2, tag: v1, origin/main, main) 1.2.3+20251109 + # * 3a2b1c0 chore: add partial tags to PSR configuration + # * 2b1c0a9 (tag: v1.2.3+20251031) 1.2.3+20251031 + # ... + +**Default:** ``false`` + +---- + .. _config-tag_format: ``tag_format`` diff --git a/src/semantic_release/cli/commands/version.py b/src/semantic_release/cli/commands/version.py index ad60b95aa..ac7a8e374 100644 --- a/src/semantic_release/cli/commands/version.py +++ b/src/semantic_release/cli/commands/version.py @@ -80,10 +80,13 @@ def is_forced_prerelease( ) -def last_released(repo_dir: Path, tag_format: str) -> tuple[Tag, Version] | None: +def last_released( + repo_dir: Path, tag_format: str, add_partial_tags: bool = False +) -> tuple[Tag, Version] | None: with Repo(str(repo_dir)) as git_repo: ts_and_vs = tags_and_versions( - git_repo.tags, VersionTranslator(tag_format=tag_format) + git_repo.tags, + VersionTranslator(tag_format=tag_format, add_partial_tags=add_partial_tags), ) return ts_and_vs[0] if ts_and_vs else None @@ -454,7 +457,11 @@ def version( # noqa: C901 if print_last_released or print_last_released_tag: # TODO: get tag format a better way if not ( - last_release := last_released(config.repo_dir, tag_format=config.tag_format) + last_release := last_released( + config.repo_dir, + tag_format=config.tag_format, + add_partial_tags=config.add_partial_tags, + ) ): logger.warning("No release tags found.") return @@ -475,6 +482,7 @@ def version( # noqa: C901 major_on_zero = runtime.major_on_zero no_verify = runtime.no_git_verify opts = runtime.global_cli_options + add_partial_tags = config.add_partial_tags gha_output = VersionGitHubActionsOutput( gh_client=hvcs_client if isinstance(hvcs_client, Github) else None, mode=( @@ -777,6 +785,27 @@ def version( # noqa: C901 tag=new_version.as_tag(), noop=opts.noop, ) + # Create or update partial tags for releases + if add_partial_tags and not prerelease: + partial_tags = [new_version.as_major_tag(), new_version.as_minor_tag()] + # If build metadata is set, also retag the version without the metadata + if build_metadata: + partial_tags.append(new_version.as_patch_tag()) + + for partial_tag in partial_tags: + project.git_tag( + tag_name=partial_tag, + message=f"{partial_tag} => {new_version.as_tag()}", + isotimestamp=commit_date.isoformat(), + noop=opts.noop, + force=True, + ) + project.git_push_tag( + remote_url=remote_url, + tag=partial_tag, + noop=opts.noop, + force=True, + ) # Update GitHub Actions output value now that release has occurred gha_output.released = True diff --git a/src/semantic_release/cli/config.py b/src/semantic_release/cli/config.py index 37b86a811..514d76ef1 100644 --- a/src/semantic_release/cli/config.py +++ b/src/semantic_release/cli/config.py @@ -366,6 +366,7 @@ class RawConfig(BaseModel): remote: RemoteConfig = RemoteConfig() no_git_verify: bool = False tag_format: str = "v{version}" + add_partial_tags: bool = False publish: PublishConfig = PublishConfig() version_toml: Optional[Tuple[str, ...]] = None version_variables: Optional[Tuple[str, ...]] = None @@ -827,7 +828,9 @@ def from_raw_config( # noqa: C901 # version_translator version_translator = VersionTranslator( - tag_format=raw.tag_format, prerelease_token=branch_config.prerelease_token + tag_format=raw.tag_format, + prerelease_token=branch_config.prerelease_token, + add_partial_tags=raw.add_partial_tags, ) build_cmd_env = {} diff --git a/src/semantic_release/gitproject.py b/src/semantic_release/gitproject.py index a29bb41de..9ea156da7 100644 --- a/src/semantic_release/gitproject.py +++ b/src/semantic_release/gitproject.py @@ -238,7 +238,12 @@ def git_commit( raise GitCommitError("Failed to commit changes") from err def git_tag( - self, tag_name: str, message: str, isotimestamp: str, noop: bool = False + self, + tag_name: str, + message: str, + isotimestamp: str, + force: bool = False, + noop: bool = False, ) -> None: try: datetime.fromisoformat(isotimestamp) @@ -248,21 +253,25 @@ def git_tag( if noop: command = str.join( " ", - [ - f"GIT_COMMITTER_DATE={isotimestamp}", - *( - [ - f"GIT_AUTHOR_NAME={self._commit_author.name}", - f"GIT_AUTHOR_EMAIL={self._commit_author.email}", - f"GIT_COMMITTER_NAME={self._commit_author.name}", - f"GIT_COMMITTER_EMAIL={self._commit_author.email}", - ] - if self._commit_author - else [""] - ), - f"git tag -a {tag_name} -m '{message}'", - ], - ) + filter( + None, + [ + f"GIT_COMMITTER_DATE={isotimestamp}", + *( + [ + f"GIT_AUTHOR_NAME={self._commit_author.name}", + f"GIT_AUTHOR_EMAIL={self._commit_author.email}", + f"GIT_COMMITTER_NAME={self._commit_author.name}", + f"GIT_COMMITTER_EMAIL={self._commit_author.email}", + ] + if self._commit_author + else [""] + ), + f"git tag -a {tag_name} -m '{message}'", + "--force" if force else "", + ], + ), + ).strip() noop_report( indented( @@ -279,7 +288,7 @@ def git_tag( {"GIT_COMMITTER_DATE": isotimestamp}, ): try: - repo.git.tag("-a", tag_name, m=message) + repo.git.tag(tag_name, a=True, m=message, force=force) except GitCommandError as err: self.logger.exception(str(err)) raise GitTagError(f"Failed to create tag ({tag_name})") from err @@ -305,13 +314,15 @@ def git_push_branch(self, remote_url: str, branch: str, noop: bool = False) -> N f"Failed to push branch ({branch}) to remote" ) from err - def git_push_tag(self, remote_url: str, tag: str, noop: bool = False) -> None: + def git_push_tag( + self, remote_url: str, tag: str, noop: bool = False, force: bool = False + ) -> None: if noop: noop_report( indented( f"""\ would have run: - git push {self._cred_masker.mask(remote_url)} tag {tag} + git push {self._cred_masker.mask(remote_url)} tag {tag} {"--force" if force else ""} """ # noqa: E501 ) ) @@ -319,7 +330,7 @@ def git_push_tag(self, remote_url: str, tag: str, noop: bool = False) -> None: with Repo(str(self.project_root)) as repo: try: - repo.git.push(remote_url, "tag", tag) + repo.git.push(remote_url, "tag", tag, force=force) except GitCommandError as err: self.logger.exception(str(err)) raise GitPushError(f"Failed to push tag ({tag}) to remote") from err diff --git a/src/semantic_release/version/translator.py b/src/semantic_release/version/translator.py index 16885af10..5026fd089 100644 --- a/src/semantic_release/version/translator.py +++ b/src/semantic_release/version/translator.py @@ -48,11 +48,19 @@ def __init__( self, tag_format: str = "v{version}", prerelease_token: str = "rc", # noqa: S107 + add_partial_tags: bool = False, ) -> None: check_tag_format(tag_format) self.tag_format = tag_format self.prerelease_token = prerelease_token + self.add_partial_tags = add_partial_tags self.from_tag_re = self._invert_tag_format_to_re(self.tag_format) + self.partial_tag_re = regexp( + regex_escape(tag_format).replace( + regex_escape(r"{version}"), r"[0-9]+(\.(0|[1-9][0-9]*))?$" + ), + flags=VERBOSE, + ) def from_string(self, version_str: str) -> Version: """ @@ -75,6 +83,10 @@ def from_tag(self, tag: str) -> Version | None: tag_match = self.from_tag_re.match(tag) if not tag_match: return None + if self.add_partial_tags: + partial_tag_match = self.partial_tag_re.match(tag) + if partial_tag_match: + return None raw_version_str = tag_match.group("version") return self.from_string(raw_version_str) diff --git a/src/semantic_release/version/version.py b/src/semantic_release/version/version.py index 032596e4a..3e97be9fe 100644 --- a/src/semantic_release/version/version.py +++ b/src/semantic_release/version/version.py @@ -203,6 +203,15 @@ def __repr__(self) -> str: def as_tag(self) -> str: return self.tag_format.format(version=str(self)) + def as_major_tag(self) -> str: + return self.tag_format.format(version=f"{self.major}") + + def as_minor_tag(self) -> str: + return self.tag_format.format(version=f"{self.major}.{self.minor}") + + def as_patch_tag(self) -> str: + return self.tag_format.format(version=f"{self.major}.{self.minor}.{self.patch}") + def as_semver_tag(self) -> str: return f"v{self!s}" diff --git a/tests/e2e/cmd_version/test_version_partial_tag.py b/tests/e2e/cmd_version/test_version_partial_tag.py new file mode 100644 index 000000000..b6d2024d4 --- /dev/null +++ b/tests/e2e/cmd_version/test_version_partial_tag.py @@ -0,0 +1,346 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import pytest +import tomlkit +from pytest_lazy_fixtures.lazy_fixture import lf as lazy_fixture + +from tests.const import EXAMPLE_PROJECT_NAME, MAIN_PROG_NAME, VERSION_SUBCMD +from tests.fixtures import ( + repo_w_no_tags_conventional_commits, +) +from tests.util import ( + assert_successful_exit_code, + dynamic_python_import, +) + +if TYPE_CHECKING: + from typing import List + from unittest.mock import MagicMock + + from requests_mock import Mocker + from typing_extensions import TypeAlias + + from tests.conftest import RunCliFn + from tests.fixtures.example_project import ( + ExProjectDir, + GetExpectedVersionPyFileContentFn, + UpdatePyprojectTomlFn, + ) + from tests.fixtures.git_repo import BuiltRepoResult + + CaseId: TypeAlias = str + CliArgs: TypeAlias = List[str] + NextReleaseVersion: TypeAlias = str + ExistingTags: TypeAlias = List[str] + ExpectedNewPartialTags: TypeAlias = List[str] + ExpectedMovedPartialTags: TypeAlias = List[str] + + +cases: tuple[ + tuple[ + CaseId, + CliArgs, + NextReleaseVersion, + ExistingTags, + ExpectedNewPartialTags, + ExpectedMovedPartialTags, + ], + ..., +] = ( + # pre-release should not affect partial tags + ( + "pre-release", + ["--prerelease"], + "0.0.0-rc.1", + ["v0", "v0.0"], + [], + [], + ), + # Create partial tags when they don't exist + ( + "create-partial-tags-when-they-dont-exist__build-metadata", + ["--minor", "--build-metadata", "build.12345"], + "0.1.0+build.12345", + [], + ["v0", "v0.1", "v0.1.0"], + [], + ), + ( + "create-partial-tags-when-they-dont-exist__patch", + ["--patch"], + "0.0.1", + [], + ["v0", "v0.0"], + [], + ), + ( + "create-partial-tags-when-they-dont-exist__minor", + ["--minor"], + "0.1.0", + [], + ["v0", "v0.1"], + [], + ), + ( + "create-partial-tags-when-they-dont-exist__major", + ["--major"], + "1.0.0", + [], + ["v1", "v1.0"], + [], + ), + # Update existing partial tags + ( + "update-existing-partial-tags__build-metadata", + ["--patch", "--build-metadata", "build.12345"], + "0.1.1+build.12345", + ["v0", "v0.0", "v0.1", "v0.1.0"], + ["v0.1.1"], + ["v0", "v0.1"], + ), + ( + "update-existing-partial-tags__patch", + ["--patch"], + "0.0.1", + ["v0", "v0.0"], + [], + ["v0", "v0.0"], + ), + ( + "update-existing-partial-tags__minor", + ["--minor"], + "0.1.0", + ["v0", "v0.0", "v0.1"], + [], + ["v0", "v0.1"], + ), + ( + "update-existing-partial-tags__major", + ["--major"], + "1.0.0", + ["v0", "v0.0", "v0.1", "v1", "v1.0"], + [], + ["v1", "v1.0"], + ), + # Update existing partial tags and create new one + ( + "update-existing-partial-tags-and-create-new-one", + ["--minor"], + "0.1.0", + ["v0", "v0.0"], + ["v0.1"], + ["v0"], + ), + # Partial tag disabled for older version, now enabled + ( + "partial-tag-disabled-for-older-version__build-metadata", + ["--patch", "--build-metadata", "build.12345"], + "1.1.2+build.12345", + ["v0.1.0", "v0.1.1", "v1.0.0", "v1.1.0", "v1.1.1+build.1234"], + ["v1", "v1.1", "v1.1.2"], + [], + ), + ( + "partial-tag-disabled-for-older-version__patch", + ["--patch"], + "1.1.2", + ["v0.1.0", "v0.1.1", "v1.0.0", "v1.1.0", "v1.1.1"], + ["v1", "v1.1"], + [], + ), + ( + "partial-tag-disabled-for-older-version__minor", + ["--minor"], + "1.2.0", + ["v0.1.0", "v0.1.1", "v1.0.0", "v1.1.0", "v1.1.1"], + ["v1", "v1.2"], + [], + ), + ( + "partial-tag-enabled-for-newer-version__major", + ["--major"], + "2.0.0", + ["v0.1.0", "v0.1.1", "v1.0.0", "v1.1.0", "v1.1.1"], + ["v2", "v2.0"], + [], + ), +) + + +@pytest.mark.parametrize( + "repo_result, add_partial_tags, cli_args, next_release_version, existing_partial_tags, expected_new_partial_tags, expected_moved_partial_tags", + [ + *( + pytest.param( + lazy_fixture(repo_w_no_tags_conventional_commits.__name__), + True, + cli_args, + next_release_version, + existing_tags, + expected_new_partial_tags, + expected_moved_partial_tags, + id=f"{case_id}__partial-tags-enabled", + ) + for case_id, cli_args, next_release_version, existing_tags, expected_new_partial_tags, expected_moved_partial_tags in cases + ), + *( + pytest.param( + lazy_fixture(repo_w_no_tags_conventional_commits.__name__), + False, + cli_args, + next_release_version, + existing_tags, + expected_new_partial_tags, + expected_moved_partial_tags, + id=f"{case_id}__partial-tags-disabled", + ) + for case_id, cli_args, next_release_version, existing_tags, expected_new_partial_tags, expected_moved_partial_tags in cases + ), + ], +) +def test_version_partial_tag_creation( + repo_result: BuiltRepoResult, + add_partial_tags: bool, + cli_args: list[str], + next_release_version: str, + example_project_dir: ExProjectDir, + example_pyproject_toml: Path, + existing_partial_tags: list[str], + expected_new_partial_tags: list[str], + expected_moved_partial_tags: list[str], + run_cli: RunCliFn, + mocked_git_fetch: MagicMock, + mocked_git_push: MagicMock, + post_mocker: Mocker, + update_pyproject_toml: UpdatePyprojectTomlFn, + pyproject_toml_file: Path, + changelog_md_file: Path, + version_py_file: Path, + get_expected_version_py_file_content: GetExpectedVersionPyFileContentFn, +): + # Force clean directory state before test (needed for the repo_w_no_tags) + repo = repo_result["repo"] + repo.git.reset("HEAD", hard=True) + + # Enable partial tags + update_pyproject_toml("tool.semantic_release.add_partial_tags", add_partial_tags) + + expected_changed_files = sorted( + [ + str(changelog_md_file), + str(pyproject_toml_file), + str(version_py_file), + ] + ) + expected_new_partial_tags = expected_new_partial_tags if add_partial_tags else [] + expected_moved_partial_tags = ( + expected_moved_partial_tags if add_partial_tags else [] + ) + + expected_version_py_content = get_expected_version_py_file_content( + next_release_version + ) + + # Setup: create existing tags + for tag in existing_partial_tags: + repo.create_tag(tag) + + # Setup: take measurement before running the version command + head_sha_before = repo.head.commit.hexsha + tags_before = {tag.name: repo.commit(tag) for tag in repo.tags} + + pyproject_toml_before = tomlkit.loads( + example_pyproject_toml.read_text(encoding="utf-8") + ) + + # Modify the pyproject.toml to remove the version so we can compare it later + pyproject_toml_before.get("tool", {}).get("poetry", {}).pop("version", None) + + # Define expectations before execution (hypothesis) + expected_git_fetch_calls = 1 + expected_vcs_release_calls = 1 + # 1 for commit, 1 for tag, 1 for each moved or created partial tag + expected_git_push_calls = ( + 2 + len(expected_new_partial_tags) + len(expected_moved_partial_tags) + ) + + # Act + cli_cmd = [MAIN_PROG_NAME, VERSION_SUBCMD, *cli_args] + result = run_cli(cli_cmd[1:]) + + # take measurement after running the version command + head_after = repo.head.commit + tags_after = {tag.name: repo.commit(tag.name) for tag in repo.tags} + new_tags = {tag: sha for tag, sha in tags_after.items() if tag not in tags_before} + moved_tags = { + tag: sha + for tag, sha in tags_after.items() + if tag in tags_before and sha != tags_before[tag] + } + differing_files = sorted( + [ + # Make sure filepath uses os specific path separators + str(Path(file)) + for file in str( + repo.git.diff("HEAD", "HEAD~1", name_only=True) + ).splitlines() + ] + ) + pyproject_toml_after = tomlkit.loads( + example_pyproject_toml.read_text(encoding="utf-8") + ) + pyproj_version_after = ( + pyproject_toml_after.get("tool", {}).get("poetry", {}).pop("version") + ) + + # Load python module for reading the version (ensures the file is valid) + actual_version_py_content = (example_project_dir / version_py_file).read_text() + + # Evaluate (normal release actions should have occurred when forced patch bump) + assert_successful_exit_code(result, cli_cmd) + + # A commit has been made + assert [head_sha_before] == [head.hexsha for head in head_after.parents] + + # A version tag and the expected partial tag have been created + assert 1 + len(expected_new_partial_tags) == len(new_tags) + assert len(expected_moved_partial_tags) == len(moved_tags) + assert f"v{next_release_version}" in new_tags + + # Check that all new tags and moved tags are present and on the head commit + for partial_tag in expected_new_partial_tags: + assert partial_tag in new_tags + assert repo.commit(partial_tag).hexsha == head_after.hexsha + + for partial_tag in expected_moved_partial_tags: + assert partial_tag in moved_tags + assert repo.commit(partial_tag).hexsha == head_after.hexsha + + # Expected external calls + assert ( + expected_git_fetch_calls == mocked_git_fetch.call_count + ) # fetch occurred before push + assert expected_git_push_calls == mocked_git_push.call_count + assert ( + expected_vcs_release_calls == post_mocker.call_count + ) # vcs release creation occurred + + # Changelog already reflects changes this should introduce + assert expected_changed_files == differing_files + + # Compare pyproject.toml + assert pyproject_toml_before == pyproject_toml_after + assert next_release_version == pyproj_version_after + + # Compare _version.py + assert expected_version_py_content == actual_version_py_content + + # Verify content is parsable & importable + dynamic_version = dynamic_python_import( + example_project_dir / version_py_file, f"{EXAMPLE_PROJECT_NAME}._version" + ).__version__ + + assert next_release_version == dynamic_version From eb841f9a95650921ff7e7fc94208b6560366a854 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Sun, 9 Nov 2025 22:27:18 +0000 Subject: [PATCH 35/35] chore: release v10.5.0 Automatically generated by python-semantic-release --- CHANGELOG.rst | 87 +++++++++++++++++++ .../automatic-releases/github-actions.rst | 18 ++-- docs/configuration/configuration.rst | 2 +- pyproject.toml | 2 +- src/gh_action/requirements.txt | 2 +- 5 files changed, 99 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 458f1a5ee..f175879c6 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,93 @@ CHANGELOG ========= +.. _changelog-v10.5.0: + +v10.5.0 (2025-11-09) +==================== + +✨ Features +----------- + +* **cmd-version**: Add automatic repository un-shallowing to version workflow (`PR#1366`_, + `90a1ffa`_) + +* **cmd-version**: Add functionality to create & update partial version tags (`PR#1115`_, + `a28f940`_) + +* **cmd-version**: Adds c-macro style version definition support to ``version_variables``, closes + `#1348`_ (`PR#1349`_, `4ce1fca`_) + +* **cmd-version**: Adds upstream check into workflow to prevent commit push collisions (`PR#1360`_, + `d77193e`_) + +🪲 Bug Fixes +------------ + +* **cmd-version**: Prevent regular expression errors on ``tag_format`` (`PR#1367`_, `e7d7aa7`_) + +📖 Documentation +---------------- + +* **commands**: Add description of automated upstream version checking upon version creation + (`PR#1360`_, `d77193e`_) + +* **configuration**: Add description for ``add_partial_tags`` setting & usage examples (`PR#1115`_, + `a28f940`_) + +* **configuration**: Fix ``tag_format`` definition (`PR#1367`_, `e7d7aa7`_) + +* **configuration**: Update ``version_variables`` examples with a c-macro style replacement + (`PR#1349`_, `4ce1fca`_) + +* **github-actions**: Adds release job outputs definition to example (`PR#1344`_, `0fb4875`_) + +* **github-actions**: Removed verify upstream status step from example workflow (`PR#1360`_, + `d77193e`_) + +* **github-actions**: Update example to remove need to specify repo checkout's fetch depth + (`PR#1366`_, `90a1ffa`_) + +* **uv-integration**: Remove verify upstream check from uv integration example (`PR#1360`_, + `d77193e`_) + +* **uv-integration**: Update example to remove need to specify repo checkout's fetch depth + (`PR#1366`_, `90a1ffa`_) + +⚙️ Build System +---------------- + +* **deps**: Bump ``tomlkit`` dependency from ~=0.11.0 to ~=0.13.0 (`PR#1355`_, `55c94ec`_) + +* **deps**: Change github-actions container image to ``python:3.14-slim-trixie`` (`PR#1346`_, + `1a23712`_) + +💡 Additional Release Information +--------------------------------- + +* **cmd-version**: If you were previously handling the unshallowing of a repository clone in your + CI/CD pipelines, you may now remove that step from your workflow. PSR will now detect a shallow + repository and unshallow it before evaluating the commit history. + +.. _#1348: https://github.com/python-semantic-release/python-semantic-release/issues/1348 +.. _0fb4875: https://github.com/python-semantic-release/python-semantic-release/commit/0fb4875fa24ed283ed2d97ff6ab1879669a787ca +.. _1a23712: https://github.com/python-semantic-release/python-semantic-release/commit/1a237125badcb597ae7a92db4e01c2ff3293bce8 +.. _4ce1fca: https://github.com/python-semantic-release/python-semantic-release/commit/4ce1fcac60ac73657a4aaaaa3cb7c4afc7eac2c1 +.. _55c94ec: https://github.com/python-semantic-release/python-semantic-release/commit/55c94ecde1aec47b88aa172d031ab33afa7f795d +.. _90a1ffa: https://github.com/python-semantic-release/python-semantic-release/commit/90a1ffa55c5a1605c59cb26a1797f9a37fdfa784 +.. _a28f940: https://github.com/python-semantic-release/python-semantic-release/commit/a28f9401c4b285aa1007b72eb051d42567f33f93 +.. _d77193e: https://github.com/python-semantic-release/python-semantic-release/commit/d77193e30807968ba6a26bd356a868db62dc1098 +.. _e7d7aa7: https://github.com/python-semantic-release/python-semantic-release/commit/e7d7aa74a216cd2fdd78afc1e0e8b6b8044954ec +.. _PR#1115: https://github.com/python-semantic-release/python-semantic-release/pull/1115 +.. _PR#1344: https://github.com/python-semantic-release/python-semantic-release/pull/1344 +.. _PR#1346: https://github.com/python-semantic-release/python-semantic-release/pull/1346 +.. _PR#1349: https://github.com/python-semantic-release/python-semantic-release/pull/1349 +.. _PR#1355: https://github.com/python-semantic-release/python-semantic-release/pull/1355 +.. _PR#1360: https://github.com/python-semantic-release/python-semantic-release/pull/1360 +.. _PR#1366: https://github.com/python-semantic-release/python-semantic-release/pull/1366 +.. _PR#1367: https://github.com/python-semantic-release/python-semantic-release/pull/1367 + + .. _changelog-v10.4.1: v10.4.1 (2025-09-13) diff --git a/docs/configuration/automatic-releases/github-actions.rst b/docs/configuration/automatic-releases/github-actions.rst index cf8610c10..ed9c2d3e0 100644 --- a/docs/configuration/automatic-releases/github-actions.rst +++ b/docs/configuration/automatic-releases/github-actions.rst @@ -893,14 +893,14 @@ 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@v10.4.1 + uses: python-semantic-release/python-semantic-release@v10.5.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} git_committer_name: "github-actions" git_committer_email: "actions@users.noreply.github.com" - name: Publish | Upload to GitHub Release Assets - uses: python-semantic-release/publish-action@v10.4.1 + uses: python-semantic-release/publish-action@v10.5.0 if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} @@ -969,7 +969,7 @@ to the GitHub Release Assets as well. the ``token`` input) in order to gain push access. .. note:: - As of $NEW_RELEASE_TAG, Python Semantic Release automatically detects and converts + As of v10.5.0, Python Semantic Release automatically detects and converts shallow clones to full clones when needed. While you can still use ``fetch-depth: 0`` with ``actions/checkout@v4`` to fetch the full history upfront, it is no longer required. If you use the default shallow clone, Python Semantic Release will @@ -977,7 +977,7 @@ to the GitHub Release Assets as well. an older version of PSR, you will need to unshallow the repository prior to use. .. note:: - As of $NEW_RELEASE_TAG, the verify upstream step is no longer required as it has been + As of v10.5.0, the verify upstream step is no longer required as it has been integrated into PSR directly. If you are using an older version of PSR, you will need to review the older documentation for that step. See Issue `#1201`_ for more details. @@ -1005,7 +1005,7 @@ 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@v10.4.1 + uses: python-semantic-release/python-semantic-release@v10.5.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} force: patch @@ -1064,14 +1064,14 @@ Publish Action. - name: Release submodule 1 id: release-submod-1 - uses: python-semantic-release/python-semantic-release@v10.4.1 + uses: python-semantic-release/python-semantic-release@v10.5.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.4.1 + uses: python-semantic-release/python-semantic-release@v10.5.0 with: directory: ${{ env.SUBMODULE_2_DIR }} github_token: ${{ secrets.GITHUB_TOKEN }} @@ -1083,7 +1083,7 @@ Publish Action. # ------------------------------------------------------------------- # - name: Publish | Upload package 1 to GitHub Release Assets - uses: python-semantic-release/publish-action@v10.4.1 + uses: python-semantic-release/publish-action@v10.5.0 if: steps.release-submod-1.outputs.released == 'true' with: directory: ${{ env.SUBMODULE_1_DIR }} @@ -1091,7 +1091,7 @@ Publish Action. tag: ${{ steps.release-submod-1.outputs.tag }} - name: Publish | Upload package 2 to GitHub Release Assets - uses: python-semantic-release/publish-action@v10.4.1 + uses: python-semantic-release/publish-action@v10.5.0 if: steps.release-submod-2.outputs.released == 'true' with: directory: ${{ env.SUBMODULE_2_DIR }} diff --git a/docs/configuration/configuration.rst b/docs/configuration/configuration.rst index 342239152..690a3e4c3 100644 --- a/docs/configuration/configuration.rst +++ b/docs/configuration/configuration.rst @@ -1398,7 +1398,7 @@ 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. As of v10.0.0, a double-equals (``==``) operator is also supported - as a valid operand symbol. As of $NEW_RELEASE_TAG, PSR can omit all operands as long + as a valid operand symbol. As of v10.5.0, PSR can omit all operands as long as there is at least one whitespace character between the variable name and the version. 3. The value of the variable must match a `SemVer`_ regular expression and can be diff --git a/pyproject.toml b/pyproject.toml index 259975c08..be6f4ac91 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-semantic-release" -version = "10.4.1" +version = "10.5.0" description = "Automatic Semantic Versioning for Python projects" requires-python = "~= 3.8" license = { text = "MIT" } diff --git a/src/gh_action/requirements.txt b/src/gh_action/requirements.txt index c624d98e8..65e627774 100644 --- a/src/gh_action/requirements.txt +++ b/src/gh_action/requirements.txt @@ -1 +1 @@ -python-semantic-release == 10.4.1 +python-semantic-release == 10.5.0