refactor: subcommand dispatch; server moves under 'custard serve'
094699372503b4714db97f06bd619793315bc79f
humdrum <me@humdrum.me> · 2026-06-18 07:54
parent fe6753f9
refactor: subcommand dispatch; server moves under 'custard serve' Prep for the deploy/release CLI (TASK-007). One binary, subcommands: - custard serve → the forge server (config.Load now parses a serve FlagSet) - check/preview/promote/release → stubbed (next) - internal/cli holds serve; deploy.sh ExecStart updated to 'custard serve' Zero behavior change to the running forge. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
6 files changed
- → Webhook-driven-auto-publish-to-the-Homebrew-tap.md +9 −9
@@ -1,10 +1,10 @@
---
id: TASK-006
title: Webhook-driven auto-publish to the Homebrew tap
-status: "\U0001F7E2 In progress"
+status: "\U0001F3C1 Done"
assignee: []
created_date: '2026-06-18 05:53'
-updated_date: '2026-06-18 05:59'
+updated_date: '2026-06-18 06:33'
labels:
- feature
dependencies: []
@@ -20,11 +20,11 @@ <!-- SECTION:DESCRIPTION:END -->
## Acceptance Criteria
<!-- AC:BEGIN -->
-- [ ] #1 Soft Serve branch_tag_create webhook (signed w/ secret) → custard endpoint POST /hooks/release
-- [ ] #2 Endpoint verifies the HMAC signature against a configured secret; rejects bad/unsigned
-- [ ] #3 Only semver tags (vX.Y.Z) trigger a release; non-semver/branch events ignored
-- [ ] #4 Per-repo opt-in: .custard.yaml at repo root (brew.enabled: true, optional package path); no file or disabled = skipped
-- [ ] #5 Release runs in custard (Go): archive tag → tarball to /dl → sha256 → render formula → commit to homebrew-tap bare repo
-- [ ] #6 Re-publishing same version overwrites cleanly; logged
-- [ ] #7 Docs: how to enable a repo + how to register the webhook (ssh soft repo webhook create)
+- [x] #1 Soft Serve branch_tag_create webhook (signed w/ secret) → custard endpoint POST /hooks/release
+- [x] #2 Endpoint verifies the HMAC signature against a configured secret; rejects bad/unsigned
+- [x] #3 Only semver tags (vX.Y.Z) trigger a release; non-semver/branch events ignored
+- [x] #4 Per-repo opt-in: .custard.yaml at repo root (brew.enabled: true, optional package path); no file or disabled = skipped
+- [x] #5 Release runs in custard (Go): archive tag → tarball to /dl → sha256 → render formula → commit to homebrew-tap bare repo
+- [x] #6 Re-publishing same version overwrites cleanly; logged
+- [x] #7 Docs: how to enable a repo + how to register the webhook (ssh soft repo webhook create)
<!-- AC:END -->
- → custard-CI-deploy-gate-checks-before-deploy.md +67 −0
@@ -0,0 +1,67 @@
+---
+id: TASK-007
+title: 'custard CLI: preview / promote / release (Charm spinner)'
+status: "\U0001F7E2 In progress"
+assignee: []
+created_date: '2026-06-18 06:36'
+updated_date: '2026-06-18 14:51'
+labels:
+ - feature
+dependencies: []
+priority: medium
+ordinal: 7000
+---
+
+## Description
+
+<!-- SECTION:DESCRIPTION:BEGIN -->
+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.
+<!-- SECTION:DESCRIPTION:END -->
+
+## Acceptance Criteria
+<!-- AC:BEGIN -->
+- [ ] #1 CLI reads .custard.yaml: ci check commands + deploy (preview/prod) config
+- [ ] #2 promote: vercel promote latest preview (or 'promote <url>') → 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
+<!-- AC:END -->
+
+## Implementation Notes
+
+<!-- SECTION:NOTES:BEGIN -->
+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 <url> (no rebuild)
+
+Verbs (one binary, 'custard <verb>'):
+ 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.
+<!-- SECTION:NOTES:END -->
cmd/custard/main.go +36 −35
@@ -1,49 +1,50 @@
-// Command custard is a read-only web code forge over bare git repositories.
+// Command custard is a read-only web code forge over bare git repositories,
+// plus a small client for deploying and releasing from those repos.
+//
+// custard serve run the forge server (the droplet runs this)
+// custard check run a repo's checks against the working tree
+// custard preview build HEAD, run checks, deploy a preview
+// custard promote promote the last preview to production
+// custard release tag + push a release (auto-publishes to the Homebrew tap)
package main
import (
- "context"
- "errors"
- "log"
- "net/http"
+ "fmt"
"os"
- "os/signal"
- "syscall"
- "time"
- "custard/internal/config"
- "custard/internal/server"
+ "custard/internal/cli"
)
func main() {
- cfg := config.Load()
- srv, err := server.New(cfg)
- if err != nil {
- log.Fatalf("init: %v", err)
+ if len(os.Args) < 2 {
+ usage()
+ os.Exit(2)
}
-
- httpSrv := &http.Server{
- Addr: cfg.ListenAddr,
- Handler: srv.Handler(),
- ReadHeaderTimeout: 10 * time.Second,
+ switch os.Args[1] {
+ case "serve":
+ cli.Serve(os.Args[2:])
+ case "check", "preview", "promote", "release":
+ fmt.Fprintf(os.Stderr, "custard %s: not implemented yet\n", os.Args[1])
+ os.Exit(1)
+ case "-h", "--help", "help":
+ usage()
+ default:
+ fmt.Fprintf(os.Stderr, "custard: unknown command %q\n\n", os.Args[1])
+ usage()
+ os.Exit(2)
}
+}
- // Serve until an interrupt, then drain in flight requests.
- go func() {
- log.Printf("custard listening on %s (repos: %s)", cfg.ListenAddr, cfg.ReposPath)
- if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
- log.Fatalf("server error: %v", err)
- }
- }()
+func usage() {
+ fmt.Fprint(os.Stderr, `custard — web code forge + deploy/release client
- stop := make(chan os.Signal, 1)
- signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
- <-stop
+usage:
+ custard serve [flags] run the forge server
+ custard check run checks on the working tree
+ custard preview build HEAD, check, deploy a preview
+ custard promote [url] promote the last preview to production
+ custard release vX.Y.Z tag + push a release to the Homebrew tap
- ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
- defer cancel()
- if err := httpSrv.Shutdown(ctx); err != nil {
- log.Printf("shutdown error: %v", err)
- }
- log.Println("custard stopped")
+run "custard serve -h" for server flags
+`)
}
deploy/deploy.sh +1 −1
@@ -60,7 +60,7 @@ [Service]
User=${RUN_USER}
Group=${RUN_USER}
EnvironmentFile=-/etc/custard.env
-ExecStart=/usr/local/bin/custard --repos ${REPOS_PATH} --addr 127.0.0.1:8080 --base-url https://${DOMAIN} --soft-serve-http https://${DOMAIN}/git --tap-repo ${TAP_REPO} --dl-path ${DL_PATH}${dbflag}
+ExecStart=/usr/local/bin/custard serve --repos ${REPOS_PATH} --addr 127.0.0.1:8080 --base-url https://${DOMAIN} --soft-serve-http https://${DOMAIN}/git --tap-repo ${TAP_REPO} --dl-path ${DL_PATH}${dbflag}
Restart=on-failure
RestartSec=2
NoNewPrivileges=true
internal/cli/serve.go +51 −0
@@ -0,0 +1,51 @@
+// Package cli implements custard's subcommands: the long-running forge server
+// (serve) and the local client verbs (check/preview/promote/release).
+package cli
+
+import (
+ "context"
+ "errors"
+ "log"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "custard/internal/config"
+ "custard/internal/server"
+)
+
+// Serve runs the forge HTTP server until interrupted, then drains in-flight
+// requests. This is what the droplet runs (via systemd).
+func Serve(args []string) {
+ cfg := config.Load(args)
+ srv, err := server.New(cfg)
+ if err != nil {
+ log.Fatalf("init: %v", err)
+ }
+
+ httpSrv := &http.Server{
+ Addr: cfg.ListenAddr,
+ Handler: srv.Handler(),
+ ReadHeaderTimeout: 10 * time.Second,
+ }
+
+ go func() {
+ log.Printf("custard listening on %s (repos: %s)", cfg.ListenAddr, cfg.ReposPath)
+ if err := httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Fatalf("server error: %v", err)
+ }
+ }()
+
+ stop := make(chan os.Signal, 1)
+ signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
+ <-stop
+
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if err := httpSrv.Shutdown(ctx); err != nil {
+ log.Printf("shutdown error: %v", err)
+ }
+ log.Println("custard stopped")
+}
internal/config/config.go +13 −11
@@ -18,18 +18,20 @@ DLPath string // directory where release tarballs are written (served at /dl)
TapRepo string // bare repo name of the Homebrew tap (default "homebrew-tap")
}
-// Load parses flags (with env fallbacks) and returns the config.
-func Load() Config {
+// Load parses the `serve` flags (with env fallbacks) from args and returns the
+// config. Pass the subcommand's arguments (os.Args[2:]).
+func Load(args []string) Config {
+ fs := flag.NewFlagSet("serve", flag.ExitOnError)
var c Config
- flag.StringVar(&c.ReposPath, "repos", env("REPOS_PATH", "./repos"), "directory of bare *.git repositories")
- flag.StringVar(&c.ListenAddr, "addr", env("LISTEN_ADDR", ":8080"), "listen address")
- flag.StringVar(&c.BaseURL, "base-url", env("BASE_URL", "http://localhost:8080"), "public base URL")
- flag.StringVar(&c.SoftServeHTTP, "soft-serve-http", env("SOFT_SERVE_HTTP", "http://localhost:23232"), "git clone base shown in the UI footer (e.g. https://your-host/git)")
- flag.StringVar(&c.SoftServeDB, "soft-serve-db", env("SOFT_SERVE_DB", ""), "path to soft-serve.db; when set, only public (non-private, non-hidden) repos are served")
- flag.StringVar(&c.WebhookSecret, "webhook-secret", env("WEBHOOK_SECRET", ""), "shared secret for the Soft Serve release webhook (empty disables /hooks/release)")
- flag.StringVar(&c.DLPath, "dl-path", env("DL_PATH", "/var/lib/custard/dl"), "directory where release tarballs are written")
- flag.StringVar(&c.TapRepo, "tap-repo", env("TAP_REPO", "homebrew-tap"), "bare repo name of the Homebrew tap")
- flag.Parse()
+ fs.StringVar(&c.ReposPath, "repos", env("REPOS_PATH", "./repos"), "directory of bare *.git repositories")
+ fs.StringVar(&c.ListenAddr, "addr", env("LISTEN_ADDR", ":8080"), "listen address")
+ fs.StringVar(&c.BaseURL, "base-url", env("BASE_URL", "http://localhost:8080"), "public base URL")
+ fs.StringVar(&c.SoftServeHTTP, "soft-serve-http", env("SOFT_SERVE_HTTP", "http://localhost:23232"), "git clone base shown in the UI footer (e.g. https://your-host/git)")
+ fs.StringVar(&c.SoftServeDB, "soft-serve-db", env("SOFT_SERVE_DB", ""), "path to soft-serve.db; when set, only public (non-private, non-hidden) repos are served")
+ fs.StringVar(&c.WebhookSecret, "webhook-secret", env("WEBHOOK_SECRET", ""), "shared secret for the Soft Serve release webhook (empty disables /hooks/release)")
+ fs.StringVar(&c.DLPath, "dl-path", env("DL_PATH", "/var/lib/custard/dl"), "directory where release tarballs are written")
+ fs.StringVar(&c.TapRepo, "tap-repo", env("TAP_REPO", "homebrew-tap"), "bare repo name of the Homebrew tap")
+ _ = fs.Parse(args)
return c
}