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

Deploy the pushed non-empty commit; harden deploy URL parsing

39c8f278d42a1398e8130d8dba1dc459b1276025
humdrum <me@humdrum.me> · 2026-06-28 12:47

parent 8cd8056f

Deploy the pushed non-empty commit; harden deploy URL parsing

Build the newest non-empty, pushed commit (was: local HEAD via git archive). Skips jj's empty working-copy commit that sits on top after a push, and keeps deployed == in-repo. Replace lastURL with deployURL: strips punctuation and ignores Vercel meta hosts (api.vercel.com, dashboard/inspect, sso-api) so the real *.vercel.app URL is surfaced. Tag release at the resolved commit. Add deployURL tests.

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

4 files changed

README.md +17 −8
@@ -156,23 +156,32 @@ ci:
   - go vet ./...
   - go test ./...
 deploy:
-  preview: vercel deploy              # any command that prints a URL (vercel/netlify/fly/rsync…)
-  promote: vercel promote {{url}}     # {{url}} = the preview; defaults to vercel promote
+  # any command that prints a deployment URL (vercel/netlify/fly/rsync…).
+  # Vercel needs its project pinned — see the note below.
+  preview: VERCEL_ORG_ID=… VERCEL_PROJECT_ID=… vercel deploy --yes
+  promote: VERCEL_ORG_ID=… VERCEL_PROJECT_ID=… vercel promote {{url}} --yes
 ```
 
 ```bash
 custard check               # run ci on the working tree (fast inner loop)
-custard preview             # export HEAD, run ci, deploy a preview → URL
+custard preview             # export pushed commit, run ci, deploy a preview → URL
 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 a clean `git archive` of the **newest non-empty, pushed commit** — not your working
+tree — so deployed == what's in the repo. (Under **jj**, the empty working-copy commit on top after a
+push is skipped automatically.) Because the archive holds only committed, tracked files, **gitignored
+and uncommitted files are absent**:
+
+> - If `ci` runs **`npm ci`**, commit your **`package-lock.json`** (a gitignored lockfile fails in the
+>   export). `node_modules`/`.env`/build artifacts are gone too — `ci` must regenerate them.
+> - **Vercel:** `.vercel/project.json` (gitignored) isn't present, so 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`). custard warns on an unpinned Vercel deploy and surfaces
+>   the real `*.vercel.app` URL. Previews may sit behind Deployment Protection (team-login); prod is public.
 
-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
+`preview`/`promote` report signed status back to the forge (`~/.custardrc` or
 `CUSTARD_URL`/`CUSTARD_TOKEN`), which badges commits **✓ in production / 👁 preview / ⚠ unverified**.
 
 ## Docs
docs/MANUAL.md +27 −9
@@ -62,21 +62,39 @@ ```yaml
 ci:
   - npm test
 deploy:
-  preview: vercel deploy            # any command that prints a URL (netlify/fly/rsync work too)
-  promote: vercel promote {{url}}   # {{url}} = the preview; default is vercel
+  # any command that prints a deployment URL (netlify/fly/rsync work too).
+  # Vercel needs its project pinned — see "What's in the build" below.
+  preview: VERCEL_ORG_ID=… VERCEL_PROJECT_ID=… vercel deploy --yes
+  promote: VERCEL_ORG_ID=… VERCEL_PROJECT_ID=… vercel promote {{url}} --yes
 ```
 ```sh
 custard check     # run ci on the working tree (fast loop)
-custard preview   # build HEAD → run ci → deploy preview → URL
+custard preview   # build pushed commit → run ci → deploy preview → URL
 custard promote   # promote the last preview → production
 ```
-Deploys build the **committed HEAD** (uncommitted edits never ship); HEAD must be **pushed**. The CLI
-never runs `git push` — you push however you like.
+### What's in the build
+
+custard deploys the **newest non-empty, pushed commit** — a clean `git archive` of it, **not** your
+working tree. Two consequences:
 
