Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
31f6790
feat(ts-dsl): scaffold harmont-ts package
markovejnovic May 24, 2026
b4e65b6
feat(ts-dsl): Step primitive with sh/scratch/wait/fork
markovejnovic May 24, 2026
ad03b57
feat(ts-dsl): cache policy types (forever/ttl/onChange/compose)
markovejnovic May 24, 2026
ad2ddd1
feat(ts-dsl): trigger types (push/pullRequest/schedule)
markovejnovic May 24, 2026
8c79be8
feat(ts-dsl): step key resolution (slugify/hash/resolve)
markovejnovic May 24, 2026
7a026ba
feat(ts-dsl): target system (memoized pipeline building blocks)
markovejnovic May 24, 2026
b992bd5
feat(ts-dsl): pipeline lowering (step chains → petgraph IR)
markovejnovic May 24, 2026
8814a1e
feat(ts-dsl): envelope rendering (schema_version:1)
markovejnovic May 24, 2026
104c6b0
feat(ts-dsl): public API barrel export + integration tests
markovejnovic May 24, 2026
c1f0174
feat(ts-dsl): npm toolchain (install chain + test/lint/run)
markovejnovic May 24, 2026
884e7c0
chore(ts-dsl): remove passWithNoTests scaffold flag
markovejnovic May 24, 2026
e0842da
feat(ts-dsl): add vitest resolve aliases for harmont imports
markovejnovic May 24, 2026
86de27d
feat(ts-dsl): add pipeline.ts to typescript/react/nextjs examples
markovejnovic May 24, 2026
c5b2c1f
feat(ts-dsl): add pipeline.ts to go example (raw sh API)
markovejnovic May 24, 2026
cb23830
feat(ts-dsl): examples rendering test (mirrors Python test_examples_r…
markovejnovic May 24, 2026
eee4a37
ci: add harmont-ts type check + vitest job
markovejnovic May 24, 2026
61f368d
feat(ts-dsl): make target() generic for non-Step memoization
markovejnovic May 24, 2026
b031442
feat(ts-dsl): go toolchain
markovejnovic May 24, 2026
db99b12
feat(ts-dsl): rust toolchain
markovejnovic May 24, 2026
ebb9454
feat(ts-dsl): python (uv) toolchain
markovejnovic May 24, 2026
f27c480
feat(ts-dsl): cmake (c/cpp) toolchain
markovejnovic May 24, 2026
1b8ed3c
feat(ts-dsl): gradle (java/kotlin) toolchain
markovejnovic May 24, 2026
34ea469
feat(ts-dsl): dotnet (c#) toolchain
markovejnovic May 24, 2026
c4dd2ce
feat(ts-dsl): ruby + perl toolchains
markovejnovic May 24, 2026
90d1fa9
feat(ts-dsl): composer (php/laravel) toolchain
markovejnovic May 24, 2026
c074ad7
feat(ts-dsl): elm toolchain
markovejnovic May 24, 2026
73dc0cc
feat(ts-dsl): zig toolchain (dual-mode)
markovejnovic May 24, 2026
76f2010
feat(ts-dsl): ocaml toolchain
markovejnovic May 24, 2026
d49dfc3
feat(ts-dsl): haskell toolchain (multi-package)
markovejnovic May 24, 2026
e1714c8
feat(ts-dsl): export all language toolchains from barrel
markovejnovic May 24, 2026
864d1bd
feat(ts-dsl): add pipeline.ts to all 18 examples
markovejnovic May 24, 2026
9f1d6d2
refactor(ts-dsl): rename PipelineDefinition.ir to .pipeline
markovejnovic May 24, 2026
597426d
feat: python E2E pipeline fixtures (4 scenarios)
markovejnovic May 24, 2026
6b6f9f8
feat: typescript E2E pipeline fixtures (4 scenarios)
markovejnovic May 24, 2026
c5b48a2
test: add Rust E2E deserialization + cross-DSL parity tests
markovejnovic May 24, 2026
b392a86
chore: accept schema snapshot after doc-comment additions
markovejnovic May 24, 2026
51fa6ce
fix: Python Haskell toolchain emit glob patterns instead of resolving
markovejnovic May 24, 2026
984e921
fix: suppress ruff S108 on test fixture string
markovejnovic May 24, 2026
d90e03e
fix: keygen resolves glob patterns in on_change paths
markovejnovic May 24, 2026
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
24 changes: 24 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,30 @@ jobs:
--deselect tests/test_gradle.py \
--deselect tests/test_haskell.py

ts-dsl:
name: harmont-ts (vitest + tsc)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
cache-dependency-path: dsls/harmont-ts/package-lock.json

- name: Install
working-directory: dsls/harmont-ts
run: npm ci

- name: Type check
working-directory: dsls/harmont-ts
run: npx tsc --noEmit

- name: Test
working-directory: dsls/harmont-ts
run: npm test

integration:
name: docker-gated integration test
runs-on: ubuntu-latest
Expand Down
250 changes: 250 additions & 0 deletions crates/hm-pipeline-ir/tests/e2e_fixtures.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
#![allow(
clippy::cargo_common_metadata,
clippy::multiple_crate_versions,
clippy::unwrap_used,
clippy::expect_used,
clippy::panic
)]

