@savvy-web/github-action-builder 1.0.2 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -11,7 +11,7 @@ Build a GitHub Action from TypeScript source without writing build config. The b
11
11
  - **No build config required** - Picks up entry points from `src/main.ts`, `src/pre.ts`, `src/post.ts` on its own
12
12
  - **Node.js 24** - Emits ESM actions that run on the `node24` GitHub Actions runtime
13
13
  - **Schema validation** - Validates `action.yml` against GitHub's official metadata specification
14
- - **Single-file bundles** - All npm dependencies inlined via rsbuild; `node:` builtins externalized; user-configured `externals` and `ignore` options for optional or native modules
14
+ - **Single-file bundles** - All npm dependencies inlined via rsbuild; `node:` builtins externalized; user-configured `externals`, `ignore` and `nativeDynamicImports` options for native modules, optional dependencies and runtime-resolved dynamic imports
15
15
  - **Local testing** - Auto-persists build output for testing with [nektos/act](https://github.com/nektos/act)
16
16
  - **CI-aware** - Strict validation in CI, warnings-only locally
17
17
 
@@ -26,7 +26,7 @@ const rootCommand = Command.make("github-action-builder").pipe(Command.withSubco
26
26
  */
27
27
  const cli = Command.run(rootCommand, {
28
28
  name: "github-action-builder",
29
- version: "1.0.2"
29
+ version: "1.1.0"
30
30
  });
31
31
  /**
32
32
  * Combined layer: AppLayer + NodeContext for CLI.
@@ -20,7 +20,7 @@ const forceOption = Options.boolean("force").pipe(Options.withAlias("f"), Option
20
20
  * Get current package version (replaced at build time).
21
21
  */
22
22
  const getPackageVersion = () => {
23
- return "1.0.2";
23
+ return "1.1.0";
24
24
  };
25
25
  /**
26
26
  * Generate package.json content.
package/index.d.ts CHANGED
@@ -51,6 +51,18 @@ declare const BuildOptionsSchema: Schema.Struct<{
51
51
  ignore: Schema.optionalWith<Schema.Array$<typeof Schema.String>, {
52
52
  default: () => never[];
53
53
  }>;
54
+ /**
55
+ * Packages whose dynamic `import(...)` calls must stay native `import()` at runtime instead of
56
+ * being compiled into an rspack context module. Use this for packages that resolve a module path
57
+ * at runtime (e.g. from a config value or a computed changelog id) and dynamically import it —
58
+ * rspack cannot statically analyze a fully dynamic `import(expr)`, so it emits an empty-context
59
+ * stub that throws `Cannot find module` at runtime even though the file exists on disk. Listing
60
+ * the package here injects a `webpackIgnore` comment into its dynamic imports so rspack leaves
61
+ * them alone. Defaults to [].
62
+ */
63
+ nativeDynamicImports: Schema.optionalWith<Schema.Array$<typeof Schema.String>, {
64
+ default: () => never[];
65
+ }>;
54
66
  }>;
55
67
  /**
56
68
  * Build options for the bundler.
@@ -127,6 +139,7 @@ declare const ConfigInputSchema: Schema.Struct<{
127
139
  sourceMap: Schema.optional<typeof Schema.Boolean>;
128
140
  externals: Schema.optional<Schema.Array$<typeof Schema.String>>;
129
141
  ignore: Schema.optional<Schema.Array$<typeof Schema.String>>;
142
+ nativeDynamicImports: Schema.optional<Schema.Array$<typeof Schema.String>>;
130
143
  }>>;
131
144
  validation: Schema.optional<Schema.Struct<{
132
145
  requireActionYml: Schema.optional<typeof Schema.Boolean>;
@@ -176,6 +189,18 @@ declare const ConfigSchema: Schema.Struct<{
176
189
  ignore: Schema.optionalWith<Schema.Array$<typeof Schema.String>, {
177
190
  default: () => never[];
178
191
  }>;
192
+ /**
193
+ * Packages whose dynamic `import(...)` calls must stay native `import()` at runtime instead of
194
+ * being compiled into an rspack context module. Use this for packages that resolve a module path
195
+ * at runtime (e.g. from a config value or a computed changelog id) and dynamically import it —
196
+ * rspack cannot statically analyze a fully dynamic `import(expr)`, so it emits an empty-context
197
+ * stub that throws `Cannot find module` at runtime even though the file exists on disk. Listing
198
+ * the package here injects a `webpackIgnore` comment into its dynamic imports so rspack leaves
199
+ * them alone. Defaults to [].
200
+ */
201
+ nativeDynamicImports: Schema.optionalWith<Schema.Array$<typeof Schema.String>, {
202
+ default: () => never[];
203
+ }>;
179
204
  }>;
