Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
Design, debug, and harden GitHub Actions CI/CD workflows, including reusable workflows, matrix builds, self-hosted runners, OIDC authentication, caching, environments, secrets, and release automation.
category
devops
risk
safe
source
community
date_added
2026-05-30
GitHub Actions Advanced Skill
Expert guidance for designing, writing, debugging, and securing production-grade GitHub Actions workflows.
When to Use This Skill
User mentions GitHub Actions, .github/workflows, CI/CD pipelines, runners, jobs, steps, or actions
User wants to automate builds, tests, deployments, or releases via GitHub
User asks about matrix builds, reusable workflows, composite actions, or self-hosted runners
User needs help with OIDC authentication, caching strategies, or secrets management
User says "my GitHub pipeline is failing" or "set up CI for my repo"
User asks about workflow security, hardening, or environment protection rules
When NOT to Use This Skill
The user is working with GitLab CI/CD → recommend gitlab-ci-patterns
The user is working with CircleCI, Jenkins, or other CI platforms
The task is purely about Docker image building without GitHub context → recommend docker-expert
The task is about Kubernetes deployment configuration → recommend kubernetes-architect
Step 1: Understand Context Before Responding
When invoked, first gather context:
# Discover existing workflows in the repo
find .github/workflows -name "*.yml" -o -name "*.yaml" 2>/dev/null | head -20
# Check for composite actions
find .github/actions -name "action.yml" 2>/dev/null
# Detect tech stack (influences runner OS, language setup actions)ls package.json requirements.txt Gemfile go.mod Cargo.toml pom.xml 2>/dev/null
Then adapt recommendations to:
Existing workflow patterns in the repo
The tech stack and language runtime
Whether this is a monorepo or single-project repo
Whether self-hosted or GitHub-hosted runners are in use
Workflow Structure Reference
name:WorkflowNameon:# Triggers (see Triggers section)push:branches: [main]
permissions:# Always declare — principle of least privilegecontents:readenv:# Workflow-level env varsNODE_VERSION:'20'concurrency:# Prevent duplicate runsgroup:${{github.workflow}}-${{github.ref}}cancel-in-progress:true# Cancel older runs for same branchjobs:job-id:name:Human-readablenameruns-on:ubuntu-24.04# Pin OS version — never use -latest in prodtimeout-minutes:15# Always set — prevents runaway jobsenvironment:production# Links to GitHub Environment (approvals/secrets)steps:-uses:actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683# v4.2.2-name:Stepnamerun:echo"hello"
Triggers (on:)
Common Patterns
on:push:branches: [main, 'release/**']
paths-ignore: ['**.md', 'docs/**'] # Skip docs-only changespull_request:types: [opened, synchronize, reopened]
branches: [main]
workflow_dispatch:# Manual trigger with inputsinputs:environment:description:'Deploy target'required:truetype:choiceoptions: [staging, production]
dry-run:description:'Dry run only?'type:booleandefault:falseschedule:-cron:'0 2 * * 1'# Monday 2am UTCworkflow_call:# Called by other workflows (reusable)inputs:image-tag:type:stringrequired:truesecrets:deploy-token:required:truerelease:types: [published] # Trigger only on published releasespull_request_target:# Runs with repo secrets — use with care!types: [labeled] # Gate with label + author_association check
Security Warning:pull_request_target runs with repo secrets. Only use after a maintainer labels the PR. Never check out fork code without explicit sandboxing.
Reusable Workflows
Split large pipelines into composable units stored in .github/workflows/.
Convention: Prefix internal/reusable workflows with _ (e.g., _build.yml).
-uses:docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75# v6.9.0with:cache-from:type=ghacache-to:type=gha,mode=max# For registry-backed cache (cross-branch):# cache-from: type=registry,ref=ghcr.io/myorg/myapp:buildcache# cache-to: type=registry,ref=ghcr.io/myorg/myapp:buildcache,mode=max
OIDC Authentication (Keyless Cloud Auth)
Never store long-lived cloud credentials as secrets. Use OIDC to get short-lived tokens that expire automatically.
AWS
permissions:id-token:writecontents:readsteps:-uses:aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502# v4.0.2with:role-to-assume:arn:aws:iam::123456789012:role/GitHubActionsRoleaws-region:us-east-1role-session-name:GitHubActions-${{github.run_id}}# Trust policy on the IAM role must include:# "token.actions.githubusercontent.com" as OIDC provider# Condition: "repo:org/repo:ref:refs/heads/main" (restrict to branch)
GCP (Workload Identity Federation)
permissions:id-token:writecontents:readsteps:-uses:google-github-actions/auth@6fc4af4b145ae7821d527454aa9bd537d1f2dc5f# v2.1.7with:workload_identity_provider:projects/123456789/locations/global/workloadIdentityPools/github-pool/providers/github-providerservice_account:github-actions@my-project.iam.gserviceaccount.comtoken_format:access_token# or 'id_token'
Azure (Federated Identity)
permissions:id-token:writecontents:readsteps:-uses:azure/login@a65d910e8af852a8061c627c456678983e180302# v2.2.0with:client-id:${{secrets.AZURE_CLIENT_ID}}tenant-id:${{secrets.AZURE_TENANT_ID}}subscription-id:${{secrets.AZURE_SUBSCRIPTION_ID}}# No client secret needed! Uses OIDC federated credentials
Environments & Deployment Protection
jobs:deploy-staging:environment:name:stagingurl:https://staging.myapp.comruns-on:ubuntu-24.04timeout-minutes:30steps:-run:./scripts/deploy.shstagingdeploy-production:needs:deploy-stagingenvironment:name:productionurl:https://myapp.com# Shown in the GitHub UI deployment panelruns-on:ubuntu-24.04timeout-minutes:30steps:-run:./scripts/deploy.shproduction
Configure in Settings → Environments:
Required reviewers — manual approval gate before run
Wait timer — delay after approval (e.g., 10-minute buffer)
Branch/tag restrictions — only main or v* tags can deploy to prod
Environment-specific secrets — override repo-level secrets per environment
Deployment branches — whitelist which branches can target this environment
-name:Generateandmaskdynamictokenrun:|
TOKEN=$(./scripts/generate-token.sh)
echo "::add-mask::$TOKEN" # Mask in all subsequent logs
echo "DEPLOY_TOKEN=$TOKEN" >> $GITHUB_ENV
Secrets in Composite Actions
# Secrets cannot be passed as inputs to composite actions# Pass them as env vars instead:-uses:./.github/actions/my-actionenv:SECRET_VALUE:${{secrets.MY_SECRET}}
Composite Actions
Package reusable step sequences into local actions. No container spin-up, no separate workflow file needed.
# Condition on branch + event-run:./scripts/deploy.shif:github.ref=='refs/heads/main'&&github.event_name=='push'# Continue on error (non-blocking steps)-run:./scripts/lint.shcontinue-on-error:true# Job dependency and conditional executionjobs:test:runs-on:ubuntu-24.04outputs:result:${{steps.run-tests.outcome}}deploy:needs: [test, build]
if:|
needs.test.result == 'success' &&
needs.build.result == 'success' &&
github.ref == 'refs/heads/main'
runs-on:ubuntu-24.04notify-failure:needs: [test, deploy]
if:failure()# Runs even if earlier jobs failruns-on:ubuntu-24.04steps:-run:./scripts/notify-slack.sh"Pipeline failed!"
Passing Data Between Jobs
jobs:prepare:runs-on:ubuntu-24.04outputs:version:${{steps.get-version.outputs.version}}should-deploy:${{steps.check.outputs.deploy}}steps:-id:get-versionrun:|
VERSION=$(tr -d '\r\n' < VERSION)
case "$VERSION" in
""|*[!0-9A-Za-z._-]*) echo "Invalid VERSION" >&2; exit 1 ;;
esac
printf 'version=%s\n' "$VERSION" >> "$GITHUB_OUTPUT"
-id:checkrun:|
if git log -1 --pretty=%B | grep -q '\[deploy\]'; then
echo "deploy=true" >> $GITHUB_OUTPUT
else
echo "deploy=false" >> $GITHUB_OUTPUT
fi
build:needs:prepareif:needs.prepare.outputs.should-deploy=='true'runs-on:ubuntu-24.04steps:-env:VERSION:${{needs.prepare.outputs.version}}run:echo"Building version $VERSION"
Security Hardening
1. Always Declare Permissions (Least Privilege)
# Workflow-level default — restrict everythingpermissions:contents:readjobs:publish:# Job-level override — only expand what's neededpermissions:contents:write# Only for release/publish jobspackages:write# Only for container push jobspull-requests:write# Only for PR comment jobsid-token:write# Only for OIDC auth jobs
2. Pin Third-Party Actions to Full Commit SHA
# ❌ UNSAFE — tag can be mutated or hijacked-uses:actions/checkout@v4# ✅ SAFE — commit SHA is immutable-uses:actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683# v4.2.2# Tool to automate SHA pinning:# npx pin-github-action .github/workflows/*.yml# or: pip install ratchet && ratchet pin .github/workflows/
3. Prevent Script Injection
# ❌ UNSAFE — attacker controls PR title, which gets expanded in shell-run:echo"${{ github.event.pull_request.title }}"# ✅ SAFE — pass through environment variable (shell doesn't evaluate it)-env:PR_TITLE:${{github.event.pull_request.title}}run:echo"$PR_TITLE"# ✅ SAFE — expressions in if: conditions are evaluated by Actions, not shell-if:github.event.pull_request.draft==falserun:echo"Not a draft"
Never place ${{ ... }} directly inside run: when the value can come from
PR metadata, workflow inputs, repository files, matrix JSON, or earlier job
outputs. Put it in env: first, validate allowlisted values where possible, and
reference the shell variable with quotes.
4. Restrict pull_request_target Usage
# Only run when a maintainer adds a specific label — prevents untrusted executionon:pull_request_target:types: [labeled]
jobs:validate:# Double-guard: check label name AND author_associationif:|
github.event.label.name == 'safe-to-test' &&
(github.event.pull_request.author_association == 'COLLABORATOR' ||
github.event.pull_request.author_association == 'MEMBER' ||
github.event.pull_request.author_association == 'OWNER')
5. Harden with StepSecurity
# Add to every workflow — hardens runner, monitors outbound traffic-uses:step-security/harden-runner@4d991eb9995541a0b71d1b66f1f98a5f1bef422c# v2.11.0with:egress-policy:audit# Start with 'audit', move to 'block' after confirming allowlistallowed-endpoints:>
api.github.com:443
registry.npmjs.org:443
objects.githubusercontent.com:443
Debugging Techniques
# Enable runner diagnostic logging via repo secrets:# ACTIONS_RUNNER_DEBUG = true# ACTIONS_STEP_DEBUG = true# Dump full GitHub context for inspection-name:Debug—dumpgithubcontextif:runner.debug=='1'env:GITHUB_CONTEXT:${{toJson(github)}}run:echo"$GITHUB_CONTEXT"|jq'.'# Dump all available contexts-name:Debug—dumpallcontextsif:runner.debug=='1'run:|
echo "github: ${{ toJson(github) }}"
echo "env: ${{ toJson(env) }}"
echo "vars: ${{ toJson(vars) }}"
echo "runner: ${{ toJson(runner) }}"
# SSH into a failing runner for interactive debugging-uses:mxschmitt/action-tmate@7b04f3521e6b0a9fc56fa8f9f50da4bcfb5fc7b5# v3.19.0if:failure()&&runner.debug=='1'with:limit-access-to-actor:true# Only the workflow triggerer can SSH intimeout-minutes:30# Check what's pre-installed on GitHub-hosted runners-run:|
echo "=== Tool Versions ==="
node --version
python3 --version
go version
docker --version
echo "=== Disk Space ==="
df -h
echo "=== Memory ==="
free -h
Complete Pipeline Patterns
Pattern 1: Build → Test → Push → Deploy
name:CI/CDPipelineon:push:branches: [main]
pull_request:branches: [main]
concurrency:group:${{github.workflow}}-${{github.ref}}cancel-in-progress:${{github.ref!='refs/heads/main'}}permissions:contents:readjobs:# ── Build & Test ──────────────────────────────────────build-test:runs-on:ubuntu-24.04timeout-minutes:20permissions:contents:readchecks:write# For test result reportingsteps:-uses:actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683# v4.2.2-uses:actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af# v4.1.0with:node-version:'20'cache:'npm'-run:npmci-run:npmrunlint-run:npmruntest----coverage-run:npmrunbuild-uses:actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882# v4.4.3with:name:build-artifactspath:dist/retention-days:7# ── Push Image (main branch only) ─────────────────────push-image:needs:build-testif:github.ref=='refs/heads/main'runs-on:ubuntu-24.04timeout-minutes:20permissions:contents:readpackages:writeid-token:write# For OIDCoutputs:image-digest:${{steps.push.outputs.digest}}steps:-uses:actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683# v4.2.2-uses:docker/setup-buildx-action@c47758b77c9736f4b2ef4073d4d51994fabfe349# v3.7.1-uses:docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567# v3.3.0with:registry:ghcr.iousername:${{github.actor}}password:${{secrets.GITHUB_TOKEN}}-uses:docker/metadata-action@70b2cdc6480c1a8b86edf1777157f8f437de2166# v5.5.1id:metawith:images:ghcr.io/${{github.repository}}tags:|
type=sha,format=long
type=raw,value=latest
-id:pushuses:docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75# v6.9.0with:context:.push:truetags:${{steps.meta.outputs.tags}}labels:${{steps.meta.outputs.labels}}cache-from:type=ghacache-to:type=gha,mode=maxprovenance:true# SLSA provenance attestationsbom:true# Software Bill of Materials# ── Deploy Staging ────────────────────────────────────deploy-staging:needs:push-imageruns-on:ubuntu-24.04timeout-minutes:30environment:name:stagingurl:https://staging.myapp.comsteps:-uses:actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683# v4.2.2-env:IMAGE_DIGEST:${{needs.push-image.outputs.image-digest}}run:./scripts/deploy.shstaging"$IMAGE_DIGEST"# ── Deploy Production (manual approval required) ──────deploy-production:needs:deploy-stagingruns-on:ubuntu-24.04timeout-minutes:30environment:name:productionurl:https://myapp.comsteps:-uses:actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683# v4.2.2-env:IMAGE_DIGEST:${{needs.push-image.outputs.image-digest}}run:./scripts/deploy.shproduction"$IMAGE_DIGEST"
Pattern 2: Automated Release with Changelog
name:Releaseon:push:tags: ['v[0-9]+.[0-9]+.[0-9]+']
permissions:contents:writejobs:release:runs-on:ubuntu-24.04timeout-minutes:15steps:-uses:actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683# v4.2.2with:fetch-depth:0# Full history needed for changelog generation-uses:softprops/action-gh-release@e7a8f85e1c67a31e6ed99a94b41bd0b71bbee6b8# v2.0.9with:generate_release_notes:true# Auto-generates from PR titles and commitsmake_latest:truefail_on_unmatched_files:truefiles:|
dist/**/*.tar.gz
dist/**/*.zip
Pattern 3: Dependency Auto-Update with PR
name:DependencyUpdateson:schedule:-cron:'0 9 * * 1'# Every Monday at 9am UTCworkflow_dispatch:permissions:contents:writepull-requests:writejobs:update-deps:runs-on:ubuntu-24.04timeout-minutes:20steps:-uses:actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683# v4.2.2-uses:actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af# v4.1.0with:node-version:'20'-run:npxnpm-check-updates-u-run:npminstall-uses:peter-evans/create-pull-request@5e914681df9dc83aa4e4905692ca88beb2f9e91f# v7.0.5with:commit-message:'chore: update npm dependencies'title:'chore: update npm dependencies'branch:'chore/npm-updates'delete-branch:truebody:|
Automated dependency updates generated by the dependency update workflow.
Please review and test before merging.
Use pull_request not pull_request_target; avoid repo secrets in fork context
Secret is *** in logs but exposed
Dynamic value not masked
Use echo "::add-mask::$VALUE" before using it
Cache never hits across branches
Cache key too specific
Add restore-keys fallback without branch or hash segment
Matrix job fails silently
fail-fast: true (default) cancels siblings
Set fail-fast: false during debugging
Job hangs indefinitely
No timeout-minutes set
Always set timeout-minutes on every job
$GITHUB_OUTPUT not set
Old set-output command used
Use echo "key=value" >> $GITHUB_OUTPUT
OIDC token request fails
Missing id-token: write permission
Add to job-level permissions block
Reusable workflow can't access caller secrets
No secrets: inherit
Add secrets: inherit or explicitly pass secrets
GitHub Actions Expressions Reference
# Context objects available in expressions${{github.sha}}# Commit SHA${{github.ref}}# Branch/tag ref${{github.ref_name}}# Short branch/tag name${{github.event_name}}# Event name (push, pull_request, etc.)${{github.actor}}# Username who triggered the run${{github.repository}}# org/repo${{github.run_id}}# Unique run ID${{runner.os}}# Linux, Windows, macOS# Built-in functions${{toJson(github)}}# Serialize context to JSON${{fromJson(needs.job.outputs.matrix)}}# Parse JSON string${{hashFiles('**/package-lock.json')}}# Hash file(s) for cache keys${{format('{0}/{1}',var1,var2)}}# String formatting${{join(matrix.items,',')}}# Join array# Status functions (use in if: conditions)${{success()}}# All previous steps succeeded${{failure()}}# Any previous step failed${{cancelled()}}# Workflow was cancelled${{always()}}# Always runs (success OR failure OR cancelled)
Production Readiness Checklist
Before merging any workflow to main, verify:
Security
All third-party actions pinned to full commit SHA
permissions: declared at workflow and job level (least privilege)
No ${{ }} expressions directly in run: blocks (use env vars)
OIDC used for cloud credentials (no long-lived secrets stored)
pull_request_target gated with label check + author_association guard
Secrets never echoed or logged
Reliability
timeout-minutes set on every job
fail-fast: false set for matrix builds used for debugging
concurrency configured to cancel stale runs
Retry logic for flaky external calls
Artifact retention policy set appropriately
Performance
Dependency caching configured (setup-* cache or actions/cache)
Docker layer caching enabled (type=gha)
Path filters on push/pull_request to skip unrelated changes