From 49efab80f81356eec2d670e8c66644d7e56a748a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 22 Jul 2026 11:13:32 +0000 Subject: [PATCH 1/4] 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. From 540fc30da683b4e54855e55849e25d4e9516580d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 13:12:35 +0000 Subject: [PATCH 2/4] Rework release flow: author-written changelog, verify-only pipeline, test dedup - docs/release-notes.md gains an Unreleased section maintained per PR - changelog.yml enforces an Unreleased entry per PR (skip-changelog label bypass) - scripts/prepare_release.py cuts releases locally (bump + rename Unreleased); removes PR-scraping generator - publish.yml is verify-only (no file mutation, contents: read) and checks that release notes were prepared - docs.yml deploys from the tagged release commit, not master - extract reusable tests.yml called by CI and publish, gating publish (#39) Co-authored-by: Liron Ilouz --- .cursor/rules/diataxis-docs.mdc | 4 +- .github/workflows/changelog.yml | 62 ++++++ .github/workflows/docs.yml | 6 +- .github/workflows/publish.yml | 105 ++++------ .github/workflows/test.yml | 24 +-- .github/workflows/tests.yml | 33 +++ Readme.md | 22 +- docs/release-notes.md | 10 + scripts/prepare_release.py | 139 +++++++++++++ scripts/update_release_notes_from_prs.py | 243 ----------------------- 10 files changed, 316 insertions(+), 332 deletions(-) create mode 100644 .github/workflows/changelog.yml create mode 100644 .github/workflows/tests.yml create mode 100644 scripts/prepare_release.py delete mode 100644 scripts/update_release_notes_from_prs.py diff --git a/.cursor/rules/diataxis-docs.mdc b/.cursor/rules/diataxis-docs.mdc index 8b00c22..2864596 100644 --- a/.cursor/rules/diataxis-docs.mdc +++ b/.cursor/rules/diataxis-docs.mdc @@ -37,6 +37,6 @@ This repository's docs follow [Diátaxis](https://diataxis.fr/). Keep them consi ## Published site -Configured by `mkdocs.yml` (Material + mike version picker). Changelog: `docs/release-notes.md`. +Configured by `mkdocs.yml` (Material + mike version picker). Changelog: `docs/release-notes.md`, which keeps an `## Unreleased` section maintained per PR (renamed to the version at release time by `scripts/prepare_release.py`). -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. +GitHub Pages is the `gh-pages` branch (mike). The Deploy docs workflow builds on docs PRs and deploys a versioned site (from the tagged release commit) only after a successful Publish to PyPI run (`workflow_run`), not on every `master` docs push. diff --git a/.github/workflows/changelog.yml b/.github/workflows/changelog.yml new file mode 100644 index 0000000..c28e7d7 --- /dev/null +++ b/.github/workflows/changelog.yml @@ -0,0 +1,62 @@ +name: Changelog + +# Every PR must add a user-facing entry under the "## Unreleased" section of +# docs/release-notes.md. Trivial PRs (CI, refactors, typo fixes) can bypass this +# by adding the "skip-changelog" label. + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review, labeled, unlabeled] + +permissions: + contents: read + +concurrency: + group: changelog-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + verify: + runs-on: ubuntu-latest + if: ${{ !contains(github.event.pull_request.labels.*.name, 'skip-changelog') }} + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + ref: ${{ github.event.pull_request.head.sha }} + + - name: Require a new Unreleased entry + env: + BASE_REF: ${{ github.base_ref }} + run: | + set -euo pipefail + FILE="docs/release-notes.md" + git fetch origin "$BASE_REF" --depth=1 + + count_unreleased_bullets() { + # Reads a file on stdin, prints the number of bullet lines that live + # under the first "## Unreleased" heading (until the next "## "). + awk ' + /^## / { + in_block = ($0 ~ /^## Unreleased([[:space:]]|$)/) ? 1 : 0 + next + } + in_block && /^[[:space:]]*[*-][[:space:]]+/ { n++ } + END { print n + 0 } + ' + } + + HEAD_BULLETS=$(count_unreleased_bullets < "$FILE") + if git show "origin/${BASE_REF}:${FILE}" > /tmp/base-release-notes.md 2>/dev/null; then + BASE_BULLETS=$(count_unreleased_bullets < /tmp/base-release-notes.md) + else + BASE_BULLETS=0 + fi + + echo "Unreleased bullets: base=${BASE_BULLETS} head=${HEAD_BULLETS}" + + if [ "$HEAD_BULLETS" -le "$BASE_BULLETS" ]; then + echo "::error file=${FILE}::Add a bullet under the '## Unreleased' section in ${FILE}, or label this PR 'skip-changelog'." + exit 1 + fi + echo "Changelog entry found." diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 53f4e90..d1d4701 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -69,10 +69,12 @@ jobs: permissions: contents: write steps: - - name: Checkout master (includes post-publish release notes) + - name: Checkout the tagged release commit uses: actions/checkout@v4 with: - ref: master + # workflow_run: the commit Publish ran on (the tagged release commit). + # workflow_dispatch: the vX.Y.Z tag for the requested version. + ref: ${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || format('v{0}', inputs.version) }} fetch-depth: 0 - uses: actions/setup-python@v5 diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a95f9ba..b8e9ae7 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,20 +1,22 @@ 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: +# Releasing (prep-commit-then-tag; this pipeline only verifies, never mutates) +# --------------------------------------------------------------------------- +# 1. Land PRs on master. Each PR adds its entry under "## Unreleased" in +# docs/release-notes.md (enforced by the Changelog workflow). +# 2. Cut the release locally: # -# git tag -a v0.7.0 -m "Release 0.7.0" -# git push origin v0.7.0 +# python scripts/prepare_release.py X.Y.Z +# # review the diff, then: +# git add tapsdk/__version__.py docs/release-notes.md +# git commit -m "Release X.Y.Z" +# git tag -a vX.Y.Z -m "Release X.Y.Z" +# git push origin HEAD vX.Y.Z # -# 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). +# This workflow re-runs the reusable test matrix on the tagged commit, verifies +# the tag matches tapsdk.__version__ and that release notes were prepared, then +# builds and uploads to PyPI via Trusted Publishing. Versioned docs deploy +# separately in "Deploy docs" after this workflow succeeds. # # Maintainers must configure PyPI Trusted Publishing for project tap-python-sdk, # repository TapWithUs/tap-python-sdk, and a GitHub `pypi` environment. @@ -25,32 +27,12 @@ on: - "v*" permissions: - contents: write + contents: read id-token: write jobs: test: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.9", "3.10", "3.11"] - steps: - - uses: actions/checkout@v4 - - name: Set up Python - uses: actions/setup-python@v5 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" - - name: Lint with flake8 - run: | - pip install flake8 - flake8 examples tapsdk tests - - name: Run tests - run: pytest -v + uses: ./.github/workflows/tests.yml publish: needs: test @@ -76,12 +58,33 @@ jobs: fi echo "Version validated: $PACKAGE_VERSION" - - name: Ensure release notes section + - name: Verify release notes were prepared env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GITHUB_REPOSITORY: ${{ github.repository }} - GITHUB_SHA: ${{ github.sha }} - run: python scripts/update_release_notes_from_prs.py + VERSION: ${{ github.ref_name }} + run: | + set -euo pipefail + FILE="docs/release-notes.md" + VER="${VERSION#v}" + + if ! grep -qE "^## ${VER//./\\.} \(" "$FILE"; then + echo "::error file=${FILE}::No '## ${VER} ()' section found. Run scripts/prepare_release.py ${VER} before tagging." + exit 1 + fi + + # The Unreleased section must be empty (entries moved into the version). + UNRELEASED_BULLETS=$(awk ' + /^## / { + in_block = ($0 ~ /^## Unreleased([[:space:]]|$)/) ? 1 : 0 + next + } + in_block && /^[[:space:]]*[*-][[:space:]]+/ { n++ } + END { print n + 0 } + ' "$FILE") + if [ "$UNRELEASED_BULLETS" -ne 0 ]; then + echo "::error file=${FILE}::'## Unreleased' still has ${UNRELEASED_BULLETS} entrie(s); run scripts/prepare_release.py to move them into ${VER}." + exit 1 + fi + echo "Release notes verified for ${VER}." - name: Install build dependencies run: | @@ -113,25 +116,3 @@ jobs: - 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/.github/workflows/test.yml b/.github/workflows/test.yml index e50c48a..73f0739 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -6,25 +6,5 @@ on: branches: [master] jobs: - build: - runs-on: ${{ matrix.os }} - strategy: - matrix: - os: [ubuntu-latest, windows-latest, macos-latest] - python-version: ["3.9", "3.10", "3.11"] - steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -e ".[dev]" - - name: Lint with flake8 - run: | - pip install flake8 - flake8 examples tapsdk tests - - name: Run tests - run: pytest -v + test: + uses: ./.github/workflows/tests.yml diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..594efcc --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,33 @@ +name: Tests + +# Reusable lint + test matrix. Single source of truth called by the CI workflow +# (on PRs and pushes to master) and by the Publish workflow (on tags), so the +# matrix is defined once and publish is gated on the same tests (#39). + +on: + workflow_call: + +jobs: + test: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + python-version: ["3.9", "3.10", "3.11"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + - name: Lint with flake8 + run: | + pip install flake8 + flake8 examples tapsdk tests + - name: Run tests + run: pytest -v diff --git a/Readme.md b/Readme.md index 6a9a1d0..dd31508 100644 --- a/Readme.md +++ b/Readme.md @@ -59,9 +59,29 @@ Pair the Tap with the OS first. Update firmware with Tap Manager. More complete 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). +### Contributing + +Every pull request should add a user-facing entry under the **Unreleased** +heading in [Release notes](docs/release-notes.md). PRs with no user-facing change +(CI, refactors, typo fixes) can skip this by adding the `skip-changelog` label. + ### 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). +Releases use a prep-commit-then-tag flow so the tag, PyPI artifact, and docs all +match: + +```bash +python scripts/prepare_release.py X.Y.Z # bumps version, cuts Unreleased -> X.Y.Z +git add tapsdk/__version__.py docs/release-notes.md +git commit -m "Release X.Y.Z" +git tag -a vX.Y.Z -m "Release X.Y.Z" +git push origin HEAD vX.Y.Z +``` + +Pushing the tag runs [`.github/workflows/publish.yml`](.github/workflows/publish.yml), +which re-runs tests, verifies the version and release notes, and publishes to +PyPI. Versioned docs deploy separately after a successful publish. See the header +comments in that workflow for details. ### Testing diff --git a/docs/release-notes.md b/docs/release-notes.md index c3e1a4c..def9668 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -2,6 +2,16 @@ Changelog for published `tap-python-sdk` releases on PyPI. +Add user-facing changes for the next release under **Unreleased** in your pull +request. At release time `scripts/prepare_release.py` renames this section to the +new version and opens a fresh empty one. + +## Unreleased +______________________ +### Main features + +### Bug fixes + ## 0.7.0 (2026-06-09) ______________________ ### Main features diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py new file mode 100644 index 0000000..94e3e97 --- /dev/null +++ b/scripts/prepare_release.py @@ -0,0 +1,139 @@ +#!/usr/bin/env python3 +"""Prepare a release commit: bump the version and cut the changelog. + +This keeps the release pipeline read-only. Run it locally, review the diff, +then commit and tag the result (the script prints the exact commands). + +What it does: + 1. Sets tapsdk/__version__.py to X.Y.Z. + 2. Renames the "## Unreleased" section in docs/release-notes.md to + "## X.Y.Z (YYYY-MM-DD)" and inserts a fresh, empty "## Unreleased". + +It refuses to run if the current Unreleased section has no bullet entries. + +Usage: + python scripts/prepare_release.py X.Y.Z [--date YYYY-MM-DD] +""" + +from __future__ import annotations + +import argparse +import re +import sys +from datetime import date +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +VERSION_FILE = ROOT / "tapsdk" / "__version__.py" +RELEASE_NOTES = ROOT / "docs" / "release-notes.md" + +VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") +BULLET_RE = re.compile(r"^\s*[*-]\s+\S") +UNRELEASED_HEADING = "## Unreleased" + +EMPTY_UNRELEASED = ( + "## Unreleased\n" + "______________________\n" + "### Main features\n" + "\n" + "### Bug fixes\n" +) + + +def fail(message: str) -> None: + raise SystemExit(f"error: {message}") + + +def split_unreleased(text: str) -> tuple[str, list[str], str]: + """Return (before, unreleased_block_lines, after). + + `unreleased_block_lines` covers the "## Unreleased" heading through the line + before the next "## " heading. + """ + lines = text.splitlines(keepends=True) + start = None + for i, line in enumerate(lines): + if line.rstrip("\n") == UNRELEASED_HEADING or line.startswith( + UNRELEASED_HEADING + " " + ): + start = i + break + if start is None: + fail(f"no '{UNRELEASED_HEADING}' heading found in {RELEASE_NOTES}") + + end = len(lines) + for j in range(start + 1, len(lines)): + if lines[j].startswith("## "): + end = j + break + + return "".join(lines[:start]), lines[start:end], "".join(lines[end:]) + + +def bump_version(version: str) -> None: + VERSION_FILE.write_text(f'__version__ = "{version}"\n', encoding="utf-8") + print(f"Set {VERSION_FILE.relative_to(ROOT)} to {version}") + + +def cut_changelog(version: str, release_date: str) -> None: + text = RELEASE_NOTES.read_text(encoding="utf-8") + + if re.search(rf"^## {re.escape(version)}(?:\s|\(|$)", text, re.MULTILINE): + fail(f"docs/release-notes.md already has a '## {version}' section") + + before, block, after = split_unreleased(text) + + if not any(BULLET_RE.match(line) for line in block[1:]): + fail( + "the '## Unreleased' section has no entries; nothing to release. " + "Add bullets before preparing a release." + ) + + # Replace the heading line with the versioned heading; keep the entries. + versioned = [f"## {version} ({release_date})\n"] + block[1:] + released_block = "".join(versioned).rstrip("\n") + "\n" + + new_text = ( + before + + EMPTY_UNRELEASED + + "\n" + + released_block + + ("\n" + after.lstrip("\n") if after.strip() else "") + ) + RELEASE_NOTES.write_text(new_text, encoding="utf-8") + print( + f"Renamed Unreleased -> {version} ({release_date}) in " + f"{RELEASE_NOTES.relative_to(ROOT)} and opened a fresh Unreleased section" + ) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("version", help="Release version, e.g. 0.8.0") + parser.add_argument( + "--date", + default=date.today().isoformat(), + help="Release date (YYYY-MM-DD); defaults to today", + ) + args = parser.parse_args(argv) + + version = args.version.lstrip("v") + if not VERSION_RE.match(version): + fail(f"invalid version {args.version!r}; expected X.Y.Z") + + bump_version(version) + cut_changelog(version, args.date) + + tag = f"v{version}" + print() + print("Review the changes, then commit and tag:") + print(" git add tapsdk/__version__.py docs/release-notes.md") + print(f' git commit -m "Release {version}"') + print(f' git tag -a {tag} -m "Release {version}"') + print(" git push origin HEAD") + print(f" git push origin {tag}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/update_release_notes_from_prs.py b/scripts/update_release_notes_from_prs.py deleted file mode 100644 index 074c8e2..0000000 --- a/scripts/update_release_notes_from_prs.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/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()) From 39d93796b334bf931cb5b88fdc4e9364292af5cd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 27 Jul 2026 13:53:43 +0000 Subject: [PATCH 3/4] docs: add Unreleased entries for this PR Satisfy the new changelog check by recording the user-facing docs and release-flow changes introduced in #48. Co-authored-by: Liron Ilouz --- docs/release-notes.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/release-notes.md b/docs/release-notes.md index def9668..20d59db 100644 --- a/docs/release-notes.md +++ b/docs/release-notes.md @@ -10,6 +10,10 @@ new version and opens a fresh empty one. ______________________ ### Main features +* Versioned docs site (mike) deployed after successful PyPI publish, with a release notes page derived from `docs/release-notes.md` (#47) (#48) +* Prep-commit-then-tag release flow: author-written `Unreleased` entries, `scripts/prepare_release.py`, and a verify-only publish pipeline (#47) (#48) +* Shared reusable test workflow used by CI and Publish (#39) (#48) + ### Bug fixes ## 0.7.0 (2026-06-09) From d0cb3326f8d88b794c6f59880c4386468e7ab22b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 28 Jul 2026 09:09:15 +0000 Subject: [PATCH 4/4] fix: prepare_release validates before writing version Build and validate the cut changelog in memory first, then write __version__.py and release-notes.md. A failed Unreleased check no longer leaves a half-applied version bump. Co-authored-by: Liron Ilouz --- scripts/prepare_release.py | 26 +++++++++++++++----------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/scripts/prepare_release.py b/scripts/prepare_release.py index 94e3e97..74c8160 100644 --- a/scripts/prepare_release.py +++ b/scripts/prepare_release.py @@ -5,9 +5,10 @@ then commit and tag the result (the script prints the exact commands). What it does: - 1. Sets tapsdk/__version__.py to X.Y.Z. - 2. Renames the "## Unreleased" section in docs/release-notes.md to - "## X.Y.Z (YYYY-MM-DD)" and inserts a fresh, empty "## Unreleased". + 1. Validates the Unreleased section and builds the cut changelog in memory. + 2. Sets tapsdk/__version__.py to X.Y.Z. + 3. Writes docs/release-notes.md with Unreleased renamed to + "## X.Y.Z (YYYY-MM-DD)" and a fresh empty "## Unreleased". It refuses to run if the current Unreleased section has no bullet entries. @@ -75,7 +76,8 @@ def bump_version(version: str) -> None: print(f"Set {VERSION_FILE.relative_to(ROOT)} to {version}") -def cut_changelog(version: str, release_date: str) -> None: +def prepare_changelog(version: str, release_date: str) -> str: + """Validate and return the updated release-notes text (does not write).""" text = RELEASE_NOTES.read_text(encoding="utf-8") if re.search(rf"^## {re.escape(version)}(?:\s|\(|$)", text, re.MULTILINE): @@ -93,18 +95,13 @@ def cut_changelog(version: str, release_date: str) -> None: versioned = [f"## {version} ({release_date})\n"] + block[1:] released_block = "".join(versioned).rstrip("\n") + "\n" - new_text = ( + return ( before + EMPTY_UNRELEASED + "\n" + released_block + ("\n" + after.lstrip("\n") if after.strip() else "") ) - RELEASE_NOTES.write_text(new_text, encoding="utf-8") - print( - f"Renamed Unreleased -> {version} ({release_date}) in " - f"{RELEASE_NOTES.relative_to(ROOT)} and opened a fresh Unreleased section" - ) def main(argv: list[str] | None = None) -> int: @@ -121,8 +118,15 @@ def main(argv: list[str] | None = None) -> int: if not VERSION_RE.match(version): fail(f"invalid version {args.version!r}; expected X.Y.Z") + # Validate and build the new changelog before writing anything, so a failed + # check cannot leave __version__.py bumped with notes uncut. + new_notes = prepare_changelog(version, args.date) bump_version(version) - cut_changelog(version, args.date) + RELEASE_NOTES.write_text(new_notes, encoding="utf-8") + print( + f"Renamed Unreleased -> {version} ({args.date}) in " + f"{RELEASE_NOTES.relative_to(ROOT)} and opened a fresh Unreleased section" + ) tag = f"v{version}" print()