Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/concepts/changelog_templates.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <config-changelog-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 <monorepos>` 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
Expand Down
115 changes: 43 additions & 72 deletions docs/configuration/configuration-guides/monorepos.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 <config-changelog-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 <monorepos-config-example_advanced>`
for consolidated documentation across packages.

.. seealso::

- For situations with more complex documentation needs, see our :ref:`Advanced Example <monorepos-config-example_advanced>`.
Expand Down Expand Up @@ -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 <config-changelog-output_dir>` setting to write changelogs directly
to package-specific documentation folders.

The directory structure looks like:

Expand All @@ -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/
Expand All @@ -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"
Expand All @@ -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(?:\([^)]*?\))?: .+''',
Expand All @@ -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 <https://github.com/codejedi365/psr-monorepo-poweralpha>`_
- Example Monorepo: `codejedi365/psr-monorepo-poweralpha <https://github.com/codejedi365/psr-monorepo-poweralpha>`_
34 changes: 34 additions & 0 deletions docs/configuration/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -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 ``<output_dir>/<changelog_file_name>``. 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``
Expand Down
23 changes: 18 additions & 5 deletions src/semantic_release/cli/changelog_writer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)

Expand All @@ -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,
)

Expand All @@ -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,
Expand Down
47 changes: 47 additions & 0 deletions src/semantic_release/cli/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,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
Expand Down Expand Up @@ -163,6 +182,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
Expand Down Expand Up @@ -567,6 +587,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, ...]
Expand Down Expand Up @@ -825,12 +846,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,
Expand Down Expand Up @@ -908,6 +954,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,
Expand Down
Loading