From f53b00247e8f0e05fe110d170531bedd740bce08 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Wed, 7 Oct 2026 17:18:05 +0100 Subject: [PATCH 1/2] fix(sites): deploy handles rebuilt output, symlinks, nested bunny.jsonc, and upload and publish failures --- .changeset/sites-deploy.md | 5 ++ packages/cli/README.md | 4 +- packages/cli/src/commands/sites/api.ts | 30 ++++++++--- packages/cli/src/commands/sites/build.ts | 3 +- .../cli/src/commands/sites/config.test.ts | 14 +++++- packages/cli/src/commands/sites/config.ts | 11 +++- .../cli/src/commands/sites/deploy.test.ts | 21 ++++++-- packages/cli/src/commands/sites/deploy.ts | 35 ++++++++++--- .../src/commands/sites/deployments/publish.ts | 9 +++- packages/cli/src/commands/sites/migrate.ts | 2 + .../cli/src/commands/sites/uploader.test.ts | 22 +++++++- packages/cli/src/commands/sites/uploader.ts | 50 +++++++++++++++++-- packages/cli/src/core/bunny-config.ts | 6 +-- skills/bunny-cli/references/sites.md | 4 +- 14 files changed, 177 insertions(+), 39 deletions(-) create mode 100644 .changeset/sites-deploy.md diff --git a/.changeset/sites-deploy.md b/.changeset/sites-deploy.md new file mode 100644 index 00000000..94b1a479 --- /dev/null +++ b/.changeset/sites-deploy.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/cli": patch +--- + +`bunny sites deploy` handles changed build output at the same commit, symlinks, and nested `bunny.jsonc`; failed uploads name the file, build logs stay out of `--output json`, and an unconfirmed publish warns diff --git a/packages/cli/README.md b/packages/cli/README.md index f87bd5ce..920d48fa 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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--` (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//` 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//` 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. @@ -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 --from-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. diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index 1b599de6..4b97ade2 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -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 { +): Promise { 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. } @@ -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, @@ -864,7 +870,7 @@ 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", { @@ -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. @@ -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; @@ -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. @@ -997,7 +1011,7 @@ export async function migrateSite(opts: { } } - return { state, detachedScriptId, deletedScriptId, scriptError }; + return { state, detachedScriptId, deletedScriptId, scriptError, confirmed }; } interface TeardownResult { diff --git a/packages/cli/src/commands/sites/build.ts b/packages/cli/src/commands/sites/build.ts index 1e83183e..486c9569 100644 --- a/packages/cli/src/commands/sites/build.ts +++ b/packages/cli/src/commands/sites/build.ts @@ -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; diff --git a/packages/cli/src/commands/sites/config.test.ts b/packages/cli/src/commands/sites/config.test.ts index ec5004a7..d283b46b 100644 --- a/packages/cli/src/commands/sites/config.test.ts +++ b/packages/cli/src/commands/sites/config.test.ts @@ -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( @@ -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, + }); +}); diff --git a/packages/cli/src/commands/sites/config.ts b/packages/cli/src/commands/sites/config.ts index 67d0a39f..0fbdd1d3 100644 --- a/packages/cli/src/commands/sites/config.ts +++ b/packages/cli/src/commands/sites/config.ts @@ -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"; @@ -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 | null)?.sites; diff --git a/packages/cli/src/commands/sites/deploy.test.ts b/packages/cli/src/commands/sites/deploy.test.ts index 5a3da1dd..f74f2d9b 100644 --- a/packages/cli/src/commands/sites/deploy.test.ts +++ b/packages/cli/src/commands/sites/deploy.test.ts @@ -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. diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 96293a4e..d6506e0b 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -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"; @@ -21,6 +22,7 @@ import { fetchSystemHostname, promoteDeploy, rereadRemoteState, + UNCONFIRMED_PUBLISH_WARNING, writeRemoteState, } from "./api.ts"; import { @@ -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); const skipUpload = alreadyUploaded !== undefined; if (customId && !skipUpload) { @@ -262,7 +273,10 @@ export const sitesDeployCommand = defineCommand({ 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 cwdConfig = loadSiteConfig(); + const siteConfig = + cwdConfig ?? (args.dir ? loadSiteConfig(args.dir) : null); const root = siteConfig?.root ?? process.cwd(); const explicitDir = args.dir ?? siteConfig?.config.dir; @@ -288,7 +302,8 @@ export const sitesDeployCommand = defineCommand({ // No `force` here: deploy's --force only redeploys unchanged content, so the picker stays. const { site, offerLink } = await selectSite(coreClient, { - site: args.site, + // Site selection reads bunny.jsonc from cwd, so a nested config's name is passed in explicitly. + site: args.site ?? (cwdConfig ? undefined : siteConfig?.config.name), link: args.link, output, offerCreate: async () => { @@ -369,7 +384,10 @@ export const sitesDeployCommand = defineCommand({ "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, { @@ -460,9 +478,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 }), ); + if (!confirmed) logger.warn(UNCONFIRMED_PUBLISH_WARNING); if (output === "json") { logger.log( JSON.stringify( @@ -541,12 +560,14 @@ export const sitesDeployCommand = defineCommand({ 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") { diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts index eacaf4b8..14ade2df 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, + UNCONFIRMED_PUBLISH_WARNING, writeRemoteState, } from "@/commands/sites/api.ts"; import { findDeploy, markCurrent } from "@/commands/sites/constants.ts"; @@ -132,6 +133,7 @@ export const sitesDeploymentsPublishCommand = defineCommand({ 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( @@ -144,12 +146,17 @@ export const sitesDeploymentsPublishCommand = defineCommand({ "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( diff --git a/packages/cli/src/commands/sites/migrate.ts b/packages/cli/src/commands/sites/migrate.ts index 04f06500..7eda9d52 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, + UNCONFIRMED_PUBLISH_WARNING, type ZoneState, } from "./api.ts"; import { @@ -126,6 +127,7 @@ export const sitesMigrateCommand = defineCommand({ ); 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}`, diff --git a/packages/cli/src/commands/sites/uploader.test.ts b/packages/cli/src/commands/sites/uploader.test.ts index 2dc1e15b..80303dcc 100644 --- a/packages/cli/src/commands/sites/uploader.test.ts +++ b/packages/cli/src/commands/sites/uploader.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync } from "node:fs"; +import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { StorageZone } from "@/commands/storage/files-api.ts"; @@ -37,6 +37,26 @@ test("collectFiles skips dotfiles and node_modules but keeps .well-known, sorted ]); }); +test("collectFiles follows symlinked files and dirs without looping", () => { + const dir = mkdtempSync(join(tmpdir(), "bunny-sites-links-")); + mkdirSync(join(dir, "shared")); + writeFileSync(join(dir, "shared", "logo.svg"), ""); + symlinkSync(join(dir, "shared"), join(dir, "img")); + symlinkSync(join(dir, "shared", "logo.svg"), join(dir, "favicon.svg")); + symlinkSync(dir, join(dir, "shared", "loop")); + // Links out of the deploy dir, or onto an excluded dotfile inside it, never ship. + const outside = mkdtempSync(join(tmpdir(), "bunny-sites-secret-")); + writeFileSync(join(outside, ".env"), "SECRET=1"); + symlinkSync(join(outside, ".env"), join(dir, "config.txt")); + writeFileSync(join(dir, ".env"), "SECRET=1"); + symlinkSync(join(dir, ".env"), join(dir, "env.txt")); + expect(collectFiles(dir).map((f) => f.path)).toEqual([ + "favicon.svg", + "img/logo.svg", + "shared/logo.svg", + ]); +}); + test("uploadDeploy targets deploys/{id}, sends checksums, and retries failures", async () => { const dir = tree(); const files = await hashFiles(collectFiles(dir)); diff --git a/packages/cli/src/commands/sites/uploader.ts b/packages/cli/src/commands/sites/uploader.ts index 1fcae100..54e14d4e 100644 --- a/packages/cli/src/commands/sites/uploader.ts +++ b/packages/cli/src/commands/sites/uploader.ts @@ -1,7 +1,9 @@ -import { readdirSync, statSync } from "node:fs"; -import { join } from "node:path"; +import { readdirSync, realpathSync, statSync } from "node:fs"; +import { isAbsolute, join, relative, sep } from "node:path"; import type { StorageZone } from "@/commands/storage/files-api.ts"; import { mapWithConcurrency } from "@/core/concurrency.ts"; +import { errorMessage, UserError } from "@/core/errors.ts"; +import { logger } from "@/core/logger.ts"; import { siteFiles } from "./api.ts"; import { deployPrefix } from "./constants.ts"; @@ -31,15 +33,47 @@ function shouldSkipEntry(name: string): boolean { /** Recursively collect the files to deploy, sorted by path for determinism. */ export function collectFiles(dir: string): LocalFile[] { const files: LocalFile[] = []; + // Real paths of the directories on the current walk, so a symlink back up the tree can't loop. + const ancestors = new Set(); + const rootReal = realpathSync(dir); + + // A link may only resolve to deployable content inside the deploy dir, so `config -> ../.env` can't publish a private file. + const linkStaysInside = (entryAbs: string): boolean => { + let target: string; + try { + target = realpathSync(entryAbs); + } catch { + return true; // Dangling: statSync below finds nothing and it's skipped. + } + const rel = relative(rootReal, target); + return ( + !rel.startsWith("..") && + !isAbsolute(rel) && + !rel.split(sep).some((part) => part && shouldSkipEntry(part)) + ); + }; const walk = (abs: string, rel: string) => { + const real = realpathSync(abs); + if (ancestors.has(real)) return; + ancestors.add(real); for (const entry of readdirSync(abs, { withFileTypes: true })) { if (shouldSkipEntry(entry.name)) continue; const entryAbs = join(abs, entry.name); const entryRel = rel ? `${rel}/${entry.name}` : entry.name; - if (entry.isDirectory()) { + if (entry.isSymbolicLink() && !linkStaysInside(entryAbs)) { + logger.warn( + `Skipped ${entryRel}: it links outside the deploy directory or to an excluded path.`, + ); + continue; + } + // statSync follows symlinks, so linked files and dirs ship as their targets. + const stat = entry.isSymbolicLink() + ? statSync(entryAbs, { throwIfNoEntry: false }) + : entry; + if (stat?.isDirectory()) { walk(entryAbs, entryRel); - } else if (entry.isFile()) { + } else if (stat?.isFile()) { files.push({ path: entryRel, absPath: entryAbs, @@ -48,6 +82,7 @@ export function collectFiles(dir: string): LocalFile[] { } // Sockets, FIFOs, and dangling symlinks are silently skipped. } + ancestors.delete(real); }; walk(dir, ""); @@ -105,7 +140,12 @@ export async function uploadDeploy( Bun.file(file.absPath).stream(), { sha256Checksum: file.sha256.toUpperCase() }, ), - ); + ).catch((err) => { + throw new UserError( + `Uploading ${file.path} failed: ${errorMessage(err)}`, + "Re-run the deploy; nothing goes live until every file is uploaded.", + ); + }); done++; opts?.onFileUploaded?.(done, files.length, file); }); diff --git a/packages/cli/src/core/bunny-config.ts b/packages/cli/src/core/bunny-config.ts index 4664b0ac..e5224b2c 100644 --- a/packages/cli/src/core/bunny-config.ts +++ b/packages/cli/src/core/bunny-config.ts @@ -8,9 +8,9 @@ export const CONFIG_FILENAME = "bunny.jsonc"; export const SCHEMA_REF = "./node_modules/@bunny.net/config/generated/schema.json"; -// Walk up from cwd to the directory holding `bunny.jsonc`, or null when none exists. -export function findConfigRoot(): string | null { - let dir = resolve(process.cwd()); +// Walk up from `from` (default cwd) to the directory holding `bunny.jsonc`, or null when none exists. +export function findConfigRoot(from = process.cwd()): string | null { + let dir = resolve(from); while (true) { if (existsSync(join(dir, CONFIG_FILENAME))) return dir; const parent = dirname(dir); diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 8b907231..1d1633b2 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -43,13 +43,13 @@ Content is root-served, so root-absolute assets work as-is. Single-page apps get ## Deploy IDs -- The deploy ID is the **git short-sha** when the working tree is clean, otherwise a 12-char **content hash**. Re-deploying identical content is a no-op (`--force` overrides). +- The deploy ID is the **git short-sha** when the working tree is clean, otherwise a 12-char **content hash**; a clean tree whose sha is already deployed with different bytes (a changed build env, a non-deterministic build) also falls back to the content hash. Re-deploying identical content is a no-op (`--force` overrides). - `--deploy-id ` sets the ID yourself, so a deploy can carry the same identifier as whatever produced it (a release tag, a catalog build, a timestamped artifact) and `deployments list` needs no cross-referencing. The ID is used **exactly as given**, case included: it exists to match your identifier, and it never appears in a client-facing URL (the edge rule builds the origin path from it server-side). IDs become storage paths, so they take letters, digits and `-`, `_` or `.`, 4 to 64 characters, starting and ending alphanumeric: `20260827-1433-r42`, `Catalog_V3`, `v1.2.3`. - Deploy IDs are therefore **case-sensitive**. `publish`/`delete` match exactly and suggest a case variant when one exists, and deploying an ID that differs from an existing one only in case is refused (not even with `--force`), since two storage paths differing only by case are indistinguishable to anything that folds case. - An explicit ID is an assertion about identity, so it is never aliased onto an earlier deploy that happens to share content: each release keeps its own ID and rollback target even when the bytes are unchanged. - Reusing an ID for **different** content asks before replacing, because rolling back to that ID would then serve the new files instead of the originals (`--force` skips the prompt for CI). A replacement clears the old files first, so nothing stale survives. The **live deploy and the rollback target are never replaceable in place** (not even with `--force`): that would empty and rewrite the files being served. Deploy under a new ID, or publish another deploy first. - The git sha is still recorded alongside a custom ID when the deploy came from a repo, so provenance is not lost; `deployments list` shows it as `custom (git abc12345)`. -- Dotfiles and `node_modules` are never uploaded. +- Dotfiles and `node_modules` are never uploaded, except `.well-known/`. Symlinked files and directories upload as their targets when those resolve inside the deploy directory; a link pointing outside it (or onto an excluded dotfile) is skipped with a warning. --- From 41c7c53af847f7491bb5c4378f715ad846523c1c Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Wed, 7 Oct 2026 17:56:34 +0100 Subject: [PATCH 2/2] fix(sites): skip symlinks instead of following them, and keep linked-site precedence --- .changeset/sites-deploy.md | 2 +- packages/cli/src/commands/sites/deploy.ts | 6 +-- .../cli/src/commands/sites/uploader.test.ts | 22 +--------- packages/cli/src/commands/sites/uploader.ts | 43 +++---------------- skills/bunny-cli/references/sites.md | 2 +- 5 files changed, 12 insertions(+), 63 deletions(-) diff --git a/.changeset/sites-deploy.md b/.changeset/sites-deploy.md index 94b1a479..4a13dfba 100644 --- a/.changeset/sites-deploy.md +++ b/.changeset/sites-deploy.md @@ -2,4 +2,4 @@ "@bunny.net/cli": patch --- -`bunny sites deploy` handles changed build output at the same commit, symlinks, and nested `bunny.jsonc`; failed uploads name the file, build logs stay out of `--output json`, and an unconfirmed publish warns +`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` diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index d6506e0b..f563c057 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -274,9 +274,8 @@ export const sitesDeployCommand = defineCommand({ handler: async (args) => { const { profile, output, verbose, apiKey } = args; // CI deploys a nested project's output from the repo root, so fall back to the bunny.jsonc above the deploy directory. - const cwdConfig = loadSiteConfig(); const siteConfig = - cwdConfig ?? (args.dir ? loadSiteConfig(args.dir) : null); + loadSiteConfig() ?? (args.dir ? loadSiteConfig(args.dir) : null); const root = siteConfig?.root ?? process.cwd(); const explicitDir = args.dir ?? siteConfig?.config.dir; @@ -302,8 +301,7 @@ export const sitesDeployCommand = defineCommand({ // No `force` here: deploy's --force only redeploys unchanged content, so the picker stays. const { site, offerLink } = await selectSite(coreClient, { - // Site selection reads bunny.jsonc from cwd, so a nested config's name is passed in explicitly. - site: args.site ?? (cwdConfig ? undefined : siteConfig?.config.name), + site: args.site, link: args.link, output, offerCreate: async () => { diff --git a/packages/cli/src/commands/sites/uploader.test.ts b/packages/cli/src/commands/sites/uploader.test.ts index 80303dcc..2dc1e15b 100644 --- a/packages/cli/src/commands/sites/uploader.test.ts +++ b/packages/cli/src/commands/sites/uploader.test.ts @@ -1,5 +1,5 @@ import { afterEach, expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, symlinkSync, writeFileSync } from "node:fs"; +import { mkdirSync, mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import type { StorageZone } from "@/commands/storage/files-api.ts"; @@ -37,26 +37,6 @@ test("collectFiles skips dotfiles and node_modules but keeps .well-known, sorted ]); }); -test("collectFiles follows symlinked files and dirs without looping", () => { - const dir = mkdtempSync(join(tmpdir(), "bunny-sites-links-")); - mkdirSync(join(dir, "shared")); - writeFileSync(join(dir, "shared", "logo.svg"), ""); - symlinkSync(join(dir, "shared"), join(dir, "img")); - symlinkSync(join(dir, "shared", "logo.svg"), join(dir, "favicon.svg")); - symlinkSync(dir, join(dir, "shared", "loop")); - // Links out of the deploy dir, or onto an excluded dotfile inside it, never ship. - const outside = mkdtempSync(join(tmpdir(), "bunny-sites-secret-")); - writeFileSync(join(outside, ".env"), "SECRET=1"); - symlinkSync(join(outside, ".env"), join(dir, "config.txt")); - writeFileSync(join(dir, ".env"), "SECRET=1"); - symlinkSync(join(dir, ".env"), join(dir, "env.txt")); - expect(collectFiles(dir).map((f) => f.path)).toEqual([ - "favicon.svg", - "img/logo.svg", - "shared/logo.svg", - ]); -}); - test("uploadDeploy targets deploys/{id}, sends checksums, and retries failures", async () => { const dir = tree(); const files = await hashFiles(collectFiles(dir)); diff --git a/packages/cli/src/commands/sites/uploader.ts b/packages/cli/src/commands/sites/uploader.ts index 54e14d4e..233bdb18 100644 --- a/packages/cli/src/commands/sites/uploader.ts +++ b/packages/cli/src/commands/sites/uploader.ts @@ -1,5 +1,5 @@ -import { readdirSync, realpathSync, statSync } from "node:fs"; -import { isAbsolute, join, relative, sep } from "node:path"; +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; import type { StorageZone } from "@/commands/storage/files-api.ts"; import { mapWithConcurrency } from "@/core/concurrency.ts"; import { errorMessage, UserError } from "@/core/errors.ts"; @@ -33,56 +33,27 @@ function shouldSkipEntry(name: string): boolean { /** Recursively collect the files to deploy, sorted by path for determinism. */ export function collectFiles(dir: string): LocalFile[] { const files: LocalFile[] = []; - // Real paths of the directories on the current walk, so a symlink back up the tree can't loop. - const ancestors = new Set(); - const rootReal = realpathSync(dir); - - // A link may only resolve to deployable content inside the deploy dir, so `config -> ../.env` can't publish a private file. - const linkStaysInside = (entryAbs: string): boolean => { - let target: string; - try { - target = realpathSync(entryAbs); - } catch { - return true; // Dangling: statSync below finds nothing and it's skipped. - } - const rel = relative(rootReal, target); - return ( - !rel.startsWith("..") && - !isAbsolute(rel) && - !rel.split(sep).some((part) => part && shouldSkipEntry(part)) - ); - }; const walk = (abs: string, rel: string) => { - const real = realpathSync(abs); - if (ancestors.has(real)) return; - ancestors.add(real); for (const entry of readdirSync(abs, { withFileTypes: true })) { if (shouldSkipEntry(entry.name)) continue; const entryAbs = join(abs, entry.name); const entryRel = rel ? `${rel}/${entry.name}` : entry.name; - if (entry.isSymbolicLink() && !linkStaysInside(entryAbs)) { - logger.warn( - `Skipped ${entryRel}: it links outside the deploy directory or to an excluded path.`, - ); + if (entry.isSymbolicLink()) { + logger.warn(`Skipped ${entryRel}: symlinks aren't deployed.`); continue; } - // statSync follows symlinks, so linked files and dirs ship as their targets. - const stat = entry.isSymbolicLink() - ? statSync(entryAbs, { throwIfNoEntry: false }) - : entry; - if (stat?.isDirectory()) { + if (entry.isDirectory()) { walk(entryAbs, entryRel); - } else if (stat?.isFile()) { + } else if (entry.isFile()) { files.push({ path: entryRel, absPath: entryAbs, size: statSync(entryAbs).size, }); } - // Sockets, FIFOs, and dangling symlinks are silently skipped. + // Sockets and FIFOs are silently skipped. } - ancestors.delete(real); }; walk(dir, ""); diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 1d1633b2..918f6ed9 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -49,7 +49,7 @@ Content is root-served, so root-absolute assets work as-is. Single-page apps get - An explicit ID is an assertion about identity, so it is never aliased onto an earlier deploy that happens to share content: each release keeps its own ID and rollback target even when the bytes are unchanged. - Reusing an ID for **different** content asks before replacing, because rolling back to that ID would then serve the new files instead of the originals (`--force` skips the prompt for CI). A replacement clears the old files first, so nothing stale survives. The **live deploy and the rollback target are never replaceable in place** (not even with `--force`): that would empty and rewrite the files being served. Deploy under a new ID, or publish another deploy first. - The git sha is still recorded alongside a custom ID when the deploy came from a repo, so provenance is not lost; `deployments list` shows it as `custom (git abc12345)`. -- Dotfiles and `node_modules` are never uploaded, except `.well-known/`. Symlinked files and directories upload as their targets when those resolve inside the deploy directory; a link pointing outside it (or onto an excluded dotfile) is skipped with a warning. +- Dotfiles and `node_modules` are never uploaded, except `.well-known/`. Symlinks are skipped with a warning. ---