From 49efab80f81356eec2d670e8c66644d7e56a748a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 11:13:32 +0000 Subject: [PATCH] Align versioned docs with PyPI releases (#47) Deploy MkDocs with mike only after a successful PyPI publish (separate workflow), migrate History.md into docs/release-notes.md, and auto-fill missing release-note sections from merged PRs on tag publish. Co-authored-by: Liron Ilouz --- .cursor/rules/diataxis-docs.mdc | 4 +- .github/workflows/docs.yml | 120 +++++++---- .github/workflows/publish.yml | 68 ++++++- MANIFEST.in | 2 +- Readme.md | 9 +- docs/index.md | 1 + History.md => docs/release-notes.md | 20 +- mkdocs.yml | 17 +- requirements-docs.txt | 1 + scripts/update_release_notes_from_prs.py | 243 +++++++++++++++++++++++ setup.py | 2 +- 11 files changed, 418 insertions(+), 69 deletions(-) rename History.md => docs/release-notes.md (71%) create mode 100644 scripts/update_release_notes_from_prs.py diff --git a/.cursor/rules/diataxis-docs.mdc b/.cursor/rules/diataxis-docs.mdc index fa5bdd7..8b00c22 100644 --- a/.cursor/rules/diataxis-docs.mdc +++ b/.cursor/rules/diataxis-docs.mdc @@ -37,4 +37,6 @@ This repository's docs follow [Diátaxis](https://diataxis.fr/). Keep them consi ## Published site -Configured by `mkdocs.yml` (Material). GitHub Pages workflow: `.github/workflows/docs.yml`. +Configured by `mkdocs.yml` (Material + mike version picker). Changelog: `docs/release-notes.md`. + +GitHub Pages is the `gh-pages` branch (mike). The Deploy docs workflow builds on docs PRs and deploys a versioned site only after a successful Publish to PyPI run (`workflow_run`), not on every `master` docs push. diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index ff610fc..53f4e90 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -1,43 +1,43 @@ name: Deploy docs -# Test on this PR: the build job runs automatically on pull_request. -# Test full Pages deploy before merge: Actions → Deploy docs → Run workflow -# (select this branch). Requires Pages source = GitHub Actions in repo settings. +# PR / path changes: build-only (mkdocs --strict). +# Versioned deploy (mike → gh-pages) runs after a successful "Publish to PyPI" +# workflow, or via workflow_dispatch for manual recovery. +# +# One-time repo setting: Pages source = Deploy from a branch → gh-pages / (root). + on: - push: - branches: [master] - paths: - - "docs/**" - - "docs/assets/**" - - "mkdocs.yml" - - "requirements-docs.txt" - - ".github/workflows/docs.yml" pull_request: types: [opened, synchronize, reopened, ready_for_review] paths: - "docs/**" - - "docs/assets/**" - "mkdocs.yml" - "requirements-docs.txt" - ".github/workflows/docs.yml" + workflow_run: + workflows: ["Publish to PyPI"] + types: [completed] workflow_dispatch: inputs: - deploy: - description: "Upload artifact and deploy to GitHub Pages" + version: + description: "Docs version to deploy (e.g. 0.7.0, without v prefix)" + required: true + type: string + update_latest: + description: "Also update the latest alias and set it as default" type: boolean default: true permissions: contents: read - pages: write - id-token: write concurrency: - group: pages + group: docs-${{ github.event_name }}-${{ github.ref }} cancel-in-progress: false jobs: build: + if: github.event_name == 'pull_request' runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -60,24 +60,76 @@ jobs: - name: Build site run: mkdocs build --strict --clean - - name: Upload Pages artifact - if: > - github.event_name == 'push' || - (github.event_name == 'workflow_dispatch' && inputs.deploy) - uses: actions/upload-pages-artifact@v3 - with: - path: site - deploy: if: > - github.event_name == 'push' || - (github.event_name == 'workflow_dispatch' && inputs.deploy) - needs: build + (github.event_name == 'workflow_run' && + github.event.workflow_run.conclusion == 'success') || + github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest - environment: - name: github-pages - url: ${{ steps.deployment.outputs.page_url }} + permissions: + contents: write steps: - - name: Deploy to GitHub Pages - id: deployment - uses: actions/deploy-pages@v4 + - name: Checkout master (includes post-publish release notes) + uses: actions/checkout@v4 + with: + ref: master + fetch-depth: 0 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install docs dependencies + run: pip install -r requirements-docs.txt + + - name: Resolve version + id: ver + env: + EVENT_NAME: ${{ github.event_name }} + DISPATCH_VERSION: ${{ inputs.version }} + HEAD_SHA: ${{ github.event.workflow_run.head_sha }} + HEAD_BRANCH: ${{ github.event.workflow_run.head_branch }} + UPDATE_LATEST_INPUT: ${{ inputs.update_latest }} + run: | + set -euo pipefail + if [ "$EVENT_NAME" = "workflow_dispatch" ]; then + VERSION="${DISPATCH_VERSION#v}" + UPDATE_LATEST="${UPDATE_LATEST_INPUT:-true}" + else + git fetch --tags origin + VERSION="" + if [[ "${HEAD_BRANCH:-}" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + VERSION="${HEAD_BRANCH#v}" + elif [ -n "${HEAD_SHA:-}" ]; then + TAG=$(git tag --points-at "$HEAD_SHA" | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' | sort -V | tail -1 || true) + if [ -n "$TAG" ]; then + VERSION="${TAG#v}" + fi + fi + if [ -z "$VERSION" ]; then + echo "Could not resolve release version from workflow_run (branch=$HEAD_BRANCH sha=$HEAD_SHA)" + exit 1 + fi + UPDATE_LATEST=true + fi + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "update_latest=$UPDATE_LATEST" >> "$GITHUB_OUTPUT" + echo "Deploying docs version $VERSION (update_latest=$UPDATE_LATEST)" + + - name: Configure git for mike + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Deploy with mike + env: + VERSION: ${{ steps.ver.outputs.version }} + UPDATE_LATEST: ${{ steps.ver.outputs.update_latest }} + run: | + set -euo pipefail + if [ "$UPDATE_LATEST" = "true" ]; then + mike deploy --push --update-aliases "$VERSION" latest + mike set-default --push latest + else + mike deploy --push "$VERSION" + fi diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0893a46..a95f9ba 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,12 +1,31 @@ name: Publish to PyPI +# Releasing +# --------- +# 1. Bump `__version__` in tapsdk/__version__.py. +# Optionally pre-write the matching section in docs/release-notes.md +# (if omitted, this workflow inserts one from PRs merged into master). +# 2. Merge the release commit into master. +# 3. Create and push an annotated tag matching the package version: +# +# git tag -a v0.7.0 -m "Release 0.7.0" +# git push origin v0.7.0 +# +# This workflow runs lint/tests, ensures release notes, builds the sdist/wheel, +# and uploads to PyPI via Trusted Publishing. Versioned docs deploy separately +# in "Deploy docs" after this workflow succeeds (so a docs failure cannot fail +# the PyPI publish). +# +# Maintainers must configure PyPI Trusted Publishing for project tap-python-sdk, +# repository TapWithUs/tap-python-sdk, and a GitHub `pypi` environment. + on: push: tags: - "v*" permissions: - contents: read + contents: write id-token: write jobs: @@ -17,9 +36,9 @@ jobs: os: [ubuntu-latest, windows-latest, macos-latest] python-version: ["3.9", "3.10", "3.11"] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - name: Install dependencies @@ -38,11 +57,15 @@ jobs: runs-on: ubuntu-latest environment: pypi steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: "3.11" + - name: Validate tag version matches package version run: | TAG="${GITHUB_REF_NAME#v}" @@ -52,16 +75,27 @@ jobs: exit 1 fi echo "Version validated: $PACKAGE_VERSION" + + - name: Ensure release notes section + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_SHA: ${{ github.sha }} + run: python scripts/update_release_notes_from_prs.py + - name: Install build dependencies run: | python -m pip install --upgrade pip pip install build + - name: Build package run: python -m build + - name: Check package metadata run: | pip install twine twine check dist/* + # Import package/version only — avoid TapSDK/bleak, which needs bluetoothctl on Linux. - name: Smoke test built wheel run: | @@ -69,11 +103,35 @@ jobs: /tmp/wheel-venv/bin/pip install --upgrade pip /tmp/wheel-venv/bin/pip install dist/*.whl /tmp/wheel-venv/bin/python -c "import tapsdk; from tapsdk.__version__ import __version__; print(__version__)" + - name: Smoke test built sdist run: | python -m venv /tmp/sdist-venv /tmp/sdist-venv/bin/pip install --upgrade pip /tmp/sdist-venv/bin/pip install dist/*.tar.gz /tmp/sdist-venv/bin/python -c "import tapsdk; from tapsdk.__version__ import __version__; print(__version__)" + - name: Publish to PyPI uses: pypa/gh-action-pypi-publish@release/v1 + + - name: Commit release notes to master if changed + run: | + set -euo pipefail + if git diff --quiet -- docs/release-notes.md; then + echo "No release notes changes to commit" + exit 0 + fi + VERSION="${GITHUB_REF_NAME#v}" + cp docs/release-notes.md /tmp/release-notes.md + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git fetch origin master + git checkout -B _release_notes origin/master + cp /tmp/release-notes.md docs/release-notes.md + git add docs/release-notes.md + if git diff --cached --quiet; then + echo "master already has the updated release notes" + exit 0 + fi + git commit -m "docs: add release notes for ${VERSION}" + git push origin HEAD:master diff --git a/MANIFEST.in b/MANIFEST.in index 219957b..0ecf124 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,6 +1,6 @@ -include History.md include LICENSE include Readme.md +include docs/release-notes.md recursive-include tests * recursive-exclude * __pycache__ diff --git a/Readme.md b/Readme.md index 8561afe..6a9a1d0 100644 --- a/Readme.md +++ b/Readme.md @@ -8,7 +8,7 @@ BLE SDK for building Python apps that connect to **Tap Strap** and **TapXR**, se ### Documentation -Published docs (MkDocs Material): [https://tapwithus.github.io/tap-python-sdk/](https://tapwithus.github.io/tap-python-sdk/) +Published docs (MkDocs Material, versioned with mike): [https://tapwithus.github.io/tap-python-sdk/](https://tapwithus.github.io/tap-python-sdk/) Pick the path that matches your goal: @@ -18,6 +18,7 @@ Pick the path that matches your goal: | Solve a specific task | [How-to guides](docs/how-to/index.md) | | Look up APIs and types | [Reference](docs/reference/index.md) | | Understand modes and sensors | [Explanation](docs/explanation/index.md) | +| Read the changelog | [Release notes](docs/release-notes.md) | Full index: [docs/index.md](docs/index.md). Local preview: `pip install -r requirements-docs.txt && mkdocs serve`. @@ -56,7 +57,11 @@ Pair the Tap with the OS first. Update firmware with Tap Manager. More complete ### Migrating from 0.6.x -Breaking API changes are listed in [Migrate from 0.6](docs/how-to/migrate-from-0.6.md) and [History.md](History.md). +Breaking API changes are listed in [Migrate from 0.6](docs/how-to/migrate-from-0.6.md) and [Release notes](docs/release-notes.md). + +### Releasing + +PyPI releases publish on annotated `v*` tags. Docs deploy separately after a successful publish. See the header comments in [`.github/workflows/publish.yml`](.github/workflows/publish.yml). ### Testing diff --git a/docs/index.md b/docs/index.md index 9dc8248..d17ce80 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,6 +8,7 @@ Pick the section that matches what you need: | Solve a specific task | [How-to guides](how-to/index.md) | | Look up an API, type, or event | [Reference](reference/index.md) | | Understand how modes and sensors work | [Explanation](explanation/index.md) | +| Read the changelog for a PyPI release | [Release notes](release-notes.md) | ## Package diff --git a/History.md b/docs/release-notes.md similarity index 71% rename from History.md rename to docs/release-notes.md index c42fee8..c3e1a4c 100644 --- a/History.md +++ b/docs/release-notes.md @@ -1,21 +1,6 @@ -# History +# Release notes -## Releasing - -PyPI releases are published automatically when a version tag is pushed to GitHub. - -1. Bump `__version__` in `tapsdk/__version__.py` and update this file. -2. Merge the release changes into `develop`, then into `master` as needed. -3. Create and push an annotated tag whose name matches the package version (for example `v0.7.0`): - - ```bash - git tag -a v0.7.0 -m "Release 0.7.0" - git push origin v0.7.0 - ``` - -The `Publish to PyPI` workflow runs the same lint and test matrix as CI, verifies that the tag (without the `v` prefix) matches `tapsdk.__version__`, builds the package with `python -m build`, and uploads it to PyPI using Trusted Publishing. - -Maintainers must configure PyPI Trusted Publishing for the `tap-python-sdk` project name, the `TapWithUs/tap-python-sdk` repository, and a GitHub `pypi` environment before the first automated release. +Changelog for published `tap-python-sdk` releases on PyPI. ## 0.7.0 (2026-06-09) ______________________ @@ -84,4 +69,3 @@ ______________________ ### Main features * SDK created. - diff --git a/mkdocs.yml b/mkdocs.yml index 4f758ff..eb0a76c 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -41,6 +41,15 @@ theme: plugins: - search +extra: + version: + provider: mike + social: + - icon: fontawesome/brands/github + link: https://github.com/TapWithUs/tap-python-sdk + - icon: fontawesome/brands/python + link: https://pypi.org/project/tap-python-sdk/ + markdown_extensions: - admonition - attr_list @@ -82,10 +91,4 @@ nav: - Connection model: explanation/connection-model.md - Input modes: explanation/input-modes.md - Raw sensors: explanation/raw-sensors.md - -extra: - social: - - icon: fontawesome/brands/github - link: https://github.com/TapWithUs/tap-python-sdk - - icon: fontawesome/brands/python - link: https://pypi.org/project/tap-python-sdk/ + - Release notes: release-notes.md diff --git a/requirements-docs.txt b/requirements-docs.txt index d752c76..ff4cec4 100644 --- a/requirements-docs.txt +++ b/requirements-docs.txt @@ -1,2 +1,3 @@ mkdocs>=1.6,<2 mkdocs-material>=9.5,<10 +mike>=2.1,<3 diff --git a/scripts/update_release_notes_from_prs.py b/scripts/update_release_notes_from_prs.py new file mode 100644 index 0000000..074c8e2 --- /dev/null +++ b/scripts/update_release_notes_from_prs.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Ensure docs/release-notes.md has a section for the release version. + +If the section is already present, the file is left unchanged. +Otherwise a new section is inserted from PRs merged into master between the +previous v* tag and the current release commit. + +Usage: + python scripts/update_release_notes_from_prs.py [--version X.Y.Z] [--repo OWNER/REPO] + +Environment: + GITHUB_TOKEN / GH_TOKEN — optional; raises API rate limits + GITHUB_REPOSITORY — default owner/repo when --repo is omitted + GITHUB_SHA — release commit (defaults to HEAD) +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import urllib.error +import urllib.request +from datetime import date +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +RELEASE_NOTES = ROOT / "docs" / "release-notes.md" +SECTION_RE = re.compile(r"^## (\d+\.\d+\.\d+)(?:\s|\(|$)", re.MULTILINE) + + +def run_git(*args: str) -> str: + return subprocess.check_output(["git", *args], cwd=ROOT, text=True).strip() + + +def package_version() -> str: + about: dict[str, str] = {} + version_file = ROOT / "tapsdk" / "__version__.py" + exec(version_file.read_text(encoding="utf-8"), about) + return about["__version__"] + + +def previous_tag(current_version: str) -> str | None: + tags = list_version_tags() + # Tags strictly older than current version + older = [] + cur = tuple(int(p) for p in current_version.split(".")) + for tag in tags: + ver = tuple(int(p) for p in tag[1:].split(".")) + if ver < cur: + older.append(tag) + if not older: + return None + return older[0] # highest among older (list_version_tags is desc) + + +def list_version_tags() -> list[str]: + raw = run_git("tag", "-l", "v*") + tags = raw.splitlines() if raw else [] + # Prefer tags that look like vX.Y.Z + version_tags = [t for t in tags if re.fullmatch(r"v\d+\.\d+\.\d+", t)] + if not version_tags: + return [] + + def key(tag: str) -> tuple[int, ...]: + return tuple(int(p) for p in tag[1:].split(".")) + + return sorted(version_tags, key=key, reverse=True) + + +def github_headers() -> dict[str, str]: + headers = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "tap-python-sdk-release-notes", + } + token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN") + if token: + headers["Authorization"] = f"Bearer {token}" + return headers + + +def api_get(url: str) -> object: + req = urllib.request.Request(url, headers=github_headers()) + try: + with urllib.request.urlopen(req) as resp: + return json.load(resp) + except urllib.error.HTTPError as exc: + body = exc.read().decode("utf-8", errors="replace") + raise SystemExit(f"GitHub API error {exc.code} for {url}: {body}") from exc + + +def merged_prs(owner: str, repo: str, base_sha: str | None, head_sha: str) -> list[dict]: + """Return merged PRs for commits reachable from head but not base.""" + if base_sha: + try: + commit_range = run_git("log", "--format=%H", f"{base_sha}..{head_sha}") + except subprocess.CalledProcessError: + commit_range = run_git("log", "--format=%H", head_sha) + else: + commit_range = run_git("log", "--format=%H", head_sha) + + shas = commit_range.splitlines() if commit_range else [] + if not shas: + return [] + + # Prefer the commits/{sha}/pulls association API (handles squash merges). + by_number: dict[int, dict] = {} + for sha in shas: + url = f"https://api.github.com/repos/{owner}/{repo}/commits/{sha}/pulls" + try: + prs = api_get(url) + except SystemExit: + continue + if not isinstance(prs, list): + continue + for pr in prs: + if pr.get("merged_at") or pr.get("merged"): + by_number[pr["number"]] = pr + + selected = list(by_number.values()) + selected.sort(key=lambda p: p.get("merged_at") or "") + return selected + + +def has_section(text: str, version: str) -> bool: + for match in SECTION_RE.finditer(text): + if match.group(1) == version: + return True + return False + + +def format_section(version: str, prs: list[dict]) -> str: + today = date.today().isoformat() + lines = [ + f"## {version} ({today})", + "______________________", + "### Main features", + "", + ] + if prs: + for pr in prs: + title = (pr.get("title") or "").strip().rstrip(".") + number = pr["number"] + lines.append(f"* {title} (#{number})") + else: + lines.append("* Release packaging and documentation updates.") + lines.append("") + return "\n".join(lines) + + +def insert_section(text: str, section: str) -> str: + # Insert after the title block (first heading + optional intro paragraphs) + lines = text.splitlines(keepends=True) + if not lines: + return f"# Release notes\n\n{section}" + + # Find end of preamble: after first H1 and following non-heading lines, + # before the first ## version section. + insert_at = 0 + seen_h1 = False + for i, line in enumerate(lines): + if line.startswith("# ") and not line.startswith("## "): + seen_h1 = True + insert_at = i + 1 + continue + if seen_h1 and line.startswith("## "): + insert_at = i + break + if seen_h1: + insert_at = i + 1 + + # Ensure blank line before inserted section + before = "".join(lines[:insert_at]).rstrip() + "\n\n" + after = "".join(lines[insert_at:]).lstrip() + return before + section + ("\n" if after and not section.endswith("\n") else "") + after + + +def parse_repo(repo: str) -> tuple[str, str]: + owner, _, name = repo.partition("/") + if not owner or not name: + raise SystemExit(f"Invalid repo {repo!r}; expected OWNER/REPO") + return owner, name + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", help="Release version (default: tapsdk.__version__)") + parser.add_argument( + "--repo", + default=os.environ.get("GITHUB_REPOSITORY", "TapWithUs/tap-python-sdk"), + help="GitHub OWNER/REPO", + ) + parser.add_argument( + "--head-sha", + default=os.environ.get("GITHUB_SHA") or None, + help="Release commit SHA (default: GITHUB_SHA or HEAD)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Print the new section without writing the file", + ) + args = parser.parse_args(argv) + + version = args.version or package_version() + text = RELEASE_NOTES.read_text(encoding="utf-8") + if has_section(text, version): + print(f"Release notes already include ## {version}; leaving unchanged.") + return 0 + + head_sha = args.head_sha or run_git("rev-parse", "HEAD") + prev = previous_tag(version) + base_sha = None + if prev: + try: + base_sha = run_git("rev-list", "-n", "1", prev) + except subprocess.CalledProcessError: + base_sha = None + print(f"Collecting PRs since {prev} ({base_sha}) → {head_sha}") + else: + print(f"No previous v* tag found; collecting PRs reachable from {head_sha}") + + owner, repo = parse_repo(args.repo) + prs = merged_prs(owner, repo, base_sha, head_sha) + print(f"Found {len(prs)} merged PR(s) in range.") + + section = format_section(version, prs) + if args.dry_run: + print(section) + return 0 + + updated = insert_section(text, section) + RELEASE_NOTES.write_text(updated, encoding="utf-8") + print(f"Inserted ## {version} into {RELEASE_NOTES.relative_to(ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/setup.py b/setup.py index 226063a..c904e5c 100644 --- a/setup.py +++ b/setup.py @@ -31,7 +31,7 @@ here = os.path.abspath(os.path.dirname(__file__)) with io.open(os.path.join(here, "Readme.md"), encoding="utf-8") as f: long_description = "\n" + f.read() -with io.open(os.path.join(here, "History.md"), encoding="utf-8") as f: +with io.open(os.path.join(here, "docs", "release-notes.md"), encoding="utf-8") as f: long_description += "\n\n" + f.read() # Load the package's __version__.py module as a dictionary.