Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/sites-deploy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": patch
---

`bunny sites deploy` handles changed build output at the same commit and nested `bunny.jsonc`, warns about skipped symlinks and unconfirmed publishes, names the file when an upload fails, and keeps build logs out of `--output json`
4 changes: 2 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1035,7 +1035,7 @@ bunny scripts docs

Host static sites on bunny.net. Each site is two resources provisioned and wired together for you: a **storage zone** holding the files and a **pull zone** serving them over the CDN, with edge rules that route requests to the deploy that should answer them. Zones are named `sites-<name>-<suffix>` (the prefix groups them in the dashboard; the suffix is because zone names are global across bunny.net) while commands take the clean site name.

Deploys are immutable: every `sites deploy` uploads to its own `deploys/<id>/` directory and then goes live. Publishing retargets the pull zone's rewrite rule and purges the cache, so going live and rolling back to any earlier deploy are instant and move no files. HTML is served with `max-age=0` so browsers pick up new deploys immediately, while static assets get a one-day browser cache. Deploy IDs are the git short SHA when the working tree is clean and a content hash otherwise, which makes redeploying identical content a no-op.
Deploys are immutable: every `sites deploy` uploads to its own `deploys/<id>/` directory and then goes live. Publishing retargets the pull zone's rewrite rule and purges the cache, so going live and rolling back to any earlier deploy are instant and move no files. HTML is served with `max-age=0` so browsers pick up new deploys immediately, while static assets get a one-day browser cache. Deploy IDs are the git short SHA when the working tree is clean and a content hash otherwise (also when that SHA is already deployed with different bytes, say after changing a build env var), which makes redeploying identical content a no-op.

Commands take the site as an optional positional (`[site]`), except `deploy`, `ci init`, and `deployments publish`, which use `--site`. Either accepts the site name or its storage zone ID. When omitted, the site resolves from the directory's linked site (`.bunny/site.json`, written by `sites link` or by `create`/`deploy`), then `sites.name` in `bunny.jsonc`, then an interactive picker that offers to link. Non-interactive runs (`--output json`, no TTY, or `--force` on a destructive command) error instead of prompting.

Expand Down Expand Up @@ -1085,7 +1085,7 @@ bunny sites delete my-site --keep-storage # typed-name confirmation;

**Client-side routing and 404s.** Single-page apps serve `index.html` for extensionless paths that aren't files (a refresh on `/products/42` returns the app with a 200), while missing assets still 404. This is on automatically when the detected framework is a single-page one (Vite, Create React App, React Router, Angular, Vue CLI, Ember, Preact, Blazor WebAssembly) and the output has a root `index.html` and no `404.html` (a built not-found page wins over the framework heuristic); set `sites.spa` to `true` or `false` in `bunny.jsonc`, or pass `--spa`/`--no-spa` on the deploy, to decide yourself. When neither applies but the output looks client-routed (a single root `index.html` plus scripts), an interactive deploy asks once and saves the answer to `sites.spa`; the flags answer it in scripts. Otherwise a root `404.html` in the output (as Astro, Eleventy, Hugo, and most static generators emit) becomes the site's not-found page, served with a 404 status. The choice is recorded per deploy and follows rollbacks; `sites show` prints the live one.

Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`, `spa`) and a deploy needs no arguments: `bunny sites deploy --build`. `sites ci init` reads the same block, so the generated workflow builds and deploys exactly what the local command does; without it, the framework is detected from `package.json` deps, a Blazor WebAssembly `.csproj`, `Gemfile`, or a `hugo`/`python`/`zola` config file, with the lockfile picking the package manager. `sites create` offers to scaffold the workflow on GitHub repos.
Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`, `spa`) and a deploy needs no arguments: `bunny sites deploy --build`. `deploy` reads the nearest `bunny.jsonc` up from the working directory, else the one above the deploy directory, so a run from a monorepo root still picks up a nested project's config. `sites ci init` reads the same block, so the generated workflow builds and deploys exactly what the local command does; without it, the framework is detected from `package.json` deps, a Blazor WebAssembly `.csproj`, `Gemfile`, or a `hugo`/`python`/`zola` config file, with the lockfile picking the package manager. `sites create` offers to scaffold the workflow on GitHub repos.

