From 88c1b1c7101561336604a2e017602cc135929ce8 Mon Sep 17 00:00:00 2001 From: Chuck Meyer Date: Wed, 26 Aug 2026 21:23:23 -0400 Subject: [PATCH] feat: single source of truth for the version, enforced by CI The package version drifted from the release tags: pyproject.toml said 0.1.0 across v1.0.0, v1.1.0 and v1.2.0, and the client sent "algolia-agent-cli/0.1.0" in every request. There was also no way to ask the CLI what version it was. __version__ in src/algolia_agent/__init__.py is now the only place a version is written. pyproject reads it via [tool.setuptools.dynamic], `--version` reports it, and the client builds its User-Agent from it, so the four surfaces cannot disagree. A CI job on v* tag pushes compares the tag to __version__ and fails the build if they differ, which is exactly the drift that went unnoticed for three releases. The push trigger now includes tags so that job can run. Tests cover the semver shape, the User-Agent constant, the header actually sent on a request, and agreement with installed package metadata. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/ci.yml | 22 ++++++++++++++++++++++ README.md | 15 +++++++++++++++ pyproject.toml | 5 ++++- src/algolia_agent/__init__.py | 7 +++++++ src/algolia_agent/cli.py | 3 +++ src/algolia_agent/client.py | 9 +++++++-- tests/test_client.py | 35 +++++++++++++++++++++++++++++++++++ 7 files changed, 93 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1956421..ec250b1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,6 +3,7 @@ name: CI on: push: branches: [main] + tags: ["v*"] pull_request: jobs: @@ -18,3 +19,24 @@ jobs: python-version: ${{ matrix.python-version }} - run: pip install -e ".[dev]" - run: pytest tests/ -v + + # The package version and the git tag drifted for three releases: tags said 1.x + # while pyproject said 0.1.0. This fails the tag build rather than shipping a + # release that misreports itself. + version-matches-tag: + if: startsWith(github.ref, 'refs/tags/v') + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + - name: Compare __version__ with the tag + run: | + tag="${GITHUB_REF_NAME#v}" + pkg=$(python -c "import sys; sys.path.insert(0, 'src'); import algolia_agent; print(algolia_agent.__version__)") + echo "tag=$tag __version__=$pkg" + if [ "$tag" != "$pkg" ]; then + echo "::error::tag ${GITHUB_REF_NAME} does not match __version__ ${pkg}" + exit 1 + fi diff --git a/README.md b/README.md index 8a13c24..fbce3b6 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,21 @@ algolia-agent delete --confirm ``` Add `--json` to any command except `init` for machine-readable output. +`algolia-agent --version` reports the installed version. + +## Releasing + +`src/algolia_agent/__init__.py` holds `__version__` and everything derives from it: +`pyproject.toml` reads it via `[tool.setuptools.dynamic]`, `--version` reports it, and +the client sends it in the `User-Agent`. To cut a release, bump that one value, merge, +then tag from `main`: + +```bash +git checkout main && git pull +git tag vX.Y.Z && git push origin vX.Y.Z +``` + +CI fails the tag build if the tag and `__version__` disagree. ## Config formats diff --git a/pyproject.toml b/pyproject.toml index e19faf9..8f91bf9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "algolia-agent" -version = "0.1.0" +dynamic = ["version"] description = "CLI for Algolia Agent Studio" readme = "README.md" requires-python = ">=3.10" @@ -23,6 +23,9 @@ dev = ["pytest>=7"] [project.scripts] algolia-agent = "algolia_agent.cli:main" +[tool.setuptools.dynamic] +version = { attr = "algolia_agent.__version__" } + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/algolia_agent/__init__.py b/src/algolia_agent/__init__.py index e69de29..e7aa3e6 100644 --- a/src/algolia_agent/__init__.py +++ b/src/algolia_agent/__init__.py @@ -0,0 +1,7 @@ +"""CLI for the Algolia Agent Studio REST API.""" + +# Single source of truth for the version. pyproject.toml reads it via +# [tool.setuptools.dynamic], the CLI reports it through --version, and the client +# sends it in the User-Agent — so a release cannot disagree with itself. CI checks +# this value against the git tag whenever a v* tag is pushed. +__version__ = "1.3.0" diff --git a/src/algolia_agent/cli.py b/src/algolia_agent/cli.py index 0ad528e..377abc3 100644 --- a/src/algolia_agent/cli.py +++ b/src/algolia_agent/cli.py @@ -21,6 +21,7 @@ from InquirerPy import inquirer +from . import __version__ from .client import AgentAPIError, AlgoliaAgentClient from .template import extract_variables, render @@ -1180,6 +1181,8 @@ def build_parser() -> argparse.ArgumentParser: prog="algolia-agent", description="Algolia Agent Studio CLI", ) + parser.add_argument("--version", action="version", + version=f"algolia-agent {__version__}") parser.add_argument("--app-id", help="Algolia Application ID (overrides env/dotenv)") parser.add_argument("--api-key", help="Algolia API Key (overrides env/dotenv)") diff --git a/src/algolia_agent/client.py b/src/algolia_agent/client.py index 3f7b5ea..ac75d43 100644 --- a/src/algolia_agent/client.py +++ b/src/algolia_agent/client.py @@ -19,6 +19,11 @@ _RETRY_BACKOFF = 1.0 # seconds; doubles each retry +from . import __version__ + +_USER_AGENT = f"algolia-agent-cli/{__version__}" + + def _load_dotenv(path: Path) -> dict[str, str]: """Parse a .env file and return key/value pairs. Ignores comments and blank lines.""" result = {} @@ -66,7 +71,7 @@ def _request(self, path: str, method: str = "GET", body: dict | None = None) -> req.add_header("x-algolia-api-key", self.api_key) req.add_header("Content-Type", "application/json") req.add_header("Accept", "application/json") - req.add_header("User-Agent", "algolia-agent-cli/0.1.0") + req.add_header("User-Agent", _USER_AGENT) delay = _RETRY_BACKOFF for attempt in range(_MAX_RETRIES): try: @@ -151,7 +156,7 @@ def list_indices(self) -> list[str]: req.add_header("x-algolia-application-id", self.app_id) req.add_header("x-algolia-api-key", self.api_key) req.add_header("Accept", "application/json") - req.add_header("User-Agent", "algolia-agent-cli/0.1.0") + req.add_header("User-Agent", _USER_AGENT) try: with urllib.request.urlopen(req, timeout=_TIMEOUT) as resp: result = json.loads(resp.read()) diff --git a/tests/test_client.py b/tests/test_client.py index 920615e..3853066 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -384,3 +384,38 @@ def test_timeout_error_retries_then_succeeds(client): with patch("time.sleep"): result = client.get_agent("abc") assert result == agent + + +# ── version reporting ───────────────────────────────────────────────────────── + +def test_user_agent_reports_the_package_version(): + """The User-Agent was frozen at 0.1.0 through three tagged releases.""" + import re + + import algolia_agent + from algolia_agent.client import _USER_AGENT + + assert re.fullmatch(r"\d+\.\d+\.\d+", algolia_agent.__version__), algolia_agent.__version__ + assert _USER_AGENT == f"algolia-agent-cli/{algolia_agent.__version__}" + + +def test_user_agent_header_is_actually_sent(client): + import algolia_agent + + with patch("urllib.request.urlopen", return_value=_mock_response({"data": []})) as m: + client.list_agents() + req = m.call_args.args[0] + assert req.get_header("User-agent") == f"algolia-agent-cli/{algolia_agent.__version__}" + + +def test_installed_metadata_matches_dunder_version(): + """pyproject reads the version from the package, so these cannot diverge.""" + from importlib.metadata import PackageNotFoundError, version + + import algolia_agent + + try: + installed = version("algolia-agent") + except PackageNotFoundError: + pytest.skip("package not installed") + assert installed == algolia_agent.__version__