diff --git a/.changeset/quiet-roads-return.md b/.changeset/quiet-roads-return.md new file mode 100644 index 0000000..fb212b1 --- /dev/null +++ b/.changeset/quiet-roads-return.md @@ -0,0 +1,11 @@ +--- +"@savvy-web/github-action-builder": minor +--- + +## Bug Fixes + +Fixed user-configured `build.externals` entries not being honored after the v0.6.4 `node:` interop fix. A change to the rspack externals config structure had caused the trailing string entries to be silently ignored, so configured package names were bundled and hard-failed to resolve instead of being externalized. + +## Features + +Added `build.ignore` — a `string[]` option listing modules to exclude from the bundle and replace with a throwing stub. Use it for optional transitive dependencies the action never exercises (for example, native modules pulled in as optional plugins by a dependency). Unlike `build.externals` (packages excluded because they are available at runtime), `build.ignore` packages are absent at runtime; code that wraps their `require()` calls in `try/catch` correctly detects them as unavailable. diff --git a/.claude/design/github-action-builder/architecture.md b/.claude/design/github-action-builder/architecture.md index 598902c..9878961 100644 --- a/.claude/design/github-action-builder/architecture.md +++ b/.claude/design/github-action-builder/architecture.md @@ -243,6 +243,7 @@ const BuildOptionsSchema = Schema.Struct({ minify: Schema.optionalWith(Schema.Boolean, { default: () => true }), sourceMap: Schema.optionalWith(Schema.Boolean, { default: () => false }), // Off by default externals: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] }), + ignore: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] }), // target hardcoded to ES2024 internally (Node 24 = V8 12.x) // quiet removed (was ncc-specific) }) @@ -329,7 +330,8 @@ The output structure is **flat** - all files go directly in `dist/`. | ------ | ------- | ----------- | | `minify` | `true` | Minify output for smaller bundles | | `sourceMap` | `false` | Source maps disabled by default | -| `externals` | `[]` | Additional packages to exclude (node: always external) | +| `externals` | `[]` | Packages excluded from the bundle, expected to be available at runtime | +| `ignore` | `[]` | Packages excluded from the bundle and replaced with a stub that throws if loaded at runtime | ### Persist-Local Defaults @@ -385,7 +387,8 @@ export default defineConfig({ build: { minify: true, // Default: true sourceMap: false, // Default: false (disabled for smaller bundles) - externals: [], // Additional packages to exclude (node: always external) + externals: [], // Packages excluded from bundle, available at runtime (node: always external) + ignore: [], // Packages replaced with a throwing stub (takes precedence over externals) }, // Validation rules @@ -488,31 +491,44 @@ const BundleStatsSchema = Schema.Struct({ ### Rsbuild Bundler Configuration +When `build.ignore` is non-empty, `IGNORE_STUB_SOURCE` (a single `throw` statement) is written to the OS temp dir as a `.mjs` file before rsbuild runs. Each ignored specifier is mapped to that stub via `resolve.alias` using a `$`-suffixed key for exact-match aliasing, so rspack substitutes the stub in place of the real module. + ```typescript +// Stub written to tmpdir() when build.ignore is non-empty +const IGNORE_STUB_SOURCE = `throw new Error("A module excluded via the build 'ignore' option was loaded at runtime.");\n`; + +// resolve.alias entries added when build.ignore is non-empty: +// { "some-module$": "/tmp/github-action-builder-ignore-stub.mjs" } + const rsbuildConfig = { source: { entry: { [entry.type]: entry.path } }, + resolve: { alias: ignoreAlias }, // conditionally populated from build.ignore output: { target: "node", - module: true, // ESM output (experimental) + module: true, // ESM output (experimental) distPath: { root: outputDir }, filename: { js: "[name].js" }, - externals: [ - // Force node: builtins to resolve as CJS so bundled CJS deps that call - // __importDefault(require("node:stream")) get real module exports, not - // an ESM namespace. Without this, instanceof checks on e.g. Stream throw - // "Right-hand side of 'instanceof' is not callable". - (data) => - data.request?.startsWith("node:") - ? `node-commonjs ${data.request}` - : false, - ...config.build.externals, - ], - minify: config.build.minify, // Default: true - sourceMap: config.build.sourceMap // Default: false - ? { js: "source-map" } : false, + // A single function makes the whole externalization decision so it never + // depends on rspack's array fall-through. Previously, leading the array + // with a function caused rspack to stop consulting trailing string entries, + // so user-configured externals were bundled and hard-failed to resolve (#81). + externals: (data) => { + const request = data.request; + if (!request) return false; + // node: builtins: force CommonJS require() semantics so bundled CJS deps + // that call __importDefault(require("node:stream")) get real exports, not + // an ESM namespace. Prevents "instanceof is not callable" (#79). + if (request.startsWith("node:")) return `node-commonjs ${request}`; + // User externals: left as runtime imports. ignore takes precedence — + // a module in both lists is handled by resolve.alias, not externalized. + if (config.build.externals.includes(request) && !config.build.ignore.includes(request)) return request; + return false; + }, + minify: config.build.minify, // Default: true + sourceMap: config.build.sourceMap ? { js: "source-map" } : false, }, performance: { - chunkSplit: { strategy: "all-in-one" }, // Single-file output + chunkSplit: { strategy: "all-in-one" }, // Single-file output }, }; ``` @@ -1059,6 +1075,8 @@ Resolved questions from initial design: | Output format | ESM (`output.module: true`) | Node 24 actions use `"type": "module"` | | Chunk strategy | `all-in-one` | GitHub Actions need single-file per entry | | `node:` external type | `node-commonjs` via function external | RegExp external with ESM output resolves builtins as ESM namespace; CJS deps doing `__importDefault(require("node:stream"))` then get a non-callable `.default`, breaking `instanceof`. Function external forces `createRequire(import.meta.url)("node:stream")` which returns real CJS exports. | +| Externals array form (#81 regression) | Single function replaces function + string array | Leading an `externals` array with a function caused rspack to stop consulting trailing string entries; user-configured string externals were bundled instead of externalized. A single function that handles all cases (`node:` → `node-commonjs`, user strings → default, everything else → bundle) eliminates the fall-through dependency. | +| `build.ignore` stub mechanism | Throwing `.mjs` stub written to `tmpdir()`, aliased via `resolve.alias` with `$` exact-match suffix | Ignored modules must be removed from the bundle without relying on them being available at runtime (unlike `externals`). A stub that throws gives a clear error message if a module is accidentally loaded. The `$` suffix prevents partial matches (e.g., `foo$` matches `foo` but not `foobar`). `ignore` takes precedence over `externals` — a module in both lists is stubbed. | --- @@ -1097,11 +1115,16 @@ src/ __test__/ └── integration/ - ├── cjs-node-interop.int.test.ts # Regression test: builds fixture, asserts isStream=true - └── fixtures/cjs-node-interop/ # Fixture with action.yml, src/main.ts and hand-authored legacy-cjs-dep.cjs + ├── cjs-node-interop.int.test.ts # Regression: builds fixture, asserts isStream=true + ├── string-externals.int.test.ts # Regression: builds fixture with externals[], asserts module is external not bundled + ├── ignore-modules.int.test.ts # E2E: builds fixture with ignore[], asserts stub throws at load time + └── fixtures/ + ├── cjs-node-interop/ # Fixture with action.yml, src/main.ts and hand-authored legacy-cjs-dep.cjs + ├── string-externals/ # Fixture that configures externals to verify array fall-through fix (#81) + └── ignore-modules/ # Fixture that configures ignore to verify stub aliasing ``` -Integration tests (`*.int.test.ts`) are auto-discovered by `@savvy-web/vitest` as a `:int` project. The `cjs-node-interop` fixture exercises the `node-commonjs` external fix end-to-end: it builds via `GitHubAction.create()` with `skipValidation: true` and `persistLocal.enabled: false`, runs `dist/main.js` with Node, and asserts `isStream=true`. +Integration tests (`*.int.test.ts`) are auto-discovered by `@savvy-web/vitest` as a `:int` project. Each builds its fixture via `GitHubAction.create()` with `skipValidation: true` and `persistLocal.enabled: false`, then runs `dist/main.js` with Node to assert runtime behavior. `cjs-node-interop` asserts `isStream=true` (the `node-commonjs` fix); `string-externals` asserts that user-configured externals are not bundled (the #81 regression fix); `ignore-modules` asserts that the stub throws a descriptive error when an ignored module is imported. --- diff --git a/.gitignore b/.gitignore index efb9db8..602fb6a 100644 --- a/.gitignore +++ b/.gitignore @@ -34,4 +34,5 @@ pnpm-release.json .claude/plans/ .claude/plugins-debug.log .playwright-mcp -.mcp.json \ No newline at end of file +.mcp.json +docs/superpowers/ \ No newline at end of file diff --git a/README.md b/README.md index afbfa30..ccc6b70 100644 --- a/README.md +++ b/README.md @@ -1,27 +1,23 @@ # @savvy-web/github-action-builder -A zero-config build tool for creating GitHub Actions from TypeScript source -code. Bundles your action with [@rsbuild/core](https://github.com/web-infra-dev/rsbuild) -(rspack-based), validates `action.yml` against GitHub's official schema, and -outputs production-ready Node.js 24 actions. +[![npm](https://img.shields.io/npm/v/@savvy-web%2Fgithub-action-builder?label=npm&color=cb3837)](https://www.npmjs.com/package/@savvy-web/github-action-builder) +[![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT) +[![Node.js 24](https://img.shields.io/badge/Node.js-24-5fa04e.svg)](https://nodejs.org/) + +Build a GitHub Action from TypeScript source without writing build config. The builder bundles your code with [@rsbuild/core](https://github.com/web-infra-dev/rsbuild) (rspack under the hood) and checks `action.yml` against GitHub's published metadata schema. The output is a single Node.js 24 file you commit to your repo. ## Features -- **Zero-config** - Auto-detects entry points from `src/main.ts`, `src/pre.ts`, - `src/post.ts` -- **Node.js 24** - Builds modern ESM actions for the latest GitHub Actions - runtime -- **Schema validation** - Validates `action.yml` against GitHub's official - metadata specification -- **Single-file bundles** - All npm dependencies inlined via rsbuild, `node:` - builtins externalized, with tree-shaking support -- **Local testing** - Auto-persists build output for testing with - [nektos/act](https://github.com/nektos/act) +- **No build config required** - Picks up entry points from `src/main.ts`, `src/pre.ts`, `src/post.ts` on its own +- **Node.js 24** - Emits ESM actions that run on the `node24` GitHub Actions runtime +- **Schema validation** - Validates `action.yml` against GitHub's official metadata specification +- **Single-file bundles** - All npm dependencies inlined via rsbuild; `node:` builtins externalized; user-configured `externals` and `ignore` options for optional or native modules +- **Local testing** - Auto-persists build output for testing with [nektos/act](https://github.com/nektos/act) - **CI-aware** - Strict validation in CI, warnings-only locally -## Quick Start +## Quick start -Create a new GitHub Action project with a single command: +Scaffold a new GitHub Action project, then build it: ```bash npx @savvy-web/github-action-builder init my-action @@ -30,8 +26,7 @@ npm install npm run build ``` -That's it! Your action is built and ready. The `init` command generates a -complete project: +The `init` command lays down the project: ```text my-action/ @@ -45,10 +40,9 @@ my-action/ └── tsconfig.json # TypeScript configuration ``` -Edit `src/main.ts` with your action logic, then rebuild with `npm run build`. -Your bundled action is in `dist/main.js`, ready to commit and use. +Put your action logic in `src/main.ts` and run `npm run build` again. The bundled action lands at `dist/main.js` — commit that file to your repo. -## Basic Usage +## Basic usage ### Initialize @@ -78,7 +72,7 @@ npm run validate npx @savvy-web/github-action-builder validate ``` -## Project Structure +## Project structure The builder expects this structure: @@ -112,7 +106,7 @@ export default GitHubAction.create({ }); ``` -## action.yml Requirements +## action.yml requirements Your `action.yml` must use Node.js 24: @@ -127,12 +121,12 @@ runs: ## Documentation -- [Getting Started](./docs/getting-started.md) - Installation and first build -- [Configuration](./docs/configuration.md) - All configuration options -- [Local Testing](./docs/local-testing.md) - Testing with nektos/act -- [CLI Reference](./docs/cli-reference.md) - Complete command reference -- [Architecture](./docs/architecture.md) - How it works internally -- [Troubleshooting](./docs/troubleshooting.md) - Common issues and solutions +- [Getting started](./docs/01-getting-started.md) - Installation and first build +- [Configuration](./docs/02-configuration.md) - All configuration options +- [Local testing](./docs/03-local-testing.md) - Testing with nektos/act +- [CLI reference](./docs/04-cli-reference.md) - Complete command reference +- [Architecture](./docs/05-architecture.md) - How it works internally +- [Troubleshooting](./docs/06-troubleshooting.md) - Common issues and solutions ## Programmatic API @@ -146,10 +140,11 @@ const result = await action.build(); if (result.success) { console.log(`Built ${result.build?.entries.length} entry points`); + // e.g. "Built 3 entry points" } ``` -## Shared TypeScript Configuration +## Shared TypeScript configuration The package exports a base `tsconfig.json` for GitHub Action projects: @@ -159,9 +154,7 @@ The package exports a base `tsconfig.json` for GitHub Action projects: } ``` -This provides sensible defaults for Node.js 24 ESM actions including strict -mode, ES2022 target, and bundler module resolution. Override or extend as -needed in your project's `tsconfig.json`. +It sets up Node.js 24 ESM actions with strict mode, an ES2022 target and bundler module resolution. Override any of it in your own `tsconfig.json`. ## Requirements diff --git a/__test__/integration/fixtures/ignore-modules/action.yml b/__test__/integration/fixtures/ignore-modules/action.yml new file mode 100644 index 0000000..01bd763 --- /dev/null +++ b/__test__/integration/fixtures/ignore-modules/action.yml @@ -0,0 +1,5 @@ +name: ignore-modules +description: Integration fixture for the build.ignore option (issue #81). +runs: + using: node24 + main: dist/main.js diff --git a/__test__/integration/fixtures/ignore-modules/src/main.ts b/__test__/integration/fixtures/ignore-modules/src/main.ts new file mode 100644 index 0000000..d2ef813 --- /dev/null +++ b/__test__/integration/fixtures/ignore-modules/src/main.ts @@ -0,0 +1,3 @@ +import { xmlAvailable } from "../vendor/optional-loader.cjs"; + +process.stdout.write(`xmlAvailable=${xmlAvailable}\n`); diff --git a/__test__/integration/fixtures/ignore-modules/vendor/optional-loader.cjs b/__test__/integration/fixtures/ignore-modules/vendor/optional-loader.cjs new file mode 100644 index 0000000..49ca0a3 --- /dev/null +++ b/__test__/integration/fixtures/ignore-modules/vendor/optional-loader.cjs @@ -0,0 +1,14 @@ +"use strict"; +// Mirrors the optional-dependency pattern used by packages such as cyclonedx: +// an optional native module is required inside a try/catch, so an absent +// module is treated as "feature unavailable" rather than crashing the action. +// "excluded-native-dep" is intentionally NOT present in node_modules. +Object.defineProperty(exports, "__esModule", { value: true }); +let available; +try { + require("excluded-native-dep"); + available = true; +} catch { + available = false; +} +exports.xmlAvailable = available; diff --git a/__test__/integration/fixtures/ignore-modules/vendor/optional-loader.d.cts b/__test__/integration/fixtures/ignore-modules/vendor/optional-loader.d.cts new file mode 100644 index 0000000..c7be9ff --- /dev/null +++ b/__test__/integration/fixtures/ignore-modules/vendor/optional-loader.d.cts @@ -0,0 +1 @@ +export declare const xmlAvailable: boolean; diff --git a/__test__/integration/fixtures/string-externals/action.yml b/__test__/integration/fixtures/string-externals/action.yml new file mode 100644 index 0000000..42c6f8d --- /dev/null +++ b/__test__/integration/fixtures/string-externals/action.yml @@ -0,0 +1,5 @@ +name: string-externals +description: Integration fixture for issue #81 — string externals regression. +runs: + using: node24 + main: dist/main.js diff --git a/__test__/integration/fixtures/string-externals/src/main.ts b/__test__/integration/fixtures/string-externals/src/main.ts new file mode 100644 index 0000000..da7bbc6 --- /dev/null +++ b/__test__/integration/fixtures/string-externals/src/main.ts @@ -0,0 +1,8 @@ +import { randomUUID } from "node:crypto"; + +// "uninstalled-peer-dep" is deliberately NOT present in node_modules. The build +// can only succeed if the configured string external is honored — otherwise +// rspack hard-fails with "Module not found". See issue #81. +import "uninstalled-peer-dep"; + +process.stdout.write(`id=${randomUUID()}\n`); diff --git a/__test__/integration/fixtures/string-externals/uninstalled-peer-dep.d.ts b/__test__/integration/fixtures/string-externals/uninstalled-peer-dep.d.ts new file mode 100644 index 0000000..aa19e4c --- /dev/null +++ b/__test__/integration/fixtures/string-externals/uninstalled-peer-dep.d.ts @@ -0,0 +1,4 @@ +// Ambient declaration so the fixture type-checks. The module is intentionally +// absent from node_modules — only the bundler's externals handling makes the +// build resolve it. See issue #81. +declare module "uninstalled-peer-dep"; diff --git a/__test__/integration/ignore-modules.int.test.ts b/__test__/integration/ignore-modules.int.test.ts new file mode 100644 index 0000000..14a5cdd --- /dev/null +++ b/__test__/integration/ignore-modules.int.test.ts @@ -0,0 +1,50 @@ +/** + * Integration test for the build.ignore option (issue #81 design). + * + * A module listed in build.ignore must be replaced with a throwing stub, so + * the build succeeds even when the module is absent from node_modules, the + * bundle carries the stub (not rspack's own missing-module shim), and a + * try/catch-guarded require treats the module as unavailable. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { GitHubAction } from "../../src/index.js"; + +const fixtureDir = resolve(dirname(fileURLToPath(import.meta.url)), "fixtures/ignore-modules"); +const mainJs = join(fixtureDir, "dist", "main.js"); + +describe("issue #81: build.ignore option", () => { + it("replaces ignored modules with a throwing stub", async () => { + rmSync(join(fixtureDir, "dist"), { recursive: true, force: true }); + const action = GitHubAction.create({ + cwd: fixtureDir, + skipValidation: true, + config: { + build: { minify: false, ignore: ["excluded-native-dep"] }, + persistLocal: { enabled: false }, + }, + }); + const result = await action.build(); + + expect(result.success).toBe(true); + expect(existsSync(mainJs)).toBe(true); + + // build.ignore replaces the module with the builder's throwing stub. + // Without the feature, rspack instead emits its own missing-module + // shim — so the stub's message is the discriminating signal. + const bundle = readFileSync(mainJs, "utf8"); + expect(bundle).toContain("excluded via the build 'ignore' option"); + + // At runtime the stubbed require throws; the fixture's try/catch + // catches it and reports the optional dependency as unavailable. + const output = execFileSync("node", [mainJs], { + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + expect(output.trim()).toBe("xmlAvailable=false"); + }, 120_000); +}); diff --git a/__test__/integration/string-externals.int.test.ts b/__test__/integration/string-externals.int.test.ts new file mode 100644 index 0000000..a9ccf4a --- /dev/null +++ b/__test__/integration/string-externals.int.test.ts @@ -0,0 +1,44 @@ +/** + * Integration regression test for issue #81. + * + * The builder must honor user-configured string `externals` in addition to + * externalizing `node:` builtins. 0.6.4 regressed this: leading the externals + * array with a function caused rspack to stop consulting the trailing string + * entries, so configured externals were bundled (and hard-failed to resolve) + * instead of externalized. + */ +import { existsSync, readFileSync, rmSync } from "node:fs"; +import { dirname, join, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; + +import { GitHubAction } from "../../src/index.js"; + +const fixtureDir = resolve(dirname(fileURLToPath(import.meta.url)), "fixtures/string-externals"); +const mainJs = join(fixtureDir, "dist", "main.js"); + +describe("issue #81: string externals regression", () => { + it("externalizes node: builtins and configured string externals", async () => { + rmSync(join(fixtureDir, "dist"), { recursive: true, force: true }); + const action = GitHubAction.create({ + cwd: fixtureDir, + skipValidation: true, + config: { + build: { minify: false, externals: ["uninstalled-peer-dep"] }, + persistLocal: { enabled: false }, + }, + }); + const result = await action.build(); + + // Build must succeed even though "uninstalled-peer-dep" is not installed — + // it resolves only if the configured string external is honored. + expect(result.success).toBe(true); + expect(existsSync(mainJs)).toBe(true); + + const bundle = readFileSync(mainJs, "utf8"); + // Configured string external is left as a runtime import, not bundled. + expect(bundle).toMatch(/uninstalled-peer-dep/); + // node: builtins still externalize (guards the issue #79 fix). + expect(bundle).toMatch(/node:crypto/); + }, 120_000); +}); diff --git a/docs/getting-started.md b/docs/01-getting-started.md similarity index 70% rename from docs/getting-started.md rename to docs/01-getting-started.md index 6a3205b..30d3f3c 100644 --- a/docs/getting-started.md +++ b/docs/01-getting-started.md @@ -1,7 +1,6 @@ -# Getting Started +# Getting started -This guide walks you through setting up your first GitHub Action with -`@savvy-web/github-action-builder`. +Set up your first GitHub Action with `@savvy-web/github-action-builder`. ## Prerequisites @@ -9,9 +8,9 @@ This guide walks you through setting up your first GitHub Action with - **npm**, **pnpm**, or **yarn** - A GitHub repository for your action -## Quick Start +## Quick start -Create a new GitHub Action project with a single command: +Scaffold a new GitHub Action project, then build it: ```bash npx @savvy-web/github-action-builder init my-action @@ -20,10 +19,9 @@ npm install npm run build ``` -This creates a complete, ready-to-build project. Edit `src/main.ts` with your -action logic and run `npm run build` again. +The `init` command generates a project that builds without further setup. Put your action logic in `src/main.ts` and run `npm run build` again. -## Generated Project Structure +## Generated project structure The `init` command creates this structure: @@ -39,7 +37,7 @@ my-action/ └── tsconfig.json # TypeScript configuration ``` -## Understanding the Generated Files +## Understanding the generated files ### action.config.ts @@ -105,10 +103,7 @@ The generated `tsconfig.json` extends the shared base configuration: } ``` -This provides ES2022 target, strict mode, bundler module resolution, and -includes patterns for `src/`, `lib/`, `__test__/`, and root-level TypeScript -files like `action.config.ts`. You can override any setting in your project's -`tsconfig.json`. +This provides ES2022 target, strict mode, bundler module resolution, and includes patterns for `src/`, `lib/`, `__test__/`, and root-level TypeScript files like `action.config.ts`. You can override any setting in your project's `tsconfig.json`. ### src/main.ts @@ -137,9 +132,9 @@ async function run(): Promise { run(); ``` -## Alternative Installation Methods +## Alternative installation methods -### Global Installation +### Global installation Install globally to use the `github-action-builder` command anywhere: @@ -147,7 +142,7 @@ Install globally to use the `github-action-builder` command anywhere: npm install -g @savvy-web/github-action-builder ``` -### Project Installation +### Project installation Install as a dev dependency (already included in generated projects): @@ -164,9 +159,9 @@ npx @savvy-web/github-action-builder build npx @savvy-web/github-action-builder validate ``` -## Building Your Action +## Building your action -### First Build +### First build Run the build command: @@ -186,16 +181,16 @@ Validating... Building... Build Summary: - ✓ main: 474.7 KB (909ms) → dist/main.js - ✓ pre: 474.6 KB (632ms) → dist/pre.js - ✓ post: 474.6 KB (561ms) → dist/post.js + ✓ main: (