From 713bd15e7642e08eade4d20bfb7e8122492365cd Mon Sep 17 00:00:00 2001 From: Bilel Omrani Date: Fri, 2 Jan 2026 03:48:19 +0100 Subject: [PATCH 1/3] test(changelog): add tests for output_dir configuration --- tests/e2e/cmd_changelog/test_changelog.py | 213 ++++++++++++++++++ .../unit/semantic_release/cli/test_config.py | 178 ++++++++++++++- 2 files changed, 390 insertions(+), 1 deletion(-) diff --git a/tests/e2e/cmd_changelog/test_changelog.py b/tests/e2e/cmd_changelog/test_changelog.py index 0ff1bc710..0023e83d1 100644 --- a/tests/e2e/cmd_changelog/test_changelog.py +++ b/tests/e2e/cmd_changelog/test_changelog.py @@ -1,6 +1,7 @@ from __future__ import annotations import os +import shutil from textwrap import dedent from typing import TYPE_CHECKING from unittest import mock @@ -68,6 +69,7 @@ assert_successful_exit_code, get_func_qual_name, get_release_history_from_context, + temporary_working_directory, ) if TYPE_CHECKING: @@ -1254,3 +1256,214 @@ def test_changelog_prevent_external_path_traversal_dir( "Template directory must be inside of the repository directory." in result.stderr ) + + +@pytest.mark.usefixtures(repo_w_trunk_only_conventional_commits.__name__) +def test_changelog_generated_in_output_dir( + example_project_dir: ExProjectDir, + update_pyproject_toml: UpdatePyprojectTomlFn, + run_cli: RunCliFn, +): + # Setup: Configure output_dir to a subdirectory + output_subdir = "docs" + update_pyproject_toml("tool.semantic_release.changelog.output_dir", output_subdir) + update_pyproject_toml( + "tool.semantic_release.changelog.mode", ChangelogMode.INIT.value + ) + + # Remove the default changelog if it exists + default_changelog = example_project_dir / "CHANGELOG.md" + if default_changelog.exists(): + default_changelog.unlink() + + # Act + cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] + result = run_cli(cli_cmd[1:]) + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + + # Changelog should be in output_dir, not project root + expected_changelog = example_project_dir / output_subdir / "CHANGELOG.md" + assert expected_changelog.exists(), f"Expected changelog at {expected_changelog}" + assert not default_changelog.exists(), "Changelog should not be in project root" + + +@pytest.mark.usefixtures(repo_w_trunk_only_conventional_commits.__name__) +def test_changelog_file_with_directory_component_backward_compatibility( + example_project_dir: ExProjectDir, + update_pyproject_toml: UpdatePyprojectTomlFn, + run_cli: RunCliFn, +): + # Test backward compatibility: changelog_file with directory works when output_dir is default. + # This ensures we don't break existing configurations where users specify: + # changelog_file = "docs/CHANGELOG.md" + # without setting output_dir (which defaults to "."). + + # Setup: Configure changelog_file with a directory component, no output_dir + update_pyproject_toml( + "tool.semantic_release.changelog.default_templates.changelog_file", + "docs/CHANGELOG.md", + ) + update_pyproject_toml( + "tool.semantic_release.changelog.mode", ChangelogMode.INIT.value + ) + + # Create the docs directory + docs_dir = example_project_dir / "docs" + docs_dir.mkdir(parents=True, exist_ok=True) + + # Remove the default changelog if it exists + default_changelog = example_project_dir / "CHANGELOG.md" + if default_changelog.exists(): + default_changelog.unlink() + + # Act + cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] + result = run_cli(cli_cmd[1:]) + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + + # Changelog should be in docs/ subdirectory as specified + expected_changelog = example_project_dir / "docs" / "CHANGELOG.md" + assert expected_changelog.exists(), f"Expected changelog at {expected_changelog}" + assert not default_changelog.exists(), "Changelog should not be in project root" + + +@pytest.mark.usefixtures(repo_w_trunk_only_conventional_commits.__name__) +def test_changelog_update_mode_reads_from_output_dir( + example_project_dir: ExProjectDir, + update_pyproject_toml: UpdatePyprojectTomlFn, + run_cli: RunCliFn, +): + # Setup: Configure output_dir to a subdirectory with update mode + output_subdir = "docs" + update_pyproject_toml("tool.semantic_release.changelog.output_dir", output_subdir) + update_pyproject_toml( + "tool.semantic_release.changelog.mode", ChangelogMode.UPDATE.value + ) + + # Create output directory and place an existing changelog there + output_dir = example_project_dir / output_subdir + output_dir.mkdir(parents=True, exist_ok=True) + + # Create a changelog with a marker we can verify is preserved + existing_content = """\ +# CHANGELOG + + + +## v0.1.0 (1999-01-01) + +### Fix + +- Existing entry that should be preserved +""" + changelog_in_output = output_dir / "CHANGELOG.md" + changelog_in_output.write_text(existing_content, encoding="utf-8") + + # Remove any changelog in project root to ensure we're reading from output_dir + default_changelog = example_project_dir / "CHANGELOG.md" + if default_changelog.exists(): + default_changelog.unlink() + + # Act + cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] + result = run_cli(cli_cmd[1:]) + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + + # Changelog should still be in output_dir + assert changelog_in_output.exists(), f"Expected changelog at {changelog_in_output}" + assert not default_changelog.exists(), "Changelog should not be in project root" + + # Verify the existing content was preserved (update mode should keep old entries) + updated_content = changelog_in_output.read_text(encoding="utf-8") + assert "Existing entry that should be preserved" in updated_content + + +@pytest.mark.usefixtures(repo_w_trunk_only_conventional_commits.__name__) +def test_changelog_from_subdirectory_with_custom_template_and_output_dir( + example_project_dir: ExProjectDir, + update_pyproject_toml: UpdatePyprojectTomlFn, + example_project_template_dir: Path, + default_changelog_md_template: Path, + run_cli: RunCliFn, +): + # Test that running changelog from a subdirectory with both custom template_dir + # and output_dir correctly writes the changelog to output_dir (not repo root). + + # Setup: Create a monorepo-like structure + # packages/pkg1/ - subdirectory to run from + # docs/pkg1/ - where changelog should be written (output_dir) + # templates/ - custom templates directory (at repo root) + + pkg_subdir = example_project_dir / "packages" / "pkg1" + pkg_subdir.mkdir(parents=True) + + output_dir = example_project_dir / "docs" / "pkg1" + output_dir.mkdir(parents=True) + + # Copy the pyproject.toml to the package subdirectory + pkg_pyproject = pkg_subdir / "pyproject.toml" + shutil.copy(example_project_dir / "pyproject.toml", pkg_pyproject) + + # Copy default templates to project template_dir + shutil.copytree( + src=default_changelog_md_template.parent, + dst=example_project_template_dir, + dirs_exist_ok=True, + ) + + # Configure template_dir relative to repo root and output_dir relative to pkg subdir + # When running from packages/pkg1/, these paths should work correctly: + # - template_dir: "templates" (at repo root, relative from CWD: "../../templates") + # - output_dir: "../../docs/pkg1" (relative from CWD) + update_pyproject_toml( + "tool.semantic_release.changelog.template_dir", + str(example_project_template_dir.relative_to(example_project_dir)), + toml_file=pkg_pyproject, + ) + update_pyproject_toml( + "tool.semantic_release.changelog.output_dir", + "../../docs/pkg1", + toml_file=pkg_pyproject, + ) + update_pyproject_toml( + "tool.semantic_release.changelog.mode", + ChangelogMode.INIT.value, + toml_file=pkg_pyproject, + ) + + # Remove any existing changelog files + default_changelog = example_project_dir / "CHANGELOG.md" + if default_changelog.exists(): + default_changelog.unlink() + + pkg_changelog = pkg_subdir / "CHANGELOG.md" + if pkg_changelog.exists(): + pkg_changelog.unlink() + + # Act: Run changelog command from the package subdirectory + cli_cmd = [MAIN_PROG_NAME, CHANGELOG_SUBCMD] + with temporary_working_directory(pkg_subdir): + result = run_cli(cli_cmd[1:]) + + # Evaluate + assert_successful_exit_code(result, cli_cmd) + + # Changelog should be in output_dir (docs/pkg1/), NOT in: + # - repo root (example_project_dir/CHANGELOG.md) + # - package subdirectory (packages/pkg1/CHANGELOG.md) + expected_changelog = output_dir / "CHANGELOG.md" + assert expected_changelog.exists(), ( + f"Expected changelog at {expected_changelog}, but it doesn't exist. " + f"Repo root changelog exists: {default_changelog.exists()}, " + f"Package subdir changelog exists: {pkg_changelog.exists()}" + ) + assert ( + not default_changelog.exists() + ), "Changelog should NOT be written to repo root when output_dir is configured" + assert not pkg_changelog.exists(), "Changelog should NOT be written to package subdirectory when output_dir is configured" diff --git a/tests/unit/semantic_release/cli/test_config.py b/tests/unit/semantic_release/cli/test_config.py index 343748187..e94dfbce1 100644 --- a/tests/unit/semantic_release/cli/test_config.py +++ b/tests/unit/semantic_release/cli/test_config.py @@ -31,7 +31,7 @@ from semantic_release.commit_parser.tag import TagParserOptions from semantic_release.const import DEFAULT_COMMIT_AUTHOR from semantic_release.enums import LevelBump -from semantic_release.errors import ParserLoadError +from semantic_release.errors import InvalidConfiguration, ParserLoadError from tests.fixtures.repos import repo_w_no_tags_conventional_commits from tests.util import ( @@ -463,3 +463,179 @@ def test_git_remote_url_w_insteadof_alias( # Evaluate: the remote URL should be the full URL assert expected_url.url == actual_url + + +def test_output_dir_default_resolves_to_repo_root( + repo_w_initial_commit: BuiltRepoResult, + example_pyproject_toml: Path, +): + repo = repo_w_initial_commit["repo"] + repo_dir = Path(repo.working_dir).resolve() + + with mock.patch.dict(os.environ, {}, clear=True): + project_config = tomlkit.loads( + example_pyproject_toml.read_text(encoding="utf-8") + ).unwrap() + + runtime = RuntimeContext.from_raw_config( + raw=RawConfig.model_validate( + project_config.get("tool", {}).get("semantic_release", {}), + ), + global_cli_options=GlobalCommandLineOptions(), + ) + + assert runtime.output_dir == repo_dir + + +def test_output_dir_inside_repo_accepted( + repo_w_initial_commit: BuiltRepoResult, + example_pyproject_toml: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +): + repo = repo_w_initial_commit["repo"] + repo_dir = Path(repo.working_dir).resolve() + + update_pyproject_toml("tool.semantic_release.changelog.output_dir", "docs") + + with mock.patch.dict(os.environ, {}, clear=True): + project_config = tomlkit.loads( + example_pyproject_toml.read_text(encoding="utf-8") + ).unwrap() + + runtime = RuntimeContext.from_raw_config( + raw=RawConfig.model_validate( + project_config.get("tool", {}).get("semantic_release", {}), + ), + global_cli_options=GlobalCommandLineOptions(), + ) + + expected_output_dir = repo_dir / "docs" + assert runtime.output_dir == expected_output_dir + assert expected_output_dir.is_dir() # should be auto-created + + +def test_output_dir_outside_repo_rejected( + repo_w_initial_commit: BuiltRepoResult, + example_pyproject_toml: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +): + # Set output_dir to a path outside the repo + update_pyproject_toml("tool.semantic_release.changelog.output_dir", "/tmp/outside") # noqa: S108 + + with mock.patch.dict(os.environ, {}, clear=True): + project_config = tomlkit.loads( + example_pyproject_toml.read_text(encoding="utf-8") + ).unwrap() + + with pytest.raises( + InvalidConfiguration, match="Output directory must be inside" + ): + RuntimeContext.from_raw_config( + raw=RawConfig.model_validate( + project_config.get("tool", {}).get("semantic_release", {}), + ), + global_cli_options=GlobalCommandLineOptions(), + ) + + +def test_output_dir_with_parent_traversal_rejected( + repo_w_initial_commit: BuiltRepoResult, + example_pyproject_toml: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +): + # Set output_dir to a path that tries to escape the repo + update_pyproject_toml("tool.semantic_release.changelog.output_dir", "../outside") + + with mock.patch.dict(os.environ, {}, clear=True): + project_config = tomlkit.loads( + example_pyproject_toml.read_text(encoding="utf-8") + ).unwrap() + + with pytest.raises( + InvalidConfiguration, match="Output directory must be inside" + ): + RuntimeContext.from_raw_config( + raw=RawConfig.model_validate( + project_config.get("tool", {}).get("semantic_release", {}), + ), + global_cli_options=GlobalCommandLineOptions(), + ) + + +def test_output_dir_and_changelog_file_with_dir_rejected( + repo_w_initial_commit: BuiltRepoResult, + example_pyproject_toml: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +): + # Set both output_dir and changelog_file with a directory component + update_pyproject_toml("tool.semantic_release.changelog.output_dir", "build") + update_pyproject_toml( + "tool.semantic_release.changelog.default_templates.changelog_file", + "docs/CHANGELOG.md", + ) + + with mock.patch.dict(os.environ, {}, clear=True): + project_config = tomlkit.loads( + example_pyproject_toml.read_text(encoding="utf-8") + ).unwrap() + + with pytest.raises(InvalidConfiguration, match="Cannot specify both"): + RuntimeContext.from_raw_config( + raw=RawConfig.model_validate( + project_config.get("tool", {}).get("semantic_release", {}), + ), + global_cli_options=GlobalCommandLineOptions(), + ) + + +def test_output_dir_with_bare_changelog_filename_from_subdirectory_accepted( + repo_w_initial_commit: BuiltRepoResult, + example_pyproject_toml: Path, + update_pyproject_toml: UpdatePyprojectTomlFn, +): + # This simulates a monorepo scenario where the user runs PSR from packages/pkg1/ + # with output_dir pointing to ../../docs/source/pkg1 and changelog_file being just + # a filename like 'changelog.md'. + + repo = repo_w_initial_commit["repo"] + repo_dir = Path(repo.working_dir).resolve() + + # Create a subdirectory to simulate monorepo package structure + subdir = repo_dir / "packages" / "pkg1" + subdir.mkdir(parents=True, exist_ok=True) + + # Create output directory + output_target = repo_dir / "docs" / "source" / "pkg1" + output_target.mkdir(parents=True, exist_ok=True) + + # Configure output_dir with relative path from subdirectory and bare filename + update_pyproject_toml( + "tool.semantic_release.changelog.output_dir", "../../docs/source/pkg1" + ) + update_pyproject_toml( + "tool.semantic_release.changelog.default_templates.changelog_file", + "changelog.md", # Bare filename, no directory component + ) + + # Change to the subdirectory to simulate running PSR from there + original_cwd = Path.cwd() + try: + os.chdir(subdir) + + with mock.patch.dict(os.environ, {}, clear=True): + project_config = tomlkit.loads( + example_pyproject_toml.read_text(encoding="utf-8") + ).unwrap() + + # This should NOT raise an error - bare filename with output_dir is valid + runtime = RuntimeContext.from_raw_config( + raw=RawConfig.model_validate( + project_config.get("tool", {}).get("semantic_release", {}), + ), + global_cli_options=GlobalCommandLineOptions(), + ) + + # Verify the output_dir resolved correctly + assert runtime.output_dir == output_target + finally: + os.chdir(original_cwd) From 44874e2bc0e955aead1d31ea061118fde4966fdf Mon Sep 17 00:00:00 2001 From: Bilel Omrani Date: Fri, 2 Jan 2026 03:49:54 +0100 Subject: [PATCH 2/3] feat(changelog): add output_dir configuration option --- src/semantic_release/cli/changelog_writer.py | 23 +++++++--- src/semantic_release/cli/config.py | 47 ++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/src/semantic_release/cli/changelog_writer.py b/src/semantic_release/cli/changelog_writer.py index 65e387896..8df4fecbb 100644 --- a/src/semantic_release/cli/changelog_writer.py +++ b/src/semantic_release/cli/changelog_writer.py @@ -168,15 +168,28 @@ def write_changelog_files( hvcs_client: HvcsBase, noop: bool = False, ) -> list[str]: - project_dir = Path(runtime_ctx.repo_dir) + output_dir = runtime_ctx.output_dir template_dir = runtime_ctx.template_dir + # Determine the changelog file path: + # - If output_dir is the repo root (default), use changelog_file as configured + # (preserves backward compatibility for changelog_file with directory component) + # - If output_dir is explicitly set, use output_dir with just the filename + # TODO: v11 simplification - once changelog_file is renamed to changelog_filename + # and directory components are disallowed, this conditional can be replaced with: + # changelog_file = output_dir / runtime_ctx.changelog_file.name + # See DefaultChangelogTemplatesConfig in config.py for details. + if output_dir == runtime_ctx.repo_dir: + changelog_file = runtime_ctx.changelog_file + else: + changelog_file = output_dir / runtime_ctx.changelog_file.name + changelog_context = make_changelog_context( hvcs_client=hvcs_client, release_history=release_history, mode=runtime_ctx.changelog_mode, insertion_flag=runtime_ctx.changelog_insertion_flag, - prev_changelog_file=runtime_ctx.changelog_file, + prev_changelog_file=changelog_file, mask_initial_release=runtime_ctx.changelog_mask_initial_release, ) @@ -203,7 +216,7 @@ def write_changelog_files( environment=changelog_context.bind_to_environment( runtime_ctx.template_environment ), - destination_dir=project_dir, + destination_dir=output_dir, noop=noop, ) @@ -212,8 +225,8 @@ def write_changelog_files( ) return [ write_default_changelog( - changelog_file=runtime_ctx.changelog_file, - destination_dir=project_dir, + changelog_file=changelog_file, + destination_dir=output_dir, output_format=runtime_ctx.changelog_output_format, changelog_context=changelog_context, changelog_style=runtime_ctx.changelog_style, diff --git a/src/semantic_release/cli/config.py b/src/semantic_release/cli/config.py index 514d76ef1..b76f88ab2 100644 --- a/src/semantic_release/cli/config.py +++ b/src/semantic_release/cli/config.py @@ -128,6 +128,25 @@ class ChangelogEnvironmentConfig(BaseModel): class DefaultChangelogTemplatesConfig(BaseModel): + # TODO: v11 suggested breaking change - rename to `changelog_filename` and disallow + # directory components. Users should use `output_dir` to specify the destination + # directory instead. + # + # Why this simplifies the code: + # - changelog_writer.py currently has conditional logic to check if output_dir equals + # repo_dir to decide whether to preserve the directory component of changelog_file + # or use just the filename. With a pure filename, we can always use + # `output_dir / changelog_filename` unconditionally. + # - config validation currently checks the raw config value for directory components + # to prevent ambiguous configurations. This validation becomes unnecessary. + # + # Why output_dir is valuable: + # - Enables writing changelogs to directories outside the current working directory, + # which is essential for monorepos where PSR runs from a package subdirectory + # (e.g., packages/pkg1/) but needs to write to a consolidated docs directory + # (e.g., ../../docs/source/pkg1/). + # - Before output_dir, achieving this required complex shell scripts or custom + # templates that manually handled path resolution and file copying. changelog_file: str = "CHANGELOG.md" output_format: ChangelogOutputFormat = ChangelogOutputFormat.NONE mask_initial_release: bool = True @@ -162,6 +181,7 @@ class ChangelogConfig(BaseModel): mode: ChangelogMode = ChangelogMode.UPDATE insertion_flag: str = "" template_dir: str = "templates" + output_dir: str = "." @field_validator("exclude_commit_patterns", mode="after") @classmethod @@ -566,6 +586,7 @@ class RuntimeContext: ignore_token_for_push: bool template_environment: Environment template_dir: Path + output_dir: Path build_command: Optional[str] build_command_env: dict[str, str] dist_glob_patterns: Tuple[str, ...] @@ -814,12 +835,37 @@ def from_raw_config( # noqa: C901 template_dir = ( Path(raw.changelog.template_dir).expanduser().resolve().absolute() ) + output_dir = Path(raw.changelog.output_dir).expanduser().resolve().absolute() # Prevent path traversal attacks if raw.repo_dir not in template_dir.parents: raise InvalidConfiguration( "Template directory must be inside of the repository directory." ) + if raw.repo_dir not in output_dir.parents and output_dir != raw.repo_dir: + raise InvalidConfiguration( + "Output directory must be inside of the repository directory." + ) + + # Prevent ambiguous configuration: output_dir and changelog_file with directory + # Only validate when output_dir is explicitly set (not the default ".") + # This preserves backward compatibility for users who set changelog_file with a + # directory component (e.g., "docs/CHANGELOG.md") without setting output_dir + # TODO: v11 simplification - once changelog_file is renamed to changelog_filename + # and directory components are disallowed, this entire validation block can be + # removed. See DefaultChangelogTemplatesConfig for details. + output_dir_explicitly_set = raw.changelog.output_dir != "." + configured_changelog_path = Path(raw.changelog.default_templates.changelog_file) + has_directory_component = str(configured_changelog_path.parent) != "." + + if output_dir_explicitly_set and has_directory_component: + raise InvalidConfiguration( + "Cannot specify both 'changelog.output_dir' and a directory path in " + "'changelog.default_templates.changelog_file'. Use 'output_dir' for " + "the destination directory and 'changelog_file' for just the filename." + ) + + output_dir.mkdir(parents=True, exist_ok=True) template_environment = environment( template_dir=template_dir, @@ -897,6 +943,7 @@ def from_raw_config( # noqa: C901 ignore_token_for_push=raw.remote.ignore_token_for_push, template_dir=template_dir, template_environment=template_environment, + output_dir=output_dir, dist_glob_patterns=raw.publish.dist_glob_patterns, upload_to_vcs_release=raw.publish.upload_to_vcs_release, global_cli_options=global_cli_options, From 9387cab7c0477073ea473f53957707a1f15924f6 Mon Sep 17 00:00:00 2001 From: Bilel Omrani Date: Fri, 2 Jan 2026 03:50:06 +0100 Subject: [PATCH 3/3] docs(changelog): document output_dir and update monorepo guide --- docs/concepts/changelog_templates.rst | 16 +++ .../configuration-guides/monorepos.rst | 115 +++++++----------- docs/configuration/configuration.rst | 34 ++++++ 3 files changed, 93 insertions(+), 72 deletions(-) diff --git a/docs/concepts/changelog_templates.rst b/docs/concepts/changelog_templates.rst index d42a210d7..8a6c7bba2 100644 --- a/docs/concepts/changelog_templates.rst +++ b/docs/concepts/changelog_templates.rst @@ -408,6 +408,22 @@ Importantly, note the following: file ``ch-templates/static/config.cfg`` is *copied, not rendered* to the new top-level ``static`` folder. +.. tip:: + By default, templates are rendered relative to the project root. To render templates + to a different directory (e.g., ``docs/``), use the :ref:`output_dir ` + setting: + + .. code-block:: toml + + [tool.semantic_release.changelog] + template_dir = "ch-templates" + output_dir = "docs" + + This will render all templates to the ``docs/`` directory instead of the project root. + + This is particularly useful in :ref:`monorepo setups ` where you want to + consolidate changelogs into a shared documentation directory for multiple packages. + You may wish to leverage this behavior to modularize your changelog template, to define macros in a separate file, or to reference static data which you would like to avoid duplicating between your template environment and the remainder of your diff --git a/docs/configuration/configuration-guides/monorepos.rst b/docs/configuration/configuration-guides/monorepos.rst index 1f173998c..4753b32c7 100644 --- a/docs/configuration/configuration-guides/monorepos.rst +++ b/docs/configuration/configuration-guides/monorepos.rst @@ -181,6 +181,20 @@ Considerations ├── .gitignore └── README.md +.. tip:: + If you want changelogs written to a different directory (such as a package-specific + documentation folder), use the :ref:`output_dir ` setting + + .. code-block:: toml + + # FILE: pkg1/pyproject.toml + [tool.semantic_release.changelog] + output_dir = "docs" + + This writes the changelog to ``pkg1/docs/CHANGELOG.md`` using the default template, + without needing custom templates. See the :ref:`Advanced Example ` + for consolidated documentation across packages. + .. seealso:: - For situations with more complex documentation needs, see our :ref:`Advanced Example `. @@ -211,10 +225,9 @@ Considerations Example: Advanced """"""""""""""""" - -If you want to consolidate documentation into a single top-level directory, the setup becomes more complex. In this example, there is a common documentation folder at the top level, and each package has its own subfolder within the documentation folder. - -Due to naming conventions, PSR cannot automatically accomplish this with its default changelog templates. For this scenario, you must copy the internal PSR templates into a custom directory (even if you do not modify them) and add custom scripting to prepare for each release. +If you want to consolidate documentation into a single top-level directory, use the +:ref:`output_dir ` setting to write changelogs directly +to package-specific documentation folders. The directory structure looks like: @@ -223,29 +236,14 @@ The directory structure looks like: project/ ├── .git/ ├── docs/ - │ ├── source/ - │ │ ├── pkg1/ - │ │ │ ├── changelog.md - │ │ │ └── README.md - │ │ ├── pkg2/ - │ │ │ ├── changelog.md - │ │ │ └── README.md - │ │ └── index.rst - │ │ - │ └── templates/ - │ ├── .base_changelog_template/ - │ │ ├── components/ - │ │ │ ├── changelog_header.md.j2 - │ │ │ ├── changelog_init.md.j2 - │ │ │ ├── changelog_update.md.j2 - │ │ │ ├── changes.md.j2 - │ │ │ ├── first_release.md.j2 - │ │ │ ├── macros.md.j2 - │ │ │ ├── unreleased_changes.md.j2 - │ │ │ └── versioned_changes.md.j2 - │ │ └── changelog.md.j2 - │ ├── .gitignore - │ └── .release_notes.md.j2 + │ └── source/ + │ ├── pkg1/ + │ │ ├── changelog.md + │ │ └── README.md + │ ├── pkg2/ + │ │ ├── changelog.md + │ │ └── README.md + │ └── index.rst │ ├── packages/ │ ├── pkg1/ @@ -262,18 +260,14 @@ The directory structure looks like: │ │ └── __main__.py │ └── pyproject.toml │ - └── scripts/ - ├── release-pkg1.sh - └── release-pkg2.sh - + └── README.md -Each package should point to the ``docs/templates/`` directory to use a common release notes template. PSR ignores hidden files and directories when searching for template files to create, allowing you to hide shared templates in the directory for use in your release setup script. -Here is our configuration file for package 1 (package 2 is similarly defined): +Here is the configuration file for package 1: .. code-block:: toml - # FILE: pkg1/pyproject.toml + # FILE: packages/pkg1/pyproject.toml [project] name = "pkg1" version = "1.0.0" @@ -291,12 +285,12 @@ Here is our configuration file for package 1 (package 2 is similarly defined): [tool.semantic_release.commit_parser_options] path_filters = [ ".", - "../../../docs/source/pkg1/**", + "../../docs/source/pkg1/**", ] scope_prefix = "pkg1-" [tool.semantic_release.changelog] - template_dir = "../../../docs/templates" + output_dir = "../../docs/source/pkg1" mode = "update" exclude_commit_patterns = [ '''^chore(?:\([^)]*?\))?: .+''', @@ -308,51 +302,28 @@ Here is our configuration file for package 1 (package 2 is similarly defined): ] [tool.semantic_release.changelog.default_templates] - # To enable update mode: this value must set here because the default is not the - # same as the default in the other package & must be the final destination filename - # for the changelog relative to this file - changelog_file = "../../../docs/source/pkg1/changelog.md" + changelog_file = "changelog.md" -Note: In this configuration, we added path filters for additional documentation files related to the package so that the changelog will include documentation changes as well. +Package 2 follows the same pattern with its own paths and scope prefix. +Note: We added path filters for documentation files related to the package so that +documentation changes are included in the changelog. -Next, define a release script to set up the common changelog templates in the correct directory format so PSR will create the desired files at the proper locations. Following the :ref:`changelog-templates-template-rendering` reference, you must define the folder structure from the root of the project within the templates directory so PSR will properly lay down the files across the repository. The script cleans up any previous templates, dynamically creates the necessary directories, and copies over the shared templates into a package-named directory. Now you are prepared to run PSR for a release of ``pkg1``. +To release a package, simply run: .. code-block:: bash - #!/bin/bash - # FILE: scripts/release-pkg1.sh - - set -euo pipefail - - PROJECT_ROOT="$(dirname "$(dirname "$(readlink -f "${BASH_SOURCE[0]}")")")" - VIRTUAL_ENV="$PROJECT_ROOT/.venv" - - PACKAGE_NAME="pkg1" - - cd "$PROJECT_ROOT" || exit 1 - - # Setup documentation template - pushd "docs/templates" >/dev/null || exit 1 - - rm -rf docs/ - mkdir -p "docs/source/" - cp -r .base_changelog_template/ "docs/source/$PACKAGE_NAME" - - popd >/dev/null || exit 1 - - # Release the package - pushd "packages/$PACKAGE_NAME" >/dev/null || exit 1 - - printf '%s\n' "Releasing $PACKAGE_NAME..." - "$VIRTUAL_ENV/bin/semantic-release" -v version --no-push - - popd >/dev/null || exit 1 + cd packages/pkg1 + semantic-release version +The changelog will be written directly to ``docs/source/pkg1/changelog.md``. -That's it! This example demonstrates how to set up a monorepo with shared changelog templates and a consolidated documentation folder for multiple packages. +.. tip:: + For custom changelog formatting beyond the defaults, you can still use a custom + ``template_dir``. The ``output_dir`` setting controls where all templates (default + or custom) are rendered. See :ref:`changelog-templates` for template customization. .. seealso:: - - Advanced Example Monorepo: `codejedi365/psr-monorepo-poweralpha `_ + - Example Monorepo: `codejedi365/psr-monorepo-poweralpha `_ diff --git a/docs/configuration/configuration.rst b/docs/configuration/configuration.rst index 690a3e4c3..b44f1dae7 100644 --- a/docs/configuration/configuration.rst +++ b/docs/configuration/configuration.rst @@ -744,6 +744,40 @@ This option is discussed in more detail at :ref:`changelog-templates` ---- +.. _config-changelog-output_dir: + +``output_dir`` +************** + +**Type:** ``str`` + +Specifies the directory where rendered changelog templates will be written. By default, +templates are rendered to the project root directory. Use this setting to output the +changelog to a different location, such as a ``docs/`` subdirectory. + +When using the default changelog template (no custom ``template_dir``), the changelog +file will be placed at ``/``. For example, with +``output_dir = "docs"`` and the default ``changelog_file = "CHANGELOG.md"``, the +changelog will be written to ``docs/CHANGELOG.md``. + +When using custom templates via ``template_dir``, the entire template directory structure +will be rendered relative to ``output_dir`` instead of the project root. + +.. note:: + You cannot specify both ``output_dir`` and a directory path in ``changelog_file``. + Use ``output_dir`` for the destination directory and ``changelog_file`` for just the + filename. For example, use ``output_dir = "docs"`` with ``changelog_file = "CHANGELOG.md"`` + rather than ``changelog_file = "docs/CHANGELOG.md"``. + +**Default:** ``"."`` (project root) + +.. seealso:: + + - :ref:`monorepos` - Using ``output_dir`` to consolidate changelogs in monorepo documentation directories + - :ref:`changelog-templates` - Custom template directory rendering with ``output_dir`` + +---- + .. _config-commit_author: ``commit_author``