Write pipelines with the high-level toolchain APIs. Reach for hm.sh only when no toolchain covers your case.
This page is the canonical guidance for writing Harmont pipelines well. Each rule stands on its own — read any section in isolation. The single most important rule: prefer a toolchain over raw shell.
Prefer toolchains over raw shell
Harmont ships toolchains for Rust, Python, Go, JavaScript/TypeScript, CMake
(C/C++), Elixir, and Zig. A toolchain installs the runtime, warms the
dependency cache, and exposes opinionated build/test/lint/fmt actions.
Use it. It gives you correct install, caching, and parallelism for free.
hm.sh(...) is the general-purpose escape hatch — a raw shell step. It is the
right tool only when no toolchain covers your case. Reaching for it first
means you re-implement install and caching by hand, usually worse.
✅ Do — use the toolchain for your language:
import harmont as hm
@hm.pipeline("ci")
def ci() -> tuple[hm.Step, ...]:
project = hm.rust.toolchain(path=".")
return (project.build(), project.test(), project.clippy(), project.fmt())import { pipeline, type PipelineDefinition } from "@harmont/hm";
import { rust } from "@harmont/hm/toolchains";
const project = rust.toolchain({ path: "." });
const pipelines: PipelineDefinition[] = [
{ slug: "ci", pipeline: pipeline([project.build(), project.test(), project.clippy(), project.fmt()]) },
];
export default pipelines;❌ Don't — hand-roll the same thing with raw shell:
import harmont as hm
# Anti-pattern: no managed toolchain install, no dependency caching, and you
# now own the correctness of every command.
@hm.pipeline("ci")
def ci() -> tuple[hm.Step, ...]:
return (
hm.sh("curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y")
.sh("cargo build")
.sh("cargo test"),
)import { pipeline, sh, type PipelineDefinition } from "@harmont/hm";
// Anti-pattern: no managed toolchain install, no dependency caching.
const pipelines: PipelineDefinition[] = [
{
slug: "ci",
pipeline: pipeline([
sh("curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y")
.sh("cargo build")
.sh("cargo test"),
]),
},
];
export default pipelines;When you genuinely have no toolchain (a bespoke build tool, a one-off script),
hm.sh(...) is correct and supported — see Chains & steps.
Splice prep with .setup(); don't fork a parallel shell step
Sometimes a toolchain's actions need a step the toolchain doesn't model first —
code generation, a fixture, an extra tool. Reach for .setup(), not a separate
hm.sh leaf. .setup() appends a command to the toolchain's install chain and
returns an advanced toolchain; every action you emit afterward (compile,
test, …) forks from that step, so they all see its results. It's chainable —
call it more than once to layer prep.
A standalone hm.sh(...) leaf is not a dependency of the toolchain's
actions. It runs in parallel, and compile() forks the install step without
ever seeing your prep — so the build fails, or silently uses stale output.
✅ Do — splice the prep step into the chain:
import harmont as hm
@hm.pipeline("ci")
def ci() -> tuple[hm.Step, ...]:
# proto.gen runs after install, before anything compiles. .setup() splices
# it into the chain; compile and test fork from it, so they see the output.
project = hm.elixir(path="elixir").setup(
"mix proto.gen", cache=hm.on_change("proto/agent.proto")
)
return (project.compile(), project.test())import { pipeline, onChange, type PipelineDefinition } from "@harmont/hm";
import { elixir } from "@harmont/hm/toolchains";
// .setup() returns an advanced project; compile and test fork from the prep step.
const project = elixir({ path: "elixir" }).setup("mix proto.gen", {
cache: onChange("proto/agent.proto"),
});
const pipelines: PipelineDefinition[] = [
{ slug: "ci", pipeline: pipeline([project.compile(), project.test()]) },
];
export default pipelines;❌ Don't — emit the prep as an unrelated leaf:
import harmont as hm
# Anti-pattern: a standalone hm.sh leaf is not a dependency of compile(). It runs
# in parallel, and compile() forks the install step — never seeing the generated
# code. The build fails, or silently uses stale stubs.
@hm.pipeline("ci")
def ci() -> tuple[hm.Step, ...]:
project = hm.elixir(path="elixir")
return (hm.sh("cd elixir && mix proto.gen"), project.compile(), project.test())import { pipeline, sh, type PipelineDefinition } from "@harmont/hm";
import { elixir } from "@harmont/hm/toolchains";
const project = elixir({ path: "elixir" });
// Anti-pattern: sh("…") is a separate leaf, not a prerequisite of compile().
const pipelines: PipelineDefinition[] = [
{
slug: "ci",
pipeline: pipeline([sh("cd elixir && mix proto.gen"), project.compile(), project.test()]),
},
];
export default pipelines;For toolchains with a separate dependency-precompile warmup (Rust, CMake), call
.setup() on the toolchain before you create the project —
hm.rust.toolchain().setup(…).project(…) — so the prep runs before the warmup
build, not after it.
Fork shared work; don't repeat install
Toolchain actions already fork off one shared install step, so adding a check costs only the check. When you do build chains by hand, fork off a common root instead of re-running setup in every branch.
✅ Do — one root, parallel forks:
import harmont as hm
root = hm.sh("make deps")
lint = root.fork("lint").sh("make lint")
test = root.fork("test").sh("make test")import { sh } from "@harmont/hm";
const root = sh("make deps");
const lint = root.fork({ label: "lint" }).sh("make lint");
const test = root.fork({ label: "test" }).sh("make test");❌ Don't — re-install dependencies in every chain:
import harmont as hm
lint = hm.sh("make deps").sh("make lint") # installs deps
test = hm.sh("make deps").sh("make test") # installs deps againimport { sh } from "@harmont/hm";
const lint = sh("make deps").sh("make lint"); // installs deps
const test = sh("make deps").sh("make test"); // installs deps againDeclare triggers in code; don't gate inside steps
Run-on-push / run-on-PR belongs in the pipeline's triggers, not in shell
conditionals. The trigger is visible to Harmont; a branch check buried in a
step is not.
✅ Do:
import harmont as hm
@hm.pipeline("ci", triggers=[hm.push(branch="main")])
def ci() -> tuple[hm.Step, ...]:
project = hm.rust.toolchain(path=".")
return (project.build(), project.test())import { pipeline, push, type PipelineDefinition } from "@harmont/hm";
import { rust } from "@harmont/hm/toolchains";
const project = rust.toolchain({ path: "." });
const pipelines: PipelineDefinition[] = [
{
slug: "ci",
triggers: [push({ branch: "main" })],
pipeline: pipeline([project.build(), project.test()]),
},
];
export default pipelines;❌ Don't — branch-gate inside a shell step:
import harmont as hm
@hm.pipeline("ci")
def ci() -> tuple[hm.Step, ...]:
return (hm.sh('[ "$BRANCH" = "main" ] && cargo test || true'),)import { pipeline, sh, type PipelineDefinition } from "@harmont/hm";
const pipelines: PipelineDefinition[] = [
{ slug: "ci", pipeline: pipeline([sh('[ "$BRANCH" = "main" ] && cargo test || true')]) },
];
export default pipelines;See Triggers for the full trigger surface.
Write pipelines with the SDK; don't hand-author IR JSON
Pipelines are programs. Author them with the harmont / @harmont/hm SDK and
let hm lower them. Never hand-write the underlying v0 IR JSON — it is a
compiler target, not an authoring format, and it is unstable across versions.