---
name: od-deploy
description: How and when to trigger packaging/deploy-local.sh — single-consumer model, the decision ladder, the deploy lock, deploy clone vs dev checkout, the systray dev-checkout gotcha, and the web-build fast path. Use before deploying, when a deploy is stuck, or a landed change doesn't show up in a running service.
---

# Overdeck local deploy

audience: AI coding agents first. BLUF: one script, one clone, one lock, one standing consumer.

## Single deploy authority

`overdeck-deploy.service` is the ONLY process that ever runs the deploy body (lock,
build, install, restart). Everything else — a hand caller running
`packaging/deploy-local.sh`, the controller's `DeployWatcher`
(`controller/src/deploy-watcher.ts`) — only REQUESTS a deploy: `deploy-local.sh`
invoked without the consumer's env var drops one coalescing request file into
`~/.local/share/overdeck/deploy-queue` and returns in milliseconds. `overdeck-deploy.path`
notices the non-empty queue and starts `overdeck-deploy.service` (which runs with
`OVERDECK_DEPLOY_CONSUMER=1`, the flag that unlocks the real deploy path) to drain it.
This closes the moving-target race by construction — nobody pins a stale snapshot,
because the one consumer always deploys whatever `origin/main` is at drain time, not
whatever it was at request time.

## Decision ladder — before requesting a deploy

1. Landed via `ship.sh` → dev checkout already fast-forwarded. Nothing extra.
2. Landed via direct-land escape hatch (raw `git push origin HEAD:refs/heads/main`) → dev checkout at `~/Projects/overdeck` NOT fast-forwarded automatically. Fast-forward by hand before trusting any dev-checkout-live service (systray — see below):
   ```bash
   git -C ~/Projects/overdeck merge --ff-only origin/main   # refuses cleanly on conflict; NEVER reset/checkout -f
   ```
3. Run `bash packaging/deploy-local.sh` — this ENQUEUES a request for the standing
   consumer and returns immediately; it does not deploy anything itself. The consumer
   drains the queue on its own, usually within seconds. Only pass `--now` (runs
   synchronously in the calling process, bypassing the queue) for the genuine
   emergency/bootstrap case where you need the deploy result before you can proceed —
   e.g. the DIRECT LAND escape hatch in the repo's `CLAUDE.md`, or a fresh machine where
   `overdeck-deploy.service` isn't installed yet. Reaching for `--now` as a habit defeats
   the single-consumer model — prefer the enqueue path.
