From 58ba8a1f55949d4bc2cbd18178e5317789feca61 Mon Sep 17 00:00:00 2001 From: "C. Spencer Beggs" Date: Wed, 20 May 2026 13:03:15 -0400 Subject: [PATCH] fix: shim dirname/filename globals and disable async chunks in ESM action bundles Inject CJS compatibility shims (dirname, filename, require) into every ESM action bundle so CJS dependencies referencing these globals work at runtime without needing to be externalized. Set asyncChunks to false per entry so each action always produces a single predictable output file. Signed-off-by: C. Spencer Beggs --- .changeset/fuzzy-shims-patch.md | 7 +++++ .../github-action-builder/architecture.md | 27 ++++++++++++++----- .github/workflows/release.yml | 5 ++-- docs/05-architecture.md | 2 ++ src/services/build-live.ts | 16 +++++++++++ 5 files changed, 48 insertions(+), 9 deletions(-) create mode 100644 .changeset/fuzzy-shims-patch.md diff --git a/.changeset/fuzzy-shims-patch.md b/.changeset/fuzzy-shims-patch.md new file mode 100644 index 0000000..34e017a --- /dev/null +++ b/.changeset/fuzzy-shims-patch.md @@ -0,0 +1,7 @@ +--- +"@savvy-web/github-action-builder": patch +--- + +## Bug Fixes + +Fix `__dirname` and `__filename` being undefined in ESM action bundles by injecting CJS compatibility shims, and ensure each action entry produces a single predictable output file by disabling async chunks. diff --git a/.claude/design/github-action-builder/architecture.md b/.claude/design/github-action-builder/architecture.md index 9878961..a7e1b91 100644 --- a/.claude/design/github-action-builder/architecture.md +++ b/.claude/design/github-action-builder/architecture.md @@ -4,8 +4,8 @@ type: architecture status: current completeness: 95 created: 2026-01-29 -updated: 2026-05-15 -last-synced: 2026-05-15 +updated: 2026-05-20 +last-synced: 2026-05-20 authors: - C. Spencer Beggs tags: @@ -174,7 +174,8 @@ interface BuildService { - Cleans `dist/` directory before building (configurable) - Bundles each detected entry point - Writes `dist/package.json` with `{ "type": "module" }` -- Enforces single-file output via `all-in-one` chunk strategy +- Enforces single-file output: `all-in-one` chunk strategy plus `asyncChunks: false` so dynamic imports do not emit separate chunk files +- Injects `__dirname` and `__filename` shims via `tools.rspack.node` so CJS deps that reference these globals work inside the ESM bundle - Releases rsbuild resources via `buildResult.close()` after each entry ### PersistLocalService (`src/services/persist-local.ts`) @@ -319,10 +320,8 @@ The builder automatically detects which optional entry points exist using | `src/pre.ts` | `dist/pre.js` | | `src/post.ts` | `dist/post.js` | | (generated) | `dist/package.json` | -| (if chunks exist) | `dist/*.js` (dynamic imports) | -All outputs are self-contained ESM bundles with all dependencies included. -The output structure is **flat** - all files go directly in `dist/`. +All outputs are self-contained ESM bundles with all dependencies included. Dynamic imports inside action source are folded back into the parent entry file (`asyncChunks: false`), so the output structure is always flat — exactly one `.js` file per detected entry point. ### Build Defaults @@ -530,6 +529,18 @@ const rsbuildConfig = { performance: { chunkSplit: { strategy: "all-in-one" }, // Single-file output }, + tools: { + rspack: { + // CJS deps that reference __dirname / __filename (e.g. @cyclonedx/cyclonedx-library) + // throw "__dirname is not defined" when bundled into ESM. "node-module" makes rspack + // derive these globals from import.meta.url. + node: { __dirname: "node-module", __filename: "node-module" }, + // Prevent dynamic import() calls from emitting separate chunk files. + // Each action entry must be a single file; asyncChunks: false folds + // dynamically-imported code back into the parent chunk without affecting tree-shaking. + output: { asyncChunks: false }, + }, + }, }; ``` @@ -990,7 +1001,7 @@ Each error carries contextual data: ### Why `@rsbuild/core`? - Clean ESM output without `eval("require")` hacks (ncc's webpack 4 runtime broke Node 24's strict ESM format detection) -- Proper CJS-to-ESM interop via rspack (replaces ncc's broken webpack 4 approach); `node:` builtins are forced to `node-commonjs` external type so bundled CJS deps that call `__importDefault(require("node:stream"))` receive real CJS module exports rather than an ESM namespace — without this, `instanceof` checks on Node built-in classes throw at runtime +- Proper CJS-to-ESM interop via rspack: `node:` builtins forced to `node-commonjs` external type so bundled CJS deps receive real CJS exports rather than an ESM namespace (prevents `instanceof` failures); `__dirname`/`__filename` shims via `tools.rspack.node` so CJS deps that reference those globals work inside the ESM bundle - Tree-shaking via rspack dead code elimination - Already in the Savvy Web ecosystem (`@savvy-web/rslib-builder` uses rspack) - Programmatic API: `createRsbuild()` + `.build()` for clean integration @@ -1077,6 +1088,8 @@ Resolved questions from initial design: | `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. | +| `__dirname` / `__filename` shims | `tools.rspack.node: { __dirname: "node-module", __filename: "node-module" }` | CJS dependencies that reference these module globals (e.g. `@cyclonedx/cyclonedx-library`) throw `"__dirname is not defined"` when bundled into an ESM output. The `"node-module"` option makes rspack inject shims derived from `import.meta.url`, restoring the expected string values without requiring those deps to be externalized. | +| `asyncChunks: false` | Set via `tools.rspack.output.asyncChunks: false` | Dynamic `import()` calls in action source would otherwise emit separate numbered chunk files alongside the entry. A committed GitHub Action must be a single self-contained file per entry so `action.yml` can reference a known path. Disabling async chunks folds all dynamically-imported code back into the parent bundle. Tree-shaking is unaffected. | --- diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 98364cf..b767c3c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -20,7 +20,7 @@ concurrency: jobs: workflow: name: Workflow - uses: savvy-web/.github/.github/workflows/release.yml@main + uses: savvy-web/.github/.github/workflows/release.yml@dev permissions: contents: write pull-requests: write @@ -28,9 +28,10 @@ jobs: packages: write attestations: write checks: write + artifact-metadata: write with: dry-run: ${{ inputs.dry_run || false }} app-bot-id: ${{ vars.APP_BOT_ID }} app-bot-name: ${{ vars.APP_BOT_NAME }} - skip-claude-review: false + skip-claude-review: true secrets: inherit diff --git a/docs/05-architecture.md b/docs/05-architecture.md index fa13621..6e2fa98 100644 --- a/docs/05-architecture.md +++ b/docs/05-architecture.md @@ -124,6 +124,8 @@ interface BuildService { * Writes `dist/package.json` with `{ "type": "module" }` * Emits plain ESM, with no `eval("require")` hacks * Tree-shakes through rspack and externalizes `node:` builtins +* Produces exactly one `.js` file per entry — dynamic `import()` calls in action source are folded back into the parent bundle (`asyncChunks: false`) so `action.yml` can always reference a predictable path +* Shims `__dirname` and `__filename` inside the ESM bundle so CJS dependencies that reference those globals work without being externalized ## Layer composition diff --git a/src/services/build-live.ts b/src/services/build-live.ts index b2adcba..f6a4669 100644 --- a/src/services/build-live.ts +++ b/src/services/build-live.ts @@ -193,6 +193,22 @@ function bundleEntry( performance: { chunkSplit: { strategy: "all-in-one" }, }, + tools: { + // Output is ESM (`output.module`). CommonJS dependencies that + // reference the module globals `__dirname` / `__filename` — e.g. + // `@cyclonedx/cyclonedx-library` — throw "__dirname is not defined" + // once bundled, because ESM has no such globals. `"node-module"` + // makes rspack derive them from `import.meta.url`. + rspack: { + node: { __dirname: "node-module", __filename: "node-module" }, + // A committed GitHub Action is cleaner as one file per entry. + // `asyncChunks: false` folds dynamically-imported code into the + // parent chunk, so a dynamic `import()` in the source no longer + // emits a separate `.js` chunk — the build produces just + // `main.js` / `pre.js` / `post.js`. Tree-shaking is unaffected. + output: { asyncChunks: false }, + }, + }, }, }), catch: (error) =>