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:
44
.claude/commands/release-notes.md
Normal file
44
.claude/commands/release-notes.md
Normal 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
2
.gitattributes
vendored
@@ -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
16
.githooks/commit-msg
Normal 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
28
CHANGELOG.md
Normal 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.
|
||||
13
CLAUDE.md
13
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 (`<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).
|
||||
```
|
||||
|
||||
40
README.md
40
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 `<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
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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__"]
|
||||
|
||||
@@ -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__"]
|
||||
|
||||
@@ -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__"]
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
44
cim_suite/shell/whats_new.py
Normal file
44
cim_suite/shell/whats_new.py
Normal 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)
|
||||
@@ -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=[],
|
||||
|
||||
@@ -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
75
scripts/bump.py
Normal 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())
|
||||
37
scripts/commit_msg_hook.py
Normal file
37
scripts/commit_msg_hook.py
Normal 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))
|
||||
8
scripts/release/__init__.py
Normal file
8
scripts/release/__init__.py
Normal 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
154
scripts/release/commits.py
Normal 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
50
scripts/release/gitio.py
Normal 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
40
scripts/release/repo.py
Normal 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,
|
||||
)
|
||||
67
scripts/release/versioning.py
Normal file
67
scripts/release/versioning.py
Normal 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
84
scripts/release_notes.py
Normal 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
33
scripts/setup_hooks.py
Normal 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())
|
||||
0
tests/release/__init__.py
Normal file
0
tests/release/__init__.py
Normal file
14
tests/release/conftest.py
Normal file
14
tests/release/conftest.py
Normal 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))
|
||||
173
tests/release/test_commits.py
Normal file
173
tests/release/test_commits.py
Normal 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
|
||||
61
tests/release/test_gitio.py
Normal file
61
tests/release/test_gitio.py
Normal 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
|
||||
92
tests/release/test_versioning.py
Normal file
92
tests/release/test_versioning.py
Normal 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__
|
||||
28
tests/shell/test_whats_new.py
Normal file
28
tests/shell/test_whats_new.py
Normal 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)
|
||||
Reference in New Issue
Block a user