Skip to content
5 changes: 5 additions & 0 deletions .changeset/sites-custom-deploy-id.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@bunny.net/cli": minor
---

Add `--deploy-id` to `bunny sites deploy` so a deploy can carry your own release identifier. Reusing an ID for different content asks before replacing (`--force` skips the prompt) and clears the old files first; the live deploy and the rollback target are never replaced in place
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ bun ny sites create my-site --tier ssd # provision a site whose files live
bun ny sites deploy # no linked site? offers to create one or pick an existing; detects the framework, offers to build, then deploys (a site's first deploy also offers to attach a custom domain)
bun ny sites deploy ./dist # deploy a directory and publish it as the live site
bun ny sites deploy --build # run `sites.build` from bunny.jsonc (else the detected framework's build), then deploy `sites.dir` (or the detected output dir)
bun ny sites deploy ./catalog --deploy-id 20260827-1433-r42 # identify the deploy with your own release ID instead of the git sha / content hash
bun ny sites deployments list # list deploys with the live one marked
bun ny sites deployments publish --previous # instant rollback to the previous deploy
bun ny sites deployments prune # delete old deploys (keeps the newest 5, never current/previous)
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -927,6 +927,7 @@ bunny sites deploy ./dist # deploy a directory and p
bunny sites deploy --build # run `sites.build` from bunny.jsonc (else the detected build), then deploy
bunny sites deploy --build "npm run build" --env API_URL=https://api.example.com
bunny sites deploy ./dist --site my-site --force # target a site explicitly; redeploy unchanged content
bunny sites deploy ./catalog --deploy-id 20260827-1433-r42 # your own release ID instead of the git sha / content hash

