diff --git a/.changeset/sites-custom-deploy-id.md b/.changeset/sites-custom-deploy-id.md new file mode 100644 index 00000000..ee2e7089 --- /dev/null +++ b/.changeset/sites-custom-deploy-id.md @@ -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 diff --git a/README.md b/README.md index f62160b8..4e4dd5b4 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/packages/cli/README.md b/packages/cli/README.md index 474ad0d9..302daeab 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 @@ -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//` 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//` 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 | diff --git a/packages/cli/src/commands/sites/api.ts b/packages/cli/src/commands/sites/api.ts index bf70c4a0..d4be32ba 100644 --- a/packages/cli/src/commands/sites/api.ts +++ b/packages/cli/src/commands/sites/api.ts @@ -25,6 +25,7 @@ import { connectStorageZone, deleteFile, downloadFile, + listFiles, type StorageZone, uploadFile, } from "../storage/files-api.ts"; @@ -48,6 +49,7 @@ type PullZone = components["schemas"]["PullZoneModel"]; export const siteFiles = { connect: connectStorageZone, download: downloadFile, + list: listFiles, upload: uploadFile, remove: deleteFile, }; @@ -653,5 +655,10 @@ export async function deleteDeployFiles( connection: StorageZone, deployId: string, ): Promise { - 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; + } } diff --git a/packages/cli/src/commands/sites/constants.test.ts b/packages/cli/src/commands/sites/constants.test.ts index 101b3c98..4ff55a58 100644 --- a/packages/cli/src/commands/sites/constants.test.ts +++ b/packages/cli/src/commands/sites/constants.test.ts @@ -1,6 +1,9 @@ import { expect, test } from "bun:test"; +import type { DeployRecord } from "./constants.ts"; import { + deployIdError, deployPrefix, + findDeploy, isValidDeployId, isValidSiteName, parseRemoteState, @@ -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); }); @@ -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 }); +}); diff --git a/packages/cli/src/commands/sites/constants.ts b/packages/cli/src/commands/sites/constants.ts index 0728e13e..c13f2fa6 100644 --- a/packages/cli/src/commands/sites/constants.ts +++ b/packages/cli/src/commands/sites/constants.ts @@ -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. */ @@ -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. diff --git a/packages/cli/src/commands/sites/deploy-id.test.ts b/packages/cli/src/commands/sites/deploy-id.test.ts index 76d53b2e..a9555896 100644 --- a/packages/cli/src/commands/sites/deploy-id.test.ts +++ b/packages/cli/src/commands/sites/deploy-id.test.ts @@ -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"), "

hi

