From e31b9557d26f714db0802d5a06fbdf7f348cb68b Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Mon, 12 Oct 2026 18:29:25 +0200 Subject: [PATCH] fix(env): keep the host .env when the runner container runs make setup The repository is bind-mounted into the infinito container, and every deploy runs `make setup` there, which depends on `dotenv`. The generator therefore rewrote the host's .env with the values the container sees: the host lost INFINITO_RUNNING_ON_GITHUB=true that way, and the guide replay pulled alpine:latest past the GHCR mirror. compose.yml now marks the infinito service with INFINITO_DOTENV_HOST_OWNED, and `python -m cli.meta.env` keeps an existing .env while that marker is "true". A missing .env is still generated, and `make dotenv-force` (env -i) still rewrites it. The key stays out of the env registry: only compose.yml sets it, it is no configuration value, and a .env entry could freeze the host .env. test_distros now also strips INFINITO_ENV_LOADED. load.sh returns early while that guard is set and the mtime of .env is unchanged, so the test only reloaded the budget keys it strips because the container kept rewriting .env. Verified: unit tests cover a kept .env, a missing one being generated, and a rewrite without the marker. Local compose deploys print "Kept .env: the host checkout owns it" where they printed "Wrote .env" before, the host .env checksum stayed the same across a deploy, and the four keys holding host paths resolve inside the container under the same paths. Co-Authored-By: Claude Opus 5.5 (1M context) --- cli/meta/env/__main__.py | 11 +++++ compose.yml | 1 + tests/unit/python/cli/meta/env/__init__.py | 0 tests/unit/python/cli/meta/env/test_main.py | 45 +++++++++++++++++++ .../scripts/tests/deploy/test_distros.py | 1 + 5 files changed, 58 insertions(+) create mode 100644 tests/unit/python/cli/meta/env/__init__.py create mode 100644 tests/unit/python/cli/meta/env/test_main.py diff --git a/cli/meta/env/__main__.py b/cli/meta/env/__main__.py index 9ab0ca4b71..d4f7e3764f 100644 --- a/cli/meta/env/__main__.py +++ b/cli/meta/env/__main__.py @@ -2,6 +2,7 @@ from __future__ import annotations +import os import sys from utils.env.builder import build_env @@ -10,6 +11,8 @@ from utils.env.writer import write_dotenv from . import PROJECT_ROOT as REPO_ROOT +HOST_OWNED_KEY = "INFINITO_DOTENV_HOST_OWNED" # nocheck: dotenv-host-owned-marker Reason: set only by compose.yml inside the runner container; not a configuration key, a .env entry could freeze the host .env + def main() -> int: static_path = REPO_ROOT / "default.env" @@ -19,6 +22,14 @@ def main() -> int: print(f"ERROR: missing {static_path}", file=sys.stderr) return 2 + if out_path.is_file() and os.environ.get(HOST_OWNED_KEY) == "true": + print( + f"Kept {out_path.relative_to(REPO_ROOT)}: the host checkout owns it " + f"({HOST_OWNED_KEY})", + file=sys.stderr, + ) + return 0 + static, static_comments = parse_static_env_with_comments(static_path) eb = build_env(static, repo_root=REPO_ROOT, comments=static_comments) write_dotenv(eb, out_path) diff --git a/compose.yml b/compose.yml index 3bb9f3e536..8d12571046 100644 --- a/compose.yml +++ b/compose.yml @@ -78,6 +78,7 @@ services: INFINITO_DIR_VAR_LIB: ${INFINITO_DIR_VAR_LIB} INFINITO_DNS_IP: ${INFINITO_DNS_IP} INFINITO_DOMAIN: ${INFINITO_DOMAIN} + INFINITO_DOTENV_HOST_OWNED: "true" INFINITO_GHCR_MIRROR_PREFIX: ${INFINITO_GHCR_MIRROR_PREFIX} INFINITO_IMAGE_TAG: ${INFINITO_IMAGE_TAG} INFINITO_IP4: ${INFINITO_IP4} diff --git a/tests/unit/python/cli/meta/env/__init__.py b/tests/unit/python/cli/meta/env/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/python/cli/meta/env/test_main.py b/tests/unit/python/cli/meta/env/test_main.py new file mode 100644 index 0000000000..d2cc44c42f --- /dev/null +++ b/tests/unit/python/cli/meta/env/test_main.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +import cli.meta.env.__main__ as env_main + + +class TestDotenvHostOwned(unittest.TestCase): + def _run(self, *, dotenv_exists: bool, host_owned: str | None) -> tuple[int, bool]: + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + (root / "default.env").write_text("INFINITO_DISTRO=debian\n") + if dotenv_exists: + (root / ".env").write_text("INFINITO_RUNNING_ON_GITHUB=true\n") + environ = ( + {} if host_owned is None else {env_main.HOST_OWNED_KEY: host_owned} + ) + with ( + patch.object(env_main, "REPO_ROOT", root), + patch.dict("os.environ", environ, clear=True), + patch.object( + env_main, "parse_static_env_with_comments", return_value=({}, {}) + ), + patch.object(env_main, "build_env") as build_env, + patch.object(env_main, "write_dotenv") as write_dotenv, + ): + build_env.return_value.values = {} + rc = env_main.main() + return rc, write_dotenv.called + + def test_a_host_owned_dotenv_is_kept(self) -> None: + self.assertEqual(self._run(dotenv_exists=True, host_owned="true"), (0, False)) + + def test_a_missing_dotenv_is_generated_even_when_host_owned(self) -> None: + self.assertEqual(self._run(dotenv_exists=False, host_owned="true"), (0, True)) + + def test_without_the_marker_the_dotenv_is_regenerated(self) -> None: + self.assertEqual(self._run(dotenv_exists=True, host_owned=None), (0, True)) + + +if __name__ == "__main__": # pragma: no cover + unittest.main() diff --git a/tests/unit/python/scripts/tests/deploy/test_distros.py b/tests/unit/python/scripts/tests/deploy/test_distros.py index 0abb3c7479..4b6cb76aee 100644 --- a/tests/unit/python/scripts/tests/deploy/test_distros.py +++ b/tests/unit/python/scripts/tests/deploy/test_distros.py @@ -29,6 +29,7 @@ STRIPPED = ( "INFINITO_DISTRO", "INFINITO_CI_DISTRO_BUDGET_SECONDS", "INFINITO_SWARM_STEP_TIMEOUT_MINUTES", + "INFINITO_ENV_LOADED", # nocheck: env-loader-internal-guard )