Chapter 18Lesson 02~220 minutes

Reusable Workflows, Composite Actions, JavaScript Actions, and Automation Reuse: Guided Hands-On Workflow and Core Operations

You will build two different reuse boundaries in a disposable public repository. A reusable workflow owns a complete job-level validation policy. A composite action owns a repeated step sequence inside that job. You will pass typed inputs, map outputs across boundaries, inspect the exact source and permissions used, and review a third-party action reference without running it.

Typed interfacesLocal reusePinned reviewObservable outputs

Learning objectives

  • Create a disposable public repository with one reusable workflow and one repository-local composite action.
  • Pass typed inputs into the reusable workflow, consume composite-action output, map job/workflow outputs, and verify results from the caller.
  • Inspect exact run SHA, job graph, output evidence, and effective permission behavior through the Actions UI, gh, and REST.
  • Resolve a third-party action tag to a commit SHA and review metadata/source without executing that dependency.
  • Inspect a minimal JavaScript action scaffold and explain why runtime packaging and generated distribution code become maintenance responsibilities.

Lab boundary: Use only the disposable public repository created here. No PAT, cloud secret, Marketplace purchase, organization admin, private component sharing, or self-hosted runner is needed. The normal workflow is read-only. A later checkpoint temporarily grants issue-write only to demonstrate a permission contract, then restores it.

1. Preflight: prove authentication, identity, and Actions availability

gh --version
gh auth status
git --version

OWNER="$(gh api user --jq .login)"
REPO="$OWNER/c18-reuse-lab"
printf 'repo=%s\n' "$REPO"

gh auth status proves authentication, not authorization to every resource. Repository ownership in this disposable scenario gives you the required write access to create workflow files. All workflow token permissions are still declared explicitly later.

2. Create a disposable repository and capture the initial state

mkdir -p "$HOME/c18-labs"
cd "$HOME/c18-labs"

gh repo create "$REPO" --public --clone
cd c18-reuse-lab

printf '%s\n' '# Chapter 18 reuse lab' > README.md
git add README.md
git commit -m "chore: initialize Chapter 18 reuse lab"
git push -u origin HEAD

DEFAULT_BRANCH="$(git branch --show-current)"
BASE_SHA="$(git rev-parse HEAD)"
printf 'default_branch=%s base_sha=%s\n' "$DEFAULT_BRANCH" "$BASE_SHA"

gh repo view "$REPO" --json nameWithOwner,visibility,defaultBranchRef,url

The repository is intentionally public so the mandatory path does not consume private Actions minutes. Do not reuse an employer repository or a repository containing secrets.

3. Build the step-level component: a local composite action

Create .github/actions/c18-normalize/action.yml. The action does one thing: normalize a caller-provided label. It has no network call and no GitHub token requirement.

name: Normalize component label
description: Convert a caller-provided label into a deterministic safe identifier
inputs:
  label:
    description: Label to normalize
    required: true
outputs:
  normalized:
    description: Lowercase normalized identifier
    value: ${{ steps.normalize.outputs.value }}
runs:
  using: composite
  steps:
    - name: Normalize label
      id: normalize
      shell: bash
      env:
        RAW_LABEL: ${{ inputs.label }}
      run: |
        normalized="$(printf '%s' "$RAW_LABEL" | tr '[:upper:]' '[:lower:]' | tr -cs 'a-z0-9._-' '-' | sed 's/^-//; s/-$//')"
        if [ -z "$normalized" ]; then
          printf '%s\n' 'Input did not contain a usable identifier.' >&2
          exit 1
        fi
        printf 'value=%s\n' "$normalized" >> "$GITHUB_OUTPUT"

The input is copied into RAW_LABEL and then read by the shell. This preserves the “untrusted text is data” rule from Chapter 14. The output is written through GITHUB_OUTPUT, then exposed through action metadata.

4. Build the job-level component: a reusable workflow

name: C18 reusable validation

on:
  workflow_call:
    inputs:
      component:
        description: Human-readable component name
        required: true
        type: string
      create_probe_issue:
        description: Exercise the caller permission contract
        required: false
        type: boolean
        default: false
    outputs:
      normalized_component:
        description: Normalized component identifier
        value: ${{ jobs.validate.outputs.normalized_component }}
      report:
        description: Compact validation evidence
        value: ${{ jobs.validate.outputs.report }}

