Composite Actions, JavaScript Actions, Docker Actions, and Custom Automation: Diagnostics, Failure Modes, and Production Practices
Custom-action failures can originate in metadata, packaging, runner capability, shell quoting, runtime selection, hidden permission assumptions or mutable supply-chain identity. This lesson preserves first-failure evidence, identifies the causal boundary and repairs only that boundary.
Learning objectives
- Diagnose metadata, packaging, runtime, runner and permission failures as separate causal layers.
- Preserve the original run/attempt, action identity and first failing step before repair.
- Recognize unsafe shell input handling and hidden capability assumptions in custom actions.
- Differentiate dependency packaging failures from mutable-reference and release-provenance failures.
- Apply the least destructive correction and rerun only the smallest equivalent scope.
1. Evidence-first diagnostic sequence
When an action fails, do not begin by changing YAML or rerunning. Preserve the run ID, attempt, source SHA, action reference, runner OS/arch, metadata and first failing log. Then walk the layers: caller event/revision → job permissions/runner → action identity → metadata/runtime → implementation/dependencies → outputs/files → external side effects. Repair the first proven mismatch.
2. Broken JavaScript packaging: source exists, dependency does not
A common failure occurs when action.yml points to
src/index.js, the source imports
@actions/core, but node_modules is neither
shipped nor bundled. The runner is not a development checkout with
your npm dependencies. The first failure will be module resolution,
not a token or runner-capacity problem.
Error [ERR_MODULE_NOT_FOUND]: Cannot find package '@actions/core'
imported from /home/runner/work/_actions/example/action/<sha>/src/index.js
The repair is release packaging: lock dependencies, create a
reviewed bundle such as dist/index.js, point
runs.main there, and publish a new immutable action
commit. Do not add npm install to every caller as a
troubleshooting shortcut.
3. Hidden runner-global dependency
A composite action works on one hosted image because a CLI happens to be preinstalled, then fails on another OS or after an image update. Evidence should compare runner image/tool versions and the action's documented prerequisites. The fix is to make the dependency explicit—support a constrained runner baseline or install/setup the tool through a versioned mechanism.
4. Hidden permission requirement
An action that calls the Issues API may fail with 403/404 under
permissions: {}. Do not change to
write-all. Inspect the action documentation/source and
API requirement, then grant only issues: write to the
specific caller job if the side effect is intentional. If the action
is supposed to be read-only, a write requirement is an
implementation defect.
5. Unsafe untrusted input interpolation
# Broken: expression result becomes shell program text.
- shell: bash
run: echo "processing ${{ inputs.name }}"
If the caller controls name, special shell syntax can
alter execution. The repair is to place the expression into an
environment variable and quote the variable as data.
- shell: bash
env:
NAME: ${{ inputs.name }}
run: printf 'processing %s
' "$NAME"
For higher-risk operations, validate an allow-list or use a structured API rather than a shell.
6. Mutable tag without release provenance
Suppose two runs both say uses: vendor/action@v1 but
behave differently a week apart. The workflow text is identical
while the dependency identity moved. Capture the resolved action SHA
from logs/metadata where available and compare release history. The
production fix is a reviewed full SHA plus a release mapping.
7. Custom action that should have been a reusable workflow
If an action starts launching its own background services, simulating multiple independent jobs, or encoding deployment gates, the abstraction is fighting Actions itself. Move job-graph concerns to a reusable workflow and keep custom actions focused on step-level execution.
8. Docker-specific failure layers
A Docker action on Windows or macOS will not run because Docker container actions require Linux. A self-hosted Linux runner without Docker has a different failure. An executable entrypoint permission error differs again. Preserve the exact runner and daemon evidence before changing the image or runner label.
Do not fix a Dockerfile workspace-access problem by granting
privileged host access. GitHub's Docker action model has specific
constraints, including default-user expectations for
GITHUB_WORKSPACE.
9. Intentionally broken checkpoint fragment
permissions: {}
jobs:
validate:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
- uses: ./.github/actions/ch17-contract
with:
mode: unsupported
repeat: '2'
The expected failure is exit 64 in the composite validation step. If
the run instead fails before that—checkout failure, path-not-found,
YAML validation—diagnose that earlier layer first. The intended
repair is one caller value: mode: strict. Do not edit
permissions, checkout SHA or action code when the action is
correctly rejecting the caller input.
10. Recovery rule: smallest equivalent rerun
After preserving the first failure, commit the minimal repair and dispatch a new run. Keep the old run. A rerun of the old attempt would still use its old workflow/action revision in ways that can obscure what changed; a new source SHA makes the repair explicit and reviewable.
Knowledge check
A JavaScript action cannot find @actions/core. What layer should you inspect first?
Release packaging: whether the action shipped/bundled its dependencies and whether runs.main points to the packaged entrypoint.
Why is permissions: write-all not a valid fix for an API 403?
It removes least privilege and may not address the actual required scope. Determine the exact API permission and grant only that capability if the side effect is intended.
How should a composite action handle caller-controlled text passed to Bash?
Move the expression into an environment variable and quote the variable; validate allowed values when appropriate.
Two runs use @v1 but execute different action code. What state was missing?
The immutable action commit SHA/release provenance. A mutable tag alone does not prove identical code.
Why preserve the broken run before a new commit?
It records the original source/action identity, first-failure log and causal boundary; the repaired run can then be compared without destroying evidence.
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.