fix(cli/update): let install ownership win over bare /.dockerenv so venv installs self-update (#2830)

## Description

`headroom update` refuses to self-update for any install that happens to
run inside a container, including a plain `pip install` into a venv,
because `detect_install_method` checks `_in_docker()` before the pipx /
uv-tool / venv / user-site branches. The guidance it prints does not
apply: there is no Headroom image in the picture, the container is the
environment and Headroom was pip-installed into a venv inside it.

```console
$ headroom update --check
Update available: 0.32.0 -> 0.34.0
Running inside a container - pull a newer Headroom image instead of self-updating.
```

`_in_docker()` is purely environmental (`/.dockerenv` exists, or
`HEADROOM_IN_DOCKER` is set), with no reference to how the package was
installed, so `/.dockerenv` alone shadows a venv that clearly owns the
install. This hits devcontainers, GitHub Codespaces, docker/LXC
self-hosting, and dev images.

The fix splits the check by intent. An EXPLICIT `HEADROOM_IN_DOCKER`
(which the official image can set) is a deliberate opt-out and still
refuses up front, even over a venv, so the real-image behavior is
preserved. The bare `/.dockerenv` heuristic now runs after ownership
detection, so a venv / pipx / uv / user-site install self-updates and
only a container whose own system interpreter owns the install still
gets the pull-a-new-image guidance.

Fixes #2816

## Type of Change

- [x] Bug fix (non-breaking change that fixes an issue)
- [ ] New feature (non-breaking change that adds functionality)
- [ ] Breaking change (fix or feature that would cause existing
functionality to change)
- [ ] Documentation update
- [ ] Performance improvement
- [ ] Code refactoring (no functional changes)

## Changes Made

- `headroom/cli/update.py` (`detect_install_method`): replaced the
up-front `_in_docker()` refusal with an explicit
`os.environ.get("HEADROOM_IN_DOCKER")` refusal (the official image
opt-out), and added the bare `_in_docker()` refusal after the pipx /
uv-tool / venv / user-site branches so ownership wins over environment.
Updated the resolution-order docstring.
- `tests/test_update_helpers.py`: added
`test_venv_inside_bare_dockerenv_still_self_updates` (the fix),
`test_explicit_headroom_in_docker_still_refuses_over_venv` (image
opt-out preserved), and `test_bare_dockerenv_without_owner_refuses`
(system-interpreter container still refuses).
- `tests/test_cli_update.py` (`test_detect_docker`): updated to drive
the bare-`/.dockerenv`-no-owner path deterministically (mock ownership
to absent), since a real venv underneath now correctly wins.

## Testing

- [x] Unit tests pass (`pytest`)
- [x] Linting passes (`ruff check .`)
- [x] Type checking passes (`mypy headroom`)
- [x] New tests added for new functionality
- [ ] Manual testing performed

### Test Output

```text
# Fail-before (source fix stashed, new test kept):
tests/test_update_helpers.py::test_venv_inside_bare_dockerenv_still_self_updates FAILED
  assert method.kind == "pip"
  AssertionError: assert 'docker' == 'pip'

# Pass-after (fix applied), all update suites:
tests/test_update_helpers.py tests/test_cli_update.py tests/test_update_check.py
95 passed

# uvx ruff@0.15.17 check  -> All checks passed!
# uvx mypy@1.20.2 headroom/cli/update.py -> Success: no issues found in 1 source file
```

## Real Behavior Proof

- Environment: Windows 11, Python 3.12.11, project venv, pytest 9.1.1,
ruff 0.15.17 and mypy 1.20.2 via uvx.
- Exact command / steps: read `detect_install_method` to confirm
`_in_docker()` (line 354) preceded the pipx (377) / uv-tool (385) / venv
(392) branches, reproduced the issue's environment in a test (bare
`/.dockerenv` via `_in_docker` monkeypatched True, `HEADROOM_IN_DOCKER`
unset, a venv layout under `sys.prefix`), fail-before with `git stash
push headroom/cli/update.py` and `python -m pytest
tests/test_update_helpers.py -k venv_inside_bare_dockerenv` (the venv is
refused with `kind == "docker"`), then pass-after with `git stash pop`
and rerunning the full update suites (95 passed).
- Observed result: a venv/pip install inside a bare `/.dockerenv`
container now resolves to `kind="pip"`, `can_self_update=True`,
`argv=[sys.executable, "-m", "pip", "install", "-U", ...]`, matching the
manual command the issue reporter confirmed works. An explicit
`HEADROOM_IN_DOCKER=1` still resolves to `kind="docker"` even over a
venv, and a container whose system interpreter owns the install still
resolves to `kind="docker"`.
- Not tested: an end-to-end `headroom update` run inside a real
devcontainer against live PyPI (no container in this environment). The
resolution is a pure classification function verified directly, and the
actual upgrade command it builds is the existing, already-tested venv
path.

