Skip to content
Merged
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
22 changes: 0 additions & 22 deletions .changeset/cli-package-manager-bun.md

This file was deleted.

15 changes: 8 additions & 7 deletions .github/workflows/cli-scaffold-matrix.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,10 @@ on:
- "packages/cli/**"
- "scripts/scaffold-check.mjs"

concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

permissions:
contents: read

Expand All @@ -35,7 +39,6 @@ jobs:
runs-on: ubuntu-latest
outputs:
combos: ${{ steps.list.outputs.combos }}
bunCombos: ${{ steps.list.outputs.bunCombos }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
Expand All @@ -45,12 +48,10 @@ jobs:
node-version: "25.2.0"
# No install needed: the script answers --list using node builtins only.
- id: list
run: |
echo "combos=$(node scripts/scaffold-check.mjs --list)" >> "$GITHUB_OUTPUT"
echo "bunCombos=$(node scripts/scaffold-check.mjs --list-needs-bun)" >> "$GITHUB_OUTPUT"
run: echo "combos=$(node scripts/scaffold-check.mjs --list)" >> "$GITHUB_OUTPUT"

scaffold:
name: "init ${{ matrix.combo }}"
name: "init ${{ matrix.combo.name }}"
needs: discover
runs-on: ubuntu-latest
strategy:
Expand All @@ -66,7 +67,7 @@ jobs:
with:
node-version: "25.2.0"
cache: pnpm
- if: contains(fromJSON(needs.discover.outputs.bunCombos), matrix.combo)
- if: matrix.combo.needsBun
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
with:
# Pinned, not `latest`: a mutable external release would make this gate's
Expand All @@ -81,4 +82,4 @@ jobs:
# means the pinned base and the live example diverged. Fix the scaffold fragment,
# then tag examples and bump DEFAULT_BASE_REF — do not bump the tag alone.
- name: Scaffold and validate
run: pnpm scaffold:check --combo ${{ matrix.combo }}
run: pnpm scaffold:check --combo ${{ matrix.combo.name }}
17 changes: 6 additions & 11 deletions packages/cli/src/scaffold/transform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -266,17 +266,12 @@ export function transformBase(files: ReadonlyMap<string, string>, config: Scaffo
out.set("tests/e2e/events.test.ts", generateEventsTest(config.runtime));
}

// pnpm 11 **fails the install** (exit 1, ERR_PNPM_IGNORED_BUILDS) when a dependency
// has an unapproved build script — @bufbuild/buf declares a `postinstall`. The buf
// binary itself is unaffected either way: it ships as an optionalDependencies
// platform package behind a Node shim, and the postinstall is only a validator that
// rescues the `--no-optional` case. What breaks is the exit code, which fails the
// scaffolded project's very first command and CI. The approval lives in
// `pnpm-workspace.yaml` under **`allowBuilds`** (a map), which is what the monorepo
// root and every dogfooded example use. (The `onlyBuiltDependencies` list named in
// pnpm's deprecation warning for the old package.json `pnpm.*` field is NOT honoured
// by pnpm 11.0.4 — verified by CI.) npm and bun need no file: npm runs postinstalls
// by default, and bun exits 0 even when it blocks one.
// pnpm 11 fails the install (exit 1, ERR_PNPM_IGNORED_BUILDS) when a dependency has
// an unapproved build script, and @bufbuild/buf declares a `postinstall` — a
// non-zero install breaks the scaffolded project's first command. The approval key
// is **`allowBuilds`** (a map); the `onlyBuiltDependencies` list named in pnpm's
// deprecation warning is NOT honoured by pnpm 11.0.4 — verified by CI. Only pnpm
// needs the file.
if (config.packageManager === "pnpm") {
out.set("pnpm-workspace.yaml", "allowBuilds:\n '@bufbuild/buf': true\n esbuild: true\n");
}
Expand Down
12 changes: 4 additions & 8 deletions packages/cli/tests/unit/scaffold-transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ describe("resolveConfig", () => {
it("rejects invalid enum values", () => {
assert.throws(() => resolveConfig({ name: "x", runtime: "deno" }), /invalid --runtime/);
assert.throws(() => resolveConfig({ name: "x", packageManager: "yarn" }), /invalid --package-manager/);
// Accepted values come from the runtime array, not the type union: dropping one
// there breaks `--package-manager <value>` without failing typecheck.
assert.equal(resolveConfig({ name: "x", packageManager: "bun" }).packageManager, "bun");
assert.throws(() => resolveConfig({ name: "x", nodeExec: "swc" }), /invalid --node-exec/);
});
});
Expand Down Expand Up @@ -209,18 +212,11 @@ describe("transformBase", () => {
assert.match(pnpmOut.get("pnpm-workspace.yaml") ?? "", /allowBuilds:/);
assert.match(pnpmOut.get("pnpm-workspace.yaml") ?? "", /'@bufbuild\/buf': true/);

// Only pnpm exits non-zero on an unapproved build script, so only pnpm needs the
// file. npm runs postinstalls by default; bun exits 0 even when it blocks one.
// Only pnpm exits non-zero on an unapproved build script — see transform.ts.
for (const packageManager of ["npm", "bun"] as const) {
const out = transformBase(base, { ...nodePnpm, packageManager });
assert.equal(out.has("pnpm-workspace.yaml"), false, `${packageManager} must not get pnpm-workspace.yaml`);
}
});

it("writes bun run scripts into the README for --package-manager bun", () => {
const out = transformBase(base, { ...nodePnpm, packageManager: "bun" });
const readme = out.get("README.md") ?? "";
assert.match(readme, /bun install/);
assert.match(readme, /bun run start/);
});
});
28 changes: 12 additions & 16 deletions scripts/scaffold-check.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
* pnpm scaffold:check --keep # keep the generated projects for inspection
* pnpm scaffold:check --runtime bun # also run the Bun cell (requires bun on PATH)
* pnpm scaffold:check --list # combination names as JSON (CI builds its matrix from this)
* pnpm scaffold:check --list-needs-bun # which of those need bun (CI gates setup-bun on this)
*
* Exit code is non-zero if any combination fails; a summary table is always printed.
*
Expand Down Expand Up @@ -75,18 +74,20 @@ const COMBOS = [
{ name: "base-drift", pm: "npm", args: ["--ref", "main", "--otel", "--events", "nats", "--auth", "--catalog"], optional: true },
];

