Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/skills/convert-gha/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ Convert existing GitHub Actions workflows (`.github/workflows/*.yml` / `*.yaml`)
| `actions/cache` | **Not needed — caching is implicit in Harmont** | Harmont automatically caches build artifacts, dependency installs, and toolchain outputs between runs. Remove all cache steps. |
| `actions/setup-*` (setup-node, setup-python, etc.) | Harmont toolchains (`hm.js`, `hm.python`, etc.) | Toolchains handle installation. Specify version via toolchain config. |
| `actions/checkout` | **Not needed — source is always available** | Harmont automatically provides the source code to every step. |
| `runs-on: ubuntu-latest` | `default_image: "ubuntu:24.04"` | Harmont runs steps in Docker containers |
| `runs-on: ubuntu-latest` | (default base is `ubuntu:24.04`; set a per-step `image="..."` to override) | Harmont runs steps in Docker containers |
| `services:` (e.g., postgres) | Service containers in step config | Check docs for service container syntax |
| `matrix:` | Multiple pipelines or parameterized steps | No direct matrix — may need separate pipeline definitions or `.fork()` |
| `env:` / `secrets.*` | `env: {}` on pipeline or step | Secrets must be passed as environment variables |
Expand Down
2 changes: 1 addition & 1 deletion .claude/skills/write-pipeline/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Write, modify, or extend Harmont CI pipelines defined in `.hm/pipeline.py` (Pyth
- Prefer toolchains over raw `sh()` calls when a toolchain exists for the language.
- Use `.fork()` for steps that can run in parallel.
- Set triggers (`push`, `pull_request`) appropriate to the project.
- Use `default_image: "ubuntu:24.04"` unless the project needs something specific.
- Steps run on `ubuntu:24.04` by default; set a per-step `image="..."` only when a specific step needs a different base.
- Set `env: {"CI": "true"}` on the pipeline.

5. **Validate the pipeline renders correctly:**
Expand Down
4 changes: 1 addition & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,6 @@ def project() -> PythonToolchain:

@hm.pipeline(
"ci",
default_image="ubuntu:24.04",
triggers=[hm.push(branch="main")],
)
def ci(project: hm.Target[PythonToolchain]) -> tuple[hm.Step, ...]:
Expand Down Expand Up @@ -148,7 +147,6 @@ const pipelines: PipelineDefinition[] = [
project.fmt(),
project.typecheck(),
],
{ defaultImage: "ubuntu:24.04" },
),
},
];
Expand Down Expand Up @@ -192,7 +190,7 @@ maps it over for you:
- `actions/setup-*` → replaced by a typed toolchain
- `actions/cache` → not needed (Harmont caches Docker layers automatically)
- `jobs.*.needs` → the DAG `hm` derives from your code
- `runs-on` → `default_image`
- `runs-on` → per-step `image=` (the default base is `ubuntu:24.04`)

The result is a pipeline you can run **locally** before it ever hits CI.

Expand Down
6 changes: 3 additions & 3 deletions crates/hm-dsl-engine/harmont-py/harmont/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
Step.fork(label=None) -> Step
wait(*, continue_on_failure=False) -> Step

pipeline(leaves, *, env=None, default_image=None) -> dict (v0 IR)
pipeline(leaves, *, env=None) -> dict (v0 IR)
pipeline_to_json(p, **kw) -> str

@pipeline(slug, ..., triggers=[...], allow_manual=True) -> decorator
Expand Down Expand Up @@ -68,12 +68,12 @@ def pipeline(*args: Any, **kwargs: Any) -> Any:

Factory form — first positional is a list/tuple of ``Step``s:

pipeline([step1, step2, ...], env=None, default_image=None) -> dict
pipeline([step1, step2, ...], env=None) -> dict

Decorator form — no positionals or a string slug:

@pipeline(slug=None, *, name=None, triggers=(), allow_manual=True,
env=None, default_image=None)
env=None)
def my_pipeline() -> Step: ...