## Review Readiness

- [x] I have performed a self-review
- [x] This PR is ready for human review

## Checklist

- [x] My code follows the project's style guidelines
- [x] I have performed a self-review of my code
- [x] I have commented my code, particularly in hard-to-understand areas
- [ ] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] I did **not** edit `CHANGELOG.md`: it is generated by
release-please from my Conventional Commit PR title (a CI guard enforces
this)

## Additional Notes

The official image opt-out is preserved by design: the issue notes
`_in_docker()` already honors `HEADROOM_IN_DOCKER`, so the image can
keep refusing self-update by setting it, which this PR routes to the
explicit up-front check that wins even over a venv. Only the bare
`/.dockerenv` auto-detection was demoted below ownership.
This commit is contained in:
Abhay Singh 2026-08-12 03:53:29 +05:30 committed by GitHub
parent de9e0523da
commit 7092b53c46
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 90 additions and 4 deletions

View file

@ -328,13 +328,14 @@ def detect_install_method(extras: str | None = None) -> InstallMethod:
1. git checkout refuse (`git pull`) 1. git checkout refuse (`git pull`)
2. editable install refuse (reinstall from source) 2. editable install refuse (reinstall from source)
3. Docker refuse (pull a new image) 3. explicit HEADROOM_IN_DOCKER refuse (official image opt-out)
4. pipx `pipx upgrade` 4. pipx `pipx upgrade`
5. uv tool `uv tool upgrade` 5. uv tool `uv tool upgrade`
6. venv / virtualenv / conda `sys.executable -m pip install -U` 6. venv / virtualenv / conda `sys.executable -m pip install -U`
7. user-site (`pip --user`) `sys.executable -m pip install -U --user` 7. user-site (`pip --user`) `sys.executable -m pip install -U --user`
8. externally-managed system Python (PEP 668) refuse with guidance 8. bare /.dockerenv (system interpreter) refuse (pull a new image)
9. writable global Python `sys.executable -m pip install -U` (last resort) 9. externally-managed system Python (PEP 668) refuse with guidance
10. writable global Python `sys.executable -m pip install -U` (last resort)
""" """
if _is_source_checkout(): if _is_source_checkout():
return InstallMethod( return InstallMethod(
@ -351,7 +352,13 @@ def detect_install_method(extras: str | None = None) -> InstallMethod:
"reinstall with `pip install -U --force-reinstall .`." "reinstall with `pip install -U --force-reinstall .`."
), ),
) )
if _in_docker(): # An EXPLICIT HEADROOM_IN_DOCKER (set by the official image) is a deliberate
# "pull a newer image" opt-out and wins up front, even over a venv. The bare
# /.dockerenv heuristic is handled far lower, after ownership detection, so a
# pip / pipx / uv install inside a devcontainer, Codespace, or docker dev
# image is not shadowed by the mere fact that the environment is a container
# (#2816).
if os.environ.get("HEADROOM_IN_DOCKER", "").strip():
return InstallMethod( return InstallMethod(
kind="docker", kind="docker",
can_self_update=False, can_self_update=False,
@ -404,6 +411,18 @@ def detect_install_method(extras: str | None = None) -> InstallMethod:
argv=[sys.executable, "-m", "pip", "install", "-U", "--user", _spec(extras)], argv=[sys.executable, "-m", "pip", "install", "-U", "--user", _spec(extras)],
) )
# Bare /.dockerenv with no venv / pipx / uv / user-site owner: the install
# belongs to the container's own interpreter, where "pull a newer image" is
# the only real route. An explicit HEADROOM_IN_DOCKER already returned above.
if _in_docker():
return InstallMethod(
kind="docker",
can_self_update=False,
guidance=(
"Running inside a container — pull a newer Headroom image instead of self-updating."
),
)
if _is_externally_managed(): if _is_externally_managed():
return InstallMethod( return InstallMethod(
kind="system", kind="system",

View file

@ -40,7 +40,16 @@ def test_detect_editable(monkeypatch):
def test_detect_docker(monkeypatch): def test_detect_docker(monkeypatch):
# A bare /.dockerenv container whose system interpreter owns the install
# (no venv / pipx / uv / user-site) still refuses. When an install method
# owns it, ownership wins over the container environment (#2816) -- see
# test_update_helpers.test_venv_inside_bare_dockerenv_still_self_updates.
monkeypatch.delenv("HEADROOM_IN_DOCKER", raising=False)
monkeypatch.setattr(up, "_in_docker", lambda: True) monkeypatch.setattr(up, "_in_docker", lambda: True)
monkeypatch.setattr(up, "_in_virtualenv", lambda: False)
monkeypatch.setattr(up, "_is_user_site_install", lambda loc: False)
monkeypatch.setattr(up.sys, "prefix", "/usr")
monkeypatch.setattr(up.sys, "executable", "/usr/bin/python3")
m = up.detect_install_method() m = up.detect_install_method()
assert m.kind == "docker" and m.can_self_update is False assert m.kind == "docker" and m.can_self_update is False

View file

@ -267,6 +267,64 @@ def test_update_externally_managed_refuses_via_command(monkeypatch):
assert "PEP 668" in res.output assert "PEP 668" in res.output
def test_venv_inside_bare_dockerenv_still_self_updates(monkeypatch):
"""A venv/pip install inside a container (bare /.dockerenv, no explicit
HEADROOM_IN_DOCKER) must self-update, not be refused with image guidance (#2816).
"""
monkeypatch.setattr(up, "_is_source_checkout", lambda: False)
monkeypatch.setattr(up, "_is_editable_install", lambda: False)
monkeypatch.delenv("HEADROOM_IN_DOCKER", raising=False)
# Deterministic, pipx/uv-free venv layout (mirrors the issue's environment).
monkeypatch.setenv("PIPX_HOME", "")
monkeypatch.setenv("UV_TOOL_DIR", "")
monkeypatch.setattr(up.sys, "executable", "/config/.headroom-venv/bin/python")
monkeypatch.setattr(up.sys, "prefix", "/config/.headroom-venv")
monkeypatch.setattr(
up, "_package_location", lambda: "/config/.headroom-venv/lib/python3.12/headroom"
)
# The container is real (/.dockerenv), but a venv owns the install.
monkeypatch.setattr(up, "_in_docker", lambda: True)
monkeypatch.setattr(up, "_in_virtualenv", lambda: True)
method = up.detect_install_method()
assert method.kind == "pip"
assert method.can_self_update is True
assert method.argv[:4] == [up.sys.executable, "-m", "pip", "install"]
def test_explicit_headroom_in_docker_still_refuses_over_venv(monkeypatch):
"""The official image's explicit HEADROOM_IN_DOCKER opt-out wins up front,
even when a venv owns the install."""
monkeypatch.setattr(up, "_is_source_checkout", lambda: False)
monkeypatch.setattr(up, "_is_editable_install", lambda: False)
monkeypatch.setenv("HEADROOM_IN_DOCKER", "1")
monkeypatch.setattr(up, "_in_virtualenv", lambda: True)
method = up.detect_install_method()
assert method.kind == "docker"
assert method.can_self_update is False
def test_bare_dockerenv_without_owner_refuses(monkeypatch):
"""A container whose system interpreter owns the install (bare /.dockerenv, no
venv/pipx/uv/user-site) still refuses with the pull-a-new-image guidance."""
monkeypatch.setattr(up, "_is_source_checkout", lambda: False)
monkeypatch.setattr(up, "_is_editable_install", lambda: False)
monkeypatch.delenv("HEADROOM_IN_DOCKER", raising=False)
monkeypatch.setenv("PIPX_HOME", "")
monkeypatch.setenv("UV_TOOL_DIR", "")
monkeypatch.setattr(up.sys, "executable", "/usr/bin/python3")
monkeypatch.setattr(up.sys, "prefix", "/usr")
monkeypatch.setattr(up, "_package_location", lambda: "/usr/lib/python3.12/headroom")
monkeypatch.setattr(up, "_in_docker", lambda: True)
monkeypatch.setattr(up, "_in_virtualenv", lambda: False)
monkeypatch.setattr(up, "_is_user_site_install", lambda loc: False)
method = up.detect_install_method()
assert method.kind == "docker"
assert method.can_self_update is False
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #
# update_check helpers / branches # update_check helpers / branches
# --------------------------------------------------------------------------- # # --------------------------------------------------------------------------- #