PHASE 6 OF 14
GitHub Actions: Advanced Patterns & Reuse
Reusable workflows, composite actions, custom JS and Docker actions, dynamic matrices, concurrency groups, OIDC keyless auth to cloud providers, ARC autoscaling runners, and pinning actions securely — the techniques that distinguish platform engineering from basic CI configuration
Reusable Workflows
Composite Actions
OIDC
Dynamic Matrix
ARC
Supply Chain
6.1
Reusable Workflows: workflow_call, Inputs, Secrets & Outputs
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 workflow reusable 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
The reusable workflow (callee)
on:
workflow_call:
inputs:
environment:
type: string
required: true
image_tag:
type: string
required: true
dry_run:
type: boolean
default: false
secrets:
DEPLOY_KEY:
required: true
outputs:
deployed_url:
description: URL of the deployed service
value: ${{ jobs.deploy.outputs.url }}
jobs:
deploy:
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
outputs:
url: ${{ steps.do-deploy.outputs.url }}
steps:
- id: do-deploy
run: |
echo "Deploying ${{ inputs.image_tag }} to ${{ inputs.environment }}"
echo "url=https://${{ inputs.environment }}.acme.com" >> $GITHUB_OUTPUT
env:
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
The caller
jobs:
deploy-prod:
needs: test
uses: acme/platform-workflows/.github/workflows/deploy.yml@main
with:
environment: production
image_tag: ${{ needs.test.outputs.image_tag }}
secrets:
DEPLOY_KEY: ${{ secrets.PROD_DEPLOY_KEY }}
notify:
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
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
name: Setup Node and restore cache
description: Runs actions/setup-node and actions/cache with standard config
inputs:
node-version:
description: Node.js version to use
default: '20'
cache-key-prefix:
description: Prefix for cache key
default: node
outputs:
cache-hit:
description: Whether the cache was restored
value: ${{ steps.cache.outputs.cache-hit }}
runs:
using: composite
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- id: cache
uses: actions/cache@v4
with:
path: ~/.npm
key: ${{ inputs.cache-key-prefix }}-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: ${{ inputs.cache-key-prefix }}-${{ runner.os }}-
- shell: bash
run: npm ci
if: steps.cache.outputs.cache-hit != 'true'
Using the composite action
steps:
- uses: ./.github/actions/setup-and-cache
with:
node-version: '22'
- uses: acme/shared-actions/setup-and-cache@v2
with:
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
index.js
package.json
node_modules/
name: Post deploy comment
description: Comments the deploy URL on the PR that triggered this run
inputs:
deploy-url:
description: URL of the deployed environment
required: true
runs:
using: node20
main: dist/index.js
const core = require('@actions/core')
const github = require('@actions/github')
async function run() {
try {
const deployUrl = core.getInput('deploy-url', { required: true })
const token = core.getInput('github-token') || process.env.GITHUB_TOKEN
const octokit = github.getOctokit(token)
const { context } = github
if (!context.payload.pull_request) {
core.info('Not a pull request — skipping comment')
return
}
await octokit.rest.issues.createComment({
...context.repo,
issue_number: context.payload.pull_request.number,
body: `🚀 Deployed to [staging](${deployUrl})`
})
core.setOutput('comment-id', result.data.id)
} catch (err) {
core.setFailed(err.message)
}
}
run()
Bundling with ncc (recommended)
$ npm install --save-dev @vercel/ncc
$ npx ncc build index.js --license licenses.txt -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.
name: Run Python linter
description: Runs ruff on the repository with configurable settings
inputs:
path:
description: Path to lint
default: '.'
runs:
using: docker
image: Dockerfile
args:
- ${{ inputs.path }}
FROM python:3.12-slim
RUN pip install ruff
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/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
max-parallel: 4
matrix:
os: [ubuntu-24.04, windows-2025, macos-15]
node: [18, 20, 22]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/setup-node@v4
with:
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:
- os: ubuntu-24.04
node: 20
coverage: true
- os: macos-15
node: 20
experimental: true
exclude — remove specific combinations from the grid
matrix:
os: [ubuntu-24.04, windows-2025]
node: [18, 20, 22]
exclude:
- os: windows-2025
node: 18
Using matrix values in if: for conditional steps
- name: Upload coverage
if: matrix.coverage == true
run: npx codecov
- name: Allow experimental failure
continue-on-error: ${{ matrix.experimental == true }}
fail-fast DEFAULT
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.
jobs:
compute-matrix:
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set.outputs.matrix }}
steps:
- uses: actions/checkout@v4
- id: set
run: |
SERVICES=$(git diff --name-only origin/main... \
| grep '^services/' | cut -d/ -f2 | sort -u \
| jq -R . | jq -sc '{"service":.}')
echo "matrix=$SERVICES" >> $GITHUB_OUTPUT
echo "Matrix: $SERVICES"
test:
needs: compute-matrix
if: needs.compute-matrix.outputs.matrix != '{"service":[]}'
strategy:
matrix: ${{ fromJSON(needs.compute-matrix.outputs.matrix) }}
runs-on: ubuntu-latest
steps:
- run: echo "Testing ${{ matrix.service }}"
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
{
"include": [
{"service": "payments", "java": "21", "db": "postgres"},
{"service": "auth", "java": "21", "db": "postgres"},
{"service": "legacy", "java": "11", "db": "mysql"}
]
}
- id: load
run: echo "matrix=$(cat test-matrix.json | jq -c)" >> $GITHUB_OUTPUT
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)
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
concurrency:
group: deploy-production
cancel-in-progress: false
Per-PR concurrency (cancel previous run for the same PR)
concurrency:
group: pr-${{ github.event.pull_request.number }}
cancel-in-progress: true
Job-level concurrency (more granular)
jobs:
deploy:
concurrency:
group: deploy-${{ inputs.environment }}
cancel-in-progress: false
| Scenario | Group key | cancel-in-progress |
| Feature branch CI | ci-${{ github.ref }} | true — cancel stale runs |
| PR preview deploy | preview-${{ github.event.pull_request.number }} | true — redeploy on every push |
| Production deploy | deploy-production | false — serialise, never cancel |
| Scheduled report | weekly-report | true — if a run is late and a new one starts, cancel the old |
6.8
Workflow Permissions: Least-Privilege GITHUB_TOKEN
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
permissions:
contents: read
pull-requests: write
issues: write
packages: write
id-token: write
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
release:
runs-on: ubuntu-latest
permissions:
contents: write
packages: 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 runner AWS / 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:
$ aws iam create-open-id-connect-provider \
--url https://token.actions.githubusercontent.com \
--client-id-list sts.amazonaws.com \
--thumbprint-list 6938fd4d98bab03faadb97b34396831e3780aea1
Step 2 — create an IAM role with a trust policy:
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:sub": "repo:acme/payments:ref:refs/heads/main"
}
}
}]
}
Step 3 — use it in your workflow:
permissions:
id-token: write
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions-deploy
aws-region: us-east-1
- run: aws s3 sync dist/ s3://my-bucket/
GCP (Workload Identity Federation)
- uses: google-github-actions/auth@v2
with:
workload_identity_provider: projects/123/locations/global/workloadIdentityPools/github/providers/github
service_account: deploy-sa@my-project.iam.gserviceaccount.com
Azure (Federated Credentials)
- uses: azure/login@v2
with:
client-id: ${{ secrets.AZURE_CLIENT_ID }}
tenant-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 Actions Your 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
Quick ARC setup (Helm)
$ helm install arc \
--namespace arc-systems \
--create-namespace \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller
$ helm install arc-runner-set \
--namespace arc-runners \
--create-namespace \
--set githubConfigUrl="https://github.com/acme" \
--set githubConfigSecret.github_token="$GITHUB_PAT" \
--set minRunners=0 \
--set maxRunners=10 \
oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set
jobs:
build:
runs-on: arc-runner-set
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
on:
push:
paths-ignore:
- 'docs/**'
- '**.md'
- '.github/PULL_REQUEST_TEMPLATE**'
- id: changes
run: |
echo "src=$(git diff --name-only origin/main... | grep '^src/' | wc -l)" >> $GITHUB_OUTPUT
- name: Run tests
if: steps.changes.outputs.src != '0'
run: npm test
Reducing checkout overhead in monorepos
- uses: actions/checkout@v4
with:
fetch-depth: 1
sparse-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) |
| Total CI (PR) | <5 min | Anything longer degrades developer experience significantly |
6.12
Pinning Actions: SHA vs Tag vs Branch
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.
- uses: actions/checkout@v4
- uses: actions/checkout@v4.1.7
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
Finding the SHA for a tag
$ gh api repos/actions/checkout/git/refs/tags/v4.2.2 --jq '.object.sha'
11bd71901bbe5b1630ceea73d27597364c9af683
Keeping SHA-pinned actions updated with Dependabot
version: 2
updates:
- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
groups:
actions:
patterns: ["*"]
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.
Continue to Phase 7 →
Back to Hub