Composite Actions, JavaScript Actions, Docker Actions, and Custom Automation: Core Concepts and Mental Model
Chapter 16 reused whole job graphs through workflow_call. Chapter 17 moves the reuse boundary inside one caller-owned job. A custom action is executable step-level code selected by uses, described by action.yml, and executed as composite shell steps, a JavaScript runtime, or a Docker container. The execution model determines portability, packaging, trust, state and failure behavior.
Learning objectives
- Explain why workflow reuse and step-level custom actions solve different abstraction problems.
- Trace a step from uses → action.yml → execution runtime → outputs and post-state.
- Distinguish composite, JavaScript and Docker execution boundaries and runner requirements.
- Inspect action identity, metadata, runtime, bundled dependencies, permissions and release provenance before execution.
- Treat action inputs as untrusted strings unless the action validates them explicitly.
1. The practical problem: repeated step logic needs an execution contract
Chapter 16 extracted repeated jobs into reusable workflows. Sometimes the duplicated unit is smaller: normalize a version string, validate a manifest, compute a digest, configure a tool, or perform a consistent repository check inside an existing job. Copying those steps into every job creates drift, but moving them into a reusable workflow would also move runner and job ownership. A custom action packages step-level behavior while the caller keeps the job.
The important question is therefore not “how do I make an action?” but “which execution model matches the trust, portability and dependency boundary?” A composite action reuses workflow-like steps; a JavaScript action runs packaged Node code directly on the runner; a Docker action runs code in a Linux container with a controlled image environment.
2. Mental model: uses → metadata → runtime → result
A workflow step begins with an action identity:
repository/path/ref/SHA for an external action or a local path after
checkout. GitHub reads action.yml, validates its
declared inputs/outputs and runs model, then dispatches
to composite steps, a Node runtime, or a Docker image. That
implementation reads caller-provided values, environment and files,
may save action-local state for a post phase, writes non-sensitive
outputs, and returns a step conclusion to the caller.
flowchart TD
A[Caller event + exact workflow SHA] --> B[Job on chosen runner]
B --> C[Step uses action identity]
C --> D[action.yml metadata]
D --> E{runs.using}
E -->|composite| F[Composite run/uses steps]
E -->|node24| G[Bundled JavaScript dist]
E -->|docker| H[Linux Docker image/container]
F --> I[Inputs env workspace]
G --> I
H --> I
I --> J[GITHUB_OUTPUT / action state]
J --> K[Caller step conclusion + outputs]
K --> L[Evidence: SHA runtime runner logs provenance]
Every arrow is an ownership boundary. The workflow chooses the action and the job permissions. The metadata chooses the execution model. The runner supplies operating-system capabilities. The action implementation owns validation and side effects. The caller must verify both the action identity and the result.
3. State ledger before execution
| State | Examples | Why it matters |
|---|---|---|
| Event/revision | event name, caller ref/SHA, run ID/attempt | proves which workflow revision selected the action |
| Action identity | owner/repo/path + full SHA, or local path | proves which executable dependency ran |
| Metadata |
action.yml inputs/outputs/runs
|
defines the public action contract and runtime |
| Runner | OS/arch/image/self-hosted identity | determines shell, Docker and binary availability |
| Trust/token |
job permissions, secrets passed through
env/with
|
an action executes with the caller job capability |
| Implementation |
composite steps, bundled dist, or
image/Dockerfile
|
the actual executable content under review |
| State/output |
GITHUB_OUTPUT, GITHUB_STATE,
workspace files
|
cross-step or pre/post data must be explicit |
| Provenance | action commit SHA plus release/tag mapping | lets reviewers connect a friendly version to immutable code |
4. action.yml is an interface, not a permission declaration
Every custom action needs action.yml or
action.yaml; GitHub prefers action.yml. It
declares a name, description, optional author, input/output metadata
and a runs block. Unlike
workflow_call inputs, custom-action metadata does not
provide a general type: field. Inputs arrive as
strings, so “integer,” “boolean,” or enum semantics belong in
documentation and executable validation.
name: Chapter 17 contract
description: Validate a mode and return a safe summary
inputs:
mode:
description: "Expected enum: strict or relaxed"
required: true
repeat:
description: "Expected integer string from 1 through 3"
required: false
default: "1"
outputs:
summary:
description: Non-sensitive normalized result
value: ${{ steps.validate.outputs.summary }}
runs:
using: composite
steps:
- id: validate
shell: bash
env:
MODE: ${{ inputs.mode }}
REPEAT: ${{ inputs.repeat }}
run: |
set -euo pipefail
case "$MODE" in strict|relaxed) ;; *) exit 64 ;; esac
[[ "$REPEAT" =~ ^[1-3]$ ]] || exit 65
printf 'summary=mode-%s-repeat-%s
' "$MODE" "$REPEAT" >> "$GITHUB_OUTPUT"
Notice that expression values are transferred into environment variables and then quoted by the shell. Directly inserting attacker-controlled input into shell source would merge the data plane with the program text and create an injection boundary.
5. Composite action: reuse steps, inherit the caller runner
A composite action sets runs.using: composite. Its
internal entries can be run steps or
uses steps. It executes inside the caller job and
therefore inherits that job's runner operating system, installed
tools, workspace, network reachability and token/secrets made
available to the step.
That makes composite actions excellent for transparent orchestration
but less isolated than a Docker action. If the composite expects
Bash, Python, Git or another binary, document that prerequisite or
install it explicitly through a reviewed mechanism. Use
GITHUB_ACTION_PATH for files shipped with the action
rather than assuming the caller's current directory.
6. JavaScript action: package code with a known Node runtime
A JavaScript action uses a GitHub-provided Node runtime and normally
commits a bundled dist/ file so callers do not run
npm install during every workflow. As of this chapter's
baseline, new actions should use node24. If source code
depends on @actions/core,
@actions/github or other packages, the release process
must bundle those dependencies into the shipped executable.
runs:
using: node24
main: dist/index.js
post: dist/post.js
post-if: always()
JavaScript actions can run on Linux, Windows and macOS when their
packaged code does not assume platform-specific binaries. A
post phase can consume action-local state saved through
the Actions state mechanism; do not use post steps as an excuse to
perform privileged side effects unconditionally.
7. Docker action: stronger environment packaging, Linux-only execution
A Docker action sets runs.using: docker and identifies
a Dockerfile or public image. GitHub builds or retrieves the image
and executes the action in a container. This packages
operating-system dependencies better than a JavaScript or composite
action, but Docker action startup is heavier and the runner must be
Linux. A self-hosted Linux runner also needs Docker installed.
Docker pre-entrypoint, main entrypoint and post-entrypoint phases
run in distinct containers. Persist required state through the
workspace, HOME or action state rather than assuming an in-memory
process survives. GitHub also requires Docker actions that access
GITHUB_WORKSPACE to run as the default Docker user; a
Dockerfile USER instruction can break workspace access.
8. Read-only inspection before trusting an action
# Bounded inspection in a disposable repository
printf 'repo=%s
sha=%s
run=%s
attempt=%s
' "$GITHUB_REPOSITORY" "$GITHUB_SHA" "$GITHUB_RUN_ID" "$GITHUB_RUN_ATTEMPT"
printf 'runner_os=%s arch=%s
' "$RUNNER_OS" "$RUNNER_ARCH"
# Inspect local metadata without executing it.
sed -n '1,220p' .github/actions/ch17-contract/action.yml
For an external action, add the full commit SHA, reviewed source URL and release/tag mapping to the evidence packet. A friendly tag is navigation; the full SHA is the immutable execution identity.
9. Actions do not secretly own permissions
A custom action executes inside a caller job. If it needs
contents: write, issues: write, a cloud
credential, a Docker socket or privileged network access, the action
documentation must say so and the caller must decide whether to
grant it. Hiding required capability inside implementation details
defeats least privilege and makes security review impossible.
For a read-only local validation action, start the workflow with
permissions: {}. If a later action genuinely requires
GitHub API access, add only the specific permission to that job and
pass only the necessary credential surface.
10. Why this matters in DevOps
Custom actions become supply-chain components. Their execution model affects portability; their runtime affects upgrade risk; their dependency packaging affects reproducibility; their action SHA affects provenance; their input validation affects security; and their caller permissions bound potential damage. A reusable step is production-grade only when these states can be independently inspected.
Knowledge check
Why is a custom action not the same as a reusable workflow?
A custom action is invoked as a step inside a caller-owned job. A reusable workflow is invoked at the job level and can own one or more jobs/runners.
Are custom-action inputs strongly typed by action.yml?
No. Action metadata inputs are strings. Document expected semantics and validate enum, integer or boolean-like values in executable code.
Why should a new JavaScript action use node24?
GitHub has moved Actions to Node 24 and Node 20 is in its final deprecation window, scheduled for removal on September 23, 2026.
What extra platform requirement does a Docker action introduce?
It can run only on Linux runners; self-hosted Linux runners must also have Docker installed.
Who decides the GITHUB_TOKEN permission ceiling for a custom action?
The caller workflow/job. The action executes with capabilities the caller makes available; it cannot declare a permission block in action.yml that magically grants itself more authority.
Official references and version notes
- GitHub Docs — About custom actions — execution-model and portability comparison.
-
GitHub Docs — Metadata syntax reference
— current
action.ymlinputs, outputs andrunsmodels. -
GitHub Docs — Create a composite action
— composite steps and
GITHUB_ACTION_PATH. - GitHub Docs — Create a JavaScript action — JavaScript packaging and runtime model.
- GitHub Docs — Create a Docker container action — Docker metadata, inputs and outputs.
- GitHub Docs — Dockerfile support for Actions — workspace, USER, ENTRYPOINT and CMD constraints.
- GitHub Docs — Managing custom actions — release management and immutable SHA references.
- actions/toolkit — official JavaScript action helper packages.
- GitHub Changelog — Node 20 deprecation — current Node 24 migration/removal schedule.
Version-sensitive behavior was rechecked on
2026-09-10 for GitHub.com. New JavaScript actions
should target runs.using: node24. GitHub moved
JavaScript actions to Node 24 by default in 2026; Node 20 is in
its final deprecation window and is scheduled for removal from
Actions runners on 2026-09-23. Current
@actions/core source reports version
3.0.1 and is ESM-only. Action metadata inputs do
not provide reusable-workflow-style typed
schemas; document the expected type/enum and validate the string
value in the action. Composite actions access declared values
through the inputs context and should use
GITHUB_ACTION_PATH for action-relative scripts.
JavaScript actions can define
pre/main/post; Docker
actions can define
pre-entrypoint/entrypoint/post-entrypoint, with
pre/main/post Docker phases running in distinct containers. Docker
container actions run only on Linux runners; self-hosted runners
also need Docker. Production references to external actions should
use a reviewed full commit SHA and record the release/tag that
maps to it. The mandatory lab uses
actions/checkout v7.0.1 pinned to
3d3c42e5aac5ba805825da76410c181273ba90b1; every other
executable action is repository-local.
Keep the academy open
Support free, practical DevOps education.
Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this
address.