cmd / deploy

deploy is a Go CLI that deploys services to Render from the latest origin/main.

Run from any branch or git worktree:

go run ./cmd/deploy

It requires these environment variables (loaded from .env):

The cibot token comes from git instead, as Recording the deploy describes.

Flow

  1. Fetch origin/main to get the latest commit merged by cibot.
  2. Prompt to check Render status for incidents.
  3. For each service, compare the live deploy commit to origin/main. If there are new commits, show the log and prompt to deploy.
  4. Deploy selected services at origin/main.
  5. Wait for app-jobs to reach live so db migrations complete before deploying other services.
  6. Record each service in cibot as it goes live.
  7. If anything deployed, create and tag a Sentry release.

Example session:

$ go run ./cmd/deploy
From https://cibot.example.com/git/app
 * branch            main       -> FETCH_HEAD
Check https://status.render.com/ for incidents before continuing.
Press any key to continue or ctrl+c to exit...

```
a1b2c3d4e fix session expiry on token refresh
f5e6d7c8b add retry logic to webhook delivery
```

deploy app-jobs? (y/n) y
deploying app-jobs...
waiting for app-jobs deploy dep-abc123 to go live...
app-jobs live

skipping app-web, already up to date...

Services

The script deploys multiple Render services:

Render API client

The render package (render/client.go) wraps these endpoints:

WaitForDeploy polls every 10s with a 30 minute timeout. Any terminal state other than live (build_failed, update_failed, pre_deploy_failed, deactivated, canceled) is an error. A failed migration surfaces as pre_deploy_failed.

Recording the deploy

Each service POSTs a row to cibot as it reaches live, inside the loop rather than once at the end, so a failure partway through still records what shipped. The row carries Render's deploy id, which is the way back to its console.

Render's service names carry the environment (app-production). cibot keeps service and environment in separate columns, so the script translates: (app-web, production), (app-jobs, production). Renaming the Render services would mean moving DNS, since a custom domain is a CNAME to <service>.onrender.com. Translating at the call site costs one line and no downtime.

The script holds no cibot token. cibot validates the same personal token for its API and its git transport, so the script asks git for the one cibot git setup already stored for the farmer's host:

in := "protocol=https\nhost=cibot.example.com\n\n"
c := exec.Command("git", "credential", "fill")
c.Env = append(os.Environ(), "GIT_TERMINAL_PROMPT=0")

One credential, in the Keychain rather than a dotfile, rotated in one place. cibot attributes the deploy to the token holder, so the row names the person who ran it. That token also pushes and merges, which is the trade for reusing it: good for an operator at a terminal, wrong for CI, which should hold a service token instead.

The lookup happens up front, with the other credentials. A token git does not hold should stop the deploy before it starts, not after something has shipped unrecorded.

Sentry release (API, not CLI)

After deploying, the script calls Sentry's API:

  1. Create release (version = short SHA, what the UI shows)
  2. Set release refs (repository + full SHA, so the GitHub integration can pull commit metadata)
  3. Record deploy for production

This connects Sentry errors to the deploy that introduced them.

The three calls are idempotent, and the release runs last, after every deploy is live. A failed release only warns and prints the retry command: the deploys already succeeded.

Design

The script uses origin/main. This makes it work from any git worktree and ignores local main commits that haven't been pushed yet.

origin is cibot's self-hosted git, which is where a change merges. Render builds from the GitHub mirror the merge pushes to, so both read the same commit.

app-jobs is the migration gate: waiting for it to reach live confirms its pre-deploy db migration step finished before other services. Skipping it prints a warning that later services may run against an old schema.

Service order matters, and app-web waits too, so a failed web deploy aborts before the Sentry release is tagged.

Each service is deployed independently with a y/n prompt, so you can skip a service if it has unrelated changes or you want to deploy incrementally.

← All articles