Composite Actions, JavaScript Actions, Docker Actions, and Custom Automation: Guided Hands-On Workflow
This lab builds a small local composite action first, validates its inputs and outputs without unsafe shell interpolation, then compares the same contract with JavaScript and Docker designs. It finishes by showing how a released repository action is referenced by a reviewed full commit SHA rather than by a mutable branch or tag.
Learning objectives
- Build a repository-local composite action and invoke it safely after an immutable checkout action.
- Validate enum/integer-like inputs without interpolating untrusted data into shell source.
- Compare how the same contract would be packaged as Node 24 JavaScript or a Linux Docker action.
- Record action outputs, runner metadata, run identity and failure conclusions as evidence.
- Explain how a released action transitions from local path testing to a full-SHA repository reference.
1. Disposable lab preflight
Create a throwaway repository named
gha-custom-action-lab. The mandatory path uses
GitHub-hosted ubuntu-24.04, one repository-local
composite action and actions/checkout pinned to its
verified v7.0.1 commit. It requires no secret, registry, package
publication, cloud account, Docker push or organization setting.
Before execution, record the commit SHA containing the workflow and local action. Predict that the composite action runs on the caller job's Ubuntu runner, that valid inputs produce one non-sensitive output, and that an unsupported mode exits before the caller's subsequent step.
2. Repository layout
.github/
actions/
ch17-contract/
action.yml
README.md
workflows/
ch17-local-action.yml
Keeping repository-local actions under
.github/actions makes ownership visible without
polluting application source. The workflow must check out the
repository before a ./.github/actions/... reference
because the runner needs those files in its workspace.
3. Implement the composite action
# .github/actions/ch17-contract/action.yml
name: Chapter 17 contract
description: Validate a documented string contract and return a safe summary
author: Abolfazl Mohammadijoo
inputs:
mode:
description: "Enum string: strict or relaxed"
required: true
repeat:
description: "Integer string: 1, 2, or 3"
required: false
default: "1"
outputs:
summary:
description: Non-sensitive normalized result
value: ${{ steps.validate.outputs.summary }}
contract_kind:
description: Fixed contract classification
value: ${{ steps.validate.outputs.contract_kind }}
runs:
using: composite
steps:
- id: validate
name: Validate caller data
shell: bash
env:
MODE: ${{ inputs.mode }}
REPEAT: ${{ inputs.repeat }}
run: |
set -euo pipefail
case "$MODE" in
strict|relaxed) ;;
*) echo "unsupported mode" >&2; exit 64 ;;
esac
[[ "$REPEAT" =~ ^[1-3]$ ]] || {
echo "repeat must be 1, 2, or 3" >&2
exit 65
}
printf 'summary=mode-%s-repeat-%s
' "$MODE" "$REPEAT" >> "$GITHUB_OUTPUT"
printf 'contract_kind=validated-string-contract
' >> "$GITHUB_OUTPUT"
The action never evaluates input as code. The case and
regular expression implement the type-like contract that
action.yml itself cannot express. Failure codes 64 and
65 distinguish two validation boundaries without leaking the
rejected value.
4. Invoke the local action
# .github/workflows/ch17-local-action.yml
name: chapter17-local-action
on: workflow_dispatch
permissions: {}
jobs:
contract:
runs-on: ubuntu-24.04
steps:
- name: Record bounded run evidence
shell: bash
run: |
echo "repo=$GITHUB_REPOSITORY"
echo "sha=$GITHUB_SHA"
echo "run=$GITHUB_RUN_ID attempt=$GITHUB_RUN_ATTEMPT"
echo "runner_os=$RUNNER_OS arch=$RUNNER_ARCH"
- name: Check out exact caller revision
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- id: contract
name: Run local composite action
uses: ./.github/actions/ch17-contract
with:
mode: strict
repeat: '2'
- name: Verify non-sensitive outputs
shell: bash
env:
SUMMARY: ${{ steps.contract.outputs.summary }}
KIND: ${{ steps.contract.outputs.contract_kind }}
run: |
test "$SUMMARY" = "mode-strict-repeat-2"
test "$KIND" = "validated-string-contract"
printf 'summary=%s
kind=%s
' "$SUMMARY" "$KIND"
Expected evidence is a successful run at the recorded SHA and one
action step that returns the two fixed-format outputs. There is no
artifact, cache or external target. The output is control data, so
GITHUB_OUTPUT is the correct channel.
5. Deliberate failure and causal evidence
For a controlled negative test, change only
mode: strict to mode: unsupported, commit
that workflow revision and dispatch it. The checkout still succeeds.
The action starts, reaches its validation boundary, emits the
generic error unsupported mode and exits 64. The
output-verification step does not run.
Preserve that run ID, attempt, source SHA and failing step before
repairing the caller. Do not add
continue-on-error merely to make the run green; the
failure is the evidence.
6. Same public contract as a JavaScript action
If this logic grows into cross-platform parsing, API calls or structured data processing, a JavaScript action can provide a cleaner implementation. The metadata changes execution model while keeping public names stable.
# Design comparison only — not required for the mandatory lab
name: Chapter 17 JS contract
inputs:
mode:
description: "Enum string: strict or relaxed"
required: true
outputs:
summary:
description: Non-sensitive normalized result
runs:
using: node24
main: dist/index.js
// src/index.js — source; release process bundles dependencies into dist/index.js
import * as core from '@actions/core'
const mode = core.getInput('mode', { required: true })
if (!['strict', 'relaxed'].includes(mode)) {
core.setFailed('unsupported mode')
} else {
core.setOutput('summary', `mode-${mode}`)
}
Do not ship source that requires the caller to run
npm install. Commit the reviewed bundle produced by
your release build and record its source commit. The bundled
dist directory is part of the action release, not a
cache.
7. Docker action constraints before choosing it
# Design comparison only
runs:
using: docker
image: Dockerfile
args:
- ${{ inputs.mode }}
The Docker form can package OS-level dependencies, but it is
Linux-only and needs Docker on self-hosted runners. Inputs passed as
args become process arguments rather than automatically
typed values. The image build context, base image and Dockerfile
become supply-chain dependencies. Do not publish or push an image
merely to complete this chapter.
Use Docker when the environment itself is part of the action contract. Do not use it simply to avoid packaging JavaScript or writing a small composite action.
8. Move from local test to immutable repository reference
After the action is committed and reviewed, record its full
40-character commit SHA. A caller in another repository should pin
that SHA. Keep a release tag such as v1.0.0 as the
human mapping, not as the immutable execution identity.
# Replace OWNER/REPO and the SHA with the reviewed release commit.
- name: Use released Chapter 17 action
uses: OWNER/REPO/.github/actions/ch17-contract@0123456789abcdef0123456789abcdef01234567
with:
mode: strict
repeat: '2'
The hexadecimal value above is intentionally illustrative, not a
real published action. In a real disposable repository, copy the
exact commit SHA from Git and record the mapping
v1.0.0 → <full SHA> in the evidence packet. Never
silently substitute @main for convenience.
9. Challenge: choose the boundary, not the shortest YAML
You need to run the same three validation commands in twelve jobs, all on caller-selected runners, with no secret and no external side effect. Choose a composite action and justify it. Then change the requirement: the automation now needs two coordinated jobs on different runners. The correct abstraction becomes a reusable workflow, not a larger composite action.
10. Verification and cleanup
- Confirm valid run output exactly matches the expected summary.
- Confirm the broken run stops in the action validation step with exit 64.
- Confirm no input value is interpolated directly into shell source.
- Confirm the only external action is checkout pinned to its full v7.0.1 SHA.
- Record action metadata path and caller source SHA.
- Delete the throwaway repository after preserving non-sensitive evidence.
Knowledge check
Why must a ./ local action be checked out first?
The runner needs the repository action files in GITHUB_WORKSPACE before it can resolve a relative local action path.
Why does the lab preserve the invalid-mode run instead of masking it with continue-on-error?
The failed step conclusion, exit code, source SHA and logs are the evidence needed to prove the validation boundary and later repair.
Why is dist/index.js part of a JavaScript action release?
It is the packaged executable containing required dependencies, so callers do not perform mutable runtime dependency installation.
What changes when moving from local action to external repository action?
The action becomes an executable supply-chain dependency; record owner/repo/path and pin the reviewed full commit SHA.
When would the three-command validator become a reusable workflow instead?
When reuse needs to own jobs/runners, job dependencies, permissions or other workflow-level orchestration rather than a step inside one caller job.
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. The repository-reference
SHA shown in examples is deliberately synthetic because each
learner repository has a different real release commit. The secure
invariant is a full reviewed 40-character SHA plus a documented
human release mapping.
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.