▍ humdrum codex / custard v0.3.0
license AGPL-3.0

Warn + document: pin Vercel project IDs for clean-export deploys

8cd8056ffe3e18611df1548647e725648169fb16
humdrum <me@humdrum.me> · 2026-06-28 11:13

parent 791e1a74

Warn + document: pin Vercel project IDs for clean-export deploys

custard deploys a 'git archive HEAD' export with no .vercel/project.json (gitignored), so a bare 'vercel deploy' creates a stray project named after the temp dir. Add a warning in 'custard preview' when it detects an unpinned Vercel deploy, and fix README/MANUAL/MIGRATION to pin VERCEL_ORG_ID/VERCEL_PROJECT_ID.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

4 files changed

README.md +4 −0
@@ -167,6 +167,10 @@ custard promote             # promote the last preview → production
 custard release v0.2.0      # run ci, tag + push → tap auto-publishes
 ```
 
+> **Vercel deploys:** the deploy runs against a clean `git archive HEAD` export, so `.vercel/project.json`
+> (gitignored) isn't there — a bare `vercel deploy` makes a stray project. Pin it:
+> `VERCEL_ORG_ID=… VERCEL_PROJECT_ID=… vercel deploy --yes` (IDs aren't secrets; auth is your local `vercel login`).
+
 Deploys **build from the committed HEAD** (uncommitted edits are never deployed) and require HEAD
 pushed. `preview`/`promote` report signed status back to the forge (`~/.custardrc` or
 `CUSTARD_URL`/`CUSTARD_TOKEN`), which badges commits **✓ in production / 👁 preview / ⚠ unverified**.
docs/MANUAL.md +5 −0
@@ -73,6 +73,11 @@ ```
 Deploys build the **committed HEAD** (uncommitted edits never ship); HEAD must be **pushed**. The CLI
 never runs `git push` — you push however you like.
 
+> **Vercel:** because the deploy runs against a clean `git archive HEAD` export, `.vercel/project.json`
+> (gitignored) isn't present — a bare `vercel deploy` would create a stray new project. Pin the target
+> with `VERCEL_ORG_ID=… VERCEL_PROJECT_ID=… vercel deploy --yes` (IDs are not secrets; auth comes from
+> your local `vercel login`). custard prints a warning if it spots an unpinned Vercel deploy.
+
 ## Release a CLI to Homebrew
 
 `.custard.yaml`:
docs/MIGRATION.md +8 −4
@@ -62,14 +62,18 @@
 Vercel's **git integration supports only GitHub/GitLab/Bitbucket**, so a Soft-hosted repo can't
 auto-deploy via Vercel's hook. Keep Vercel as the host, deploy from the **CLI**:
 
-1. `vercel link` the repo to the existing Vercel project (keeps the prod URL).
+1. `vercel link` the repo to the existing Vercel project (keeps the prod URL), then note the
+   `orgId` + `projectId` it writes to `.vercel/project.json`.
 2. In Vercel's dashboard, **disconnect the GitHub integration** (stop it deploying the old repo).
-3. `.custard.yaml`:
+3. `.custard.yaml` — **pin the project with env IDs.** custard deploys a clean `git archive HEAD`
+   export, so `.vercel/project.json` (gitignored) is not present; without the IDs the CLI creates a
+   stray new project. The IDs are identifiers, not secrets — safe to commit. Auth still comes from
+   your local `vercel login`.
    ```yaml
    ci: ["pnpm test", "pnpm build"]
    deploy:
-     preview: vercel deploy
-     promote: vercel promote {{url}}
+     preview: VERCEL_ORG_ID=team_xxx VERCEL_PROJECT_ID=prj_xxx vercel deploy --yes
+     promote: VERCEL_ORG_ID=team_xxx VERCEL_PROJECT_ID=prj_xxx vercel promote {{url}} --yes
    ```
 4. Push to Soft → `custard preview` (build HEAD, run ci, preview URL) → `custard promote` (same build → prod).
 
internal/cli/verbs.go +23 −0
@@ -25,6 +25,7 @@ 	badStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("1")).Bold(true)
 	dimStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
 	urlStyle  = lipgloss.NewStyle().Foreground(lipgloss.Color("6")).Underline(true)
 	headStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5")).Bold(true)
+	warnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3")).Bold(true)
 )
 
 // fail prints a styled error and exits non-zero.
@@ -58,6 +59,7 @@
 	if cfg.Deploy.Preview == "" {
 		fail("no `deploy.preview` command in .custard.yaml")
 	}
+	warnVercelUnlinked(dir, cfg.Deploy.Preview)
 	out := step("deploying preview", func() (string, error) { return sh(dir, cfg.Deploy.Preview) })
 	url := lastURL(out)
 	if url == "" {
@@ -227,6 +229,27 @@ }
 
 func indent(s string) string {
 	return "  " + strings.ReplaceAll(s, "\n", "\n  ")
+}
+
+// warnVercelUnlinked flags the common footgun: `vercel deploy` run against the
+// clean `git archive HEAD` export has no .vercel/project.json (it's gitignored,
+// so it never lands in the archive). Without it the Vercel CLI creates a STRAY
+// new project named after the temp dir instead of deploying to the linked one.
+// The fix is to pin VERCEL_ORG_ID / VERCEL_PROJECT_ID in the deploy command.
+func warnVercelUnlinked(dir, cmd string) {
+	if !strings.Contains(cmd, "vercel") {
+		return // not a Vercel deploy; nothing to check
+	}
+	if strings.Contains(cmd, "VERCEL_PROJECT_ID") || strings.Contains(cmd, "--project") {
+		return // already pinned
+	}
+	if _, err := os.Stat(filepath.Join(dir, ".vercel", "project.json")); err == nil {
+		return // export somehow carries the link; fine
+	}
+	fmt.Println(warnStyle.Render("⚠ vercel deploy has no project link") +
+		dimStyle.Render(" — the clean HEAD export omits .vercel/project.json"))
+	fmt.Println(dimStyle.Render("  Vercel will create a STRAY project. Pin it in .custard.yaml deploy.preview:"))
+	fmt.Println(dimStyle.Render("    VERCEL_ORG_ID=… VERCEL_PROJECT_ID=… vercel deploy --yes"))
 }
 
 // sh runs a shell command line in dir and returns combined output.