180
205
  validation: Schema.Struct<{
181
206
  /** Require action.yml to exist and be valid. Defaults to true. */requireActionYml: Schema.optionalWith<typeof Schema.Boolean, {
@@ -247,6 +272,7 @@ type Config = typeof ConfigSchema.Type;
247
272
  * sourceMap: true,
248
273
  * externals: ["@aws-sdk/client-s3"],
249
274
  * ignore: ["libxmljs2"],
275
+ * nativeDynamicImports: ["@changesets/apply-release-plan"],
250
276
  * },
251
277
  * validation: {
252
278
  * requireActionYml: true,
@@ -0,0 +1,100 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * rspack loader that injects the `webpackIgnore` magic comment into dynamic
5
+ * `import(...)` calls whose argument is not a string literal (or is a
6
+ * template literal that isn't fully static).
7
+ *
8
+ * @remarks
9
+ * rspack compiles a fully dynamic `import(expr)` (an argument it cannot
10
+ * statically resolve to a literal path) into a "context module" — a stub
11
+ * that globs a directory at build time and throws `Cannot find module` at
12
+ * runtime for any path it didn't see, even when the real file exists on
13
+ * disk. Some third-party packages (e.g. `@changesets/apply-release-plan`)
14
+ * resolve a module path at runtime and dynamically import it; bundling that
15
+ * call as a context module breaks it. The same is true of an *interpolated*
16
+ * template literal — `` import(`./x/${y}.js`) `` compiles to the same
17
+ * context-module stub as a bare identifier, because rspack cannot statically
18
+ * resolve the interpolated segment either.
19
+ *
20
+ * rspack (like webpack) respects a `/* webpackIgnore: true *\/` comment
21
+ * immediately inside the `import(` call — it skips context-module analysis
22
+ * for that call and leaves a plain, native, runtime `import()` in the
23
+ * output. This loader is a pure string transform (no AST parsing, so no
24
+ * source-map chaining — the returned source is treated as a 1:1 replacement
25
+ * and any pre-existing source map for this file is not remapped) that runs
26
+ * in two passes:
27
+ *
28
+ * 1. Inject the comment into any `import(` call whose argument is not a
29
+ * string literal, not a backtick template literal (handled in pass 2),
30
+ * and does not already carry a `webpackIgnore` magic comment. A
31
+ * *different* leading magic comment (e.g. `webpackChunkName`) does not
32
+ * suppress injection — the ignore comment is prepended alongside it, so
33
+ * both survive: `import(/* webpackIgnore: true *\/ /* webpackChunkName:
34
+ * "x" *\/ ident)`.
35
+ * 2. Inject the comment into a backtick template-literal argument, but only
36
+ * when it contains `${` interpolation. A fully static template literal
37
+ * (e.g. `` import(`./static.js`) ``) compiles to a literal path just like
38
+ * a plain string, so it is left untouched. Finding the template
39
+ * literal's closing backtick is a pragmatic linear scan (skipping
40
+ * backslash-escaped characters), not a real parser: it does not track
41
+ * nested template literals inside an interpolation (e.g.
42
+ * `` import(`${`nested`}`) ``) and will stop at the first unescaped
43
+ * backtick it sees, which is a documented limitation of this non-AST
44
+ * heuristic.
45
+ *
46
+ * Deliberately skipped (left untouched):
47
+ * - `import("./static.js")` — a string-literal import; the bundler already
48
+ * resolves and bundles these correctly, no need to touch them.
49
+ * - `` import(`./static.js`) `` — a fully static template literal; same
50
+ * reasoning as a plain string (see pass 2 above).
51
+ * - `import(/* webpackIgnore: true *\/ x)` — already has the comment
52
+ * injected (idempotent: running this loader twice must not double-inject).
53
+ * - `important(x)` — the `\b` word-boundary guard on `import` prevents
54
+ * matching inside a longer identifier.
55
+ *
56
+ * This file is shipped as a genuine on-disk `.cjs` file (not bundled away)
57
+ * because rspack loaders are loaded via `require()` at build time — see
58
+ * `public/` in this package's `package.json`, which is copied verbatim to
59
+ * the package root by the build (so this ships at `<pkg>/loaders/
60
+ * webpack-ignore-dynamic-imports.cjs`, not under a `public/` prefix).
61
+ *
62
+ * @param source - The original module source text.
63
+ * @returns The source text with `webpackIgnore` comments injected into
64
+ * every fully-dynamic `import(` call.
65
+ */
66
+ module.exports = function webpackIgnoreDynamicImportsLoader(source) {
67
+ // Pass 1: string-literal and backtick arguments are excluded from this
68
+ // pass (the former never needs the comment; the latter is handled by
69
+ // pass 2 below). An optional single leading `/* ... */` magic comment is
70
+ // captured so it can be inspected: if it already contains
71
+ // `webpackIgnore`, the call is left untouched (idempotent); otherwise the
72
+ // new comment is prepended ahead of it.
73
+ let result = source.replace(/\bimport\s*\(\s*(\/\*[\s\S]*?\*\/\s*)?(?!["'`])/g, (match, existingComment) => {
74
+ if (existingComment && /webpackIgnore/.test(existingComment)) {
75
+ return match;
76
+ }
77
+ return match.replace("(", "(/* webpackIgnore: true */ ");
78
+ });
79
+
80
+ // Pass 2: backtick template-literal arguments. Only interpolated
81
+ // template literals (containing `${`) need the comment; a fully static
82
+ // one is left untouched.
83
+ result = result.replace(/\bimport\s*\(\s*`/g, (match, offset, full) => {
84
+ const backtickIndex = offset + match.length - 1;
85
+ let end = backtickIndex + 1;
86
+ while (end < full.length && full[end] !== "`") {
87
+ if (full[end] === "\\") {
88
+ end++;
89
+ }
90
+ end++;
91
+ }
92
+ const literal = full.slice(backtickIndex, end + 1);
93
+ if (!literal.includes("${")) {
94
+ return match;
95
+ }
96
+ return match.replace(/`$/, "/* webpackIgnore: true */ `");
97
+ });
98
+
99
+ return result;
100
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@savvy-web/github-action-builder",
3
- "version": "1.0.2",
3
+ "version": "1.1.0",
4
4
  "private": false,
5
5
  "description": "A zero-config build tool for creating GitHub Actions from TypeScript. Bundles with rsbuild, validates action.yml against GitHub's schema, and outputs production-ready Node.js 24 actions.",
6
6
  "keywords": [
@@ -40,6 +40,7 @@
40
40
  "import": "./index.js"
41
41
  },
42
42
  "./tsconfig/action.json": "./tsconfig/action.json",
43
+ "./loaders/webpack-ignore-dynamic-imports.cjs": "./loaders/webpack-ignore-dynamic-imports.cjs",
43
44
  "./package.json": "./package.json"
44
45
  },
45
46
  "bin": {
@@ -62,7 +63,7 @@
62
63
  "yaml-effect": "^0.7.0"
63
64
  },
64
65
  "peerDependencies": {
65
- "@types/node": "^26.0.0",
66
+ "@types/node": "^26.1.0",
66
67
  "@typescript/native-preview": "^7.0.0-dev.20260612.1",
67
68
  "typescript": "^6.0.0"
68
69
  },
package/schemas/config.js CHANGED
@@ -54,7 +54,17 @@ const BuildOptionsSchema = Schema.Struct({
54
54
  /** Packages to exclude from the bundle (in addition to node: builtins). Defaults to []. */
55
55
  externals: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] }),
56
56
  /** 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 []. */
57
- ignore: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] })
57
+ ignore: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] }),
58
+ /**
59
+ * Packages whose dynamic `import(...)` calls must stay native `import()` at runtime instead of
60
+ * being compiled into an rspack context module. Use this for packages that resolve a module path
61
+ * at runtime (e.g. from a config value or a computed changelog id) and dynamically import it —
62
+ * rspack cannot statically analyze a fully dynamic `import(expr)`, so it emits an empty-context
63
+ * stub that throws `Cannot find module` at runtime even though the file exists on disk. Listing
64
+ * the package here injects a `webpackIgnore` comment into its dynamic imports so rspack leaves
65
+ * them alone. Defaults to [].
66
+ */
67
+ nativeDynamicImports: Schema.optionalWith(Schema.Array(Schema.String), { default: () => [] })
58
68
  });
