diff --git a/crates/hm-dsl-engine/harmont-py/harmont/__init__.py b/crates/hm-dsl-engine/harmont-py/harmont/__init__.py index 9448277f..87c41242 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/__init__.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/__init__.py @@ -40,7 +40,7 @@ from ._pipeline import pipeline_to_json from ._python import python from ._rust import RustProject, rust -from ._step import Step, scratch, wait +from ._step import Step, StepAction, scratch, wait from ._target import clear_target_cache, target # noqa: F401 clear_target_cache used by tests from ._toolchain import apt_base from ._typing import BaseImage, Target @@ -276,6 +276,46 @@ def sh( ) +def mount( + *, + from_: str, + to: str, + label: str = ":ext: dir", + image: str | None = None, + strict: bool = True, +) -> Step: + """Declare a workspace-directory bind mount. + + Shorthand for ``scratch().mount(from_=from_, to=to, ...)``. + All keyword arguments are forwarded to ``Step.mount``. + + Args: + from_: Source directory relative to the workspace root. + to: Destination directory inside the step container, + relative to the workspace root. + label: Human-facing label shown in the UI. + image: Local-mode Docker base image override for this step. + strict: Aditional check which validates if the source file + exists during the function's call. Defaults to ``True``. + It's not included in the IR. + + Returns: + A new root ``Step`` with the mount set. + + Examples: + >>> import harmont as hm + >>> step = hm.mount(from_=".cache/npm", to="node_modules") + """ + return scratch().mount( + from_=from_, + to=to, + cache=CacheNone(), + label=label, + image=image, + strict=strict, + ) + + def group(steps: list[Step] | tuple[Step, ...]) -> tuple[Step, ...]: """Collect a list of steps into a tuple for use as a target return value. @@ -310,6 +350,7 @@ def group(steps: list[Step] | tuple[Step, ...]) -> tuple[Step, ...]: "Pipeline", "RustProject", "Step", + "StepAction", "Target", "apt_base", "cmake", @@ -320,6 +361,7 @@ def group(steps: list[Step] | tuple[Step, ...]) -> tuple[Step, ...]: "go", "group", "js", + "mount" "on_change", "pipeline", "pipeline_to_json", diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_keys.py b/crates/hm-dsl-engine/harmont-py/harmont/_keys.py index 0e440001..c6b6f299 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_keys.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_keys.py @@ -17,10 +17,12 @@ import re from typing import TYPE_CHECKING +from ._step import Command, Mount + if TYPE_CHECKING: from collections.abc import Iterable - from ._step import Step + from ._step import Step, StepAction _EMOJI_SHORTCODE_RE = re.compile(r":[a-z0-9_+-]+:") _NON_ALNUM_RE = re.compile(r"[^a-z0-9]+") @@ -43,15 +45,21 @@ def slugify_label(label: str) -> str: return s.strip("-") -def hash_key(parent_key: str, cmd: str, position: int) -> str: +def hash_key(parent_key: str, action: StepAction | None, position: int) -> str: """Stable 12-char SHA-256 prefix over (parent_key, cmd, position). Used as the fallback key when no usable slug is available.""" h = hashlib.sha256() h.update(parent_key.encode("utf-8")) h.update(b"\x00") - h.update(cmd.encode("utf-8")) - h.update(b"\x00") + if isinstance(action, Command): + h.update(action.cmd.encode("utf-8")) + h.update(b"\x00") + elif isinstance(action, Mount): + h.update(action.from_.encode("utf-8")) + h.update(b"\x00") + h.update(action.to.encode("utf-8")) + h.update(b"\x00") h.update(str(position).encode("utf-8")) return h.hexdigest()[:12] @@ -117,5 +125,5 @@ def resolve_keys(steps: Iterable[Step]) -> dict[int, str]: parent_key = "" if s.parent is not None and id(s.parent) in keys: parent_key = keys[id(s.parent)] - keys[sid] = hash_key(parent_key, s.cmd or "", position) + keys[sid] = hash_key(parent_key, s.action, position) return keys diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_pipeline.py b/crates/hm-dsl-engine/harmont-py/harmont/_pipeline.py index 38b25ea1..ddee6402 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_pipeline.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_pipeline.py @@ -15,6 +15,7 @@ from ._duration import parse_duration from ._keys import resolve_keys +from ._step import Command, Mount from .cache import ( CacheCompose, CacheForever, @@ -25,7 +26,7 @@ ) if TYPE_CHECKING: - from ._step import Step + from ._step import Step, StepAction # Across-the-board default image for imageless root steps. The SDK's # toolchains assume an apt-capable base (apt-get), so ubuntu:24.04 is the @@ -34,6 +35,19 @@ DEFAULT_IMAGE = "ubuntu:24.04" +def _action_to_dict(action: StepAction) -> dict[str, Any]: + action_dict = {} + if isinstance(action, Command): + action_dict["cmd"] = action.cmd + if action.env: + action_dict["env"] = action.env + elif isinstance(action, Mount): + action_dict["from"] = action.from_ + action_dict["to"] = action.to + + return action_dict + + def pipeline( leaves: list[Step] | tuple[Step, ...], *, @@ -76,7 +90,7 @@ def _lower_to_graph( explicit ``depends_on`` edges. """ ordered = _topo_collect(leaves) - command_steps = [s for s in ordered if s.cmd is not None and not s.is_wait] + command_steps = [s for s in ordered if s.action and not s.is_wait] keys = resolve_keys(command_steps) # Assign integer node indices (dense, in emission order). @@ -105,18 +119,16 @@ def _lower_to_graph( pre_wait_indices = [] continue - if s.cmd is None: + if not s.action: # scratch or fork — passthrough, not emitted. continue node_idx = idx_by_id[id(s)] step_key = keys[id(s)] - # Build the CommandStep dict (no "type" or "builds_in" fields). - step_dict: dict[str, Any] = { - "key": step_key, - "cmd": s.cmd, - } + # Build the Step dict (no "type" or "builds_in" fields). + step_dict: dict[str, Any] = {"key": step_key, "action": _action_to_dict(s.action)} + if s.label is not None: step_dict["label"] = s.label if s.cache is not None: @@ -137,8 +149,8 @@ def _lower_to_graph( } if env: merged_env.update(env) - if s.env: - merged_env.update(s.env) + if isinstance(s.action, Command) and s.action.env: + merged_env.update(s.action.env) nodes.append({"step": step_dict, "env": merged_env}) @@ -172,12 +184,12 @@ def _lower_to_graph( def _find_idx_by_key( key: str, - command_steps: list[Step], + steps: list[Step], keys: dict[int, str], idx_by_id: dict[int, int], ) -> int: """Return the node index for the step with the given resolved key.""" - for s in command_steps: + for s in steps: if keys[id(s)] == key: return idx_by_id[id(s)] msg = f"BUG: no step with key {key!r}" @@ -216,7 +228,7 @@ def _resolved_parent_key(s: Step, keys: dict[int, str]) -> str | None: """Walk back through scratch/fork nodes to the nearest emitted ancestor.""" node = s.parent while node is not None: - if node.cmd is not None and not node.is_wait: + if node.action and not node.is_wait: return keys[id(node)] node = node.parent return None diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_step.py b/crates/hm-dsl-engine/harmont-py/harmont/_step.py index 2fb8e090..f0a31bee 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_step.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_step.py @@ -8,22 +8,40 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Any +from pathlib import Path +from typing import TYPE_CHECKING, Any, Union if TYPE_CHECKING: from .cache import CachePolicy +@dataclass(frozen=True) +class Command: + cmd: str + env: dict[str, str] | None = None + + +@dataclass(frozen=True) +class Mount: + from_: str + to: str + + +StepAction = Union["Command", "Mount"] + + @dataclass(frozen=True) class Step: """Immutable chain node — the primitive the DSL is built on. Steps are constructed via `scratch()` or `wait()` and extended by - calling ``.sh()`` or ``.fork()`` on the result. Every mutating method + calling ``.sh()``, ``mount()`` or ``.fork()`` on the result. Every mutating method returns a new ``Step``; the receiver is unchanged. """ - cmd: str | None = None + action: StepAction | None = None + """Action realized by the Step. Can be either a Mount or a Commmand""" + parent: Step | None = None """In-tree pointer used by the lowering pass to walk back to the nearest emitted ancestor. Distinct from the wire-format @@ -33,7 +51,6 @@ class Step: continue_on_failure: bool = False label: str | None = None cache: CachePolicy | None = None - env: dict[str, str] | None = None timeout_seconds: int | None = None image: str | None = None """Local-mode Docker base image override for this step. Ignored when @@ -53,6 +70,86 @@ class Step: The field is renamed so it doesn't shadow the runtime-derived key the lowering pass produces in pipeline.py.""" + def mount( + self, + *, + from_: str, + to: str, + label: str | None = None, + cache: CachePolicy | None = None, + image: str | None = None, + runner: str | None = None, + runner_args: dict[str, Any] | None = None, + key: str | None = None, + strict: bool = True, + ) -> Step: + """Injects an external directory into the workspace during the pipeline execution. + + Returns a new ``Step``; the receiver is unchanged (steps are immutable). + + The mount is a hint to the executor to bind-mount the file/directory at + ``from_`` onto the container's path ``to``. + Both paths must be relative to the workspace root and ``to`` + should always point to a directory inside the workspace. + + Args: + from_: Source directory relative to the workspace root. + to: Destination directory inside the step container, relative + to the workspace root. Must exist as a directory or not + exist yet; must be inside the workspace. + label: Human-facing label shown in the UI. + cache: Cache policy controlling result reuse across builds. + image: Local-mode Docker base image for this step. + runner: Executor plugin runner name. ``None`` selects the + default Docker runner. + runner_args: Plugin-specific arguments validated by the + runner's schema. + key: Manual key override for this step in the v0 IR. + strict: Aditional check which validates if the source file + exists during the function's call. Defaults to ``True``. + It's not included in the IR. + + Returns: + A new ``Step`` with this mount appended to the chain. + + Raises: + ValueError: If ``from_`` or ``to`` is an absolute path, if + ``to`` resolves to an existing file (non-directory), or + outside the workspace root. + """ + + effective_image = image or (self.image if self.action is None else None) + source_path = Path(from_) + dest_path = Path(to) + workspace = Path(".").resolve() + + if strict and not source_path.exists(): + msg = ( + "Source path does not exists." + "If you are generating it dinamically" + "declare the ``strict`` argument as ``False``" + ) + raise ValueError(msg) + if dest_path.is_absolute() or source_path.is_absolute(): + msg = "Both paths should be relative to the workspace" + raise ValueError(msg) + if dest_path.exists() and not dest_path.is_dir(): + msg = "Destination path of a mount should be a directory" + raise ValueError(msg) + if workspace == (r_dest := dest_path.resolve()) or workspace in r_dest.parents: + return Step( + action=Mount(from_=from_, to=to), + parent=self, + label=label, + cache=cache, + image=effective_image, + runner=runner, + runner_args=runner_args, + key_override=key, + ) + msg = f"Destination path of a mount should be inside the workspace, but points to {to}" + raise ValueError(msg) + def sh( self, cmd: str, @@ -107,15 +204,12 @@ def sh( # passes it down to the first emitted command step. Once the # chain has a real cmd, inheritance stops — keeps wire format # identical for normal chains. - effective_image = ( - image if image is not None else (self.image if self.cmd is None else None) - ) + effective_image = image or (self.image if self.action is None else None) return Step( - cmd=effective_cmd, + action=Command(cmd=effective_cmd, env=env), parent=self, label=label, cache=cache, - env=env, image=effective_image, runner=runner, runner_args=runner_args, @@ -135,7 +229,7 @@ def fork(self, label: str | None = None) -> Step: Returns: A new ``Step`` branching from this one. """ - return Step(cmd=None, parent=self, label=label) + return Step(parent=self, label=label) def scratch() -> Step: diff --git a/crates/hm-dsl-engine/harmont-py/harmont/keygen.py b/crates/hm-dsl-engine/harmont-py/harmont/keygen.py index f7285c33..bf3ed386 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/keygen.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/keygen.py @@ -11,8 +11,8 @@ policy_resolution branches: none -> "none" (no key emitted) - forever -> "forever-" + sha256(cmd NUL env_subset) - ttl -> "ttl-N-" + sha256(cmd NUL env_subset) N = now // duration + forever -> "forever-" + sha256(action_str NUL env_subset) + ttl -> "ttl-N-" + sha256(action_str NUL env_subset) N = now // duration on_change -> "sha-" + sha256(concat(file_hash(p) NUL for p in sorted)) compose -> "compose-" + sha256(concat(resolve(sub) or "none")) @@ -61,10 +61,15 @@ def resolve_pipeline_keys( cache = step.get("cache") if not cache or cache["policy"] == "none": continue - cmd = step.get("cmd", "") + action_str = "" + if "action" in step: + if "cmd" in step["action"]: + action_str = step["action"]["cmd"] + elif "from" in step["action"] and "to" in step["action"]: + action_str = step["action"]["from"] + step["action"]["to"] parent = parent_key_map.get(step["key"]) parent_resolved = _lookup_parent(parent, resolved) - policy_res = _resolve_policy(cache, cmd, now, base_path, env) + policy_res = _resolve_policy(cache, action_str, now, base_path, env) key = _sha256_hex( pipeline_org + NUL @@ -96,7 +101,7 @@ def _lookup_parent(parent: str | None, resolved: dict[str, str]) -> str: def _resolve_policy( policy: dict[str, Any], - cmd: str, + action_str: str, now: int, base_path: Path, env: Mapping[str, str], @@ -106,12 +111,14 @@ def _resolve_policy( return "none" if kind == "forever": env_keys = policy.get("env_keys", []) - return "forever-" + _sha256_hex(cmd + NUL + _env_subset(env_keys, env)) + return "forever-" + _sha256_hex(action_str + NUL + _env_subset(env_keys, env)) if kind == "ttl": duration = policy["duration_seconds"] bucket = now // duration env_keys = policy.get("env_keys", []) - return "ttl-" + str(bucket) + "-" + _sha256_hex(cmd + NUL + _env_subset(env_keys, env)) + return ( + "ttl-" + str(bucket) + "-" + _sha256_hex(action_str + NUL + _env_subset(env_keys, env)) + ) if kind == "on_change": resolved: list[Path] = [] for p in sorted(policy["paths"]): @@ -126,7 +133,9 @@ def _resolve_policy( if kind == "compose": subs = policy["sub_policies"] parts = [ - _resolve_policy(sub, cmd, now, base_path, env) if sub["policy"] != "none" else "none" + _resolve_policy(sub, action_str, now, base_path, env) + if sub["policy"] != "none" + else "none" for sub in subs ] return "compose-" + _sha256_hex("".join(parts)) diff --git a/crates/hm-dsl-engine/harmont-py/tests/conftest.py b/crates/hm-dsl-engine/harmont-py/tests/conftest.py index ccba7b88..8dd7d4c4 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/conftest.py +++ b/crates/hm-dsl-engine/harmont-py/tests/conftest.py @@ -8,6 +8,7 @@ from __future__ import annotations from pathlib import Path +from typing import Any import pytest @@ -17,3 +18,16 @@ @pytest.fixture(autouse=True) def _chdir_to_repo_root(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.chdir(_REPO_ROOT) + + +def cmd_from_step(step: dict[str, Any]) -> str | None: + """Extract the command string from a step dict (v0 IR format).""" + action = step.get("action") + if isinstance(action, dict): + return action.get("cmd") + return None + + +def cmds_from_graph(pipeline: dict[str, Any]) -> list[str | None]: + """Extract all command strings from a pipeline graph's nodes.""" + return [cmd_from_step(n["step"]) for n in pipeline["graph"]["nodes"]] diff --git a/crates/hm-dsl-engine/harmont-py/tests/grep b/crates/hm-dsl-engine/harmont-py/tests/grep new file mode 100644 index 00000000..2bf16e00 --- /dev/null +++ b/crates/hm-dsl-engine/harmont-py/tests/grep @@ -0,0 +1,9 @@ +============================= test session starts ============================== +platform android -- Python 3.13.13, pytest-9.1.0, pluggy-1.6.0 -- /data/data/com.termux/files/usr/bin/python3.13 +cachedir: .pytest_cache +rootdir: /data/data/com.termux/files/home/harmont-cli/crates/hm-dsl-engine/harmont-py +configfile: pyproject.toml +plugins: anyio-4.13.0, typeguard-4.5.2 +collecting ... collected 0 items + +============================ no tests ran in 0.00s ============================= diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_cmake.py b/crates/hm-dsl-engine/harmont-py/tests/test_cmake.py index 6615498c..105a064a 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_cmake.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_cmake.py @@ -8,7 +8,7 @@ def _cmds(p: dict) -> list[str]: - return [n["step"]["cmd"] for n in p["graph"]["nodes"]] + return [n["step"]["action"]["cmd"] for n in p["graph"]["nodes"]] # --------------------------------------------------------------------------- @@ -226,7 +226,7 @@ def test_vcpkg_step_has_on_change_cache_policy(self): proj = hm.cmake(path="svc", deps="vcpkg") p = hm.pipeline([proj.built]) nodes = p["graph"]["nodes"] - vcpkg_node = next(n for n in nodes if "bootstrap-vcpkg" in n["step"]["cmd"]) + vcpkg_node = next(n for n in nodes if "bootstrap-vcpkg" in n["step"]["action"]["cmd"]) assert vcpkg_node["step"]["cache"]["policy"] == "on_change" diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_e2e_fixtures.py b/crates/hm-dsl-engine/harmont-py/tests/test_e2e_fixtures.py index 2d106491..309dcb3c 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_e2e_fixtures.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_e2e_fixtures.py @@ -160,7 +160,7 @@ def test_e2e_fixture(name: str) -> None: for node in ir["graph"]["nodes"]: assert "key" in node["step"] - assert "cmd" in node["step"] + assert "action" in node["step"] assert isinstance(node["env"], dict) for src, dst, kind in ir["graph"]["edges"]: diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_elixir.py b/crates/hm-dsl-engine/harmont-py/tests/test_elixir.py index ea042abd..ad861b7a 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_elixir.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_elixir.py @@ -8,12 +8,12 @@ def _cmds(p: dict) -> list[str]: - return [n["step"]["cmd"] for n in p["graph"]["nodes"]] + return [n["step"]["action"]["cmd"] for n in p["graph"]["nodes"]] def _step_by_substring(p: dict, needle: str) -> dict: for n in p["graph"]["nodes"]: - if needle in (n["step"].get("cmd") or ""): + if needle in (n["step"].get("action", {}).get("cmd") or ""): return n["step"] msg = f"no command step containing {needle!r}" raise AssertionError(msg) @@ -55,8 +55,8 @@ def test_elixir_version_in_install_cmd(): ex = hm.elixir(elixir_version="1.18.3", otp_version="27.3.3") p = hm.pipeline([ex.compile()]) elixir_step = _step_by_substring(p, "elixir-otp") - assert "1.18.3" in elixir_step["cmd"] - assert "27" in elixir_step["cmd"] + assert "1.18.3" in elixir_step["action"]["cmd"] + assert "27" in elixir_step["action"]["cmd"] def test_elixir_invalid_version_rejected(): @@ -93,11 +93,13 @@ def test_elixir_action_labels(): def test_elixir_plt_cached_on_lock(): ex = hm.elixir() step = ex.plt() - assert "mix dialyzer --plt" in (step.cmd or "") + assert "mix dialyzer --plt" in (step.action.cmd or "") assert step.label == ":ex: plt" p = hm.pipeline([step]) plt_ir = next( - n["step"] for n in p["graph"]["nodes"] if "dialyzer --plt" in (n["step"].get("cmd") or "") + n["step"] + for n in p["graph"]["nodes"] + if "dialyzer --plt" in (n["step"].get("action", {}).get("cmd") or "") ) assert plt_ir["cache"]["policy"] == "on_change" assert "./mix.lock" in plt_ir["cache"]["paths"] @@ -123,30 +125,30 @@ def test_elixir_with_base_skips_apt(): def test_elixir_test_cover_flag(): ex = hm.elixir() step = ex.test(cover=True) - assert "--cover" in (step.cmd or "") + assert "--cover" in (step.action.cmd or "") def test_elixir_test_partitions_flag(): ex = hm.elixir() step = ex.test(partitions=4) - assert "--partitions 4" in (step.cmd or "") + assert "--partitions 4" in (step.action.cmd or "") def test_elixir_credo_no_strict(): ex = hm.elixir() step = ex.credo(strict=False) - assert "--strict" not in (step.cmd or "") - assert "mix credo" in (step.cmd or "") + assert "--strict" not in (step.action.cmd or "") + assert "mix credo" in (step.action.cmd or "") def test_elixir_release_custom_env(): ex = hm.elixir() step = ex.release(mix_env="staging") - assert "MIX_ENV=staging" in (step.cmd or "") + assert "MIX_ENV=staging" in (step.action.cmd or "") def test_elixir_mix_escape_hatch(): ex = hm.elixir() step = ex.mix("phx.digest") - assert "mix phx.digest" in (step.cmd or "") + assert "mix phx.digest" in (step.action.cmd or "") assert step.label == ":ex: phx.digest" diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_envelope.py b/crates/hm-dsl-engine/harmont-py/tests/test_envelope.py index 48fe783b..5ad941cf 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_envelope.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_envelope.py @@ -30,7 +30,7 @@ def _graph_edges(definition): def _step_cmds(definition): - return [n["step"].get("cmd") for n in _graph_nodes(definition)] + return [n["step"].get("action", {}).get("cmd") for n in _graph_nodes(definition)] def _builds_in_children(definition, parent_key): @@ -72,7 +72,7 @@ def ci() -> hm.Step: assert definition["version"] == "0" nodes = _graph_nodes(definition) assert len(nodes) == 1 - assert nodes[0]["step"]["cmd"] == "echo hi" + assert nodes[0]["step"]["action"]["cmd"] == "echo hi" assert nodes[0]["step"]["label"] == "hi" @@ -107,7 +107,7 @@ def ci() -> hm.Pipeline: out = json.loads(hm.dump_registry_json()) p = out["pipelines"][0] - cmds = sorted(n["step"]["cmd"] for n in _graph_nodes(p["definition"])) + cmds = sorted(n["step"]["action"]["cmd"] for n in _graph_nodes(p["definition"])) assert cmds == ["a", "b"] @@ -151,7 +151,7 @@ def ci(): out = json.loads(hm.dump_registry_json()) nodes = _graph_nodes(out["pipelines"][0]["definition"]) - cmds = [n["step"].get("cmd") for n in nodes] + cmds = [n["step"].get("action", {}).get("cmd") for n in nodes] assert any("go build" in (c or "") for c in cmds) @@ -175,11 +175,11 @@ def ci() -> tuple[hm.Step, ...]: out = json.loads(hm.dump_registry_json()) definition = out["pipelines"][0]["definition"] nodes = _graph_nodes(definition) - apt_nodes = [n for n in nodes if n["step"].get("cmd") == "apt-get update"] + apt_nodes = [n for n in nodes if n["step"].get("action", {}).get("cmd") == "apt-get update"] assert len(apt_nodes) == 1 # deduplicated via target memoization children = _builds_in_children(definition, apt_nodes[0]["step"]["key"]) assert len(children) == 2 - child_cmds = sorted(n["step"]["cmd"] for n in children) + child_cmds = sorted(n["step"]["action"]["cmd"] for n in children) assert child_cmds == ["cabal build", "pytest"] diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_go.py b/crates/hm-dsl-engine/harmont-py/tests/test_go.py index 5b9cdf14..4d775b9d 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_go.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_go.py @@ -8,12 +8,12 @@ def _cmds(p: dict) -> list[str]: - return [n["step"]["cmd"] for n in p["graph"]["nodes"]] + return [n["step"]["action"]["cmd"] for n in p["graph"]["nodes"]] def _step_by_substring(p: dict, needle: str) -> dict: for n in p["graph"]["nodes"]: - if needle in (n["step"].get("cmd") or ""): + if needle in (n["step"].get("action", {}).get("cmd") or ""): return n["step"] msg = f"no command step containing {needle!r}" raise AssertionError(msg) @@ -50,7 +50,7 @@ def test_go_version_in_install_cmd(): go = hm.go(path=".", version="1.23.2") p = hm.pipeline([go.build()]) install = _step_by_substring(p, "go.dev/dl/") - assert "go1.23.2" in install["cmd"] + assert "go1.23.2" in install["action"]["cmd"] def test_go_invalid_version_rejected(): diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_har_28_example.py b/crates/hm-dsl-engine/harmont-py/tests/test_har_28_example.py index 2e65fbcb..4a66e9ad 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_har_28_example.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_har_28_example.py @@ -58,13 +58,15 @@ def ci(): p = out["pipelines"][0] nodes = _graph_nodes(p["definition"]) - cmds = [n["step"].get("cmd") for n in nodes] + cmds = [n["step"].get("action", {}).get("cmd") for n in nodes] assert any("pytest -v" in (c or "") for c in cmds) assert any("go build" in (c or "") for c in cmds) assert any("npm" in (c or "") for c in cmds) # apt-base used by the venv chain appears exactly once (memoized). - apt_update_nodes = [n for n in nodes if n["step"].get("cmd") == "apt-get update"] + apt_update_nodes = [ + n for n in nodes if n["step"].get("action", {}).get("cmd") == "apt-get update" + ] assert len(apt_update_nodes) == 1 @@ -75,5 +77,5 @@ def ci(): out = json.loads(hm.dump_registry_json()) nodes = _graph_nodes(out["pipelines"][0]["definition"]) - cmds = [n["step"]["cmd"] for n in nodes] + cmds = [n["step"]["action"]["cmd"] for n in nodes] assert "cd cidsl/py && pytest -v" in cmds diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_js.py b/crates/hm-dsl-engine/harmont-py/tests/test_js.py index dd7f2797..3bcd0a43 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_js.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_js.py @@ -18,23 +18,23 @@ def test_defaults_node_npm() -> None: p = js.project() assert isinstance(p, JsProject) assert p.path == "." - assert "npm ci" in p.install().cmd + assert "npm ci" in p.install().action.cmd def test_accepts_path() -> None: p = js.project(path="packages/app") assert p.path == "packages/app" - assert "packages/app" in p.install().cmd + assert "packages/app" in p.install().action.cmd def test_accepts_node_version() -> None: p = js.project(version="22") - assert "setup_22" in p.install().parent.cmd + assert "setup_22" in p.install().parent.action.cmd def test_pm_defaults_to_bun_for_bun_runtime() -> None: p = js.project(runtime="bun") - assert "bun install" in p.install().cmd + assert "bun install" in p.install().action.cmd # --------------------------------------------------------------------------- @@ -109,40 +109,40 @@ def test_rejects_deno_as_pm(runtime: str) -> None: def test_chain_node_npm() -> None: """scratch -> apt-base -> node-install -> npm-ci.""" npm_ci = js.project().install() - assert "npm ci" in npm_ci.cmd + assert "npm ci" in npm_ci.action.cmd node_install = npm_ci.parent - assert "nodejs" in node_install.cmd + assert "nodejs" in node_install.action.cmd assert node_install.cache is not None apt_base = node_install.parent - assert "apt-get" in apt_base.cmd + assert "apt-get" in apt_base.action.cmd - assert apt_base.parent.cmd is None # scratch + assert apt_base.parent.action is None # scratch def test_chain_node_pnpm() -> None: """scratch -> apt-base -> node-install -> corepack-pnpm -> pnpm-deps.""" pnpm_deps = js.project(pm="pnpm").install() - assert "pnpm install --frozen-lockfile" in pnpm_deps.cmd + assert "pnpm install --frozen-lockfile" in pnpm_deps.action.cmd corepack = pnpm_deps.parent - assert "corepack enable pnpm" in corepack.cmd + assert "corepack enable pnpm" in corepack.action.cmd - assert "nodejs" in corepack.parent.cmd + assert "nodejs" in corepack.parent.action.cmd def test_chain_node_yarn_classic() -> None: deps = js.project(pm="yarn-classic").install() - assert "yarn install --frozen-lockfile" in deps.cmd - assert "corepack enable" in deps.parent.cmd - assert "nodejs" in deps.parent.parent.cmd + assert "yarn install --frozen-lockfile" in deps.action.cmd + assert "corepack enable" in deps.parent.action.cmd + assert "nodejs" in deps.parent.parent.action.cmd def test_chain_node_yarn_berry() -> None: deps = js.project(pm="yarn-berry").install() - assert "yarn install --immutable" in deps.cmd - assert "corepack enable" in deps.parent.cmd + assert "yarn install --immutable" in deps.action.cmd + assert "corepack enable" in deps.parent.action.cmd @pytest.mark.parametrize("pm", ["yarn-classic", "yarn-berry"]) @@ -154,45 +154,45 @@ def test_yarn_watches_yarn_lock(pm: str) -> None: def test_chain_bun_runtime() -> None: """scratch -> apt-base(+unzip) -> bun-install -> bun-deps.""" bun_deps = js.project(runtime="bun").install() - assert "bun install --frozen-lockfile" in bun_deps.cmd + assert "bun install --frozen-lockfile" in bun_deps.action.cmd bun_setup = bun_deps.parent - assert "bun.sh/install" in bun_setup.cmd + assert "bun.sh/install" in bun_setup.action.cmd assert bun_setup.cache is not None apt_base = bun_setup.parent - assert "apt-get" in apt_base.cmd - assert "unzip" in apt_base.cmd - assert apt_base.parent.cmd is None + assert "apt-get" in apt_base.action.cmd + assert "unzip" in apt_base.action.cmd + assert apt_base.parent.action is None def test_chain_node_bun_as_pm() -> None: """scratch -> apt-base(+unzip) -> node-install -> bun-pm -> bun-deps.""" bun_deps = js.project(runtime="node", pm="bun").install() - assert "bun install --frozen-lockfile" in bun_deps.cmd + assert "bun install --frozen-lockfile" in bun_deps.action.cmd bun_pm = bun_deps.parent - assert "bun.sh/install" in bun_pm.cmd + assert "bun.sh/install" in bun_pm.action.cmd node_install = bun_pm.parent - assert "nodejs" in node_install.cmd + assert "nodejs" in node_install.action.cmd apt_base = node_install.parent - assert "unzip" in apt_base.cmd + assert "unzip" in apt_base.action.cmd def test_chain_deno() -> None: """scratch -> apt-base(+unzip) -> deno-install -> deno-deps.""" deno_deps = js.project(runtime="deno").install() - assert "deno install" in deno_deps.cmd + assert "deno install" in deno_deps.action.cmd deno_setup = deno_deps.parent - assert "deno.land/install.sh" in deno_setup.cmd + assert "deno.land/install.sh" in deno_setup.action.cmd assert deno_setup.cache is not None apt_base = deno_setup.parent - assert "unzip" in apt_base.cmd - assert apt_base.parent.cmd is None + assert "unzip" in apt_base.action.cmd + assert apt_base.parent.action is None # --------------------------------------------------------------------------- @@ -218,24 +218,24 @@ def test_accepts_custom_image() -> None: def test_run_uses_npm_run() -> None: - assert "npm run typecheck" in js.project().run("typecheck").cmd + assert "npm run typecheck" in js.project().run("typecheck").action.cmd def test_run_uses_pnpm_run() -> None: - assert "pnpm run typecheck" in js.project(pm="pnpm").run("typecheck").cmd + assert "pnpm run typecheck" in js.project(pm="pnpm").run("typecheck").action.cmd @pytest.mark.parametrize("pm", ["yarn-classic", "yarn-berry"]) def test_run_uses_yarn_run(pm: str) -> None: - assert "yarn run test" in js.project(pm=pm).run("test").cmd + assert "yarn run test" in js.project(pm=pm).run("test").action.cmd def test_run_uses_bun_run() -> None: - assert "bun run typecheck" in js.project(runtime="bun").run("typecheck").cmd + assert "bun run typecheck" in js.project(runtime="bun").run("typecheck").action.cmd def test_run_uses_deno_task() -> None: - assert "deno task typecheck" in js.project(runtime="deno").run("typecheck").cmd + assert "deno task typecheck" in js.project(runtime="deno").run("typecheck").action.cmd def test_actions_attach_to_install() -> None: @@ -245,7 +245,7 @@ def test_actions_attach_to_install() -> None: def test_actions_respect_path() -> None: - assert "cd packages/ui" in js.project(path="packages/ui").run("test").cmd + assert "cd packages/ui" in js.project(path="packages/ui").run("test").action.cmd def test_actions_accept_step_options() -> None: @@ -288,60 +288,60 @@ def test_detects_pnpm(self, tmp_path) -> None: (tmp_path / "package.json").write_text("{}") (tmp_path / "pnpm-lock.yaml").touch() p = js.project(path=str(tmp_path)) - assert "pnpm install --frozen-lockfile" in p.install().cmd + assert "pnpm install --frozen-lockfile" in p.install().action.cmd def test_detects_bun(self, tmp_path) -> None: (tmp_path / "package.json").write_text("{}") (tmp_path / "bun.lock").touch() p = js.project(path=str(tmp_path)) - assert "bun install --frozen-lockfile" in p.install().cmd - assert "bun.sh/install" in p.install().parent.cmd + assert "bun install --frozen-lockfile" in p.install().action.cmd + assert "bun.sh/install" in p.install().parent.action.cmd def test_detects_bun_from_engines(self, tmp_path) -> None: (tmp_path / "package.json").write_text(json.dumps({"engines": {"bun": ">=1.0"}})) p = js.project(path=str(tmp_path)) - assert "bun install --frozen-lockfile" in p.install().cmd + assert "bun install --frozen-lockfile" in p.install().action.cmd def test_detects_deno(self, tmp_path) -> None: (tmp_path / "package.json").write_text("{}") (tmp_path / "deno.lock").touch() p = js.project(path=str(tmp_path)) - assert "deno install" in p.install().cmd + assert "deno install" in p.install().action.cmd def test_detects_yarn_berry(self, tmp_path) -> None: (tmp_path / "package.json").write_text(json.dumps({"packageManager": "yarn@4.5.0"})) (tmp_path / "yarn.lock").touch() p = js.project(path=str(tmp_path)) - assert "yarn install --immutable" in p.install().cmd + assert "yarn install --immutable" in p.install().action.cmd def test_detects_yarn_classic(self, tmp_path) -> None: (tmp_path / "package.json").write_text("{}") (tmp_path / "yarn.lock").touch() p = js.project(path=str(tmp_path)) - assert "yarn install --frozen-lockfile" in p.install().cmd + assert "yarn install --frozen-lockfile" in p.install().action.cmd def test_explicit_opts_skip_detection(self, tmp_path) -> None: (tmp_path / "package.json").write_text("{}") (tmp_path / "bun.lock").touch() p = js.project(path=str(tmp_path), pm="npm", runtime="node") - assert "npm ci" in p.install().cmd + assert "npm ci" in p.install().action.cmd def test_defaults_when_no_signals(self, tmp_path) -> None: (tmp_path / "package.json").write_text("{}") p = js.project(path=str(tmp_path)) - assert "npm ci" in p.install().cmd + assert "npm ci" in p.install().action.cmd def test_skips_detection_when_runtime_set(self, tmp_path) -> None: (tmp_path / "package.json").write_text("{}") (tmp_path / "pnpm-lock.yaml").touch() p = js.project(path=str(tmp_path), runtime="node") - assert "npm ci" in p.install().cmd + assert "npm ci" in p.install().action.cmd def test_skips_detection_when_pm_set(self, tmp_path) -> None: (tmp_path / "package.json").write_text("{}") (tmp_path / "bun.lock").touch() p = js.project(path=str(tmp_path), pm="pnpm") - assert "pnpm install --frozen-lockfile" in p.install().cmd + assert "pnpm install --frozen-lockfile" in p.install().action.cmd # --------------------------------------------------------------------------- @@ -355,27 +355,27 @@ def test_pnpm_command_includes_version(self, tmp_path) -> None: (tmp_path / "pnpm-lock.yaml").touch() deps = js.project(path=str(tmp_path)).install() corepack = deps.parent - assert corepack.cmd == "corepack enable pnpm && corepack install -g pnpm@10.33.0" + assert corepack.action.cmd == "corepack enable pnpm && corepack install -g pnpm@10.33.0" def test_yarn_berry_command_includes_version(self, tmp_path) -> None: (tmp_path / "package.json").write_text(json.dumps({"packageManager": "yarn@4.5.0"})) (tmp_path / "yarn.lock").touch() deps = js.project(path=str(tmp_path)).install() corepack = deps.parent - assert corepack.cmd == "corepack enable yarn && corepack install -g yarn@4.5.0" + assert corepack.action.cmd == "corepack enable yarn && corepack install -g yarn@4.5.0" def test_command_has_no_version_when_field_absent(self, tmp_path) -> None: (tmp_path / "package.json").write_text("{}") (tmp_path / "pnpm-lock.yaml").touch() deps = js.project(path=str(tmp_path)).install() corepack = deps.parent - assert corepack.cmd == "corepack enable pnpm" + assert corepack.action.cmd == "corepack enable pnpm" def test_explicit_pm_omits_version(self) -> None: # An explicit pm option skips detection entirely, so no pin is applied. deps = js.project(pm="pnpm").install() corepack = deps.parent - assert corepack.cmd == "corepack enable pnpm" + assert corepack.action.cmd == "corepack enable pnpm" def test_cache_watches_package_json_when_pinned(self, tmp_path) -> None: (tmp_path / "package.json").write_text(json.dumps({"packageManager": "pnpm@10.33.0"})) diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_json_emit.py b/crates/hm-dsl-engine/harmont-py/tests/test_json_emit.py index 08167f99..c77d4470 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_json_emit.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_json_emit.py @@ -69,7 +69,7 @@ def test_minimal_command(): step = _nodes(out)[0]["step"] assert step["key"] == "hello" assert step["label"] == "hello" - assert step["cmd"] == "echo hi" + assert step["action"]["cmd"] == "echo hi" # No "type" or "builds_in" field on step dicts. assert "type" not in step assert "builds_in" not in step diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_keygen.py b/crates/hm-dsl-engine/harmont-py/tests/test_keygen.py index 38a30e92..cebfc9a6 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_keygen.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_keygen.py @@ -34,7 +34,7 @@ def test_none_policy_emits_no_key(): graph = _make_graph( [ { - "step": {"key": "a", "cmd": "echo", "cache": {"policy": "none"}}, + "step": {"key": "a", "action": {"cmd": "echo"}, "cache": {"policy": "none"}}, "env": {}, }, ] @@ -56,7 +56,7 @@ def test_forever_policy_key_matches_scheme_formula(): { "step": { "key": "a", - "cmd": "echo hi", + "action": {"cmd": "echo hi"}, "cache": {"policy": "forever", "env_keys": []}, }, "env": {}, @@ -85,7 +85,7 @@ def test_ttl_policy_key_includes_bucket(): { "step": { "key": "a", - "cmd": "x", + "action": {"cmd": "x"}, "cache": {"policy": "ttl", "duration_seconds": 3600, "env_keys": []}, }, "env": {}, @@ -117,7 +117,7 @@ def test_on_change_reads_file_contents(): { "step": { "key": "a", - "cmd": "make", + "action": {"cmd": "make"}, "cache": {"policy": "on_change", "paths": ["file.txt"]}, }, "env": {}, @@ -158,7 +158,7 @@ def test_on_change_handles_directory_paths(): { "step": { "key": "s", - "cmd": "make", + "action": {"cmd": "make"}, "cache": {"policy": "on_change", "paths": ["dir/"]}, }, "env": {}, @@ -181,7 +181,7 @@ def test_on_change_handles_directory_paths(): { "step": { "key": "s", - "cmd": "make", + "action": {"cmd": "make"}, "cache": {"policy": "on_change", "paths": ["dir/"]}, }, "env": {}, @@ -205,7 +205,7 @@ def test_on_change_handles_directory_paths(): { "step": { "key": "s", - "cmd": "make", + "action": {"cmd": "make"}, "cache": {"policy": "on_change", "paths": ["dir/"]}, }, "env": {}, @@ -230,7 +230,7 @@ def test_on_change_missing_path_skipped(): { "step": { "key": "s", - "cmd": "make", + "action": {"cmd": "make"}, "cache": {"policy": "on_change", "paths": ["nope/"]}, }, "env": {}, @@ -254,7 +254,7 @@ def test_env_keys_are_sorted_and_picked_up(): { "step": { "key": "a", - "cmd": "echo", + "action": {"cmd": "echo"}, "cache": {"policy": "forever", "env_keys": ["BAR", "FOO"]}, }, "env": {}, @@ -284,7 +284,7 @@ def test_parent_key_chains_through_resolved_cache_keys(): { "step": { "key": "a", - "cmd": "x", + "action": {"cmd": "x"}, "cache": {"policy": "forever", "env_keys": []}, }, "env": {}, @@ -292,7 +292,7 @@ def test_parent_key_chains_through_resolved_cache_keys(): { "step": { "key": "b", - "cmd": "y", + "action": {"cmd": "y"}, "cache": {"policy": "forever", "env_keys": []}, }, "env": {}, @@ -323,7 +323,7 @@ def test_compose_concatenates_subpolicies(): { "step": { "key": "a", - "cmd": "z", + "action": {"cmd": "z"}, "cache": { "policy": "compose", "sub_policies": [ @@ -359,13 +359,13 @@ def test_parent_without_cache_is_planerror(): graph = _make_graph( [ { - "step": {"key": "a", "cmd": "x"}, + "step": {"key": "a", "action": {"cmd": "x"}}, "env": {}, }, { "step": { "key": "b", - "cmd": "y", + "action": {"cmd": "y"}, "cache": {"policy": "forever", "env_keys": []}, }, "env": {}, @@ -395,7 +395,7 @@ def test_golden_hash_cross_sdk_reference_pipeline(): { "step": { "key": "build", - "cmd": "make build", + "action": {"cmd": "make build"}, "cache": {"policy": "forever", "env_keys": []}, }, "env": {}, @@ -428,7 +428,7 @@ def test_golden_hash_cross_sdk_chained_pipeline(): { "step": { "key": "setup", - "cmd": "apt-get update && apt-get install -y gcc", + "action": {"cmd": "apt-get update && apt-get install -y gcc"}, "cache": {"policy": "forever", "env_keys": []}, }, "env": {}, @@ -436,7 +436,7 @@ def test_golden_hash_cross_sdk_chained_pipeline(): { "step": { "key": "compile", - "cmd": "gcc -o main main.c", + "action": {"cmd": "gcc -o main main.c"}, "cache": {"policy": "forever", "env_keys": []}, }, "env": {}, diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_keys.py b/crates/hm-dsl-engine/harmont-py/tests/test_keys.py index 1007d265..777d5e36 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_keys.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_keys.py @@ -3,7 +3,7 @@ from __future__ import annotations from harmont._keys import hash_key, resolve_keys, slugify_label -from harmont._step import scratch +from harmont._step import Command, scratch def test_slugify_strips_emoji_shortcodes(): @@ -50,10 +50,10 @@ def test_hash_key_is_deterministic_12_hex_chars(): def test_hash_key_changes_with_inputs(): - a = hash_key("p", "make", 0) - b = hash_key("p", "make", 1) - c = hash_key("p", "test", 0) - d = hash_key("q", "make", 0) + a = hash_key("p", Command(cmd="make"), 0) + b = hash_key("p", Command(cmd="make"), 1) + c = hash_key("p", Command(cmd="test"), 0) + d = hash_key("q", Command(cmd="make"), 0) assert len({a, b, c, d}) == 4 diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_pipeline_fixtures.py b/crates/hm-dsl-engine/harmont-py/tests/test_pipeline_fixtures.py index 037827b2..3aa9b8e1 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_pipeline_fixtures.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_pipeline_fixtures.py @@ -31,7 +31,7 @@ def ci() -> hm.Step: out = json.loads(hm.dump_registry_json()) nodes = _graph_nodes(out["pipelines"][0]["definition"]) - assert any(n["step"].get("cmd") == "echo hi" for n in nodes) + assert any(n["step"].get("action", {}).get("cmd") == "echo hi" for n in nodes) def test_pipeline_receives_target_as_param(): @@ -45,7 +45,7 @@ def ci(apt_base: hm.Target[hm.Step]) -> hm.Step: out = json.loads(hm.dump_registry_json()) nodes = _graph_nodes(out["pipelines"][0]["definition"]) - cmds = [n["step"].get("cmd") for n in nodes] + cmds = [n["step"].get("action", {}).get("cmd") for n in nodes] assert "apt-get update" in cmds assert "smoke" in cmds @@ -72,9 +72,9 @@ def ci( out = json.loads(hm.dump_registry_json()) nodes = _graph_nodes(out["pipelines"][0]["definition"]) - apt = [n for n in nodes if n["step"].get("cmd") == "apt-get update"] + apt = [n for n in nodes if n["step"].get("action", {}).get("cmd") == "apt-get update"] assert len(apt) == 1 # apt_base deduped via target memoization - cmds = sorted(n["step"].get("cmd") for n in nodes) + cmds = sorted(n["step"].get("action", {}).get("cmd") for n in nodes) assert "cabal build" in cmds assert "pytest" in cmds diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_pipeline_lowering.py b/crates/hm-dsl-engine/harmont-py/tests/test_pipeline_lowering.py index a02a55ad..c39b3688 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_pipeline_lowering.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_pipeline_lowering.py @@ -118,7 +118,7 @@ def test_command_omits_optional_fields_when_unset(): step = graph["nodes"][0]["step"] # Required fields present. assert "key" in step - assert "cmd" in step + assert "action" in step # No "type" or "builds_in" fields in the new format. assert "type" not in step assert "builds_in" not in step diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_py_uv.py b/crates/hm-dsl-engine/harmont-py/tests/test_py_uv.py index 919c99fd..6bf980c9 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_py_uv.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_py_uv.py @@ -9,12 +9,12 @@ def _cmds(p: dict) -> list[str]: - return [n["step"]["cmd"] for n in p["graph"]["nodes"]] + return [n["step"]["action"]["cmd"] for n in p["graph"]["nodes"]] def _step_by_substring(p: dict, needle: str) -> dict: for n in p["graph"]["nodes"]: - if needle in (n["step"].get("cmd") or ""): + if needle in (n["step"].get("action", {}).get("cmd") or ""): return n["step"] msg = f"no command step containing {needle!r}" raise AssertionError(msg) @@ -82,17 +82,17 @@ def test_label_override(self): def test_typecheck_paths_string(self): proj = hm.py.uv(path="myapp") s = proj.typecheck(paths="src") - assert "uv run ty check src" in s.cmd + assert "uv run ty check src" in s.action.cmd def test_typecheck_paths_list(self): proj = hm.py.uv(path="myapp") s = proj.typecheck(paths=["src", "tests"]) - assert "uv run ty check src tests" in s.cmd + assert "uv run ty check src tests" in s.action.cmd def test_typecheck_paths_default(self): proj = hm.py.uv(path="myapp") s = proj.typecheck() - assert "uv run ty check ." in s.cmd + assert "uv run ty check ." in s.action.cmd def test_cache_forwarded(self): proj = hm.py.uv(path=".") @@ -166,7 +166,7 @@ def test_pinned_version(self): proj = hm.py.uv(path=".", version="0.4.18") p = hm.pipeline([proj.test()]) install = _step_by_substring(p, "astral.sh/uv/install.sh") - assert "UV_VERSION=0.4.18" in install["cmd"] + assert "UV_VERSION=0.4.18" in install["action"]["cmd"] def test_invalid_version_rejected(self): with pytest.raises(ValueError, match="invalid version"): diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_python.py b/crates/hm-dsl-engine/harmont-py/tests/test_python.py index cace93a0..f8544de4 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_python.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_python.py @@ -9,12 +9,12 @@ def _cmds(p: dict) -> list[str]: - return [n["step"]["cmd"] for n in p["graph"]["nodes"]] + return [n["step"]["action"]["cmd"] for n in p["graph"]["nodes"]] def _step_by_substring(p: dict, needle: str) -> dict: for n in p["graph"]["nodes"]: - if needle in (n["step"].get("cmd") or ""): + if needle in (n["step"].get("action", {}).get("cmd") or ""): return n["step"] msg = f"no command step containing {needle!r}" raise AssertionError(msg) @@ -84,19 +84,19 @@ def test_python_action_labels_auto_generated(): def test_python_typecheck_paths_string(): py = hm.python(path="myapp") s = py.typecheck(paths="src") - assert "uv run ty check src" in s.cmd + assert "uv run ty check src" in s.action.cmd def test_python_typecheck_paths_list(): py = hm.python(path="myapp") s = py.typecheck(paths=["src", "tests"]) - assert "uv run ty check src tests" in s.cmd + assert "uv run ty check src tests" in s.action.cmd def test_python_typecheck_paths_default(): py = hm.python(path="myapp") s = py.typecheck() - assert "uv run ty check ." in s.cmd + assert "uv run ty check ." in s.action.cmd def test_python_action_label_override(): @@ -142,7 +142,7 @@ def test_python_uv_version_in_install_cmd(): py = hm.python(path=".", uv_version="0.4.18") p = hm.pipeline([py.test()]) install = _step_by_substring(p, "astral.sh/uv/install.sh") - assert "UV_VERSION=0.4.18" in install["cmd"] + assert "UV_VERSION=0.4.18" in install["action"]["cmd"] def test_python_invalid_uv_version_rejected(): diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_rust.py b/crates/hm-dsl-engine/harmont-py/tests/test_rust.py index 819d32e3..f2412d8b 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_rust.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_rust.py @@ -13,12 +13,12 @@ def _cmds(p: dict) -> list[str]: - return [n["step"]["cmd"] for n in p["graph"]["nodes"]] + return [n["step"]["action"]["cmd"] for n in p["graph"]["nodes"]] def _step_by_substring(p: dict, needle: str) -> dict: for n in p["graph"]["nodes"]: - if needle in (n["step"].get("cmd") or ""): + if needle in (n["step"].get("action", {}).get("cmd") or ""): return n["step"] msg = f"no command step containing {needle!r}" raise AssertionError(msg) @@ -48,12 +48,12 @@ def test_actions_share_install_step(self): def test_build_release(self): tc = hm.rust.toolchain(path=".") s = tc.build(release=True) - assert "cargo build --release" in s.cmd + assert "cargo build --release" in s.action.cmd def test_test_release(self): tc = hm.rust.toolchain(path=".") s = tc.test(release=True) - assert "cargo test --release" in s.cmd + assert "cargo test --release" in s.action.cmd def test_rustup_cache_forever(self): tc = hm.rust.toolchain(path="cli") @@ -65,20 +65,20 @@ def test_default_components(self): tc = hm.rust.toolchain(path=".") p = hm.pipeline([tc.build()]) rustup = _step_by_substring(p, "sh.rustup.rs") - assert "--component clippy,rustfmt" in rustup["cmd"] + assert "--component clippy,rustfmt" in rustup["action"]["cmd"] def test_components_override(self): tc = hm.rust.toolchain(path=".", components=("clippy",)) p = hm.pipeline([tc.build()]) rustup = _step_by_substring(p, "sh.rustup.rs") - assert "--component clippy" in rustup["cmd"] - assert "rustfmt" not in rustup["cmd"] + assert "--component clippy" in rustup["action"]["cmd"] + assert "rustfmt" not in rustup["action"]["cmd"] def test_version_in_rustup_cmd(self): tc = hm.rust.toolchain(path=".", version="1.81.0") p = hm.pipeline([tc.build()]) rustup = _step_by_substring(p, "sh.rustup.rs") - assert "--default-toolchain 1.81.0" in rustup["cmd"] + assert "--default-toolchain 1.81.0" in rustup["action"]["cmd"] def test_invalid_version_rejected(self): with pytest.raises(ValueError, match="version"): @@ -131,8 +131,8 @@ def test_with_base_skips_apt(self): def test_warmup_returns_step(self): tc = hm.rust.toolchain(path="cli") w = tc.warmup() - assert w.cmd is not None - assert "cargo build --workspace --tests --locked" in w.cmd + assert w.action.cmd is not None + assert "cargo build --workspace --tests --locked" in w.action.cmd def test_warmup_chains_from_installed(self): tc = hm.rust.toolchain(path="cli") @@ -164,34 +164,36 @@ def test_warmup_in_pipeline(self): def test_build_locked_by_default(self): tc = hm.rust.toolchain(path=".") - assert tc.build().cmd.endswith("cargo build --locked") + assert tc.build().action.cmd.endswith("cargo build --locked") def test_build_unlocked(self): tc = hm.rust.toolchain(path=".") - assert tc.build(locked=False).cmd.endswith("cargo build") + assert tc.build(locked=False).action.cmd.endswith("cargo build") def test_build_features(self): tc = hm.rust.toolchain(path=".") s = tc.build(features=("a", "b"), release=True) - assert "cargo build --features a,b --release --locked" in s.cmd + assert "cargo build --features a,b --release --locked" in s.action.cmd def test_build_packages(self): tc = hm.rust.toolchain(path=".") s = tc.build(packages=("core", "cli")) - assert "cargo build -p core -p cli --locked" in s.cmd + assert "cargo build -p core -p cli --locked" in s.action.cmd def test_test_all_features(self): tc = hm.rust.toolchain(path=".") - assert "cargo test --all-features --locked" in tc.test(all_features=True).cmd + assert "cargo test --all-features --locked" in tc.test(all_features=True).action.cmd def test_test_nextest_switches_runner(self): tc = hm.rust.toolchain(path=".") s = tc.test(nextest=True, workspace=True) - assert "cargo nextest run --workspace --locked" in s.cmd + assert "cargo nextest run --workspace --locked" in s.action.cmd def test_doctest_appends_doc(self): tc = hm.rust.toolchain(path=".") - assert tc.doctest(workspace=True).cmd.endswith("cargo test --workspace --locked --doc") + assert tc.doctest(workspace=True).action.cmd.endswith( + "cargo test --workspace --locked --doc" + ) def test_doctest_default_label(self): tc = hm.rust.toolchain(path=".") @@ -199,62 +201,62 @@ def test_doctest_default_label(self): def test_clippy_all_targets_locked_deny(self): tc = hm.rust.toolchain(path=".") - assert "cargo clippy --all-targets --locked -- -D warnings" in tc.clippy().cmd + assert "cargo clippy --all-targets --locked -- -D warnings" in tc.clippy().action.cmd def test_clippy_extra_lints(self): tc = hm.rust.toolchain(path=".") s = tc.clippy(extra_lints=("-W clippy::pedantic",)) - assert "-- -D warnings -W clippy::pedantic" in s.cmd + assert "-- -D warnings -W clippy::pedantic" in s.action.cmd def test_clippy_no_deny(self): tc = hm.rust.toolchain(path=".") - assert " -- " not in tc.clippy(deny_warnings=False).cmd + assert " -- " not in tc.clippy(deny_warnings=False).action.cmd def test_fmt_all_check_default(self): tc = hm.rust.toolchain(path=".") - assert tc.fmt().cmd.endswith("cargo fmt --all --check") + assert tc.fmt().action.cmd.endswith("cargo fmt --all --check") def test_doc_sets_rustdocflags_env(self): tc = hm.rust.toolchain(path=".") s = tc.doc() - assert "cargo doc --no-deps --locked" in s.cmd - assert s.env == {"RUSTDOCFLAGS": "-D warnings"} + assert "cargo doc --no-deps --locked" in s.action.cmd + assert s.action.env == {"RUSTDOCFLAGS": "-D warnings"} def test_doc_respects_user_rustdocflags(self): tc = hm.rust.toolchain(path=".") s = tc.doc(env={"RUSTDOCFLAGS": "-D rustdoc::all"}) - assert s.env == {"RUSTDOCFLAGS": "-D rustdoc::all"} + assert s.action.env == {"RUSTDOCFLAGS": "-D rustdoc::all"} def test_doc_no_deny(self): tc = hm.rust.toolchain(path=".") - assert tc.doc(deny_warnings=False).env is None + assert tc.doc(deny_warnings=False).action.env is None def test_test_all_targets(self): tc = hm.rust.toolchain(path=".") s = tc.test(all_targets=True, workspace=True) - assert "cargo test --workspace --all-targets --locked" in s.cmd + assert "cargo test --workspace --all-targets --locked" in s.action.cmd def test_doctest_target(self): tc = hm.rust.toolchain(path=".") s = tc.doctest(target="wasm32-unknown-unknown") - assert s.cmd.endswith("cargo test --target wasm32-unknown-unknown --locked --doc") - assert "rustup target add wasm32-unknown-unknown && cargo test" in s.cmd + assert s.action.cmd.endswith("cargo test --target wasm32-unknown-unknown --locked --doc") + assert "rustup target add wasm32-unknown-unknown && cargo test" in s.action.cmd def test_test_nextest_target_auto_installs(self): tc = hm.rust.toolchain(path=".") s = tc.test(nextest=True, target="wasm32-unknown-unknown") - assert "rustup target add wasm32-unknown-unknown && cargo nextest run" in s.cmd + assert "rustup target add wasm32-unknown-unknown && cargo nextest run" in s.action.cmd def test_clippy_extra_lints_without_deny(self): tc = hm.rust.toolchain(path=".") s = tc.clippy(deny_warnings=False, extra_lints=("-W clippy::pedantic",)) - assert s.cmd.rstrip().endswith("-- -W clippy::pedantic") - assert "-D warnings" not in s.cmd + assert s.action.cmd.rstrip().endswith("-- -W clippy::pedantic") + assert "-D warnings" not in s.action.cmd def test_feature_powerset_default(self): tc = hm.rust.toolchain(path=".") s = tc.feature_powerset() - assert "cargo hack check --feature-powerset --depth 2 --no-dev-deps" in s.cmd + assert "cargo hack check --feature-powerset --depth 2 --no-dev-deps" in s.action.cmd def test_feature_powerset_installs_cargo_hack(self): tc = hm.rust.toolchain(path="cli") @@ -266,8 +268,8 @@ def test_feature_powerset_installs_cargo_hack(self): def test_feature_powerset_each_feature(self): tc = hm.rust.toolchain(path=".") s = tc.feature_powerset(each_feature=True) - assert "--each-feature" in s.cmd - assert "--feature-powerset" not in s.cmd + assert "--each-feature" in s.action.cmd + assert "--feature-powerset" not in s.action.cmd def test_feature_powerset_skip_and_keep_going(self): tc = hm.rust.toolchain(path=".") @@ -276,7 +278,7 @@ def test_feature_powerset_skip_and_keep_going(self): "cargo hack test --feature-powerset --depth 2" " --no-dev-deps --skip __tls,http3 --keep-going" ) - assert expected in s.cmd + assert expected in s.action.cmd def test_feature_powerset_label(self): tc = hm.rust.toolchain(path=".") @@ -285,40 +287,40 @@ def test_feature_powerset_label(self): def test_build_target_auto_installs(self): tc = hm.rust.toolchain(path=".") s = tc.build(target="wasm32-unknown-unknown") - assert s.cmd.startswith( + assert s.action.cmd.startswith( ". $HOME/.cargo/env && cd . && rustup target add wasm32-unknown-unknown && cargo build" ) - assert "--target wasm32-unknown-unknown" in s.cmd + assert "--target wasm32-unknown-unknown" in s.action.cmd def test_build_target_add_opt_out(self): tc = hm.rust.toolchain(path=".") s = tc.build(target="wasm32-unknown-unknown", add_target=False) - assert "rustup target add" not in s.cmd - assert "--target wasm32-unknown-unknown" in s.cmd + assert "rustup target add" not in s.action.cmd + assert "--target wasm32-unknown-unknown" in s.action.cmd def test_clippy_target_auto_installs(self): tc = hm.rust.toolchain(path=".") s = tc.clippy(target="wasm32-unknown-unknown") - assert "rustup target add wasm32-unknown-unknown && cargo clippy" in s.cmd + assert "rustup target add wasm32-unknown-unknown && cargo clippy" in s.action.cmd def test_test_target_auto_installs(self): tc = hm.rust.toolchain(path=".") s = tc.test(target="wasm32-unknown-unknown") - assert "rustup target add wasm32-unknown-unknown && cargo test" in s.cmd + assert "rustup target add wasm32-unknown-unknown && cargo test" in s.action.cmd def test_doc_target_auto_installs(self): tc = hm.rust.toolchain(path=".") s = tc.doc(target="wasm32-unknown-unknown") - assert "rustup target add wasm32-unknown-unknown && cargo doc" in s.cmd + assert "rustup target add wasm32-unknown-unknown && cargo doc" in s.action.cmd def test_no_target_no_rustup_add(self): tc = hm.rust.toolchain(path=".") - assert "rustup target add" not in tc.build().cmd + assert "rustup target add" not in tc.build().action.cmd def test_target_value_quoted_in_rustup_add(self): tc = hm.rust.toolchain(path=".") s = tc.build(target="x; rm -rf /") - assert "rustup target add 'x; rm -rf /' &&" in s.cmd + assert "rustup target add 'x; rm -rf /' &&" in s.action.cmd # --- RustProject (hm.rust.project) --- @@ -327,10 +329,10 @@ def test_target_value_quoted_in_rustup_add(self): class TestRustProject: def test_project_has_all_methods(self): proj = hm.rust.project(path="cli") - assert proj.warmup.cmd is not None - assert proj.test().cmd is not None - assert proj.clippy().cmd is not None - assert proj.fmt().cmd is not None + assert proj.warmup.action.cmd is not None + assert proj.test().action.cmd is not None + assert proj.clippy().action.cmd is not None + assert proj.fmt().action.cmd is not None def test_empty_path_throws_error_is_caught(self): proj = hm.rust.project(path="") @@ -381,32 +383,37 @@ def test_warmup_cache_override(self): def test_test_command(self): proj = hm.rust.project(path="cli") - assert "cargo test --workspace --locked" in proj.test().cmd + assert "cargo test --workspace --locked" in proj.test().action.cmd def test_test_flags(self): proj = hm.rust.project(path=".") step = proj.test(flags=("--lib", "--no-fail-fast")) - assert "cargo test --workspace --locked --lib --no-fail-fast" in step.cmd + assert "cargo test --workspace --locked --lib --no-fail-fast" in step.action.cmd def test_clippy_command(self): proj = hm.rust.project(path="cli") assert ( - "cargo clippy --workspace --all-targets --locked -- -D warnings" in proj.clippy().cmd + "cargo clippy --workspace --all-targets --locked -- -D warnings" + in proj.clippy().action.cmd ) def test_clippy_flags(self): proj = hm.rust.project(path=".") step = proj.clippy(flags=("--fix",)) - assert "cargo clippy --workspace --all-targets --locked --fix -- -D warnings" in step.cmd + assert ( + "cargo clippy --workspace --all-targets --locked --fix -- -D warnings" + in step.action.cmd + ) def test_fmt_command(self): proj = hm.rust.project(path="cli") - assert proj.fmt().cmd.endswith("cargo fmt --all --check") + assert proj.fmt().action.cmd.endswith("cargo fmt --all --check") def test_fmt_flags(self): proj = hm.rust.project(path=".") assert ( - "cargo fmt --all --check --config-path x" in proj.fmt(flags=("--config-path", "x")).cmd + "cargo fmt --all --check --config-path x" + in proj.fmt(flags=("--config-path", "x")).action.cmd ) def test_test_chains_off_warmup(self): @@ -456,37 +463,37 @@ def test_version_forwarded(self): proj = hm.rust.project(path=".", version="1.81.0") p = hm.pipeline([proj.test()]) rustup = _step_by_substring(p, "sh.rustup.rs") - assert "--default-toolchain 1.81.0" in rustup["cmd"] + assert "--default-toolchain 1.81.0" in rustup["action"]["cmd"] def test_test_packages(self): proj = hm.rust.project(path=".") step = proj.test(packages=("core",)) - assert "cargo test -p core --locked" in step.cmd + assert "cargo test -p core --locked" in step.action.cmd def test_test_nextest(self): proj = hm.rust.project(path=".") - assert "cargo nextest run --workspace --locked" in proj.test(nextest=True).cmd + assert "cargo nextest run --workspace --locked" in proj.test(nextest=True).action.cmd def test_build_method_exists(self): proj = hm.rust.project(path=".") - assert "cargo build --workspace --locked" in proj.build().cmd + assert "cargo build --workspace --locked" in proj.build().action.cmd assert proj.build().parent is proj.warmup def test_doctest_method(self): proj = hm.rust.project(path=".") - assert proj.doctest().cmd.endswith("cargo test --workspace --locked --doc") + assert proj.doctest().action.cmd.endswith("cargo test --workspace --locked --doc") assert proj.doctest().label == ":rust: doctest" def test_clippy_packages(self): proj = hm.rust.project(path=".") step = proj.clippy(packages=("core",)) - assert "cargo clippy -p core --all-targets --locked -- -D warnings" in step.cmd + assert "cargo clippy -p core --all-targets --locked -- -D warnings" in step.action.cmd def test_doc_method(self): proj = hm.rust.project(path=".") s = proj.doc() - assert "cargo doc --no-deps --workspace --locked" in s.cmd - assert s.env == {"RUSTDOCFLAGS": "-D warnings"} + assert "cargo doc --no-deps --workspace --locked" in s.action.cmd + assert s.action.env == {"RUSTDOCFLAGS": "-D warnings"} def test_ci_returns_test_clippy_fmt(self): proj = hm.rust.project(path=".") @@ -499,8 +506,8 @@ def test_ci_nextest_adds_doctest(self): steps = proj.ci(nextest=True) labels = [s.label for s in steps] assert labels == [":rust: test", ":rust: doctest", ":rust: clippy", ":rust: fmt"] - assert any("cargo nextest run" in s.cmd for s in steps) - assert any(s.cmd.endswith("--doc") for s in steps) + assert any("cargo nextest run" in s.action.cmd for s in steps) + assert any(s.action.cmd.endswith("--doc") for s in steps) def test_ci_doc_flag_adds_doc(self): proj = hm.rust.project(path=".") @@ -510,7 +517,7 @@ def test_ci_doc_flag_adds_doc(self): def test_doc_exclude(self): proj = hm.rust.project(path=".") s = proj.doc(exclude=("integration",)) - assert "cargo doc --no-deps --workspace --exclude integration --locked" in s.cmd + assert "cargo doc --no-deps --workspace --exclude integration --locked" in s.action.cmd def test_ci_in_pipeline(self): proj = hm.rust.project(path="cli") @@ -521,7 +528,7 @@ def test_ci_in_pipeline(self): def test_feature_powerset_delegates(self): proj = hm.rust.project(path=".") s = proj.feature_powerset(subcommand="clippy") - assert "cargo hack clippy --feature-powerset --depth 2 --no-dev-deps" in s.cmd + assert "cargo hack clippy --feature-powerset --depth 2 --no-dev-deps" in s.action.cmd def test_project_build_target_auto_installs(self): proj = hm.rust.project(path=".") @@ -529,18 +536,18 @@ def test_project_build_target_auto_installs(self): assert ( "rustup target add wasm32-unknown-unknown && " "cargo build --workspace --target wasm32-unknown-unknown --locked" - ) in s.cmd + ) in s.action.cmd def test_no_shell_injection_via_packages(): tc = hm.rust.toolchain(path=".") s = tc.build(packages=("a; touch /tmp/pwned",)) # The malicious value is single-quoted, so the shell sees one -p argument. - assert "-p 'a; touch /tmp/pwned'" in s.cmd - assert "; touch /tmp/pwned --locked" not in s.cmd + assert "-p 'a; touch /tmp/pwned'" in s.action.cmd + assert "; touch /tmp/pwned --locked" not in s.action.cmd def test_no_shell_injection_via_target(): tc = hm.rust.toolchain(path=".") s = tc.build(target="x; rm -rf /") - assert "--target 'x; rm -rf /'" in s.cmd + assert "--target 'x; rm -rf /'" in s.action.cmd diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_rust_parity.py b/crates/hm-dsl-engine/harmont-py/tests/test_rust_parity.py index e08018d2..3d545825 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_rust_parity.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_rust_parity.py @@ -13,21 +13,21 @@ def _tail(cmd: str) -> str: def test_golden_commands(): p = hm.rust.project(path=".") - assert _tail(p.test(features=("a", "b"), nextest=True).cmd) == ( + assert _tail(p.test(features=("a", "b"), nextest=True).action.cmd) == ( "cargo nextest run --workspace --features a,b --locked" ) - assert _tail(p.clippy(all_features=True).cmd) == ( + assert _tail(p.clippy(all_features=True).action.cmd) == ( "cargo clippy --workspace --all-targets --all-features --locked -- -D warnings" ) - assert _tail(p.fmt().cmd) == "cargo fmt --all --check" - assert _tail(p.doc(document_private_items=True).cmd) == ( + assert _tail(p.fmt().action.cmd) == "cargo fmt --all --check" + assert _tail(p.doc(document_private_items=True).action.cmd) == ( "cargo doc --no-deps --document-private-items --workspace --locked" ) - assert _tail(p.build(packages=("core",), target="wasm32-unknown-unknown").cmd) == ( + assert _tail(p.build(packages=("core",), target="wasm32-unknown-unknown").action.cmd) == ( "rustup target add wasm32-unknown-unknown && " "cargo build -p core --target wasm32-unknown-unknown --locked" ) assert ( - _tail(p.feature_powerset(subcommand="check", skip=("a b", "c")).cmd) + _tail(p.feature_powerset(subcommand="check", skip=("a b", "c")).action.cmd) == "cargo hack check --feature-powerset --depth 2 --no-dev-deps --skip 'a b',c" ) diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_setup.py b/crates/hm-dsl-engine/harmont-py/tests/test_setup.py index 9d835f77..d710d0bb 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_setup.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_setup.py @@ -29,7 +29,7 @@ def _render_keys_and_edges(leaf: hm.Step) -> tuple[dict, list]: doc = json.loads(hm.pipeline_to_json(hm.pipeline([leaf]))) graph = doc["graph"] keys = [n["step"]["key"] for n in graph["nodes"]] - cmds = [n["step"].get("cmd") for n in graph["nodes"]] + cmds = [n["step"].get("action", {}).get("cmd") for n in graph["nodes"]] return {"keys": keys, "cmds": cmds}, graph["edges"] diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_sh_shorthand.py b/crates/hm-dsl-engine/harmont-py/tests/test_sh_shorthand.py index 2bc439c4..5e7df0df 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_sh_shorthand.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_sh_shorthand.py @@ -10,16 +10,16 @@ def test_hm_sh_returns_step_rooted_at_scratch(): s = hm.sh("apt-get update") assert isinstance(s, hm.Step) assert s.parent is not None - assert s.parent.cmd is None + assert s.parent.action is None assert s.parent.parent is None - assert s.cmd == "apt-get update" + assert s.action.cmd == "apt-get update" def test_hm_sh_chains_with_sh(): s = hm.sh("apt-get update").sh("apt-get install -y python3") - assert s.cmd == "apt-get install -y python3" + assert s.action.cmd == "apt-get install -y python3" assert s.parent is not None - assert s.parent.cmd == "apt-get update" + assert s.parent.action.cmd == "apt-get update" def test_hm_sh_accepts_all_step_sh_kwargs(): @@ -36,7 +36,7 @@ def test_hm_sh_accepts_all_step_sh_kwargs(): ) assert s.label == "build" assert s.cache == CacheNone() - assert s.env == {"CI": "true"} + assert s.action.env == {"CI": "true"} assert s.timeout_seconds == 600 assert s.image == "alpine:3.20" assert s.key_override == "explicit" @@ -44,4 +44,4 @@ def test_hm_sh_accepts_all_step_sh_kwargs(): def test_hm_sh_cwd_kwarg(): s = hm.sh("pytest -v", cwd="cidsl/py") - assert s.cmd == "cd cidsl/py && pytest -v" + assert s.action.cmd == "cd cidsl/py && pytest -v" diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_step_chain.py b/crates/hm-dsl-engine/harmont-py/tests/test_step_chain.py index 32bdf03c..17f615a4 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_step_chain.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_step_chain.py @@ -11,10 +11,10 @@ from harmont.cache import CacheNone -def test_scratch_has_no_parent_no_cmd(): +def test_scratch_has_no_parent_no_action(): s = scratch() assert s.parent is None - assert s.cmd is None + assert s.action is None assert s.is_wait is False @@ -22,7 +22,7 @@ def test_sh_links_parent_and_sets_cmd(): parent = scratch() child = parent.sh("echo hi") assert child.parent is parent - assert child.cmd == "echo hi" + assert child.action.cmd == "echo hi" assert child.is_wait is False @@ -32,14 +32,14 @@ def test_sh_returns_new_instance_parent_unchanged(): parent.sh("b") # parent must be untouched (frozen dataclass) assert parent.parent is None - assert parent.cmd is None + assert parent.action is None def test_fork_makes_branded_passthrough(): parent = scratch().sh("install") branch = parent.fork(label="branch-a") assert branch.parent is parent - assert branch.cmd is None + assert branch.action is None assert branch.label == "branch-a" assert branch.is_wait is False @@ -68,7 +68,7 @@ def test_sh_kwargs_carried_through(): ) assert s.label == "build" assert s.cache == CacheNone() - assert s.env == {"CI": "true"} + assert s.action.env == {"CI": "true"} assert s.timeout_seconds == 600 assert s.key_override == "explicit-key" @@ -76,13 +76,13 @@ def test_sh_kwargs_carried_through(): def test_step_is_frozen(): s = scratch() with pytest.raises(FrozenInstanceError): - s.cmd = "mutated" # type: ignore[misc] + s.action = "mutated" # type: ignore[misc] -def test_wait_has_no_cmd_no_parent_and_is_wait_true(): +def test_wait_has_no_action_no_parent_and_is_wait_true(): w = wait() assert w.parent is None - assert w.cmd is None + assert w.action is None assert w.is_wait is True diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_step_mount.py b/crates/hm-dsl-engine/harmont-py/tests/test_step_mount.py new file mode 100644 index 00000000..5356edbc --- /dev/null +++ b/crates/hm-dsl-engine/harmont-py/tests/test_step_mount.py @@ -0,0 +1,208 @@ +"""Mount action tests — Step.mount() and hm.mount().""" + +from __future__ import annotations + +import pytest + +import harmont as hm +from harmont._step import Mount, scratch +from harmont.cache import CacheNone + + +def test_mount_creates_mount_action(): + s = scratch().mount(from_=".cache/data", to="./_hm_mount_1", strict=False) + assert isinstance(s.action, Mount) + assert s.action.from_ == ".cache/data" + assert s.action.to == "./_hm_mount_1" + assert not s.is_wait + + +def test_mount_after_sh_chains_properly(): + root = scratch().sh("npm ci") + s = root.mount(from_=".cache/npm", to="./_hm_mount_2", strict=False) + assert s.parent is root + assert isinstance(s.action, Mount) + + +def test_mount_root_has_scratch_parent(): + root = scratch() + s = root.mount(from_=".cache/go", to="./_hm_mount_3", strict=False) + assert s.parent is root + assert s.parent.action is None + + +def test_mount_carries_kwargs(): + s = scratch().mount( + from_=".cache/gems", + to="./_hm_mount_4", + label="ruby gems", + cache=CacheNone(), + runner="krun", + runner_args={"opt": 1}, + key="gem-mount", + strict=False, + ) + assert s.label == "ruby gems" + assert s.cache == CacheNone() + assert s.runner == "krun" + assert s.runner_args == {"opt": 1} + assert s.key_override == "gem-mount" + + +def test_mount_inherits_image_from_scratch(): + from harmont._step import Step + + root = Step(image="ubuntu-24.04") + s = root.mount(from_=".cache/data", to="./_hm_mount_5", strict=False) + assert s.image == "ubuntu-24.04" + + +def test_mount_explicit_image_wins(): + s = scratch().mount(from_=".cache/data", to="./_hm_mount_6", image="alpine:3.20", strict=False) + assert s.image == "alpine:3.20" + + +def test_mount_does_not_inherit_image_from_sh_child(): + from harmont._step import Step + + root = Step(image="ubuntu-24.04") + s = root.sh("echo a").mount(from_=".cache/data", to="./_hm_mount_7", strict=False) + assert s.image is None + + +def test_mount_absolute_path_raises(): + with pytest.raises(ValueError, match="relative to the workspace"): + scratch().mount(from_="/cache", to="./_hm_outside", strict=False) + + +def test_mount_source_path_raises_when_strict_and_doesnt_exists(): + with pytest.raises(ValueError, match="Source path does not exists"): + scratch().mount(from_="./cache", to="./_hm_outside") + + +def test_mount_to_existing_file_raises(tmp_path): + import os + + sf = tmp_path / "mount_target.txt" + sf.write_text("data") + to = str(sf.name) + + df = tmp_path / "mount_source.txt" + df.write_text("data") + from_ = str(df.name) + + old = os.getcwd() + try: + os.chdir(tmp_path) + with pytest.raises(ValueError, match="should be a directory"): + scratch().mount(from_=from_, to=to) + finally: + os.chdir(old) + + +def test_mount_outside_workspace_raises(): + with pytest.raises(ValueError, match="should be inside the workspace"): + scratch().mount(from_=".cache/data", to="../_hm_outside", strict=False) + + +def test_hm_mount_convenience(): + s = hm.mount(from_=".cache/pip", to="./_hm_mount_9", strict=False) + assert isinstance(s.action, Mount) + assert s.action.from_ == ".cache/pip" + assert s.action.to == "./_hm_mount_9" + assert isinstance(s.cache, CacheNone) + assert s.image is None + assert s.parent is not None + assert s.parent.action is None + + +def test_mount_lowers_to_json_with_correct_action_shape(): + """Mount step renders to IR JSON with the action dict containing from/to.""" + import json + from pathlib import Path + + from harmont.json_emit import pipeline_to_json + + mount_dir = ".cache/data" + dest_dir = "./_hm_mount_json" + p = hm.pipeline([scratch().mount(from_=mount_dir, to=dest_dir, strict=False)]) + out = json.loads(pipeline_to_json(p, now=0, base_path=Path(), env={})) + nodes = out["graph"]["nodes"] + assert len(nodes) == 1 + step = nodes[0]["step"] + assert step["action"]["from"] == mount_dir + assert step["action"]["to"] == dest_dir + # Step should have an image stamped (root mount step gets the default). + assert "image" in step + + +def test_mount_and_sh_chain_in_pipeline(): + """Mount followed by a command step in the same chain.""" + import json + from pathlib import Path + + from harmont.json_emit import pipeline_to_json + + s = ( + scratch() + .mount(from_=".cache/npm", to="./_hm_mount_chain", strict=False) + .sh("npm ci", label="install") + ) + p = hm.pipeline([s]) + out = json.loads(pipeline_to_json(p, now=0, base_path=Path(), env={})) + nodes = out["graph"]["nodes"] + # Both the mount and the command step should be present. + {n["step"]["key"] for n in nodes} + # Find which node is the mount and which is the sh + mount_nodes = [n for n in nodes if "from" in n["step"].get("action", {})] + sh_nodes = [n for n in nodes if "cmd" in n["step"].get("action", {})] + assert len(mount_nodes) == 1 + assert len(sh_nodes) == 1 + assert mount_nodes[0]["step"]["action"]["from"] == ".cache/npm" + assert mount_nodes[0]["step"]["action"]["to"] == "./_hm_mount_chain" + # Builds_in edge should connect mount -> sh + edges = out["graph"]["edges"] + idx_mount = nodes.index(mount_nodes[0]) + idx_sh = nodes.index(sh_nodes[0]) + assert [idx_mount, idx_sh, "builds_in"] in edges + + +def test_mount_pipeline_parallel_branches(): + """Two independent mount chains in the same pipeline.""" + import json + from pathlib import Path + + from harmont.json_emit import pipeline_to_json + + a = scratch().mount(from_=".cache/go", to="./_hm_go", strict=False) + b = scratch().mount(from_=".cache/npm", to="./_hm_npm", strict=False) + p = hm.pipeline([a, b]) + out = json.loads(pipeline_to_json(p, now=0, base_path=Path(), env={})) + nodes = out["graph"]["nodes"] + assert len(nodes) == 2 + tos = {n["step"]["action"]["to"] for n in nodes} + assert tos == {"./_hm_go", "./_hm_npm"} + + +def test_mount_absolute_path_both_sides_rejected(): + """Absolute source or destination path raises ValueError.""" + with pytest.raises(ValueError, match="relative to the workspace"): + scratch().mount(from_=".cache/data", to="/etc/passwd", strict=False) + with pytest.raises(ValueError, match="relative to the workspace"): + scratch().mount(from_="/var/cache", to="./_hm_out", strict=False) + + +def test_mount_inside_workspace_valid_path(tmp_path): + """Mount to a path that exists inside the workspace succeeds.""" + import os + + old = os.getcwd() + try: + os.chdir(tmp_path) + os.makedirs("./subdir/mount_target") + os.makedirs(".cache/data") + s = scratch().mount(from_=".cache/data", to="subdir/mount_target") + assert isinstance(s.action, Mount) + assert s.action.to == "subdir/mount_target" + finally: + os.chdir(old) diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_step_sh.py b/crates/hm-dsl-engine/harmont-py/tests/test_step_sh.py index 355ff780..b2e37f14 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_step_sh.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_step_sh.py @@ -11,7 +11,7 @@ def test_sh_links_parent_and_sets_cmd(): parent = scratch() child = parent.sh("echo hi") assert child.parent is parent - assert child.cmd == "echo hi" + assert child.action.cmd == "echo hi" assert child.is_wait is False @@ -28,19 +28,19 @@ def test_sh_carries_all_kwargs(): ) assert s.label == "build" assert s.cache == CacheNone() - assert s.env == {"CI": "true"} + assert s.action.env == {"CI": "true"} assert s.timeout_seconds == 600 assert s.key_override == "explicit-key" def test_sh_cwd_prepends_cd(): s = scratch().sh("pytest -v", cwd="cidsl/py") - assert s.cmd == "cd cidsl/py && pytest -v" + assert s.action.cmd == "cd cidsl/py && pytest -v" def test_sh_cwd_none_leaves_cmd_unchanged(): s = scratch().sh("echo hi", cwd=None) - assert s.cmd == "echo hi" + assert s.action.cmd == "echo hi" def test_sh_cwd_empty_string_is_rejected(): diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_strict_signature.py b/crates/hm-dsl-engine/harmont-py/tests/test_strict_signature.py index eaa0af81..efc1820c 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_strict_signature.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_strict_signature.py @@ -13,7 +13,7 @@ resolve_deps, validate_target_signature, ) -from harmont._step import Step +from harmont._step import Command, Step @pytest.fixture(autouse=True) @@ -24,14 +24,14 @@ def _reset(): def test_target_marker_resolves_via_registry(): - register_named_target("apt_base", lambda: Step(cmd="apt-get update")) + register_named_target("apt_base", lambda: Step(action=Command(cmd="apt-get update"))) def fn(apt_base: hm.Target[Step]) -> Step: # type: ignore[empty-body] ... kwargs = resolve_deps(fn) assert isinstance(kwargs["apt_base"], Step) - assert kwargs["apt_base"].cmd == "apt-get update" + assert kwargs["apt_base"].action.cmd == "apt-get update" def test_target_marker_missing_target_raises(): @@ -50,7 +50,7 @@ def fn(base: Annotated[Step, hm.BaseImage("ubuntu-24.04")]) -> Step: # type: ig base = kwargs["base"] assert isinstance(base, Step) assert base.parent is None - assert base.cmd is None + assert base.action is None assert base.image == "ubuntu-24.04" diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_target.py b/crates/hm-dsl-engine/harmont-py/tests/test_target.py index e445e512..f251f8e1 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_target.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_target.py @@ -26,7 +26,7 @@ def apt_base() -> hm.Step: # callable with no args, returns a Step result = apt_base() assert isinstance(result, hm.Step) - assert result.cmd == "apt-get update" + assert result.action.cmd == "apt-get update" def test_target_memoizes_within_one_render(): @@ -77,7 +77,7 @@ def api() -> hm.Step: # Both targets chained off the SAME apt-base step (memoized). assert v.parent is a.parent assert v.parent is not None - assert v.parent.cmd == "apt-get update" + assert v.parent.action.cmd == "apt-get update" def test_target_with_toolchain_return_passes_through(): diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_target_cross_module.py b/crates/hm-dsl-engine/harmont-py/tests/test_target_cross_module.py index ac5fcd90..ce10f88e 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_target_cross_module.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_target_cross_module.py @@ -42,7 +42,7 @@ def ci(py_test: hm.Target[hm.Step]) -> hm.Step: out = json.loads(hm.dump_registry_json()) nodes = out["pipelines"][0]["definition"]["graph"]["nodes"] - cmds = sorted(n["step"].get("cmd") for n in nodes) + cmds = sorted(n["step"].get("action", {}).get("cmd") for n in nodes) assert "apt-get update" in cmds assert "cd cidsl/py && pytest -v" in cmds diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_target_fixtures.py b/crates/hm-dsl-engine/harmont-py/tests/test_target_fixtures.py index 336024ec..e6c853e8 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_target_fixtures.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_target_fixtures.py @@ -24,7 +24,7 @@ def apt_base() -> hm.Step: return hm.sh("apt-get update") s = apt_base() - assert s.cmd == "apt-get update" + assert s.action.cmd == "apt-get update" def test_target_param_receives_dependency_value(): @@ -38,7 +38,7 @@ def venv(apt_base: hm.Target[hm.Step]) -> hm.Step: v = venv() assert v.parent is not None - assert v.parent.cmd == "apt-get update" + assert v.parent.action.cmd == "apt-get update" def test_multi_param_target(): @@ -59,8 +59,8 @@ def project( return (apt_base, node_install) base, node = project() - assert base.cmd == "apt-get update" - assert "curl" in node.cmd + assert base.action.cmd == "apt-get update" + assert "curl" in node.action.cmd def test_param_named_after_unregistered_target_raises(): @@ -104,7 +104,7 @@ def maybe_extra(image_tag: str = "ubuntu:24.04") -> hm.Step: return hm.sh(f"echo {image_tag}") s = maybe_extra() - assert s.cmd == "echo ubuntu:24.04" + assert s.action.cmd == "echo ubuntu:24.04" def test_memoization_still_works_with_params(): diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_target_unwrap.py b/crates/hm-dsl-engine/harmont-py/tests/test_target_unwrap.py index 2f2668e3..d800f138 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_target_unwrap.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_target_unwrap.py @@ -31,23 +31,23 @@ def test_rust_toolchain_unwraps_to_build(): tc = hm.rust.toolchain(path="cli", version="stable") leaves = as_leaves(tc) assert len(leaves) == 1 - assert "cargo build" in leaves[0].cmd + assert "cargo build" in leaves[0].action.cmd def test_rust_project_unwraps_to_test_clippy_fmt(): proj = hm.rust.project(path="cli") leaves = as_leaves(proj) assert len(leaves) == 3 - assert "cargo test" in leaves[0].cmd - assert "cargo clippy" in leaves[1].cmd - assert "cargo fmt" in leaves[2].cmd + assert "cargo test" in leaves[0].action.cmd + assert "cargo clippy" in leaves[1].action.cmd + assert "cargo fmt" in leaves[2].action.cmd def test_js_project_unwraps_to_install(): proj = hm.js.project(path="app", version="20") leaves = as_leaves(proj) assert len(leaves) == 1 - assert "npm ci" in leaves[0].cmd + assert "npm ci" in leaves[0].action.cmd def test_nested_tuple_is_flattened(): diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_timeout.py b/crates/hm-dsl-engine/harmont-py/tests/test_timeout.py index 0d9b94d4..7325835e 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_timeout.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_timeout.py @@ -9,7 +9,7 @@ def test_timeout_sets_seconds_on_step(): step = hm.timeout("30s", hm.sh("echo foo")) assert step.timeout_seconds == 30 - assert step.cmd == "echo foo" + assert step.action.cmd == "echo foo" def test_timeout_accepts_int_and_timedelta(): diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_toolchain.py b/crates/hm-dsl-engine/harmont-py/tests/test_toolchain.py index 4af81532..3afa5bc8 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_toolchain.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_toolchain.py @@ -41,11 +41,11 @@ def test_make_install_chain_default_emits_apt_then_tool(): ) apt = tool.parent assert apt is not None - assert "apt-get install -y curl" in (apt.cmd or "") + assert "apt-get install -y curl" in (apt.action.cmd or "") assert apt.label == ":lang: apt-base" assert isinstance(apt.cache, CacheTTL) assert apt.cache.duration == APT_TTL - assert tool.cmd == "install_tool.sh" + assert tool.action.cmd == "install_tool.sh" assert tool.label == ":lang: tool" assert isinstance(tool.cache, CacheOnChange) assert tool.cache.paths == ("lockfile",) @@ -63,7 +63,7 @@ def test_make_install_chain_with_base_skips_apt(): base=base, ) assert tool.parent is base - assert tool.cmd == "install.sh" + assert tool.action.cmd == "install.sh" assert tool.label == ":lang: tool" diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_toolchain_compose.py b/crates/hm-dsl-engine/harmont-py/tests/test_toolchain_compose.py index 7e5d6167..6f523817 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_toolchain_compose.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_toolchain_compose.py @@ -6,7 +6,7 @@ def _cmds(p: dict) -> list[str]: - return [n["step"]["cmd"] for n in p["graph"]["nodes"]] + return [n["step"]["action"]["cmd"] for n in p["graph"]["nodes"]] def test_stack_npm_on_spec_step(): @@ -56,7 +56,7 @@ def test_mixed_pipeline_compiles(): def _step_by_substring(p: dict, needle: str) -> dict: for n in p["graph"]["nodes"]: - if needle in (n["step"].get("cmd") or ""): + if needle in (n["step"].get("action", {}).get("cmd") or ""): return n["step"] msg = f"no command step containing {needle!r}" raise AssertionError(msg) diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_zig.py b/crates/hm-dsl-engine/harmont-py/tests/test_zig.py index 015c347f..437b436e 100644 --- a/crates/hm-dsl-engine/harmont-py/tests/test_zig.py +++ b/crates/hm-dsl-engine/harmont-py/tests/test_zig.py @@ -8,12 +8,12 @@ def _cmds(p: dict) -> list[str]: - return [n["step"]["cmd"] for n in p["graph"]["nodes"]] + return [n["step"]["action"]["cmd"] for n in p["graph"]["nodes"]] def _step_by_substring(p: dict, needle: str) -> dict: for n in p["graph"]["nodes"]: - if needle in (n["step"].get("cmd") or ""): + if needle in (n["step"].get("action", {}).get("cmd") or ""): return n["step"] raise AssertionError(needle) @@ -39,7 +39,7 @@ def test_zig_version_in_install_cmd(): z = hm.zig(path=".", version="0.14.1") p = hm.pipeline([z.build()]) install = _step_by_substring(p, "ziglang.org") - assert "0.14.1" in install["cmd"] + assert "0.14.1" in install["action"]["cmd"] def test_zig_invalid_version_rejected(): @@ -66,7 +66,7 @@ def test_zig_old_version_uses_old_url_format(): z = hm.zig(path=".", version="0.13.0") p = hm.pipeline([z.build()]) install = _step_by_substring(p, "ziglang.org") - assert "zig-linux-x86_64-0.13.0" in install["cmd"] + assert "zig-linux-x86_64-0.13.0" in install["action"]["cmd"] def test_zig_new_version_uses_new_url_format(): @@ -74,7 +74,7 @@ def test_zig_new_version_uses_new_url_format(): z = hm.zig(path=".", version="0.14.1") p = hm.pipeline([z.build()]) install = _step_by_substring(p, "ziglang.org") - assert "zig-x86_64-linux-0.14.1" in install["cmd"] + assert "zig-x86_64-linux-0.14.1" in install["action"]["cmd"] def test_zig_with_base_skips_apt(): diff --git a/crates/hm-exec/src/local/cache.rs b/crates/hm-exec/src/local/cache.rs index 3d609db7..d5f412cf 100644 --- a/crates/hm-exec/src/local/cache.rs +++ b/crates/hm-exec/src/local/cache.rs @@ -6,7 +6,7 @@ //! Cache keys are computed by `harmont.keygen` at plan time and ride //! along the JSON in `cache.key`. -use hm_plugin_protocol::CommandStep; +use hm_plugin_protocol::Step; fn sanitize_for_tag(s: &str) -> String { s.chars() @@ -25,7 +25,7 @@ fn sanitize_for_tag(s: &str) -> String { /// Returns `None` when the step has no cache, a `"none"` policy, or no /// cache key. #[must_use] -pub(crate) fn stable_cache_tag(step: &CommandStep) -> Option { +pub(crate) fn stable_cache_tag(step: &Step) -> Option { let cache = step.cache.as_ref()?; if cache.policy == "none" { return None; @@ -40,15 +40,17 @@ pub(crate) fn stable_cache_tag(step: &CommandStep) -> Option { #[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] mod tests { use super::*; - use hm_plugin_protocol::Cache; + use hm_plugin_protocol::{Cache, Step, StepAction}; - fn step(cache: Option) -> CommandStep { - CommandStep { + fn step(cache: Option) -> Step { + Step { key: "build".into(), + action: StepAction::Command { + cmd: "true".into(), + env: None, + }, label: None, - cmd: "true".into(), image: None, - env: None, timeout_seconds: None, cache, runner: None, diff --git a/crates/hm-exec/src/local/runner/vm.rs b/crates/hm-exec/src/local/runner/vm.rs index 325634fc..a57410f1 100644 --- a/crates/hm-exec/src/local/runner/vm.rs +++ b/crates/hm-exec/src/local/runner/vm.rs @@ -11,7 +11,7 @@ use std::sync::Arc; use anyhow::{Context, Result}; use hm_plugin_protocol::{ - BuildEvent, CacheDecision, ExecutorInput, SnapshotRef, StdStream, StepResult, + BuildEvent, CacheDecision, ExecutorInput, SnapshotRef, StdStream, StepAction, StepResult, }; use hm_vm::types::OutputSink; use hm_vm::{Action, CachingPolicy, HmVm, ImageSource, SnapshotId}; @@ -68,13 +68,6 @@ impl StepRunner for VmRunner { #[tracing::instrument(skip(vm, ctx), fields(step_key = %input.step.key))] async fn run_step_vm(vm: &HmVm, ctx: &StepContext, input: ExecutorInput) -> Result { - let policy = match &input.cache_lookup { - CacheDecision::Hit { tag } | CacheDecision::MissBuildAs { tag } => { - CachingPolicy::Cache { key: tag.0.clone() } - } - CacheDecision::MissNoCommit => CachingPolicy::None, - }; - let source = if let Some(ref snap) = input.parent_snapshot { ImageSource::Snapshot(SnapshotId::new(snap.0.clone())) } else { @@ -90,74 +83,84 @@ async fn run_step_vm(vm: &HmVm, ctx: &StepContext, input: ExecutorInput) -> Resu ) }; - // Inject the current workspace on every executing step, overlaying it - // onto the system state inherited from the parent snapshot (apt packages, - // installed runtimes, `node_modules`, …). Injecting only at the chain root - // is wrong: root steps such as `apt_base` are `CacheForever`, so their - // snapshots freeze the source tree captured at first build and every COW - // descendant inherits that stale tree — source edits never reach leaf - // steps. A true cache hit short-circuits inside `HmVm::execute` before - // inject runs, so this overlay only happens when a step actually executes; - // the overlay (Docker PUT-archive) adds/overwrites files without deleting - // the inherited system state. - let (inject, _temp_guard) = { - let archive_bytes = ctx - .archives - .get_bytes(input.workspace_archive_id) - .ok_or_else(|| anyhow::anyhow!("source archive not found"))?; - let dir = - extract_archive_to_tempdir(&archive_bytes).context("extracting workspace archive")?; - let path = dir.path().to_path_buf(); - (Some(path), Some(dir)) - }; - - // Baseline env for shell operation inside VMs. - let mut env: Vec<(String, String)> = vec![ - ("HOME".into(), "/root".into()), - ( - "PATH".into(), - "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into(), - ), - ]; - env.extend(input.env); - - let action = Action { - source, - cmd: input.step.cmd.clone(), - env, - working_dir: input.workdir.clone(), - timeout: None, - inject, - }; - - let sink = EventBusSink { - step_id: input.step_id, - bus: Arc::clone(&ctx.event_bus), - }; - - let result = tokio::select! { - r = vm.execute(action, policy, &sink) => r, - () = ctx.cancel.cancelled() => { - anyhow::bail!("step cancelled (build timeout or sibling failure)") + let result = match &input.step.action { + StepAction::Command { cmd, .. } => { + let policy = match &input.cache_lookup { + CacheDecision::Hit { tag } | CacheDecision::MissBuildAs { tag } => { + CachingPolicy::Cache { key: tag.0.clone() } + } + CacheDecision::MissNoCommit => CachingPolicy::None, + }; + + // Inject the current workspace on every executing step, overlaying it + // onto the system state inherited from the parent snapshot (apt packages, + // installed runtimes, `node_modules`, …). Injecting only at the chain root + // is wrong: root steps such as `apt_base` are `CacheForever`, so their + // snapshots freeze the source tree captured at first build and every COW + // descendant inherits that stale tree — source edits never reach leaf + // steps. A true cache hit short-circuits inside `HmVm::execute` before + // inject runs, so this overlay only happens when a step actually executes; + // the overlay (Docker PUT-archive) adds/overwrites files without deleting + // the inherited system state. + let (inject, _temp_guard) = { + let archive_bytes = ctx + .archives + .get_bytes(input.workspace_archive_id) + .ok_or_else(|| anyhow::anyhow!("source archive not found"))?; + let dir = extract_archive_to_tempdir(&archive_bytes) + .context("extracting workspace archive")?; + let path = dir.path().to_path_buf(); + (Some(path), Some(dir)) + }; + + // Baseline env for shell operation inside VMs. + let mut env: Vec<(String, String)> = vec![ + ("HOME".into(), "/root".into()), + ( + "PATH".into(), + "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into(), + ), + ]; + env.extend(input.env); + + let action = Action { + source, + cmd: cmd.clone(), + env, + working_dir: input.workdir.clone(), + timeout: None, + inject, + }; + + let sink = EventBusSink { + step_id: input.step_id, + bus: Arc::clone(&ctx.event_bus), + }; + + let result = tokio::select! { + r = vm.execute(action, policy, &sink) => r, + () = ctx.cancel.cancelled() => { + anyhow::bail!("step cancelled (build timeout or sibling failure)") + } + } + .context("vm execute failed")?; + + if result.cached { + ctx.event_bus.emit(BuildEvent::StepCacheHit { + step_id: input.step_id, + key: input.step.key.clone(), + tag: result + .snapshot + .as_ref() + .map_or_else(String::new, ToString::to_string), + }); + } + result } - } - .context("vm execute failed")?; - - if result.cached { - ctx.event_bus.emit(BuildEvent::StepCacheHit { - step_id: input.step_id, - key: input - .step - .cache - .as_ref() - .and_then(|c| c.key.clone()) - .unwrap_or_default(), - tag: result - .snapshot - .as_ref() - .map_or_else(String::new, ToString::to_string), - }); - } + StepAction::Mount { from, to } => { + vm.mount_into_vm(from, to, &input.workdir, &source).await? + } + }; Ok(StepResult { exit_code: result.exit_code, diff --git a/crates/hm-exec/src/local/scheduler.rs b/crates/hm-exec/src/local/scheduler.rs index 5d49f8d9..31f6cad1 100644 --- a/crates/hm-exec/src/local/scheduler.rs +++ b/crates/hm-exec/src/local/scheduler.rs @@ -39,7 +39,7 @@ use hm_plugin_protocol::{ }; use uuid::Uuid; -use hm_pipeline_ir::{EdgeKind, PipelineGraph, Transition}; +use hm_pipeline_ir::{EdgeKind, PipelineGraph, StepAction, Transition}; use crate::local::runner::{RunnerRegistry, StepContext}; use crate::local::source::build_archive_bytes; @@ -390,14 +390,28 @@ async fn execute_step( let step_wire = transition.step; let step_key = step_wire.key.clone(); let display_name = step_wire.label.clone().unwrap_or_else(|| { - let cmd = step_wire.cmd.trim(); - if cmd.chars().count() <= 40 { - cmd.to_owned() - } else { - // Truncate on a char boundary, not a byte offset: `&cmd[..39]` - // panics if byte 39 falls inside a multibyte UTF-8 sequence. - let truncated: String = cmd.chars().take(39).collect(); - format!("{truncated}…") + match &step_wire.action { + StepAction::Command { cmd, .. } => { + if cmd.chars().count() <= 40 { + cmd.to_owned() + } else { + // Truncate on a char boundary, not a byte offset: `&cmd[..39]` + // panics if byte 39 falls inside a multibyte UTF-8 sequence. + let truncated: String = cmd.chars().take(39).collect(); + format!("{truncated}…") + } + } + StepAction::Mount { from, .. } => { + if from.chars().count() <= 40 { + from.to_owned() + } else { + from.rmatches(r"/.+") + .collect::>() + .first() + .map(|x| x.split_at(0)) + .map_or_else(|| from.clone(), |(_, x)| x.to_owned()) + } + } } }); let env_map = transition.env; diff --git a/crates/hm-exec/src/request.rs b/crates/hm-exec/src/request.rs index e8a74a50..02e655fa 100644 --- a/crates/hm-exec/src/request.rs +++ b/crates/hm-exec/src/request.rs @@ -129,8 +129,8 @@ mod tests { "default_image": "ubuntu:24.04", "graph": { "nodes": [ - {"step": {"key": "a", "cmd": "echo a", "image": "ubuntu:24.04"}, "env": {}}, - {"step": {"key": "b", "cmd": "echo b"}, "env": {}} + {"step": {"key": "a", "action": {"cmd": "echo a"}, "image": "ubuntu:24.04"}, "env": {}}, + {"step": {"key": "b", "action": {"cmd": "echo b"}}, "env": {}} ], "node_holes": [], "edge_property": "directed", @@ -154,8 +154,8 @@ mod tests { "version": "0", "graph": { "nodes": [ - {"step": {"key": "a", "cmd": "echo a", "image": "ubuntu:24.04"}, "env": {}}, - {"step": {"key": "b", "cmd": "echo b", "image": "ubuntu:24.04"}, "env": {}} + {"step": {"key": "a", "action": {"cmd": "echo a"}, "image": "ubuntu:24.04"}, "env": {}}, + {"step": {"key": "b", "action": {"cmd": "echo b"}, "image": "ubuntu:24.04"}, "env": {}} ], "node_holes": [], "edge_property": "directed", diff --git a/crates/hm-pipeline-ir/src/graph.rs b/crates/hm-pipeline-ir/src/graph.rs index 8d9ad13a..4cfa990d 100644 --- a/crates/hm-pipeline-ir/src/graph.rs +++ b/crates/hm-pipeline-ir/src/graph.rs @@ -3,31 +3,44 @@ use std::num::NonZeroU32; use daggy::Dag; -use schemars::JsonSchema as DeriveJsonSchema; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -/// A single build command within a pipeline. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum StepAction { + /// A single build command within a pipeline. + Command { + /// Shell command to execute inside the container. + cmd: String, + /// Per-step environment variables merged on top of the pipeline env. + #[serde(default)] + env: Option>, + }, + /// Archive mount from a local path to a workspace path + Mount { from: String, to: String }, +} + +/// A single build action within a pipeline. /// /// Serialized as a JSON object inside each graph node's `step` field. /// The `key` is the unique identifier used to reference this step in /// edges and log output. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] -pub struct CommandStep { +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct Step { /// Unique identifier for this step within the pipeline. pub key: String, + /// Behavior of the node + pub action: StepAction, /// Human-readable label shown in build output. #[serde(default)] pub label: Option, - /// Shell command to execute inside the container. - pub cmd: String, /// Docker image to boot from. Root steps without an image inherit /// `PipelineGraph::default_image`; child steps boot from their /// parent's committed snapshot. #[serde(default)] pub image: Option, /// Per-step environment variables merged on top of the pipeline env. - #[serde(default)] - pub env: Option>, /// Maximum wall-clock seconds before the step is killed. /// /// `NonZeroU32`: a `0`-second budget is rejected at the wire boundary. @@ -46,7 +59,7 @@ pub struct CommandStep { } /// Snapshot cache configuration for a step. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] pub struct Cache { /// Cache policy name (e.g. `"content-hash"`). pub policy: String, @@ -55,13 +68,13 @@ pub struct Cache { pub key: Option, } -/// A graph node: a [`CommandStep`] paired with its resolved environment. +/// A graph node: a [`Step`] paired with its resolved environment. /// /// The `env` map is the final merged result of pipeline-level defaults /// and per-step overrides — ready to hand to the executor as-is. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Transition { - pub step: CommandStep, + pub step: Step, pub env: BTreeMap, } diff --git a/crates/hm-pipeline-ir/src/lib.rs b/crates/hm-pipeline-ir/src/lib.rs index cd55c2a9..2009670f 100644 --- a/crates/hm-pipeline-ir/src/lib.rs +++ b/crates/hm-pipeline-ir/src/lib.rs @@ -9,4 +9,4 @@ mod graph; -pub use graph::{Cache, CommandStep, EdgeKind, PipelineGraph, Transition}; +pub use graph::{Cache, EdgeKind, PipelineGraph, Step, StepAction, Transition}; diff --git a/crates/hm-pipeline-ir/tests/e2e_fixtures.rs b/crates/hm-pipeline-ir/tests/e2e_fixtures.rs index 2ea71319..8b9f19bd 100644 --- a/crates/hm-pipeline-ir/tests/e2e_fixtures.rs +++ b/crates/hm-pipeline-ir/tests/e2e_fixtures.rs @@ -11,7 +11,7 @@ use std::fs; use std::path::PathBuf; use daggy::petgraph::visit::{EdgeRef, IntoNodeReferences}; -use hm_pipeline_ir::{EdgeKind, PipelineGraph}; +use hm_pipeline_ir::{EdgeKind, PipelineGraph, StepAction}; const SCENARIOS: &[&str] = &[ "monorepo-ci", @@ -134,11 +134,13 @@ fn all_fixtures_have_valid_structure() { for (_, t) in g.dag().graph().node_references() { assert!(!t.step.key.is_empty(), "py/{scenario}: empty key"); - assert!( - !t.step.cmd.is_empty(), - "py/{scenario}: empty cmd for {}", - t.step.key, - ); + if let StepAction::Command { cmd, .. } = &t.step.action { + assert!( + !cmd.is_empty(), + "py/{scenario}: empty cmd for {}", + t.step.key + ); + } } let (bi, dep) = edge_kinds(&g); diff --git a/crates/hm-pipeline-ir/tests/graph_build.rs b/crates/hm-pipeline-ir/tests/graph_build.rs index 4a777931..77fd4c37 100644 --- a/crates/hm-pipeline-ir/tests/graph_build.rs +++ b/crates/hm-pipeline-ir/tests/graph_build.rs @@ -32,9 +32,9 @@ fn builds_simple_chain() { "default_image": "ubuntu:24.04", "graph": { "nodes": [ - {"step": {"key": "a", "cmd": "echo a", "image": "ubuntu:24.04"}, "env": {}}, - {"step": {"key": "b", "cmd": "echo b"}, "env": {}}, - {"step": {"key": "c", "cmd": "echo c"}, "env": {}} + {"step": {"key": "a", "action": {"cmd": "echo a"}, "image": "ubuntu:24.04"}, "env": {}}, + {"step": {"key": "b", "action": {"cmd": "echo b"}}, "env": {}}, + {"step": {"key": "c", "action": {"cmd": "echo c"}}, "env": {}} ], "edge_property": "directed", "edges": [ @@ -56,7 +56,7 @@ fn root_inherits_default_image() { "default_image": "ubuntu:24.04", "graph": { "nodes": [ - {"step": {"key": "a", "cmd": "echo a", "image": "ubuntu:24.04"}, "env": {}} + {"step": {"key": "a", "action": {"cmd": "echo a"}, "image": "ubuntu:24.04"}, "env": {}} ], "edge_property": "directed", "edges": [] @@ -75,8 +75,8 @@ fn child_does_not_inherit_default_image() { "default_image": "ubuntu:24.04", "graph": { "nodes": [ - {"step": {"key": "a", "cmd": "echo a", "image": "ubuntu:24.04"}, "env": {}}, - {"step": {"key": "b", "cmd": "echo b"}, "env": {}} + {"step": {"key": "a", "action": {"cmd": "echo a"}, "image": "ubuntu:24.04"}, "env": {}}, + {"step": {"key": "b", "action": {"cmd": "echo b"}}, "env": {}} ], "edge_property": "directed", "edges": [ @@ -96,9 +96,9 @@ fn wait_inserts_implicit_deps() { "version": "0", "graph": { "nodes": [ - {"step": {"key": "a", "cmd": "echo a"}, "env": {}}, - {"step": {"key": "b", "cmd": "echo b"}, "env": {}}, - {"step": {"key": "c", "cmd": "echo c"}, "env": {}} + {"step": {"key": "a", "action": {"cmd": "echo a"}}, "env": {}}, + {"step": {"key": "b", "action": {"cmd": "echo b"}}, "env": {}}, + {"step": {"key": "c", "action": {"cmd": "echo c"}}, "env": {}} ], "edge_property": "directed", "edges": [ diff --git a/crates/hm-pipeline-ir/tests/graph_serde.rs b/crates/hm-pipeline-ir/tests/graph_serde.rs index 52251dbc..ea8178c8 100644 --- a/crates/hm-pipeline-ir/tests/graph_serde.rs +++ b/crates/hm-pipeline-ir/tests/graph_serde.rs @@ -8,17 +8,19 @@ use std::collections::BTreeMap; -use hm_pipeline_ir::{CommandStep, EdgeKind, Transition}; +use hm_pipeline_ir::{EdgeKind, Step, StepAction, Transition}; #[test] fn transition_round_trips() { let nw = Transition { - step: CommandStep { + step: Step { key: "a".into(), + action: StepAction::Command { + cmd: "echo a".into(), + env: None, + }, label: Some("step A".into()), - cmd: "echo a".into(), image: Some("ubuntu:24.04".into()), - env: None, timeout_seconds: None, cache: None, runner: None, @@ -60,9 +62,9 @@ fn build_test_graph() -> PipelineGraph { "default_image": "ubuntu:24.04", "graph": { "nodes": [ - {"step": {"key": "a", "cmd": "echo a", "image": "ubuntu:24.04"}, "env": {}}, - {"step": {"key": "b", "cmd": "echo b"}, "env": {}}, - {"step": {"key": "c", "cmd": "echo c", "image": "ubuntu:24.04"}, "env": {}} + {"step": {"key": "a", "action": {"cmd": "echo a"}, "image": "ubuntu:24.04"}, "env": {}}, + {"step": {"key": "b", "action": {"cmd": "echo b"}}, "env": {}}, + {"step": {"key": "c", "action": {"cmd": "echo c"}, "image": "ubuntu:24.04"}, "env": {}} ], "node_holes": [], "edge_property": "directed", diff --git a/crates/hm-pipeline-ir/tests/schema_snapshot.rs b/crates/hm-pipeline-ir/tests/schema_snapshot.rs index 816f34dc..3e925a93 100644 --- a/crates/hm-pipeline-ir/tests/schema_snapshot.rs +++ b/crates/hm-pipeline-ir/tests/schema_snapshot.rs @@ -2,6 +2,6 @@ #[test] fn command_step_schema_is_stable() { - let schema = schemars::schema_for!(hm_pipeline_ir::CommandStep); + let schema = schemars::schema_for!(hm_pipeline_ir::Step); insta::assert_json_snapshot!(schema); } diff --git a/crates/hm-pipeline-ir/tests/snapshots/graph_serde__pipeline_graph_snapshot.snap b/crates/hm-pipeline-ir/tests/snapshots/graph_serde__pipeline_graph_snapshot.snap index 4a02d56c..c0815281 100644 --- a/crates/hm-pipeline-ir/tests/snapshots/graph_serde__pipeline_graph_snapshot.snap +++ b/crates/hm-pipeline-ir/tests/snapshots/graph_serde__pipeline_graph_snapshot.snap @@ -1,6 +1,5 @@ --- source: crates/hm-pipeline-ir/tests/graph_serde.rs -assertion_line: 88 expression: json --- { @@ -19,9 +18,11 @@ expression: json { "env": {}, "step": { + "action": { + "cmd": "echo a", + "env": null + }, "cache": null, - "cmd": "echo a", - "env": null, "image": "ubuntu:24.04", "key": "a", "label": null, @@ -31,9 +32,11 @@ expression: json { "env": {}, "step": { + "action": { + "cmd": "echo b", + "env": null + }, "cache": null, - "cmd": "echo b", - "env": null, "image": null, "key": "b", "label": null, @@ -43,9 +46,11 @@ expression: json { "env": {}, "step": { + "action": { + "cmd": "echo c", + "env": null + }, "cache": null, - "cmd": "echo c", - "env": null, "image": "ubuntu:24.04", "key": "c", "label": null, diff --git a/crates/hm-pipeline-ir/tests/snapshots/schema_snapshot__command_step_schema_is_stable.snap b/crates/hm-pipeline-ir/tests/snapshots/schema_snapshot__command_step_schema_is_stable.snap index 83725012..4facfd09 100644 --- a/crates/hm-pipeline-ir/tests/snapshots/schema_snapshot__command_step_schema_is_stable.snap +++ b/crates/hm-pipeline-ir/tests/snapshots/schema_snapshot__command_step_schema_is_stable.snap @@ -4,11 +4,11 @@ expression: schema --- { "$schema": "http://json-schema.org/draft-07/schema#", - "title": "CommandStep", - "description": "A single build command within a pipeline.\n\nSerialized as a JSON object inside each graph node's `step` field. The `key` is the unique identifier used to reference this step in edges and log output.", + "title": "Step", + "description": "A single build action within a pipeline.\n\nSerialized as a JSON object inside each graph node's `step` field. The `key` is the unique identifier used to reference this step in edges and log output.", "type": "object", "required": [ - "cmd", + "action", "key" ], "properties": { @@ -16,6 +16,14 @@ expression: schema "description": "Unique identifier for this step within the pipeline.", "type": "string" }, + "action": { + "description": "Behavior of the node", + "allOf": [ + { + "$ref": "#/definitions/StepAction" + } + ] + }, "label": { "description": "Human-readable label shown in build output.", "default": null, @@ -24,10 +32,6 @@ expression: schema "null" ] }, - "cmd": { - "description": "Shell command to execute inside the container.", - "type": "string" - }, "image": { "description": "Docker image to boot from. Root steps without an image inherit `PipelineGraph::default_image`; child steps boot from their parent's committed snapshot.", "default": null, @@ -36,19 +40,8 @@ expression: schema "null" ] }, - "env": { - "description": "Per-step environment variables merged on top of the pipeline env.", - "default": null, - "type": [ - "object", - "null" - ], - "additionalProperties": { - "type": "string" - } - }, "timeout_seconds": { - "description": "Maximum wall-clock seconds before the step is killed.\n\n`NonZeroU32`: a `0`-second budget is rejected at the wire boundary.", + "description": "Per-step environment variables merged on top of the pipeline env. Maximum wall-clock seconds before the step is killed.\n\n`NonZeroU32`: a `0`-second budget is rejected at the wire boundary.", "default": null, "type": [ "integer", @@ -81,6 +74,50 @@ expression: schema } }, "definitions": { + "StepAction": { + "anyOf": [ + { + "description": "A single build command within a pipeline.", + "type": "object", + "required": [ + "cmd" + ], + "properties": { + "cmd": { + "description": "Shell command to execute inside the container.", + "type": "string" + }, + "env": { + "description": "Per-step environment variables merged on top of the pipeline env.", + "default": null, + "type": [ + "object", + "null" + ], + "additionalProperties": { + "type": "string" + } + } + } + }, + { + "description": "Archive mount from a local path to a workspace path", + "type": "object", + "required": [ + "from", + "to" + ], + "properties": { + "from": { + "type": "string" + }, + "to": { + "type": "string" + } + } + } + ] + }, "Cache": { "description": "Snapshot cache configuration for a step.", "type": "object", diff --git a/crates/hm-plugin-protocol/src/executor.rs b/crates/hm-plugin-protocol/src/executor.rs index 75323192..2f8e7447 100644 --- a/crates/hm-plugin-protocol/src/executor.rs +++ b/crates/hm-plugin-protocol/src/executor.rs @@ -6,7 +6,7 @@ use schemars::JsonSchema as DeriveJsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::ir::CommandStep; +use crate::ir::Step; /// Opaque archive handle. The plugin streams bytes via /// `hm_archive_read(id, offset, max)`. @@ -72,7 +72,7 @@ pub enum CacheDecision { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, DeriveJsonSchema)] #[serde(deny_unknown_fields)] pub struct ExecutorInput { - pub step: CommandStep, + pub step: Step, pub workspace_archive_id: ArchiveId, pub env: BTreeMap, pub workdir: String, diff --git a/crates/hm-plugin-protocol/src/ir.rs b/crates/hm-plugin-protocol/src/ir.rs index 727be450..3bb04762 100644 --- a/crates/hm-plugin-protocol/src/ir.rs +++ b/crates/hm-plugin-protocol/src/ir.rs @@ -2,4 +2,4 @@ //! live in the `hm-pipeline-ir` crate; this module keeps the //! `hm_plugin_protocol::ir::*` import path working. -pub use hm_pipeline_ir::{Cache, CommandStep}; +pub use hm_pipeline_ir::{Cache, Step, StepAction}; diff --git a/crates/hm-plugin-protocol/src/lib.rs b/crates/hm-plugin-protocol/src/lib.rs index f3c3745b..709a1581 100644 --- a/crates/hm-plugin-protocol/src/lib.rs +++ b/crates/hm-plugin-protocol/src/lib.rs @@ -11,4 +11,4 @@ pub mod ir; pub use events::{BuildEvent, PlanSummary, StdStream}; pub use executor::{ArchiveId, ArtifactRef, CacheDecision, ExecutorInput, SnapshotRef, StepResult}; -pub use ir::{Cache, CommandStep}; +pub use ir::{Cache, Step, StepAction}; diff --git a/crates/hm-plugin-protocol/tests/round_trip.rs b/crates/hm-plugin-protocol/tests/round_trip.rs index 0c0f763e..f4ada277 100644 --- a/crates/hm-plugin-protocol/tests/round_trip.rs +++ b/crates/hm-plugin-protocol/tests/round_trip.rs @@ -26,12 +26,14 @@ where #[test] fn executor_input_round_trip() { let inp = ExecutorInput { - step: CommandStep { + step: Step { key: "build".into(), + action: StepAction::Command { + cmd: "cargo build".into(), + env: None, + }, label: None, - cmd: "cargo build".into(), image: Some("rust:1.82".into()), - env: None, timeout_seconds: None, cache: None, runner: Some("docker".into()), diff --git a/crates/hm-vm/src/docker.rs b/crates/hm-vm/src/docker.rs index 87f776b2..8f331319 100644 --- a/crates/hm-vm/src/docker.rs +++ b/crates/hm-vm/src/docker.rs @@ -168,15 +168,22 @@ impl Drop for DockerVm { } } -/// Build a tar archive from a host directory. +/// Build a tar archive from a host path. /// -/// The archive contains all files under `host_path` with paths relative +/// If the path points to a directory, the archive will contain all files under `host_path` with paths relative /// to `host_path` itself (i.e. the directory contents, not the directory). -fn tar_directory(host_path: &Path) -> Result> { +fn tar_from(host_path: &Path) -> Result> { let mut archive = tar::Builder::new(Vec::new()); - archive - .append_dir_all(".", host_path) - .with_context(|| format!("archiving '{}'", host_path.display()))?; + if host_path.is_dir() { + archive + .append_dir_all(".", host_path) + .with_context(|| format!("archiving '{}'", host_path.display()))?; + } else if host_path.is_file() { + archive + .append_path(host_path) + .with_context(|| format!("archiving '{}'", host_path.display()))?; + } + archive.finish().context("finalizing tar archive")?; archive.into_inner().context("extracting tar bytes") } @@ -212,7 +219,7 @@ impl Vm for DockerVm { while output.next().await.is_some() {} } - let tar_bytes = tar_directory(host_path)?; + let tar_bytes = tar_from(host_path)?; let options = UploadToContainerOptions { path: guest_path, ..Default::default() diff --git a/crates/hm-vm/src/vm.rs b/crates/hm-vm/src/vm.rs index 07f00a9b..6edc9234 100644 --- a/crates/hm-vm/src/vm.rs +++ b/crates/hm-vm/src/vm.rs @@ -1,5 +1,6 @@ //! High-level VM orchestrator. +use std::path::Path; use std::sync::Arc; use anyhow::Result; @@ -149,6 +150,42 @@ impl HmVm { cached: false, }) } + + /// Mounts a directory into the workspace + /// as an archive. + /// + /// # Errors + /// Fails if the destination directory is outside the + /// workspace, the source doesn't exist, the injection + /// into the vm fails of the mount couldn't ve registered + pub async fn mount_into_vm( + &self, + from: &str, + to: &str, + working_dir: &str, + source: &ImageSource, + ) -> Result { + anyhow::ensure!( + Path::new(&from).is_relative() && Path::new(&to).is_relative(), + "Source and destination paths of a mount should be relative, but got {from} as a source and {to} as a destination", + ); + let wd = working_dir.to_string(); + let src_path = wd.clone() + from; + let dest_path = wd + to; + + let mut vm = match source { + ImageSource::Image(image) => self.backend.create(image, &self.config).await?, + ImageSource::Snapshot(snap) => self.backend.restore(snap, &self.config).await?, + }; + vm.inject(Path::new(&src_path), &dest_path).await?; + let snapshot = vm.snapshot(&SnapshotLabel::Ephemeral).await?; + + Ok(ExecutionResult { + exit_code: 0, + snapshot: Some(snapshot), + cached: false, + }) + } } #[cfg(test)] @@ -303,6 +340,100 @@ mod tests { // Tests // // ------------------------------------------------------------------ // + #[tokio::test] + async fn mount_into_vm_rejects_absolute_source() { + let backend = MockBackend::new(0, false); + let (registry, _dir) = open_temp_registry(10); + let hm = HmVm::new(Arc::new(backend.clone()), registry, VmConfig::default()); + + let err = hm + .mount_into_vm( + "/abs/path", + "./rel", + "/work", + &ImageSource::Image("ubuntu:24.04".into()), + ) + .await + .unwrap_err(); + + let msg = err.to_string(); + assert!( + msg.contains("relative"), + "expected relative-path error, got: {msg}" + ); + } + + #[tokio::test] + async fn mount_into_vm_rejects_absolute_destination() { + let backend = MockBackend::new(0, false); + let (registry, _dir) = open_temp_registry(10); + let hm = HmVm::new(Arc::new(backend.clone()), registry, VmConfig::default()); + + let err = hm + .mount_into_vm( + "./rel", + "/abs/path", + "/work", + &ImageSource::Image("ubuntu:24.04".into()), + ) + .await + .unwrap_err(); + + let msg = err.to_string(); + assert!( + msg.contains("relative"), + "expected relative-path error, got: {msg}" + ); + } + + #[tokio::test] + async fn mount_into_vm_rejects_both_absolute() { + let backend = MockBackend::new(0, false); + let (registry, _dir) = open_temp_registry(10); + let hm = HmVm::new(Arc::new(backend.clone()), registry, VmConfig::default()); + + let err = hm + .mount_into_vm( + "/src", + "/dst", + "/work", + &ImageSource::Image("ubuntu:24.04".into()), + ) + .await + .unwrap_err(); + + let msg = err.to_string(); + assert!( + msg.contains("relative"), + "expected relative-path error, got: {msg}" + ); + } + + #[tokio::test] + async fn mount_into_vm_relative_paths_succeed() { + let backend = MockBackend::new(0, false); + let (registry, _dir) = open_temp_registry(10); + let hm = HmVm::new(Arc::new(backend.clone()), registry, VmConfig::default()); + + let result = hm + .mount_into_vm( + ".cache/data", + "./_hm_mount", + "/work", + &ImageSource::Image("ubuntu:24.04".into()), + ) + .await + .expect("mount should succeed with relative paths"); + + assert_eq!(result.exit_code, 0); + assert!(result.snapshot.is_some()); + + let log = calls(&backend); + assert!(log.iter().any(|c| c.starts_with("create:"))); + assert!(log.iter().any(|c| c.starts_with("inject:"))); + assert!(log.iter().any(|c| c.starts_with("snapshot:"))); + } + #[tokio::test] async fn cache_miss_creates_executes_and_snapshots() { let backend = MockBackend::new(0, false); diff --git a/crates/hm/tests/default_image_inheritance.rs b/crates/hm/tests/default_image_inheritance.rs index 9c706436..7e002b59 100644 --- a/crates/hm/tests/default_image_inheritance.rs +++ b/crates/hm/tests/default_image_inheritance.rs @@ -20,7 +20,7 @@ fn decode(json: &[u8]) -> PipelineGraph { serde_json::from_slice::(json).unwrap() } -fn find_step<'a>(g: &'a PipelineGraph, key: &str) -> &'a hm_pipeline_ir::CommandStep { +fn find_step<'a>(g: &'a PipelineGraph, key: &str) -> &'a hm_pipeline_ir::Step { let dag = g.dag(); let (_, t) = dag .graph() @@ -37,7 +37,7 @@ fn root_step_inherits_default_image() { "default_image": "ubuntu:24.04", "graph": { "nodes": [ - {"step": {"key": "apt-base", "cmd": "apt-get update", "image": "ubuntu:24.04"}, "env": {}} + {"step": {"key": "apt-base", "action": {"cmd": "apt-get update"}, "image": "ubuntu:24.04"}, "env": {}} ], "edge_property": "directed", "edges": [] @@ -59,7 +59,7 @@ fn root_step_explicit_image_wins() { "default_image": "ubuntu:24.04", "graph": { "nodes": [ - {"step": {"key": "rust", "cmd": "cargo build", "image": "rust:1.82"}, "env": {}} + {"step": {"key": "rust", "action": {"cmd": "cargo build"}, "image": "rust:1.82"}, "env": {}} ], "edge_property": "directed", "edges": [] @@ -85,8 +85,8 @@ fn child_step_unchanged_by_default_image() { "default_image": "ubuntu:24.04", "graph": { "nodes": [ - {"step": {"key": "parent", "cmd": "echo p", "image": "ubuntu:24.04"}, "env": {}}, - {"step": {"key": "child", "cmd": "echo c"}, "env": {}} + {"step": {"key": "parent", "action": {"cmd": "echo p"}, "image": "ubuntu:24.04"}, "env": {}}, + {"step": {"key": "child", "action": {"cmd": "echo c"}}, "env": {}} ], "edge_property": "directed", "edges": [ @@ -109,7 +109,7 @@ fn no_default_image_leaves_root_alone() { "version": "0", "graph": { "nodes": [ - {"step": {"key": "k", "cmd": "true"}, "env": {}} + {"step": {"key": "k", "action": {"cmd": "true"}}, "env": {}} ], "edge_property": "directed", "edges": [] diff --git a/crates/hm/tests/local_e2e.rs b/crates/hm/tests/local_e2e.rs index d4e6c954..29ab5af3 100644 --- a/crates/hm/tests/local_e2e.rs +++ b/crates/hm/tests/local_e2e.rs @@ -256,3 +256,34 @@ fn resolves_pipeline_via_slug() { "expected scratch fixture output: {stderr}" ); } + +#[test] +fn mount_works_propperly() { + if skip_if_no_docker() { + return; + } + let tmp = tempfile::tempdir().expect("mktempdir"); + let workspace_dir = tmp.path().join("workspace"); + let harmont_dir = workspace_dir.join(".hm"); + let pipeline = r#"import harmont as hm + +@hm.pipeline('ci', triggers=[hm.push(branch='main')]) +def ci() -> hm.Step: + return hm.scratch() + .mount(from_="../hommer.txt",to=".", image="alpine:3.20") + .sh('cat homer.txt', label='homer') +"#; + let homer = "~(_8^(I)"; + + std::fs::create_dir_all(&harmont_dir).unwrap(); + std::fs::write(tmp.path().join("homer.txt"), homer).unwrap(); + std::fs::write(harmont_dir.join("pipeline.py"), pipeline).unwrap(); + + assert_cmd::Command::cargo_bin("hm") + .unwrap() + .args(["run", "ci"]) + .current_dir(workspace_dir) + .assert() + .success() + .stderr(predicates::str::contains("[homer] ~(_8^(I)")); +} diff --git a/tests/e2e/fixtures/python/cmake-advanced.json b/tests/e2e/fixtures/python/cmake-advanced.json index b1ccf9b3..f1b1233f 100644 --- a/tests/e2e/fixtures/python/cmake-advanced.json +++ b/tests/e2e/fixtures/python/cmake-advanced.json @@ -37,12 +37,14 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "apt-get update && apt-get install -y cmake build-essential pkg-config ninja-build ccache clang-format clang-tidy clang-18 lld-18" + }, "cache": { "duration_seconds": 86400, "env_keys": [], "policy": "ttl" }, - "cmd": "apt-get update && apt-get install -y cmake build-essential pkg-config ninja-build ccache clang-format clang-tidy clang-18 lld-18", "image": "ubuntu:24.04", "key": "apt-base", "label": ":cmake: apt-base" @@ -55,11 +57,13 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "cmake --version && ninja --version && ccache --version && clang-18 --version" + }, "cache": { "env_keys": [], "policy": "forever" }, - "cmd": "cmake --version && ninja --version && ccache --version && clang-18 --version", "key": "verify", "label": ":cmake: verify" } @@ -71,13 +75,15 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "cd . && cmake -S . -B build -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_C_COMPILER=clang-18 -DCMAKE_CXX_COMPILER=clang++-18 -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_STANDARD=20 && cmake --build build --parallel $(nproc)" + }, "cache": { "paths": [ "CMakeLists.txt" ], "policy": "on_change" }, - "cmd": "cd . && cmake -S . -B build -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache -DCMAKE_C_COMPILER=clang-18 -DCMAKE_CXX_COMPILER=clang++-18 -DCMAKE_BUILD_TYPE=Release -DCMAKE_CXX_STANDARD=20 && cmake --build build --parallel $(nproc)", "key": "build", "label": ":cmake: build" } @@ -89,7 +95,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cmake --build ./build --parallel $(nproc) && ctest --test-dir ./build --output-on-failure --parallel $(nproc)", + "action": { + "cmd": "cmake --build ./build --parallel $(nproc) && ctest --test-dir ./build --output-on-failure --parallel $(nproc)" + }, "key": "test", "label": ":cmake: test" } @@ -101,7 +109,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd . && run-clang-tidy -p build", + "action": { + "cmd": "cd . && run-clang-tidy -p build" + }, "key": "lint", "label": ":cmake: lint" } @@ -113,7 +123,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd . && find . -not -path './build/*' \\( -name '*.c' -o -name '*.h' -o -name '*.cpp' -o -name '*.hpp' -o -name '*.cc' -o -name '*.cxx' \\) | xargs clang-format --dry-run --Werror", + "action": { + "cmd": "cd . && find . -not -path './build/*' \\( -name '*.c' -o -name '*.h' -o -name '*.cpp' -o -name '*.hpp' -o -name '*.cc' -o -name '*.cxx' \\) | xargs clang-format --dry-run --Werror" + }, "key": "fmt", "label": ":cmake: fmt" } diff --git a/tests/e2e/fixtures/python/kitchen-sink.json b/tests/e2e/fixtures/python/kitchen-sink.json index e1fcb4ea..9dd584a5 100644 --- a/tests/e2e/fixtures/python/kitchen-sink.json +++ b/tests/e2e/fixtures/python/kitchen-sink.json @@ -52,12 +52,14 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "apt-get update && apt-get install -y cmake build-essential pkg-config ninja-build ccache clang-format clang-tidy" + }, "cache": { "duration_seconds": 86400, "env_keys": [], "policy": "ttl" }, - "cmd": "apt-get update && apt-get install -y cmake build-essential pkg-config ninja-build ccache clang-format clang-tidy", "image": "ubuntu:24.04", "key": "fdcc2fd62363", "label": ":cmake: apt-base" @@ -70,11 +72,13 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "cmake --version && ninja --version && ccache --version" + }, "cache": { "env_keys": [], "policy": "forever" }, - "cmd": "cmake --version && ninja --version && ccache --version", "key": "verify", "label": ":cmake: verify" } @@ -86,13 +90,15 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "cd infra/agent && cmake -S . -B build -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache && cmake --build build --parallel $(nproc)" + }, "cache": { "paths": [ "infra/agent/CMakeLists.txt" ], "policy": "on_change" }, - "cmd": "cd infra/agent && cmake -S . -B build -G Ninja -DCMAKE_EXPORT_COMPILE_COMMANDS=ON -DCMAKE_C_COMPILER_LAUNCHER=ccache -DCMAKE_CXX_COMPILER_LAUNCHER=ccache && cmake --build build --parallel $(nproc)", "key": "build", "label": ":cmake: build" } @@ -104,7 +110,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cmake --build infra/agent/build --parallel $(nproc) && ctest --test-dir infra/agent/build --output-on-failure --parallel $(nproc)", + "action": { + "cmd": "cmake --build infra/agent/build --parallel $(nproc) && ctest --test-dir infra/agent/build --output-on-failure --parallel $(nproc)" + }, "key": "431f35b84318", "label": ":cmake: test" } @@ -116,7 +124,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd infra/agent && find . -not -path './build/*' \\( -name '*.c' -o -name '*.h' -o -name '*.cpp' -o -name '*.hpp' -o -name '*.cc' -o -name '*.cxx' \\) | xargs clang-format --dry-run --Werror", + "action": { + "cmd": "cd infra/agent && find . -not -path './build/*' \\( -name '*.c' -o -name '*.h' -o -name '*.cpp' -o -name '*.hpp' -o -name '*.cc' -o -name '*.cxx' \\) | xargs clang-format --dry-run --Werror" + }, "key": "fmt", "label": ":cmake: fmt" } @@ -128,12 +138,14 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "apt-get update && apt-get install -y curl ca-certificates python3 python3-venv" + }, "cache": { "duration_seconds": 86400, "env_keys": [], "policy": "ttl" }, - "cmd": "apt-get update && apt-get install -y curl ca-certificates python3 python3-venv", "image": "ubuntu:24.04", "key": "c8d9fda86ff3", "label": ":python: apt-base" @@ -146,11 +158,13 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "curl -LsSf https://astral.sh/uv/install.sh | sh && ln -sf /root/.local/bin/uv /usr/local/bin/uv && uv --version" + }, "cache": { "env_keys": [], "policy": "forever" }, - "cmd": "curl -LsSf https://astral.sh/uv/install.sh | sh && ln -sf /root/.local/bin/uv /usr/local/bin/uv && uv --version", "key": "uv-install", "label": ":python: uv-install" } @@ -162,6 +176,9 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "cd services/web && uv sync --all-extras" + }, "cache": { "paths": [ "services/web/uv.lock", @@ -169,7 +186,6 @@ ], "policy": "on_change" }, - "cmd": "cd services/web && uv sync --all-extras", "key": "uv-sync", "label": ":python: uv-sync" } @@ -181,7 +197,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd services/web && uv run pytest", + "action": { + "cmd": "cd services/web && uv run pytest" + }, "key": "239c4926568b", "label": ":python: test" } @@ -193,7 +211,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd services/web && uv run ruff check .", + "action": { + "cmd": "cd services/web && uv run ruff check ." + }, "key": "lint", "label": ":python: lint" } diff --git a/tests/e2e/fixtures/python/monorepo-ci.json b/tests/e2e/fixtures/python/monorepo-ci.json index ec0ef385..e3911ae6 100644 --- a/tests/e2e/fixtures/python/monorepo-ci.json +++ b/tests/e2e/fixtures/python/monorepo-ci.json @@ -82,12 +82,14 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "apt-get update && apt-get install -y curl ca-certificates git" + }, "cache": { "duration_seconds": 86400, "env_keys": [], "policy": "ttl" }, - "cmd": "apt-get update && apt-get install -y curl ca-certificates git", "image": "ubuntu:24.04", "key": "334b29e96b76", "label": ":go: apt-base" @@ -100,11 +102,13 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "curl -fsSL https://go.dev/dl/go1.23.2.linux-amd64.tar.gz -o /tmp/go.tgz && rm -rf /usr/local/go && tar -C /usr/local -xzf /tmp/go.tgz && ln -sf /usr/local/go/bin/go /usr/local/bin/go && ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt && go version" + }, "cache": { "env_keys": [], "policy": "forever" }, - "cmd": "curl -fsSL https://go.dev/dl/go1.23.2.linux-amd64.tar.gz -o /tmp/go.tgz && rm -rf /usr/local/go && tar -C /usr/local -xzf /tmp/go.tgz && ln -sf /usr/local/go/bin/go /usr/local/bin/go && ln -sf /usr/local/go/bin/gofmt /usr/local/bin/gofmt && go version", "key": "e0b494124562", "label": ":go: install" } @@ -116,7 +120,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd services/api && go build ./...", + "action": { + "cmd": "cd services/api && go build ./..." + }, "key": "6f9493b7219f", "label": ":go: build" } @@ -128,7 +134,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd services/api && go test ./...", + "action": { + "cmd": "cd services/api && go test ./..." + }, "key": "1ad6d86b2c0a", "label": ":go: test" } @@ -140,7 +148,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd services/api && go vet ./...", + "action": { + "cmd": "cd services/api && go vet ./..." + }, "key": "vet", "label": ":go: vet" } @@ -152,12 +162,14 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "apt-get update && apt-get install -y curl ca-certificates python3 python3-venv" + }, "cache": { "duration_seconds": 86400, "env_keys": [], "policy": "ttl" }, - "cmd": "apt-get update && apt-get install -y curl ca-certificates python3 python3-venv", "image": "ubuntu:24.04", "key": "c8d9fda86ff3", "label": ":python: apt-base" @@ -170,11 +182,13 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "curl -LsSf https://astral.sh/uv/install.sh | sh && ln -sf /root/.local/bin/uv /usr/local/bin/uv && uv --version" + }, "cache": { "env_keys": [], "policy": "forever" }, - "cmd": "curl -LsSf https://astral.sh/uv/install.sh | sh && ln -sf /root/.local/bin/uv /usr/local/bin/uv && uv --version", "key": "uv-install", "label": ":python: uv-install" } @@ -186,6 +200,9 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "cd services/ml && uv sync --all-extras" + }, "cache": { "paths": [ "services/ml/uv.lock", @@ -193,7 +210,6 @@ ], "policy": "on_change" }, - "cmd": "cd services/ml && uv sync --all-extras", "key": "uv-sync", "label": ":python: uv-sync" } @@ -205,7 +221,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd services/ml && uv run pytest", + "action": { + "cmd": "cd services/ml && uv run pytest" + }, "key": "847020e744bc", "label": ":python: test" } @@ -217,7 +235,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd services/ml && uv run ruff check .", + "action": { + "cmd": "cd services/ml && uv run ruff check ." + }, "key": "6c48498afb84", "label": ":python: lint" } @@ -229,7 +249,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd services/ml && uv run ty check .", + "action": { + "cmd": "cd services/ml && uv run ty check ." + }, "key": "typecheck", "label": ":python: typecheck" } @@ -241,12 +263,14 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "apt-get update && apt-get install -y curl ca-certificates" + }, "cache": { "duration_seconds": 86400, "env_keys": [], "policy": "ttl" }, - "cmd": "apt-get update && apt-get install -y curl ca-certificates", "image": "ubuntu:24.04", "key": "3c2cfedcad46", "label": ":node: apt-base" @@ -259,11 +283,13 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs" + }, "cache": { "env_keys": [], "policy": "forever" }, - "cmd": "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs", "key": "ed974519b390", "label": ":node: install" } @@ -275,13 +301,15 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "cd web && npm ci" + }, "cache": { "paths": [ "web/package-lock.json" ], "policy": "on_change" }, - "cmd": "cd web && npm ci", "key": "deps", "label": ":node: deps" } @@ -293,7 +321,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd web && npm run build", + "action": { + "cmd": "cd web && npm run build" + }, "key": "a94a0f84e711", "label": ":node: build" } @@ -305,7 +335,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd web && npm run test", + "action": { + "cmd": "cd web && npm run test" + }, "key": "d2438adde70d", "label": ":node: test" } @@ -317,7 +349,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd web && npm run lint", + "action": { + "cmd": "cd web && npm run lint" + }, "key": "74c52c9e5ef6", "label": ":node: lint" } diff --git a/tests/e2e/fixtures/python/rust-release.json b/tests/e2e/fixtures/python/rust-release.json index 4f9cc69d..3a0132a5 100644 --- a/tests/e2e/fixtures/python/rust-release.json +++ b/tests/e2e/fixtures/python/rust-release.json @@ -42,12 +42,14 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "apt-get update && apt-get install -y curl ca-certificates build-essential pkg-config libssl-dev" + }, "cache": { "duration_seconds": 86400, "env_keys": [], "policy": "ttl" }, - "cmd": "apt-get update && apt-get install -y curl ca-certificates build-essential pkg-config libssl-dev", "image": "ubuntu:24.04", "key": "apt-base", "label": ":rust: apt-base" @@ -60,11 +62,13 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal --component clippy,rustfmt && . $HOME/.cargo/env && rustc --version && cargo --version" + }, "cache": { "env_keys": [], "policy": "forever" }, - "cmd": "curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal --component clippy,rustfmt && . $HOME/.cargo/env && rustc --version && cargo --version", "key": "rustup", "label": ":rust: rustup" } @@ -76,7 +80,9 @@ "TERM": "dumb" }, "step": { - "cmd": ". $HOME/.cargo/env && cd . && cargo build --locked", + "action": { + "cmd": ". $HOME/.cargo/env && cd . && cargo build --locked" + }, "key": "build", "label": ":rust: build" } @@ -88,7 +94,9 @@ "TERM": "dumb" }, "step": { - "cmd": ". $HOME/.cargo/env && cd . && cargo test --locked", + "action": { + "cmd": ". $HOME/.cargo/env && cd . && cargo test --locked" + }, "key": "test", "label": ":rust: test" } @@ -100,7 +108,9 @@ "TERM": "dumb" }, "step": { - "cmd": ". $HOME/.cargo/env && cd . && cargo clippy --all-targets --locked -- -D warnings", + "action": { + "cmd": ". $HOME/.cargo/env && cd . && cargo clippy --all-targets --locked -- -D warnings" + }, "key": "clippy", "label": ":rust: clippy" } @@ -112,7 +122,9 @@ "TERM": "dumb" }, "step": { - "cmd": ". $HOME/.cargo/env && cd . && cargo fmt --all --check", + "action": { + "cmd": ". $HOME/.cargo/env && cd . && cargo fmt --all --check" + }, "key": "fmt", "label": ":rust: fmt" } @@ -125,7 +137,12 @@ "TERM": "dumb" }, "step": { - "cmd": ". $HOME/.cargo/env && cd . && cargo doc --no-deps --locked", + "action": { + "cmd": ". $HOME/.cargo/env && cd . && cargo doc --no-deps --locked", + "env": { + "RUSTDOCFLAGS": "-D warnings" + } + }, "key": "doc", "label": ":rust: doc" } diff --git a/tests/e2e/fixtures/python/zig-node-polyglot.json b/tests/e2e/fixtures/python/zig-node-polyglot.json index 0ddbebff..681f2a01 100644 --- a/tests/e2e/fixtures/python/zig-node-polyglot.json +++ b/tests/e2e/fixtures/python/zig-node-polyglot.json @@ -62,12 +62,14 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "apt-get update && apt-get install -y --no-install-recommends curl ca-certificates xz-utils" + }, "cache": { "duration_seconds": 86400, "env_keys": [], "policy": "ttl" }, - "cmd": "apt-get update && apt-get install -y --no-install-recommends curl ca-certificates xz-utils", "image": "ubuntu:24.04", "key": "base", "label": ":apt: base" @@ -80,11 +82,13 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "curl -fsSL https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz -o /tmp/zig.tar.xz && rm -rf /usr/local/zig && mkdir -p /usr/local/zig && tar -xJf /tmp/zig.tar.xz -C /usr/local/zig --strip-components=1 && ln -sf /usr/local/zig/zig /usr/local/bin/zig && zig version" + }, "cache": { "env_keys": [], "policy": "forever" }, - "cmd": "curl -fsSL https://ziglang.org/download/0.14.1/zig-x86_64-linux-0.14.1.tar.xz -o /tmp/zig.tar.xz && rm -rf /usr/local/zig && mkdir -p /usr/local/zig && tar -xJf /tmp/zig.tar.xz -C /usr/local/zig --strip-components=1 && ln -sf /usr/local/zig/zig /usr/local/bin/zig && zig version", "key": "3083a531d11a", "label": ":zig: install" } @@ -96,7 +100,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd zig-a && zig build", + "action": { + "cmd": "cd zig-a && zig build" + }, "key": "zig-a-build", "label": ":zig: zig-a build" } @@ -108,7 +114,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd zig-a && zig build test", + "action": { + "cmd": "cd zig-a && zig build test" + }, "key": "zig-a-test", "label": ":zig: zig-a test" } @@ -120,7 +128,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd zig-b && zig build", + "action": { + "cmd": "cd zig-b && zig build" + }, "key": "zig-b-build", "label": ":zig: zig-b build" } @@ -132,7 +142,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd zig-b && zig build test", + "action": { + "cmd": "cd zig-b && zig build test" + }, "key": "zig-b-test", "label": ":zig: zig-b test" } @@ -144,11 +156,13 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs" + }, "cache": { "env_keys": [], "policy": "forever" }, - "cmd": "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs", "key": "a8f0d9d99460", "label": ":node: install" } @@ -160,13 +174,15 @@ "TERM": "dumb" }, "step": { + "action": { + "cmd": "cd web && npm ci" + }, "cache": { "paths": [ "web/package-lock.json" ], "policy": "on_change" }, - "cmd": "cd web && npm ci", "key": "deps", "label": ":node: deps" } @@ -178,7 +194,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd web && npm run build", + "action": { + "cmd": "cd web && npm run build" + }, "key": "build", "label": ":node: build" } @@ -190,7 +208,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd web && npm run test", + "action": { + "cmd": "cd web && npm run test" + }, "key": "test", "label": ":node: test" } @@ -202,7 +222,9 @@ "TERM": "dumb" }, "step": { - "cmd": "cd web && npm run lint", + "action": { + "cmd": "cd web && npm run lint" + }, "key": "lint", "label": ":node: lint" }