Skip to content

Commit 9c71c30

Browse files
committed
Merge remote-tracking branch 'origin/main' into cq/hm-exec-replace-raw-string-literal-job-state-mat-6
# Conflicts: # crates/hm-exec/src/cloud/watch.rs
2 parents 49b0145 + a65fcb7 commit 9c71c30

51 files changed

Lines changed: 1286 additions & 284 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.claude/skills/write-pipeline/SKILL.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ Write, modify, or extend Harmont CI pipelines defined in `.hm/pipeline.py` (Pyth
2929
Read it carefully. It covers correct vs. incorrect approaches, when to use toolchains vs. raw shell, and common anti-patterns.
3030

3131
2. If you need the full API reference for a specific toolchain or feature, fetch the relevant page (append `.md` to any docs.harmont.dev URL for raw Markdown):
32-
- Toolchain reference: `https://docs.harmont.dev/pipeline-sdk/reference/toolchains/<name>.md` (rust, python, npm, go, cmake, zig, elixir, ruby, etc.)
32+
- Toolchain reference: `https://docs.harmont.dev/pipeline-sdk/reference/toolchains/<name>.md` (rust, python, js, go, cmake, zig, elixir, etc.)
3333
- Chains and steps: `https://docs.harmont.dev/pipeline-sdk/reference/chains.md`
3434
- Triggers: `https://docs.harmont.dev/pipeline-sdk/reference/triggers.md`
3535
- Caching: `https://docs.harmont.dev/pipeline-sdk/reference/cache.md`
@@ -40,15 +40,15 @@ Write, modify, or extend Harmont CI pipelines defined in `.hm/pipeline.py` (Pyth
4040
```
4141
WebFetch https://docs.harmont.dev/examples/<language>.md
4242
```
43-
Available examples: rust, go, cmake, zig, nextjs, python-uv, ruby, elixir
43+
Available examples: rust, go, cmake, zig, nextjs, python-uv, elixir
4444

4545
## Procedure
4646

47-
1. **Identify the project's language and build system.** Look at the project root for `Cargo.toml` (Rust), `package.json` (JS/TS), `pyproject.toml` or `setup.py` (Python), `go.mod` (Go), `CMakeLists.txt` (C/C++), `mix.exs` (Elixir), `build.zig` (Zig), `Gemfile` (Ruby).
47+
1. **Identify the project's language and build system.** Look at the project root for `Cargo.toml` (Rust), `package.json` (JS/TS), `pyproject.toml` or `setup.py` (Python), `go.mod` (Go), `CMakeLists.txt` (C/C++), `mix.exs` (Elixir), `build.zig` (Zig).
4848

4949
2. **Check for an existing pipeline.** Look for `.hm/pipeline.py` or `.hm/pipeline.ts`. If none exists, pick the DSL that matches the project's ecosystem before asking the user to confirm:
5050
- **TypeScript DSL** if the project already has `package.json`, `tsconfig.json`, or is primarily TypeScript/JavaScript (the team is already comfortable with the TS toolchain).
51-
- **Python DSL** for everything else — Rust, Go, C/C++, Elixir, Zig, Ruby, Python, or mixed-language projects (Python is the simpler, more universal choice).
51+
- **Python DSL** for everything else — Rust, Go, C/C++, Elixir, Zig, Python, or mixed-language projects (Python is the simpler, more universal choice).
5252
- Present your recommendation and rationale, then let the user override if they prefer the other DSL.
5353
Then either run `hm init --template <kind>` to scaffold or write the pipeline file directly.
5454

@@ -57,7 +57,7 @@ Write, modify, or extend Harmont CI pipelines defined in `.hm/pipeline.py` (Pyth
5757
4. **Write or modify the pipeline.** Follow the patterns guide strictly:
5858
- Prefer toolchains over raw `sh()` calls when a toolchain exists for the language.
5959
- Use `.fork()` for steps that can run in parallel.
60-
- Set triggers (`push`, `pull_request`, `schedule`) appropriate to the project.
60+
- Set triggers (`push`, `pull_request`) appropriate to the project.
6161
- Use `default_image: "ubuntu:24.04"` unless the project needs something specific.
6262
- Set `env: {"CI": "true"}` on the pipeline.
6363

.github/workflows/release.yml

Lines changed: 68 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,25 +34,46 @@ jobs:
3434
VERSION="${GITHUB_REF_NAME#v}"
3535
echo "VERSION=$VERSION" >> "$GITHUB_ENV"
3636
37-
# Bump crate versions
37+
# Bump crate versions. Every workspace member that harmont-cli
38+
# depends on (directly or transitively) must be published, so each
39+
# one needs the tagged version stamped in.
3840
sed -i "0,/version = \"0.0.0-dev\"/s//version = \"$VERSION\"/" crates/hm-util/Cargo.toml
3941
sed -i "0,/version = \"0.0.0-dev\"/s//version = \"$VERSION\"/" crates/hm-pipeline-ir/Cargo.toml
42+
sed -i "0,/version = \"0.0.0-dev\"/s//version = \"$VERSION\"/" crates/hm-config/Cargo.toml
4043
sed -i "0,/version = \"0.0.0-dev\"/s//version = \"$VERSION\"/" crates/hm-plugin-protocol/Cargo.toml
44+
sed -i "0,/version = \"0.0.0-dev\"/s//version = \"$VERSION\"/" crates/hm-render/Cargo.toml
45+
sed -i "0,/version = \"0.0.0-dev\"/s//version = \"$VERSION\"/" crates/hm-vm/Cargo.toml
46+
sed -i "0,/version = \"0.0.0-dev\"/s//version = \"$VERSION\"/" crates/hm-exec/Cargo.toml
4147
sed -i "0,/version = \"0.0.0-dev\"/s//version = \"$VERSION\"/" crates/hm-plugin-cloud/Cargo.toml
4248
sed -i "0,/version = \"0.0.0-dev\"/s//version = \"$VERSION\"/" crates/hm-dsl-engine/Cargo.toml
4349
sed -i "0,/version = \"0.0.0-dev\"/s//version = \"$VERSION\"/" crates/hm/Cargo.toml
4450
4551
# Rewrite workspace.dependencies pins so dependents resolve to the
4652
# tagged version (cargo publish strips path deps; the version field
4753
# is what consumers will receive).
48-
sed -i "s|hm-util = { path = \"crates/hm-util\", version = \"0.0.0-dev\" }|hm-util = { path = \"crates/hm-util\", version = \"$VERSION\" }|" Cargo.toml
49-
sed -i "s|hm-pipeline-ir = { path = \"crates/hm-pipeline-ir\", version = \"0.0.0-dev\" }|hm-pipeline-ir = { path = \"crates/hm-pipeline-ir\", version = \"$VERSION\" }|" Cargo.toml
54+
sed -i "s|hm-exec = { path = \"crates/hm-exec\", version = \"0.0.0-dev\" }|hm-exec = { path = \"crates/hm-exec\", version = \"$VERSION\" }|" Cargo.toml
5055
sed -i "s|hm-plugin-protocol = { path = \"crates/hm-plugin-protocol\", version = \"0.0.0-dev\" }|hm-plugin-protocol = { path = \"crates/hm-plugin-protocol\", version = \"$VERSION\" }|" Cargo.toml
5156
sed -i "s|hm-plugin-cloud = { path = \"crates/hm-plugin-cloud\", version = \"0.0.0-dev\" }|hm-plugin-cloud = { path = \"crates/hm-plugin-cloud\", version = \"$VERSION\" }|" Cargo.toml
57+
sed -i "s|hm-pipeline-ir = { path = \"crates/hm-pipeline-ir\", version = \"0.0.0-dev\" }|hm-pipeline-ir = { path = \"crates/hm-pipeline-ir\", version = \"$VERSION\" }|" Cargo.toml
58+
sed -i "s|hm-util = { path = \"crates/hm-util\", version = \"0.0.0-dev\" }|hm-util = { path = \"crates/hm-util\", version = \"$VERSION\" }|" Cargo.toml
59+
sed -i "s|hm-config = { path = \"crates/hm-config\", version = \"0.0.0-dev\" }|hm-config = { path = \"crates/hm-config\", version = \"$VERSION\" }|" Cargo.toml
5260
sed -i "s|hm-dsl-engine = { path = \"crates/hm-dsl-engine\", version = \"0.0.0-dev\" }|hm-dsl-engine = { path = \"crates/hm-dsl-engine\", version = \"$VERSION\" }|" Cargo.toml
61+
sed -i "s|hm-render = { path = \"crates/hm-render\", version = \"0.0.0-dev\" }|hm-render = { path = \"crates/hm-render\", version = \"$VERSION\" }|" Cargo.toml
62+
sed -i "s|hm-vm = { path = \"crates/hm-vm\", version = \"0.0.0-dev\" }|hm-vm = { path = \"crates/hm-vm\", version = \"$VERSION\" }|" Cargo.toml
5363
5464
cargo check --workspace --exclude hm-fixtures
5565
66+
- name: Publishability guard (dry-run package the whole graph)
67+
# Fail fast before any real `cargo publish` if the dependency graph
68+
# isn't publishable — e.g. a publishable crate depending on a
69+
# `publish = false` crate, or a missing version bump. `cargo package
70+
# --workspace` resolves sibling path deps locally (no index lookup,
71+
# unlike `cargo publish --dry-run`, which the not-yet-published deps
72+
# would fail), so it catches exactly the publish=false / unpublished-dep
73+
# class of regression. `--no-verify` skips the per-crate rebuild;
74+
# `cargo check --workspace` above already proved it compiles.
75+
run: cargo package --workspace --exclude hm-fixtures --allow-dirty --no-verify
76+
5677
- name: Publish hm-util
5778
run: |
5879
if curl -sf -A "harmont-release-ci (github-actions)" "https://crates.io/api/v1/crates/hm-util/$VERSION" > /dev/null 2>&1; then
@@ -75,6 +96,17 @@ jobs:
7596
- name: Wait for crates.io index
7697
run: sleep 30
7798

99+
- name: Publish hm-config
100+
run: |
101+
if curl -sf -A "harmont-release-ci (github-actions)" "https://crates.io/api/v1/crates/hm-config/$VERSION" > /dev/null 2>&1; then
102+
echo "hm-config@$VERSION already published, skipping"
103+
else
104+
cargo publish -p hm-config --token ${{ secrets.CRATES_IO_TOKEN }} --allow-dirty
105+
fi
106+
107+
- name: Wait for crates.io index
108+
run: sleep 30
109+
78110
- name: Publish hm-plugin-protocol
79111
run: |
80112
if curl -sf -A "harmont-release-ci (github-actions)" "https://crates.io/api/v1/crates/hm-plugin-protocol/$VERSION" > /dev/null 2>&1; then
@@ -86,6 +118,39 @@ jobs:
86118
- name: Wait for crates.io index
87119
run: sleep 30
88120

121+
- name: Publish hm-render
122+
run: |
123+
if curl -sf -A "harmont-release-ci (github-actions)" "https://crates.io/api/v1/crates/hm-render/$VERSION" > /dev/null 2>&1; then
124+
echo "hm-render@$VERSION already published, skipping"
125+
else
126+
cargo publish -p hm-render --token ${{ secrets.CRATES_IO_TOKEN }} --allow-dirty
127+
fi
128+
129+
- name: Wait for crates.io index
130+
run: sleep 30
131+
132+
- name: Publish hm-vm
133+
run: |
134+
if curl -sf -A "harmont-release-ci (github-actions)" "https://crates.io/api/v1/crates/hm-vm/$VERSION" > /dev/null 2>&1; then
135+
echo "hm-vm@$VERSION already published, skipping"
136+
else
137+
cargo publish -p hm-vm --token ${{ secrets.CRATES_IO_TOKEN }} --allow-dirty
138+
fi
139+
140+
- name: Wait for crates.io index
141+
run: sleep 30
142+
143+
- name: Publish hm-exec
144+
run: |
145+
if curl -sf -A "harmont-release-ci (github-actions)" "https://crates.io/api/v1/crates/hm-exec/$VERSION" > /dev/null 2>&1; then
146+
echo "hm-exec@$VERSION already published, skipping"
147+
else
148+
cargo publish -p hm-exec --token ${{ secrets.CRATES_IO_TOKEN }} --allow-dirty
149+
fi
150+
151+
- name: Wait for crates.io index
152+
run: sleep 30
153+
89154
- name: Publish hm-plugin-cloud
90155
run: |
91156
if curl -sf -A "harmont-release-ci (github-actions)" "https://crates.io/api/v1/crates/hm-plugin-cloud/$VERSION" > /dev/null 2>&1; then

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
- **DSL:** Fix example Python pipelines to use current API (`hm.js.project()` instead of removed `hm.npm()`/`hm.bun()`) ([#77][pr77])
3737
- **DSL:** Use correct Zig download URL for >= 0.14.1 and bump default to 0.14.1 ([`1bf727e`][c1bf727e])
3838
- **CLI:** Fix `hm pipelines` returning errors on repos without pipeline files ([#34][pr34])
39+
- **CLI:** `hm init --force` no longer wipes the entire `.hm/` directory; it now overwrites only the target template file, preserving `config.toml` and any co-resident pipelines.
40+
- **CLI:** `hm init` no longer silently overwrites customized `.claude/skills/*/SKILL.md` files; edited skills are skipped with a warning unless `--force` is passed.
3941

4042
[pr33]: https://github.com/harmont-dev/harmont-cli/pull/33
4143
[pr34]: https://github.com/harmont-dev/harmont-cli/pull/34

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -132,8 +132,8 @@ def ci(project: hm.Target[PythonToolchain]) -> tuple[hm.Step, ...]:
132132
<summary><b>TypeScript</b></summary>
133133

134134
```typescript
135-
import { pipeline, push, type PipelineDefinition } from "harmont";
136-
import { python } from "harmont/toolchains";
135+
import { pipeline, push, type PipelineDefinition } from "@harmont/hm";
136+
import { python } from "@harmont/hm/toolchains";
137137

138138
const project = python({ path: "." });
139139

RELEASING.md

Lines changed: 21 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -36,23 +36,30 @@ test changes to the export script.
3636

3737
Versioning is **driven by git tags on the public mirror**. The release
3838
workflow in `.github/workflows/release.yml` triggers on any tag matching
39-
`v*`, seds the version from the tag into all three crates' `Cargo.toml`
40-
files plus the `workspace.dependencies` pins, and publishes
41-
`hm-plugin-protocol`, `hm-plugin-sdk`, and `harmont-cli` to crates.io in
42-
that order. The bundled WASM plugins (`hm-plugin-docker`,
43-
`hm-plugin-output-human`, `hm-plugin-output-json`, `hm-plugin-cloud`)
44-
and `hm-fixtures` are not published — they ship embedded inside the
45-
`hm` binary.
39+
`v*`, seds the version from the tag into every publishable crate's
40+
`Cargo.toml` plus the `workspace.dependencies` pins, runs
41+
`cargo package --workspace` as a fail-fast guard (it resolves sibling
42+
path deps locally, so it catches the `publish = false` /
43+
unpublished-dep class of regression without needing the deps on the
44+
index yet — which `cargo publish --dry-run` would), then publishes the
45+
crates to crates.io in dependency (topological) order:
46+
47+
```
48+
hm-util → hm-pipeline-ir → hm-config → hm-plugin-protocol → hm-render
49+
→ hm-vm → hm-exec → hm-plugin-cloud → hm-dsl-engine → harmont-cli
50+
```
51+
52+
`harmont-cli` (the `hm` binary) depends on every other crate, so they
53+
must all reach crates.io first — including `hm-vm`, which carries the
54+
local Docker backend. `hm-fixtures` is test-only and not published.
4655

4756
### Prerequisites (one-time)
4857

4958
- `CRATES_IO_TOKEN` set as a repository secret on
5059
https://github.com/harmont-dev/harmont-cli/settings/secrets/actions.
51-
Generate it from https://crates.io/me with the `publish-update` scope
52-
on `hm-plugin-protocol`, `hm-plugin-sdk`, and `harmont-cli`.
53-
- The three crates exist on crates.io (first publish only requires
54-
`publish-new` scope). After the initial publish, narrow the token to
55-
`publish-update`.
60+
Generate it from https://crates.io/me. The first release needs the
61+
`publish-new` scope (the crates do not yet exist on crates.io); after
62+
the initial publish, narrow the token to `publish-update`.
5663

5764
### Per-release procedure
5865

@@ -72,9 +79,8 @@ and `hm-fixtures` are not published — they ship embedded inside the
7279
https://github.com/harmont-dev/harmont-cli/actions/workflows/release.yml.
7380
Each crate's publish step skips if the version is already on
7481
crates.io, so re-running after a partial success is safe.
75-
5. After the workflow completes, verify on crates.io:
76-
- https://crates.io/crates/hm-plugin-protocol/1.2.3
77-
- https://crates.io/crates/hm-plugin-sdk/1.2.3
82+
5. After the workflow completes, verify the leaf crate on crates.io
83+
(it depends on all the others, so its presence implies theirs):
7884
- https://crates.io/crates/harmont-cli/1.2.3
7985

8086
### Tagging in the monorepo (optional)

crates/hm-config/src/creds.rs

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,13 @@ fn load() -> CredentialFile {
3333
fn save(file: &CredentialFile) -> Result<()> {
3434
let p = path()?;
3535
let serialized = toml::to_string_pretty(file).context("serializing credentials")?;
36-
hm_util::os::fs::blocking::write_atomic_restricted(&p, serialized.as_bytes(), 0o600, 0o700)
37-
.with_context(|| format!("writing {}", p.display()))?;
36+
hm_util::os::fs::blocking::write_atomic_restricted(
37+
&p,
38+
serialized.as_bytes(),
39+
hm_util::os::fs::FileMode(0o600),
40+
hm_util::os::fs::DirMode(0o700),
41+
)
42+
.with_context(|| format!("writing {}", p.display()))?;
3843
Ok(())
3944
}
4045

crates/hm-config/src/lib.rs

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,43 @@ pub mod creds;
1515

1616
pub const DEFAULT_API_URL: &str = "https://api.harmont.dev";
1717

18+
/// Derive the SPA (dashboard) base URL from the API base.
19+
///
20+
/// The CLI talks to `api.harmont.dev`, but a human clicks through to the
21+
/// dashboard at `app.harmont.dev`. A watch/login link built from the API host
22+
/// lands on raw JSON, so every surface that emits a user-clickable URL must map
23+
/// the host first.
24+
///
25+
/// Priority:
26+
/// 1. `override_url` (e.g. the `HARMONT_APP_URL` env override) when non-empty,
27+
/// 2. heuristic mapping of `api.` → `app.` on the API host,
28+
/// 3. the API base itself (last-resort dev fallback for hosts like
29+
/// `localhost` that have no `api.`/`app.` split).
30+
///
31+
/// The returned URL never has a trailing slash.
32+
#[must_use]
33+
pub fn app_url(api: &str, override_url: Option<&str>) -> String {
34+
if let Some(u) = override_url.map(str::trim).filter(|u| !u.is_empty()) {
35+
return u.trim_end_matches('/').to_string();
36+
}
37+
let api = api.trim_end_matches('/');
38+
if let Some(rest) = api.strip_prefix("https://api.") {
39+
return format!("https://app.{rest}");
40+
}
41+
if let Some(rest) = api.strip_prefix("http://api.") {
42+
return format!("http://app.{rest}");
43+
}
44+
api.to_string()
45+
}
46+
1847
/// Default execution backend for `hm run` when no `--backend`/`--cloud` flag
1948
/// is given.
2049
fn default_backend() -> String {
2150
"docker".to_owned()
2251
}
2352

2453
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
54+
#[non_exhaustive]
2555
pub struct CloudConfig {
2656
pub org: Option<String>,
2757
pub api_url: String,
@@ -37,6 +67,7 @@ impl Default for CloudConfig {
3767
}
3868

3969
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
70+
#[non_exhaustive]
4071
pub struct Preferences {
4172
pub format: String,
4273
pub auto_watch: bool,
@@ -52,6 +83,7 @@ impl Default for Preferences {
5283
}
5384

5485
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
86+
#[non_exhaustive]
5587
pub struct Config {
5688
#[serde(default = "default_backend")]
5789
pub backend: String,
@@ -131,8 +163,8 @@ impl Config {
131163
hm_util::os::fs::blocking::write_atomic_restricted(
132164
path,
133165
serialized.as_bytes(),
134-
0o644,
135-
0o700,
166+
hm_util::os::fs::FileMode(0o644),
167+
hm_util::os::fs::DirMode(0o700),
136168
)
137169
.with_context(|| format!("writing {}", path.display()))
138170
}
@@ -153,6 +185,37 @@ mod tests {
153185
use super::*;
154186
use std::io::Write as _;
155187

188+
#[test]
189+
fn app_url_maps_prod_api_to_app() {
190+
assert_eq!(app_url(DEFAULT_API_URL, None), "https://app.harmont.dev");
191+
}
192+
193+
#[test]
194+
fn app_url_override_wins_and_trims_trailing_slash() {
195+
assert_eq!(
196+
app_url(DEFAULT_API_URL, Some("http://localhost:5173/")),
197+
"http://localhost:5173"
198+
);
199+
}
200+
201+
#[test]
202+
fn app_url_empty_override_is_ignored() {
203+
assert_eq!(
204+
app_url(DEFAULT_API_URL, Some(" ")),
205+
"https://app.harmont.dev"
206+
);
207+
}
208+
209+
#[test]
210+
fn app_url_falls_back_to_api_for_unmapped_host() {
211+
assert_eq!(
212+
app_url("http://localhost:4000", None),
213+
"http://localhost:4000"
214+
);
215+
// http api. → http app.
216+
assert_eq!(app_url("http://api.dev.test/", None), "http://app.dev.test");
217+
}
218+
156219
#[test]
157220
fn default_config_values() {
158221
let cfg = Config::default();
@@ -264,12 +327,11 @@ org = "project-org"
264327
let tmp = tempfile::tempdir().unwrap();
265328
let path = tmp.path().join("config.toml");
266329
let cfg = Config {
267-
backend: default_backend(),
268330
cloud: CloudConfig {
269331
org: Some("saved-org".into()),
270-
api_url: DEFAULT_API_URL.to_owned(),
332+
..CloudConfig::default()
271333
},
272-
preferences: Preferences::default(),
334+
..Config::default()
273335
};
274336
cfg.save_to(&path).unwrap();
275337

crates/hm-dsl-engine/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ path = "src/lib.rs"
1414
[dependencies]
1515
anyhow = { workspace = true }
1616
async-trait = { workspace = true }
17+
derive_more = { workspace = true }
1718
serde = { workspace = true }
1819
serde_json = { workspace = true }
1920
include_dir = "0.7"

0 commit comments

Comments
 (0)