A reusable workflow is a full workflow file that can be called as a job from another workflow. It runs in its own runner environment, with its own job context — but it shares the same repository secrets (if the caller passes them) and can return outputs back to the caller.
This is the right tool for sharing complete, multi-step job sequences across repos — like a standard deploy pipeline, a security scan, or a release process.
caller workflowreusable workflow
(in your service repo) (in platform-workflows repo)
jobs: on:
deploy: workflow_call:
uses: org/platform/.github/ inputs:
workflows/deploy.yml@main ────────► environment: string
with: image_tag: string
environment: production secrets:
image_tag: v2.4.1 DEPLOY_KEY: required
secrets:
DEPLOY_KEY: ${{ secrets.PROD_KEY }}
◄──── outputs.deployed_url
# In any service repo's workflowjobs:deploy-prod:needs: test
uses:acme/platform-workflows/.github/workflows/deploy.yml@mainwith:environment:productionimage_tag:${{ needs.test.outputs.image_tag }}secrets:DEPLOY_KEY:${{ secrets.PROD_DEPLOY_KEY }}# OR pass ALL caller secrets: secrets: inheritnotify:needs: deploy-prod
runs-on: ubuntu-latest
steps:
- run:echo "Deployed to ${{ needs.deploy-prod.outputs.deployed_url }}"
NESTING LIMIT
Reusable workflows can be nested up to 4 levels deep. A caller can call a reusable workflow that itself calls another — but only 4 hops total. Also, a reusable workflow cannot call another reusable workflow that calls yet another within the same job (it can as separate jobs). Keep the chain shallow.
SECRETS: INHERIT VS EXPLICIT
secrets: inherit is convenient but it passes every secret the caller has access to — including ones unrelated to the deploy. Prefer explicit secret forwarding (secrets: DEPLOY_KEY: ...) so the reusable workflow only has access to what it actually needs. This limits the blast radius if the reusable workflow is ever compromised.
6.2
Composite Actions: Packaging Steps as Reusable Units
📚 Free Weekly Tutorials
Java, Spring Boot, AWS, DevOps & AI — straight to your inbox.
A composite action packages a sequence of steps (not jobs) into a reusable unit that runs inside the caller's job. Use composite actions to DRY up repeated step sequences across workflows in the same repo — or publish them as public actions for wider use.
The key difference from reusable workflows: composite actions run in the caller's job on the caller's runner. They share the same workspace, env vars, and job context. Reusable workflows get their own isolated runner.
Creating a composite action
# .github/actions/setup-and-cache/action.ymlname:Setup Node and restore cachedescription:Runs actions/setup-node and actions/cache with standard configinputs:node-version:description:Node.js version to usedefault:'20'cache-key-prefix:description:Prefix for cache keydefault:nodeoutputs:cache-hit:description:Whether the cache was restoredvalue:${{ steps.cache.outputs.cache-hit }}runs:using:composite# required — identifies this as compositesteps:
- uses:actions/setup-node@v4with:node-version:${{ inputs.node-version }}
- id: cache
uses:actions/cache@v4with:path:~/.npmkey:${{ inputs.cache-key-prefix }}-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}restore-keys:${{ inputs.cache-key-prefix }}-${{ runner.os }}-
- shell: bash # shell MUST be specified for run steps in composite actionsrun:npm ciif:steps.cache.outputs.cache-hit != 'true'
Using the composite action
steps:
- uses:./.github/actions/setup-and-cache# local path (relative to repo root)with:node-version:'22'# Or reference from another repo:
- uses:acme/shared-actions/setup-and-cache@v2with:node-version:'20'
SHELL IS REQUIRED
Every run: step inside a composite action must specify shell: explicitly — it doesn't inherit the caller's defaults.run.shell. Forgetting this causes a cryptic validation error. Always add shell: bash (or shell: pwsh on Windows) to every run step.
6.3
Custom JavaScript Actions
JavaScript actions run directly on the runner (no container startup cost) and have full access to the GitHub API via @actions/github. Use them when your logic is too complex for shell, or when you need to interact with GitHub's API as part of the action itself.
Project structure
my-action/
action.yml← action metadataindex.js← entry point (must be committed with node_modules OR use ncc)package.jsonnode_modules/← OR use dist/ with @vercel/ncc bundle
// action.ymlname:Post deploy commentdescription:Comments the deploy URL on the PR that triggered this runinputs:deploy-url:description:URL of the deployed environmentrequired:trueruns:using:node20# node16 | node20 — must match what's on the runnermain:dist/index.js# bundled with ncc
# Install ncc$ npm install --save-dev @vercel/ncc
# Bundle into a single file — commit dist/ to the repo$ npx ncc build index.js --license licenses.txt -o dist
# Add to package.json scripts:# "prepare": "ncc build index.js -o dist"
Bundling with ncc produces a single dist/index.js with all dependencies inlined. Consumers don't need to run npm install — and you don't commit node_modules/ (which can be hundreds of MB).
Key @actions/core methods
Method
Purpose
core.getInput('name')
Read an action input
core.setOutput('name', val)
Set an action output (readable by later steps)
core.setFailed('msg')
Fail the step with a message
core.info / warning / error
Log at different levels
core.setSecret('value')
Mask a value in all subsequent logs
core.exportVariable('K','V')
Set an env var for all subsequent steps in the job
core.addPath('/path')
Prepend a directory to PATH for subsequent steps
core.saveState / getState
Pass data from the main function to a post cleanup function
6.4
Docker Container Actions
Docker actions run inside a container you define. Use them when your action needs a specific runtime environment, system-level dependencies, or a language other than JavaScript/TypeScript.
# action.ymlname:Run Python linterdescription:Runs ruff on the repository with configurable settingsinputs:path:description:Path to lintdefault:'.'runs:using:dockerimage:Dockerfile# build from local Dockerfile# OR: image: 'docker://python:3.12-slim' ← use a pre-built imageargs:
- ${{ inputs.path }}
# Dockerfile
FROM python:3.12-slim
RUN pip install ruff
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
#!/bin/sh# entrypoint.sh
set -e
PATH_TO_LINT="${1:-.}"
echo "Running ruff on $PATH_TO_LINT"
ruff check "$PATH_TO_LINT"
JS action vs Docker action — when to use each
JavaScript action
Docker action
Startup time
Instant (runs on existing runner Node)
30–90 sec (container pull + start)
Platform
Linux, Windows, macOS
Linux only (Docker not available on macOS/Windows hosted runners)
Dependencies
npm packages, bundled with ncc
Anything installable in a Dockerfile
Best for
GitHub API interactions, string manipulation, fast utilities
Tools with complex system dependencies (compilers, specific Python/Ruby/Go versions, custom binaries)
6.5
Matrix Strategy: include, exclude, fail-fast
A matrix spawns multiple job instances from a single job definition. Each combination of matrix values gets its own runner, running in parallel by default.
jobs:test:strategy:fail-fast:false# don't cancel other matrix jobs if one failsmax-parallel:4# limit concurrency (saves runner quota)matrix:os: [ubuntu-24.04, windows-2025, macos-15]
node: [18, 20, 22]
# Produces 3 × 3 = 9 jobsruns-on:${{ matrix.os }}steps:
- uses:actions/setup-node@v4with:node-version:${{ matrix.node }}
- run:npm test
include — add extra variables to specific combinations
matrix:os: [ubuntu-24.04, windows-2025]
node: [18, 20]
include:# Add an extra variable to one combination
- os: ubuntu-24.04
node:20coverage:true# only this combo uploads coverage# Add a brand-new combination not in the grid
- os: macos-15
node:20experimental:true
exclude — remove specific combinations from the grid
matrix:os: [ubuntu-24.04, windows-2025]
node: [18, 20, 22]
exclude:# Node 18 is EOL on Windows — skip this combination
- os: windows-2025
node:18
fail-fast defaults to true — if any matrix job fails, GitHub cancels all remaining ones immediately. For cross-platform testing this is usually wrong: you want to see all the failing platforms, not just the first one. Set fail-fast: false for test matrices. Keep true only for deploy matrices where the first failure should halt everything.
6.6
Dynamic Matrices: Generating the Matrix at Runtime
Sometimes the matrix isn't known until runtime — which services changed, which test suites exist, which versions to test. Generate the matrix in a prior job and pass it to the test job via fromJSON.
The output of compute-matrix is a JSON string like {"service":["payments","auth"]}. fromJSON() parses it back into an object that the matrix: key can use.
Dynamic matrix from a JSON file in the repo
# test-matrix.json (committed to the repo)
{
"include": [
{"service": "payments", "java": "21", "db": "postgres"},
{"service": "auth", "java": "21", "db": "postgres"},
{"service": "legacy", "java": "11", "db": "mysql"}
]
}
# Workflow: load it at runtime
- id: load
run:echo "matrix=$(cat test-matrix.json | jq -c)" >> $GITHUB_OUTPUT# Use in the next job:matrix:${{ fromJSON(needs.load-matrix.outputs.matrix) }}
6.7
Concurrency Groups: Cancel-in-Progress Strategies
Concurrency groups prevent redundant runs — when a new run starts, any in-progress run in the same group is cancelled or queued depending on configuration.
Workflow-level concurrency (most common)
# Cancel the in-progress run when a new push arrives on the same branch# — ideal for CI: no point finishing a run that's already outdatedconcurrency:group:${{ github.workflow }}-${{ github.ref }}cancel-in-progress:true
# Queue runs on main — never cancel, just serialise# — right for deploy workflows where partial deploys are dangerousconcurrency:group:deploy-productioncancel-in-progress:false
Per-PR concurrency (cancel previous run for the same PR)
The GITHUB_TOKEN is an automatically-generated short-lived token scoped to the current repository. Its default permissions depend on your org/repo settings — and the principle of least privilege says you should always declare exactly what you need.
Setting permissions
# Workflow-level: applies to all jobs unless overriddenpermissions:contents:read# read repo filespull-requests:write# post PR commentsissues:write# create/update issuespackages:write# push to GitHub Packages / GHCRid-token:write# required for OIDC (see 6.9)# Lock down everything else implicitly# Any permission not listed defaults to 'none' when you declare the block
# Lock to read-only globally, then elevate one jobpermissions:contents:read# global minimumjobs:test:runs-on: ubuntu-latest
# inherits global read-onlyrelease:runs-on: ubuntu-latest
permissions:contents:write# only this job can push/tagpackages:write
Permission scopes reference
Scope
read allows
write allows
contents
Clone, read files
Push commits, create/delete branches and tags, create releases
pull-requests
List/read PRs
Create, update, merge PRs; post comments
issues
Read issues
Create, update, close issues; post comments; manage labels
packages
Pull packages
Push packages to GitHub Packages / GHCR
checks
Read check runs
Create and update check runs
deployments
Read deployments
Create/update deployments and deployment statuses
id-token
N/A
Request an OIDC JWT from the GitHub token endpoint (for keyless cloud auth)
statuses
Read commit statuses
Create commit statuses (for Commit Status Publisher)
6.9
OIDC: Keyless Auth to AWS, GCP & Azure
The traditional approach to cloud auth in Actions is storing a long-lived cloud credential (AWS access key, GCP service account JSON) as a GitHub secret. This creates a credential that lives forever, can be exfiltrated, and needs manual rotation.
OIDC (OpenID Connect) eliminates stored credentials entirely. GitHub issues a short-lived JWT for each workflow run; your cloud provider is configured to trust GitHub as an identity provider and exchange the JWT for a temporary cloud credential. No secrets to store, no rotation needed.
GitHub Actions runnerAWS / GCP / Azure
1. Job starts
2. Runner requests OIDC JWT ──────────► GitHub OIDC endpoint
3. Receives signed JWT ◄────────── (contains claims: repo, branch, actor...)
4. Exchanges JWT for cloud creds ──────► Cloud IAM (STS / Workload Identity)
5. Receives temp credentials ◄─────── (valid for ~1 hour)
6. Uses temp creds for AWS/GCP/Azure ops
No secrets stored in GitHub. JWT expires after the run.
Setup: AWS (OIDC + IAM Role)
Step 1 — create the OIDC identity provider in AWS:
- uses:azure/login@v2with:client-id:${{ secrets.AZURE_CLIENT_ID }}# not a secret — can be a vartenant-id:${{ secrets.AZURE_TENANT_ID }}subscription-id:${{ secrets.AZURE_SUBSCRIPTION_ID }}
LOCK DOWN THE TRUST POLICY
Always add sub condition claims to your OIDC trust policy. Without them, any repo on GitHub that knows your role ARN can assume it. At minimum restrict to repo:org/repo-name:*. For production deployments, further restrict to ref:refs/heads/main so only main-branch runs can deploy.
6.10
Self-Hosted Runners: Security Model & ARC Autoscaling
Persistent self-hosted runners accumulate state between jobs (environment variables, tool installs, left-over files from previous builds) which causes subtle, hard-to-reproduce failures. The solution is ephemeral runners — each job gets a fresh runner that is destroyed after completion.
Actions Runner Controller (ARC)
ARC is a Kubernetes operator that manages ephemeral self-hosted runners. It watches the GitHub Actions queue and scales runner pods up and down dynamically — zero runners when idle, one per queued job when busy.
GitHub ActionsYour Kubernetes cluster
Job queued ─────────────────► ARC controller watches queue
ARC creates a runner Pod
Runner Pod registers ────────► Job assigned to runner
Job runs (in your network)
Runner Pod deleted ──────────► Pod torn down, state gone
Next job gets fresh Pod
# Workflow using the ARC runner setjobs:build:runs-on:arc-runner-set# matches the Helm release name
Security model for self-hosted runners
Concern
Mitigation
Poisoned pipeline via malicious PR
Use ephemeral runners (ARC default); never allow forks to run on self-hosted runners for public repos
Network access to internal systems
Feature, not a bug — but scope what the runner can reach via network policies; use separate runner pools per environment
Credential residue between jobs
Ephemeral runners: the pod is destroyed, so credentials can't persist. Never use persistent runners for sensitive workloads.
Privileged container operations
Run runners as non-root in containers; use a separate runner pool for Docker-in-Docker builds that require elevated privileges
6.11
Workflow Performance: Parallelism & Skipping Redundant Work
Parallelism checklist
Split independent jobs. Unit tests and lint don't need each other — run them as separate parallel jobs, not sequential steps in one job.
Use a matrix for parallel test sharding. Split a large test suite across N runners: matrix: shard: [1,2,3,4] with --shard=${{ matrix.shard }}/4 in your test runner command.
Cache aggressively. Dependency install should be a cache restore, not a network download, on 95%+ of runs.
Use actions/setup-* built-in caches. They handle lock-file hashing correctly by default (Phase 5.10).
Skipping redundant jobs
# Skip docs-only changes from triggering the full test suiteon:push:paths-ignore:
- 'docs/**'
- '**.md'
- '.github/PULL_REQUEST_TEMPLATE**'# Or, skip in steps using git diff:
- id: changes
run: |
echo "src=$(git diff --name-only origin/main... | grep '^src/' | wc -l)" >> $GITHUB_OUTPUT
- name:Run testsif:steps.changes.outputs.src != '0'run:npm test
Reducing checkout overhead in monorepos
- uses:actions/checkout@v4with:fetch-depth:1# shallow clone — fastest for most CI jobssparse-checkout: |
services/payments
shared/utils
sparse-checkout-cone-mode:true
Typical CI time budget
Step
Target time
How to hit it
Checkout
<10 sec
Shallow clone + sparse checkout for monorepos
Dependency install
<30 sec
Cache hit — npm ci from cache should be seconds
Build/compile
<2 min
Incremental builds; Docker layer cache; split heavy languages to larger runners
Unit tests
<3 min
Parallel sharding; exclude integration tests from the fast suite
Lint
<1 min
Run in parallel with tests; use incremental lint (only changed files)
When you write uses: actions/checkout@v4, GitHub fetches that action at the v4 tag at run time. If the action maintainer moves the v4 tag to a new commit (maliciously or accidentally), your workflow silently runs different code. SHA pinning eliminates this risk.
# Least secure: mutable tag — can be overwritten at any time
- uses:actions/checkout@v4# Better: major+minor tag — less likely to be moved
- uses:actions/checkout@v4.1.7# Most secure: immutable full SHA — this exact commit, always
- uses:actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683# v4.2.2# Add a comment with the version for human readability ↑
Finding the SHA for a tag
# Get the commit SHA behind a tag$ gh api repos/actions/checkout/git/refs/tags/v4.2.2 --jq '.object.sha'
11bd71901bbe5b1630ceea73d27597364c9af683# Or use the GitHub UI: go to the action's repo → Tags → click the tag → copy the commit SHA
Keeping SHA-pinned actions updated with Dependabot
Dependabot opens PRs that update the SHA to the latest release. You review the diff (or auto-merge patches), merge, and your workflow uses the new SHA. You get the security of pinning with the convenience of automated updates.
Reference type
Mutable?
Recommended for
@main / @master
Yes — always latest
Never in production workflows; only for local testing of your own actions
@v4 (major tag)
Yes — maintainer can push new commits
Low-risk internal actions where you trust the maintainer completely
@v4.2.2 (semver tag)
Sometimes — some maintainers move minor/patch tags
Acceptable for trusted Marketplace actions from major publishers
@abc1234 (full SHA)
No — immutable
All external actions in security-sensitive or regulated workflows
SUPPLY CHAIN RULE
GitHub's own actions (actions/*) and major publisher actions (aws-actions/*, google-github-actions/*, azure/*) are broadly trusted — pinning to a semver tag is acceptable. Third-party actions with few stars, recent forks, or single maintainers should always be SHA-pinned, and ideally audited before first use.
Up Next — Phase 7: Security — Repository & Code Security
Dependabot alerts vs security updates vs version updates, secret scanning with push protection, the dependency-review action, private vulnerability reporting, and minimising blast radius with environment-scoped secrets.