-> **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.
+- **Pushed only.** custard never runs `git push`; it deploys what's on the remote, so deployed == what's
+  in the repo. Push first, then `preview`/`promote`. (Under **jj**, the working copy is an empty commit
+  that normally sits on top after a push — custard skips it and deploys the described commit beneath, so
+  you never ship an empty tree.)
+- **Gitignored + uncommitted files are ABSENT.** The archive contains only committed, tracked files.
+  So:
+  - If `ci` uses **`npm ci`**, the `package-lock.json` **must be committed** (a gitignored lockfile →
+    `npm ci` fails in the export). `node_modules`, `.env`, and build artifacts are likewise gone — your
+    `ci` must regenerate whatever it needs.
+  - **Vercel:** `.vercel/project.json` (gitignored) isn't present, so a bare `vercel deploy` creates a
+    stray new project. Pin the target with `VERCEL_ORG_ID=… VERCEL_PROJECT_ID=… vercel deploy --yes`
+    (IDs aren't secrets; auth still comes from your local `vercel login`). custard warns on an unpinned
+    Vercel deploy, and surfaces the real `*.vercel.app` URL even though Vercel also prints API/inspect
+    links.
+
+> **Preview privacy:** Vercel previews are often behind **Deployment Protection** — the preview URL
+> redirects to an SSO login and is viewable only by your team. Production (your custom domain) is public.
+> Toggle it in Project → Settings → Deployment Protection.
 
 ## Release a CLI to Homebrew
 
internal/cli/verbs.go +56 −23
@@ -48,11 +48,10 @@
 // Preview builds an export of HEAD, runs checks against it, and deploys a preview.
 func Preview(args []string) {
 	cfg := mustLoad()
-	requirePushed()
-	commit := gitOut("rev-parse", "HEAD")
+	commit := deployCommit()
 	fmt.Println(headStyle.Render("▍ preview") + dimStyle.Render("  "+shortCommit(commit)))
 
-	dir := exportHEAD()
+	dir := exportCommit(commit)
 	defer os.RemoveAll(dir)
 	runChecks(cfg.CI, dir)
 
@@ -61,7 +60,7 @@ 		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)
+	url := deployURL(out)
 	if url == "" {
 		fail("deploy succeeded but no URL found in output")
 	}
@@ -91,7 +90,7 @@ 	}
 	cmd = strings.ReplaceAll(cmd, "{{url}}", url)
 	fmt.Println(headStyle.Render("▍ promote") + dimStyle.Render("  "+url))
 	out := step("promoting to production", func() (string, error) { return sh(".", cmd) })
-	prodURL := lastURL(out)
+	prodURL := deployURL(out)
 	if prodURL == "" {
 		prodURL = url
 	}
@@ -157,14 +156,14 @@ 	if !release.IsReleaseTag(ver) {
 		fail("version must be semver vX.Y.Z (got %q)", ver)
 	}
 	cfg := mustLoad()
-	requirePushed()
-	fmt.Println(headStyle.Render("▍ release " + ver))
+	commit := deployCommit()
+	fmt.Println(headStyle.Render("▍ release "+ver) + dimStyle.Render("  "+shortCommit(commit)))
 
-	dir := exportHEAD()
+	dir := exportCommit(commit)
 	defer os.RemoveAll(dir)
 	runChecks(cfg.CI, dir)
 
-	step("tagging "+ver, func() (string, error) { return sh(".", "git tag -a "+ver+" -m "+ver) })
+	step("tagging "+ver, func() (string, error) { return sh(".", "git tag -a "+ver+" -m "+ver+" "+commit) })
 	step("pushing tag", func() (string, error) { return sh(".", "git push origin "+ver) })
 	fmt.Println(okStyle.Render("✓ released " + ver))
 	fmt.Println(dimStyle.Render("  tag pushed — the tap updates automatically (brew upgrade shortly)"))
@@ -275,37 +274,71 @@ 	}
 	return c
 }
 