59
69
  /**
60
70
  * Schema for validation options.
@@ -113,7 +123,8 @@ const ConfigInputSchema = Schema.Struct({
113
123
  minify: Schema.optional(Schema.Boolean),
114
124
  sourceMap: Schema.optional(Schema.Boolean),
115
125
  externals: Schema.optional(Schema.Array(Schema.String)),
116
- ignore: Schema.optional(Schema.Array(Schema.String))
126
+ ignore: Schema.optional(Schema.Array(Schema.String)),
127
+ nativeDynamicImports: Schema.optional(Schema.Array(Schema.String))
117
128
  })),
118
129
  validation: Schema.optional(Schema.Struct({
119
130
  requireActionYml: Schema.optional(Schema.Boolean),
@@ -178,6 +189,7 @@ const ConfigSchema = Schema.Struct({
178
189
  * sourceMap: true,
179
190
  * externals: ["@aws-sdk/client-s3"],
180
191
  * ignore: ["libxmljs2"],
192
+ * nativeDynamicImports: ["@changesets/apply-release-plan"],
181
193
  * },
182
194
  * validation: {
183
195
  * requireActionYml: true,
@@ -1,9 +1,11 @@
1
1
  import { BundleFailed, CleanError, WriteError } from "../errors.js";
2
2
  import { BuildService } from "./build.js";
3
3
  import { ConfigService } from "./config.js";
4
+ import { buildNativeDynamicImportRules } from "./native-dynamic-imports.js";
4
5
  import { Effect, Layer } from "effect";
5
6
  import { existsSync, mkdirSync, rmSync, statSync, writeFileSync } from "node:fs";
6
7
  import { resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
7
9
  import { createRsbuild } from "@rsbuild/core";
8
10
 
9
11
  //#region src/services/build-live.ts
@@ -18,6 +20,23 @@ import { createRsbuild } from "@rsbuild/core";
18
20
  */
