Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
50b6a7d
feat(py): add warmup() to RustToolchain for shared build layer
markovejnovic May 24, 2026
b80b54e
feat(ts): add warmup() to RustToolchain for shared build layer
markovejnovic May 24, 2026
2674b23
feat(ts): add aptBase() factory for cross-toolchain apt sharing
markovejnovic May 24, 2026
53d7e75
feat(py): add apt_base() factory for cross-toolchain apt sharing
markovejnovic May 24, 2026
ae1f30e
perf: dogfood pipeline uses shared apt-base + warmup for faster CI
markovejnovic May 24, 2026
a120cd2
fix: use --lib for cargo test in dogfood (no python3 in Rust container)
markovejnovic May 24, 2026
ff98d34
fix: install harmont-py in Rust container for full test suite
markovejnovic May 24, 2026
b8b67ba
fix: revert to --lib for cargo test (integration tests need Docker so…
markovejnovic May 24, 2026
b45782b
perf: cache warmup layer on Cargo.lock (skip rebuild when deps unchan…
markovejnovic May 24, 2026
15ccaff
perf: persist harmont Docker cache across GHA runs via actions/cache
markovejnovic May 24, 2026
ac4b240
ci: trigger cache-warm run
markovejnovic May 24, 2026
4f30d29
feat(py): add RustProject high-level abstraction with rust.project()/…
markovejnovic May 24, 2026
ce87183
feat(py): wire RustProject into as_leaves + exports
markovejnovic May 24, 2026
9cc9e72
test(py): update Rust tests for rust.toolchain()/rust.project() API
markovejnovic May 24, 2026
845e3a8
feat(ts): add RustProject with rust.project()/rust.toolchain() + upda…
markovejnovic May 24, 2026
e3cbd2d
refactor: dogfood pipelines use rust.project() high-level API
markovejnovic May 24, 2026
f43fd7d
refactor: RustProject methods with flags= instead of frozen attributes
markovejnovic May 24, 2026
efa214c
fix: update Python Rust example to use rust.toolchain()
markovejnovic May 24, 2026
508eebf
fix: ruff import sorting + SLF001 noqa for friend access
markovejnovic May 24, 2026
c68ecb4
fix: line length in Rust project tests
markovejnovic May 24, 2026
962e4f5
style: ruff format test_rust.py
markovejnovic May 24, 2026
53671e3
ci: add 15min timeout to integration test job
markovejnovic May 25, 2026
0e65c1b
feat: add export_image, import_image, list_images_by_prefix to Docker…
markovejnovic May 25, 2026
8fe4e5f
feat: add cache manifest types and filename helpers
markovejnovic May 25, 2026
e9b9296
feat: implement hm cache save
markovejnovic May 25, 2026
d57416f
feat: implement hm cache restore
markovejnovic May 25, 2026
739b8b5
feat: wire hm cache save/restore CLI subcommands
markovejnovic May 25, 2026
65ef6e6
ci: use hm cache save/restore for per-image Docker caching
markovejnovic May 25, 2026
2ad0bee
test: add docker-gated integration tests for hm cache save/restore
markovejnovic May 25, 2026
f9ac8e3
fix: use eprintln for cache progress, keep only hash on stdout
markovejnovic May 25, 2026
9b0ab25
style: cargo fmt
markovejnovic May 25, 2026
28e8fd1
fix: revert to tracing::info!, direct tracing subscriber to stderr
markovejnovic May 25, 2026
def73ff
ci: retrigger
markovejnovic May 25, 2026
a9121d4
ci: retrigger (2)
markovejnovic May 25, 2026
d9adfcb
merge: resolve origin/main conflict, fix CI push trigger for all bran…
markovejnovic May 25, 2026
89e93cc
chore: remove haskell example
markovejnovic May 25, 2026
a8c32b3
style: cargo fmt main.rs (remove leading blank line)
markovejnovic May 25, 2026
cbf45a9
fix: update TS example count after haskell removal
markovejnovic May 25, 2026
d6b2f7b
fix: port-of outputs to stdout, not tracing
markovejnovic May 25, 2026
5b2d20d
ci: retrigger for clean PR checks
markovejnovic May 25, 2026
83d168f
ci: restore push trigger to main-only (avoid double runs on PRs)
markovejnovic May 25, 2026
5143af1
ci: retrigger PR checks
markovejnovic May 25, 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
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 0 additions & 1 deletion .github/workflows/examples.yml
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,6 @@ jobs:
- cpp
- csharp
- go
- haskell
- java
- kotlin
- nextjs
Expand Down
66 changes: 38 additions & 28 deletions .harmont/ci.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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]
27 changes: 22 additions & 5 deletions .harmont/ci.ts
Original file line number Diff line number Diff line change
@@ -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(),
Expand Down
30 changes: 30 additions & 0 deletions crates/hm/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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
Expand All @@ -72,6 +98,10 @@ pub async fn dispatch(command: Command, ctx: RunContext) -> Result<i32> {
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) => {
Expand Down
108 changes: 108 additions & 0 deletions crates/hm/src/commands/cache/manifest.rs
Original file line number Diff line number Diff line change
@@ -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<String, String>,
}

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<String> {
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()));
}
}
6 changes: 6 additions & 0 deletions crates/hm/src/commands/cache/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
pub mod manifest;
mod restore;
mod save;

pub use restore::handle_restore;
pub use save::handle_save;
Loading
Loading