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.
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?
It makes authority explicit at the job that needs it and prevents unrelated jobs from inheriting repository permissions.
Why is Payments API passed through an environment
variable inside the composite action shell?
It keeps caller-controlled text as data instead of interpolating it directly into generated shell syntax.
Does resolving docker/setup-buildx-action@v3 to a
SHA mean the action is approved?
No. Resolution provides code identity; approval still requires repository, release, source, metadata, dependency, and behavior review.
Why can the downstream caller job consume the reusable workflow output without a shared workspace?
The output is a declared Actions data contract mapped from step
→ job → workflow → caller needs, not a filesystem
side effect.
When would the optional JavaScript action require a committed distribution bundle?
When it has runtime dependencies or source that must be packaged into the complete code GitHub downloads and executes.
Further reading — current official GitHub sources
- GitHub Docs — Reuse workflows
- GitHub Docs — Reusing workflow configurations
- GitHub Docs — Workflow syntax for reusable workflows
- GitHub Docs — About custom actions
- GitHub Docs — Metadata syntax for actions
- GitHub Docs — Creating a composite action
- GitHub Docs — Creating a JavaScript action
- GitHub Docs — Managing custom actions
- GitHub Docs — Releasing and maintaining actions
- GitHub Docs — Secure use reference
- GitHub Docs — Sharing actions/workflows with an organization
- GitHub Docs — Sharing across private repositories
- GitHub REST — Actions permissions
- GitHub CLI — gh workflow run
- GitHub CLI — gh run view
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.
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0
Send only Ethereum/ERC-20 compatible assets to this
address.