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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,32 @@ jobs:
working-directory: dsls/harmont-ts
run: npm test

dogfood:
name: dogfood (hm run ci)
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@v4

- uses: dtolnay/rust-toolchain@stable
with:
targets: wasm32-wasip1

- uses: Swatinem/rust-cache@v2

- name: Build hm
run: cargo build -p harmont-cli

- name: Install harmont-py into system Python
run: |
sudo /usr/bin/python3 -m pip install --break-system-packages dsls/harmont-py
/usr/bin/python3 -c "import harmont; print('harmont', harmont.__file__)"

- name: hm run ci
env:
HM_NONINTERACTIVE: '1'
run: ./target/debug/hm run ci

integration:
name: docker-gated integration test
runs-on: ubuntu-latest
Expand Down
49 changes: 49 additions & 0 deletions .harmont/ci.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
"""Harmont CI pipeline — dogfood."""
from __future__ import annotations

import harmont as hm
from harmont.py.uv import UvProject
from harmont.rust import RustToolchain


@hm.target()
def rust_project() -> RustToolchain:
return hm.rust(path=".")


@hm.target()
def py_project() -> UvProject:
return hm.py.uv(path="dsls/harmont-py")


@hm.pipeline(
"ci",
env={"CI": "true"},
default_image="ubuntu:24.04",
triggers=[
hm.push(branch="main"),
hm.pull_request(branches="main"),
],
)
def ci(
rust_project: hm.Target[RustToolchain],
py_project: hm.Target[UvProject],
) -> tuple[hm.Step, ...]:
return (
rust_project.build(),
rust_project.installed.sh(
". $HOME/.cargo/env && cd . && cargo test --lib",
label=":rust: test",
),
rust_project.clippy(),
rust_project.fmt(),
py_project.lint(),
py_project.fmt(),
py_project.typecheck(paths="harmont"),
py_project.run(
"pytest -v"
" --deselect tests/test_gradle.py"
" --deselect tests/test_haskell.py",
label=":python: test",
),
)
28 changes: 28 additions & 0 deletions .harmont/ci.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { pipeline, push, pullRequest, type PipelineDefinition } from "harmont";
import { rust, py } from "harmont/toolchains";

const rustProject = rust({ path: "." });
const pyProject = py.uv({ path: "dsls/harmont-py" });

const pipelines: PipelineDefinition[] = [
{
slug: "ci",
triggers: [push({ branch: "main" }), pullRequest({ branches: ["main"] })],
pipeline: pipeline(
rustProject.build(),
rustProject.install().sh(`. $HOME/.cargo/env && cd . && cargo test --lib`, { label: ":rust: test" }),
rustProject.clippy(),
rustProject.fmt(),
pyProject.lint(),
pyProject.fmt(),
pyProject.typecheck({ paths: "harmont" }),
pyProject.run(
"pytest -v --deselect tests/test_gradle.py --deselect tests/test_haskell.py",
{ label: ":python: test" },
),
{ env: { CI: "true" }, defaultImage: "ubuntu:24.04" },
),
},
];

export default pipelines;
50 changes: 31 additions & 19 deletions crates/hm-pipeline-ir/tests/e2e_fixtures.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,10 +26,8 @@ fn fixtures_dir() -> PathBuf {

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}"))
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> {
Expand Down Expand Up @@ -61,8 +59,16 @@ fn python_monorepo_ci() {
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")));
assert!(
labels
.iter()
.any(|l| l.contains("python") || l.contains("uv"))
);
assert!(
labels
.iter()
.any(|l| l.contains("node") || l.contains("npm"))
);
}

#[test]
Expand All @@ -81,7 +87,11 @@ fn python_zig_node_polyglot() {
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")));
assert!(
labels
.iter()
.any(|l| l.contains("node") || l.contains("npm"))
);
}

#[test]
Expand All @@ -91,9 +101,17 @@ fn python_kitchen_sink() {
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:")));
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);
assert!(
t.env.contains_key("CI"),
"node {} missing CI env",
t.step.key
);
}
}

Expand Down Expand Up @@ -148,11 +166,7 @@ fn all_fixtures_have_valid_structure() {
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",
);
assert_ne!(e.source(), e.target(), "{dsl}/{scenario}: self-loop");
}
}
}
Expand Down Expand Up @@ -197,7 +211,8 @@ fn parity_step_labels() {
let py_labels = step_labels(&py);
let ts_labels = step_labels(&ts);
assert_eq!(
py_labels, ts_labels,
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<_>>(),
Expand Down Expand Up @@ -241,10 +256,7 @@ fn parity_env_keys() {
.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",
);
assert_eq!(py_env, ts_env, "parity/{scenario}/{label}: env keys");
}
}
}
32 changes: 22 additions & 10 deletions crates/hm-pipeline-ir/tests/graph_build.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ fn find_by_key<'a>(g: &'a PipelineGraph, key: &str) -> &'a hm_pipeline_ir::Trans

#[test]
fn builds_simple_chain() {
let g = graph(br#"{
let g = graph(
br#"{
"version": "0",
"default_image": "ubuntu:24.04",
"graph": {
Expand All @@ -41,14 +42,16 @@ fn builds_simple_chain() {
[1, 2, "builds_in"]
]
}
}"#);
}"#,
);
assert_eq!(g.node_count(), 3);
assert_eq!(g.default_image(), Some("ubuntu:24.04"));
}

