A GitHub Action at work goes from a Node.js action to a composite action running a single bash script.
The scenario is ideal: the action only does an HTTP request, JSON parsing, and some conditions.
Before and after ΒΆ
| Before (Node) | After (Bash) | |
|---|---|---|
| Files | ~15 | 1 |
| Size | 137M | 36K |
| Runtime | node20 | composite + bash |
| Dependencies | 19 npm packages | curl, jq, openssl |
| Build step | ncc bundle + commit dist/ | none |
| Lines of logic | 2000 | ~179 |
| CI workflows | lint, format, test, pkg | bash -n |
| Supply chain risk | medium (npm) | near zero |
Before file tree:
.babelrc
.eslintignore
.eslintrc.json
.prettierignore
.prettierrc.json
.node-version
action.yml
package.json (7 deps, 12 devDeps)
package-lock.json
node_modules/ (hundreds of packages)
dist/index.js (bundled with ncc)
src/main.js
src/functions/service.jsAfter file tree:
action.sh
action.ymlBenefits ΒΆ
Fewer moving parts.
Before: Babel transpiles, ncc bundles, eslint lints, prettier formats, jest tests, got makes HTTP calls,
@actions/corereads inputs, crypto signs.After: one script, three system tools:
curlfor HTTP,jqfor JSON,opensslfor HMAC signing.No JS or npm to manage.
Before: 19 packages, each can ship a breaking change, get a CVE, or add Dependabot noise.
After: tools pinned to the runner image.
At this point, I would rather not have to touch Javascript anymore.
No build step, simpler CI.
Before: every JS change needs
ncc build, then commitdist/. Forget to build? The action runs stale code. CI needs lint + format-check + test + package-check + acceptance just to catch that.After: edit the script, push. CI is shellcheck + acceptance.
Smaller attack surface.
Before:
node_modulesexposes transitive dep injection vectors. JS crypto wrapper in between.After: no
node_modules. HMAC signing usesopenssl dgstdirectly.Simple to reason about.
Linear logic: fetch, check, set an output. No async, no class hierarchy, no module graph. If the runner has
curl,jq, andopenssl, there’s nothing to install.
Shortcomings ΒΆ
Bash does not fit when:
- You need rich SDK features: pagination, GraphQL clients, retries with backoff.
- The logic branches heavily or manages complex state.
- You need unit tests with mocks for many code paths.
Conclusion ΒΆ
The best code is code you don’t maintain. Bash gives this action fewer files, fewer deps, fewer failure modes, and the same result.
If everything you need is already in the default Unix toolbox, use it.