diff --git a/.github/workflows/heart-tests.yml b/.github/workflows/heart-tests.yml index 5a0677f..6d58252 100644 --- a/.github/workflows/heart-tests.yml +++ b/.github/workflows/heart-tests.yml @@ -5,10 +5,21 @@ name: Heart Tests # correct?", which nothing else runs automatically: the health agent runs # Heart's checks *against other repos*, never Heart's own tests. # -# Deliberately ONLY pytest. It must not run `pyauto-heart tick`/`readiness` or -# any health check — those need live repo checkouts + network and are the -# scheduled health agent's job, not a PR gate. The suite is stdlib + PyYAML -# only, so it stays fast and flake-free. +# pytest plus ONE drift leg. The suite must not run `pyauto-heart tick`/ +# `readiness` or any health check — those need live repo checkouts + network +# and are the scheduled health agent's job, not a PR gate. The suite is stdlib +# + PyYAML only, so it stays fast and flake-free. The one non-pytest step is +# the tenant-firewall gate (PyAutoMind#198): every firewall finding to date +# merged through a green PR because no PR CI ran the check, so the leg an +# organ PR can cause is verified here, in the PR that authors it. +# +# TWO repos are checked out on purpose: the firewall gate is +# `PyAutoMind/scripts/repos_sync.py --only "tenant firewall (organ code)"`, +# which reads the body map from a PyAutoMind sibling — the same workspace +# layout a local checkout has. `--only` keeps Mind-side legs (map blocks, +# registries) from reddening Heart PRs on drift Heart cannot cause; organs not +# checked out (Brain, Hands) are skipped by the check itself, so this gates +# exactly Heart. Both repos are public — the default GITHUB_TOKEN suffices. # One run per commit: PR events carry the CI; pushes only build main # (Heart's ws_ci gate reads main-HEAD conclusions). Superseded runs are @@ -30,13 +41,32 @@ jobs: matrix: python-version: ["3.12", "3.13"] steps: - - uses: actions/checkout@v4 + - name: Checkout PyAutoHeart + uses: actions/checkout@v4 + with: + path: PyAutoHeart + # The body map + firewall checker. Pinned to main: this gate tests + # Heart against the CURRENT organism identity, the same pairing a local + # workspace has. + - name: Checkout PyAutoMind (the body map — the firewall gate reads it) + uses: actions/checkout@v4 + with: + repository: PyAutoLabs/PyAutoMind + path: PyAutoMind - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} cache: pip + cache-dependency-path: PyAutoHeart/pyproject.toml - name: Install (PyYAML core + pytest dev extra) + working-directory: PyAutoHeart run: pip install -e ".[dev]" - name: Run tests + working-directory: PyAutoHeart run: pytest tests/ -q + - name: Tenant firewall (instance facts stay in declared config surfaces) + run: > + python3 PyAutoMind/scripts/repos_sync.py --check + --only "tenant firewall (organ code)" + --root "$GITHUB_WORKSPACE" diff --git a/bin/pyauto-heart b/bin/pyauto-heart index d3836bc..f231620 100755 --- a/bin/pyauto-heart +++ b/bin/pyauto-heart @@ -376,9 +376,9 @@ pyauto-heart smoke [WORKSPACE ...] [--prepare-only] [--rebuild] [--python PATH] [--root DIR] [--state-dir DIR] Prepare a separate, cached Python environment for each selected workspace, -then run its workspace-owned smoke runner. The default is all six workspaces; -valid filters are: autofit, autogalaxy, autolens, autolens_test, euclid, -howtolens. +then run its workspace-owned smoke runner. The workspace table (keys, +directories, install chains) is the smoke: block of config/repos.yaml; the +default is every workspace configured there. Environment dependencies come from each workspace's CI smoke_install.sh (or a metadata-derived fallback for a legacy workspace), not from the invoking shell. @@ -389,7 +389,7 @@ source imports, and Jupyter kernel ownership before science scripts begin. --prepare-only Build/reuse and preflight; do not run workspace scripts --rebuild Force selected cached environments to rebuild --python PATH Base Python used to create environments (default: current) - --root DIR PyAutoLabs root (default: \$PYAUTO_ROOT) + --root DIR organism root (default: \$PYAUTO_ROOT) --state-dir DIR Environment/cache root (default: \$HEART_STATE_DIR) EOF } diff --git a/config/repos.yaml b/config/repos.yaml index 723e475..f0322a8 100644 --- a/config/repos.yaml +++ b/config/repos.yaml @@ -113,6 +113,39 @@ version_skew: euclid_strong_lens_modeling_pipeline: {library: PyAutoLens, package: autolens} autolens_assistant: {library: PyAutoLens, package: autolens} +# Smoke-suite table: the workspaces the local smoke runner (heart/smoke.py) +# can prepare and run — each key names its workspace checkout directory and the +# library install chain — plus the repo -> import-package map the preflight +# proves. Read strictly by heart/smoke.py (a missing block fails loudly rather +# than silently preparing nothing); an adopter replaces this with their own +# workspaces. Repo IDENTITY here must match the body map, same as above. +smoke: + import_names: + PyAutoNerves: autonerves + PyAutoFit: autofit + PyAutoArray: autoarray + PyAutoGalaxy: autogalaxy + PyAutoLens: autolens + workspaces: + autofit: + directory: autofit_workspace + chain: [PyAutoNerves, PyAutoFit] + autogalaxy: + directory: autogalaxy_workspace + chain: [PyAutoNerves, PyAutoFit, PyAutoArray, PyAutoGalaxy] + autolens: + directory: autolens_workspace + chain: [PyAutoNerves, PyAutoFit, PyAutoArray, PyAutoGalaxy, PyAutoLens] + autolens_test: + directory: autolens_workspace_test + chain: [PyAutoNerves, PyAutoFit, PyAutoArray, PyAutoGalaxy, PyAutoLens] + euclid: + directory: euclid_strong_lens_modeling_pipeline + chain: [PyAutoNerves, PyAutoFit, PyAutoArray, PyAutoGalaxy, PyAutoLens] + howtolens: + directory: HowToLens + chain: [PyAutoNerves, PyAutoFit, PyAutoArray, PyAutoGalaxy, PyAutoLens] + # Excluded — not polled. Listed here for documentation only. excluded: - admin_jammy # personal tooling diff --git a/heart/smoke.py b/heart/smoke.py index c61c576..dd1ccad 100644 --- a/heart/smoke.py +++ b/heart/smoke.py @@ -29,9 +29,14 @@ from pathlib import Path from typing import Iterable, Mapping, Sequence +import yaml + FINGERPRINT_SCHEMA = 1 MARKER_NAME = ".pyauto-smoke-environment.json" +HEART_HOME = Path(__file__).resolve().parents[1] +CONFIG_PATH = HEART_HOME / "config" / "repos.yaml" + @dataclass(frozen=True) class WorkspaceSpec: @@ -40,69 +45,25 @@ class WorkspaceSpec: chain: tuple[str, ...] -WORKSPACES: dict[str, WorkspaceSpec] = { - "autofit": WorkspaceSpec( - "autofit", "autofit_workspace", ("PyAutoNerves", "PyAutoFit") - ), - "autogalaxy": WorkspaceSpec( - "autogalaxy", - "autogalaxy_workspace", - ("PyAutoNerves", "PyAutoFit", "PyAutoArray", "PyAutoGalaxy"), - ), - "autolens": WorkspaceSpec( - "autolens", - "autolens_workspace", - ( - "PyAutoNerves", - "PyAutoFit", - "PyAutoArray", - "PyAutoGalaxy", - "PyAutoLens", - ), - ), - "autolens_test": WorkspaceSpec( - "autolens_test", - "autolens_workspace_test", - ( - "PyAutoNerves", - "PyAutoFit", - "PyAutoArray", - "PyAutoGalaxy", - "PyAutoLens", - ), - ), - "euclid": WorkspaceSpec( - "euclid", - "euclid_strong_lens_modeling_pipeline", - ( - "PyAutoNerves", - "PyAutoFit", - "PyAutoArray", - "PyAutoGalaxy", - "PyAutoLens", - ), - ), - "howtolens": WorkspaceSpec( - "howtolens", - "HowToLens", - ( - "PyAutoNerves", - "PyAutoFit", - "PyAutoArray", - "PyAutoGalaxy", - "PyAutoLens", - ), - ), -} +def load_smoke_config( + config_path: Path | str = CONFIG_PATH, +) -> tuple[dict[str, WorkspaceSpec], dict[str, str]]: + """Workspace specs + repo -> import-name map, from the policy file's + ``smoke`` block. Strict: a missing block is a config bug and fails + loudly rather than silently preparing nothing (the ``version_skew`` + idiom). Which workspaces exist is instance policy, so it lives in + ``config/repos.yaml`` — the declared surface an adopting fork replaces — + not in this module.""" + cfg = yaml.safe_load(Path(config_path).read_text()) or {} + block = cfg["smoke"] + workspaces = { + key: WorkspaceSpec(key, spec["directory"], tuple(spec["chain"])) + for key, spec in block["workspaces"].items() + } + return workspaces, dict(block["import_names"]) -IMPORT_NAMES = { - "PyAutoNerves": "autonerves", - "PyAutoFit": "autofit", - "PyAutoArray": "autoarray", - "PyAutoGalaxy": "autogalaxy", - "PyAutoLens": "autolens", -} +WORKSPACES, IMPORT_NAMES = load_smoke_config() class SmokeEnvironmentError(RuntimeError): @@ -588,7 +549,7 @@ def _parser() -> argparse.ArgumentParser: default=Path( os.environ.get("PYAUTO_ROOT", Path(__file__).resolve().parents[2]) ), - help="PyAutoLabs organism root", + help="organism root", ) parser.add_argument( "--state-dir", diff --git a/tests/test_release_run.py b/tests/test_release_run.py index f4fa580..44e9275 100644 --- a/tests/test_release_run.py +++ b/tests/test_release_run.py @@ -255,12 +255,17 @@ def test_migration_blocked_by_timeouts_alone(): def test_migration_blocked_by_per_project_counts_alone(): """`per_project` is merged independently of `totals`, so it can be the only adverse signal — and the guard must use the same definition of "adverse" - that `validate._has_adverse_evidence` does.""" + that `validate._has_adverse_evidence` does. + + The project key is deliberately synthetic (`demo_workspace`): it is merged + generically, never matched against a repo list, so a real name here would + be an instance fact in organ code (the tenant firewall's concern) for no + test value.""" d = rr.decide( {"ts": "2026-08-14T20:00:00+00:00", "stages": {"integrate": {"status": "pass"}}, "totals": {"passed": 9, "failed": 0, "skipped": 0, "timeout": 0}, - "per_project": {"autolens_workspace": + "per_project": {"demo_workspace": {"passed": 6, "failed": 1, "skipped": 0, "timeout": 0}}}, {"last_ingested_run_id": 7}, _run(run_id=7), ) diff --git a/tests/test_smoke.py b/tests/test_smoke.py index c84df81..fd113c5 100644 --- a/tests/test_smoke.py +++ b/tests/test_smoke.py @@ -10,8 +10,13 @@ from heart import smoke +# Fixture repo and root names are deliberately synthetic (LibraryA/LibraryB +# chains under an `organism` root): nothing in smoke.py matches a spec against +# a real repo list, so a real name here would be an instance fact in organ code +# — the tenant firewall's concern (PyAutoMind/scripts/repos_sync.py) — for no +# test value. Keep them synthetic. def make_tree(tmp_path: Path, spec: smoke.WorkspaceSpec) -> Path: - root = tmp_path / "PyAutoLabs" + root = tmp_path / "organism" workspace = root / spec.directory (workspace / ".github" / "scripts").mkdir(parents=True) (workspace / ".github" / "scripts" / "smoke_install.sh").write_text( @@ -32,7 +37,7 @@ def completed(stdout: str = "") -> subprocess.CompletedProcess[str]: def test_fingerprint_changes_with_installer_and_dependency_metadata(tmp_path): - spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("PyAutoArray",)) + spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("LibraryB",)) root = make_tree(tmp_path, spec) identity = {"executable": "/usr/bin/python3", "version": "3.12.8"} @@ -44,8 +49,8 @@ def test_fingerprint_changes_with_installer_and_dependency_metadata(tmp_path): installer_changed = smoke.fingerprint_digest( smoke.environment_fingerprint(root, spec, identity) ) - (root / "PyAutoArray" / "pyproject.toml").write_text( - '[project]\nname = "autoarray"\nversion = "2"\n' + (root / "LibraryB" / "pyproject.toml").write_text( + '[project]\nname = "libraryb"\nversion = "2"\n' ) metadata_changed = smoke.fingerprint_digest( smoke.environment_fingerprint(root, spec, identity) @@ -58,7 +63,7 @@ def test_fingerprint_changes_with_installer_and_dependency_metadata(tmp_path): def test_runtime_environment_replaces_ambient_python_and_pyauto_state( tmp_path, monkeypatch ): - spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("PyAutoFit", "PyAutoArray")) + spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("LibraryA", "LibraryB")) root = make_tree(tmp_path, spec) environment = tmp_path / "environment" smoke._environment_bin(environment).mkdir(parents=True) @@ -69,8 +74,8 @@ def test_runtime_environment_replaces_ambient_python_and_pyauto_state( assert "/ambient/leak" not in env["PYTHONPATH"] assert env["PYTHONPATH"].split(os.pathsep) == [ - str(root / "PyAutoFit"), - str(root / "PyAutoArray"), + str(root / "LibraryA"), + str(root / "LibraryB"), str(root / "PyAutoHands" / "autohands"), ] assert "PYAUTO_TEST_MODE" not in env @@ -81,7 +86,7 @@ def test_runtime_environment_replaces_ambient_python_and_pyauto_state( def test_prepare_reuses_cache_then_rebuilds_after_metadata_change( tmp_path, monkeypatch ): - spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("PyAutoArray",)) + spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("LibraryB",)) root = make_tree(tmp_path, spec) state = tmp_path / "state" identity = {"executable": "/fake/python", "version": "3.12.8"} @@ -112,8 +117,8 @@ def fake_run(command, **kwargs): first, first_built = smoke.prepare_environment(root, state, spec) second, second_built = smoke.prepare_environment(root, state, spec) - (root / "PyAutoArray" / "pyproject.toml").write_text( - '[project]\nname = "autoarray"\nversion = "2"\n' + (root / "LibraryB" / "pyproject.toml").write_text( + '[project]\nname = "libraryb"\nversion = "2"\n' ) third, third_built = smoke.prepare_environment(root, state, spec) @@ -127,7 +132,7 @@ def fake_run(command, **kwargs): def test_failed_rebuild_restores_previous_complete_environment(tmp_path, monkeypatch): - spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("PyAutoArray",)) + spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("LibraryB",)) root = make_tree(tmp_path, spec) state = tmp_path / "state" identity = {"executable": "/fake/python", "version": "3.12.8"} @@ -146,8 +151,8 @@ def fake_run(command, **kwargs): monkeypatch.setattr(smoke, "_preflight", lambda *_: None) target, _ = smoke.prepare_environment(root, state, spec) old_marker = (target / smoke.MARKER_NAME).read_text() - (root / "PyAutoArray" / "pyproject.toml").write_text( - '[project]\nname = "autoarray"\nversion = "2"\n' + (root / "LibraryB" / "pyproject.toml").write_text( + '[project]\nname = "libraryb"\nversion = "2"\n' ) monkeypatch.setattr( smoke, @@ -163,7 +168,7 @@ def fake_run(command, **kwargs): def test_workspace_installer_is_the_dependency_source_of_truth(tmp_path, monkeypatch): - spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("PyAutoFit",)) + spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("LibraryA",)) root = make_tree(tmp_path, spec) environment = tmp_path / "environment" python = smoke._environment_python(environment) @@ -188,19 +193,19 @@ def test_workspace_installer_is_the_dependency_source_of_truth(tmp_path, monkeyp installer = root / spec.directory / ".github" / "scripts" / "smoke_install.sh" assert any(command == ["bash", str(installer)] for command, _ in calls) - assert all("./PyAutoFit" not in command for command, _ in calls) + assert all("./LibraryA" not in command for command, _ in calls) installer_call = next(kwargs for command, kwargs in calls if command[0] == "bash") assert installer_call["cwd"] == root assert installer_call["env"]["PYTHON_VERSION"] == "3.12" def test_legacy_installer_derives_optional_extras_from_pyproject(tmp_path, monkeypatch): - spec = smoke.WorkspaceSpec("legacy", "legacy_workspace", ("PyAutoArray",)) + spec = smoke.WorkspaceSpec("legacy", "legacy_workspace", ("LibraryB",)) root = make_tree(tmp_path, spec) (root / spec.directory / ".github" / "scripts" / "smoke_install.sh").unlink() - (root / "PyAutoArray" / "pyproject.toml").write_text(""" + (root / "LibraryB" / "pyproject.toml").write_text(""" [project] -name = "autoarray" +name = "libraryb" version = "1" [project.optional-dependencies] @@ -225,8 +230,8 @@ def test_legacy_installer_derives_optional_extras_from_pyproject(tmp_path, monke {"executable": "/fake/python", "version": "3.12.8"}, ) - assert any("./PyAutoArray" in command for command in commands) - assert any("./PyAutoArray[optional]" in command for command in commands) + assert any("./LibraryB" in command for command in commands) + assert any("./LibraryB[optional]" in command for command in commands) assert all("nufftax" not in command for command in commands) @@ -341,7 +346,7 @@ def test_safe_remove_refuses_paths_outside_smoke_cache(tmp_path): def test_run_workspace_uses_prepared_python_and_isolated_environment( tmp_path, monkeypatch ): - spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("PyAutoFit",)) + spec = smoke.WorkspaceSpec("demo", "demo_workspace", ("LibraryA",)) root = make_tree(tmp_path, spec) runner = root / spec.directory / ".github" / "scripts" / "run_smoke.py" runner.write_text("")