| name | github-actions-hardened |
| description | Generate production-hardened GitHub Actions CI/CD workflows enforcing least-privilege permissions, concurrency groups, timeout guards, dependency caching, and latest major version action tags. Use this skill — not github-actions-templates — whenever the user asks for "hardened", "secure", "production-ready", or "compliant" workflows, mentions security audits, or when quality and reliability matter. Always co-generates a .github/dependabot.yml. Trigger for any GitHub Actions request where quality or security posture matters. |
GitHub Actions Hardened Workflows
You are acting as a Staff DevOps Engineer. Every workflow you generate must enforce all five hardening principles below — no exceptions, no shortcuts.
When to Use This Skill
Prefer this skill over github-actions-templates when:
- The project will ship to production or is externally visible
- Security audits, SOC 2, or compliance are in scope
- The user asks for "hardened", "secure", or "production-ready" workflows
- Any third-party action is involved (which is almost always)
For quick throwaway prototypes or purely internal scripts, github-actions-templates is fine.
Five Hardening Principles
These aren't arbitrary rules — each addresses a real class of incident.
1. Job-Level Least-Privilege Permissions
GitHub Actions grants the GITHUB_TOKEN broad permissions by default. If a step in your workflow is compromised (e.g., a malicious npm postinstall script), it can use that token to push code, create releases, or exfiltrate secrets.
Set permissions explicitly at the job level (not the workflow level) so that each job gets only what it needs:
jobs:
test:
permissions:
contents: read
Common permission scopes:
contents: read — checkout code
contents: write — create releases, push tags
packages: write — push to GHCR
id-token: write — OIDC token for cloud auth or SLSA provenance
pull-requests: write — post PR comments
checks: write — post check run results
security-events: write — upload SARIF to Security tab
Never use permissions: write-all. Never omit permissions entirely on a job that uses GITHUB_TOKEN.
2. Latest Major Version Action Tags
Always pin actions to their latest major version tag (e.g., @v4). Never use @latest, @main, or @master — these are moving targets that can introduce breaking changes or security regressions without warning.
- uses: actions/checkout@latest
- uses: actions/checkout@main
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
Before writing any workflow, look up the current latest major version for each action you plan to use:
- With web/search access: search
github.com/<owner>/<repo>/releases for the latest tag
- With
gh CLI: gh api repos/<owner>/<repo>/releases/latest --jq .tag_name
- Without either: use the most recent major version you know confidently — do NOT guess minor/patch numbers. If uncertain, note it explicitly so the user can verify.
Dependabot (see below) will keep these tags current automatically.
3. Concurrency Groups
Without concurrency controls, every push to a PR branch queues a new run while the previous one is still in progress. The old run produces stale results, wastes runner minutes, and can cause race conditions in deployments.
Add this to every workflow:
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
The condition github.ref != 'refs/heads/main' is important: cancel redundant runs on PR branches, but never cancel a run that is already deploying to production.
4. Timeout Guards
GitHub's default job timeout is 360 minutes (6 hours). A test suite that hangs on network I/O, a Docker build stuck waiting for a layer, or a deployment waiting for a lock will consume 6 hours of runner minutes per occurrence — and on private repos, this directly costs money.
Always set timeout-minutes on every job:
jobs:
test:
timeout-minutes: 15
build:
timeout-minutes: 30
deploy:
timeout-minutes: 20
Choose values that are 2–3× your typical run time, not the absolute maximum.
5. Native Dependency Caching
Re-downloading all dependencies on every run is the single largest source of avoidable latency in most CI pipelines. Use the cache parameter built into setup actions when available:
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
For ecosystems without built-in cache support, use actions/cache with a meaningful key.
Hardened Workflow Patterns
Pattern A: CI Test Workflow (Node.js)
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
jobs:
test:
name: Test (Node ${{ matrix.node-version }})
runs-on: ubuntu-latest
timeout-minutes: 15
permissions:
contents: read
strategy:
fail-fast: false
matrix:
node-version: ['20', '22']
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
- name: Test
run: npm test -- --coverage
- name: Upload coverage
if: matrix.node-version == '20'
uses: codecov/codecov-action@v5
with:
fail_ci_if_error: true
For pnpm: change cache: 'npm' to cache: 'pnpm' and add pnpm/action-setup@v4 before setup-node with corepack enable. For yarn: change cache: 'npm' to cache: 'yarn'.
Pattern B: Docker Build and Push to GHCR
name: Docker Build and Push
on:
push:
branches: [main]
tags: ['v*']
pull_request:
branches: [main]
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: ${{ github.ref != 'refs/heads/main' }}
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
name: Build and Push
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: read
packages: write
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=sha,prefix=sha-
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
Pattern C: Tagged Release
name: Release
on:
push:
tags: ['v*.*.*']
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: false
jobs:
release:
name: Create Release
runs-on: ubuntu-latest
timeout-minutes: 20
permissions:
contents: write
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Build
run: |
# Replace with your actual build command
npm ci && npm run build
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
generate_release_notes: true
files: dist/**
Dependabot Configuration
Always co-generate this file alongside any workflow you create. Dependabot will automatically open PRs to update action versions when new major/minor/patch releases ship.
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
day: "monday"
groups:
github-actions:
patterns:
- "*"
commit-message:
prefix: "chore(ci)"
labels:
- "dependencies"
- "ci"
For extended variants (with reviewers, ignore patterns, multiple ecosystems), see references/dependabot-config.md.
Ecosystem Adaptation
Adapt Pattern A's Node.js CI to other ecosystems:
| Ecosystem | Setup Action | Cache Approach | Install | Test |
|---|
| Python | actions/setup-python@v5 | cache: 'pip' | pip install -r requirements.txt | pytest |
| Go | actions/setup-go@v5 | cache: true (built-in) | implicit | go test ./... |
| Rust | dtolnay/rust-toolchain@stable | actions/cache@v4 on ~/.cargo + ./target | implicit | cargo test |
| Java/Maven | actions/setup-java@v4 | cache: 'maven' | mvn -B install -DskipTests | mvn test |
| Java/Gradle | actions/setup-java@v4 | cache: 'gradle' | ./gradlew dependencies | ./gradlew test |
Common Anti-Patterns
permissions: write-all — Grants every possible scope. A compromised step could push malicious commits or delete releases. Always enumerate only what each job needs.
uses: action/foo@latest or @main — Moving targets that can silently introduce breaking changes or security regressions. Always pin to a major version tag.
No concurrency block — Queues redundant runs and produces stale status checks on PRs.
No timeout-minutes — Allows a hung job to consume 6 hours of runner capacity per occurrence.
Separate actions/cache step when the setup action has a cache: parameter — Redundant and error-prone. Use the built-in parameter where available.
run: echo "${{ github.event.pull_request.title }}" — Direct interpolation of untrusted input in run: blocks enables script injection. Always pass untrusted values through environment variables instead.