# Deploys: list, publish (roll back), prune
bunny sites deployments list # ● Live / ○ Previous markers, created, source, files, size
Expand Down Expand Up @@ -959,14 +960,15 @@ bunny sites delete my-site --keep-storage # typed-name confirmation;

Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) 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, `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.

Every deploy publishes: the files land in an immutable `deploys/<id>/` directory and the router 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. Content is root-served, so client-side routing and absolute asset paths work as-is. Site state lives at `_bunny/site.json` inside the storage zone (the router blocks it with a 403); `.bunny/site.json` is only a local pointer, so a fresh clone can `sites link` and pick up where the last machine left off.
Every deploy publishes: the files land in an immutable `deploys/<id>/` directory and the router 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 client-side routing and absolute asset paths work as-is. Site state lives at `_bunny/site.json` inside the storage zone (the router blocks it with a 403); `.bunny/site.json` is only a local pointer, so a fresh clone can `sites link` and pick up where the last machine left off.

| Flag | Commands | Description |
| -------------------------------------- | ---------------------------------------------------------- | -------------------------------------------------------------------------------------------------- |
| `--region`, `--domain` | `create` | Main storage region code (default `DE`); custom production domain to attach |
| `--site` | `deploy`, `ci init`, `deployments publish` | Site name or storage zone ID (defaults to the linked site) |
| `--build [cmd]`, `--env`, `--env-file` | `deploy` | Build before deploying (bare flag uses the configured or detected build); build-time env overrides |
| `--force` | `deploy` | Deploy even when the content is unchanged |
| `--force` | `deploy` | Deploy even when the content is unchanged, and replace an existing `--deploy-id` without asking |
| `--deploy-id` | `deploy` | Identify the deploy yourself (release tag, catalog ID); case-sensitive, used exactly as given |
| `--previous` | `deployments publish` | Publish the previous deploy (instant rollback) |
| `--keep` | `deployments prune` | Number of recent deploys to keep (default 5; live and previous are always kept) |
| `--ssl`, `--wait`, `--force-ssl` | `domains add` | Issue SSL now; wait up to 10 minutes for DNS then issue it; `--no-force-ssl` keeps HTTP working |
Expand Down
9 changes: 8 additions & 1 deletion packages/cli/src/commands/sites/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
connectStorageZone,
deleteFile,
downloadFile,
listFiles,
type StorageZone,
uploadFile,
} from "../storage/files-api.ts";
Expand All @@ -48,6 +49,7 @@ type PullZone = components["schemas"]["PullZoneModel"];
export const siteFiles = {
connect: connectStorageZone,
download: downloadFile,
list: listFiles,
upload: uploadFile,
remove: deleteFile,
};
Expand Down Expand Up @@ -653,5 +655,10 @@ export async function deleteDeployFiles(
connection: StorageZone,
deployId: string,
): Promise<void> {
await siteFiles.remove(connection, `${deployPrefix(deployId)}/`);
try {
await siteFiles.remove(connection, `${deployPrefix(deployId)}/`);
} catch (err) {
// An absent prefix is already the goal (a fresh ID, or a re-run after a partial delete).
if (!isNotFoundError(err)) throw err;
}
}
64 changes: 62 additions & 2 deletions packages/cli/src/commands/sites/constants.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { expect, test } from "bun:test";
import type { DeployRecord } from "./constants.ts";
import {
deployIdError,
deployPrefix,
findDeploy,
isValidDeployId,
isValidSiteName,
parseRemoteState,
Expand Down Expand Up @@ -48,11 +51,12 @@ test("deploy path helper", () => {
expect(deployPrefix("a1b2c3d4")).toBe("deploys/a1b2c3d4");
});

test("isValidDeployId accepts git shas and content hashes", () => {
test("isValidDeployId accepts git shas, content hashes and caller-supplied IDs", () => {
expect(isValidDeployId("a1b2c3d4")).toBe(true);
expect(isValidDeployId("0f9e8d7c6b5a4321")).toBe(true);
// Case is part of a caller-supplied ID, not something to normalize away.
expect(isValidDeployId("HAS-CAPS")).toBe(true);
expect(isValidDeployId("ab")).toBe(false); // too short
expect(isValidDeployId("HAS-CAPS")).toBe(false);
expect(isValidDeployId("has/slash")).toBe(false);
expect(isValidDeployId("")).toBe(false);
});
Expand Down Expand Up @@ -82,3 +86,59 @@ test("suffixed resource names round-trip through the site pattern", () => {
});

// Cleanup and site discovery key on the name shape, and the router parses the same shape from the hostname, so the round-trip must be exact and everything else rejected.

test("deployIdError accepts shas, hashes, and release-style IDs, case intact", () => {
for (const id of [
"a1b2c3d4",
"0f1e2d3c4b5a",
"20260827-1433-r42",
"catalog_v3",
"2026.08.27-r42",
"v1.2.3",
"Release-42",
"a".repeat(64),
]) {
expect(deployIdError(id)).toBeNull();
}
});

// The ID is interpolated into a storage path and the router's URL pathname, so anything
// that could escape the deploy prefix or leave an empty/hidden segment has to be rejected.
test("deployIdError rejects path escapes and edge separators", () => {
for (const id of [
"../etc/passwd",
"a/../b",
"foo..bar",
"a/b",
"a\\b",
"a b",
"a?b",
"a%2fb",
"-abc",
"abc.",
"_abc",
]) {
expect(deployIdError(id)).not.toBeNull();
}
expect(deployIdError("abc")).toBe("must be 4 to 64 characters");
expect(deployIdError("a".repeat(65))).toBe("must be 4 to 64 characters");
});

test("findDeploy matches exactly and surfaces a case variant for 'did you mean'", () => {
const deploys: DeployRecord[] = [
{
id: "Release-42",
createdAt: "2026-08-27T00:00:00.000Z",
source: "custom",
contentHash: "hash1",
files: 1,
bytes: 10,
},
];

expect(findDeploy(deploys, "Release-42")).toEqual({ deploy: deploys[0] });
expect(findDeploy(deploys, "release-42")).toEqual({
caseVariant: deploys[0],
});
expect(findDeploy(deploys, "r99")).toEqual({ caseVariant: undefined });
});
36 changes: 32 additions & 4 deletions packages/cli/src/commands/sites/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ export interface SiteManifest {
export interface DeployRecord {
id: string;
createdAt: string;
source: "git" | "content";
/** How the ID was chosen; "custom" means the caller supplied it with --deploy-id. */
source: "git" | "content" | "custom";
gitSha?: string;
dirty?: boolean;
/** Hash of the deployed bytes; the no-op check keys on this. */
Expand Down Expand Up @@ -81,11 +82,38 @@ export function routerScriptName(siteName: string): string {
return `${siteName}-router`;
}

// Deploy IDs are git short-shas or content hashes (lowercase hex-ish); the router regex and storage paths rely on this.
const DEPLOY_ID_RE = /^[a-z0-9]{4,40}$/;
// A deploy ID becomes a storage path and the router's CURRENT_DEPLOY, so its charset is a boundary, not a style choice: alphanumerics plus `-`, `_` and `.`, bounded by an alphanumeric, and never a traversal sequence. Case is preserved rather than folded: a caller-supplied ID exists to match whatever produced the deploy, and the ID never reaches a client-facing URL (the router builds the origin path itself), so nothing downstream needs it normalized.
const DEPLOY_ID_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{2,62}[A-Za-z0-9]$/;

/** Why an ID is unusable, or null when it's fine. Phrased to complete "Deploy ID ...". */
export function deployIdError(id: string): string | null {
if (id.length < 4 || id.length > 64) return "must be 4 to 64 characters";
if (id.includes("..")) return 'must not contain ".."';
if (!DEPLOY_ID_RE.test(id)) {
return "may use only letters, digits, and -, _ or ., and must start and end with a letter or digit";
}
return null;
}

export function isValidDeployId(id: string): boolean {
return DEPLOY_ID_RE.test(id);
return deployIdError(id) === null;
}

/**
* Look up a deploy by ID, exactly.
*
* `caseVariant` is the deploy that differs only in case, so a caller can say
* "did you mean" instead of a bare not-found: IDs preserve the case they were
* given, and eyeballing `Release-42` against `release-42` in a list is no fun.
*/
export function findDeploy(
deploys: DeployRecord[],
id: string,
): { deploy?: DeployRecord; caseVariant?: DeployRecord } {
const deploy = deploys.find((d) => d.id === id);
if (deploy) return { deploy };
const lower = id.toLowerCase();
return { caseVariant: deploys.find((d) => d.id.toLowerCase() === lower) };
}

// Site names become `sites-{name}-{suffix}` zone names; 3-47 chars keeps those within zone-name limits.
Expand Down
33 changes: 33 additions & 0 deletions packages/cli/src/commands/sites/deploy-id.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,3 +82,36 @@ test("clean git repo uses the short sha; dirty tree falls back to content", asyn
expect(dirty.id).toBe(contentHashId(FILES));
expect(dirty.gitSha).toBe(clean.id);
});

test("a custom id wins over git and content, but still records both", async () => {
const dir = mkdtempSync(join(tmpdir(), "bunny-sites-custom-"));
await run(dir, ["init", "-q"]);
await Bun.write(join(dir, "index.html"), "<h1>hi</h1>");
await run(dir, ["add", "."]);
await run(dir, [
"-c",
"user.email=test@example.com",
"-c",
"user.name=test",
"commit",
"-q",
"-m",
"init",
]);

const identity = await resolveDeployIdentity(dir, FILES, "20260827-1433-r42");
expect(identity.id).toBe("20260827-1433-r42");
expect(identity.source).toBe("custom");
// Provenance survives: the git sha is still recorded, and the content hash still drives the no-op check.
expect(identity.gitSha).toMatch(/^[0-9a-f]{8}$/);
expect(identity.contentHash).toBe(contentHashId(FILES));
});

test("a custom id works outside a git repo", async () => {
const dir = mkdtempSync(join(tmpdir(), "bunny-sites-custom-nogit-"));
const identity = await resolveDeployIdentity(dir, FILES, "catalog_v3");
expect(identity.id).toBe("catalog_v3");
expect(identity.source).toBe("custom");
expect(identity.gitSha).toBeUndefined();
expect(identity.contentHash).toBe(contentHashId(FILES));
});
24 changes: 22 additions & 2 deletions packages/cli/src/commands/sites/deploy-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface HashedFile {

export interface DeployIdentity {
id: string;
source: "git" | "content";
source: "git" | "content" | "custom";
gitSha?: string;
dirty?: boolean;
// Hash of the deployed bytes; the no-op check keys on this (not `id`), so a rebuilt `dist/` at the same git sha isn't wrongly skipped.
Expand Down Expand Up @@ -40,13 +40,33 @@ export async function gitIdentity(
};
}

// Resolve the deploy identity: display `id` is the git short-sha on a clean tree, else the content hash; `contentHash` always hashes what ships and drives the no-op check.
/**
* Resolve the deploy identity.
*
* `customId` wins when given, so a release can carry the same ID as whatever
* produced it. Otherwise the display `id` is the git short-sha on a clean tree
* and the content hash elsewhere. `contentHash` always hashes what ships and
* drives the no-op check, so an explicit ID never disturbs change detection;
* the git sha is still recorded when there is one, for provenance.
*/
export async function resolveDeployIdentity(
cwd: string,
files: HashedFile[],
customId?: string,
): Promise<DeployIdentity> {
const contentHash = contentHashId(files);
const gitInfo = await gitIdentity(cwd);

if (customId) {
return {
id: customId,
source: "custom",
gitSha: gitInfo?.sha,
dirty: gitInfo?.dirty,
contentHash,
};
}

if (gitInfo && !gitInfo.dirty) {
return { id: gitInfo.sha, source: "git", gitSha: gitInfo.sha, contentHash };
}
Expand Down
Loading
Loading