Harmont docs
Pipeline SDK

Patterns & anti-patterns

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:

.hm/pipeline.py
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())
.hm/pipeline.ts
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:

.hm/pipeline.py
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"),
    )
.hm/pipeline.ts
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:

.hm/pipeline.py
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())
.hm/pipeline.ts
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:

.hm/pipeline.py
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())
.hm/pipeline.ts
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 again
import { sh } from "@harmont/hm";

const lint = sh("make deps").sh("make lint");  // installs deps
const test = sh("make deps").sh("make test");  // installs deps again

Declare 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.

On this page