From 3e52a2334c46dc0d9cf4b00a43470c2259d78806 Mon Sep 17 00:00:00 2001 From: "John M. Kuchta" Date: Wed, 10 Jun 2026 11:08:19 -0700 Subject: [PATCH] docs: add towncrier changelog management Introduce towncrier-based changelog management to avoid CHANGELOG.md merge conflicts across the main/beta/httpx branch model. Contributors add one news fragment per user-facing change under changelog.d/; towncrier renders them into CHANGELOG.md at release time. - pyproject.toml: towncrier config + dev dependency - CHANGELOG.md: skeleton with towncrier marker - changelog.d/.gitignore: keep empty fragment dir tracked - create-release.yml: build + commit changelog before release - CONTRIBUTING.md, docs/releasing.md: fragment workflow docs Co-Authored-By: Claude Opus 4.8 --- .github/workflows/create-release.yml | 35 ++++++++++++++++++++-- .gitignore | 5 +++- CHANGELOG.md | 9 ++++++ CONTRIBUTING.md | 21 +++++++++++++ changelog.d/.gitignore | 1 + docs/releasing.md | 8 +++++ pyproject.toml | 44 ++++++++++++++++++++++++++++ uv.lock | 27 +++++++++++++++++ 8 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 changelog.d/.gitignore diff --git a/.github/workflows/create-release.yml b/.github/workflows/create-release.yml index 4ede64dd..f761dde7 100644 --- a/.github/workflows/create-release.yml +++ b/.github/workflows/create-release.yml @@ -12,10 +12,11 @@ on: types: [completed] branches: ['main', 'beta', 'httpx'] -# The release is created with a GitHub App token; the default token only needs -# read access for checkout and `gh release view`. +# The release is created with a GitHub App token. The "Build changelog" step +# commits the rendered CHANGELOG.md back to the release branch, so the default +# token needs write access to contents. permissions: - contents: read + contents: write jobs: create-release: @@ -80,6 +81,34 @@ jobs: exit 1 fi + # Render changelog.d/ fragments into CHANGELOG.md and commit before the + # release is cut. Reuses the version already read from pyproject.toml, so + # there is no separate towncrier version to keep in sync. --generate-notes + # below remains as a fallback for the GitHub release body. + - name: Build changelog + env: + GH_TOKEN: ${{ steps.app-token.outputs.token }} + run: | + VERSION="${{ steps.version.outputs.version }}" + BRANCH="${{ inputs.branch || github.event.workflow_run.head_branch }}" + + # Nothing to do if there are no fragments to consume. + if ! ls changelog.d/*.md >/dev/null 2>&1; then + echo "No changelog fragments; skipping towncrier build." + exit 0 + fi + + pip install --quiet towncrier + towncrier build --yes --version "$VERSION" + + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add CHANGELOG.md changelog.d/ + if ! git diff --cached --quiet; then + git commit -m "docs: update changelog for $VERSION" + git push origin "HEAD:$BRANCH" + fi + - name: Create release if: steps.check-release.outputs.exists == 'false' env: diff --git a/.gitignore b/.gitignore index 56e0d59b..261f6fac 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,7 @@ venv/ .DS_Store # Project -.scratch/ \ No newline at end of file +.scratch/ + +# Git worktrees +.worktrees/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 00000000..ef55270a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog + +All notable changes to this library are documented here. + +This file is maintained with [towncrier](https://towncrier.readthedocs.io/); +add a news fragment under `changelog.d/` for every user-facing change. See +[CONTRIBUTING.md](CONTRIBUTING.md) for the fragment format. + + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 343ad828..f208d604 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,27 @@ uv sync - Generated API scope files (`meraki/api/`, `meraki/aio/api/`) are auto-generated from the OpenAPI spec. Changes here will be overwritten. Fix the generator instead. - Do not vendor or bundle dependencies. +## Changelog Fragments + +Every user-facing change needs a news fragment in `changelog.d/`. Do not edit `CHANGELOG.md` by hand; towncrier builds it at release time. + +File name: `changelog.d/{issue}.{type}.md`. With no issue, use a slug prefixed with `+`: `changelog.d/+httpx-migration.changed.md`. + +Types: `added`, `changed`, `deprecated`, `removed`, `fixed`, `security`. + +```bash +# Issue-linked +echo "Retry on 429 now honors Retry-After." > changelog.d/1234.fixed.md + +# Or via towncrier +uv run towncrier create -c "Retry on 429 now honors Retry-After." 1234.fixed.md + +# Preview the rendered changelog without consuming fragments +uv run towncrier build --draft --version "$(grep '^version' pyproject.toml | sed 's/version = "\(.*\)"/\1/')" +``` + +One fragment per change. Multiple fragments per issue are fine (e.g. `1234.added.md` and `1234.fixed.md`). Fragments are not wiped by library regeneration; they survive until the next release build. + ## Running the Generator ```bash diff --git a/changelog.d/.gitignore b/changelog.d/.gitignore new file mode 100644 index 00000000..f935021a --- /dev/null +++ b/changelog.d/.gitignore @@ -0,0 +1 @@ +!.gitignore diff --git a/docs/releasing.md b/docs/releasing.md index 4de91678..76e0862c 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -46,6 +46,14 @@ Starting from `main` at `3.1.0`, `beta` at `3.1.0`, `httpx` at `4.0.0`: When a new prerelease is detected, the `enable-early-access.yml` workflow runs first (waited on synchronously). This enables early access features on the test organizations so the generated beta and dev SDKs can be tested against them. +## Changelog management + +`CHANGELOG.md` is generated by [towncrier](https://towncrier.readthedocs.io/) from news fragments in `changelog.d/`. Contributors add one fragment per user-facing change (see [CONTRIBUTING.md](../CONTRIBUTING.md) for the format); nobody edits `CHANGELOG.md` directly. + +The `create-release.yml` workflow runs `towncrier build --yes --version ` after version validation and before `gh release create`. The version comes from the same `pyproject.toml` read that drives the release tag, so there is no separate towncrier version to keep in sync. The build renders the fragments into `CHANGELOG.md`, deletes the consumed fragments, and commits both changes to the release branch before the GitHub release is cut. + +This runs only at release time, not during regeneration. `regenerate-library.yml` rewrites generated code under `meraki/` but never touches `changelog.d/` or `CHANGELOG.md`, so fragments added on `main`, `beta`, or `httpx` survive regeneration PRs and are consumed only by the next release on that branch. Each branch keeps its own fragments and accumulates them independently across the three-branch model. + ## Release branches and PRs | Stage | Source branch | Release branch | PR target | diff --git a/pyproject.toml b/pyproject.toml index 1e2592ce..9ef081c1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -41,6 +41,7 @@ dev = [ "flake8>=7.0,<8", "pre-commit>=4.6.0", "ruff>=0.15.12", + "towncrier>=24.8.0", ] generator = ["jinja2==3.1.6"] @@ -63,3 +64,46 @@ omit = ["meraki/api/*", "meraki/aio/api/*", "meraki/aio/__init__.py"] fail_under = 90 show_missing = true exclude_lines = ["pragma: no cover", "if __name__"] + +[tool.towncrier] +directory = "changelog.d" +filename = "CHANGELOG.md" +name = "meraki" +title_format = "## {version} ({project_date})" +issue_format = "[#{issue}](https://github.com/meraki/dashboard-api-python/issues/{issue})" +start_string = "\n" +wrap = false +all_bullets = true + +# Version is passed via `--version` from a single source of truth (pyproject +# [project].version) at build time, so there is no version pinned here to drift. + +[[tool.towncrier.type]] +directory = "added" +name = "Added" +showcontent = true + +[[tool.towncrier.type]] +directory = "changed" +name = "Changed" +showcontent = true + +[[tool.towncrier.type]] +directory = "deprecated" +name = "Deprecated" +showcontent = true + +[[tool.towncrier.type]] +directory = "removed" +name = "Removed" +showcontent = true + +[[tool.towncrier.type]] +directory = "fixed" +name = "Fixed" +showcontent = true + +[[tool.towncrier.type]] +directory = "security" +name = "Security" +showcontent = true diff --git a/uv.lock b/uv.lock index e8a71beb..4ef13e84 100644 --- a/uv.lock +++ b/uv.lock @@ -201,6 +201,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/20/94/c5790835a017658cbfabd07f3bfb549140c3ac458cfc196323996b10095a/charset_normalizer-3.4.2-py3-none-any.whl", hash = "sha256:7f56930ab0abd1c45cd15be65cc741c28b1c9a34876ce8c17a2fa107810c0af0", size = 52626, upload-time = "2025-05-02T08:34:40.053Z" }, ] +[[package]] +name = "click" +version = "8.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9b/98/518d8e5081007684232226f475082b30087d0f585e8457db087298259f49/click-8.4.1.tar.gz", hash = "sha256:918b5633eddf6b41c32d4f454bf0de810065c74e3f7dbf8ee5452f8be88d3e96", size = 353007, upload-time = "2026-05-22T04:08:37.769Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/0d/67e5b4109ea4a837e80daa87c2c696711955e40449a97e8926672534def2/click-8.4.1-py3-none-any.whl", hash = "sha256:482be17c6991b8c19c5429a1e995d9b0efdbb63172824c41f99965dc0ade8ec2", size = 116639, upload-time = "2026-05-22T04:08:35.26Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -537,6 +549,7 @@ dev = [ { name = "pytest-cov" }, { name = "responses" }, { name = "ruff" }, + { name = "towncrier" }, ] generator = [ { name = "jinja2" }, @@ -557,6 +570,7 @@ dev = [ { name = "pytest-cov", specifier = ">=7.1.0,<8" }, { name = "responses", specifier = ">=0.25,<1" }, { name = "ruff", specifier = ">=0.15.12" }, + { name = "towncrier", specifier = ">=24.8.0" }, ] generator = [{ name = "jinja2", specifier = "==3.1.6" }] @@ -1008,6 +1022,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[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 = "typing-extensions" version = "4.13.2"