use std::collections::BTreeSet;
use std::fs;
use std::path::PathBuf;

use daggy::petgraph::visit::{EdgeRef, IntoNodeReferences};
use hm_pipeline_ir::{EdgeKind, PipelineGraph};

const SCENARIOS: &[&str] = &[
"monorepo-ci",
"rust-release",
"zig-node-polyglot",
"kitchen-sink",
];

fn fixtures_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../../tests/e2e/fixtures")
}

fn load_fixture(dsl: &str, scenario: &str) -> PipelineGraph {
let path = fixtures_dir().join(dsl).join(format!("{scenario}.json"));
let bytes =
fs::read(&path).unwrap_or_else(|e| panic!("read {}: {e}", path.display()));
serde_json::from_slice(&bytes)
.unwrap_or_else(|e| panic!("parse {dsl}/{scenario}: {e}"))
}

fn step_labels(g: &PipelineGraph) -> BTreeSet<String> {
g.dag()
.graph()
.node_references()
.filter_map(|(_, t)| t.step.label.clone())
.collect()
}

fn edge_kinds(g: &PipelineGraph) -> (usize, usize) {
let mut builds_in = 0usize;
let mut depends_on = 0usize;
for e in g.dag().graph().edge_references() {
match e.weight() {
EdgeKind::BuildsIn => builds_in += 1,
EdgeKind::DependsOn => depends_on += 1,
}
}
(builds_in, depends_on)
}

// ---- Python fixtures ----

#[test]
fn python_monorepo_ci() {
let g = load_fixture("python", "monorepo-ci");
assert_eq!(g.default_image(), Some("ubuntu:24.04"));
assert!(g.node_count() >= 15, "nodes: {}", g.node_count());
let labels = step_labels(&g);
assert!(labels.iter().any(|l| l.contains("go")));
assert!(labels.iter().any(|l| l.contains("python") || l.contains("uv")));
assert!(labels.iter().any(|l| l.contains("node") || l.contains("npm")));
}

#[test]
fn python_rust_release() {
let g = load_fixture("python", "rust-release");
assert_eq!(g.default_image(), Some("ubuntu:24.04"));
assert!(g.node_count() >= 5, "nodes: {}", g.node_count());
let labels = step_labels(&g);
assert!(labels.iter().any(|l| l.contains("rust")));
}

#[test]
fn python_zig_node_polyglot() {
let g = load_fixture("python", "zig-node-polyglot");
assert_eq!(g.default_image(), Some("ubuntu:24.04"));
assert!(g.node_count() >= 10, "nodes: {}", g.node_count());
let labels = step_labels(&g);
assert!(labels.iter().any(|l| l.contains("zig")));
assert!(labels.iter().any(|l| l.contains("node") || l.contains("npm")));
}

#[test]
fn python_kitchen_sink() {
let g = load_fixture("python", "kitchen-sink");
assert_eq!(g.default_image(), Some("ubuntu:24.04"));
assert!(g.node_count() >= 12, "nodes: {}", g.node_count());
let labels = step_labels(&g);
assert!(labels.iter().any(|l| l.contains("haskell")));
assert!(labels.iter().any(|l| l.contains("cmake") || l.contains(":c:")));
for (_, t) in g.dag().graph().node_references() {
assert!(t.env.contains_key("CI"), "node {} missing CI env", t.step.key);
}
}

// ---- TypeScript fixtures ----

#[test]
fn ts_monorepo_ci() {
let g = load_fixture("ts", "monorepo-ci");
assert_eq!(g.default_image(), Some("ubuntu:24.04"));
assert!(g.node_count() >= 15);
}

#[test]
fn ts_rust_release() {
let g = load_fixture("ts", "rust-release");
assert_eq!(g.default_image(), Some("ubuntu:24.04"));
assert!(g.node_count() >= 5);
}

#[test]
fn ts_zig_node_polyglot() {
let g = load_fixture("ts", "zig-node-polyglot");
assert_eq!(g.default_image(), Some("ubuntu:24.04"));
assert!(g.node_count() >= 10);
}

#[test]
fn ts_kitchen_sink() {
let g = load_fixture("ts", "kitchen-sink");
assert_eq!(g.default_image(), Some("ubuntu:24.04"));
assert!(g.node_count() >= 12);
}

// ---- Structural invariants on all fixtures ----

#[test]
fn all_fixtures_have_valid_structure() {
for dsl in ["python", "ts"] {
for scenario in SCENARIOS {
let g = load_fixture(dsl, scenario);

for (_, t) in g.dag().graph().node_references() {
assert!(!t.step.key.is_empty(), "{dsl}/{scenario}: empty key");
assert!(
!t.step.cmd.is_empty(),
"{dsl}/{scenario}: empty cmd for {}",
t.step.key,
);
}

let (bi, dep) = edge_kinds(&g);
assert!(bi + dep > 0, "{dsl}/{scenario}: no edges");

for e in g.dag().graph().edge_references() {
assert_ne!(
e.source(),
e.target(),
"{dsl}/{scenario}: self-loop",
);
}
}
}
}

// ---- Cross-DSL parity ----

#[test]
fn parity_node_count() {
for scenario in SCENARIOS {
let py = load_fixture("python", scenario);
let ts = load_fixture("ts", scenario);
assert_eq!(
py.node_count(),
ts.node_count(),
"parity/{scenario}: node count (py={}, ts={})",
py.node_count(),
ts.node_count(),
);
}
}

#[test]
fn parity_edge_kinds() {
for scenario in SCENARIOS {
let py = load_fixture("python", scenario);
let ts = load_fixture("ts", scenario);
let py_ek = edge_kinds(&py);
let ts_ek = edge_kinds(&ts);
assert_eq!(
py_ek, ts_ek,
"parity/{scenario}: edge kinds (py={py_ek:?}, ts={ts_ek:?})",
);
}
}

#[test]
fn parity_step_labels() {
for scenario in SCENARIOS {
let py = load_fixture("python", scenario);
let ts = load_fixture("ts", scenario);
let py_labels = step_labels(&py);
let ts_labels = step_labels(&ts);
assert_eq!(
py_labels, ts_labels,
"parity/{scenario}: labels\npy-only: {:?}\nts-only: {:?}",
py_labels.difference(&ts_labels).collect::<Vec<_>>(),
ts_labels.difference(&py_labels).collect::<Vec<_>>(),
);
}
}