jobs:
  validate:
    runs-on: ubuntu-24.04
    outputs:
      normalized_component: ${{ steps.normalize.outputs.normalized }}
      report: ${{ steps.report.outputs.report }}
    steps:
      - name: Checkout caller repository
        uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1

      - name: Normalize with the repository-local composite action
        id: normalize
        uses: ./.github/actions/c18-normalize
        with:
          label: ${{ inputs.component }}

      - name: Build reusable-workflow output
        id: report
        shell: bash
        env:
          NORMALIZED: ${{ steps.normalize.outputs.normalized }}
          SOURCE_SHA: ${{ github.sha }}
        run: |
          printf 'report=%s@%s\n' "$NORMALIZED" "$SOURCE_SHA" >> "$GITHUB_OUTPUT"

      - name: Permission probe — create one disposable issue
        if: ${{ inputs.create_probe_issue }}
        shell: bash
        env:
          GH_TOKEN: ${{ github.token }}
        run: |
          gh api --method POST "repos/${GITHUB_REPOSITORY}/issues" \
            -f title='Chapter 18 permission probe' \
            -f body='Disposable issue created only to verify reusable-workflow permission flow.' \
            --jq '{number,title,state}'

The workflow exposes two typed inputs and two outputs. The create_probe_issue input defaults to false, so ordinary runs perform no mutation. Notice that the called workflow does not create a broader token; the caller job controls the permissions that arrive here. The checkout action is pinned to the full reviewed v7.0.1 commit SHA.

5. Build the caller with explicit permission and output contracts

name: C18 reuse checkpoint

on:
  workflow_dispatch:
    inputs:
      component:
        description: Component to validate
        required: true
        type: string
        default: Payments API
      permission_probe:
        description: Attempt the disposable issue-write probe
        required: true
        type: boolean
        default: false

permissions: {}

jobs:
  shared-validation:
    permissions:
      contents: read
      issues: read
    uses: ./.github/workflows/c18-reusable.yml
    with:
      component: ${{ inputs.component }}
      create_probe_issue: ${{ inputs.permission_probe }}

  consume-contract:
    needs: shared-validation
    runs-on: ubuntu-24.04
    permissions: {}
    steps:
      - name: Consume only declared workflow outputs
        shell: bash
        env:
          NORMALIZED: ${{ needs.shared-validation.outputs.normalized_component }}
          REPORT: ${{ needs.shared-validation.outputs.report }}
        run: |
          printf 'normalized=%s\n' "$NORMALIZED"
          printf 'report=%s\n' "$REPORT"

The top-level workflow starts with permissions: {}. The reusable-workflow call receives only contents: read and issues: read. The downstream consumer has no repository permissions because it only prints already-produced outputs. The normal path sets permission_probe=false, so issue permission is not used yet.

6. Commit the reuse graph as one reviewable change

mkdir -p .github/actions/c18-normalize .github/workflows
# Save the three YAML blocks to their documented paths:
# .github/actions/c18-normalize/action.yml
# .github/workflows/c18-reusable.yml
# .github/workflows/c18-caller.yml

git add .github/actions/c18-normalize/action.yml \
        .github/workflows/c18-reusable.yml \
        .github/workflows/c18-caller.yml
git diff --cached --check
git diff --cached
git commit -m "ci: add Chapter 18 reusable automation lab"
git push origin "$DEFAULT_BRANCH"
LAB_SHA="$(git rev-parse HEAD)"
printf 'lab_sha=%s\n' "$LAB_SHA"

This commit is the first complete dependency identity: caller, reusable workflow, and local action are all versioned by the same source SHA because they live in one repository.

7. Dispatch the healthy caller and inspect causality

gh workflow run c18-caller.yml -R "$REPO" --ref "$DEFAULT_BRANCH" \
  -f component='Payments API' \
  -f permission_probe=false

sleep 3
RUN_ID="$(gh run list -R "$REPO" --workflow c18-caller.yml \
  --event workflow_dispatch --limit 1 --json databaseId --jq '.[0].databaseId')"

gh run watch "$RUN_ID" -R "$REPO" --exit-status
gh run view "$RUN_ID" -R "$REPO" \
  --json headSha,event,status,conclusion,jobs,url
gh run view "$RUN_ID" -R "$REPO" --log

Expected evidence includes a reusable-workflow job, the composite-action step inside that called job, and a downstream caller job that prints normalized=payments-api. The report output should end with the exact run source SHA. This proves the data crossed explicit output contracts rather than a shared filesystem.

8. Inspect the run and workflow files as structured hosted objects

gh api -H "X-GitHub-Api-Version: 2026-03-10" \
  "repos/$REPO/actions/runs/$RUN_ID/jobs?per_page=100" \
  --jq '.jobs[] | {id,name,status,conclusion,started_at,completed_at}'

for path in \
  .github/workflows/c18-caller.yml \
  .github/workflows/c18-reusable.yml \
  .github/actions/c18-normalize/action.yml