19
21
  const IGNORE_STUB_SOURCE = `throw new Error("A module excluded via the build 'ignore' option was loaded at runtime.");\n`;
20
22
  /**
23
+ * Self-referencing specifier for the `webpackIgnore`-injecting loader
24
+ * shipped from `public/loaders/webpack-ignore-dynamic-imports.cjs` (see
25
+ * `package.json` `exports`). Resolved through the package's own `exports`
26
+ * map via `import.meta.resolve`, which stays correct whether this module is
27
+ * running from `src` (the map points at `./public/loaders/...`) or from a
28
+ * built `dist` (the map points at the flattened `./loaders/...`, since the
29
+ * `public/` copy step drops the `public/` prefix both on disk and in the
30
+ * built manifest) — no relative-path assumption needed either way.
31
+ */
32
+ const WEBPACK_IGNORE_LOADER_SPECIFIER = "@savvy-web/github-action-builder/loaders/webpack-ignore-dynamic-imports.cjs";
33
+ /**
34
+ * Resolve the absolute on-disk path to the `webpackIgnore`-injecting loader.
35
+ */
36
+ function resolveWebpackIgnoreLoaderPath() {
37
+ return fileURLToPath(import.meta.resolve(WEBPACK_IGNORE_LOADER_SPECIFIER));
38
+ }
39
+ /**
21
40
  * Format bytes as a human-readable string.
22
41
  */