#[test]
fn parity_default_image() {
for scenario in SCENARIOS {
let py = load_fixture("python", scenario);
let ts = load_fixture("ts", scenario);
assert_eq!(
py.default_image(),
ts.default_image(),
"parity/{scenario}: default_image",
);
}
}

#[test]
fn parity_env_keys() {
for scenario in SCENARIOS {
let py = load_fixture("python", scenario);
let ts = load_fixture("ts", scenario);
let py_labels = step_labels(&py);
let ts_labels = step_labels(&ts);

for label in py_labels.intersection(&ts_labels) {
let py_env: BTreeSet<_> = py
.dag()
.graph()
.node_references()
.find(|(_, t)| t.step.label.as_deref() == Some(label))
.map(|(_, t)| t.env.keys().cloned().collect())
.unwrap();
let ts_env: BTreeSet<_> = ts
.dag()
.graph()
.node_references()
.find(|(_, t)| t.step.label.as_deref() == Some(label))
.map(|(_, t)| t.env.keys().cloned().collect())
.unwrap();
assert_eq!(
py_env, ts_env,
"parity/{scenario}/{label}: env keys",
);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,38 +1,43 @@
---
source: crates/hm-pipeline-ir/tests/schema_snapshot.rs
assertion_line: 6
expression: schema
---
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "CommandStep",
"description": "A single build command within a pipeline.\n\nSerialized as a JSON object inside each graph node's `step` field. The `key` is the unique identifier used to reference this step in edges and log output.",
"type": "object",
"required": [
"cmd",
"key"
],
"properties": {
"key": {
"description": "Unique identifier for this step within the pipeline.",
"type": "string"
},
"label": {
"description": "Human-readable label shown in build output.",
"default": null,
"type": [
"string",
"null"
]
},
"cmd": {
"description": "Shell command to execute inside the container.",
"type": "string"
},
"image": {
"description": "Docker image to boot from. Root steps without an image inherit `PipelineGraph::default_image`; child steps boot from their parent's committed snapshot.",
"default": null,
"type": [
"string",
"null"
]
},
"env": {
"description": "Per-step environment variables merged on top of the pipeline env.",
"default": null,
"type": [
"object",
Expand All @@ -43,6 +48,7 @@ expression: schema
}
},
"timeout_seconds": {
"description": "Maximum wall-clock seconds before the step is killed.",
"default": null,
"type": [
"integer",
Expand All @@ -52,6 +58,7 @@ expression: schema
"minimum": 0.0
},
"cache": {
"description": "Cache configuration for this step's committed snapshot.",
"default": null,
"anyOf": [
{
Expand All @@ -63,24 +70,30 @@ expression: schema
]
},
"runner": {
"description": "Step-executor plugin name. `None` falls back to the default runner (Docker in the shipped configuration).",
"type": [
"string",
"null"
]
},
"runner_args": true
"runner_args": {
"description": "Plugin-specific extra fields passed verbatim to the runner."
}
},
"definitions": {
"Cache": {
"description": "Snapshot cache configuration for a step.",
"type": "object",
"required": [
"policy"
],
"properties": {
"policy": {
"description": "Cache policy name (e.g. `\"content-hash\"`).",
"type": "string"
},
"key": {
"description": "Explicit cache key override; derived from the step if absent.",
"default": null,
"type": [
"string",
Expand Down
6 changes: 1 addition & 5 deletions dsls/harmont-py/harmont/haskell.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@

import re
from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any, overload

from ._toolchain import make_install_chain
Expand Down Expand Up @@ -130,10 +129,7 @@ def package(
if cache_paths is not None:
paths = cache_paths
else:
paths = (
tuple(sorted(p.as_posix() for p in Path(path).glob("*.cabal")))
+ ((f"{path}/cabal.project",) if Path(path, "cabal.project").exists() else ())
)
paths = (f"{path}/*.cabal", f"{path}/cabal.project")
deps = self.installed.sh(
f"cabal update && cd {path} && cabal build all --only-dependencies",
label=f":haskell: {path} deps",
Expand Down
Loading
Loading