**Importing an existing zone.** `sites create <name> --from-zone <zone>` turns a storage zone you already serve through a pull zone (for example one deployed by a community storage action) into a site, so custom hostnames, certificates, and DNS stay as they are. The import only writes the site state and the edge rules that block `/_bunny/` and `/deploys/`; nothing else visitors see changes until the first `sites deploy`, which adds the rest of the site's rules and cache settings and goes live on the existing hostnames (a wildcard hostname never becomes the production domain). Files already at the zone root stay in storage but stop being served after that deploy; delete them yourself once you're happy. The edge blocks `_bunny/` and `deploys/` at any depth, so a nested path such as `docs/_bunny/` or `assets/deploys/` stops being served at import. The import refuses a zone that already has a root `deploys` or `_bunny` entry, a pull zone running an edge script, or anything but exactly one storage-backed pull zone; `--region`, `--tier` and `--domain` don't apply. Other edge rules on the pull zone are left in place. `sites delete` treats an imported site like any other and deletes both zones with everything in them.

Expand Down
30 changes: 22 additions & 8 deletions packages/cli/src/commands/sites/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -814,21 +814,23 @@ export const promoteVerification = {
new Promise((resolve) => setTimeout(resolve, ms)),
};

// Wait until the edge serves the promoted deploy, identified by the rewrite rule's response header.
// Wait until the edge serves the promoted deploy, identified by the rewrite rule's response header; false when the deadline passed unconfirmed.
async function waitForEdgePropagation(
host: string,
deployId: string,
): Promise<void> {
): Promise<boolean> {
const start = Date.now();
const deadline = start + PROPAGATION_DEADLINE_MS;
let attempt = 0;
while (Date.now() < deadline) {
let confirmed = false;
while (!confirmed && 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;
confirmed = deploy === deployId;
if (confirmed) break;
} catch {
// Edge briefly unreachable (DNS/warmup); keep trying until the deadline.
}
Expand All @@ -839,8 +841,12 @@ async function waitForEdgePropagation(
if (elapsed < SETTLE_FLOOR_MS) {
await promoteVerification.wait(SETTLE_FLOOR_MS - elapsed);
}
return confirmed;
}

export const UNCONFIRMED_PUBLISH_WARNING =
"The edge hasn't confirmed the new deploy yet; it can take a minute to show everywhere.";

async function ensureNotFoundSettings(
coreClient: CoreClient,
storageZone: StorageZoneModel,
Expand All @@ -864,7 +870,7 @@ export async function promoteDeploy(opts: {
coreClient: CoreClient;
state: RemoteSiteState;
deployId: string;
}): Promise<void> {
}): Promise<boolean> {
const { coreClient, state, deployId } = opts;
const purge = () =>
coreClient.POST("/pullzone/{id}/purgeCache", {
Expand Down Expand Up @@ -894,8 +900,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.
Expand Down Expand Up @@ -935,6 +942,8 @@ export interface MigrateResult {
detachedScriptId: number | null;
deletedScriptId: number | null;
scriptError?: string;
/** False when the edge didn't confirm the republished deploy in time. */
confirmed: boolean;
}
export async function migrateSite(opts: {
coreClient: CoreClient;
Expand Down Expand Up @@ -968,9 +977,14 @@ export async function migrateSite(opts: {
});
await applySiteCacheSettings(coreClient, state.pullZoneId);

let confirmed = true;
if (state.current) {
step("Publishing the current deploy...");
await promoteDeploy({ coreClient, state, deployId: state.current });
confirmed = 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.
Expand All @@ -997,7 +1011,7 @@ export async function migrateSite(opts: {
}
}

return { state, detachedScriptId, deletedScriptId, scriptError };
return { state, detachedScriptId, deletedScriptId, scriptError, confirmed };
}

interface TeardownResult {
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/sites/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ export async function runBuildCommand(
cwd,
env: { ...process.env, ...env },
stdin: "ignore",
stdout: "inherit",
// Build logs go to stderr so `--output json` keeps stdout to the JSON payload.
stdout: 2,
stderr: "inherit",
});
const code = await proc.exited;
Expand Down
14 changes: 12 additions & 2 deletions packages/cli/src/commands/sites/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { expect, test } from "bun:test";
import { mkdtempSync, readFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { parse as parseJsonc } from "jsonc-parser";
import { saveSiteConfig } from "./config.ts";
import { loadSiteConfig, saveSiteConfig } from "./config.ts";

test("saveSiteConfig edits the sites block in place, keeping comments and siblings", async () => {
const path = join(
Expand All @@ -29,3 +29,13 @@ test("saveSiteConfig edits the sites block in place, keeping comments and siblin
sites: { name: "my-site", spa: false },
});
});

test("loadSiteConfig(from) finds the bunny.jsonc above a nested deploy directory", async () => {
const project = join(mkdtempSync(join(tmpdir(), "sites-config-")), "web");
mkdirSync(join(project, "dist"), { recursive: true });
await Bun.write(join(project, "bunny.jsonc"), `{ "sites": { "spa": true } }`);
expect(loadSiteConfig(join(project, "dist"))).toEqual({
config: { spa: true },
root: project,
});
});
11 changes: 9 additions & 2 deletions packages/cli/src/commands/sites/config.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import {
CURRENT_VERSION,
type SiteConfig,
SiteConfigSchema,
} from "@bunny.net/config";
import {
CONFIG_FILENAME,
configPath,
findConfigRoot,
readBunnyConfig,
SCHEMA_REF,
} from "@/core/bunny-config.ts";
Expand All @@ -19,8 +22,12 @@ interface LoadedSiteConfig {
}

// Read the `sites` block from `bunny.jsonc`, validating only that block so a sites-only file needs no `app` (or `version`); returns null when no file or no `sites` block.
export function loadSiteConfig(): LoadedSiteConfig | null {
const found = readBunnyConfig();
export function loadSiteConfig(from?: string): LoadedSiteConfig | null {
const fromRoot = from ? findConfigRoot(from) : undefined;
if (fromRoot === null) return null;
const found = readBunnyConfig(
fromRoot ? join(fromRoot, CONFIG_FILENAME) : undefined,
);
if (!found) return null;

const sites = (found.data as Record<string, unknown> | null)?.sites;
Expand Down
21 changes: 16 additions & 5 deletions packages/cli/src/commands/sites/deploy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,16 +135,27 @@ test("replacing the live or rollback deploy's content is refused, even with --fo
reason: "rollback",
});

// Same git sha over different bytes lands on the same ID without --deploy-id.
const gitLive = deploy("aaaa1111", "hash1", "git");
// Same git sha over different bytes falls back to the content hash instead of colliding with the live deploy.
expect(
resolveDeployTarget({
deploys: [gitLive],
deploys: [deploy("aaaa1111", "hash1", "git")],
identity: identity("aaaa1111", "hash2", "git"),
force: false,
current: "aaaa1111",
}).conflict,
).toEqual({ record: gitLive, reason: "live" });
}),
).toEqual({ deployId: "hash2", skipUpload: false });
// ...unless another deploy already holds the hash as its ID, which the fallback must not quietly replace.
expect(
resolveDeployTarget({
deploys: [
deploy("aaaa1111", "hash1", "git"),
deploy("hash2", "other", "custom"),
],
identity: identity("aaaa1111", "hash2", "git"),
force: false,
current: "aaaa1111",
}).deployId,
).toBe("aaaa1111");
});

// --force's "redeploy unchanged content" path: every write is byte-identical, so in-place is safe.
Expand Down
31 changes: 25 additions & 6 deletions packages/cli/src/commands/sites/deploy.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { existsSync, statSync } from "node:fs";
import { resolve } from "node:path";
import { join, resolve } from "node:path";
import { createCoreClient } from "@bunny.net/openapi-client";
import { resolveConfig } from "@/config/index.ts";
import { CONFIG_FILENAME } from "@/core/bunny-config.ts";
import { clientOptions } from "@/core/client-options.ts";
import { defineCommand } from "@/core/define-command.ts";
import { collectEnv } from "@/core/env.ts";
Expand All @@ -21,6 +22,7 @@ import {
fetchSystemHostname,
promoteDeploy,
rereadRemoteState,
UNCONFIRMED_PUBLISH_WARNING,
writeRemoteState,
} from "./api.ts";
import {
Expand Down Expand Up @@ -143,8 +145,17 @@ export function resolveDeployTarget(opts: {
? d.id === customId && d.contentHash === identity.contentHash
: d.contentHash === identity.contentHash,
);
// A rebuild at an already-deployed git sha with different bytes (new env, non-deterministic build) lands under its content hash instead.
const heldElsewhere = (id: string) =>
deploys.some((d) => d.id === id && d.contentHash !== identity.contentHash);
// Only when the hash ID is free: falling back onto another deploy's ID would quietly replace it.
const shaTaken =
identity.source === "git" &&
heldElsewhere(identity.id) &&
!heldElsewhere(identity.contentHash);
// A skipped deploy reuses the already-uploaded deploy's id; that's where its files live.
const deployId = alreadyUploaded?.id ?? identity.id;
const deployId =
alreadyUploaded?.id ?? (shaTaken ? identity.contentHash : identity.id);
Comment on lines +157 to +158

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mark hash-fallback deployments as content-derived

When a clean git SHA is already associated with different bytes, this branch changes the selected ID to identity.contentHash but leaves identity.source as "git". The newly written DeployRecord and the deploy command's JSON response therefore claim that the content-hash ID was chosen from git, and deployments list renders the original SHA as its source; carry a content-derived source through DeployTarget when this fallback is selected.

Useful? React with 👍 / 👎.

const skipUpload = alreadyUploaded !== undefined;

if (customId && !skipUpload) {
Expand Down Expand Up @@ -262,7 +273,9 @@ export const sitesDeployCommand = defineCommand<DeployArgs>({

handler: async (args) => {
const { profile, output, verbose, apiKey } = args;
const siteConfig = loadSiteConfig();
// CI deploys a nested project's output from the repo root, so fall back to the bunny.jsonc above the deploy directory.
const siteConfig =
loadSiteConfig() ?? (args.dir ? loadSiteConfig(args.dir) : null);
const root = siteConfig?.root ?? process.cwd();
const explicitDir = args.dir ?? siteConfig?.config.dir;

Expand Down Expand Up @@ -369,7 +382,10 @@ export const sitesDeployCommand = defineCommand<DeployArgs>({
"This looks like a single-page app. Serve index.html for client-side routes so deep links survive a refresh?",
{ initial: true, optional: true },
);
const savedTo = saveSiteConfig({ spa: configuredSpa });
const savedTo = saveSiteConfig(
{ spa: configuredSpa },
siteConfig ? join(siteConfig.root, CONFIG_FILENAME) : undefined,
);
logger.dim(` Saved sites.spa: ${configuredSpa} to ${savedTo}`);
}
const notFound = resolveNotFoundMode(paths, {
Expand Down Expand Up @@ -460,9 +476,10 @@ export const sitesDeployCommand = defineCommand<DeployArgs>({
const production = productionUrl(state, systemHost);

if (skipUpload && alreadyLive) {
await withSpinner("Checking routing...", () =>
const confirmed = await withSpinner("Checking routing...", () =>
promoteDeploy({ coreClient, state, deployId }),
);
if (!confirmed) logger.warn(UNCONFIRMED_PUBLISH_WARNING);
if (output === "json") {
logger.log(
JSON.stringify(
Expand Down Expand Up @@ -541,12 +558,14 @@ export const sitesDeployCommand = defineCommand<DeployArgs>({
etag = await writeRemoteState(connection, state, etag);
}

let confirmed = true;
await withSpinner("Publishing to production...", async () => {
await promoteDeploy({ coreClient, state, deployId });
confirmed = await promoteDeploy({ coreClient, state, deployId });
markCurrent(state, deployId);
etag = await writeRemoteState(connection, state, etag, {
promotedTo: deployId,
});
if (!confirmed) logger.warn(UNCONFIRMED_PUBLISH_WARNING);
});

if (output === "json") {
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/commands/sites/deployments/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createCoreClient } from "@bunny.net/openapi-client";
import {
promoteDeploy,
rereadRemoteState,
UNCONFIRMED_PUBLISH_WARNING,
writeRemoteState,
} from "@/commands/sites/api.ts";
import { findDeploy, markCurrent } from "@/commands/sites/constants.ts";
Expand Down Expand Up @@ -132,6 +133,7 @@ export const sitesDeploymentsPublishCommand = defineCommand<PublishArgs>({
return;
}

let confirmed = true;
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(
Expand All @@ -144,12 +146,17 @@ export const sitesDeploymentsPublishCommand = defineCommand<PublishArgs>({
"Run `bunny sites deployments list` and retry.",
);
}
await promoteDeploy({ coreClient, state: latest, deployId: targetId });
confirmed = await promoteDeploy({
coreClient,
state: latest,
deployId: targetId,
});
markCurrent(latest, targetId);
await writeRemoteState(connection, latest, latestEtag, {
promotedTo: targetId,
});
});
if (!confirmed) logger.warn(UNCONFIRMED_PUBLISH_WARNING);

if (output === "json") {
logger.log(
Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/sites/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
fetchSystemHostname,
migrateSite,
siteFiles,
UNCONFIRMED_PUBLISH_WARNING,
type ZoneState,
} from "./api.ts";
import {
Expand Down Expand Up @@ -126,6 +127,7 @@ export const sitesMigrateCommand = defineCommand<MigrateArgs>({
);

logger.success(`Migrated "${name}" to the edge-rule architecture.`);
if (!result.confirmed) logger.warn(UNCONFIRMED_PUBLISH_WARNING);
if (result.scriptError) {
logger.warn(
`Couldn't delete edge script ${result.detachedScriptId}: ${result.scriptError}`,
Expand Down
Loading
Loading