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)

# .github/workflows/deploy.yml — in org/platform-workflows repo 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

# In any service repo's workflow 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 }} # OR pass ALL caller secrets: secrets: inherit 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

# .github/actions/setup-and-cache/action.yml 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 # required — identifies this as 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 # shell MUST be specified for run steps in composite actions run: npm ci if: 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@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 ← action metadata index.js ← entry point (must be committed with node_modules OR use ncc) package.json node_modules/ ← OR use dist/ with @vercel/ncc bundle
// action.yml 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 # node16 | node20 — must match what's on the runner main: dist/index.js # bundled with ncc
// 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) // marks the step as failed } } run()

Bundling with ncc (recommended)

# 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

MethodPurpose
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 / errorLog 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 / getStatePass 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.yml 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 # build from local Dockerfile # OR: image: 'docker://python:3.12-slim' ← use a pre-built image args: - ${{ 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 actionDocker action
Startup timeInstant (runs on existing runner Node)30–90 sec (container pull + start)
PlatformLinux, Windows, macOSLinux only (Docker not available on macOS/Windows hosted runners)
Dependenciesnpm packages, bundled with nccAnything installable in a Dockerfile
Best forGitHub API interactions, string manipulation, fast utilitiesTools 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 fails max-parallel: 4 # limit concurrency (saves runner quota) matrix: os: [ubuntu-24.04, windows-2025, macos-15] node: [18, 20, 22] # Produces 3 × 3 = 9 jobs 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: # Add an extra variable to one combination - os: ubuntu-24.04 node: 20 coverage: true # only this combo uploads coverage # Add a brand-new combination not in the grid - 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: # Node 18 is EOL on Windows — skip this combination - 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: | # Build matrix from changed service directories 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" # log for debugging 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

# 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 outdated concurrency: 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 dangerous 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 # queue, don't cancel — one deploy at a time
ScenarioGroup keycancel-in-progress
Feature branch CIci-${{ github.ref }}true — cancel stale runs
PR preview deploypreview-${{ github.event.pull_request.number }}true — redeploy on every push
Production deploydeploy-productionfalse — serialise, never cancel
Scheduled reportweekly-reporttrue — 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

# Workflow-level: applies to all jobs unless overridden permissions: contents: read # read repo files pull-requests: write # post PR comments issues: write # create/update issues packages: write # push to GitHub Packages / GHCR id-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 job permissions: contents: read # global minimum jobs: test: runs-on: ubuntu-latest # inherits global read-only release: runs-on: ubuntu-latest permissions: contents: write # only this job can push/tag packages: write

Permission scopes reference

Scoperead allowswrite allows
contentsClone, read filesPush commits, create/delete branches and tags, create releases
pull-requestsList/read PRsCreate, update, merge PRs; post comments
issuesRead issuesCreate, update, close issues; post comments; manage labels
packagesPull packagesPush packages to GitHub Packages / GHCR
checksRead check runsCreate and update check runs
deploymentsRead deploymentsCreate/update deployments and deployment statuses
id-tokenN/ARequest an OIDC JWT from the GitHub token endpoint (for keyless cloud auth)
statusesRead commit statusesCreate 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", // Lock to a specific repo — ALWAYS include this "token.actions.githubusercontent.com:sub": "repo:acme/payments:ref:refs/heads/main" } } }] }

Step 3 — use it in your workflow:

permissions: id-token: write # required — allows requesting the OIDC JWT 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 # No aws-access-key-id or aws-secret-access-key needed! - 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 }} # not a secret — can be a var 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)

# Install ARC $ helm install arc \ --namespace arc-systems \ --create-namespace \ oci://ghcr.io/actions/actions-runner-controller-charts/gha-runner-scale-set-controller # Create a runner scale set $ 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
# Workflow using the ARC runner set jobs: build: runs-on: arc-runner-set # matches the Helm release name

Security model for self-hosted runners

ConcernMitigation
Poisoned pipeline via malicious PRUse ephemeral runners (ARC default); never allow forks to run on self-hosted runners for public repos
Network access to internal systemsFeature, not a bug — but scope what the runner can reach via network policies; use separate runner pools per environment
Credential residue between jobsEphemeral runners: the pod is destroyed, so credentials can't persist. Never use persistent runners for sensitive workloads.
Privileged container operationsRun 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 suite on: 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 tests if: steps.changes.outputs.src != '0' run: npm test

Reducing checkout overhead in monorepos

- uses: actions/checkout@v4 with: fetch-depth: 1 # shallow clone — fastest for most CI jobs sparse-checkout: | services/payments shared/utils sparse-checkout-cone-mode: true

Typical CI time budget

StepTarget timeHow to hit it
Checkout<10 secShallow clone + sparse checkout for monorepos
Dependency install<30 secCache hit — npm ci from cache should be seconds
Build/compile<2 minIncremental builds; Docker layer cache; split heavy languages to larger runners
Unit tests<3 minParallel sharding; exclude integration tests from the fast suite
Lint<1 minRun in parallel with tests; use incremental lint (only changed files)
Total CI (PR)<5 minAnything 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.

# 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

# .github/dependabot.yml — Dependabot updates pinned action SHAs 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 typeMutable?Recommended for
@main / @masterYes — always latestNever in production workflows; only for local testing of your own actions
@v4 (major tag)Yes — maintainer can push new commitsLow-risk internal actions where you trust the maintainer completely
@v4.2.2 (semver tag)Sometimes — some maintainers move minor/patch tagsAcceptable for trusted Marketplace actions from major publishers
@abc1234 (full SHA)No — immutableAll 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