23
42
  function formatBytes(bytes) {
@@ -90,6 +109,8 @@ function bundleEntry(entry, config, cwd) {
90
109
  const externalsSet = new Set(config.build.externals);
91
110
  const ignoreSet = new Set(config.build.ignore);
92
111
  const ignoreAlias = {};
112
+ // webpackIgnore-injecting loader below leaves those calls as native
113
+ const nativeDynamicImportRules = config.build.nativeDynamicImports.length > 0 ? buildNativeDynamicImportRules(config.build.nativeDynamicImports, resolveWebpackIgnoreLoaderPath()) : [];
93
114
  if (config.build.ignore.length > 0) {
94
115
  const stubPath = resolve(cwd, "node_modules", ".cache", "github-action-builder", "ignore-stub.mjs");
95
116
  yield* writeFile(stubPath, IGNORE_STUB_SOURCE);
@@ -122,7 +143,11 @@ function bundleEntry(entry, config, cwd) {
122
143
  __dirname: "node-module",
123
144
  __filename: "node-module"
124
145
  },
125
- module: { parser: { javascript: { importMeta: false } } },
146
+ module: {
147
+ parser: { javascript: { importMeta: false } },
148
+ // webpackIgnore-injecting loader (empty when the option is unset).
149
+ rules: nativeDynamicImportRules
150
+ },
126
151
  output: { asyncChunks: false }
127
152
  } }
128
153
  } }),
@@ -0,0 +1,65 @@
1
+ //#region src/services/native-dynamic-imports.ts
2
+ /**
3
+ * Helpers for the `build.nativeDynamicImports` option: building the rspack
4
+ * module-rule `test` pattern that matches a package's resolved module path
5
+ * under `node_modules`, and the module rules themselves.
6
+ *
7
+ * @remarks
8
+ * See {@link ../schemas/config.js#BuildOptionsSchema} for the option this
9
+ * supports, and `webpack-ignore-dynamic-imports.cjs` (shipped from
10
+ * `public/loaders/`, see `package.json` `exports`) for the loader these
11
+ * rules point at.
12
+ *
13
+ * @internal
14
+ */
15
+ /**
16
+ * Escape a string for literal use inside a `RegExp`, and turn every `/` into
17
+ * a `[\/]` alternation so the result matches both POSIX and Windows path
18
+ * separators.
19
+ *
20
+ * @remarks
21
+ * Order matters: the regex-metacharacter escape runs first (it never
22
+ * touches `/`, which is not a regex metacharacter), then `/` is expanded to
23
+ * `[\/]` afterward — a scoped package name like `@changesets/apply-release-plan`
24
+ * has a literal `/` that must become a path-separator alternation, not stay
25
+ * a bare `/`.
26
+ */
27
+ function escapePathSegment(value) {
28
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&").replace(/\//g, "[\\/]");
29
+ }
30
+ /**
31
+ * Build a `RegExp` matching a package's resolved absolute module path under
32
+ * `node_modules`, in both the flat layout (`node_modules/<name>/`) and the
33
+ * pnpm layout (`node_modules/.pnpm/<name>@x.y.z/node_modules/<name>/`).
34
+ *
35
+ * @param packageName - The npm package name to match, as configured in
36
+ * `build.nativeDynamicImports` (e.g. `"@changesets/apply-release-plan"` or
37
+ * `"some-unscoped-pkg"`).
38
+ * @returns A `RegExp` suitable for an rspack module rule's `test`.
39
+ *
40
+ * @internal
41
+ */
42
+ function buildNativeDynamicImportPathPattern(packageName) {
43
+ const escapedName = escapePathSegment(packageName);
44
+ return new RegExp(`[\\/]node_modules[\\/](\\.pnpm[\\/][^\\/]+[\\/]node_modules[\\/])?${escapedName}[\\/]`);
45
+ }
46
+ /**
47
+ * Build the rspack `module.rules` entries for `build.nativeDynamicImports`:
48
+ * one rule per configured package name, each pointing at the
49
+ * `webpackIgnore`-injecting loader.
50
+ *
51
+ * @param packageNames - Package names configured in `build.nativeDynamicImports`.
52
+ * @param loaderPath - Absolute, resolved path to `webpack-ignore-dynamic-imports.cjs`.
53
+ * @returns One module rule per package name. Empty when `packageNames` is empty.
54
+ *
55
+ * @internal
56
+ */
57
+ function buildNativeDynamicImportRules(packageNames, loaderPath) {
58
+ return packageNames.map((packageName) => ({
59
+ test: buildNativeDynamicImportPathPattern(packageName),
60
+ use: [{ loader: loaderPath }]
61
+ }));
62
+ }
63
+
64
+ //#endregion
65
+ export { buildNativeDynamicImportPathPattern, buildNativeDynamicImportRules };