---
name: od-deploy
description: How and when to run packaging/deploy-local.sh — decision ladder, the deploy lock, deploy clone vs dev checkout, the systray dev-checkout gotcha, and the web-build fast path. Use before running deploy-local.sh, 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.

## Decision ladder — before running

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` (or let `ship.sh` postlandcmd run it). Idempotent, fail-closed — safe to re-run after any failure.
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

Contended deploys enqueue under `~/.local/share/overdeck/deploy-queue` and coalesce into next successful `origin/main` deploy. Callers need no changes. 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 drain when queue remains non-empty.

## 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.
