GitHub Actions Deep Dive: Beyond Basic CI
Advanced GitHub Actions patterns including matrix builds, reusable workflows, custom actions, secrets management, artifacts, caching, and deployment automation.
GitHub Actions Deep Dive: Beyond Basic CI
Most GitHub Actions workflows stop at "run tests on push." That is barely scratching the surface. Actions can build and deploy to multiple environments, run matrix tests across Node versions and operating systems, share workflows across repositories, manage secrets per environment, cache dependencies for faster builds, and create custom actions that encapsulate your team's patterns.
I run Actions on every project. The workflows in this guide are production patterns — not toy examples, but the configurations I use to ship software reliably.
Prerequisites
- A GitHub repository
- Basic YAML knowledge
- Understanding of CI/CD concepts
- Node.js project (for examples)
Workflow Structure
# .github/workflows/ci.yml
name: CI Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm test
Advanced Triggers
Filtered Triggers
on:
push:
branches: [main, 'release/**']
paths:
- 'src/**'
- 'tests/**'
- 'package.json'
paths-ignore:
- '**.md'
- 'docs/**'
pull_request:
types: [opened, synchronize, reopened]
branches: [main]
schedule:
- cron: '0 2 * * 1' # Monday at 2 AM UTC
workflow_dispatch:
inputs:
environment:
description: 'Deploy target'
required: true
default: 'staging'
type: choice
options:
- staging
- production
dry_run:
description: 'Dry run mode'
type: boolean
default: false
release:
types: [published]
Conditional Execution
jobs:
deploy:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
runs-on: ubuntu-latest
steps:
- run: echo "Deploying to production"
skip-draft:
if: "!github.event.pull_request.draft"
runs-on: ubuntu-latest
steps:
- run: echo "Only runs on non-draft PRs"
Matrix Builds
Test across multiple configurations simultaneously:
jobs:
test:
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest, macos-latest]
fail-fast: false # Continue other matrix jobs if one fails
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
Matrix Includes and Excludes
strategy:
matrix:
node-version: [18, 20, 22]
os: [ubuntu-latest, windows-latest]
include:
# Add extra config for specific combinations
- node-version: 22
os: ubuntu-latest
coverage: true
exclude:
# Skip Node 18 on Windows
- node-version: 18
os: windows-latest
steps:
- run: npm test
- run: npm run coverage
if: matrix.coverage == true
Caching
npm Cache
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm' # Built-in npm caching
Custom Caching
- uses: actions/cache@v4
id: npm-cache
with:
path: node_modules
key: ${{ runner.os }}-node-${{ hashFiles('package-lock.json') }}
restore-keys: |
${{ runner.os }}-node-
- run: npm ci
if: steps.npm-cache.outputs.cache-hit != 'true'
Multi-Path Caching
- uses: actions/cache@v4
with:
path: |
node_modules
~/.npm
.next/cache
key: ${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-${{ hashFiles('**/*.js', '**/*.jsx') }}
restore-keys: |
${{ runner.os }}-nextjs-${{ hashFiles('package-lock.json') }}-
${{ runner.os }}-nextjs-
Artifacts
Uploading Build Artifacts
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
retention-days: 7
Downloading in Another Job
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:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/download-artifact@v4
with:
name: dist
path: dist/
- run: ls -la dist/
- run: echo "Deploy dist/ to server"
Test Reports
- run: npm test -- --reporter=junit --outputFile=test-results.xml
- uses: actions/upload-artifact@v4
if: always() # Upload even if tests fail
with:
name: test-results
path: test-results.xml
Secrets and Environment Variables
Using Secrets
steps:
- run: npm run deploy
env:
API_KEY: ${{ secrets.API_KEY }}
DATABASE_URL: ${{ secrets.DATABASE_URL }}
Environments
jobs:
deploy-staging:
environment: staging
runs-on: ubuntu-latest
steps:
- run: echo "Deploying to ${{ vars.DEPLOY_URL }}"
env:
API_KEY: ${{ secrets.API_KEY }} # Staging-specific secret
deploy-production:
needs: deploy-staging
environment:
name: production
url: https://myapp.com
runs-on: ubuntu-latest
steps:
- run: echo "Deploying to production"
env:
API_KEY: ${{ secrets.API_KEY }} # Production-specific secret
Environments support:
- Environment-specific secrets and variables
- Required reviewers before deployment
- Wait timers
- Branch restrictions
OIDC for Cloud Authentication
permissions:
id-token: write
contents: read
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/github-actions
aws-region: us-east-1
# No AWS keys needed — uses OIDC federation
- run: aws s3 sync dist/ s3://my-bucket/
Job Dependencies and Outputs
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run lint
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci && npm test
build:
needs: [lint, test] # Wait for both to pass
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.value }}
steps:
- uses: actions/checkout@v4
- run: npm ci && npm run build
- id: version
run: echo "value=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- run: echo "Deploying version ${{ needs.build.outputs.version }}"
Reusable Workflows
Creating a Reusable Workflow
# .github/workflows/reusable-test.yml
name: Reusable Test Workflow
on:
workflow_call:
inputs:
node-version:
required: false
type: string
default: '20'
working-directory:
required: false
type: string
default: '.'
secrets:
npm-token:
required: false
jobs:
test:
runs-on: ubuntu-latest
defaults:
run:
working-directory: ${{ inputs.working-directory }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
cache-dependency-path: ${{ inputs.working-directory }}/package-lock.json
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.npm-token }}
- run: npm test
- run: npm run lint
Using a Reusable Workflow
# .github/workflows/ci.yml
name: CI
on: [push, pull_request]
jobs:
test-api:
uses: ./.github/workflows/reusable-test.yml
with:
working-directory: packages/api
node-version: '20'
test-web:
uses: ./.github/workflows/reusable-test.yml
with:
working-directory: packages/web
node-version: '20'
# Use from another repository
shared-checks:
uses: myorg/shared-workflows/.github/workflows/node-checks.yml@main
with:
node-version: '20'
secrets: inherit # Pass all secrets
Custom Actions
Composite Action
# .github/actions/setup-node-project/action.yml
name: 'Setup Node Project'
description: 'Install Node.js, restore cache, install dependencies'
inputs:
node-version:
description: 'Node.js version'
required: false
default: '20'
working-directory:
description: 'Working directory'
required: false
default: '.'
runs:
using: 'composite'
steps:
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
- uses: actions/cache@v4
id: cache
with:
path: ${{ inputs.working-directory }}/node_modules
key: ${{ runner.os }}-node-${{ inputs.node-version }}-${{ hashFiles(format('{0}/package-lock.json', inputs.working-directory)) }}
- run: npm ci
shell: bash
working-directory: ${{ inputs.working-directory }}
if: steps.cache.outputs.cache-hit != 'true'
Usage:
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/setup-node-project
with:
node-version: '20'
- run: npm test
JavaScript Action
// .github/actions/check-package-version/index.js
var core = require("@actions/core");
var fs = require("fs");
function run() {
try {
var packagePath = core.getInput("package-path") || "package.json";
var packageJson = JSON.parse(fs.readFileSync(packagePath, "utf-8"));
var version = packageJson.version;
core.info("Package version: " + version);
core.setOutput("version", version);
var parts = version.split(".");
core.setOutput("major", parts[0]);
core.setOutput("minor", parts[1]);
core.setOutput("patch", parts[2]);
// Check if version was bumped
var minVersion = core.getInput("min-version");
if (minVersion) {
var current = version.split(".").map(Number);
var minimum = minVersion.split(".").map(Number);
for (var i = 0; i < 3; i++) {
if (current[i] > minimum[i]) break;
if (current[i] < minimum[i]) {
core.setFailed("Version " + version + " is below minimum " + minVersion);
return;
}
}
}
} catch (error) {
core.setFailed(error.message);
}
}
run();
# .github/actions/check-package-version/action.yml
name: 'Check Package Version'
description: 'Read and validate package.json version'
inputs:
package-path:
description: 'Path to package.json'
default: 'package.json'
min-version:
description: 'Minimum allowed version'
required: false
outputs:
version:
description: 'Full version string'
major:
description: 'Major version number'
minor:
description: 'Minor version number'
patch:
description: 'Patch version number'
runs:
using: 'node20'
main: 'index.js'
Complete Working Example: Production CI/CD Pipeline
# .github/workflows/pipeline.yml
name: CI/CD Pipeline
on:
push:
branches: [main]
pull_request:
branches: [main]
release:
types: [published]
permissions:
contents: read
packages: write
env:
NODE_VERSION: '20'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run lint
test:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18, 20, 22]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm ci
- run: npm test
- uses: actions/upload-artifact@v4
if: matrix.node-version == 20 && always()
with:
name: test-results
path: coverage/
build:
needs: [lint, test]
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.value }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ env.NODE_VERSION }}
cache: 'npm'
- run: npm ci
- run: npm run build
- id: version
run: echo "value=$(node -p 'require(\"./package.json\").version')" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
with:
name: build
path: dist/
deploy-staging:
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
needs: build
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.myapp.com
steps:
- uses: actions/download-artifact@v4
with:
name: build
path: dist/
- run: echo "Deploying v${{ needs.build.outputs.version }} to staging"
- run: echo "Deploy commands here"
env:
DEPLOY_KEY: ${{ secrets.STAGING_DEPLOY_KEY }}
deploy-production:
if: github.event_name == 'release'
needs: build
runs-on: ubuntu-latest
environment:
name: production
url: https://myapp.com
steps:
- uses: actions/download-artifact@v4
with:
name: build
path: dist/
- run: echo "Deploying v${{ needs.build.outputs.version }} to production"
env:
DEPLOY_KEY: ${{ secrets.PRODUCTION_DEPLOY_KEY }}
Common Issues and Troubleshooting
Workflow does not trigger on expected event
The trigger conditions do not match or the workflow file has YAML errors:
Fix: Check paths filters — they are case-sensitive. Verify branch names match exactly. Check the Actions tab for workflow parse errors. Use workflow_dispatch to test triggers manually.
Cache not being restored
The cache key does not match any existing cache:
Fix: Check that hashFiles() targets the correct lock file path. Use restore-keys with progressively broader patterns as fallbacks. Cache is scoped to the branch — PR caches can restore from the default branch but not other PRs.
Secret is empty in the workflow
Secrets are not available in forked repository pull requests (security feature):
Fix: For pull requests from forks, secrets are intentionally unavailable. Use pull_request_target trigger (with caution) or run sensitive steps only on push to main. Check that the secret name matches exactly (case-sensitive).
Job times out or runs too long
Default timeout is 360 minutes. Complex builds or hanging processes consume minutes:
Fix: Set explicit timeouts: timeout-minutes: 15 on jobs or steps. Kill background processes in your scripts. Use --forceExit flags on test runners like Jest.
Best Practices
- Cache aggressively.
npm ciwith cachednode_modulestakes seconds instead of minutes. Useactions/cacheor the built-in cache option inactions/setup-node. - Use matrix builds for compatibility testing. Test across Node versions and operating systems in parallel. Use
fail-fast: falseto see all failures, not just the first. - Pin action versions to SHA hashes.
uses: actions/checkout@abc1234is more secure than@v4. Tags can be moved, but SHA hashes are immutable. - Separate build and deploy into different jobs. Build artifacts once and deploy to multiple environments. This saves time and ensures identical deployments.
- Use environments for deployment protection. Required reviewers, wait timers, and branch restrictions prevent accidental production deployments.
- Create reusable workflows for shared patterns. If multiple repos use the same CI steps, extract them into a shared workflow repository. Changes propagate automatically.
- Set explicit permissions. Use
permissions: contents: readinstead of the default write access. Follow the principle of least privilege.