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 reads these environment variables from .env:

The cibot token comes from git (see Recording the deploy).

Flow

  1. Fetch origin/main, the latest commit merged by cibot.
  2. Prompt to check Render status for incidents.
  3. For each service, compare the live commit to origin/main. If there are new commits, show the log and prompt to deploy.
  4. Deploy the selected services at origin/main.
  5. Wait for app-jobs to reach live so db migrations finish before other services deploy.
  6. Record each service in cibot as it goes live.
  7. If anything deployed, create 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 these 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 is an error. A failed migration appears as pre_deploy_failed.

Recording the deploy

The script POSTs a row to cibot as each service reaches live, so a failure partway through still records what shipped. The row carries Render's deploy id.

Render's service names carry the environment (app-production), and cibot keeps the two in separate columns, so the script translates at the call site.

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 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 lives in the Keychain, and cibot names the token holder as the deployer. That token also pushes and merges, so CI should hold a service token.

The script looks the token up first, so a missing token stops the deploy before a service ships unrecorded.

Sentry release

After the deploys, the script calls Sentry's API:

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

This connects a Sentry error to the deploy that introduced it.

The three calls are idempotent and run after every deploy reaches live. A failed release warns and prints the retry command.

Design

The script reads origin/main, so it runs from any git worktree and ignores unpushed local commits. origin is cibot's self-hosted git, and Render builds from the GitHub mirror, so both read the same commit.

app-jobs is the migration gate. The script waits for it to reach live so its pre-deploy migration finishes before other services deploy. If I skip it, the script warns that later services may run against an old schema.

app-web waits too, so a failed web deploy aborts before the Sentry release. Each service has its own y/n prompt, so I can skip one.

← All articles