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)
+ }
+ })
+ }
+}