4. Change touched only `apps/web/**`, `packages/**`, or root manifests (`package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`) → full web build runs, several minutes.
5. Change touched none of those → build auto-skips (fast path below), deploy finishes in seconds.
6. Change touched `modules/systray/**` → deploy-local.sh installs systray CLI commands, but does NOT restart the tray icon (systray-ai.service is not deploy-local.sh's concern — see gotcha). Restart it yourself:
   ```bash
   systemctl --user restart systray-ai.service
   ```

## Deploy clone vs dev checkout — two different trees

`packaging/deploy-local.sh` operates ONLY on the standalone clone at `~/.local/share/overdeck/deploy`: fetch + detached-checkout `origin/main` there. NEVER touches `~/Projects/overdeck` (shared dev checkout, other sessions' WIP lives there). This is why a deploy can never be blocked or poisoned by someone else's uncommitted work.

**Gotcha: `systray-ai.service` is the one live service that does NOT read from the deploy clone.** Its unit is symlinked straight from the dev checkout:
```
~/.config/systemd/user/systray-ai.service -> ~/Projects/overdeck/modules/systray/systemd/systray-ai.service
ExecStart=%h/Projects/overdeck/modules/systray/systray_codex_switcher.py
```
A landed systray change reaches the tray only once the DEV checkout has the commit AND the service restarts. `ship.sh` land does both automatically; direct-land does neither — do both by hand (step 2 + step 6 above). A deploy reporting `"status":"deployed"` is NOT proof the tray updated — check `systemctl --user status systray-ai.service` (`Active: active (running) since <time>` after your fast-forward) before declaring a systray feature visible.

## The lock

One `flock` (`~/.local/share/overdeck/deploy.lock`, up to 1800s wait) serializes the WHOLE script — not per-component. Every step after checkout (`deckctl sync apply`, controller/collector/web/systray installs, git-guard pin, land-guard hook) mutates the ONE checked-out tree plus shared system paths (`~/.claude/*`, systemd units, `/usr/local/lib/notif-gate`). Two deploys on two different commits running concurrently would race every one of those installs and corrupt the clone. Do NOT split this lock per component — there is no safe way to make components in this design install concurrently without a larger redesign (separate clones/worktrees per component, each owning only its own install targets).

Diagnose a long wait — never assume stale:
```bash
fuser -v ~/.local/share/overdeck/deploy.lock       # PIDs holding/waiting
ps -p <pid> -o pid,ppid,etime,cmd                  # still alive? how long?
```
Dead holder PID, lock file present → genuinely stale, safe to remove. Live holder → real work in progress; its `pnpm --filter web deploy` child is normally the slow step — inspect that child, not the outer bash.

## Deploy queue — every caller's actual path in

Every request — a hand-run `deploy-local.sh`, the controller's `DeployWatcher`, several
requests landing close together — enqueues under
`~/.local/share/overdeck/deploy-queue` and coalesces into the single standing consumer's
next drain of `origin/main`. Callers need no changes and never see a lock wait
themselves. Inspect queue and drain history:

```bash
ls -la ~/.local/share/overdeck/deploy-queue
systemctl --user status overdeck-deploy.service
```

Successful deploy removes requests present when it started. Failed or timed-out deploy
preserves requests for next drain. `overdeck-deploy.path` starts the drain whenever the
queue is non-empty — including a request orphaned by a consumer process that died
mid-run (crashed, or SIGKILLed by its own restart of `overdeck-controller.service`; see
the `deploy-watcher.ts` header comment), so a request never gets silently lost even if
the run that would have served it does not finish.

## Fast path — build auto-skips when nothing web-relevant changed

Script diffs `HEAD` against the commit embedded in the served release's directory name (`apps/web/.releases/current` → `.build-<sha>-XXXXXX`, always the exact deployed commit). Nothing under `apps/web/`, `packages/`, or root manifests (`package.json`, `pnpm-lock.yaml`, `pnpm-workspace.yaml`, `tsconfig*.json`, `.npmrc`) in that diff → reuses the existing release instead of rebuilding. A systray-only or collector-only deploy finishes in seconds, not minutes. Missing/unparseable prior release, or diff failure → ALWAYS rebuilds; never skips on an inconclusive diff (fail closed).

This is why splitting the lock isn't needed for the common case: a component-only change no longer holds the lock through a multi-minute web build.

## pnpm cache

`~/.local/share/pnpm/store` — persistent, already warm (tens of GB, confirmed). `pnpm install` and both `pnpm --filter web deploy --prod` calls use `--prefer-offline`. Deploy slow AND store cold/absent → that's the real bottleneck, re-warm the store. Store warm and still slow → cost is the `astro build` step itself, not installs — check `/tmp/overdeck-deploy-build.log` timings before blaming pnpm.

The deploy clone is reused every run (`git fetch` + `git checkout --detach` on the SAME persistent clone, never re-cloned) — already cheap, no action needed here.

## Verify a change to deploy-local.sh itself

```bash
bash packaging/test-deploy-local.sh     # fixture suite, incl. fast-path skip/rebuild cases; must stay green
```
Never touch production (`~/.local/share/overdeck/deploy`, real systemd services) to test a script change — the fixture suite fakes git/pnpm/systemctl/curl for exactly this reason. For a change too large for the fixtures to exercise faithfully, clone the real repo into a scratch dir and point `OVERDECK_DEPLOY_DIR` at that instead — never the real deploy clone.

## Self-update — MANDATORY

This doc is the fleet's memory. You changed the architecture, wiring, commands, or
doctrine this skill describes — or a live incident just proved a rule here wrong or
missing → EDIT THIS FILE in the same landing as the change. Adopted AND rejected
decisions both go in, with the measured why. An owner reminder to record a lesson is
a failure of this rule, not the trigger for it.
