From 7df3d7329ff782211cc5725e83309249e99606b1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 28 Aug 2026 05:34:51 +0000 Subject: [PATCH 1/5] test(util): add additional clarity for e2e test failure --- tests/util.py | 36 ++++++++++++++++++++++++++++++++++-- 1 file changed, 34 insertions(+), 2 deletions(-) diff --git a/tests/util.py b/tests/util.py index ef3f5c001..7d0e16690 100644 --- a/tests/util.py +++ b/tests/util.py @@ -8,8 +8,9 @@ import string from contextlib import contextmanager, suppress from pathlib import Path -from re import compile as regexp +from re import compile as regexp, sub as regexp_sub from textwrap import indent +from traceback import format_exception from typing import TYPE_CHECKING, Tuple from git import Git, Repo @@ -51,6 +52,21 @@ GitCommandWrapperType: TypeAlias = Git +_ANSI_ESCAPE_RE = regexp(r"\x1b\[[0-9;]*[A-Za-z]") +_CONTROL_CHARS_RE = regexp(r"[\x00-\x08\x0b\x0c\x0e-\x1f]") + + +def sanitize_output(text: str) -> str: + r""" + Strip ANSI escape sequences and non-printable control characters from text. + + Preserves tab (``\t``), newline (``\n``), and carriage return (``\r``). + This prevents control characters from corrupting assertion messages and JUnit + XML reports. + """ + return regexp_sub(_CONTROL_CHARS_RE, "", regexp_sub(_ANSI_ESCAPE_RE, "", text)) + + def get_func_qual_name(func: Callable[[Any], Any]) -> str: return str.join(".", filter(None, [func.__module__, func.__qualname__])) @@ -61,15 +77,31 @@ def assert_exit_code( if result.exit_code == exit_code: return True + stdout = sanitize_output(result.output or "") + stderr = sanitize_output(getattr(result, "stderr", "") or "") + exc_info = result.exc_info + exc_lines = ( + format_exception(exc_info[0], exc_info[1], exc_info[2]) if exc_info else [] + ) + exc_text = sanitize_output(str.join("", exc_lines)) + raise AssertionError( str.join( os.linesep, [ f"{result.exit_code} != {exit_code} (actual != expected)", "", - # Explain what command failed "Unexpected exit code from command:", indent(f"'{str.join(' ', cli_cmd)}'", " " * 2), + "", + "Captured stdout:", + indent(stdout or "(empty)", " " * 2), + "", + "Captured stderr:", + indent(stderr or "(empty)", " " * 2), + "", + "Exception:", + indent(exc_text or "(none)", " " * 2), ], ) ) From 9a56ace313a1f3710cc0e0683285cde133704f01 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Fri, 28 Aug 2026 02:18:37 -0600 Subject: [PATCH 2/5] fix(cmd-changelog): fix reading of TOML when file was created on Windows --- src/semantic_release/cli/commands/changelog.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/semantic_release/cli/commands/changelog.py b/src/semantic_release/cli/commands/changelog.py index 523c1d2a5..c895eb8e5 100644 --- a/src/semantic_release/cli/commands/changelog.py +++ b/src/semantic_release/cli/commands/changelog.py @@ -36,7 +36,12 @@ def get_license_name_for_release(tag_name: str, project_root: Path) -> str: toml_contents = git_repo.git.show( f"{tag_name}:{proj_toml.relative_to(project_root)}" ) - config_toml = tomlkit.parse(toml_contents) + # Normalize line endings: `git show` returns the blob verbatim, which + # may contain CRLF (or bare CR) when committed from Windows. + # tomlkit >= 0.15 rejects bare carriage returns as invalid characters. + config_toml = tomlkit.parse( + toml_contents.replace("\r\n", "\n").replace("\r", "") + ) project_metadata = config_toml.unwrap().get("project", project_metadata) break From 414e8fb4ec464ab986162ddfda53e5aaf53d30c8 Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Thu, 27 Aug 2026 23:52:42 -0600 Subject: [PATCH 3/5] ci(validate): improve Windows e2e test reliability With the upgrade to Windows Server 2025 as the base for the Windows GitHub Runner, this introduced a bunch of slowdowns to run the tests and caused them to fail. This configuration change ensures that Defender does not cause files to hang open which ultimately crash our test cases --- .github/workflows/validate.yml | 28 +++++++++++++++++++++++++--- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index ea3e66cbd..a024e483b 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -341,6 +341,28 @@ jobs: python -c 'import pathlib, semantic_release; print(f"PKG_INSTALLED_DIR={pathlib.Path(semantic_release.__file__).resolve().parent}")' | Tee-Object -Variable cmdOutput echo $cmdOutput >> $env:GITHUB_OUTPUT + - name: Setup | Harden Windows runner for git-heavy tests + if: runner.os == 'Windows' + shell: pwsh + run: | + # 1. Exclude workspace & temp dirs from Defender real-time scanning + Add-MpPreference -ExclusionPath "${{ github.workspace }}" + Add-MpPreference -ExclusionPath "$env:TEMP" + Add-MpPreference -ExclusionProcess "git.exe" + Add-MpPreference -ExclusionProcess "python.exe" + + # 2. Move temp (pytest tmp_path factory) to the faster D: drive + New-Item -ItemType Directory -Force -Path "D:\tmp" | Out-Null + "TEMP=D:\tmp" >> $env:GITHUB_ENV + "TMP=D:\tmp" >> $env:GITHUB_ENV + + # 3. Disable git background features that race with rapid repo churn + git config --global core.fsmonitor false + git config --global core.untrackedCache false + git config --global gc.auto 0 + git config --global maintenance.auto false + git config --global core.longpaths true + - name: Test | Run pytest -m e2e id: tests shell: pwsh @@ -349,13 +371,13 @@ jobs: # Required for GitPython to work on Windows because of getpass.getuser() # USERNAME: "runneradmin" # COLUMNS: 150 - # Because GHA is currently broken on Windows to pass these varables, we do it manually + # Because GHA is currently broken on Windows to pass these variables, we do it manually run: | $env:USERNAME = "runneradmin" $env:COLUMNS = 150 pytest ` -vv ` - -nauto ` + -n 2 ` -m e2e ` `--cov=${{ steps.install.outputs.PKG_INSTALLED_DIR }} ` `--cov-context=test ` @@ -377,7 +399,7 @@ jobs: if: ${{ failure() && steps.tests.outcome == 'failure' }} with: name: ${{ format('tested-repos-{0}-{1}', matrix.os, matrix.python-version) }} - path: ~/AppData/Local/Temp/pytest-of-runneradmin/pytest-current/* + path: D:\tmp\pytest-of-runneradmin\pytest-current\* include-hidden-files: true if-no-files-found: error retention-days: 1 From 10afa8ec4be54216258b275b5e1be0facf8ea8d7 Mon Sep 17 00:00:00 2001 From: Olivia Lewke <40345824+olivialewke@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:04:58 -0700 Subject: [PATCH 4/5] fix(config): validate commit author in config internally (#1477) With the change in GitPython v3.1.60, they removed the regex for a commit author string, so we must do it ourselves. Resolves: #1476 Co-authored-by: codejedi365 * test(config): add unit tests for config loading of commit author Co-authored-by: codejedi365 --- src/semantic_release/cli/config.py | 87 +++++++-- .../unit/semantic_release/cli/test_config.py | 179 ++++++++++++++++-- 2 files changed, 239 insertions(+), 27 deletions(-) diff --git a/src/semantic_release/cli/config.py b/src/semantic_release/cli/config.py index 8284e23c7..995b44f7c 100644 --- a/src/semantic_release/cli/config.py +++ b/src/semantic_release/cli/config.py @@ -36,6 +36,7 @@ Field, RootModel, ValidationError, + field_serializer, field_validator, model_validator, ) @@ -364,8 +365,12 @@ class RawConfig(BaseModel): build_command: Optional[str] = None build_command_env: List[str] = [] changelog: ChangelogConfig = ChangelogConfig() - commit_author: MaybeFromEnv = EnvConfigVar( - env="GIT_COMMIT_AUTHOR", default=DEFAULT_COMMIT_AUTHOR + commit_author: Actor = Field( + default=cast( + "Actor", + EnvConfigVar(env="GIT_COMMIT_AUTHOR", default=DEFAULT_COMMIT_AUTHOR), + ), + validate_default=True, ) commit_message: str = COMMIT_MESSAGE commit_parser: NonEmptyString = "conventional" @@ -390,6 +395,72 @@ def convert_str_to_path(cls, value: Any) -> Path: raise TypeError(f"Invalid type: {type(value)}, expected str or Path.") return Path(value) + # Note: mode="plain" must be declared before mode="before" here, as pydantic + # composes same-field validators in declaration order and a later "before" + # validator wraps (runs prior to) an earlier "plain" validator. + @field_validator("commit_author", mode="plain") + @classmethod + def validate_commit_author(cls, val: Any) -> Actor: + if isinstance(val, Actor): + return val + + if isinstance(val, dict): + if "name" not in val or "email" not in val: + msg = "commit_author dict must contain 'name' and 'email' keys." + raise ValueError(msg) + if not isinstance(val["name"], str) or not isinstance(val["email"], str): + msg = "commit_author 'name' and 'email' must be strings." + raise ValueError(msg) # noqa: TRY004 + if not val["name"].strip() or not val["email"].strip(): + msg = "commit_author 'name' and 'email' cannot be empty." + raise ValueError(msg) + # TODO: add email format validation (breaking change) + return Actor(**val) + + if isinstance(val, str): + if not val.strip(): + msg = "commit_author string cannot be empty." + raise ValueError(msg) + + name_email_pattern = regexp( + r"^(?P[^<]{1,255}) ?<(?P[^>]{1,320})>$" + ) + value = val.strip().splitlines()[0] + + if not (m := name_email_pattern.search(value)): + msg = "commit_author string must be in the format 'Name '." + raise ValueError(msg) + + # TODO: add email format validation (breaking change) + email = m.group("email").strip() + + return Actor(name=m.group("name").strip(), email=email) + + msg = f"Invalid type for commit_author: {type(val)}, expected Actor, dict, or str." + raise TypeError(msg) + + # TODO: apply to more fields that can be set via environment variables + @field_validator("commit_author", mode="before") + @classmethod + def resolve_env_vars(cls, val: Any) -> Any | str | None: + if isinstance(val, EnvConfigVar): + return val.getvalue() + + if not isinstance(val, dict): + return val + + try: + return EnvConfigVar.model_validate(val).getvalue() + except ValidationError: + if "env" in val: + raise + return val + + @field_serializer("commit_author", mode="plain") + @classmethod + def serialize_commit_author(cls, val: Actor) -> str: + return f"{val.name} <{val.email}>" + @field_validator("repo_dir", mode="after") @classmethod def verify_git_repo_dir(cls, dir_path: Path) -> Path: @@ -741,16 +812,6 @@ def from_raw_config( # noqa: C901 *(regexp(pattern) for pattern in raw.changelog.exclude_commit_patterns), ) - _commit_author_str = cls.resolve_from_env(raw.commit_author) or "" - _commit_author_valid = Actor.name_email_regex.match(_commit_author_str) - if not _commit_author_valid: - raise ValueError( - f"Invalid git author: {_commit_author_str} " - f"should match {Actor.name_email_regex}" - ) - - commit_author = Actor(*_commit_author_valid.groups()) - version_declarations: list[IVersionReplacer] = [] try: @@ -909,7 +970,7 @@ def from_raw_config( # noqa: C901 changelog_mask_initial_release=raw.changelog.default_templates.mask_initial_release, changelog_insertion_flag=raw.changelog.insertion_flag, assets=raw.assets, - commit_author=commit_author, + commit_author=raw.commit_author, commit_message=raw.commit_message, changelog_excluded_commit_patterns=changelog_excluded_commit_patterns, # TODO: change when we have other styles per parser diff --git a/tests/unit/semantic_release/cli/test_config.py b/tests/unit/semantic_release/cli/test_config.py index 343748187..6cc799f18 100644 --- a/tests/unit/semantic_release/cli/test_config.py +++ b/tests/unit/semantic_release/cli/test_config.py @@ -10,6 +10,7 @@ import pytest import tomlkit +from git import Actor from pydantic import RootModel, ValidationError from urllib3.util.url import parse_url @@ -33,7 +34,6 @@ from semantic_release.enums import LevelBump from semantic_release.errors import ParserLoadError -from tests.fixtures.repos import repo_w_no_tags_conventional_commits from tests.util import ( CustomParserOpts, CustomParserWithNoOpts, @@ -185,27 +185,178 @@ def test_default_toml_config_valid(example_project_dir: ExProjectDir): ({"GIT_COMMIT_AUTHOR": "foo "}, "foo "), ], ) -@pytest.mark.usefixtures(repo_w_no_tags_conventional_commits.__name__) def test_commit_author_configurable( - example_pyproject_toml: Path, mock_env: dict[str, str], expected_author: str, - change_to_ex_proj_dir: None, ): - content = tomlkit.loads(example_pyproject_toml.read_text(encoding="utf-8")).unwrap() - with mock.patch.dict(os.environ, mock_env): - raw = RawConfig.model_validate(content) - runtime = RuntimeContext.from_raw_config( - raw=raw, - global_cli_options=GlobalCommandLineOptions(), - ) - resulting_author = ( - f"{runtime.commit_author.name} <{runtime.commit_author.email}>" - ) + raw = RawConfig.model_validate({}) + resulting_author = f"{raw.commit_author.name} <{raw.commit_author.email}>" assert expected_author == resulting_author +def test_commit_author_accepts_actor_instance(): + author = Actor(name="Foo Bar", email="foo@bar.com") + raw = RawConfig(commit_author=author) + assert author.name == raw.commit_author.name + assert author.email == raw.commit_author.email + + +def test_commit_author_valid_dict_input(): + expected_name = "Foo Bar" + expected_email = "foo@bar.com" + raw = RawConfig.model_validate( + {"commit_author": {"name": expected_name, "email": expected_email}} + ) + assert expected_name == raw.commit_author.name + assert expected_email == raw.commit_author.email + + +@pytest.mark.parametrize( + "commit_author_str, expected_name, expected_email", + [ + ("Foo Bar ", "Foo Bar", "foo@bar.com"), + ("FooBar", "FooBar", "foo@bar.com"), + # only the first line of a multiline value is parsed + ("Foo Bar \nnot-part-of-the-author", "Foo Bar", "foo@bar.com"), + ], +) +def test_commit_author_valid_string_formats( + commit_author_str: str, expected_name: str, expected_email: str +): + raw = RawConfig.model_validate({"commit_author": commit_author_str}) + assert expected_name == raw.commit_author.name + assert expected_email == raw.commit_author.email + + +@pytest.mark.parametrize( + "commit_author_dict, mock_env, expected_author", + [ + ( + {"env": "PSR_TEST_COMMIT_AUTHOR_ENV"}, + {"PSR_TEST_COMMIT_AUTHOR_ENV": "Env Name "}, + "Env Name ", + ), + ( + { + "env": "PSR_TEST_COMMIT_AUTHOR_ENV", + "default": "Default Name ", + }, + {}, + "Default Name ", + ), + ( + { + "env": "PSR_TEST_COMMIT_AUTHOR_ENV", + "default_env": "PSR_TEST_COMMIT_AUTHOR_FALLBACK_ENV", + }, + { + "PSR_TEST_COMMIT_AUTHOR_FALLBACK_ENV": ( + "Fallback Name " + ) + }, + "Fallback Name ", + ), + ], +) +def test_commit_author_resolves_env_config_var( + commit_author_dict: dict[str, str], + mock_env: dict[str, str], + expected_author: str, +): + with mock.patch.dict(os.environ, mock_env, clear=True): + raw = RawConfig.model_validate({"commit_author": commit_author_dict}) + + resulting_author = f"{raw.commit_author.name} <{raw.commit_author.email}>" + assert expected_author == resulting_author + + +def test_commit_author_env_config_var_resolves_to_none_raises_type_error(): + # nested "with" kept separate for py38 compatibility (no parenthesized context managers) + with mock.patch.dict(os.environ, {}, clear=True): # noqa: SIM117 + with pytest.raises(TypeError, match="Invalid type for commit_author"): + RawConfig.model_validate( + {"commit_author": {"env": "PSR_TEST_COMMIT_AUTHOR_UNSET_ENV"}} + ) + + +@pytest.mark.parametrize("commit_author_dict", [{"env": 123}]) +def test_commit_author_invalid_env_config_var(commit_author_dict: dict[str, int]): + with pytest.raises(ValidationError, match="commit_author.env"): + RawConfig.model_validate({"commit_author": commit_author_dict}) + + +@pytest.mark.parametrize( + "commit_author_dict, expected_err_msg", + [ + ( + {"name": "Foo Bar"}, + "commit_author dict must contain 'name' and 'email' keys.", + ), + ( + {"email": "foo@bar.com"}, + "commit_author dict must contain 'name' and 'email' keys.", + ), + ( + {"name": 123, "email": "foo@bar.com"}, + "commit_author 'name' and 'email' must be strings.", + ), + ( + {"name": " ", "email": "foo@bar.com"}, + "commit_author 'name' and 'email' cannot be empty.", + ), + ], +) +def test_commit_author_invalid_dict_input( + commit_author_dict: dict[str, Any], expected_err_msg: str +): + with pytest.raises(ValidationError, match=expected_err_msg): + RawConfig.model_validate({"commit_author": commit_author_dict}) + + +@pytest.mark.parametrize( + "commit_author_str, expected_err_msg", + [ + ("", "commit_author string cannot be empty."), + (" ", "commit_author string cannot be empty."), + ( + "NoAngleBracketsHere", + "commit_author string must be in the format 'Name '.", + ), + ( + "", + "commit_author string must be in the format 'Name '.", + ), + ( + "Foo Bar <>", + "commit_author string must be in the format 'Name '.", + ), + ], +) +def test_commit_author_invalid_string_input( + commit_author_str: str, expected_err_msg: str +): + with pytest.raises(ValidationError, match=expected_err_msg): + RawConfig.model_validate({"commit_author": commit_author_str}) + + +@pytest.mark.parametrize( + "commit_author_val", [123, 12.3, ["Foo Bar", "foo@bar.com"], None] +) +def test_commit_author_invalid_type_input(commit_author_val: Any): + # TypeError is not a pydantic-recognized validation exception, so it is not + # wrapped into a ValidationError like the other invalid input cases above + with pytest.raises(TypeError, match="Invalid type for commit_author"): + RawConfig.model_validate({"commit_author": commit_author_val}) + + +def test_commit_author_serialization(): + name_email_str = "Foo Bar " + raw = RawConfig.model_validate({"commit_author": name_email_str}) + serialized_author = raw.model_dump(mode="json").get("commit_author") + assert name_email_str == serialized_author + + def test_load_valid_runtime_config( build_configured_base_repo: BuildRepoFn, example_project_dir: ExProjectDir, From 9a026e9303981c866c3425723009becb2437c757 Mon Sep 17 00:00:00 2001 From: semantic-release Date: Fri, 28 Aug 2026 20:22:32 +0000 Subject: [PATCH 5/5] chore: release v10.6.2 Automatically generated by python-semantic-release --- CHANGELOG.rst | 32 +++++++++++++++++++ .../automatic-releases/github-actions.rst | 4 +-- pyproject.toml | 2 +- src/gh_action/requirements.txt | 2 +- 4 files changed, 36 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index fc6c8fac4..babc2cc5b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,6 +4,38 @@ CHANGELOG ========= +.. _changelog-v10.6.2: + +v10.6.2 (2026-08-28) +==================== + +🪲 Bug Fixes +------------ + +* **cmd-changelog**: Fix reading of TOML when file was created on Windows (`9a56ace`_) + +* **config**: Validate commit author in config internally, closes `#1476`_ (`PR#1477`_, `10afa8e`_) + +⚙️ Build System +---------------- + +* **deps**: Adjust ``importlib`` use to maintain 3.8 support (`59fea4f`_) + +* **deps**: Bump ``rich`` requirement from ``v14.0+`` to ``v15.0+`` (`PR#1464`_, `abd942d`_) + +* **deps**: Bump ``tomlkit`` requirement to ``~=0.15.0`` (`PR#1463`_, `429588d`_) + +.. _#1476: https://github.com/python-semantic-release/python-semantic-release/issues/1476 +.. _10afa8e: https://github.com/python-semantic-release/python-semantic-release/commit/10afa8ec4be54216258b275b5e1be0facf8ea8d7 +.. _429588d: https://github.com/python-semantic-release/python-semantic-release/commit/429588da71dfc7bd88ff27618cec2fe843dedd7c +.. _59fea4f: https://github.com/python-semantic-release/python-semantic-release/commit/59fea4f231bd84c5af58785d9fd487cbcf8d3407 +.. _9a56ace: https://github.com/python-semantic-release/python-semantic-release/commit/9a56ace313a1f3710cc0e0683285cde133704f01 +.. _abd942d: https://github.com/python-semantic-release/python-semantic-release/commit/abd942d71c077e012eb49c43e094c9bf4a352a44 +.. _PR#1463: https://github.com/python-semantic-release/python-semantic-release/pull/1463 +.. _PR#1464: https://github.com/python-semantic-release/python-semantic-release/pull/1464 +.. _PR#1477: https://github.com/python-semantic-release/python-semantic-release/pull/1477 + + .. _changelog-v10.6.1: v10.6.1 (2026-07-06) diff --git a/docs/configuration/automatic-releases/github-actions.rst b/docs/configuration/automatic-releases/github-actions.rst index 763edddd2..0a1d1deea 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@COMMIT_HASH # v10.6.1 + uses: python-semantic-release/python-semantic-release@COMMIT_HASH # v10.6.2 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@COMMIT_HASH # v10.6.1 + uses: python-semantic-release/publish-action@COMMIT_HASH # v10.6.2 if: steps.release.outputs.released == 'true' with: github_token: ${{ secrets.GITHUB_TOKEN }} diff --git a/pyproject.toml b/pyproject.toml index 47160bae9..f9ce55a79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,7 +6,7 @@ build-backend = "setuptools.build_meta" [project] name = "python-semantic-release" -version = "10.6.1" +version = "10.6.2" 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 bd2ef457b..629cc6bc5 100644 --- a/src/gh_action/requirements.txt +++ b/src/gh_action/requirements.txt @@ -1 +1 @@ -python-semantic-release == 10.6.1 +python-semantic-release == 10.6.2