From a96197222353c48dfa6282e12c5f681ebf38fa05 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 13 Oct 2026 08:27:18 +0100 Subject: [PATCH 1/5] fix(sites): keep serve rule exclusions on publish and warn when the edge never confirms the deploy --- .changeset/sites-publish-rules.md | 5 ++ AGENTS.md | 1 + packages/cli/README.md | 2 + packages/cli/src/commands/sites/api.test.ts | 35 ++++++++++ packages/cli/src/commands/sites/api.ts | 64 +++++++++++++++---- packages/cli/src/commands/sites/deploy.ts | 23 ++++--- .../src/commands/sites/deployments/publish.ts | 11 +++- packages/cli/src/commands/sites/migrate.ts | 2 + 8 files changed, 122 insertions(+), 21 deletions(-) create mode 100644 .changeset/sites-publish-rules.md diff --git a/.changeset/sites-publish-rules.md b/.changeset/sites-publish-rules.md new file mode 100644 index 00000000..081b724f --- /dev/null +++ b/.changeset/sites-publish-rules.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +Sites publishes keep paths excluded from the serve rule and warn when the edge never confirms the new deploy diff --git a/AGENTS.md b/AGENTS.md index 3488d94d..b57f955c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -561,6 +561,7 @@ Hard-won, not inferable from the code, and expensive to rediscover. - **At the edge, `ctx.request.url` is origin-facing** (`http://:9000/...`), not the requested host. The client hostname must come from the `CDN-Host`/`Host` headers. Hostname routing on `url.hostname` never fires. - **Sites must use the edge rule `OriginStorage` action for deploy routing.** Its parameters are storage-zone ID, storage-zone name, and the origin path prefix (`/deploys//`). A `Change Origin URL` self-hop leaks that internal prefix into root-relative HTML links; pointing it at the Storage API instead loses linked-origin index handling and its injected `AccessKey` is not reliable at the edge. `OriginStorage` changes only the origin path, so the client URL stays clean while Storage authentication and `index.html` handling remain native. `BlockRequest` runs pre-cache, so it applies to cache hits too. - **The pull zone delivery layer rewrites `Cache-Control`** from the zone's cache settings; origin- or script-set values reach browsers only when both `CacheControlMaxAgeOverride` and `CacheControlPublicMaxAgeOverride` are `-1`, and a `no-cache` the CDN respects also stops it storing the object. Sites sidesteps this with a zone-level override plus per-extension browser-cache rules. +- **A storage-origin edge rule always beats an Override Origin URL rule matching the same request**, regardless of creation order or `OrderIndex` (verified live). Routing a path around a site therefore means excluding it from the rewrite rule's match-none trigger, so `ensureSiteRules` carries extra patterns on that trigger through every publish; everything else in the site's rules is rebuilt in full. - **Sites edge rules are identified by their `Description` strings** (constants in `sites/constants.ts`); upserts key on them, so treat them as frozen. - **An unchanged, already-live `sites deploy` still converges and purges the site's edge rules.** It is the repair/upgrade path for existing sites, so do not return from the no-op branch before `promoteDeploy()`. - **The `/storagezone/regions` endpoint is not reliable.** The region catalog is hand-maintained in the storage constants. The available set is a function of both tier and S3 support: Edge (SSD) zones can only be primaried in DE and the create API silently rewrites any other region rather than erroring, so reject a conflicting `--region` client-side. diff --git a/packages/cli/README.md b/packages/cli/README.md index 0f665dbf..3e321fd8 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1093,6 +1093,8 @@ Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`, `spa`) Every deploy publishes: the files land in an immutable `deploys//` directory and the rewrite rule is pointed at it, so `deployments publish` rolls back to any earlier deploy by moving that pointer, with no files moving and nothing re-uploaded. The ID is the git short-sha when the tree is clean, a content hash otherwise, or whatever `--deploy-id` supplies (letters, digits, `-`, `_`, `.`; 4-64 chars; case-sensitive): a custom ID never aliases onto another deploy's content, and reusing one for different content asks before replacing (`--force` skips the prompt); a replacement clears the old files first, so nothing stale survives. The live deploy and the rollback target are never replaced in place; deploy those under a new ID. Content is root-served, so absolute asset paths work as-is. Direct `/deploys//` URLs are blocked at the edge. Site state lives at `_bunny/site.json` inside the storage zone (also blocked at the edge); `.bunny/site.json` is only a local pointer, so a fresh clone can `sites link` and pick up where the last machine left off. +**Routing a path to another origin.** An Override Origin URL edge rule never takes priority over the site's storage origin, whatever the rule order. To send a path such as `/api/*` to another backend, add its pattern (`*/api/*`) to the match-none conditions of the `bunny sites: serve the published deploy` rule, then add your own Override Origin URL rule for that path; deploys keep those exclusions. The pull zone caches responses for 30 days and ignores query strings, so give a dynamic route an Override Cache Time of `0` too. The dashboard currently rejects the serve rule's `/deploys//` path value, so make the edit through the API. Every publish checks that the edge serves the new deploy and warns when it can't confirm it within about 20 seconds. + | Flag | Commands | Description | | -------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | | `--region`, `--domain` | `create` | Main storage region code (default `DE`); custom production domain to attach | diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 2dd215bc..8bd2ef3c 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -726,6 +726,41 @@ test("promoteDeploy retargets the rewrite rule, probes the edge, and purges twic expect(purges[0]?.params).toEqual({ path: { id: 30 } }); }); +test("promoteDeploy keeps paths a user excluded from the rewrite rule", async () => { + const coreClient = fakeCoreClient({ calls: [], storageZones: [ZONE] }); + await promoteDeploy({ coreClient, state: fakeState(), deployId: "aaaa" }); + + const rulesOf = async () => + (await coreClient.GET("/pullzone/{id}", { params: { path: { id: 30 } } })) + .data?.EdgeRules ?? []; + const rewrite = (await rulesOf()).find( + (r) => r.Description === REWRITE_RULE_DESC, + ) as EdgeRule; + await coreClient.POST("/pullzone/{pullZoneId}/edgerules/addOrUpdate", { + params: { path: { pullZoneId: 30 } }, + body: { + ...rewrite, + Triggers: [ + { + ...rewrite.Triggers?.[0], + PatternMatches: ["*/deploys/*", "*/api/*"], + }, + ], + }, + }); + + await promoteDeploy({ coreClient, state: fakeState(), deployId: "bbbb" }); + const after = (await rulesOf()).filter( + (r) => r.Description === REWRITE_RULE_DESC, + ); + expect(after).toHaveLength(1); + expect(after[0]?.ActionParameter3).toBe("/deploys/bbbb/"); + expect(after[0]?.Triggers?.[0]?.PatternMatches).toEqual([ + "*/deploys/*", + "*/api/*", + ]); +}); + test("promoteDeploy keeps the storage zone's not-found settings on the live deploy", async () => { const coreCalls: Call[] = []; const coreClient = fakeCoreClient({ diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 0d67f0f7..0fde55d7 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -379,12 +379,14 @@ async function fetchMiddlewareScriptId( return id == null || id === NO_MIDDLEWARE_SCRIPT ? null : id; } -// The rules that serve a site, rewrite last so a failed upsert never switches routing early; bodies are always rebuilt in full from these so a hand-edited rule heals on the next upsert. +const DEPLOYS_PATTERN = `*/${DEPLOYS_DIR}/*`; + +// The rules that serve a site, rewrite last so a failed upsert never switches routing early; bodies are rebuilt in full so a hand-edited rule heals, except the rewrite rule's extra exclusions. function siteRules( storageZone: { Id: number; Name: string }, deployId: string, + exclusions: string[] = [], ): Array { - const deploysPattern = `*/${DEPLOYS_DIR}/*`; return [ { Description: GATE_RULE_DESC, @@ -394,7 +396,7 @@ function siteRules( Triggers: [ { Type: EdgeRuleTriggerType.Url, - PatternMatches: [deploysPattern], + PatternMatches: [DEPLOYS_PATTERN], PatternMatchingType: EdgeRuleMatch.Any, }, ], @@ -444,7 +446,7 @@ function siteRules( Triggers: [ { Type: EdgeRuleTriggerType.Url, - PatternMatches: [deploysPattern], + PatternMatches: [DEPLOYS_PATTERN, ...exclusions], PatternMatchingType: EdgeRuleMatch.None, }, ], @@ -452,6 +454,18 @@ function siteRules( ]; } +// Paths a user excluded from the rewrite rule (e.g. `*/api/*` routed to another origin); storage-origin rules win over origin-URL rules whatever the order, so this is the only way to route around the site. +function rewriteExclusions(rules: EdgeRule[]): string[] { + const trigger = rules + .find((r) => r.Description === REWRITE_RULE_DESC) + ?.Triggers?.find( + (t) => + t.Type === EdgeRuleTriggerType.Url && + t.PatternMatchingType === EdgeRuleMatch.None, + ); + return (trigger?.PatternMatches ?? []).filter((p) => p !== DEPLOYS_PATTERN); +} + // Converge the pull zone's rules on `deployId`; create and promote both funnel through here, so a missing or stale rule heals on any run. async function ensureSiteRules(opts: { coreClient: CoreClient; @@ -468,6 +482,7 @@ async function ensureSiteRules(opts: { for (const rule of siteRules( { Id: storageZoneId, Name: storageZoneName }, deployId, + rewriteExclusions(existing), )) { await upsertEdgeRule(coreClient, pullZoneId, rule, existing); } @@ -832,17 +847,21 @@ export const promoteVerification = { async function waitForEdgePropagation( host: string, deployId: string, -): Promise { +): Promise { const start = Date.now(); const deadline = start + PROPAGATION_DEADLINE_MS; let attempt = 0; + let confirmed = false; while (Date.now() < deadline) { try { // A unique query per attempt keeps each probe out of the CDN cache so a stale entry can't mask a propagated rule. const { deploy } = await promoteVerification.probe( `https://${host}/?__bunny_promote=${deployId}-${attempt++}`, ); - if (deploy === deployId) break; + if (deploy === deployId) { + confirmed = true; + break; + } } catch { // Edge briefly unreachable (DNS/warmup); keep trying until the deadline. } @@ -853,6 +872,7 @@ async function waitForEdgePropagation( if (elapsed < SETTLE_FLOOR_MS) { await promoteVerification.wait(SETTLE_FLOOR_MS - elapsed); } + return confirmed; } async function ensureNotFoundSettings( @@ -873,12 +893,20 @@ async function ensureNotFoundSettings( }); } -// Point production at a deploy: retarget the rewrite rule and purge. The rule propagates async, so purge, wait for the edge to serve it, then purge again so nothing stale survives (the header can confirm on a cached response, which is why the second purge is load-bearing). +/** Warn when the edge never reported `deployId`; call after any spinner stops. */ +export function warnIfUnconfirmed(confirmed: boolean, deployId: string): void { + if (confirmed) return; + logger.warn( + `Couldn't confirm the edge is serving ${deployId}. Check the ${DEPLOY_HEADER} header on the live site; another storage-origin edge rule on the pull zone can keep a different deploy live.`, + ); +} + +// Point production at a deploy: retarget the rewrite rule and purge; resolves to whether the edge confirmed it. The rule propagates async, so purge, wait for the edge to serve it, then purge again so nothing stale survives (the header can confirm on a cached response, which is why the second purge is load-bearing). export async function promoteDeploy(opts: { coreClient: CoreClient; state: RemoteSiteState; deployId: string; -}): Promise { +}): Promise { const { coreClient, state, deployId } = opts; const purge = () => coreClient.POST("/pullzone/{id}/purgeCache", { @@ -908,8 +936,9 @@ export async function promoteDeploy(opts: { }); await ensureNotFoundSettings(coreClient, storageZone, state, deployId); await purge(); - await waitForEdgePropagation(host, deployId); + const confirmed = await waitForEdgePropagation(host, deployId); await purge(); + return confirmed; } // Refuse to replace version-1 state that changed since it was read. `writeRemoteState`'s own conflict merge can't cover this: it reconciles against the current format, and the file being replaced is the older one, so it would abort as unparseable rather than merge. @@ -949,6 +978,8 @@ export interface MigrateResult { detachedScriptId: number | null; deletedScriptId: number | null; scriptError?: string; + /** False when the current deploy was republished but the edge never reported it. */ + promoteConfirmed: boolean; } export async function migrateSite(opts: { coreClient: CoreClient; @@ -982,9 +1013,14 @@ export async function migrateSite(opts: { }); await applySiteCacheSettings(coreClient, state.pullZoneId); + let promoteConfirmed = true; if (state.current) { step("Publishing the current deploy..."); - await promoteDeploy({ coreClient, state, deployId: state.current }); + promoteConfirmed = await promoteDeploy({ + coreClient, + state, + deployId: state.current, + }); } // Committed only once every fallible remote step is done: while the file still reads as version 1, a failed run above is fully resumable. @@ -1011,7 +1047,13 @@ export async function migrateSite(opts: { } } - return { state, detachedScriptId, deletedScriptId, scriptError }; + return { + state, + detachedScriptId, + deletedScriptId, + scriptError, + promoteConfirmed, + }; } interface TeardownResult { diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 96293a4e..14de5ec3 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -21,6 +21,7 @@ import { fetchSystemHostname, promoteDeploy, rereadRemoteState, + warnIfUnconfirmed, writeRemoteState, } from "./api.ts"; import { @@ -460,9 +461,10 @@ export const sitesDeployCommand = defineCommand({ const production = productionUrl(state, systemHost); if (skipUpload && alreadyLive) { - await withSpinner("Checking routing...", () => + const confirmed = await withSpinner("Checking routing...", () => promoteDeploy({ coreClient, state, deployId }), ); + warnIfUnconfirmed(confirmed, deployId); if (output === "json") { logger.log( JSON.stringify( @@ -541,13 +543,18 @@ export const sitesDeployCommand = defineCommand({ etag = await writeRemoteState(connection, state, etag); } - await withSpinner("Publishing to production...", async () => { - await promoteDeploy({ coreClient, state, deployId }); - markCurrent(state, deployId); - etag = await writeRemoteState(connection, state, etag, { - promotedTo: deployId, - }); - }); + const promoteConfirmed = await withSpinner( + "Publishing to production...", + async () => { + const confirmed = await promoteDeploy({ coreClient, state, deployId }); + markCurrent(state, deployId); + etag = await writeRemoteState(connection, state, etag, { + promotedTo: deployId, + }); + return confirmed; + }, + ); + warnIfUnconfirmed(promoteConfirmed, deployId); if (output === "json") { logger.log( diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts index eacaf4b8..3872c9be 100644 --- a/packages/cli/src/commands/sites/deployments/publish.ts +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -2,6 +2,7 @@ import { createCoreClient } from "@bunny.net/openapi-client"; import { promoteDeploy, rereadRemoteState, + warnIfUnconfirmed, writeRemoteState, } from "@/commands/sites/api.ts"; import { findDeploy, markCurrent } from "@/commands/sites/constants.ts"; @@ -132,7 +133,7 @@ export const sitesDeploymentsPublishCommand = defineCommand({ return; } - await withSpinner("Publishing...", async () => { + const promoteConfirmed = await withSpinner("Publishing...", async () => { // Revalidate on fresh state right before promoting: the confirmation window is long enough for a concurrent replace to have dropped this deploy's record and started rewriting its files. const { state: latest, etag: latestEtag } = await rereadRemoteState( connection, @@ -144,12 +145,18 @@ export const sitesDeploymentsPublishCommand = defineCommand({ "Run `bunny sites deployments list` and retry.", ); } - await promoteDeploy({ coreClient, state: latest, deployId: targetId }); + const confirmed = await promoteDeploy({ + coreClient, + state: latest, + deployId: targetId, + }); markCurrent(latest, targetId); await writeRemoteState(connection, latest, latestEtag, { promotedTo: targetId, }); + return confirmed; }); + warnIfUnconfirmed(promoteConfirmed, targetId); if (output === "json") { logger.log( diff --git a/packages/cli/src/commands/sites/migrate.ts b/packages/cli/src/commands/sites/migrate.ts index 04f06500..6c3d2702 100644 --- a/packages/cli/src/commands/sites/migrate.ts +++ b/packages/cli/src/commands/sites/migrate.ts @@ -20,6 +20,7 @@ import { fetchSystemHostname, migrateSite, siteFiles, + warnIfUnconfirmed, type ZoneState, } from "./api.ts"; import { @@ -139,6 +140,7 @@ export const sitesMigrateCommand = defineCommand({ ); } if (result.state.current) { + warnIfUnconfirmed(result.promoteConfirmed, result.state.current); const systemHost = result.state.domain ? undefined : await fetchSystemHostname(coreClient, result.state.pullZoneId); From 1061f01fe3b40c5d5dd87da1e10b9c0ac1d34373 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 15 Oct 2026 14:48:48 +0000 Subject: [PATCH 2/5] fix(sites): report unconfirmed deploys as not live and probe a unique path to bypass the cache --- packages/cli/src/commands/sites/api.test.ts | 2 +- packages/cli/src/commands/sites/api.ts | 4 ++-- packages/cli/src/commands/sites/deploy.ts | 10 +++++++--- .../cli/src/commands/sites/deployments/publish.ts | 13 +++++++++++-- 4 files changed, 21 insertions(+), 8 deletions(-) diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index 8bd2ef3c..e9d2ade9 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -44,7 +44,7 @@ beforeEach(() => { promoteVerification.probe = async (url) => ({ status: 200, deploy: - new URL(url).searchParams.get("__bunny_promote")?.replace(/-\d+$/, "") ?? + new URL(url).pathname.match(/^\/__bunny_promote\/(.+)-\d+$/)?.[1] ?? null, }); siteFiles.connect = (zone) => diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 0fde55d7..da9cfca7 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -854,9 +854,9 @@ async function waitForEdgePropagation( let confirmed = false; while (Date.now() < deadline) { try { - // A unique query per attempt keeps each probe out of the CDN cache so a stale entry can't mask a propagated rule. + // A unique path per attempt keeps each probe out of the CDN cache (the zone ignores query strings) so a stale entry can't mask a propagated rule. const { deploy } = await promoteVerification.probe( - `https://${host}/?__bunny_promote=${deployId}-${attempt++}`, + `https://${host}/__bunny_promote/${deployId}-${attempt++}`, ); if (deploy === deployId) { confirmed = true; diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 14de5ec3..a2b8e4d6 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -472,7 +472,7 @@ export const sitesDeployCommand = defineCommand({ site: state.name, id: deployId, unchanged: true, - live: true, + live: confirmed, production: production ?? null, }, null, @@ -566,7 +566,7 @@ export const sitesDeployCommand = defineCommand({ files: files.length, bytes: totalBytes, unchanged: skipUpload, - live: true, + live: promoteConfirmed, production: production ?? null, }, null, @@ -577,7 +577,11 @@ export const sitesDeployCommand = defineCommand({ } if (skipUpload) { - logger.success(`Deploy ${deployId} is now live.`); + logger.success( + promoteConfirmed + ? `Deploy ${deployId} is now live.` + : `Published deploy ${deployId}.`, + ); } else { logger.success( `Deployed ${deployId} (${files.length} files, ${formatBytes(totalBytes)}).`, diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts index 3872c9be..bb178b83 100644 --- a/packages/cli/src/commands/sites/deployments/publish.ts +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -161,7 +161,12 @@ export const sitesDeploymentsPublishCommand = defineCommand({ if (output === "json") { logger.log( JSON.stringify( - { site: state.name, id: targetId, published: true }, + { + site: state.name, + id: targetId, + published: true, + live: promoteConfirmed, + }, null, 2, ), @@ -169,7 +174,11 @@ export const sitesDeploymentsPublishCommand = defineCommand({ return; } - logger.success(`Deploy ${targetId} is now live.`); + logger.success( + promoteConfirmed + ? `Deploy ${targetId} is now live.` + : `Published deploy ${targetId}.`, + ); if (state.domain) logger.info(`Production: https://${state.domain}`); await offerLink(); From 8658771904b093719e6f3c25fd7982269ee250fa Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 20 Oct 2026 13:43:52 +0100 Subject: [PATCH 3/5] fmt --- packages/cli/src/commands/sites/api.test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/cli/src/commands/sites/api.test.ts b/packages/cli/src/commands/sites/api.test.ts index e9d2ade9..4cbdfd9a 100644 --- a/packages/cli/src/commands/sites/api.test.ts +++ b/packages/cli/src/commands/sites/api.test.ts @@ -44,8 +44,7 @@ beforeEach(() => { promoteVerification.probe = async (url) => ({ status: 200, deploy: - new URL(url).pathname.match(/^\/__bunny_promote\/(.+)-\d+$/)?.[1] ?? - null, + new URL(url).pathname.match(/^\/__bunny_promote\/(.+)-\d+$/)?.[1] ?? null, }); siteFiles.connect = (zone) => ({ zoneName: zone.Name }) as unknown as ReturnType< From 9aecabe9635578e3bbe420de7d2d0ef55719c381 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 20 Oct 2026 13:59:46 +0100 Subject: [PATCH 4/5] add warning about rules --- .changeset/sites-publish-rules.md | 2 +- packages/cli/README.md | 2 +- packages/cli/src/commands/sites/api.ts | 78 +++++++++++++++---- packages/cli/src/commands/sites/deploy.ts | 20 ++--- .../src/commands/sites/deployments/publish.ts | 14 ++-- packages/cli/src/commands/sites/migrate.ts | 6 +- 6 files changed, 86 insertions(+), 36 deletions(-) diff --git a/.changeset/sites-publish-rules.md b/.changeset/sites-publish-rules.md index 081b724f..31279589 100644 --- a/.changeset/sites-publish-rules.md +++ b/.changeset/sites-publish-rules.md @@ -2,4 +2,4 @@ "@bunny.net/cli": patch --- -Sites publishes keep paths excluded from the serve rule and warn when the edge never confirms the new deploy +Sites publishes keep paths excluded from the serve rule, warn about Override Origin URL rules the site still shadows, and warn when the edge never confirms the new deploy diff --git a/packages/cli/README.md b/packages/cli/README.md index 3e321fd8..ff6395ae 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -1093,7 +1093,7 @@ Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`, `spa`) Every deploy publishes: the files land in an immutable `deploys//` directory and the rewrite rule is pointed at it, so `deployments publish` rolls back to any earlier deploy by moving that pointer, with no files moving and nothing re-uploaded. The ID is the git short-sha when the tree is clean, a content hash otherwise, or whatever `--deploy-id` supplies (letters, digits, `-`, `_`, `.`; 4-64 chars; case-sensitive): a custom ID never aliases onto another deploy's content, and reusing one for different content asks before replacing (`--force` skips the prompt); a replacement clears the old files first, so nothing stale survives. The live deploy and the rollback target are never replaced in place; deploy those under a new ID. Content is root-served, so absolute asset paths work as-is. Direct `/deploys//` URLs are blocked at the edge. Site state lives at `_bunny/site.json` inside the storage zone (also blocked at the edge); `.bunny/site.json` is only a local pointer, so a fresh clone can `sites link` and pick up where the last machine left off. -**Routing a path to another origin.** An Override Origin URL edge rule never takes priority over the site's storage origin, whatever the rule order. To send a path such as `/api/*` to another backend, add its pattern (`*/api/*`) to the match-none conditions of the `bunny sites: serve the published deploy` rule, then add your own Override Origin URL rule for that path; deploys keep those exclusions. The pull zone caches responses for 30 days and ignores query strings, so give a dynamic route an Override Cache Time of `0` too. The dashboard currently rejects the serve rule's `/deploys//` path value, so make the edit through the API. Every publish checks that the edge serves the new deploy and warns when it can't confirm it within about 20 seconds. +**Routing a path to another origin.** An Override Origin URL edge rule never takes priority over the site's storage origin, whatever the rule order. To send a path such as `/api/*` to another backend, add its pattern (`*/api/*`) to the match-none conditions of the `bunny sites: serve the published deploy` rule, then add your own Override Origin URL rule for that path; deploys keep those exclusions. The pull zone caches responses for 30 days and ignores query strings, so give a dynamic route an Override Cache Time of `0` too. The dashboard currently rejects the serve rule's `/deploys//` path value, so make the edit through the API. Every publish warns about Override Origin URL rules whose paths the serve rule doesn't exclude, checks that the edge serves the new deploy, and warns when it can't confirm it within about 20 seconds. | Flag | Commands | Description | | -------------------------------------- | ---------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index da9cfca7..e801d5d8 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -466,26 +466,62 @@ function rewriteExclusions(rules: EdgeRule[]): string[] { return (trigger?.PatternMatches ?? []).filter((p) => p !== DEPLOYS_PATTERN); } -// Converge the pull zone's rules on `deployId`; create and promote both funnel through here, so a missing or stale rule heals on any run. +/** An Override Origin URL rule whose URL patterns the serve rule still matches, so the site answers instead. */ +export interface ShadowedRule { + name: string; + patterns: string[]; +} + +function shadowedOriginRules( + rules: EdgeRule[], + exclusions: string[], +): ShadowedRule[] { + const shadowed: ShadowedRule[] = []; + for (const rule of rules) { + if (rule.Enabled === false) continue; + const routesOrigin = + rule.ActionType === EdgeRuleAction.OriginUrl || + rule.ExtraActions?.some((a) => a.ActionType === EdgeRuleAction.OriginUrl); + if (!routesOrigin) continue; + const patterns = (rule.Triggers ?? []) + .filter( + (t) => + t.Type === EdgeRuleTriggerType.Url && + t.PatternMatchingType !== EdgeRuleMatch.None, + ) + .flatMap((t) => t.PatternMatches ?? []) + .filter((p) => !exclusions.includes(p)); + if (patterns.length > 0) { + shadowed.push({ + name: rule.Description || rule.Guid || "unnamed", + patterns, + }); + } + } + return shadowed; +} + async function ensureSiteRules(opts: { coreClient: CoreClient; pullZoneId: number; storageZone: StorageZoneModel; deployId: string; -}): Promise { +}): Promise { const { coreClient, pullZoneId, storageZone, deployId } = opts; const { Id: storageZoneId, Name: storageZoneName } = storageZone; if (storageZoneId == null || !storageZoneName) { throw new UserError("The site's storage zone is missing its ID or name."); } const existing = await fetchEdgeRules(coreClient, pullZoneId); + const exclusions = rewriteExclusions(existing); for (const rule of siteRules( { Id: storageZoneId, Name: storageZoneName }, deployId, - rewriteExclusions(existing), + exclusions, )) { await upsertEdgeRule(coreClient, pullZoneId, rule, existing); } + return shadowedOriginRules(existing, exclusions); } interface CreateSiteOptions { @@ -893,20 +929,32 @@ async function ensureNotFoundSettings( }); } -/** Warn when the edge never reported `deployId`; call after any spinner stops. */ -export function warnIfUnconfirmed(confirmed: boolean, deployId: string): void { - if (confirmed) return; +export interface PromoteResult { + confirmed: boolean; + shadowed: ShadowedRule[]; +} + +export function warnAfterPromote( + result: PromoteResult, + deployId: string, +): void { + for (const { name, patterns } of result.shadowed) { + logger.warn( + `The Edge rule "${name}" routes ${patterns.join(", ")} to another origin, but the site's serve rule still matches it, so the site answers instead. Exclude it from the "${REWRITE_RULE_DESC}" rule.`, + ); + } + if (result.confirmed) return; logger.warn( `Couldn't confirm the edge is serving ${deployId}. Check the ${DEPLOY_HEADER} header on the live site; another storage-origin edge rule on the pull zone can keep a different deploy live.`, ); } -// Point production at a deploy: retarget the rewrite rule and purge; resolves to whether the edge confirmed it. The rule propagates async, so purge, wait for the edge to serve it, then purge again so nothing stale survives (the header can confirm on a cached response, which is why the second purge is load-bearing). +// Point production at a deploy: retarget the rewrite rule and purge; resolves to whether the edge confirmed it and which origin rules the site shadows. The rule propagates async, so purge, wait for the edge to serve it, then purge again so nothing stale survives (the header can confirm on a cached response, which is why the second purge is load-bearing). export async function promoteDeploy(opts: { coreClient: CoreClient; state: RemoteSiteState; deployId: string; -}): Promise { +}): Promise { const { coreClient, state, deployId } = opts; const purge = () => coreClient.POST("/pullzone/{id}/purgeCache", { @@ -928,7 +976,7 @@ export async function promoteDeploy(opts: { if (!state.current) { await applySiteCacheSettings(coreClient, state.pullZoneId); } - await ensureSiteRules({ + const shadowed = await ensureSiteRules({ coreClient, pullZoneId: state.pullZoneId, storageZone, @@ -938,7 +986,7 @@ export async function promoteDeploy(opts: { await purge(); const confirmed = await waitForEdgePropagation(host, deployId); await purge(); - return confirmed; + return { confirmed, shadowed }; } // Refuse to replace version-1 state that changed since it was read. `writeRemoteState`'s own conflict merge can't cover this: it reconciles against the current format, and the file being replaced is the older one, so it would abort as unparseable rather than merge. @@ -978,8 +1026,8 @@ export interface MigrateResult { detachedScriptId: number | null; deletedScriptId: number | null; scriptError?: string; - /** False when the current deploy was republished but the edge never reported it. */ - promoteConfirmed: boolean; + + promoted?: PromoteResult; } export async function migrateSite(opts: { coreClient: CoreClient; @@ -1013,10 +1061,10 @@ export async function migrateSite(opts: { }); await applySiteCacheSettings(coreClient, state.pullZoneId); - let promoteConfirmed = true; + let promoted: PromoteResult | undefined; if (state.current) { step("Publishing the current deploy..."); - promoteConfirmed = await promoteDeploy({ + promoted = await promoteDeploy({ coreClient, state, deployId: state.current, @@ -1052,7 +1100,7 @@ export async function migrateSite(opts: { detachedScriptId, deletedScriptId, scriptError, - promoteConfirmed, + promoted, }; } diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index a2b8e4d6..e6ee2e61 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -21,7 +21,7 @@ import { fetchSystemHostname, promoteDeploy, rereadRemoteState, - warnIfUnconfirmed, + warnAfterPromote, writeRemoteState, } from "./api.ts"; import { @@ -461,10 +461,10 @@ export const sitesDeployCommand = defineCommand({ const production = productionUrl(state, systemHost); if (skipUpload && alreadyLive) { - const confirmed = await withSpinner("Checking routing...", () => + const promoted = await withSpinner("Checking routing...", () => promoteDeploy({ coreClient, state, deployId }), ); - warnIfUnconfirmed(confirmed, deployId); + warnAfterPromote(promoted, deployId); if (output === "json") { logger.log( JSON.stringify( @@ -472,7 +472,7 @@ export const sitesDeployCommand = defineCommand({ site: state.name, id: deployId, unchanged: true, - live: confirmed, + live: promoted.confirmed, production: production ?? null, }, null, @@ -543,18 +543,18 @@ export const sitesDeployCommand = defineCommand({ etag = await writeRemoteState(connection, state, etag); } - const promoteConfirmed = await withSpinner( + const promoted = await withSpinner( "Publishing to production...", async () => { - const confirmed = await promoteDeploy({ coreClient, state, deployId }); + const result = await promoteDeploy({ coreClient, state, deployId }); markCurrent(state, deployId); etag = await writeRemoteState(connection, state, etag, { promotedTo: deployId, }); - return confirmed; + return result; }, ); - warnIfUnconfirmed(promoteConfirmed, deployId); + warnAfterPromote(promoted, deployId); if (output === "json") { logger.log( @@ -566,7 +566,7 @@ export const sitesDeployCommand = defineCommand({ files: files.length, bytes: totalBytes, unchanged: skipUpload, - live: promoteConfirmed, + live: promoted.confirmed, production: production ?? null, }, null, @@ -578,7 +578,7 @@ export const sitesDeployCommand = defineCommand({ if (skipUpload) { logger.success( - promoteConfirmed + promoted.confirmed ? `Deploy ${deployId} is now live.` : `Published deploy ${deployId}.`, ); diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts index bb178b83..f418e79c 100644 --- a/packages/cli/src/commands/sites/deployments/publish.ts +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -2,7 +2,7 @@ import { createCoreClient } from "@bunny.net/openapi-client"; import { promoteDeploy, rereadRemoteState, - warnIfUnconfirmed, + warnAfterPromote, writeRemoteState, } from "@/commands/sites/api.ts"; import { findDeploy, markCurrent } from "@/commands/sites/constants.ts"; @@ -133,7 +133,7 @@ export const sitesDeploymentsPublishCommand = defineCommand({ return; } - const promoteConfirmed = await withSpinner("Publishing...", async () => { + const promoted = await withSpinner("Publishing...", async () => { // Revalidate on fresh state right before promoting: the confirmation window is long enough for a concurrent replace to have dropped this deploy's record and started rewriting its files. const { state: latest, etag: latestEtag } = await rereadRemoteState( connection, @@ -145,7 +145,7 @@ export const sitesDeploymentsPublishCommand = defineCommand({ "Run `bunny sites deployments list` and retry.", ); } - const confirmed = await promoteDeploy({ + const result = await promoteDeploy({ coreClient, state: latest, deployId: targetId, @@ -154,9 +154,9 @@ export const sitesDeploymentsPublishCommand = defineCommand({ await writeRemoteState(connection, latest, latestEtag, { promotedTo: targetId, }); - return confirmed; + return result; }); - warnIfUnconfirmed(promoteConfirmed, targetId); + warnAfterPromote(promoted, targetId); if (output === "json") { logger.log( @@ -165,7 +165,7 @@ export const sitesDeploymentsPublishCommand = defineCommand({ site: state.name, id: targetId, published: true, - live: promoteConfirmed, + live: promoted.confirmed, }, null, 2, @@ -175,7 +175,7 @@ export const sitesDeploymentsPublishCommand = defineCommand({ } logger.success( - promoteConfirmed + promoted.confirmed ? `Deploy ${targetId} is now live.` : `Published deploy ${targetId}.`, ); diff --git a/packages/cli/src/commands/sites/migrate.ts b/packages/cli/src/commands/sites/migrate.ts index 6c3d2702..e9167cb6 100644 --- a/packages/cli/src/commands/sites/migrate.ts +++ b/packages/cli/src/commands/sites/migrate.ts @@ -20,7 +20,7 @@ import { fetchSystemHostname, migrateSite, siteFiles, - warnIfUnconfirmed, + warnAfterPromote, type ZoneState, } from "./api.ts"; import { @@ -140,7 +140,9 @@ export const sitesMigrateCommand = defineCommand({ ); } if (result.state.current) { - warnIfUnconfirmed(result.promoteConfirmed, result.state.current); + if (result.promoted) { + warnAfterPromote(result.promoted, result.state.current); + } const systemHost = result.state.domain ? undefined : await fetchSystemHostname(coreClient, result.state.pullZoneId); From 64df7ad708ebdd2ac91f3bb2dae2f54e0d054d59 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 20 Oct 2026 14:05:02 +0100 Subject: [PATCH 5/5] fix casing and add comment for future refactoring --- packages/cli/src/commands/sites/api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index e801d5d8..6c4cb092 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -501,6 +501,7 @@ function shadowedOriginRules( return shadowed; } +// Converge the pull zone's rules on `deployId`; create and promote both funnel through here, so a missing or stale rule heals on any run. async function ensureSiteRules(opts: { coreClient: CoreClient; pullZoneId: number; @@ -940,7 +941,7 @@ export function warnAfterPromote( ): void { for (const { name, patterns } of result.shadowed) { logger.warn( - `The Edge rule "${name}" routes ${patterns.join(", ")} to another origin, but the site's serve rule still matches it, so the site answers instead. Exclude it from the "${REWRITE_RULE_DESC}" rule.`, + `The edge rule "${name}" routes ${patterns.join(", ")} to another origin, but the site's serve rule still matches it, so the site answers instead. Exclude it from the "${REWRITE_RULE_DESC}" rule.`, ); } if (result.confirmed) return; @@ -1026,7 +1027,6 @@ export interface MigrateResult { detachedScriptId: number | null; deletedScriptId: number | null; scriptError?: string; - promoted?: PromoteResult; } export async function migrateSite(opts: {