Description
A Charm-style CLI that owns DEPLOY + RELEASE — not git push (you keep committing/pushing via lazygit/Claude; deploy is decoupled since 'vercel deploy' uploads the local dir, not the remote). Foreground + inline spinner per stage, so no notification system needed. Reads .custard.yaml for check + deploy config. Verbs: (1) preview — run checks, then vercel deploy (preview), print URL; (2) promote — vercel promote the latest/chosen preview to prod (same build, no rebuild); (3) release vX.Y.Z — run checks, tag + push the tag so the existing brew webhook auto-publishes. Supersedes the earlier server-side-CI sketch in this task's ACs.
Acceptance Criteria
- #1 CLI reads .custard.yaml: ci check commands + deploy (preview/prod) config
- #2 promote: vercel promote latest preview (or 'promote ') → prod, no rebuild
- #3 release vX.Y.Z: run checks, then tag + push tag → existing brew webhook publishes
- #4 Charm UI (Bubble Tea/Lipgloss spinner); distributed via the self-hosted tap (dogfood)
- #5 Does NOT perform git push — lazygit/Claude workflow unaffected
- #6 Multiple previews before promote supported; promote defaults to most recent
- #7 check verb: runs .custard.yaml checks on the working tree (dirty allowed) — fast inner loop, no deploy
- #8 Status reporting: CLI POSTs signed {repo, commit, state(checked|preview|prod), url} to custard
- #9 Forge badges: mark commits ✓ in production / 👁 preview / ✓ checked / ⚠ unverified so the live forge shows what's real vs in-flight
- #10 preview: build a clean export of HEAD (git archive/worktree) → run checks → vercel deploy (preview) → print URL; spinner per stage, ✗ + captured output on failure
- #11 Deploys build from the COMMIT, never the working dir — uncommitted edits are structurally excluded (never deployed), and a dirty tree does not block deploying committed work
- #12 preview/promote require HEAD pushed to soft (deployed code == an in-repo commit the forge badge maps to); if unpushed, CLI says push first
- #13 preview/release run checks against the EXPORTED HEAD (committed code); standalone check runs against the working tree — distinct by design
- #14 Single 'custard' binary: 'serve' runs the server (droplet), client verbs (check/preview/promote/release) run locally; refactor cmd/custard into subcommands
- #15 CLI config via ~/.custardrc (custard base URL + status token); vercel auth uses local vercel login
- #16 Server: POST /status (HMAC-signed) persists {repo,commit,state,url} to a writable store (e.g. /var/lib/custard/state); systemd ReadWritePaths includes it
- #17 promote tracks the last preview (local state, e.g. ~/.custard/state.json) or accepts an explicit url; errors clearly if no preview exists
Implementation Notes
Design detail / schema:
.custard.yaml (per repo): brew: { enabled: true, package: "." } # TASK-006 (release) ci: # commands run in order; nonzero exit = fail - go vet ./... - go test ./... deploy: preview: vercel deploy # must print the preview URL on stdout # promote uses: vercel promote (no rebuild)
Verbs (one binary, 'custard '): check run ci on WORKING TREE (dirty ok); no deploy; fast inner loop preview export HEAD (git archive→tmp) → run ci on export → deploy.preview → capture+print URL → POST /status(preview) promote [url] vercel promote last/given preview → prod → POST /status(prod) release vX.Y.Z run ci on HEAD → tag + push tag (TASK-006 webhook publishes brew) → POST /status
Gates: preview/promote do NOT block on a dirty tree (they build from the commit, so uncommitted edits are excluded); they DO require HEAD pushed to soft so deployed==in-repo and the badge maps. check has no gate.
Server additions: POST /status (HMAC, STATUS_SECRET in /etc/custard.env) → persist per (repo,commit); forge reads it to badge commits/branch as: ✓ in production / 👁 preview / ✓ checked / ⚠ unverified. State store writable (ReadWritePaths).
Distribution: CLI ships via the self-hosted tap (custard formula) — dogfoods TASK-006. Open: status-store backend (flat json vs sqlite); whether release should also require clean tree.