Skip to content

fix(upgrade): pin version + verify post-install to prevent silent no-ops#17

Merged
xergioalex merged 1 commit into
mainfrom
fix/upgrade-pin-version-and-verify
May 4, 2026
Merged

fix(upgrade): pin version + verify post-install to prevent silent no-ops#17
xergioalex merged 1 commit into
mainfrom
fix/upgrade-pin-version-and-verify

Conversation

@xergioalex
Copy link
Copy Markdown
Member

What broke

Right after v1.7.0 shipped, a user ran dailybot upgrade and got this:

```
$ dailybot upgrade
Checking PyPI for the latest version...
Current version: 1.6.1
Latest version: 1.7.0 (update available)
Install method: pip (/usr/local/lib/python3.14/site-packages/dailybot_cli)
Running: /usr/local/bin/python3 -m pip install --upgrade dailybot-cli

Defaulting to user installation because normal site-packages is not writeable
Requirement already satisfied: dailybot-cli in /usr/local/lib/python3.14/site-packages (1.6.1)
... [no Collecting / Downloading / Installing] ...

OK Upgrade complete. Run 'dailybot --version' to confirm.
$ dailybot --version
dailybot 1.6.1 (Python 3.14.4) ← unchanged
```

The user ended up running curl -sSL https://cli.dailybot.com/install.sh | bash to actually get 1.7.0.

Why it happened

Two cooperating issues in pip's resolver:

  1. Stale pip index cache. PyPI's JSON API (which our _fetch_latest_pypi_version queries) sees a new release within seconds. pip's package index cache can lag — so even with --upgrade, pip can resolve dailybot-cli to 1.6.1 for a few minutes after a release.
  2. Read-only system site-packages. When the system Python's site-packages isn't writable, pip silently falls back to --user. With the old version already in system site-packages and the resolver matching 1.6.1 as "latest available", pip decides the requirement is already satisfied and exits 0 without doing anything.

subprocess.run(..., check=True) was happy because pip returned 0. We falsely reported success.

Fix (defense-in-depth, two layers)

1. Pin ==<latest> in pip's argv

When _fetch_latest_pypi_version() returns a version, the command becomes:

```bash
python -m pip install --upgrade dailybot-cli==1.7.0
```

pip can no longer say "already satisfied" because the requested version (1.7.0) differs from what's installed (1.6.1). The pin also forces pip to fetch fresh metadata, sidestepping the stale-cache issue. Falls back to bare dailybot-cli when the JSON API is unreachable so we don't make things worse.

2. Verify the on-disk version after pip exits

New _query_installed_version() helper spawns a fresh python -c "from importlib.metadata import version; print(version('dailybot-cli'))" subprocess to read what's actually on disk now. (Can't use the running interpreter — it has the old version cached.)

If the installed version still equals what we started with, the success message becomes a warning pointing at the install.sh fallback. The user gets honest feedback instead of "Upgrade complete" plus a still-old binary.

This second layer catches the specific pip scenario AND any future edge cases (broken PATH, package-manager bugs, etc.).

