5.1.1. JavaScript, Docker, and Composite Actions
💡 First Principle: The three types differ in what the runner has to do before your code executes — run bundled JS on the preinstalled Node runtime, build or pull a container image, or simply inline your steps into the calling job — and that startup work is exactly the performance and portability trade-off.
| Dimension | JavaScript | Docker | Composite |
|---|---|---|---|
runs.using | node20 (or node24) | docker | composite |
| Runner OS | Linux, Windows, macOS | Linux only | Any (inherits caller) |
| Startup cost | Fastest — runtime already present | Slowest — build or pull image | None — steps inline |
| Language | JavaScript/TypeScript (compiled to JS) | Any language in the image | Shell + other actions |
| Isolation | Runs on the runner host | Isolated container | Runs on the runner host |
Can set runs-on | ❌ inherits | ❌ inherits | ❌ inherits |
Access to secrets context | Via inputs/env | Via inputs/env | ⚠️ Must be passed as inputs |
| Typical use | API calls, cross-platform tooling | Custom toolchains, non-JS languages | Bundling your own repeated steps |
A JavaScript action points at a single entry file, and that file must be self-contained: the runner does not run npm install, so dependencies must be committed either as a checked-in node_modules or, far better, bundled with @vercel/ncc into one dist/index.js. It uses the @actions/core toolkit to read inputs and write outputs, and it can declare pre: and post: entry points that run before and after the main job step — the mechanism behind actions that set something up and reliably tear it down (actions/cache saves its cache in a post: step).
A Docker action names either an image (image: 'docker://alpine:3.19') or a local Dockerfile that GitHub builds at run time. Inputs arrive as INPUT_<NAME> environment variables, and args:/entrypoint: in the metadata override the image's defaults. The Linux-only restriction is absolute on hosted runners, and building the image per run is the reason these are slowest — pre-building and referencing a published image is the standard optimization.
A composite action lists steps: directly in action.yml. Every run: step must declare a shell:, and the steps execute in the caller's job on the caller's runner, sharing its filesystem. The catch is context access: secrets are not automatically available, so anything sensitive must be declared as an input and passed by the caller.
⚠️ Exam Trap: Docker container actions cannot run on Windows or macOS runners. If a scenario requires cross-platform support, the answer is JavaScript (or composite) — no amount of configuration makes a Docker action run on windows-latest.
Reflection Question: A JavaScript action's repository has a package.json with three dependencies and no dist/. It works locally and fails on the runner with "Cannot find module." Explain the mechanism, and name the two accepted fixes.