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
11 changes: 11 additions & 0 deletions .changeset/quiet-roads-return.md
Original file line number Diff line number Diff line change
@@ -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.
65 changes: 44 additions & 21 deletions .claude/design/github-action-builder/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
},
};
```
Expand Down Expand Up @@ -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. |

---

Expand Down Expand Up @@ -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.

---

Expand Down
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,4 +34,5 @@ pnpm-release.json
.claude/plans/
.claude/plugins-debug.log
.playwright-mcp
.mcp.json
.mcp.json
docs/superpowers/
59 changes: 26 additions & 33 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,27 +1,23 @@
# @savvy-web/github-action-builder

A zero-config build tool for creating GitHub Actions from TypeScript source
code. Bundles your action with [@rsbuild/core](https://github.com/web-infra-dev/rsbuild)
(rspack-based), validates `action.yml` against GitHub's official schema, and
outputs production-ready Node.js 24 actions.
[![npm](https://img.shields.io/npm/v/@savvy-web%2Fgithub-action-builder?label=npm&color=cb3837)](https://www.npmjs.com/package/@savvy-web/github-action-builder)
[![License: MIT](https://img.shields.io/badge/License-MIT-4caf50.svg)](https://opensource.org/licenses/MIT)
[![Node.js 24](https://img.shields.io/badge/Node.js-24-5fa04e.svg)](https://nodejs.org/)

Build a GitHub Action from TypeScript source without writing build config. The builder bundles your code with [@rsbuild/core](https://github.com/web-infra-dev/rsbuild) (rspack under the hood) and checks `action.yml` against GitHub's published metadata schema. The output is a single Node.js 24 file you commit to your repo.

## Features

- **Zero-config** - Auto-detects entry points from `src/main.ts`, `src/pre.ts`,
`src/post.ts`
- **Node.js 24** - Builds modern ESM actions for the latest GitHub Actions
runtime
- **Schema validation** - Validates `action.yml` against GitHub's official
metadata specification
- **Single-file bundles** - All npm dependencies inlined via rsbuild, `node:`
builtins externalized, with tree-shaking support
- **Local testing** - Auto-persists build output for testing with
[nektos/act](https://github.com/nektos/act)
- **No build config required** - Picks up entry points from `src/main.ts`, `src/pre.ts`, `src/post.ts` on its own
- **Node.js 24** - Emits ESM actions that run on the `node24` GitHub Actions runtime
- **Schema validation** - Validates `action.yml` against GitHub's official metadata specification
- **Single-file bundles** - All npm dependencies inlined via rsbuild; `node:` builtins externalized; user-configured `externals` and `ignore` options for optional or native modules
- **Local testing** - Auto-persists build output for testing with [nektos/act](https://github.com/nektos/act)
- **CI-aware** - Strict validation in CI, warnings-only locally

## Quick Start
## Quick start

Create a new GitHub Action project with a single command:
Scaffold a new GitHub Action project, then build it:

```bash
npx @savvy-web/github-action-builder init my-action
Expand All @@ -30,8 +26,7 @@ npm install
npm run build
```

That's it! Your action is built and ready. The `init` command generates a
complete project:
The `init` command lays down the project:

```text
my-action/
Expand All @@ -45,10 +40,9 @@ my-action/
└── tsconfig.json # TypeScript configuration
```

Edit `src/main.ts` with your action logic, then rebuild with `npm run build`.
Your bundled action is in `dist/main.js`, ready to commit and use.
Put your action logic in `src/main.ts` and run `npm run build` again. The bundled action lands at `dist/main.js` — commit that file to your repo.

## Basic Usage
## Basic usage

### Initialize

Expand Down Expand Up @@ -78,7 +72,7 @@ npm run validate
npx @savvy-web/github-action-builder validate
```

## Project Structure
## Project structure

The builder expects this structure:

Expand Down Expand Up @@ -112,7 +106,7 @@ export default GitHubAction.create({
});
```

## action.yml Requirements
## action.yml requirements

Your `action.yml` must use Node.js 24:

Expand All @@ -127,12 +121,12 @@ runs:

## Documentation

- [Getting Started](./docs/getting-started.md) - Installation and first build
- [Configuration](./docs/configuration.md) - All configuration options
- [Local Testing](./docs/local-testing.md) - Testing with nektos/act
- [CLI Reference](./docs/cli-reference.md) - Complete command reference
- [Architecture](./docs/architecture.md) - How it works internally
- [Troubleshooting](./docs/troubleshooting.md) - Common issues and solutions
- [Getting started](./docs/01-getting-started.md) - Installation and first build
- [Configuration](./docs/02-configuration.md) - All configuration options
- [Local testing](./docs/03-local-testing.md) - Testing with nektos/act
- [CLI reference](./docs/04-cli-reference.md) - Complete command reference
- [Architecture](./docs/05-architecture.md) - How it works internally
- [Troubleshooting](./docs/06-troubleshooting.md) - Common issues and solutions

## Programmatic API

Expand All @@ -146,10 +140,11 @@ const result = await action.build();

if (result.success) {
console.log(`Built ${result.build?.entries.length} entry points`);
// e.g. "Built 3 entry points"
}
```

## Shared TypeScript Configuration
## Shared TypeScript configuration

The package exports a base `tsconfig.json` for GitHub Action projects:

Expand All @@ -159,9 +154,7 @@ The package exports a base `tsconfig.json` for GitHub Action projects:
}
```

This provides sensible defaults for Node.js 24 ESM actions including strict
mode, ES2022 target, and bundler module resolution. Override or extend as
needed in your project's `tsconfig.json`.
It sets up Node.js 24 ESM actions with strict mode, an ES2022 target and bundler module resolution. Override any of it in your own `tsconfig.json`.

## Requirements

Expand Down
5 changes: 5 additions & 0 deletions __test__/integration/fixtures/ignore-modules/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name: ignore-modules
description: Integration fixture for the build.ignore option (issue #81).
runs:
using: node24
main: dist/main.js
3 changes: 3 additions & 0 deletions __test__/integration/fixtures/ignore-modules/src/main.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { xmlAvailable } from "../vendor/optional-loader.cjs";

process.stdout.write(`xmlAvailable=${xmlAvailable}\n`);
Original file line number Diff line number Diff line change
@@ -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;
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export declare const xmlAvailable: boolean;
5 changes: 5 additions & 0 deletions __test__/integration/fixtures/string-externals/action.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
name: string-externals
description: Integration fixture for issue #81 — string externals regression.
runs:
using: node24
main: dist/main.js
8 changes: 8 additions & 0 deletions __test__/integration/fixtures/string-externals/src/main.ts
Original file line number Diff line number Diff line change
@@ -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`);

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

50 changes: 50 additions & 0 deletions __test__/integration/ignore-modules.int.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading