From 7eacefb4fa0f999d15a7c33d124b75963408cf49 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Sun, 6 Sep 2026 12:30:16 +0200 Subject: [PATCH 1/2] [ticket-087] fix(publish): preserve virtualenv interpreter isolation --- goal/cli/publish.py | 5 ++- project/ticket-087/README.md | 20 +++++++++ project/ticket-087/intent.json | 78 ++++++++++++++++++++++++++++++++++ tests/test_publish_python.py | 60 ++++++++++++++++++++++++++ 4 files changed, 161 insertions(+), 2 deletions(-) create mode 100644 project/ticket-087/README.md create mode 100644 project/ticket-087/intent.json create mode 100644 tests/test_publish_python.py diff --git a/goal/cli/publish.py b/goal/cli/publish.py index bfb2baf..6844077 100644 --- a/goal/cli/publish.py +++ b/goal/cli/publish.py @@ -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 diff --git a/project/ticket-087/README.md b/project/ticket-087/README.md new file mode 100644 index 0000000..55a7cfa --- /dev/null +++ b/project/ticket-087/README.md @@ -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. diff --git a/project/ticket-087/intent.json b/project/ticket-087/intent.json new file mode 100644 index 0000000..69d6b1d --- /dev/null +++ b/project/ticket-087/intent.json @@ -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." + } + ] + } +} diff --git a/tests/test_publish_python.py b/tests/test_publish_python.py new file mode 100644 index 0000000..5776b53 --- /dev/null +++ b/tests/test_publish_python.py @@ -0,0 +1,60 @@ +"""Publication must run inside the selected environment, not its base Python.""" + +import json +import os +import subprocess +import sys +import venv +from pathlib import Path + +import pytest + +from goal.cli.publish import _get_python_bin + + +@pytest.mark.skipif(os.name == "nt", reason="POSIX virtualenv executable layout") +@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 From ca9094d68282363d5ba00cbb5fe19285a3265e98 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Sun, 6 Sep 2026 12:37:42 +0200 Subject: [PATCH 2/2] [ticket-087] test(publish): always execute virtualenv isolation regressions --- tests/test_publish_python.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/test_publish_python.py b/tests/test_publish_python.py index 5776b53..d3c5d28 100644 --- a/tests/test_publish_python.py +++ b/tests/test_publish_python.py @@ -1,7 +1,6 @@ """Publication must run inside the selected environment, not its base Python.""" import json -import os import subprocess import sys import venv @@ -12,7 +11,6 @@ from goal.cli.publish import _get_python_bin -@pytest.mark.skipif(os.name == "nt", reason="POSIX virtualenv executable layout") @pytest.mark.parametrize("selection", ["active", ".venv", "venv", "env"]) def test_selected_python_keeps_virtualenv_isolation(tmp_path, monkeypatch, selection): monkeypatch.chdir(tmp_path)