From 02de7efebf883a63f2955031038d21116b9aa51e Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 14 Dec 2025 13:36:39 -0700 Subject: [PATCH 1/3] test(fixtures): upgrade commit simulation to modify specific files --- tests/fixtures/git_repo.py | 52 ++++++++++++-------------------------- 1 file changed, 16 insertions(+), 36 deletions(-) diff --git a/tests/fixtures/git_repo.py b/tests/fixtures/git_repo.py index 46ebed0d6..613955290 100644 --- a/tests/fixtures/git_repo.py +++ b/tests/fixtures/git_repo.py @@ -142,6 +142,7 @@ class CommitDef(TypedDict): sha: str datetime: NotRequired[DatetimeISOStr] include_in_changelog: bool + file_to_change: NotRequired[Path | str] class BaseRepoVersionDef(TypedDict): """A Common Repo definition for a get_commits_repo_*() fixture with all commit convention types""" @@ -290,6 +291,7 @@ class CommitSpec(TypedDict): scipy: str datetime: NotRequired[DatetimeISOStr] include_in_changelog: NotRequired[bool] + file_to_change: NotRequired[Path | str] class DetailsBase(TypedDict): pre_actions: NotRequired[Sequence[RepoActions]] @@ -1127,7 +1129,9 @@ def _simulate_change_commits_n_rtn_changelog_entry( changelog_entries: list[CommitDef] = [] for commit_msg in commit_msgs: if not git_repo.is_dirty(index=True, working_tree=False): - add_text_to_file(git_repo, file_in_repo) + add_text_to_file( + git_repo, str(commit_msg.get("file_to_change", file_in_repo)) + ) changelog_entries.append(commit_n_rtn_changelog_entry(git_repo, commit_msg)) @@ -1336,39 +1340,6 @@ def _configure_base_repo( # noqa: C901 @pytest.fixture(scope="session") def separate_squashed_commit_def() -> SeparateSquashedCommitDefFn: - # default_conventional_parser: ConventionalCommitParser, - # default_emoji_parser: EmojiCommitParser, - # default_scipy_parser: ScipyCommitParser, - # message_parsers: dict[ - # CommitConvention, - # ConventionalCommitParser | EmojiCommitParser | ScipyCommitParser, - # ] = { - # "conventional": ConventionalCommitParser( - # options=ConventionalCommitParserOptions( - # **{ - # **default_conventional_parser.options.__dict__, - # "parse_squash_commits": True, - # } - # ) - # ), - # "emoji": EmojiCommitParser( - # options=EmojiParserOptions( - # **{ - # **default_emoji_parser.options.__dict__, - # "parse_squash_commits": True, - # } - # ) - # ), - # "scipy": ScipyCommitParser( - # options=ScipyParserOptions( - # **{ - # **default_scipy_parser.options.__dict__, - # "parse_squash_commits": True, - # } - # ) - # ), - # } - def _separate_squashed_commit_def( squashed_commit_def: CommitDef, parser: SquashedCommitSupportedParser, @@ -1435,7 +1406,7 @@ def _convert( ) # Extract the correct commit message for the commit type - return { + commit_def: CommitDef = { **parse_msg_fn(commit_spec[commit_type], parser=parser), "cid": commit_spec["cid"], "datetime": ( @@ -1443,9 +1414,18 @@ def _convert( if "datetime" in commit_spec else stable_now_date.isoformat(timespec="seconds") ), - "include_in_changelog": (commit_spec.get("include_in_changelog", True)), + "include_in_changelog": commit_spec.get("include_in_changelog", True), } + if "file_to_change" in commit_spec: + commit_def.update( + { + "file_to_change": commit_spec["file_to_change"], + } + ) + + return commit_def + return _convert From 41167f9dd1a0c6507a747ba41bdbaaf138bbb71b Mon Sep 17 00:00:00 2001 From: codejedi365 Date: Sun, 14 Dec 2025 13:37:18 -0700 Subject: [PATCH 2/3] test(fixtures): upgrade monorepos to monitor external package files --- tests/fixtures/monorepos/example_monorepo.py | 44 ++++++++++++++++++- .../github_flow/monorepo_w_default_release.py | 29 ++++++++---- .../monorepo_w_release_channels.py | 25 ++++++----- .../trunk_based_dev/monorepo_w_tags.py | 30 ++++++++++--- 4 files changed, 101 insertions(+), 27 deletions(-) diff --git a/tests/fixtures/monorepos/example_monorepo.py b/tests/fixtures/monorepos/example_monorepo.py index 6150e0635..1b46bde2b 100644 --- a/tests/fixtures/monorepos/example_monorepo.py +++ b/tests/fixtures/monorepos/example_monorepo.py @@ -68,8 +68,12 @@ def build_spec_hash_4_example_monorepo( @pytest.fixture(scope="session") def cached_example_monorepo( build_repo_or_copy_cache: BuildRepoOrCopyCacheFn, - monorepo_pkg1_dir: Path, - monorepo_pkg2_dir: Path, + monorepo_pkg1_name: str, + monorepo_pkg2_name: str, + monorepo_pkg1_dir: str, + monorepo_pkg2_dir: str, + monorepo_pkg1_docs_dir: str, + monorepo_pkg2_docs_dir: str, monorepo_pkg1_version_py_file: Path, monorepo_pkg2_version_py_file: Path, monorepo_pkg1_pyproject_toml_file: Path, @@ -104,6 +108,13 @@ def hello_world() -> None: print("{pkg_name} Hello World") ''' ).lstrip() + doc_index_contents = dedent( + """ + ================== + {pkg_name} Documentation + ================== + """ + ).lstrip() with temporary_working_directory(cached_project_path): update_version_py_file( @@ -127,6 +138,14 @@ def hello_world() -> None: (".gitignore", gitignore_contents), (monorepo_pkg1_pyproject_toml_file, EXAMPLE_PYPROJECT_TOML_CONTENT), (monorepo_pkg2_pyproject_toml_file, EXAMPLE_PYPROJECT_TOML_CONTENT), + ( + Path(monorepo_pkg1_docs_dir, "index.rst"), + doc_index_contents.format(pkg_name=monorepo_pkg1_name), + ), + ( + Path(monorepo_pkg2_docs_dir, "index.rst"), + doc_index_contents.format(pkg_name=monorepo_pkg2_name), + ), ] for file, contents in file_2_contents: @@ -216,6 +235,27 @@ def monorepo_pkg2_name() -> str: return "pkg2" +@pytest.fixture(scope="session") +def monorepo_pkg_docs_dir_pattern() -> str: + return str(Path("docs", "source", "{package_name}")) + + +@pytest.fixture(scope="session") +def monorepo_pkg1_docs_dir( + monorepo_pkg1_name: str, + monorepo_pkg_docs_dir_pattern: str, +) -> str: + return monorepo_pkg_docs_dir_pattern.format(package_name=monorepo_pkg1_name) + + +@pytest.fixture(scope="session") +def monorepo_pkg2_docs_dir( + monorepo_pkg2_name: str, + monorepo_pkg_docs_dir_pattern: str, +) -> str: + return monorepo_pkg_docs_dir_pattern.format(package_name=monorepo_pkg2_name) + + @pytest.fixture(scope="session") def monorepo_pkg_dir_pattern() -> str: return str(Path("packages", "{package_name}")) diff --git a/tests/fixtures/monorepos/github_flow/monorepo_w_default_release.py b/tests/fixtures/monorepos/github_flow/monorepo_w_default_release.py index f42789953..ecbe491ed 100644 --- a/tests/fixtures/monorepos/github_flow/monorepo_w_default_release.py +++ b/tests/fixtures/monorepos/github_flow/monorepo_w_default_release.py @@ -96,8 +96,10 @@ def get_repo_definition_4_github_flow_monorepo_w_default_release_channel( monorepo_pkg2_changelog_rst_file: Path, monorepo_pkg1_name: str, monorepo_pkg2_name: str, - monorepo_pkg1_dir: Path, - monorepo_pkg2_dir: Path, + monorepo_pkg1_dir: str, + monorepo_pkg2_dir: str, + monorepo_pkg1_docs_dir: str, + monorepo_pkg2_docs_dir: str, monorepo_pkg1_version_py_file: Path, monorepo_pkg2_version_py_file: Path, monorepo_pkg1_pyproject_toml_file: Path, @@ -117,7 +119,8 @@ def get_repo_definition_4_github_flow_monorepo_w_default_release_channel( * chore(release): pkg1@1.1.0 [skip ci] (tag: pkg1-v1.1.0, branch: main, HEAD -> main) * feat(pkg1): file modified outside of pkg 1, identified by scope (#5) | - | * feat(pkg1): file modified outside of pkg 1, identified by scope (branch: pkg1/feat/pr-4) + | * docs: pkg1 docs modified outside of pkg 1, identified by path filter (branch: pkg1/feat/pr-4) + | * feat(pkg1): file modified outside of pkg 1, identified by scope |/ * chore(release): pkg2@1.1.1 [skip ci] (tag: pkg2-v1.1.1) * fix(pkg2-cli): file modified outside of pkg 2, identified by scope (#4) @@ -207,21 +210,23 @@ def _get_repo_from_definition( if commit_type != "conventional": raise ValueError(f"Unsupported commit type: {commit_type}") + pkg1_path_filters = (".", f"../../{monorepo_pkg1_docs_dir}") pkg1_commit_parser = ConventionalCommitMonorepoParser( options=ConventionalCommitMonorepoParserOptions( parse_squash_commits=True, ignore_merge_commits=ignore_merge_commits, scope_prefix=f"{monorepo_pkg1_name}-?", - path_filters=(".",), + path_filters=pkg1_path_filters, ) ) + pkg2_path_filters = (".", f"../../{monorepo_pkg2_docs_dir}") pkg2_commit_parser = ConventionalCommitMonorepoParser( options=ConventionalCommitMonorepoParserOptions( parse_squash_commits=pkg1_commit_parser.options.parse_squash_commits, ignore_merge_commits=pkg1_commit_parser.options.ignore_merge_commits, scope_prefix=f"{monorepo_pkg2_name}-?", - path_filters=(".",), + path_filters=pkg2_path_filters, ) ) @@ -277,7 +282,7 @@ def _get_repo_from_definition( ) ), "tool.semantic_release.commit_parser_options.scope_prefix": pkg1_commit_parser.options.scope_prefix, - "tool.semantic_release.commit_parser_options.path_filters": pkg1_commit_parser.options.path_filters, + "tool.semantic_release.commit_parser_options.path_filters": pkg1_path_filters, **(extra_configs or {}), }, }, @@ -301,7 +306,7 @@ def _get_repo_from_definition( ) ), "tool.semantic_release.commit_parser_options.scope_prefix": pkg2_commit_parser.options.scope_prefix, - "tool.semantic_release.commit_parser_options.path_filters": pkg2_commit_parser.options.path_filters, + "tool.semantic_release.commit_parser_options.path_filters": pkg2_path_filters, **(extra_configs or {}), }, }, @@ -774,7 +779,15 @@ def _get_repo_from_definition( "emoji": ":sparkles: (pkg1) file modified outside of pkg 1, identified by scope", "scipy": "ENH:pkg1: file modified outside of pkg 1, identified by scope", "datetime": next(commit_timestamp_gen), - } + }, + { + "cid": "pkg1-docs-2-squashed", + "conventional": "docs: pkg1 docs modified outside of pkg 1, identified by path filter", + "emoji": ":book: pkg1 docs modified outside of pkg 1, identified by path filter", + "scipy": "DOC: pkg1 docs modified outside of pkg 1, identified by path filter", + "datetime": next(commit_timestamp_gen), + "file_to_change": f"{monorepo_pkg1_docs_dir}/index.rst", + }, ] repo_construction_steps.extend( diff --git a/tests/fixtures/monorepos/github_flow/monorepo_w_release_channels.py b/tests/fixtures/monorepos/github_flow/monorepo_w_release_channels.py index 1e25cdb4f..9b4027195 100644 --- a/tests/fixtures/monorepos/github_flow/monorepo_w_release_channels.py +++ b/tests/fixtures/monorepos/github_flow/monorepo_w_release_channels.py @@ -97,8 +97,10 @@ def get_repo_definition_4_github_flow_monorepo_w_feature_release_channel( monorepo_pkg2_changelog_rst_file: Path, monorepo_pkg1_name: str, monorepo_pkg2_name: str, - monorepo_pkg1_dir: Path, - monorepo_pkg2_dir: Path, + monorepo_pkg1_dir: str, + monorepo_pkg2_dir: str, + monorepo_pkg1_docs_dir: str, + monorepo_pkg2_docs_dir: str, monorepo_pkg1_version_py_file: Path, monorepo_pkg2_version_py_file: Path, stable_now_date: GetStableDateNowFn, @@ -119,7 +121,7 @@ def get_repo_definition_4_github_flow_monorepo_w_feature_release_channel( | * chore(release): pkg2@1.1.0-alpha.2 [skip ci] (tag: pkg2-v1.1.0-alpha.2, branch: pkg2/feat/pr-2) | * fix(pkg2-cli): file modified outside of pkg 2, identified by scope | * chore(release): pkg2@1.1.0-alpha.1 [skip ci] (tag: pkg2-v1.1.0-alpha.1) - | * docs: add cli documentation + | * docs: pkg2 docs modified outside of pkg 2, identified by path filter | * test: add cli tests | * feat: no pkg scope but file in pkg 2 directory |/ @@ -203,21 +205,23 @@ def _get_repo_from_definition( if commit_type != "conventional": raise ValueError(f"Unsupported commit type: {commit_type}") + pkg1_path_filters = (".", f"../../{monorepo_pkg1_docs_dir}") pkg1_commit_parser = ConventionalCommitMonorepoParser( options=ConventionalCommitMonorepoParserOptions( parse_squash_commits=True, ignore_merge_commits=ignore_merge_commits, scope_prefix=f"{monorepo_pkg1_name}-?", - path_filters=(".",), + path_filters=pkg1_path_filters, ) ) + pkg2_path_filters = (".", f"../../{monorepo_pkg2_docs_dir}") pkg2_commit_parser = ConventionalCommitMonorepoParser( options=ConventionalCommitMonorepoParserOptions( parse_squash_commits=pkg1_commit_parser.options.parse_squash_commits, ignore_merge_commits=pkg1_commit_parser.options.ignore_merge_commits, scope_prefix=f"{monorepo_pkg2_name}-?", - path_filters=(".",), + path_filters=pkg2_path_filters, ) ) @@ -277,7 +281,7 @@ def _get_repo_from_definition( "prerelease_token": "alpha", }, "tool.semantic_release.commit_parser_options.scope_prefix": pkg1_commit_parser.options.scope_prefix, - "tool.semantic_release.commit_parser_options.path_filters": pkg1_commit_parser.options.path_filters, + "tool.semantic_release.commit_parser_options.path_filters": pkg1_path_filters, **(extra_configs or {}), }, }, @@ -307,7 +311,7 @@ def _get_repo_from_definition( "prerelease_token": "alpha", }, "tool.semantic_release.commit_parser_options.scope_prefix": pkg2_commit_parser.options.scope_prefix, - "tool.semantic_release.commit_parser_options.path_filters": pkg2_commit_parser.options.path_filters, + "tool.semantic_release.commit_parser_options.path_filters": pkg2_path_filters, **(extra_configs or {}), }, }, @@ -646,11 +650,12 @@ def _get_repo_from_definition( cid_pkg2_feb1_c3_docs := "pkg2_feat_branch_1_c3_docs" ), - "conventional": "docs: add cli documentation", - "emoji": ":memo: add cli documentation", - "scipy": "DOC: add cli documentation", + "conventional": "docs: pkg2 docs modified outside of pkg 2, identified by path filter", + "emoji": ":book: pkg2 docs modified outside of pkg 2, identified by path filter", + "scipy": "DOC: pkg2 docs modified outside of pkg 2, identified by path filter", "datetime": next(commit_timestamp_gen), "include_in_changelog": True, + "file_to_change": f"../../{monorepo_pkg2_docs_dir}/index.rst", }, ], commit_type, diff --git a/tests/fixtures/monorepos/trunk_based_dev/monorepo_w_tags.py b/tests/fixtures/monorepos/trunk_based_dev/monorepo_w_tags.py index 9687e088f..71bc6e9c7 100644 --- a/tests/fixtures/monorepos/trunk_based_dev/monorepo_w_tags.py +++ b/tests/fixtures/monorepos/trunk_based_dev/monorepo_w_tags.py @@ -88,8 +88,10 @@ def get_repo_definition_4_trunk_only_monorepo_w_tags( monorepo_pkg2_changelog_rst_file: Path, monorepo_pkg1_name: str, monorepo_pkg2_name: str, - monorepo_pkg1_dir: Path, - monorepo_pkg2_dir: Path, + monorepo_pkg1_dir: str, + monorepo_pkg2_dir: str, + monorepo_pkg1_docs_dir: str, + monorepo_pkg2_docs_dir: str, monorepo_pkg1_version_py_file: Path, monorepo_pkg2_version_py_file: Path, stable_now_date: GetStableDateNowFn, @@ -104,6 +106,7 @@ def get_repo_definition_4_trunk_only_monorepo_w_tags( ``` * chore(release): pkg1@0.1.0 [skip ci] (tag: pkg1-v0.1.0, branch: main) + * docs: pkg1 docs modified outside of pkg 1, identified by path filter * feat(pkg1): file modified outside of pkg 1, identified by scope * chore(release): pkg2@0.1.1 [skip ci] (tag: pkg2-v0.1.1) * fix(pkg2-cli): file modified outside of pkg 2, identified by scope @@ -182,21 +185,23 @@ def _get_repo_from_definition( if commit_type != "conventional": raise ValueError(f"Unsupported commit type: {commit_type}") + pkg1_path_filters = (".", f"../../{monorepo_pkg1_docs_dir}") pkg1_commit_parser = ConventionalCommitMonorepoParser( options=ConventionalCommitMonorepoParserOptions( parse_squash_commits=True, ignore_merge_commits=ignore_merge_commits, scope_prefix=f"{monorepo_pkg1_name}-?", - path_filters=(".",), + path_filters=pkg1_path_filters, ) ) + pkg2_path_filters = (".", f"../../{monorepo_pkg2_docs_dir}") pkg2_commit_parser = ConventionalCommitMonorepoParser( options=ConventionalCommitMonorepoParserOptions( parse_squash_commits=pkg1_commit_parser.options.parse_squash_commits, ignore_merge_commits=pkg1_commit_parser.options.ignore_merge_commits, scope_prefix=f"{monorepo_pkg2_name}-?", - path_filters=(".",), + path_filters=pkg2_path_filters, ) ) @@ -247,7 +252,7 @@ def _get_repo_from_definition( ) ), "tool.semantic_release.commit_parser_options.scope_prefix": pkg1_commit_parser.options.scope_prefix, - "tool.semantic_release.commit_parser_options.path_filters": pkg1_commit_parser.options.path_filters, + "tool.semantic_release.commit_parser_options.path_filters": pkg1_path_filters, **(extra_configs or {}), }, }, @@ -271,7 +276,7 @@ def _get_repo_from_definition( ) ), "tool.semantic_release.commit_parser_options.scope_prefix": pkg2_commit_parser.options.scope_prefix, - "tool.semantic_release.commit_parser_options.path_filters": pkg2_commit_parser.options.path_filters, + "tool.semantic_release.commit_parser_options.path_filters": pkg2_path_filters, **(extra_configs or {}), }, }, @@ -524,6 +529,14 @@ def _get_repo_from_definition( "scipy": "ENH:pkg1: file modified outside of pkg 1, identified by scope", "datetime": next(commit_timestamp_gen), }, + { + "cid": (cid_c11_pkg1_docs := "c11_pkg1_docs"), + "conventional": "docs: pkg1 docs modified outside of pkg 1, identified by path filter", + "emoji": ":book: pkg1 docs modified outside of pkg 1, identified by path filter", + "scipy": "DOC: pkg1 docs modified outside of pkg 1, identified by path filter", + "datetime": next(commit_timestamp_gen), + "file_to_change": f"{monorepo_pkg1_docs_dir}/index.rst", + }, ], commit_type, parser=cast( @@ -550,7 +563,10 @@ def _get_repo_from_definition( "details": { "new_version": pkg1_new_version, "dest_files": pkg1_changelog_file_definitions, - "commit_ids": [cid_c10_pkg1_feat], + "commit_ids": [ + cid_c10_pkg1_feat, + cid_c11_pkg1_docs, + ], }, }, change_to_pkg1_dir, From 24ad90afc7fef08537fc3f168d45b7e78296e943 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Guillaume=20Ch=C3=A9rel?= Date: Mon, 1 Dec 2025 12:35:06 +0100 Subject: [PATCH 3/3] fix(parser-conventional-monorepo): fix parser opts validator for outside dir path matches (#1382) Resolves: #1380 --- .../conventional/options_monorepo.py | 43 +++++++++++-------- 1 file changed, 26 insertions(+), 17 deletions(-) diff --git a/src/semantic_release/commit_parser/conventional/options_monorepo.py b/src/semantic_release/commit_parser/conventional/options_monorepo.py index 58bcaf47d..241f7aaba 100644 --- a/src/semantic_release/commit_parser/conventional/options_monorepo.py +++ b/src/semantic_release/commit_parser/conventional/options_monorepo.py @@ -43,37 +43,46 @@ class ConventionalCommitMonorepoParserOptions(ConventionalCommitParserOptions): to match them literally. """ - @classmethod @field_validator("path_filters", mode="before") - def convert_strs_to_paths(cls, value: Any) -> tuple[Path, ...]: - values = value if isinstance(value, Iterable) else [value] - results: list[Path] = [] + @classmethod + def convert_strs_to_paths(cls, value: Any) -> tuple[str, ...]: + if isinstance(value, str): + return (value,) - for val in values: - if isinstance(val, (str, Path)): - results.append(Path(val)) - continue + if isinstance(value, Path): + return (str(value),) - raise TypeError(f"Invalid type: {type(val)}, expected str or Path.") + if isinstance(value, Iterable): + results: list[str] = [] + for val in value: + if isinstance(val, (str, Path)): + results.append(str(Path(val))) + continue - return tuple(results) + msg = f"Invalid type: {type(val)}, expected str or Path." + raise TypeError(msg) + + return tuple(results) + + msg = f"Invalid type: {type(value)}, expected str, Path, or Iterable." + raise TypeError(msg) - @classmethod @field_validator("path_filters", mode="after") - def resolve_path(cls, dir_paths: tuple[Path, ...]) -> tuple[Path, ...]: + @classmethod + def resolve_path(cls, dir_path_strs: tuple[str, ...]) -> tuple[str, ...]: return tuple( ( - Path(f"!{Path(str_path[1:]).expanduser().absolute().resolve()}") + f"!{Path(str_path[1:]).expanduser().absolute().resolve()}" # maintains the negation prefix if it exists - if (str_path := str(path)).startswith("!") + if str_path.startswith("!") # otherwise, resolve the path normally - else path.expanduser().absolute().resolve() + else str(Path(str_path).expanduser().absolute().resolve()) ) - for path in dir_paths + for str_path in dir_path_strs ) - @classmethod @field_validator("scope_prefix", mode="after") + @classmethod def validate_scope_prefix(cls, scope_prefix: str) -> str: if not scope_prefix: return ""