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.