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) <noreply@anthropic.com>
This commit is contained in:
2026-06-06 11:31:22 -04:00
parent 1219517d14
commit 63169a7644
30 changed files with 1219 additions and 6 deletions

View File

@@ -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 <commit-or-tag>` 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.

2
.gitattributes vendored
View File

@@ -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

16
.githooks/commit-msg Normal file
View File

@@ -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

28
CHANGELOG.md Normal file
View File

@@ -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.

View File

@@ -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 (`<type>: <desc>`) — 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).
```

View File

@@ -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 `<type>: <description>`.
- **[`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 <rev>` 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

View File

@@ -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")

View File

@@ -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};

View File

@@ -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__"]

View File

@@ -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__"]

View File

@@ -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__"]

View File

@@ -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))

View File

@@ -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)

View File

@@ -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=[],

View File

@@ -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"]

75
scripts/bump.py Normal file
View File

@@ -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 <rev> # 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())

View File

@@ -0,0 +1,37 @@
#!/usr/bin/env python
"""commit-msg hook body: reject messages that aren't ``<type>: <description>``.
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 : <type>(<optional scope>): <description>\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))

View File

@@ -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.
"""

154
scripts/release/commits.py Normal file
View File

@@ -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<type>[a-z]+)(?:\((?P<scope>[^)]+)\))?(?P<bang>!)?:\s*(?P<desc>.*)$"
)
_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 '<type>: <description>' (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

50
scripts/release/gitio.py Normal file
View File

@@ -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

40
scripts/release/repo.py Normal file
View File

@@ -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,
)

View File

@@ -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() :]

84
scripts/release_notes.py Normal file
View File

@@ -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 <rev>
"""
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())

33
scripts/setup_hooks.py Normal file
View File

@@ -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())

View File

14
tests/release/conftest.py Normal file
View File

@@ -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))

View File

@@ -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

View File

@@ -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

View File

@@ -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__

View File

@@ -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)