do
  gh api -H "X-GitHub-Api-Version: 2026-03-10" \
    "repos/$REPO/contents/$path?ref=$LAB_SHA" \
    --jq '{path,sha,size}'
done

The REST content object SHA is a Git blob SHA, while LAB_SHA is the commit that selected those blobs. Keep those identities distinct. The run headSha should equal the commit you dispatched.

9. Review a third-party action reference without executing it

Use a real public action only for inspection. This example resolves Docker’s Buildx action v3 tag to the current commit and fetches metadata at that exact commit. Do not add it to the lab workflow merely to practice pinning.

ACTION_REPO="docker/setup-buildx-action"
REQUESTED_REF="v3"

RESOLVED_SHA="$(gh api "repos/$ACTION_REPO/commits/$REQUESTED_REF" --jq .sha)"
printf 'requested=%s resolved=%s\n' "$REQUESTED_REF" "$RESOLVED_SHA"

gh api "repos/$ACTION_REPO/commits/$RESOLVED_SHA" \
  --jq '{sha,commit:{message:.commit.message},html_url}'

gh api "repos/$ACTION_REPO/contents/action.yml?ref=$RESOLVED_SHA" \
  --jq '{path,sha,size,html_url}'

# After source/release review, a production workflow would reference:
printf 'uses: %s@%s\n' "$ACTION_REPO" "$RESOLVED_SHA"

Before adoption, inspect the release diff, action.yml, runtime, bundled dependencies, network behavior, required inputs, and maintainer/repository provenance. The resolved SHA is not “approved” merely because the API returned it.

10. Optional scaffold: understand the JavaScript action package boundary

The following local action has no npm dependencies; it exists only to expose the metadata/runtime boundary. It is optional and does not need to be added to the checkpoint.

name: Minimal JavaScript output action
description: Optional Chapter 18 scaffold with no third-party runtime packages
inputs:
  message:
    description: Message to report
    required: true
outputs:
  length:
    description: UTF-16 JavaScript string length of the message
runs:
  using: node20
  main: index.js
const fs = require('fs');
const message = process.env.INPUT_MESSAGE || '';
const outputFile = process.env.GITHUB_OUTPUT;
if (!outputFile) {
  console.error('GITHUB_OUTPUT is unavailable.');
  process.exit(1);
}
fs.appendFileSync(outputFile, `length=${message.length}\n`, { encoding: 'utf8' });
console.log(`Processed ${message.length} characters.`);

If this action later imports @actions/core or other npm packages, do not assume npm install will happen for consumers. Build and commit the distribution bundle (for example dist/index.js) according to your release process, then review generated output and dependency changes together.

11. Mental-model challenge: choose the boundary before the syntax

Requirement Best boundary Reason
Standardize build → test jobs and runner choices Reusable workflow Owns jobs, runners, permissions, and job dependencies
Repeat “normalize → validate → emit output” inside many jobs Composite action Step bundle inside caller-selected job
Implement API-heavy transformation with tested Node code JavaScript action Encapsulates step logic/runtime with action metadata
Provide a starting workflow users copy and then edit Workflow template Bootstrap, not a runtime dependency

For a new requirement, first ask who owns the runner/job graph and whether the component needs a runtime package. Only then choose workflow_call, composite, JavaScript, or a template.

12. End-of-lesson state and cleanup

Keep the disposable repository for Lessons 3–5. Do not delete it yet. Confirm there are no secrets and that the reusable workflow has only the permissions granted by its caller.

gh secret list -R "$REPO" --json name --jq 'length'
gh workflow list -R "$REPO" --all
gh repo view "$REPO" --json nameWithOwner,visibility,isArchived,url

13. Lesson summary

You created a job-level reusable workflow and a step-level composite action, passed typed inputs and explicit outputs, observed exact run/source identity, and inspected a third-party tag without executing it. Reuse is now concrete: every boundary has an interface, code identity, permission source, and owner.

Knowledge check

Why does the caller use permissions: {} at the top level and add permissions only to the reusable-workflow call job?

Why is Payments API passed through an environment variable inside the composite action shell?

Does resolving docker/setup-buildx-action@v3 to a SHA mean the action is approved?

Why can the downstream caller job consume the reusable workflow output without a shared workspace?

When would the optional JavaScript action require a committed distribution bundle?

Next lesson

Next: Reusable Workflows, Composite Actions, JavaScript Actions, and Automation Reuse: Configuration, Design Choices, and Tradeoffs

Further reading — current official GitHub sources

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
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0 Send only Ethereum/ERC-20 compatible assets to this address.