diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a723a2b4..44d64e25 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -47,37 +47,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\n----\n\n${{ steps.calver.outputs.release }}\n${{ steps.changelog_underline.outputs.underline\ - \ }}\n" - 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 +86,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 - name: Build a binary wheel and a source tarball env: diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 56a5d300..b6688933 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,8 +1,7 @@ Changelog ========= -Next ----- +.. towncrier release notes start 2026.02.25.1 ------------ diff --git a/docs/source/conf.py b/docs/source/conf.py index 920ba1a4..12553403 100755 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -23,8 +23,17 @@ "sphinx.ext.napoleon", "sphinx_substitution_extensions", "sphinxcontrib.spelling", + "sphinxcontrib.towncrier.ext", ] +# 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}" + templates_path = ["_templates"] source_suffix = ".rst" master_doc = "index" diff --git a/docs/source/index.rst b/docs/source/index.rst index c61c2a30..ae571282 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -124,4 +124,5 @@ Reference exceptions contributing release-process + unreleased changelog diff --git a/docs/source/unreleased.rst b/docs/source/unreleased.rst new file mode 100644 index 00000000..22ac7472 --- /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 00000000..6da87833 --- /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 00000000..e69de29b diff --git a/pyproject.toml b/pyproject.toml index c9b8b1b3..91ac42e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -76,6 +76,9 @@ optional-dependencies.dev = [ "sphinx-pyproject==0.3.0", "sphinx-substitution-extensions==2026.1.12", "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.19.post3", "sybil==9.3.0", # Listed explicitly (despite being transitive via vws-python-mock) so that @@ -83,6 +86,7 @@ optional-dependencies.dev = [ # See: https://vws-python.github.io/vws-python-mock/installation.html#faster-installation "torch>=2.5.1", "torchvision>=0.20.1", + "towncrier==25.8.0", "ty==0.0.37", "types-requests==2.33.0.20260518", "vulture==2.16", @@ -284,6 +288,8 @@ ignore = [ "*.enc", ".pre-commit-config.yaml", "CHANGELOG.rst", + "newsfragments", + "newsfragments/**", "CODE_OF_CONDUCT.rst", "CONTRIBUTING.rst", "LICENSE", @@ -349,6 +355,30 @@ report.exclude_also = [ ] 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 @@ -413,6 +443,9 @@ ignore_names = [ "spelling_word_list_filename", "templates_path", "warning_is_error", + "towncrier_draft_autoversion_mode", + "towncrier_draft_include_empty", + "towncrier_draft_working_directory", ] # Duplicate some of .gitignore exclude = [ ".venv" ]