Chapter 17Lesson 02~235 minutes

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.

Disposable labLocal actionInput validationOutputsSHA pinning

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?

Why does the lab preserve the invalid-mode run instead of masking it with continue-on-error?

Why is dist/index.js part of a JavaScript action release?

What changes when moving from local action to external repository action?

When would the three-command validator become a reusable workflow instead?

Next lesson

Choose the execution model intentionally

Lesson 3 turns the three implementations into an architecture decision table and release strategy.

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. 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.

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