Skip to content
This repository was archived by the owner on May 30, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions .changeset/lucky-cloud-f3939504.md

This file was deleted.

9 changes: 0 additions & 9 deletions .changeset/merry-apple-3f960668.md

This file was deleted.

13 changes: 0 additions & 13 deletions .changeset/merry-island-22bcbaa0.md

This file was deleted.

7 changes: 7 additions & 0 deletions .changeset/silent-streams-fix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@savvy-web/github-action-builder": patch
---

## Bug Fixes

Fixed a runtime crash when bundling CommonJS dependencies that use TypeScript's `__importDefault(require("node:*"))` interop helper. The builder previously externalized `node:` builtins as ESM namespace imports in ESM-output mode, causing downstream `instanceof` checks to throw `TypeError: Right-hand side of 'instanceof' is not callable`. The fix externalizes `node:` builtins with `node-commonjs` type so bundled CJS deps receive real CommonJS `require()` semantics. A regression integration test is included.
31 changes: 24 additions & 7 deletions .claude/design/github-action-builder/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ type: architecture
status: current
completeness: 95
created: 2026-01-29
updated: 2026-03-19
last-synced: 2026-03-19
updated: 2026-05-15
last-synced: 2026-05-15
authors:
- C. Spencer Beggs
tags:
Expand Down Expand Up @@ -496,7 +496,17 @@ const rsbuildConfig = {
module: true, // ESM output (experimental)
distPath: { root: outputDir },
filename: { js: "[name].js" },
externals: [/^node:/, ...config.build.externals],
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,
Expand Down Expand Up @@ -963,9 +973,8 @@ 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)
- 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
- 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
Expand Down Expand Up @@ -1049,6 +1058,7 @@ Resolved questions from initial design:
| ES target | ES2024 (hardcoded) | Node 24 = V8 12.x, no config needed |
| 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. |

---

Expand All @@ -1070,7 +1080,7 @@ src/
│ ├── validation.ts # ValidationService definition
│ ├── validation-live.ts # ValidationService implementation
│ ├── build.ts # BuildService definition
│ ├── build-live.ts # BuildService implementation
│ ├── build-live.ts # BuildService implementation (node: externals fix here)
│ ├── persist-local.ts # PersistLocalService definition
│ ├── persist-local-live.ts # PersistLocalService implementation
│ ├── persist-local.test.ts # PersistLocalService tests
Expand All @@ -1084,8 +1094,15 @@ src/
├── build.ts # Build command handler
├── validate.ts # Validate command handler
└── init.ts # Init command handler

__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
```

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`.

---

## Implementation Status
Expand Down
26 changes: 13 additions & 13 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,15 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with
code in this repository.
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## Project Status

**GitHub Action Builder** - CLI tool and library for building Node.js 24
GitHub Actions using `@rsbuild/core` (rspack-based). Validates `action.yml`
against GitHub schema and bundles TypeScript into production-ready JavaScript.
**GitHub Action Builder** - CLI tool and library for building Node.js 24 GitHub Actions using `@rsbuild/core` (rspack-based). Validates `action.yml` against GitHub schema and bundles TypeScript into production-ready JavaScript.

**For detailed architecture:**
-> `@./.claude/design/github-action-builder/architecture.md`
→ `@./.claude/design/github-action-builder/architecture.md`

Load when working on service layer design, build pipeline implementation,
or making architectural decisions.
Load when working on service layer design, build pipeline implementation, or making architectural decisions.

## Quick Reference

Expand Down Expand Up @@ -47,8 +43,10 @@ pnpm run build:prod # Build production/npm output only
### Running a Single Test

```bash
pnpm vitest run src/errors.test.ts # Specific file
pnpm vitest run -t "defineConfig" # Pattern match
pnpm vitest run src/errors.test.ts # Specific unit test file
pnpm vitest run -t "defineConfig" # Pattern match
pnpm vitest run --project :int # All integration tests
pnpm vitest run __test__/integration/cjs-node-interop.int.test.ts # Specific integration test
```

## Architecture
Expand All @@ -64,11 +62,11 @@ pnpm vitest run -t "defineConfig" # Pattern match

Uses ConfigService, ValidationService, and BuildService composed via AppLayer.

-> `@./.claude/design/github-action-builder/architecture.md#service-layer`
→ `@./.claude/design/github-action-builder/architecture.md#service-layer`

### Build Pipeline

Uses Rslib with dual output:
Uses `@rsbuild/core` (rspack-based) with dual output:

1. `dist/dev/` - Development build with source maps
2. `dist/npm/` - Production build for npm publishing
Expand All @@ -95,7 +93,9 @@ Turbo tasks define dependencies: `typecheck` depends on `build` completing first

