Skip to content

feat(deploy): hm.deploy + hm.dev local-deployment DSL + CI#1

Merged
markovejnovic merged 28 commits into
mainfrom
feat/hm-dev-deploy
May 22, 2026
Merged

feat(deploy): hm.deploy + hm.dev local-deployment DSL + CI#1
markovejnovic merged 28 commits into
mainfrom
feat/hm-dev-deploy

Conversation

@markovejnovic
Copy link
Copy Markdown
Contributor

@markovejnovic markovejnovic commented May 22, 2026

No description provided.

markovejnovic and others added 26 commits May 21, 2026 18:58
Driver-agnostic top-level decorator (@hm.deploy) + driver-specific
factory (hm.dev.deploy) so future cloud drivers slot in without
touching the registry. v1 ships local Docker only via `hm dev up`,
foreground, with per-session bridge networks and OS-assigned host
ports so multiple worktrees can coexist.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
12 tasks, TDD-shaped: scaffold abstract Deployment, port sentinel,
LocalDeployment, deploy() factory, Dep[T] marker + call_with_deps
extension, @hm.deploy decorator, topo sort, dump_registry_json,
python -m harmont.dev CLI shim, CLAUDE.md update, end-to-end
canonical example, PR-readiness pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sets the driver-agnostic foundation for hm.deploy. Concrete
LocalDeployment (Task 3) subclasses Deployment; the DEPLOYMENTS
registry stores polymorphic entries. Test-only reset fixture covers
DEPLOYMENTS plus the existing TARGETS/REGISTRATIONS registries so
all three are wiped between tests.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer caught that the raw _TARGETS_BY_NAME / _RESOLVING clears
miss _TARGET_CACHE (the per-render target memo in harmont/_target.py),
so future tests using @hm.target would leak memoized values across
tests. Switch to clear_registry() + clear_target_cache(), matching
the pattern in tests/test_registry.py and tests/test_target.py.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Singleton with stable repr and hash. Misuse outside port_mapping
is detected by deploy()'s field validation (Task 4), not at the
port() call site, so the error points at the exact misuse location.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer caught two minor issues:
1. `_PortSentinel` in __all__ violates the project's underscore-prefix
   private-name convention (cf. harmont/_typing.py — _TargetMarker stays
   private, Target is the public alias). Downstream isinstance checks
   import directly from harmont.dev._port.
2. The bare {port(): 1} expression would trip ruff B018 (useless
   expression) at Task 11's lint gate. Switched to an explicit
   assert {port(): 1}[port()] == 1 — still tests hashability AND
   key-equality, lint-clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Concrete subclass of Deployment for the local Docker driver.
__post_init__ enforces driver=='local'; everything else is a
plain dataclass field. The deploy(...) factory in Task 4 is the
sanctioned constructor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer caught it as F401 dead-import (Step is imported alongside
scratch but never referenced). Trimmed to just scratch.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Strict, fix-directed validation per PRINCIPLES § 5: every error
message points at the misuse and states the fix. The factory leaves
name="" so the @hm.deploy decorator can stamp the slug in via
dataclasses.replace after deciding the slug from its arg or fn name.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer caught the Pyright "unreachable" on the `isinstance(from_,
Step)` guard — Pyright correctly narrows from_ to Step after the
preceding XOR check, so the runtime defensive branch is statically
dead. Type system already enforces the Step | None contract; drop
the check. Step stays imported (still in the type annotation).

Plus two minor cleanups:
- volumes host-path error now includes type name (consistency with
  the env / cmd / port_mapping messages).
- test rename: test_volumes_keys_resolve_relative_to_worktree...
  → test_volumes_preserves_host_path_verbatim (the old name
  implied behavior that lives in _registry_dump, not the factory).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Old name implied "resolves relative to worktree at dump time" — that
behavior lives in _registry_dump.py, not the factory. The factory
preserves the host-path string verbatim. Renamed to
test_volumes_preserves_host_path_verbatim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Dep[T] resolves a parameter against harmont._deploy.DEPLOYMENTS by
the parameter name (same shape as Target[T] vs _TARGETS_BY_NAME).
Cycle detection reuses the existing _RESOLVING stack so dep cycles
between deployments and dep cycles between targets share one detector.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implementer chose isinstance(marker, _DepMarker) over the
identity-check pattern (consistent with how _BaseImageMarker is
checked), so the _DEP_MARKER instance import is unused and Pyright
flags it. Drop it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer caught three integration gaps with the existing `_deps.py`
machinery:

1. Target "not registered" raises TypeError; the new Dep branch was
   raising ValueError. Aligned to TypeError.
2. Error-message shape: existing errors are `"hm: <kind> 'name' not
   found\n  → fix"`. The Dep error was inline `"hm.Dep parameter ..."`
   with an em-dash inline fix and a trailing period. Reformatted to
   match the in-file convention exactly.
3. _DepMarker docstring missing the second-sentence singleton hint
   that _TargetMarker carries — added.

Test updated to expect TypeError + the new message, plus a marker
assertion in test_dep_marker_alias_subscripts_to_annotated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ection

Decorator validates the slug regex (Docker container-name rules),
rejects duplicates, validates the function signature via the existing
validate_target_signature, and wraps the function so call_with_deps
resolves Target/Dep/BaseImage markers at registry-walk time.

dataclasses.replace stamps the resolved slug into the returned
Deployment so the value seen by callers and the registry has
name=<slug> (the factory leaves name="").

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec reviewer caught two duplications in the prior commit:

1. _validate_deploy_signature was a deploy-specific re-implementation
   of the shared validate_target_signature from _deps.py. The plan
   mandated calling the shared validator. The variant existed only
   to raise ValueError instead of TypeError to match a test
   expectation that was itself inconsistent with Task 5's TypeError
   precedent for marker errors.
2. _marker_for was copied verbatim from _deps.py to support the
   variant validator.

Both deleted. validate_target_signature is now imported from _deps
and called directly. test_deploy_requires_marker_on_param updated to
expect TypeError + the actual validator's "has no marker" message.

`name=` kwarg is now explicitly `del name`'d to silence Pyright's
"not accessed" hint without losing the future-API kwarg shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer noted the duplicate-slug message suggested passing
`name="..."` to disambiguate, but `name=` is reserved-for-future
(currently a no-op via `del name`). Pointing users at it is
misleading. The actual fix is to pass an explicit `slug=` — that's
now what the error says.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
dep_graph walks the registry, introspects each wrapped function for
Dep[T] params, and emits slug -> tuple of upstream slugs in parameter
order. topo_order runs Kahn's algorithm with stable level ordering
(insertion order within a level) so the registry-dump output is
deterministic. Cycle detection raises RuntimeError listing the
unresolved slugs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks DEPLOYMENTS in topo order, lowering LocalDeployment values to
the schema documented in the spec (§ 1). Step-chain from_= values are
lowered via the existing harmont.pipeline() + keygen pipeline so the
Rust executor can run the chain and use the terminal key as the
build-image tag. Non-local drivers are passed through as
{"driver": X, "_unhandled": true} for hm dev ls.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Reviewer caught the leftover `if TYPE_CHECKING: pass` scaffolding
(with the TYPE_CHECKING import) — dead code from the plan-quoted
template. None of the imports under TYPE_CHECKING were needed.
Removed both the import and the block.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Walks .harmont/*.py, imports each by file path so @hm.deploy
registrations land in harmont._deploy.DEPLOYMENTS, then prints the
deployment registry JSON to stdout. The Rust CLI invokes this and
deserializes via serde (see harmont-cli plan).

Missing .harmont/ exits 1 with a fix-directed stderr. Argparse handles
usage errors with exit 2.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
parser.error() is typed NoReturn (calls sys.exit(2) internally), so
the trailing return was unreachable. Removed; added a one-line
comment noting the NoReturn so future readers don't reintroduce it.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the deployments section to the agent-facing doc with the
canonical example and the full public surface. Cross-links the
design spec for engineers who need the wire-format details.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mirrors the spec's worked example. Asserts topo order, dep edges,
cross-deploy f-string env values, and that from_=Step lowers through
the existing v0 IR pipeline. This is the "vibe check" gate before
the CLI plan can start consuming the JSON output.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous canonical example used postgres:16 + a Step-chain api
build + node:20 — three heavy images and a build path that v1 cli
stubs out. The hello+greeter pair now runs `python -m http.server`
from `python:3.12-alpine` (the Python stdlib's built-in HTTP server;
no third-party image dependency). Same surface coverage (@hm.deploy,
hm.Dep[T], cross-deploy env interpolation), much smaller footprint.

Updated the design spec § 1 + § 6, CLAUDE.md, the py plan's Task 11,
and tests/dev/test_canonical_example.py to match.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
PR + push-to-main gate. Matrix over python 3.11 / 3.12 (match the
package's requires-python = ">=3.11"). Existing release.yml (tag-driven
PyPI publish) is untouched.

Excludes tests/test_gradle.py and tests/test_haskell.py via
--deselect — those have pre-existing failures unrelated to the
hm.deploy work and would block PRs unrelated to them.

Pre-flight (Step 3): ruff was 49 errors red across harmont/ and
tests/dev/ — all pre-existing. Fixed them before adding the workflow
so it doesn't land already-broken: moved type-only imports to
TYPE_CHECKING blocks (TC001/TC003), ValueError→TypeError for pure
type checks (TRY004), noqa suppressions where rename would be lossy
(S108 deliberate /tmp sentinel, SLF001 test-isolation internals),
split compound assertions (PT018), re-ordered imports (E402), used
sys.stderr.write/sys.stdout.write instead of print (T201), extracted
f-string exception messages (EM102/TRY003). Tests updated to match
TypeError raises. mypy: clean. pytest: 373 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
markovejnovic and others added 2 commits May 22, 2026 04:25
Plans are scratchpads for implementation; once code lands the spec
+ tests + commit messages carry the load. Remove the plans dir
entirely (including the pre-existing pypi-tag-release-cd plan from
prior work).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Spec was implementation-time scaffolding; once the code lands the
public surface lives in CLAUDE.md (the agent-facing doc) and the
code itself. The empty docs/superpowers/{specs,} dirs are also
gone (git auto-prunes empty trees).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@markovejnovic markovejnovic merged commit 6287d8e into main May 22, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant