From 0c7bcf90803bb052120c1adce9ef90042989dab4 Mon Sep 17 00:00:00 2001 From: Nitin T Date: Wed, 29 Jul 2026 10:19:30 +0100 Subject: [PATCH 1/6] feat: add Hermes deployment pipeline --- docs/deployment-pipeline-v1.md | 85 +++ hermes_cli/deployment.py | 594 +++++++++++++++++++ hermes_cli/main.py | 13 + hermes_cli/subcommands/deploy.py | 68 +++ scripts/hermes-deploy | 4 + tests/hermes_cli/test_deployment_pipeline.py | 123 ++++ 6 files changed, 887 insertions(+) create mode 100644 docs/deployment-pipeline-v1.md create mode 100644 hermes_cli/deployment.py create mode 100644 hermes_cli/subcommands/deploy.py create mode 100755 scripts/hermes-deploy create mode 100644 tests/hermes_cli/test_deployment_pipeline.py diff --git a/docs/deployment-pipeline-v1.md b/docs/deployment-pipeline-v1.md new file mode 100644 index 000000000000..1b12ba2e8a0e --- /dev/null +++ b/docs/deployment-pipeline-v1.md @@ -0,0 +1,85 @@ +# Hermes Deployment Pipeline v1 + +`hermes deploy` is the permanent deployment gate for OVOS milestones on the +Hermes VPS. + +## Current Architecture + +- VPS SSH alias: `hermes-vps`. +- Runtime service: user-scoped `hermes-gateway.service`. +- Gateway process: + `/opt/ai-stack/hermes-agent/venv/bin/python -m hermes_cli.main gateway run`. +- Hermes Agent checkout: `/opt/ai-stack/hermes-agent`. +- OVOS Core checkout: `/opt/ai-stack/ovos-core`. +- OVOS is installed editable into the Hermes Agent virtual environment. +- OVOS production credentials are injected through + `/opt/ai-stack/ovos-core/.env.supabase` via the systemd drop-in + `hermes-gateway.service.d/ovos.conf`. +- Existing local database validation uses Supabase CLI against local ports only. +- Production migrations are applied with `npx supabase db push` from the OVOS + checkout after a dry run. + +The gateway API server is intentionally disabled unless explicitly configured, +so deployment health uses systemd, gateway runtime state, OVOS status, Supabase +migration state and EDE CLI smoke tests instead of assuming an HTTP API port. + +## Command + +Dry-run plan: + +```bash +hermes deploy +``` + +Execute: + +```bash +hermes deploy --execute --expected-ovos-commit +``` + +## Fail-Closed Order + +1. Resolve and verify the expected OVOS `origin/main` commit. +2. Run local validation unless `--skip-local-validation` is explicitly used: + compileall, pytest, ruff, format check, mypy, local Supabase reset and all + EDE/Hermes MVP pgtap suites. +3. Verify local `main == origin/main`. +4. Verify remote tracked files are clean. +5. Run production migration dry-run. +6. Pull/reset remote OVOS to `origin/main`. +7. Verify the editable OVOS install in the Hermes venv, reinstalling only if + the active import path is not the deployed OVOS checkout. +8. Apply production migrations. +9. Restart only `hermes-gateway.service`. +10. Wait for active service state. +11. Run health verification. +12. Run smoke tests. +13. Emit a JSON deployment report. + +If any step fails, later steps do not run. + +## Health Verification + +The health gate checks: + +- Hermes gateway systemd service is active. +- A `hermes_cli.main gateway run` process exists. +- Required OVOS Supabase environment variables are present without printing + secret values. +- Production Supabase migrations include the Hermes MVP migration + `20260729130000`. +- `/ovos status --json` reports a non-critical state and a running gateway. +- The deployed OVOS commit matches the expected SHA. + +## Smoke Tests + +The smoke gate checks: + +- `hermes --version` starts. +- EDE Event Journal fixture ingestion works locally on the VPS. +- EDE Event Journal list works. +- deterministic Daily Brief generation works. +- EDE-007A execution targets still report `live adapter: none`. +- EDE-007A controls still expose the Execution Safety Kernel. + +No live execution adapter is enabled by this pipeline. diff --git a/hermes_cli/deployment.py b/hermes_cli/deployment.py new file mode 100644 index 000000000000..4662ed1b3cfa --- /dev/null +++ b/hermes_cli/deployment.py @@ -0,0 +1,594 @@ +"""Fail-closed Hermes VPS deployment pipeline.""" + +from __future__ import annotations + +import json +import os +import shlex +import subprocess +import sys +import time +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Protocol + + +@dataclass(frozen=True) +class CommandResult: + code: int + stdout: str = "" + stderr: str = "" + + @property + def ok(self) -> bool: + return self.code == 0 + + +class CommandRunner(Protocol): + def run( + self, + args: list[str], + *, + cwd: Path | None = None, + timeout: int = 120, + input_text: str | None = None, + ) -> CommandResult: ... + + +class SubprocessCommandRunner: + def run( + self, + args: list[str], + *, + cwd: Path | None = None, + timeout: int = 120, + input_text: str | None = None, + ) -> CommandResult: + try: + completed = subprocess.run( + args, + cwd=str(cwd) if cwd else None, + input=input_text, + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired as exc: + return CommandResult( + code=124, + stdout=exc.stdout or "", + stderr=exc.stderr or f"timed out after {timeout}s", + ) + return CommandResult(completed.returncode, completed.stdout, completed.stderr) + + +@dataclass(frozen=True) +class DeploymentConfig: + remote_host: str = "hermes-vps" + local_ovos_core: Path = Path( + "/Users/nitinteckchandani/Projects/Hermes-Build/ovos-core" + ) + local_ovos_python: Path | None = None + remote_ovos_core: str = "/opt/ai-stack/ovos-core" + remote_hermes_agent: str = "/opt/ai-stack/hermes-agent" + service: str = "hermes-gateway.service" + expected_ovos_commit: str | None = None + skip_local_validation: bool = False + execute: bool = False + report_file: Path | None = None + required_env_vars: tuple[str, ...] = ( + "SUPABASE_URL", + "SUPABASE_SECRET_KEY", + "OVOS_DEFAULT_TENANT_ID", + "OVOS_DEFAULT_OWNER_USER_ID", + "OVOS_SUPABASE_SCHEMA", + ) + + +@dataclass +class DeploymentStep: + name: str + command: list[str] | None = None + cwd: Path | None = None + timeout: int = 120 + remote_script: str | None = None + mutates: bool = False + + +@dataclass +class StepRecord: + name: str + status: str + code: int | None = None + stdout_tail: str = "" + stderr_tail: str = "" + + +@dataclass +class DeploymentReport: + started_at: str + completed_at: str | None = None + status: str = "planned" + deployed_commit: str | None = None + previous_remote_commit: str | None = None + report_file: str | None = None + steps: list[StepRecord] = field(default_factory=list) + + def to_jsonable(self) -> dict[str, Any]: + return { + "completed_at": self.completed_at, + "deployed_commit": self.deployed_commit, + "previous_remote_commit": self.previous_remote_commit, + "report_file": self.report_file, + "started_at": self.started_at, + "status": self.status, + "steps": [step.__dict__ for step in self.steps], + } + + +def _tail(value: str, limit: int = 2000) -> str: + return value[-limit:] + + +def _utc_stamp() -> str: + return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) + + +def _shell_join(parts: list[str]) -> str: + return " ".join(shlex.quote(part) for part in parts) + + +def _ssh_command(config: DeploymentConfig, script: str) -> list[str]: + return ["ssh", config.remote_host, "bash", "-lc", script] + + +def _default_local_ovos_python(local_ovos_core: Path) -> Path: + configured = os.environ.get("OVOS_DEPLOY_PYTHON") + if configured: + return Path(configured) + for candidate in ( + local_ovos_core / ".venv" / "bin" / "python", + Path("/private/tmp/ovos-core-venv313/bin/python"), + ): + if candidate.exists(): + return candidate + return Path(sys.executable) + + +def _local_ovos_python(config: DeploymentConfig) -> str: + return str( + config.local_ovos_python or _default_local_ovos_python(config.local_ovos_core) + ) + + +def _safe_remote_env_names(config: DeploymentConfig) -> str: + names = " ".join(shlex.quote(name) for name in config.required_env_vars) + env_file = shlex.quote(f"{config.remote_ovos_core}/.env.supabase") + return ( + f"set -eu; test -f {env_file}; " + f'for name in {names}; do grep -Eq "^${{name}}=" {env_file}; done; ' + "printf 'required OVOS env vars present\\n'" + ) + + +def _remote_python(config: DeploymentConfig) -> str: + return f"{config.remote_hermes_agent}/venv/bin/python" + + +def _remote_hermes(config: DeploymentConfig) -> str: + return f"{config.remote_hermes_agent}/venv/bin/hermes" + + +def _remote_base_env(config: DeploymentConfig) -> str: + return ( + f"set -a; . {shlex.quote(config.remote_ovos_core + '/.env.supabase')}; set +a; " + f"export PYTHONPATH={shlex.quote(config.remote_ovos_core)};" + ) + + +def _health_script(config: DeploymentConfig) -> str: + service = shlex.quote(config.service) + ovos = shlex.quote(config.remote_ovos_core) + py = shlex.quote(_remote_python(config)) + expected = shlex.quote(config.expected_ovos_commit or "") + return ( + "set -eu; " + f"systemctl --user is-active --quiet {service}; " + f"pgrep -af 'hermes_cli.main gateway run' >/dev/null; " + f"{_safe_remote_env_names(config)}; " + f"cd {ovos}; npx supabase migration list --linked >/tmp/hermes-deploy-migrations.txt; " + f"grep -q 20260729130000 /tmp/hermes-deploy-migrations.txt; " + f"{_remote_base_env(config)} " + f"{py} - <<'PY'\n" + "import json\n" + "from ovos_core.hermes_plugin import handle_ovos_command\n" + "payload = json.loads(handle_ovos_command('status --json'))\n" + "if payload.get('overall') == 'Critical':\n" + " raise SystemExit('OVOS status is Critical')\n" + "gateway = payload.get('gateway') or {}\n" + "if gateway.get('running') is not True:\n" + " raise SystemExit('gateway is not running in OVOS status')\n" + "print(json.dumps({'overall': payload.get('overall'), 'gateway': gateway.get('state')}))\n" + "PY\n" + f'test -z {expected} || test "$(git -C {ovos} rev-parse HEAD)" = {expected}; ' + "printf 'health ok\\n'" + ) + + +def _smoke_script(config: DeploymentConfig) -> str: + py = shlex.quote(_remote_python(config)) + hermes = shlex.quote(_remote_hermes(config)) + fixture = shlex.quote( + f"{config.remote_ovos_core}/tests/fixtures/hermes_mvp_events.json" + ) + store = shlex.quote( + f"/tmp/hermes-deploy-smoke-{config.expected_ovos_commit or 'current'}.json" + ) + tenant = "00000000-0000-0000-0000-000000000101" + return ( + "set -eu; " + f"{hermes} --version >/dev/null; " + f"{_remote_base_env(config)} " + f"OVOS_EDE_LOCAL_STORE={store} {py} -m ovos_core.ede.cli journal ingest {fixture} --json " + '| grep -q \'"execution_status": "not_executed"\'; ' + f"{_remote_base_env(config)} " + f"OVOS_EDE_LOCAL_STORE={store} {py} -m ovos_core.ede.cli journal list --tenant {tenant} --json " + "| grep -q '\"events\"'; " + f"{_remote_base_env(config)} " + f"OVOS_EDE_LOCAL_STORE={store} {py} -m ovos_core.ede.cli brief generate " + f"--tenant {tenant} --date 2026-07-29 --json " + '| grep -q \'"approved_state": "approved_not_executable"\'; ' + f"{_remote_base_env(config)} " + f"{py} -m ovos_core.ede.cli execution targets list | grep -q 'live adapter: none'; " + f"{_remote_base_env(config)} " + f"{py} -m ovos_core.ede.cli execution controls status | grep -q 'Execution Safety Kernel'; " + "printf 'smoke ok\\n'" + ) + + +class DeploymentPipeline: + def __init__( + self, + config: DeploymentConfig, + *, + runner: CommandRunner | None = None, + ) -> None: + self.config = config + self.runner = runner or SubprocessCommandRunner() + + def resolve_expected_commit(self) -> str: + if self.config.expected_ovos_commit: + return self.config.expected_ovos_commit + result = self.runner.run( + ["git", "rev-parse", "origin/main"], + cwd=self.config.local_ovos_core, + timeout=30, + ) + if not result.ok: + raise RuntimeError( + result.stderr.strip() or "could not resolve local origin/main" + ) + return result.stdout.strip() + + def build_steps(self) -> list[DeploymentStep]: + expected = self.config.expected_ovos_commit or self.resolve_expected_commit() + config = DeploymentConfig(**{ + **self.config.__dict__, + "expected_ovos_commit": expected, + }) + steps: list[DeploymentStep] = [] + if not config.skip_local_validation: + py = _local_ovos_python(config) + steps.extend([ + DeploymentStep( + "local ovos compile", + [ + py, + "-X", + "pycache_prefix=/private/tmp/ovos-core-pycache", + "-m", + "compileall", + "ovos_core", + "tests", + ], + cwd=config.local_ovos_core, + timeout=180, + ), + DeploymentStep( + "local ovos pytest", + [py, "-m", "pytest", "-q"], + cwd=config.local_ovos_core, + timeout=300, + ), + DeploymentStep( + "local ovos ruff", + [py, "-m", "ruff", "check", "."], + cwd=config.local_ovos_core, + timeout=120, + ), + DeploymentStep( + "local ovos format check", + [py, "-m", "ruff", "format", "--check", "."], + cwd=config.local_ovos_core, + timeout=120, + ), + DeploymentStep( + "local ovos mypy", + [py, "-m", "mypy", "ovos_core", "tests"], + cwd=config.local_ovos_core, + timeout=180, + ), + DeploymentStep( + "local ovos diff check", + ["git", "diff", "--check"], + cwd=config.local_ovos_core, + timeout=30, + ), + DeploymentStep( + "local supabase reset", + ["supabase", "db", "reset", "--local"], + cwd=config.local_ovos_core, + timeout=300, + ), + DeploymentStep( + "local ede pgtap base", + [ + "psql", + "postgresql://postgres:postgres@127.0.0.1:55422/postgres", + "-v", + "ON_ERROR_STOP=1", + "-f", + "tests/db/ede_db_validation.sql", + ], + cwd=config.local_ovos_core, + timeout=120, + ), + DeploymentStep( + "local ede pgtap patterns", + [ + "psql", + "postgresql://postgres:postgres@127.0.0.1:55422/postgres", + "-v", + "ON_ERROR_STOP=1", + "-f", + "tests/db/ede_005_pattern_validation.sql", + ], + cwd=config.local_ovos_core, + timeout=120, + ), + DeploymentStep( + "local ede pgtap planning", + [ + "psql", + "postgresql://postgres:postgres@127.0.0.1:55422/postgres", + "-v", + "ON_ERROR_STOP=1", + "-f", + "tests/db/ede_006_planning_validation.sql", + ], + cwd=config.local_ovos_core, + timeout=120, + ), + DeploymentStep( + "local ede pgtap safety kernel", + [ + "psql", + "postgresql://postgres:postgres@127.0.0.1:55422/postgres", + "-v", + "ON_ERROR_STOP=1", + "-f", + "tests/db/ede_007a_execution_safety_validation.sql", + ], + cwd=config.local_ovos_core, + timeout=120, + ), + DeploymentStep( + "local hermes mvp pgtap", + [ + "psql", + "postgresql://postgres:postgres@127.0.0.1:55422/postgres", + "-v", + "ON_ERROR_STOP=1", + "-f", + "tests/db/hermes_mvp_daily_brief_validation.sql", + ], + cwd=config.local_ovos_core, + timeout=120, + ), + ]) + steps.extend([ + DeploymentStep( + "verify local ovos main", + remote_script=None, + command=[ + "bash", + "-lc", + ( + "set -eu; " + 'test "$(git rev-parse main)" = "$(git rev-parse origin/main)"; ' + f'test "$(git rev-parse origin/main)" = {shlex.quote(expected)}' + ), + ], + cwd=config.local_ovos_core, + timeout=30, + ), + DeploymentStep( + "remote current commit", + remote_script=( + f"set -eu; git -C {shlex.quote(config.remote_ovos_core)} rev-parse HEAD" + ), + ), + DeploymentStep( + "remote tracked clean", + remote_script=( + f'set -eu; test -z "$(git -C {shlex.quote(config.remote_ovos_core)} ' + 'status --porcelain --untracked-files=no)"' + ), + ), + DeploymentStep( + "remote dry-run migrations", + remote_script=( + f"set -eu; cd {shlex.quote(config.remote_ovos_core)}; " + "npx supabase db push --dry-run" + ), + timeout=300, + ), + DeploymentStep( + "pull latest ovos main", + remote_script=( + f"set -eu; git -C {shlex.quote(config.remote_ovos_core)} fetch origin main; " + f"git -C {shlex.quote(config.remote_ovos_core)} switch main; " + f"git -C {shlex.quote(config.remote_ovos_core)} reset --hard origin/main; " + f'test "$(git -C {shlex.quote(config.remote_ovos_core)} rev-parse HEAD)" ' + f"= {shlex.quote(expected)}" + ), + mutates=True, + timeout=180, + ), + DeploymentStep( + "verify editable ovos install", + remote_script=( + f"set -eu; cd {shlex.quote(config.remote_ovos_core)}; " + f"if {shlex.quote(_remote_python(config))} - <<'PY'\n" + "from pathlib import Path\n" + "import ovos_core\n" + f"expected = Path({config.remote_ovos_core!r}).resolve()\n" + "actual = Path(ovos_core.__file__).resolve()\n" + "raise SystemExit(0 if expected in actual.parents else 1)\n" + "PY\n" + "then printf 'editable OVOS install already active\\n'; " + f"else {shlex.quote(_remote_python(config))} -m pip install -e .; fi" + ), + mutates=True, + timeout=300, + ), + DeploymentStep( + "apply production migrations", + remote_script=( + f"set -eu; cd {shlex.quote(config.remote_ovos_core)}; npx supabase db push" + ), + mutates=True, + timeout=600, + ), + DeploymentStep( + "restart gateway service", + remote_script=( + f"set -eu; systemctl --user restart {shlex.quote(config.service)}" + ), + mutates=True, + timeout=120, + ), + DeploymentStep( + "wait for healthy service", + remote_script=( + f"set -eu; for i in $(seq 1 30); do " + f"systemctl --user is-active --quiet {shlex.quote(config.service)} && exit 0; " + "sleep 2; done; systemctl --user status " + f"{shlex.quote(config.service)} --no-pager --lines=40; exit 1" + ), + timeout=90, + ), + DeploymentStep( + "health verification", + remote_script=_health_script(config), + timeout=240, + ), + DeploymentStep( + "smoke tests", + remote_script=_smoke_script(config), + timeout=240, + ), + ]) + return steps + + def plan(self) -> list[str]: + lines: list[str] = [] + for step in self.build_steps(): + if step.remote_script: + lines.append( + f"{step.name}: ssh {self.config.remote_host} {step.remote_script}" + ) + elif step.command: + prefix = f"(cd {step.cwd} && " if step.cwd else "" + suffix = ")" if step.cwd else "" + lines.append( + f"{step.name}: {prefix}{_shell_join(step.command)}{suffix}" + ) + return lines + + def run(self) -> DeploymentReport: + report = DeploymentReport(started_at=_utc_stamp()) + if not self.config.execute: + report.status = "planned" + report.completed_at = _utc_stamp() + report.steps = [ + StepRecord(name=line, status="planned") for line in self.plan() + ] + self._write_report(report) + return report + + expected = self.resolve_expected_commit() + object.__setattr__(self.config, "expected_ovos_commit", expected) + report.deployed_commit = expected + for step in self.build_steps(): + result = self._run_step(step) + record = StepRecord( + name=step.name, + status="passed" if result.ok else "failed", + code=result.code, + stdout_tail=_tail(result.stdout), + stderr_tail=_tail(result.stderr), + ) + report.steps.append(record) + if step.name == "remote current commit" and result.ok: + report.previous_remote_commit = result.stdout.strip() + if not result.ok: + report.status = "failed" + report.completed_at = _utc_stamp() + self._write_report(report) + return report + report.status = "healthy" + report.completed_at = _utc_stamp() + self._write_report(report) + return report + + def _run_step(self, step: DeploymentStep) -> CommandResult: + if step.remote_script: + return self.runner.run( + _ssh_command(self.config, step.remote_script), + timeout=step.timeout, + ) + if step.command is None: + return CommandResult(2, stderr="step has no command") + return self.runner.run(step.command, cwd=step.cwd, timeout=step.timeout) + + def _write_report(self, report: DeploymentReport) -> None: + if self.config.report_file is None: + return + self.config.report_file.parent.mkdir(parents=True, exist_ok=True) + report.report_file = str(self.config.report_file) + self.config.report_file.write_text( + json.dumps(report.to_jsonable(), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + + +def cmd_deploy(args: Any) -> int: + config = DeploymentConfig( + remote_host=args.remote_host, + local_ovos_core=Path(args.local_ovos_core), + local_ovos_python=Path(args.local_ovos_python) + if args.local_ovos_python + else None, + remote_ovos_core=args.remote_ovos_core, + remote_hermes_agent=args.remote_hermes_agent, + service=args.service, + expected_ovos_commit=args.expected_ovos_commit, + skip_local_validation=args.skip_local_validation, + execute=args.execute, + report_file=Path(args.report_file) if args.report_file else None, + ) + report = DeploymentPipeline(config).run() + print(json.dumps(report.to_jsonable(), indent=2, sort_keys=True)) + return 0 if report.status in {"planned", "healthy"} else 1 diff --git a/hermes_cli/main.py b/hermes_cli/main.py index c233594fcf81..ae18a6c0f36f 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -419,6 +419,7 @@ def _try_termux_ultrafast_version() -> bool: from hermes_cli.subcommands.security import build_security_parser from hermes_cli.subcommands.dump import build_dump_parser from hermes_cli.subcommands.debug import build_debug_parser +from hermes_cli.subcommands.deploy import build_deploy_parser from hermes_cli.subcommands.backup import build_backup_parser from hermes_cli.subcommands.import_cmd import build_import_cmd_parser from hermes_cli.subcommands.config import build_config_parser @@ -4450,6 +4451,13 @@ def cmd_status(args): show_status(args) +def cmd_deploy(args): + """Run the Hermes deployment pipeline.""" + from hermes_cli.deployment import cmd_deploy as _cmd_deploy + + return _cmd_deploy(args) + + def cmd_cron(args): """Cron job management.""" from hermes_cli.cron import cron_command @@ -13846,6 +13854,11 @@ def _dispatch_secrets(args): # noqa: ANN001 # ========================================================================= build_status_parser(subparsers, cmd_status=cmd_status) + # ========================================================================= + # deploy command (parser built in hermes_cli/subcommands/deploy.py) + # ========================================================================= + build_deploy_parser(subparsers, cmd_deploy=cmd_deploy) + # ========================================================================= # cron command (parser built in hermes_cli/subcommands/cron.py) # ========================================================================= diff --git a/hermes_cli/subcommands/deploy.py b/hermes_cli/subcommands/deploy.py new file mode 100644 index 000000000000..a3efe4c072c8 --- /dev/null +++ b/hermes_cli/subcommands/deploy.py @@ -0,0 +1,68 @@ +"""``hermes deploy`` subcommand parser.""" + +from __future__ import annotations + +from typing import Callable + + +def build_deploy_parser(subparsers, *, cmd_deploy: Callable) -> None: + """Attach the deployment pipeline command to ``subparsers``.""" + deploy = subparsers.add_parser( + "deploy", + help="Deploy validated OVOS milestones to the Hermes VPS", + description=( + "Run the fail-closed Hermes deployment pipeline. Defaults to dry-run " + "planning; pass --execute to mutate the VPS." + ), + ) + deploy.add_argument( + "--execute", + action="store_true", + help="Actually run deployment steps on the VPS. Without this, only a plan is printed.", + ) + deploy.add_argument("--remote-host", default="hermes-vps", help="SSH host alias.") + deploy.add_argument( + "--remote-ovos-core", + default="/opt/ai-stack/ovos-core", + help="OVOS Core checkout on the VPS.", + ) + deploy.add_argument( + "--remote-hermes-agent", + default="/opt/ai-stack/hermes-agent", + help="Hermes Agent checkout on the VPS.", + ) + deploy.add_argument( + "--local-ovos-core", + default="/Users/nitinteckchandani/Projects/Hermes-Build/ovos-core", + help="Local OVOS Core checkout used for validation and expected commit.", + ) + deploy.add_argument( + "--local-ovos-python", + default=None, + help=( + "Python interpreter with OVOS dev dependencies. Defaults to " + "OVOS_DEPLOY_PYTHON, the local OVOS venv when present, then the " + "current Python." + ), + ) + deploy.add_argument( + "--service", + default="hermes-gateway.service", + help="User-scoped systemd service to restart.", + ) + deploy.add_argument( + "--expected-ovos-commit", + default=None, + help="Required OVOS commit SHA after pulling main. Defaults to local origin/main.", + ) + deploy.add_argument( + "--skip-local-validation", + action="store_true", + help="Skip local validation commands. Intended only after a fresh validation gate.", + ) + deploy.add_argument( + "--report-file", + default=None, + help="Optional local JSON deployment report path.", + ) + deploy.set_defaults(func=cmd_deploy) diff --git a/scripts/hermes-deploy b/scripts/hermes-deploy new file mode 100755 index 000000000000..d6108eb47b67 --- /dev/null +++ b/scripts/hermes-deploy @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail + +exec hermes deploy "$@" diff --git a/tests/hermes_cli/test_deployment_pipeline.py b/tests/hermes_cli/test_deployment_pipeline.py new file mode 100644 index 000000000000..ad47b65d6d74 --- /dev/null +++ b/tests/hermes_cli/test_deployment_pipeline.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +import json +from pathlib import Path + +from hermes_cli.deployment import CommandResult, DeploymentConfig, DeploymentPipeline + + +class FakeRunner: + def __init__(self, failures: dict[str, CommandResult] | None = None) -> None: + self.failures = failures or {} + self.calls: list[list[str]] = [] + + def run( + self, + args: list[str], + *, + cwd: Path | None = None, + timeout: int = 120, + input_text: str | None = None, + ) -> CommandResult: + del cwd, timeout, input_text + self.calls.append(args) + command = " ".join(args) + for token, result in self.failures.items(): + if token in command: + return result + if "rev-parse HEAD" in command: + return CommandResult(0, "previous-sha\n", "") + if "rev-parse origin/main" in command: + return CommandResult(0, "target-sha\n", "") + return CommandResult(0, "ok\n", "") + + +def _config(tmp_path: Path, *, execute: bool = False) -> DeploymentConfig: + return DeploymentConfig( + execute=execute, + expected_ovos_commit="target-sha", + local_ovos_core=tmp_path, + report_file=tmp_path / "deploy-report.json", + skip_local_validation=True, + ) + + +def test_deploy_defaults_to_dry_run_plan(tmp_path: Path) -> None: + runner = FakeRunner() + pipeline = DeploymentPipeline(_config(tmp_path), runner=runner) + + report = pipeline.run() + + assert report.status == "planned" + assert runner.calls == [] + assert any("remote dry-run migrations" in step.name for step in report.steps) + assert ( + json.loads((tmp_path / "deploy-report.json").read_text())["status"] == "planned" + ) + + +def test_execute_stops_before_mutating_steps_when_remote_clean_check_fails( + tmp_path: Path, +) -> None: + runner = FakeRunner( + failures={ + "status --porcelain": CommandResult(1, "", "tracked files are dirty"), + } + ) + pipeline = DeploymentPipeline(_config(tmp_path, execute=True), runner=runner) + + report = pipeline.run() + commands = [" ".join(call) for call in runner.calls] + + assert report.status == "failed" + assert any( + step.name == "remote tracked clean" and step.status == "failed" + for step in report.steps + ) + assert not any("systemctl --user restart" in command for command in commands) + assert not any( + "supabase db push" in command and "--dry-run" not in command + for command in commands + ) + + +def test_execute_records_previous_and_deployed_commit_when_healthy( + tmp_path: Path, +) -> None: + runner = FakeRunner() + pipeline = DeploymentPipeline(_config(tmp_path, execute=True), runner=runner) + + report = pipeline.run() + + assert report.status == "healthy" + assert report.previous_remote_commit == "previous-sha" + assert report.deployed_commit == "target-sha" + commands = [" ".join(call) for call in runner.calls] + assert any("npx supabase db push --dry-run" in command for command in commands) + assert any( + "npx supabase db push" in command and "--dry-run" not in command + for command in commands + ) + assert any( + "systemctl --user restart hermes-gateway.service" in command + for command in commands + ) + + +def test_plan_includes_safety_kernel_and_non_execution_smoke(tmp_path: Path) -> None: + pipeline = DeploymentPipeline(_config(tmp_path), runner=FakeRunner()) + + plan = "\n".join(pipeline.plan()) + + assert "execution targets list | grep -q 'live adapter: none'" in plan + assert "execution controls status | grep -q 'Execution Safety Kernel'" in plan + assert "approved_not_executable" in plan + assert "not_executed" in plan + + +def test_health_requires_hermes_mvp_migration(tmp_path: Path) -> None: + pipeline = DeploymentPipeline(_config(tmp_path), runner=FakeRunner()) + + plan = "\n".join(pipeline.plan()) + + assert "grep -q 20260729130000" in plan From d3f794f044076460e8956339758fa566e161e26c Mon Sep 17 00:00:00 2001 From: Nitin T Date: Wed, 29 Jul 2026 10:25:29 +0100 Subject: [PATCH 2/6] fix: harden deployment ssh fetch boundary --- docs/deployment-pipeline-v1.md | 17 ++++++----- hermes_cli/deployment.py | 31 +++++++++++++++----- hermes_cli/subcommands/deploy.py | 5 ++++ tests/hermes_cli/test_deployment_pipeline.py | 26 +++++++++++++++- 4 files changed, 62 insertions(+), 17 deletions(-) diff --git a/docs/deployment-pipeline-v1.md b/docs/deployment-pipeline-v1.md index 1b12ba2e8a0e..e25d18a064a3 100644 --- a/docs/deployment-pipeline-v1.md +++ b/docs/deployment-pipeline-v1.md @@ -45,16 +45,17 @@ hermes deploy --execute --expected-ovos-commit EDE/Hermes MVP pgtap suites. 3. Verify local `main == origin/main`. 4. Verify remote tracked files are clean. -5. Run production migration dry-run. -6. Pull/reset remote OVOS to `origin/main`. +5. Fetch latest OVOS `main` from the read-only HTTPS repository URL. +6. Pull/reset remote OVOS to the fetched and verified commit. 7. Verify the editable OVOS install in the Hermes venv, reinstalling only if the active import path is not the deployed OVOS checkout. -8. Apply production migrations. -9. Restart only `hermes-gateway.service`. -10. Wait for active service state. -11. Run health verification. -12. Run smoke tests. -13. Emit a JSON deployment report. +8. Run production migration dry-run against the newly fetched migration files. +9. Apply production migrations. +10. Restart only `hermes-gateway.service`. +11. Wait for active service state. +12. Run health verification. +13. Run smoke tests. +14. Emit a JSON deployment report. If any step fails, later steps do not run. diff --git a/hermes_cli/deployment.py b/hermes_cli/deployment.py index 4662ed1b3cfa..270a0ba6d9ca 100644 --- a/hermes_cli/deployment.py +++ b/hermes_cli/deployment.py @@ -70,6 +70,7 @@ class DeploymentConfig: "/Users/nitinteckchandani/Projects/Hermes-Build/ovos-core" ) local_ovos_python: Path | None = None + remote_ovos_repo_url: str = "https://github.com/nitinteck/ovos-core.git" remote_ovos_core: str = "/opt/ai-stack/ovos-core" remote_hermes_agent: str = "/opt/ai-stack/hermes-agent" service: str = "hermes-gateway.service" @@ -140,7 +141,7 @@ def _shell_join(parts: list[str]) -> str: def _ssh_command(config: DeploymentConfig, script: str) -> list[str]: - return ["ssh", config.remote_host, "bash", "-lc", script] + return ["ssh", config.remote_host, "bash", "-lc", shlex.quote(script)] def _default_local_ovos_python(local_ovos_core: Path) -> Path: @@ -427,19 +428,24 @@ def build_steps(self) -> list[DeploymentStep]: ), ), DeploymentStep( - "remote dry-run migrations", + "fetch latest ovos main", remote_script=( - f"set -eu; cd {shlex.quote(config.remote_ovos_core)}; " - "npx supabase db push --dry-run" + f"set -eu; git -C {shlex.quote(config.remote_ovos_core)} " + f"fetch --force {shlex.quote(config.remote_ovos_repo_url)} " + "main:refs/remotes/hermes-deploy/main; " + f'test "$(git -C {shlex.quote(config.remote_ovos_core)} ' + 'rev-parse refs/remotes/hermes-deploy/main)" ' + f"= {shlex.quote(expected)}" ), - timeout=300, + mutates=True, + timeout=180, ), DeploymentStep( "pull latest ovos main", remote_script=( - f"set -eu; git -C {shlex.quote(config.remote_ovos_core)} fetch origin main; " - f"git -C {shlex.quote(config.remote_ovos_core)} switch main; " - f"git -C {shlex.quote(config.remote_ovos_core)} reset --hard origin/main; " + f"set -eu; git -C {shlex.quote(config.remote_ovos_core)} switch main; " + f"git -C {shlex.quote(config.remote_ovos_core)} " + "reset --hard refs/remotes/hermes-deploy/main; " f'test "$(git -C {shlex.quote(config.remote_ovos_core)} rev-parse HEAD)" ' f"= {shlex.quote(expected)}" ), @@ -463,6 +469,14 @@ def build_steps(self) -> list[DeploymentStep]: mutates=True, timeout=300, ), + DeploymentStep( + "remote dry-run migrations", + remote_script=( + f"set -eu; cd {shlex.quote(config.remote_ovos_core)}; " + "npx supabase db push --dry-run" + ), + timeout=300, + ), DeploymentStep( "apply production migrations", remote_script=( @@ -581,6 +595,7 @@ def cmd_deploy(args: Any) -> int: local_ovos_python=Path(args.local_ovos_python) if args.local_ovos_python else None, + remote_ovos_repo_url=args.remote_ovos_repo_url, remote_ovos_core=args.remote_ovos_core, remote_hermes_agent=args.remote_hermes_agent, service=args.service, diff --git a/hermes_cli/subcommands/deploy.py b/hermes_cli/subcommands/deploy.py index a3efe4c072c8..7b298b170f6b 100644 --- a/hermes_cli/subcommands/deploy.py +++ b/hermes_cli/subcommands/deploy.py @@ -45,6 +45,11 @@ def build_deploy_parser(subparsers, *, cmd_deploy: Callable) -> None: "current Python." ), ) + deploy.add_argument( + "--remote-ovos-repo-url", + default="https://github.com/nitinteck/ovos-core.git", + help="Read-only OVOS Core repository URL used by the VPS fetch step.", + ) deploy.add_argument( "--service", default="hermes-gateway.service", diff --git a/tests/hermes_cli/test_deployment_pipeline.py b/tests/hermes_cli/test_deployment_pipeline.py index ad47b65d6d74..4ae8362b22ba 100644 --- a/tests/hermes_cli/test_deployment_pipeline.py +++ b/tests/hermes_cli/test_deployment_pipeline.py @@ -3,7 +3,12 @@ import json from pathlib import Path -from hermes_cli.deployment import CommandResult, DeploymentConfig, DeploymentPipeline +from hermes_cli.deployment import ( + CommandResult, + DeploymentConfig, + DeploymentPipeline, + _ssh_command, +) class FakeRunner: @@ -121,3 +126,22 @@ def test_health_requires_hermes_mvp_migration(tmp_path: Path) -> None: plan = "\n".join(pipeline.plan()) assert "grep -q 20260729130000" in plan + + +def test_ssh_command_quotes_remote_script_as_single_shell_argument() -> None: + command = _ssh_command(DeploymentConfig(), "set -eu; false; echo unsafe") + + assert command[:4] == ["ssh", "hermes-vps", "bash", "-lc"] + assert command[4] == "'set -eu; false; echo unsafe'" + + +def test_remote_fetch_uses_https_url_and_validates_expected_commit( + tmp_path: Path, +) -> None: + pipeline = DeploymentPipeline(_config(tmp_path), runner=FakeRunner()) + + plan = "\n".join(pipeline.plan()) + + assert "fetch --force https://github.com/nitinteck/ovos-core.git" in plan + assert "refs/remotes/hermes-deploy/main" in plan + assert "origin main" not in plan From 5bea886d81e6c2d5bd53ec8849cad19319593f6d Mon Sep 17 00:00:00 2001 From: Nitin T Date: Wed, 29 Jul 2026 10:28:27 +0100 Subject: [PATCH 3/6] fix: deploy ovos via verified bundle --- docs/deployment-pipeline-v1.md | 3 +- hermes_cli/deployment.py | 36 +++++++++++++++++--- hermes_cli/subcommands/deploy.py | 7 ++-- tests/hermes_cli/test_deployment_pipeline.py | 22 ++++++++++-- 4 files changed, 59 insertions(+), 9 deletions(-) diff --git a/docs/deployment-pipeline-v1.md b/docs/deployment-pipeline-v1.md index e25d18a064a3..7b1912a516cb 100644 --- a/docs/deployment-pipeline-v1.md +++ b/docs/deployment-pipeline-v1.md @@ -45,7 +45,8 @@ hermes deploy --execute --expected-ovos-commit EDE/Hermes MVP pgtap suites. 3. Verify local `main == origin/main`. 4. Verify remote tracked files are clean. -5. Fetch latest OVOS `main` from the read-only HTTPS repository URL. +5. Create a local `git bundle` from the verified `main` commit and copy it to + the VPS, unless `--remote-ovos-repo-url` is explicitly supplied. 6. Pull/reset remote OVOS to the fetched and verified commit. 7. Verify the editable OVOS install in the Hermes venv, reinstalling only if the active import path is not the deployed OVOS checkout. diff --git a/hermes_cli/deployment.py b/hermes_cli/deployment.py index 270a0ba6d9ca..2804e3f19963 100644 --- a/hermes_cli/deployment.py +++ b/hermes_cli/deployment.py @@ -70,7 +70,7 @@ class DeploymentConfig: "/Users/nitinteckchandani/Projects/Hermes-Build/ovos-core" ) local_ovos_python: Path | None = None - remote_ovos_repo_url: str = "https://github.com/nitinteck/ovos-core.git" + remote_ovos_repo_url: str | None = None remote_ovos_core: str = "/opt/ai-stack/ovos-core" remote_hermes_agent: str = "/opt/ai-stack/hermes-agent" service: str = "hermes-gateway.service" @@ -144,6 +144,14 @@ def _ssh_command(config: DeploymentConfig, script: str) -> list[str]: return ["ssh", config.remote_host, "bash", "-lc", shlex.quote(script)] +def _local_bundle_path(expected_commit: str) -> Path: + return Path(f"/private/tmp/hermes-ovos-{expected_commit}.bundle") + + +def _remote_bundle_path(expected_commit: str) -> str: + return f"/tmp/hermes-ovos-{expected_commit}.bundle" + + def _default_local_ovos_python(local_ovos_core: Path) -> Path: configured = os.environ.get("OVOS_DEPLOY_PYTHON") if configured: @@ -398,7 +406,7 @@ def build_steps(self) -> list[DeploymentStep]: timeout=120, ), ]) - steps.extend([ + steps.append( DeploymentStep( "verify local ovos main", remote_script=None, @@ -413,7 +421,27 @@ def build_steps(self) -> list[DeploymentStep]: ], cwd=config.local_ovos_core, timeout=30, - ), + ) + ) + fetch_source = config.remote_ovos_repo_url + if fetch_source is None: + local_bundle = _local_bundle_path(expected) + remote_bundle = _remote_bundle_path(expected) + steps.extend([ + DeploymentStep( + "create local ovos deploy bundle", + ["git", "bundle", "create", str(local_bundle), "main"], + cwd=config.local_ovos_core, + timeout=60, + ), + DeploymentStep( + "copy ovos deploy bundle", + ["scp", str(local_bundle), f"{config.remote_host}:{remote_bundle}"], + timeout=120, + ), + ]) + fetch_source = remote_bundle + steps.extend([ DeploymentStep( "remote current commit", remote_script=( @@ -431,7 +459,7 @@ def build_steps(self) -> list[DeploymentStep]: "fetch latest ovos main", remote_script=( f"set -eu; git -C {shlex.quote(config.remote_ovos_core)} " - f"fetch --force {shlex.quote(config.remote_ovos_repo_url)} " + f"fetch --force {shlex.quote(fetch_source)} " "main:refs/remotes/hermes-deploy/main; " f'test "$(git -C {shlex.quote(config.remote_ovos_core)} ' 'rev-parse refs/remotes/hermes-deploy/main)" ' diff --git a/hermes_cli/subcommands/deploy.py b/hermes_cli/subcommands/deploy.py index 7b298b170f6b..031b89fbe8d4 100644 --- a/hermes_cli/subcommands/deploy.py +++ b/hermes_cli/subcommands/deploy.py @@ -47,8 +47,11 @@ def build_deploy_parser(subparsers, *, cmd_deploy: Callable) -> None: ) deploy.add_argument( "--remote-ovos-repo-url", - default="https://github.com/nitinteck/ovos-core.git", - help="Read-only OVOS Core repository URL used by the VPS fetch step.", + default=None, + help=( + "Optional OVOS Core repository URL used by the VPS fetch step. " + "Defaults to transferring a verified local git bundle." + ), ) deploy.add_argument( "--service", diff --git a/tests/hermes_cli/test_deployment_pipeline.py b/tests/hermes_cli/test_deployment_pipeline.py index 4ae8362b22ba..afa4388f6685 100644 --- a/tests/hermes_cli/test_deployment_pipeline.py +++ b/tests/hermes_cli/test_deployment_pipeline.py @@ -135,13 +135,31 @@ def test_ssh_command_quotes_remote_script_as_single_shell_argument() -> None: assert command[4] == "'set -eu; false; echo unsafe'" -def test_remote_fetch_uses_https_url_and_validates_expected_commit( +def test_remote_fetch_uses_verified_local_bundle_by_default( tmp_path: Path, ) -> None: pipeline = DeploymentPipeline(_config(tmp_path), runner=FakeRunner()) plan = "\n".join(pipeline.plan()) - assert "fetch --force https://github.com/nitinteck/ovos-core.git" in plan + assert "create local ovos deploy bundle" in plan + assert "copy ovos deploy bundle" in plan + assert "fetch --force /tmp/hermes-ovos-target-sha.bundle" in plan assert "refs/remotes/hermes-deploy/main" in plan assert "origin main" not in plan + + +def test_remote_fetch_can_use_explicit_repo_url(tmp_path: Path) -> None: + config = DeploymentConfig( + execute=False, + expected_ovos_commit="target-sha", + local_ovos_core=tmp_path, + remote_ovos_repo_url="https://example.test/ovos-core.git", + skip_local_validation=True, + ) + pipeline = DeploymentPipeline(config, runner=FakeRunner()) + + plan = "\n".join(pipeline.plan()) + + assert "create local ovos deploy bundle" not in plan + assert "fetch --force https://example.test/ovos-core.git" in plan From 9761cf7cbf2a32840754ee1715bfa800c1705a90 Mon Sep 17 00:00:00 2001 From: Nitin T Date: Wed, 29 Jul 2026 10:31:49 +0100 Subject: [PATCH 4/6] fix: harden systemd deployment health wait --- hermes_cli/deployment.py | 5 +++-- tests/hermes_cli/test_deployment_pipeline.py | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/hermes_cli/deployment.py b/hermes_cli/deployment.py index 2804e3f19963..4ca810243ace 100644 --- a/hermes_cli/deployment.py +++ b/hermes_cli/deployment.py @@ -203,7 +203,7 @@ def _health_script(config: DeploymentConfig) -> str: expected = shlex.quote(config.expected_ovos_commit or "") return ( "set -eu; " - f"systemctl --user is-active --quiet {service}; " + f'test "$(systemctl --user is-active {service})" = active; ' f"pgrep -af 'hermes_cli.main gateway run' >/dev/null; " f"{_safe_remote_env_names(config)}; " f"cd {ovos}; npx supabase migration list --linked >/tmp/hermes-deploy-migrations.txt; " @@ -525,7 +525,8 @@ def build_steps(self) -> list[DeploymentStep]: "wait for healthy service", remote_script=( f"set -eu; for i in $(seq 1 30); do " - f"systemctl --user is-active --quiet {shlex.quote(config.service)} && exit 0; " + f"state=$(systemctl --user is-active {shlex.quote(config.service)} || true); " + 'test "$state" = active && exit 0; ' "sleep 2; done; systemctl --user status " f"{shlex.quote(config.service)} --no-pager --lines=40; exit 1" ), diff --git a/tests/hermes_cli/test_deployment_pipeline.py b/tests/hermes_cli/test_deployment_pipeline.py index afa4388f6685..1d9468e51bb3 100644 --- a/tests/hermes_cli/test_deployment_pipeline.py +++ b/tests/hermes_cli/test_deployment_pipeline.py @@ -126,6 +126,8 @@ def test_health_requires_hermes_mvp_migration(tmp_path: Path) -> None: plan = "\n".join(pipeline.plan()) assert "grep -q 20260729130000" in plan + assert "--quiet" not in plan + assert "systemctl --user is-active hermes-gateway.service" in plan def test_ssh_command_quotes_remote_script_as_single_shell_argument() -> None: From a412e3c2e054c3def4ae67d8eede068e0b678d91 Mon Sep 17 00:00:00 2001 From: Nitin T Date: Wed, 29 Jul 2026 10:34:29 +0100 Subject: [PATCH 5/6] fix: avoid remote shell exit in service wait --- hermes_cli/deployment.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/hermes_cli/deployment.py b/hermes_cli/deployment.py index 4ca810243ace..4e1fa0554b56 100644 --- a/hermes_cli/deployment.py +++ b/hermes_cli/deployment.py @@ -524,11 +524,14 @@ def build_steps(self) -> list[DeploymentStep]: DeploymentStep( "wait for healthy service", remote_script=( - f"set -eu; for i in $(seq 1 30); do " + f"set -eu; healthy=0; for i in $(seq 1 30); do " f"state=$(systemctl --user is-active {shlex.quote(config.service)} || true); " - 'test "$state" = active && exit 0; ' - "sleep 2; done; systemctl --user status " + 'if [ "$state" = active ]; then healthy=1; break; fi; ' + "sleep 2; done; " + 'if [ "$healthy" = 1 ]; then printf "service active\\n"; ' + "else systemctl --user status " f"{shlex.quote(config.service)} --no-pager --lines=40; exit 1" + "; fi" ), timeout=90, ), From a65d9d6f8b4a84f145076d47a4d84ffac70aae88 Mon Sep 17 00:00:00 2001 From: Nitin T Date: Wed, 29 Jul 2026 12:11:47 +0100 Subject: [PATCH 6/6] fix: satisfy deployment pipeline type checks --- hermes_cli/deployment.py | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/hermes_cli/deployment.py b/hermes_cli/deployment.py index 4e1fa0554b56..9688b7c64782 100644 --- a/hermes_cli/deployment.py +++ b/hermes_cli/deployment.py @@ -8,7 +8,7 @@ import subprocess import sys import time -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from pathlib import Path from typing import Any, Protocol @@ -57,8 +57,8 @@ def run( except subprocess.TimeoutExpired as exc: return CommandResult( code=124, - stdout=exc.stdout or "", - stderr=exc.stderr or f"timed out after {timeout}s", + stdout=_text_output(exc.stdout), + stderr=_text_output(exc.stderr) or f"timed out after {timeout}s", ) return CommandResult(completed.returncode, completed.stdout, completed.stderr) @@ -132,6 +132,14 @@ def _tail(value: str, limit: int = 2000) -> str: return value[-limit:] +def _text_output(value: str | bytes | None) -> str: + if value is None: + return "" + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return value + + def _utc_stamp() -> str: return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) @@ -282,10 +290,7 @@ def resolve_expected_commit(self) -> str: def build_steps(self) -> list[DeploymentStep]: expected = self.config.expected_ovos_commit or self.resolve_expected_commit() - config = DeploymentConfig(**{ - **self.config.__dict__, - "expected_ovos_commit": expected, - }) + config = replace(self.config, expected_ovos_commit=expected) steps: list[DeploymentStep] = [] if not config.skip_local_validation: py = _local_ovos_python(config)