diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index de93bb10..55f4f9c5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -112,14 +112,40 @@ jobs: sudo /usr/bin/python3 -m pip install --break-system-packages dsls/harmont-py /usr/bin/python3 -c "import harmont; print('harmont', harmont.__file__)" + - name: Restore harmont Docker cache + uses: actions/cache/restore@v4 + with: + path: .harmont-cache/ + key: harmont-v1-will-never-match + restore-keys: | + harmont-v1- + + - name: Load cached Docker images + run: ./target/debug/hm cache restore .harmont-cache/ + - name: hm run ci env: HM_NONINTERACTIVE: '1' run: ./target/debug/hm run ci + - name: Save harmont Docker images + id: cache-manifest + if: always() + run: | + hash=$(./target/debug/hm cache save .harmont-cache/) + echo "key=harmont-v1-${hash}" >> "$GITHUB_OUTPUT" + + - name: Upload Docker cache + if: always() + uses: actions/cache/save@v4 + with: + path: .harmont-cache/ + key: ${{ steps.cache-manifest.outputs.key }} + integration: name: docker-gated integration test runs-on: ubuntu-latest + timeout-minutes: 15 # Skip the heavy job on draft PRs to save runner minutes. Push to # main always runs it. if: github.event_name == 'push' || (github.event_name == 'pull_request' && !github.event.pull_request.draft) diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index eccaebe0..e1699e7d 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -85,7 +85,6 @@ jobs: - cpp - csharp - go - - haskell - java - kotlin - nextjs diff --git a/.harmont/ci.py b/.harmont/ci.py index a1afc663..2170fb11 100644 --- a/.harmont/ci.py +++ b/.harmont/ci.py @@ -2,18 +2,45 @@ 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=".") +def shared_base() -> hm.Step: + return hm.apt_base(packages=( + "curl", + "ca-certificates", + "build-essential", + "pkg-config", + "libssl-dev", + "python3", + "python3-venv", + )) @hm.target() -def py_project() -> UvProject: - return hm.py.uv(path="dsls/harmont-py") +def rust_project(shared_base: hm.Target[hm.Step]) -> tuple[hm.Step, ...]: + project = hm.rust.project(path=".", base=shared_base) + return hm.group([ + project.test(flags=("--lib",)), + project.clippy(), + project.fmt(), + ]) + + +@hm.target() +def py_project(shared_base: hm.Target[hm.Step]) -> tuple[hm.Step, ...]: + project = hm.py.uv(path="dsls/harmont-py", base=shared_base) + return hm.group([ + project.lint(), + project.fmt(), + project.typecheck(paths="harmont"), + project.run( + "pytest -v" + " --deselect tests/test_gradle.py" + " --deselect tests/test_haskell.py", + label=":python: test", + ), + ]) @hm.pipeline( @@ -22,28 +49,11 @@ def py_project() -> UvProject: default_image="ubuntu:24.04", triggers=[ hm.push(branch="main"), - hm.pull_request(branches="main"), + hm.pr(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", - ), - ) + rust_project: hm.Target[tuple[hm.Step, ...]], + py_project: hm.Target[tuple[hm.Step, ...]], +) -> list: + return [rust_project, py_project] diff --git a/.harmont/ci.ts b/.harmont/ci.ts index 308083e5..8914786d 100644 --- a/.harmont/ci.ts +++ b/.harmont/ci.ts @@ -1,16 +1,33 @@ -import { pipeline, push, pullRequest, type PipelineDefinition } from "harmont"; +import { + pipeline, + push, + pullRequest, + aptBase, + type PipelineDefinition, +} from "harmont"; import { rust, py } from "harmont/toolchains"; -const rustProject = rust({ path: "." }); -const pyProject = py.uv({ path: "dsls/harmont-py" }); +const base = aptBase({ + packages: [ + "curl", + "ca-certificates", + "build-essential", + "pkg-config", + "libssl-dev", + "python3", + "python3-venv", + ], +}); + +const rustProject = rust.project({ path: ".", base }); +const pyProject = py.uv({ path: "dsls/harmont-py", base }); 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.test({ flags: ["--lib"] }), rustProject.clippy(), rustProject.fmt(), pyProject.lint(), diff --git a/crates/hm/src/cli/mod.rs b/crates/hm/src/cli/mod.rs index 80c9d07f..6213b6f9 100644 --- a/crates/hm/src/cli/mod.rs +++ b/crates/hm/src/cli/mod.rs @@ -7,6 +7,8 @@ pub use dev::{DevCommand, DevDownArgs, DevExecArgs, DevLogsArgs, DevPortOfArgs, pub use plugin::PluginCommand; pub use run::RunArgs; +use std::path::PathBuf; + use anyhow::Result; use clap::{Parser, Subcommand}; @@ -58,11 +60,35 @@ pub enum Command { #[command(subcommand)] Dev(DevCommand), + /// Manage harmont Docker image cache. + #[command(subcommand)] + Cache(CacheCommand), + /// Interact with the Harmont cloud API. #[command(subcommand)] Cloud(hm_plugin_cloud::cli::CloudCommand), } +#[derive(Debug, Clone, Subcommand)] +pub enum CacheCommand { + /// Save harmont Docker images to a cache directory. + Save(CacheSaveArgs), + /// Restore harmont Docker images from a cache directory. + Restore(CacheRestoreArgs), +} + +#[derive(Debug, Clone, clap::Args)] +pub struct CacheSaveArgs { + /// Directory to save image tars into. + pub dir: PathBuf, +} + +#[derive(Debug, Clone, clap::Args)] +pub struct CacheRestoreArgs { + /// Directory containing cached image tars. + pub dir: PathBuf, +} + /// Dispatch a parsed CLI command to the appropriate handler. Returns an exit code. /// /// # Errors @@ -72,6 +98,10 @@ pub async fn dispatch(command: Command, ctx: RunContext) -> Result { match command { Command::Run(args) => crate::commands::run::handle(args, ctx).await, Command::Dev(cmd) => dev::dispatch(cmd, ctx).await, + Command::Cache(cmd) => match cmd { + CacheCommand::Save(args) => crate::commands::cache::handle_save(&args.dir).await, + CacheCommand::Restore(args) => crate::commands::cache::handle_restore(&args.dir).await, + }, Command::Version => version::run().await.map(|()| 0), Command::Plugin(cmd) => plugin::run(cmd).await.map(|()| 0), Command::Cloud(cmd) => { diff --git a/crates/hm/src/commands/cache/manifest.rs b/crates/hm/src/commands/cache/manifest.rs new file mode 100644 index 00000000..c0f11a4e --- /dev/null +++ b/crates/hm/src/commands/cache/manifest.rs @@ -0,0 +1,108 @@ +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Manifest { + pub version: u32, + pub images: BTreeMap, +} + +impl Manifest { + #[must_use] + pub const fn new() -> Self { + Self { + version: 1, + images: BTreeMap::new(), + } + } + + /// SHA-256 content hash of the JSON-serialized manifest, truncated to 16 + /// hex characters. + /// + /// # Panics + /// + /// Panics if the manifest cannot be serialized to JSON (should never + /// happen for this type). + #[must_use] + #[allow(clippy::expect_used)] + pub fn content_hash(&self) -> String { + let json = serde_json::to_string(self).expect("manifest serialization cannot fail"); + let hash = Sha256::digest(json.as_bytes()); + hex::encode(&hash[..8]) + } +} + +/// Convert a Docker image tag to the corresponding tar filename. +/// +/// `"harmont-local/base:a1b2c3d4"` → `"base--a1b2c3d4.tar"` +#[must_use] +pub fn tar_name_for_tag(tag: &str) -> String { + let stripped = tag.strip_prefix("harmont-local/").unwrap_or(tag); + format!("{}.tar", stripped.replace(':', "--")) +} + +/// Inverse of [`tar_name_for_tag`]. +/// +/// `"base--a1b2c3d4.tar"` → `Some("harmont-local/base:a1b2c3d4")` +#[must_use] +pub fn tag_from_tar_name(filename: &str) -> Option { + let stem = filename.strip_suffix(".tar")?; + let (name, hash) = stem.split_once("--")?; + Some(format!("harmont-local/{name}:{hash}")) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, reason = "unit tests")] +mod tests { + use super::*; + + #[test] + fn tar_filename_from_tag() { + assert_eq!( + tar_name_for_tag("harmont-local/base:a1b2c3d4"), + "base--a1b2c3d4.tar" + ); + } + + #[test] + fn tag_from_tar_filename() { + assert_eq!( + tag_from_tar_name("base--a1b2c3d4.tar"), + Some("harmont-local/base:a1b2c3d4".to_string()) + ); + } + + #[test] + fn tag_from_bad_filename_returns_none() { + assert_eq!(tag_from_tar_name("random-file.tar"), None); + assert_eq!(tag_from_tar_name("no-extension"), None); + } + + #[test] + fn manifest_round_trip() { + let mut m = Manifest::new(); + m.images + .insert("base".to_string(), "harmont-local/base:abc123".to_string()); + + let json = serde_json::to_string(&m).unwrap(); + let m2: Manifest = serde_json::from_str(&json).unwrap(); + assert_eq!(m, m2); + } + + #[test] + fn manifest_content_hash_is_deterministic() { + let mut m = Manifest::new(); + m.images.insert( + "step1".to_string(), + "harmont-local/step1:deadbeef".to_string(), + ); + + let h1 = m.content_hash(); + let h2 = m.content_hash(); + assert_eq!(h1, h2); + assert_eq!(h1.len(), 16); + assert!(h1.chars().all(|c| c.is_ascii_hexdigit())); + } +} diff --git a/crates/hm/src/commands/cache/mod.rs b/crates/hm/src/commands/cache/mod.rs new file mode 100644 index 00000000..c011ea79 --- /dev/null +++ b/crates/hm/src/commands/cache/mod.rs @@ -0,0 +1,6 @@ +pub mod manifest; +mod restore; +mod save; + +pub use restore::handle_restore; +pub use save::handle_save; diff --git a/crates/hm/src/commands/cache/restore.rs b/crates/hm/src/commands/cache/restore.rs new file mode 100644 index 00000000..86815cdc --- /dev/null +++ b/crates/hm/src/commands/cache/restore.rs @@ -0,0 +1,72 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use tracing::{info, warn}; + +use super::manifest; +use crate::orchestrator::docker_client::DockerClient; + +/// Restore cached Docker images from tar files in the given directory. +/// +/// Each `.tar` file is mapped back to its `harmont-local/*` tag via +/// [`manifest::tag_from_tar_name`]. Images that already exist in the +/// local Docker daemon are skipped. +/// +/// # Errors +/// +/// Returns an error if the Docker daemon is unreachable or a filesystem +/// operation on `dir` fails. +#[allow(clippy::print_stderr)] +pub async fn handle_restore(dir: &Path) -> Result { + let docker = DockerClient::connect()?; + docker.ping().await?; + + if !dir.exists() { + info!("cache dir does not exist, nothing to restore"); + eprintln!("restored 0/0 images (cache dir missing)"); + return Ok(0); + } + + let mut tars = Vec::new(); + let mut entries = tokio::fs::read_dir(dir) + .await + .with_context(|| format!("read cache dir {}", dir.display()))?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let name_str = name.to_string_lossy().to_string(); + if std::path::Path::new(&name_str) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("tar")) + { + tars.push((name_str, entry.path())); + } + } + + let total = tars.len(); + let mut restored = 0u32; + let mut skipped = 0u32; + + for (filename, tar_path) in &tars { + let Some(tag) = manifest::tag_from_tar_name(filename) else { + warn!("skip unrecognized tar: {filename}"); + continue; + }; + + if docker.image_exists(&tag).await? { + info!("skip (present): {tag}"); + skipped += 1; + continue; + } + + info!("restore: {filename} → {tag}"); + match docker.import_image(tar_path).await { + Ok(()) => restored += 1, + Err(e) => { + warn!("failed to load {filename}: {e}"); + } + } + } + + eprintln!("restored {restored}/{total} images ({skipped} already present)"); + Ok(0) +} diff --git a/crates/hm/src/commands/cache/save.rs b/crates/hm/src/commands/cache/save.rs new file mode 100644 index 00000000..195fea0d --- /dev/null +++ b/crates/hm/src/commands/cache/save.rs @@ -0,0 +1,66 @@ +use std::path::Path; + +use anyhow::{Context, Result}; +use tracing::info; + +use super::manifest::{self, Manifest}; +use crate::orchestrator::docker_client::DockerClient; + +/// Save all `harmont-local/*` images to a cache directory as tar files, +/// write a manifest, and prune stale tars that no longer correspond to +/// any known image. +/// +/// Prints the manifest's content hash to stdout so CI runners (e.g. +/// GitHub Actions) can capture it for use as a cache key. +/// +/// # Errors +/// +/// Returns an error if the Docker daemon is unreachable, an image +/// export fails, or any filesystem operation on `dir` fails. +#[allow(clippy::print_stdout)] +pub async fn handle_save(dir: &Path) -> Result { + let docker = DockerClient::connect()?; + docker.ping().await?; + + tokio::fs::create_dir_all(dir) + .await + .with_context(|| format!("create cache dir {}", dir.display()))?; + + let tags = docker.list_images_by_prefix("harmont-local/").await?; + + let mut manifest = Manifest::new(); + + for tag in &tags { + let filename = manifest::tar_name_for_tag(tag); + let tar_path = dir.join(&filename); + + if tar_path.exists() { + info!("skip (exists): {filename}"); + } else { + info!("save: {tag} → {filename}"); + docker.export_image(tag, &tar_path).await?; + } + + manifest.images.insert(filename, tag.clone()); + } + + let manifest_json = serde_json::to_string_pretty(&manifest)?; + tokio::fs::write(dir.join("manifest.json"), &manifest_json) + .await + .context("write manifest.json")?; + + let mut entries = tokio::fs::read_dir(dir).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let name_str = name.to_string_lossy(); + if name_str.ends_with(".tar") && !manifest.images.contains_key(name_str.as_ref()) { + info!("prune stale: {name_str}"); + tokio::fs::remove_file(entry.path()).await.ok(); + } + } + + let hash = manifest.content_hash(); + println!("{hash}"); + + Ok(0) +} diff --git a/crates/hm/src/commands/dev/port_of.rs b/crates/hm/src/commands/dev/port_of.rs index e7870df8..b9e41917 100644 --- a/crates/hm/src/commands/dev/port_of.rs +++ b/crates/hm/src/commands/dev/port_of.rs @@ -84,7 +84,10 @@ pub async fn handle(args: DevPortOfArgs, _ctx: RunContext) -> Result { ); return Ok(5); }; - tracing::info!("{host_port}"); + #[allow(clippy::print_stdout)] + { + println!("{host_port}"); + } Ok(0) } diff --git a/crates/hm/src/commands/mod.rs b/crates/hm/src/commands/mod.rs index cead5fb1..957e4112 100644 --- a/crates/hm/src/commands/mod.rs +++ b/crates/hm/src/commands/mod.rs @@ -1,2 +1,3 @@ +pub mod cache; pub mod dev; pub mod run; diff --git a/crates/hm/src/main.rs b/crates/hm/src/main.rs index a8232ed2..32f0ad9c 100644 --- a/crates/hm/src/main.rs +++ b/crates/hm/src/main.rs @@ -21,6 +21,7 @@ async fn main() { EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new(default_level)); tracing_subscriber::fmt() + .with_writer(std::io::stderr) .with_env_filter(filter) .with_target(false) .without_time() diff --git a/crates/hm/src/orchestrator/docker_client.rs b/crates/hm/src/orchestrator/docker_client.rs index ae5657d3..83d064fd 100644 --- a/crates/hm/src/orchestrator/docker_client.rs +++ b/crates/hm/src/orchestrator/docker_client.rs @@ -15,7 +15,8 @@ use bollard::container::{ }; use bollard::exec::{CreateExecOptions, StartExecResults}; use bollard::image::{ - CommitContainerOptions, CreateImageOptions, ListImagesOptions, RemoveImageOptions, + CommitContainerOptions, CreateImageOptions, ImportImageOptions, ListImagesOptions, + RemoveImageOptions, }; use futures_util::StreamExt; use tokio::io::AsyncWrite; @@ -343,6 +344,91 @@ impl DockerClient { Ok(()) } + /// Export a Docker image to a tar file on disk. + /// + /// Streams the image layer data from the daemon and writes it to + /// `dest` using a buffered writer. + /// + /// # Errors + /// + /// Returns [`HmError::Docker`] if the daemon's export stream fails, + /// or an I/O error if writing to `dest` fails. + pub async fn export_image(&self, image: &str, dest: &std::path::Path) -> Result<()> { + use tokio::io::AsyncWriteExt; + + let mut stream = self.inner.export_image(image); + let file = tokio::fs::File::create(dest) + .await + .with_context(|| format!("create export file '{}'", dest.display()))?; + let mut writer = tokio::io::BufWriter::new(file); + while let Some(chunk) = stream.next().await { + let bytes = + chunk.map_err(|e| HmError::Docker(format!("export_image '{image}': {e}")))?; + writer + .write_all(&bytes) + .await + .with_context(|| format!("write export data to '{}'", dest.display()))?; + } + writer + .flush() + .await + .with_context(|| format!("flush export file '{}'", dest.display()))?; + Ok(()) + } + + /// Import a Docker image from a tar file on disk. + /// + /// Reads the full tar file into memory and loads it into the + /// daemon via the image import API. + /// + /// # Errors + /// + /// Returns [`HmError::Docker`] if the daemon rejects the import + /// stream, or an I/O error if reading `src` fails. + pub async fn import_image(&self, src: &std::path::Path) -> Result<()> { + let body = tokio::fs::read(src) + .await + .with_context(|| format!("read import file '{}'", src.display()))?; + let mut stream = + self.inner + .import_image(ImportImageOptions { quiet: true }, body.into(), None); + while let Some(item) = stream.next().await { + item.map_err(|e| HmError::Docker(format!("import_image '{}': {e}", src.display())))?; + } + Ok(()) + } + + /// List all image tags whose name starts with `prefix`. + /// + /// Uses the Docker `reference` filter with a glob pattern and then + /// post-filters the returned `repo_tags` to those that truly begin + /// with `prefix`. The result is sorted lexicographically. + /// + /// # Errors + /// + /// Returns [`HmError::Docker`] if the `list_images` API call + /// fails (daemon unreachable, malformed filter). + pub async fn list_images_by_prefix(&self, prefix: &str) -> Result> { + let mut filters = HashMap::new(); + filters.insert("reference".to_string(), vec![format!("{prefix}*")]); + let images = self + .inner + .list_images(Some(ListImagesOptions { + filters, + ..Default::default() + })) + .await + .map_err(|e| HmError::Docker(format!("list_images: {e}")))?; + let mut tags: Vec = images + .iter() + .flat_map(|img| &img.repo_tags) + .filter(|tag| tag.starts_with(prefix)) + .cloned() + .collect(); + tags.sort(); + Ok(tags) + } + pub async fn stop_remove(&self, container_id: &str) { let _ = self .inner diff --git a/crates/hm/tests/cache_round_trip.rs b/crates/hm/tests/cache_round_trip.rs new file mode 100644 index 00000000..226625e9 --- /dev/null +++ b/crates/hm/tests/cache_round_trip.rs @@ -0,0 +1,107 @@ +//! Docker-gated integration test for `hm cache save` / `hm cache restore`. +//! +//! Run: `cargo test -p harmont-cli --features docker-integration -- --ignored cache` + +#![cfg(feature = "docker-integration")] +#![allow( + clippy::unwrap_used, + reason = "integration tests panic on unexpected failures" +)] +#![allow( + clippy::expect_used, + reason = "integration tests panic on unexpected failures" +)] +#![allow( + clippy::ignore_without_reason, + reason = "reason is in the test name and doc comment above" +)] + +use assert_cmd::Command; +use predicates::str::contains; +use tempfile::TempDir; + +#[test] +#[ignore] +fn cache_save_creates_manifest() { + let cache_dir = TempDir::new().unwrap(); + let cache_path = cache_dir.path(); + + let out = Command::cargo_bin("hm") + .unwrap() + .args(["cache", "save", cache_path.to_str().unwrap()]) + .assert() + .success(); + + // manifest.json must exist + let manifest_path = cache_path.join("manifest.json"); + assert!(manifest_path.exists(), "manifest.json should exist"); + + let content = std::fs::read_to_string(&manifest_path).unwrap(); + let manifest: serde_json::Value = serde_json::from_str(&content).unwrap(); + assert_eq!(manifest["version"], 1); + + // stdout has the 16-char hex content hash + let stdout = String::from_utf8(out.get_output().stdout.clone()).unwrap(); + let hash = stdout.trim(); + assert_eq!(hash.len(), 16, "content hash should be 16 hex chars"); + assert!(hash.chars().all(|c| c.is_ascii_hexdigit()), "should be hex"); +} + +#[test] +#[ignore] +fn cache_save_is_deterministic() { + let cache_dir = TempDir::new().unwrap(); + let path = cache_dir.path().to_str().unwrap(); + + let out1 = Command::cargo_bin("hm") + .unwrap() + .args(["cache", "save", path]) + .assert() + .success(); + let out2 = Command::cargo_bin("hm") + .unwrap() + .args(["cache", "save", path]) + .assert() + .success(); + + let h1 = String::from_utf8(out1.get_output().stdout.clone()).unwrap(); + let h2 = String::from_utf8(out2.get_output().stdout.clone()).unwrap(); + assert_eq!(h1.trim(), h2.trim(), "content hash should be deterministic"); +} + +#[test] +#[ignore] +fn cache_restore_after_save() { + let cache_dir = TempDir::new().unwrap(); + let path = cache_dir.path().to_str().unwrap(); + + // Save first + Command::cargo_bin("hm") + .unwrap() + .args(["cache", "save", path]) + .assert() + .success(); + + // Restore — all images already present + Command::cargo_bin("hm") + .unwrap() + .args(["cache", "restore", path]) + .assert() + .success() + .stderr(contains("already present")); +} + +#[test] +#[ignore] +fn cache_restore_missing_dir() { + Command::cargo_bin("hm") + .unwrap() + .args([ + "cache", + "restore", + "/tmp/harmont-nonexistent-cache-dir-test", + ]) + .assert() + .success() + .stderr(contains("0/0")); +} diff --git a/dsls/harmont-py/harmont/__init__.py b/dsls/harmont-py/harmont/__init__.py index 829b8706..d39f3d01 100644 --- a/dsls/harmont-py/harmont/__init__.py +++ b/dsls/harmont-py/harmont/__init__.py @@ -34,6 +34,7 @@ from ._envelope import dump_registry_json from ._step import Step, scratch, wait from ._target import clear_target_cache, target # noqa: F401 clear_target_cache used by tests +from ._toolchain import apt_base from ._typing import BaseImage, Dep, Target from .cache import ( CacheCompose, @@ -57,8 +58,9 @@ from .pipeline import pipeline_to_json from .python import python from .ruby import ruby -from .rust import rust +from .rust import RustProject, rust from .triggers import pull_request, push, schedule +from .triggers import pull_request as pr from .types import Pipeline from .zig import zig @@ -127,6 +129,11 @@ def sh( ) +def group(steps: list[Step] | tuple[Step, ...]) -> tuple[Step, ...]: + """Combine steps into a group for use as a target return value.""" + return tuple(steps) + + __all__ = [ "BaseImage", "CacheCompose", @@ -138,8 +145,10 @@ def sh( "Dep", "Deployment", "Pipeline", + "RustProject", "Step", "Target", + "apt_base", "cmake", "compose", "composer", @@ -151,6 +160,7 @@ def sh( "forever", "go", "gradle", + "group", "haskell", "npm", "ocaml", @@ -158,6 +168,7 @@ def sh( "perl", "pipeline", "pipeline_to_json", + "pr", "pull_request", "push", "py", diff --git a/dsls/harmont-py/harmont/_toolchain.py b/dsls/harmont-py/harmont/_toolchain.py index fb67b6af..5bd3c305 100644 --- a/dsls/harmont-py/harmont/_toolchain.py +++ b/dsls/harmont-py/harmont/_toolchain.py @@ -77,3 +77,18 @@ def make_install_chain( label=f":{lang_tag}: {install_tag}", cache=install_cache, ) + + +def apt_base( + *, + packages: tuple[str, ...], + image: str | None = None, + label: str = ":apt: base", +) -> Step: + """Create a standalone apt-base step sharable across toolchains via ``base=``.""" + return scratch().sh( + apt_install_cmd(packages), + label=label, + image=image, + cache=CacheTTL(duration=APT_TTL), + ) diff --git a/dsls/harmont-py/harmont/_unwrap.py b/dsls/harmont-py/harmont/_unwrap.py index 718bd369..3155b302 100644 --- a/dsls/harmont-py/harmont/_unwrap.py +++ b/dsls/harmont-py/harmont/_unwrap.py @@ -19,12 +19,14 @@ from .haskell import HaskellPackage from .npm import NpmProject from .py.uv import UvProject -from .rust import RustToolchain +from .rust import RustProject, RustToolchain def _one(obj: object) -> tuple[Step, ...]: if isinstance(obj, Step): return (obj,) + if isinstance(obj, RustProject): + return (obj.test(), obj.clippy(), obj.fmt()) if isinstance(obj, HaskellPackage): return (obj.build(),) if isinstance(obj, RustToolchain): @@ -39,8 +41,8 @@ def _one(obj: object) -> tuple[Step, ...]: return as_leaves(obj) msg = ( f"hm.target: cannot use {type(obj).__name__} as a pipeline leaf\n" - " → return one of: Step, tuple[Step, ...], HaskellPackage, " - "RustToolchain, NpmProject, ElmProject, UvProject" + " → return one of: Step, tuple[Step, ...], RustProject, RustToolchain, " + "HaskellPackage, NpmProject, ElmProject, UvProject" ) raise TypeError(msg) diff --git a/dsls/harmont-py/harmont/rust.py b/dsls/harmont-py/harmont/rust.py index b7189522..a9c90322 100644 --- a/dsls/harmont-py/harmont/rust.py +++ b/dsls/harmont-py/harmont/rust.py @@ -1,8 +1,9 @@ """Rust toolchain abstraction (HAR-15). -Public surface lives on the module-level singleton :data:`rust`. Call it -to construct a :class:`RustToolchain`, or use the bare-form action -methods (``rust.build()``, ``rust.test()``, etc.) for a one-shot leaf. +Public surface lives on the module-level singleton :data:`rust`: + + hm.rust.toolchain(...) -> RustToolchain (install-only) + hm.rust.project(...) -> RustProject (full CI DAG) """ from __future__ import annotations @@ -12,10 +13,11 @@ from typing import TYPE_CHECKING, Any from ._toolchain import make_install_chain -from .cache import CacheForever +from .cache import CacheForever, CacheOnChange if TYPE_CHECKING: from ._step import Step + from .cache import CachePolicy APT_PACKAGES = ( "curl", @@ -25,8 +27,6 @@ "libssl-dev", ) -_ACTION_KWARGS = frozenset(("cache", "env", "timeout_seconds", "label", "key")) - _VERSION_RE = re.compile(r"^[a-z0-9.-]+$") @@ -73,10 +73,49 @@ def clippy(self, **kw: Any) -> Step: def fmt(self, **kw: Any) -> Step: return self._emit("cargo fmt --check", ":rust: fmt", **kw) + def warmup(self, **kw: Any) -> Step: + return self._emit( + "cargo build --workspace --tests --locked", + ":rust: warmup", + **kw, + ) + def doc(self, **kw: Any) -> Step: return self._emit("cargo doc --no-deps", ":rust: doc", **kw) +@dataclass(frozen=True) +class RustProject: + """High-level Rust CI DAG — constructed via ``hm.rust.project()``.""" + + toolchain: RustToolchain + warmup: Step + + def test(self, *, flags: tuple[str, ...] = (), **kw: Any) -> Step: + extra = (" " + " ".join(flags)) if flags else "" + return self.warmup.sh( + self.toolchain._wrap(f"cargo test --workspace --locked{extra}"), # noqa: SLF001 + label=kw.pop("label", ":rust: test"), + **kw, + ) + + def clippy(self, *, flags: tuple[str, ...] = (), **kw: Any) -> Step: + extra = (" " + " ".join(flags)) if flags else "" + return self.warmup.sh( + self.toolchain._wrap( # noqa: SLF001 + f"cargo clippy --workspace --tests --locked{extra} -- -D warnings" + ), + label=kw.pop("label", ":rust: clippy"), + **kw, + ) + + def fmt(self, *, flags: tuple[str, ...] = (), **kw: Any) -> Step: + extra = (" " + " ".join(flags)) if flags else "" + return self.toolchain._emit( # noqa: SLF001 + f"cargo fmt --check{extra}", ":rust: fmt", **kw + ) + + def _make_rust( *, path: str = ".", @@ -104,11 +143,40 @@ def _make_rust( return RustToolchain(path=path, installed=installed) +def _make_rust_project( + *, + path: str = ".", + version: str = "stable", + image: str | None = None, + components: tuple[str, ...] = ("clippy", "rustfmt"), + base: Step | None = None, + cache: CachePolicy | None = None, +) -> RustProject: + tc = _make_rust( + path=path, + version=version, + image=image, + components=components, + base=base, + ) + + lock_path = f"{path}/Cargo.lock" if path != "." else "Cargo.lock" + warmup_cache = cache if cache is not None else CacheOnChange(paths=(lock_path,)) + + warm = tc._emit( # noqa: SLF001 + "cargo build --workspace --tests --locked", + ":rust: warmup", + cache=warmup_cache, + ) + + return RustProject(toolchain=tc, warmup=warm) + + class _RustEntry: - """Callable singleton — supports both object form and bare form.""" + """Namespace for ``hm.rust.toolchain()`` and ``hm.rust.project()``.""" - def __call__( - self, + @staticmethod + def toolchain( *, path: str = ".", version: str = "stable", @@ -124,25 +192,24 @@ def __call__( base=base, ) - def build(self, *, release: bool = False, **kw: Any) -> Step: - action_kw = {k: kw.pop(k) for k in list(kw) if k in _ACTION_KWARGS} - return self(**kw).build(release=release, **action_kw) - - def test(self, *, release: bool = False, **kw: Any) -> Step: - action_kw = {k: kw.pop(k) for k in list(kw) if k in _ACTION_KWARGS} - return self(**kw).test(release=release, **action_kw) - - def clippy(self, **kw: Any) -> Step: - action_kw = {k: kw.pop(k) for k in list(kw) if k in _ACTION_KWARGS} - return self(**kw).clippy(**action_kw) - - def fmt(self, **kw: Any) -> Step: - action_kw = {k: kw.pop(k) for k in list(kw) if k in _ACTION_KWARGS} - return self(**kw).fmt(**action_kw) - - def doc(self, **kw: Any) -> Step: - action_kw = {k: kw.pop(k) for k in list(kw) if k in _ACTION_KWARGS} - return self(**kw).doc(**action_kw) + @staticmethod + def project( + *, + path: str = ".", + version: str = "stable", + image: str | None = None, + components: tuple[str, ...] = ("clippy", "rustfmt"), + base: Step | None = None, + cache: CachePolicy | None = None, + ) -> RustProject: + return _make_rust_project( + path=path, + version=version, + image=image, + components=components, + base=base, + cache=cache, + ) rust = _RustEntry() diff --git a/dsls/harmont-py/tests/test_e2e_fixtures.py b/dsls/harmont-py/tests/test_e2e_fixtures.py index 1bae78fc..f65ab8cf 100644 --- a/dsls/harmont-py/tests/test_e2e_fixtures.py +++ b/dsls/harmont-py/tests/test_e2e_fixtures.py @@ -69,7 +69,7 @@ def _build_monorepo_ci() -> dict: def _build_rust_release() -> dict: - project = rust(path=".") + project = rust.toolchain(path=".") return hm.pipeline( project.build(), diff --git a/dsls/harmont-py/tests/test_rust.py b/dsls/harmont-py/tests/test_rust.py index 99e8334d..d6a9a889 100644 --- a/dsls/harmont-py/tests/test_rust.py +++ b/dsls/harmont-py/tests/test_rust.py @@ -1,4 +1,4 @@ -"""Rust toolchain abstraction tests.""" +"""Rust toolchain and project abstraction tests.""" from __future__ import annotations @@ -20,157 +20,245 @@ def _step_by_substring(p: dict, needle: str) -> dict: raise AssertionError(msg) -def test_rust_object_form_full_chain(): - rust = hm.rust(path="cli") - p = hm.pipeline(rust.build(), default_image="ubuntu:24.04") - cmds = _cmds(p) - assert any("apt-get install" in c for c in cmds) - assert any("sh.rustup.rs" in c for c in cmds) - assert any("cd cli && cargo build" in c for c in cmds) - - -def test_rust_actions_share_install_step(): - rust = hm.rust(path="cli") - p = hm.pipeline( - rust.build(), - rust.test(), - rust.clippy(), - rust.fmt(), - rust.doc(), - default_image="ubuntu:24.04", - ) - cmds = _cmds(p) - assert len([c for c in cmds if "sh.rustup.rs" in c]) == 1 - assert len([c for c in cmds if "apt-get install" in c]) == 1 - assert any("cargo build" in c for c in cmds) - assert any("cargo test" in c for c in cmds) - assert any("cargo clippy --all-targets -- -D warnings" in c for c in cmds) - assert any("cargo fmt --check" in c for c in cmds) - assert any("cargo doc --no-deps" in c for c in cmds) - - -def test_rust_build_release_flag(): - rust = hm.rust(path=".") - s = rust.build(release=True) - assert s.cmd is not None - assert "cargo build --release" in s.cmd - - -def test_rust_test_release_flag(): - rust = hm.rust(path=".") - s = rust.test(release=True) - assert s.cmd is not None - assert "cargo test --release" in s.cmd - - -def test_rust_rustup_cache_forever(): - rust = hm.rust(path="cli") - p = hm.pipeline(rust.build()) - rustup = _step_by_substring(p, "sh.rustup.rs") - assert rustup["cache"]["policy"] == "forever" - - -def test_rust_default_components(): - rust = hm.rust(path=".") - p = hm.pipeline(rust.build()) - rustup = _step_by_substring(p, "sh.rustup.rs") - assert "--component clippy,rustfmt" in rustup["cmd"] - - -def test_rust_components_override(): - rust = hm.rust(path=".", components=("clippy",)) - p = hm.pipeline(rust.build()) - rustup = _step_by_substring(p, "sh.rustup.rs") - assert "--component clippy" in rustup["cmd"] - assert "rustfmt" not in rustup["cmd"] - - -def test_rust_version_in_rustup_cmd(): - rust = hm.rust(path=".", version="1.81.0") - p = hm.pipeline(rust.build()) - rustup = _step_by_substring(p, "sh.rustup.rs") - assert "--default-toolchain 1.81.0" in rustup["cmd"] - - -def test_rust_invalid_version_rejected(): - with pytest.raises(ValueError, match="version"): - hm.rust(version="not a valid; version") - - -def test_rust_installed_escape_hatch_chains(): - rust = hm.rust(path="cli") - custom = rust.installed.sh( - "cd cli && cargo build --release --features foo", - label=":rust: custom", - ) - p = hm.pipeline(custom) - cmds = _cmds(p) - assert any("--features foo" in c for c in cmds) - - -def test_rust_action_labels_auto_generated(): - rust = hm.rust(path=".") - assert rust.build().label == ":rust: build" - assert rust.test().label == ":rust: test" - assert rust.clippy().label == ":rust: clippy" - assert rust.fmt().label == ":rust: fmt" - assert rust.doc().label == ":rust: doc" - - -def test_rust_action_label_override(): - rust = hm.rust(path=".") - s = rust.build(label=":rust: dev build") - assert s.label == ":rust: dev build" - - -def test_rust_action_cache_forwarded(): - rust = hm.rust(path=".") - s = rust.build(cache=CacheOnChange(paths=("Cargo.lock",))) - assert s.cache == CacheOnChange(paths=("Cargo.lock",)) - - -def test_rust_image_emitted_on_apt_step(): - rust = hm.rust(path=".", image="alpine:3.20") - p = hm.pipeline(rust.build()) - apt = _step_by_substring(p, "apt-get install") - assert apt.get("image") == "alpine:3.20" - - -def test_rust_with_base_skips_apt(): - base = hm.scratch().sh("custom base", label="base") - rust = hm.rust(path="cli", base=base) - p = hm.pipeline(rust.build(), default_image="ubuntu:24.04") - cmds = _cmds(p) - assert not any("apt-get install" in c for c in cmds) - assert any("custom base" in c for c in cmds) - assert any("sh.rustup.rs" in c for c in cmds) - assert any("cd cli && cargo build" in c for c in cmds) - - -def test_rust_bare_form_build(): - p = hm.pipeline(hm.rust.build()) - cmds = _cmds(p) - assert any("cd . && cargo build" in c for c in cmds) - - -def test_rust_bare_form_all_actions(): - p = hm.pipeline( - hm.rust.build(), hm.rust.test(), hm.rust.clippy(), hm.rust.fmt(), hm.rust.doc() - ) - cmds = _cmds(p) - assert any("cargo build" in c for c in cmds) - assert any("cargo test" in c for c in cmds) - assert any("cargo clippy" in c for c in cmds) - assert any("cargo fmt --check" in c for c in cmds) - assert any("cargo doc --no-deps" in c for c in cmds) - - -def test_rust_bare_form_accepts_path_kwarg(): - p = hm.pipeline(hm.rust.test(path="cli")) - cmds = _cmds(p) - assert any("cd cli && cargo test" in c for c in cmds) - - -def test_rust_bare_form_forwards_action_kwargs(): - s = hm.rust.build(path="cli", label=":rust: custom") - assert s.label == ":rust: custom" +# --- RustToolchain (hm.rust.toolchain) --- + + +class TestRustToolchain: + def test_full_chain(self): + tc = hm.rust.toolchain(path="cli") + p = hm.pipeline(tc.build(), default_image="ubuntu:24.04") + cmds = _cmds(p) + assert any("apt-get install" in c for c in cmds) + assert any("sh.rustup.rs" in c for c in cmds) + assert any("cd cli && cargo build" in c for c in cmds) + + def test_actions_share_install_step(self): + tc = hm.rust.toolchain(path="cli") + p = hm.pipeline( + tc.build(), + tc.test(), + tc.clippy(), + tc.fmt(), + tc.doc(), + default_image="ubuntu:24.04", + ) + cmds = _cmds(p) + assert len([c for c in cmds if "sh.rustup.rs" in c]) == 1 + assert len([c for c in cmds if "apt-get install" in c]) == 1 + + def test_build_release(self): + tc = hm.rust.toolchain(path=".") + s = tc.build(release=True) + assert "cargo build --release" in s.cmd + + def test_test_release(self): + tc = hm.rust.toolchain(path=".") + s = tc.test(release=True) + assert "cargo test --release" in s.cmd + + def test_rustup_cache_forever(self): + tc = hm.rust.toolchain(path="cli") + p = hm.pipeline(tc.build()) + rustup = _step_by_substring(p, "sh.rustup.rs") + assert rustup["cache"]["policy"] == "forever" + + def test_default_components(self): + tc = hm.rust.toolchain(path=".") + p = hm.pipeline(tc.build()) + rustup = _step_by_substring(p, "sh.rustup.rs") + assert "--component clippy,rustfmt" in rustup["cmd"] + + def test_components_override(self): + tc = hm.rust.toolchain(path=".", components=("clippy",)) + p = hm.pipeline(tc.build()) + rustup = _step_by_substring(p, "sh.rustup.rs") + assert "--component clippy" in rustup["cmd"] + assert "rustfmt" not in rustup["cmd"] + + def test_version_in_rustup_cmd(self): + tc = hm.rust.toolchain(path=".", version="1.81.0") + p = hm.pipeline(tc.build()) + rustup = _step_by_substring(p, "sh.rustup.rs") + assert "--default-toolchain 1.81.0" in rustup["cmd"] + + def test_invalid_version_rejected(self): + with pytest.raises(ValueError, match="version"): + hm.rust.toolchain(version="not a valid; version") + + def test_installed_escape_hatch(self): + tc = hm.rust.toolchain(path="cli") + custom = tc.installed.sh( + "cd cli && cargo build --release --features foo", + label=":rust: custom", + ) + p = hm.pipeline(custom) + cmds = _cmds(p) + assert any("--features foo" in c for c in cmds) + + def test_action_labels(self): + tc = hm.rust.toolchain(path=".") + assert tc.build().label == ":rust: build" + assert tc.test().label == ":rust: test" + assert tc.clippy().label == ":rust: clippy" + assert tc.fmt().label == ":rust: fmt" + assert tc.doc().label == ":rust: doc" + + def test_action_label_override(self): + tc = hm.rust.toolchain(path=".") + s = tc.build(label=":rust: dev build") + assert s.label == ":rust: dev build" + + def test_action_cache_forwarded(self): + tc = hm.rust.toolchain(path=".") + s = tc.build(cache=CacheOnChange(paths=("Cargo.lock",))) + assert s.cache == CacheOnChange(paths=("Cargo.lock",)) + + def test_image_emitted_on_apt_step(self): + tc = hm.rust.toolchain(path=".", image="alpine:3.20") + p = hm.pipeline(tc.build()) + apt = _step_by_substring(p, "apt-get install") + assert apt.get("image") == "alpine:3.20" + + def test_with_base_skips_apt(self): + base = hm.scratch().sh("custom base", label="base") + tc = hm.rust.toolchain(path="cli", base=base) + p = hm.pipeline(tc.build(), default_image="ubuntu:24.04") + cmds = _cmds(p) + assert not any("apt-get install" in c for c in cmds) + assert any("custom base" in c for c in cmds) + assert any("sh.rustup.rs" in c for c in cmds) + assert any("cd cli && cargo build" in c for c in cmds) + + def test_warmup_returns_step(self): + tc = hm.rust.toolchain(path="cli") + w = tc.warmup() + assert w.cmd is not None + assert "cargo build --workspace --tests --locked" in w.cmd + + def test_warmup_chains_from_installed(self): + tc = hm.rust.toolchain(path="cli") + w = tc.warmup() + assert w.parent is tc.installed + + def test_warmup_default_label(self): + tc = hm.rust.toolchain(path=".") + assert tc.warmup().label == ":rust: warmup" + + def test_warmup_label_override(self): + tc = hm.rust.toolchain(path=".") + assert tc.warmup(label=":rust: pre-build").label == ":rust: pre-build" + + def test_warmup_in_pipeline(self): + tc = hm.rust.toolchain(path="cli") + w = tc.warmup() + t = w.sh( + ". $HOME/.cargo/env && cd cli && cargo test --workspace --locked", + label=":rust: test", + ) + p = hm.pipeline(t, tc.fmt(), default_image="ubuntu:24.04") + cmds = _cmds(p) + assert any("cargo build --workspace --tests --locked" in c for c in cmds) + assert any("cargo test --workspace --locked" in c for c in cmds) + assert any("cargo fmt" in c for c in cmds) + assert len([c for c in cmds if "sh.rustup.rs" in c]) == 1 + assert len([c for c in cmds if "apt-get install" in c]) == 1 + + +# --- RustProject (hm.rust.project) --- + + +class TestRustProject: + def test_project_has_all_methods(self): + proj = hm.rust.project(path="cli") + assert proj.warmup.cmd is not None + assert proj.test().cmd is not None + assert proj.clippy().cmd is not None + assert proj.fmt().cmd is not None + + def test_warmup_implicit_cache_on_change(self): + proj = hm.rust.project(path="cli") + assert proj.warmup.cache == CacheOnChange(paths=("cli/Cargo.lock",)) + + def test_warmup_implicit_cache_dot_path(self): + proj = hm.rust.project(path=".") + assert proj.warmup.cache == CacheOnChange(paths=("Cargo.lock",)) + + def test_warmup_cache_override(self): + custom = CacheOnChange(paths=("Cargo.toml",)) + proj = hm.rust.project(path=".", cache=custom) + assert proj.warmup.cache == custom + + def test_test_command(self): + proj = hm.rust.project(path="cli") + assert "cargo test --workspace --locked" in proj.test().cmd + + def test_test_flags(self): + proj = hm.rust.project(path=".") + step = proj.test(flags=("--lib", "--no-fail-fast")) + assert "cargo test --workspace --locked --lib --no-fail-fast" in step.cmd + + def test_clippy_command(self): + proj = hm.rust.project(path="cli") + assert "cargo clippy --workspace --tests --locked -- -D warnings" in proj.clippy().cmd + + def test_clippy_flags(self): + proj = hm.rust.project(path=".") + step = proj.clippy(flags=("--fix",)) + assert "cargo clippy --workspace --tests --locked --fix -- -D warnings" in step.cmd + + def test_fmt_command(self): + proj = hm.rust.project(path="cli") + assert "cargo fmt --check" in proj.fmt().cmd + + def test_fmt_flags(self): + proj = hm.rust.project(path=".") + assert "cargo fmt --check --all" in proj.fmt(flags=("--all",)).cmd + + def test_test_chains_off_warmup(self): + proj = hm.rust.project(path=".") + assert proj.test().parent is proj.warmup + + def test_clippy_chains_off_warmup(self): + proj = hm.rust.project(path=".") + assert proj.clippy().parent is proj.warmup + + def test_fmt_chains_off_install(self): + proj = hm.rust.project(path=".") + assert proj.fmt().parent is proj.toolchain.installed + + def test_toolchain_escape_hatch(self): + proj = hm.rust.project(path="cli") + custom = proj.toolchain.installed.sh("custom", label="custom") + assert custom.parent is proj.toolchain.installed + + def test_with_base_skips_apt(self): + base = hm.scratch().sh("custom base", label="base") + proj = hm.rust.project(path="cli", base=base) + p = hm.pipeline(proj.test(), proj.clippy(), proj.fmt(), default_image="ubuntu:24.04") + cmds = _cmds(p) + assert not any("apt-get install" in c for c in cmds) + assert any("custom base" in c for c in cmds) + + def test_labels(self): + proj = hm.rust.project(path=".") + assert proj.warmup.label == ":rust: warmup" + assert proj.test().label == ":rust: test" + assert proj.clippy().label == ":rust: clippy" + assert proj.fmt().label == ":rust: fmt" + + def test_pipeline_ir(self): + proj = hm.rust.project(path="cli") + p = hm.pipeline(proj.test(), proj.clippy(), proj.fmt(), default_image="ubuntu:24.04") + cmds = _cmds(p) + assert any("cargo build --workspace --tests --locked" in c for c in cmds) + assert any("cargo test --workspace --locked" in c for c in cmds) + assert any("cargo clippy" in c for c in cmds) + assert any("cargo fmt --check" in c for c in cmds) + assert len([c for c in cmds if "sh.rustup.rs" in c]) == 1 + assert len([c for c in cmds if "apt-get install" in c]) == 1 + + def test_version_forwarded(self): + proj = hm.rust.project(path=".", version="1.81.0") + p = hm.pipeline(proj.test()) + rustup = _step_by_substring(p, "sh.rustup.rs") + assert "--default-toolchain 1.81.0" in rustup["cmd"] diff --git a/dsls/harmont-py/tests/test_target_unwrap.py b/dsls/harmont-py/tests/test_target_unwrap.py index 496afccb..95664627 100644 --- a/dsls/harmont-py/tests/test_target_unwrap.py +++ b/dsls/harmont-py/tests/test_target_unwrap.py @@ -39,12 +39,21 @@ def test_haskell_package_unwraps_to_build(tmp_path, monkeypatch): def test_rust_toolchain_unwraps_to_build(): - tc = hm.rust(path="cli", version="stable") + tc = hm.rust.toolchain(path="cli", version="stable") leaves = as_leaves(tc) assert len(leaves) == 1 assert "cargo build" in leaves[0].cmd +def test_rust_project_unwraps_to_test_clippy_fmt(): + proj = hm.rust.project(path="cli") + leaves = as_leaves(proj) + assert len(leaves) == 3 + assert "cargo test" in leaves[0].cmd + assert "cargo clippy" in leaves[1].cmd + assert "cargo fmt" in leaves[2].cmd + + def test_npm_project_unwraps_to_install(): proj = hm.npm(path="app", version="20") leaves = as_leaves(proj) @@ -76,5 +85,5 @@ def test_unknown_type_raises_typeerror(): def test_unknown_type_message_lists_supported_types(): - with pytest.raises(TypeError, match=r"Step.*HaskellPackage.*ElmProject"): + with pytest.raises(TypeError, match=r"Step.*RustProject.*RustToolchain.*HaskellPackage"): as_leaves("oops") # type: ignore[arg-type] diff --git a/dsls/harmont-py/tests/test_toolchain_compose.py b/dsls/harmont-py/tests/test_toolchain_compose.py index 14eefa7a..19b2f73d 100644 --- a/dsls/harmont-py/tests/test_toolchain_compose.py +++ b/dsls/harmont-py/tests/test_toolchain_compose.py @@ -44,7 +44,7 @@ def test_stack_elm_on_npm(): def test_escape_hatch_consistent_across_toolchains(): """Every toolchain exposes .installed as a public Step.""" - rust = hm.rust(path=".") + rust = hm.rust.toolchain(path=".") ghc = hm.haskell(ghc="9.6.7") api = ghc.package("api") node = hm.npm(path=".") @@ -60,7 +60,7 @@ def test_deterministic_emission(): """Two identical pipeline constructions emit equal IR dicts.""" def build() -> dict: - rust = hm.rust(path="cli") + rust = hm.rust.toolchain(path="cli") return hm.pipeline(rust.build(), rust.test(), default_image="ubuntu:24.04") assert build() == build() @@ -69,7 +69,7 @@ def build() -> dict: def test_mixed_pipeline_compiles(): """A pipeline mixing all four toolchains lowers without error.""" ghc = hm.haskell(ghc="9.6.7") - rust = hm.rust(path="cli") + rust = hm.rust.toolchain(path="cli") node = hm.npm(path="app/codegen") elm = hm.elm(path="app", base=node.installed) p = hm.pipeline( @@ -82,3 +82,55 @@ def test_mixed_pipeline_compiles(): ) assert p["version"] == "0" assert len(p["graph"]["nodes"]) > 0 + + +def _step_by_substring(p: dict, needle: str) -> dict: + for n in p["graph"]["nodes"]: + if needle in (n["step"].get("cmd") or ""): + return n["step"] + msg = f"no command step containing {needle!r}" + raise AssertionError(msg) + + +def test_apt_base_shared_across_toolchains(): + """Single apt-base feeds both rust and python toolchains.""" + base = hm.apt_base( + packages=( + "curl", + "ca-certificates", + "build-essential", + "pkg-config", + "libssl-dev", + "python3", + "python3-venv", + ), + ) + rust = hm.rust.toolchain(path=".", base=base) + py = hm.py.uv(path="dsls/harmont-py", base=base) + p = hm.pipeline( + rust.build(), + py.test(), + default_image="ubuntu:24.04", + ) + cmds = _cmds(p) + assert len([c for c in cmds if "apt-get install" in c]) == 1 + assert any("sh.rustup.rs" in c for c in cmds) + assert any("uv" in c for c in cmds) + + +def test_apt_base_default_label(): + base = hm.apt_base(packages=("curl",)) + assert base.label == ":apt: base" + + +def test_apt_base_custom_image(): + base = hm.apt_base(packages=("curl",), image="debian:bookworm") + rust = hm.rust.toolchain(path=".", base=base) + p = hm.pipeline(rust.build(), default_image="ubuntu:24.04") + apt_step = _step_by_substring(p, "apt-get install") + assert apt_step.get("image") == "debian:bookworm" + + +def test_apt_base_custom_label(): + base = hm.apt_base(packages=("curl",), label=":lock: deps") + assert base.label == ":lock: deps" diff --git a/dsls/harmont-ts/src/index.ts b/dsls/harmont-ts/src/index.ts index 26987f16..65bf0c1b 100644 --- a/dsls/harmont-ts/src/index.ts +++ b/dsls/harmont-ts/src/index.ts @@ -21,4 +21,5 @@ export { } from "./triggers.js"; export { pipeline, type PipelineIR, type PipelineOptions } from "./pipeline.js"; export { target, clearTargetCache } from "./target.js"; +export { aptBase } from "./toolchains/shared.js"; export { renderEnvelope, type PipelineDefinition } from "./envelope.js"; diff --git a/dsls/harmont-ts/src/toolchains/index.ts b/dsls/harmont-ts/src/toolchains/index.ts index b24cec7b..da0a1f99 100644 --- a/dsls/harmont-ts/src/toolchains/index.ts +++ b/dsls/harmont-ts/src/toolchains/index.ts @@ -1,6 +1,6 @@ export { npm, NpmProject, type NpmOptions } from "./npm.js"; export { go, GoToolchain, type GoOptions } from "./go.js"; -export { rust, RustToolchain, type RustOptions } from "./rust.js"; +export { rust, RustToolchain, RustProject, type RustToolchainOptions, type RustProjectOptions } from "./rust.js"; export { python, PythonToolchain, type PythonOptions } from "./python.js"; export { cmake, CMakeProject, type CMakeOptions } from "./cmake.js"; export { gradle, GradleProject, type GradleOptions } from "./gradle.js"; diff --git a/dsls/harmont-ts/src/toolchains/rust.ts b/dsls/harmont-ts/src/toolchains/rust.ts index 2ee5b596..34790061 100644 --- a/dsls/harmont-ts/src/toolchains/rust.ts +++ b/dsls/harmont-ts/src/toolchains/rust.ts @@ -1,5 +1,5 @@ import type { Step, StepOptions } from "../step.js"; -import { forever } from "../cache.js"; +import { type CachePolicy, forever, onChange } from "../cache.js"; import { makeInstallChain } from "./shared.js"; const APT_PACKAGES = [ @@ -11,7 +11,7 @@ const APT_PACKAGES = [ ] as const; const VERSION_RE = /^[a-z0-9.-]+$/; -export interface RustOptions { +export interface RustToolchainOptions { readonly path?: string; readonly version?: string; readonly image?: string; @@ -19,6 +19,10 @@ export interface RustOptions { readonly base?: Step; } +export interface RustProjectOptions extends RustToolchainOptions { + readonly cache?: CachePolicy; +} + type ActionOptions = Omit; export class RustToolchain { @@ -34,7 +38,7 @@ export class RustToolchain { return this._installed; } - private _cargo(cmd: string, label: string, opts?: ActionOptions): Step { + _cargo(cmd: string, label: string, opts?: ActionOptions): Step { return this._installed.sh( `. $HOME/.cargo/env && cd ${this.path} && ${cmd}`, { label, ...opts }, @@ -66,16 +70,59 @@ export class RustToolchain { doc(opts?: ActionOptions): Step { return this._cargo("cargo doc --no-deps", ":rust: doc", opts); } + + warmup(opts?: ActionOptions): Step { + return this._cargo( + "cargo build --workspace --tests --locked", + ":rust: warmup", + opts, + ); + } +} + +export class RustProject { + readonly toolchain: RustToolchain; + readonly warmup: Step; + + constructor(toolchain: RustToolchain, warmup: Step) { + this.toolchain = toolchain; + this.warmup = warmup; + } + + test(opts?: { flags?: readonly string[] } & ActionOptions): Step { + const extra = opts?.flags?.length ? " " + opts.flags.join(" ") : ""; + return this.warmup.sh( + `. $HOME/.cargo/env && cd ${this.toolchain.path} && cargo test --workspace --locked${extra}`, + { label: ":rust: test", ...opts }, + ); + } + + clippy(opts?: { flags?: readonly string[] } & ActionOptions): Step { + const extra = opts?.flags?.length ? " " + opts.flags.join(" ") : ""; + return this.warmup.sh( + `. $HOME/.cargo/env && cd ${this.toolchain.path} && cargo clippy --workspace --tests --locked${extra} -- -D warnings`, + { label: ":rust: clippy", ...opts }, + ); + } + + fmt(opts?: { flags?: readonly string[] } & ActionOptions): Step { + const extra = opts?.flags?.length ? " " + opts.flags.join(" ") : ""; + return this.toolchain._cargo( + `cargo fmt --check${extra}`, + ":rust: fmt", + opts, + ); + } } -export function rust(opts?: RustOptions): RustToolchain { +function makeToolchain(opts?: RustToolchainOptions): RustToolchain { const path = opts?.path ?? "."; const version = opts?.version ?? "stable"; const components = opts?.components ?? ["clippy", "rustfmt"]; if (!VERSION_RE.test(version)) { throw new Error( - `hm.rust: invalid version "${version}"\n → use "stable", "nightly", or a semver like "1.81.0"`, + `rust.toolchain: invalid version "${version}"\n → use "stable", "nightly", or a semver like "1.81.0"`, ); } @@ -98,3 +145,24 @@ export function rust(opts?: RustOptions): RustToolchain { return new RustToolchain(path, installed); } + +function makeProject(opts?: RustProjectOptions): RustProject { + const path = opts?.path ?? "."; + const tc = makeToolchain(opts); + + const lockPath = path !== "." ? `${path}/Cargo.lock` : "Cargo.lock"; + const warmupCache = opts?.cache ?? onChange(lockPath); + + const warm = tc._cargo( + "cargo build --workspace --tests --locked", + ":rust: warmup", + { cache: warmupCache }, + ); + + return new RustProject(tc, warm); +} + +export const rust = { + toolchain: makeToolchain, + project: makeProject, +}; diff --git a/dsls/harmont-ts/src/toolchains/shared.ts b/dsls/harmont-ts/src/toolchains/shared.ts index f7a3da38..ca0922d2 100644 --- a/dsls/harmont-ts/src/toolchains/shared.ts +++ b/dsls/harmont-ts/src/toolchains/shared.ts @@ -12,6 +12,17 @@ export function nodeInstallCmd(version: string): string { return `curl -fsSL https://deb.nodesource.com/setup_${major}.x | bash - && apt-get install -y nodejs`; } +export function aptBase(opts: { + packages: readonly string[]; + image?: string; + label?: string; +}): Step { + return scratch({ image: opts.image }).sh(aptInstallCmd(opts.packages), { + label: opts.label ?? ":apt: base", + cache: ttl(APT_TTL_SECONDS), + }); +} + export function makeInstallChain(opts: { aptPackages: readonly string[]; installCmd: string; diff --git a/dsls/harmont-ts/tests/e2e-fixtures.test.ts b/dsls/harmont-ts/tests/e2e-fixtures.test.ts index d2ca6352..0789af90 100644 --- a/dsls/harmont-ts/tests/e2e-fixtures.test.ts +++ b/dsls/harmont-ts/tests/e2e-fixtures.test.ts @@ -79,7 +79,7 @@ describe("E2E pipeline fixtures", () => { }); it("rust-release", () => { - const project = rust({ path: "." }); + const project = rust.toolchain({ path: "." }); const ir = pipeline( project.build(), diff --git a/dsls/harmont-ts/tests/examples.test.ts b/dsls/harmont-ts/tests/examples.test.ts index 9cc8a6c3..6d9994c2 100644 --- a/dsls/harmont-ts/tests/examples.test.ts +++ b/dsls/harmont-ts/tests/examples.test.ts @@ -62,7 +62,7 @@ describe("examples render to v0 IR", () => { }); } - it("discovered at least 18 example pipeline.ts files", () => { - expect(examples.length).toBeGreaterThanOrEqual(18); + it("discovered at least 17 example pipeline.ts files", () => { + expect(examples.length).toBeGreaterThanOrEqual(17); }); }); diff --git a/dsls/harmont-ts/tests/toolchains/rust.test.ts b/dsls/harmont-ts/tests/toolchains/rust.test.ts index 6f3546b5..79e9561f 100644 --- a/dsls/harmont-ts/tests/toolchains/rust.test.ts +++ b/dsls/harmont-ts/tests/toolchains/rust.test.ts @@ -3,91 +3,127 @@ import { rust } from "../../src/toolchains/rust.js"; import { sh } from "../../src/step.js"; import { pipeline } from "../../src/pipeline.js"; -describe("rust factory", () => { +const cmds = (ir: ReturnType) => + ir.graph.nodes.map((n: { step: { cmd: string } }) => n.step.cmd); + +const stepBySubstring = (ir: ReturnType, needle: string) => { + const node = ir.graph.nodes.find((n: { step: { cmd: string } }) => + n.step.cmd.includes(needle), + ); + if (!node) throw new Error(`no command step containing "${needle}"`); + return node.step; +}; + +describe("rust.toolchain", () => { it("returns a RustToolchain with defaults", () => { - const r = rust(); + const r = rust.toolchain(); expect(r.path).toBe("."); expect(r.install()._cmd).toContain("rustc --version"); }); it("accepts path and version", () => { - const r = rust({ path: "crates/core", version: "nightly" }); + const r = rust.toolchain({ path: "crates/core", version: "nightly" }); expect(r.path).toBe("crates/core"); expect(r.install()._cmd).toContain("nightly"); }); it("accepts custom components", () => { - const r = rust({ components: ["clippy", "rustfmt", "miri"] }); + const r = rust.toolchain({ components: ["clippy", "rustfmt", "miri"] }); expect(r.install()._cmd).toContain("clippy,rustfmt,miri"); }); it("rejects invalid version", () => { - expect(() => rust({ version: "not valid!" })).toThrow("invalid version"); + expect(() => rust.toolchain({ version: "not valid!" })).toThrow( + "invalid version", + ); }); -}); -describe("rust actions", () => { it("build runs cargo build", () => { - const r = rust(); + const r = rust.toolchain(); expect(r.build()._cmd).toContain("cargo build"); expect(r.build()._cmd).not.toContain("--release"); }); it("build --release", () => { - const r = rust(); - expect(r.build({ release: true })._cmd).toContain("cargo build --release"); + const r = rust.toolchain(); + expect(r.build({ release: true })._cmd).toContain( + "cargo build --release", + ); }); it("test runs cargo test", () => { - const r = rust(); + const r = rust.toolchain(); expect(r.test()._cmd).toContain("cargo test"); }); it("clippy runs with -D warnings", () => { - const r = rust(); - expect(r.clippy()._cmd).toContain("cargo clippy --all-targets -- -D warnings"); + const r = rust.toolchain(); + expect(r.clippy()._cmd).toContain( + "cargo clippy --all-targets -- -D warnings", + ); }); it("fmt runs cargo fmt --check", () => { - const r = rust(); + const r = rust.toolchain(); expect(r.fmt()._cmd).toContain("cargo fmt --check"); }); it("doc runs cargo doc --no-deps", () => { - const r = rust(); + const r = rust.toolchain(); expect(r.doc()._cmd).toContain("cargo doc --no-deps"); }); it("actions source cargo env", () => { - const r = rust(); + const r = rust.toolchain(); expect(r.build()._cmd).toContain(". $HOME/.cargo/env"); }); it("actions chain from install", () => { - const r = rust(); + const r = rust.toolchain(); expect(r.build()._parent).toBe(r.install()); }); it("accepts step options", () => { - const r = rust(); + const r = rust.toolchain(); const t = r.test({ label: "my test", timeoutSeconds: 600 }); expect(t._label).toBe("my test"); expect(t._timeoutSeconds).toBe(600); }); it("default labels use :rust: prefix", () => { - const r = rust(); + const r = rust.toolchain(); expect(r.build()._label).toBe(":rust: build"); expect(r.test()._label).toBe(":rust: test"); expect(r.clippy()._label).toBe(":rust: clippy"); expect(r.fmt()._label).toBe(":rust: fmt"); expect(r.doc()._label).toBe(":rust: doc"); }); -}); -describe("rust install chain", () => { + it("warmup runs cargo build --workspace --tests --locked", () => { + const r = rust.toolchain(); + expect(r.warmup()._cmd).toContain( + "cargo build --workspace --tests --locked", + ); + }); + + it("warmup chains from install", () => { + const r = rust.toolchain(); + expect(r.warmup()._parent).toBe(r.install()); + }); + + it("warmup default label", () => { + const r = rust.toolchain(); + expect(r.warmup()._label).toBe(":rust: warmup"); + }); + + it("warmup accepts options", () => { + const r = rust.toolchain(); + const w = r.warmup({ label: ":rust: pre-build" }); + expect(w._label).toBe(":rust: pre-build"); + }); + it("chain is: scratch → apt-base → rustup", () => { - const r = rust(); + const r = rust.toolchain(); const install = r.install(); expect(install._label).toBe(":rust: rustup"); @@ -100,14 +136,12 @@ describe("rust install chain", () => { it("accepts base step", () => { const base = sh("custom base"); - const r = rust({ base }); + const r = rust.toolchain({ base }); expect(r.install()._parent).toBe(base); }); -}); -describe("rust in pipeline", () => { - it("produces valid IR", () => { - const r = rust(); + it("produces valid pipeline IR", () => { + const r = rust.toolchain(); const ir = pipeline(r.build(), r.test(), r.clippy(), r.fmt(), { defaultImage: "ubuntu:24.04", }); @@ -115,3 +149,125 @@ describe("rust in pipeline", () => { expect(ir.version).toBe("0"); }); }); + +describe("rust.project", () => { + it("has all methods", () => { + const proj = rust.project({ path: "cli" }); + expect(proj.warmup._cmd).toContain( + "cargo build --workspace --tests --locked", + ); + expect(proj.test()._cmd).toContain("cargo test --workspace --locked"); + expect(proj.clippy()._cmd).toContain( + "cargo clippy --workspace --tests --locked", + ); + expect(proj.fmt()._cmd).toContain("cargo fmt --check"); + }); + + it("warmup has implicit CacheOnChange on Cargo.lock", () => { + const proj = rust.project({ path: "cli" }); + expect(proj.warmup._cache).toEqual({ + kind: "on_change", + paths: ["cli/Cargo.lock"], + }); + }); + + it("warmup cache uses plain Cargo.lock for dot path", () => { + const proj = rust.project({ path: "." }); + expect(proj.warmup._cache).toEqual({ + kind: "on_change", + paths: ["Cargo.lock"], + }); + }); + + it("warmup cache can be overridden", () => { + const proj = rust.project({ + path: ".", + cache: { kind: "on_change", paths: ["Cargo.toml"] }, + }); + expect(proj.warmup._cache).toEqual({ + kind: "on_change", + paths: ["Cargo.toml"], + }); + }); + + it("test flags are appended", () => { + const proj = rust.project({ path: "." }); + expect(proj.test({ flags: ["--lib", "--no-fail-fast"] })._cmd).toContain( + "cargo test --workspace --locked --lib --no-fail-fast", + ); + }); + + it("clippy flags are inserted before --", () => { + const proj = rust.project({ path: "." }); + expect(proj.clippy({ flags: ["--fix"] })._cmd).toContain( + "cargo clippy --workspace --tests --locked --fix -- -D warnings", + ); + }); + + it("fmt flags are appended", () => { + const proj = rust.project({ path: "." }); + expect(proj.fmt({ flags: ["--all"] })._cmd).toContain( + "cargo fmt --check --all", + ); + }); + + it("test chains off warmup", () => { + const proj = rust.project(); + expect(proj.test()._parent).toBe(proj.warmup); + }); + + it("clippy chains off warmup", () => { + const proj = rust.project(); + expect(proj.clippy()._parent).toBe(proj.warmup); + }); + + it("fmt chains off install (not warmup)", () => { + const proj = rust.project(); + expect(proj.fmt()._parent).toBe(proj.toolchain.install()); + }); + + it("labels are correct", () => { + const proj = rust.project(); + expect(proj.warmup._label).toBe(":rust: warmup"); + expect(proj.test()._label).toBe(":rust: test"); + expect(proj.clippy()._label).toBe(":rust: clippy"); + expect(proj.fmt()._label).toBe(":rust: fmt"); + }); + + it("with base skips apt", () => { + const base = sh("custom base"); + const proj = rust.project({ path: "cli", base }); + const ir = pipeline(proj.test(), proj.clippy(), proj.fmt(), { + defaultImage: "ubuntu:24.04", + }); + const c = cmds(ir); + expect( + c.filter((cmd: string) => cmd.includes("apt-get install")), + ).toHaveLength(0); + expect(c.some((cmd: string) => cmd.includes("custom base"))).toBe(true); + }); + + it("produces valid pipeline IR", () => { + const proj = rust.project({ path: "cli" }); + const ir = pipeline(proj.test(), proj.clippy(), proj.fmt(), { + defaultImage: "ubuntu:24.04", + }); + expect(ir.version).toBe("0"); + expect(ir.graph.nodes.length).toBeGreaterThanOrEqual(4); + }); + + it("toolchain escape hatch", () => { + const proj = rust.project({ path: "cli" }); + const custom = proj.toolchain + .install() + .sh("custom", { label: "custom" }); + expect(custom._parent).toBe(proj.toolchain.install()); + }); + + it("version forwarded", () => { + const proj = rust.project({ path: ".", version: "1.81.0" }); + const ir = pipeline(proj.test()); + const rustup = stepBySubstring(ir, "sh.rustup.rs"); + expect(rustup.cmd).toContain("--default-toolchain 1.81.0"); + }); +}); diff --git a/dsls/harmont-ts/tests/toolchains/shared.test.ts b/dsls/harmont-ts/tests/toolchains/shared.test.ts new file mode 100644 index 00000000..cdd4d0d9 --- /dev/null +++ b/dsls/harmont-ts/tests/toolchains/shared.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from "vitest"; +import { aptBase } from "../../src/toolchains/shared.js"; +import { rust } from "../../src/toolchains/rust.js"; +import { uv } from "../../src/toolchains/py/uv.js"; +import { pipeline } from "../../src/pipeline.js"; + +describe("aptBase", () => { + it("creates a step with apt-get install", () => { + const base = aptBase({ packages: ["curl", "ca-certificates"] }); + expect(base._cmd).toContain( + "apt-get update && apt-get install -y curl ca-certificates", + ); + }); + + it("default label is :apt: base", () => { + const base = aptBase({ packages: ["curl"] }); + expect(base._label).toBe(":apt: base"); + }); + + it("accepts custom label", () => { + const base = aptBase({ packages: ["curl"], label: ":lock: deps" }); + expect(base._label).toBe(":lock: deps"); + }); + + it("shared across rust and python toolchains", () => { + const base = aptBase({ + packages: [ + "curl", + "ca-certificates", + "build-essential", + "pkg-config", + "libssl-dev", + "python3", + "python3-venv", + ], + }); + const r = rust.toolchain({ base }); + const p = uv({ path: "dsls/harmont-py", base }); + const ir = pipeline(r.build(), p.test(), { defaultImage: "ubuntu:24.04" }); + const cmds = ir.graph.nodes.map( + (n: { step: { cmd: string } }) => n.step.cmd, + ); + const aptSteps = cmds.filter((c: string) => c.includes("apt-get install")); + expect(aptSteps).toHaveLength(1); + }); +}); diff --git a/examples/haskell/.harmont/pipeline.py b/examples/haskell/.harmont/pipeline.py deleted file mode 100644 index fa7a51a8..00000000 --- a/examples/haskell/.harmont/pipeline.py +++ /dev/null @@ -1,30 +0,0 @@ -"""Haskell example pipeline.""" -from __future__ import annotations - -import harmont as hm -from harmont.haskell import HaskellPackage, HaskellToolchain - - -@hm.target() -def ghc() -> HaskellToolchain: - return hm.haskell(ghc="9.6.7") - - -@hm.target() -def project(ghc: hm.Target[HaskellToolchain]) -> HaskellPackage: - return ghc.cabal(path=".") - - -@hm.pipeline( - "ci", - env={"CI": "true"}, - default_image="ubuntu:24.04", - triggers=[hm.push(branch="main")], -) -def ci(project: hm.Target[HaskellPackage]) -> tuple[hm.Step, ...]: - return ( - project.build(), - project.test(), - project.lint(), - project.fmt(), - ) diff --git a/examples/haskell/.harmont/pipeline.ts b/examples/haskell/.harmont/pipeline.ts deleted file mode 100644 index ed64b3a7..00000000 --- a/examples/haskell/.harmont/pipeline.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { pipeline, push, target, type PipelineDefinition } from "harmont"; -import { haskell } from "harmont/toolchains"; - -const ghc = target("ghc", () => haskell({ ghc: "9.6.7" })); -const project = target("project", () => ghc().cabal(".")); - -const pipelines: PipelineDefinition[] = [ - { - slug: "ci", - triggers: [push({ branch: "main" })], - pipeline: pipeline( - project().build(), - project().test(), - project().lint(), - project().fmt(), - { env: { CI: "true" }, defaultImage: "ubuntu:24.04" }, - ), - }, -]; - -export default pipelines; diff --git a/examples/haskell/README.md b/examples/haskell/README.md deleted file mode 100644 index 27533977..00000000 --- a/examples/haskell/README.md +++ /dev/null @@ -1,12 +0,0 @@ -# Haskell example - -Single cabal package with an inline test-suite. Pipeline pins GHC 9.6.7 via ghcup and runs build + test + lint (--flag werror) + fmt (fourmolu check). - -## Run the pipeline - -```sh -cd examples/haskell -hm run ci --local -``` - -See `.harmont/pipeline.py` for the definition; `examples/README.md` for the full index. diff --git a/examples/haskell/cabal.project b/examples/haskell/cabal.project deleted file mode 100644 index e6fdbadb..00000000 --- a/examples/haskell/cabal.project +++ /dev/null @@ -1 +0,0 @@ -packages: . diff --git a/examples/haskell/example.cabal b/examples/haskell/example.cabal deleted file mode 100644 index 6c4caacd..00000000 --- a/examples/haskell/example.cabal +++ /dev/null @@ -1,18 +0,0 @@ -cabal-version: 3.0 -name: example -version: 0.1.0.0 -build-type: Simple - -library - exposed-modules: Lib - hs-source-dirs: src - build-depends: base >=4.18 && <5 - default-language: Haskell2010 - ghc-options: -Wall - -test-suite example-test - type: exitcode-stdio-1.0 - main-is: Spec.hs - hs-source-dirs: test - build-depends: base, example - default-language: Haskell2010 diff --git a/examples/haskell/src/Lib.hs b/examples/haskell/src/Lib.hs deleted file mode 100644 index 665c64d0..00000000 --- a/examples/haskell/src/Lib.hs +++ /dev/null @@ -1,4 +0,0 @@ -module Lib (add) where - -add :: Int -> Int -> Int -add a b = a + b diff --git a/examples/haskell/test/Spec.hs b/examples/haskell/test/Spec.hs deleted file mode 100644 index 2ce5c26d..00000000 --- a/examples/haskell/test/Spec.hs +++ /dev/null @@ -1,8 +0,0 @@ -module Main where - -import Lib (add) -import System.Exit (exitFailure, exitSuccess) - -main :: IO () -main = - if add 2 3 == 5 then exitSuccess else exitFailure diff --git a/examples/rust/.harmont/pipeline.py b/examples/rust/.harmont/pipeline.py index 1ee15f80..0cca8c32 100644 --- a/examples/rust/.harmont/pipeline.py +++ b/examples/rust/.harmont/pipeline.py @@ -7,7 +7,7 @@ @hm.target() def project() -> RustToolchain: - return hm.rust(path=".") + return hm.rust.toolchain(path=".") @hm.pipeline( diff --git a/examples/rust/.harmont/pipeline.ts b/examples/rust/.harmont/pipeline.ts index 71c3ef1d..00368388 100644 --- a/examples/rust/.harmont/pipeline.ts +++ b/examples/rust/.harmont/pipeline.ts @@ -1,7 +1,7 @@ import { pipeline, push, type PipelineDefinition } from "harmont"; import { rust } from "harmont/toolchains"; -const project = rust({ path: "." }); +const project = rust.toolchain({ path: "." }); const pipelines: PipelineDefinition[] = [ {