From c8fe3bad662bea00b13642c825b694c19d52e067 Mon Sep 17 00:00:00 2001 From: cc-vps Date: Tue, 27 Oct 2026 18:21:45 -0700 Subject: [PATCH 1/2] feat: add update command and stamped version line monarch --version now prints `monarch X.Y.Z (sha7 YYYY-MM-DD)`, with `-dirty` for a build from an uncommitted tree. A checkout reads git; an installed wheel reads the release-standard _build_info.json stamp. `monarch update [--check] [--force]` updates the CLI with the method it was installed with (source checkout, uv tool, pipx, pip, or mise) through the release-standard cli_meta module. Update is classified with a new additive local_install_change effect so it never implies mutation authorization. The capabilities manifest fixture is regenerated for the new command. Co-authored-by: Christian Catalan --- docs/RELEASING.md => RELEASING.md | 0 docs/capabilities.md | 4 +- docs/commands.md | 24 +- src/monarch_cli/cli_meta.py | 278 ++++++++++++++++++++++ src/monarch_cli/commands/update.py | 50 ++++ src/monarch_cli/core/capabilities.py | 5 + src/monarch_cli/core/operations.py | 3 + src/monarch_cli/main.py | 13 +- tests/fixtures/capabilities.manifest.json | 50 ++++ tests/test_cli_framework.py | 7 +- tests/test_cli_meta.py | 123 ++++++++++ tests/test_mutation_policy.py | 1 + 12 files changed, 551 insertions(+), 7 deletions(-) rename docs/RELEASING.md => RELEASING.md (100%) create mode 100644 src/monarch_cli/cli_meta.py create mode 100644 src/monarch_cli/commands/update.py create mode 100644 tests/test_cli_meta.py diff --git a/docs/RELEASING.md b/RELEASING.md similarity index 100% rename from docs/RELEASING.md rename to RELEASING.md diff --git a/docs/capabilities.md b/docs/capabilities.md index a5daf0f..a715f42 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -57,8 +57,8 @@ Each entry under `commands` contains: - `path` / `name` — the command path, for example `["transactions", "tags", "add"]`. - `effects` — the explicit shared effect vocabulary for the command. Current values are `read_only`, `preview`, `remote_authentication`, - `remote_mutation`, and `local_credential_change`. New effect members extend - the taxonomy additively. + `remote_mutation`, `local_credential_change`, and `local_install_change` + (`monarch update`). New effect members extend the taxonomy additively. - `safety` — the explicit safety profile (never inferred): - `requires_authorization` — the command is a remote mutation and requires the global `--allow-mutations` option for this invocation. diff --git a/docs/commands.md b/docs/commands.md index 79c8409..ede62f6 100644 --- a/docs/commands.md +++ b/docs/commands.md @@ -22,6 +22,28 @@ or config/session write). See [Capabilities manifest](capabilities.md). monarch capabilities | jq '.commands[] | select(.safety.requires_authorization)' ``` +## Updating + +`monarch update` replaces the installed copy using the method it was installed +with. A source checkout updates from `origin/main` (`git pull --ff-only` plus a +reinstall) and refuses a dirty tree or a branch other than `main`. `uv tool`, +`pipx`, `pip`, and `mise` installs run the matching upgrade command. An `npx` +run has nothing to update; `npx monarch-cli@latest` always runs the latest. + +### `update` + +```text +monarch update [--check] [--force] +``` + +| Option | Purpose | +|---|---| +| `--check` | Report only: exit `0` up to date, `1` update available, `2` unknown | +| `--force` | Update even when the install already looks current | + +`monarch update` prints each command before running it in the foreground, so +package-manager prompts reach the terminal. See `monarch update --help`. + ## Global options Global options appear before the command path: @@ -32,7 +54,7 @@ monarch [GLOBAL OPTIONS] GROUP COMMAND [COMMAND OPTIONS] | Option | Purpose | |---|---| -| `-v, --version` | Show the version and exit | +| `-v, --version` | Print `monarch X.Y.Z (sha7 YYYY-MM-DD)` and exit | | `-V, --verbose` | Show operational progress | | `--debug` | Show stack traces; implies verbose | | `--json` | Set JSON as the output default | diff --git a/src/monarch_cli/cli_meta.py b/src/monarch_cli/cli_meta.py new file mode 100644 index 0000000..d9aca44 --- /dev/null +++ b/src/monarch_cli/cli_meta.py @@ -0,0 +1,278 @@ +"""release-standard: cli_meta.py r2 (see the release standard before editing) + +Version line and self-update for a Python CLI. Copy into the package, then (Typer): + + from . import cli_meta + + def _version(value: bool) -> None: + if value: + typer.echo(cli_meta.version_line("mytool", "my-dist")) + raise typer.Exit() + + @app.command() + def update(check: bool = False, force: bool = False) -> None: + raise typer.Exit(cli_meta.run_update("mytool", "my-dist", check=check, force=force)) + +Pass published=False to run_update for a tool that is not on PyPI: it then never looks the name +up on PyPI (an unrelated package could have it) and only updates from the original source. + +A built wheel carries _build_info.json next to this file (scripts/build_info.py). A run from a +git checkout (an editable install) reads git instead, so it always shows the current commit. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +import urllib.request +from importlib import metadata +from pathlib import Path +from typing import Any, cast + +HERE = Path(__file__).resolve().parent + + +def _run(args: list[str], cwd: Path | None = None) -> str | None: + try: + return subprocess.run( + args, cwd=cwd, capture_output=True, text=True, check=True + ).stdout.strip() + except (OSError, subprocess.CalledProcessError): + return None + + +# ── version ───────────────────────────────────────────────────────────────── + + +def format_version(name: str, info: dict[str, Any]) -> str: + if not info.get("commit"): + return f"{name} {info['version']}" + sha = info["commit"][:7] + ("-dirty" if info.get("dirty") else "") + return ( + f"{name} {info['version']} ({sha} {info['date']})" + if info.get("date") + else f"{name} {info['version']} ({sha})" + ) + + +def checkout_root(package_dir: Path = HERE) -> Path | None: + """The source checkout this module runs from, or None for an installed copy.""" + if "site-packages" in package_dir.parts or "dist-packages" in package_dir.parts: + return None + top = _run(["git", "rev-parse", "--show-toplevel"], cwd=package_dir) + return Path(top) if top and (Path(top) / "pyproject.toml").exists() else None + + +def build_info(dist: str, package_dir: Path = HERE) -> dict[str, Any]: + try: + version = metadata.version(dist) + except metadata.PackageNotFoundError: + version = "unknown" + info: dict[str, Any] = {"version": version} + root = checkout_root(package_dir) + stamp = package_dir / "_build_info.json" + if not root and stamp.exists(): + try: + return {**info, **json.loads(stamp.read_text())} + except ValueError: + pass + commit = _run(["git", "rev-parse", "HEAD"], cwd=root) if root else None + if commit: + info["commit"] = commit + info["date"] = _run(["git", "show", "-s", "--format=%cs", "HEAD"], cwd=root) + info["dirty"] = _run(["git", "status", "--porcelain"], cwd=root) != "" + return info + + +def version_line(name: str, dist: str) -> str: + return format_version(name, build_info(dist)) + + +# ── install detection ─────────────────────────────────────────────────────── + + +def detect_install(package_dir: str, checkout: bool) -> str: + if checkout: + return "checkout" + p = package_dir.replace("\\", "/") + if "/uv/tools/" in p: + return "uv" + if "/mise/installs/pipx-" in p: + return "mise" + if "/pipx/venvs/" in p: + return "pipx" + if "/site-packages/" in p or "/dist-packages/" in p: + return "pip" + return "unknown" + + +def reinstall_checkout( + root: Path, prefix: str = sys.prefix, python: str = sys.executable +) -> list[str]: + """Reinstall an editable checkout with the installer that owns its environment, so dependency + changes and the version in the installed metadata follow the pull.""" + p = prefix.replace("\\", "/") + if "/uv/tools/" in p: + return ["uv", "tool", "install", "--editable", str(root), "--reinstall"] + if "/pipx/venvs/" in p: + return ["pipx", "install", "--editable", str(root), "--force"] + return [python, "-m", "pip", "install", "--editable", str(root)] + + +def update_steps( + kind: str, + dist: str, + *, + root: Path | None = None, + published: bool = True, + prefix: str = sys.prefix, + python: str = sys.executable, +) -> list[list[str]] | None: + if kind == "checkout": + return ( + [["git", "pull", "--ff-only"], reinstall_checkout(root, prefix, python)] + if root + else None + ) + # uv and pipx reinstall from the source recorded at install time; pip and mise would look the + # name up on PyPI, which is wrong for an unpublished tool. + steps = { + "uv": [["uv", "tool", "upgrade", dist]], + "pipx": [["pipx", "upgrade", dist]], + "mise": [["mise", "upgrade", "--bump", f"pipx:{dist}"]] if published else None, + "pip": [[python, "-m", "pip", "install", "--upgrade", dist]] if published else None, + } + return steps.get(kind) + + +# ── latest version ────────────────────────────────────────────────────────── + + +def compare_versions(a: str, b: str) -> int: + """Negative when a < b. Plain X.Y.Z; a prerelease sorts before its release.""" + + def parse(v: str) -> tuple[list[int], str | None]: + core, _, pre = v.lstrip("v").partition("-") + nums = [int(n) if n.isdigit() else 0 for n in core.split(".")[:3]] + return nums + [0] * (3 - len(nums)), pre or None + + (x, xp), (y, yp) = parse(a), parse(b) + if x != y: + return -1 if x < y else 1 + if xp == yp: + return 0 + if xp is None: + return 1 + if yp is None: + return -1 + return -1 if xp < yp else 1 + + +def pypi_latest(dist: str) -> str | None: + try: + with urllib.request.urlopen(f"https://pypi.org/pypi/{dist}/json", timeout=10) as r: + return cast(str, json.load(r)["info"]["version"]) + except (OSError, ValueError, KeyError): + return None + + +def check_for_update( + kind: str, dist: str, info: dict[str, Any], root: Path | None, published: bool = True +) -> dict[str, Any]: + """{'current', 'latest', 'available' (None when unknown), 'reason'}.""" + if kind == "checkout" and root: + if _run(["git", "fetch", "--quiet", "origin", "main"], cwd=root) is None: + return { + "current": info["version"], + "available": None, + "reason": "could not fetch origin/main", + } + behind = _run(["git", "rev-list", "--count", "HEAD..origin/main"], cwd=root) + head = _run(["git", "rev-parse", "--short=7", "HEAD"], cwd=root) + remote = _run(["git", "rev-parse", "--short=7", "origin/main"], cwd=root) + return {"current": head, "latest": remote, "available": behind not in (None, "0")} + if not published: + reason = ( + "unpublished, so there is no version to compare; " + "`update --force` reinstalls from the original source" + ) + return {"current": info["version"], "available": None, "reason": reason} + latest = pypi_latest(dist) + if not latest: + reason = "could not get the latest version from PyPI" + return {"current": info["version"], "available": None, "reason": reason} + return { + "current": info["version"], + "latest": latest, + "available": compare_versions(info["version"], latest) < 0, + } + + +# ── update command ────────────────────────────────────────────────────────── + + +def run_update( + name: str, dist: str, check: bool = False, force: bool = False, published: bool = True +) -> int: + """Exit code: see the release standard's update table.""" + root = checkout_root() + kind = detect_install(str(HERE), root is not None) + info = build_info(dist) + status = check_for_update(kind, dist, info, root, published) + + if status["available"] is None: + print( + f"{name}: cannot tell whether an update is available ({status['reason']}).", + file=sys.stderr, + ) + if check or not force: + return 2 + elif not status["available"] and not force: + print(f"{name} is up to date ({status['current']}).") + return 0 + elif status["available"]: + print(f"{name}: update available, {status['current']} → {status['latest']}.") + if check: + return 1 if status["available"] else 0 + + steps = update_steps(kind, dist, root=root, published=published) + if steps is None: + print( + f"{name}: cannot update the install at {HERE} automatically ({kind}).", file=sys.stderr + ) + print( + f"Update it the way it was installed, for example `uv tool upgrade {dist}`.", + file=sys.stderr, + ) + return 1 + if kind == "checkout": + branch = _run(["git", "symbolic-ref", "--quiet", "--short", "HEAD"], cwd=root) + dirty = _run(["git", "status", "--porcelain"], cwd=root) != "" + if branch != "main" or dirty: + print( + f"{name}: the checkout at {root} must be clean and on main to update.", + file=sys.stderr, + ) + return 1 + for step in steps: + print("$ " + " ".join(step)) + try: + code = subprocess.run(step, cwd=root if kind == "checkout" else None).returncode + except OSError as e: + print(f"{name}: could not run `{step[0]}` ({e.strerror or e}).", file=sys.stderr) + return 1 + if code != 0: + print(f"{name}: `{' '.join(step)}` failed.", file=sys.stderr) + if kind == "checkout" and step[0] != "git": + # The pull is done, so a plain retry would report "up to date" + # and skip the reinstall. + print( + f"Fix the cause, then run `{name} update --force` to finish the update.", + file=sys.stderr, + ) + else: + print(f"Fix the cause, then run `{name} update` again.", file=sys.stderr) + return 1 + print(f"{name} updated. Check with `{name} --version`.") + return 0 diff --git a/src/monarch_cli/commands/update.py b/src/monarch_cli/commands/update.py new file mode 100644 index 0000000..fe4e492 --- /dev/null +++ b/src/monarch_cli/commands/update.py @@ -0,0 +1,50 @@ +"""``monarch update`` command. + +Thin Typer wiring around the release-standard ``cli_meta`` module, which owns +the install-method detection and the actual upgrade steps. The command never +touches financial data or credentials: it updates the local installation the +way it was installed (source checkout, ``uv tool``, ``pipx``, ``pip``, or +``mise``) and reports what it would run before running it. +""" + +from __future__ import annotations + +import typer + +from .. import cli_meta +from ..core.error_handler import handle_errors +from ..core.operations import Effect, operation_effects + + +@handle_errors +@operation_effects(Effect.LOCAL_INSTALL_CHANGE) +def update( + check: bool = typer.Option( + False, + "--check", + help=( + "Report whether an update is available without changing anything. " + "Exit 0 when up to date, 1 when an update is available, 2 when unknown." + ), + ), + force: bool = typer.Option( + False, + "--force", + help="Update even when the install already looks current.", + ), +) -> None: + """Update monarch with the method it was installed with. + + A source checkout updates from ``origin/main`` with ``git pull --ff-only`` + and a reinstall, and refuses a dirty tree or a branch other than ``main``. + Package-manager installs run that package manager's upgrade command. + + Examples: + monarch update --check + monarch update + monarch update --force + """ + raise typer.Exit(cli_meta.run_update("monarch", "monarch-cli", check=check, force=force)) + + +__all__ = ["update"] diff --git a/src/monarch_cli/core/capabilities.py b/src/monarch_cli/core/capabilities.py index 0fa67a5..5a9f119 100644 --- a/src/monarch_cli/core/capabilities.py +++ b/src/monarch_cli/core/capabilities.py @@ -374,6 +374,11 @@ def requires_authorization(self) -> bool: frozenset({Effect.READ_ONLY}), output=OutputCapability(quiet=False), ), + "update": CommandCapability( + "update", + frozenset({Effect.LOCAL_INSTALL_CHANGE}), + output=OutputCapability(quiet=False, stable_normalized=False), + ), } #: The public command that emits this manifest. It is self-describing: the diff --git a/src/monarch_cli/core/operations.py b/src/monarch_cli/core/operations.py index 28fc36d..f2a0e4c 100644 --- a/src/monarch_cli/core/operations.py +++ b/src/monarch_cli/core/operations.py @@ -67,6 +67,9 @@ class Effect(StrEnum): REMOTE_MUTATION = "remote_mutation" #: Writes or removes local authentication state. LOCAL_CREDENTIAL_CHANGE = "local_credential_change" + #: Replaces the installed copy of the CLI itself (``monarch update``); it + #: never touches remote financial data or credentials. + LOCAL_INSTALL_CHANGE = "local_install_change" #: Attribute used to attach declared effects to a command callback. Because diff --git a/src/monarch_cli/main.py b/src/monarch_cli/main.py index a62841d..197c7d4 100644 --- a/src/monarch_cli/main.py +++ b/src/monarch_cli/main.py @@ -2,7 +2,7 @@ import typer -from monarch_cli import __version__ +from monarch_cli import cli_meta from monarch_cli.commands import ( accounts, auth, @@ -14,6 +14,7 @@ investments, subscription, transactions, + update, ) from monarch_cli.core.config import get_config, set_config from monarch_cli.core.error_handler import handle_errors @@ -37,12 +38,18 @@ # Root-level commands (no group) app.command("capabilities")(capabilities.capabilities) +app.command("update")(update.update) def version_callback(value: bool) -> None: - """Print version and exit.""" + """Print the release-standard version line and exit. + + The line is ``monarch X.Y.Z (sha7 YYYY-MM-DD)``, with ``-dirty`` when the + build came from an uncommitted tree. A checkout reads git; an installed + wheel reads the ``_build_info.json`` stamp written before it was built. + """ if value: - typer.echo(f"monarch-cli {__version__}") + typer.echo(cli_meta.version_line("monarch", "monarch-cli")) raise typer.Exit() diff --git a/tests/fixtures/capabilities.manifest.json b/tests/fixtures/capabilities.manifest.json index 70324a2..ab65938 100644 --- a/tests/fixtures/capabilities.manifest.json +++ b/tests/fixtures/capabilities.manifest.json @@ -3350,6 +3350,55 @@ "requires_destructive_confirmation": false, "supports_preview": true } + }, + { + "arguments": [], + "effects": [ + "local_install_change" + ], + "name": "update", + "options": [ + { + "choices": [], + "default": false, + "flags": [ + "--check" + ], + "kind": "option", + "name": "check", + "repeatable": false, + "required": false, + "type": "boolean" + }, + { + "choices": [], + "default": false, + "flags": [ + "--force" + ], + "kind": "option", + "name": "force", + "repeatable": false, + "required": false, + "type": "boolean" + } + ], + "output": { + "ndjson": false, + "quiet": false, + "raw": false, + "schemas": [], + "stable_normalized": false + }, + "path": [ + "update" + ], + "safety": { + "interactive": false, + "requires_authorization": false, + "requires_destructive_confirmation": false, + "supports_preview": false + } } ], "global": { @@ -3723,6 +3772,7 @@ "taxonomy": { "effects": [ "local_credential_change", + "local_install_change", "preview", "read_only", "remote_authentication", diff --git a/tests/test_cli_framework.py b/tests/test_cli_framework.py index 9163e67..af00889 100644 --- a/tests/test_cli_framework.py +++ b/tests/test_cli_framework.py @@ -78,6 +78,11 @@ def isolated_config_dir(tmp_path, monkeypatch: pytest.MonkeyPatch) -> None: ANSI_ESCAPE_RE = re.compile(rb"\x1b\[[0-9;]*m") +#: The release-standard version line: ``monarch X.Y.Z (sha7[-dirty] YYYY-MM-DD)``. +VERSION_LINE_RE = re.compile( + r"^monarch \d+\.\d+\.\d+ \([0-9a-f]{7}(?:-dirty)? \d{4}-\d{2}-\d{2}\)$" +) + @pytest.fixture(autouse=True) def reset_output_state() -> None: @@ -190,7 +195,7 @@ def test_version_flag(self) -> None: assert result.exit_code == 0, result.output assert_parsed_cleanly(result, caught) - assert "monarch-cli" in result.output + assert VERSION_LINE_RE.match(result.output.strip()), result.output def test_global_options_parse_before_subcommand(self) -> None: storage_info = { diff --git a/tests/test_cli_meta.py b/tests/test_cli_meta.py new file mode 100644 index 0000000..fc06159 --- /dev/null +++ b/tests/test_cli_meta.py @@ -0,0 +1,123 @@ +"""Release-standard version line and ``update`` command tests. + +These cover the CLI wiring only: the ``--version`` shape and the flags the +``update`` command passes to :mod:`monarch_cli.cli_meta`. The install-method +detection and the actual upgrade commands live in ``cli_meta`` and are not +invoked here (a test must never change the developer's installation). +""" + +from __future__ import annotations + +import re +from typing import Any + +import pytest +from typer.testing import CliRunner + +from monarch_cli import __version__, cli_meta +from monarch_cli.main import app + +runner = CliRunner() + +ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m") + +#: A wide terminal keeps help text from wrapping option names. +_HELP_ENV = {"COLUMNS": "200", "NO_COLOR": "1", "TERM": "dumb"} + +#: The release-standard version line: ``monarch X.Y.Z (sha7[-dirty] YYYY-MM-DD)``. +VERSION_LINE_RE = re.compile( + r"^monarch (?P\d+\.\d+\.\d+) " + r"\((?P[0-9a-f]{7})(?P-dirty)? (?P\d{4}-\d{2}-\d{2})\)$" +) + + +class TestVersionLine: + @pytest.mark.parametrize( + ("info", "expected"), + [ + ({"version": "1.4.2"}, "mytool 1.4.2"), + ( + {"version": "1.4.2", "commit": "a1b2c3d4e5", "date": "2026-10-27"}, + "mytool 1.4.2 (a1b2c3d 2026-10-27)", + ), + ( + { + "version": "1.4.3", + "commit": "9f8e7d6c5b", + "date": "2026-10-28", + "dirty": True, + }, + "mytool 1.4.3 (9f8e7d6-dirty 2026-10-28)", + ), + ], + ) + def test_format_version(self, info: dict[str, Any], expected: str) -> None: + assert cli_meta.format_version("mytool", info) == expected + + def test_version_line_uses_build_info(self, monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr( + cli_meta, + "build_info", + lambda _dist: {"version": "1.2.3", "commit": "abcdef0123", "date": "2026-01-02"}, + ) + assert cli_meta.version_line("monarch", "monarch-cli") == ( + "monarch 1.2.3 (abcdef0 2026-01-02)" + ) + + def test_cli_version_prints_the_standard_line(self) -> None: + result = runner.invoke(app, ["--version"]) + + assert result.exit_code == 0, result.output + line = result.output.strip() + match = VERSION_LINE_RE.match(line) + assert match is not None, line + assert match.group("version") == __version__ + + +class TestUpdateCommand: + def test_check_passes_the_installed_package_and_exit_code( + self, monkeypatch: pytest.MonkeyPatch + ) -> None: + calls: dict[str, Any] = {} + + def fake_run_update( + name: str, dist: str, *, check: bool = False, force: bool = False + ) -> int: + calls.update({"name": name, "dist": dist, "check": check, "force": force}) + return 1 + + monkeypatch.setattr(cli_meta, "run_update", fake_run_update) + + result = runner.invoke(app, ["update", "--check"]) + + assert result.exit_code == 1, result.output + assert calls == { + "name": "monarch", + "dist": "monarch-cli", + "check": True, + "force": False, + } + + def test_update_and_force_reach_run_update(self, monkeypatch: pytest.MonkeyPatch) -> None: + calls: list[dict[str, Any]] = [] + + def fake_run_update( + name: str, dist: str, *, check: bool = False, force: bool = False + ) -> int: + calls.append({"name": name, "dist": dist, "check": check, "force": force}) + return 0 + + monkeypatch.setattr(cli_meta, "run_update", fake_run_update) + + assert runner.invoke(app, ["update"]).exit_code == 0 + assert runner.invoke(app, ["update", "--force"]).exit_code == 0 + assert [call["force"] for call in calls] == [False, True] + assert all(call["check"] is False for call in calls) + + def test_help_documents_check_and_force(self) -> None: + result = runner.invoke(app, ["update", "--help"], env=_HELP_ENV) + + assert result.exit_code == 0, result.output + output = ANSI_ESCAPE_RE.sub("", result.output) + assert "--check" in output + assert "--force" in output diff --git a/tests/test_mutation_policy.py b/tests/test_mutation_policy.py index 2ce5c9e..75fc746 100644 --- a/tests/test_mutation_policy.py +++ b/tests/test_mutation_policy.py @@ -45,6 +45,7 @@ #: renamed command must update this mapping (and its declarations) explicitly. EXPECTED_INVENTORY: dict[str, frozenset[Effect]] = { "capabilities": frozenset({Effect.READ_ONLY}), + "update": frozenset({Effect.LOCAL_INSTALL_CHANGE}), "accounts list": frozenset({Effect.READ_ONLY}), "accounts types": frozenset({Effect.READ_ONLY}), "accounts history": frozenset({Effect.READ_ONLY}), From ed28de7ad1202ff38e5f791188e5b928e5906e70 Mon Sep 17 00:00:00 2001 From: cc-vps Date: Tue, 27 Oct 2026 18:21:50 -0700 Subject: [PATCH 2/2] chore: adopt the release standard Add the canonical release-standard scripts (prep-release, check-changelog, build_info) plus a project-owned bump script and Makefile target. Release flow now refuses a dirty tree, a branch other than main, HEAD != origin/main, an existing tag, or an empty Unreleased section; smoke-install stamps the build, checks that the wheel and sdist ship the stamp, and verifies the exact --version line of both console entry points. release.sh refuses to tag when the stamp is not HEAD and still publishes to PyPI separately, last. Move the release guide to the repository root, keep docs/RELEASING.md as a pointer, and rewrite the README install section with registry installs, a from-source section, and the update command. Co-authored-by: Christian Catalan --- .gitignore | 4 + CHANGELOG.md | 23 +++ Makefile | 9 +- README.md | 40 +++- RELEASING.md | 366 +++++++++++------------------------ docs/RELEASING.md | 5 + pyproject.toml | 3 + scripts/build_info.py | 37 ++++ scripts/bump.sh | 116 +++++++++++ scripts/check-changelog.sh | 19 ++ scripts/prep-release.sh | 44 +++++ scripts/release.sh | 88 ++++++--- scripts/smoke_install.sh | 84 +++++++- tests/test_version_parity.py | 6 +- 14 files changed, 551 insertions(+), 293 deletions(-) create mode 100644 docs/RELEASING.md create mode 100755 scripts/build_info.py create mode 100755 scripts/bump.sh create mode 100755 scripts/check-changelog.sh create mode 100755 scripts/prep-release.sh diff --git a/.gitignore b/.gitignore index f5b1900..f85d441 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,10 @@ build/ *.egg-info/ *.egg +# Build stamp generated by scripts/build_info.py before a build (release +# standard); it records the commit and must never be committed. +src/monarch_cli/_build_info.json + # Testing .pytest_cache/ .coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index 49730ad..8316992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- `monarch update` replaces the installed copy using the method it was + installed with (source checkout, `uv tool`, `pipx`, `pip`, or `mise`). + `monarch update --check` reports whether an update is available without + changing anything (exit 0 up to date, 1 update available, 2 unknown). A + source checkout pulls `origin/main` and reinstalls; it refuses a dirty tree + or a branch other than `main`. +- `--version` and `-v` now include the commit and date: `monarch 0.2.1 + (1a2b3c4 2026-06-01)`, with `-dirty` for a build from an uncommitted tree. + Built wheels and sdists carry a `_build_info.json` stamp so an installed copy + prints the same line as the checkout it came from. + +### Changed + +- Release tooling follows the release standard: `make bump VERSION=X.Y.Z` + updates the version metadata and changelog and commits `chore: release + vX.Y.Z`; `scripts/check-changelog.sh` refuses an empty Unreleased section; + `scripts/release.sh` requires a clean `main` at `HEAD == origin/main` and + refuses to tag when the build stamp does not match `HEAD`. +- Smoke-test/CI builds (`make smoke-install`) stamp the build and verify the + exact `--version` line of both console entry points. + ## [0.2.1] - 2026-09-21 ### Added diff --git a/Makefile b/Makefile index b8502a0..403d504 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: setup verify format format-check lint lint-fix typecheck test test-live test-live-mutation smoke-install release release-dry prepublish update-capabilities-fixture +.PHONY: setup verify format format-check lint lint-fix typecheck test test-live test-live-mutation smoke-install release release-dry prepublish bump update-capabilities-fixture # Python version used by the isolated wheel smoke-install environment. SMOKE_PYTHON ?= 3.13 @@ -46,6 +46,13 @@ test-live: test-live-mutation: MONARCH_LIVE_MUTATION_TESTS=1 uv run pytest tests/live/ -m live_mutation -v +# Bump the version (src/monarch_cli/__init__.py, pyproject.toml, uv.lock), move +# the Unreleased changelog entries into a dated section, and commit +# "chore: release vX.Y.Z". Refuses a dirty tree, a branch other than main, an +# existing tag, or an empty Unreleased section. See RELEASING.md. +bump: + ./scripts/bump.sh "$(VERSION)" + # Create GitHub release with tag and changelog release: ./scripts/release.sh diff --git a/README.md b/README.md index 1149d33..da2c418 100644 --- a/README.md +++ b/README.md @@ -25,32 +25,56 @@ net worth. - Side-effect-free `monarch capabilities` manifest for agent/integration discovery of commands, options, safety policy, and contract versions -## Installation +## Install -### pip +Requires Python 3.12 or newer. + +### pip, uv, or pipx ```bash pip install monarch-cli +uv tool install monarch-cli # recommended: isolated, on your PATH +pipx install monarch-cli ``` -### uv (recommended) +Run it without installing: ```bash -uv tool install monarch-cli +uvx monarch-cli --help ``` -### pipx +### From source ```bash -pipx install monarch-cli +git clone https://github.com/crcatala/monarch-cli.git +cd monarch-cli +uv tool install --editable . # puts `monarch` on PATH, running this clone's code +monarch --version # monarch X.Y.Z (sha7 YYYY-MM-DD): the commit you are on ``` -Verify the installation: +Code changes take effect on the next run; reinstall (`uv tool install +--editable . --reinstall`) after dependencies change. For a full development +environment instead, see [Development](#development) (`make setup` and +`uv run monarch`). + +Verify the install with: ```bash monarch --version +monarch --help ``` +### Updating + +```bash +monarch update --check # exit 0: up to date, 1: update available, 2: unknown +monarch update # updates the way it was installed +``` + +A source checkout updates with `git pull --ff-only` plus a reinstall, and +refuses a dirty tree or a branch other than `main`. Package-manager installs +use the matching `uv tool`, `pipx`, `pip`, or `mise` upgrade command. + ## Upgrading Version-specific migration guides, including breaking-change notes, are in @@ -344,7 +368,7 @@ make verify See [CONTRIBUTING.md](CONTRIBUTING.md) for development workflow and [docs/testing.md](docs/testing.md) for live-test safeguards. Release -instructions are in [docs/RELEASING.md](docs/RELEASING.md). +instructions are in [RELEASING.md](RELEASING.md). ## Contributing diff --git a/RELEASING.md b/RELEASING.md index 2ddfa83..530a32e 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -1,308 +1,178 @@ # Releasing -This is the single source of truth for releasing monarch-cli. Follow the steps -in order. Every command below is safe to run from the repository root. +Releases are cut by hand from a clean `main`. `make release` creates the tag +and the GitHub Release; the PyPI upload is a separate, last step from the +tagged commit, so a failed upload never leaves a half-made tag. -The release has two independent destinations: +TestPyPI is a rehearsal for the PyPI upload. Publishing versions are immutable +on both indexes. -- **GitHub Releases** — tag + changelog + wheel/sdist assets (created by `make release`). -- **PyPI** — the published package (uploaded manually; `make release` never publishes to PyPI). - -TestPyPI is a dry run for the PyPI upload. **Always test on TestPyPI first.** - -## TL;DR (happy path) - -```bash -# 1. Bump the version in three places + CHANGELOG (see Step 1) -# 2. Verify, build, and validate — wipes and rebuilds dist/ -make prepublish - -# 3. Upload to TestPyPI and verify a clean install -uv run twine upload --repository testpypi dist/* - -# 4. Create the git tag + GitHub release (does NOT touch PyPI) -make release-dry # preview -make release # for real - -# 5. Publish to PyPI -uv run twine upload dist/* -``` - -If any step fails, jump to [Failure recovery](#failure-recovery). Every guard -prints an exact cause and the next action. +`scripts/prep-release.sh`, `scripts/check-changelog.sh`, and +`scripts/build_info.py` are canonical release-standard files; do not edit them +in place. Project specifics live in `scripts/release.sh`, +`scripts/smoke_install.sh`, `scripts/bump.sh`, and this file. ## Prerequisites -### 1. Create PyPI accounts - -You need accounts on both TestPyPI (for testing) and PyPI (for production): +- Push access to `main`; `gh auth status` succeeds. +- PyPI and TestPyPI API tokens. `twine` reads `~/.pypirc`: -- **TestPyPI:** https://test.pypi.org/account/register/ -- **PyPI:** https://pypi.org/account/register/ + ```ini + [testpypi] + username = __token__ + password = pypi-YOUR_TEST_PYPI_TOKEN -### 2. Create API tokens + [pypi] + username = __token__ + password = pypi-YOUR_PYPI_TOKEN + ``` -- **TestPyPI:** https://test.pypi.org/manage/account/token/ -- **PyPI:** https://pypi.org/manage/account/token/ + `chmod 600 ~/.pypirc`. Tokens are secrets: never paste the file into a + shell, issue tracker, or chat; rotate a token immediately if it is exposed. -Select "Entire account" scope for first-time uploads. After publishing, you can -create project-scoped tokens for tighter security. +## Before releasing -### 3. Configure `~/.pypirc` +1. `git checkout main && git pull --ff-only` +2. `bash scripts/prep-release.sh`. Paste the output into an LLM, review the + result, and put the user-facing entries under `## [Unreleased]` in + `CHANGELOG.md` (Keep a Changelog groups: Added, Changed, Fixed, Removed, + Security). Commit the changelog to `main`. -```ini -[testpypi] - username = __token__ - password = pypi-YOUR_TEST_PYPI_TOKEN - -[pypi] - username = __token__ - password = pypi-YOUR_PYPI_TOKEN -``` - -Secure the file and sanity-check it: +## Release ```bash -chmod 600 ~/.pypirc -uv run twine upload --repository testpypi --help >/dev/null && echo "twine available" +make bump VERSION=X.Y.Z # version metadata + changelog section, + # commit "chore: release vX.Y.Z" +git push # the release commit must be on origin/main before tagging +make prepublish # verify, stamp, build, isolated install, --version check +make release-dry # preview the tag and GitHub Release +make release # tag vX.Y.Z (annotated "Release X.Y.Z"), push, + # GitHub Release with the changelog and dist/ attached +uv run twine upload --repository testpypi dist/* # optional rehearsal +uv run twine upload dist/* # last: publish the tagged build ``` -> Tokens are secrets. Never paste `~/.pypirc` contents into a shell, issue -> tracker, or chat. Rotate a token immediately if it is exposed. +### `make bump VERSION=X.Y.Z` -## Release workflow - -### Step 1: Update version and changelog - -The version lives in three files and they **must all agree**. `make prepublish` -and `scripts/release.sh` both verify this and fail with a clear message if they -drift. +Writes the version in every place it is declared and commits the release: | File | Field | Why it matters | |------|-------|----------------| -| `src/monarch_cli/__init__.py` | `__version__` | Source of the git tag (`v`) and `monarch --version` | +| `src/monarch_cli/__init__.py` | `__version__` | Source of the git tag (`v`) | | `pyproject.toml` | `[project].version` | Version embedded in the built wheel/sdist | -| `uv.lock` | `monarch-cli` package version | Keeps the locked editable install consistent | - -Update all three, then refresh the lock: - -```bash -# 1. edit src/monarch_cli/__init__.py -> __version__ = "X.Y.Z" -# 2. edit pyproject.toml -> version = "X.Y.Z" -uv lock # syncs uv.lock -``` - -Update `CHANGELOG.md`: - -1. Move the `## [Unreleased]` content under a new header - `## [X.Y.Z] - YYYY-MM-DD`. -2. Add a fresh empty `## [Unreleased]` section at the top. -3. Update the comparison links at the bottom: +| `uv.lock` | `monarch-cli` package version | Keeps the locked install consistent (`uv lock`) | +| `CHANGELOG.md` | Unreleased → `## [X.Y.Z] - YYYY-MM-DD` | GitHub Release notes | - ```markdown - [Unreleased]: https://github.com/crcatala/monarch-cli/compare/vX.Y.Z...HEAD - [X.Y.Z]: https://github.com/crcatala/monarch-cli/compare/vPREV...vX.Y.Z - ``` +It refuses a dirty tree, a branch other than `main`, an existing tag, or an +empty Unreleased section (`bash scripts/check-changelog.sh`). `make prepublish` +and `scripts/release.sh` re-check that the source and `pyproject.toml` +versions agree. No capabilities-fixture regeneration is needed for a version bump: the canonical capabilities fixture (`tests/fixtures/capabilities.manifest.json`) -stores a fixed placeholder CLI version and is decoupled from `__version__`. Only -after an *intentional* capabilities manifest change (a new command, option, -effect, or schema reference) run `make update-capabilities-fixture` and review -the diff. See `docs/capabilities.md`. +stores a fixed placeholder CLI version and is decoupled from `__version__`. +Only after an *intentional* capabilities manifest change (a new command, +option, effect, or schema reference) run `make update-capabilities-fixture` and +review the diff. See `docs/capabilities.md`. -Commit everything before continuing — the release script refuses to run with a -dirty working tree. +### `make prepublish` -### Step 2: Verify, build, and validate - -```bash -make prepublish -``` - -This is the one command that proves the release is publishable. It runs, in order: +The one command that proves the release is publishable. It runs, in order: 1. `format-check`, `lint`, `typecheck`, `test` (`verify`) — includes the version-parity and capabilities-fixture contract tests. -2. `smoke-install` — **wipes `dist/`**, rebuilds the wheel and sdist with - `uv build`, installs the wheel into a throwaway environment outside the - source tree, and runs both `monarch` and `monarch-cli` entry points. +2. `smoke-install` — stamps `src/monarch_cli/_build_info.json` from `HEAD`, + **wipes `dist/`**, rebuilds the wheel and sdist with `uv build`, checks that + both artifacts ship the stamp, installs the wheel into a throwaway + environment outside the source tree, and checks that `monarch` and + `monarch-cli` print exactly + `monarch X.Y.Z (sha7 YYYY-MM-DD)` (with `-dirty` only when the stamp's tree + was dirty). 3. `twine check dist/*` — validates distribution metadata. 4. `readme_renderer` — confirms the README renders on PyPI. -Success ends with all tests passing, followed by `smoke-install OK`, -`✓ Package metadata valid`, and `✓ README renders correctly`. - > **`dist/` is not a cache.** `make prepublish`, `make smoke-install`, and > `make release` each wipe and rebuild `dist/`, so the artifacts always match -> the current metadata. Do **not** run a bare `uv build` and then upload — it +> the current commit. Do **not** run a bare `uv build` and then upload — it > *adds* files without removing old ones, leaving a mixed-version `dist/` that -> PyPI rejects. Let the smoke-install target manage `dist/`. - -### Step 3: Test on TestPyPI +> PyPI rejects. -Upload the freshly built artifacts: +### `make release-dry` and `make release` -```bash -uv run twine upload --repository testpypi dist/* -``` - -Then verify a clean install in a throwaway virtualenv (the -`--extra-index-url` is required because TestPyPI does not host the runtime -dependencies): - -```bash -python -m venv /tmp/test-monarch -source /tmp/test-monarch/bin/activate -pip install --index-url https://test.pypi.org/simple/ \ - --extra-index-url https://pypi.org/simple/ monarch-cli==X.Y.Z -monarch --version # should print: monarch-cli X.Y.Z -monarch --help -deactivate -rm -rf /tmp/test-monarch -``` - -Confirm the page renders correctly: `https://test.pypi.org/project/monarch-cli/`. - -TestPyPI versions are immutable, just like PyPI. If you need to fix and re-test, -bump to a new version (for example `X.Y.Zrc2`) — you cannot overwrite. - -### Step 4: Create the GitHub release - -Once TestPyPI looks good: - -```bash -make release-dry # preview every action and the changelog that will be used -make release # create tag + GitHub release (does NOT publish to PyPI) -``` +`make release-dry` previews every action and the changelog that will be used. +It warns (rather than refuses) when the branch is not `main`, `HEAD` is not +`origin/main`, or the version's tag already exists, so it can be run on an +unreleased branch. It never creates a tag or a release. `make release` verifies, in order: - `__init__.py` and `pyproject.toml` versions match; +- the working tree is clean and the branch is `main`; +- `HEAD` equals `origin/main` (the release commit was pushed); - the tag `vX.Y.Z` does not already exist; -- the working tree is clean; - a `## [X.Y.Z]` changelog section exists; -- it is on `main` (warns and prompts otherwise); -- after building, `dist/monarch_cli-X.Y.Z-*.whl` exists. - -It then builds via `make smoke-install`, prompts for confirmation, creates and -pushes the annotated tag `vX.Y.Z`, and creates the GitHub release with the -changelog as notes and `dist/*` as assets. - -### Step 5: Publish to PyPI - -```bash -uv run twine upload dist/* -``` - -Verify at `https://pypi.org/project/monarch-cli/`. - -## Command reference +- `smoke-install` rebuilt `dist/` and the stamp in + `src/monarch_cli/_build_info.json` is `HEAD`; +- `dist/monarch_cli-X.Y.Z-*.whl` exists. -| Action | Command | -|--------|---------| -| Bump lockfile after version edit | `uv lock` | -| Verify + build + validate (wipes `dist/`) | `make prepublish` | -| Rebuild + isolated install only | `make smoke-install` | -| Regenerate capabilities fixture (intentional manifest change only) | `make update-capabilities-fixture` | -| Upload to TestPyPI | `uv run twine upload --repository testpypi dist/*` | -| Verbose upload (shows the real server error) | `uv run twine upload --verbose --repository testpypi dist/*` | -| Preview GitHub release | `make release-dry` | -| Create GitHub release | `make release` | -| Upload to PyPI | `uv run twine upload dist/*` | +It then prompts for confirmation, creates and pushes the annotated tag +`vX.Y.Z` (`Release X.Y.Z`), and creates the GitHub Release with the changelog +as notes and `dist/*` as assets. It never publishes to PyPI. -## Failure recovery +### Publish last -Each guard is designed to stop you *before* an irreversible action and tell you -exactly what to do. Find your symptom below. - -### `400 Bad Request` / `HTTPError` from `twine upload` - -Twine hides the server's reason by default. Re-run with `--verbose` (after -`upload`) to see it: +Upload from the same `dist/` built from the tagged commit: ```bash -uv run twine upload --verbose --repository testpypi dist/* -``` - -The overwhelmingly common reason is: - -``` -400 File already exists ('monarch_cli-X.Y.Z-...whl', ...) +uv run twine upload dist/* ``` -**Cause:** you are uploading a version that already exists (published versions -are immutable), usually because `dist/` is stale or was built before the version -bump. - -**Fix:** rebuild so `dist/` matches the intended version, then re-upload: +Optionally rehearse on TestPyPI first: ```bash -make smoke-install # wipes and rebuilds dist/ -ls dist/ # confirm only monarch_cli-X.Y.Z-* files are present uv run twine upload --repository testpypi dist/* -``` - -If the version genuinely already exists on the index and you need changes, bump -the version (Step 1) and start again. Never delete/re-upload a published version. - -### `❌ Version mismatch` (from `make release`) - -`src/monarch_cli/__init__.py` and `pyproject.toml` disagree. Set both to the -same version and run `uv lock`, then retry. The same invariant is enforced by -`make prepublish` via `tests/test_version_parity.py`. - -### `❌ Tag vX.Y.Z already exists` - -The tag was already created (a previous release, or you re-ran `make release`). -Either delete the tag if it was a mistake (`git tag -d vX.Y.Z && git push origin -:refs/tags/vX.Y.Z`) or bump the version and release that instead. - -### `❌ Uncommitted changes detected` - -Commit or stash your changes first. The release must correspond to a commit. -`git status --short` is printed for you. - -### `❌ No changelog entry found for version X.Y.Z` - -Add a `## [X.Y.Z]` section to `CHANGELOG.md` (Step 1), commit it, and retry. - -### `capabilities manifest drifted from the canonical fixture` - -Only legitimate after an *intentional* capabilities manifest change. Regenerate -and review the diff: - -```bash -make update-capabilities-fixture -git diff tests/fixtures/capabilities.manifest.json -``` - -This is **not** needed for a version bump — the fixture is version-independent. - -### Token not working - -- Ensure there is no extra whitespace in `~/.pypirc`. -- The token must start with `pypi-`. -- Confirm the token is not expired or revoked, and that the username is exactly - `__token__`. - -### Package not installable from TestPyPI - -TestPyPI does not host all dependencies. Always pass the production index as a -fallback: - -```bash +python -m venv /tmp/test-monarch +source /tmp/test-monarch/bin/activate pip install --index-url https://test.pypi.org/simple/ \ --extra-index-url https://pypi.org/simple/ monarch-cli==X.Y.Z -``` - -### "File already exists" on PyPI but you have not published this version - -This means the version is already on the index (possibly from a teammate's -upload or an earlier attempt). Confirm with: +monarch --version # monarch X.Y.Z (sha7 YYYY-MM-DD) +deactivate && rm -rf /tmp/test-monarch +``` + +TestPyPI does not host the runtime dependencies, so the production index +fallback is required. TestPyPI versions are immutable too: to re-test, bump to +a new version (for example `X.Y.Zrc2`). + +## When a step fails + +- **Before `git push`**: nothing is on the remote yet. Fix and repeat; + `git reset --hard origin/main` drops a local bump commit. +- **After `git push`, before `make release`**: the bump commit is on `main` but + untagged. Fix the cause and continue from `make prepublish`; do not bump + again. +- **`tag vX.Y.Z already exists`**: either delete the mistaken tag + (`git tag -d vX.Y.Z && git push origin :refs/tags/vX.Y.Z`) or bump to the + next version. +- **`HEAD is not origin/main`**: push the release commit first. +- **`No changelog entry found for version X.Y.Z`**: add the section (or use + `make bump`) and commit it. +- **`dist/ was built from ... not HEAD`**: rebuild with `make prepublish` + before tagging. +- **GitHub Release**: the tag is pushed. Create it with + `gh release create vX.Y.Z dist/* --title vX.Y.Z --notes-file
`. +- **Upload `400 Bad Request` / `HTTPError`**: twine hides the reason. Re-run + with `--verbose`; the usual cause is `File already exists`, meaning the + version is already published. Never delete or re-upload a published version; + bump instead. Files already on PyPI can be skipped with + `uv publish --check-url https://pypi.org/simple/` (or just re-run twine). +- **Token not working**: no extra whitespace in `~/.pypirc`, the token starts + with `pypi-`, the username is exactly `__token__`, and the token is not + expired or revoked. + +## Verify ```bash -curl -s https://pypi.org/pypi/monarch-cli/json | python3 -c "import sys,json;print(list(json.load(sys.stdin)['releases']))" +monarch --version # monarch X.Y.Z (sha7 YYYY-MM-DD) +monarch update --check # 0 up to date, 1 update available, 2 unknown +uvx monarch-cli@latest --version ``` - -If the version is present, you cannot re-upload it — bump the version. diff --git a/docs/RELEASING.md b/docs/RELEASING.md new file mode 100644 index 0000000..8893f33 --- /dev/null +++ b/docs/RELEASING.md @@ -0,0 +1,5 @@ +# Releasing + +The release guide lives at the repository root: [RELEASING.md](../RELEASING.md). + +This page is kept as a redirect so older links keep working. diff --git a/pyproject.toml b/pyproject.toml index f56bb87..71d35d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,9 @@ build-backend = "uv_build" [tool.ruff] target-version = "py312" line-length = 100 +# Canonical release-standard copy: kept byte-identical to its template, so +# it is not reformatted or linted here. +extend-exclude = ["scripts/build_info.py"] [tool.ruff.lint] select = ["E", "W", "F", "I", "B", "C4", "UP", "ARG", "SIM"] diff --git a/scripts/build_info.py b/scripts/build_info.py new file mode 100755 index 0000000..d582775 --- /dev/null +++ b/scripts/build_info.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""release-standard: scripts/build_info.py r1 (do not edit; see RELEASING.md) + +Usage: python3 scripts/build_info.py src//_build_info.json + +Stamps the commit, commit date and dirty state into the package. Run it right before +building the wheel and sdist, from the commit that is (or will be) tagged. +""" + +import json +import subprocess +import sys +from pathlib import Path + + +def git(*args: str) -> str | None: + try: + return subprocess.run(["git", *args], capture_output=True, text=True, check=True).stdout.strip() + except (OSError, subprocess.CalledProcessError): + return None + + +if len(sys.argv) != 2: + sys.exit(__doc__) + +info: dict = {} +commit = git("rev-parse", "HEAD") +if commit: + info = { + "commit": commit, + "date": git("show", "-s", "--format=%cs", "HEAD"), + # Ignored files (the stamp itself, build output) do not count. + "dirty": git("status", "--porcelain") != "", + } +out = Path(sys.argv[1]) +out.parent.mkdir(parents=True, exist_ok=True) +out.write_text(json.dumps(info, indent=2) + "\n") diff --git a/scripts/bump.sh b/scripts/bump.sh new file mode 100755 index 0000000..2720cff --- /dev/null +++ b/scripts/bump.sh @@ -0,0 +1,116 @@ +#!/usr/bin/env bash +# Project-owned version bump for the release standard (see RELEASING.md). +# +# Usage: make bump VERSION=X.Y.Z (or scripts/bump.sh X.Y.Z) +# +# Sets the version in src/monarch_cli/__init__.py and pyproject.toml, refreshes +# uv.lock, moves the Unreleased changelog entries into a dated section with a +# fresh empty Unreleased section, updates the comparison links, and commits +# "chore: release vX.Y.Z". The maintainer pushes that commit. +# +# It refuses a dirty tree, a branch other than main, an existing tag, or an +# empty Unreleased section (bash scripts/check-changelog.sh). +set -euo pipefail + +fail() { + printf 'Error: %s\n' "$*" >&2 + exit 1 +} + +version="${1:-}" +if [[ -z "$version" ]]; then + fail "usage: make bump VERSION=X.Y.Z (or scripts/bump.sh X.Y.Z)" +fi +if [[ ! "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + fail "VERSION must be a plain semantic version (X.Y.Z); got '$version'." +fi + +repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$repo_root" + +branch="$(git branch --show-current)" +[[ "$branch" == "main" ]] || fail "release from a clean main, not '$branch'." + +if [[ -n "$(git status --porcelain)" ]]; then + git status --short >&2 + fail "the working tree is not clean; commit or stash first." +fi + +if git rev-parse --verify --quiet "v${version}^{commit}" >/dev/null; then + fail "tag v${version} already exists; pick the next version." +fi + +# Refuses an empty Unreleased section before anything is rewritten. +bash scripts/check-changelog.sh + +python3 - "$version" <<'PY' +import datetime +import re +import sys +from pathlib import Path + +version = sys.argv[1] +today = datetime.datetime.now(datetime.timezone.utc).date().isoformat() + + +def replace_once(path: str, pattern: str, replacement: str) -> None: + text = Path(path).read_text(encoding="utf-8") + new, count = re.subn(pattern, replacement, text, count=1, flags=re.MULTILINE) + if count != 1: + sys.exit(f"could not update {path}: pattern {pattern!r} matched {count} times") + Path(path).write_text(new, encoding="utf-8") + + +replace_once( + "src/monarch_cli/__init__.py", + r'^__version__ = "[^"]+"$', + f'__version__ = "{version}"', +) +replace_once("pyproject.toml", r'^version = "[^"]+"$', f'version = "{version}"') + +changelog = Path("CHANGELOG.md") +text = changelog.read_text(encoding="utf-8") +unreleased = re.search( + r"^## \[Unreleased\]\n(?P.*?)(?=^## \[)", text, re.MULTILINE | re.DOTALL +) +if unreleased is None: + sys.exit("CHANGELOG.md has no '## [Unreleased]' section") +body = unreleased.group("body").strip("\n") +new_section = f"## [Unreleased]\n\n## [{version}] - {today}\n" +if body: + new_section += f"\n{body}\n" +new_section += "\n" +text = text[: unreleased.start()] + new_section + text[unreleased.end() :] + +links = re.search(r"^\[Unreleased\]: (?P\S+)$", text, re.MULTILINE) +if links is None: + sys.exit("CHANGELOG.md has no '[Unreleased]:' comparison link") +previous = re.search(r"/compare/v(?P[^./\s]+(?:\.\d+)*)\.\.\.HEAD$", links.group("url")) +if previous is None: + sys.exit("could not read the previous version from the Unreleased comparison link") +previous_version = previous.group("version") +repo_url = links.group("url").split("/compare/")[0] +replacement = ( + f"[Unreleased]: {repo_url}/compare/v{version}...HEAD\n" + f"[{version}]: {repo_url}/compare/v{previous_version}...v{version}" +) +text = text[: links.start()] + replacement + text[links.end() :] +changelog.write_text(text, encoding="utf-8") + +print(f"Bumped to {version} ({today}); previous release: v{previous_version}") +PY + +uv lock + +git add -A +git commit -m "chore: release v${version}" + +printf '\nCommitted: chore: release v%s\n' "$version" +cat <&2 + exit 1 +fi + +content=$(awk '/^## \[Unreleased\]/{found=1; next} /^## \[/{exit} found{print}' "$file") +if ! awk '/^### /{section=1; next} section && /^[-*+] /{found=1} END{exit !found}' <<<"$content"; then + echo "Error: $file has no unreleased user-facing entries." >&2 + echo 'Run the release prep script, then add a section under "## [Unreleased]", such as "### Added" followed by "- New feature".' >&2 + exit 1 +fi + +echo 'Changelog has unreleased entries.' diff --git a/scripts/prep-release.sh b/scripts/prep-release.sh new file mode 100755 index 0000000..b54c22f --- /dev/null +++ b/scripts/prep-release.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# release-standard: scripts/prep-release.sh r1 (do not edit; see RELEASING.md) +# Usage: scripts/prep-release.sh [last-tag] +# Prints a changelog-drafting prompt, then the commits and files changed since the last tag. +set -euo pipefail + +# Only version tags (vX.Y.Z) mark releases; other tags never become the range start. +last_tag="${1:-$(git describe --tags --abbrev=0 --match 'v[0-9]*' 2>/dev/null || true)}" +if [[ -n "$last_tag" ]] && ! git rev-parse --verify --quiet "${last_tag}^{commit}" >/dev/null; then + echo "Error: '$last_tag' is not a valid tag or commit." >&2 + exit 1 +fi + +range='HEAD' +[[ -n "$last_tag" ]] && range="${last_tag}..HEAD" + +echo '=== Release Prep ===' +if [[ -n "$last_tag" ]]; then echo "Changes since: $last_tag"; else echo 'No prior tag; showing all commits.'; fi +echo + +# Prompt first, so everything below it can be copied into an LLM in one go. +cat <<'EOF' +=== Changelog prompt (copy everything below this line) === + +Draft Keep a Changelog entries for the Unreleased section from the commits below. +Include only user-facing changes. Group items under Added, Changed, Fixed, Removed, +or Security; omit empty groups. Exclude internal tests, CI, refactors, and routine +dependency updates unless users are affected. Keep each item to one line and keep +PR or issue numbers in parentheses where the commits mention them. + +=== Full commit messages === +EOF + +# Drop co-author trailers (any case). awk, not sed's GNU-only `I` flag, so this runs on macOS too. +git log "$range" --format='## %s%n%n%b%n---' --no-merges | awk 'tolower($0) !~ /^co-authored-by:/' | cat -s + +echo +echo '=== Changed files ===' +if [[ -n "$last_tag" ]]; then + git diff --stat "$range" +else + # Compare against the empty tree so a first release shows everything. + git diff --stat "$(git hash-object -t tree /dev/null)" HEAD +fi diff --git a/scripts/release.sh b/scripts/release.sh index c2f3fb8..eff5ec6 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -1,13 +1,18 @@ #!/usr/bin/env bash -set -euo pipefail - -# Release script for monarch-cli -# Creates GitHub release with tag and changelog (does NOT publish to PyPI) +# Release script for monarch-cli. # -# Usage: ./scripts/release.sh [--dry-run] +# Creates the git tag and GitHub release (does NOT publish to PyPI). Publishing +# is the last, separate step, from the same dist/ built from the tagged commit: # -# After running this, publish to PyPI separately: # uv run twine upload dist/* +# +# Usage: ./scripts/release.sh [--dry-run] +# +# Preflight, in order: version metadata agrees, clean tree, main, HEAD equals +# origin/main, tag absent, a changelog section for the version exists, and the +# wheel/sdist build is stamped with HEAD. The tag is annotated "Release X.Y.Z" +# and the GitHub Release uses the changelog section as its notes. +set -euo pipefail DRY_RUN=false if [[ "${1:-}" == "--dry-run" ]]; then @@ -16,6 +21,12 @@ if [[ "${1:-}" == "--dry-run" ]]; then echo "" fi +warn() { printf '⚠️ %s\n' "$*"; } +fail() { + printf '❌ %s\n' "$*" >&2 + exit 1 +} + # Get version from package VERSION=$(grep -m1 '__version__' src/monarch_cli/__init__.py | cut -d'"' -f2) TAG="v${VERSION}" @@ -34,15 +45,9 @@ if [[ "$VERSION" != "$PYPROJECT_VERSION" ]]; then echo "" echo " The git tag is derived from src/monarch_cli/__init__.py, while the" echo " built wheel/sdist version comes from pyproject.toml. Make them match" - echo " (and refresh uv.lock with 'uv lock') before releasing." - echo " See docs/RELEASING.md, Step 1." - exit 1 -fi - -# Check if tag already exists -if git rev-parse "$TAG" >/dev/null 2>&1; then - echo "❌ Tag $TAG already exists!" - echo " Either bump the version or delete the existing tag." + echo " (and refresh uv.lock with 'uv lock') before releasing; 'make bump'" + echo " updates all three together." + echo " See RELEASING.md." exit 1 fi @@ -53,13 +58,33 @@ if [[ -n $(git status --porcelain) ]]; then exit 1 fi -# Check we're on main +# A real release is cut from main, at the commit that was pushed. BRANCH=$(git branch --show-current) if [[ "$BRANCH" != "main" ]]; then - echo "⚠️ Warning: You're on branch '$BRANCH', not 'main'" - read -p " Continue anyway? [y/N] " -n 1 -r - echo - [[ $REPLY =~ ^[Yy]$ ]] || exit 1 + if [[ "$DRY_RUN" == true ]]; then + warn "dry run on branch '$BRANCH', not 'main' (a real release would refuse)." + else + fail "releases are cut from main, not '$BRANCH'." + fi +fi + +if [[ "$DRY_RUN" == false ]]; then + git fetch --quiet origin main + if [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]]; then + fail "HEAD is not origin/main; push the release commit (git push) first." + fi +elif [[ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main 2>/dev/null || true)" ]]; then + warn "dry run: HEAD is not origin/main (a real release would refuse)." +fi + +# Check if tag already exists. A dry run on an already-released version still +# previews every step; a real release must bump first. +if git rev-parse "$TAG" >/dev/null 2>&1; then + if [[ "$DRY_RUN" == true ]]; then + warn "dry run: tag ${TAG} already exists; a real release must bump the version first." + else + fail "tag ${TAG} already exists; bump the version or delete the existing tag." + fi fi # Extract changelog for this version @@ -67,9 +92,7 @@ echo "📝 Extracting changelog for ${VERSION}..." CHANGELOG=$(sed -n "/^## \[${VERSION}\]/,/^## \[/p" CHANGELOG.md | head -n -1) if [[ -z "$CHANGELOG" ]]; then - echo "❌ No changelog entry found for version ${VERSION}" - echo " Add an entry to CHANGELOG.md first." - exit 1 + fail "no changelog entry found for version ${VERSION}; add one to CHANGELOG.md first." fi echo "─────────────────────────────────────────" @@ -77,12 +100,12 @@ echo "$CHANGELOG" echo "─────────────────────────────────────────" echo "" -# Build package and verify the wheel installs cleanly (canonical target) +# Build package and verify the wheel installs cleanly (canonical target). +# smoke-install stamps src/monarch_cli/_build_info.json from HEAD before +# building, wipes and rebuilds dist/, and checks the exact --version line. echo "🔨 Building package and smoke-installing wheel..." if [[ "$DRY_RUN" == false ]]; then rm -rf build/ *.egg-info - # smoke-install wipes and rebuilds dist/, so artifacts always match the - # current metadata; there is no stale-dist risk on this path. make smoke-install uv run twine check dist/* @@ -92,11 +115,19 @@ if [[ "$DRY_RUN" == false ]]; then echo "❌ Built wheel does not match version ${VERSION}." echo " dist/ contains:" ls -1 dist/ - echo " Fix the version metadata and re-run (see docs/RELEASING.md Step 1)." + echo " Fix the version metadata and re-run (see RELEASING.md)." exit 1 fi + + # Publish last, from the tagged commit: the stamp the wheel carries must be + # this exact commit. + STAMPED_COMMIT=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("commit", ""))' src/monarch_cli/_build_info.json) + if [[ "$STAMPED_COMMIT" != "$(git rev-parse HEAD)" ]]; then + fail "dist/ was built from ${STAMPED_COMMIT:-no commit}, not HEAD ($(git rev-parse HEAD)); rebuild with make prepublish." + fi else echo " [dry-run] Would run: make smoke-install && twine check dist/*" + echo " [dry-run] Would require src/monarch_cli/_build_info.json to match HEAD" fi echo "" @@ -140,7 +171,8 @@ echo "✅ GitHub release ${VERSION} complete!" echo "" echo " View release: https://github.com/crcatala/monarch-cli/releases/tag/${TAG}" echo "" -echo "⚠️ This script does NOT publish to PyPI. To publish:" +echo "⚠️ This script does NOT publish to PyPI. Publish LAST, from the same" +echo " dist/ built from the tagged commit:" echo "" echo " uv run twine upload dist/*" echo "" diff --git a/scripts/smoke_install.sh b/scripts/smoke_install.sh index 783b2be..7f814df 100755 --- a/scripts/smoke_install.sh +++ b/scripts/smoke_install.sh @@ -28,7 +28,12 @@ fail() { exit 1 } -# 1. Build the wheel + sdist from the project metadata. +# 1. Stamp the build info and build the wheel + sdist from the project +# metadata. The stamp records HEAD, its commit date, and the dirty state, so +# the installed wheel reports the same --version line as a checkout. +log "Stamping build info from HEAD" +python3 "$REPO_ROOT/scripts/build_info.py" "$REPO_ROOT/src/monarch_cli/_build_info.json" + log "Building distributions with uv build" rm -rf "$DIST_DIR" uv build --out-dir "$DIST_DIR" "$REPO_ROOT" @@ -37,6 +42,44 @@ WHEEL="$(find "$DIST_DIR" -maxdepth 1 -name '*.whl' -print -quit)" [[ -n "$WHEEL" ]] || fail "no wheel produced in ${DIST_DIR}" log "Built wheel: $(basename "$WHEEL")" +# The stamp is generated inside src/ but must still be packaged: an installed +# copy reads it instead of git, so a wheel without it cannot report a commit. +log "Verifying the wheel ships the build stamp" +python3 - "$WHEEL" <<'PY' +import json +import sys +import zipfile + +with zipfile.ZipFile(sys.argv[1]) as archive: + names = [n for n in archive.namelist() if n.endswith("monarch_cli/_build_info.json")] + if len(names) != 1: + raise SystemExit(f"wheel has {len(names)} monarch_cli/_build_info.json entries") + info = json.loads(archive.read(names[0])) +if not info.get("commit"): + raise SystemExit("wheel build info has no commit; stamp before building") +print(f" build info: {info['commit'][:7]} {info.get('date')} dirty={info.get('dirty')}") +PY + +# The sdist is uploaded too, and a source install builds its wheel from it, so +# the stamp must be in the sdist as well. +SDIST="$(find "$DIST_DIR" -maxdepth 1 -name '*.tar.gz' -print -quit)" +[[ -n "$SDIST" ]] || fail "no sdist produced in ${DIST_DIR}" +log "Verifying the sdist ships the build stamp" +python3 - "$SDIST" <<'PY' +import json +import sys +import tarfile + +with tarfile.open(sys.argv[1]) as archive: + names = [n for n in archive.getnames() if n.endswith("monarch_cli/_build_info.json")] + if len(names) != 1: + raise SystemExit(f"sdist has {len(names)} monarch_cli/_build_info.json entries") + info = json.load(archive.extractfile(names[0])) +if not info.get("commit"): + raise SystemExit("sdist build info has no commit; stamp before building") +print(f" sdist build info: {info['commit'][:7]}") +PY + # 2. Create an isolated environment outside the source checkout. Nothing here # may see the repository's src/ tree or its development virtualenv. WORKDIR="$(mktemp -d "${TMPDIR:-/tmp}/monarch-cli-smoke.XXXXXX")" @@ -94,7 +137,8 @@ for artifact in SCHEMA_ARTIFACTS.values(): print(f"loaded {len(SCHEMA_ARTIFACTS)} packaged schema artifacts") PY -# 6. Both console entry points must run from the installed wheel. +# 6. Both console entry points must run from the installed wheel, and both +# must print the release-standard version line for the stamped commit. log "Invoking console entry points" for entrypoint in monarch monarch-cli; do bin_path="$ENTRYPOINT_DIR/$entrypoint" @@ -105,9 +149,39 @@ for entrypoint in monarch monarch-cli; do env -u VIRTUAL_ENV -u PYTHONPATH "$bin_path" --version > "$WORKDIR/${entrypoint}-version.txt" \ || fail "${entrypoint} --version failed" - version_output="$(cat "$WORKDIR/${entrypoint}-version.txt")" - [[ -n "$version_output" ]] || fail "${entrypoint} --version produced no output" - printf ' %-12s --help OK, --version: %s\n' "$entrypoint" "$version_output" + python3 - "$REPO_ROOT" "$WORKDIR/${entrypoint}-version.txt" "$entrypoint" <<'PY' +import json +import subprocess +import sys +import tomllib +from pathlib import Path + +repo, version_file, entrypoint = sys.argv[1:4] +version = tomllib.loads((Path(repo) / "pyproject.toml").read_text())["project"]["version"] +stamp = json.loads((Path(repo) / "src/monarch_cli/_build_info.json").read_text()) +line = Path(version_file).read_text().strip() + +if stamp.get("commit"): + sha = stamp["commit"][:7] + ("-dirty" if stamp.get("dirty") else "") + expected = f"monarch {version} ({sha} {stamp['date']})" +else: + expected = f"monarch {version}" +if line != expected: + raise SystemExit(f"{entrypoint} --version: expected {expected!r}, got {line!r}") + +# The stamp's dirty flag must match the tree; a release build is clean, so a +# -dirty line there is a failure rather than a warning. +dirty = bool( + subprocess.run( + ["git", "-C", repo, "status", "--porcelain"], capture_output=True, text=True, check=True + ).stdout.strip() +) +if dirty != bool(stamp.get("dirty")): + raise SystemExit( + f"build stamp dirty={stamp.get('dirty')} but the tree dirty state is {dirty}; rerun" + ) +print(f" {entrypoint:<12} --help OK, --version: {line}") +PY done log "smoke-install OK" diff --git a/tests/test_version_parity.py b/tests/test_version_parity.py index a7c25c7..6c53b7d 100644 --- a/tests/test_version_parity.py +++ b/tests/test_version_parity.py @@ -5,7 +5,7 @@ version from ``pyproject.toml`` (and ``uv.lock`` mirrors it). If these disagree, a release would tag one version while publishing another. -Keep them in sync when bumping (see ``docs/RELEASING.md``, Step 1). +Keep them in sync when bumping (see ``RELEASING.md`` and ``make bump``). """ from __future__ import annotations @@ -40,7 +40,7 @@ def test_pyproject_version_matches_package_version() -> None: "pyproject.toml [project].version " f"({pyproject_version!r}) must match monarch_cli.__version__ " f"({__version__!r}); the git tag comes from __init__.py but the built " - "wheel/sdist version comes from pyproject.toml. See docs/RELEASING.md " + "wheel/sdist version comes from pyproject.toml. See RELEASING.md " "Step 1." ) @@ -50,5 +50,5 @@ def test_uv_lock_version_matches_package_version() -> None: assert lock_version == __version__, ( "uv.lock monarch-cli version " f"({lock_version!r}) must match monarch_cli.__version__ ({__version__!r}); " - "run 'uv lock' after bumping pyproject.toml (see docs/RELEASING.md Step 1)." + "run 'uv lock' after bumping pyproject.toml (RELEASING.md)." )