Chapter 17Lesson 01~175 minutes

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.

Custom actionsaction.ymlCompositeJavaScriptDocker

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.

Custom-action execution and trust boundary
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?

Are custom-action inputs strongly typed by action.yml?

Why should a new JavaScript action use node24?

What extra platform requirement does a Docker action introduce?

Who decides the GITHUB_TOKEN permission ceiling for a custom action?

Next lesson

Build the smallest safe custom action

Lesson 2 implements the composite contract, compares equivalent JavaScript/Docker packaging and then moves from a local path to immutable repository identity.

Official references and version notes

Version and compatibility note

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.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.