From 7705c8df9cdb45a9c002baeb98d62a1d39221b5c Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Wed, 7 Oct 2026 17:17:22 +0100 Subject: [PATCH 1/2] fix(sites): make ci init workflows deploy and install correctly --- .changeset/sites-ci.md | 6 ++ packages/cli/README.md | 2 +- packages/cli/src/commands/sites/ci/init.ts | 4 +- .../src/commands/sites/ci/scaffold.test.ts | 27 +++++- .../cli/src/commands/sites/ci/scaffold.ts | 93 ++++++++++++++++++- .../src/commands/sites/ci/workflow.test.ts | 24 ++++- packages/framework-detector/src/presets.ts | 3 + packages/framework-detector/src/workflow.ts | 71 +++++++++----- skills/bunny-cli/references/sites.md | 2 +- 9 files changed, 202 insertions(+), 30 deletions(-) create mode 100644 .changeset/sites-ci.md diff --git a/.changeset/sites-ci.md b/.changeset/sites-ci.md new file mode 100644 index 00000000..efdfea60 --- /dev/null +++ b/.changeset/sites-ci.md @@ -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, monorepo and Python projects diff --git a/packages/cli/README.md b/packages/cli/README.md index f87bd5ce..5f92a872 100644 --- a/packages/cli/README.md +++ b/packages/cli/README.md @@ -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 diff --git a/packages/cli/src/commands/sites/ci/init.ts b/packages/cli/src/commands/sites/ci/init.ts index 56b50aa7..a2cd24a1 100644 --- a/packages/cli/src/commands/sites/ci/init.ts +++ b/packages/cli/src/commands/sites/ci/init.ts @@ -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({ command: "init", describe: "Add a GitHub Actions workflow that deploys this site.", @@ -111,7 +111,7 @@ export const sitesCiInitCommand = defineCommand({ 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(); }, diff --git a/packages/cli/src/commands/sites/ci/scaffold.test.ts b/packages/cli/src/commands/sites/ci/scaffold.test.ts index 38bef452..c468a89f 100644 --- a/packages/cli/src/commands/sites/ci/scaffold.test.ts +++ b/packages/cli/src/commands/sites/ci/scaffold.test.ts @@ -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(""); @@ -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(); +}); diff --git a/packages/cli/src/commands/sites/ci/scaffold.ts b/packages/cli/src/commands/sites/ci/scaffold.ts index 9538db93..0838a166 100644 --- a/packages/cli/src/commands/sites/ci/scaffold.ts +++ b/packages/cli/src/commands/sites/ci/scaffold.ts @@ -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, @@ -24,6 +25,19 @@ export async function gitTopLevel(cwd: string): Promise { 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 { + const ref = await runGit(root, [ + "symbolic-ref", + "--short", + "refs/remotes/origin/HEAD", + ]); + return ref?.replace(/^origin\//, "") || "main"; +} + +// 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(/^(?:[^@/]+@)?([^/:]+):(?!\/)/); @@ -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. @@ -60,6 +76,63 @@ const LOCKFILES = [ "package-lock.json", ]; +interface JsInstallSettings { + packageManager: PackageManager; + lockfile: boolean; + pnpmVersion?: string; +} + +// A nested workspace member without a lockfile of its own installs from the monorepo root's; a standalone nested app does not. +export async function jsInstallSettings( + root: string, + projectRoot: string, +): Promise { + const rootIsWorkspace = + existsSync(join(root, "pnpm-workspace.yaml")) || + (await readPackageJson(root))?.workspaces !== undefined; + const candidates = + projectRoot === root || rootIsWorkspace + ? [projectRoot, root] + : [projectRoot]; + const lockDir = candidates.find((dir) => + LOCKFILES.some((name) => existsSync(join(dir, name))), + ); + const packageManager = await detectPackageManager(lockDir ?? projectRoot); + return { + packageManager, + lockfile: lockDir !== undefined, + pnpmVersion: + packageManager === "pnpm" + ? await pnpmVersion(root, projectRoot, lockDir) + : 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 { + 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 { @@ -190,16 +263,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); @@ -227,6 +308,7 @@ export async function scaffoldSitesWorkflow(opts: { preset, packageManager, dir: workflowPath(settings.prefix, settings.dir ?? preset.dir), + branch, }; } @@ -245,7 +327,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(); @@ -254,11 +339,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(); diff --git a/packages/cli/src/commands/sites/ci/workflow.test.ts b/packages/cli/src/commands/sites/ci/workflow.test.ts index e55707a7..c21d6139 100644 --- a/packages/cli/src/commands/sites/ci/workflow.test.ts +++ b/packages/cli/src/commands/sites/ci/workflow.test.ts @@ -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"); @@ -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"', + ); }); diff --git a/packages/framework-detector/src/presets.ts b/packages/framework-detector/src/presets.ts index b130b0c9..25ac1b13 100644 --- a/packages/framework-detector/src/presets.ts +++ b/packages/framework-detector/src/presets.ts @@ -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. @@ -162,6 +164,7 @@ export const FRAMEWORK_PRESETS: FrameworkPreset[] = [ dir: "output", toolchain: "python", build: "pelican content", + pipPackage: "pelican[markdown]", }, { id: "sphinx", diff --git a/packages/framework-detector/src/workflow.ts b/packages/framework-detector/src/workflow.ts index 2d182a1e..dda935d5 100644 --- a/packages/framework-detector/src/workflow.ts +++ b/packages/framework-detector/src/workflow.ts @@ -9,51 +9,64 @@ export const SITES_WORKFLOW_PATH = ".github/workflows/bunny-sites.yml"; // Bump the tag when a new major of the action ships; the action wraps the CLI. export const DEPLOY_SITE_ACTION = - "BunnyWay/actions/deploy-site@deploy-site_0.1.0"; + "BunnyWay/actions/deploy-site@deploy-site_0.1.1"; + +interface JsInstall { + /** Lockfile path for setup-node's cache when it isn't at the checkout root. */ + cacheDependencyPath?: string; + /** False when the project has no lockfile: no dependency cache, and a plain install instead of a frozen one. */ + lockfile?: boolean; + /** pnpm version for pnpm/action-setup, needed when the root package.json has no `packageManager` field. */ + pnpmVersion?: string; +} // Toolchain setup + dependency install, without the build line. setup-node looks for the lockfile at the checkout root, so a nested project passes its own path. -function jsSetup( - pm: PackageManager, - cacheDependencyPath: string | undefined, -): string[] { +function jsSetup(pm: PackageManager, install: JsInstall): string[] { + const { cacheDependencyPath } = install; + const frozen = install.lockfile !== false; const cachePath = cacheDependencyPath ? [ ` cache-dependency-path: ${JSON.stringify(cacheDependencyPath)}`, ] : []; + const cache = (name: string) => + frozen ? [` cache: ${name}`, ...cachePath] : []; switch (pm) { case "bun": return [ " - uses: oven-sh/setup-bun@v2", - " - run: bun install --frozen-lockfile", + ` - run: bun install${frozen ? " --frozen-lockfile" : ""}`, ]; case "pnpm": return [ " - uses: pnpm/action-setup@v6", + ...(install.pnpmVersion + ? [ + " with:", + ` version: ${JSON.stringify(install.pnpmVersion)}`, + ] + : []), " - uses: actions/setup-node@v7", " with:", ' node-version: "lts/*"', - " cache: pnpm", - ...cachePath, - " - run: pnpm install --frozen-lockfile", + ...cache("pnpm"), + ` - run: pnpm install${frozen ? " --frozen-lockfile" : ""}`, ]; case "yarn": return [ " - uses: actions/setup-node@v7", " with:", ' node-version: "lts/*"', - " cache: yarn", - ...cachePath, - " - run: yarn install --frozen-lockfile", + ...cache("yarn"), + ` - run: yarn install${frozen ? " --frozen-lockfile" : ""}`, ]; case "npm": return [ " - uses: actions/setup-node@v7", " with:", ' node-version: "lts/*"', - " cache: npm", - ...cachePath, - " - run: npm ci", + ...cache("npm"), + ` - run: ${frozen ? "npm ci" : "npm install"}`, ]; } } @@ -68,10 +81,10 @@ function jsSteps( preset: FrameworkPreset, pm: PackageManager, build: string | undefined, - cacheDependencyPath: string | undefined, + install: JsInstall, ): string[] { return [ - ...jsSetup(pm, cacheDependencyPath), + ...jsSetup(pm, install), runStep(build, presetBuildCommand(preset, pm) ?? `${pm} run build`), ]; } @@ -80,12 +93,12 @@ function buildSteps( preset: FrameworkPreset, packageManager: PackageManager, build: string | undefined, - cacheDependencyPath: string | undefined, + install: JsInstall, installDeps: boolean | undefined, ): string[] { switch (preset.toolchain) { case "js": - return jsSteps(preset, packageManager, build, cacheDependencyPath); + return jsSteps(preset, packageManager, build, install); case "ruby": return [ " - uses: ruby/setup-ruby@v1", @@ -109,7 +122,8 @@ function buildSteps( " - uses: actions/setup-python@v7", " with:", ' python-version: "3.x"', - " - run: pip install -r requirements.txt", + // A project without requirements.txt still needs the generator itself. + ` - run: ${JSON.stringify(`if [ -f requirements.txt ]; then pip install -r requirements.txt; else pip install '${preset.pipPackage ?? preset.id}'; fi`)}`, runStep(build, preset.build), ]; case "zola": @@ -130,7 +144,7 @@ function buildSteps( if (!build) return [" # No build step: static files deploy as-is."]; // An unrecognized bundler lands on the static preset; a configured build in a JS project still needs its dependencies on the runner. return installDeps - ? [...jsSetup(packageManager, cacheDependencyPath), runStep(build)] + ? [...jsSetup(packageManager, install), runStep(build)] : [runStep(build)]; } } @@ -153,6 +167,12 @@ export function renderSitesWorkflow(opts: { installDeps?: boolean; /** The branch that goes live on push; main unless the repository's default branch differs. */ branch?: string; + /** The `@bunny.net/cli` version range the action runs; the action's own default when omitted. */ + cliVersion?: string; + /** False when the project has no lockfile; see JsInstall. */ + lockfile?: boolean; + /** pnpm version to install when the root package.json doesn't pin one. */ + pnpmVersion?: string; }): string { const { site, preset, packageManager, workingDirectory } = opts; // Every `run` step builds from the project directory; `uses` inputs stay workflow-root-relative, so the deploy directory carries the prefix instead. @@ -190,7 +210,11 @@ export function renderSitesWorkflow(opts: { preset, packageManager, opts.build, - opts.cacheDependencyPath, + { + cacheDependencyPath: opts.cacheDependencyPath, + lockfile: opts.lockfile, + pnpmVersion: opts.pnpmVersion, + }, opts.installDeps, ), "", @@ -200,6 +224,9 @@ export function renderSitesWorkflow(opts: { ` site: ${JSON.stringify(site)}`, ` directory: ${JSON.stringify(workflowPath(workingDirectory, opts.dir ?? preset.dir))}`, " api_key: ${{ secrets.BUNNYNET_API_KEY }}", + ...(opts.cliVersion + ? [` cli_version: ${JSON.stringify(opts.cliVersion)}`] + : []), ]; return `${lines.join("\n")}\n`; } diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 8b907231..36880a3a 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -141,7 +141,7 @@ bunny sites ci init --framework astro # skip detection (astro, vite, react bunny sites ci init --site my-site --force # overwrite an existing workflow ``` -Writes a workflow using the `BunnyWay/actions/deploy-site` action with the site name baked in: pushes to `main` go live, plus `workflow_dispatch` for on-demand redeploys. Deploys serialize and are never cancelled in flight, since cancelling mid-upload would leave a half-written deploy directory behind. `sites.dir` and `sites.build` from `bunny.jsonc` override the preset's deploy directory and build command, so CI builds and deploys exactly what a local `sites deploy` does. The workflow is written at the git root; when `bunny.jsonc` lives below it (a monorepo package), the job gets `defaults.run.working-directory` and the deploy directory is prefixed, so those paths still mean what they do locally. Framework detection reads `package.json` dependencies, `Gemfile`, or Hugo config; the lockfile picks the package manager for the install steps. The job requests `contents: read` and `deployments: write`, so the run is recorded in the repository's Environments. After writing, the CLI offers to run `gh secret set BUNNYNET_API_KEY` (or prints the manual steps). `sites create` offers the same scaffold on GitHub repos; declining prints the workflow instead. +Writes a workflow using the `BunnyWay/actions/deploy-site` action with the site name baked in: pushes to the repository's default branch go live, plus `workflow_dispatch` for on-demand redeploys. Deploys serialize and are never cancelled in flight, since cancelling mid-upload would leave a half-written deploy directory behind. `sites.dir` and `sites.build` from `bunny.jsonc` override the preset's deploy directory and build command, so CI builds and deploys exactly what a local `sites deploy` does. The workflow is written at the git root; when `bunny.jsonc` lives below it (a monorepo package), the job gets `defaults.run.working-directory` and the deploy directory is prefixed, so those paths still mean what they do locally. Framework detection reads `package.json` dependencies, `Gemfile`, or Hugo config; the lockfile picks the package manager for the install steps (a nested workspace member without one uses the repo root's; with no lockfile the workflow does a plain, uncached install), and Python sites install `requirements.txt` when present, else the generator itself. The job requests `contents: read` and `deployments: write`, so the run is recorded in the repository's Environments. The workflow pins the action's `cli_version` to the generating CLI's minor line, so CI deploys the same way. After writing, the CLI offers to run `gh secret set BUNNYNET_API_KEY` (or prints the manual steps). `sites create` offers the same scaffold on GitHub repos; declining prints the workflow instead. --- From 4a88e7ca63b97715591aaff2b48f144ad15f32f4 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Wed, 7 Oct 2026 17:55:51 +0100 Subject: [PATCH 2/2] fix(sites): drop the root lockfile fallback from ci init --- .changeset/sites-ci.md | 2 +- .../src/commands/sites/ci/scaffold.test.ts | 27 +------------------ .../cli/src/commands/sites/ci/scaffold.ts | 16 +++-------- skills/bunny-cli/references/sites.md | 2 +- 4 files changed, 7 insertions(+), 40 deletions(-) diff --git a/.changeset/sites-ci.md b/.changeset/sites-ci.md index efdfea60..36ff5e13 100644 --- a/.changeset/sites-ci.md +++ b/.changeset/sites-ci.md @@ -3,4 +3,4 @@ "@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, monorepo and Python projects +`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 diff --git a/packages/cli/src/commands/sites/ci/scaffold.test.ts b/packages/cli/src/commands/sites/ci/scaffold.test.ts index c468a89f..38bef452 100644 --- a/packages/cli/src/commands/sites/ci/scaffold.test.ts +++ b/packages/cli/src/commands/sites/ci/scaffold.test.ts @@ -1,8 +1,5 @@ import { expect, test } from "bun:test"; -import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { jsInstallSettings, projectPrefix, remoteHost } from "./scaffold.ts"; +import { projectPrefix, remoteHost } from "./scaffold.ts"; test("projectPrefix is empty at the workflow root and the POSIX offset when nested", () => { expect(projectPrefix("/repo", "/repo")).toBe(""); @@ -33,25 +30,3 @@ 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(); -}); diff --git a/packages/cli/src/commands/sites/ci/scaffold.ts b/packages/cli/src/commands/sites/ci/scaffold.ts index 0838a166..b63efc72 100644 --- a/packages/cli/src/commands/sites/ci/scaffold.ts +++ b/packages/cli/src/commands/sites/ci/scaffold.ts @@ -82,21 +82,13 @@ interface JsInstallSettings { pnpmVersion?: string; } -// A nested workspace member without a lockfile of its own installs from the monorepo root's; a standalone nested app does not. -export async function jsInstallSettings( +async function jsInstallSettings( root: string, projectRoot: string, ): Promise { - const rootIsWorkspace = - existsSync(join(root, "pnpm-workspace.yaml")) || - (await readPackageJson(root))?.workspaces !== undefined; - const candidates = - projectRoot === root || rootIsWorkspace - ? [projectRoot, root] - : [projectRoot]; - const lockDir = candidates.find((dir) => - LOCKFILES.some((name) => existsSync(join(dir, name))), - ); + const lockDir = LOCKFILES.some((name) => existsSync(join(projectRoot, name))) + ? projectRoot + : undefined; const packageManager = await detectPackageManager(lockDir ?? projectRoot); return { packageManager, diff --git a/skills/bunny-cli/references/sites.md b/skills/bunny-cli/references/sites.md index 36880a3a..6105a9dd 100644 --- a/skills/bunny-cli/references/sites.md +++ b/skills/bunny-cli/references/sites.md @@ -141,7 +141,7 @@ bunny sites ci init --framework astro # skip detection (astro, vite, react bunny sites ci init --site my-site --force # overwrite an existing workflow ``` -Writes a workflow using the `BunnyWay/actions/deploy-site` action with the site name baked in: pushes to the repository's default branch go live, plus `workflow_dispatch` for on-demand redeploys. Deploys serialize and are never cancelled in flight, since cancelling mid-upload would leave a half-written deploy directory behind. `sites.dir` and `sites.build` from `bunny.jsonc` override the preset's deploy directory and build command, so CI builds and deploys exactly what a local `sites deploy` does. The workflow is written at the git root; when `bunny.jsonc` lives below it (a monorepo package), the job gets `defaults.run.working-directory` and the deploy directory is prefixed, so those paths still mean what they do locally. Framework detection reads `package.json` dependencies, `Gemfile`, or Hugo config; the lockfile picks the package manager for the install steps (a nested workspace member without one uses the repo root's; with no lockfile the workflow does a plain, uncached install), and Python sites install `requirements.txt` when present, else the generator itself. The job requests `contents: read` and `deployments: write`, so the run is recorded in the repository's Environments. The workflow pins the action's `cli_version` to the generating CLI's minor line, so CI deploys the same way. After writing, the CLI offers to run `gh secret set BUNNYNET_API_KEY` (or prints the manual steps). `sites create` offers the same scaffold on GitHub repos; declining prints the workflow instead. +Writes a workflow using the `BunnyWay/actions/deploy-site` action with the site name baked in: pushes to the repository's default branch go live, plus `workflow_dispatch` for on-demand redeploys. Deploys serialize and are never cancelled in flight, since cancelling mid-upload would leave a half-written deploy directory behind. `sites.dir` and `sites.build` from `bunny.jsonc` override the preset's deploy directory and build command, so CI builds and deploys exactly what a local `sites deploy` does. The workflow is written at the git root; when `bunny.jsonc` lives below it (a monorepo package), the job gets `defaults.run.working-directory` and the deploy directory is prefixed, so those paths still mean what they do locally. Framework detection reads `package.json` dependencies, `Gemfile`, or Hugo config; the lockfile picks the package manager for the install steps (with no lockfile the workflow does a plain, uncached install), and Python sites install `requirements.txt` when present, else the generator itself. The job requests `contents: read` and `deployments: write`, so the run is recorded in the repository's Environments. The workflow pins the action's `cli_version` to the generating CLI's minor line, so CI deploys the same way. After writing, the CLI offers to run `gh secret set BUNNYNET_API_KEY` (or prints the manual steps). `sites create` offers the same scaffold on GitHub repos; declining prints the workflow instead. ---