From 3a9aca8f3334f3bfc554377972f7e724eb7ed037 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Thu, 6 Aug 2026 08:18:13 +0200 Subject: [PATCH 1/4] docs(skill): correct verified command drift in the agent skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four corrections where the skill recommended commands or semantics that do not match the CLI. Each verified against the current source, not assumed. `deploy --wait` does not wait outside a terminal. `Envelope#WantsJSON()` is `JSONMode || !IsTTY` (internal/output/envelope.go:51-56), and the JSON branch in deploy returns as soon as the deployment is queued (internal/commands/deploy.go:588), so the `if wait` block below it is unreachable. In any pipe, CI job, agent invocation or redirect, `--wait` is inert and the command exits 0 while the deployment is still running. Note the trigger is the missing TTY, not the `--json` flag — passing it changes nothing. Confirmed by control test on WantsJSON. The skill documented `--wait --json` as the way to block, in four places. Those now use the safe composition — create, `deployments watch`, then assert `status == "completed"` via `deployments show` — including the two-environment worked example in servers.md. A cancelled deployment exits 0. internal/commands/watch.go:108-111 treats `cancelled` as a clean terminal state and returns nil; only `failed` is non-zero, and `dhq rollback` shares the watcher. The blanket "non-zero = failure" claim in SKILL.md was therefore false, and is now qualified. `ssh-commands create` cannot express a usable release hook: the CLI sends only command, description and timing (internal/commands/ssh_commands.go:229-231), so the callback phase, server targeting, timeout and halt-on-error all fall to backend defaults — before_changes, every server, no failure on error. Documented as a limitation, with a post_deploy ssh deployment check as the first-class alternative, including atomic ordering, variable expansion, failure semantics, the 600-second cap and the beta gate. Reference count in CLAUDE.md said 8; the directory has 9. Adds three evals: automation must not reach for `--wait`, a cancelled deploy must be detected by status rather than exit code, and post-release reconcile must use a deployment check rather than ssh-commands. Suite unchanged at 879 (documentation and evals only). Refs DHQ-695 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Hibw5xsRNGDkYzr1hXsDQz --- CLAUDE.md | 2 +- skill-evals/deployhq/evals.json | 32 ++++++++++++ skills/deployhq/SKILL.md | 4 +- skills/deployhq/references/configuration.md | 39 +++++++++++++++ skills/deployhq/references/deployments.md | 54 ++++++++++++++++++--- skills/deployhq/references/servers.md | 10 ++-- 6 files changed, 129 insertions(+), 12 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6cc2e99..aa2c1a2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ internal/skillinstaller/ Detects installed AI agents (12 supported) and write into the repo (opt-in via --agent). Powers `dhq skills` and the `dhq hello` onboarding step. -skills/deployhq/ Agent skill guide + per-domain reference docs (8 files) +skills/deployhq/ Agent skill guide + per-domain reference docs (9 files) SKILL.md is the entry point for any AI agent. skill-evals/deployhq/ Eval suite (49 cases) testing LLM → CLI translation diff --git a/skill-evals/deployhq/evals.json b/skill-evals/deployhq/evals.json index e63fea9..5235676 100644 --- a/skill-evals/deployhq/evals.json +++ b/skill-evals/deployhq/evals.json @@ -650,6 +650,38 @@ "flags": ["-p", "my-app", "managed_vps"] }, "notes": "The key is under managed_vps.ssh_key in the read-back." + }, + { + "id": "deploy-and-verify-in-automation", + "category": "gotchas", + "prompt": "In a CI pipeline, deploy my-app to Production and make the job fail if the deployment doesn't succeed", + "expected": { + "command": "dhq deployments show", + "flags": ["-p", "my-app", "status"] + }, + "must_not_contain": ["--wait"], + "notes": "--wait is inert outside a TTY: output auto-switches to JSON and the JSON path returns as soon as the deploy is queued, exiting 0. Automation must create, watch, then assert status == completed via deployments show." + }, + { + "id": "cancelled-deploy-not-success", + "category": "gotchas", + "prompt": "My deploy script exits 0 but nothing was deployed. How do I detect a cancelled deployment?", + "expected": { + "command": "dhq deployments show", + "flags": ["status"] + }, + "notes": "A cancelled deployment exits 0 from the watcher; only failed is non-zero. The status must be asserted explicitly." + }, + { + "id": "post-deploy-reconcile-check", + "category": "configuration", + "prompt": "Run a reconcile script on my-app's server srv-abc123 after each release, and fail the deployment if it errors", + "expected": { + "command": "dhq deployment-checks create", + "flags": ["-p", "my-app", "--stage", "post_deploy", "--check-type", "ssh", "--servers", "srv-abc123"] + }, + "must_not_contain": ["ssh-commands create"], + "notes": "ssh-commands create cannot set callback phase, targeting, timeout or halt-on-error; a post_deploy ssh deployment check can, and fails the deployment on non-zero exit." } ] } diff --git a/skills/deployhq/SKILL.md b/skills/deployhq/SKILL.md index 00d0455..df2be68 100644 --- a/skills/deployhq/SKILL.md +++ b/skills/deployhq/SKILL.md @@ -43,7 +43,7 @@ Verify with: `dhq auth status` - **Piped/non-TTY**: Auto-switches to JSON - **`--json`**: Force JSON output. Optionally select fields: `--json=name,status,identifier` - **Breadcrumbs**: JSON responses include `breadcrumbs` array with suggested next commands -- **Exit codes**: 0 = success, non-zero = failure +- **Exit codes**: 0 = success, non-zero = failure — with one exception: a **cancelled** deployment also exits 0. Never infer deployment success from the exit code alone; assert `status == "completed"` via `dhq deployments show --json=status` ## Non-Interactive Mode @@ -131,7 +131,7 @@ dhq api POST /projects//deployments --body '{"deployment":{...}}' - Some API fields return strings OR numbers inconsistently (handled internally by `FlexString`) - `dhq deploy` auto-fetches latest revision if `--revision` is omitted - `dhq deploy` is **incremental by default** — it picks up from the server's last successful deploy. Use `--full` for a full-branch deploy or `--start-revision ` to pin a specific start commit -- `dhq deploy --wait` blocks until deployment completes (use `--timeout` to cap) +- `dhq deploy --wait` blocks **only in an interactive terminal**. Output auto-switches to JSON whenever stdout is not a TTY, and the JSON path returns as soon as the deployment is queued — so in any pipe, CI job or agent, `--wait` does nothing and the command exits 0 immediately. In automation use create → `dhq deployments watch ` → `dhq deployments show --json=status` and require `completed` (see [deployments.md](references/deployments.md)) - Deployment `watch` uses TUI in TTY mode, append-only in pipes - `dhq env-vars create` prompts for value if `--value` is omitted (not agent-friendly — always pass `--value`) - `dhq servers create` / `dhq servers update` configure deployment behaviour with `--branch` (preferred branch), `--auto-deploy` (DeployHQ's native auto-deployment), `--atomic`, `--atomic-strategy` and `--atomic-retention`. On `update`, only flags you explicitly pass are sent — omitted flags never disturb existing settings diff --git a/skills/deployhq/references/configuration.md b/skills/deployhq/references/configuration.md index cfc766b..b825fb0 100644 --- a/skills/deployhq/references/configuration.md +++ b/skills/deployhq/references/configuration.md @@ -168,6 +168,45 @@ dhq ssh-commands list -p my-app --json dhq ssh-commands create -p my-app --command "sudo systemctl restart app" --description "Restart" --json ``` +> **Limitation — `ssh-commands create` cannot express a complete SSH command.** +> The CLI sends only `command`, `description` and `timing`. It cannot set the +> callback phase, server targeting, timeout, or halt-on-error, and the backend +> requires a valid timeout and description while defaulting the callback to +> `before_changes` and halt-on-error to `false`. So a command created this way +> runs **before changes are uploaded, on every server, and does not fail the +> deployment when it errors** — rarely what a release hook wants. +> +> **For post-release reconciliation or health gates, use a deployment check +> instead** — it is first-class in the CLI and expresses everything the hook +> needs: + +```bash +dhq deployment-checks create \ + -p my-app \ + --name "Reconcile release" \ + --stage post_deploy \ + --check-type ssh \ + --command 'cd %current_path% && ./scripts/deploy-release.sh %endrev%' \ + --servers srv-abc123 \ + --timeout 600 \ + --json +``` + +Post-deploy SSH checks: + +| Property | Behaviour | +|---|---| +| Ordering | Run **after** the atomic `symlink_release` step, so `%current_path%` is the new release | +| Variables | `%current_path%` and `%endrev%` are expanded | +| Failure | A non-zero exit **fails the deployment** | +| Timeout | Maximum **600 seconds** | +| Targeting | `--servers` takes exact server identifiers (not fuzzy names) | +| Availability | Beta-feature gated | + +If first-time bootstrap on a host can exceed 600 seconds, do that installation +separately and enrol the check afterwards, so the timeout applies only to the +steady-state reconcile. + ### `dhq ssh-commands update ` Update fields on an existing SSH command. Only flags you pass are sent — omit `--timing` to leave the current value untouched. diff --git a/skills/deployhq/references/deployments.md b/skills/deployhq/references/deployments.md index 7c043c4..13b07c1 100644 --- a/skills/deployhq/references/deployments.md +++ b/skills/deployhq/references/deployments.md @@ -22,7 +22,7 @@ Shortcut for creating a deployment with smart defaults. | `--revision` | `-r` | End revision SHA (auto-fetches latest if omitted) | | `--start-revision` | | Start revision SHA (default: server's last deployed commit) | | `--full` | | Deploy entire branch from the first commit (overrides incremental default) | -| `--wait` | `-w` | Block until deployment completes | +| `--wait` | `-w` | Block until the deployment completes. **Interactive terminals only** — see the warning below. | | `--timeout` | | Timeout in seconds for `--wait` (0 = no timeout) | ```bash @@ -32,11 +32,13 @@ dhq deploy -p my-app --json # Deploy specific branch to specific server dhq deploy -p my-app -b staging -s "Staging Server" --json -# Deploy and wait for completion -dhq deploy -p my-app -s Production --wait --json +# Deploy and wait for completion — INTERACTIVE TERMINAL ONLY. +# In a pipe, CI job or agent, --wait does nothing (see the warning below); +# use the create -> watch -> show composition instead. +dhq deploy -p my-app -s Production --wait -# Deploy with timeout -dhq deploy -p my-app --wait --timeout 300 --json +# Deploy with timeout (also interactive-only) +dhq deploy -p my-app --wait --timeout 300 # Deploy a specific commit range (e.g. a hotfix) dhq deploy -p my-app --start-revision a1b2c3d --revision e4f5g6h --json @@ -52,7 +54,47 @@ dhq deploy -p my-app --full --json - **Start revision resolution (incremental by default):** `--full` forces an empty start (full-branch deploy). Otherwise `--start-revision` is used if set. Otherwise the resolved server's `last_revision` (last successful deploy) is used — this is what makes deploys incremental. Servers with no prior deploy and server-group identifiers fall through to a full deploy because there's no single baseline to start from. - An unknown `--branch` errors out instead of silently deploying the wrong branch. - `--full` and `--start-revision` are mutually exclusive. -- `--wait` shows TUI progress in TTY, append-only in pipes +- `--wait` shows TUI progress in a TTY. It has **no effect** outside one — see the warning below. + +> **Warning — `--wait` does not wait unless stdout is a terminal.** +> Output auto-switches to JSON whenever stdout is not a TTY (`WantsJSON()` is +> `JSONMode || !IsTTY`), and the JSON path returns as soon as the deployment is +> **queued**, before the wait begins. So in any pipe, CI job, agent invocation, +> or redirect, `dhq deploy --wait` prints the queued-deployment JSON and exits +> **0 immediately** — the deployment may still be running, and may still fail. +> Passing `--json` explicitly does the same thing; the trigger is the missing +> TTY, not the flag. +> +> **Never treat a zero exit from `dhq deploy` as "the deployment succeeded".** +> Use the composition below, which is safe everywhere. + +**Deploy and verify (the safe composition — use this in automation):** + +```bash +# 1. create, and capture the identifier +id=$(dhq deploy -p my-app -s Production --json | jq -r '.data.identifier') + +# 2. follow it to a terminal state (this genuinely blocks) +dhq deployments watch "$id" -p my-app + +# 3. assert the final status — this is the step that actually decides success +status=$(dhq deployments show "$id" -p my-app --json=status | jq -r '.data.status') +[ "$status" = "completed" ] || { echo "deployment $id ended as: $status"; exit 1; } +``` + +Verify the deployed revision and server too when it matters: + +```bash +dhq deployments show "$id" -p my-app --json=status,end_revision,server +``` + +> **Warning — a cancelled deployment exits 0.** +> `dhq deployments watch` returns success for a cancelled deployment +> (`watch.go` treats `cancelled` as a clean terminal state), and `dhq rollback` +> shares the same watcher. Only `failed` produces a non-zero exit. A cancelled +> deployment has **not** deployed your code, so the explicit +> `status == "completed"` assertion in step 3 is required — for rollbacks as +> well as deploys. Do not rely on exit codes alone. ### `dhq deployments list` List recent deployments with pagination. diff --git a/skills/deployhq/references/servers.md b/skills/deployhq/references/servers.md index 3fa70ae..3ebfa1e 100644 --- a/skills/deployhq/references/servers.md +++ b/skills/deployhq/references/servers.md @@ -255,11 +255,15 @@ dhq servers show -p my-app \ # Staging has no native auto-deployment, so ship it explicitly when you want to. # No -b needed: the server's preferred branch (`staging`) is used. -dhq deploy -p my-app -s Staging --wait --json +# --wait is omitted deliberately: it has no effect outside a terminal, so this +# creates, follows, then asserts the final status. See deployments.md. +id=$(dhq deploy -p my-app -s Staging --json | jq -r '.data.identifier') +dhq deployments watch "$id" -p my-app +[ "$(dhq deployments show "$id" -p my-app --json=status | jq -r '.data.status')" = "completed" ] || exit 1 # Production needs no CLI deploy — DeployHQ auto-deploys `main` on push. -# Deploy it manually only when you want an out-of-band release: -dhq deploy -p my-app -s Production --wait --json +# Deploy it manually only when you want an out-of-band release (same pattern). +dhq deploy -p my-app -s Production --json ``` ### `dhq servers update ` From 785ed0c4ccbadbca411f3d37aca0aeffb282e4ee Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Thu, 6 Aug 2026 08:34:29 +0200 Subject: [PATCH 2/4] fix(skill): read selected fields from the top level, not .data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #40. `--json=` unwraps the response envelope: `filterFields` (internal/output/envelope.go:266-270) returns the inner Data, and WriteJSON encodes that directly. So `--json` alone emits `{"ok":true,"data":{...,"status":"completed"}}` while `--json=status` emits `{"status":"completed"}`. The verification recipe added in the previous commit read `.data.status` after `--json=status`, which is always null — so the "safe composition" written to stop automation trusting a bad deploy would instead have failed the job after every *successful* one. Confirmed by control test on WriteJSON with and without field selection. Two call sites fixed (deployments.md, servers.md). The two `.data.identifier` reads are correct and unchanged — they follow a bare `--json`, where the envelope is present. Added a note documenting the shape difference so the two forms are not conflated again. Also strengthens the deploy-and-verify eval: it previously passed for a response containing only `dhq deployments show`, without creating, watching, or asserting the status — the case could not fail for the behaviour it guards. Now requires watch, show and the literal `completed`. Note `check_eval` word-splits `expected.flags` before substring-matching, so the tokens are single words; multi-word entries would silently become separate assertions. Caught by Codex on #40 (P1 and P2). Refs DHQ-695 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Hibw5xsRNGDkYzr1hXsDQz --- skill-evals/deployhq/evals.json | 6 +++--- skills/deployhq/references/deployments.md | 9 ++++++++- skills/deployhq/references/servers.md | 3 ++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/skill-evals/deployhq/evals.json b/skill-evals/deployhq/evals.json index 5235676..1ee3aad 100644 --- a/skill-evals/deployhq/evals.json +++ b/skill-evals/deployhq/evals.json @@ -656,11 +656,11 @@ "category": "gotchas", "prompt": "In a CI pipeline, deploy my-app to Production and make the job fail if the deployment doesn't succeed", "expected": { - "command": "dhq deployments show", - "flags": ["-p", "my-app", "status"] + "command": "dhq deploy", + "flags": ["-p", "my-app", "watch", "show", "completed"] }, "must_not_contain": ["--wait"], - "notes": "--wait is inert outside a TTY: output auto-switches to JSON and the JSON path returns as soon as the deploy is queued, exiting 0. Automation must create, watch, then assert status == completed via deployments show." + "notes": "check_eval word-splits expected.flags and substring-matches each token, so single words are used: watch + show + completed together force the whole create -> watch -> assert sequence rather than any one step. --wait is inert outside a TTY: output auto-switches to JSON and the JSON path returns as soon as the deploy is queued, exiting 0." }, { "id": "cancelled-deploy-not-success", diff --git a/skills/deployhq/references/deployments.md b/skills/deployhq/references/deployments.md index 13b07c1..e1843a8 100644 --- a/skills/deployhq/references/deployments.md +++ b/skills/deployhq/references/deployments.md @@ -78,10 +78,17 @@ id=$(dhq deploy -p my-app -s Production --json | jq -r '.data.identifier') dhq deployments watch "$id" -p my-app # 3. assert the final status — this is the step that actually decides success -status=$(dhq deployments show "$id" -p my-app --json=status | jq -r '.data.status') +status=$(dhq deployments show "$id" -p my-app --json=status | jq -r '.status') [ "$status" = "completed" ] || { echo "deployment $id ended as: $status"; exit 1; } ``` +> **Note — field selection changes the JSON shape.** With `--json` alone the +> response is the full envelope (`{"ok":…, "data":{…}}`), so read values with +> `.data.`. With `--json=` the envelope is unwrapped and only the +> selected fields are emitted at the **top level** (`{"status":"completed"}`), so +> read them with `.`. Using `.data.status` after `--json=status` yields +> `null`, which would fail the check above on every successful deployment. + Verify the deployed revision and server too when it matters: ```bash diff --git a/skills/deployhq/references/servers.md b/skills/deployhq/references/servers.md index 3ebfa1e..dc10275 100644 --- a/skills/deployhq/references/servers.md +++ b/skills/deployhq/references/servers.md @@ -259,7 +259,8 @@ dhq servers show -p my-app \ # creates, follows, then asserts the final status. See deployments.md. id=$(dhq deploy -p my-app -s Staging --json | jq -r '.data.identifier') dhq deployments watch "$id" -p my-app -[ "$(dhq deployments show "$id" -p my-app --json=status | jq -r '.data.status')" = "completed" ] || exit 1 +# NB: --json= unwraps the envelope, so it is .status here, not .data.status +[ "$(dhq deployments show "$id" -p my-app --json=status | jq -r '.status')" = "completed" ] || exit 1 # Production needs no CLI deploy — DeployHQ auto-deploys `main` on push. # Deploy it manually only when you want an out-of-band release (same pattern). From 592956f1f8838a5e005e1bc31a994ad530a85343 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Thu, 6 Aug 2026 08:40:00 +0200 Subject: [PATCH 3/4] docs(skill): use the servers field, and correct the eval-suite count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-up on #40. `Deployment` serialises its targets as `servers` (pkg/sdk/types.go, `Servers []Server json:"servers"`); there is no `server` field on it — that belongs to `DeploymentStep`. The verification example selected `server`, which `pickFields` silently drops, so the field the operator asked for would simply be absent. The same silent-drop behaviour this branch documents two sections earlier. CLAUDE.md's eval-suite count said 49; the suite now has 69. The previous commit corrected the reference-file count two lines above and missed this one. Note the count is 69, not the 52 suggested in review — that assumed this PR's three cases were the only additions, but the stacked branches for DHQ-691 and DHQ-692 added five and four respectively. Verified against `jq '.evals|length'`. Caught by CodeRabbit on #40. Refs DHQ-695 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Hibw5xsRNGDkYzr1hXsDQz --- CLAUDE.md | 2 +- skills/deployhq/references/deployments.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aa2c1a2..9605def 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ internal/skillinstaller/ Detects installed AI agents (12 supported) and skills/deployhq/ Agent skill guide + per-domain reference docs (9 files) SKILL.md is the entry point for any AI agent. -skill-evals/deployhq/ Eval suite (49 cases) testing LLM → CLI translation +skill-evals/deployhq/ Eval suite (69 cases) testing LLM → CLI translation run-evals.sh drives Claude API, checks command accuracy. ``` diff --git a/skills/deployhq/references/deployments.md b/skills/deployhq/references/deployments.md index e1843a8..627f1f3 100644 --- a/skills/deployhq/references/deployments.md +++ b/skills/deployhq/references/deployments.md @@ -92,7 +92,7 @@ status=$(dhq deployments show "$id" -p my-app --json=status | jq -r '.status') Verify the deployed revision and server too when it matters: ```bash -dhq deployments show "$id" -p my-app --json=status,end_revision,server +dhq deployments show "$id" -p my-app --json=status,end_revision,servers ``` > **Warning — a cancelled deployment exits 0.** From bb743850862c4d581a96027f761238f77a9660b8 Mon Sep 17 00:00:00 2001 From: Thiago Durante Date: Thu, 6 Aug 2026 08:55:22 +0200 Subject: [PATCH 4/4] docs: add CHANGELOG entry for the agent-skill corrections skills/ is go:embed-ed into the binary (skills/embed.go:15), so a skill correction changes what ships and belongs in the changelog. #40 had no entry. Also regroups the Unreleased section. The DHQ-692 changelog edit inserted a `### Fixed` heading ahead of the DHQ-691 entries, which left seven Added items sitting under Fixed. Entries are now grouped by kind rather than by the order they were appended. Refs DHQ-695 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Hibw5xsRNGDkYzr1hXsDQz --- CHANGELOG.md | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bdcf289..13a74cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,18 +19,11 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi any request, for a non-`managed_vps` protocol or alongside `--global-key-pair-id` (the ssh/rsync equivalent). Requires the matching API change (DHQ-692). + - **SDK**: `ServerCreateRequest.KeyPairIdentifier` (hoisted, `json:"-"`), and `ManagedVPSInfo.SSHKey` (`*ManagedVPSSSHKey` — identifier, title, fingerprint) for the read-back. Purely additive. -### Fixed - -- **Agent skill**: `references/global-resources.md` documented - `dhq ssh-keys create --name --public-key`; neither flag exists. Keys are - generated server-side and the flags are `--title` (required) and `--type` - (`ED25519` default, or `RSA`). Also documents `ssh-keys download -o` and - `ssh-keys delete`. - - **`dhq servers create` / `dhq servers update`**: five deployment-configuration flags — `--branch` (the server's preferred branch), `--auto-deploy` (DeployHQ's native repository auto-deployment), `--atomic` (zero-downtime @@ -46,29 +39,55 @@ While the CLI is pre-1.0, minor versions may carry breaking changes to the publi request still succeeds, so verify with `dhq servers show -p --json atomic,atomic_strategy,atomic_retention` rather than trusting the exit code (DHQ-691). + - **`dhq servers create` / `dhq servers update`**: `--branch ""` now unpins a server so it falls back to the repository default. Previously the empty value was dropped by `omitempty`, and the command reported success having changed nothing. The backend accepts and persists a blank branch (it echoes it back as `""`, not `null`). + - **`dhq servers create` / `dhq servers update`**: warn on stderr when `--atomic` was requested but the server comes back with atomic off. On accounts without atomic deployments enabled the backend strips the atomic params before validation and returns 2xx, so this was previously a silent success. The create/update response is the read-back the docs asked operators to perform, so no extra request is made. stdout stays pure data. + - **`dhq servers create` / `dhq servers update`**: warn on stderr when `--branch` is set on a server that belongs to a server group. The backend resolves the branch as `server_group.branch || server.branch || repository.branch`, and grouped servers are excluded from auto-deployment entirely, so a branch stored on a grouped server never deploys — previously a silent no-op. stdout stays pure data. + - **SDK**: `ServerCreateRequest` and `ServerUpdateRequest` gained matching `Branch`, `AutoDeploy`, `Atomic`, `AtomicStrategy` and `AtomicRetention` fields. Purely additive — no existing field changed, so this is not a breaking change for importers of `github.com/deployhq/deployhq-cli/pkg/sdk`. `Branch` is a `*string` so an explicitly-cleared branch survives serialisation. +### Fixed + +- **Agent skill**: corrected commands and semantics that did not match the CLI. + `dhq deploy --wait` was documented as blocking, but output auto-switches to + JSON whenever stdout is not a TTY and the JSON path returns as soon as the + deployment is queued — so in any pipe, CI job or agent it exits 0 immediately + without waiting. The skill now documents the create → `deployments watch` → + `deployments show` composition and requires asserting `status == "completed"`. + Also: a cancelled deployment exits 0 (only `failed` is non-zero), so the + blanket "non-zero = failure" claim is now qualified; `ssh-commands create` + cannot set callback phase, server targeting, timeout or halt-on-error, so a + `post_deploy` SSH deployment check is documented as the first-class + alternative; and `--json=` unwraps the response envelope, so selected + fields are read as `.` rather than `.data.`. Reference-file and + eval-suite counts in `CLAUDE.md` corrected (DHQ-695). + +- **Agent skill**: `references/global-resources.md` documented + `dhq ssh-keys create --name --public-key`; neither flag exists. Keys are + generated server-side and the flags are `--title` (required) and `--type` + (`ED25519` default, or `RSA`). Also documents `ssh-keys download -o` and + `ssh-keys delete`. + ## [0.20.1] - 2026-07-24 ### Fixed