From 053ae2941293feb9478677e75f9be2517a91313b Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 17 Jun 2026 14:59:54 +0000 Subject: [PATCH 1/7] feat(ir): carry per-step and merged secret references in v0 IR --- crates/hm-exec/src/local/cache.rs | 1 + crates/hm-pipeline-ir/src/graph.rs | 7 ++++ crates/hm-pipeline-ir/tests/graph_serde.rs | 32 +++++++++++++++++++ .../graph_serde__pipeline_graph_snapshot.snap | 3 ++ ...apshot__command_step_schema_is_stable.snap | 10 ++++++ crates/hm-plugin-protocol/tests/round_trip.rs | 1 + 6 files changed, 54 insertions(+) diff --git a/crates/hm-exec/src/local/cache.rs b/crates/hm-exec/src/local/cache.rs index 3d609db7..5be45d08 100644 --- a/crates/hm-exec/src/local/cache.rs +++ b/crates/hm-exec/src/local/cache.rs @@ -53,6 +53,7 @@ mod tests { cache, runner: None, runner_args: None, + secrets: None, } } diff --git a/crates/hm-pipeline-ir/src/graph.rs b/crates/hm-pipeline-ir/src/graph.rs index 8d9ad13a..84620a89 100644 --- a/crates/hm-pipeline-ir/src/graph.rs +++ b/crates/hm-pipeline-ir/src/graph.rs @@ -28,6 +28,10 @@ pub struct CommandStep { /// Per-step environment variables merged on top of the pipeline env. #[serde(default)] pub env: Option>, + /// Per-step secret references, merged on top of pipeline secrets. + /// Maps env-var-name -> secret-name. Values are resolved at run time, never here. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub secrets: Option>, /// Maximum wall-clock seconds before the step is killed. /// /// `NonZeroU32`: a `0`-second budget is rejected at the wire boundary. @@ -63,6 +67,9 @@ pub struct Cache { pub struct Transition { pub step: CommandStep, pub env: BTreeMap, + /// Merged secret references (pipeline + per-step). env-var-name -> secret-name. + #[serde(default)] + pub secrets: BTreeMap, } /// Edge label in the pipeline DAG. diff --git a/crates/hm-pipeline-ir/tests/graph_serde.rs b/crates/hm-pipeline-ir/tests/graph_serde.rs index 52251dbc..79d944fe 100644 --- a/crates/hm-pipeline-ir/tests/graph_serde.rs +++ b/crates/hm-pipeline-ir/tests/graph_serde.rs @@ -23,8 +23,10 @@ fn transition_round_trips() { cache: None, runner: None, runner_args: None, + secrets: None, }, env: BTreeMap::from([("FOO".into(), "bar".into())]), + secrets: BTreeMap::new(), }; let json = serde_json::to_string(&nw).unwrap(); let back: Transition = serde_json::from_str(&json).unwrap(); @@ -32,6 +34,36 @@ fn transition_round_trips() { assert_eq!(back.env.get("FOO").unwrap(), "bar"); } +#[test] +fn command_step_round_trips_secrets() { + let json = r#"{ + "key": "deploy", + "cmd": "./deploy.sh", + "env": { "CI": "true" }, + "secrets": { "TOKEN": "DEPLOY_TOKEN" } + }"#; + let step: hm_pipeline_ir::CommandStep = serde_json::from_str(json).unwrap(); + let secrets = step.secrets.expect("secrets present"); + assert_eq!(secrets.get("TOKEN").map(String::as_str), Some("DEPLOY_TOKEN")); + + // Absent secrets stays None and is omitted on re-serialize. + let bare: hm_pipeline_ir::CommandStep = + serde_json::from_str(r#"{"key":"k","cmd":"c"}"#).unwrap(); + assert!(bare.secrets.is_none()); + assert!(!serde_json::to_string(&bare).unwrap().contains("secrets")); +} + +#[test] +fn transition_round_trips_secrets() { + let json = r#"{ + "step": { "key": "deploy", "cmd": "./deploy.sh" }, + "env": {}, + "secrets": { "TOKEN": "DEPLOY_TOKEN" } + }"#; + let t: hm_pipeline_ir::Transition = serde_json::from_str(json).unwrap(); + assert_eq!(t.secrets.get("TOKEN").map(String::as_str), Some("DEPLOY_TOKEN")); +} + #[test] fn edge_kind_serializes_as_snake_case() { assert_eq!( 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..3e0cc9e5 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 @@ -18,6 +18,7 @@ expression: json "nodes": [ { "env": {}, + "secrets": {}, "step": { "cache": null, "cmd": "echo a", @@ -30,6 +31,7 @@ expression: json }, { "env": {}, + "secrets": {}, "step": { "cache": null, "cmd": "echo b", @@ -42,6 +44,7 @@ expression: json }, { "env": {}, + "secrets": {}, "step": { "cache": null, "cmd": "echo c", 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..16b2c4b3 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 @@ -47,6 +47,16 @@ expression: schema "type": "string" } }, + "secrets": { + "description": "Per-step secret references, merged on top of pipeline secrets. Maps env-var-name -> secret-name. Values are resolved at run time, never here.", + "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.", "default": null, diff --git a/crates/hm-plugin-protocol/tests/round_trip.rs b/crates/hm-plugin-protocol/tests/round_trip.rs index 0c0f763e..e8a189f1 100644 --- a/crates/hm-plugin-protocol/tests/round_trip.rs +++ b/crates/hm-plugin-protocol/tests/round_trip.rs @@ -36,6 +36,7 @@ fn executor_input_round_trip() { cache: None, runner: Some("docker".into()), runner_args: None, + secrets: None, }, workspace_archive_id: ArchiveId(Uuid::nil()), env: Default::default(), From 15708c4788cea79ae4182d4499cd51084d79870b Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 17 Jun 2026 15:33:31 +0000 Subject: [PATCH 2/7] feat(sdk-py): add hm.secrets["NAME"] reference interface --- .../harmont-py/harmont/__init__.py | 6 +- .../harmont-py/harmont/_decorator.py | 3 +- .../harmont-py/harmont/_pipeline.py | 23 +++++-- .../harmont-py/harmont/_registry.py | 3 +- .../harmont-py/harmont/_secret.py | 40 +++++++++++ .../hm-dsl-engine/harmont-py/harmont/_step.py | 9 ++- .../harmont-py/tests/test_secrets.py | 43 ++++++++++++ tests/e2e/fixtures/python/cmake-advanced.json | 24 +++++-- tests/e2e/fixtures/python/kitchen-sink.json | 40 ++++++++--- tests/e2e/fixtures/python/monorepo-ci.json | 68 ++++++++++++++----- tests/e2e/fixtures/python/rust-release.json | 28 ++++++-- .../fixtures/python/zig-node-polyglot.json | 44 +++++++++--- 12 files changed, 270 insertions(+), 61 deletions(-) create mode 100644 crates/hm-dsl-engine/harmont-py/harmont/_secret.py create mode 100644 crates/hm-dsl-engine/harmont-py/tests/test_secrets.py diff --git a/crates/hm-dsl-engine/harmont-py/harmont/__init__.py b/crates/hm-dsl-engine/harmont-py/harmont/__init__.py index 9448277f..af986347 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/__init__.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/__init__.py @@ -40,6 +40,7 @@ from ._pipeline import pipeline_to_json from ._python import python from ._rust import RustProject, rust +from ._secret import SecretRef, secrets from ._step import Step, scratch, wait from ._target import clear_target_cache, target # noqa: F401 clear_target_cache used by tests from ._toolchain import apt_base @@ -58,6 +59,7 @@ from .types import Pipeline if TYPE_CHECKING: + from collections.abc import Mapping from datetime import timedelta @@ -237,7 +239,7 @@ def sh( cwd: str | None = None, label: str | None = None, cache: CachePolicy | None = None, - env: dict[str, str] | None = None, + env: Mapping[str, str | SecretRef] | None = None, image: str | None = None, key: str | None = None, ) -> Step: @@ -309,6 +311,7 @@ def group(steps: list[Step] | tuple[Step, ...]) -> tuple[Step, ...]: "JsProject", "Pipeline", "RustProject", + "SecretRef", "Step", "Target", "apt_base", @@ -330,6 +333,7 @@ def group(steps: list[Step] | tuple[Step, ...]) -> tuple[Step, ...]: "python", "rust", "scratch", + "secrets", "sh", "target", "timeout", diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_decorator.py b/crates/hm-dsl-engine/harmont-py/harmont/_decorator.py index e9d77872..b33302c3 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_decorator.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_decorator.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from collections.abc import Callable + from ._secret import SecretRef from .triggers import Trigger _SLUG_RE = re.compile(r"^[a-z][a-z0-9-]{0,63}$") @@ -33,7 +34,7 @@ def pipeline( name: str | None = None, triggers: tuple[Trigger, ...] | list[Trigger] = (), allow_manual: bool = True, - env: dict[str, str] | None = None, + env: dict[str, str | SecretRef] | None = None, timeout: str | int | None = None, ) -> Callable[[Callable[..., Any]], Callable[[], Any]]: """Register a function as a CI pipeline (decorator form). diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_pipeline.py b/crates/hm-dsl-engine/harmont-py/harmont/_pipeline.py index 38b25ea1..9a5e8ff5 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 ._secret import SecretRef from .cache import ( CacheCompose, CacheForever, @@ -37,7 +38,7 @@ def pipeline( leaves: list[Step] | tuple[Step, ...], *, - env: dict[str, str] | None = None, + env: dict[str, str | SecretRef] | None = None, timeout: str | int | None = None, ) -> dict[str, Any]: """Top-level factory. Returns a JSON-shaped dict (version "0"). @@ -66,7 +67,7 @@ def pipeline( def _lower_to_graph( leaves: list[Step], *, - env: dict[str, str] | None = None, + env: dict[str, str | SecretRef] | None = None, ) -> dict[str, Any]: """Walk back via `parent`, topo-sort, emit petgraph-serde graph dict. @@ -131,7 +132,7 @@ def _lower_to_graph( step_dict["runner_args"] = s.runner_args # Baseline env for non-interactive operation inside VMs/containers. - merged_env: dict[str, str] = { + merged_env: dict[str, str | SecretRef] = { "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb", } @@ -140,7 +141,21 @@ def _lower_to_graph( if s.env: merged_env.update(s.env) - nodes.append({"step": step_dict, "env": merged_env}) + # Split the merged env: literal strings stay as env; SecretRefs become + # an env-var-name -> secret-name map resolved at run time. The + # node-level `secrets` map is authoritative; the step-level copy + # mirrors the same merged map so consumers that read either see it. + literal_env: dict[str, str] = {} + secret_refs: dict[str, str] = {} + for var, val in merged_env.items(): + if isinstance(val, SecretRef): + secret_refs[var] = val.name + else: + literal_env[var] = val + + step_dict["secrets"] = secret_refs + + nodes.append({"step": step_dict, "env": literal_env, "secrets": secret_refs}) # builds_in edge from parent. parent_key = _resolved_parent_key(s, keys) diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_registry.py b/crates/hm-dsl-engine/harmont-py/harmont/_registry.py index 0eb5c7b8..5bf06f59 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_registry.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_registry.py @@ -12,6 +12,7 @@ if TYPE_CHECKING: from collections.abc import Callable + from ._secret import SecretRef from .triggers import Trigger @@ -21,7 +22,7 @@ class PipelineRegistration: name: str triggers: tuple[Trigger, ...] allow_manual: bool - env: dict[str, str] | None + env: dict[str, str | SecretRef] | None fn: Callable[[], object] timeout: str | int | None = None diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_secret.py b/crates/hm-dsl-engine/harmont-py/harmont/_secret.py new file mode 100644 index 00000000..2e7851c9 --- /dev/null +++ b/crates/hm-dsl-engine/harmont-py/harmont/_secret.py @@ -0,0 +1,40 @@ +"""Secret references for the Harmont pipeline SDK. + +`hm.secrets["NAME"]` returns a SecretRef — a *reference* to a secret, never its +value. The value is resolved at run time (locally from .env + the process +environment; in the cloud from the org/pipeline secret store). Use it anywhere +an env value is accepted: `hm.sh("deploy", env={"TOKEN": hm.secrets["DEPLOY_TOKEN"]})`. +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + +_SECRET_NAME = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$") + + +@dataclass(frozen=True) +class SecretRef: + """A reference to a stored secret, identified by name.""" + + name: str + + def __repr__(self) -> str: # never leaks a value — there is none to leak + return f"SecretRef({self.name!r})" + + +class _Secrets: + """Subscript accessor exposed as `hm.secrets`.""" + + def __getitem__(self, name: str) -> SecretRef: + if not isinstance(name, str) or not _SECRET_NAME.match(name): + msg = ( + f"secret name {name!r} is invalid: names must match " + r"[A-Za-z_][A-Za-z0-9_]* (letters, digits, underscores; no leading digit)." + ) + raise ValueError(msg) + return SecretRef(name) + + +secrets = _Secrets() diff --git a/crates/hm-dsl-engine/harmont-py/harmont/_step.py b/crates/hm-dsl-engine/harmont-py/harmont/_step.py index 2fb8e090..b3aa4bff 100644 --- a/crates/hm-dsl-engine/harmont-py/harmont/_step.py +++ b/crates/hm-dsl-engine/harmont-py/harmont/_step.py @@ -11,6 +11,9 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: + from collections.abc import Mapping + + from ._secret import SecretRef from .cache import CachePolicy @@ -33,7 +36,7 @@ class Step: continue_on_failure: bool = False label: str | None = None cache: CachePolicy | None = None - env: dict[str, str] | None = None + env: dict[str, str | SecretRef] | None = None timeout_seconds: int | None = None image: str | None = None """Local-mode Docker base image override for this step. Ignored when @@ -60,7 +63,7 @@ def sh( cwd: str | None = None, label: str | None = None, cache: CachePolicy | None = None, - env: dict[str, str] | None = None, + env: Mapping[str, str | SecretRef] | None = None, image: str | None = None, runner: str | None = None, runner_args: dict[str, Any] | None = None, @@ -115,7 +118,7 @@ def sh( parent=self, label=label, cache=cache, - env=env, + env=dict(env) if env is not None else None, image=effective_image, runner=runner, runner_args=runner_args, diff --git a/crates/hm-dsl-engine/harmont-py/tests/test_secrets.py b/crates/hm-dsl-engine/harmont-py/tests/test_secrets.py new file mode 100644 index 00000000..49939634 --- /dev/null +++ b/crates/hm-dsl-engine/harmont-py/tests/test_secrets.py @@ -0,0 +1,43 @@ +import json + +import harmont as hm + + +def _nodes(p): + return json.loads(hm.pipeline_to_json(p))["graph"]["nodes"] + + +def test_secret_ref_is_repr_safe(): + ref = hm.secrets["DEPLOY_TOKEN"] + assert isinstance(ref, hm.SecretRef) + assert ref.name == "DEPLOY_TOKEN" + assert "DEPLOY_TOKEN" in repr(ref) + + +def test_env_secret_ref_lowers_into_secrets_map(): + p = hm.pipeline( + [hm.sh("deploy", env={"TOKEN": hm.secrets["DEPLOY_TOKEN"], "CI": "true"})], + ) + node = _nodes(p)[0] + # NOTE: the current DSL also seeds baseline env (DEBIAN_FRONTEND, TERM). + # Assert our keys are present rather than equality on the whole dict. + assert node["env"]["CI"] == "true" + assert "TOKEN" not in node["env"] + assert node["secrets"] == {"TOKEN": "DEPLOY_TOKEN"} + assert node["step"]["secrets"] == {"TOKEN": "DEPLOY_TOKEN"} + + +def test_pipeline_level_secret_merges_under_step(): + p = hm.pipeline( + [hm.sh("deploy", env={"TOKEN": hm.secrets["PIPELINE_TOKEN"]})], + env={"TOKEN": hm.secrets["GLOBAL_TOKEN"], "SHARED": hm.secrets["SHARED_SECRET"]}, + ) + node = _nodes(p)[0] + assert node["secrets"] == {"TOKEN": "PIPELINE_TOKEN", "SHARED": "SHARED_SECRET"} + + +def test_invalid_secret_name_rejected(): + import pytest + + with pytest.raises(ValueError, match="secret name"): + hm.secrets["not valid!"] diff --git a/tests/e2e/fixtures/python/cmake-advanced.json b/tests/e2e/fixtures/python/cmake-advanced.json index b1ccf9b3..dfdc1f7d 100644 --- a/tests/e2e/fixtures/python/cmake-advanced.json +++ b/tests/e2e/fixtures/python/cmake-advanced.json @@ -36,6 +36,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -45,7 +46,8 @@ "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" + "label": ":cmake: apt-base", + "secrets": {} } }, { @@ -54,6 +56,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -61,7 +64,8 @@ }, "cmd": "cmake --version && ninja --version && ccache --version && clang-18 --version", "key": "verify", - "label": ":cmake: verify" + "label": ":cmake: verify", + "secrets": {} } }, { @@ -70,6 +74,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -79,7 +84,8 @@ }, "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" + "label": ":cmake: build", + "secrets": {} } }, { @@ -88,10 +94,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cmake --build ./build --parallel $(nproc) && ctest --test-dir ./build --output-on-failure --parallel $(nproc)", "key": "test", - "label": ":cmake: test" + "label": ":cmake: test", + "secrets": {} } }, { @@ -100,10 +108,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd . && run-clang-tidy -p build", "key": "lint", - "label": ":cmake: lint" + "label": ":cmake: lint", + "secrets": {} } }, { @@ -112,10 +122,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "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", "key": "fmt", - "label": ":cmake: fmt" + "label": ":cmake: fmt", + "secrets": {} } } ] diff --git a/tests/e2e/fixtures/python/kitchen-sink.json b/tests/e2e/fixtures/python/kitchen-sink.json index e1fcb4ea..0c43d356 100644 --- a/tests/e2e/fixtures/python/kitchen-sink.json +++ b/tests/e2e/fixtures/python/kitchen-sink.json @@ -51,6 +51,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -60,7 +61,8 @@ "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" + "label": ":cmake: apt-base", + "secrets": {} } }, { @@ -69,6 +71,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -76,7 +79,8 @@ }, "cmd": "cmake --version && ninja --version && ccache --version", "key": "verify", - "label": ":cmake: verify" + "label": ":cmake: verify", + "secrets": {} } }, { @@ -85,6 +89,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -94,7 +99,8 @@ }, "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" + "label": ":cmake: build", + "secrets": {} } }, { @@ -103,10 +109,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cmake --build infra/agent/build --parallel $(nproc) && ctest --test-dir infra/agent/build --output-on-failure --parallel $(nproc)", "key": "431f35b84318", - "label": ":cmake: test" + "label": ":cmake: test", + "secrets": {} } }, { @@ -115,10 +123,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "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", "key": "fmt", - "label": ":cmake: fmt" + "label": ":cmake: fmt", + "secrets": {} } }, { @@ -127,6 +137,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -136,7 +147,8 @@ "cmd": "apt-get update && apt-get install -y curl ca-certificates python3 python3-venv", "image": "ubuntu:24.04", "key": "c8d9fda86ff3", - "label": ":python: apt-base" + "label": ":python: apt-base", + "secrets": {} } }, { @@ -145,6 +157,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -152,7 +165,8 @@ }, "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" + "label": ":python: uv-install", + "secrets": {} } }, { @@ -161,6 +175,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -171,7 +186,8 @@ }, "cmd": "cd services/web && uv sync --all-extras", "key": "uv-sync", - "label": ":python: uv-sync" + "label": ":python: uv-sync", + "secrets": {} } }, { @@ -180,10 +196,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/web && uv run pytest", "key": "239c4926568b", - "label": ":python: test" + "label": ":python: test", + "secrets": {} } }, { @@ -192,10 +210,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/web && uv run ruff check .", "key": "lint", - "label": ":python: lint" + "label": ":python: lint", + "secrets": {} } } ] diff --git a/tests/e2e/fixtures/python/monorepo-ci.json b/tests/e2e/fixtures/python/monorepo-ci.json index ec0ef385..cfd0ac95 100644 --- a/tests/e2e/fixtures/python/monorepo-ci.json +++ b/tests/e2e/fixtures/python/monorepo-ci.json @@ -81,6 +81,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -90,7 +91,8 @@ "cmd": "apt-get update && apt-get install -y curl ca-certificates git", "image": "ubuntu:24.04", "key": "334b29e96b76", - "label": ":go: apt-base" + "label": ":go: apt-base", + "secrets": {} } }, { @@ -99,6 +101,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -106,7 +109,8 @@ }, "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" + "label": ":go: install", + "secrets": {} } }, { @@ -115,10 +119,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/api && go build ./...", "key": "6f9493b7219f", - "label": ":go: build" + "label": ":go: build", + "secrets": {} } }, { @@ -127,10 +133,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/api && go test ./...", "key": "1ad6d86b2c0a", - "label": ":go: test" + "label": ":go: test", + "secrets": {} } }, { @@ -139,10 +147,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/api && go vet ./...", "key": "vet", - "label": ":go: vet" + "label": ":go: vet", + "secrets": {} } }, { @@ -151,6 +161,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -160,7 +171,8 @@ "cmd": "apt-get update && apt-get install -y curl ca-certificates python3 python3-venv", "image": "ubuntu:24.04", "key": "c8d9fda86ff3", - "label": ":python: apt-base" + "label": ":python: apt-base", + "secrets": {} } }, { @@ -169,6 +181,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -176,7 +189,8 @@ }, "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" + "label": ":python: uv-install", + "secrets": {} } }, { @@ -185,6 +199,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -195,7 +210,8 @@ }, "cmd": "cd services/ml && uv sync --all-extras", "key": "uv-sync", - "label": ":python: uv-sync" + "label": ":python: uv-sync", + "secrets": {} } }, { @@ -204,10 +220,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/ml && uv run pytest", "key": "847020e744bc", - "label": ":python: test" + "label": ":python: test", + "secrets": {} } }, { @@ -216,10 +234,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/ml && uv run ruff check .", "key": "6c48498afb84", - "label": ":python: lint" + "label": ":python: lint", + "secrets": {} } }, { @@ -228,10 +248,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/ml && uv run ty check .", "key": "typecheck", - "label": ":python: typecheck" + "label": ":python: typecheck", + "secrets": {} } }, { @@ -240,6 +262,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -249,7 +272,8 @@ "cmd": "apt-get update && apt-get install -y curl ca-certificates", "image": "ubuntu:24.04", "key": "3c2cfedcad46", - "label": ":node: apt-base" + "label": ":node: apt-base", + "secrets": {} } }, { @@ -258,6 +282,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -265,7 +290,8 @@ }, "cmd": "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs", "key": "ed974519b390", - "label": ":node: install" + "label": ":node: install", + "secrets": {} } }, { @@ -274,6 +300,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -283,7 +310,8 @@ }, "cmd": "cd web && npm ci", "key": "deps", - "label": ":node: deps" + "label": ":node: deps", + "secrets": {} } }, { @@ -292,10 +320,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run build", "key": "a94a0f84e711", - "label": ":node: build" + "label": ":node: build", + "secrets": {} } }, { @@ -304,10 +334,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run test", "key": "d2438adde70d", - "label": ":node: test" + "label": ":node: test", + "secrets": {} } }, { @@ -316,10 +348,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run lint", "key": "74c52c9e5ef6", - "label": ":node: lint" + "label": ":node: lint", + "secrets": {} } } ] diff --git a/tests/e2e/fixtures/python/rust-release.json b/tests/e2e/fixtures/python/rust-release.json index 4f9cc69d..1052dccc 100644 --- a/tests/e2e/fixtures/python/rust-release.json +++ b/tests/e2e/fixtures/python/rust-release.json @@ -41,6 +41,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -50,7 +51,8 @@ "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" + "label": ":rust: apt-base", + "secrets": {} } }, { @@ -59,6 +61,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -66,7 +69,8 @@ }, "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" + "label": ":rust: rustup", + "secrets": {} } }, { @@ -75,10 +79,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": ". $HOME/.cargo/env && cd . && cargo build --locked", "key": "build", - "label": ":rust: build" + "label": ":rust: build", + "secrets": {} } }, { @@ -87,10 +93,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": ". $HOME/.cargo/env && cd . && cargo test --locked", "key": "test", - "label": ":rust: test" + "label": ":rust: test", + "secrets": {} } }, { @@ -99,10 +107,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": ". $HOME/.cargo/env && cd . && cargo clippy --all-targets --locked -- -D warnings", "key": "clippy", - "label": ":rust: clippy" + "label": ":rust: clippy", + "secrets": {} } }, { @@ -111,10 +121,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": ". $HOME/.cargo/env && cd . && cargo fmt --all --check", "key": "fmt", - "label": ":rust: fmt" + "label": ":rust: fmt", + "secrets": {} } }, { @@ -124,10 +136,12 @@ "RUSTDOCFLAGS": "-D warnings", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": ". $HOME/.cargo/env && cd . && cargo doc --no-deps --locked", "key": "doc", - "label": ":rust: doc" + "label": ":rust: doc", + "secrets": {} } } ] diff --git a/tests/e2e/fixtures/python/zig-node-polyglot.json b/tests/e2e/fixtures/python/zig-node-polyglot.json index 0ddbebff..286e23f6 100644 --- a/tests/e2e/fixtures/python/zig-node-polyglot.json +++ b/tests/e2e/fixtures/python/zig-node-polyglot.json @@ -61,6 +61,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -70,7 +71,8 @@ "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" + "label": ":apt: base", + "secrets": {} } }, { @@ -79,6 +81,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -86,7 +89,8 @@ }, "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" + "label": ":zig: install", + "secrets": {} } }, { @@ -95,10 +99,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd zig-a && zig build", "key": "zig-a-build", - "label": ":zig: zig-a build" + "label": ":zig: zig-a build", + "secrets": {} } }, { @@ -107,10 +113,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd zig-a && zig build test", "key": "zig-a-test", - "label": ":zig: zig-a test" + "label": ":zig: zig-a test", + "secrets": {} } }, { @@ -119,10 +127,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd zig-b && zig build", "key": "zig-b-build", - "label": ":zig: zig-b build" + "label": ":zig: zig-b build", + "secrets": {} } }, { @@ -131,10 +141,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd zig-b && zig build test", "key": "zig-b-test", - "label": ":zig: zig-b test" + "label": ":zig: zig-b test", + "secrets": {} } }, { @@ -143,6 +155,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -150,7 +163,8 @@ }, "cmd": "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs", "key": "a8f0d9d99460", - "label": ":node: install" + "label": ":node: install", + "secrets": {} } }, { @@ -159,6 +173,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -168,7 +183,8 @@ }, "cmd": "cd web && npm ci", "key": "deps", - "label": ":node: deps" + "label": ":node: deps", + "secrets": {} } }, { @@ -177,10 +193,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run build", "key": "build", - "label": ":node: build" + "label": ":node: build", + "secrets": {} } }, { @@ -189,10 +207,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run test", "key": "test", - "label": ":node: test" + "label": ":node: test", + "secrets": {} } }, { @@ -201,10 +221,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run lint", "key": "lint", - "label": ":node: lint" + "label": ":node: lint", + "secrets": {} } } ] From 9000fd1a81eb5252696fbc306f292fff49e7172e Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 17 Jun 2026 15:39:01 +0000 Subject: [PATCH 3/7] feat(sdk-ts): add hm.secrets["NAME"] reference interface --- crates/hm-dsl-engine/harmont-ts/src/index.ts | 1 + .../hm-dsl-engine/harmont-ts/src/pipeline.ts | 24 ++++++- crates/hm-dsl-engine/harmont-ts/src/secret.ts | 37 ++++++++++ crates/hm-dsl-engine/harmont-ts/src/step.ts | 9 +-- .../harmont-ts/tests/secret.test.ts | 40 +++++++++++ tests/e2e/fixtures/ts/cmake-advanced.json | 24 +++++-- tests/e2e/fixtures/ts/kitchen-sink.json | 40 ++++++++--- tests/e2e/fixtures/ts/monorepo-ci.json | 68 ++++++++++++++----- tests/e2e/fixtures/ts/rust-release.json | 28 ++++++-- tests/e2e/fixtures/ts/zig-node-polyglot.json | 44 +++++++++--- 10 files changed, 257 insertions(+), 58 deletions(-) create mode 100644 crates/hm-dsl-engine/harmont-ts/src/secret.ts create mode 100644 crates/hm-dsl-engine/harmont-ts/tests/secret.test.ts diff --git a/crates/hm-dsl-engine/harmont-ts/src/index.ts b/crates/hm-dsl-engine/harmont-ts/src/index.ts index c950c850..698a5b17 100644 --- a/crates/hm-dsl-engine/harmont-ts/src/index.ts +++ b/crates/hm-dsl-engine/harmont-ts/src/index.ts @@ -29,3 +29,4 @@ export { resolvePipelineCacheKeys, type CacheKeyOptions, } from "./keygen.js"; +export { secrets, isSecretRef, type SecretRef } from "./secret.js"; diff --git a/crates/hm-dsl-engine/harmont-ts/src/pipeline.ts b/crates/hm-dsl-engine/harmont-ts/src/pipeline.ts index bf745598..b54a15b0 100644 --- a/crates/hm-dsl-engine/harmont-ts/src/pipeline.ts +++ b/crates/hm-dsl-engine/harmont-ts/src/pipeline.ts @@ -1,6 +1,7 @@ import type { CachePolicy } from "./cache.js"; import { parseDuration } from "./duration.js"; import { resolveKeys } from "./keys.js"; +import { isSecretRef, type SecretRef } from "./secret.js"; import type { Step } from "./step.js"; // Across-the-board default image for imageless root steps. The SDK's @@ -9,7 +10,7 @@ import type { Step } from "./step.js"; const DEFAULT_IMAGE = "ubuntu:24.04"; export interface PipelineOptions { - readonly env?: Readonly>; + readonly env?: Readonly>; readonly timeout?: string | number; } @@ -27,6 +28,7 @@ export interface PipelineIR { interface GraphNode { step: Record; env: Record; + secrets: Record; } export function pipeline( @@ -93,14 +95,30 @@ function lowerToGraph( if (s._runner != null) stepDict.runner = s._runner; if (s._runnerArgs != null) stepDict.runner_args = s._runnerArgs; - const mergedEnv: Record = { + const mergedEnv: Record = { DEBIAN_FRONTEND: "noninteractive", TERM: "dumb", }; if (opts?.env) Object.assign(mergedEnv, opts.env); if (s._env) Object.assign(mergedEnv, s._env); - nodes.push({ step: stepDict, env: mergedEnv }); + // Split the merged env: literal strings stay as env; SecretRefs become an + // env-var-name -> secret-name map resolved at run time. The node-level + // `secrets` map is authoritative; the step-level copy mirrors it so + // consumers that read either see the same map. + const literalEnv: Record = {}; + const secretRefs: Record = {}; + for (const [varName, val] of Object.entries(mergedEnv)) { + if (isSecretRef(val)) { + secretRefs[varName] = val.name; + } else { + literalEnv[varName] = val; + } + } + + stepDict.secrets = secretRefs; + + nodes.push({ step: stepDict, env: literalEnv, secrets: secretRefs }); const parentKey = resolvedParentKey(s, keys); if (parentKey !== null) { diff --git a/crates/hm-dsl-engine/harmont-ts/src/secret.ts b/crates/hm-dsl-engine/harmont-ts/src/secret.ts new file mode 100644 index 00000000..e9819625 --- /dev/null +++ b/crates/hm-dsl-engine/harmont-ts/src/secret.ts @@ -0,0 +1,37 @@ +const SECRET_NAME = /^[A-Za-z_][A-Za-z0-9_]*$/; +const SECRET_BRAND = Symbol("harmont.secretRef"); + +/** A reference to a stored secret, identified by name. Never holds a value. */ +export interface SecretRef { + readonly [SECRET_BRAND]: true; + readonly name: string; +} + +export function isSecretRef(v: unknown): v is SecretRef { + return typeof v === "object" && v !== null && (v as Record)[SECRET_BRAND] === true; +} + +/** + * `secrets["NAME"]` returns a SecretRef — a reference resolved at run time + * (locally from .env + the process env; in the cloud from the secret store). + */ +export const secrets: Readonly> = new Proxy( + {}, + { + get(target, prop: string | symbol): SecretRef | undefined { + // Only string subscripting names a secret; symbol access defers to the + // target (so the object is not an accidental thenable, etc.). + if (typeof prop === "symbol") { + return (target as Record)[prop]; + } + const name = prop; + if (!SECRET_NAME.test(name)) { + throw new Error( + `secret name "${name}" is invalid: names must match [A-Za-z_][A-Za-z0-9_]* ` + + `(letters, digits, underscores; no leading digit).`, + ); + } + return { [SECRET_BRAND]: true, name }; + }, + }, +) as Readonly>; diff --git a/crates/hm-dsl-engine/harmont-ts/src/step.ts b/crates/hm-dsl-engine/harmont-ts/src/step.ts index 5803c0cf..482408d6 100644 --- a/crates/hm-dsl-engine/harmont-ts/src/step.ts +++ b/crates/hm-dsl-engine/harmont-ts/src/step.ts @@ -1,10 +1,11 @@ import type { CachePolicy } from "./cache.js"; import { parseDuration } from "./duration.js"; +import type { SecretRef } from "./secret.js"; export interface StepOptions { readonly label?: string; readonly cache?: CachePolicy; - readonly env?: Readonly>; + readonly env?: Readonly>; readonly image?: string; readonly runner?: string; readonly runnerArgs?: Readonly>; @@ -22,7 +23,7 @@ export class Step { readonly _continueOnFailure: boolean; readonly _label: string | undefined; readonly _cache: CachePolicy | undefined; - readonly _env: Readonly> | undefined; + readonly _env: Readonly> | undefined; readonly _timeoutSeconds: number | undefined; readonly _image: string | undefined; readonly _runner: string | undefined; @@ -37,7 +38,7 @@ export class Step { continueOnFailure?: boolean; label?: string; cache?: CachePolicy; - env?: Record; + env?: Record; timeoutSeconds?: number; image?: string; runner?: string; @@ -102,7 +103,7 @@ export class Step { continueOnFailure: this._continueOnFailure, label: this._label, cache: this._cache, - env: this._env as Record | undefined, + env: this._env as Record | undefined, timeoutSeconds: seconds, image: this._image, runner: this._runner, diff --git a/crates/hm-dsl-engine/harmont-ts/tests/secret.test.ts b/crates/hm-dsl-engine/harmont-ts/tests/secret.test.ts new file mode 100644 index 00000000..b2b39c67 --- /dev/null +++ b/crates/hm-dsl-engine/harmont-ts/tests/secret.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { pipeline, sh, secrets, isSecretRef } from "../src/index.js"; + +function nodes(p: unknown) { + return JSON.parse(JSON.stringify(p)).graph.nodes; +} + +describe("secrets", () => { + it("secrets[name] is a reference, not a value", () => { + const ref = secrets["DEPLOY_TOKEN"]; + expect(isSecretRef(ref)).toBe(true); + expect(ref.name).toBe("DEPLOY_TOKEN"); + }); + + it("env secret refs lower into the secrets map", () => { + const p = pipeline([sh("deploy", { env: { TOKEN: secrets["DEPLOY_TOKEN"], CI: "true" } })]); + const n = nodes(p)[0]; + // The DSL also seeds baseline env (DEBIAN_FRONTEND, TERM) — assert our keys. + expect(n.env.CI).toBe("true"); + expect(n.env.TOKEN).toBeUndefined(); + expect(n.secrets).toEqual({ TOKEN: "DEPLOY_TOKEN" }); + expect(n.step.secrets).toEqual({ TOKEN: "DEPLOY_TOKEN" }); + }); + + it("pipeline secrets merge under step secrets", () => { + const p = pipeline([sh("deploy", { env: { TOKEN: secrets["PIPELINE_TOKEN"] } })], { + env: { TOKEN: secrets["GLOBAL_TOKEN"], SHARED: secrets["SHARED_SECRET"] }, + }); + const n = nodes(p)[0]; + expect(n.secrets).toEqual({ TOKEN: "PIPELINE_TOKEN", SHARED: "SHARED_SECRET" }); + }); + + it("rejects invalid secret names", () => { + expect(() => secrets["not valid!"]).toThrow(/secret name/); + }); + + it("does not fabricate refs for symbol access", () => { + expect((secrets as Record)[Symbol.iterator]).toBeUndefined(); + }); +}); diff --git a/tests/e2e/fixtures/ts/cmake-advanced.json b/tests/e2e/fixtures/ts/cmake-advanced.json index b1ccf9b3..dfdc1f7d 100644 --- a/tests/e2e/fixtures/ts/cmake-advanced.json +++ b/tests/e2e/fixtures/ts/cmake-advanced.json @@ -36,6 +36,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -45,7 +46,8 @@ "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" + "label": ":cmake: apt-base", + "secrets": {} } }, { @@ -54,6 +56,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -61,7 +64,8 @@ }, "cmd": "cmake --version && ninja --version && ccache --version && clang-18 --version", "key": "verify", - "label": ":cmake: verify" + "label": ":cmake: verify", + "secrets": {} } }, { @@ -70,6 +74,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -79,7 +84,8 @@ }, "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" + "label": ":cmake: build", + "secrets": {} } }, { @@ -88,10 +94,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cmake --build ./build --parallel $(nproc) && ctest --test-dir ./build --output-on-failure --parallel $(nproc)", "key": "test", - "label": ":cmake: test" + "label": ":cmake: test", + "secrets": {} } }, { @@ -100,10 +108,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd . && run-clang-tidy -p build", "key": "lint", - "label": ":cmake: lint" + "label": ":cmake: lint", + "secrets": {} } }, { @@ -112,10 +122,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "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", "key": "fmt", - "label": ":cmake: fmt" + "label": ":cmake: fmt", + "secrets": {} } } ] diff --git a/tests/e2e/fixtures/ts/kitchen-sink.json b/tests/e2e/fixtures/ts/kitchen-sink.json index e1fcb4ea..0c43d356 100644 --- a/tests/e2e/fixtures/ts/kitchen-sink.json +++ b/tests/e2e/fixtures/ts/kitchen-sink.json @@ -51,6 +51,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -60,7 +61,8 @@ "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" + "label": ":cmake: apt-base", + "secrets": {} } }, { @@ -69,6 +71,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -76,7 +79,8 @@ }, "cmd": "cmake --version && ninja --version && ccache --version", "key": "verify", - "label": ":cmake: verify" + "label": ":cmake: verify", + "secrets": {} } }, { @@ -85,6 +89,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -94,7 +99,8 @@ }, "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" + "label": ":cmake: build", + "secrets": {} } }, { @@ -103,10 +109,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cmake --build infra/agent/build --parallel $(nproc) && ctest --test-dir infra/agent/build --output-on-failure --parallel $(nproc)", "key": "431f35b84318", - "label": ":cmake: test" + "label": ":cmake: test", + "secrets": {} } }, { @@ -115,10 +123,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "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", "key": "fmt", - "label": ":cmake: fmt" + "label": ":cmake: fmt", + "secrets": {} } }, { @@ -127,6 +137,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -136,7 +147,8 @@ "cmd": "apt-get update && apt-get install -y curl ca-certificates python3 python3-venv", "image": "ubuntu:24.04", "key": "c8d9fda86ff3", - "label": ":python: apt-base" + "label": ":python: apt-base", + "secrets": {} } }, { @@ -145,6 +157,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -152,7 +165,8 @@ }, "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" + "label": ":python: uv-install", + "secrets": {} } }, { @@ -161,6 +175,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -171,7 +186,8 @@ }, "cmd": "cd services/web && uv sync --all-extras", "key": "uv-sync", - "label": ":python: uv-sync" + "label": ":python: uv-sync", + "secrets": {} } }, { @@ -180,10 +196,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/web && uv run pytest", "key": "239c4926568b", - "label": ":python: test" + "label": ":python: test", + "secrets": {} } }, { @@ -192,10 +210,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/web && uv run ruff check .", "key": "lint", - "label": ":python: lint" + "label": ":python: lint", + "secrets": {} } } ] diff --git a/tests/e2e/fixtures/ts/monorepo-ci.json b/tests/e2e/fixtures/ts/monorepo-ci.json index ec0ef385..cfd0ac95 100644 --- a/tests/e2e/fixtures/ts/monorepo-ci.json +++ b/tests/e2e/fixtures/ts/monorepo-ci.json @@ -81,6 +81,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -90,7 +91,8 @@ "cmd": "apt-get update && apt-get install -y curl ca-certificates git", "image": "ubuntu:24.04", "key": "334b29e96b76", - "label": ":go: apt-base" + "label": ":go: apt-base", + "secrets": {} } }, { @@ -99,6 +101,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -106,7 +109,8 @@ }, "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" + "label": ":go: install", + "secrets": {} } }, { @@ -115,10 +119,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/api && go build ./...", "key": "6f9493b7219f", - "label": ":go: build" + "label": ":go: build", + "secrets": {} } }, { @@ -127,10 +133,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/api && go test ./...", "key": "1ad6d86b2c0a", - "label": ":go: test" + "label": ":go: test", + "secrets": {} } }, { @@ -139,10 +147,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/api && go vet ./...", "key": "vet", - "label": ":go: vet" + "label": ":go: vet", + "secrets": {} } }, { @@ -151,6 +161,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -160,7 +171,8 @@ "cmd": "apt-get update && apt-get install -y curl ca-certificates python3 python3-venv", "image": "ubuntu:24.04", "key": "c8d9fda86ff3", - "label": ":python: apt-base" + "label": ":python: apt-base", + "secrets": {} } }, { @@ -169,6 +181,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -176,7 +189,8 @@ }, "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" + "label": ":python: uv-install", + "secrets": {} } }, { @@ -185,6 +199,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -195,7 +210,8 @@ }, "cmd": "cd services/ml && uv sync --all-extras", "key": "uv-sync", - "label": ":python: uv-sync" + "label": ":python: uv-sync", + "secrets": {} } }, { @@ -204,10 +220,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/ml && uv run pytest", "key": "847020e744bc", - "label": ":python: test" + "label": ":python: test", + "secrets": {} } }, { @@ -216,10 +234,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/ml && uv run ruff check .", "key": "6c48498afb84", - "label": ":python: lint" + "label": ":python: lint", + "secrets": {} } }, { @@ -228,10 +248,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd services/ml && uv run ty check .", "key": "typecheck", - "label": ":python: typecheck" + "label": ":python: typecheck", + "secrets": {} } }, { @@ -240,6 +262,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -249,7 +272,8 @@ "cmd": "apt-get update && apt-get install -y curl ca-certificates", "image": "ubuntu:24.04", "key": "3c2cfedcad46", - "label": ":node: apt-base" + "label": ":node: apt-base", + "secrets": {} } }, { @@ -258,6 +282,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -265,7 +290,8 @@ }, "cmd": "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs", "key": "ed974519b390", - "label": ":node: install" + "label": ":node: install", + "secrets": {} } }, { @@ -274,6 +300,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -283,7 +310,8 @@ }, "cmd": "cd web && npm ci", "key": "deps", - "label": ":node: deps" + "label": ":node: deps", + "secrets": {} } }, { @@ -292,10 +320,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run build", "key": "a94a0f84e711", - "label": ":node: build" + "label": ":node: build", + "secrets": {} } }, { @@ -304,10 +334,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run test", "key": "d2438adde70d", - "label": ":node: test" + "label": ":node: test", + "secrets": {} } }, { @@ -316,10 +348,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run lint", "key": "74c52c9e5ef6", - "label": ":node: lint" + "label": ":node: lint", + "secrets": {} } } ] diff --git a/tests/e2e/fixtures/ts/rust-release.json b/tests/e2e/fixtures/ts/rust-release.json index 4f9cc69d..1052dccc 100644 --- a/tests/e2e/fixtures/ts/rust-release.json +++ b/tests/e2e/fixtures/ts/rust-release.json @@ -41,6 +41,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -50,7 +51,8 @@ "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" + "label": ":rust: apt-base", + "secrets": {} } }, { @@ -59,6 +61,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -66,7 +69,8 @@ }, "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" + "label": ":rust: rustup", + "secrets": {} } }, { @@ -75,10 +79,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": ". $HOME/.cargo/env && cd . && cargo build --locked", "key": "build", - "label": ":rust: build" + "label": ":rust: build", + "secrets": {} } }, { @@ -87,10 +93,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": ". $HOME/.cargo/env && cd . && cargo test --locked", "key": "test", - "label": ":rust: test" + "label": ":rust: test", + "secrets": {} } }, { @@ -99,10 +107,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": ". $HOME/.cargo/env && cd . && cargo clippy --all-targets --locked -- -D warnings", "key": "clippy", - "label": ":rust: clippy" + "label": ":rust: clippy", + "secrets": {} } }, { @@ -111,10 +121,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": ". $HOME/.cargo/env && cd . && cargo fmt --all --check", "key": "fmt", - "label": ":rust: fmt" + "label": ":rust: fmt", + "secrets": {} } }, { @@ -124,10 +136,12 @@ "RUSTDOCFLAGS": "-D warnings", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": ". $HOME/.cargo/env && cd . && cargo doc --no-deps --locked", "key": "doc", - "label": ":rust: doc" + "label": ":rust: doc", + "secrets": {} } } ] diff --git a/tests/e2e/fixtures/ts/zig-node-polyglot.json b/tests/e2e/fixtures/ts/zig-node-polyglot.json index 0ddbebff..286e23f6 100644 --- a/tests/e2e/fixtures/ts/zig-node-polyglot.json +++ b/tests/e2e/fixtures/ts/zig-node-polyglot.json @@ -61,6 +61,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "duration_seconds": 86400, @@ -70,7 +71,8 @@ "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" + "label": ":apt: base", + "secrets": {} } }, { @@ -79,6 +81,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -86,7 +89,8 @@ }, "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" + "label": ":zig: install", + "secrets": {} } }, { @@ -95,10 +99,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd zig-a && zig build", "key": "zig-a-build", - "label": ":zig: zig-a build" + "label": ":zig: zig-a build", + "secrets": {} } }, { @@ -107,10 +113,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd zig-a && zig build test", "key": "zig-a-test", - "label": ":zig: zig-a test" + "label": ":zig: zig-a test", + "secrets": {} } }, { @@ -119,10 +127,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd zig-b && zig build", "key": "zig-b-build", - "label": ":zig: zig-b build" + "label": ":zig: zig-b build", + "secrets": {} } }, { @@ -131,10 +141,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd zig-b && zig build test", "key": "zig-b-test", - "label": ":zig: zig-b test" + "label": ":zig: zig-b test", + "secrets": {} } }, { @@ -143,6 +155,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "env_keys": [], @@ -150,7 +163,8 @@ }, "cmd": "curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && apt-get install -y nodejs", "key": "a8f0d9d99460", - "label": ":node: install" + "label": ":node: install", + "secrets": {} } }, { @@ -159,6 +173,7 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cache": { "paths": [ @@ -168,7 +183,8 @@ }, "cmd": "cd web && npm ci", "key": "deps", - "label": ":node: deps" + "label": ":node: deps", + "secrets": {} } }, { @@ -177,10 +193,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run build", "key": "build", - "label": ":node: build" + "label": ":node: build", + "secrets": {} } }, { @@ -189,10 +207,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run test", "key": "test", - "label": ":node: test" + "label": ":node: test", + "secrets": {} } }, { @@ -201,10 +221,12 @@ "DEBIAN_FRONTEND": "noninteractive", "TERM": "dumb" }, + "secrets": {}, "step": { "cmd": "cd web && npm run lint", "key": "lint", - "label": ":node: lint" + "label": ":node: lint", + "secrets": {} } } ] From e3531787b9bd4815ee1e70ad7b080e9af38b87db Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 17 Jun 2026 15:45:43 +0000 Subject: [PATCH 4/7] docs(sdk): show hm.secrets usage in hm init templates --- crates/hm/src/commands/init_templates/js.ts | 16 +++++++++++++--- crates/hm/src/commands/init_templates/python.py | 2 ++ 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/crates/hm/src/commands/init_templates/js.ts b/crates/hm/src/commands/init_templates/js.ts index 5cfc833e..8c46e9b6 100644 --- a/crates/hm/src/commands/init_templates/js.ts +++ b/crates/hm/src/commands/init_templates/js.ts @@ -7,9 +7,19 @@ const pipelines: PipelineDefinition[] = [ { slug: "ci", triggers: [push({ branch: "main" })], - pipeline: pipeline([project.run("build"), project.run("test"), project.run("lint")], { - env: { CI: "true" }, - }), + pipeline: pipeline( + [ + project.run("build"), + project.run("test"), + project.run("lint"), + // Reference a secret stored on your org or pipeline (set it with `hm secret set`). + // Import `secrets` from "@harmont/hm" to use this: + // project.install().sh("deploy", { env: { DEPLOY_TOKEN: secrets["DEPLOY_TOKEN"] } }), + ], + { + env: { CI: "true" }, + }, + ), }, ]; diff --git a/crates/hm/src/commands/init_templates/python.py b/crates/hm/src/commands/init_templates/python.py index 6b122046..9c616a34 100644 --- a/crates/hm/src/commands/init_templates/python.py +++ b/crates/hm/src/commands/init_templates/python.py @@ -21,4 +21,6 @@ def ci(project: hm.Target[PythonToolchain]) -> tuple[hm.Step, ...]: project.lint(), project.fmt(), project.typecheck(), + # Reference a secret stored on your org or pipeline (set it with `hm secret set`): + # hm.sh("deploy", env={"DEPLOY_TOKEN": hm.secrets["DEPLOY_TOKEN"]}), ) From 102ee607125b501a0530fcc2c6b215b63b5c9461 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 17 Jun 2026 15:50:54 +0000 Subject: [PATCH 5/7] feat(hm): resolve secret refs locally from .env + process env, fail fast --- crates/hm-exec/Cargo.toml | 1 + crates/hm-exec/src/local/mod.rs | 1 + crates/hm-exec/src/local/scheduler.rs | 58 +++++- crates/hm-exec/src/local/secret_resolver.rs | 209 ++++++++++++++++++++ 4 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 crates/hm-exec/src/local/secret_resolver.rs diff --git a/crates/hm-exec/Cargo.toml b/crates/hm-exec/Cargo.toml index a697b34b..4464bf18 100644 --- a/crates/hm-exec/Cargo.toml +++ b/crates/hm-exec/Cargo.toml @@ -29,6 +29,7 @@ tar = "0.4" flate2 = "1" ignore = "0.4" tempfile = "3" +dotenvy = "0.15" daggy = { workspace = true } [dev-dependencies] diff --git a/crates/hm-exec/src/local/mod.rs b/crates/hm-exec/src/local/mod.rs index a9286543..9355e57d 100644 --- a/crates/hm-exec/src/local/mod.rs +++ b/crates/hm-exec/src/local/mod.rs @@ -10,6 +10,7 @@ mod cache; mod events; pub mod runner; mod scheduler; +mod secret_resolver; mod source; pub use backend::LocalBackend; diff --git a/crates/hm-exec/src/local/scheduler.rs b/crates/hm-exec/src/local/scheduler.rs index 5d49f8d9..2373e49c 100644 --- a/crates/hm-exec/src/local/scheduler.rs +++ b/crates/hm-exec/src/local/scheduler.rs @@ -42,6 +42,7 @@ use uuid::Uuid; use hm_pipeline_ir::{EdgeKind, PipelineGraph, Transition}; use crate::local::runner::{RunnerRegistry, StepContext}; +use crate::local::secret_resolver::{MissingSecret, SecretResolver, resolve_step_env}; use crate::local::source::build_archive_bytes; use crate::{BuildOutcome, BuildStatus, StepResultSummary, StepStatus}; @@ -134,6 +135,11 @@ pub(crate) async fn run( .map_err(|e| crate::BackendError::Local(format!("{e:#}")))?; let archive_id = archives.register(archive_bytes); + // Build the secret resolver once for the whole run, from the project's + // `.env` overlaid by the live process env. Shared across every concurrent + // step task; each step resolves its own `secrets` references against it. + let secret_resolver = Arc::new(SecretResolver::from_project_dir(&repo_root)); + let run_ctx = StepContext { event_bus: bus.clone(), archives: archives.clone(), @@ -186,6 +192,7 @@ pub(crate) async fn run( let bus = bus.clone(); let cancel = cancel.clone(); let run_ctx = run_ctx.clone(); + let secret_resolver = secret_resolver.clone(); let fut: StepFuture = async move { // Await all predecessors. @@ -244,6 +251,7 @@ pub(crate) async fn run( bus, cancel, keep_going, + secret_resolver, ) .await { @@ -386,7 +394,9 @@ async fn execute_step( bus: Arc, cancel: CancellationToken, keep_going: bool, + secret_resolver: Arc, ) -> anyhow::Result { + let secret_refs = transition.secrets; let step_wire = transition.step; let step_key = step_wire.key.clone(); let display_name = step_wire.label.clone().unwrap_or_else(|| { @@ -400,7 +410,6 @@ async fn execute_step( format!("{truncated}…") } }); - let env_map = transition.env; let step_id = Uuid::new_v4(); bus.emit(BuildEvent::StepQueued { @@ -411,6 +420,53 @@ async fn execute_step( display_name: display_name.clone(), }); + // Resolve secret references into the step's env before it reaches the + // container. A reference that can't be resolved is fatal: we fail the + // step (and, unless --keep-going, cancel siblings) with an actionable + // message rather than silently injecting an empty value. + let env_map = match resolve_step_env(transition.env, &secret_refs, &secret_resolver) { + Ok(env) => env, + Err(MissingSecret { + env_var, + secret_name, + }) => { + let message = format!( + "secret \"{secret_name}\" (referenced by env var \"{env_var}\") was not found.\n\ + Set it in a `.env` file in your project directory or export it in your shell, then re-run.\n \ + e.g. echo '{secret_name}=...' >> .env" + ); + bus.emit(BuildEvent::StepEnd { + step_id, + exit_code: 1, + duration_ms: 0, + snapshot: None, + }); + bus.emit(BuildEvent::ChainFailed { + chain_idx: chain_id, + failed_step_id: step_id, + failed_step_key: step_key.clone(), + exit_code: 1, + message, + ts: chrono::Utc::now(), + }); + if !keep_going { + cancel.cancel(); + } + return Ok(StepOutcome { + exit_code: 1, + snapshot: None, + summary: Some(StepResultSummary { + step_id, + key: step_key.clone(), + status: StepStatus::Failed, + exit_code: Some(1), + duration_ms: 0, + }), + failed_or_skipped: true, + }); + } + }; + // Compute the cache lookup for the runner. The runner (VmRunner) // handles cache hit/miss internally via ImageRegistry. let cache_tag = cache::stable_cache_tag(&step_wire); diff --git a/crates/hm-exec/src/local/secret_resolver.rs b/crates/hm-exec/src/local/secret_resolver.rs new file mode 100644 index 00000000..3a89dd29 --- /dev/null +++ b/crates/hm-exec/src/local/secret_resolver.rs @@ -0,0 +1,209 @@ +//! Local secret resolution for `hm run`. +//! +//! A pipeline node carries a `secrets` map (env-var-name -> secret-name) +//! alongside its literal `env`. For a local run those references are +//! resolved to values from a project `.env` file, overlaid by the live +//! process environment (process env wins). A reference that cannot be +//! resolved is a hard, fail-fast error — we never inject an empty value. + +use std::collections::BTreeMap; +use std::path::Path; + +/// Resolves secret references for a local `hm run` from a project `.env` file +/// overlaid by the process environment (process env wins). +#[derive(Debug, Clone)] +pub(crate) struct SecretResolver { + dotenv: BTreeMap, + proc_env: BTreeMap, +} + +/// A secret reference that could not be resolved from any source. +/// +/// Carries both the referencing env var and the missing secret name so the +/// surfaced error can name precisely what failed and how to fix it. +#[derive(Debug, Clone)] +pub(crate) struct MissingSecret { + pub env_var: String, + pub secret_name: String, +} + +impl SecretResolver { + /// Construct a resolver from explicit `.env` and process-env maps. + /// + /// Test-only constructor; production code builds via + /// [`SecretResolver::from_project_dir`]. + #[cfg(test)] + pub(crate) const fn new( + dotenv: BTreeMap, + proc_env: BTreeMap, + ) -> Self { + Self { dotenv, proc_env } + } + + /// Parse `/.env` if present; read the live process env. + /// + /// A missing or unreadable `.env` is not an error — secrets may all come + /// from the process environment. Malformed lines in `.env` are skipped + /// (via `flatten`) rather than aborting the run; a referenced secret that + /// ends up unresolved still fails fast at [`Self::resolve`]. + pub(crate) fn from_project_dir(dir: &Path) -> Self { + let mut dotenv = BTreeMap::new(); + let path = dir.join(".env"); + if path.exists() + && let Ok(iter) = dotenvy::from_path_iter(&path) + { + for (k, v) in iter.flatten() { + dotenv.insert(k, v); + } + } + Self { + dotenv, + proc_env: std::env::vars().collect(), + } + } + + /// Look up a secret by name. Process env wins over `.env`. + pub(crate) fn get(&self, name: &str) -> Option { + self.proc_env + .get(name) + .or_else(|| self.dotenv.get(name)) + .cloned() + } + + /// Resolve env-var -> secret-name refs into env-var -> value. Fails fast on + /// the first missing secret (`BTreeMap` iteration order is stable, so the + /// reported missing secret is deterministic). + /// + /// # Errors + /// Returns [`MissingSecret`] for the first reference whose secret name is + /// not found in either the process env or the `.env` file. + pub(crate) fn resolve( + &self, + refs: &BTreeMap, + ) -> Result, MissingSecret> { + let mut out = BTreeMap::new(); + for (env_var, secret_name) in refs { + match self.get(secret_name) { + Some(v) => { + out.insert(env_var.clone(), v); + } + None => { + return Err(MissingSecret { + env_var: env_var.clone(), + secret_name: secret_name.clone(), + }); + } + } + } + Ok(out) + } +} + +/// Merge resolved secrets into a step's literal env map. +/// +/// `env` is the step's already-merged literal environment; `secrets` is the +/// node's env-var -> secret-name reference map. On success the resolved +/// secret values are layered on top of `env` (a secret reference wins over a +/// same-named literal, matching the IR's "secrets resolved at run time" +/// contract) and the merged map is returned. +/// +/// # Errors +/// Returns a [`MissingSecret`] when any referenced secret is unresolved, so +/// the caller can abort the step rather than silently inject an empty value. +pub(crate) fn resolve_step_env( + mut env: BTreeMap, + secrets: &BTreeMap, + resolver: &SecretResolver, +) -> Result, MissingSecret> { + let values = resolver.resolve(secrets)?; + env.extend(values); + Ok(env) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + #[test] + fn resolves_from_dotenv_then_process_env() { + let dotenv: BTreeMap = + [("DEPLOY_TOKEN".into(), "from-dotenv".into())].into(); + let proc_env: BTreeMap = [ + ("DEPLOY_TOKEN".into(), "from-proc".into()), + ("OTHER".into(), "x".into()), + ] + .into(); + let resolver = SecretResolver::new(dotenv, proc_env); + assert_eq!(resolver.get("DEPLOY_TOKEN").as_deref(), Some("from-proc")); // process wins + assert_eq!(resolver.get("MISSING"), None); + } + + #[test] + fn falls_back_to_dotenv_when_not_in_process_env() { + let dotenv: BTreeMap = [("ONLY_DOTENV".into(), "v".into())].into(); + let resolver = SecretResolver::new(dotenv, BTreeMap::new()); + assert_eq!(resolver.get("ONLY_DOTENV").as_deref(), Some("v")); + } + + #[test] + fn resolve_refs_injects_values_or_reports_first_missing() { + let resolver = SecretResolver::new([("A".into(), "av".into())].into(), BTreeMap::new()); + let mut refs = BTreeMap::new(); + refs.insert("VAR_A".to_string(), "A".to_string()); + assert_eq!( + resolver.resolve(&refs).unwrap().get("VAR_A").map(String::as_str), + Some("av") + ); + + refs.insert("VAR_B".to_string(), "B".to_string()); + let err = resolver.resolve(&refs).unwrap_err(); + assert_eq!(err.secret_name, "B"); + assert_eq!(err.env_var, "VAR_B"); + } + + #[test] + fn resolve_step_env_merges_resolved_secrets_into_env() { + let resolver = SecretResolver::new([("TOK".into(), "sekret".into())].into(), BTreeMap::new()); + let env: BTreeMap = [("PLAIN".into(), "p".into())].into(); + let secrets: BTreeMap = [("DEPLOY_TOKEN".into(), "TOK".into())].into(); + + let merged = resolve_step_env(env, &secrets, &resolver).unwrap(); + assert_eq!(merged.get("PLAIN").map(String::as_str), Some("p")); + assert_eq!(merged.get("DEPLOY_TOKEN").map(String::as_str), Some("sekret")); + } + + #[test] + fn resolve_step_env_errors_naming_both_var_and_secret() { + let resolver = SecretResolver::new(BTreeMap::new(), BTreeMap::new()); + let secrets: BTreeMap = [("DEPLOY_TOKEN".into(), "MISSING".into())].into(); + + let err = resolve_step_env(BTreeMap::new(), &secrets, &resolver).unwrap_err(); + assert_eq!(err.env_var, "DEPLOY_TOKEN"); + assert_eq!(err.secret_name, "MISSING"); + } + + #[test] + fn empty_refs_is_a_no_op() { + let resolver = SecretResolver::new(BTreeMap::new(), BTreeMap::new()); + let env: BTreeMap = [("A".into(), "1".into())].into(); + let merged = resolve_step_env(env.clone(), &BTreeMap::new(), &resolver).unwrap(); + assert_eq!(merged, env); + } + + #[test] + fn from_project_dir_reads_dotenv_file() { + let dir = tempfile::tempdir().unwrap(); + std::fs::write(dir.path().join(".env"), "FROM_FILE=hello\n").unwrap(); + let resolver = SecretResolver::from_project_dir(dir.path()); + assert_eq!(resolver.get("FROM_FILE").as_deref(), Some("hello")); + } + + #[test] + fn from_project_dir_without_dotenv_is_fine() { + let dir = tempfile::tempdir().unwrap(); + let resolver = SecretResolver::from_project_dir(dir.path()); + assert_eq!(resolver.get("NOPE"), None); + } +} From c339fcf63d70ed828571a21486382f5847cc3725 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 17 Jun 2026 16:41:16 +0000 Subject: [PATCH 6/7] feat(hm): hm cloud secret set/list/rm for org and pipeline scope --- Cargo.lock | 11 + crates/hm-plugin-cloud/Cargo.toml | 6 + crates/hm-plugin-cloud/src/cli.rs | 37 ++ crates/hm-plugin-cloud/src/settings.rs | 25 ++ crates/hm-plugin-cloud/src/verbs/mod.rs | 1 + crates/hm-plugin-cloud/src/verbs/secret.rs | 500 +++++++++++++++++++++ crates/hm/tests/cmd_cloud_gate.rs | 29 ++ 7 files changed, 609 insertions(+) create mode 100644 crates/hm-plugin-cloud/src/verbs/secret.rs diff --git a/Cargo.lock b/Cargo.lock index b8ef7f44..b42237a3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -702,6 +702,12 @@ version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "780955b8b195a21ab8e4ac6b60dd1dbdcec1dc6c51c0617964b08c81785e12c9" +[[package]] +name = "dotenvy" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" + [[package]] name = "dunce" version = "1.0.5" @@ -1259,6 +1265,7 @@ dependencies = [ "async-trait", "chrono", "daggy", + "dotenvy", "flate2", "futures", "futures-util", @@ -1306,7 +1313,11 @@ dependencies = [ "hm-exec", "hm-plugin-protocol", "hm-render", + "percent-encoding", + "reqwest", + "serde", "serde_json", + "tempfile", "tokio", "tracing", "uuid", diff --git a/crates/hm-plugin-cloud/Cargo.toml b/crates/hm-plugin-cloud/Cargo.toml index 97d20a07..2ee55bc7 100644 --- a/crates/hm-plugin-cloud/Cargo.toml +++ b/crates/hm-plugin-cloud/Cargo.toml @@ -14,6 +14,7 @@ path = "src/lib.rs" [dependencies] harmont-cloud = { workspace = true } harmont-cloud-raw = { workspace = true } +serde = { workspace = true } hm-exec = { workspace = true } hm-render = { workspace = true } hm-config = { workspace = true } @@ -24,10 +25,15 @@ uuid = { workspace = true } clap = { version = "4", features = ["derive"] } anyhow = "1" base64 = "0.22" +percent-encoding = "2.3" tokio = { version = "1", features = ["net", "rt", "time", "sync", "macros"] } webbrowser = "1" dialoguer = "0.11" tracing = { workspace = true } +reqwest = { version = "0.13", default-features = false, features = ["rustls", "http2", "json"] } + +[dev-dependencies] +tempfile = "3" [lints] workspace = true diff --git a/crates/hm-plugin-cloud/src/cli.rs b/crates/hm-plugin-cloud/src/cli.rs index bb2b519a..37167cd6 100644 --- a/crates/hm-plugin-cloud/src/cli.rs +++ b/crates/hm-plugin-cloud/src/cli.rs @@ -68,10 +68,46 @@ pub enum CloudCommand { /// Manage credits, top-ups, and usage. #[command(subcommand)] Billing(BillingCommand), + /// Manage org- and pipeline-scoped CI secrets. + #[command(subcommand)] + Secret(SecretCommand), /// Submit the local pipeline to the cloud and watch its build. Run(verbs::run::RunArgs), } +#[derive(Debug, Clone, Subcommand)] +pub enum SecretCommand { + /// Set (create or overwrite) a secret. The value is write-only — the + /// API never returns it, so neither does this CLI. + Set { + /// Secret name (e.g. `DEPLOY_TOKEN`). + name: String, + /// The secret value. Pass `-` to read the value from stdin. + /// Omit when using `--from-file`. + value: Option, + /// Read the value from a file (a single trailing newline is trimmed). + #[arg(long, value_name = "PATH")] + from_file: Option, + /// Scope to a pipeline instead of the whole organization. + #[arg(long, value_name = "SLUG")] + pipeline: Option, + }, + /// List secret names (and timestamps). Never prints values. + List { + /// Scope to a pipeline instead of the whole organization. + #[arg(long, value_name = "SLUG")] + pipeline: Option, + }, + /// Remove a secret. + Rm { + /// Secret name to remove. + name: String, + /// Scope to a pipeline instead of the whole organization. + #[arg(long, value_name = "SLUG")] + pipeline: Option, + }, +} + #[derive(Debug, Clone, Subcommand)] pub enum OrgCommand { /// Set the active organization. @@ -213,6 +249,7 @@ pub async fn dispatch_command(command: CloudCommand, env: BTreeMap verbs::build::run(&env, cmd).await, CloudCommand::Job(cmd) => verbs::job::run(&env, cmd).await, CloudCommand::Billing(cmd) => verbs::billing::run(&env, cmd).await, + CloudCommand::Secret(cmd) => verbs::secret::run(&env, cmd).await, CloudCommand::Run(args) => verbs::run::run(&env, args).await, }; match result { diff --git a/crates/hm-plugin-cloud/src/settings.rs b/crates/hm-plugin-cloud/src/settings.rs index d3a2a2bc..17e91274 100644 --- a/crates/hm-plugin-cloud/src/settings.rs +++ b/crates/hm-plugin-cloud/src/settings.rs @@ -57,6 +57,31 @@ pub fn client() -> Result<(HarmontClient, ResolvedCtx)> { )) } +/// Resolve `(api base, bearer token, org slug)` for verbs that make raw +/// HTTP calls instead of going through the generated SDK client (e.g. the +/// secret verbs, whose endpoints the generated client doesn't carry yet). +/// +/// Shares the exact config/credential/org resolution — and the +/// not-logged-in and org-missing error strings — with [`client`] and +/// [`ResolvedCtx::org`], so those messages live in one place. +/// +/// # Errors +/// +/// Returns an error if config can't be loaded, no token is available, or no +/// organization is configured. +pub fn raw_org_ctx() -> Result<(String, String, String)> { + let cfg = hm_config::Config::load(None).context("loading config")?; + let api = cfg.cloud.api_url.clone(); + let token = hm_config::creds::cloud_token(&api) + .context("not logged in — run `hm cloud login` or set HM_API_TOKEN")?; + let org = ResolvedCtx { + api: api.clone(), + org: cfg.cloud.org, + } + .org()?; + Ok((api, token, org)) +} + /// An anonymous client (for the login flow) + the resolved API base. /// /// # Errors diff --git a/crates/hm-plugin-cloud/src/verbs/mod.rs b/crates/hm-plugin-cloud/src/verbs/mod.rs index f8a9a693..d6e5c43c 100644 --- a/crates/hm-plugin-cloud/src/verbs/mod.rs +++ b/crates/hm-plugin-cloud/src/verbs/mod.rs @@ -8,3 +8,4 @@ pub(crate) mod job; pub(crate) mod org; pub(crate) mod pipeline; pub(crate) mod run; +pub(crate) mod secret; diff --git a/crates/hm-plugin-cloud/src/verbs/secret.rs b/crates/hm-plugin-cloud/src/verbs/secret.rs new file mode 100644 index 00000000..61327803 --- /dev/null +++ b/crates/hm-plugin-cloud/src/verbs/secret.rs @@ -0,0 +1,500 @@ +//! `hm cloud secret set|list|rm` — manage org- and pipeline-scoped CI +//! secrets. +//! +//! Secret *values* are write-only: the API never returns them, so `list` +//! shows names and timestamps only. The CLI mirrors that — it never prints +//! a value back, even the one it just set. +//! +//! The published `harmont-cloud` SDK does not (yet) expose secret +//! operations, so these verbs make the authenticated HTTP calls directly, +//! reusing the same config/credential resolution the SDK-backed verbs use +//! (`hm_config` for the API base + active org, `hm_config::creds` for the +//! bearer token). No new auth stack — just a thin reqwest call for the +//! endpoints the generated client doesn't carry. + +use std::collections::BTreeMap; +use std::io::Read; +use std::path::Path; + +use anyhow::{Context, Result, bail}; +use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode}; + +use crate::cli::SecretCommand; + +/// Characters to percent-encode in a single URL path segment. +/// +/// RFC 3986 leaves the *unreserved* set (`A-Z a-z 0-9 - . _ ~`) safe in a +/// path segment; everything else is escaped. We start from [`CONTROLS`] and +/// add every printable ASCII byte that is reserved, a delimiter, or +/// otherwise unsafe in a path — notably `/` and space, which must survive +/// as `%2F`/`%20` so a name like `a/b c` can't escape its scope. Secret +/// names are conventionally `[A-Za-z0-9_]`, but we don't assume that. +const SEGMENT: &AsciiSet = &CONTROLS + .add(b' ') + .add(b'!') + .add(b'"') + .add(b'#') + .add(b'$') + .add(b'%') + .add(b'&') + .add(b'\'') + .add(b'(') + .add(b')') + .add(b'*') + .add(b'+') + .add(b',') + .add(b'/') + .add(b':') + .add(b';') + .add(b'<') + .add(b'=') + .add(b'>') + .add(b'?') + .add(b'@') + .add(b'[') + .add(b'\\') + .add(b']') + .add(b'^') + .add(b'`') + .add(b'{') + .add(b'|') + .add(b'}'); + +/// Where the secret value comes from on `set`. +/// +/// Exactly one source is allowed; the resolver rejects none and ambiguity. +#[derive(Debug, Clone)] +struct ValueSources<'a> { + /// The positional `VALUE` argument, if given. `Some("-")` means stdin. + positional: Option<&'a str>, + /// The `--from-file ` argument, if given. + from_file: Option<&'a Path>, +} + +/// Build the REST path for the secrets collection, scoped to the org or to +/// a pipeline within it. +/// +/// - org scope: `/api/v0/organizations/{org}/secrets` +/// - pipeline scope: `/api/v0/organizations/{org}/pipelines/{pipeline}/secrets` +fn secrets_path(org: &str, pipeline: Option<&str>) -> String { + match pipeline { + Some(p) => format!("/api/v0/organizations/{org}/pipelines/{p}/secrets"), + None => format!("/api/v0/organizations/{org}/secrets"), + } +} + +/// Append a URL-path-escaped secret name to a collection path. +fn secret_item_path(collection: &str, name: &str) -> String { + format!("{collection}/{}", utf8_percent_encode(name, SEGMENT)) +} + +/// Resolve the secret value from exactly one of: positional `VALUE`, +/// `--from-file`, or stdin (`VALUE` == `-`). +/// +/// `read_stdin` is injected so the non-stdin branches are unit-testable; in +/// production it reads the real stdin. +/// +/// Trailing newline handling: a single trailing `\n` (or `\r\n`) is trimmed +/// from file and stdin sources, since editors and `echo` append one and a +/// secret almost never wants it. The positional `VALUE` is taken verbatim. +/// +/// # Errors +/// +/// Returns an error if no source is given, if both a positional value and +/// `--from-file` are given (ambiguous), or if the file/stdin read fails. +fn resolve_value( + sources: &ValueSources<'_>, + read_stdin: impl FnOnce() -> Result, +) -> Result { + match (sources.positional, sources.from_file) { + (Some(_), Some(_)) => bail!( + "ambiguous secret value: pass either VALUE or --from-file, not both\n \u{2192} drop one source" + ), + (None, None) => bail!( + "no secret value: pass VALUE, `-` to read stdin, or --from-file \n \u{2192} e.g. `hm cloud secret set TOKEN abc123` or `--from-file ./token.txt`" + ), + (Some("-"), None) => { + let raw = read_stdin().context("reading secret value from stdin")?; + Ok(trim_one_trailing_newline(&raw).to_string()) + } + (Some(v), None) => Ok(v.to_string()), + (None, Some(path)) => { + let raw = std::fs::read_to_string(path) + .with_context(|| format!("reading secret value from {}", path.display()))?; + Ok(trim_one_trailing_newline(&raw).to_string()) + } + } +} + +/// Strip a single trailing `\n` or `\r\n`, leaving any other whitespace +/// intact (a secret may legitimately contain interior or leading spaces). +fn trim_one_trailing_newline(s: &str) -> &str { + s.strip_suffix('\n') + .map(|s| s.strip_suffix('\r').unwrap_or(s)) + .unwrap_or(s) +} + +/// Entry point dispatched from `cli::dispatch_command`. +pub(crate) async fn run(_env: &BTreeMap, cmd: SecretCommand) -> Result<()> { + // Share config/credential/org resolution — and its error strings — with + // the SDK-backed verbs (`settings::client` / `ResolvedCtx::org`). These + // verbs need the raw token + API base for direct reqwest calls, so they + // use `raw_org_ctx` rather than building an SDK client. + let (api, token, org) = crate::settings::raw_org_ctx()?; + + match cmd { + SecretCommand::Set { + name, + value, + from_file, + pipeline, + } => { + set( + &api, + &token, + &org, + pipeline.as_deref(), + &name, + value.as_deref(), + from_file.as_deref(), + ) + .await + } + SecretCommand::List { pipeline } => { + list(&api, &token, &org, pipeline.as_deref()).await + } + SecretCommand::Rm { name, pipeline } => { + rm(&api, &token, &org, pipeline.as_deref(), &name).await + } + } +} + +/// Build an authenticated reqwest client carrying the bearer token. +fn http_client(token: &str) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + let mut auth = reqwest::header::HeaderValue::from_str(&format!("Bearer {token}")) + .context("API token contains characters invalid for an Authorization header")?; + auth.set_sensitive(true); + headers.insert(reqwest::header::AUTHORIZATION, auth); + reqwest::Client::builder() + .default_headers(headers) + .build() + .context("building HTTP client") +} + +/// Turn a non-success response into a readable error in the house shape +/// (`\n → `), honoring the status semantics from PRINCIPLES.md. +/// +/// The cloud dispatcher (`cli::dispatch_command`) is anyhow-based and maps +/// every error to the generic runtime exit code, so this cannot itself +/// select the semantic auth/API exit codes (3/5). What it *can* do — and +/// does — is make the 401 message text match the existing not-logged-in +/// path (`settings::client` / `raw_org_ctx`) so an expired/absent token +/// reads consistently regardless of where it's detected, and add a `→` fix +/// line. See the module-level note and [`from_status`]. +async fn into_api_error(resp: reqwest::Response) -> anyhow::Error { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + from_status(status, &body) +} + +/// Pure status-to-error mapping, split out from [`into_api_error`] so it is +/// unit-testable without spinning up an HTTP server. +fn from_status(status: reqwest::StatusCode, body: &str) -> anyhow::Error { + use reqwest::StatusCode; + + let server_message = || { + let parsed: serde_json::Value = + serde_json::from_str(body).unwrap_or(serde_json::Value::Null); + let obj = parsed.get("error").cloned().unwrap_or(parsed); + obj.get("message") + .and_then(|m| m.as_str()) + .map(ToOwned::to_owned) + }; + + match status { + // Match the not-logged-in text used by `settings::raw_org_ctx` so an + // expired/rejected token reads the same wherever it surfaces. + StatusCode::UNAUTHORIZED => anyhow::anyhow!( + "not logged in — your token was rejected\n \u{2192} run `hm cloud login`" + ), + StatusCode::NOT_FOUND => anyhow::anyhow!( + "secret or scope not found\n \u{2192} check the secret name and `--pipeline` scope, or `hm cloud secret list`" + ), + _ => { + let detail = server_message().unwrap_or_else(|| { + if body.is_empty() { + status.as_str().to_owned() + } else { + body.to_owned() + } + }); + anyhow::anyhow!("API error ({}): {detail}", status.as_u16()) + } + } +} + +async fn set( + api: &str, + token: &str, + org: &str, + pipeline: Option<&str>, + name: &str, + value: Option<&str>, + from_file: Option<&Path>, +) -> Result<()> { + let resolved = resolve_value( + &ValueSources { + positional: value, + from_file, + }, + read_stdin, + )?; + + let url = format!("{api}{}", secrets_path(org, pipeline)); + let client = http_client(token)?; + let resp = client + .post(&url) + .json(&serde_json::json!({ "name": name, "value": resolved })) + .send() + .await + .context("sending request")?; + if !resp.status().is_success() { + return Err(into_api_error(resp).await); + } + let scope = pipeline.map_or_else(|| format!("org {org}"), |p| format!("pipeline {p}")); + tracing::info!("set secret {name} ({scope})"); + Ok(()) +} + +async fn list(api: &str, token: &str, org: &str, pipeline: Option<&str>) -> Result<()> { + #[derive(serde::Deserialize)] + struct Secret { + name: String, + updated_at: Option, + } + #[derive(serde::Deserialize)] + struct Listing { + secrets: Vec, + } + + let url = format!("{api}{}", secrets_path(org, pipeline)); + let client = http_client(token)?; + let resp = client.get(&url).send().await.context("sending request")?; + if !resp.status().is_success() { + return Err(into_api_error(resp).await); + } + let listing: Listing = resp.json().await.context("decoding response")?; + if listing.secrets.is_empty() { + tracing::info!("No secrets."); + return Ok(()); + } + for s in &listing.secrets { + match &s.updated_at { + Some(ts) => tracing::info!("{:<32} {ts}", s.name), + None => tracing::info!("{}", s.name), + } + } + Ok(()) +} + +async fn rm(api: &str, token: &str, org: &str, pipeline: Option<&str>, name: &str) -> Result<()> { + let collection = secrets_path(org, pipeline); + let url = format!("{api}{}", secret_item_path(&collection, name)); + let client = http_client(token)?; + let resp = client.delete(&url).send().await.context("sending request")?; + if !resp.status().is_success() { + return Err(into_api_error(resp).await); + } + tracing::info!("removed secret {name}"); + Ok(()) +} + +/// Read all of stdin to a `String`. +fn read_stdin() -> Result { + let mut buf = String::new(); + std::io::stdin() + .read_to_string(&mut buf) + .context("reading stdin")?; + Ok(buf) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] +mod tests { + use super::*; + + #[test] + fn path_org_scope() { + assert_eq!( + secrets_path("acme", None), + "/api/v0/organizations/acme/secrets" + ); + } + + #[test] + fn path_pipeline_scope() { + assert_eq!( + secrets_path("acme", Some("ci")), + "/api/v0/organizations/acme/pipelines/ci/secrets" + ); + } + + #[test] + fn item_path_appends_encoded_name() { + let coll = secrets_path("acme", None); + assert_eq!( + secret_item_path(&coll, "DEPLOY_TOKEN"), + "/api/v0/organizations/acme/secrets/DEPLOY_TOKEN" + ); + } + + #[test] + fn item_path_escapes_funny_names() { + let coll = secrets_path("acme", Some("ci")); + assert_eq!( + secret_item_path(&coll, "a/b c"), + "/api/v0/organizations/acme/pipelines/ci/secrets/a%2Fb%20c" + ); + } + + fn no_stdin() -> Result { + bail!("stdin must not be read in this branch") + } + + #[test] + fn resolve_positional_verbatim() { + let sources = ValueSources { + positional: Some("hunter2"), + from_file: None, + }; + assert_eq!(resolve_value(&sources, no_stdin).unwrap(), "hunter2"); + } + + #[test] + fn resolve_positional_keeps_interior_spaces() { + let sources = ValueSources { + positional: Some(" spaced value "), + from_file: None, + }; + // Positional is verbatim — no trimming at all. + assert_eq!( + resolve_value(&sources, no_stdin).unwrap(), + " spaced value " + ); + } + + #[test] + fn resolve_from_file_trims_one_trailing_newline() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token.txt"); + std::fs::write(&path, "abc123\n").unwrap(); + let sources = ValueSources { + positional: None, + from_file: Some(&path), + }; + assert_eq!(resolve_value(&sources, no_stdin).unwrap(), "abc123"); + } + + #[test] + fn resolve_from_file_trims_crlf() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token.txt"); + std::fs::write(&path, "abc123\r\n").unwrap(); + let sources = ValueSources { + positional: None, + from_file: Some(&path), + }; + assert_eq!(resolve_value(&sources, no_stdin).unwrap(), "abc123"); + } + + #[test] + fn resolve_from_file_keeps_interior_newlines() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("key.pem"); + std::fs::write(&path, "line1\nline2\n").unwrap(); + let sources = ValueSources { + positional: None, + from_file: Some(&path), + }; + assert_eq!(resolve_value(&sources, no_stdin).unwrap(), "line1\nline2"); + } + + #[test] + fn resolve_stdin_dash_trims_trailing_newline() { + let sources = ValueSources { + positional: Some("-"), + from_file: None, + }; + let got = resolve_value(&sources, || Ok("from-stdin\n".to_string())).unwrap(); + assert_eq!(got, "from-stdin"); + } + + #[test] + fn resolve_errors_when_no_source() { + let sources = ValueSources { + positional: None, + from_file: None, + }; + let err = resolve_value(&sources, no_stdin).unwrap_err(); + assert!(err.to_string().contains("no secret value")); + } + + #[test] + fn resolve_errors_when_ambiguous() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("token.txt"); + std::fs::write(&path, "abc").unwrap(); + let sources = ValueSources { + positional: Some("inline"), + from_file: Some(&path), + }; + let err = resolve_value(&sources, no_stdin).unwrap_err(); + assert!(err.to_string().contains("ambiguous")); + } + + #[test] + fn resolve_missing_file_errors() { + let sources = ValueSources { + positional: None, + from_file: Some(Path::new("/nonexistent/secret/path/xyz")), + }; + assert!(resolve_value(&sources, no_stdin).is_err()); + } + + #[test] + fn unreserved_name_is_not_encoded() { + let coll = secrets_path("acme", None); + // RFC 3986 unreserved chars (incl. `-` `_` `.` `~`) must pass through. + assert_eq!( + secret_item_path(&coll, "DEPLOY-TOKEN_v2.0~rc"), + "/api/v0/organizations/acme/secrets/DEPLOY-TOKEN_v2.0~rc" + ); + } + + #[test] + fn error_401_matches_not_logged_in_text() { + let err = from_status(reqwest::StatusCode::UNAUTHORIZED, ""); + let msg = err.to_string(); + // Consistent with the `settings::raw_org_ctx` not-logged-in path. + assert!(msg.contains("not logged in"), "got: {msg}"); + assert!(msg.contains("hm cloud login"), "got: {msg}"); + } + + #[test] + fn error_404_is_not_found_with_hint() { + let err = from_status(reqwest::StatusCode::NOT_FOUND, ""); + let msg = err.to_string(); + assert!(msg.contains("not found"), "got: {msg}"); + assert!(msg.contains('\u{2192}'), "expected a fix hint, got: {msg}"); + } + + #[test] + fn error_other_surfaces_status_and_server_message() { + let body = r#"{"error":{"message":"name already taken"}}"#; + let err = from_status(reqwest::StatusCode::CONFLICT, body); + let msg = err.to_string(); + assert!(msg.contains("409"), "got: {msg}"); + assert!(msg.contains("name already taken"), "got: {msg}"); + } +} diff --git a/crates/hm/tests/cmd_cloud_gate.rs b/crates/hm/tests/cmd_cloud_gate.rs index 92018d72..2ffda6e4 100644 --- a/crates/hm/tests/cmd_cloud_gate.rs +++ b/crates/hm/tests/cmd_cloud_gate.rs @@ -45,5 +45,34 @@ fn cloud_help_lists_real_subcommands_without_waitlist_text() { .stdout(contains("login")) .stdout(contains("whoami")) .stdout(contains("build")) + .stdout(contains("secret")) .stdout(predicates::str::contains("not yet available").not()); } + +/// `hm cloud secret --help` must advertise the three verbs. +#[test] +fn cloud_secret_help_lists_verbs() { + Command::cargo_bin("hm") + .unwrap() + .args(["cloud", "secret", "--help"]) + .assert() + .success() + .stdout(contains("set")) + .stdout(contains("list")) + .stdout(contains("rm")); +} + +/// An unauthenticated `hm cloud secret list` must fail fast with the +/// login hint (proves the verb is wired through the shared auth path). +#[test] +fn cloud_secret_list_unauthed_fails_with_login_hint() { + let tmp = tempfile::tempdir().unwrap(); + Command::cargo_bin("hm") + .unwrap() + .args(["cloud", "secret", "list"]) + .env("HOME", tmp.path()) + .env_remove("HM_API_TOKEN") + .assert() + .failure() + .stderr(contains("not logged in")); +} From 86836dc4a4c09e807073fa346679f6e9cefbb043 Mon Sep 17 00:00:00 2001 From: Marko Vejnovic Date: Wed, 17 Jun 2026 17:58:58 +0000 Subject: [PATCH 7/7] fix(hm-exec): skip ExecutorInput in tracing span so secret env values aren't logged --- crates/hm-exec/src/local/runner/vm.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/hm-exec/src/local/runner/vm.rs b/crates/hm-exec/src/local/runner/vm.rs index 325634fc..bb9caf31 100644 --- a/crates/hm-exec/src/local/runner/vm.rs +++ b/crates/hm-exec/src/local/runner/vm.rs @@ -66,7 +66,9 @@ impl StepRunner for VmRunner { } } -#[tracing::instrument(skip(vm, ctx), fields(step_key = %input.step.key))] +// `input` is skipped: its `env` map holds resolved secret values, which must +// never be Debug-recorded into a span field. Only the safe `step_key` is logged. +#[tracing::instrument(skip(vm, ctx, input), 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 } => {