Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions goal/cli/publish.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,13 +126,14 @@ def _get_python_bin() -> str:
if active_venv:
active_python = Path(active_venv) / "bin" / "python"
if active_python.exists():
return str(active_python.resolve())
# Resolving the symlink selects base Python and loses the virtualenv.
return str(active_python.absolute())

# Check for venv in current directory (priority order: .venv, venv, env)
for venv_name in [".venv", "venv", "env"]:
venv_python = Path(".") / venv_name / "bin" / "python"
if venv_python.exists():
return str(venv_python.resolve())
return str(venv_python.absolute())

return sys.executable

Expand Down
20 changes: 20 additions & 0 deletions project/ticket-087/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Ticket 087: Preserve virtualenv isolation during package publication

- **ID**: ticket-087
- **Owner**: codex
- **Status**: IN_PROGRESS
- **Workflow state**: PUBLICATION
- **Created**: 2026-09-06

## Authorization and scope

SESSION_EXECUTION_AUTHORIZATION: the user requested continuing repairs and publishing all changes to GitHub through Goal. Repair the interpreter selection exposed by the redeploy 0.2.80 publication attempt. A symlink resolution selected uv-managed base Python and caused an externally-managed-environment failure.

## Acceptance criteria

- [x] AC-01: Active and local virtualenv selection preserve real subprocess isolation, including installed modules.
- [ ] AC-02: Focused/full tests, governance and Docker validation pass; protected exact-head review merges the source repair.

## Validation

Before the fix: four real virtualenv isolation cases fail; fallback passes. After the fix: 723 tests pass, 2 skip. Managed governance reports zero errors or warnings. Docker engine and Compose configuration pass. New tests pass full Ruff; changed source passes the CI fatal-error selection. Existing broad Ruff findings in publish.py are outside this repair. Exact-head protected publication remains pending.
78 changes: 78 additions & 0 deletions project/ticket-087/intent.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
{
"schema": "new-project.intent/v3",
"ticket": "ticket-087",
"summary": "Preserve virtualenv isolation during package publication",
"workstream": "application",
"classification": {
"kind": "BUG",
"priority": "P1",
"origin": "requested"
},
"allowedPaths": [
"goal/cli/publish.py",
"tests/test_publish_python.py",
"project/ticket-087/**"
],
"forbiddenPaths": [
"project/ticket-*/user-*.md"
],
"stacks": [
"python",
"docker"
],
"dependsOn": [],
"conflictsWith": [],
"integrationTicket": null,
"delivery": {
"acceptedBaseSha": "06f70a83b500e5804e6051f46d03aa56f59fd250",
"targetBranch": "main",
"outcome": "Publish a tested fix that preserves virtualenv isolation when selecting the Python interpreter for package publication.",
"nonGoals": [
"Package version changes",
"Dependency policy changes"
],
"complexity": "XS",
"estimatedMinutes": 10,
"budgets": {
"maxImplementationFiles": 2,
"maxAffectedComponents": 1,
"maxPublicInterfaceChanges": 0,
"maxRuntimeDependencies": 0
},
"architecture": {
"status": "accepted",
"decision": "Preserve the virtualenv executable path without dereferencing Python symlinks.",
"components": [
{
"name": "publish-interpreter",
"paths": [
"goal/cli/publish.py",
"tests/test_publish_python.py"
]
}
],
"responsibilityChanges": false,
"interfaceChanges": [],
"dataChanges": [],
"ui": {
"impact": "none",
"states": [],
"evidence": []
},
"rollback": "Revert the two interpreter-path selections and regression tests."
},
"runtimeDependencies": [],
"validation": [
{
"criterion": "AC-01",
"commands": [
"python -m pytest -q tests/test_publish_python.py",
"python -m pytest -q",
"./project/governance-check.sh --actor agent",
"docker compose config --quiet"
],
"evidence": "Real virtualenv subprocess regression and full suite logs in external execution receipts."
}
]
}
}
58 changes: 58 additions & 0 deletions tests/test_publish_python.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
"""Publication must run inside the selected environment, not its base Python."""

import json
import subprocess
import sys
import venv
from pathlib import Path

import pytest

from goal.cli.publish import _get_python_bin


@pytest.mark.parametrize("selection", ["active", ".venv", "venv", "env"])
def test_selected_python_keeps_virtualenv_isolation(tmp_path, monkeypatch, selection):
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
environment = tmp_path / ("external environment" if selection == "active" else selection)
venv.EnvBuilder(with_pip=False, symlinks=True).create(environment)
python = environment / "bin" / "python"
assert python.is_symlink()
if selection == "active":
monkeypatch.setenv("VIRTUAL_ENV", str(environment))
# An active environment takes precedence over an existing project venv.
venv.EnvBuilder(with_pip=False, symlinks=True).create(tmp_path / ".venv")
else:
monkeypatch.setenv("VIRTUAL_ENV", str(tmp_path / "missing-environment"))
if selection == ".venv":
# Project .venv wins even when the lower-priority names exist.
for name in ("venv", "env"):
venv.EnvBuilder(with_pip=False, symlinks=True).create(tmp_path / name)
site_packages = subprocess.check_output(
[str(python), "-c", "import sysconfig; print(sysconfig.get_path('purelib'))"],
text=True,
).strip()
Path(site_packages, "publish_environment_probe.py").write_text("VALUE = 'isolated'\n")
result = subprocess.check_output(
[
_get_python_bin(),
"-I",
"-c",
(
"import json, sys, publish_environment_probe as probe; "
"print(json.dumps([sys.prefix, sys.base_prefix, probe.VALUE]))"
),
],
text=True,
)
prefix, base_prefix, value = json.loads(result)
assert Path(prefix) == environment
assert prefix != base_prefix
assert value == "isolated"


def test_no_virtualenv_uses_running_interpreter(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
monkeypatch.delenv("VIRTUAL_ENV", raising=False)
assert _get_python_bin() == sys.executable