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

Filter by extension

Filter by extension

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

`bunny sites ci init` workflows deploy again (deploy-site 0.1.1, pinned to this CLI's version) and install correctly in pnpm, lockfile-less, monorepo and Python projects; deploys handle changed build output at the same commit, symlinks, and nested `bunny.jsonc`, and failed uploads, creates and deletes explain how to recover
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ bun ny stream transcribe 1a2b3c4d-... --languages en,de # paid: transcribe the
bun ny stream smart 1a2b3c4d-... --title --chapters # paid: generate a title and chapters from the transcript (offers to transcribe first if the video has no captions)
```

Every deploy is published as the live site. Deploys are immutable under their own ID, so `bun ny sites deployments publish` rolls back to any earlier one without re-uploading. Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`) so a deploy needs no flags: `bun ny sites deploy --build`. `bun ny sites ci init` writes the same `build` and `dir` into the generated workflow. See [`examples/sites/`](examples/sites/) for ready-to-copy configs (Vite, Astro, Next.js static export, Hugo, plain HTML, and a combined app + site file).
Every deploy is published as the live site. Deploys are immutable under their own ID, so `bun ny sites deployments publish` rolls back to any earlier one without re-uploading. Preconfigure the `sites` block in `bunny.jsonc` (`name`, `build`, `dir`, `spa`) so a deploy needs no flags: `bun ny sites deploy --build`. `bun ny sites ci init` writes the same `build` and `dir` into the generated workflow. See [`examples/sites/`](examples/sites/) for ready-to-copy configs (Vite, Astro, Next.js static export, Hugo, plain HTML, and a combined app + site file).

### Available scripts

Expand Down
1 change: 0 additions & 1 deletion examples/sites/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,6 @@ With `name`, `build`, and `dir` set, the entire deploy is one command:

```bash
bun ny sites deploy --build # runs `build`, uploads `dir`, publishes it live
bun ny sites deploy --build # same, published as the live site
```

No `--site`, no build command, no directory argument. Without the config you'd
Expand Down
50 changes: 27 additions & 23 deletions packages/cli/README.md

Large diffs are not rendered by default.

70 changes: 55 additions & 15 deletions packages/cli/src/commands/sites/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,11 +257,15 @@ async function fetchPullZones(
}

