Advanced
Example 58: Reusable Workflow with Inputs, Outputs, and Secrets
A reusable workflow is a separate .yml file that other workflows call via workflow_call. It accepts typed inputs, returns outputs back to the caller, and inherits secrets explicitly. This pattern eliminates duplication when many repositories share the same CI logic.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph LR
A["Caller Workflow<br/>caller.yml"]
B["Reusable Workflow<br/>reusable-build.yml"]
C["Outputs<br/>artifact-url"]
D["Secrets<br/>NPM_TOKEN"]
A -->|uses: ./.github/workflows/reusable-build.yml| B
A -->|with: inputs| B
A -->|secrets: inherit| D
D -->|passed to| B
B -->|outputs| C
C -->|available to| A
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
style D fill:#CC78BC,stroke:#000,color:#fff
Reusable workflow definition (.github/workflows/reusable-build.yml):
on:
workflow_call: # => Marks this workflow as callable by other workflows
# => Cannot be triggered directly (no push/pull_request)
inputs:
node-version: # => Typed input parameter
required: true # => Callers must provide this value
type: string # => Only string, boolean, or number allowed
description: "Node.js version to use"
environment:
required: false # => Optional input
type: string
default: "staging" # => Default value when caller omits it
outputs:
artifact-url: # => Named output the caller can read
description: "URL of the uploaded build artifact"
value: ${{ jobs.build.outputs.artifact-url }}
# => References the job-level output below
secrets:
NPM_TOKEN: # => Explicit secret declaration
required: true # => Caller must pass this secret
description: "npm registry token for private packages"
jobs:
build:
runs-on: ubuntu-latest
outputs:
artifact-url: ${{ steps.upload.outputs.artifact-url }}
# => Bubbles step output up to workflow output level
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
# => References the workflow_call input
# => inputs context only available in reusable workflows
- name: Install dependencies
run: npm ci
env:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
# => secrets.NPM_TOKEN comes from the caller's passed secret
# => NOT from the reusable workflow's own repository secrets
- name: Build
run: npm run build -- --env=${{ inputs.environment }}
- name: Upload artifact
id: upload # => Step ID enables referencing outputs below
uses: actions/upload-artifact@v4
with:
name: build-${{ github.run_id }}
path: dist/Caller workflow (.github/workflows/deploy.yml):
name: Deploy Pipeline
on:
push:
branches: [main]
jobs:
build-app:
uses: ./.github/workflows/reusable-build.yml
# => Calls reusable workflow from same repository
# => Use org/repo/.github/workflows/file.yml@ref for external repos
with:
node-version: "20" # => Satisfies the required input
environment: "production" # => Overrides default value
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
# => Explicitly forwards a secret from this repo's secrets
# => Alternative: secrets: inherit (forwards ALL secrets)
deploy:
needs: build-app # => Waits for the reusable workflow job to complete
runs-on: ubuntu-latest
steps:
- name: Use artifact URL
run: echo "Artifact at ${{ needs.build-app.outputs.artifact-url }}"
# => needs.<job-id>.outputs.<name> reads reusable workflow outputs
# => Only outputs declared in workflow_call.outputs are accessibleKey Takeaway: Reusable workflows with workflow_call enable sharing CI logic across jobs and repositories. Inputs enforce interface contracts, outputs share results back to callers, and secrets require explicit forwarding for security.
Why It Matters: Large engineering organizations maintain dozens of microservices that need identical build pipelines. Without reusable workflows, every repository copies the same 200-line CI file, creating drift as each team patches their local copy differently. Reusable workflows establish a versioned, shared pipeline that engineering platform teams can update centrally. The explicit secrets declaration prevents accidental secret exposure — callers must consciously choose which secrets to forward, eliminating the entire class of “secret leak via forked workflow” vulnerabilities documented in GitHub’s security advisories.
Example 59: Composite Action in action.yml
A composite action bundles multiple workflow steps into a reusable unit defined in an action.yml file. Unlike reusable workflows (which run as separate jobs), composite actions run as steps inside the caller’s job, sharing the runner environment, file system, and step context.
Action definition (.github/actions/setup-and-cache/action.yml):
name: "Setup Node with Smart Cache" # => Action display name
description: "Installs Node.js and restores npm cache with fallback keys"
# => Description shown in GitHub Marketplace and Actions tab
inputs: # => Typed input parameters for this composite action
node-version:
description: "Node.js version"
required: true
cache-key-prefix: # => Allows callers to namespace their caches
description: "Prefix for cache key to avoid collisions"
required: false
default: "v1"
outputs: # => Values this action returns to the calling workflow
cache-hit:
description: "Whether the cache was restored"
value: ${{ steps.cache.outputs.cache-hit }}
# => Forwards the output from a step inside this composite action
runs:
using: "composite" # => Marks this as a composite action (not JS or Docker)
steps:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
# => inputs context works inside composite action steps
- name: Restore npm cache
id: cache # => ID needed to reference step outputs
uses: actions/cache@v4
with:
path: ~/.npm # => npm's global cache directory
key: ${{ inputs.cache-key-prefix }}-node-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
# => hashFiles hashes all package-lock.json files
# => Cache miss when any lock file changes
restore-keys: |
${{ inputs.cache-key-prefix }}-node-${{ runner.os }}-
${{ inputs.cache-key-prefix }}-node-
# => Fallback keys tried in order when exact key misses
- name: Install dependencies
run: npm ci --prefer-offline
# => --prefer-offline uses cache aggressively
# => Falls back to network only when cache incomplete
shell: bash # => Required in composite actions (no default shell)Caller workflow (.github/workflows/ci.yml):
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node with cache
id: setup
uses: ./.github/actions/setup-and-cache
# => Relative path to local composite action directory
with:
node-version: "20"
cache-key-prefix: "my-app-v2"
- name: Show cache result
run: echo "Cache hit: ${{ steps.setup.outputs.cache-hit }}"
# => steps.<id>.outputs.<name> reads composite action outputs
# => Returns 'true' if cache was restored, 'false' otherwiseKey Takeaway: Composite actions bundle reusable steps with runs.using: composite. Each step requires an explicit shell: field, and outputs must reference inner step outputs via value: ${{ steps.<id>.outputs.<name> }}.
Why It Matters: Composite actions are the right abstraction when you need to share setup steps without the overhead of a separate job. Unlike reusable workflows, composite actions run inside the caller’s job — sharing the workspace directory, environment variables, and installed tools. This makes them ideal for setup sequences (install tool + configure + verify) that precede actual work. Teams that standardize setup via composite actions reduce runner minutes significantly by ensuring every job uses the same caching strategy rather than each developer inventing their own.
Example 60: JavaScript Action with action.yml and index.js
A JavaScript action runs Node.js code directly on the runner. It receives inputs, performs arbitrary logic, sets outputs, and reports success or failure — all without shell scripting limitations. The action.yml declares the interface and index.js implements the logic.
Action metadata (action.yml):
name: "Semantic Version Bumper"
description: "Reads current version from package.json and bumps it based on commit message"
inputs:
bump-type:
description: "Version bump type: patch, minor, or major"
required: false
default: "patch"
github-token:
description: "GitHub token for creating tags"
required: true
outputs:
new-version: # => Output the caller reads after this action runs
description: "The newly bumped version string"
runs:
using:
"node20" # => Specifies Node.js 20 runtime on the runner
# => Options: node16, node20
main:
"dist/index.js" # => Entry point after bundling with ncc or esbuild
# => Bundle all dependencies into dist/ to avoid node_modules commitAction implementation (src/index.js):
const core = require("@actions/core"); // => Official GitHub Actions toolkit
// => Provides getInput, setOutput, setFailed, info, warning, etc.
const github = require("@actions/github"); // => Octokit client pre-authenticated
// => Provides context (repo, sha, ref) and REST/GraphQL API
async function run() {
// => Wrap everything in try/catch for clean failure reporting
try {
const bumpType = core.getInput("bump-type");
// => getInput reads the value declared in action.yml inputs
// => bumpType is "patch", "minor", or "major"
// => Returns empty string if not required and not provided
const token = core.getInput("github-token");
// => token is the GitHub token for API calls
// => Never log this value
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
// => Reads package.json from the workspace root
// => GitHub Actions checkout sets CWD to repository root
const [major, minor, patch] = pkg.version.split(".").map(Number);
// => Destructures "1.2.3" into [1, 2, 3]
// => .map(Number) converts string array to number array
let newVersion; // => Will hold the bumped version string
if (bumpType === "major") {
newVersion = `${major + 1}.0.0`; // => 1.2.3 -> 2.0.0
} else if (bumpType === "minor") {
newVersion = `${major}.${minor + 1}.0`; // => 1.2.3 -> 1.3.0
} else {
newVersion = `${major}.${minor}.${patch + 1}`; // => 1.2.3 -> 1.2.4
}
core.setOutput("new-version", newVersion);
// => setOutput makes this value available to the calling workflow
// => steps.<id>.outputs.new-version in the caller
const octokit = github.getOctokit(token);
// => Returns authenticated Octokit instance
// => context provides { owner, repo, sha, ref, ... }
const { owner, repo } = github.context.repo;
// => Destructures from the workflow's repository context
// => owner is "my-org", repo is "my-repo"
await octokit.rest.git.createRef({
owner,
repo,
ref: `refs/tags/v${newVersion}`, // => Creates git tag like "v1.2.4"
sha: github.context.sha, // => Tags the current commit SHA
});
// => REST API call to create a tag on GitHub
core.info(`Bumped to v${newVersion} and created tag`);
// => core.info logs to the step's output without failing
// => Visible in the Actions run log
} catch (error) {
core.setFailed(error.message);
// => setFailed marks the step as failed and sets exit code 1
// => error.message appears in the Actions run summary
}
}
run(); // => Invoke the async function
// => Node.js exits when the event loop empties (after all promises resolve)
Key Takeaway: JavaScript actions use @actions/core for inputs/outputs/logging and @actions/github for an authenticated Octokit client. Bundle all dependencies into dist/ with ncc so the action runs without npm install on the runner.
Why It Matters: JavaScript actions are the most flexible action type for complex logic that exceeds shell scripting — they handle API interactions, file parsing, cryptographic operations, and cross-platform execution without bash compatibility concerns. The @actions/core and @actions/github toolkits abstract the undocumented INPUT_* environment variable protocol and workflow command syntax (::set-output::) behind a stable API. Teams that package organizational automation as JS actions can version and reuse them across hundreds of repositories without embedding fragile shell scripts in every workflow file.
Example 61: Docker Container Action
A Docker container action packages the action runtime and all dependencies into a container image. This guarantees identical behavior across all runner environments and supports languages beyond JavaScript (Python, Ruby, Go, Rust, etc.).
Action metadata (action.yml):
name: "OWASP Dependency Check"
description: "Runs OWASP dependency-check scanner against the project"
inputs:
project-name:
description: "Project name for the report"
required: true
format:
description: "Report format: HTML, JSON, XML"
required: false
default: "JSON"
outputs:
report-path:
description: "Path to the generated report file"
runs:
using: "docker" # => Instructs GitHub to build and run a Docker container
image:
"Dockerfile" # => Build from local Dockerfile (relative to action.yml)
# => Alternative: image: "docker://ghcr.io/org/image:tag"
args: # => Command-line arguments passed to the container ENTRYPOINT
- ${{ inputs.project-name }} # => First arg to entrypoint script
- ${{ inputs.format }} # => Second arg to entrypoint script
env: # => Environment variables injected into the container
GITHUB_TOKEN: ${{ github.token }}
# => Passes the workflow token into the container environmentDockerfile (Dockerfile):
FROM owasp/dependency-check:latest
# => Base image with OWASP dependency-check pre-installed
# => Docker actions inherit from any valid base image
COPY entrypoint.sh /entrypoint.sh
# => Copies the entrypoint script into the container
# => Script will be the container's main process
RUN chmod +x /entrypoint.sh
# => Makes script executable inside the container
ENTRYPOINT ["/entrypoint.sh"]
# => GitHub passes action inputs as arguments to this entrypoint
# => $1 = project-name, $2 = format (from action.yml args)Entrypoint script (entrypoint.sh):
#!/bin/bash
set -e
# => Exit immediately if any command fails
PROJECT_NAME="$1" # => First argument from action.yml args
FORMAT="$2" # => Second argument from action.yml args
REPORT_DIR="/github/workspace/dependency-check-report"
# => /github/workspace is the mounted repository checkout
mkdir -p "$REPORT_DIR"
# => Create report output directory in the workspace
/usr/share/dependency-check/bin/dependency-check.sh \
--project "$PROJECT_NAME" \
--format "$FORMAT" \
--out "$REPORT_DIR" \
--scan /github/workspace
# => Runs the OWASP scanner against the checked-out code
REPORT_FILE="$REPORT_DIR/dependency-check-report.${FORMAT,,}"
# => ${FORMAT,,} lowercases the format string (HTML -> html)
echo "report-path=$REPORT_FILE" >> "$GITHUB_OUTPUT"
# => Sets action output using the GITHUB_OUTPUT file mechanism
# => The GITHUB_OUTPUT env var points to a temp file
# => GitHub reads this file after the container exitsKey Takeaway: Docker actions use runs.using: docker and build from a local Dockerfile. The repository workspace mounts at /github/workspace inside the container, and outputs are written via echo "name=value" >> "$GITHUB_OUTPUT".
Why It Matters: Docker container actions eliminate “works on my machine” CI failures caused by tool version drift across runners. Security scanning, code generation, and language-specific tooling that requires precise environment configuration benefit enormously — the action ships its own scanner binary, runtime, and configuration as an immutable image. The trade-off is longer startup time (container pull + layer extraction) compared to JavaScript actions, making Docker actions most appropriate for heavyweight tooling like security scanners, documentation generators, and specialized compilers where correctness outweighs startup latency.
Example 62: Matrix with include/exclude and Dynamic fromJSON
The matrix strategy runs a job multiple times with different parameter combinations. The include key adds extra variables to specific combinations, while exclude removes unwanted combinations. Dynamic matrices computed from fromJSON enable data-driven job generation.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Matrix Definition<br/>os x node-version"]
B["ubuntu + 18<br/>EXCLUDED"]
C["ubuntu + 20<br/>+ coverage: true"]
D["ubuntu + 22"]
E["windows + 20"]
F["macos + 20"]
A -->|generate combinations| B
A -->|generate combinations| C
A -->|generate combinations| D
A -->|generate combinations| E
A -->|generate combinations| F
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#CA9161,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
style D fill:#DE8F05,stroke:#000,color:#fff
style E fill:#DE8F05,stroke:#000,color:#fff
style F fill:#DE8F05,stroke:#000,color:#fff
Static matrix with include/exclude:
jobs:
test:
strategy:
fail-fast:
false # => Don't cancel other matrix jobs when one fails
# => Default is true; set false for independent jobs
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
# => Three values for os dimension
node-version: ["18", "20", "22"]
# => Three values for node-version dimension
# => Total: 3 x 3 = 9 combinations before exclude/include
exclude: # => Remove specific combinations from the matrix
- os: ubuntu-latest
node-version: "18"
# => Removes the ubuntu + Node 18 combination
# => Node 18 EOL; skip on Linux where we run full coverage
include: # => Add extra variables to specific combinations
- os: ubuntu-latest
node-version: "20"
coverage: true
# => When os=ubuntu AND node=20, adds coverage=true variable
# => Does NOT create a new job; enriches existing combination
runs-on: ${{ matrix.os }}
# => matrix.os resolves to "ubuntu-latest", "windows-latest", etc.
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
# => matrix.node-version is "18", "20", or "22"
- run: npm ci && npm test
- name: Upload coverage
if: matrix.coverage == true
# => Only runs on the ubuntu + Node 20 combination
# => matrix.coverage is true only for that specific job
run: npm run coverage:uploadDynamic matrix from a previous job:
jobs:
compute-matrix: # => Job that generates the matrix dynamically
runs-on: ubuntu-latest
outputs:
matrix: ${{ steps.set-matrix.outputs.matrix }}
# => Exposes the JSON string to downstream jobs
steps:
- id: set-matrix
run: |
SERVICES=$(find services/ -name 'package.json' -maxdepth 2 \
| jq -R -s 'split("\n") | map(select(. != "")) | map(ltrimstr("services/") | split("/")[0]) | unique' \
--arg prefix "services/")
echo "matrix={\"service\":${SERVICES}}" >> "$GITHUB_OUTPUT"
# => Dynamically discovers service names from directory structure
# => jq constructs a JSON array: ["auth", "api", "worker"]
# => Sets matrix output as JSON string
test-services:
needs: compute-matrix # => Waits for the matrix to be computed
strategy:
matrix: ${{ fromJSON(needs.compute-matrix.outputs.matrix) }}
# => fromJSON parses the JSON string into a matrix object
# => Generates one job per discovered service
runs-on: ubuntu-latest
steps:
- run: echo "Testing service: ${{ matrix.service }}"
# => matrix.service is "auth", "api", "worker", etc.Key Takeaway: Use exclude to prune specific combinations from the Cartesian product, include to enrich existing combinations with extra variables, and fromJSON to build data-driven dynamic matrices from upstream job outputs.
Why It Matters: Matrix strategies replace manual copy-paste of nearly-identical jobs, reducing workflow file size by 80% in multi-OS or multi-version test suites. Dynamic matrices powered by fromJSON allow CI to automatically scale as services are added to a monorepo without manual workflow edits. The fail-fast: false setting is critical for cross-platform testing — a Windows failure should not cancel the Linux job that produces coverage reports for the PR. Teams that master matrix strategies reduce CI maintenance overhead from a recurring weekly task to a one-time setup.
Example 63: Workflow Chaining with workflow_run
The workflow_run trigger starts a workflow when another workflow completes, succeeds, or fails. This enables multi-stage pipelines where later stages only run after earlier stages succeed, without combining all stages into one monolithic workflow file.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
sequenceDiagram
participant Push as Git Push
participant CI as CI Workflow
participant Deploy as Deploy Workflow
participant Notify as Notify Workflow
Push->>CI: Triggers on push to main
CI->>CI: Run tests and build
CI-->>Deploy: workflow_run completed + success
Deploy->>Deploy: Deploy to staging
Deploy-->>Notify: workflow_run completed
Notify->>Notify: Send Slack notification
Triggering workflow (.github/workflows/ci.yml):
name: CI
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm testDependent workflow (.github/workflows/deploy.yml):
name: Deploy
on:
workflow_run: # => Triggered by another workflow's completion
workflows:
["CI"] # => References the triggering workflow by NAME (not filename)
# => Must exactly match the 'name:' field in ci.yml
types:
[completed] # => Triggers when CI finishes (regardless of result)
# => Options: completed, requested, in_progress
branches:
[main] # => Only trigger when CI ran on main branch
# => Prevents staging deploys from feature branch CI
jobs:
deploy:
if: github.event.workflow_run.conclusion == 'success'
# => workflow_run event includes details about the triggering run
# => .conclusion is 'success', 'failure', 'cancelled', 'skipped'
# => Guard prevents deploying when CI failed
runs-on: ubuntu-latest
steps:
- name: Download artifact from CI run
uses: actions/github-script@v7
with:
script: |
const artifacts = await github.rest.actions.listWorkflowRunArtifacts({
owner: context.repo.owner,
repo: context.repo.repo,
run_id: ${{ github.event.workflow_run.id }},
// => github.event.workflow_run.id is the CI run's ID
// => Use it to fetch artifacts from the PREVIOUS workflow
});
// => artifacts.data.artifacts is the list of uploaded artifacts
const build = artifacts.data.artifacts.find(a => a.name === 'build');
core.setOutput('artifact-id', build.id);
- name: Deploy
run: echo "Deploying build artifact ${{ steps.download.outputs.artifact-id }}"Key Takeaway: workflow_run chains workflows sequentially. Always guard with if: github.event.workflow_run.conclusion == 'success' to prevent deploying broken builds. Access artifacts from the upstream run using the github.event.workflow_run.id.
Why It Matters: Monolithic workflow files with 50+ jobs become unmaintainable and impossible to rerun partially. workflow_run splits the CI/CD pipeline across focused files — CI handles testing, Deploy handles infrastructure, Notify handles communications — each independently readable, rerunnable, and permissioned. The design matches Conway’s Law: teams owning deployment can manage the deploy workflow without touching the CI workflow owned by the platform team. Artifact sharing between workflow runs requires the GitHub API but avoids the complexity of separate artifact storage systems.
Example 64: Deployment Environments with Required Approvals
GitHub deployment environments enforce protection rules before a workflow deploys to them. Required reviewers pause the workflow until a human approves, and environment-scoped secrets are only available when deploying to that specific environment.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
stateDiagram-v2
[*] --> BuildSuccess: Tests pass
BuildSuccess --> WaitingApproval: Job requests production env
WaitingApproval --> Approved: Reviewer approves
WaitingApproval --> Rejected: Reviewer rejects
Approved --> Deploying: Secrets unlocked
Deploying --> Deployed: Deploy step succeeds
Rejected --> [*]: Workflow cancelled
Deployed --> [*]: Success
name: Deploy to Production
on:
push:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with:
name: dist
path: dist/
deploy-staging:
needs: build
runs-on: ubuntu-latest
environment: staging
# => Requests the 'staging' environment
# => If staging has no protection rules, job runs immediately
# => Environment-scoped secrets (DATABASE_URL) become available
steps:
- uses: actions/download-artifact@v4
with:
name: dist
- name: Deploy to staging
run: ./scripts/deploy.sh staging
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
# => secrets.DATABASE_URL is scoped to 'staging' environment
# => Different value than production's DATABASE_URL
deploy-production:
needs: deploy-staging # => Requires staging deploy to succeed first
runs-on: ubuntu-latest
environment: production
# => Requests the 'production' environment
# => Production has "Required reviewers" protection rule configured
# => Workflow PAUSES here until an approved reviewer approves
# => Timeout: reviewer must act within configured wait timer (e.g., 30 days)
steps:
- uses: actions/download-artifact@v4
with:
name: dist
- name: Deploy to production
run: ./scripts/deploy.sh production
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
# => secrets.DATABASE_URL is the PRODUCTION value now
# => Same secret name, different environment-scoped value
DEPLOY_KEY: ${{ secrets.DEPLOY_KEY }}
# => Additional production-only secret
post-deploy:
needs: deploy-production
runs-on: ubuntu-latest
environment:
name: production
url: https://app.example.com
# => Sets the deployment URL shown in GitHub's "Environments" tab
# => Also shown on PR merge commits for quick access
steps:
- name: Run smoke tests
run: ./scripts/smoke-test.sh https://app.example.comKey Takeaway: Declare environment: <name> on a job to request that environment’s protection rules and secrets. Required reviewers pause execution; secrets are only decrypted after approval and are scoped to the specific environment.
Why It Matters: Deployment environments solve the governance problem of “who approved this production deploy?” that compliance teams require. Before environments, teams implemented approval gates via external tools (PagerDuty, Jira tickets, Slack approvals) that were disconnected from the deployment record. GitHub environments create an auditable approval chain directly linked to the commit, reviewer identity, and deployment URL — satisfying SOC 2 change management controls without external tooling. Environment-scoped secrets prevent staging credentials from ever being used in production jobs, eliminating a common misconfiguration class.
Example 65: OIDC Federated Identity for AWS (No Long-Lived Credentials)
OpenID Connect (OIDC) federation allows GitHub Actions to authenticate to cloud providers using short-lived tokens instead of long-lived static credentials stored as secrets. AWS assumes an IAM role after verifying the GitHub-issued JWT token’s claims (repository, branch, workflow).
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
sequenceDiagram
participant GH as GitHub Actions Runner
participant GHOIDC as GitHub OIDC Provider
participant AWS as AWS STS
participant S3 as AWS S3
GH->>GHOIDC: Request OIDC token (JWT)
GHOIDC-->>GH: JWT with claims (repo, ref, workflow)
GH->>AWS: AssumeRoleWithWebIdentity(JWT, RoleARN)
AWS->>GHOIDC: Verify JWT signature
GHOIDC-->>AWS: Valid
AWS->>AWS: Check trust policy conditions
AWS-->>GH: Temporary credentials (15min TTL)
GH->>S3: API call with temp credentials
S3-->>GH: Response
name: Deploy to AWS
on:
push:
branches: [main]
permissions:
id-token:
write # => Required to request the OIDC JWT token
# => Without this permission, the OIDC request fails
contents: read # => Minimal permission for checkout
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials via OIDC
uses: aws-actions/configure-aws-credentials@v4
# => Pinned to major version tag (still use SHA pin in production)
with:
role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy
# => IAM role ARN configured to trust GitHub's OIDC provider
# => Role trust policy restricts which repos/branches can assume it
aws-region: us-east-1
role-session-name: github-actions-${{ github.run_id }}
# => Session name appears in CloudTrail for audit tracing
# => Links AWS API calls back to the specific workflow run
- name: Deploy to S3
run: |
aws s3 sync dist/ s3://my-production-bucket/ --delete
# => Uses temporary AWS credentials from the OIDC exchange
# => Credentials expire after 15 minutes (configurable)
# => No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY secrets needed
- name: Invalidate CloudFront cache
run: |
aws cloudfront create-invalidation \
--distribution-id ${{ vars.CLOUDFRONT_DISTRIBUTION_ID }} \
--paths "/*"
# => vars context reads repository or environment variables
# => Variables (vars) are non-secret; secrets are for sensitive valuesAWS IAM trust policy (set up once, not in workflow):
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012: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:my-org/my-repo:ref:refs/heads/main"
}
}
}
]
}Key Takeaway: OIDC federation requires permissions.id-token: write in the workflow and an IAM role with a trust policy that restricts token.actions.githubusercontent.com:sub to specific repositories and branches. Temporary credentials expire after 15 minutes, eliminating credential rotation maintenance.
Why It Matters: Long-lived AWS access keys stored as GitHub secrets are a persistent security risk — they never expire, can be exfiltrated from logs, and require manual rotation that teams consistently defer. The 2023 CircleCI breach exposed exactly this attack surface when static credentials in CI secrets were compromised. OIDC federation eliminates the entire class of static credential vulnerabilities: tokens are minted per-run, expire in 15 minutes, and the IAM trust policy’s sub condition ensures only your specific repository’s main branch can assume the production role. AWS CloudTrail logs show the GitHub run ID as the session name, providing full deployment audit trails.
Example 66: OIDC for GCP and Azure
The OIDC pattern extends to GCP Workload Identity Federation and Azure Federated Identity Credentials using the same GitHub-issued JWT. The trust configuration differs per provider but the workflow pattern is identical.
GCP Workload Identity Federation:
jobs:
deploy-gcp:
runs-on: ubuntu-latest
permissions:
id-token: write # => Required for OIDC token request
contents: read
steps:
- uses: actions/checkout@v4
- name: Authenticate to Google Cloud
uses: google-github-actions/auth@v2
with:
workload_identity_provider: "projects/123456/locations/global/workloadIdentityPools/github-pool/providers/github-provider"
# => Full resource name of the Workload Identity Provider
# => Created once via gcloud CLI or Terraform
service_account: "github-actions@my-project.iam.gserviceaccount.com"
# => GCP service account that the GitHub identity impersonates
# => Service account must have roles/iam.workloadIdentityUser binding
- name: Deploy to Cloud Run
run: |
gcloud run deploy my-service \
--image gcr.io/my-project/my-service:${{ github.sha }} \
--region us-central1 \
--platform managed
# => gcloud uses Application Default Credentials set by the auth step
# => No GOOGLE_APPLICATION_CREDENTIALS file or key JSON neededAzure Federated Identity Credentials:
jobs:
deploy-azure:
runs-on: ubuntu-latest
permissions:
id-token: write # => Required for OIDC token request
contents: read
steps:
- uses: actions/checkout@v4
- name: Azure Login via OIDC
uses: azure/login@v2
with:
client-id: ${{ vars.AZURE_CLIENT_ID }}
# => App registration client ID (non-sensitive, use vars not secrets)
tenant-id: ${{ vars.AZURE_TENANT_ID }}
# => Azure Active Directory tenant ID
subscription-id: ${{ vars.AZURE_SUBSCRIPTION_ID }}
# => Target Azure subscription
- name: Deploy to Azure App Service
uses: azure/webapps-deploy@v3
with:
app-name: my-app-service
package: dist/
# => Deploys the dist/ directory to Azure App Service
# => Uses the federated credentials from the login stepKey Takeaway: GCP uses workload_identity_provider and service_account parameters; Azure uses client-id, tenant-id, and subscription-id. All three cloud providers require permissions.id-token: write and a one-time trust configuration in the cloud provider’s IAM system.
Why It Matters: Multi-cloud organizations deploy to AWS, GCP, and Azure from the same repository. Maintaining static access keys for all three clouds triples the credential rotation burden and security surface area. OIDC federation with consistent permissions.id-token: write syntax across cloud providers enables a single workflow pattern for all targets. Platform teams configure the trust policy once per environment, and development teams write workflows without ever handling long-lived credentials — aligning with zero-standing-privilege security models adopted by organizations pursuing FedRAMP authorization or ISO 27001 certification.
Example 67: Self-Hosted Runners and Runner Groups
Self-hosted runners execute workflow jobs on your own infrastructure — on-premises servers, private cloud VMs, or bare-metal machines with specialized hardware (GPUs, HSMs, specific OS versions). Runner groups control which repositories can use which runners.
name: GPU Training Job
on:
push:
branches: [main]
paths:
- "models/**" # => Only trigger when model files change
jobs:
train:
runs-on: [self-hosted, gpu, linux]
# => Labels filter which runner picks up this job
# => self-hosted: matches any self-hosted runner
# => gpu: custom label assigned when runner was registered
# => linux: built-in OS label
# => ALL labels must match (AND logic, not OR)
timeout-minutes:
480 # => 8 hour timeout for long training runs
# => GitHub-hosted runners cap at 6 hours
# => Self-hosted runners cap at 35 days
steps:
- uses: actions/checkout@v4
with:
lfs:
true # => Download Git LFS objects (large model files)
# => Required when training data stored in LFS
- name: Check GPU availability
run: nvidia-smi
# => Verifies GPU is accessible in the runner environment
# => nvidia-smi is pre-installed on GPU runner images
- name: Run training
run: python train.py --epochs 100 --batch-size 256
env:
CUDA_VISIBLE_DEVICES: "0,1"
# => Use GPUs 0 and 1 for training
# => Self-hosted runners keep environment between runs
# => Unlike GitHub-hosted runners which are ephemeral
- name: Save model artifact
uses: actions/upload-artifact@v4
with:
name: trained-model-${{ github.sha }}
path: checkpoints/
retention-days:
90 # => Keep artifact for 90 days (default is 90)
# => Large model checkpoints may incur storage costsRunner registration script (run on the machine once):
./config.sh \
--url https://github.com/my-org/my-repo \
--token AABCXXX... \
# => Registration token from GitHub Settings > Actions > Runners
# => Token expires after 1 hour; generate fresh token per registration
--labels gpu,linux,high-memory \
# => Custom labels assigned to this runner
# => Match these labels in workflow runs-on field
--runnergroup "ml-team"
# => Assigns runner to a runner group for access control
# => Groups restrict which repositories can use these runnersKey Takeaway: Self-hosted runners use labels for job routing (runs-on: [self-hosted, gpu, linux]). Register runners with custom labels matching their capabilities, and assign them to runner groups to control repository access.
Why It Matters: Self-hosted runners are essential for workloads that exceed GitHub-hosted runner constraints: 7 GB RAM limit, 6-hour timeout, no GPU access, no access to private network resources, and limited disk space. Machine learning training jobs, embedded systems cross-compilation requiring licensed toolchains, and integration tests against on-premises databases all require self-hosted infrastructure. The runner group access control is a critical security boundary — without it, any repository in the organization could schedule jobs on runners with privileged internal network access, violating network segmentation requirements in PCI-DSS and HIPAA environments.
Example 68: GitHub Apps for Workflow Authentication
GitHub Apps provide fine-grained, repository-scoped tokens for workflows that need to create commits, push branches, or perform actions that GITHUB_TOKEN cannot do (like triggering downstream workflows or bypassing branch protections for automation accounts).
name: Auto-Update Dependencies
on:
schedule:
- cron: "0 9 * * 1" # => Every Monday at 9 AM UTC
jobs:
update-deps:
runs-on: ubuntu-latest
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
# => Official action to generate a token from a GitHub App
with:
app-id: ${{ vars.AUTOMATION_APP_ID }}
# => GitHub App ID from the App's settings page
# => App ID is non-sensitive; use vars not secrets
private-key: ${{ secrets.AUTOMATION_APP_PRIVATE_KEY }}
# => PEM-encoded private key downloaded when creating the App
# => Sensitive; store as secret
- uses: actions/checkout@v4
with:
token: ${{ steps.app-token.outputs.token }}
# => Checks out with the App token instead of GITHUB_TOKEN
# => Commits made with this token appear as "Your App [bot]"
# => Pushes from this token CAN trigger downstream push workflows
# => GITHUB_TOKEN pushes are blocked from triggering new workflows
# => This is the primary reason to use GitHub Apps for automation
- name: Update dependencies
run: |
npm update
npm audit fix
# => Updates package.json and package-lock.json
- name: Create Pull Request
uses: peter-evans/create-pull-request@v6
# => Creates or updates a PR with the changes
with:
token: ${{ steps.app-token.outputs.token }}
# => Must use App token for the PR to trigger CI workflows
commit-message: "chore: update npm dependencies"
title: "chore: automated dependency update"
body: |
## Automated Dependency Update
This PR was created automatically by the dependency update workflow.
### Changes
- Updated npm dependencies to latest compatible versions
- Applied `npm audit fix` for known vulnerabilities
branch: "automated/dependency-update-${{ github.run_id }}"
# => Creates a unique branch name per run
# => Prevents conflicts between concurrent runs
labels: ["dependencies", "automated"]Key Takeaway: GitHub Apps generate short-lived tokens scoped to specific repositories and permissions. Use actions/create-github-app-token to generate tokens at runtime. App-authored commits trigger downstream workflows, unlike GITHUB_TOKEN commits which are blocked to prevent infinite loops.
Why It Matters: The GITHUB_TOKEN has two intentional limitations that force teams to GitHub Apps for real automation: it cannot push to branches with required status checks enforced, and its commits intentionally do not trigger downstream push workflows to prevent infinite CI loops. GitHub Apps with narrowly scoped permissions (e.g., contents: write and pull-requests: write only) replace the anti-pattern of storing personal access tokens in secrets — PATs grant full account access and expire, while App tokens expire in 1 hour and are limited to exactly the permissions the App was granted, satisfying least-privilege security requirements.
Example 69: GitHub API in Workflows with gh CLI and Octokit
Workflows interact with the GitHub API using either the pre-installed gh CLI (for shell-based interactions) or actions/github-script (for JavaScript-based Octokit calls). Both are pre-authenticated with GITHUB_TOKEN.
name: PR Automation
on:
pull_request:
types: [opened, synchronize]
jobs:
label-and-comment:
runs-on: ubuntu-latest
permissions:
pull-requests: write # => Required to add labels and comments
contents: read
steps:
- name: Add label based on changed files
env:
GH_TOKEN:
${{ github.token }} # => gh CLI reads this environment variable
# => Alternative: use ${{ secrets.GITHUB_TOKEN }}
run: |
CHANGED_FILES=$(gh pr view ${{ github.event.pull_request.number }} \
--json files --jq '.files[].path')
# => gh pr view fetches PR details in JSON format
# => jq .files[].path extracts the list of changed file paths
if echo "$CHANGED_FILES" | grep -q "^docs/"; then
gh pr edit ${{ github.event.pull_request.number }} \
--add-label "documentation"
# => Adds the 'documentation' label to the PR
# => Label must already exist in the repository
fi
if echo "$CHANGED_FILES" | grep -q "^src/api/"; then
gh pr edit ${{ github.event.pull_request.number }} \
--add-label "api-change" \
--add-reviewer "api-reviewers-team"
# => Adds label and requests review from a team
fi
- name: Post welcome comment with Octokit
uses: actions/github-script@v7
# => Runs JavaScript with pre-configured Octokit client
with:
script: |
const { data: comments } = await github.rest.issues.listComments({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
// => PRs are issues in GitHub's API model
});
// => comments is an array of comment objects
const botComment = comments.find(c =>
c.user.type === 'Bot' && c.body.includes('PR Checklist')
);
// => Check if we already posted this comment (idempotency)
// => Prevents duplicate comments on re-runs
if (!botComment) {
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
body: `## PR Checklist\n- [ ] Tests added\n- [ ] Docs updated\n- [ ] CHANGELOG entry`
// => Markdown-formatted comment with task checkboxes
});
// => Creates the comment only if it doesn't exist
}Key Takeaway: The gh CLI requires GH_TOKEN environment variable and is best for simple shell-based API calls. actions/github-script provides full Octokit with async/await and is better for complex logic. Both use GITHUB_TOKEN and respect workflow permissions.
Why It Matters: Manual PR triage — adding labels, requesting reviewers, posting checklists — consumes developer attention disproportionate to its value. Automating these hygiene tasks through GitHub API integration ensures consistency: every API PR gets an API reviewer, every docs change gets the documentation label, and every PR gets the same checklist template regardless of who opened it. The idempotency check (finding existing bot comments before creating new ones) is essential for workflows triggered on synchronize — without it, each commit push generates a duplicate comment flood that buries the PR’s discussion.
Example 70: Release Automation with Semantic Release
Release automation generates changelogs, bumps versions, creates GitHub Releases, and publishes packages based on Conventional Commits. Semantic release analyzes commit messages since the last tag to determine the next version automatically.
name: Release
on:
push:
branches: [main]
permissions:
contents: write # => Required to push tags and create GitHub Releases
issues: write # => Required to close issues referenced in commits
pull-requests: write # => Required to comment on merged PRs with release notes
id-token: write # => Required for npm provenance attestation
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth:
0 # => Fetch full git history (all tags and commits)
# => Semantic release needs history to find previous tags
# => Default fetch-depth: 1 only fetches the latest commit
- uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
# => registry-url sets up NODE_AUTH_TOKEN for npm publish
- run: npm ci
- name: Release
run: npx semantic-release
# => Analyzes commits since last tag using conventional commits spec
# => feat: -> minor version bump (1.2.0 -> 1.3.0)
# => fix: -> patch version bump (1.2.0 -> 1.2.1)
# => BREAKING CHANGE: in footer -> major bump (1.2.0 -> 2.0.0)
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# => Used to create GitHub Release and push version tags
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
# => Used to publish the package to npm
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
# => setup-node uses NODE_AUTH_TOKEN for npm publish authenticationrelease.config.js (semantic-release configuration):
module.exports = {
branches: ["main"], // => Only release from main branch
// => Prevents accidental releases from feature branches
plugins: [
"@semantic-release/commit-analyzer", // => Analyzes commits using conventional commits
// => Determines version bump type from commit types
"@semantic-release/release-notes-generator", // => Generates CHANGELOG content from commits
// => Groups commits by type: Features, Bug Fixes, etc.
[
"@semantic-release/changelog",
{ changelogFile: "CHANGELOG.md" }, // => Writes changelog to CHANGELOG.md
// => Commit message: "chore(release): x.y.z [skip ci]"
// => [skip ci] prevents release commit from triggering CI again
],
[
"@semantic-release/npm",
{ npmPublish: true }, // => Publishes package to npm registry
// => Uses NODE_AUTH_TOKEN from environment
],
"@semantic-release/github", // => Creates GitHub Release with notes
// => Attaches release assets if configured
// => Comments on merged PRs and closed issues
[
"@semantic-release/git",
{
assets: ["CHANGELOG.md", "package.json"],
// => Commits updated CHANGELOG and package.json back to main
// => Creates a "chore(release): x.y.z" commit
message: "chore(release): ${nextRelease.version} [skip ci]",
// => [skip ci] in commit message skips GitHub Actions trigger
// => Prevents infinite workflow loop from the release commit
},
],
],
};Key Takeaway: Semantic release requires fetch-depth: 0 for full git history, contents: write permission for tags and releases, and [skip ci] in release commits to prevent infinite loops. Conventional Commits (feat:, fix:, BREAKING CHANGE:) drive automatic version determination.
Why It Matters: Manual release processes are the number-one source of release failures in fast-moving teams. Developers forget to update CHANGELOG.md, choose the wrong version bump type, publish to npm before creating the GitHub Release, or create the tag before the package is built. Semantic release executes these steps atomically — if npm publish fails, no GitHub Release is created and the tag is not pushed, leaving the repository in a consistent state. The conventional commits requirement is a beneficial forcing function: teams that adopt it for releases discover that structured commit messages improve code review quality as a secondary benefit.
Example 71: Monorepo CI with Path Filters and Conditional Jobs
Monorepo workflows run only the CI relevant to changed packages using paths filters and dorny/paths-filter for fine-grained conditional logic. This prevents rebuilding all services when only one service’s code changes.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["Git Push<br/>changes detected"]
B["Paths Filter Job<br/>detect-changes"]
C["Build Auth Service<br/>if auth changed"]
D["Build API Service<br/>if api changed"]
E["Build Frontend<br/>if frontend changed"]
F["Deploy All Changed<br/>needs: all builds"]
A --> B
B -->|auth: true| C
B -->|api: true| D
B -->|frontend: true| E
C --> F
D --> F
E --> F
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#029E73,stroke:#000,color:#fff
style D fill:#029E73,stroke:#000,color:#fff
style E fill:#029E73,stroke:#000,color:#fff
style F fill:#CC78BC,stroke:#000,color:#fff
name: Monorepo CI
on:
push:
branches: [main]
pull_request:
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs: # => Expose filter results to downstream jobs
auth: ${{ steps.filter.outputs.auth }}
api: ${{ steps.filter.outputs.api }}
frontend: ${{ steps.filter.outputs.frontend }}
shared: ${{ steps.filter.outputs.shared }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
auth:
- 'services/auth/**'
- 'libs/shared/**'
api:
- 'services/api/**'
- 'libs/shared/**'
frontend:
- 'apps/web/**'
- 'libs/ui/**'
shared:
- 'libs/shared/**'
# => Each filter is a list of glob patterns
# => 'auth' is true if ANY auth/** OR libs/shared/** file changed
# => Multiple filters can be true in one push
build-auth:
needs: detect-changes
if: needs.detect-changes.outputs.auth == 'true'
# => Conditional job: only runs if auth service files changed
# => 'true' is a string comparison (outputs are always strings)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build auth service
run: |
cd services/auth
npm ci && npm run build && npm run test
build-api:
needs: detect-changes
if: needs.detect-changes.outputs.api == 'true'
# => Only runs if api service or shared lib files changed
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build API service
run: |
cd services/api
npm ci && npm run build && npm run test
build-frontend:
needs: detect-changes
if: needs.detect-changes.outputs.frontend == 'true'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build frontend
run: |
cd apps/web
npm ci && npm run build
deploy:
needs: [build-auth, build-api, build-frontend]
if: |
always() &&
!contains(needs.*.result, 'failure') &&
!contains(needs.*.result, 'cancelled')
# => always() ensures deploy runs even when some build jobs were skipped
# => Without always(), a skipped job causes 'needs' to skip deploy too
# => !contains checks no build job actually failed or was cancelled
runs-on: ubuntu-latest
steps:
- run: echo "Deploying changed services"Key Takeaway: Use dorny/paths-filter to detect changed paths and expose results as job outputs. Downstream jobs use if: needs.<id>.outputs.<filter> == 'true'. The deploy job must use always() to run when some build jobs were legitimately skipped rather than failed.
Why It Matters: Monorepos with 20+ services cannot afford to rebuild and test everything on every commit — a 30-minute full build pipeline becomes the primary bottleneck to developer velocity. Path filtering reduces CI time by 70-90% on targeted changes: a CSS fix in apps/web runs only the frontend build, not the auth service or API. The always() expression in the deploy job is a subtle but critical correctness requirement — GitHub treats skipped jobs as “not completed”, and a needs: dependency on a skipped job will also skip by default, breaking the deploy step for partial updates without always().
Example 72: Advanced Dependency Caching Strategies
Effective caching strategies match cache keys to the granularity of their dependencies. Over-broad keys cause unnecessary cache misses; over-narrow keys prevent sharing across branches and produce redundant downloads.
name: Advanced Caching
on: [push, pull_request]
jobs:
node-cache:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Cache npm with lock file hash
uses: actions/cache@v4
with:
path: |
~/.npm
node_modules/.cache
# => Cache both the npm registry cache and build caches
key: npm-${{ runner.os }}-${{ hashFiles('**/package-lock.json') }}
# => Key changes only when package-lock.json changes
# => hashFiles hashes all matching files across the repo
restore-keys: |
npm-${{ runner.os }}-
# => Fallback: restore most recent cache for this OS even if exact key misses
# => Saves partial restoration for branches without recent cache
- run: npm ci --prefer-offline
go-cache:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod # => Read Go version from go.mod
cache:
true # => setup-go has built-in caching since v5
# => Caches $GOPATH/pkg/mod (module cache)
# => Key: go.sum hash
# => Equivalent to manual actions/cache setup
- run: go build ./...
docker-layer-cache:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
# => Required for cache-to/cache-from with gha backend
- name: Build with layer cache
uses: docker/build-push-action@v6
with:
context: .
push: false
tags: my-app:${{ github.sha }}
cache-from: type=gha
# => Restores Docker layer cache from GitHub Actions cache
# => gha (GitHub Actions) backend uses actions/cache under the hood
cache-to: type=gha,mode=max
# => Saves all layers to cache (mode=max vs mode=min for final image only)
# => Reduces build time by 60-80% when base layers are unchanged
gradle-cache:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: "21"
distribution: "temurin"
- name: Cache Gradle dependencies
uses: actions/cache@v4
with:
path: |
~/.gradle/caches
~/.gradle/wrapper
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
# => Hashes all Gradle build files AND wrapper properties
# => Wrapper properties contain Gradle version -> cache miss on upgrade
restore-keys: |
gradle-${{ runner.os }}-
- run: ./gradlew build --build-cache
# => --build-cache enables Gradle's own incremental build cache
# => Combined with restored dependencies, maximizes build speedKey Takeaway: Use hashFiles('**/lockfile') to create deterministic cache keys that miss only when dependencies change. Include restore-keys as fallback prefixes for cross-branch cache sharing. Docker layer caching requires docker/setup-buildx-action and the gha cache backend.
Why It Matters: Dependency installation is typically 40-60% of total CI run time for JavaScript and Java projects. Effective cache keys reduce this to under 10 seconds on cache hits — a 10x improvement that compounds across hundreds of daily workflow runs. The restore-keys fallback is essential for new branches: without it, a fresh feature branch misses the cache entirely and downloads all dependencies from the network, defeating the caching benefit for exactly the scenario where developers most need fast feedback. Docker layer caching provides the largest absolute time savings for multi-stage builds where base image installation (apt packages, pip install) accounts for 5-10 minutes per run.
Example 73: Build Artifact Retention and Cross-Job Sharing
Artifacts enable sharing build outputs between jobs without re-building. Retention policies control storage costs, and artifact attestation adds supply chain provenance for security-conscious deployments.
name: Build and Test
on: [push]
jobs:
build:
runs-on: ubuntu-latest
outputs:
artifact-id: ${{ steps.upload.outputs.artifact-id }}
# => Artifact ID enables targeted downloads (not just by name)
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- name: Upload build artifact
id: upload
uses: actions/upload-artifact@v4
with:
name: dist-${{ github.sha }}
# => Include SHA in name for uniqueness across runs
# => Without SHA, parallel runs on different commits share artifact names
path: dist/
retention-days: 7
# => Delete artifact after 7 days (default: 90 days)
# => Reduce storage costs for ephemeral build outputs
# => Use 30-90 days for artifacts that may be needed for rollbacks
compression-level: 9
# => Maximum compression (0-9, default 6)
# => Trade CPU time for reduced storage and upload bandwidth
if-no-files-found: error
# => Fail if dist/ is empty or missing (catches silent build failures)
# => Options: error, warn, ignore
test-e2e:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/download-artifact@v4
with:
name: dist-${{ github.sha }}
# => Downloads artifact by name to current directory
path: dist/
# => Specifies where to extract the artifact
- run: npm ci && npm run test:e2e
deploy:
needs: [test-e2e]
runs-on: ubuntu-latest
steps:
- name: Download artifact by ID
uses: actions/download-artifact@v4
with:
artifact-id: ${{ needs.build.outputs.artifact-id }}
# => Download by ID rather than name (more precise)
# => Guaranteed to get exactly the artifact from this run
- name: Generate build attestation
uses: actions/attest-build-provenance@v2
# => Creates SLSA provenance attestation for the artifact
# => Cryptographically signed by GitHub's Sigstore integration
with:
subject-path: dist/**
# => Attests to all files in dist/
# => Consumers can verify: gh attestation verify <artifact>
- run: ./deploy.shKey Takeaway: Set retention-days to match actual needs rather than the 90-day default to control storage costs. Use if-no-files-found: error to catch silent build failures. actions/attest-build-provenance adds SLSA provenance attestation for supply chain security.
Why It Matters: Build artifact management directly affects CI costs and security posture. At scale, 90-day retention on every build artifact generates significant storage bills for teams running hundreds of daily builds — a monorepo with 50 jobs each producing 50 MB artifacts accumulates 225 GB per month at default retention. Retention policies aligned with deployment rollback windows (7-30 days) reduce costs by 80-90%. SLSA attestation addresses the growing supply chain attack surface: signed provenance records that an artifact was built from a specific commit by a specific workflow, enabling downstream consumers to verify “did this binary actually come from our CI system or was it tampered with?”
Example 74: Security Hardening — Pin Actions to SHA and Least-Privilege Permissions
Workflow security depends on two practices: pinning third-party actions to full commit SHAs (not mutable tags) and granting only the specific permissions each job needs. These practices mitigate supply chain attacks and limit blast radius from compromised tokens.
name: Hardened CI
on:
pull_request:
permissions: # => Workflow-level permission defaults
contents:
none # => Deny ALL permissions by default at workflow level
# => Override per-job for minimum required access
# => Replaces the permissive default (read-all)
jobs:
lint:
runs-on: ubuntu-latest
permissions:
contents:
read # => Only reading repository contents
# => No write, no packages, no id-token
steps:
- uses: actions/checkout@v4
# => actions/checkout is owned by GitHub (trusted first-party)
# => SHA pinning still recommended for maximum control
- uses: actions/setup-node@v4
# => actions/setup-node is owned by GitHub (trusted first-party)
- run: npm ci && npm run lint
security-scan:
runs-on: ubuntu-latest
permissions:
contents: read
security-events: write # => Required to upload SARIF results to Code Scanning
steps:
- uses: actions/checkout@v4
- name: Run CodeQL analysis
uses: github/codeql-action/analyze@b56ba49b26a50fe1e099cd8c8a8cef41c0d2ebca
# => Pinned to exact SHA (40 hex chars)
# => SHA is immutable; tag v3 could be overwritten by attacker
# => Find SHA: git ls-remote https://github.com/github/codeql-action.git refs/tags/v3
with:
category: "/language:javascript"
dependency-review:
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: write # => Required to post review comments
steps:
- uses: actions/checkout@v4
- uses: actions/dependency-review-action@67d4f4b89d2eda2571a18ea7efbe28d3b4bef05b
# => SHA pin for third-party action
# => Blocks PRs that introduce known vulnerable dependencies
with:
fail-on-severity: high
# => Only fail for HIGH and CRITICAL CVEs
# => LOW and MEDIUM generate warnings without blocking merge
deny-licenses: AGPL-3.0, GPL-2.0, GPL-3.0
# => Block licenses incompatible with commercial useKey Takeaway: Set permissions: contents: none at workflow level and grant minimum required permissions per job. Pin third-party actions (non-GitHub-owned) to full commit SHAs. Find SHAs via git ls-remote or github.com/<owner>/<action>/tags.
Why It Matters: The 2021 Codecov supply chain attack compromised CI credentials by injecting malicious code into a tool downloaded during CI runs. A GitHub Action equivalent attack would use a compromised action tag (e.g., attacker pushes new code to the v3 tag) to exfiltrate GITHUB_TOKEN and all secrets accessible in the workflow. SHA pinning makes this attack impossible: the runner downloads exactly the code at that SHA, and any subsequent tag modification is irrelevant. Least-privilege permissions limit the damage when secrets are compromised — a contents: read token cannot push malicious commits or create releases, containing the attack surface to read-only repository access.
Example 75: Workflow Dispatch with Complex Inputs
workflow_dispatch enables manual workflow triggers from the GitHub UI or API, with typed input forms that validate values before the workflow runs. This pattern is ideal for operational runbooks, one-off deployments, and debugging triggers.
name: Manual Deployment Runbook
on:
workflow_dispatch: # => Enables manual trigger from GitHub UI (Actions tab)
# => Also triggerable via GitHub API
inputs:
environment:
description: "Target deployment environment"
required: true
type: choice # => Renders as a dropdown in the GitHub UI
options: # => Available options for the dropdown
- staging
- production
- canary
default: staging
version:
description: "Version tag to deploy (e.g., v1.2.3)"
required: true
type: string # => Free-text input with validation pattern below
dry-run:
description: "Perform a dry run without making changes"
required: false
type: boolean # => Renders as a checkbox in GitHub UI
default: true # => Safe default: don't actually deploy without confirming
rollback-to:
description: "Previous version to rollback to (leave empty for forward deploy)"
required: false
type: string
jobs:
validate:
runs-on: ubuntu-latest
steps:
- name: Validate version format
run: |
VERSION="${{ inputs.version }}"
if ! echo "$VERSION" | grep -qE '^v[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "Error: Version must match pattern v1.2.3"
exit 1
fi
# => Server-side validation since workflow_dispatch has no regex input type
# => Fail early before any infrastructure changes are made
deploy:
needs: validate
runs-on: ubuntu-latest
environment: ${{ inputs.environment }}
# => Dynamically selects environment from input
# => Triggers environment protection rules (required approvals)
steps:
- uses: actions/checkout@v4
with:
ref: ${{ inputs.version }}
# => Checks out the specific version tag
# => inputs.version is "v1.2.3" as entered by the operator
- name: Deploy or dry run
run: |
if [[ "${{ inputs.dry-run }}" == "true" ]]; then
echo "DRY RUN: Would deploy ${{ inputs.version }} to ${{ inputs.environment }}"
./deploy.sh --dry-run --env=${{ inputs.environment }} --version=${{ inputs.version }}
else
echo "DEPLOYING: ${{ inputs.version }} to ${{ inputs.environment }}"
./deploy.sh --env=${{ inputs.environment }} --version=${{ inputs.version }}
fi
# => inputs.dry-run is the string "true" or "false"
# => Boolean inputs are stringified in expressions
- name: Notify deployment
if: inputs.dry-run != true
# => In if: expressions, boolean inputs are actual booleans
# => In ${{ }} expressions, they are strings "true"/"false"
run: |
gh release view ${{ inputs.version }} --json body | \
jq -r '.body' | \
./scripts/notify-slack.sh "${{ inputs.environment }}"
env:
GH_TOKEN: ${{ github.token }}Key Takeaway: workflow_dispatch inputs support choice, string, and boolean types. Boolean inputs are strings ("true"/"false") inside ${{ }} expressions but actual booleans inside if: conditions. Always validate string inputs server-side since no regex input type exists.
Why It Matters: Operational runbooks encoded as workflow_dispatch workflows solve the documentation rot problem: runbook instructions in Confluence or Notion drift from the actual deployment scripts they describe. When the workflow IS the runbook, the documentation and the implementation are the same artifact. The input form with a boolean dry-run defaulting to true implements a safety gate that prevents accidental production deployments — an operator must consciously uncheck the dry-run box, creating a moment of intentionality before irreversible infrastructure changes. This pattern satisfies change management requirements while remaining faster than ticket-based approval processes.
Example 76: Caching Build Outputs for Incremental Compilation
Beyond dependency caching, some build systems support incremental compilation where only changed files are recompiled. Go, Rust, and Gradle support this through build caches that persist between runs.
name: Incremental Build Cache
on: [push, pull_request]
jobs:
go-incremental:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache:
true # => Caches $GOPATH/pkg/mod (module download cache)
# => Does NOT cache build outputs by default
- name: Cache Go build cache
uses: actions/cache@v4
with:
path: |
~/.cache/go-build
# => Go's build cache: compiled packages and test binaries
# => Cache hits mean unchanged packages are not recompiled
key: go-build-${{ runner.os }}-${{ github.sha }}
# => Exact key changes every commit (unique build per SHA)
restore-keys: |
go-build-${{ runner.os }}-
# => Restore previous commit's build cache as starting point
# => Go only recompiles packages whose source changed
- run: go build ./...
# => First run: compiles everything, populates ~/.cache/go-build
# => Subsequent runs: only recompiles changed packages (cache hits)
rust-incremental:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/cache@v4
with:
path: |
~/.cargo/registry
~/.cargo/git
target/
# => target/ is Rust's build output directory
# => Contains compiled .rlib files and test binaries
# => Can be large (1-5 GB); use exact SHA key with OS restore-key
key: rust-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}-${{ github.sha }}
restore-keys: |
rust-${{ runner.os }}-${{ hashFiles('**/Cargo.lock') }}-
rust-${{ runner.os }}-
# => Three-level fallback: exact build, same deps, same OS
- run: cargo build --release
# => --release enables optimizations; cached in target/release/
env:
CARGO_INCREMENTAL: "1"
# => Enables Cargo's incremental compilation
# => Default: enabled in debug, disabled in release
# => Enable explicitly for CI to benefit from cached artifacts
gradle-build-cache:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
with:
java-version: "21"
distribution: "temurin"
- uses: gradle/actions/setup-gradle@v4
# => Official Gradle action handles caching automatically
# => Caches ~/.gradle/wrapper, ~/.gradle/caches, and build outputs
# => Wraps actions/cache with Gradle-specific key logic
- run: ./gradlew build
# => Gradle's build cache skips tasks whose inputs haven't changed
# => gradle/actions/setup-gradle stores build cache between runsKey Takeaway: Incremental build caches (Go ~/.cache/go-build, Rust target/, Gradle build cache) reduce compilation time on unchanged code by 60-80%. Use restore-keys with SHA-based exact keys so each run starts from the previous run’s cached artifacts.
Why It Matters: Modern language build tools are designed for incremental compilation but only benefit from it when the build cache persists between invocations. GitHub-hosted runners start fresh every run, discarding the build cache that local development benefits from automatically. For Rust projects with 200+ crates, cold compilation takes 20-30 minutes; warm incremental builds take 2-3 minutes. Caching target/ in GitHub Actions brings CI build times within striking distance of local development speeds, shortening the feedback loop that determines whether developers run tests before pushing or defer them to CI.
Example 77: Artifact Attestation and SLSA Provenance
Software supply chain security requires verifiable build provenance. GitHub Actions integrates with Sigstore’s cosign to generate cryptographically signed SLSA (Supply-chain Levels for Software Artifacts) provenance attestations that consumers can verify independently.
name: Build and Attest
on:
push:
tags:
- "v*" # => Only create attestations on version tags
permissions:
contents: read
id-token: write # => Required for Sigstore OIDC signing
attestations: write # => Required to write attestations to GitHub
jobs:
build-and-attest:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build release binary
run: |
go build -o my-app-linux-amd64 ./cmd/my-app
# => Builds the binary with default Go toolchain
sha256sum my-app-linux-amd64 > my-app-linux-amd64.sha256
# => Generate checksum for verification
- name: Attest build provenance
uses: actions/attest-build-provenance@v2
id: attest
with:
subject-path: my-app-linux-amd64
# => The artifact to attest
# => Supports globs: dist/** for multiple files
- name: Upload binary with attestation
uses: actions/upload-artifact@v4
with:
name: my-app-linux-amd64
path: my-app-linux-amd64
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: |
my-app-linux-amd64
my-app-linux-amd64.sha256
generate_release_notes: true
# => Generates release notes from merged PRs since last tag
# => Requires pull-requests: read permissionVerification command (run by artifact consumer):
gh attestation verify my-app-linux-amd64 \
--repo my-org/my-repo
# => Verifies the artifact matches its attestation
# => Checks: correct repository, correct workflow, correct branch
# => Output: "Attestation verified. Artifact was produced by workflow..."
# => Fails if binary was modified after build or not built by expected workflowKey Takeaway: actions/attest-build-provenance requires id-token: write and attestations: write permissions. Attestations are stored in GitHub’s artifact attestation service, and consumers verify with gh attestation verify <artifact> --repo <owner>/<repo>.
Why It Matters: The SolarWinds and Log4Shell incidents demonstrated that attackers compromise build pipelines to inject malicious code into software that organizations trust and deploy. SLSA provenance attestation provides cryptographic proof that a binary was built by a specific workflow at a specific commit — proof that cannot be forged without compromising GitHub’s OIDC infrastructure. Organizations that distribute tooling to downstream users (CLI tools, SDKs, container images) can now offer verifiable supply chain integrity: consumers run a single gh attestation verify command to confirm they have an unmodified artifact from the expected build pipeline before deploying it to production.
Example 78: Large Runner Features and GPU Workflows
GitHub offers larger hosted runners (up to 64 vCPU, 256 GB RAM) and GPU-enabled runners for machine learning workloads. These runners support workflows that exceed standard runner constraints without the operational overhead of self-hosted infrastructure.
name: ML Training Workflow
on:
push:
branches: [main]
paths: ["models/**", "training/**"]
jobs:
benchmark:
runs-on: ubuntu-latest-4-cores
# => 4 vCPU, 16 GB RAM GitHub-hosted runner
# => Naming: ubuntu-latest-{N}-cores where N is 4, 8, 16, 32, 64
# => Requires Team or Enterprise plan
steps:
- uses: actions/checkout@v4
- name: Run parallel benchmark
run: |
make benchmark PARALLEL=4
# => Benchmark leverages all 4 cores
# => Standard ubuntu-latest has only 2 cores
train-model:
runs-on: ubuntu-latest-gpu-t4-2
# => 2x NVIDIA T4 GPU runner (GitHub-hosted)
# => Available in public beta for Enterprise accounts
# => T4 GPUs ideal for inference and small training runs
steps:
- uses: actions/checkout@v4
- name: Verify GPU
run: |
nvidia-smi --query-gpu=name,memory.total --format=csv
# => Output: Tesla T4, 15109 MiB (per GPU)
- uses: actions/setup-python@v5
with:
python-version: "3.11"
- name: Install PyTorch with CUDA
run: |
pip install torch torchvision --index-url https://download.pytorch.org/whl/cu121
# => cu121 = CUDA 12.1 build of PyTorch
# => Must match the CUDA version installed on the GPU runner
- name: Train model
run: python training/train.py --epochs 10 --device cuda
# => Model training runs on GPU
# => --device cuda selects GPU automatically
env:
WANDB_API_KEY: ${{ secrets.WANDB_API_KEY }}
# => Weights & Biases experiment tracking
large-build:
runs-on: ubuntu-latest-16-cores
# => 16 vCPU, 64 GB RAM for memory-intensive builds
steps:
- uses: actions/checkout@v4
- name: Build large Docker image
uses: docker/build-push-action@v6
with:
context: .
push: false
cache-from: type=gha
cache-to: type=gha,mode=max
build-args: |
PARALLELISM=16
# => Use all 16 cores for multi-stage build parallelismKey Takeaway: Large runners use labels like ubuntu-latest-4-cores and ubuntu-latest-gpu-t4-2. GPU runners require CUDA-compatible library versions matching the runner’s installed CUDA toolkit. Large runners require Team or Enterprise plans.
Why It Matters: Standard 2-core GitHub-hosted runners cannot effectively run machine learning training, large Docker builds, or parallel test suites without timing out or running out of memory. Teams that use self-hosted GPU clusters for CI face significant infrastructure overhead: runner registration, security patching, instance scheduling, and cost management. GitHub-hosted large runners eliminate this operational burden while providing predictable, ephemeral environments — each run starts on a fresh instance with no state contamination from previous runs, which is impossible to guarantee with shared self-hosted GPU infrastructure without complex image management.
Example 79: Concurrency Control and Workflow Cancellation
The concurrency key prevents redundant workflow runs by cancelling in-progress runs when new ones start, or queuing them to run sequentially. This pattern is essential for deployment workflows that must not run in parallel.
name: Deploy
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency: # => Workflow-level concurrency control
group: deploy-${{ github.workflow }}-${{ github.ref }}
# => Group key: all runs for same workflow + branch share this group
# => github.workflow is the workflow name ("Deploy")
# => github.ref is "refs/heads/main" or "refs/pull/123/merge"
# => Runs on different branches get different groups (no cross-branch cancellation)
cancel-in-progress: true
# => When new run starts, cancel any in-progress run in the same group
# => For deploy: prevents parallel deployments to same environment
# => For PR CI: cancels previous CI run when new commit is pushed
jobs:
deploy-staging:
runs-on: ubuntu-latest
concurrency: # => Job-level concurrency (more granular than workflow-level)
group: staging-environment
# => Single group for all staging deployments regardless of branch
# => Ensures only one deployment to staging at a time
cancel-in-progress: false
# => false: queue instead of cancel (sequential deploys)
# => true would cancel the in-progress deploy, leaving staging half-deployed
steps:
- run: echo "Deploying to staging..."
- run: ./deploy.sh staging
- run: echo "Staging deployment complete"
deploy-production:
needs: deploy-staging
runs-on: ubuntu-latest
concurrency:
group: production-environment
# => Separate group from staging (staging and production can deploy in parallel)
cancel-in-progress: false
# => Never cancel in-progress production deployments
# => Queue the next deployment until current completes safely
environment: production
steps:
- run: ./deploy.sh production
lint-and-test:
runs-on: ubuntu-latest
concurrency:
group: pr-checks-${{ github.ref }}
cancel-in-progress: true
# => Cancel previous lint run when new commit pushed to same PR
# => Results from old commit are irrelevant once new commit arrives
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run lint && npm run testKey Takeaway: Set cancel-in-progress: true for CI jobs (cancel old results when new commits arrive) and cancel-in-progress: false for deployment jobs (queue deployments to prevent partial states). Workflow-level concurrency uses ${{ github.ref }} to scope cancellation per branch.
Why It Matters: Without concurrency control, a rapid succession of commits to a busy repository generates a queue of CI runs that consume runner minutes on results developers no longer care about. On a busy main branch, 10 commits in 5 minutes generate 10 concurrent CI runs — most finishing after the 10th run already shows the final state. Cancelling superseded runs reduces wasted compute by 60-80% during active development sprints. For deployment workflows, the opposite applies: cancel-in-progress: false with queuing prevents the database migration race condition where two simultaneous deploys run the same schema migration twice, causing data corruption or deployment failures.
Example 80: Status Checks and Branch Protection Integration
Workflows create status checks that branch protection rules enforce. Required status checks prevent merging PRs until specific workflow jobs succeed, creating a quality gate that scales across hundreds of developers without manual oversight.
name: Required Checks
on:
pull_request:
types: [opened, synchronize, reopened]
branches: [main, "release/**"]
jobs:
# Each job creates a separate status check named after the job
# Branch protection rules reference jobs by their exact name
unit-tests:
name: "Unit Tests / Node ${{ matrix.node }}"
# => name: overrides the displayed status check name
# => Matrix creates multiple checks: "Unit Tests / Node 18", "Unit Tests / Node 20"
runs-on: ubuntu-latest
strategy:
matrix:
node: ["18", "20"]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
- run: npm ci && npm test
type-check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run typecheck
security-audit:
runs-on: ubuntu-latest
continue-on-error: true
# => continue-on-error: true marks check as neutral on failure (not required)
# => Job still runs but branch protection does not block merge on failure
# => Use for informational checks (security advisories, experimental linters)
steps:
- uses: actions/checkout@v4
- run: npm audit --audit-level=critical
# => Only fail on critical CVEs; lower severity advisory only
all-checks-pass:
name: "All Required Checks"
# => Sentinel job: branch protection requires only THIS job
# => When required jobs change, only update this job's needs list
# => Avoids updating branch protection rules in GitHub Settings
needs: [unit-tests, type-check]
# => Does NOT include security-audit (it's informational)
if: always()
# => always() ensures this runs even when needed jobs were skipped
runs-on: ubuntu-latest
steps:
- name: Check all required jobs passed
run: |
if [[ "${{ contains(needs.*.result, 'failure') }}" == "true" ]]; then
echo "Required checks failed"
exit 1
fi
if [[ "${{ contains(needs.*.result, 'cancelled') }}" == "true" ]]; then
echo "Required checks were cancelled"
exit 1
fi
echo "All required checks passed"
# => Passes only if no needed job failed or was cancelled
# => Skipped jobs (due to path filters) do not cause failureKey Takeaway: Use a sentinel job (all-checks-pass) that aggregates required checks via needs:. Branch protection requires only this sentinel, decoupling the branch protection configuration from the individual job names that may change. Use continue-on-error: true for informational (non-blocking) checks.
Why It Matters: Branch protection with required status checks is the primary quality gate preventing broken code from reaching main. Without it, a single developer merging before CI finishes cascades into broken main branches that block the entire team. The sentinel pattern (all-checks-pass) solves a practical maintenance problem: GitHub branch protection rules reference specific job names by string, and renaming a required job breaks protection rules silently — the renamed check no longer satisfies the requirement but GitHub doesn’t warn you. By requiring only the sentinel, teams add, rename, or remove individual checks by updating needs: in the workflow, not by editing branch protection rules in the repository settings.
Example 81: Debugging Workflows with tmate and Step Summaries
When workflows fail for non-obvious reasons, tmate provides an interactive SSH session into the runner, and step summaries write structured diagnostic output to the workflow run’s summary page.
name: Debug-Enabled CI
on: [push, pull_request]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run tests
id: tests
run: npm ci && npm test
continue-on-error: true
# => Allow test failures without stopping the next debugging step
- name: Setup tmate for interactive debugging
uses: mxschmitt/action-tmate@v3
if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main'
# => Only open debug session on main branch push failures (not PRs)
# => Prevents exposing runner to PR authors from forks
# => tmate provides SSH access to the runner for 5 minutes
with:
limit-access-to-actor: true
# => Only the workflow trigger actor can SSH in
# => Without this, any team member could access the runner
timeout-minutes: 30
# => Maximum time the SSH session stays open
# => Runner terminates after timeout
- name: Write test summary
if: always()
# => always() ensures summary is written even on failure
run: |
echo "## Test Results" >> "$GITHUB_STEP_SUMMARY"
# => GITHUB_STEP_SUMMARY is a Markdown file rendered on the run page
# => Appending to it accumulates output from multiple steps
echo "| Status | Count |" >> "$GITHUB_STEP_SUMMARY"
echo "|--------|-------|" >> "$GITHUB_STEP_SUMMARY"
PASSED=$(npm test -- --reporter=json 2>/dev/null | jq '.numPassedTests // 0')
FAILED=$(npm test -- --reporter=json 2>/dev/null | jq '.numFailedTests // 0')
# => Parses Jest JSON output for test counts
echo "| Passed | $PASSED |" >> "$GITHUB_STEP_SUMMARY"
echo "| Failed | $FAILED |" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [[ "${{ steps.tests.outcome }}" == "failure" ]]; then
echo "### Failure Summary" >> "$GITHUB_STEP_SUMMARY"
echo ":x: Tests failed. Check logs above for details." >> "$GITHUB_STEP_SUMMARY"
# => steps.<id>.outcome is 'success', 'failure', 'cancelled', 'skipped'
fi
- name: Annotate failing tests
if: failure()
uses: actions/github-script@v7
with:
script: |
await github.rest.checks.create({
owner: context.repo.owner,
repo: context.repo.repo,
name: 'Test Annotations',
head_sha: context.sha,
status: 'completed',
conclusion: 'failure',
output: {
title: 'Test failures detected',
summary: 'Some tests failed. See annotations below.',
annotations: [{
path: 'src/utils.test.js',
start_line: 42,
end_line: 42,
annotation_level: 'failure',
message: 'Expected 42, received 43',
title: 'assertEquals failed'
}]
// => Annotations appear as inline comments in the PR diff view
// => annotation_level: 'failure', 'warning', 'notice'
}
});Key Takeaway: GITHUB_STEP_SUMMARY creates Markdown content on the workflow run page. tmate with limit-access-to-actor: true provides safe interactive debugging on private branches. Check annotations via the Checks API appear inline in PR diffs.
Why It Matters: Workflow failures that only reproduce in CI (not locally) are the most time-consuming class of CI issues because the standard debugging loop is edit → commit → push → wait 10 minutes → read logs → repeat. tmate breaks this loop by giving developers a terminal inside the exact runner environment where the failure occurred, reducing “why does this only fail in CI” debugging from hours to minutes. Step summaries address the opposite problem: passing workflows that need to communicate rich diagnostic output (test coverage, benchmark comparisons, security scan results) beyond the binary pass/fail status that logs convey.
Example 82: Calling GitHub REST API with Pagination
The GitHub API returns paginated responses for list endpoints. Workflows that process all items (repositories, issues, artifacts) must handle pagination using Link headers or octokit auto-pagination.
name: Stale Issue Cleanup
on:
schedule:
- cron: "0 0 * * 0" # => Every Sunday at midnight UTC
jobs:
close-stale:
runs-on: ubuntu-latest
permissions:
issues: write # => Required to close and comment on issues
steps:
- name: Process all stale issues
uses: actions/github-script@v7
with:
script: |
const thirtyDaysAgo = new Date();
thirtyDaysAgo.setDate(thirtyDaysAgo.getDate() - 30);
// => Calculate cutoff date: 30 days before today
// Auto-pagination: Octokit fetches ALL pages automatically
const issues = await github.paginate(
github.rest.issues.listForRepo,
// => paginate() wraps the API method with pagination logic
// => Automatically follows Link: <url>; rel="next" headers
{
owner: context.repo.owner,
repo: context.repo.repo,
state: 'open',
labels: 'needs-response',
per_page: 100,
// => per_page: 100 is the maximum GitHub allows
// => paginate() fetches additional pages until no 'next' link
}
);
// => issues is the FLAT array of all items across all pages
// => Not wrapped in {data: ...}; paginate unwraps each page's data
core.info(`Found ${issues.length} issues with 'needs-response' label`);
// => core.info logs to the step output (not workflow summary)
let closedCount = 0;
for (const issue of issues) {
const updatedAt = new Date(issue.updated_at);
// => issue.updated_at is ISO 8601 string: "2024-01-15T10:30:00Z"
if (updatedAt < thirtyDaysAgo) {
// => Issue hasn't been updated in 30+ days
await github.rest.issues.createComment({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
body: 'Closing due to 30 days of inactivity. Reopen if still relevant.'
});
// => Post closing comment before closing for transparency
await github.rest.issues.update({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issue.number,
state: 'closed',
state_reason: 'not_planned'
// => state_reason: 'completed', 'not_planned', null
// => 'not_planned' is for stale/abandoned issues
});
// => Closes the issue
closedCount++;
}
}
core.summary.addHeading('Stale Issue Cleanup Results', 2);
core.summary.addTable([
[{data: 'Metric', header: true}, {data: 'Value', header: true}],
['Issues scanned', String(issues.length)],
['Issues closed', String(closedCount)],
]);
await core.summary.write();
// => core.summary API writes structured output to GITHUB_STEP_SUMMARY
// => addHeading, addTable, addRaw provide Markdown building blocksKey Takeaway: Use github.paginate(github.rest.<endpoint>, params) to fetch all pages automatically. core.summary.addTable() writes structured Markdown to the step summary. The paginate() method returns a flat array, unlike direct API calls that return { data: [...] }.
Why It Matters: GitHub API list endpoints return at most 100 items per page. Workflows that naively call listForRepo without pagination silently miss items beyond the first page — a bug that’s invisible when the repository has fewer than 100 issues but causes data loss at scale. Auto-pagination with Octokit’s paginate() handles this correctly without manual cursor tracking. The pattern applies to any large-scale GitHub maintenance automation: artifact cleanup, repository audit, contributor statistics, dependency graph analysis — all require iterating over collections too large for a single API response.
Example 83: Workflow Job Dependencies and Fan-Out/Fan-In Patterns
Complex CI pipelines fan out to parallel jobs and fan in to aggregate results. The needs array controls dependency ordering, and always() with result inspection ensures aggregation runs correctly when some upstream jobs were conditionally skipped.
%% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
A["checkout<br/>clone code"]
B["lint<br/>code style"]
C["unit-tests<br/>fast feedback"]
D["build<br/>compile artifact"]
E["integration-tests<br/>slow, real DB"]
F["security-scan<br/>SAST analysis"]
G["aggregate<br/>fan-in gate"]
H["deploy<br/>production"]
A --> B
A --> C
A --> D
D --> E
D --> F
B --> G
C --> G
E --> G
F --> G
G --> H
style A fill:#0173B2,stroke:#000,color:#fff
style B fill:#DE8F05,stroke:#000,color:#fff
style C fill:#DE8F05,stroke:#000,color:#fff
style D fill:#DE8F05,stroke:#000,color:#fff
style E fill:#029E73,stroke:#000,color:#fff
style F fill:#029E73,stroke:#000,color:#fff
style G fill:#CC78BC,stroke:#000,color:#fff
style H fill:#CA9161,stroke:#000,color:#fff
name: Full CI Pipeline
on:
push:
branches: [main]
jobs:
checkout: # => Shared artifact upload to avoid re-cloning in each job
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/upload-artifact@v4
with:
name: source-${{ github.sha }}
path: .
lint:
needs: checkout
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: source-${{ github.sha }}
- run: npm ci && npm run lint
unit-tests:
needs: checkout
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: source-${{ github.sha }}
- run: npm ci && npm test
build:
needs: checkout
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: source-${{ github.sha }}
- run: npm ci && npm run build
- uses: actions/upload-artifact@v4
with:
name: dist-${{ github.sha }}
path: dist/
integration-tests:
needs: build # => Requires compiled output from build job
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_PASSWORD: test
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
# => Service container runs alongside the job
# => Health check ensures PostgreSQL is ready before steps run
steps:
- uses: actions/download-artifact@v4
with:
name: source-${{ github.sha }}
- run: npm ci && npm run test:integration
env:
DATABASE_URL: postgresql://postgres:test@localhost:5432/test
# => localhost connects to the service container
# => Services are accessible via localhost on the runner
security-scan:
needs: build
runs-on: ubuntu-latest
continue-on-error: true # => Don't block deployment for advisory-level findings
steps:
- uses: actions/download-artifact@v4
with:
name: source-${{ github.sha }}
- run: npm audit
aggregate: # => Fan-in: waits for all parallel jobs, checks results
needs: [lint, unit-tests, integration-tests, security-scan]
if: always() # => Run even if some upstream jobs were skipped
runs-on: ubuntu-latest
steps:
- name: Check job results
run: |
LINT="${{ needs.lint.result }}"
UNIT="${{ needs.unit-tests.result }}"
INTEGRATION="${{ needs.integration-tests.result }}"
# => needs.<job-id>.result is 'success', 'failure', 'cancelled', 'skipped'
if [[ "$LINT" == "failure" || "$UNIT" == "failure" || "$INTEGRATION" == "failure" ]]; then
echo "Required jobs failed: lint=$LINT unit=$UNIT integration=$INTEGRATION"
exit 1
# => Fail the aggregate job to block downstream deployment
fi
echo "All required checks passed"
# => security-scan can fail (continue-on-error) without blocking here
deploy:
needs: aggregate # => Only deploys when aggregate passes
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/download-artifact@v4
with:
name: dist-${{ github.sha }}
- run: ./deploy.shKey Takeaway: Fan-out by listing independent jobs with the same needs: [checkout]. Fan-in with an aggregate job using if: always() that inspects needs.<job-id>.result to distinguish failures from skips. Service containers in services: are accessible at localhost from job steps.
Why It Matters: The fan-out/fan-in pattern reduces total CI time from the sum of all job durations to the duration of the longest parallel path. A pipeline with lint (2 min), unit tests (5 min), and build (3 min) running sequentially takes 10 minutes; running in parallel takes 5 minutes — a 50% reduction without changing any of the underlying tooling. The aggregate pattern with explicit result inspection handles the tricky edge case of skipped jobs: a skipped job (due to if: false) returns skipped not success, so a naive check for “all succeeded” incorrectly fails when optional jobs are legitimately skipped. The explicit failure/cancellation checks allow skips while blocking on actual failures.
Example 84: OpenID Connect Token Claims and Advanced Trust Policies
Beyond basic repository-level OIDC federation, GitHub’s JWT token includes rich claims that cloud provider trust policies can use for fine-grained authorization — restricting deployment by environment, job workflow, branch, tag pattern, and pull request status.
name: Multi-Environment OIDC Deploy
on:
push:
branches: [main, "release/**"]
tags: ["v*"]
permissions:
id-token: write
contents: read
jobs:
deploy-staging:
if: github.ref == 'refs/heads/main'
# => Only staging deploys from main branch pushes
runs-on: ubuntu-latest
environment: staging
steps:
- uses: actions/checkout@v4
- name: Decode OIDC token claims (for debugging)
run: |
TOKEN=$(curl -s -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=sts.amazonaws.com")
echo "$TOKEN" | jq -r '.value' | cut -d'.' -f2 | base64 -d | jq .
# => Decodes the JWT payload to inspect claims
# => Claims include: sub, aud, iss, ref, sha, repository, environment, job_workflow_ref
# => sub: "repo:my-org/my-repo:environment:staging"
# => job_workflow_ref: "my-org/my-repo/.github/workflows/deploy.yml@refs/heads/main"
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/staging-deploy-role
# => Role trust policy condition on environment:
# => "token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:environment:staging"
aws-region: us-east-1
- run: aws s3 sync dist/ s3://staging-bucket/
deploy-production:
if: startsWith(github.ref, 'refs/tags/v')
# => Production deploys only from version tags (v1.2.3 etc.)
runs-on: ubuntu-latest
environment: production
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/production-deploy-role
# => Role trust policy with multiple conditions:
# => sub: "repo:my-org/my-repo:environment:production"
# => job_workflow_ref matches specific workflow file
# => Prevents other workflows in same repo from assuming prod role
aws-region: us-east-1
- run: aws s3 sync dist/ s3://production-bucket/Advanced trust policy with job_workflow_ref (AWS IAM):
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com",
"token.actions.githubusercontent.com:environment": "production"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:*",
"token.actions.githubusercontent.com:job_workflow_ref": "my-org/my-repo/.github/workflows/deploy.yml@refs/tags/v*"
}
}
}Key Takeaway: The OIDC JWT sub claim includes the environment name when a job targets an environment (environment: in the job). The job_workflow_ref claim restricts which specific workflow file can assume a role, preventing other workflows in the same repository from escalating privileges.
Why It Matters: Basic OIDC trust policies that only check repository allow ANY workflow in your repository to assume any cloud role. A developer who adds a debug workflow or a malicious collaborator who opens a PR with a new workflow file could potentially exfiltrate production cloud credentials. The job_workflow_ref claim closes this gap: only the specific workflow file you designate can assume the production IAM role, and only when targeting the production environment. This satisfies the “separation of duty” control in cloud security frameworks where the same identity that writes code (developer) must not have unilateral ability to deploy to production.
Example 85: Complete Production Workflow — Integration of All Advanced Patterns
A production-grade workflow integrates multiple advanced patterns: OIDC authentication, reusable workflows, environment approvals, path filtering, concurrency control, artifact attestation, and step summaries. This example shows how the patterns compose.
name: Production CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read # => Workflow default: read-only
# => Individual jobs override with minimum required permissions
concurrency:
group: pipeline-${{ github.workflow }}-${{ github.ref }}
# => One active run per branch at a time
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
# => Cancel superseded PR runs; queue production pushes
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
backend: ${{ steps.filter.outputs.backend }}
frontend: ${{ steps.filter.outputs.frontend }}
infra: ${{ steps.filter.outputs.infra }}
steps:
- uses: actions/checkout@v4
- uses: dorny/paths-filter@v3
id: filter
with:
filters: |
backend: ['backend/**', 'shared/**']
frontend: ['frontend/**', 'shared/**']
infra: ['terraform/**', '.github/**']
build-backend:
needs: detect-changes
if: needs.detect-changes.outputs.backend == 'true'
uses: ./.github/workflows/reusable-build.yml
# => Reusable workflow handles build + test + artifact upload
with:
service: backend
node-version: "20"
secrets:
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}
permissions:
contents: read
packages: write # => Push to GitHub Container Registry
build-frontend:
needs: detect-changes
if: needs.detect-changes.outputs.frontend == 'true'
uses: ./.github/workflows/reusable-build.yml
with:
service: frontend
node-version: "20"
secrets: inherit
security-checks:
runs-on: ubuntu-latest
permissions:
security-events: write
contents: read
steps:
- uses: actions/checkout@v4
- uses: github/codeql-action/analyze@b56ba49b26a50fe1e099cd8c8a8cef41c0d2ebca
# => SHA-pinned third-party action
with:
category: "/language:javascript"
deploy-staging:
needs: [build-backend, build-frontend, security-checks]
if: |
always() &&
github.event_name == 'push' &&
!contains(needs.*.result, 'failure') &&
!contains(needs.*.result, 'cancelled')
# => always() + result checks handle skipped optional jobs correctly
# => Only deploy on push (not PR), only if no failures
runs-on: ubuntu-latest
environment: staging
# => Triggers staging environment protection rules
concurrency:
group: staging-deploy
cancel-in-progress: false
# => Queue staging deploys; never interrupt an in-progress deploy
permissions:
id-token: write # => OIDC for cloud authentication
contents: read
attestations: write # => Sign build artifacts
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.STAGING_ROLE_ARN }}
# => vars for non-sensitive configuration; secrets for sensitive
aws-region: us-east-1
role-session-name: staging-deploy-${{ github.run_id }}
# => Session name links CloudTrail entries to this specific run
- name: Deploy to staging
run: |
aws s3 sync dist/frontend s3://${{ vars.STAGING_FRONTEND_BUCKET }}/
aws ecs update-service \
--cluster staging \
--service backend \
--force-new-deployment
# => Triggers rolling ECS deployment with the new container image
- name: Write deployment summary
if: always()
run: |
echo "## Staging Deployment" >> "$GITHUB_STEP_SUMMARY"
echo "- **Commit**: ${{ github.sha }}" >> "$GITHUB_STEP_SUMMARY"
echo "- **Triggered by**: ${{ github.actor }}" >> "$GITHUB_STEP_SUMMARY"
echo "- **Time**: $(date -u +%Y-%m-%dT%H:%M:%SZ)" >> "$GITHUB_STEP_SUMMARY"
# => GITHUB_STEP_SUMMARY creates a persistent record on the run page
deploy-production:
needs: deploy-staging
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: production
# => production environment has required reviewers configured
# => Workflow PAUSES until an approved reviewer approves in GitHub UI
concurrency:
group: production-deploy
cancel-in-progress: false
permissions:
id-token: write
contents: read
attestations: write
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ vars.PRODUCTION_ROLE_ARN }}
# => Separate IAM role with production-only trust policy
# => job_workflow_ref claim restricts to this exact workflow file
aws-region: us-east-1
role-session-name: prod-deploy-${{ github.run_id }}
- name: Deploy to production
run: |
aws s3 sync dist/frontend s3://${{ vars.PRODUCTION_FRONTEND_BUCKET }}/ --delete
aws ecs update-service \
--cluster production \
--service backend \
--force-new-deployment
aws cloudfront create-invalidation \
--distribution-id ${{ vars.CF_DISTRIBUTION_ID }} \
--paths "/*"
- name: Attest deployment
uses: actions/attest-build-provenance@v2
# => Creates SLSA attestation for the deployed artifacts
with:
subject-path: dist/**
- name: Post-deployment smoke tests
run: |
sleep 30 # => Wait for ECS tasks to stabilize
curl --fail https://api.example.com/health
# => curl --fail returns exit code 1 on HTTP 4xx/5xx
# => Fails the step and marks deployment as failed if health check failsKey Takeaway: Production pipelines compose: path filtering triggers selective builds, reusable workflows handle build logic, if: always() && !contains(needs.*.result, 'failure') gates deployment on partial success, environment protection requires approvals, OIDC provides credentials, and step summaries create audit records.
Why It Matters: This pattern represents the maturation of CI/CD practices that distinguishes engineering organizations with reliable release cadences from those plagued by deployment incidents. Each individual pattern addresses a specific failure mode: path filtering prevents unnecessary rebuilds, concurrency control prevents deployment races, OIDC eliminates credential exposure, environment approvals enforce change management, and SHA-pinned actions prevent supply chain compromise. The sum of these practices — not any single one — is what enables teams to deploy to production dozens of times per day with confidence. Organizations that invest in this infrastructure reduce their mean time to recovery (MTTR) from hours to minutes because every deployment is auditable, reproducible, and reversible.