# Deploy

One call from "I have a repo" to "it's live on `<name>.h4a.site`".

```
deploy(name="overstap", repo="github.com/michielb/overstap")
```

The platform clones the repo, inspects it, picks a tier, builds (if needed), and publishes. **The agent does not declare the tier** — repo contents dictate the answer.

## Request

| Field          | Type   | Required | Default   | Notes |
|---             |---     |---       |---        |---    |
| `name`         | string | yes      |             | DNS-safe `[a-z0-9][a-z0-9-]{0,62}`. Becomes the subdomain `<name>.h4a.site`. |
| `repo`         | string | yes      |             | Public HTTPS git URL. `github.com/a/b` is accepted and rewritten to `https://github.com/a/b`. `ssh://…` / `git@…` is rejected — private repos arrive in wave B. |
| `tenant`       | string | no       | `default`   | Tenant scope. |
| `ref`          | string | no       | `HEAD`      | Branch, tag, or commit SHA. |
| `strategy`     | enum   | no       | `blue-green`| `blue-green` (MB-430): build a new VM, flip DNS, reap the old VM. `in-place`: escape hatch — no-op on a running workload. |
| `health_check` | object | no       | see below   | Controls the post-boot probe used to gate the blue→green flip. See [repo-config.md](./repo-config.md) for the fields. |
| `size`         | enum   | no       | `small`     | `nano`/`small`/`medium`/`large`. Ignored for static (no VM). |

## Response

```json
{
  "name":               "overstap",
  "tenant":             "default",
  "tier":               "static",
  "reason":             "package.json build → static output (node)",
  "url":                "https://overstap.h4a.site",
  "deploy_id":          "01KPG06RQ5KRJR2KZGMW8RXZP6",
  "status":             "running",
  "strategy":           "blue-green",
  "previous_deploy_id": "01KPG06RN06X8YX6KGAV6G3FN1",
  "build_log":          "\n$ npm ci --no-fund --no-audit\nadded 198 packages in 5s\n\n$ npm run build\n..."
}
```

`deploy_id` is a 26-char time-sortable identifier (ULID-shaped; Crockford base32). `previous_deploy_id` is set only on a blue-green swap and points at the deploy that was retired as part of the flip — it is the default rollback target once MB-431 lands.

On build failure the response has the same shape with `status: "failed"`, an empty `url`, and the full failure output in `build_log`. HTTP status is 400 but the body is still a DeployResponse — parse it; the build log is the whole point.

## Tier detection

Rules are evaluated in this order; first match wins.

| # | Signal                                         | Tier    | Reason string                                                                                 |
|---|---                                             |---      |---                                                                                            |
| 1 | `.h4a.yaml` present                            | from yaml| `declared in .h4a.yaml`                                                                       |
| 2 | `Dockerfile` present                           | dynamic | `contains Dockerfile`                                                                         |
| 3 | `package.json` with `scripts.start`            | dynamic | `package.json declares 'start' script`                                                        |
| 4 | `package.json` with `scripts.build` (no start) | static  | `package.json build → static output (node)`                                                   |
| 5 | `Procfile` present                             | dynamic | `contains Procfile`                                                                           |
| 6 | `main.go` or `go.mod`                          | dynamic | `Go entrypoint detected`                                                                      |
| 7 | All files are static assets                    | static  | `only static assets detected`                                                                 |
| 8 | Fallback                                       | static  | `ambiguous payload; defaulted to static. Add a Dockerfile to force dynamic, or a package.json with a 'start' script.` |

### Node build output directory

When rule 4 fires, the builder uploads one of:

- **`out/`** if `next.config.{js,mjs,ts}` is present (Next.js static export)
- **`build/`** if `public/index.html` exists and there's no `vite.config.*` (Create React App)
- **`dist/`** otherwise (Vite, default)

### `.h4a.yaml` override

Drop this in the repo root to force a decision:

```yaml
tier: static          # static | dynamic
build_kind: node      # plain | node | docker   (optional; defaults per tier)
build_dir: dist       # directory to upload for static-node builds
```

Useful when a repo has a Dockerfile used only for CI but should ship as static, or when the auto-detector guesses wrong.

## Availability

| Tier    | Status                                                                                          |
|---      |---                                                                                              |
| static  | **live** — `plain` and `node` build kinds both work. Bunny deploys atomically; `strategy` is effectively always blue-green. |
| dynamic | **live** — Hetzner VM + Docker cloud-init. `blue-green` is the default: a second deploy spawns a new VM, health-checks it, flips DNS, and reaps the old VM. `in-place` is the escape hatch for a no-op re-deploy. |

## Limits

- **Repo size**: 100 MB post-clone. Larger repos fail with `repo exceeds 104857600 bytes`.
- **Build timeout**: 5 minutes.
- **Build runtime**: Node 22 LTS (on the control plane host).
- **Region**: Bunny Storage zone in Frankfurt (`DE`), served from Bunny's global CDN. All EU-sovereign.

## First-deploy HTTPS timing

After a successful deploy, plain HTTP works immediately. HTTPS on `<name>.h4a.site` takes an extra 30 seconds – 5 minutes while Bunny's AutoSSL provisions a Let's Encrypt cert for the custom hostname. During that window, `curl https://<name>.h4a.site` returns a TLS error. Retry — the deploy itself is done.

## Full example (REST)

```
POST /api/deploy
Authorization: Bearer <subkey>
Content-Type: application/json

{
  "name":   "overstap",
  "repo":   "github.com/michielb/overstap",
  "tenant": "michielb"
}
```

```
200 OK
{ "name": "overstap", "tier": "static", "url": "https://overstap.h4a.site", "status": "running", ... }
```

## Full example (CLI)

```
h4a deploy overstap https://github.com/michielb/overstap --tenant michielb
#   name:      overstap
#   tier:      static (package.json build → static output (node))
#   url:       https://overstap.h4a.site
#   deploy_id: overstap-20260417-150211-7ae5abb3
#   status:    running
#
#   --- build log (tail) ---
#   added 198 packages in 5s
#   vite v6.4.2 building for production...
#   ✓ 39 modules transformed.
#   ✓ built in 2.98s
#   uploaded 5 files to h4a-michielb-overstap
```

## Errors

| `message`                                  | Cause / fix                                                              |
|---                                         |---                                                                       |
| `repo is required`                         | Pass a git URL in `repo`.                                                |
| `private repos need SSH or a token …`      | Wave A is public-HTTPS only. Make the repo public or wait for wave B.    |
| `repo exceeds N bytes …`                   | Over 100 MB. Split the repo or contact support.                          |
| `build failed: npm run build failed: …`    | The build output is in `build_log`. Fix in the repo and retry.           |
| `green deploy <id> never became healthy …` | Blue-green health-check timed out. Your container didn't answer on `:8080<path>` within the budget. Blue is still serving — fix the build and re-deploy. |
| `deploy <id> is already in progress …`     | A previous `deploy` is still provisioning the green VM. Wait for it (or `destroy` if it's stuck). |
| `static-tier deploy requires …`            | Control plane was started without `BUNNY_API_KEY` — operator issue.      |

## Destroy

```
h4a destroy overstap --tenant michielb
```

Idempotent. Removes the Bunny storage zone, pull zone, and DNS CNAME.
