feat(sites): one command deploys a framework project or a directory - #172
Draft
bogdan-at-bunny wants to merge 10 commits into
Draft
feat(sites): one command deploys a framework project or a directory#172bogdan-at-bunny wants to merge 10 commits into
bogdan-at-bunny wants to merge 10 commits into
Conversation
|
@codex review |
🦋 Changeset detectedLatest commit: 0c6a1a9 The changes in this PR will be included in the next version bump. This PR includes changesets to release 7 packages
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 |
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
bogdan-at-bunny
marked this pull request as draft
August 19, 2026 15:45
Putting an Astro site on Edge Scripting took five steps, two passwords,
three environment variables, and two raw API calls. It now takes one
command:
bunny deploy
The CLI learns nothing about Astro. It reads `.bunny/build.json`, the
build manifest a framework adapter writes, and that file says what to
deploy: the one script file, the folder of client files, and the pull
zone settings and script variables the site needs. So the next adapter
needs no CLI release. The schema lives in @bunny.net/config, next to
bunny.jsonc, because it is a contract two repositories share.
A framework site is a `sites` site whose script comes from the build,
which is why this re-uses almost everything: the storage zone, the
`deploys/{id}/` layout, the state file, promote and rollback, domains,
ssl, and delete. `state.kind` tells the two apart. The router commands
refuse a framework site, and `deployments publish` delegates to the code
path that publishes one.
Two things a framework site needs that a static one does not:
- The deploy's server bundle is kept in storage, under `_bunny/`, so a
promote or a rollback restores the code and the files it names
together. Astro puts hashed asset names inside the server bundle, so
old files with a new renderer is a broken page.
- The CLI writes `globalThis.__BUNNY_DEPLOY__` onto the front of the
bundle at publish time. The release then carries the name of its own
asset folder, and cannot read another deploy's files.
Also: a project with no adapter installed or configured gets an offer to
add one. The Astro config edit refuses any config it cannot edit safely,
and prints the lines to paste instead.
Verified against a real account with a fresh `npm create astro` project:
provision from nothing, a page rendered per request, a prerendered page
and assets from Storage, an unchanged redeploy that does nothing, and a
rollback that brings back the old page with its own assets.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DNEZw6pebXfttb98s3ihG8
Three real projects met the command: Starlight, astro.build and AstroWind. None of them deployed. Each failure has its own fix: - A workspace root is not a project. The command now finds the projects below it and offers them, so `bunny deploy` at Starlight's root reaches `docs/`. - The package manager comes from the nearest lockfile up the tree. In `starlight/docs` there is none, so npm was assumed, and npm cannot read `workspace:*`. - `pnpm add` at a workspace root needs `-w`, and Yarn needs `-W`. - Another vendor's adapter is replaced rather than reported, and the prompt names both sides of the swap. - `output: "server"` is never written. Astro's own default prerenders, and a page asks for the edge itself; overriding it tripled astro.build's script. - The 10 MB script check runs before anything is created, so a script that cannot be deployed leaves no half-made site behind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNEZw6pebXfttb98s3ihG8
Four small things a real deploy ran into: - `bunny sites deploy` no longer offers the build that `bunny deploy` has already run. A project whose build produced files was asked twice. - `bunny deploy --name` is honoured when the deploy creates a static site. - The domain prompt refuses an answer that is not a hostname, and says so. It used to send it, and the API answers "An error has occurred." - A failing build names the command that was actually running. A first deploy also adds `.bunny/` to .gitignore, in a git repository that does not ignore it yet. The directory holds a build output and a link to a site, as `bunny scripts init` already knows. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNEZw6pebXfttb98s3ihG8
A published script that will not start makes the edge answer 400 with an empty body. `bunny deploy` printed a green line and a URL above it, and astro.build deployed exactly that way. The command now probes production, gives a cold start two more chances, and says what to do when the answer is a fault. A script above 7.5 MB gets the size named, because that is where scripts stop starting, whatever the documented 10 MB says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNEZw6pebXfttb98s3ihG8
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNEZw6pebXfttb98s3ihG8
`bunny deploy --name my-site` stopped an unattended static deploy with "No site specified and no linked site found", though the name is exactly what a first deploy needs. `bunny sites deploy` also never declared `--name`, so passing it printed the help. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNEZw6pebXfttb98s3ihG8
astro.build's deploy is 8828 files and 1.4 GB, and it spends ten minutes in one spinner. A file count does not say how much of that is left; the bytes do. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNEZw6pebXfttb98s3ihG8
Router v4 reads three file names out of the deploy it is serving: `404.html`, `_redirects`, and `_headers`. Cloudflare Pages and Netlify read the same three, so nothing here knows a framework and all thirty static presets get it. The files are read once per deploy and held in memory, never written into the script: one script serves production and every preview, so a promote stays an environment variable change. Without a 404 page of its own a pull zone answers a miss with bunny.net's, which shipped: a documentation site went up and every wrong URL showed bunny.net's page. The router answers it with the deploy's own page now, at status 404 and `no-cache`, so the next deploy's fix is not outlived. `_redirects` is the subset both hosts agree on: `from to [status]`, `#` comments, a trailing `*` captured as `:splat`, and `!` to beat a file at the same path. Only a forced rule is answered before the origin is asked, which is what makes a real file win. A rewrite (`200`) is left out: it would have the router fetch another path of its own site, and a pathological pair of rules can make that loop. The router reads its own files through a reserved `/_bunny/router/<name>` path with a four-name allowlist. That path is the whole permission, so nothing else under `_bunny/` becomes reachable, and the mapped request carries a flag that stops the response phase touching it or recursing through it. `CacheControlMaxAgeOverride` goes to -1 on a site's zone and every preview zone, because the router now owns `Cache-Control` and the zone default of 2592000 replaced every answer it gave: an HTML page could be a month stale in a browser no purge reaches, and a 404 could outlive the deploy that fixed it. A page gets 60 seconds, anything else keeps 30 days, and `_headers` wins where it speaks. The edge hit rate for HTML has not been measured either side of this change. `deploy/health.ts` is the module the plan named. `findDeployFault` moves there from `framework.ts`, and `findMissingPageFault` joins it: both deploy paths ask the published site for a path it cannot hold, and report an answer that is not the deploy's own page. The probe is a path, not a query string, because a sites zone ignores query strings. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DNEZw6pebXfttb98s3ihG8
`bunny deploy` and `bunny sites deploy` both deployed a site, and a developer had to know which. The project already says which shape it is, in the build manifest, so the choice was never the developer's to make. `bunny deploy` and `bunny rollback` are gone, and `bunny sites deploy` takes both paths. Neither command shipped, so nothing here is a migration. `deploy/` moves into `sites/`: the build manifest reader and the health probe sit beside the command that reads them, and everything a framework site alone needs is under `sites/framework/`. Detection is the part that needed a design. `bunny deploy` offered its adapter to any Astro project, which is wrong: an Astro project that prerenders every page is a directory of files, and `bunny sites deploy` has always deployed one. So `projectNeedsServer` names four signals, and only a project that shows one hears about an adapter: another vendor's adapter in the config, `output: "server"`, our adapter as a dependency, or a route under `src/pages/` with `prerender = false`. The route scan 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 instead of after it. Three things the merge fixed on the way: - The build now runs before the site is resolved, so a failing build cannot leave an empty site behind. Only the framework path did this before. - A build and a site have to be the same kind. A script's type is fixed when the API creates it, so the deploy says which mismatch it found instead of uploading into it. - `deployments delete` and `prune` delete the deploy's server bundle with it. Every pruned framework deploy used to leave one behind, forever. The first-deploy domain offer was written twice, and one copy printed `bunny domains add`, which is not a command. It is one helper now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NpdDkzyH7pPq5FZqPCRhzE
`bunny sites delete` printed "Deleted router script 86366" for a site whose script is the build's own server. `sites show` already picks its label from the kind, and the teardown now does the same. Found while deploying a real Astro site and deleting it again. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NpdDkzyH7pPq5FZqPCRhzE
bogdan-at-bunny
force-pushed
the
feat/framework-deploys
branch
from
August 21, 2026 15:39
1b8b844 to
0c6a1a9
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
bunny sites deploydeploys this project, whichever shape it is: a build thatrenders 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.
Putting an Astro site on Edge Scripting took five steps, two passwords, three
environment variables, and two raw
bunny api POSTcalls. It now takes the firstline above.
An earlier revision of this branch added a top-level
bunny deployandbunny rollbackbesidebunny sites deploy. Neither has shipped, and both aregone: 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 buildmanifest 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
BuildManifestSchemain@bunny.net/config, besidebunny.jsonc, because two repositories share it. Anew adapter therefore needs no CLI release.
A framework site is a
sitessite whose script comes from the build. So itre-uses almost all of
sites: the storage zone, thedeploys/{id}/layout, thestate file, promote and rollback, domains, ssl, delete.
state.kind(
static|framework) tells them apart. The router commands refuse a frameworksite, and
sites deployments publishdelegates to the framework path.Two things a framework site needs that a static one does not:
_bunny/deploys/{id}/server.js, soa 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.
(
globalThis.__BUNNY_DEPLOY__). A published release carries the name of its ownasset 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 deployhas always deployed one, so it must not be pushedtowards a server it does not want.
sites/framework/detect.tsnames four signals,any one of which means the developer already asked:
src/pages/setsprerender = falseoutput: "server"@bunny.net/astro-adapteris a dependencyThe first matters most: since Astro 5 a project prerenders every page unless a
page opts out, and
astro buildstops with its own error when a page opts out andno adapter is installed. Reading the routes puts the offer before that failure.
The scan reads
src/pages/only, because that is whereprerenderapplies, andit stops at the first match.
patchAstroConfigrefuses any config it cannot editsafely, 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 intocommands/sites/.build-manifest.tsandhealth.tssit directly undersites/, 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 itsstatic-outputfixture (every route prerendered), deployed with this branch'scompiled binary on 2026-08-21:
.bunny/site.jsonprivate, no-store, a live request id and client IPmax-age=60/immutablesrc/pages/api/Two earlier findings, both fixed here:
StorageZoneId: -1, notnull, so sitediscovery has to treat anything below 1 as absent. Without it,
bunny sites listnever finds a framework site.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 296days in a visitor's browser. Router v4 sets
Cache-Controlonly on a responsethat carries none, and Bunny Storage sends one after all: it arrives with
cdn-requestpullcode: 206from the file server, on a hit and on a miss. So thestatic layer's 60 second page lifetime never reaches a page, and turning the
zone's
CacheControlMaxAgeOverrideoff 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
--previewfor a build that renders per request, and the commandsays 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, withbunny sites ci initfor framework sites and a top-levelbunny env.Tests
bun test962 pass,bun run typecheckclean,bun run lintclean, and thecompiled 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