/** Combinations that need `bun` on PATH — the only selector for them, locally and in CI. */
const BUN_COMBOS = COMBOS.filter((c) => c.needsBun === true);

/** Combinations a bare `pnpm scaffold:check` runs. */
const DEFAULT_COMBOS = COMBOS.filter((c) => c.optional !== true);

/** Parse `--flag value` / `--flag` arguments without pulling in a dependency. */
function parseArgs(argv) {
const opts = { combos: undefined, drift: false, keep: false, list: false, listNeedsBun: false, runtime: undefined };
const opts = { combos: undefined, drift: false, keep: false, list: false, runtime: undefined };
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
if (arg === "--drift") opts.drift = true;
else if (arg === "--keep") opts.keep = true;
else if (arg === "--list") opts.list = true;
else if (arg === "--list-needs-bun") opts.listNeedsBun = true;
else if (arg === "--combo") opts.combos = (argv[++i] ?? "").split(",").filter(Boolean);
else if (arg === "--runtime") opts.runtime = argv[++i];
else if (arg === "--help" || arg === "-h") opts.help = true;
Expand Down Expand Up @@ -130,10 +131,9 @@ function main() {
"",
" --combo <a,b> only the named combinations",
" --drift also scaffold from examples@main (the base-drift cell)",
" --runtime bun include the Bun cell (requires bun on PATH)",
" --runtime bun include the Bun cells (requires bun on PATH)",
" --keep keep the generated projects instead of deleting them",
" --list print every combination name as JSON (used by CI to build its matrix)",
" --list-needs-bun print the combinations that require bun as JSON (CI gates its setup step on this)",
" --list print every combination as JSON {name, needsBun} (CI builds its matrix from this)",
"",
`Default: ${DEFAULT_COMBOS.map((c) => c.name).join(", ")}`,
`Opt-in: ${COMBOS.filter((c) => c.optional)
Expand All @@ -145,15 +145,11 @@ function main() {
}

// Answered before anything is built: the CI job that reads this only has a checkout.
// Descriptors, not bare names: the workflow needs per-cell properties (which cells
// require bun on the runner), and emitting them from the same table that declares
// them is what stops a new bun combination from silently missing its setup step.
if (opts.list) {
console.log(JSON.stringify(COMBOS.map((c) => c.name)));
return;
}

// Which cells the workflow must install bun for. Emitted from the same table that
// declares them, so adding a bun combination cannot silently miss the setup step.
if (opts.listNeedsBun) {
console.log(JSON.stringify(COMBOS.filter((c) => c.needsBun === true).map((c) => c.name)));
console.log(JSON.stringify(COMBOS.map(({ name, needsBun }) => ({ name, needsBun: needsBun === true }))));
return;
}

Expand All @@ -172,7 +168,7 @@ function main() {
// Explicit selection reaches the opt-in combinations too — this is how CI runs them.
selected = COMBOS.filter((c) => opts.combos.includes(c.name));
} else {
if (opts.runtime === "bun") selected = [...selected, ...COMBOS.filter((c) => c.name === "bun")];
if (opts.runtime === "bun") selected = [...selected, ...BUN_COMBOS];
if (opts.drift) selected = [...selected, ...COMBOS.filter((c) => c.name === "base-drift")];
}

Expand All @@ -181,7 +177,7 @@ function main() {
try {
execFileSync("bun", ["--version"], { stdio: "pipe" });
} catch {
throw new Error("scaffold-check: the `bun` combination needs bun on PATH (https://bun.sh). Drop --runtime bun, or install bun.");
throw new Error("scaffold-check: the Bun combinations need bun on PATH (https://bun.sh). Install bun, or select other combinations.");
}
}

Expand Down
Loading