The discriminant is the type of the first positional argument:
Expand Down
4 changes: 0 additions & 4 deletions crates/hm-dsl-engine/harmont-py/harmont/_decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ def pipeline(
triggers: tuple[Trigger, ...] | list[Trigger] = (),
allow_manual: bool = True,
env: dict[str, str] | None = None,
default_image: str | None = None,
timeout: str | int | None = None,
) -> Callable[[Callable[..., Any]], Callable[[], Any]]:
"""Register a function as a CI pipeline (decorator form).
Expand All @@ -56,8 +55,6 @@ def pipeline(
allow_manual: When ``True``, the pipeline can be triggered manually
via the UI or API in addition to its configured triggers.
env: Pipeline-level environment variables applied to every step.
default_image: Local-mode Docker base image applied to root steps
that lack an explicit ``image`` or ``builds_in`` parent.
timeout: Whole-build wall-clock budget ("30m", "1h", or int
seconds). The build is killed and fails as timed out once it
elapses.
Expand Down Expand Up @@ -86,7 +83,6 @@ def wrapper() -> Any:
triggers=tuple(triggers),
allow_manual=allow_manual,
env=env,
default_image=default_image,
fn=wrapper,
timeout=timeout,
)
Expand Down
2 changes: 1 addition & 1 deletion crates/hm-dsl-engine/harmont-py/harmont/_envelope.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ def _render_one(
except TypeError as e:
msg = f"pipeline {reg.slug!r}: invalid return value\n → {e}"
raise TypeError(msg) from e
ir = _assemble(leaves, env=reg.env, default_image=reg.default_image, timeout=reg.timeout)
ir = _assemble(leaves, env=reg.env, timeout=reg.timeout)
resolve_pipeline_keys(
ir.get("graph", {}),
pipeline_org=pipeline_org,
Expand Down
36 changes: 18 additions & 18 deletions crates/hm-dsl-engine/harmont-py/harmont/_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,19 +27,24 @@
if TYPE_CHECKING:
from ._step import Step

# Across-the-board default image for imageless root steps. The SDK's
# toolchains assume an apt-capable base (apt-get), so ubuntu:24.04 is the
# universal default; child steps boot from their parent's snapshot and
# stay imageless.
DEFAULT_IMAGE = "ubuntu:24.04"


def pipeline(
leaves: list[Step] | tuple[Step, ...],
*,
env: dict[str, str] | None = None,
default_image: str | None = None,
timeout: str | int | None = None,
) -> dict[str, Any]:
"""Top-level factory. Returns a JSON-shaped dict (version "0").

``default_image`` is the local-mode fallback Docker image: it
applies to every command step that lacks both a ``builds_in``
parent edge and a per-step ``image`` override.
Every imageless root command step (one with no ``builds_in`` parent
and no per-step ``image``) is stamped with ``DEFAULT_IMAGE``
(``ubuntu:24.04``). Set a per-step ``image=`` on a step to override.

``timeout`` is a whole-build wall-clock budget (``"30m"``, ``"1h"``,
or an int number of seconds). When it elapses the build is killed and
Expand All @@ -52,23 +57,16 @@ def pipeline(
)
raise ValueError(msg)
out: dict[str, Any] = {"version": "0"}
if default_image is not None:
out["default_image"] = default_image
if timeout is not None:
out["timeout_seconds"] = parse_duration(timeout)
out["graph"] = _lower_to_graph(
list(leaves),
env=env,
default_image=default_image,
)
out["graph"] = _lower_to_graph(list(leaves), env=env)
return out


def _lower_to_graph(
leaves: list[Step],
*,
env: dict[str, str] | None = None,
default_image: str | None = None,
) -> dict[str, Any]:
"""Walk back via `parent`, topo-sort, emit petgraph-serde graph dict.

Expand All @@ -86,7 +84,7 @@ def _lower_to_graph(
for i, s in enumerate(command_steps):
idx_by_id[id(s)] = i

# Track which node indices have a builds_in parent (for default_image).
# Track which node indices have a builds_in parent (child steps stay image-less).
has_builds_in_parent: set[int] = set()

nodes: list[dict[str, Any]] = []
Expand Down Expand Up @@ -156,11 +154,13 @@ def _lower_to_graph(

pre_wait_indices.append(node_idx)

# Apply default_image to root nodes (those without a builds_in parent).
if default_image is not None:
for i, node in enumerate(nodes):
if i not in has_builds_in_parent and "image" not in node["step"]:
node["step"]["image"] = default_image
# Stamp the default image on every root command step that lacks an
# explicit one. Root steps boot from an image tag (not a parent
# snapshot); child steps inherit the parent's committed snapshot and
# must stay image-less.
for i, node in enumerate(nodes):
if i not in has_builds_in_parent and "image" not in node["step"]:
node["step"]["image"] = DEFAULT_IMAGE

return {
"nodes": nodes,
Expand Down
1 change: 0 additions & 1 deletion crates/hm-dsl-engine/harmont-py/harmont/_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,6 @@ class PipelineRegistration:
triggers: tuple[Trigger, ...]
allow_manual: bool
env: dict[str, str] | None
default_image: str | None
fn: Callable[[], object]
timeout: str | int | None = None

Expand Down
5 changes: 3 additions & 2 deletions crates/hm-dsl-engine/harmont-py/harmont/_step.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,9 @@ class Step:
timeout_seconds: int | None = None
image: str | None = None
"""Local-mode Docker base image override for this step. Ignored when
the step has a ``builds_in`` parent (the parent's snapshot wins);
falls back to the pipeline's ``default_image`` when unset."""
the step has a ``builds_in`` parent (the parent's snapshot wins).
When unset, root steps fall back to ``ubuntu:24.04``; child steps
inherit the parent's snapshot."""

runner: str | None = None
"""Step-executor plugin runner name. ``None`` = default (Docker)."""
Expand Down
Loading
Loading