skip to content

What are the three types of GitHub Action you can author, and how do they differ?

level: middleimportance: should knowfreq 58%

answer

  1. The runs.using key names the model
  2. One is just packaged steps
  3. One needs a shell: on every run step
  4. One is confined to Linux runners
  5. Startup cost differs sharply

basics

~20 s

Composite actions bundle a sequence of workflow steps in YAML. JavaScript actions run a Node script directly on the runner. Docker container actions run your code inside an image. The action.yml runs.using key picks which, and Docker actions run only on Linux runners.

solid answer

~50 s

All three are declared in `action.yml` by `runs.using`. A **composite** action (`using: composite`) is a list of `steps` — `run` commands and even other `uses:` steps — that are spliced into the calling job; each `run` step must declare a `shell`. A **JavaScript** action (`using: node20`, with `main:` pointing at a bundled entry file) runs on the runner's own Node runtime: it is the fastest to start and the only type that works identically on Linux, Windows and macOS runners. A **Docker container** action (`using: docker`, with `image: Dockerfile` or `image: docker://...`) runs your code in a container, giving you any language and full dependency control, at the cost of a build or pull per run and **Linux runners only**. Rule of thumb: composite for gluing shell steps, JavaScript for cross-platform actions, Docker when you need a runtime the runner does not have.

code

yaml · 15 lines
yaml
name: Publish report
description: Uploads a build report and prints a summary
inputs:
  path:
    description: Report file to publish
    required: true
runs:
  using: composite
  steps:
    - run: cat "${{ inputs.path }}" >> "$GITHUB_STEP_SUMMARY"
      shell: bash
    - uses: actions/upload-artifact@v4
      with:
        name: report
        path: ${{ inputs.path }}

go deeper

for a junior

Recall that actions come in composite, JavaScript and Docker flavours, and that action.yml is what makes a directory an action.

for a middle

Explain runs.using for each type, the shell: requirement on composite steps, the INPUT_ environment-variable convention, and Docker's Linux-only constraint.

for a senior

Show judgment about which type to reach for given startup cost, cross-platform reach and dependency control, and about publishing pre-built images rather than Dockerfiles.

for a principal

Own the shared-action strategy: how many internal actions exist, their runtime-deprecation upgrade path, and whether teams should write actions at all versus reusable workflows.

## The metadata file decides Every action is a repository (or a directory) containing `action.yml` (or `action.yaml`). Its required keys are `name`, `description` and `runs`; `runs.using` selects the execution model. ## Composite actions ```yaml name: Setup toolchain description: Installs and configures the build toolchain inputs: version: description: Toolchain version required: true runs: using: composite steps: - run: ./install.sh "${{ inputs.version }}" shell: bash ``` A composite action is packaged workflow steps. It executes on the runner, in the job's context, and can itself call other actions with `uses:`. Two details catch people out: every `run` step **must** specify `shell:`, and the `secrets` context is **not** available inside a composite action — secrets must be passed in explicitly as inputs. Reference files shipped alongside the action with the `GITHUB_ACTION_PATH` environment variable, because the working directory is the caller's workspace, not the action's directory. Use it when the thing you are factoring out is genuinely a sequence of shell steps you keep copying between workflows. ## JavaScript actions ```yaml runs: using: node20 main: dist/index.js post: dist/cleanup.js ``` The runner executes the entry file with its bundled Node runtime. There is no container to pull and no shell to spawn, so startup is the cheapest of the three, and the same action runs on Linux, Windows and macOS. Inputs arrive as `INPUT_<NAME>` environment variables (upper-cased, spaces replaced by underscores); the `@actions/core` toolkit wraps that and the output/logging commands. Because the runner will not install dependencies for you, the convention is to bundle `node_modules` into a single `dist/index.js` and commit it. `pre:` and `post:` entry points let an action do setup and guaranteed cleanup around the job — how `actions/cache` saves at job end. Node runtimes are versioned and periodically deprecated, so an action's `using:` value is a maintenance item. ## Docker container actions ```yaml runs: using: docker image: Dockerfile args: - ${{ inputs.target }} ``` The runner builds the `Dockerfile` (or pulls the referenced image) and runs the container with the workspace mounted. You control the entire environment, so any language works and the tool versions are exactly what you shipped. The costs are real: a build or pull on every run unless the image is pre-published, and **Linux runners only** — a Docker action cannot run on a Windows or macOS hosted runner. Inputs again arrive as `INPUT_*` environment variables. ## Choosing - Repeating five shell steps across ten workflows? **Composite.** - Publishing an action others will use on any runner OS, with logic rather than shell glue? **JavaScript.** - Need a compiler, CLI or runtime the runner image does not have, and Linux-only is acceptable? **Docker**, ideally referencing a pre-built published image rather than a `Dockerfile` so consumers do not pay build time. All three are equally "real" actions from the caller's side: the `uses:` step looks identical, and the caller cannot tell which model is behind it except from execution time and OS support.

  • Why can a composite action not read the secrets context?
    Composite actions are not given the secrets context at all; only the calling workflow can reference `secrets.*`. The caller must pass what the action needs as an explicit input, which is arguably better design because the action's dependency on a credential becomes visible in its action.yml rather than implicit.
  • Why do JavaScript actions commit a bundled dist directory?
    The runner does not install dependencies before executing an action, so a bare src/index.js with a package.json would fail on a missing module. Authors bundle the entry point and its dependencies into one committed file, typically with a build step, so the action is self-contained at whatever ref a consumer pins.
  • What does a post: entry point in a JavaScript action do?
    It runs after the job's steps complete, even if a step failed, giving the action guaranteed cleanup or finalisation. That is how caching actions save at the end of a job and how setup actions can tear down credentials they wrote, without the caller having to add an explicit if: always() step.

saying these in an interview costs you the question

  • Forgetting shell: on a composite run step
  • Expecting a Docker action to run on Windows runners
  • Assuming secrets are visible inside a composite action
  • Thinking the runner installs a JavaScript action's dependencies
  • Choosing Docker for what is really three shell commands

context