From ccca672ccc497d5fac42b84f11216fb85ec36c5e Mon Sep 17 00:00:00 2001 From: Adam Dangoor Date: Wed, 20 May 2026 12:04:58 +0100 Subject: [PATCH] Drive changelog releases from towncrier --- .github/workflows/release.yml | 41 ++++++++++--------------------- CHANGELOG.rst | 3 +-- docs/source/conf.py | 9 +++++++ docs/source/index.rst | 1 + docs/source/unreleased.rst | 8 ++++++ docs/towncrier_template.rst.jinja | 14 +++++++++++ newsfragments/.gitkeep | 0 pyproject.toml | 33 +++++++++++++++++++++++++ uv.lock | 30 ++++++++++++++++++++++ 9 files changed, 109 insertions(+), 30 deletions(-) create mode 100644 docs/source/unreleased.rst create mode 100644 docs/towncrier_template.rst.jinja create mode 100644 newsfragments/.gitkeep diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0abd7187..f59301d4 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -52,36 +52,21 @@ 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" - name: Update VERSION file for Nix flake env: @@ -93,7 +78,7 @@ jobs: id: commit with: commit_message: Bump CHANGELOG and VERSION - file_pattern: CHANGELOG.rst VERSION + file_pattern: CHANGELOG.rst newsfragments VERSION # Error if there are no changes. skip_dirty_check: true @@ -203,7 +188,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 build-linux: name: Build Linux binary (${{ matrix.binary.name }}) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 23cf9ed5..3c6a7d06 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -1,8 +1,7 @@ Changelog ========= -Next ----- +.. towncrier release notes start 2026.02.22 ---------- diff --git a/docs/source/conf.py b/docs/source/conf.py index 6140c8f5..9b7fa2bb 100755 --- a/docs/source/conf.py +++ b/docs/source/conf.py @@ -22,11 +22,20 @@ extensions = [ "sphinx_copybutton", "sphinxcontrib.spelling", + "sphinxcontrib.towncrier.ext", "sphinx_click.ext", "sphinx_inline_tabs", "sphinx_substitution_extensions", ] +# 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 25754db6..78fe3f2c 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -20,4 +20,5 @@ Reference commands 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 574a347e..48158ae6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -75,12 +75,16 @@ 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", # Listed explicitly (despite being transitive via vws-python-mock) so that # [tool.uv.sources] can redirect to the CPU-only PyTorch index. # See: https://vws-python.github.io/vws-python-mock/installation.html#faster-installation "strict-kwargs==2026.5.19.post3", "torch>=2.5.1", "torchvision>=0.20.1", + "towncrier==25.8.0", "ty==0.0.38", "types-pyyaml==6.0.12.20260518", "vulture==2.16", @@ -277,6 +281,8 @@ ignore = [ "*.enc", ".pre-commit-config.yaml", "CHANGELOG.rst", + "newsfragments", + "newsfragments/**", "CODE_OF_CONDUCT.rst", "CONTRIBUTING.rst", "LICENSE", @@ -350,6 +356,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 @@ -410,6 +440,9 @@ ignore_names = [ "spelling_word_list_filename", "templates_path", "warning_is_error", + "towncrier_draft_autoversion_mode", + "towncrier_draft_include_empty", + "towncrier_draft_working_directory", ] exclude = [ # Duplicate some of .gitignore diff --git a/uv.lock b/uv.lock index da73ac1f..3faee74c 100644 --- a/uv.lock +++ b/uv.lock @@ -2036,6 +2036,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/6e/c5/bcd32aa919c9e1652cca5bed6478202656a320c407793b547f8c16c179e3/sphinxcontrib_spelling-8.0.2-py3-none-any.whl", hash = "sha256:db8b3b2945683d49e87a8a5133d2b8ed4206cb593038b986ca8686a485f9980d", size = 14587, upload-time = "2025-11-28T15:31:48.957Z" }, ] +[[package]] +name = "sphinxcontrib-towncrier" +version = "0.5.0a0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "sphinx" }, + { name = "towncrier" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/21/fe/72ed57093e28af10595c50839b183c5fdf0952482e9ef0ca6eb90eb85c5d/sphinxcontrib_towncrier-0.5.0a0.tar.gz", hash = "sha256:294e69df6e275e7a86df7ea6a927cc7c28c2c370a884cd5c45de6ec989858f27", size = 62453, upload-time = "2025-02-28T01:59:16.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ac/5c/f7e39f243636a5e1894f2f5a72579977bf3968922afdb75175ee45062066/sphinxcontrib_towncrier-0.5.0a0-py3-none-any.whl", hash = "sha256:11d130c3ad5e4649821d543c4ea7ab64bbe78df4d859ef94f4298e7845dc0f59", size = 12609, upload-time = "2025-02-28T01:59:15.178Z" }, +] + [[package]] name = "stevedore" version = "5.6.0" @@ -2282,6 +2295,19 @@ wheels = [ { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.27.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:c8b3c995d4294551b5ab33cbcf60d700819dc23d53b21a9c74936e521c88de33", upload-time = "2026-05-12T16:20:37Z" }, ] +[[package]] +name = "towncrier" +version = "25.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "jinja2" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c2/eb/5bf25a34123698d3bbab39c5bc5375f8f8bcbcc5a136964ade66935b8b9d/towncrier-25.8.0.tar.gz", hash = "sha256:eef16d29f831ad57abb3ae32a0565739866219f1ebfbdd297d32894eb9940eb1", size = 76322, upload-time = "2025-08-30T11:41:55.393Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/06/8ba22ec32c74ac1be3baa26116e3c28bc0e76a5387476921d20b6fdade11/towncrier-25.8.0-py3-none-any.whl", hash = "sha256:b953d133d98f9aeae9084b56a3563fd2519dfc6ec33f61c9cd2c61ff243fb513", size = 65101, upload-time = "2025-08-30T11:41:53.644Z" }, +] + [[package]] name = "trove-classifiers" version = "2026.1.14.14" @@ -2455,11 +2481,13 @@ dev = [ { name = "sphinx-pyproject" }, { name = "sphinx-substitution-extensions" }, { name = "sphinxcontrib-spelling" }, + { name = "sphinxcontrib-towncrier" }, { name = "strict-kwargs" }, { name = "torch", version = "2.12.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, { name = "torch", version = "2.12.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, { name = "torchvision", version = "0.27.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version < '3.15' and sys_platform == 'darwin'" }, { name = "torchvision", version = "0.27.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "python_full_version >= '3.15' or sys_platform != 'darwin'" }, + { name = "towncrier" }, { name = "ty" }, { name = "types-pyyaml" }, { name = "vulture" }, @@ -2514,9 +2542,11 @@ requires-dist = [ { name = "sphinx-pyproject", marker = "extra == 'dev'", specifier = "==0.3.0" }, { name = "sphinx-substitution-extensions", marker = "extra == 'dev'", specifier = "==2026.1.12" }, { name = "sphinxcontrib-spelling", marker = "extra == 'dev'", specifier = "==8.0.2" }, + { name = "sphinxcontrib-towncrier", marker = "extra == 'dev'", specifier = "==0.5.0a0" }, { name = "strict-kwargs", marker = "extra == 'dev'", specifier = "==2026.5.19.post3" }, { name = "torch", marker = "extra == 'dev'", specifier = ">=2.5.1", index = "https://download.pytorch.org/whl/cpu" }, { name = "torchvision", marker = "extra == 'dev'", specifier = ">=0.20.1", index = "https://download.pytorch.org/whl/cpu" }, + { name = "towncrier", marker = "extra == 'dev'", specifier = "==25.8.0" }, { name = "ty", marker = "extra == 'dev'", specifier = "==0.0.38" }, { name = "types-pyyaml", marker = "extra == 'dev'", specifier = "==6.0.12.20260518" }, { name = "vulture", marker = "extra == 'dev'", specifier = "==2.16" },