Skip to content

feat(sites): one command deploys a framework project or a directory - #172

Draft
bogdan-at-bunny wants to merge 4 commits into
mainfrom
feat/framework-deploys
Draft

feat(sites): one command deploys a framework project or a directory#172
bogdan-at-bunny wants to merge 4 commits into
mainfrom
feat/framework-deploys

Conversation

@bogdan-at-bunny

@bogdan-at-bunny bogdan-at-bunny commented Aug 19, 2026

Copy link
Copy Markdown

What

bunny sites deploy deploys this project, whichever shape it is: a build that
renders each page per request on Edge Scripting, or a directory of static files.
The build decides which, and the command is the one that has always deployed a
directory.

bunny sites deploy                           # build if needed, then deploy
bunny sites deploy --build                   # run the project's own build first
bunny sites deploy ./dist --production       # a directory, published live
bunny sites deployments publish --previous   # back to the deploy that was live before

Putting an Astro site on Edge Scripting took five steps, two passwords, three
environment variables, and two raw bunny api POST calls. It now takes the first
line above.

An earlier revision of this branch added a top-level bunny deploy and
bunny rollback beside bunny sites deploy. Neither has shipped, and both are
gone: the project already says which shape it is, so choosing a command was never
the developer's job.

How

The CLI learns nothing about Astro. It reads .bunny/build.json, the build
manifest a framework adapter writes (the contract), and that file says
what to deploy: the one script file, the folder of client files, and the pull zone
settings and variables the site needs. The schema is BuildManifestSchema in
@bunny.net/config, beside bunny.jsonc, because two repositories share it. A
new adapter therefore needs no CLI release.

A framework site is a sites site whose script comes from the build. So it
re-uses almost all of sites: the storage zone, the deploys/{id}/ layout, the
state file, promote and rollback, domains, ssl, delete. state.kind
(static | framework) tells them apart. The router commands refuse a framework
site, and sites deployments publish delegates to the framework path.

Two things a framework site needs that a static one does not:

  • The server bundle is kept in storage, at _bunny/deploys/{id}/server.js, so
    a promote or rollback restores the code and the files it names together. An
    Astro server bundle contains the hashed asset names it renders, so old files
    with a new renderer is a broken page.
  • The deploy's folder is written into the bundle at publish time
    (globalThis.__BUNNY_DEPLOY__). A published release carries the name of its own
    asset folder, and cannot read another deploy's files.

Only a project that asks for a server is offered an adapter. This is the part
worth reviewing. An Astro project that prerenders every page is a directory of
files, and bunny sites deploy has always deployed one, so it must not be pushed
towards a server it does not want. sites/framework/detect.ts names four signals,
any one of which means the developer already asked:

The signal What it means
A route under src/pages/ sets prerender = false That route renders per request
The config sets output: "server" Every page renders per request
The config names another vendor's adapter The project renders on demand elsewhere today
@bunny.net/astro-adapter is a dependency Somebody installed it and stopped

The first matters most: since Astro 5 a project prerenders every page unless a
page opts out, and astro build stops with its own error when a page opts out and
no adapter is installed. Reading the routes puts the offer before that failure.
The scan reads src/pages/ only, because that is where prerender applies, and
it stops at the first match. patchAstroConfig refuses any config it cannot edit
safely, and prints the lines to paste.

A build and a site must be the same kind. A script's type is fixed when the
API creates it, so a static site cannot run a build's server and a framework site
cannot serve a directory. The deploy names the mismatch before it uploads
anything.

The build now runs before the site is resolved. Only the framework path did
that. So a failing build can no longer leave an empty site behind, and neither can
a script the platform refuses: the 10 MB check reads the bundle first, in
readServerBundle.

Layout

commands/deploy/ moves into commands/sites/. build-manifest.ts and
health.ts sit directly under sites/, because both deploy paths read them;
everything a framework site alone needs is under sites/framework/.

Verified against a real account

The showcase from BunnyWay/bunny-adapters (a server build) and its
static-output fixture (every route prerendered), deployed with this branch's
compiled binary on 2026-08-21:

