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 other services deploy.
  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 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 (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. This runs inside the loop rather than at the end, so a failure partway through still records what shipped. The row carries Render's deploy id, which links 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 lives 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. Reusing it works for an operator at a terminal, but CI should hold a service token instead.

The lookup happens up front with the other credentials. A missing token stops the deploy before it starts, rather than after a service ships 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 reaches live. A failed release only warns and prints the retry command, because the deploys already succeeded.

Design

The script uses origin/main. This allows it to run from any git worktree and ignores unpushed local main commits.

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

app-jobs is the migration gate. Waiting for it to reach live confirms that its pre-deploy db migration step finished before other services deploy. 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 tagging the Sentry release.

Each service deploys independently with a y/n prompt, so you can skip a service with unrelated changes or deploy incrementally.

← All articles