#[test]
fn root_inherits_default_image() {
let g = graph(br#"{
let g = graph(
br#"{
"version": "0",
"default_image": "ubuntu:24.04",
"graph": {
Expand All @@ -58,14 +61,16 @@ fn root_inherits_default_image() {
"edge_property": "directed",
"edges": []
}
}"#);
}"#,
);
let t = find_by_key(&g, "a");
assert_eq!(t.step.image.as_deref(), Some("ubuntu:24.04"));
}

#[test]
fn child_does_not_inherit_default_image() {
let g = graph(br#"{
let g = graph(
br#"{
"version": "0",
"default_image": "ubuntu:24.04",
"graph": {
Expand All @@ -78,14 +83,16 @@ fn child_does_not_inherit_default_image() {
[0, 1, "builds_in"]
]
}
}"#);
}"#,
);
let b = find_by_key(&g, "b");
assert!(b.step.image.is_none());
}

#[test]
fn wait_inserts_implicit_deps() {
let g = graph(br#"{
let g = graph(
br#"{
"version": "0",
"graph": {
"nodes": [
Expand All @@ -99,13 +106,18 @@ fn wait_inserts_implicit_deps() {
[1, 2, "depends_on"]
]
}
}"#);
}"#,
);
let dag = g.dag();
let c_idx = dag.graph().node_references()
let c_idx = dag
.graph()
.node_references()
.find(|(_, t)| t.step.key == "c")
.map(|(idx, _)| idx)
.unwrap();
let parent_keys: Vec<String> = dag.parents(c_idx).iter(dag)
let parent_keys: Vec<String> = dag
.parents(c_idx)
.iter(dag)
.map(|(_, p)| dag[p].step.key.clone())
.collect();
assert!(parent_keys.contains(&"a".to_string()));
Expand Down
37 changes: 28 additions & 9 deletions crates/hm-pipeline-ir/tests/graph_serde.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,14 @@ fn transition_round_trips() {

#[test]
fn edge_kind_serializes_as_snake_case() {
assert_eq!(serde_json::to_string(&EdgeKind::BuildsIn).unwrap(), "\"builds_in\"");
assert_eq!(serde_json::to_string(&EdgeKind::DependsOn).unwrap(), "\"depends_on\"");
assert_eq!(
serde_json::to_string(&EdgeKind::BuildsIn).unwrap(),
"\"builds_in\""
);
assert_eq!(
serde_json::to_string(&EdgeKind::DependsOn).unwrap(),
"\"depends_on\""
);
}

#[test]
Expand Down Expand Up @@ -64,30 +70,43 @@ fn build_test_graph() -> PipelineGraph {
[0, 1, "builds_in"]
]
}
})).unwrap()
}))
.unwrap()
}

#[test]
fn pipeline_graph_round_trips_through_json() {
use daggy::{Walker, petgraph::visit::IntoNodeReferences};

let g = build_test_graph();
let json = serde_json::to_string_pretty(&g).unwrap();
let back: PipelineGraph = serde_json::from_str(&json).unwrap();
assert_eq!(back.node_count(), 3);
assert_eq!(back.default_image(), Some("ubuntu:24.04"));
use daggy::Walker;
use daggy::petgraph::visit::IntoNodeReferences;

let a_idx = back.dag().graph().node_references()
let a_idx = back
.dag()
.graph()
.node_references()
.find(|(_, t)| t.step.key == "a")
.map(|(idx, _)| idx)
.unwrap();
assert_eq!(back.dag()[a_idx].step.image.as_deref(), Some("ubuntu:24.04"));
assert_eq!(
back.dag()[a_idx].step.image.as_deref(),
Some("ubuntu:24.04")
);

let b_idx = back.dag().graph().node_references()
let b_idx = back
.dag()
.graph()
.node_references()
.find(|(_, t)| t.step.key == "b")
.map(|(idx, _)| idx)
.unwrap();
let has_builds_in_parent = back.dag().parents(b_idx).iter(back.dag())
let has_builds_in_parent = back
.dag()
.parents(b_idx)
.iter(back.dag())
.any(|(e, _)| *back.dag().edge_weight(e).unwrap() == EdgeKind::BuildsIn);
assert!(has_builds_in_parent);
}
Expand Down
4 changes: 1 addition & 3 deletions crates/hm-plugin-cloud/src/auth/login.rs
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,7 @@ async fn login_loopback(

tracing::info!("opening browser to {auth_url}");
if webbrowser::open(&auth_url).is_err() {
eprintln!(
"couldn't auto-open the browser. Open this URL manually:\n {auth_url}"
);
eprintln!("couldn't auto-open the browser. Open this URL manually:\n {auth_url}");
}

// Wait for a single connection with a 180-second timeout.
Expand Down
5 changes: 1 addition & 4 deletions crates/hm-plugin-cloud/src/auth/whoami.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,7 @@ use crate::http::Client;
pub(crate) async fn run(env: &BTreeMap<String, String>) -> Result<()> {
let cfg = Config::from_env(env);
let token = creds::load_token(&cfg.api_base, env).ok_or_else(|| {
anyhow::anyhow!(
"not logged in to {}\n fix: `hm cloud login`",
cfg.api_base
)
anyhow::anyhow!("not logged in to {}\n fix: `hm cloud login`", cfg.api_base)
})?;
let client = Client::new(&cfg, Some(token));
let me: User = client.get("/auth/me").await?;
Expand Down
Loading
Loading