diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7fbc246b6..0040c3ce2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -43,41 +43,27 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - name: Get the changelog underline - id: changelog_underline + # towncrier writes the rendered notes to stdout (informational + # chatter goes to stderr), so this is the curated release body for + # this version, not github-tag-action's commit-derived changelog. + - name: Generate the GitHub release notes env: RELEASE: ${{ steps.calver.outputs.release }} - run: | - underline="$(echo "$RELEASE" | tr -c '\n' '-')" - echo "underline=${underline}" >> "$GITHUB_OUTPUT" - - - name: Update changelog - id: update_changelog - uses: jacobtomlinson/gha-find-replace@v3 - with: - find: "Next\n----" - replace: | - Next - ---- - - ${{ steps.calver.outputs.release }} - ${{ steps.changelog_underline.outputs.underline }} - include: CHANGELOG.rst - regex: false + run: uv run --extra=release towncrier build --draft --version "$RELEASE" > + release-notes.md - - name: Check Update changelog was modified + # Assemble the same fragments into CHANGELOG.rst under a new + # ``$RELEASE`` section and delete the consumed fragment files. + - name: Update the changelog env: - MODIFIED_FILES: ${{ steps.update_changelog.outputs.modifiedFiles }} - run: | - if [ "$MODIFIED_FILES" = "0" ]; then - echo "Error: No files were modified when updating changelog" - exit 1 - fi + RELEASE: ${{ steps.calver.outputs.release }} + run: uv run --extra=release towncrier build --yes --version "$RELEASE" + - uses: stefanzweifel/git-auto-commit-action@v7 id: commit with: commit_message: Bump CHANGELOG - file_pattern: CHANGELOG.rst + file_pattern: CHANGELOG.rst newsfragments # Error if there are no changes. skip_dirty_check: true @@ -96,7 +82,7 @@ jobs: tag: ${{ steps.tag_version.outputs.new_tag }} makeLatest: true name: Release ${{ steps.tag_version.outputs.new_tag }} - body: ${{ steps.tag_version.outputs.changelog }} + bodyFile: release-notes.md pypi: name: Publish to PyPI diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 5578d136b..227edfb5c 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,8 +1,7 @@ Changelog ========= -Next ----- +.. towncrier release notes start 2026.04.26 ---------- diff --git a/docs/source/conf.py b/docs/source/conf.py index 760f0dde1..7ffa7efca 100755 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -24,10 +24,19 @@ "sphinx_paramlinks", "sphinx_substitution_extensions", "sphinxcontrib.spelling", + "sphinxcontrib.towncrier.ext", "sphinxcontrib.autohttp.flask", "sphinx_toolbox.more_autodoc.autoprotocol", ] +# Render the unreleased ``newsfragments/`` entries into +# ``docs/source/unreleased.rst`` so the Sphinx spelling, doc-build and +# link-checking gates cover the prose before it is assembled into +# CHANGELOG.rst at release time. +towncrier_draft_autoversion_mode = "draft" +towncrier_draft_include_empty = True +towncrier_draft_working_directory = f"{_pyproject_file.parent}" + # Required by sphinx-toolbox 4.2.0rc1 for compatibility with Sphinx 9. # See https://github.com/sphinx-toolbox/sphinx-toolbox/issues/201#issuecomment-4313483053. autodoc_use_legacy_class_based = True diff --git a/docs/source/index.rst b/docs/source/index.rst index 04e81ebfe..3abf865f4 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -40,6 +40,7 @@ Reference .. toctree:: :hidden: + unreleased changelog release-process ci-setup diff --git a/docs/source/unreleased.rst b/docs/source/unreleased.rst new file mode 100644 index 000000000..22ac74723 --- /dev/null +++ b/docs/source/unreleased.rst @@ -0,0 +1,8 @@ +Unreleased changes +================== + +Changes that have landed on the main branch but are not yet part of a +tagged release. These entries are assembled into the +:doc:`changelog` when the next release is published. + +.. towncrier-draft-entries:: diff --git a/docs/towncrier_template.rst.jinja b/docs/towncrier_template.rst.jinja new file mode 100644 index 000000000..6da878330 --- /dev/null +++ b/docs/towncrier_template.rst.jinja @@ -0,0 +1,14 @@ + +{% for section_name, section in sections.items() %} +{% if section %} +{% for category, entries in section.items() %} +{% for text, _ in entries.items() %} +- {{ text }} + +{% endfor %} +{% endfor %} +{% else %} +No significant changes. + +{% endif %} +{% endfor %} diff --git a/newsfragments/.gitkeep b/newsfragments/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/pyproject.toml b/pyproject.toml index 8862df37f..caa7ac885 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -96,9 +96,13 @@ optional-dependencies.dev = [ "sphinx-toolbox==4.2.0rc1", "sphinxcontrib-httpdomain==2.0.0", "sphinxcontrib-spelling==8.0.2", + # ``sphinxcontrib-towncrier`` renders unreleased news fragments + # into docs/source/unreleased.rst during Sphinx builds. + "sphinxcontrib-towncrier==0.5.0a0", "strict-kwargs==2026.5.20", "sybil==10.0.1", "tenacity==9.1.4", + "towncrier==25.8.0", "ty==0.0.38", "types-docker==7.1.0.20260518", "types-pyyaml==6.0.12.20260518", @@ -314,6 +318,8 @@ ignore = [ "*.enc", "admin/**", "CHANGELOG.rst", + "newsfragments", + "newsfragments/**", "CODE_OF_CONDUCT.rst", "CONTRIBUTING.rst", "LICENSE", @@ -404,6 +410,30 @@ report.exclude_also = [ report.fail_under = 100 report.show_missing = true +[tool.towncrier] +# The changelog and the per-release GitHub release notes are both built +# from news fragments under ``newsfragments/``. The release workflow +# runs ``towncrier build`` to assemble them; contributors add one +# fragment file per user-facing change. +directory = "newsfragments" +filename = "CHANGELOG.rst" +# Custom template so an assembled version reproduces the historical +# style exactly: a bare ```` heading (no project name, no +# date) followed by a flat bullet list with no per-type sub-headings. +template = "docs/towncrier_template.rst.jinja" +title_format = "{version}" +# ``title_format`` underline first, then any nested headings. A bare +# version such as ``2026.05.18`` underlined with ``-`` matches every +# pre-towncrier entry in CHANGELOG.rst. +underlines = [ "-", "~", "^" ] +issue_format = "#{issue}" +type = [ + # A single, unnamed fragment type keeps the assembled output as one + # flat bullet list, matching the historical changelog (which never + # grouped entries under "Features"/"Bugfixes"/... sub-headings). + { directory = "change", name = "", showcontent = true }, +] + [tool.pydocstringformatter] write = true split-summary-body = false @@ -482,6 +512,9 @@ ignore_names = [ "DatabaseDict", "VuMarkDatabaseDict", "VuMarkTargetDict", + "towncrier_draft_autoversion_mode", + "towncrier_draft_include_empty", + "towncrier_draft_working_directory", ] # Duplicate some of .gitignore exclude = [ ".venv" ]