// Discover sites: every storage-backed pull zone gets the per-zone `_bunny/site.json` read (concurrency-capped). A candidate is only a site when the state names it as the site's own pull zone, so another zone pointed at the same storage origin is never mistaken for one.
export async function fetchSites(client: CoreClient): Promise<SiteSummary[]> {
export async function fetchSites(
client: CoreClient,
opts: { strict?: boolean } = {},
): Promise<SiteSummary[]> {
const candidates = (await fetchPullZones(client)).filter(
(pz: PullZone) => pz.StorageZoneId != null,
);

let unreadable = 0;
const summaries = await mapWithConcurrency(
candidates,
8,
Expand All @@ -272,10 +276,17 @@ export async function fetchSites(client: CoreClient): Promise<SiteSummary[]> {
if (!context || context.state.pullZoneId !== pz.Id) return null;
return { ...context, systemHostname: systemHostname(pz.Hostnames) };
} catch {
unreadable++;
return null;
}
},
);
if (unreadable > 0) {
const message = `Couldn't read ${unreadable} storage zone${unreadable === 1 ? "" : "s"}, so some sites may be missing`;
// A uniqueness check can't trust a partial list.
if (opts.strict) throw new UserError(`${message}.`, "Re-run to retry.");
logger.warn(`${message}; re-run to retry.`);
}

return summaries
.filter((s): s is SiteSummary => s !== null)
Expand Down Expand Up @@ -520,6 +531,14 @@ export async function createSite(
}
reused.storageZone = true;
} else {
// An imported site keeps its original zone names, so the name-pattern scan above can't see it.
const sites = await fetchSites(coreClient, { strict: true });
if (sites.some((s) => s.state.name === name)) {
throw new UserError(
`Site "${name}" already exists.`,
`Run \`bunny sites link ${name}\` to use it from this directory.`,
);
}
// The suffix keeps the globally-unique name from colliding with other accounts; retry fresh suffixes on the off chance one still does.
for (let attempt = 0; !storageZone && attempt < 3; attempt++) {
const zoneName = suffixedResourceName(name);
Expand Down Expand Up @@ -717,7 +736,7 @@ export async function planSiteImport(opts: {
);
}

const taken = (await fetchSites(coreClient)).some(
const taken = (await fetchSites(coreClient, { strict: true })).some(
(site) => site.state.name === name,
);
if (taken) {
Expand Down Expand Up @@ -814,21 +833,23 @@ export const promoteVerification = {
new Promise((resolve) => setTimeout(resolve, ms)),
};

// Wait until the edge serves the promoted deploy, identified by the rewrite rule's response header.
// Wait until the edge serves the promoted deploy, identified by the rewrite rule's response header; false when the deadline passed unconfirmed.
async function waitForEdgePropagation(
host: string,
deployId: string,
): Promise<void> {
): Promise<boolean> {
const start = Date.now();
const deadline = start + PROPAGATION_DEADLINE_MS;
let attempt = 0;
while (Date.now() < deadline) {
let confirmed = false;
while (!confirmed && Date.now() < deadline) {
try {
// A unique query per attempt keeps each probe out of the CDN cache so a stale entry can't mask a propagated rule.
const { deploy } = await promoteVerification.probe(
`https://${host}/?__bunny_promote=${deployId}-${attempt++}`,
);
if (deploy === deployId) break;
confirmed = deploy === deployId;
if (confirmed) break;
} catch {
// Edge briefly unreachable (DNS/warmup); keep trying until the deadline.
}
Expand All @@ -839,8 +860,12 @@ async function waitForEdgePropagation(
if (elapsed < SETTLE_FLOOR_MS) {
await promoteVerification.wait(SETTLE_FLOOR_MS - elapsed);
}
return confirmed;
}

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

async function ensureNotFoundSettings(
coreClient: CoreClient,
storageZone: StorageZoneModel,
Expand All @@ -864,7 +889,7 @@ export async function promoteDeploy(opts: {
coreClient: CoreClient;
state: RemoteSiteState;
deployId: string;
}): Promise<void> {
}): Promise<boolean> {
const { coreClient, state, deployId } = opts;
const purge = () =>
coreClient.POST("/pullzone/{id}/purgeCache", {
Expand Down Expand Up @@ -894,8 +919,9 @@ export async function promoteDeploy(opts: {
});
await ensureNotFoundSettings(coreClient, storageZone, state, deployId);
await purge();
await waitForEdgePropagation(host, deployId);
const confirmed = await waitForEdgePropagation(host, deployId);
await purge();
return confirmed;
}

// Refuse to replace version-1 state that changed since it was read. `writeRemoteState`'s own conflict merge can't cover this: it reconciles against the current format, and the file being replaced is the older one, so it would abort as unparseable rather than merge.
Expand Down Expand Up @@ -935,6 +961,8 @@ export interface MigrateResult {
detachedScriptId: number | null;
deletedScriptId: number | null;
scriptError?: string;
/** False when the edge didn't confirm the republished deploy in time. */
confirmed: boolean;
}
export async function migrateSite(opts: {
coreClient: CoreClient;
Expand Down Expand Up @@ -968,9 +996,14 @@ export async function migrateSite(opts: {
});
await applySiteCacheSettings(coreClient, state.pullZoneId);

let confirmed = true;
if (state.current) {
step("Publishing the current deploy...");
await promoteDeploy({ coreClient, state, deployId: state.current });
confirmed = await promoteDeploy({
coreClient,
state,
deployId: state.current,
});
}

// Committed only once every fallible remote step is done: while the file still reads as version 1, a failed run above is fully resumable.
Expand All @@ -997,7 +1030,7 @@ export async function migrateSite(opts: {
}
}

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

interface TeardownResult {
Expand Down Expand Up @@ -1031,11 +1064,18 @@ export async function deleteSiteResources(opts: {
}
};

await attempt("pull zone", state.pullZoneId, () =>
coreClient.DELETE("/pullzone/{id}", {
params: { path: { id: state.pullZoneId } },
}),
);
await attempt("pull zone", state.pullZoneId, async () => {
try {
await coreClient.DELETE("/pullzone/{id}", {
params: { path: { id: state.pullZoneId } },
});
} catch (err) {
// Already gone (a re-run after a partial delete) is the goal.
if (!(err instanceof ApiError && err.status === 404)) throw err;
}
});
// The storage zone's site marker is the only way back to a pull zone that failed to delete, so keep both for the re-run.
if (!results.every((r) => r.deleted)) return results;
if (opts.keepStorage) {
// The zone survives, so remove its site marker, else list/link/show rediscover a "site" whose pull zone is gone. But only once everything else deleted: the marker is what makes a re-run able to find and retry the failures.
if (opts.connection && results.every((r) => r.deleted)) {
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/sites/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ export async function runBuildCommand(
cwd,
env: { ...process.env, ...env },
stdin: "ignore",
stdout: "inherit",
// Build logs go to stderr so `--output json` keeps stdout to the JSON payload.
stdout: 2,
stderr: "inherit",
});
const code = await proc.exited;
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/sites/ci/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ interface CiInitArgs extends SiteSelectorArgs {
force?: boolean;
}

// Scaffold `.github/workflows/bunny-sites.yml` via the BunnyWay/actions deploy-site action: pushes to main go live, and `workflow_dispatch` redeploys on demand.
// Scaffold `.github/workflows/bunny-sites.yml` via the BunnyWay/actions deploy-site action: pushes to the default branch go live, and `workflow_dispatch` redeploys on demand.
export const sitesCiInitCommand = defineCommand<CiInitArgs>({
command: "init",
describe: "Add a GitHub Actions workflow that deploys this site.",
Expand Down Expand Up @@ -111,7 +111,7 @@ export const sitesCiInitCommand = defineCommand<CiInitArgs>({
logger.log();
await offerGitHubSecret({ apiKey: config.apiKey, root, interactive });
logger.log();
logger.dim(" Push to main on GitHub and the site goes live.");
logger.dim(` Push to ${result.branch} on GitHub and the site goes live.`);

await offerLink();
},
Expand Down
27 changes: 26 additions & 1 deletion packages/cli/src/commands/sites/ci/scaffold.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { expect, test } from "bun:test";
import { projectPrefix, remoteHost } from "./scaffold.ts";
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { jsInstallSettings, projectPrefix, remoteHost } from "./scaffold.ts";

test("projectPrefix is empty at the workflow root and the POSIX offset when nested", () => {
expect(projectPrefix("/repo", "/repo")).toBe("");
Expand Down Expand Up @@ -30,3 +33,25 @@ test("remoteHost does not confuse lookalike hosts for github.com", () => {
);
expect(remoteHost("https://notgithub.com/a/b")).toBe("notgithub.com");
});

test("a nested workspace member without its own lockfile installs with the monorepo root's", async () => {
const root = mkdtempSync(join(tmpdir(), "sites-scaffold-"));
const web = join(root, "apps", "web");
mkdirSync(web, { recursive: true });
writeFileSync(join(root, "package.json"), "{}");
writeFileSync(join(root, "pnpm-lock.yaml"), "lockfileVersion: '6.0'\n");
// Not a workspace yet: a standalone nested app can't use the root lockfile.
expect((await jsInstallSettings(root, web)).lockfile).toBe(false);
writeFileSync(join(root, "pnpm-workspace.yaml"), "packages: [apps/*]\n");
expect(await jsInstallSettings(root, web)).toEqual({
packageManager: "pnpm",
lockfile: true,
pnpmVersion: "8",
});
// A root `packageManager` pin is what pnpm/action-setup reads, so no explicit version.
writeFileSync(
join(root, "package.json"),
'{ "packageManager": "pnpm@9.15.0" }',
);
expect((await jsInstallSettings(root, web)).pnpmVersion).toBeUndefined();
});
Loading
Loading