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 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 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 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, 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), ], ) )