"); + 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)); +}); diff --git a/packages/cli/src/commands/sites/deploy-id.ts b/packages/cli/src/commands/sites/deploy-id.ts index b810348d..4f6b8d59 100644 --- a/packages/cli/src/commands/sites/deploy-id.ts +++ b/packages/cli/src/commands/sites/deploy-id.ts @@ -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. @@ -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 { 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 }; } diff --git a/packages/cli/src/commands/sites/deploy.test.ts b/packages/cli/src/commands/sites/deploy.test.ts index ad2e303a..80a8527c 100644 --- a/packages/cli/src/commands/sites/deploy.test.ts +++ b/packages/cli/src/commands/sites/deploy.test.ts @@ -1,7 +1,12 @@ import { expect, test } from "bun:test"; import { resolve } from "node:path"; -import type { RemoteSiteState } from "./constants.ts"; -import { productionUrl, resolveDeployDir } from "./deploy.ts"; +import type { DeployRecord, RemoteSiteState } from "./constants.ts"; +import { + productionUrl, + resolveDeployDir, + resolveDeployTarget, +} from "./deploy.ts"; +import type { DeployIdentity } from "./deploy-id.ts"; const ROOT = "/project/root"; @@ -39,3 +44,152 @@ test("productionUrl prefers the custom domain over the system host", () => { test("productionUrl is undefined when the site has neither a domain nor a system host", () => { expect(productionUrl(stateWithDomain(undefined), undefined)).toBeUndefined(); }); + +const deploy = ( + id: string, + contentHash: string, + source: DeployRecord["source"] = "content", +): DeployRecord => ({ + id, + createdAt: "2026-08-27T00:00:00.000Z", + source, + contentHash, + files: 1, + bytes: 10, +}); + +const identity = ( + id: string, + contentHash: string, + source: DeployIdentity["source"] = "content", +): DeployIdentity => ({ id, source, contentHash }); + +test("matching content skips the upload, aliasing onto the existing deploy's id", () => { + const deploys = [deploy("aaaa1111", "hash1")]; + + expect( + resolveDeployTarget({ + deploys, + identity: identity("aaaa1111", "hash1"), + force: false, + }), + ).toEqual({ deployId: "aaaa1111", skipUpload: true }); + + // A different git sha over identical bytes reuses the earlier deploy rather than duplicating it. + expect( + resolveDeployTarget({ + deploys, + identity: identity("bbbb2222", "hash1", "git"), + force: false, + }), + ).toEqual({ deployId: "aaaa1111", skipUpload: true }); +}); + +// A catalog release must keep its own ID even when the bytes match another deploy. +test("a custom id is used exactly as given and never aliases onto another deploy", () => { + expect( + resolveDeployTarget({ + deploys: [deploy("r41", "hash1")], + identity: identity("r42", "hash1", "custom"), + customId: "r42", + force: false, + }), + ).toEqual({ deployId: "r42", skipUpload: false }); + + // Same id, same content: a no-op redeploy. + expect( + resolveDeployTarget({ + deploys: [deploy("Release-42", "hash1", "custom")], + identity: identity("Release-42", "hash1", "custom"), + customId: "Release-42", + force: false, + }), + ).toEqual({ deployId: "Release-42", skipUpload: true }); +}); + +// The handler resolves a content conflict by asking (--force answers yes), so it is reported regardless of force. +test("reusing a custom id for different content conflicts instead of overwriting", () => { + const existing = deploy("r42", "hash1", "custom"); + for (const force of [false, true]) { + const target = resolveDeployTarget({ + deploys: [existing], + identity: identity("r42", "hash2", "custom"), + customId: "r42", + force, + }); + expect(target.conflict).toEqual({ record: existing, reason: "content" }); + } +}); + +// Two deploys whose storage paths differ only by case are indistinguishable to anything that folds case, +// so this one conflict stands even under --force. +test("an id differing only in case is refused, with or without --force", () => { + const existing = deploy("Release-42", "hash1", "custom"); + const args = { + deploys: [existing], + identity: identity("release-42", "hash2", "custom"), + customId: "release-42", + }; + const conflict = { record: existing, reason: "case" } as const; + + expect(resolveDeployTarget({ ...args, force: false }).conflict).toEqual( + conflict, + ); + expect(resolveDeployTarget({ ...args, force: true }).conflict).toEqual( + conflict, + ); +}); + +// Replacing the deploy production serves (or the rollback target) rewrites its prefix while the router reads it, so it is never forceable — custom ID or not. +test("replacing the live or rollback deploy's content is refused, even with --force", () => { + const live = deploy("r42", "hash1", "custom"); + const args = { + deploys: [live], + identity: identity("r42", "hash2", "custom"), + customId: "r42", + force: true, + }; + expect(resolveDeployTarget({ ...args, current: "r42" }).conflict).toEqual({ + record: live, + reason: "live", + }); + expect(resolveDeployTarget({ ...args, previous: "r42" }).conflict).toEqual({ + record: live, + reason: "rollback", + }); + + // Same git sha over different bytes lands on the same ID without --deploy-id. + const gitLive = deploy("aaaa1111", "hash1", "git"); + expect( + resolveDeployTarget({ + deploys: [gitLive], + identity: identity("aaaa1111", "hash2", "git"), + force: false, + current: "aaaa1111", + }).conflict, + ).toEqual({ record: gitLive, reason: "live" }); +}); + +// --force's "redeploy unchanged content" path: every write is byte-identical, so in-place is safe. +test("a forced same-content redeploy of the live deploy is allowed", () => { + expect( + resolveDeployTarget({ + deploys: [deploy("r42", "hash1", "custom")], + identity: identity("r42", "hash1", "custom"), + customId: "r42", + force: true, + current: "r42", + }), + ).toEqual({ deployId: "r42", skipUpload: false }); +}); + +test("a brand new custom id on an empty site just uploads", () => { + expect( + resolveDeployTarget({ + deploys: [], + identity: identity("20260827-1433-r42", "hash1", "custom"), + customId: "20260827-1433-r42", + force: false, + }), + ).toEqual({ deployId: "20260827-1433-r42", skipUpload: false }); +}); diff --git a/packages/cli/src/commands/sites/deploy.ts b/packages/cli/src/commands/sites/deploy.ts index 2609516e..5312777f 100644 --- a/packages/cli/src/commands/sites/deploy.ts +++ b/packages/cli/src/commands/sites/deploy.ts @@ -12,8 +12,15 @@ import { errorMessage, UserError } from "../../core/errors.ts"; import { formatBytes } from "../../core/format.ts"; import { normalizeHostname } from "../../core/hostnames/index.ts"; import { logger } from "../../core/logger.ts"; -import { confirm, isInteractive, prompts, withSpinner } from "../../core/ui.ts"; import { + confirm, + isInteractive, + prompts, + requireConfirmable, + withSpinner, +} from "../../core/ui.ts"; +import { + deleteDeployFiles, ensureRouterCurrent, fetchSystemHostname, promoteDeploy, @@ -28,10 +35,12 @@ import { import { loadSiteConfig } from "./config.ts"; import { type DeployRecord, + deployIdError, + findDeploy, markCurrent, type RemoteSiteState, } from "./constants.ts"; -import { resolveDeployIdentity } from "./deploy-id.ts"; +import { type DeployIdentity, resolveDeployIdentity } from "./deploy-id.ts"; import { setupSiteDomain } from "./domains/index.ts"; import { type SiteSelectorArgs, @@ -48,11 +57,108 @@ interface DeployArgs extends SiteSelectorArgs { env?: string[]; "env-file"?: string; force?: boolean; + "deploy-id"?: string; +} + +export interface DeployTarget { + /** The ID this deploy will live under in storage. */ + deployId: string; + /** True when these exact bytes are already uploaded under `deployId`. */ + skipUpload: boolean; + /** + * An existing deploy that blocks this one. + * + * `content`: the same ID already holds different bytes; the handler asks + * before replacing them (--force answers yes). + * `case`: an ID differing only in case exists. Never replaceable, because two + * deploys whose paths differ only by case are indistinguishable to anything + * that folds case, and the loser's files would back the winner's rollback. + * `live`/`rollback`: the ID holds different bytes AND is the production + * deploy or the rollback target. Never replaceable, because a replacement + * empties and rewrites the very prefix the router serves (or would roll + * back to). + */ + conflict?: { + record: DeployRecord; + reason: "content" | "case" | "live" | "rollback"; + }; +} + +/** + * Decide which ID this deploy lands under and whether the upload can be skipped. + * + * Change detection keys on content, not the display ID, so a rebuilt `dist/` at + * the same git sha is never wrongly skipped. An explicit ID is an assertion + * about identity, so it never aliases onto an earlier deploy that merely shares + * content: a catalog release keeps its own ID even when the bytes are identical + * to the last one. Reusing an ID for different bytes rewrites what a rollback + * to it would serve, so that is reported as a conflict rather than done quietly. + */ +export function resolveDeployTarget(opts: { + deploys: DeployRecord[]; + identity: DeployIdentity; + customId?: string; + force: boolean; + /** The production deploy and the rollback target; their content is never replaced in place. */ + current?: string; + previous?: string; +}): DeployTarget { + const { deploys, identity, customId, force, current, previous } = opts; + + const alreadyUploaded = force + ? undefined + : deploys.find((d) => + customId + ? d.id === customId && d.contentHash === identity.contentHash + : d.contentHash === 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 skipUpload = alreadyUploaded !== undefined; + + if (customId && !skipUpload) { + const { caseVariant } = findDeploy(deploys, customId); + if (caseVariant) { + return { + deployId, + skipUpload, + conflict: { record: caseVariant, reason: "case" }, + }; + } + } + + if (!skipUpload) { + const existing = deploys.find((d) => d.id === deployId); + if (existing && existing.contentHash !== identity.contentHash) { + // Replacing the deploy production serves (or would roll back to) rewrites its prefix while the router reads it, so it is refused outright — checked before the confirmable content conflict, which would otherwise send the caller down a dead end. A same-bytes re-upload stays fine: every write is byte-identical. + if (deployId === current || deployId === previous) { + return { + deployId, + skipUpload, + conflict: { + record: existing, + reason: deployId === current ? "live" : "rollback", + }, + }; + } + if (customId) { + return { + deployId, + skipUpload, + conflict: { record: existing, reason: "content" }, + }; + } + } + } + return { deployId, skipUpload }; } const DOMAIN_HINT = " Add a custom production domain: bunny sites domains add "; +const DEPLOY_ID_HINT = + "IDs become storage paths, so they take letters, digits, and -, _ or . (e.g. 20260827-1433-r42)."; + // A site's live URL: the custom domain when it has one, else its b-cdn.net host. Always https (b-cdn.net hosts carry bunny's certificate). export function productionUrl( state: RemoteSiteState, @@ -85,6 +191,10 @@ export const sitesDeployCommand = defineCommand({ "Explicit build command", ], ["$0 sites deploy ./dist --site my-site", "Target a specific site"], + [ + "$0 sites deploy ./catalog --deploy-id 20260827-1433-r42", + "Identify the deploy with your own release ID", + ], ], builder: (yargs) => @@ -113,7 +223,13 @@ export const sitesDeployCommand = defineCommand({ .option("force", { type: "boolean", default: false, - describe: "Deploy even when the content is unchanged", + describe: + "Deploy even when the content is unchanged, and replace an existing --deploy-id's content without asking", + }) + .option("deploy-id", { + type: "string", + describe: + "Identify this deploy yourself (e.g. a release or catalog ID) instead of using the git sha or content hash; used exactly as given", }), ), @@ -223,15 +339,64 @@ export const sitesDeployCommand = defineCommand({ } const totalBytes = files.reduce((sum, f) => sum + f.size, 0); - const identity = await resolveDeployIdentity(dir, files); + const customId = args["deploy-id"]?.trim(); + if (customId) { + const problem = deployIdError(customId); + if (problem) { + throw new UserError( + `Deploy ID "${customId}" ${problem}.`, + DEPLOY_ID_HINT, + ); + } + } - // The no-op check keys on content, not the display id, so a rebuilt `dist/` at the same git sha isn't wrongly skipped. - const alreadyUploaded = args.force - ? undefined - : state.deploys.find((d) => d.contentHash === identity.contentHash); - const skipUpload = alreadyUploaded !== undefined; - // A skipped deploy reuses the already-uploaded deploy's id; that's where its files live. - const deployId = alreadyUploaded?.id ?? identity.id; + const identity = await resolveDeployIdentity(dir, files, customId); + const target = resolveDeployTarget({ + deploys: state.deploys, + identity, + customId, + force: args.force ?? false, + current: state.current, + previous: state.previous, + }); + + if ( + target.conflict?.reason === "live" || + target.conflict?.reason === "rollback" + ) { + const role = + target.conflict.reason === "live" + ? "the live production deploy" + : "the rollback target"; + throw new UserError( + `Deploy ${target.deployId} is ${role} for ${state.name}, and this content differs from what it holds.`, + "Replacing it in place would rewrite files while the router serves them. Deploy under a new --deploy-id, or publish another deploy first and re-run.", + ); + } + if (target.conflict?.reason === "case") { + throw new UserError( + `Deploy ${target.conflict.record.id} already exists for ${state.name}, differing from "${customId}" only in case.`, + `Reuse that exact ID to redeploy it, or pick one that isn't a case variant.`, + ); + } + if (target.conflict?.reason === "content") { + // Rolling back to the ID would serve the new files instead of the originals, so replacing is opt-in. + requireConfirmable(output, { + force: args.force, + message: `Deploy ${customId} already exists for ${state.name} with different content; replacing it needs a confirmation prompt.`, + hint: "Pick another ID, or re-run with --force to replace it non-interactively.", + }); + const proceed = await confirm( + `Deploy ${customId} already exists for ${state.name} with different content. Replace it?`, + { force: args.force }, + ); + if (!proceed) { + logger.log("Cancelled."); + return; + } + } + + const { deployId, skipUpload } = target; const alreadyLive = state.current === deployId; // The production URL prefers the custom domain; only fetch the system host when there is none. @@ -271,6 +436,19 @@ export const sitesDeployCommand = defineCommand({ } if (!skipUpload) { + // Unless these exact bytes are already recorded under the ID, the upload starts from an empty prefix: emptying first is what keeps a replaced deploy free of files the new content dropped, and also clears half-written leftovers from an interrupted earlier run. + const existing = state.deploys.find((d) => d.id === deployId); + if (existing && existing.contentHash !== identity.contentHash) { + // Drop the record before deleting its files, so no record ever vouches for a prefix mid-rewrite (a concurrent publish re-reads state and refuses an ID without one), and a crashed replacement re-runs as a fresh upload. + state.deploys = state.deploys.filter((d) => d.id !== deployId); + etag = await writeRemoteState(connection, state, etag, { + removedIds: [deployId], + }); + } + if (existing?.contentHash !== identity.contentHash) { + await deleteDeployFiles(connection, deployId); + } + await withSpinner(`Uploading ${files.length} files...`, (spin) => uploadDeploy(connection, deployId, files, { onFileUploaded: (done, total) => { @@ -279,7 +457,7 @@ export const sitesDeployCommand = defineCommand({ }), ); - // Record the deploy. A re-deployed ID keeps its slot but gets fresh metadata; the promote below purges the zone, so its old bytes can't be served. + // Record the deploy. A re-deployed ID keeps its slot but gets fresh metadata; the purge on promote drops the old bytes from cache. const record: DeployRecord = { id: deployId, createdAt: new Date().toISOString(), diff --git a/packages/cli/src/commands/sites/deployments/delete.ts b/packages/cli/src/commands/sites/deployments/delete.ts index 14aab61c..6fc170a2 100644 --- a/packages/cli/src/commands/sites/deployments/delete.ts +++ b/packages/cli/src/commands/sites/deployments/delete.ts @@ -10,7 +10,11 @@ import { readRemoteState, writeRemoteState, } from "../api.ts"; -import { isValidDeployId, type RemoteSiteState } from "../constants.ts"; +import { + findDeploy, + isValidDeployId, + type RemoteSiteState, +} from "../constants.ts"; import { type SiteSelectorArgs, selectSite, @@ -86,7 +90,7 @@ export const sitesDeploymentsDeleteCommand = defineCommand({ // No etag here: the destructive phase re-reads state and writes with the fresh one. const { state, connection } = site; - const record = state.deploys.find((d) => d.id === id); + const { deploy: record, caseVariant } = findDeploy(state.deploys, id); if (!record) { // Idempotent for CI: a retry after a successful delete still exits 0. if (output === "json") { @@ -98,6 +102,12 @@ export const sitesDeploymentsDeleteCommand = defineCommand({ logger.info( `Deploy ${id} not found on ${state.name}; nothing to delete.`, ); + // A case typo would otherwise look like a successful no-op. + if (caseVariant) { + logger.dim( + ` Did you mean ${caseVariant.id}? Deploy IDs are case-sensitive.`, + ); + } return; } diff --git a/packages/cli/src/commands/sites/deployments/list.ts b/packages/cli/src/commands/sites/deployments/list.ts index e1dfc5b4..c5b7860f 100644 --- a/packages/cli/src/commands/sites/deployments/list.ts +++ b/packages/cli/src/commands/sites/deployments/list.ts @@ -8,6 +8,7 @@ import { formatTable, } from "../../../core/format.ts"; import { logger } from "../../../core/logger.ts"; +import type { DeployRecord } from "../constants.ts"; import { type SiteSelectorArgs, selectSite, @@ -17,6 +18,15 @@ import { type ListArgs = SiteSelectorArgs; +// How a deploy got its ID, for the Source column. A custom ID still shows the git sha when one was recorded, since that's the only provenance it carries. +function deploySource(d: DeployRecord): string { + if (d.source === "git") return `git ${d.gitSha ?? d.id}`; + if (d.source === "custom") { + return d.gitSha ? `custom (git ${d.gitSha})` : "custom"; + } + return `content${d.dirty ? " (dirty tree)" : ""}`; +} + export const sitesDeploymentsListCommand = defineCommand({ command: "list [site]", aliases: ["ls"], @@ -76,9 +86,7 @@ export const sitesDeploymentsListCommand = defineCommand({ ? "○ Previous" : "○", formatDateTime(d.createdAt), - d.source === "git" - ? `git ${d.gitSha ?? d.id}` - : `content${d.dirty ? " (dirty tree)" : ""}`, + deploySource(d), String(d.files), formatBytes(d.bytes), ]), diff --git a/packages/cli/src/commands/sites/deployments/publish.ts b/packages/cli/src/commands/sites/deployments/publish.ts index a15477a3..50e676d9 100644 --- a/packages/cli/src/commands/sites/deployments/publish.ts +++ b/packages/cli/src/commands/sites/deployments/publish.ts @@ -8,8 +8,8 @@ import { defineCommand } from "../../../core/define-command.ts"; import { UserError } from "../../../core/errors.ts"; import { logger } from "../../../core/logger.ts"; import { confirm, requireConfirmable, withSpinner } from "../../../core/ui.ts"; -import { promoteDeploy, writeRemoteState } from "../api.ts"; -import { markCurrent } from "../constants.ts"; +import { promoteDeploy, readRemoteState, writeRemoteState } from "../api.ts"; +import { findDeploy, markCurrent } from "../constants.ts"; import { type SiteSelectorArgs, selectSite, @@ -66,7 +66,8 @@ export const sitesDeploymentsPublishCommand = defineCommand({ output, force: args.force, }); - const { state, connection, etag } = site; + // No etag kept from this read: the destructive phase re-reads state and writes with the fresh one. + const { state, connection } = site; let targetId = args.id; if (args.previous) { @@ -88,14 +89,15 @@ export const sitesDeploymentsPublishCommand = defineCommand({ ); } - const deploy = state.deploys.find((d) => d.id === targetId); + const { deploy, caseVariant } = findDeploy(state.deploys, targetId); if (!deploy) { throw new UserError( `Deploy ${targetId} not found for site ${state.name}.`, - "Run `bunny sites deployments list` to see available deploys.", + caseVariant + ? `Did you mean ${caseVariant.id}? Deploy IDs are case-sensitive.` + : "Run `bunny sites deployments list` to see available deploys.", ); } - if (state.current === targetId) { if (output === "json") { logger.log( @@ -131,14 +133,29 @@ export const sitesDeploymentsPublishCommand = defineCommand({ } 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 fresh = await readRemoteState(connection); + if (!fresh) { + throw new UserError( + "Couldn't re-read the site state.", + "Retry the publish; nothing was changed.", + ); + } + const { state: latest, etag: latestEtag } = fresh; + if (!latest.deploys.some((d) => d.id === targetId)) { + throw new UserError( + `Deploy ${targetId} is gone from ${latest.name} (a concurrent replace or delete?) and can't be published.`, + "Run `bunny sites deployments list` and retry.", + ); + } await promoteDeploy({ computeClient, coreClient, - state, + state: latest, deployId: targetId, }); - markCurrent(state, targetId); - await writeRemoteState(connection, state, etag, { + markCurrent(latest, targetId); + await writeRemoteState(connection, latest, latestEtag, { promotedTo: targetId, }); }); diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 25611300..fcff67fb 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -43,7 +43,12 @@ Content is root-served, so client-side routers (TanStack Router, React Router, V ## Deploy IDs -- The deploy ID is the **git short-sha** when the working tree is clean, otherwise an 8-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**. 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 router 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 the router is serving. 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. ---