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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/sites-ci.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 running this CLI's version), follow the default branch, and install correctly in pnpm, lockfile-less and Python projects
2 changes: 1 addition & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1076,7 +1076,7 @@ bunny sites open --print
bunny sites ssl --no-force-ssl

# CI, linking, maintenance
bunny sites ci init # GitHub Actions: push to main goes live
bunny sites ci init # GitHub Actions: push to the default branch goes live
bunny sites ci init --framework astro
bunny sites link my-site
bunny sites unlink
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
85 changes: 83 additions & 2 deletions packages/cli/src/commands/sites/ci/scaffold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { UserError } from "@/core/errors.ts";
import { runGit } from "@/core/git.ts";
import { logger } from "@/core/logger.ts";
import { confirm, prompts } from "@/core/ui.ts";
import { VERSION } from "@/core/version.ts";
import {
detectFramework,
detectPackageManager,
Expand All @@ -24,6 +25,19 @@ export async function gitTopLevel(cwd: string): Promise<string | null> {
return runGit(cwd, ["rev-parse", "--show-toplevel"]);
}

/** The remote's default branch (from `origin/HEAD`), or main when it isn't known locally; never the checked-out branch, which may be a feature branch. */
export async function defaultBranch(root: string): Promise<string> {
const ref = await runGit(root, [
"symbolic-ref",
"--short",
"refs/remotes/origin/HEAD",
]);
return ref?.replace(/^origin\//, "") || "main";
Comment on lines +29 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Refresh the remote default branch before embedding it

refs/remotes/origin/HEAD is only a locally cached symbolic ref, not necessarily the remote's current default branch. I reproduced with Git 2.43.0 that after changing a bare remote from master to main and fetching normally, this command still returns origin/master; Git exposes git remote set-head <name> --auto as a separate operation. Running sites ci init from such a common post-rename clone therefore generates a workflow that listens to the obsolete branch, so pushes to the documented default branch never deploy; resolve the remote HEAD or refresh the cached symref first.

AGENTS.md reference: AGENTS.md:L3-L5

Useful? React with 👍 / 👎.

Comment on lines +29 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Default-branch pushes can be missed

If origin/HEAD is missing after git init and a push, or stale after GitHub's default branch changes, defaultBranch selects main or the old branch. The generated workflow then ignores pushes to the actual default branch, so they never deploy. Query the remote's current default branch before falling back.

Fix in Claude Code

}

// The action runs this CLI's minor line, so CI deploys sites the same way the local command does.
const CLI_VERSION_RANGE = VERSION.split(".").slice(0, 2).join(".");

/** The host of a git remote URL, handling both scp-style (`git@host:path`) and URL forms; null when neither parses. */
export function remoteHost(url: string): string | null {
const scp = url.match(/^(?:[^@/]+@)?([^/:]+):(?!\/)/);
Expand All @@ -49,6 +63,8 @@ interface ScaffoldResult {
packageManager: PackageManager;
/** Directory the workflow deploys, relative to the repo root: `sites.dir` when configured, else the preset's, prefixed when the project sits below the root. */
dir: string;
/** The branch whose pushes go live. */
branch: string;
}

// Lockfiles that decide the package manager; a nested project may carry its own, in which case setup-node needs to be pointed at it.
Expand All @@ -60,6 +76,55 @@ const LOCKFILES = [
"package-lock.json",
];

interface JsInstallSettings {
packageManager: PackageManager;
lockfile: boolean;
pnpmVersion?: string;
}

async function jsInstallSettings(
root: string,
projectRoot: string,
): Promise<JsInstallSettings> {
const lockDir = LOCKFILES.some((name) => existsSync(join(projectRoot, name)))
? projectRoot
: undefined;
const packageManager = await detectPackageManager(lockDir ?? projectRoot);
return {
packageManager,
lockfile: lockDir !== undefined,
pnpmVersion:
packageManager === "pnpm"
? await pnpmVersion(root, projectRoot, lockDir)
Comment on lines +92 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Honor packageManager when no lockfile exists

When lockDir is absent, detectPackageManager(projectRoot) only inspects lockfile names and therefore always falls back to npm, even if package.json declares "packageManager": "pnpm@10" or the repository contains pnpm-workspace.yaml. The generated lockfile-less workflow then emits npm install and npm run build; pnpm/Yarn-specific workspaces or dependency protocols can fail even though this change claims to support lockfile-less projects. Use the package-manager field or workspace marker before falling back to npm.

Useful? React with 👍 / 👎.

Comment on lines +92 to +98

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 pnpm projects install with npm

The lockfile-free path still chooses npm for pnpm projects. detectPackageManager ignores packageManager and pnpm-workspace.yaml, so even a project pinned to pnpm gets npm install. If the workspace uses workspace:* dependencies, npm rejects them and deployment stops at installation. Without a lockfile, use the project's manager pin or workspace configuration before defaulting to npm.

Knowledge Base Used: Framework detection

Fix in Claude Code

: undefined,
};
}

// pnpm/action-setup reads only the root package.json's `packageManager`; without one it needs an explicit version, taken from the project's own field or the lockfile format.
async function pnpmVersion(
root: string,
projectRoot: string,
lockDir: string | undefined,
): Promise<string | undefined> {
const pinned = (dir: string) =>
readPackageJson(dir).then((pkg) => {
const field = pkg?.packageManager;
return typeof field === "string" && field.startsWith("pnpm@")
? field.slice("pnpm@".length).split("+")[0]
: undefined;
});
if (await pinned(root)) return undefined;
const own = await pinned(projectRoot);
if (own) return own;
const lock = lockDir
? await Bun.file(join(lockDir, "pnpm-lock.yaml"))
.text()
.catch(() => "")
: "";
const format = lock.match(/^lockfileVersion:\s*['"]?(\d+)/m)?.[1];
return format === "5" ? "7" : format === "6" ? "8" : "10";
}

// The git top level and the bunny.jsonc directory can reach the same place by different paths (macOS /tmp -> /private/tmp), which would read as "outside the repo".
function realOrSelf(path: string): string {
try {
Expand Down Expand Up @@ -190,16 +255,24 @@ export async function scaffoldSitesWorkflow(opts: {
opts.interactive,
settings.dir,
);
const packageManager = await detectPackageManager(settings.projectRoot);
const { packageManager, lockfile, pnpmVersion } = await jsInstallSettings(
opts.root,
settings.projectRoot,
);
const branch = await defaultBranch(opts.root);
const content = renderSitesWorkflow({
site: opts.site,
preset,
packageManager,
lockfile,
pnpmVersion,
dir: settings.dir,
build: settings.build,
workingDirectory: settings.prefix || undefined,
cacheDependencyPath: settings.cacheDependencyPath,
installDeps: await needsJsInstall(preset, settings),
branch,
cliVersion: CLI_VERSION_RANGE,
});

const target = join(opts.root, SITES_WORKFLOW_PATH);
Expand Down Expand Up @@ -227,6 +300,7 @@ export async function scaffoldSitesWorkflow(opts: {
preset,
packageManager,
dir: workflowPath(settings.prefix, settings.dir ?? preset.dir),
branch,
};
}

Expand All @@ -245,7 +319,10 @@ export async function printWorkflowInstructions(
const preset =
(await detectFramework(settings.projectRoot)) ?? findPreset("static");
if (!preset) return;
const packageManager = await detectPackageManager(settings.projectRoot);
const { packageManager, lockfile, pnpmVersion } = await jsInstallSettings(
root,
settings.projectRoot,
);
logger.log();
logger.log(`To deploy from GitHub later, add ${SITES_WORKFLOW_PATH}:`);
logger.log();
Expand All @@ -254,11 +331,15 @@ export async function printWorkflowInstructions(
site,
preset,
packageManager,
lockfile,
pnpmVersion,
dir: settings.dir,
build: settings.build,
workingDirectory: settings.prefix || undefined,
cacheDependencyPath: settings.cacheDependencyPath,
installDeps: await needsJsInstall(preset, settings),
branch: await defaultBranch(root),
cliVersion: CLI_VERSION_RANGE,
}),
);
printSecretHint();
Expand Down
24 changes: 23 additions & 1 deletion packages/cli/src/commands/sites/ci/workflow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,9 @@ test("mkdocs uses the python toolchain and deploys site", () => {
packageManager: "npm",
});
expect(yml).toContain("uses: actions/setup-python@v7");
expect(yml).toContain("run: pip install -r requirements.txt");
expect(yml).toContain(
"then pip install -r requirements.txt; else pip install 'mkdocs'; fi",
);
expect(yml).toContain("run: mkdocs build");
expect(yml).toContain('directory: "site"');
expect(yml).not.toContain("setup-node");
Expand Down Expand Up @@ -187,4 +189,24 @@ test("npm and pnpm projects get the matching install steps", () => {
});
expect(pnpm).toContain("uses: pnpm/action-setup@v6");
expect(pnpm).toContain("run: pnpm install --frozen-lockfile");

// No lockfile: setup-node's cache and `npm ci` would both fail on the runner.
const unlocked = renderSitesWorkflow({
site: "s",
preset: preset("vite"),
packageManager: "npm",
lockfile: false,
});
expect(unlocked).toContain("run: npm install");
expect(unlocked).not.toContain("cache:");

const pinned = renderSitesWorkflow({
site: "s",
preset: preset("vite"),
packageManager: "pnpm",
pnpmVersion: "10",
});
expect(pinned).toContain(
'uses: pnpm/action-setup@v6\n with:\n version: "10"',
);
});
3 changes: 3 additions & 0 deletions packages/framework-detector/src/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export interface FrameworkPreset {
spa?: boolean;
/** SDK channel for the dotnet toolchain's setup step, e.g. `9.0.x`. */
dotnetVersion?: string;
/** pip requirement the python toolchain installs when the project has no requirements.txt; defaults to the preset id. */
pipPackage?: string;
}

// Static must stay last: the interactive prompt defaults to it.
Expand Down Expand Up @@ -162,6 +164,7 @@ export const FRAMEWORK_PRESETS: FrameworkPreset[] = [
dir: "output",
toolchain: "python",
build: "pelican content",
pipPackage: "pelican[markdown]",
},
{
id: "sphinx",
Expand Down
Loading
Loading