Check Result
Server build, site created from nothing storage zone, script, pull zone, .bunny/site.json
Page that renders per request 200, private, no-store, a live request id and client IP
Prerendered page, assets 200 from Storage, max-age=60 / immutable
Endpoint in src/pages/api/ 200, JSON, per-request values
A path the deploy does not hold 404, with the deploy's own page
Unchanged redeploy "No changes", nothing uploaded
Rollback of a changed pair code and asset both came back
Static build, its own site 200, its own preview URL, its own 404 page
A static build against a framework site refused, before anything uploaded
A server build against a static site refused, before anything uploaded
Both sites deleted every zone and script gone

Two earlier findings, both fixed here:

  • A script-backed pull zone reports StorageZoneId: -1, not null, so site
    discovery has to treat anything below 1 as absent. Without it,
    bunny sites list never finds a framework site.
  • A publish is not instant. The CLI waits for the release to reach the nodes and
    purges again; a probe cannot tell the outgoing release from the incoming one,
    since both answer 200 with a page.

One defect this does not fix

A static site's HTML answers with cache-control: max-age=25600000, about 296
days in a visitor's browser. Router v4 sets Cache-Control only on a response
that carries none, and Bunny Storage sends one after all: it arrives with
cdn-requestpullcode: 206 from the file server, on a hit and on a miss. So the
static layer's 60 second page lifetime never reaches a page, and turning the
zone's CacheControlMaxAgeOverride off did not achieve what it was meant to.

That is the static layer's own ground, and the fix changes caching for every
static site, so it needs its own change and its own measurement.

Not in this change

There is no --preview for a build that renders per request, and the command
says so rather than publishing production in silence. Such a preview cannot be a
per-deploy snapshot: one script publishes one release at a time, and the page and
its assets are one unit. The design (an environment per preview, sharing the
storage zone) is in bunny-adapters, plans/one-command-deploys.md, with
bunny sites ci init for framework sites and a top-level bunny env.

Tests

bun test 962 pass, bun run typecheck clean, bun run lint clean, and the
compiled binary ran every live check above. New unit tests cover the four
detection signals and the project that shows none of them, the manifest reader
(bad JSON, unknown version, version floor, missing build), the Astro config
patch, the pull-zone settings diff, the env writer (a secret is written once, so
a rotated password survives a deploy), framework-site discovery, and deleting a
deploy's server bundle with its files.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NpdDkzyH7pPq5FZqPCRhzE

@bunnynet-devops

Copy link
Copy Markdown

@codex review

@changeset-bot

changeset-bot Bot commented Aug 19, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: be46e3f

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@bunny.net/cli Minor
@bunny.net/config Minor
@bunny.net/cli-linux-x64 Minor
@bunny.net/cli-linux-arm64 Minor
@bunny.net/cli-darwin-x64 Minor
@bunny.net/cli-darwin-arm64 Minor
@bunny.net/cli-windows-x64 Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@bogdan-at-bunny
bogdan-at-bunny marked this pull request as draft August 19, 2026 15:45
@bogdan-at-bunny bogdan-at-bunny changed the title feat(deploy): one command deploys a framework project or a directory feat(sites): one command deploys a framework project or a directory Aug 21, 2026
@bogdan-at-bunny
bogdan-at-bunny force-pushed the feat/framework-deploys branch 2 times, most recently from f07936e to d0444bd Compare August 25, 2026 10:59
bogdan-at-bunny and others added 4 commits August 25, 2026 11:00
`BuildManifestSchema` describes `.bunny/build.json`. It is the contract
between an adapter and the CLI, and it holds everything the CLI needs to
deploy a build it did not make: the server entry, the client directory,
the pull zone settings the build asks for, the variables the script
reads, and whether the build renders pages per request.

The CLI knows no framework. It reads the manifest, so a new adapter
needs no new CLI. The specification lives beside the adapters, in
BunnyWay/bunny-adapters.

