From 63169a7644647d4f6770d3a9f8d3e797c6db5df1 Mon Sep 17 00:00:00 2001 From: Andy Date: Sat, 6 Jun 2026 11:31:22 -0400 Subject: [PATCH] feat: add versioning and changelog system Conventional-Commit-driven version bumps (scripts/bump.py), a commit-msg validation hook, and a /release-notes workflow that produces a human-reviewed CHANGELOG.md. Adds an in-app version badge + 'What's new' dialog on the launcher. The version is single-sourced from pyproject.toml (cim_suite.__version__), and the deterministic bump backbone lives in scripts/release/ with tests in tests/release/. Marks the 1.0.0 baseline. Co-Authored-By: Claude Opus 4.8 (1M context) --- .claude/commands/release-notes.md | 44 +++++++ .gitattributes | 2 + .githooks/commit-msg | 16 +++ CHANGELOG.md | 28 ++++ CLAUDE.md | 13 ++ README.md | 40 ++++++ cim_suite/__init__.py | 63 ++++++++- cim_suite/core/ui/theme/stylesheet.py | 18 +++ cim_suite/modules/da07/__init__.py | 4 +- cim_suite/modules/da12/__init__.py | 4 +- cim_suite/modules/iomodbus/__init__.py | 4 +- cim_suite/shell/launcher.py | 20 ++- cim_suite/shell/whats_new.py | 44 +++++++ packaging/suite.spec | 7 + pyproject.toml | 2 +- scripts/bump.py | 75 +++++++++++ scripts/commit_msg_hook.py | 37 ++++++ scripts/release/__init__.py | 8 ++ scripts/release/commits.py | 154 ++++++++++++++++++++++ scripts/release/gitio.py | 50 +++++++ scripts/release/repo.py | 40 ++++++ scripts/release/versioning.py | 67 ++++++++++ scripts/release_notes.py | 84 ++++++++++++ scripts/setup_hooks.py | 33 +++++ tests/release/__init__.py | 0 tests/release/conftest.py | 14 ++ tests/release/test_commits.py | 173 +++++++++++++++++++++++++ tests/release/test_gitio.py | 61 +++++++++ tests/release/test_versioning.py | 92 +++++++++++++ tests/shell/test_whats_new.py | 28 ++++ 30 files changed, 1219 insertions(+), 6 deletions(-) create mode 100644 .claude/commands/release-notes.md create mode 100644 .githooks/commit-msg create mode 100644 CHANGELOG.md create mode 100644 cim_suite/shell/whats_new.py create mode 100644 scripts/bump.py create mode 100644 scripts/commit_msg_hook.py create mode 100644 scripts/release/__init__.py create mode 100644 scripts/release/commits.py create mode 100644 scripts/release/gitio.py create mode 100644 scripts/release/repo.py create mode 100644 scripts/release/versioning.py create mode 100644 scripts/release_notes.py create mode 100644 scripts/setup_hooks.py create mode 100644 tests/release/__init__.py create mode 100644 tests/release/conftest.py create mode 100644 tests/release/test_commits.py create mode 100644 tests/release/test_gitio.py create mode 100644 tests/release/test_versioning.py create mode 100644 tests/shell/test_whats_new.py diff --git a/.claude/commands/release-notes.md b/.claude/commands/release-notes.md new file mode 100644 index 0000000..f98fe4e --- /dev/null +++ b/.claude/commands/release-notes.md @@ -0,0 +1,44 @@ +--- +description: Draft plain-language CHANGELOG.md release notes from commits since the last version, for human review before committing. +--- + +You are drafting the human-facing release notes for the CIMTechniques Service Suite. +The git history is the technical record; `CHANGELOG.md` is what non-technical users +read. Accuracy is paramount — **never invent a user-facing benefit a commit doesn't +support**, and when a commit's user impact is unclear, flag it for the user instead of +guessing. + +Follow these steps exactly: + +1. **Collect the commits.** Run: + ``` + .venv\Scripts\python scripts/release_notes.py --json + ``` + If there is no version tag yet and the digest reaches back into pre-rewrite history, + ask the user for a `--baseline ` and re-run with it. The JSON gives you + `current_version`, `suggested_version`, `suggested_bump`, and commits bucketed into + `features`, `fixes`, `breaking`, and an `internal_count`. + +2. **Draft entries** in Keep a Changelog format for the **suggested_version** (or a + version the user specifies), dated today: + - Group under **Added / Changed / Fixed / Removed** — omit any empty group. + - `feat` commits → usually Added (or Changed if they alter existing behavior). + `fix` → Fixed. `breaking` → call out clearly (often Changed/Removed). + - Write for a non-technical reader: explain **what** changed and **why it matters to + them**, not the implementation. One bullet per user-visible change; merge related + commits into a single clear bullet. + - Collapse all internal work (refactor/test/chore/style/docs/build/ci) into at most a + single line: "Maintenance and under-the-hood improvements." Drop it if there were + none. + - Flag any commit whose user impact you're unsure of, and ask rather than guess. + +3. **Show the draft to the user** in chat and ask them to edit/approve. Do **not** write + the file yet. + +4. **Only after approval**, prepend the approved entry above the previous version's entry + in `CHANGELOG.md` (reverse-chronological). Do not auto-commit; leave the change in the + working tree for the user unless they ask you to commit it. + +5. If the version number should change, remind the user to run + `.venv\Scripts\python scripts/bump.py` (which is deterministic and edits + `pyproject.toml`). You never set the version number yourself. diff --git a/.gitattributes b/.gitattributes index e545696..31ea5ce 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,7 @@ # Normalize line endings; keep legacy VB6 sources as-is. * text=auto eol=lf +# The commit-msg hook is run by git's sh; CRLF would break the shebang. Pin LF. +/.githooks/** text eol=lf *.frm text eol=crlf *.bas text eol=crlf *.vbp text eol=crlf diff --git a/.githooks/commit-msg b/.githooks/commit-msg new file mode 100644 index 0000000..912013b --- /dev/null +++ b/.githooks/commit-msg @@ -0,0 +1,16 @@ +#!/bin/sh +# Validate the commit message against the Conventional Commit format. +# Delegates to scripts/commit_msg_hook.py (logic is unit-tested in tests/release). +# Installed via: python scripts/setup_hooks.py (sets core.hooksPath -> .githooks) + +hook_dir=$(dirname "$0") +script="$hook_dir/../scripts/commit_msg_hook.py" + +for py in python python3 py; do + if command -v "$py" >/dev/null 2>&1; then + exec "$py" "$script" "$1" + fi +done + +echo "commit-msg hook: no python interpreter found on PATH; skipping validation" >&2 +exit 0 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7a7872a --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,28 @@ +# Changelog + +All notable changes to the CIMTechniques Service Suite are recorded here in plain +language, for the people who use it. (The full technical record lives in the project's +git history.) + +This project follows [Keep a Changelog](https://keepachangelog.com/) and +[Semantic Versioning](https://semver.org/): the version number is `MAJOR.MINOR.PATCH`. + +## 1.0.0 - 2026-06-06 + +First release of the unified **CIMTechniques Service Suite** — one application that +brings the separate legacy service tools together under a single, modern interface. + +### Added +- **One app for every instrument.** A start page where you pick your service cable / + COM port once, then open the tool you need. +- **DA-12 Monitoring Station Service Tool** — configure and monitor DA-12 sensor + stations: sensors, alarm limits, statistics, and calibration. +- **DA-07 (eLink) Service Tool** — set up and monitor DA-07 stations, their pods, + and channels. +- **IOModbus Service Utility** — read and write supported Modbus devices using a + built-in device catalog. +- **Setting history.** The suite remembers configuration changes made to your DA-12 + and DA-07 stations, so you can see what changed and when. +- **Export to spreadsheet.** Any data screen can be saved to an Excel (`.xlsx`) file. +- **What's new.** The current version and these release notes are shown right on the + start page. diff --git a/CLAUDE.md b/CLAUDE.md index 48f2f17..cadf632 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -213,4 +213,17 @@ test fixtures. checklist. - **Spreadsheet export:** every data screen can be exported to `.xlsx`; the engine is `cim_suite/core/export/` and the per-module adoption checklist is in `docs/EXPORT.md`. +- **Versioning & changelog:** SemVer, single-sourced from `[project] version` in + `pyproject.toml` (`cim_suite.__version__` reads it; the frozen build bundles the file). + Commits are Conventional Commits (`: `) — validated by `.githooks/commit-msg` + (install once with `python scripts/setup_hooks.py`). The deterministic + `python scripts/bump.py` computes the next version from commit prefixes + (`fix`=patch, `feat`=minor, `BREAKING CHANGE`/`!`=major) — **never let an LLM pick the + bump.** The pure logic lives in `scripts/release/` (tested in `tests/release/`); git I/O + is isolated in `scripts/release/gitio.py`. **Two records, two audiences:** git history is + the technical record; `CHANGELOG.md` is plain-language release notes for non-technical + users (also shown via the launcher's "What's new" dialog). The changelog *prose* is the + only LLM-assisted step — draft it with the `/release-notes` command, and **never commit + it unreviewed**; accuracy over flattery (don't invent user benefits a commit doesn't + support). ``` diff --git a/README.md b/README.md index 48316b5..6918d52 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,45 @@ A green `pytest` and a clean `ruff` are both part of "done" here. & "C:\Program Files (x86)\Inno Setup 6\ISCC.exe" packaging\installer.iss ``` +## Versioning & releases + +The suite uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`). There +are **two records of change, with different audiences**: + +- **Git history** — the technical record. Every commit uses the + [Conventional Commit](https://www.conventionalcommits.org/) format `: `. +- **[`CHANGELOG.md`](CHANGELOG.md)** — the human-facing record: plain-language release + notes for the (non-technical) people who use the app. It's also shown in-app via the + **What's new** button on the start page. + +| Commit type | Meaning | Version bump | In changelog? | +|---|---|---|---| +| `feat` | A new user-facing capability | **minor** | Yes (Added / Changed) | +| `fix` | A bug fix | **patch** | Yes (Fixed) | +| `feat!` / `BREAKING CHANGE:` in body | Removes or breaks existing behavior | **major** | Yes (Changed / Removed) | +| `docs`, `style`, `refactor`, `perf`, `test`, `build`, `ci`, `chore`, `revert` | Internal work | none | Collapsed to one "maintenance" line, or dropped | + +```powershell +# One-time on a fresh clone: install the commit-message validation hook +.venv\Scripts\python scripts\setup_hooks.py + +# Bump the version (deterministic; edits pyproject.toml only). --dry-run to preview. +.venv\Scripts\python scripts\bump.py --dry-run +.venv\Scripts\python scripts\bump.py # auto-detect from commits +.venv\Scripts\python scripts\bump.py --minor # or force a bump level + +# Draft release notes: in Claude Code run /release-notes (collects commits, drafts +# plain-language entries for your review, and only writes CHANGELOG.md once approved). +# Under the hood it calls: +.venv\Scripts\python scripts\release_notes.py --json +``` + +The version string lives in **one place** — `[project] version` in `pyproject.toml`; +`cim_suite.__version__` reads from it. The bump level is computed from commit prefixes, +never by an LLM. The changelog *prose* is the only LLM-assisted step and is never +committed without review. Releases start from the last `vX.Y.Z` git tag (or a +`--baseline ` you pass when there's no tag yet). + ## Architecture Five strictly-ordered layers. The protocol core is **pure** (no I/O, no Qt) and the @@ -105,6 +144,7 @@ UI edit → controller.set_* → encode → transport.write | [`docs/DESIGN-SYSTEM.md`](docs/DESIGN-SYSTEM.md) | Suite-wide visual standard | | [`docs/EXPORT.md`](docs/EXPORT.md) | `.xlsx` export engine + adoption checklist | | [`docs/VB6-MIGRATION-PLAYBOOK.md`](docs/VB6-MIGRATION-PLAYBOOK.md) | Reverse-engineering traps from the VB6 port | +| [`CHANGELOG.md`](CHANGELOG.md) | Human-facing release notes (see _Versioning & releases_ above) | ## License diff --git a/cim_suite/__init__.py b/cim_suite/__init__.py index e7c8072..e3adfc9 100644 --- a/cim_suite/__init__.py +++ b/cim_suite/__init__.py @@ -1,3 +1,64 @@ """CIMTechniques Service Suite — unified desktop suite of instrument service tools.""" -__version__ = "0.1.0" +from __future__ import annotations + +import sys +from pathlib import Path + + +def _candidate_pyprojects() -> list[Path]: + """Places pyproject.toml may live, in priority order (frozen bundle, then source).""" + candidates = [] + meipass = getattr(sys, "_MEIPASS", None) + if meipass is not None: + candidates.append(Path(meipass) / "pyproject.toml") + candidates.append(Path(__file__).resolve().parent.parent / "pyproject.toml") + return candidates + + +def _detect_version() -> str: + """Resolve the suite version from its single source of truth, ``pyproject.toml``. + + A source checkout (incl. editable installs) reads the live file, so a + ``scripts/bump.py`` edit shows up immediately with no reinstall; the frozen build + bundles the same file (see packaging/suite.spec). A plain wheel install with no + pyproject alongside the package falls back to the baked-in distribution metadata. + """ + for pyproject in _candidate_pyprojects(): + if pyproject.is_file(): + try: + import tomllib + + return tomllib.loads(pyproject.read_text("utf-8"))["project"]["version"] + except Exception: # pragma: no cover - malformed pyproject falls through + pass + try: + from importlib.metadata import PackageNotFoundError, version + + return version("cim-service-suite") + except PackageNotFoundError: # pragma: no cover - not installed and no pyproject + return "0.0.0" + + +__version__ = _detect_version() + + +def changelog_path() -> Path | None: + """Locate the bundled ``CHANGELOG.md`` (repo root in source, bundle dir when frozen).""" + candidates = [] + meipass = getattr(sys, "_MEIPASS", None) + if meipass is not None: + candidates.append(Path(meipass) / "CHANGELOG.md") + candidates.append(Path(__file__).resolve().parent.parent / "CHANGELOG.md") + for path in candidates: + if path.is_file(): + return path + return None + + +def changelog_markdown() -> str: + """The release notes as markdown, or a friendly placeholder if none is bundled.""" + path = changelog_path() + if path is None: + return "# Changelog\n\nNo release notes are available in this build." + return path.read_text("utf-8") diff --git a/cim_suite/core/ui/theme/stylesheet.py b/cim_suite/core/ui/theme/stylesheet.py index 45c78fc..8ecb1e8 100644 --- a/cim_suite/core/ui/theme/stylesheet.py +++ b/cim_suite/core/ui/theme/stylesheet.py @@ -371,6 +371,24 @@ QLabel#LauncherHeading {{ font-weight: 900; padding: {S.LG}px {S.XS}px {S.SM}px {S.XS}px; }} +QLabel#LauncherVersion {{ + color: {C.TEXT_MUTED}; + font-size: {T.SMALL_PT}pt; + font-weight: 700; + padding-right: {S.SM}px; +}} +QPushButton#WhatsNewButton {{ + background-color: transparent; + color: {C.ACCENT}; + border: 1px solid {C.ACCENT_SOFT_LINE}; + border-radius: {R.MD}px; + padding: {S.XS}px {S.MD}px; + font-weight: 700; +}} +QPushButton#WhatsNewButton:hover {{ + background-color: {C.ACCENT_SOFT}; + border-color: {C.ACCENT}; +}} QFrame#ModuleCard {{ background-color: {C.BG_SURFACE}; border: 1px solid {C.BORDER}; diff --git a/cim_suite/modules/da07/__init__.py b/cim_suite/modules/da07/__init__.py index bfe26aa..3ccf3cc 100644 --- a/cim_suite/modules/da07/__init__.py +++ b/cim_suite/modules/da07/__init__.py @@ -1,3 +1,5 @@ """DA-07 ("eLink") Monitoring Station Service Tool — modern rebuild.""" -__version__ = "3.0.0" +from cim_suite import __version__ # one suite-wide version (single-sourced in pyproject) + +__all__ = ["__version__"] diff --git a/cim_suite/modules/da12/__init__.py b/cim_suite/modules/da12/__init__.py index 974143a..b6ad378 100644 --- a/cim_suite/modules/da12/__init__.py +++ b/cim_suite/modules/da12/__init__.py @@ -1,3 +1,5 @@ """DA-12 Monitoring Station Service Tool — modern rebuild.""" -__version__ = "3.0.0" +from cim_suite import __version__ # one suite-wide version (single-sourced in pyproject) + +__all__ = ["__version__"] diff --git a/cim_suite/modules/iomodbus/__init__.py b/cim_suite/modules/iomodbus/__init__.py index 963d71f..68bd8dd 100644 --- a/cim_suite/modules/iomodbus/__init__.py +++ b/cim_suite/modules/iomodbus/__init__.py @@ -4,4 +4,6 @@ A config-driven Modbus RTU master (rebuild of the legacy VB6 ``IOModbus.exe``). ``docs/superpowers/specs/2026-06-03-iomodbus-service-tool-rebuild-design.md``. """ -__version__ = "3.0.0" +from cim_suite import __version__ # one suite-wide version (single-sourced in pyproject) + +__all__ = ["__version__"] diff --git a/cim_suite/shell/launcher.py b/cim_suite/shell/launcher.py index 2ae4416..a967c60 100644 --- a/cim_suite/shell/launcher.py +++ b/cim_suite/shell/launcher.py @@ -12,6 +12,7 @@ from PySide6.QtCore import Qt, Signal from PySide6.QtWidgets import ( QFrame, QGridLayout, + QHBoxLayout, QLabel, QPushButton, QScrollArea, @@ -19,11 +20,13 @@ from PySide6.QtWidgets import ( QWidget, ) +import cim_suite from cim_suite.core.module import Module from cim_suite.core.transport.port_scan import DetectedPort, scan_ports from cim_suite.core.ui.theme import Space from .cable_panel import CablePanel +from .whats_new import WhatsNewDialog _COLUMNS = 3 @@ -87,9 +90,21 @@ class LauncherView(QScrollArea): outer.setContentsMargins(Space.XL, Space.LG, Space.XL, Space.XL) outer.setSpacing(Space.MD) + header = QHBoxLayout() heading = QLabel("CIMTechniques Service Suite") heading.setObjectName("LauncherHeading") - outer.addWidget(heading) + header.addWidget(heading) + header.addStretch(1) + + version = QLabel(f"v{cim_suite.__version__}") + version.setObjectName("LauncherVersion") + header.addWidget(version, alignment=Qt.AlignmentFlag.AlignVCenter) + + self._whats_new_btn = QPushButton("What's new") + self._whats_new_btn.setObjectName("WhatsNewButton") + self._whats_new_btn.clicked.connect(self._show_whats_new) + header.addWidget(self._whats_new_btn, alignment=Qt.AlignmentFlag.AlignVCenter) + outer.addLayout(header) self._cable = CablePanel(include_simulator=include_simulator, scan_fn=scan_fn) self._cable.sourceChanged.connect(self._on_source_changed) @@ -120,6 +135,9 @@ class LauncherView(QScrollArea): self._cable.set_scan_fn(self._scan_fn) return self._cable.rescan() + def _show_whats_new(self) -> None: + WhatsNewDialog(self).exec() + def _on_source_changed(self, source: str) -> None: self._set_cards_enabled(bool(source)) diff --git a/cim_suite/shell/whats_new.py b/cim_suite/shell/whats_new.py new file mode 100644 index 0000000..93585a4 --- /dev/null +++ b/cim_suite/shell/whats_new.py @@ -0,0 +1,44 @@ +"""The "What's new" dialog: renders the bundled CHANGELOG.md as release notes. + +Plain-language notes for a non-technical audience live in CHANGELOG.md; this just +displays them. The markdown is located/read by :mod:`cim_suite` so the same file +works from a source checkout and a frozen build. +""" + +from __future__ import annotations + +from PySide6.QtWidgets import ( + QDialog, + QHBoxLayout, + QPushButton, + QTextBrowser, + QVBoxLayout, + QWidget, +) + +import cim_suite +from cim_suite.core.ui.theme import Space + + +class WhatsNewDialog(QDialog): + def __init__(self, parent: QWidget | None = None) -> None: + super().__init__(parent) + self.setWindowTitle("What's new") + self.resize(580, 540) + + layout = QVBoxLayout(self) + layout.setContentsMargins(Space.LG, Space.LG, Space.LG, Space.LG) + layout.setSpacing(Space.MD) + + self.notes = QTextBrowser() + self.notes.setOpenExternalLinks(True) + self.notes.setMarkdown(cim_suite.changelog_markdown()) + layout.addWidget(self.notes) + + buttons = QHBoxLayout() + buttons.addStretch(1) + close = QPushButton("Close") + close.setProperty("variant", "primary") + close.clicked.connect(self.accept) + buttons.addWidget(close) + layout.addLayout(buttons) diff --git a/packaging/suite.spec b/packaging/suite.spec index ea69374..3174492 100644 --- a/packaging/suite.spec +++ b/packaging/suite.spec @@ -25,6 +25,11 @@ _font_src = os.path.join(_repo_root, "cim_suite", "core", "ui", "theme", "fonts" # (importlib.resources) can read it. _iomodbus_res = os.path.join(_repo_root, "cim_suite", "modules", "iomodbus", "resources") +# Version is single-sourced from pyproject.toml and the "What's new" dialog renders +# CHANGELOG.md; both are read at runtime from the bundle root (sys._MEIPASS). +_pyproject = os.path.join(_repo_root, "pyproject.toml") +_changelog = os.path.join(_repo_root, "CHANGELOG.md") + a = Analysis( [os.path.join(SPECPATH, "suite_launcher.py")], pathex=[_repo_root], @@ -32,6 +37,8 @@ a = Analysis( datas=[ (_font_src, os.path.join("cim_suite", "core", "ui", "theme", "fonts")), (_iomodbus_res, os.path.join("cim_suite", "modules", "iomodbus", "resources")), + (_pyproject, "."), + (_changelog, "."), ], hiddenimports=["serial.tools.list_ports", "openpyxl", "PySide6.QtCharts"], hookspath=[], diff --git a/pyproject.toml b/pyproject.toml index 6024fc4..6bebb9c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "cim-service-suite" -version = "3.0.0" +version = "1.0.0" description = "DA-12 Monitoring Station Service Tool (modern rebuild)" requires-python = ">=3.11" dependencies = ["PySide6>=6.7", "pyserial>=3.5", "openpyxl>=3.1"] diff --git a/scripts/bump.py b/scripts/bump.py new file mode 100644 index 0000000..bedaa40 --- /dev/null +++ b/scripts/bump.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python +"""Deterministically bump the suite version from Conventional Commit history. + +The bump type is computed from commit prefixes (fix=patch, feat=minor, a +``BREAKING CHANGE`` body / ``!`` marker = major) unless overridden. The version +lives in exactly one place — ``[project] version`` in pyproject.toml — and this is +the only writer. An LLM is never asked to decide the bump. + + python scripts/bump.py # auto-detect from commits, write pyproject + python scripts/bump.py --dry-run # show what it would do, change nothing + python scripts/bump.py --minor # force a minor bump + python scripts/bump.py --baseline # range start when there is no version tag +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from release import repo # noqa: E402 +from release.versioning import BumpKind, set_version # noqa: E402 + + +def _override(args: argparse.Namespace) -> BumpKind | None: + for kind in ("major", "minor", "patch"): + if getattr(args, kind): + return kind # type: ignore[return-value] + return None + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + group = parser.add_mutually_exclusive_group() + group.add_argument("--major", action="store_true", help="force a major bump") + group.add_argument("--minor", action="store_true", help="force a minor bump") + group.add_argument("--patch", action="store_true", help="force a patch bump") + parser.add_argument("--dry-run", action="store_true", help="print, do not write") + parser.add_argument("--baseline", help="range start commit/tag when no version tag exists") + args = parser.parse_args(argv) + + plan = repo.gather(baseline=args.baseline) + rng = plan.range_spec or "(all commits)" + print(f"Current version: {plan.current}") + print(f"Scanning {rng}: {len(plan.commits)} commit(s).") + + bump = _override(args) or plan.detected_bump + if bump is None: + print( + "No releasable commits (no feat/fix/breaking) in range.\n" + "Nothing to bump — pass --patch/--minor/--major to force." + ) + return 0 + + new_version = plan.current.bump(bump) + source = "forced" if _override(args) else "detected" + print(f"Bump ({source}): {bump} -> {new_version}") + + if args.dry_run: + print("Dry run - pyproject.toml not modified.") + return 0 + + repo.PYPROJECT.write_text(set_version(repo.PYPROJECT.read_text("utf-8"), str(new_version))) + print(f"Updated {repo.PYPROJECT.name} to version {new_version}.") + print( + f"Next: draft release notes (/release-notes), review CHANGELOG.md, then\n" + f" git commit -am 'chore: release {new_version}' && git tag v{new_version}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/commit_msg_hook.py b/scripts/commit_msg_hook.py new file mode 100644 index 0000000..3fa89b8 --- /dev/null +++ b/scripts/commit_msg_hook.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python +"""commit-msg hook body: reject messages that aren't ``: ``. + +Invoked by ``.githooks/commit-msg`` with the path to the commit message file. +Validation logic lives in :mod:`release.commits` so it is unit-tested. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from release.commits import KNOWN_TYPES, validate_commit_message # noqa: E402 + + +def main(argv: list[str]) -> int: + if len(argv) < 2: + return 0 + message = Path(argv[1]).read_text("utf-8") + error = validate_commit_message(message) + if error is None: + return 0 + indented = "\n ".join(error.splitlines()) + sys.stderr.write( + "\n x Commit rejected: invalid commit message.\n\n" + f" {indented}\n\n" + f" Format : (): \n" + f" Types : {', '.join(KNOWN_TYPES)}\n" + " Example: feat(da12): add live sensor sparkline\n\n" + ) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv)) diff --git a/scripts/release/__init__.py b/scripts/release/__init__.py new file mode 100644 index 0000000..0cce720 --- /dev/null +++ b/scripts/release/__init__.py @@ -0,0 +1,8 @@ +"""Pure, testable logic for the suite's versioning + changelog tooling. + +This package holds the deterministic backbone (semver arithmetic, commit parsing, +range resolution, classification) used by the ``scripts/*.py`` CLIs and the +``.githooks/commit-msg`` hook. It is import-only: no side effects, no git I/O. +The git plumbing lives in :mod:`release.gitio`; the prose summarization is a +human+LLM step that never lives in code. +""" diff --git a/scripts/release/commits.py b/scripts/release/commits.py new file mode 100644 index 0000000..4baccd4 --- /dev/null +++ b/scripts/release/commits.py @@ -0,0 +1,154 @@ +"""Conventional-commit parsing, bump detection, range resolution, classification. + +All pure functions over plain data — the git plumbing that produces ``ParsedCommit`` +records lives in :mod:`release.gitio`. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +from .versioning import BumpKind, Version + +# The commit types we recognise. feat/fix are user-visible; the rest are internal +# noise the changelog collapses. Unknown prefixes (e.g. a stray "polish:") are +# rejected by the commit-msg hook and treated as internal if they slip through. +USER_VISIBLE_TYPES = ("feat", "fix") +INTERNAL_TYPES = ( + "docs", + "style", + "refactor", + "perf", + "test", + "build", + "ci", + "chore", + "revert", +) +KNOWN_TYPES = (*USER_VISIBLE_TYPES, *INTERNAL_TYPES) + +_SUBJECT_RE = re.compile( + r"^(?P[a-z]+)(?:\((?P[^)]+)\))?(?P!)?:\s*(?P.*)$" +) +_BREAKING_BODY_RE = re.compile(r"(?m)^BREAKING[ -]CHANGE:") +# Subjects git generates itself, which are not Conventional Commits but are valid. +_MERGE_RE = re.compile(r"^Merge ") +_REVERT_RE = re.compile(r'^Revert "') +_AUTOSQUASH_RE = re.compile(r"^(fixup|squash|amend)! ") + + +@dataclass(frozen=True) +class ParsedCommit: + """One commit decomposed into Conventional-Commit parts. + + ``type`` is ``None`` for a non-conventional subject (e.g. a merge commit). + """ + + sha: str + type: str | None + scope: str | None + breaking: bool + description: str + body: str + + +def parse_commit(sha: str, subject: str, body: str = "") -> ParsedCommit: + """Decompose a commit subject/body into a :class:`ParsedCommit`.""" + breaking = bool(_BREAKING_BODY_RE.search(body)) + m = _SUBJECT_RE.match(subject) + if m is None or m.group("type") not in KNOWN_TYPES: + return ParsedCommit(sha, None, None, breaking, subject.strip(), body) + return ParsedCommit( + sha=sha, + type=m.group("type"), + scope=m.group("scope"), + breaking=breaking or bool(m.group("bang")), + description=m.group("desc").strip(), + body=body, + ) + + +def determine_bump(commits: list[ParsedCommit]) -> BumpKind | None: + """Highest-priority bump implied by the commits, or ``None`` if none are releasable.""" + if any(c.breaking for c in commits): + return "major" + if any(c.type == "feat" for c in commits): + return "minor" + if any(c.type == "fix" for c in commits): + return "patch" + return None + + +_TAG_RE = re.compile(r"^v(\d+)\.(\d+)\.(\d+)$") + + +def last_version_tag(tags: list[str]) -> str | None: + """The highest ``vX.Y.Z`` tag by semver, or ``None`` if there are none.""" + versioned = [t for t in tags if _TAG_RE.match(t)] + if not versioned: + return None + return max(versioned, key=lambda t: Version.parse(t[1:])) + + +def commit_range(*, last_tag: str | None, baseline: str | None) -> str | None: + """Git revision range to scan. ``None`` means 'all commits'.""" + start = last_tag or baseline + if start is None: + return None + return f"{start}..HEAD" + + +@dataclass(frozen=True) +class ReleaseDigest: + """The user-review material for one release: visible changes + collapsed internals.""" + + features: list[ParsedCommit] + fixes: list[ParsedCommit] + breaking: list[ParsedCommit] + internal: list[ParsedCommit] + + +def build_digest(commits: list[ParsedCommit]) -> ReleaseDigest: + """Split commits into feat / fix / breaking / internal buckets for summarization.""" + return ReleaseDigest( + features=[c for c in commits if c.type == "feat"], + fixes=[c for c in commits if c.type == "fix"], + breaking=[c for c in commits if c.breaking], + internal=[c for c in commits if c.type not in USER_VISIBLE_TYPES], + ) + + +def validate_commit_message(message: str) -> str | None: + """Return an error string if ``message`` is not a valid commit subject, else ``None``.""" + subject = "" + for line in message.splitlines(): + if line.startswith("#"): + continue # git comment lines (and the editor template) are not the subject + if line.strip(): + subject = line.rstrip() + break + + if not subject: + return "empty commit message" + if ( + _MERGE_RE.match(subject) + or _REVERT_RE.match(subject) + or _AUTOSQUASH_RE.match(subject) + ): + return None # git-generated subjects (merge/revert/autosquash) are allowed through + + m = _SUBJECT_RE.match(subject) + if m is None: + return ( + f"commit subject must be ': ' (got: {subject!r}).\n" + f" Valid types: {', '.join(KNOWN_TYPES)}" + ) + if m.group("type") not in KNOWN_TYPES: + return ( + f"unknown commit type {m.group('type')!r}.\n" + f" Valid types: {', '.join(KNOWN_TYPES)}" + ) + if not m.group("desc").strip(): + return "commit description is empty after the ':'" + return None diff --git a/scripts/release/gitio.py b/scripts/release/gitio.py new file mode 100644 index 0000000..ee85c87 --- /dev/null +++ b/scripts/release/gitio.py @@ -0,0 +1,50 @@ +"""Thin git plumbing: list tags, read commits in a range. The only impure module.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +from .commits import ParsedCommit, parse_commit + +# Field/record separators unlikely to appear in commit text. +_FS = "\x1f" +_RS = "\x1e" + + +def _git(args: list[str], cwd: Path | str | None) -> str: + result = subprocess.run( + ["git", *args], + cwd=cwd, + check=True, + capture_output=True, + text=True, + encoding="utf-8", + ) + return result.stdout + + +def list_tags(*, cwd: Path | str | None = None) -> list[str]: + """All tag names in the repo.""" + out = _git(["tag", "--list"], cwd) + return [line.strip() for line in out.splitlines() if line.strip()] + + +def commits_in_range( + range_spec: str | None, *, cwd: Path | str | None = None +) -> list[ParsedCommit]: + """Parsed commits in ``range_spec`` (newest first). ``None`` means all commits.""" + fmt = f"%H{_FS}%s{_FS}%b{_RS}" + args = ["log", f"--pretty=format:{fmt}"] + if range_spec: + args.append(range_spec) + out = _git(args, cwd) + + commits: list[ParsedCommit] = [] + for record in out.split(_RS): + record = record.strip("\n") + if not record: + continue + sha, subject, body = record.split(_FS) + commits.append(parse_commit(sha, subject, body)) + return commits diff --git a/scripts/release/repo.py b/scripts/release/repo.py new file mode 100644 index 0000000..d761e15 --- /dev/null +++ b/scripts/release/repo.py @@ -0,0 +1,40 @@ +"""Shared glue between the pure logic and the on-disk repo for the CLI entry points.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from . import gitio +from .commits import ParsedCommit, commit_range, determine_bump, last_version_tag +from .versioning import BumpKind, Version, read_version + +REPO_ROOT = Path(__file__).resolve().parents[2] +PYPROJECT = REPO_ROOT / "pyproject.toml" + + +@dataclass(frozen=True) +class ReleasePlan: + """Everything the bump/changelog CLIs need, computed once from the repo state.""" + + current: Version + range_spec: str | None + commits: list[ParsedCommit] + detected_bump: BumpKind | None + baseline: str | None + + +def gather(baseline: str | None = None) -> ReleasePlan: + """Read the current version and the commits since the last tag (or baseline).""" + current = Version.parse(read_version(PYPROJECT.read_text("utf-8"))) + tags = gitio.list_tags(cwd=REPO_ROOT) + last_tag = last_version_tag(tags) + range_spec = commit_range(last_tag=last_tag, baseline=baseline) + commits = gitio.commits_in_range(range_spec, cwd=REPO_ROOT) + return ReleasePlan( + current=current, + range_spec=range_spec, + commits=commits, + detected_bump=determine_bump(commits), + baseline=baseline, + ) diff --git a/scripts/release/versioning.py b/scripts/release/versioning.py new file mode 100644 index 0000000..9351fd2 --- /dev/null +++ b/scripts/release/versioning.py @@ -0,0 +1,67 @@ +"""Semver value object and the pyproject version single-source read/write.""" + +from __future__ import annotations + +import re +import tomllib +from dataclasses import dataclass +from typing import Literal + +BumpKind = Literal["major", "minor", "patch"] + +_SEMVER_RE = re.compile(r"^(\d+)\.(\d+)\.(\d+)$") + + +@dataclass(frozen=True, order=True) +class Version: + """A semantic version ``major.minor.patch`` (no pre-release/build metadata).""" + + major: int + minor: int + patch: int + + @classmethod + def parse(cls, text: str) -> "Version": + m = _SEMVER_RE.match(text.strip()) + if m is None: + raise ValueError(f"not a MAJOR.MINOR.PATCH version: {text!r}") + return cls(int(m.group(1)), int(m.group(2)), int(m.group(3))) + + def __str__(self) -> str: + return f"{self.major}.{self.minor}.{self.patch}" + + def bump(self, kind: BumpKind) -> "Version": + if kind == "major": + return Version(self.major + 1, 0, 0) + if kind == "minor": + return Version(self.major, self.minor + 1, 0) + if kind == "patch": + return Version(self.major, self.minor, self.patch + 1) + raise ValueError(f"unknown bump kind: {kind!r}") + + +# The [project] table header up to the next table header or end of text. +_PROJECT_TABLE_RE = re.compile(r"(?ms)^\[project\]\s*$.*?(?=^\[|\Z)") +_VERSION_LINE_RE = re.compile(r'(?m)^(version\s*=\s*")([^"]*)(")') + + +def read_version(pyproject_text: str) -> str: + """Return the ``[project] version`` string from pyproject.toml text.""" + return tomllib.loads(pyproject_text)["project"]["version"] + + +def set_version(pyproject_text: str, new_version: str) -> str: + """Return pyproject.toml text with the ``[project] version`` set to ``new_version``. + + Only the version line inside the ``[project]`` table is touched; identical-looking + lines in other tables (e.g. ``[tool.x] version = ...``) are left alone, and all + surrounding formatting/comments are preserved. + """ + Version.parse(new_version) # reject garbage before we rewrite the file + + table = _PROJECT_TABLE_RE.search(pyproject_text) + if table is None or not _VERSION_LINE_RE.search(table.group(0)): + raise ValueError("no [project] version line found in pyproject.toml") + + new_table = _VERSION_LINE_RE.sub(rf"\g<1>{new_version}\g<3>", table.group(0), count=1) + return pyproject_text[: table.start()] + new_table + pyproject_text[table.end() :] diff --git a/scripts/release_notes.py b/scripts/release_notes.py new file mode 100644 index 0000000..a072195 --- /dev/null +++ b/scripts/release_notes.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python +"""Collect commits since the last release and emit a digest for changelog drafting. + +This produces the STRUCTURED INPUT for the human + Claude changelog step. It never +writes CHANGELOG.md and never invents prose — it just buckets commits into +user-visible features/fixes/breaking changes vs. collapsible internal work, using the +same range logic as the bump script. The plain-language summary is drafted by Claude +from this digest and reviewed before it lands (see ``.claude/commands/release-notes.md``). + + python scripts/release_notes.py # human-readable digest + python scripts/release_notes.py --json # machine-readable digest (for Claude) + python scripts/release_notes.py --baseline +""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from release import repo # noqa: E402 +from release.commits import ParsedCommit, build_digest # noqa: E402 + + +def _commit_dict(c: ParsedCommit) -> dict[str, object]: + return { + "sha": c.sha[:8], + "type": c.type, + "scope": c.scope, + "breaking": c.breaking, + "description": c.description, + } + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true", help="emit JSON for tooling/Claude") + parser.add_argument("--baseline", help="range start commit/tag when no version tag exists") + args = parser.parse_args(argv) + + plan = repo.gather(baseline=args.baseline) + digest = build_digest(plan.commits) + bump = plan.detected_bump or "patch" + suggested = plan.current.bump(bump) + + if args.json: + payload = { + "current_version": str(plan.current), + "suggested_version": str(suggested), + "suggested_bump": bump, + "detected_bump": plan.detected_bump, + "range": plan.range_spec, + "features": [_commit_dict(c) for c in digest.features], + "fixes": [_commit_dict(c) for c in digest.fixes], + "breaking": [_commit_dict(c) for c in digest.breaking], + "internal_count": len(digest.internal), + "internal_types": sorted({c.type or "other" for c in digest.internal}), + } + print(json.dumps(payload, indent=2)) + return 0 + + rng = plan.range_spec or "(all commits)" + print(f"# Release digest ({plan.current} -> {suggested}, {bump})") + print(f"Range: {rng} Commits: {len(plan.commits)}\n") + for title, items in ( + ("Breaking changes", digest.breaking), + ("Features (feat)", digest.features), + ("Fixes (fix)", digest.fixes), + ): + print(f"## {title} ({len(items)})") + for c in items: + scope = f"({c.scope})" if c.scope else "" + print(f" - {c.sha[:8]} {c.type}{scope}: {c.description}") + print() + types = sorted({c.type or "other" for c in digest.internal}) + print(f"## Internal (collapse to one line): {len(digest.internal)} commit(s) {types}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/setup_hooks.py b/scripts/setup_hooks.py new file mode 100644 index 0000000..0f4c97a --- /dev/null +++ b/scripts/setup_hooks.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +"""One-time install of the repo's git hooks on a fresh clone. + +Points git at the version-controlled ``.githooks`` directory so the commit-msg +validator runs without copying anything into ``.git``. + + python scripts/setup_hooks.py +""" + +from __future__ import annotations + +import os +import stat +import subprocess +from pathlib import Path + +ROOT = Path(__file__).resolve().parent.parent + + +def main() -> int: + subprocess.run(["git", "config", "core.hooksPath", ".githooks"], cwd=ROOT, check=True) + + hook = ROOT / ".githooks" / "commit-msg" + if hook.is_file() and os.name != "nt": + hook.chmod(hook.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + print("Git hooks installed: core.hooksPath -> .githooks") + print("Commit messages will now be validated against the Conventional Commit format.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/release/__init__.py b/tests/release/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/release/conftest.py b/tests/release/conftest.py new file mode 100644 index 0000000..b3f35d9 --- /dev/null +++ b/tests/release/conftest.py @@ -0,0 +1,14 @@ +"""Make the dev-only ``scripts/release`` package importable as ``release``. + +It is deliberately not part of the shipped ``cim_suite`` wheel, so it is not on +``sys.path`` via the editable install. Prepend the repo's ``scripts`` dir here. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_SCRIPTS = Path(__file__).resolve().parents[2] / "scripts" +if str(_SCRIPTS) not in sys.path: + sys.path.insert(0, str(_SCRIPTS)) diff --git a/tests/release/test_commits.py b/tests/release/test_commits.py new file mode 100644 index 0000000..45855ae --- /dev/null +++ b/tests/release/test_commits.py @@ -0,0 +1,173 @@ +"""Tests for commit parsing, bump detection, range resolution, and classification.""" + +from __future__ import annotations + +from release.commits import ( + ParsedCommit, + build_digest, + commit_range, + determine_bump, + last_version_tag, + parse_commit, + validate_commit_message, +) + + +# --- parse_commit ---------------------------------------------------------- +def test_parse_plain_type(): + c = parse_commit("abc", "feat: add launcher") + assert c.type == "feat" + assert c.scope is None + assert c.breaking is False + assert c.description == "add launcher" + + +def test_parse_with_scope(): + c = parse_commit("abc", "fix(repository): stretch Value column") + assert c.type == "fix" + assert c.scope == "repository" + assert c.description == "stretch Value column" + + +def test_parse_bang_marks_breaking(): + c = parse_commit("abc", "feat(api)!: drop wireless sensors") + assert c.type == "feat" + assert c.scope == "api" + assert c.breaking is True + + +def test_parse_breaking_change_in_body(): + c = parse_commit("abc", "refactor: rework transport", body="BREAKING CHANGE: ports moved") + assert c.breaking is True + + +def test_parse_breaking_change_hyphen_variant(): + c = parse_commit("abc", "feat: x", body="BREAKING-CHANGE: y") + assert c.breaking is True + + +def test_parse_non_conventional_subject(): + c = parse_commit("abc", "Merge branch 'feat/foo' into main") + assert c.type is None + assert c.breaking is False + assert c.description == "Merge branch 'feat/foo' into main" + + +# --- determine_bump -------------------------------------------------------- +def test_bump_breaking_wins(): + commits = [ + parse_commit("1", "feat: a"), + parse_commit("2", "fix: b"), + parse_commit("3", "feat!: c"), + ] + assert determine_bump(commits) == "major" + + +def test_bump_feat_over_fix(): + commits = [parse_commit("1", "fix: a"), parse_commit("2", "feat: b")] + assert determine_bump(commits) == "minor" + + +def test_bump_fix_only(): + commits = [parse_commit("1", "fix: a"), parse_commit("2", "docs: b")] + assert determine_bump(commits) == "patch" + + +def test_bump_none_when_no_releasable_commits(): + commits = [parse_commit("1", "docs: a"), parse_commit("2", "chore: b")] + assert determine_bump(commits) is None + + +def test_bump_none_for_empty(): + assert determine_bump([]) is None + + +# --- last_version_tag ------------------------------------------------------ +def test_last_version_tag_picks_highest_semver(): + tags = ["v1.0.0", "v1.2.0", "v1.10.0", "v1.9.0"] + assert last_version_tag(tags) == "v1.10.0" + + +def test_last_version_tag_ignores_non_version_tags(): + tags = ["baseline", "v1.0.0", "release-candidate", "not-a-version"] + assert last_version_tag(tags) == "v1.0.0" + + +def test_last_version_tag_none_when_empty(): + assert last_version_tag([]) is None + assert last_version_tag(["nightly", "stable"]) is None + + +# --- commit_range ---------------------------------------------------------- +def test_range_prefers_last_tag(): + assert commit_range(last_tag="v1.2.0", baseline="abc123") == "v1.2.0..HEAD" + + +def test_range_falls_back_to_baseline(): + assert commit_range(last_tag=None, baseline="abc123") == "abc123..HEAD" + + +def test_range_all_commits_when_neither(): + assert commit_range(last_tag=None, baseline=None) is None + + +# --- build_digest ---------------------------------------------------------- +def _commits() -> list[ParsedCommit]: + return [ + parse_commit("1", "feat: new screen"), + parse_commit("2", "fix: crash on open"), + parse_commit("3", "feat!: remove old mode"), + parse_commit("4", "refactor: tidy"), + parse_commit("5", "test: add coverage"), + parse_commit("6", "Merge branch 'x'"), + ] + + +def test_digest_buckets(): + d = build_digest(_commits()) + assert [c.sha for c in d.features] == ["1", "3"] + assert [c.sha for c in d.fixes] == ["2"] + assert [c.sha for c in d.breaking] == ["3"] + # refactor, test, and the merge all collapse into internal. + assert [c.sha for c in d.internal] == ["4", "5", "6"] + + +# --- validate_commit_message ---------------------------------------------- +def test_validate_accepts_conventional(): + assert validate_commit_message("feat: add thing") is None + assert validate_commit_message("fix(scope): bug") is None + assert validate_commit_message("feat(api)!: breaking") is None + + +def test_validate_accepts_merge_and_revert(): + assert validate_commit_message("Merge branch 'main' into feat/x") is None + assert validate_commit_message('Revert "feat: add thing"') is None + + +def test_validate_accepts_fixup_and_squash(): + # git commit --fixup/--squash generate these; rejecting them breaks autosquash. + assert validate_commit_message("fixup! feat: add thing") is None + assert validate_commit_message("squash! fix: the bug") is None + assert validate_commit_message("amend! feat: add thing") is None + + +def test_validate_rejects_missing_type(): + err = validate_commit_message("add a thing") + assert err is not None + assert "type" in err.lower() + + +def test_validate_rejects_unknown_type(): + err = validate_commit_message("feet: typo in type") + assert err is not None + + +def test_validate_rejects_empty_description(): + err = validate_commit_message("feat: ") + assert err is not None + + +def test_validate_ignores_comment_lines(): + # git passes the whole COMMIT_EDITMSG; comment lines must not be validated. + msg = "feat: real subject\n\n# Please enter the commit message\n# comment" + assert validate_commit_message(msg) is None diff --git a/tests/release/test_gitio.py b/tests/release/test_gitio.py new file mode 100644 index 0000000..bba8970 --- /dev/null +++ b/tests/release/test_gitio.py @@ -0,0 +1,61 @@ +"""Integration tests for the git plumbing, against throwaway temp repos.""" + +from __future__ import annotations + +import subprocess +from pathlib import Path + +import pytest + +from release import gitio + + +def _git(repo: Path, *args: str) -> None: + subprocess.run(["git", *args], cwd=repo, check=True, capture_output=True, text=True) + + +@pytest.fixture +def repo(tmp_path: Path) -> Path: + r = tmp_path / "repo" + r.mkdir() + _git(r, "init", "-q") + _git(r, "config", "user.email", "t@t.t") + _git(r, "config", "user.name", "t") + return r + + +def _commit(repo: Path, subject: str, body: str = "") -> None: + (repo / "f.txt").write_text(subject) + _git(repo, "add", "-A") + msg = subject if not body else f"{subject}\n\n{body}" + _git(repo, "commit", "-q", "-m", msg) + + +def test_list_tags(repo: Path): + _commit(repo, "feat: one") + _git(repo, "tag", "v1.0.0") + _git(repo, "tag", "baseline") + assert set(gitio.list_tags(cwd=repo)) == {"v1.0.0", "baseline"} + + +def test_commits_in_range_all(repo: Path): + _commit(repo, "feat: one") + _commit(repo, "fix: two") + commits = gitio.commits_in_range(None, cwd=repo) + # Newest first, parsed. + assert [c.type for c in commits] == ["fix", "feat"] + assert [c.description for c in commits] == ["two", "one"] + + +def test_commits_in_range_since_tag(repo: Path): + _commit(repo, "feat: before") + _git(repo, "tag", "v1.0.0") + _commit(repo, "fix: after") + commits = gitio.commits_in_range("v1.0.0..HEAD", cwd=repo) + assert [c.description for c in commits] == ["after"] + + +def test_commits_in_range_parses_body_breaking(repo: Path): + _commit(repo, "refactor: rework", body="BREAKING CHANGE: moved ports") + commits = gitio.commits_in_range(None, cwd=repo) + assert commits[0].breaking is True diff --git a/tests/release/test_versioning.py b/tests/release/test_versioning.py new file mode 100644 index 0000000..e7d8d33 --- /dev/null +++ b/tests/release/test_versioning.py @@ -0,0 +1,92 @@ +"""Tests for the semver value object and the pyproject version single-source.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from release.versioning import Version, read_version, set_version + +_REPO_ROOT = Path(__file__).resolve().parents[2] + + +def test_parse_roundtrip(): + assert str(Version.parse("1.2.3")) == "1.2.3" + assert Version.parse("1.2.3") == Version(1, 2, 3) + + +def test_parse_rejects_garbage(): + with pytest.raises(ValueError): + Version.parse("1.2") + with pytest.raises(ValueError): + Version.parse("v1.2.3") # the 'v' prefix is a tag concern, not a version string + with pytest.raises(ValueError): + Version.parse("1.2.x") + + +def test_bump_patch(): + assert Version(1, 2, 3).bump("patch") == Version(1, 2, 4) + + +def test_bump_minor_resets_patch(): + assert Version(1, 2, 3).bump("minor") == Version(1, 3, 0) + + +def test_bump_major_resets_minor_and_patch(): + assert Version(1, 2, 3).bump("major") == Version(2, 0, 0) + + +def test_versions_are_ordered(): + assert Version(1, 0, 0) < Version(1, 0, 1) < Version(1, 1, 0) < Version(2, 0, 0) + + +PYPROJECT = """\ +[project] +name = "cim-service-suite" +version = "1.0.0" +description = "x" +requires-python = ">=3.11" + +[build-system] +requires = ["setuptools>=68"] +""" + + +def test_read_version(): + assert read_version(PYPROJECT) == "1.0.0" + + +def test_set_version_changes_only_project_version(): + out = set_version(PYPROJECT, "1.1.0") + assert read_version(out) == "1.1.0" + # Everything else is preserved verbatim. + assert 'name = "cim-service-suite"' in out + assert 'requires = ["setuptools>=68"]' in out + assert out.count('version = "') == 1 + + +def test_set_version_ignores_lookalike_version_in_other_table(): + text = ( + '[project]\nname = "x"\nversion = "1.0.0"\n\n' + '[tool.thing]\nversion = "9.9.9"\n' + ) + out = set_version(text, "1.0.1") + assert read_version(out) == "1.0.1" + assert 'version = "9.9.9"' in out # untouched + + +def test_cim_suite_version_is_single_sourced_from_pyproject(): + import cim_suite + + pyproject = (_REPO_ROOT / "pyproject.toml").read_text("utf-8") + assert cim_suite.__version__ == read_version(pyproject) + + +def test_modules_reexport_the_suite_version(): + import cim_suite + from cim_suite.modules import da07, da12, iomodbus + + assert da12.__version__ == cim_suite.__version__ + assert da07.__version__ == cim_suite.__version__ + assert iomodbus.__version__ == cim_suite.__version__ diff --git a/tests/shell/test_whats_new.py b/tests/shell/test_whats_new.py new file mode 100644 index 0000000..d74f65f --- /dev/null +++ b/tests/shell/test_whats_new.py @@ -0,0 +1,28 @@ +"""Launcher version badge + the What's-new release-notes dialog.""" + +from __future__ import annotations + +from PySide6.QtWidgets import QLabel + +import cim_suite +from cim_suite.modules.da12.module import Da12Module +from cim_suite.shell.launcher import LauncherView +from cim_suite.shell.whats_new import WhatsNewDialog + + +def _scan_none(*, include_simulator=False): + return [] + + +def test_launcher_shows_suite_version(qtbot): + view = LauncherView([Da12Module(simulate=True)], scan_fn=_scan_none) + qtbot.addWidget(view) + texts = [w.text() for w in view.findChildren(QLabel)] + assert f"v{cim_suite.__version__}" in texts + + +def test_whats_new_dialog_renders_changelog(qtbot): + dlg = WhatsNewDialog() + qtbot.addWidget(dlg) + rendered = dlg.notes.toPlainText() + assert rendered.strip() # never empty (real changelog or a friendly placeholder)