Change log

  • commands/upgrade.py::_build_upgrade_argv — accepts optional latest; pins the version for pip when known.
  • commands/upgrade.py::_query_installed_version — new helper.
  • commands/upgrade.py::upgrade — calls the verification after pip exits; downgrades the success message to a clear warning when the version didn't actually change.
  • tests/commands_test.py — updated 4 existing upgrade tests to mock _query_installed_version (so they don't accidentally exercise the verification subprocess); added 4 new tests:
    • pip pins ==<latest> when JSON API returns a version
    • pip falls back to bare dailybot-cli when JSON API is unreachable
    • pip silent no-op → warning + install.sh hint, no false success
    • version actually changed → reports success normally

Compat / risk

  • pipx and uv-tool argv unchanged. They handle their own version resolution; pinning would be redundant or conflict.
  • --force on same version still works. Verified by test_upgrade_force_runs_even_when_latest.
  • Verification adds ~150–300 ms to a successful upgrade. Worth it: silent failures are far worse.
  • If a user is on a system where the verification subprocess fails (very locked-down sandbox), we just don't print the extra warning — never makes the upgrade worse.

Test plan

  • ruff check / ruff format --check / mypy / pytest -x all green
  • 223 tests pass locally (192 existing + 31 in upgrade tests after this PR)
  • Mentally walked through the user's exact scenario against the new code: _fetch_latest_pypi_version returns 1.7.0, argv becomes pip install --upgrade dailybot-cli==1.7.0, pip can no longer no-op, verification confirms 1.7.0 is installed, success.
  • CI matrix (Py 3.10 + 3.12) green on this PR
  • Reviewer can repro by deleting their local ~/.cache/pip/http* between releases (forces stale-index conditions)

🤖 Generated with Claude Code

## Summary
A user reported that `dailybot upgrade` claimed success but the on-disk
version stayed at v1.6.1. Reproducible scenario:

  $ dailybot upgrade
  Latest version: 1.7.0  (update available)
  Install method: pip
  Running: /usr/local/bin/python3 -m pip install --upgrade dailybot-cli
  Defaulting to user installation because normal site-packages is not writeable
  Requirement already satisfied: dailybot-cli (1.6.1)
  ...
  OK Upgrade complete.
  $ dailybot --version
  dailybot 1.6.1   ← unchanged

Two cooperating bugs in pip's resolver caused the no-op:

  1. After a fresh release, `_fetch_latest_pypi_version` (PyPI's JSON
     API) saw 1.7.0 immediately, but pip's package index cache still
     resolved `dailybot-cli` (no version pin) to 1.6.1 as "latest
     available".
  2. With user-fallback active because system site-packages is
     read-only, pip's resolver decided the existing 1.6.1 install
     "satisfied" the unpinned requirement and exited 0 without doing
     anything.

The user had to run `install.sh` manually to actually upgrade.

## Fix (two layers, defense-in-depth)

  1. **Pin the version in pip's argv.** When `_fetch_latest_pypi_version`
     returns a concrete version, build `pip install --upgrade
     dailybot-cli==<version>` instead of bare `dailybot-cli`. pip can no
     longer claim "already satisfied" because the requested version
     differs from what's installed. Falls back to bare `dailybot-cli`
     when the JSON API is unreachable (preserves the old behavior in
     that fallback path).
  2. **Verify the on-disk version after pip exits.** New
     `_query_installed_version()` spawns a fresh subprocess to read
     `importlib.metadata.version("dailybot-cli")` — the running
     interpreter has the old version cached, so we need a fresh
     process. If the installed version still equals what we started
     with, surface a warning that points the user at the install.sh
     fallback instead of falsely reporting success.

Layer (2) catches future edge cases beyond the specific pip scenario
(read-only site-packages with broken PATH, package-manager bugs, etc.).

## Change Log
- `commands/upgrade.py::_build_upgrade_argv` — accepts an optional
  `latest` argument; pins the version for `pip` when known.
- `commands/upgrade.py::_query_installed_version` — new helper, reads
  the installed version from a fresh Python subprocess.
- `commands/upgrade.py::upgrade` — calls verification after the
  upgrade subprocess returns, downgrades the success message to a
  warning when the version didn't actually change.
- `tests/commands_test.py` — updated 4 existing tests to mock
  `_query_installed_version` so they don't accidentally exercise the
  verification subprocess. Added 4 new tests:
    * pip pins `==<latest>` when the JSON API returns a version
    * pip falls back to bare `dailybot-cli` when JSON API returns None
    * pip silent no-op (post-version unchanged) prints a warning
      pointing at install.sh and does NOT claim success
    * post-version actually changed → reports success normally

## Risks
- pipx and uv-tool argv unchanged (they have their own version
  resolution; pinning would be redundant or conflict). Verified by
  the existing `test_upgrade_pipx_runs_pipx` and `test_upgrade_uv_tool`.
- The verification subprocess adds ~150–300 ms to a successful
  upgrade. Acceptable given the alternative is users following a
  bogus "Upgrade complete" message.
- If a user genuinely wants to "reinstall the same version", `--force`
  on the same version still works — `_query_installed_version` will
  match and we'll report success normally (covered by
  `test_upgrade_force_runs_even_when_latest`).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@xergioalex xergioalex merged commit 5694e09 into main May 4, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant