diff --git a/src/ci_orchestrator/commands/template.py b/src/ci_orchestrator/commands/template.py
index 77e54af..5b828f0 100644
--- a/src/ci_orchestrator/commands/template.py
+++ b/src/ci_orchestrator/commands/template.py
@@ -848,6 +848,19 @@ def _sha256_file(path: Path) -> str:
return h.hexdigest().upper()
+def _patched_iso_inputs_key(
+ base_iso: Path, autounattend_xml: str, post_install_ps1: str
+) -> str:
+ """Cache key for a patched ISO — changes if the base ISO or answer files do."""
+ st = base_iso.stat()
+ h = hashlib.sha256()
+ h.update(f"{base_iso.name}|{st.st_size}|{int(st.st_mtime)}\n".encode())
+ h.update(autounattend_xml.encode("utf-8"))
+ h.update(b"\x00")
+ h.update(post_install_ps1.encode("utf-8"))
+ return h.hexdigest()
+
+
def _build_seed_iso(staging_dir: Path, iso_path: Path) -> None:
tool = shutil.which("genisoimage") or shutil.which("mkisofs")
if not tool:
@@ -2219,13 +2232,24 @@ def template_deploy_win(
# ── Step 4: patch ISO (inject answer files + no-prompt boot) ───────────────
_deploy_step("Step 4: patch ISO (inject autounattend + no-prompt boot)", 4, start_from_step)
if start_from_step <= 4:
- win_iso_patch.patch_iso(
- base_iso,
- autounattend_xml=autounattend_xml,
- post_install_ps1=post_install_ps1,
- out_iso=patched_iso,
- work_dir=vm_dir,
+ key = _patched_iso_inputs_key(base_iso, autounattend_xml, post_install_ps1)
+ sidecar = patched_iso.with_suffix(patched_iso.suffix + ".inputs")
+ cached = (
+ patched_iso.is_file()
+ and sidecar.is_file()
+ and sidecar.read_text(encoding="utf-8").strip() == key
)
+ if cached:
+ click.echo(f" [OK] patched ISO up to date — skipping repack: {patched_iso}")
+ else:
+ win_iso_patch.patch_iso(
+ base_iso,
+ autounattend_xml=autounattend_xml,
+ post_install_ps1=post_install_ps1,
+ out_iso=patched_iso,
+ work_dir=vm_dir,
+ )
+ sidecar.write_text(key, encoding="utf-8")
# ── Step 5: create VMDK ────────────────────────────────────────────────────
_deploy_step("Step 5: create VMDK", 5, start_from_step)
diff --git a/tests/python/test_commands_deploy_win.py b/tests/python/test_commands_deploy_win.py
index 6e6dc77..87c3409 100644
--- a/tests/python/test_commands_deploy_win.py
+++ b/tests/python/test_commands_deploy_win.py
@@ -151,6 +151,50 @@ def test_deploy_win_winiso_happy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch
assert 'ethernet0.virtualDev = "vmxnet3"' in text
+def test_patched_iso_inputs_key_stable(tmp_path: Path) -> None:
+ iso = tmp_path / "b.iso"
+ iso.write_bytes(b"x" * 10)
+ k1 = tmpl_mod._patched_iso_inputs_key(iso, "", "ps")
+ k2 = tmpl_mod._patched_iso_inputs_key(iso, "", "ps")
+ k3 = tmpl_mod._patched_iso_inputs_key(iso, "", "ps")
+ assert k1 == k2 and k1 != k3
+
+
+def test_deploy_win_skips_patch_when_cached(
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
+) -> None:
+ from ci_orchestrator.winimage import autounattend as au
+
+ root = tmp_path
+ (root / "iso").mkdir(parents=True)
+ (root / "iso" / "VMware-tools-windows.iso").write_bytes(b"tools")
+ base = root / "iso" / "ws.iso"
+ base.write_bytes(b"win")
+ _patch_deploy(monkeypatch, root)
+
+ # Pre-create the patched ISO + a matching inputs sidecar.
+ vm_dir = root / "templates" / "WinBuild2025"
+ vm_dir.mkdir(parents=True)
+ patched = vm_dir / "install-noprompt.iso"
+ patched.write_bytes(b"iso")
+ params = au.WinUnattendParams()
+ key = tmpl_mod._patched_iso_inputs_key(
+ base, au.render_autounattend(params), au.render_post_install(params)
+ )
+ (vm_dir / "install-noprompt.iso.inputs").write_text(key, encoding="utf-8")
+
+ def boom(*_a: Any, **_k: Any) -> Path:
+ raise AssertionError("patch_iso should not run when cache is valid")
+
+ monkeypatch.setattr(tmpl_mod.win_iso_patch, "patch_iso", boom)
+
+ result = CliRunner().invoke(
+ cli, ["template", "deploy-win", "--win-iso", str(base), "--nogui", "--force"]
+ )
+ assert result.exit_code == 0, result.output
+ assert "skipping repack" in result.output
+
+
def test_deploy_win_uup_happy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
root = tmp_path
(root / "iso").mkdir(parents=True)