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 new file mode 100644 index 0000000..530a32e --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,178 @@ +# Releasing + +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. + +TestPyPI is a rehearsal for the PyPI upload. Publishing versions are immutable +on both indexes. + +`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 + +- Push access to `main`; `gh auth status` succeeds. +- PyPI and TestPyPI API tokens. `twine` reads `~/.pypirc`: + + ```ini + [testpypi] + username = __token__ + password = pypi-YOUR_TEST_PYPI_TOKEN + + [pypi] + username = __token__ + password = pypi-YOUR_PYPI_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. + +## Before releasing + +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`. + +## Release + +```bash +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 +``` + +### `make bump VERSION=X.Y.Z` + +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`) | +| `pyproject.toml` | `[project].version` | Version embedded in the built wheel/sdist | +| `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 | + +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`. + +### `make prepublish` + +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` — 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. + +> **`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 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. + +### `make release-dry` and `make release` + +`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; +- a `## [X.Y.Z]` changelog section exists; +- `smoke-install` rebuilt `dist/` and the stamp in + `src/monarch_cli/_build_info.json` is `HEAD`; +- `dist/monarch_cli-X.Y.Z-*.whl` exists. + +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. + +### Publish last + +Upload from the same `dist/` built from the tagged commit: + +```bash +uv run twine upload dist/* +``` + +Optionally rehearse on TestPyPI first: + +```bash +uv run twine upload --repository testpypi dist/* +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 # 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 +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 +``` diff --git a/docs/RELEASING.md b/docs/RELEASING.md index 2ddfa83..8893f33 100644 --- a/docs/RELEASING.md +++ b/docs/RELEASING.md @@ -1,308 +1,5 @@ # 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. +The release guide lives at the repository root: [RELEASING.md](../RELEASING.md). -The release has two independent destinations: - -- **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. - -## Prerequisites - -### 1. Create PyPI accounts - -You need accounts on both TestPyPI (for testing) and PyPI (for production): - -- **TestPyPI:** https://test.pypi.org/account/register/ -- **PyPI:** https://pypi.org/account/register/ - -### 2. Create API tokens - -- **TestPyPI:** https://test.pypi.org/manage/account/token/ -- **PyPI:** https://pypi.org/manage/account/token/ - -Select "Entire account" scope for first-time uploads. After publishing, you can -create project-scoped tokens for tighter security. - -### 3. Configure `~/.pypirc` - -```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: - -```bash -chmod 600 ~/.pypirc -uv run twine upload --repository testpypi --help >/dev/null && echo "twine available" -``` - -> Tokens are secrets. Never paste `~/.pypirc` contents into a shell, issue -> tracker, or chat. Rotate a token immediately if it is exposed. - -## 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. - -| File | Field | Why it matters | -|------|-------|----------------| -| `src/monarch_cli/__init__.py` | `__version__` | Source of the git tag (`v`) and `monarch --version` | -| `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: - - ```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 - ``` - -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`. - -Commit everything before continuing — the release script refuses to run with a -dirty working tree. - -### Step 2: Verify, build, and validate - -```bash -make prepublish -``` - -This is 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. -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 -> *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 - -Upload the freshly built artifacts: - -```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` verifies, in order: - -- `__init__.py` and `pyproject.toml` versions match; -- 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 - -| 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/*` | - -## Failure recovery - -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: - -```bash -uv run twine upload --verbose --repository testpypi dist/* -``` - -The overwhelmingly common reason is: - -``` -400 File already exists ('monarch_cli-X.Y.Z-...whl', ...) -``` - -**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: - -```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 -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: - -```bash -curl -s https://pypi.org/pypi/monarch-cli/json | python3 -c "import sys,json;print(list(json.load(sys.stdin)['releases']))" -``` - -If the version is present, you cannot re-upload it — bump the version. +This page is kept as a redirect so older links keep working. 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/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/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}), 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)." )