-// requirePushed ensures HEAD exists on a remote (so deployed == in-repo).
-func requirePushed() {
-	out, _ := exec.Command("git", "branch", "-r", "--contains", "HEAD").Output()
+// deployCommit resolves the commit to build and deploy: the newest non-empty
+// ancestor of HEAD that exists on a remote. Deploying the PUSHED commit keeps
+// "deployed == what's in the repo"; skipping empty commits sidesteps jj, whose
+// undescribed working-copy commit normally sits on top of HEAD after a push —
+// archiving that would ship an empty tree.
+func deployCommit() string {
+	c := gitOut("rev-parse", "HEAD")
+	for isEmptyCommit(c) {
+		parent, err := exec.Command("git", "rev-parse", c+"^").Output()
+		if err != nil {
+			fail("no non-empty commit at or below HEAD to deploy — commit your work first")
+		}
+		c = strings.TrimSpace(string(parent))
+	}
+	out, _ := exec.Command("git", "branch", "-r", "--contains", c).Output()
 	if !strings.Contains(string(out), "/") {
-		fail("HEAD isn't pushed — push to soft first, then preview")
+		fail("commit %s isn't pushed — push to the remote first, then deploy", shortCommit(c))
+	}
+	return c
+}
+
+// isEmptyCommit reports whether a commit has no diff against its parent (an
+// empty, usually undescribed, commit — e.g. jj's working-copy commit). A root
+// commit (no parent) is never treated as empty.
+func isEmptyCommit(c string) bool {
+	if err := exec.Command("git", "rev-parse", "--verify", "-q", c+"^").Run(); err != nil {
+		return false
 	}
+	// `git diff --quiet` exits 0 when the two trees are identical.
+	return exec.Command("git", "diff", "--quiet", c+"^", c).Run() == nil
 }
 
-// exportHEAD writes a clean checkout of HEAD to a temp dir (uncommitted edits excluded).
-func exportHEAD() string {
+// exportCommit writes a clean checkout of a commit to a temp dir (uncommitted
+// edits and gitignored files are excluded — it's a pure `git archive`).
+func exportCommit(commit string) string {
 	dir, err := os.MkdirTemp("", "custard-build-")
 	if err != nil {
 		fail("temp dir: %v", err)
 	}
-	cmd := exec.Command("sh", "-c", "git archive HEAD | tar -x -C "+dir)
+	cmd := exec.Command("sh", "-c", "git archive "+commit+" | tar -x -C "+dir)
 	if out, err := cmd.CombinedOutput(); err != nil {
 		os.RemoveAll(dir)
-		fail("export HEAD: %v: %s", err, out)
+		fail("export %s: %v: %s", shortCommit(commit), err, out)
 	}
 	return dir
 }
 
-var urlRe = regexp.MustCompile(`https?://[^\s]+`)
+var urlRe = regexp.MustCompile(`https?://[^\s"',)]+`)
 
-// lastURL returns the last http(s) URL in the output (vercel prints it last).
-func lastURL(out string) string {
+// deployURL returns the public deployment URL from command output. It strips
+// surrounding punctuation and ignores Vercel meta hosts (api.vercel.com, the
+// dashboard/inspect URL, and the sso-api auth redirect — all on vercel.com) so
+// the real *.vercel.app (or custom-domain) URL is surfaced, not an API or
+// inspect link. Non-Vercel tools are unaffected: the last URL is returned.
+func deployURL(out string) string {
 	m := urlRe.FindAllString(out, -1)
-	if len(m) == 0 {
-		return ""
+	var last string
+	for _, u := range m {
+		u = strings.Trim(u, `."',)`)
+		if strings.Contains(u, "vercel.com") {
+			continue
+		}
+		last = u
 	}
-	return strings.TrimRight(m[len(m)-1], ".,)")
+	return last
 }
 
 func repoName() string {
internal/cli/verbs_test.go +54 −0
@@ -0,0 +1,54 @@
+package cli
+
+import "testing"
+
+func TestDeployURL(t *testing.T) {
+	cases := []struct {
+		name string
+		out  string
+		want string
+	}{
+		{
+			name: "vercel json picks vercel.app not api",
+			out: `Deploying humdrumone/guilds
+  Inspect    https://vercel.com/humdrumone/guilds/GybubpArrMaKQALBaSct613mdJGq
+  Preview    https://guilds-ivafhzmbb-humdrumone.vercel.app
+{
+  "deployment": {
+    "url": "https://guilds-ivafhzmbb-humdrumone.vercel.app",
+    "inspectorUrl": "https://vercel.com/humdrumone/guilds/GybubpArrMaKQALBaSct613mdJGq",
+    "deploymentApiUrl": "https://api.vercel.com/v13/deployments/dpl_GybubpArrMaKQALBaSct613mdJGq"
+  }
+}`,
+			want: "https://guilds-ivafhzmbb-humdrumone.vercel.app",
+		},
+		{
+			name: "trailing quote and comma stripped",
+			out:  `{"url": "https://example.vercel.app",}`,
+			want: "https://example.vercel.app",
+		},
+		{
+			name: "non-vercel tool returns last url",
+			out:  "Deployed to https://my-site.netlify.app",
+			want: "https://my-site.netlify.app",
+		},
+		{
+			name: "custom domain on vercel is kept",
+			out: `  Inspect  https://vercel.com/humdrumone/guilds/abc
+Success! https://guilds.quest`,
+			want: "https://guilds.quest",
+		},
+		{
+			name: "no url",
+			out:  "nothing here",
+			want: "",
+		},
+	}
+	for _, c := range cases {
+		t.Run(c.name, func(t *testing.T) {
+			if got := deployURL(c.out); got != c.want {
+				t.Errorf("deployURL() = %q, want %q", got, c.want)
+			}
+		})
+	}
+}