GitHub Actions workflows
Author YAML with minimized permissions, pinned actions (commit SHA preferred), reusable workflows, and environment protection rules.
The SKILL defines on triggers, path filters, and concurrency group names; third-party actions need vendor review and prefer official or pinned versions.
When using GITHUB_TOKEN, explicitly downgrade defaults; for cross-repo or cloud access use OIDC with provider trust—avoid long-lived secrets.
Document when to split reusable workflows vs composite actions; self-hosted runner labels and isolation belong in the SKILL when applicable.
- Cache:
actions/cachekeys andrestore-keys—avoid bad hits. - Container jobs: image provenance and pull policy—align with Dockerfile guidance.
- Reuse: validate
workflow_callinputs and document defaults.
GHA main flow (skill-flow-block)
[ Event: push / PR / schedule / workflow_dispatch / workflow_call ]
│
▼
[ Match: workflow file + branch / paths / activity types ]
│
▼
[ Concurrency: group cancels or queues—watch deploy job races ]
│
▼
[ Job: runs-on, lowered permissions, environment, secrets ]
│
┌────────┴────────┐
▼ ▼
[ Inline steps: pinned uses + explicit with ] [ Reuse: workflow_call / composite / templates ]
│ │
└────────┬────────┘
▼
[ Outputs: artifacts, summaries, checks—on failure keep logs + repro commands ]
jobs.*.name, and unintended expansion of pull_request_target risk.
Reusable workflow comparison matrix
Compare common reuse patterns; click a row for SKILL talking points (local highlight only—no network).
| Pattern | Entry | Inputs / secrets | Permission boundary | Typical use |
|---|---|---|---|---|
| Reusable workflow | on.workflow_call, consumed via uses: org/repo/.github/workflows/x.yml@ref |
inputs / secrets explicit; optional outputs |
Caller and callee permissions are separate; cross-repo needs trusted ref + CODEOWNERS |
Shared CI or release templates across repos |
| Composite action | action.yml with runs.using: composite; uses: ./path or published action |
inputs; secrets from calling workflow |
Same actor as calling job—good for bundling shell, not whole pipelines | Repeated setup, toolchain install, cache wrappers |
| Docker / JS action | uses: owner/action@vX or SHA; optional docker:// |
with; sensitive via secrets |
Vendor review + SHA pins; watch post steps and cache side effects | Official or community single-purpose steps |
| Job matrix | strategy.matrix / fail-fast inside one workflow |
Matrix dims as env; secrets not auto-shared beyond declared scopes | Watch matrix explosion and minutes; split via workflow_call when huge | Multi language versions, OS grids, parameterized tests |
Selected: Click a row for SKILL emphasis.
Triggers, concurrency, and path filters
-
on: separatepaths/paths-ignorefor PR vs push; typedworkflow_dispatchinputs with defaults and descriptions. -
concurrency: group names include env/branch tokens so PRs don’t cancel each other; be careful withcancel-in-progresson deploy jobs. -
Callable workflows: validate
inputsenums; reject empty strings or dangerous default branch names.
Complete reusable workflow example with typed inputs, secrets, and outputs:
# .github/workflows/reusable-build.yml — reusable workflow
name: Reusable Build
on:
workflow_call:
inputs:
node-version:
description: 'Node.js version'
type: string
default: '20'
required: false
environment:
description: 'Target deployment environment'
type: string
required: true
secrets:
REGISTRY_TOKEN:
required: true
outputs:
image-tag:
description: 'Built image tag'
value: ${{ jobs.build.outputs.tag }}
jobs:
build:
runs-on: ubuntu-24.04
timeout-minutes: 20
outputs:
tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ inputs.node-version }}
cache: 'npm'
- name: Restore build cache
uses: actions/cache@v4
with:
path: |
.next/cache
node_modules/.cache
key: build-${{ runner.os }}-${{ inputs.node-version }}-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
build-${{ runner.os }}-${{ inputs.node-version }}-
build-${{ runner.os }}-
- run: npm ci --prefer-offline
- run: npm run build
- id: tag
run: |
echo "tag=$(node -p "require('./package.json').version")-${{ github.sha }}" >> "$GITHUB_OUTPUT"
# ---- caller workflow ----
# jobs:
# build:
# uses: ./.github/workflows/reusable-build.yml
# with:
# node-version: '20'
# environment: staging
# secrets:
# REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
Matrix testing: multiple Node.js versions across multiple platforms in parallel:
# Matrix test: Node 18/20/22 x ubuntu/windows
jobs:
test:
strategy:
fail-fast: false # one failing combo won't stop others
matrix:
node: ['18', '20', '22']
os: [ubuntu-24.04, windows-latest]
exclude:
- node: '18'
os: windows-latest # exclude specific combo
runs-on: ${{ matrix.os }}
name: Test Node ${{ matrix.node }} on ${{ matrix.os }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node }}
cache: 'npm'
- run: npm ci
- run: npm test
permissions, OIDC, and action pins
-
Least privilege: tighten top-level
permissions, widen per job only as needed; justifycontents: write. -
OIDC: cloud trust policies bind
sub/aud; don’t rely on spoofable claims alone. - Pins: prefer full commit SHAs; tags need org allow lists or Dependabot update policy.
Minimal permissions + environment protection with required reviewers and pinned action:
# Tighten top-level permissions, loosen per job + environment approval gate
permissions: {} # close everything at top level
jobs:
test:
runs-on: ubuntu-24.04
permissions:
contents: read # read-only source
steps:
- uses: actions/checkout@v4
- run: npm test
deploy-prod:
runs-on: ubuntu-24.04
needs: test
# environment binds protection rules — set in repo Settings > Environments
# Required reviewers: at least 1 approval before job proceeds
environment:
name: production
url: https://myapp.example.com
permissions:
id-token: write # OIDC token
contents: read
steps:
- uses: actions/checkout@v4
# Pin action to full commit SHA, not @v4 tag
- uses: aws-actions/configure-aws-credentials@e3dd6a429d7300a6a4c196c26e071d42e0343502
with:
role-to-assume: ${{ vars.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- run: |
# Secrets are scoped to the production environment job only
aws ssm put-parameter --name "/myapp/prod/db-password" --value "${{ secrets.DB_PASSWORD }}" --type SecureString --overwrite
Cache, containers, and environments
-
Cache: keys include lockfile hash + runner OS; document acceptable partial
restore-keyshits. - Container jobs: image digest, non-root user, SBOM/scan gates per supply-chain policy.
- Environments: protection rules, reviewers, secrets per env; separate prod deploy workflows from plain CI.
Cache optimization: correct key design for npm, Docker layers, and Terraform providers:
# npm dependency cache: exact key + two-level fallback
- name: Cache npm dependencies
uses: actions/cache@v4
id: npm-cache
with:
path: ~/.npm
# Exact key: OS + node version + lockfile hash
key: npm-${{ runner.os }}-node20-${{ hashFiles('**/package-lock.json') }}
restore-keys: |
npm-${{ runner.os }}-node20-
npm-${{ runner.os }}-
# cache-hit: true skips npm install entirely
- run: npm ci --prefer-offline
# --prefer-offline: use ~/.npm cache before hitting registry
# Docker layer cache (BuildKit)
- name: Build with layer cache
uses: docker/build-push-action@v6
with:
context: .
push: false
cache-from: type=gha,scope=myapp-build
cache-to: type=gha,mode=max,scope=myapp-build
tags: myapp:${{ github.sha }}
# Terraform provider cache
- name: Cache Terraform providers
uses: actions/cache@v4
with:
path: ~/.terraform.d/plugin-cache
key: tf-${{ runner.os }}-${{ hashFiles('**/.terraform.lock.hcl') }}
restore-keys: |
tf-${{ runner.os }}-
---
name: github-actions-workflows
description: Secure, reusable GitHub Actions YAML with OIDC and caching
tags: [github-actions, ci-cd, security]
---
# Triggers and Concurrency
- Configure paths filters separately for push and PR to avoid noisy re-runs
- concurrency.group = workflow + ref; PRs must not cancel each other
- workflow_dispatch inputs: require type, description, and sane defaults
- workflow_call inputs: validate enums, reject empty strings or reserved branch names
# Least Privilege
- Top-level permissions: {} closed; each job opens only what it needs
- OIDC federation: id-token: write + cloud provider configure-credentials action
- Trust policy binds sub (repo:org/repo:ref:refs/heads/main) and aud together
- Action pins: full commit SHA preferred; pair with Dependabot for updates
# Reuse and Caching
- Reusable workflows: declare inputs/secrets/outputs explicitly; caller uses ./.github/workflows/x.yml
- Composite actions: bundle shell steps only, no independent deploy permissions
- actions/cache key = OS + tool version + lockfile hash; two-level restore-keys fallback
- Docker layer cache via BuildKit type=gha; scope per service
# Environments and Secrets
- environment binds required_reviewers; production deploy needs manual approval
- Production secrets visible only in jobs bound to the production environment
- pull_request_target: never checkout or run fork code in the same job
- Never echo or cat secrets; they must not appear in logs