* **Framework**: Vitest with v8 coverage
* **Pool**: Uses forks (not threads) for Effect-TS compatibility
* **Config**: `vitest.config.ts` supports project-based filtering
* **Config**: `vitest.config.ts` supports project-based filtering; coverage level is `none` (targets set to `strict`)
* **Unit tests**: `src/**/*.test.ts`
* **Integration tests**: `__test__/integration/**/*.int.test.ts` — auto-discovered by `@savvy-web/vitest` as the `:int` project

For Effect-TS testing patterns, see the architecture design doc.

Expand Down
55 changes: 55 additions & 0 deletions __test__/integration/cjs-node-interop.int.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
/**
* Integration regression test for issue #79.
*
* A bundled CommonJS dependency that does `__importDefault(require("node:*"))`
* must keep working at runtime. The builder has to externalize `node:` builtins
* with CommonJS require() semantics; otherwise `require("node:stream")` resolves
* to an ESM namespace object and downstream `instanceof` checks throw
* `TypeError: Right-hand side of 'instanceof' is not callable`.
*/
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 { beforeAll, describe, expect, it } from "vitest";

import { GitHubAction } from "../../src/index.js";

const fixtureDir = resolve(dirname(fileURLToPath(import.meta.url)), "fixtures/cjs-node-interop");
const mainJs = join(fixtureDir, "dist", "main.js");

describe("issue #79: CJS node: builtin interop", () => {
beforeAll(async () => {
rmSync(join(fixtureDir, "dist"), { recursive: true, force: true });
const action = GitHubAction.create({
cwd: fixtureDir,
skipValidation: true,
config: {
build: { minify: false },
persistLocal: { enabled: false },
},
});
const result = await action.build();
if (!result.success) {
throw new Error(`fixture build failed: ${result.error ?? "unknown error"}`);
}
}, 120_000);

it("bundles the fixture action", () => {
expect(existsSync(mainJs)).toBe(true);
});

it("runs the bundled action without an 'instanceof is not callable' TypeError", () => {
let output: string;
try {
output = execFileSync("node", [mainJs], {
encoding: "utf8",
stdio: ["ignore", "pipe", "pipe"],
});
} catch (error) {
const err = error as { stderr?: string; stdout?: string };
throw new Error(`bundled action crashed at runtime:\n${err.stderr ?? err.stdout ?? String(error)}`);
}
expect(output.trim()).toBe("isStream=true");
});
});
5 changes: 5 additions & 0 deletions __test__/integration/fixtures/cjs-node-interop/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name: cjs-node-interop
description: Integration fixture for issue #79 — CJS node: builtin interop.
runs:
using: node24
main: dist/main.js
8 changes: 8 additions & 0 deletions __test__/integration/fixtures/cjs-node-interop/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Readable } from "node:stream";

// Imports a bundled CommonJS dependency that uses the __importDefault interop
// helper against a node: builtin. See vendor/legacy-cjs-dep.cjs and issue #79.
import { isStream } from "../vendor/legacy-cjs-dep.cjs";

const readable = Readable.from(["integration"]);
process.stdout.write(`isStream=${isStream(readable)}\n`);
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
"use strict";
// Hand-authored CommonJS module that reproduces the TypeScript-emitted interop
// pattern shipped by packages such as minipass (dist/commonjs/index.js):
//
// const node_stream_1 = __importDefault(require("node:stream"));
// ... value instanceof node_stream_1.default
//
// When the builder externalizes `node:stream` with ESM ("module") semantics,
// require("node:stream") resolves to a namespace object, __importDefault wraps
// it as { default: <namespace> }, and `instanceof` throws at runtime:
// TypeError: Right-hand side of 'instanceof' is not callable
// See issue #79.
var __importDefault =
(this && this.__importDefault) ||
function (mod) {
return mod && mod.__esModule ? mod : { default: mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.isStream = void 0;
const node_stream_1 = __importDefault(require("node:stream"));
const isStream = (value) => value instanceof node_stream_1.default;
exports.isStream = isStream;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export declare const isStream: (value: unknown) => boolean;
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -67,16 +67,16 @@
"@effect/printer": "catalog:silk",
"@effect/printer-ansi": "catalog:silk",
"@effect/typeclass": "catalog:silk",
"@rsbuild/core": "^1.7.4",
"@rsbuild/core": "^2.0.6",
"effect": "catalog:silk",
"jiti": "^2.6.1",
"jiti": "^2.7.0",
"picocolors": "^1.1.1",
"yaml-effect": "^0.2.3"
"yaml-effect": "^0.6.0"
},
"devDependencies": {
"@savvy-web/changesets": "^0.9.0",
"@savvy-web/changesets": "^0.10.0",
"@savvy-web/commitlint": "^0.9.0",
"@savvy-web/lint-staged": "^1.0.0",
"@savvy-web/lint-staged": "^1.0.1",
"@savvy-web/rslib-builder": "^0.20.3",
"@savvy-web/vitest": "^1.3.2"
},
Expand Down
Loading
Loading