From a4f3e01f89c4a35e51e6fd44a3b8c1fee211328a Mon Sep 17 00:00:00 2001 From: "C. Spencer Beggs" Date: Fri, 15 May 2026 19:19:31 -0400 Subject: [PATCH 1/6] feat(build): restore string externals and add build.ignore option Fixes a regression from v0.6.4 in which user-configured string externals were silently ignored because leading the externals array with a function caused rspack to stop consulting the trailing string entries. The entire externalization decision is now folded into a single function. Adds a new build.ignore config option: a string array of module names to exclude from the bundle and replace with a throwing ESM stub. Use it for optional transitive dependencies the action never exercises (e.g. native modules pulled in as optional plugins). Modules in build.ignore throw at runtime so try/catch-guarded optional require calls detect them as absent. build.ignore takes precedence over build.externals. Closes #81. Signed-off-by: C. Spencer Beggs --- .changeset/quiet-roads-return.md | 11 + .../github-action-builder/architecture.md | 65 ++- README.md | 37 +- .../fixtures/ignore-modules/action.yml | 5 + .../fixtures/ignore-modules/src/main.ts | 3 + .../ignore-modules/vendor/optional-loader.cjs | 14 + .../vendor/optional-loader.d.cts | 1 + .../fixtures/string-externals/action.yml | 5 + .../fixtures/string-externals/src/main.ts | 8 + .../uninstalled-peer-dep.d.ts | 4 + .../integration/ignore-modules.int.test.ts | 50 ++ .../integration/string-externals.int.test.ts | 44 ++ docs/configuration.md | 128 +++--- .../plans/2026-05-15-build-ignore-option.md | 429 ++++++++++++++++++ .../2026-05-15-build-ignore-option-design.md | 171 +++++++ src/schemas/config.test.ts | 19 + src/schemas/config.ts | 4 + src/services/build-live.ts | 62 ++- 18 files changed, 944 insertions(+), 116 deletions(-) create mode 100644 .changeset/quiet-roads-return.md create mode 100644 __test__/integration/fixtures/ignore-modules/action.yml create mode 100644 __test__/integration/fixtures/ignore-modules/src/main.ts create mode 100644 __test__/integration/fixtures/ignore-modules/vendor/optional-loader.cjs create mode 100644 __test__/integration/fixtures/ignore-modules/vendor/optional-loader.d.cts create mode 100644 __test__/integration/fixtures/string-externals/action.yml create mode 100644 __test__/integration/fixtures/string-externals/src/main.ts create mode 100644 __test__/integration/fixtures/string-externals/uninstalled-peer-dep.d.ts create mode 100644 __test__/integration/ignore-modules.int.test.ts create mode 100644 __test__/integration/string-externals.int.test.ts create mode 100644 docs/superpowers/plans/2026-05-15-build-ignore-option.md create mode 100644 docs/superpowers/specs/2026-05-15-build-ignore-option-design.md 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/README.md b/README.md index afbfa30..f7ea579 100644 --- a/README.md +++ b/README.md @@ -1,25 +1,17 @@ # @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. +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. ## 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) +- **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; 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: @@ -30,8 +22,7 @@ npm install npm run build ``` -That's it! Your action is built and ready. The `init` command generates a -complete project: +That's it! Your action is built and ready. The `init` command generates a complete project: ```text my-action/ @@ -48,7 +39,7 @@ my-action/ 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. -## Basic Usage +## Basic usage ### Initialize @@ -78,7 +69,7 @@ npm run validate npx @savvy-web/github-action-builder validate ``` -## Project Structure +## Project structure The builder expects this structure: @@ -112,7 +103,7 @@ export default GitHubAction.create({ }); ``` -## action.yml Requirements +## action.yml requirements Your `action.yml` must use Node.js 24: @@ -149,7 +140,7 @@ if (result.success) { } ``` -## Shared TypeScript Configuration +## Shared TypeScript configuration The package exports a base `tsconfig.json` for GitHub Action projects: @@ -159,9 +150,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`. +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`. ## 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/configuration.md b/docs/configuration.md index d568202..394eac3 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -2,7 +2,7 @@ This guide covers all configuration options for `@savvy-web/github-action-builder`. -## Configuration File +## Configuration file Create `action.config.ts` in your project root to customize the build process. The file is optional - the builder uses sensible defaults without it. @@ -17,10 +17,9 @@ export default GitHubAction.create({ }); ``` -The `GitHubAction.create()` helper provides TypeScript autocomplete and validates -your configuration at build time. +The `GitHubAction.create()` helper provides TypeScript autocomplete and validates your configuration at build time. -## Configuration Sections +## Configuration sections ### entries @@ -32,7 +31,7 @@ Configure entry point paths for your action. | `pre` | `string` | `undefined` | Pre-action hook (runs before main) | | `post` | `string` | `undefined` | Post-action hook (runs after main) | -#### Custom Entry Points +#### Custom entry points ```typescript import { GitHubAction } from "@savvy-web/github-action-builder"; @@ -46,7 +45,7 @@ export default GitHubAction.create({ }); ``` -#### Main Only (Default Behavior) +#### Main only (default behavior) ```typescript import { GitHubAction } from "@savvy-web/github-action-builder"; @@ -59,10 +58,9 @@ export default GitHubAction.create({ }); ``` -#### Auto-Detection +#### Auto-detection -If you do not specify `pre` or `post` in your config, the builder automatically -detects these files: +If you do not specify `pre` or `post` in your config, the builder automatically detects these files: * `src/pre.ts` - included if exists * `src/post.ts` - included if exists @@ -78,10 +76,11 @@ Configure how your action is bundled. | `minify` | `boolean` | `true` | Minify output for smaller bundles | | `target` | `string` | `"es2022"` | ECMAScript target version | | `sourceMap` | `boolean` | `false` | Generate source maps | -| `externals` | `string[]` | `[]` | Packages to exclude from bundle | +| `externals` | `string[]` | `[]` | Packages to leave out of the bundle; must be available at runtime | +| `ignore` | `string[]` | `[]` | Packages to leave out of the bundle and replace with a stub that throws if loaded | | `quiet` | `boolean` | `false` | Suppress build output | -#### Development Build with Source Maps +#### Development build with source maps ```typescript import { GitHubAction } from "@savvy-web/github-action-builder"; @@ -94,7 +93,7 @@ export default GitHubAction.create({ }); ``` -#### Exclude Packages from Bundle +#### Exclude packages from bundle (available at runtime) ```typescript import { GitHubAction } from "@savvy-web/github-action-builder"; @@ -106,7 +105,19 @@ export default GitHubAction.create({ }); ``` -#### Build Options Details +#### Exclude packages from bundle (not available at runtime) + +```typescript +import { GitHubAction } from "@savvy-web/github-action-builder"; + +export default GitHubAction.create({ + build: { + ignore: ["cpu-features", "bufferutil"], + }, +}); +``` + +#### Build options details ##### minify @@ -131,14 +142,11 @@ The ECMAScript target for the output. Valid values: * `"es2023"` * `"es2024"` -The target affects which JavaScript features are used in the output. Since -GitHub Actions runs Node.js 24, `es2022` or later is recommended. +The target affects which JavaScript features are used in the output. Since GitHub Actions runs Node.js 24, `es2022` or later is recommended. ##### sourceMap -When `true`, generates source map files alongside the bundled JavaScript. Source -maps help with debugging by mapping minified code back to your original -TypeScript source. +When `true`, generates source map files alongside the bundled JavaScript. Source maps help with debugging by mapping minified code back to your original TypeScript source. Source maps are **disabled by default** because: @@ -156,11 +164,7 @@ build: { ##### externals -An array of package names to exclude from the bundle. Use this when: - -* A package cannot be bundled (native modules) -* You want to reduce bundle size for packages that will be installed separately -* Testing with mocked dependencies +An array of package names to leave out of the bundle. The packages are not bundled into `dist/main.js` and must be available in the action's runtime environment (i.e., installed in the action's `node_modules`). Use this for packages that cannot be bundled or that you install alongside the action. ```typescript build: { @@ -168,8 +172,26 @@ build: { } ``` -**Note:** External packages must be installed in the action's runtime -environment, which is not typical for GitHub Actions. Use sparingly. +**Note:** Relying on runtime-installed packages is not typical for GitHub Actions. Most actions should bundle everything. Use `externals` only when bundling is genuinely not an option. For optional transitive dependencies that the action never actually loads, use [`ignore`](#ignore) instead. + +##### ignore + +An array of package names to leave out of the bundle and replace with a stub that throws a descriptive error if the module is ever loaded at runtime. Use this for optional transitive dependencies — native modules or optional peer dependencies that the action's code guards with a `try/catch` and never exercises on the GitHub Actions runner. + +```typescript +build: { + ignore: ["cpu-features", "bufferutil"], +} +``` + +The difference from `externals`: + +| Option | Bundled? | Available at runtime? | What happens if loaded? | +| --- | --- | --- | --- | +| `externals` | No | Must be | Resolves normally | +| `ignore` | No | No | Throws a descriptive error | + +If a module appears in both lists, `ignore` takes precedence — the stub is applied and the module is not externalized. ##### quiet @@ -192,7 +214,7 @@ Configure validation behavior. | `maxBundleSize` | `string` | `undefined` | Maximum bundle size (e.g., "5mb") | | `strict` | `boolean` | `undefined` | Treat warnings as errors | -#### Set Bundle Size Limit +#### Set bundle size limit ```typescript import { GitHubAction } from "@savvy-web/github-action-builder"; @@ -205,7 +227,7 @@ export default GitHubAction.create({ }); ``` -#### Skip action.yml Validation +#### Skip action.yml validation ```typescript import { GitHubAction } from "@savvy-web/github-action-builder"; @@ -217,7 +239,7 @@ export default GitHubAction.create({ }); ``` -#### Validation Options Details +#### Validation options details ##### requireActionYml @@ -265,8 +287,7 @@ validation: { ### persistLocal -Configure automatic persistence of build output to a local action directory -for testing with [nektos/act](https://github.com/nektos/act). +Configure automatic persistence of build output to a local action directory for testing with [nektos/act](https://github.com/nektos/act). | Option | Type | Default | Description | | --- | --- | --- | --- | @@ -274,14 +295,11 @@ for testing with [nektos/act](https://github.com/nektos/act). | `path` | `string` | `".github/actions/local"` | Output directory relative to project root | | `actTemplate` | `boolean` | `true` | Generate act boilerplate files if they don't exist | -#### Default Behavior +#### Default behavior -With no configuration, `persistLocal` is enabled. After each build, the builder -copies `action.yml` and `dist/` to `.github/actions/local/` and generates act -template files (`.actrc` and `.github/workflows/act-test.yml`) if they do not -already exist. +With no configuration, `persistLocal` is enabled. After each build, the builder copies `action.yml` and `dist/` to `.github/actions/local/` and generates act template files (`.actrc` and `.github/workflows/act-test.yml`) if they do not already exist. -#### Custom Output Path +#### Custom output path ```typescript import { defineConfig } from "@savvy-web/github-action-builder"; @@ -293,7 +311,7 @@ export default defineConfig({ }); ``` -#### Disable Persist Local +#### Disable persist local ```typescript import { defineConfig } from "@savvy-web/github-action-builder"; @@ -305,13 +323,11 @@ export default defineConfig({ }); ``` -#### Persist Local Options Details +#### Persist local options details ##### enabled -When `true` (default), the builder copies `action.yml` and the `dist/` -directory to the local action path after every successful build. Uses hash-based -comparison to skip unchanged files and removes stale files from the destination. +When `true` (default), the builder copies `action.yml` and the `dist/` directory to the local action path after every successful build. Uses hash-based comparison to skip unchanged files and removes stale files from the destination. Disable to skip local persistence entirely: @@ -321,8 +337,7 @@ persistLocal: { } ``` -You can also skip persistence for a single build using the `--no-persist` CLI -flag without changing your configuration. +You can also skip persistence for a single build using the `--no-persist` CLI flag without changing your configuration. ##### path @@ -345,8 +360,7 @@ not already exist: * `.actrc` - Default act options * `.github/workflows/act-test.yml` - Minimal workflow for local testing -Existing files are never overwritten. Set to `false` if you manage these files -yourself: +Existing files are never overwritten. Set to `false` if you manage these files yourself: ```typescript persistLocal: { @@ -354,10 +368,9 @@ persistLocal: { } ``` -For a detailed guide on local testing with act, see -[Local Testing](./local-testing.md). +For a detailed guide on local testing with act, see [Local Testing](./local-testing.md). -## Full Configuration Example +## Full configuration example ```typescript import { GitHubAction } from "@savvy-web/github-action-builder"; @@ -376,6 +389,7 @@ export default GitHubAction.create({ target: "es2022", sourceMap: false, externals: [], + ignore: [], quiet: false, }, @@ -395,7 +409,7 @@ export default GitHubAction.create({ }); ``` -## Zero Configuration +## Zero configuration The builder works without any configuration file. These defaults are applied: @@ -410,6 +424,7 @@ The builder works without any configuration file. These defaults are applied: target: "es2022", sourceMap: false, externals: [], + ignore: [], quiet: false, }, validation: { @@ -424,7 +439,7 @@ The builder works without any configuration file. These defaults are applied: } ``` -## Config File Location +## Config file location The builder looks for configuration in this order: @@ -433,11 +448,9 @@ The builder looks for configuration in this order: 3. `action.config.js` in the current directory 4. `action.config.mjs` in the current directory -TypeScript config files (`.ts`) are loaded using -[jiti](https://github.com/unjs/jiti), so they work in any environment including -CI runners without a TypeScript loader registered. +TypeScript config files (`.ts`) are loaded using [jiti](https://github.com/unjs/jiti), so they work in any environment including CI runners without a TypeScript loader registered. -## Shared TypeScript Configuration +## Shared TypeScript configuration The package exports a base `tsconfig.json` for GitHub Action projects: @@ -452,12 +465,11 @@ This includes sensible defaults for Node.js 24 ESM actions: * `target`: ES2022 * `module`: ESNext with bundler resolution * `strict`: enabled -* Includes patterns for `src/`, `lib/`, `__test__/`, `types/`, and root-level - TypeScript files (covers `action.config.ts`) +* Includes patterns for `src/`, `lib/`, `__test__/`, `types/`, and root-level TypeScript files (covers `action.config.ts`) Override any setting in your project's `tsconfig.json` as needed. -## Programmatic Configuration +## Programmatic configuration When using the programmatic API, pass configuration directly: @@ -484,7 +496,7 @@ const action = GitHubAction.create({ }); ``` -## Related Documentation +## Related documentation * [CLI Reference](./cli-reference.md) - Command-line options * [Getting Started](./getting-started.md) - Project setup diff --git a/docs/superpowers/plans/2026-05-15-build-ignore-option.md b/docs/superpowers/plans/2026-05-15-build-ignore-option.md new file mode 100644 index 0000000..8c71603 --- /dev/null +++ b/docs/superpowers/plans/2026-05-15-build-ignore-option.md @@ -0,0 +1,429 @@ +# `build.ignore` Config Option Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `build.ignore` config option that replaces excluded modules with a throwing stub so optional transitive dependencies the action never exercises do not have to be bundled or resolved. + +**Architecture:** A new `string[]` field on the build config schema. In the build service, when `ignore` is non-empty, the builder writes a tiny ESM stub to the OS temp dir and adds a `resolve.alias` entry mapping each ignored specifier to that stub. rspack bundles the stub in place of the real module; any load of an ignored module throws. `ignore` takes precedence over `externals`. + +**Tech Stack:** TypeScript, Effect (Schema + Layer), `@rsbuild/core` (rspack), Vitest. + +**Spec:** `docs/superpowers/specs/2026-05-15-build-ignore-option-design.md` + +--- + +## File Structure + +- `src/schemas/config.ts` — add `ignore` to `BuildOptionsSchema` and `ConfigInputSchema.build`; update the `defineConfig` docstring example. +- `src/schemas/config.test.ts` — unit tests for the `ignore` schema field. +- `src/services/build-live.ts` — write the throwing stub, add `resolve.alias`, add the `ignore`-over-`externals` precedence guard. +- `__test__/integration/fixtures/ignore-modules/` — new integration fixture (`action.yml`, `src/main.ts`, `vendor/optional-loader.cjs`, `vendor/optional-loader.d.cts`). +- `__test__/integration/ignore-modules.int.test.ts` — new end-to-end regression test. + +--- + +## Task 1: Commit the pending #81 regression fix + +The branch already contains the completed, verified #81 string-externals regression fix (in `src/services/build-live.ts`) and its `string-externals` integration test, all uncommitted. Commit it as its own change before starting feature work so the feature commits stay clean. + +**Files:** + +- Modify: `src/services/build-live.ts` (already changed — the regression fix) +- Test: `__test__/integration/string-externals.int.test.ts` (already created) +- Plus the fixture under `__test__/integration/fixtures/string-externals/` + +- [ ] **Step 1: Confirm the working tree state** + +Run: `git status --short` +Expected: `src/services/build-live.ts` shown as modified (`M`), and `__test__/integration/string-externals.int.test.ts` plus `__test__/integration/fixtures/string-externals/` shown as untracked (`??`). + +- [ ] **Step 2: Confirm the regression fix works** + +Run: `pnpm vitest run __test__/integration/string-externals.int.test.ts` +Expected: PASS — 1 test passes ("externalizes node: builtins and configured string externals"). + +- [ ] **Step 3: Stage and commit** + +```bash +git add src/services/build-live.ts __test__/integration/string-externals.int.test.ts __test__/integration/fixtures/string-externals +git commit -m "$(cat <<'EOF' +fix(build): honor configured string externals alongside node: builtins + +0.6.4 regressed user-configured string externals: leading the externals +array with a function stopped rspack from consulting the trailing string +entries, so configured externals were bundled and hard-failed to resolve. +Fold the whole externalization decision into a single function. Adds a +regression integration test. + +Signed-off-by: C. Spencer Beggs +EOF +)" +``` + +Expected: commit succeeds (pre-commit lint-staged and commit-msg hooks pass). + +--- + +## Task 2: Add `ignore` to the config schema + +**Files:** + +- Modify: `src/schemas/config.ts` +- Test: `src/schemas/config.test.ts` + +- [ ] **Step 1: Write the failing tests** + +In `src/schemas/config.test.ts`, inside the `describe("BuildOptions Schema", ...)` block, add these two tests immediately after the existing `it("decodes from plain object", ...)` test (before that block's closing `});`): + +```ts + it("defaults ignore to an empty array", () => { + const options = Schema.decodeUnknownSync(BuildOptionsSchema)({}); + expect(options.ignore).toEqual([]); + }); + + it("accepts an ignore list", () => { + const options = Schema.decodeUnknownSync(BuildOptionsSchema)({ + ignore: ["libxmljs2"], + }); + expect(options.ignore).toEqual(["libxmljs2"]); + }); +``` + +In the same file, inside the `describe("ConfigInput Schema", ...)` block, add this test immediately after the existing `it("decodes from plain object", ...)` test (before that block's closing `});`): + +```ts + it("accepts build.ignore", () => { + const input = Schema.decodeUnknownSync(ConfigInputSchema)({ + build: { ignore: ["libxmljs2"] }, + }); + expect(input.build?.ignore).toEqual(["libxmljs2"]); + }); +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `pnpm vitest run src/schemas/config.test.ts` +Expected: FAIL — the two `ignore` assertions fail because `options.ignore` / `input.build?.ignore` are `undefined` (the schema has no `ignore` field yet). + +- [ ] **Step 3: Add `ignore` to `BuildOptionsSchema`** + +In `src/schemas/config.ts`, in `BuildOptionsSchema`, add the `ignore` field immediately after the `externals` field: + +```ts +export const BuildOptionsSchema = Schema.Struct({ + /** Enable minification to reduce bundle size. Defaults to true. */ + minify: Schema.optionalWith(Schema.Boolean, { default: () => true }), + /** Generate source maps for debugging. Defaults to false. */ + sourceMap: Schema.optionalWith(Schema.Boolean, { default: () => false }), + /** Packages to exclude from the bundle (in addition to node: builtins). Defaults to []. */ + externals: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] }), + /** Packages to exclude from the bundle and replace with a stub that throws if loaded at runtime. Use for optional transitive dependencies the action never exercises (e.g. native modules). Defaults to []. */ + ignore: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] }), +}); +``` + +- [ ] **Step 4: Add `ignore` to `ConfigInputSchema`** + +In `src/schemas/config.ts`, in `ConfigInputSchema`, add `ignore` to the inline `build` struct immediately after its `externals` field: + +```ts + build: Schema.optional( + Schema.Struct({ + minify: Schema.optional(Schema.Boolean), + sourceMap: Schema.optional(Schema.Boolean), + externals: Schema.optional(Schema.Array(Schema.String)), + ignore: Schema.optional(Schema.Array(Schema.String)), + }), + ), +``` + +- [ ] **Step 5: Update the `defineConfig` docstring example** + +In `src/schemas/config.ts`, in the `defineConfig` JSDoc "Full configuration with all options" example, add an `ignore` line immediately after the `externals` line: + +```ts + * build: { + * minify: true, + * sourceMap: true, + * externals: ["@aws-sdk/client-s3"], + * ignore: ["libxmljs2"], + * }, +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `pnpm vitest run src/schemas/config.test.ts` +Expected: PASS — all `BuildOptions Schema` and `ConfigInput Schema` tests pass. + +- [ ] **Step 7: Run the full unit suite and type check** + +Run: `pnpm vitest run && pnpm run typecheck` +Expected: all unit tests pass; type check reports no errors. + +- [ ] **Step 8: Commit** + +```bash +git add src/schemas/config.ts src/schemas/config.test.ts +git commit -m "$(cat <<'EOF' +feat(config): add build.ignore option to the config schema + +build.ignore lists modules to exclude from the bundle and replace with +a throwing stub, for optional transitive dependencies the action never +exercises. This commit adds the schema field only; the build service +wiring follows. + +Signed-off-by: C. Spencer Beggs +EOF +)" +``` + +Expected: commit succeeds. + +--- + +## Task 3: Implement `ignore` handling in the build service + +This task creates the integration fixture and test, confirms the test fails, then implements the bundler wiring and confirms it passes. + +**Files:** + +- Create: `__test__/integration/fixtures/ignore-modules/action.yml` +- Create: `__test__/integration/fixtures/ignore-modules/vendor/optional-loader.cjs` +- Create: `__test__/integration/fixtures/ignore-modules/vendor/optional-loader.d.cts` +- Create: `__test__/integration/fixtures/ignore-modules/src/main.ts` +- Create: `__test__/integration/ignore-modules.int.test.ts` +- Modify: `src/services/build-live.ts` + +- [ ] **Step 1: Create the fixture manifest** + +Create `__test__/integration/fixtures/ignore-modules/action.yml`: + +```yaml +name: ignore-modules +description: Integration fixture for the build.ignore option (issue #81). +runs: + using: node24 + main: dist/main.js +``` + +- [ ] **Step 2: Create the optional-dependency loader** + +Create `__test__/integration/fixtures/ignore-modules/vendor/optional-loader.cjs`: + +```js +"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; +``` + +- [ ] **Step 3: Create the loader's type declaration** + +Create `__test__/integration/fixtures/ignore-modules/vendor/optional-loader.d.cts`: + +```ts +export declare const xmlAvailable: boolean; +``` + +- [ ] **Step 4: Create the fixture entry point** + +Create `__test__/integration/fixtures/ignore-modules/src/main.ts`: + +```ts +import { xmlAvailable } from "../vendor/optional-loader.cjs"; + +process.stdout.write(`xmlAvailable=${xmlAvailable}\n`); +``` + +- [ ] **Step 5: Create the integration test** + +Create `__test__/integration/ignore-modules.int.test.ts`: + +```ts +/** + * Integration test for the build.ignore option (issue #81 design). + * + * A module listed in build.ignore must be replaced with a stub that throws if + * loaded, so the build succeeds even when the module is absent from + * node_modules and a try/catch-guarded require treats it as unavailable. + */ +import { execFileSync } from "node:child_process"; +import { existsSync, 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("stubs ignored modules so the build succeeds and the stub throws at runtime", 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(); + + // The build succeeds even though "excluded-native-dep" is absent from + // node_modules — build.ignore replaces it with a throwing stub. + expect(result.success).toBe(true); + expect(existsSync(mainJs)).toBe(true); + + // 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); +}); +``` + +- [ ] **Step 6: Run the integration test to verify it fails** + +Run: `pnpm vitest run __test__/integration/ignore-modules.int.test.ts` +Expected: FAIL — `expect(result.success).toBe(true)` fails (received `false`). The build hard-fails because rspack cannot resolve the string-literal `require("excluded-native-dep")` and `ignore` is not yet wired into the build service. + +- [ ] **Step 7: Add the `node:os` import and the stub source constant** + +In `src/services/build-live.ts`, add the `node:os` import between the existing `node:fs` and `node:path` imports: + +```ts +import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { resolve } from "node:path"; +``` + +Then, immediately after the import block (after the line `import { ConfigService } from "./config.js";`), add the stub source constant: + +```ts +/** + * Source of the stub module that replaces packages listed in `build.ignore`. + * It is bundled in place of the real module and throws if ever loaded. + */ +const IGNORE_STUB_SOURCE = `throw new Error("A module excluded via the build 'ignore' option was loaded at runtime.");\n`; +``` + +- [ ] **Step 8: Write the stub and build the alias map in `bundleEntry`** + +In `src/services/build-live.ts`, in the `bundleEntry` function, insert the stub-write and alias-map logic immediately after the `const outputDir = resolve(cwd, "dist");` line: + +```ts + return Effect.gen(function* () { + const startTime = Date.now(); + const outputDir = resolve(cwd, "dist"); + + // Modules listed in `build.ignore` are aliased to a throwing stub so + // they are neither bundled nor resolved against node_modules. See + // docs/superpowers/specs/2026-05-15-build-ignore-option-design.md. + const stubPath = resolve(tmpdir(), "github-action-builder-ignore-stub.mjs"); + const ignoreAlias: Record = {}; + for (const moduleName of config.build.ignore) { + ignoreAlias[`${moduleName}$`] = stubPath; + } + if (config.build.ignore.length > 0) { + yield* writeFile(stubPath, IGNORE_STUB_SOURCE); + } + + // createRsbuild returns a Promise +``` + +- [ ] **Step 9: Add `resolve.alias` to the rsbuild config** + +In `src/services/build-live.ts`, in the `rsbuildConfig` object passed to `createRsbuild`, add a `resolve` key immediately after the `source` key: + +```ts + rsbuildConfig: { + source: { entry: { [entry.type]: entry.path } }, + resolve: { alias: ignoreAlias }, + output: { +``` + +- [ ] **Step 10: Add the `ignore`-over-`externals` precedence guard** + +In `src/services/build-live.ts`, in the `externals` decision function, replace the existing externals check: + +```ts + if (config.build.externals.includes(request)) { + return request; + } +``` + +with the guarded version: + +```ts + // `ignore` takes precedence over `externals`: a module in + // both lists is stubbed via resolve.alias, not externalized. + if ( + config.build.externals.includes(request) && + !config.build.ignore.includes(request) + ) { + return request; + } +``` + +- [ ] **Step 11: Run the integration test to verify it passes** + +Run: `pnpm vitest run __test__/integration/ignore-modules.int.test.ts` +Expected: PASS — the build succeeds and the bundled action prints `xmlAvailable=false`. + +- [ ] **Step 12: Run the full suite, lint, and type check** + +Run: `pnpm vitest run && pnpm run lint && pnpm run typecheck` +Expected: all unit and integration tests pass; lint reports no errors; type check reports no errors. + +- [ ] **Step 13: Commit** + +```bash +git add src/services/build-live.ts __test__/integration/ignore-modules.int.test.ts __test__/integration/fixtures/ignore-modules +git commit -m "$(cat <<'EOF' +feat(build): stub modules listed in build.ignore + +When build.ignore is non-empty, the builder writes a throwing ESM stub to +the OS temp dir and aliases each ignored specifier to it, so the module is +neither bundled nor resolved. ignore takes precedence over externals. Adds +an end-to-end integration test. + +Signed-off-by: C. Spencer Beggs +EOF +)" +``` + +Expected: commit succeeds. + +--- + +## Self-Review + +**Spec coverage:** + +- Config API (`BuildOptionsSchema` + `ConfigInputSchema` + `defineConfig` docstring) — Task 2. +- Bundler mechanism (temp `.mjs` throwing stub + `resolve.alias`) — Task 3, Steps 7-9. +- Exact-specifier matching (`${moduleName}$` alias key) — Task 3, Step 8. +- Precedence (`ignore` over `externals`) — Task 3, Step 10. +- Error handling (stub write via existing `writeFile`/`WriteError`; empty `ignore` ⇒ no stub, empty alias) — Task 3, Step 8 (`writeFile` returns `Effect`, already in `bundleEntry`'s error union; the `if (config.build.ignore.length > 0)` guard skips IO when empty). +- Testing (unit + integration) — Tasks 2 and 3. +- Documentation: docstrings done in Task 2; the design doc and README are updated by the documentation agents at finalize time (recorded in the spec, out of scope for these tasks). +- The #81 regression fix relationship — Task 1 commits it independently. + +**Placeholder scan:** No TBD/TODO/placeholder steps; every code step shows complete code. + +**Type consistency:** `IGNORE_STUB_SOURCE` (string const) is defined in Step 7 and used in Step 8. `ignoreAlias` (`Record`) is defined in Step 8 and used in Step 9. `stubPath` is defined and used within Step 8. `config.build.ignore` (`readonly string[]`, from the Task 2 schema field) is read in Steps 8 and 10. `writeFile` is the existing helper in `build-live.ts`. diff --git a/docs/superpowers/specs/2026-05-15-build-ignore-option-design.md b/docs/superpowers/specs/2026-05-15-build-ignore-option-design.md new file mode 100644 index 0000000..dc478d1 --- /dev/null +++ b/docs/superpowers/specs/2026-05-15-build-ignore-option-design.md @@ -0,0 +1,171 @@ +# Design: `build.ignore` config option + +**Date:** 2026-05-15 +**Branch:** `bug/bundling` +**Related:** issue #81 (design question section), issue #79 (the `node:` interop fix that this builds on) + +## Overview + +Add a `build.ignore` configuration option to `@savvy-web/github-action-builder`. +It lists module specifiers that should be neither bundled nor externalized, but +replaced with a stub that throws when the module is required or imported at +runtime. + +## Motivation + +The builder already has `build.externals` — modules that are not bundled because +they *are* available at runtime (`node:` builtins, genuine peer dependencies). + +There is no tool for the opposite case: an optional transitive dependency that +the action never exercises and that will *not* be present at runtime. The +deployed action ships only `dist/main.js` with no `node_modules`. + +Today consumers misuse `externals` for this. Example: a dependency pulls in +`libxmljs2` (a native module that cannot be bundled at all), `xmlbuilder2`, and +`ajv-formats-draft2019` as optional plugins. Listing them in `externals` "works" +only because the consuming library wraps each optional `require` in its own +`try/catch` — the externalized reference throws at runtime and the `try/catch` +treats the dependency as absent. + +`ignore` makes that intent explicit and correct: a module that is excluded from +the bundle and is known not to be available at runtime. + +## Config API + +A new field on `BuildOptionsSchema`, symmetric with `externals`: + +```ts +build: { + externals: ["@aws-sdk/client-s3"], // not bundled — IS available at runtime + ignore: ["libxmljs2"], // not bundled — NOT available; stubbed +} +``` + +Schema changes: + +- `BuildOptionsSchema` (resolved config) gains: + `ignore: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] })` +- `ConfigInputSchema.build` (user input) gains: + `ignore: Schema.optional(Schema.Array(Schema.String))` + +Matching is **exact specifier match** — the same rule `externals` uses (a bare +string array element matches the request exactly). `ignore: ["libxmljs2"]` +matches `import "libxmljs2"` / `require("libxmljs2")` but not a deep import such +as `libxmljs2/lib/foo`. Subpath matching is out of scope for this version. + +When `ignore` is empty (the default), the build behaves exactly as before — no +`resolve.alias`, no stub written. + +## Bundler mechanism + +Implemented in `src/services/build-live.ts`, where the rsbuild config is +constructed. + +When `config.build.ignore` is non-empty: + +1. The builder writes a small static **ESM throwing stub** to the OS temp + directory. The file uses a `.mjs` extension so rspack parses it + unambiguously as `javascript/esm`. Its body is a single statement: + + ```js + throw new Error("A module excluded via the build 'ignore' option was loaded at runtime."); + ``` + +2. The rsbuild config gains a `resolve.alias` entry mapping each ignored + specifier to the stub's absolute path. + +3. rspack resolves the alias to that real local file and bundles the stub in + place of the real module. Any `require()` / `import` of an ignored module + evaluates the stub, which throws. + +Notes: + +- The stub file is bundled by rspack and never loaded directly by Node, so + Node's module-type rules do not apply to it. The `.mjs` extension is purely + to make rspack's parse unambiguous and to stay consistent with the builder's + ESM-everywhere output. CJS vs ESM is functionally equivalent for a + no-exports throw-only module; ESM is chosen for pipeline consistency. +- The stub message is generic (it does not name the specific module) because a + single shared stub file backs every ignored specifier. The consuming + `try/catch` does not depend on the message; the message only aids the + unguarded-failure case. + +### Runtime behavior + +- A `try/catch`-guarded optional `require` (the cyclonedx pattern) catches the + thrown error and correctly treats the dependency as unavailable. +- An unguarded `import` of an ignored module fails loudly at action startup + with a clear, build-origin error message — preferred over a confusing + downstream `undefined`. + +## Precedence + +`ignore` takes precedence over `externals`. The externals decision function in +`build-live.ts` gains one guard: it will not externalize a request that is also +present in `config.build.ignore`, so a module listed in both is always stubbed. + +Documented guidance: do not list a module in both; `ignore` wins. + +## Error handling + +- Invalid config (non-string-array) is rejected by Effect Schema validation, as + with every other config field. +- A failure writing the stub file surfaces through the existing + `BundleFailed` / `WriteError` error types. +- Empty `ignore` ⇒ no `resolve.alias`, no stub written, no behavior change. + +## Testing + +### Unit + +- `BuildOptionsSchema` accepts `build.ignore` and defaults it to `[]`. +- `ConfigInputSchema` accepts an optional `build.ignore`. + +### Integration + +New fixture `__test__/integration/fixtures/ignore-modules/`, auto-discovered by +`@savvy-web/vitest` as part of the `:int` project: + +- `action.yml` — minimal node24 manifest. +- `vendor/optional-loader.cjs` — a bundled CommonJS module that does + `try { require("excluded-native-dep") } catch { /* unavailable */ }` and + exports an availability flag. `excluded-native-dep` is deliberately absent + from `node_modules`. +- `vendor/optional-loader.d.cts` — declaration for the `.cjs` import. +- `src/main.ts` — imports the loader and prints the availability flag. + +Test `__test__/integration/ignore-modules.int.test.ts`: + +1. Build the fixture with `config.build.ignore: ["excluded-native-dep"]`. +2. Assert `result.success === true` — the build succeeds even though + `excluded-native-dep` is not installed, because it is stubbed rather than + resolved. +3. Run `dist/main.js` with node and assert it reports the dependency as + unavailable — proving the stub throws and the `try/catch` catches it + end-to-end. + +## Documentation + +To be updated (by the documentation agents at finalize time; recorded here so +nothing is missed): + +- `BuildOptionsSchema` / `ConfigInputSchema` docstrings in `src/schemas/config.ts`. +- The `defineConfig` example in the `config.ts` module docstring — add `ignore`. +- `.claude/design/github-action-builder/architecture.md` — config table and + bundler configuration section. +- `README.md` — externals / ignore configuration docs. + +## Out of scope + +- Subpath / glob matching for `ignore` entries (exact match only for now). +- Per-module configurable behavior (empty module vs throwing stub). The single + throwing-stub behavior covers the known use case; a mode flag can be added + later as a non-breaking change if an unguarded-top-level-require case ever + arises. +- Changes to `externals` semantics beyond the precedence guard. + +## Relationship to the #81 regression fix + +This feature ships on the same `bug/bundling` branch as the issue #81 string +externals regression fix. The regression fix is independent and already +complete; this feature is additive. diff --git a/src/schemas/config.test.ts b/src/schemas/config.test.ts index 1954b95..f1a92aa 100644 --- a/src/schemas/config.test.ts +++ b/src/schemas/config.test.ts @@ -70,6 +70,18 @@ describe("BuildOptions Schema", () => { }); expect(decoded.minify).toBe(false); }); + + it("defaults ignore to an empty array", () => { + const options = Schema.decodeUnknownSync(BuildOptionsSchema)({}); + expect(options.ignore).toEqual([]); + }); + + it("accepts an ignore list", () => { + const options = Schema.decodeUnknownSync(BuildOptionsSchema)({ + ignore: ["libxmljs2"], + }); + expect(options.ignore).toEqual(["libxmljs2"]); + }); }); describe("ValidationOptions Schema", () => { @@ -138,6 +150,13 @@ describe("ConfigInput Schema", () => { expect(decoded.entries?.pre).toBe("pre.ts"); expect(decoded.build?.minify).toBe(false); }); + + it("accepts build.ignore", () => { + const input = Schema.decodeUnknownSync(ConfigInputSchema)({ + build: { ignore: ["libxmljs2"] }, + }); + expect(input.build?.ignore).toEqual(["libxmljs2"]); + }); }); describe("Config Schema", () => { diff --git a/src/schemas/config.ts b/src/schemas/config.ts index 27aad63..21c43ca 100644 --- a/src/schemas/config.ts +++ b/src/schemas/config.ts @@ -60,6 +60,8 @@ export const BuildOptionsSchema = Schema.Struct({ sourceMap: Schema.optionalWith(Schema.Boolean, { default: () => false }), /** Packages to exclude from the bundle (in addition to node: builtins). Defaults to []. */ externals: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] }), + /** Packages to exclude from the bundle and replace with a stub that throws if loaded at runtime. Use for optional transitive dependencies the action never exercises (e.g. native modules). Defaults to []. */ + ignore: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] }), }); /** @@ -153,6 +155,7 @@ export const ConfigInputSchema = Schema.Struct({ minify: Schema.optional(Schema.Boolean), sourceMap: Schema.optional(Schema.Boolean), externals: Schema.optional(Schema.Array(Schema.String)), + ignore: Schema.optional(Schema.Array(Schema.String)), }), ), validation: Schema.optional( @@ -253,6 +256,7 @@ export type Config = typeof ConfigSchema.Type; * minify: true, * sourceMap: true, * externals: ["@aws-sdk/client-s3"], + * ignore: ["libxmljs2"], * }, * validation: { * requireActionYml: true, diff --git a/src/services/build-live.ts b/src/services/build-live.ts index 8822064..e1e85e4 100644 --- a/src/services/build-live.ts +++ b/src/services/build-live.ts @@ -4,6 +4,7 @@ * */ import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; import { resolve } from "node:path"; import { createRsbuild } from "@rsbuild/core"; import { Effect, Layer } from "effect"; @@ -15,6 +16,12 @@ import { BuildService } from "./build.js"; import type { DetectedEntry } from "./config.js"; import { ConfigService } from "./config.js"; +/** + * Source of the stub module that replaces packages listed in `build.ignore`. + * It is bundled in place of the real module and throws if ever loaded. + */ +const IGNORE_STUB_SOURCE = `throw new Error("A module excluded via the build 'ignore' option was loaded at runtime.");\n`; + /** * Format bytes as a human-readable string. */ @@ -116,30 +123,59 @@ function bundleEntry( const startTime = Date.now(); const outputDir = resolve(cwd, "dist"); + // Modules listed in `build.ignore` are aliased to a throwing stub so + // they are neither bundled nor resolved against node_modules. See + // docs/superpowers/specs/2026-05-15-build-ignore-option-design.md. + const stubPath = resolve(tmpdir(), "github-action-builder-ignore-stub.mjs"); + const ignoreAlias: Record = {}; + for (const moduleName of config.build.ignore) { + ignoreAlias[`${moduleName}$`] = stubPath; + } + if (config.build.ignore.length > 0) { + yield* writeFile(stubPath, IGNORE_STUB_SOURCE); + } + // createRsbuild returns a Promise const rsbuild = yield* Effect.tryPromise({ try: () => createRsbuild({ rsbuildConfig: { source: { entry: { [entry.type]: entry.path } }, + resolve: { alias: ignoreAlias }, output: { target: "node", module: true, distPath: { root: outputDir }, filename: { js: "[name].js" }, - externals: [ - // Externalize node: builtins with CommonJS require() semantics. - // Output is ESM (output.module), so a RegExp external would - // resolve with the default "module" type, making - // require("node:*") inside bundled CJS deps return an ESM - // namespace object. That breaks the TypeScript __importDefault - // interop helper and throws "instanceof is not callable" at - // runtime. "node-commonjs" preserves real require() semantics. - // See issue #79. - (data: { request?: string }): string | false => - data.request?.startsWith("node:") ? `node-commonjs ${data.request}` : false, - ...config.build.externals, - ], + // A single function makes the whole externalization decision so + // it never depends on rspack's array fall-through. 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 instead of being externalized (#81). + externals: (data: { request?: string }): string | false => { + const request = data.request; + if (!request) { + return false; + } + // node: builtins are externalized with CommonJS require() + // semantics. Output is ESM (output.module), so the default + // "module" type makes require("node:*") inside bundled CJS deps + // return an ESM namespace object. That breaks the TypeScript + // __importDefault interop helper and throws "instanceof is not + // callable" at runtime. "node-commonjs" preserves real + // require() semantics. See issue #79. + if (request.startsWith("node:")) { + return `node-commonjs ${request}`; + } + // User-configured externals: not bundled, left as runtime + // imports with the default external type. + // `ignore` takes precedence over `externals`: a module in + // both lists is stubbed via resolve.alias, not externalized. + if (config.build.externals.includes(request) && !config.build.ignore.includes(request)) { + return request; + } + return false; + }, cleanDistPath: false, minify: config.build.minify, sourceMap: config.build.sourceMap ? { js: "source-map" as const } : false, From c13921d4e207dceda4bdcc70970f667e5b68005f Mon Sep 17 00:00:00 2001 From: "C. Spencer Beggs" Date: Fri, 15 May 2026 19:39:46 -0400 Subject: [PATCH 2/6] fix: address PR review feedback - Write the build.ignore stub into a private mkdtemp directory instead of a predictable shared temp path, removing a symlink and race risk, and remove the directory after the bundle is written - Document the exact-match limitation of build.ignore (subpath imports of an ignored module are not stubbed) in the configuration docs - Precompute Sets for the externals and ignore lookups in the externals callback - Correct the design spec wording for the empty-ignore case Signed-off-by: C. Spencer Beggs --- docs/configuration.md | 2 ++ .../2026-05-15-build-ignore-option-design.md | 4 +-- src/services/build-live.ts | 32 ++++++++++++++----- 3 files changed, 28 insertions(+), 10 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index 394eac3..ed608ba 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -193,6 +193,8 @@ The difference from `externals`: If a module appears in both lists, `ignore` takes precedence — the stub is applied and the module is not externalized. +Matching is exact: `ignore: ["cpu-features"]` stubs `cpu-features` but not a subpath import such as `cpu-features/native`. If a dependency reaches an ignored module through a subpath, add that subpath specifier to the list as well. + ##### quiet When `true`, suppresses non-error build output. Useful in CI pipelines where diff --git a/docs/superpowers/specs/2026-05-15-build-ignore-option-design.md b/docs/superpowers/specs/2026-05-15-build-ignore-option-design.md index dc478d1..2956049 100644 --- a/docs/superpowers/specs/2026-05-15-build-ignore-option-design.md +++ b/docs/superpowers/specs/2026-05-15-build-ignore-option-design.md @@ -53,8 +53,8 @@ string array element matches the request exactly). `ignore: ["libxmljs2"]` matches `import "libxmljs2"` / `require("libxmljs2")` but not a deep import such as `libxmljs2/lib/foo`. Subpath matching is out of scope for this version. -When `ignore` is empty (the default), the build behaves exactly as before — no -`resolve.alias`, no stub written. +When `ignore` is empty (the default), the build behaves exactly as before — +`resolve.alias` is given no entries and no stub is written. ## Bundler mechanism diff --git a/src/services/build-live.ts b/src/services/build-live.ts index e1e85e4..b2adcba 100644 --- a/src/services/build-live.ts +++ b/src/services/build-live.ts @@ -3,9 +3,9 @@ * BuildService Layer implementation. * */ -import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { resolve } from "node:path"; +import { join, resolve } from "node:path"; import { createRsbuild } from "@rsbuild/core"; import { Effect, Layer } from "effect"; @@ -124,15 +124,25 @@ function bundleEntry( const outputDir = resolve(cwd, "dist"); // Modules listed in `build.ignore` are aliased to a throwing stub so - // they are neither bundled nor resolved against node_modules. See + // they are neither bundled nor resolved against node_modules. The stub + // lives in a private per-build temp directory created with mkdtemp, so + // a predictable shared path cannot be pre-created or symlinked by + // another process. See // docs/superpowers/specs/2026-05-15-build-ignore-option-design.md. - const stubPath = resolve(tmpdir(), "github-action-builder-ignore-stub.mjs"); + const externalsSet = new Set(config.build.externals); + const ignoreSet = new Set(config.build.ignore); const ignoreAlias: Record = {}; - for (const moduleName of config.build.ignore) { - ignoreAlias[`${moduleName}$`] = stubPath; - } + let stubDir: string | undefined; if (config.build.ignore.length > 0) { + stubDir = yield* Effect.try({ + try: () => mkdtempSync(join(tmpdir(), "github-action-builder-")), + catch: (error) => new WriteError({ path: tmpdir(), cause: error }), + }); + const stubPath = resolve(stubDir, "ignore-stub.mjs"); yield* writeFile(stubPath, IGNORE_STUB_SOURCE); + for (const moduleName of config.build.ignore) { + ignoreAlias[`${moduleName}$`] = stubPath; + } } // createRsbuild returns a Promise @@ -171,7 +181,7 @@ function bundleEntry( // imports with the default external type. // `ignore` takes precedence over `externals`: a module in // both lists is stubbed via resolve.alias, not externalized. - if (config.build.externals.includes(request) && !config.build.ignore.includes(request)) { + if (externalsSet.has(request) && !ignoreSet.has(request)) { return request; } return false; @@ -211,6 +221,12 @@ function bundleEntry( }), }); + // Remove the private stub directory now the bundle has been written. + if (stubDir !== undefined) { + const dir = stubDir; + yield* Effect.ignore(Effect.try(() => rmSync(dir, { recursive: true, force: true }))); + } + const outputPath = resolve(outputDir, `${entry.type}.js`); const size = yield* Effect.try({ try: () => statSync(outputPath).size, From f9f31bc34ff842d13c8ba295d0406fe05ed259ad Mon Sep 17 00:00:00 2001 From: "C. Spencer Beggs" Date: Fri, 15 May 2026 20:06:02 -0400 Subject: [PATCH 3/6] docs: normalize user-facing documentation - Rename docs/ pages to the NN-slug.md convention and add docs/README.md as a table-of-contents page - Rewrite title-case headings to sentence case across the docs - Join multi-line paragraphs onto single source lines - Add the standard badge block to the README - Add expected-output comments to programmatic examples and replace fabricated build-summary numbers with placeholders Signed-off-by: C. Spencer Beggs --- README.md | 17 ++-- ...tting-started.md => 01-getting-started.md} | 64 +++++++-------- .../{configuration.md => 02-configuration.md} | 8 +- .../{local-testing.md => 03-local-testing.md} | 77 ++++++++----------- .../{cli-reference.md => 04-cli-reference.md} | 42 +++++----- docs/{architecture.md => 05-architecture.md} | 40 +++++----- ...oubleshooting.md => 06-troubleshooting.md} | 26 +++---- docs/README.md | 12 +++ 8 files changed, 141 insertions(+), 145 deletions(-) rename docs/{getting-started.md => 01-getting-started.md} (82%) rename docs/{configuration.md => 02-configuration.md} (97%) rename docs/{local-testing.md => 03-local-testing.md} (74%) rename docs/{cli-reference.md => 04-cli-reference.md} (92%) rename docs/{architecture.md => 05-architecture.md} (95%) rename docs/{troubleshooting.md => 06-troubleshooting.md} (93%) create mode 100644 docs/README.md diff --git a/README.md b/README.md index f7ea579..4e075e8 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ # @savvy-web/github-action-builder +[![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/) + 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. ## Features @@ -118,12 +122,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 @@ -137,6 +141,7 @@ const result = await action.build(); if (result.success) { console.log(`Built ${result.build?.entries.length} entry points`); + // e.g. "Built 3 entry points" } ``` diff --git a/docs/getting-started.md b/docs/01-getting-started.md similarity index 82% rename from docs/getting-started.md rename to docs/01-getting-started.md index 6a3205b..b17aa3e 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`. +This guide walks you through setting up your first GitHub Action with `@savvy-web/github-action-builder`. ## Prerequisites @@ -9,7 +8,7 @@ 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: @@ -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. +This creates a complete, ready-to-build project. Edit `src/main.ts` with your action logic 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: (