`manifestVersion` moves only for a change an older CLI cannot read. A
new optional field is not one, because the CLI ignores what it does not
know.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124kDtA5hbacU1qEJ5pQo5M
Router v6 reads three file names out of the deploy it serves:
`404.html`, `_redirects`, and `_headers`. Cloudflare Pages and Netlify
read the same three, so nothing in the router knows about a framework,
and every preset gets it.

- `404.html` answers a path the deploy does not hold, at status 404.
  Without it the pull zone answers with bunny.net's error page, whatever
  the site built. That shipped: a documentation site went up, and every
  wrong URL showed bunny.net's page.
- `_redirects` sends a real redirect. One rule per line, `/from /to
  [status]`, `#` comments, a trailing `*` captured as `:splat`, and `!`
  to beat a file at the same path. A rewrite (`200`) is not read: it
  would have the router fetch another path of its own site, which can be
  made to loop.
- `_headers` carries the headers Bunny Storage cannot hold.

The router reads the three through its own reserved `/_bunny/router/`
path, which is the whole permission. Nothing else under `_bunny/`
becomes reachable. The rules are held in memory, never written into the
script, so a publish stays an environment variable change.

The router now sets `Cache-Control` on every response, and a site's pull
zone stops overriding it (`CacheControlMaxAgeOverride: -1`). The zone
default of 30 days replaced every answer the script gave, so an HTML
page could sit a month stale in a browser that no purge reaches.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124kDtA5hbacU1qEJ5pQo5M
A published Edge Script that will not start makes the edge answer 400
with an empty body, and the deploy said nothing: a green line, a URL,
and a site that served nothing. `withastro/astro.build` deployed exactly
like that.

The check probes the production URL up to three times, each with its own
query so the CDN cache cannot hold the answer. A redirect or a 404
counts as a working script; only 400 and 5xx are faults, and a site that
cannot be reached at all is not called one. It also asks for a path the
deploy cannot hold, because a 404 page that never reaches a visitor
looks deployed and is not.

Four smaller things around the same command:

- `--name <name>` is honoured when the deploy creates the site. Without
  it an unattended run stopped with "No site specified and no linked
  site found."
- `--region <code>` chooses the storage region for a site the deploy
  creates. Only `sites create` could name one before.
- The domain prompt after a first deploy refuses a value that is not a
  hostname, and says so. It used to send it, and the API's answer is
  `An error has occurred.`
- The upload counts bytes as well as files. `withastro/astro.build`
  sends 1.4 GB in 8828 files, and ten minutes of `4210/8828 files` says
  nothing about how much is left.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124kDtA5hbacU1qEJ5pQo5M
`bunny lab deploy astro` deploys an Astro project that renders pages per
request. Two commands, and no more:

    bunny lab deploy astro
    bunny lab undeploy astro

Astro's server becomes a standalone Edge Script. The client build goes
into a storage zone the script reads. The pull zone's origin is the
script, so nothing sits between a request and the code. The command
provisions the three resources on its first run, uploads the build, sets
every variable from what it already knows, applies the pull zone
settings the adapter asks for, and publishes. No password passes through
the terminal.

`bunny sites deploy` keeps deploying a directory of files. The share was
the problem: a project that renders per request cannot use
`CURRENT_DEPLOY`, because one script serves one release, and a directory
of files has no build manifest. Each flow carried checks for the shape it
was not. Nothing under `lab/` imports from `sites/`.

`lab` says the interface is still being shaped. The namespace is hidden
from help and from the landing page.

Measured against `withastro/astro/examples/ssr` and
`render-examples/astro-ssr`, deployed to a real account. What that
changed:

- Astro 7 is checked before the install, because npm answers an older
  project with an ERESOLVE a developer cannot act on.
- The adapter it replaces is uninstalled, not only unimported.
  `@astrojs/node@9` peers on `astro@^5`, so after an upgrade it makes
  every later install fail.
- The pull zone's cache override goes off, or the edge rewrites every
  `Cache-Control` the adapter sets.
- The state file belongs to the project, not to the working directory.
- The name prefix is not added twice.

`BuildManifestSchema` in `@bunny.net/config` is the contract with the
adapter. The CLI knows no framework: it reads the manifest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0124kDtA5hbacU1qEJ5pQo5M
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants